Geo-Targeted SERP Scraping Best Practices for Local SEO Data

Learn how to build city-precise SERP datasets using uule, gl, hl, and residential proxies. Includes Python code for uule construction, result parsing, sticky sessions, and retry logic.

Geo-Targeted SERP Scraping Best Practices for Local SEO Data
In this article

Geo-targeted SERP scraping best practices are essential for any SEO engineer building localized rank-tracking datasets. If you've ever collected Google results for the same keyword from two different cities and seen completely different organic rankings, local packs, and featured snippets, you've already encountered the core problem: Google personalizes SERPs by geography, and country-level targeting alone is insufficient for accurate local SEO intelligence.

Why the Same Query Returns Different Results by Location

Google's ranking pipeline applies a geographic filter long before results reach the user. The search engine infers location from several signals: the requester's IP address (resolved to city-level via IP geolocation databases), explicit URL parameters like gl and uule, browser language headers, and the user's signed-in account location. When these signals conflict, Google tends to trust the IP address most heavily for local intent queries.

This is why country-level targeting (e.g., gl=us) is insufficient for local SEO. A search for "plumber" in Austin, Texas returns a different local pack than the same query in Brooklyn, New York — even though both are in the United States. The local pack, map results, and even organic rankings shift based on proximity to the searcher. For rank-tracking systems that need to report on a client's visibility in specific markets, you need city-level or even district-level precision.

According to Google's Custom Search documentation, the gl parameter controls the country of results, while hl sets the interface language. But neither parameter pins results to a specific city. That's where uule comes in.

Core Localization Parameters for SERP Scraping

Five parameters control the geographic and linguistic context of a Google SERP request:

ParameterPurposeExample
glCountry code (ISO 3166-1 alpha-2)gl=us
hlInterface languagehl=en
google_domainGoogle domain to querygoogle_domain=google.com
lrLanguage restrict for resultslr=lang_en
uuleExact geographic location (city/district)uule=w+CAIQICI...

Always add pws=0 to disable personalization. This strips signed-in user history and provides a non-personalized baseline that's reproducible across runs — critical for rank tracking where you need consistent, comparable data over time.

Constructing the uule Value

The uule parameter encodes a canonical place name into a compact string. The format is a fixed prefix, a length byte, and a base64-encoded canonical place name:

uule = "w+CAIQICI" + chr(len(canonical_name_bytes) + 63) + base64(canonical_name)

The canonical name follows the pattern City,State,Country — for example, Austin,Texas,United States or Brooklyn,New York,United States. The length byte uses a shifted ASCII encoding where chr(byte_length + 63) represents the raw byte count of the canonical name string.

Here's a Python function that builds valid uule values:

import base64

def build_uule(canonical_name: str) -> str:
    """Build a Google uule parameter from a canonical place name.

    Canonical name format: 'City,State,Country'
    Examples:
      'Austin,Texas,United States'
      'Brooklyn,New York,United States'
      'Munich,Bavaria,Germany'
    """
    prefix = "w+CAIQICI"
    name_bytes = canonical_name.encode("utf-8")
    length_char = chr(len(name_bytes) + 63)
    encoded = base64.b64encode(name_bytes).decode("utf-8")
    return f"{prefix}{length_char}{encoded}"

# Build uule for several cities
cities = [
    "Austin,Texas,United States",
    "Brooklyn,New York,United States",
    "Munich,Bavaria,Germany",
]
for city in cities:
    uule = build_uule(city)
    print(f"{city:40s} -> uule={uule}")

# Output:
# Austin,Texas,United States               -> uule=w+CAIQICIYQXVsdGluLFRleGFzLFVuaXRlZCBTdGF0ZXM=
# Brooklyn,New York,United States          -> uule=w+CAIQICIcYnJvb2tseW4sTmV3IFlvcmssVW5pdGVkIFN0YXRlcw==
# Munich,Bavaria,Germany                    -> uule=w+CAIQICIXbXVuaWNoLEJhdmFyaWEsR2VybWFueQ==

