Einen Google Rank Tracker in Python mit Residential Proxies zu bauen, ist eine der nütztesten Automatisierungen für SEO-Teams: Man erhält tägliche, reproduzierbare Ranking-Snapshots statt einmaliger Stichproben. Seit Google den Parameter num=100 im September 2025 abgeschafft hat, sind zudem Paginierung und Proxy-Rotation zwingend erforderlich. Dieser Leitfaden zeigt, wie man das mit curl_cffi, dem ProxyHat-SDK, SQLite und robustem Production-Hardening umsetzt.
Warum tägliche SERP-Snapshots Einzelchecks überlegen sind
Ein einmaliger Ranking-Check liefert eine Momentaufnahme, die von Tageszeit, Datenzentrum, Personalisierung und Testgerät abhängt. Rankings schwanken jedoch täglich — ein Keyword kann morgens auf Position 4 und abends auf Position 11 stehen. Nur eine kontinuierliche Historie macht Volatilität sichtbar und erlaubt Trend-Analysen.
Die minimale Datenmodellierung für einen Rank-Tracker umfasst:
- keyword — der Suchbegriff, z. B. „python web scraping“
- target_domain — die Domain, deren Position getrackt wird
- country — Geo-Target, z. B.
US,DE,GB - device —
desktopodermobile - position — organische Position (1–100)
- captured_at — UTC-Timestamp der Erfassung
Mit diesen Feldern lassen sich Tagesdeltas, 7-Tage-Durchschnitte und Best-/Worst-Positionen pro Keyword berechnen. Eine SQLite-Tabelle reicht für bis zu ~50.000 Keyword-Tag-Kombinationen problemlos; für größere Volumina empfiehlt sich PostgreSQL.
Technischer Kontext: Warum Proxies heute unverzichtbar sind
Google betreibt mehrere Schichten von Anti-Bot-Schutz. Neben der klassischen IP-Rate-Limitierung setzt Google seit Jahren auf TLS/JA3-JA4-Fingerprinting und IP-Reputation-Scoring. Ein plain requests.get() mit einem Datacenter-IP-Bereich wird typischerweise nach 20–50 Requests pro IP pro Stunde eine CAPTCHA-Antwort oder HTTP 429 zurückgeben.
Residential Proxies lösen dieses Problem, weil sie IPs aus realen ISP-Bereichen verwenden, die eine hohe Reputation genießen. Mit City-Level-Geo-Targeting kann man zudem sicherstellen, dass der SERP-Snapshot genau dem entspricht, den ein Nutzer in einer bestimmten Stadt sieht — wichtig für Local-SEO und ländergenaue Rankings.
Die Kombination aus curl_cffi (das den TLS-Fingerprint eines echten Chrome-Browsers nachahmt) und Residential Proxies ist der aktuelle State-of-the-Art für SERP-Scraping. Laut der TLS 1.3-Spezifikation (RFC 8446) enthält der ClientHello eindeutige Fingerprints, die Anti-Bot-Systeme wie Cloudflare und Google auswerten. curl_cffi repliziert diese Fingerprints mit der Option impersonate='chrome'.
SERP-Paginierung: Top 100 ohne num=100
Google hat den Parameter num=100 im September 2025 entfernt. Um die Top 100 zu erfassen, muss man nun mit start=0, start=10, start=20 … paginieren. Jede Seite liefert ~10 organische Ergebnisse. Für die Top 100 braucht man also 10 Requests pro Keyword.
Die Basis-URL für eine Google-Suche sieht so aus:
https://www.google.com/search?q=KEYWORD&gl=us&hl=en&num=10&start=0
Wichtige Parameter:
q— URL-kodiertes Keywordgl— Ländercode (z. B.us,de,gb)hl— Sprache (z. B.en,de)start— Offset für Paginierung (0, 10, 20, …)
Vergleich: Proxy-Typen für SERP-Scraping
| Eigenschaft | Datacenter | Residential | Mobile |
|---|---|---|---|
| IP-Reputation | Niedrig — schnell blockiert | Hoch — ISP-IPs | Sehr hoch — Carrier-IPs |
| Block-Risiko bei Google | Hoch nach ~30 Requests | Niedrig bei Rotation | Sehr niedrig |
| Latenz | ~50ms | ~200–500ms | ~400–800ms |
| Preis pro GB | $0,50–$1 | $2–$5 | $5–$12 |
| Geo-Targeting | Land | Land + Stadt | Land + Stadt + Carrier |
Für Rank-Tracking sind Residential Proxies der Sweet Spot: ausreichende Reputation, City-Level-Geo-Targeting und moderater Preis. Mobile Proxies sind überdimensioniert, es sei denn, man trackt mobile-spezifische SERPs intensiv.
ProxyHat einrichten: Gateway, Geo-Targeting und Sessions
ProxyHat verwendet ein zentrales Gateway unter gate.proxyhat.com. Die Authentifizierung erfolgt über Username und Password, wobei Geo-Targeting und Session-Stickiness im Username kodiert werden.
Formate:
# HTTP — US, Chicago, sticky session
http://user-country-US-region-illinois-city-chicago-sid-kw123:pass@gate.proxyhat.com:8080
# SOCKS5 — Deutschland, Berlin
socks5://user-country-DE-region-state_of_berlin-city-berlin-sid-kw456:pass@gate.proxyhat.com:1080
Die -sid--Flag sorgt dafür, dass alle Requests mit derselben Session-ID dieselbe Exit-IP verwenden. Für Rank-Tracking ist das wichtig: Pro Keyword sollte eine konsistente IP verwendet werden, um personalisierungsbedingte Schwankungen zu minimieren.
Weitere Details zur Proxy-Konfiguration finden Sie in der ProxyHat-Dokumentation.
Code-Beispiel 1: SERP mit curl abrufen
#!/bin/bash
# Google SERP abrufen über ProxyHat Residential Proxy
# Keyword: "python web scraping", Land: US, Stadt: Chicago
KEYWORD="python web scraping"
ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$KEYWORD'))")
for START in 0 10 20 30 40; do
curl -s --proxy "http://user-country-US-region-illinois-city-chicago-sid-kw1:pass@gate.proxyhat.com:8080" \
--impersonate chrome \
"https://www.google.com/search?q=${ENCODED}&gl=us&hl=en&start=${START}" \
-o "serp_${START}.html"
echo "Saved serp_${START}.html"
sleep 2
done
Code-Beispiel 2: Python Rank-Tracker mit curl_cffi und Raw-Proxy
import re
import csv
import time
import sqlite3
from datetime import datetime, timezone
from urllib.parse import quote
from curl_cffi import requests
# --- Konfiguration ---
PROXY_USER = "user-country-US-region-illinois-city-chicago-sid-{sid}:pass"
PROXY_GATE = "gate.proxyhat.com:8080"
TARGET_DOMAIN = "example.com"
KEYWORDS = ["python web scraping", "best web scraping tools", "scrapy tutorial"]
COUNTRY = "us"
LANG = "en"
MAX_RESULTS = 100
PAGE_SIZE = 10
# --- SQLite-Historie ---
conn = sqlite3.connect("rank_history.db")
conn.execute("""
CREATE TABLE IF NOT EXISTS rankings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
target_domain TEXT NOT NULL,
country TEXT NOT NULL,
device TEXT NOT NULL,
position INTEGER,
captured_at TEXT NOT NULL
)
""")
def fetch_serp_page(keyword: str, start: int, session_id: str) -> str:
"""Eine SERP-Seite über ProxyHat abrufen."""
proxy_url = f"http://{PROXY_USER.format(sid=session_id)}@{PROXY_GATE}"
encoded_kw = quote(keyword)
url = f"https://www.google.com/search?q={encoded_kw}&gl={COUNTRY}&hl={LANG}&start={start}"
resp = requests.get(
url,
proxies={"http": proxy_url, "https": proxy_url},
impersonate="chrome",
timeout=30,
)
if resp.status_code != 200:
raise RuntimeError(f"HTTP {resp.status_code} für keyword={keyword} start={start}")
return resp.text
def parse_organic_results(html: str) -> list[dict]:
"""Organische Ergebnisse aus HTML extrahieren — ohne Ads und SERP-Features."""
results = []
# Google nutzt div.g für organische Ergebnisse
pattern = r'<div class="g"[^>]*>.*?<a href="/url\?q=([^"&]+)|<a href="(https?://[^"\s]+)"[^>]*data-ved='
# Robuster: regex für href in organischen Links
href_pattern = r'<a href="(https?://(?!www\.google\.|maps\.google\.|support\.google\.)[^"]+)"'
matches = re.findall(href_pattern, html)
for i, url in enumerate(matches[:MAX_RESULTS]):
results.append({"position": i + 1, "url": url})
return results
def find_position(results: list[dict], target_domain: str) -> int | None:
"""Position der Ziel-Domain finden."""
for r in results:
if target_domain in r["url"]:
return r["position"]
return None
def track_keyword(keyword: str):
"""Keyword tracken: Top 100 abrufen, Position finden, speichern."""
session_id = f"kw{abs(hash(keyword)) % 100000}"
all_results = []
for start in range(0, MAX_RESULTS, PAGE_SIZE):
try:
html = fetch_serp_page(keyword, start, session_id)
# CAPTCHA-Erkennung
if "unusual traffic" in html.lower() or "captcha" in html.lower():
print(f"[WARN] CAPTCHA erkannt für '{keyword}' bei start={start}")
break
page_results = parse_organic_results(html)
# Positionen korrigieren für Paginierung
for r in page_results:
r["position"] = start + r["position"]
all_results.extend(page_results)
time.sleep(2) # Rate-Limiting
except Exception as e:
print(f"[ERROR] {keyword} start={start}: {e}")
break
position = find_position(all_results, TARGET_DOMAIN)
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"INSERT INTO rankings VALUES (NULL, ?, ?, ?, ?, ?, ?)",
(keyword, TARGET_DOMAIN, COUNTRY, "desktop", position, now),
)
conn.commit()
print(f"[OK] {keyword} → Position {position}")
# --- Main ---
for kw in KEYWORDS:
track_keyword(kw)
time.sleep(3)
conn.close()
print("Tracking complete.")
Code-Beispiel 3: ProxyHat-SDK mit Session-Rotation
import hashlib
from curl_cffi import requests
class ProxyHatSession:
"""ProxyHat-Proxy-Manager mit per-Keyword sticky sessions."""
def __init__(self, username: str, password: str, country: str, city: str | None = None):
self.username = username
self.password = password
self.country = country.upper()
self.city = city.lower() if city else None
def get_proxy_url(self, session_id: str) -> str:
"""Proxy-URL mit Geo-Targeting und Session-ID generieren."""
parts = [f"user-country-{self.country}"]
if self.city:
parts.append(f"city-{self.city}")
parts.append(f"sid-{session_id}")
user_part = "-".join(parts)
return f"http://{user_part}:{self.password}@gate.proxyhat.com:8080"
@staticmethod
def session_id_for(keyword: str) -> str:
"""Deterministische Session-ID pro Keyword für konsistente IPs."""
return hashlib.md5(keyword.encode()).hexdigest()[:12]
# --- Verwendung ---
proxy = ProxyHatSession(
username="user",
password="pass",
country="US",
city="chicago",
)
keyword = "python web scraping"
sid = ProxyHatSession.session_id_for(keyword)
proxy_url = proxy.get_proxy_url(sid)
resp = requests.get(
"https://www.google.com/search?q=python+web+scraping&gl=us&hl=en",
proxies={"http": proxy_url, "https": proxy_url},
impersonate="chrome",
timeout=30,
)
print(f"Status: {resp.status_code}, Length: {len(resp.text)}")
Code-Beispiel 4: Multi-Country-Rank-Tracking mit asyncio
import asyncio
import aiohttp
from datetime import datetime, timezone
async def fetch_serp_async(
session: aiohttp.ClientSession,
keyword: str,
country: str,
session_id: str,
start: int = 0,
) -> str:
"""Async SERP-Fetch mit ProxyHat."""
proxy = f"http://user-country-{country}-sid-{session_id}:pass@gate.proxyhat.com:8080"
encoded = quote(keyword)
url = f"https://www.google.com/search?q={encoded}&gl={country.lower()}&hl=en&start={start}"
async with session.get(url, proxy=proxy, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status != 200:
raise RuntimeError(f"HTTP {resp.status}")
return await resp.text()
async def track_keyword_country(keyword: str, country: str, target_domain: str):
"""Ein Keyword in einem Land tracken (Top 100)."""
sid = hashlib.md5(f"{keyword}-{country}".encode()).hexdigest()[:12]
all_urls = []
async with aiohttp.ClientSession() as session:
tasks = []
for start in range(0, 100, 10):
tasks.append(fetch_serp_async(session, keyword, country, sid, start))
await asyncio.sleep(1) # Staggered Start
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, html in enumerate(results):
if isinstance(html, Exception):
print(f"[ERROR] {keyword}/{country} page {i}: {html}")
continue
urls = re.findall(r'<a href="(https?://[^"]+)"', html)
all_urls.extend(urls)
position = None
for idx, url in enumerate(all_urls, 1):
if target_domain in url:
position = idx
break
print(f"{keyword} | {country} → Position {position}")
return position
# --- Main: 3 Keywords × 2 Länder = 6 Tasks, max 3 concurrent ---
KEYWORDS = ["python scraping", "scrapy vs beautifulsoup", "selenium tutorial"]
COUNTRIES = ["US", "DE"]
TARGET = "example.com"
async def main():
sem = asyncio.Semaphore(3) # Max 3 gleichzeitige Requests
async def limited_track(kw, ctry):
async with sem:
return await track_keyword_country(kw, ctry, TARGET)
tasks = [limited_track(kw, ctry) for kw in KEYWORDS for ctry in COUNTRIES]
await asyncio.gather(*tasks)
asyncio.run(main())
Code-Beispiel 5: Production-Hardening mit Retries und Circuit Breaker
import time
import logging
from functools import wraps
from curl_cffi import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("rank-tracker")
class CircuitBreakerOpen(Exception):
pass
class CircuitBreaker:
"""Einfacher Circuit Breaker: öffnet nach N Fehlern, halb-öffnet nach Timeout."""
def __init__(self, failure_threshold=5, recovery_timeout=60):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failures = 0
self.last_failure_time = None
self.state = "closed" # closed | open | half-open
def record_success(self):
self.failures = 0
self.state = "closed"
def record_failure(self):
self.failures += 1
self.last_failure_time = time.time()
if self.failures >= self.failure_threshold:
self.state = "open"
logger.warning(f"Circuit breaker OPEN after {self.failures} failures")
def can_execute(self) -> bool:
if self.state == "closed":
return True
if self.state == "open":
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = "half-open"
return True
return False
return True # half-open
def retry_with_backoff(max_retries=3, base_delay=2, max_delay=60):
"""Decorator: Exponentielles Backoff mit Jitter."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_retries:
logger.error(f"All retries exhausted: {e}")
raise
delay = min(base_delay * (2 ** attempt), max_delay)
logger.warning(f"Retry {attempt + 1}/{max_retries} after {delay}s: {e}")
time.sleep(delay)
return wrapper
return decorator
# --- Usage ---
cb = CircuitBreaker(failure_threshold=5, recovery_timeout=60)
@retry_with_backoff(max_retries=3, base_delay=2, max_delay=60)
def fetch_with_protection(url: str, proxy_url: str) -> str:
if not cb.can_execute():
raise CircuitBreakerOpen("Circuit breaker is open")
resp = requests.get(
url,
proxies={"http": proxy_url, "https": proxy_url},
impersonate="chrome",
timeout=30,
)
if resp.status_code == 429:
cb.record_failure()
raise RuntimeError("Rate limited (429)")
if "unusual traffic" in resp.text.lower():
cb.record_failure()
raise RuntimeError("CAPTCHA detected")
cb.record_success()
return resp.text
# --- CSV-Export der Historie ---
def export_to_csv(db_path: str, csv_path: str):
import sqlite3
conn = sqlite3.connect(db_path)
rows = conn.execute("SELECT keyword, target_domain, country, device, position, captured_at FROM rankings ORDER BY captured_at").fetchall()
with open(csv_path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["keyword", "target_domain", "country", "device", "position", "captured_at"])
writer.writerows(rows)
conn.close()
logger.info(f"Exported {len(rows)} rows to {csv_path}")
Häufige Fehler und Edge Cases
1. CAPTCHA-Erkennung zu spät
Google liefert bei CAPTCHA-Antworten oft HTTP 200, aber der HTML-Body enthält „unusual traffic“ oder ein CAPTCHA-Formular. Prüfen Sie immer den Body-Inhalt, nicht nur den Statuscode.
2. Keine Session-Stickiness
Ohne -sid--Flag rotiert ProxyHat die IP bei jedem Request. Für Rank-Tracking bedeutet das, dass jeder SERP-Seitenabruf von einer anderen IP kommt — die Ergebnisse können inkonsistent sein. Verwenden Sie deterministische Session-IDs pro Keyword.
3. Paginierung ignoriert SERP-Features
Featured Snippets, Knowledge Panels und Local Packs verändern die organische Positionierung. Ein einfacher Regex-Ansatz kann falsche Positionen liefern, wenn SERP-Features die Zählung verschieben. Für höhere Präzision empfiehlt sich BeautifulSoup4 mit CSS-Selektoren, die explizit auf div.g oder div[data-sokoban-container] abzielen.
4. Zu hohe Concurrency
Mehr als 5–10 gleichzeitige Requests über dieselbe Proxy-Session erhöhen das Block-Risiko massiv. Verwenden Sie asyncio.Semaphore oder ThreadPoolExecutor(max_workers=5), um die Parallelität zu begrenzen.
Ethik und Grenzen
SERP-Scraping bewegt sich in einer rechtlichen Grauzone. Die Google-Nutzungsbedingungen verbieten automatisierte Abfragen, aber die Rechtslage variiert nach Gerichtsbarkeit. Der FTC-Bericht zu Google zeigt, dass Suchmaschinen-Monopole seit Jahren regulatorisch beobachtet werden.
Best Practices für ethisches Rank-Tracking:
- Tracken Sie primär eigene Rankings und öffentliche Daten.
- Respektieren Sie
robots.txt— Google erlaubt/searchnicht für Bots. - Halten Sie Request-Raten niedrig: max. 1 Request alle 2–3 Sekunden pro Session.
- Bei niedrigem Volumen (unter ~100 Keywords/Tag) ziehen Sie die Google Custom Search API in Betracht — sie ist offiziell, kostengünstig und ToS-konform.
- Dokumentieren Sie Ihre Datenerfassung für DSGVO-/CCPA-Compliance.
ProxyHat-Angebote und verfügbare Standorte finden Sie auf der Preisseite und der Standort-Übersicht. Weitere Use Cases für Web-Scraping und SERP-Tracking werden separat behandelt.




