Local SEO Auditing with Geo-Targeted Crawlers: Multi-City Rank Checking for Local Businesses
A plumbing client once called me, furious: "We rank #1 in every city we serve — why is the phone dead?" I'd run the audit from my desk in one metro area. The report looked gorgeous, and every number in it was fiction. Re-run from IPs pinned to each service city, three of their "top" positions vanished and two competitors appeared in the map pack where they'd been invisible. Nothing about the client's SEO had changed. Only the vantage point did.
That's the thesis of this post, and it's the thing most local SEO audits get wrong: a local SEO audit is only as truthful as the geographic vantage point it's observed from. City-level geo-targeting isn't an optimization you bolt on. It's the core requirement that makes the data mean anything at all. If your crawler isn't physically querying from the city you're auditing, you're not auditing that city.
Why Local Results Break the Country-Level Model
In the last post of this series I covered multi-country rank tracking, where gl=us plus a US exit IP gets a representative national picture. Local SEO is a different beast: Google's local results — the map pack, the local finder, "near me" organic — are keyed to a point on the map, not a country.
Google determines that point from a stack of signals, roughly in this order of strength:
- Explicit location in the query ("plumber in Austin") — parsed, not geolocated.
-
The
uuleparameter or Chrome DevTools-style geolocation override — a base64-encoded lat/lng or canonical name that tells Google "pretend I am here." - The IP address's geographic resolution — where the request physically originates.
- Account/personalization signals — signed-in history, previous searches.
Here's the trap: these signals disagree all the time, and when they do, Google tends to trust the IP. That's why a datacenter IP — even one nominally "in" Texas — produces garbage for a local audit. Datacenter ranges resolve to vague, often stale geolocation databases, and Google frequently serves them sanitized or de-localized results as part of its bot mitigation anyway. You'll query "emergency plumber" expecting Dallas and get a national results page, or a pack for a city 200 miles away, and your audit silently records it as truth.
The fix is residential proxy sessions pinned to the city level, so the IP's geo-resolution and the query's intent actually agree. For local pack scraping I also pass uule as a belt-and-suspenders signal — the two together are much more stable than either alone.
The Consistency Rules That Make Comparisons Valid
Before code, three rules. These matter more than any parser detail:
Same time window across cities. Local packs shuffle meaningfully during the day (lunch hours, evenings). If you crawl Austin at 6am, Denver at noon, and Boise at 9pm, your cross-city comparison is confounded with time-of-day. Sweep all cities inside one tight window — I target under 30 minutes for a 20-city audit.
Zero personalization. Never audit from a browser profile with cookies, and never reuse a proxy session across different clients' audits. Each observation should come from a fresh, cookieless, city-pinned session — your own past searches for the client's brand inflate their positions, and you'll never notice from inside.
Deterministic sampling. Same query format, same hl/gl, same num, same parsing path, every run. An audit is a measurement instrument; instruments don't improvise.
A Multi-City Local Audit Crawler
Here's the working skeleton. It sweeps a list of cities, queries local-intent keywords through city-pinned residential sessions, parses the local pack, and emits a per-city scorecard. Runnable with requests, beautifulsoup4, and pandas (pip install requests beautifulsoup4 pandas).
import base64
import random
import time
from datetime import datetime, timezone
import pandas as pd
import requests
from bs4 import BeautifulSoup
PROXY_HOST = "gate.thordata.com"
PROXY_PORT = 9000
PROXY_USER = "your-username"
PROXY_PASS = "your-password"
CLIENT = "brazos-valley-plumbing.com"
BUSINESS_NAME = "Brazos Valley Plumbing"
# city + the geo signal we pin the session to
CITIES = [
{"city": "austin", "uule": "Austin,TX,US"},
{"city": "round-rock", "uule": "Round Rock,TX,US"},
{"city": "georgetown", "uule": "Georgetown,TX,US"},
{"city": "san-marcos", "uule": "San Marcos,TX,US"},
]
KEYWORDS = ["emergency plumber", "water heater repair", "drain cleaning"]
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
def make_uule(canonical: str) -> str:
"""Encode a canonical location name the way Google's uule param expects."""
prefix = b"prefix + canary"
# uule is base64 of a fixed prefix + the canonical name; the
# 'w+CAIQICI' variant encodes a location name string.
payload = b"w+CAIQICI" + canonical.encode()
return base64.b64encode(payload).decode()
def fetch_local_serp(keyword: str, city: dict) -> str:
"""Fetch a SERP through a residential session pinned to the city."""
user = f"{PROXY_USER}-city-{city['city']}-us-session-{random.randint(1_000_000, 9_999_999)}"
proxy = {
"http": f"http://{user}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
"https": f"http://{user}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
}
r = requests.get(
"https://www.google.com/search",
params={
"q": keyword,
"gl": "us",
"hl": "en",
"num": 20,
"tbm": "lcl", # local results tab: pack + local finder listings
"uule": make_uule(city["uule"]),
},
headers={"User-Agent": UA, "Accept-Language": "en-US,en;q=0.9"},
proxies=proxy,
timeout=30,
)
r.raise_for_status()
return r.text
def parse_local_pack(html: str) -> list[dict]:
"""Extract ordered local-pack entries: name, position, rating, reviews."""
soup = BeautifulSoup(html, "html.parser")
results = []
# local pack cards live under role=link headings with business names
for pos, card in enumerate(
soup.select("div[data-attrid^='kc:/local'] h2, div.VkpGBb"), 1
):
name = card.get_text(strip=True)
if not name:
continue
container = card.find_parent("div")
rating = reviews = None
if container:
span = container.select_one("span[aria-hidden='true']")
if span and "star" in str(span.get("class", [])):
rating = span.get_text(strip=True)
rev = container.select_one("span:contains('reviews')")
if rev:
reviews = rev.get_text(strip=True)
results.append({
"name": name,
"pack_position": pos,
"rating": rating,
"reviews": reviews,
})
return results
Note the session string in the proxy username: -city-austin-us-session-<random>. Each request gets a fresh residential IP inside the target city — fresh enough to avoid personalization, pinned enough to resolve locally. That combination is the entire ballgame.
Running the Sweep and Scoring Cities
Now the audit loop — all cities inside one window, jittered to stay polite — and the scorecard aggregation:
def run_audit() -> pd.DataFrame:
rows = []
sweep_start = datetime.now(timezone.utc)
for city in CITIES:
for kw in KEYWORDS:
try:
html = fetch_local_serp(kw, city)
except requests.RequestException as e:
rows.append({"city": city["city"], "keyword": kw,
"error": str(e)})
continue
pack = parse_local_pack(html)
entry = next((e for e in pack
if BUSINESS_NAME.lower() in e["name"].lower()), None)
rows.append({
"city": city["city"],
"keyword": kw,
"pack_size": len(pack),
"in_pack": entry is not None,
"pack_position": entry["pack_position"] if entry else None,
"rating": entry["rating"] if entry else None,
"reviews": entry["reviews"] if entry else None,
"fetched_at": datetime.now(timezone.utc).isoformat(),
})
time.sleep(random.uniform(4, 8)) # stay under rate limits
df = pd.DataFrame(rows)
df.attrs["sweep_started"] = sweep_start
return df
def scorecard(df: pd.DataFrame) -> pd.DataFrame:
ok = df[df.get("error", pd.Series(dtype=str)).isna()
if "error" in df else True]
return (ok.groupby("city")
.agg(
checks=("keyword", "count"),
pack_presence=("in_pack", "mean"),
avg_pack_position=("pack_position", "mean"),
top3_share=("pack_position", lambda s: (s <= 3).mean()),
)
.round(2)
.sort_values("pack_presence", ascending=False))
if __name__ == "__main__":
df = run_audit()
print(scorecard(df).to_string())
df.to_csv(f"local_audit_{datetime.now():%Y%m%d_%H%M}.csv", index=False)
A realistic scorecard from one of these sweeps looks like:
| city | checks | pack_presence | avg_pack_position | top3_share |
|---|---|---|---|---|
| austin | 3 | 0.33 | 5.0 | 0.00 |
| round-rock | 3 | 1.00 | 2.3 | 0.67 |
| georgetown | 3 | 1.00 | 1.7 | 1.00 |
| san-marcos | 3 | 0.00 | — | 0.00 |
San Marcos at 0% isn't a ranking problem — it's a proximity problem, and no amount of on-page work fixes it. That distinction, visible only with per-city data, is exactly what a single-vantage-point audit flattens away.
Detecting NAP Inconsistencies Across Locations
Once you're scraping at city level, a second audit falls out almost for free: pulling the business profile data (name, address, phone) that each city's pack returns for your client, then diffing it across cities. NAP consistency is foundational local SEO hygiene, and Google's own knowledge graph will happily show a stale phone number in one metro and the current one in another — which silently bleeds calls.
def nap_check(df: pd.DataFrame) -> pd.DataFrame:
"""Diff the profile snippets Google shows per city; flag mismatches."""
profile_cols = [c for c in df.columns if c.startswith("nap_")]
if not profile_cols:
return pd.DataFrame()
grouped = (df.groupby("city")[profile_cols].first())
mismatches = []
for col in profile_cols:
variants = grouped[col].dropna().unique()
if len(variants) > 1:
mismatches.append({"field": col, "variants": list(variants),
"cities": grouped[col].dropna().to_dict()})
return pd.DataFrame(mismatches)
(The nap_* columns come from extending parse_local_pack to grab the address/phone lines under each card — same selector family, two more fields.) A phone number that differs between Austin and Round Rock is either a stale listing Google hasn't reconciled or a duplicate profile splitting review equity — visible only from the per-city vantage point.
Scheduling City Sweeps Without Getting Blocked
Twenty cities × three keywords × a local-finder follow-up per pack hit is ~120 requests per sweep. That's nothing for a residential network, but it is enough to trip rate patterns if you're careless:
- Sweep windows, not continuous crawling. Run the full city sweep once or twice daily inside a tight window, then stop. A steady drip of one request every few minutes for hours looks more bot-like than a burst that ends.
- Jitter everything. Sleeps between requests, sweep start time (never exactly 09:00:00), and the proxy session IDs. My sweeps start at a random offset within a 20-minute band.
- Rotate sessions per check, not per city. Each keyword-in-city check gets its own fresh IP. Reusing one IP for a whole city sweep builds a per-IP pattern that outlives the session.
- Cache the pack follow-ups. You rarely need deep profile data more than weekly; daily, you just need pack presence and position. Tier the request budget accordingly.
At that volume a daily 20-city audit costs maybe 60–100MB of residential traffic — a rounding error against the client's ad spend — and the data compounds: store every sweep and the trends (which cities are decaying, where a competitor is climbing) become obvious within two weeks.
The Vantage Point Is the Instrument
Strip everything else away and a local SEO audit is a measurement problem: you're trying to measure what a customer in a specific city sees when they search. The measurement is only valid if the observer stands where the customer stands. A datacenter IP in a different metro, a national gl parameter, a personalized session — each of these doesn't degrade the audit by a few percent, it invalidates it, because local search is a different result set per point on the map, not one result set with local decoration.
So the first question to ask of any local SEO report isn't "what are the rankings?" It's "where were these queries run from?" If the answer is anywhere other than city-pinned residential sessions, the rankings describe the crawler's location, not the client's visibility.
Disclosure: I use Thordata's residential proxies for the city-level geo-targeting that local SEO audits require. If you want to try them, they're at thordata.com, and the code **thor020* gets you 10% off.*
Top comments (0)