When you pass this uule alongside gl and hl, Google applies the city-level geographic filter to the SERP. But there's a catch: Google cross-checks the uule location against the requester's IP geolocation. If your uule says "Austin, Texas" but your request comes from a datacenter IP in Frankfurt, Google may ignore the uule or trigger anti-bot verification.

Why Parameters Must Agree with IP Geography

Google's anti-abuse systems compare the geographic signals in your request. If gl=us and uule point to a US city, but the IP address resolves to a non-US location or a known datacenter ASN, the request is flagged as suspicious. This manifests as CAPTCHA challenges, 429 rate limits, or silently de-personalized results that don't reflect the target location.

The solution is to route requests through residential proxies whose exit IP geographically matches your target market. With ProxyHat, you can specify country and city in the proxy username, ensuring the IP geolocation aligns with your SERP parameters.

ProxyHat Proxy URL Formats

# HTTP proxy — US, New York
http://user-country-US-city-newyork:pass@gate.proxyhat.com:8080

# HTTP proxy — Germany, Berlin
http://user-country-DE-city-berlin:pass@gate.proxyhat.com:8080

# SOCKS5 proxy — US, New York
socks5://user-country-US-city-newyork:pass@gate.proxyhat.com:1080

# Sticky session (same IP across requests)
http://user-country-US-city-newyork-session-abc123:pass@gate.proxyhat.com:8080

The raw proxy URL goes directly into your HTTP client configuration. For a more structured approach, you can wrap proxy URL construction in a helper class — a lightweight client pattern that keeps your scraping code clean:

from urllib.parse import quote

class ProxyHatClient:
    """Helper for building ProxyHat residential proxy URLs."""

    GATEWAY = "gate.proxyhat.com"
    HTTP_PORT = 8080
    SOCKS5_PORT = 1080

    def __init__(self, username: str, password: str):
        self.username = username
        self.password = password

    def http_url(self, country: str, city: str = None, session: str = None) -> str:
        """Build an HTTP proxy URL with geo-targeting."""
        user_part = f"{self.username}-country-{country.upper()}"
        if city:
            user_part += f"-city-{city.lower().replace(' ', '')}"
        if session:
            user_part += f"-session-{session}"
        return f"http://{user_part}:{self.password}@{self.GATEWAY}:{self.HTTP_PORT}"

    def socks5_url(self, country: str, city: str = None, session: str = None) -> str:
        """Build a SOCKS5 proxy URL with geo-targeting."""
        user_part = f"{self.username}-country-{country.upper()}"
        if city:
            user_part += f"-city-{city.lower().replace(' ', '')}"
        if session:
            user_part += f"-session-{session}"
        return f"socks5://{user_part}:{self.password}@{self.GATEWAY}:{self.SOCKS5_PORT}"


# --- Usage side by side: raw proxy URL vs helper ---

# Raw proxy URL (direct string)
raw_proxy = "http://user-country-US-city-newyork:pass@gate.proxyhat.com:8080"

# ProxyHat helper (structured)
client = ProxyHatClient("user", "pass")
helper_proxy = client.http_url("US", "new york")

print("Raw:  ", raw_proxy)
print("Helper:", helper_proxy)
# Both produce equivalent proxy URLs

Python Example: Rotating Residential IPs per Locale with curl_cffi

For SERP scraping, curl_cffi is a strong choice because it impersonates real browser TLS fingerprints, which helps avoid detection. Here's a complete example that builds uule values, rotates residential IPs per locale, and fetches SERPs with Chrome impersonation:

import base64
import time
import logging
from curl_cffi import requests as cffi_requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

def build_uule(canonical_name: str) -> str:
    prefix = "w+CAIQICI"
    name_bytes = canonical_name.encode("utf-8")
    length_char = chr(len(name_bytes) + 63)
    encoded = base64.b64encode(name_bytes).decode("utf-8")
    return f"{prefix}{length_char}{encoded}"

def build_proxy_url(country: str, city: str, username: str, password: str) -> str:
    """Build a ProxyHat residential proxy URL for the target locale."""
    city_slug = city.lower().replace(" ", "")
    return (
        f"http://{username}-country-{country.upper()}-city-{city_slug}"
        f":{password}@gate.proxyhat.com:8080"
    )

