Best Practices für Geo-Targeted SERP Scraping: Was Sie wissen müssen
Wenn Sie lokalisierte SERP-Datasets für Rank-Tracking, Wettbewerbsanalyse oder lokales SEO aufbauen, reicht ein einfacher HTTP-Request an google.com nicht aus. Google personalisiert Suchergebnisse nach Geolocation, Sprache, Suchverlauf und Geräte-Typ — und genau hier setzen die Best Practices für Geo-Targeted SERP Scraping an: Sie müssen URL-Parameter (gl, hl, uule), IP-Geographie und Browser-Fingerprinting koordinieren, damit die zurückgegebenen SERPs tatsächlich dem Zielmarkt entsprechen. Dieser Leitfaden zeigt Ihnen, wie Sie das mit Python, curl_cffi, selectolax und Residential Proxies über gate.proxyhat.com produktionsreif umsetzen.
Warum dieselbe Suchanfrage unterschiedliche Ergebnisse liefert
Google verwendet mindestens drei Signale, um die Geolocation eines Nutzers zu bestimmen:
- IP-Adresse — wird über GeoIP-Datenbanken wie MaxMind GeoIP2 auf Länder- und Stadtebene aufgelöst.
- URL-Parameter —
gl(Google-Ländercode),hl(Interface-Sprache),google_domain(z.B.google.devs.google.co.uk) unduule(codierter Ortsname). - Kontextsignale — Browser-Sprache (
Accept-Language), GPS/HTML5-Geolocation (nur im Browser) und Suchverlauf (personalisierte Ergebnisse).
Ein Request von einer deutschen IP ohne gl-Parameter liefert Ergebnisse für den deutschen Markt. Dieselbe Suchanfrage mit gl=us und einer US-IP liefert deutlich andere organische Ergebnisse, ein anderes Local Pack und oft andere Featured Snippets. Länderebene allein reicht jedoch nicht: Ein Nutzer in New York sieht andere Local-Pack-Einträge als jemand in Austin, Texas — selbst wenn beide gl=us setzen. Für lokales SEO benötigen Sie City- oder District-Level-Präzision über den uule-Parameter, kombiniert mit einer passenden IP aus derselben Region.
Die Kern-Parameter: gl, hl, google_domain, lr und uule
Google unterstützt mehrere Parameter zur Steuerung der Lokalisierung. Laut der Google Custom Search XML-Dokumentation steuern gl und hl die grundlegende Länder- und Sprachzuordnung. Für präzises Geo-Targeting benötigen Sie jedoch zusätzlich uule und pws=0.
| Parameter | Beschreibung | Beispiel | Pflicht für City-Targeting? |
|---|---|---|---|
gl |
Ländercode (ISO 3166-1 alpha-2) | gl=us |
Ja — Basis-Lokalisierung |
hl |
Interface-Sprache | hl=en |
Empfohlen — sollte mit gl übereinstimmen |
google_domain |
Top-Level-Domain | google.de |
Optional — gl ist verlässlicher |
lr |
Sprache der Dokumente einschränken | lr=lang_en |
Optional — für sprachspezifische Filter |
uule |
Codierter kanonischer Ortsname | w+CAIQICIdQXVzdGluLFRleGFzLFVuaXRlZCBTdGF0ZXM= |
Ja — City-Level-Präzision |
pws=0 |
Personalisierung deaktivieren | pws=0 |
Empfohlen — reproduzierbare Baseline |
uule konstruieren: City-Level-Präzision
Der uule-Parameter ist ein Base64-codierter kanonischer Ortsname mit einem festen Präfix und einem Längen-Key. Der Aufbau ist: w+CAIQICI (fester Präfix) + Längen-Key (ein Zeichen, das die Länge des Base64-Strings repräsentiert) + Base64(Ortsname). Der kanonische Ortsname folgt dem Format Stadt,Bundesland,Land, z.B. Austin,Texas,United States.
Die Base64-Codierung ist in RFC 4648 spezifiziert. Der Längen-Key wird als chr(len(base64_string) + 0x40) berechnet — bei einer Länge von 36 Zeichen ergibt das chr(100) = 'd'.
# uule-Konstruktion in reinem Python
import base64
def build_uule(canonical_name: str) -> str:
"""
Erzeugt einen Google uule-Parameter aus einem kanonischen Ortsnamen.
Format: 'w+CAIQICI' + length_key + base64(canonical_name)
Beispiele für canonical_name:
'Austin,Texas,United States'
'New York,New York,United States'
'Berlin,Berlin,Germany'
'München,Bayern,Germany'
"""
b64 = base64.b64encode(canonical_name.encode("utf-8")).decode("utf-8")
length_key = chr(len(b64) + 0x40) # '@'=0x40, len=1 -> 'A'
return f"w+CAIQICI{length_key}{b64}"
# Beispiel
uule = build_uule("Austin,Texas,United States")
print(uule)
# -> w+CAIQICIdQXVzdGluLFRleGFzLFVuaXRlZCBTdGF0ZXM=
IP-Geographie und Proxy-Koordination
Google gleicht die URL-Parameter mit der IP-Geographie ab. Wenn Sie gl=us&uule=...Austin... senden, die Anfrage aber von einer IP in Frankfurt kommt, kann Google die Parameter ignorieren oder ein CAPTCHA auslösen. Die Lösung: Residential Proxies aus demselben Markt. ProxyHat bietet Geo-Targeting auf Länder- und Stadtebene direkt im Usernamen:
user-country-US-region-new_york-city-new_york_city:pass— US-IP aus New Yorkuser-country-DE-region-state_of_berlin-city-berlin:pass— deutsche IP aus Berlinuser-country-US-region-texas-city-austin:pass— US-IP aus Austin, Texas
Die Kombination aus uule und passender IP aus derselben Stadt liefert die verlässlichsten lokalen SERP-Daten. Datacenter-IPs funktionieren für Tests, werden von Google aber häufiger herausgefiltert — Residential Proxies haben typischerweise eine 3-5× höhere Erfolgsquote bei SERP-Scraping.
Implementierung: Code-Beispiele
1. Basis-Request mit curl (Raw Proxy Usage)
Der einfachste Weg, einen geo-targeted SERP-Request abzusetzen — mit curl und ProxyHat als HTTP-Proxy:
# SERP-Request für "coffee shop" in Austin, TX
# mit gl, hl, uule und pws=0 über ProxyHat Residential Proxy
curl -s \
--proxy "http://user-country-US-region-texas-city-austin:PASS@gate.proxyhat.com:8080" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" \
-H "Accept-Language: en-US,en;q=0.9" \
"https://www.google.com/search?q=coffee+shop&gl=us&hl=en&uule=w+CAIQICIdQXVzdGluLFRleGFzLFVuaXRlZCBTdGF0ZXM=&pws=0&num=100"
2. ProxyHat Helper-Klasse (SDK-Stil) und curl_cffi mit Chrome-Impersonation
Für produktive Systeme sollten Sie den Proxy-URL-Bau kapseln. Hier eine Lightweight-Helper-Klasse, die als Mini-SDK fungiert, kombiniert mit curl_cffi für TLS-Fingerprint-Impersonation:
# pip install curl_cffi selectolax
from curl_cffi import requests as cffi_requests
from dataclasses import dataclass
import base64
import logging
import random
import time
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("serp_scraper")
# --- ProxyHat Mini-SDK ---
class ProxyHat:
"""Lightweight ProxyHat SDK für Geo-Targeted SERP Scraping."""
GATEWAY = "gate.proxyhat.com"
HTTP_PORT = 8080
SOCKS5_PORT = 1080
def __init__(self, username: str, password: str):
self.username = username
self.password = password
def proxy_url(
self,
country: str | None = None,
city: str | None = None,
session: str | None = None,
protocol: str = "http",
) -> str:
"""Erzeugt eine Proxy-URL mit Geo- und Session-Flags."""
parts = [self.username]
if country:
parts.append(f"country-{country.upper()}")
if city:
parts.append(f"city-{city.lower().replace(' ', '')}")
if session:
parts.append(f"sid-{session}")
user = "-".join(parts)
port = self.HTTP_PORT if protocol == "http" else self.SOCKS5_PORT
scheme = "http" if protocol == "http" else "socks5"
return f"{scheme}://{user}:{self.password}@{self.GATEWAY}:{port}"
def proxies(self, **kwargs) -> dict:
"""Gibt ein proxies-dict für requests/curl_cffi zurück."""
url = self.proxy_url(**kwargs)
return {"http": url, "https": url}
# --- uule-Builder ---
def build_uule(canonical_name: str) -> str:
b64 = base64.b64encode(canonical_name.encode("utf-8")).decode("utf-8")
length_key = chr(len(b64) + 0x40)
return f"w+CAIQICI{length_key}{b64}"
# --- SERP-Scraper ---
@dataclass
class LocaleConfig:
country_code: str # "us"
language: str # "en"
canonical_name: str # "Austin,Texas,United States"
proxy_city: str # "austin"
google_domain: str = "www.google.com"
LOCALES = [
LocaleConfig("us", "en", "Austin,Texas,United States", "austin"),
LocaleConfig("us", "en", "New York,New York,United States", "newyork"),
LocaleConfig("de", "de", "Berlin,Berlin,Germany", "berlin"),
LocaleConfig("de", "de", "München,Bayern,Germany", "münchen"),
]
def scrape_serp(query: str, locale: LocaleConfig, ph: ProxyHat) -> str:
"""Sendet einen geo-targeted SERP-Request mit Chrome-Impersonation."""
uule = build_uule(locale.canonical_name)
params = {
"q": query,
"gl": locale.country_code,
"hl": locale.language,
"uule": uule,
"pws": "0",
"num": "100",
}
url = f"https://{locale.google_domain}/search"
proxies = ph.proxies(
country=locale.country_code,
city=locale.proxy_city,
)
resp = cffi_requests.get(
url,
params=params,
proxies=proxies,
impersonate="chrome",
timeout=30,
)
logger.info(
f"[{locale.canonical_name}] status={resp.status_code} "
f"bytes={len(resp.text)} ip_session=rotating"
)
resp.raise_for_status()
return resp.text
# --- Main ---
if __name__ == "__main__":
ph = ProxyHat("user", "your_password")
for locale in LOCALES:
try:
html = scrape_serp("best coffee shop", locale, ph)
with open(f"serp_{locale.proxy_city}.html", "w", encoding="utf-8") as f:
f.write(html)
except Exception as e:
logger.error(f"Fehler für {locale.proxy_city}: {e}")
time.sleep(random.uniform(1.5, 3.5)) # Rate-Limiting
Raw-Proxy-Alternative ohne Helper-Klasse: Statt der ProxyHat-Klasse können Sie die Proxy-URL auch direkt konstruieren:
# Direkte Proxy-URL (ohne SDK-Helper)
proxy = "http://user-country-US-region-texas-city-austin:PASS@gate.proxyhat.com:8080"
resp = cffi_requests.get(url, params=params, proxies={"http": proxy, "https": proxy}, impersonate="chrome")
3. Organic Results und Local Pack parsen mit selectolax
Nach dem Abruf müssen Sie die HTML-Struktur parsen. Google ändert Klassen häufig, aber die folgenden Selektoren sind über mehrere Updates stabil geblieben:
from selectolax.parser import HTMLParser
from dataclasses import dataclass, asdict
import json
@dataclass
class OrganicResult:
position: int
title: str
url: str
snippet: str
@dataclass
class LocalPackEntry:
name: str
rating: float | None
reviews: int | None
address: str | None
phone: str | None
def parse_organic(html: str) -> list[OrganicResult]:
"""Parst organische Suchergebnisse."""
tree = HTMLParser(html)
results = []
pos = 0
for node in tree.css("div.g div.tF2Cxc"):
pos += 1
title_el = node.css_first("h3")
link_el = node.css_first("a[href]")
snippet_el = node.css_first("div[data-sncf], div.VwiC3b, span.aCOpRe")
title = title_el.text(strip=True) if title_el else ""
url = link_el.attributes.get("href", "") if link_el else ""
snippet = snippet_el.text(strip=True) if snippet_el else ""
if title and url:
results.append(OrganicResult(pos, title, url, snippet))
return results
def parse_local_pack(html: str) -> list[LocalPackEntry]:
"""Parst das Local Pack (Google Maps Results)."""
tree = HTMLParser(html)
entries = []
# Local Pack Container — Selektor kann variieren
for card in tree.css("div[data-cid] div.SW5HFd, div.rllt__details"):
name_el = card.css_first("div.dbg0p, span.O3ErS")
rating_el = card.css_first("span.MV3lbe, span[role='img']")
name = name_el.text(strip=True) if name_el else ""
rating = None
if rating_el:
r = rating_el.attributes.get("aria-label", "")
if "star" in r.lower():
try:
rating = float(r.split()[0].replace(",", "."))
except (ValueError, IndexError):
pass
entries.append(LocalPackEntry(name, rating, None, None, None))
return entries
# --- Usage ---
if __name__ == "__main__":
with open("serp_austin.html", "r", encoding="utf-8") as f:
html = f.read()
organic = parse_organic(html)
local = parse_local_pack(html)
print(f"=== Organic Results ({len(organic)}) ===")
for r in organic[:5]:
print(f"{r.position}. {r.title} — {r.url}")
print(f"\n=== Local Pack ({len(local)}) ===")
for e in local[:3]:
print(f"{e.name} (Rating: {e.rating})")
# Als JSON exportieren
output = {
"organic": [asdict(r) for r in organic],
"local_pack": [asdict(e) for e in local],
}
with open("serp_austin_parsed.json", "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
4. Sticky Sessions für Multi-Page-SERPs
Wenn Sie mehrere Seiten derselben Suchanfrage abrufen (z.B. Ergebnisse 1–100 über start=0, start=10, start=20), muss dieselbe IP verwendet werden — sonst kann Google unterschiedliche Personalisierungen pro Seite anwenden. ProxyHat unterstützt Sticky Sessions über das session-Flag im Usernamen:
import random
import string
from curl_cffi import requests as cffi_requests
def scrape_multi_page(query: str, locale: LocaleConfig, ph: ProxyHat, pages: int = 5):
"""
Ruft mehrere SERP-Seiten ab mit derselben IP (Sticky Session).
Die Session-ID hält die IP für die Dauer der Session stabil.
"""
session_id = "serp_" + "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
uule = build_uule(locale.canonical_name)
all_results = []
for page in range(pages):
start = page * 10
params = {
"q": query,
"gl": locale.country_code,
"hl": locale.language,
"uule": uule,
"pws": "0",
"num": "10",
"start": str(start),
}
url = f"https://{locale.google_domain}/search"
# Sticky Session: dieselbe session_id -> dieselbe IP
proxies = ph.proxies(
country=locale.country_code,
city=locale.proxy_city,
session=session_id,
)
try:
resp = cffi_requests.get(
url,
params=params,
proxies=proxies,
impersonate="chrome",
timeout=30,
)
logger.info(
f"Page {page+1}/{pages} | start={start} | "
f"status={resp.status_code} | session={session_id}"
)
resp.raise_for_status()
results = parse_organic(resp.text)
all_results.extend(results)
except Exception as e:
logger.error(f"Page {page+1} fehlgeschlagen: {e}")
break
time.sleep(random.uniform(2.0, 4.0))
return all_results
# Beispielaufruf
ph = ProxyHat("user", "your_password")
locale = LocaleConfig("us", "en", "Austin,Texas,United States", "austin")
results = scrape_multi_page("plumber", locale, ph, pages=5)
print(f"Insgesamt {len(results)} organische Ergebnisse über 5 Seiten")
5. Retry/Backoff mit per-Country Proxy-Pools und CAPTCHA-Erkennung
Google antwortet auf verdächtiges Scraping-Verhalten mit HTTP 429 (Too Many Requests), CAPTCHAs oder 302-Redirects auf sorry.google.com. Ein robuster Scraper braucht Exponential Backoff, CAPTCHA-Erkennung und separate Proxy-Pools pro Land:
import time
import random
from curl_cffi import requests as cffi_requests
from dataclasses import dataclass
@dataclass
class ScrapeResult:
success: bool
html: str | None
status_code: int
error: str | None
class SERPScraper:
"""Robuster SERP-Scraper mit Retry, Backoff und CAPTCHA-Erkennung."""
MAX_RETRIES = 4
BASE_DELAY = 3.0 # Sekunden
MAX_DELAY = 60.0
def __init__(self, ph: ProxyHat):
self.ph = ph
def _is_captcha(self, html: str) -> bool:
markers = ["sorry.google.com", "g-recaptcha", "unusual traffic", "CAPTCHA"]
return any(m in html for m in markers)
def _backoff(self, attempt: int) -> float:
delay = min(self.BASE_DELAY * (2 ** attempt) + random.uniform(0, 1), self.MAX_DELAY)
return delay
def scrape(
self,
query: str,
locale: LocaleConfig,
session: str | None = None,
) -> ScrapeResult:
"""
Sendet einen SERP-Request mit Retry-Logik.
Rotiert bei 429/CAPTCHA automatisch auf eine neue IP
(neue Session-ID = neue IP aus demselben Geo-Pool).
"""
uule = build_uule(locale.canonical_name)
url = f"https://{locale.google_domain}/search"
params = {
"q": query,
"gl": locale.country_code,
"hl": locale.language,
"uule": uule,
"pws": "0",
"num": "100",
}
for attempt in range(self.MAX_RETRIES):
# Bei Retry: neue Session-ID für neue IP aus demselben City-Pool
current_session = session if session else f"retry_{attempt}_{random.randint(1000,9999)}"
proxies = self.ph.proxies(
country=locale.country_code,
city=locale.proxy_city,
session=current_session,
)
try:
resp = cffi_requests.get(
url,
params=params,
proxies=proxies,
impersonate="chrome",
timeout=30,
)
# CAPTCHA-Erkennung
if resp.status_code == 200 and self._is_captcha(resp.text):
delay = self._backoff(attempt)
logger.warning(
f"CAPTCHA erkannt für {locale.proxy_city} | "
f"attempt={attempt+1}/{self.MAX_RETRIES} | "
f"backoff={delay:.1f}s"
)
time.sleep(delay)
continue
# Rate Limiting
if resp.status_code == 429:
delay = self._backoff(attempt)
logger.warning(
f"HTTP 429 für {locale.proxy_city} | "
f"attempt={attempt+1}/{self.MAX_RETRIES} | "
f"backoff={delay:.1f}s"
)
time.sleep(delay)
continue
resp.raise_for_status()
logger.info(
f"Erfolg: {locale.proxy_city} | "
f"status={resp.status_code} | "
f"bytes={len(resp.text)} | "
f"attempt={attempt+1}"
)
return ScrapeResult(True, resp.text, resp.status_code, None)
except cffi_requests.RequestsError as e:
delay = self._backoff(attempt)
logger.error(
f"Request-Fehler für {locale.proxy_city}: {e} | "
f"backoff={delay:.1f}s"
)
time.sleep(delay)
return ScrapeResult(False, None, 0, "Max retries exceeded")
# --- Multi-Locale Scraping mit Per-Country Pools ---
if __name__ == "__main__":
ph = ProxyHat("user", "your_password")
scraper = SERPScraper(ph)
queries = ["dentist", "electrician", "roofing contractor"]
locales = [
LocaleConfig("us", "en", "Austin,Texas,United States", "austin"),
LocaleConfig("us", "en", "New York,New York,United States", "newyork"),
LocaleConfig("de", "de", "Berlin,Berlin,Germany", "berlin"),
]
for locale in locales:
for query in queries:
result = scraper.scrape(query, locale)
if result.success:
organic = parse_organic(result.html)
local_pack = parse_local_pack(result.html)
logger.info(
f"{locale.proxy_city} | q='{query}' | "
f"organic={len(organic)} | local_pack={len(local_pack)}"
)
else:
logger.error(f"{locale.proxy_city} | q='{query}' | FAILED: {result.error}")
time.sleep(random.uniform(2.0, 5.0))
Häufige Fehler und Edge Cases
- Parameter-Mismatch:
gl=usmithl=deund einer deutschen IP führt zu unvorhersehbaren Ergebnissen.gl,hlund Proxy-Land sollten konsistent sein. - uule ohne passende IP:
uulefür Austin mit einer IP aus Los Angeles liefert verzerrte Local-Pack-Ergebnisse. Die IP-Stadt muss nicht exakt übereinstimmen, sollte aber im selben Markt liegen. - Vergessenes
pws=0: Ohnepws=0personalisiert Google Ergebnisse basierend auf Cookies und Suchverlauf — selbst bei Inprivate-Requests können personalisierte SERPs zurückkommen, wenn Google den Fingerprint assoziiert. - Zu hohe Concurrency: Mehr als 10–15 Requests pro Sekunde aus demselben Proxy-Pool erhöhen das CAPTCHA-Risiko deutlich. Eine Rate von 2–5 Requests/Sekunde mit zufälligen Delays ist sicherer.
- Veraltete CSS-Selektoren: Google ändert HTML-Klassen regelmäßig. Testen Sie Parser gegen aktuelle SERP-HTML-Snippets und implementieren Sie Fallback-Selektoren.
- Local Pack in Ländern ohne Google Maps: In einigen Märkten (z.B. China) ist das Local Pack nicht verfügbar oder unterschiedlich strukturiert.
ProxyHat-spezifische Einrichtung
ProxyHat macht Geo-Targeting einfach: Alle Parameter werden im Usernamen codiert. Für US Geo-Targeted SERP Scraping Best Practices verwenden Sie das Format user-country-US-region-{region}-city-{city}:pass. Die vollständige Dokumentation aller Parameter finden Sie unter docs.proxyhat.com.
Die wichtigsten Konfigurationen für SERP-Scraping:
- Rotierende IPs (Standard): Jeder Request erhält eine neue IP — ideal für breite SERP-Erfassung.
- Sticky Sessions:
sid-{id}hält die IP stabil — notwendig für Multi-Page-SERPs. - City-Level-Targeting:
city-{name}— kombiniert mituulefür maximale Präzision beim Local SERP Scraping by City. - SOCKS5: Für Anwendungen, die SOCKS5 benötigen, verwenden Sie Port
1080statt8080.
Preise und verfügbare Standorte finden Sie auf der Preisseite und der Standortübersicht. Weitere Use Cases für Web-Scraping und SERP-Tracking finden Sie unter Web-Scraping und SERP-Tracking.
Rechtliche Hinweise: TOS und öffentliche Daten
Das Scraping von Google-SERPs bewegt sich in einer rechtlichen Grauzone. Googles Nutzungsbedingungen verbieten automatisierte Abfragen. In der Praxis scrapen viele SEO-Tools SERP-Daten, aber Sie sollten:
- Rate-Limits respektieren und menschenähnliche Request-Muster verwenden.
robots.txtprüfen — Google erlaubt bestimmte Pfade nicht.- DSGVO/GDPR beachten, wenn personenbezogene Daten (z.B. aus Local Packs) gespeichert werden.
- Eine rechtliche Beratung einholen, bevor Sie kommerzielle SERP-Datensets verkaufen.
Wichtig: Dieser Artikel beschreibt technische Methoden für Entwickler. Die Einhaltung der Nutzungsbedingungen Dritter und geltender Gesetze liegt in Ihrer Verantwortung.
Key Takeaways
- Stellen Sie
gl,hlunduulekonsistent ein und kombinieren Sie sie mitpws=0für reproduzierbare, nicht-personalisierte SERPs. - City-Level-Targeting erfordert
uule+ passende IP: Google gleicht URL-Parameter mit der IP-Geographie ab. Verwenden Sie Residential Proxies aus derselben Stadt. - Sticky Sessions für Multi-Page-SERPs: Verwenden Sie
sid-{id}im ProxyHat-Usernamen, um dieselbe IP über mehrere Seiten zu halten. - Implementieren Sie Exponential Backoff und CAPTCHA-Erkennung: HTTP 429 und CAPTCHA-Redirects sind normal — ein robuster Scraper rotiert die IP und wartet mit Backoff.
- Chrome-Impersonation via
curl_cffireduziert TLS-Fingerprinting-Risiken erheblich gegenüber Standardrequests. - Halten Sie Parser aktuell: Google ändert HTML-Klassen regelmäßig. Testen Sie gegen aktuelle SERP-HTML-Snippets.