def fetch_serp(
    keyword: str,
    country: str,
    city: str,
    canonical_name: str,
    username: str,
    password: str,
    num: int = 100,
) -> str:
    """Fetch a geo-targeted Google SERP page."""
    uule = build_uule(canonical_name)
    proxy = build_proxy_url(country, city, username, password)

    params = {
        "q": keyword,
        "gl": country.lower(),
        "hl": "en",
        "uule": uule,
        "pws": "0",
        "num": str(num),
    }

    headers = {
        "Accept-Language": f"en-{country.upper()}",
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) "
            "Chrome/120.0.0.0 Safari/537.36"
        ),
    }

    logger.info("Fetching SERP: keyword='%s' city=%s proxy=%s", keyword, city, proxy)

    try:
        response = cffi_requests.get(
            "https://www.google.com/search",
            params=params,
            headers=headers,
            proxies={"http": proxy, "https": proxy},
            impersonate="chrome",
            timeout=30,
        )
        response.raise_for_status()
        logger.info("SERP fetched: %d bytes, status=%d", len(response.text), response.status_code)
        return response.text
    except Exception as exc:
        logger.error("SERP fetch failed for %s/%s: %s", country, city, exc)
        raise

# --- Run for multiple US cities ---
targets = [
    ("US", "new york", "New York,New York,United States"),
    ("US", "austin", "Austin,Texas,United States"),
    ("US", "chicago", "Chicago,Illinois,United States"),
    ("US", "miami", "Miami,Florida,United States"),
]

USERNAME = "user"
PASSWORD = "pass"

for country, city, canonical in targets:
    html = fetch_serp("plumber", country, city, canonical, USERNAME, PASSWORD)
    # Save or parse the HTML
    with open(f"serp_{city.replace(' ', '_')}.html", "w", encoding="utf-8") as f:
        f.write(html)
    time.sleep(2)  # Be polite between requests

This example uses impersonate="chrome" to send a TLS fingerprint that matches a real Chrome browser, significantly reducing the chance of bot detection compared to a standard requests call. Each city gets its own residential proxy exit IP via the -country-US-city-... flag, ensuring Google sees a consistent geographic story.

Parsing Organic Results and the Local Pack with selectolax

Once you have the HTML, you need to extract structured data. selectolax is a fast HTML parser that's ideal for SERP extraction. Here's a parser that pulls organic results, the local pack, and related searches:

from selectolax.parser import HTMLParser
import json

def parse_serp(html: str) -> dict:
    """Parse a Google SERP HTML page into structured data."""
    tree = HTMLParser(html)
    results = {"organic": [], "local_pack": [], "related": []}

    # --- Organic results ---
    for node in tree.css("div.g"):
        title_node = node.css_first("h3")
        link_node = node.css_first("a[href]")
        snippet_node = node.css_first("div[data-sncf], span.aCOpRe, div.VwiC3b")

        if title_node and link_node:
            results["organic"].append({
                "title": title_node.text(strip=True),
                "url": link_node.attributes.get("href", ""),
                "snippet": snippet_node.text(strip=True) if snippet_node else "",
            })

    # --- Local pack (map results) ---
    for node in tree.css("div[jsmodel] div[data-cid]"):
        name_node = node.css_first("span.OSrXXb, div.dbg0pd")
        rating_node = node.css_first("span.YDINWc, span.BTpUQb")
        if name_node:
            entry = {
                "name": name_node.text(strip=True),
                "rating": rating_node.text(strip=True) if rating_node else "",
            }
            results["local_pack"].append(entry)

    # --- Related searches ---
    for node in tree.css("a.k8XOCe"):
        text = node.text(strip=True)
        if text:
            results["related"].append(text)

    return results

# --- Parse a saved SERP ---
with open("serp_austin.html", "r", encoding="utf-8") as f:
    html = f.read()

parsed = parse_serp(html)
print(json.dumps(parsed, indent=2, ensure_ascii=False))

# Example output:
# {
#   "organic": [
#     {"title": "Austin Plumbing - 24/7 Emergency Service", "url": "https://...", "snippet": "..."},
#     ...
#   ],
#   "local_pack": [
#     {"name": "ABC Plumbing Austin", "rating": "4.8"},
#     ...
#   ],
#   "related": ["plumber near me", "emergency plumber austin", ...]
# }

Google's HTML structure changes frequently, so CSS selectors may need periodic adjustment. The key is to target structural attributes (like data-cid for local pack entries) rather than class names that change with each layout update.

Sticky Sessions for Multi-Page SERP Collection

When paginating through SERPs (e.g., collecting results beyond the first 100), you need the same IP across all page requests. If the IP rotates mid-pagination, Google may serve inconsistent results or trigger a CAPTCHA. ProxyHat sticky sessions solve this — append -session-{id} to the proxy username to pin a specific exit IP:

import uuid
import time
import logging
from curl_cffi import requests as cffi_requests

logger = logging.getLogger(__name__)

def fetch_serp_paginated(
    keyword: str,
    country: str,
    city: str,
    canonical_name: str,
    username: str,
    password: str,
    max_pages: int = 3,
) -> list[str]:
    """Fetch multiple SERP pages using a sticky session for IP consistency."""
    session_id = uuid.uuid4().hex[:12]
    city_slug = city.lower().replace(" ", "")

    proxy = (
        f"http://{username}-country-{country.upper()}-city-{city_slug}"
        f"-session-{session_id}:{password}@gate.proxyhat.com:8080"
    )

    pages = []
    for page in range(max_pages):
        start = page * 100  # num=100 per page
        params = {
            "q": keyword,
            "gl": country.lower(),
            "hl": "en",
            "uule": build_uule(canonical_name),
            "pws": "0",
            "num": "100",
            "start": str(start),
        }
        headers = {
            "Accept-Language": f"en-{country.upper()}",
            "User-Agent": (
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/120.0.0.0 Safari/537.36"
            ),
        }

        logger.info("Page %d: start=%d session=%s", page + 1, start, session_id)
        try:
            resp = cffi_requests.get(
                "https://www.google.com/search",
                params=params,
                headers=headers,
                proxies={"http": proxy, "https": proxy},
                impersonate="chrome",
                timeout=30,
            )
            resp.raise_for_status()
            pages.append(resp.text)
            logger.info("Page %d fetched: %d bytes", page + 1, len(resp.text))
        except Exception as exc:
            logger.error("Page %d failed: %s", page + 1, exc)
            break

        time.sleep(3)  # Delay between pages

    return pages

def build_uule(canonical_name: str) -> str:
    import base64
    prefix = "w+CAIQICI"
    name_bytes = canonical_name.encode("utf-8")
    length_char = chr(len(name_bytes) + 63)
    encoded = base64.b64encode(name_bytes).decode("utf-8")
    return f"{prefix}{length_char}{encoded}"

# Fetch 3 pages of results for "plumber" in Austin
pages = fetch_serp_paginated(
    keyword="plumber",
    country="US",
    city="austin",
    canonical_name="Austin,Texas,United States",
    username="user",
    password="pass",
    max_pages=3,
)
print(f"Collected {len(pages)} SERP pages")

The session ID persists the same residential IP across all page requests. Generate a new session ID for each new keyword or locale to get a fresh IP, preventing pattern detection from repeated use of the same IP across many queries.

CAPTCHA, 429 Backoff, and Retry Logic

Even with residential proxies and browser impersonation, Google may still serve CAPTCHAs or return HTTP 429 (Too Many Requests) under aggressive scraping. A robust pipeline needs exponential backoff with jitter and per-country proxy pools to distribute load:

import random
import time
import logging
from curl_cffi import requests as cffi_requests

logger = logging.getLogger(__name__)

class SERPScraper:
    """SERP scraper with retry, backoff, and per-country proxy rotation."""

    MAX_RETRIES = 4
    BASE_DELAY = 5  # seconds
    MAX_DELAY = 120  # seconds

    def __init__(self, username: str, password: str):
        self.username = username
        self.password = password
        # Per-country proxy pools (rotate IPs within a country)
        self._proxy_counter: dict[str, int] = {}

    def _next_proxy(self, country: str, city: str, session: str = None) -> str:
        """Rotate to the next proxy in the country pool."""
        count = self._proxy_counter.get(country, 0)
        self._proxy_counter[country] = count + 1
        city_slug = city.lower().replace(" ", "")
        user_part = f"{self.username}-country-{country.upper()}-city-{city_slug}"
        if session:
            user_part += f"-session-{session}"
        else:
            # Use counter as session to get different IPs
            user_part += f"-session-rot{count}"
        return f"http://{user_part}:{self.password}@gate.proxyhat.com:8080"

    def fetch_with_retry(
        self,
        url: str,
        params: dict,
        country: str,
        city: str,
        session: str = None,
    ) -> str | None:
        """Fetch with exponential backoff and jitter."""
        headers = {
            "Accept-Language": f"en-{country.upper()}",
            "User-Agent": (
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/120.0.0.0 Safari/537.36"
            ),
        }

        for attempt in range(self.MAX_RETRIES):
            proxy = self._next_proxy(country, city, session)
            try:
                resp = cffi_requests.get(
                    url,
                    params=params,
                    headers=headers,
                    proxies={"http": proxy, "https": proxy},
                    impersonate="chrome",
                    timeout=30,
                )

                if resp.status_code == 200:
                    # Check for CAPTCHA in HTML
                    if "captcha" in resp.text.lower() or "unusual traffic" in resp.text.lower():
                        logger.warning("CAPTCHA detected on attempt %d", attempt + 1)
                        delay = self._backoff_delay(attempt)
                        logger.info("Backing off %.1fs", delay)
                        time.sleep(delay)
                        continue
                    logger.info("Success on attempt %d via %s", attempt + 1, country)
                    return resp.text

                elif resp.status_code == 429:
                    delay = self._backoff_delay(attempt)
                    logger.warning("429 rate limited. Backing off %.1fs", delay)
                    time.sleep(delay)
                    continue

                else:
                    logger.error("HTTP %d on attempt %d", resp.status_code, attempt + 1)
                    if resp.status_code >= 500:
                        delay = self._backoff_delay(attempt)
                        time.sleep(delay)
                        continue
                    return None  # Client error, don't retry

            except Exception as exc:
                delay = self._backoff_delay(attempt)
                logger.error("Request failed (attempt %d): %s. Backoff %.1fs", attempt + 1, exc, delay)
                time.sleep(delay)

        logger.error("All %d retries exhausted for %s", self.MAX_RETRIES, country)
        return None

    def _backoff_delay(self, attempt: int) -> float:
        """Exponential backoff with jitter."""
        delay = min(self.BASE_DELAY * (2 ** attempt), self.MAX_DELAY)
        jitter = random.uniform(0, delay * 0.3)
        return delay + jitter

# --- Usage ---
scraper = SERPScraper("user", "pass")
import base64

def build_uule(canonical_name: str) -> str:
    prefix = "w+CAIQICI"
    name_bytes = canonical_name.encode("utf-8")
    length_char = chr(len(name_bytes) + 63)
    encoded = base64.b64encode(name_bytes).decode("utf-8")
    return f"{prefix}{length_char}{encoded}"

html = scraper.fetch_with_retry(
    url="https://www.google.com/search",
    params={
        "q": "coffee shop",
        "gl": "us",
        "hl": "en",
        "uule": build_uule("Seattle,Washington,United States"),
        "pws": "0",
        "num": "100",
    },
    country="US",
    city="seattle",
)
if html:
    print(f"Got SERP: {len(html)} bytes")
else:
    print("Failed to fetch SERP after retries")

Key design decisions in this retry logic:

  • CAPTCHA detection: Check the response body for captcha or unusual traffic strings — Google sometimes returns HTTP 200 with a CAPTCHA page instead of a proper error code.
  • Exponential backoff with jitter: Start at 5 seconds, double each attempt, cap at 120 seconds, add 0–30% random jitter to avoid thundering herd effects.
  • Per-country proxy rotation: Each retry uses a different session ID, forcing ProxyHat to assign a new residential IP within the same geographic pool.
  • 429 handling: Rate limit responses trigger backoff but don't consume the full retry budget aggressively — the jitter helps avoid synchronized retries across workers.

Per-Country Proxy Pools and Concurrency

For production SERP collection across multiple markets, organize your proxy pools by country. This prevents one market's rate limits from cascading into others and lets you tune concurrency per locale. A practical setup:

  • US markets: 50–100 concurrent sessions across cities, 2–3 second delay between requests per session.
  • EU markets: 30–50 concurrent sessions, respect local business hours for fresher results.
  • Asia-Pacific: 20–40 concurrent sessions, account for higher latency (200ms+ RTT to some regions).

Monitor success rates per country pool. If a pool's success rate drops below 90%, reduce concurrency or increase delays for that market. ProxyHat residential proxies typically achieve 99.9% uptime, but individual IPs may get temporarily flagged — rotation handles this naturally.

curl Example: Raw Proxy with uule

For quick testing or shell-based pipelines, here's a curl command using a ProxyHat residential proxy with full geo-targeting:

# Fetch SERP for "plumber" in Austin, TX via US residential proxy
# uule value pre-computed: w+CAIQICIYQXVsdGluLFRleGFzLFVuaXRlZCBTdGF0ZXM=

curl -s \
  --proxy "http://user-country-US-city-austin:pass@gate.proxyhat.com:8080" \
  -H "Accept-Language: en-US" \
  -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \
  "https://www.google.com/search?q=plumber&gl=us&hl=en&uule=w+CAIQICIYQXVsdGluLFRleGFzLFVuaXRlZCBTdGF0ZXM%3D&pws=0&num=100" \
  -o serp_austin.html

echo "Saved $(wc -c < serp_austin.html) bytes"

Note the %3D URL-encoding of the = character in the uule value. Always URL-encode uule when passing it in a query string.

Public Data and Terms of Service Considerations

Scraping Google SERPs operates in a legally gray area. Google's Terms of Service prohibit automated querying, and the hiQ Labs v. LinkedIn precedent in the US suggests that scraping publicly accessible data may be permissible, but this varies by jurisdiction. In the EU, GDPR may apply to any personal data inadvertently collected. Practical guidelines:

  • Respect robots.txt directives where technically feasible.
  • Rate-limit your requests to avoid degrading Google's service (2–5 seconds between requests per session).
  • Collect only the data you need — organic titles, URLs, snippets, and local pack entries. Avoid storing user-specific personalization data.
  • Consider using the Google Custom Search JSON API for lower-volume, ToS-compliant data collection (limited to 100 queries/day on the free tier).
  • Consult legal counsel before deploying large-scale scraping infrastructure, especially across multiple jurisdictions.

ProxyHat Setup and Resources

ProxyHat residential proxies provide city-level geo-targeting across 190+ countries. To get started:

  1. Sign up at ProxyHat pricing and choose a residential proxy plan.
  2. Check available proxy locations to confirm coverage for your target markets.
  3. Review the ProxyHat documentation for advanced session and rotation options.
  4. Explore web scraping use cases and SERP tracking for implementation patterns.

Key Takeaways

  • Country-level targeting is insufficient for local SEO. Use uule with city-level canonical names to pin results to specific markets.
  • Parameters must agree with IP geography. Route requests through residential proxies matching your target locale — Google cross-checks IP geolocation against gl and uule.
  • Always add pws=0 to disable personalization and get reproducible baseline SERPs for rank tracking.
  • Use sticky sessions for pagination to maintain IP consistency across multi-page SERP collection.
  • Implement exponential backoff with jitter and CAPTCHA detection — Google may return 200 with a CAPTCHA page rather than an error code.
  • Organize proxy pools by country to isolate rate limits and tune concurrency per market.
  • Use curl_cffi with impersonate="chrome" to send realistic TLS fingerprints that reduce detection rates.

FAQ

What is geo-targeted SERP scraping?

Geo-targeted SERP scraping is the practice of collecting Google search results pinned to a specific geographic location — typically a city or district — rather than just a country. It uses parameters like uule, gl, and hl alongside geographically matching residential proxies to ensure the SERP reflects what a local user would see. This is essential for local SEO rank tracking, competitive analysis, and market research where results vary by location.

Why does geo-targeted SERP scraping matter for proxy users?

Google personalizes search results based on the requester's IP geolocation. If your proxy IP doesn't match your target market, Google may ignore your uule and gl parameters or serve de-personalized results. Proxy users need residential proxies with city-level geo-targeting (like ProxyHat's -country-US-city-newyork flag) to ensure the IP geolocation aligns with the SERP parameters, producing accurate local results.

Which proxy type works best for geo-targeted SERP scraping?

Residential proxies are the best choice for geo-targeted SERP scraping because they use real ISP-assigned IP addresses that Google trusts. Datacenter IPs are frequently flagged by Google's anti-bot systems, leading to CAPTCHAs and blocked requests. Mobile proxies also work well but are more expensive. Residential proxies with city-level targeting provide the best balance of reliability, geographic precision, and cost for most SERP collection workflows.

How do you avoid blocks when implementing geo-targeted SERP scraping?

To avoid blocks: use residential proxies with geo-targeting that matches your SERP parameters, impersonate a real browser TLS fingerprint with curl_cffi, add pws=0 for non-personalized baselines, implement exponential backoff with jitter for 429s and CAPTCHAs, use sticky sessions for multi-page pagination, maintain 2–5 second delays between requests per session, and rotate IPs across per-country proxy pools to distribute load.

How do you construct a uule parameter for city-level targeting?

The uule parameter is built from a fixed prefix (w+CAIQICI), a length byte (chr(len(canonical_name_bytes) + 63)), and the base64-encoded canonical place name (e.g., Austin,Texas,United States). Pass the resulting string as the uule query parameter alongside gl and hl to pin SERP results to that specific city.

Frequently asked questions

What is geo-targeted SERP scraping?

Geo-targeted SERP scraping is the practice of collecting Google search results pinned to a specific geographic location — typically a city or district — rather than just a country. It uses parameters like uule, gl, and hl alongside geographically matching residential proxies to ensure the SERP reflects what a local user would see. This is essential for local SEO rank tracking, competitive analysis, and market research where results vary by location.

Why does geo-targeted SERP scraping matter for proxy users?

Google personalizes search results based on the requester's IP geolocation. If your proxy IP doesn't match your target market, Google may ignore your uule and gl parameters or serve de-personalized results. Proxy users need residential proxies with city-level geo-targeting to ensure the IP geolocation aligns with the SERP parameters, producing accurate local results.

Which proxy type works best for geo-targeted SERP scraping?

Residential proxies are the best choice for geo-targeted SERP scraping because they use real ISP-assigned IP addresses that Google trusts. Datacenter IPs are frequently flagged by Google's anti-bot systems, leading to CAPTCHAs and blocked requests. Mobile proxies also work well but are more expensive. Residential proxies with city-level targeting provide the best balance of reliability, geographic precision, and cost.

How do you avoid blocks when implementing geo-targeted SERP scraping?

To avoid blocks: use residential proxies with geo-targeting that matches your SERP parameters, impersonate a real browser TLS fingerprint with curl_cffi, add pws=0 for non-personalized baselines, implement exponential backoff with jitter for 429s and CAPTCHAs, use sticky sessions for multi-page pagination, maintain 2-5 second delays between requests per session, and rotate IPs across per-country proxy pools.

How do you construct a uule parameter for city-level targeting?

The uule parameter is built from a fixed prefix (w+CAIQICI), a length byte (chr(len(canonical_name_bytes) + 63)), and the base64-encoded canonical place name (e.g., Austin,Texas,United States). Pass the resulting string as the uule query parameter alongside gl and hl to pin SERP results to that specific city.

Rank tracking that doesn't get blocked

Accurate SERP data with residential proxies. Create an account and start monitoring.

Start tracking
← Back to Blog