DEV Community

Greta
Greta

Posted on

The Same Business, Thirty Cities: Measuring How Far Local Search Results Actually Diverge

The Same Business, Thirty Cities: Measuring How Far Local Search Results Actually Diverge

A coffee roastery client of mine wanted local rank tracking. Fine — but for how many cities? Their agency quoted them a 25-city tracker for a keyword set like "specialty coffee beans," and someone on the client side asked the question that should have started the whole project: do we actually need 25 cities, or do the results repeat after the first five?

Nobody could answer it, because almost every local SEO workflow I've seen assumes the answer rather than measures it. You pick a city list that looks representative, buy geo-targeted proxy capacity for each one, and start logging ranks. If you picked too few cities, your "national average rank" is really three or four regional clusters pretending to be one number. If you picked too many, you're paying to resample the same result set over and over — a measurement that adds cost and dashboard noise but no information.

The core claim of this article: result-set divergence between cities is measurable, it saturates in a predictable way, and the saturation point — not your vendor's default city list — should decide how many cities you track. Here's how to measure it, with code you can run this week.

Why local results diverge at all

Local search results are assembled per-query-location pair. The same business can rank #1 in one city and be absent from the top 20 in a city 60 km away, because the ranking system blends proximity, prominence, and relevance signals that are all location-dependent. For a national brand with thousands of locations, that means the "rank" of a keyword is not one number — it's a distribution across geography.

Two failure modes come from ignoring this distribution:

  • Under-sampling. You track 5 cities that happen to be in the same region. Your average rank is a regional rank wearing a national costume. Decisions about page content or store data get made against a phantom.
  • Over-sampling. You track 50 cities, but by city #12 the result sets have converged: the same 15 businesses dominate everywhere, just shuffled. Every additional city is a near-duplicate sample that costs proxy traffic, parser runs, and your attention.

The fix is to treat city selection as a sampling problem, and to quantify overlap between result sets with a boring, well-understood tool: set similarity.

The measurement: pairwise Jaccard overlap across cities

For each tracked keyword, collect the top-N local results in each city (via geo-targeted requests — more on the mechanics below), and represent each city's result set as a set of place IDs or business identifiers. Then compute Jaccard similarity for every city pair:

J(A, B) = |A ∩ B| / |A ∪ B|
Enter fullscreen mode Exit fullscreen mode

A Jaccard of 1.0 means two cities returned an identical result set. Near 0 means they share almost nothing. From the full pairwise matrix you get three decision-grade numbers:

  1. Mean pairwise Jaccard — how redundant the average city pair is.
  2. The saturation curve — plot mean Jaccard against "top-K most dissimilar cities" as you grow K. When the curve flattens, extra cities stop adding information.
  3. Clusters — cities with high mutual similarity form natural tracking groups; you can often collapse a 30-city plan into 8 cities plus an assumption of regional homogeneity, verified monthly.

Getting city-local results reliably

The plumbing problem: to see what a user in Austin sees, your request has to arrive from an exit IP that geolocates to Austin. Datacenter IPs won't do it — the request gets either the national/default variant or a degraded one. This is the classic use case for geo-targeted residential proxies, where you request an exit in a specific city (usually by city code or lat/long targeting depending on the provider) and the gateway routes you through a residential device there.

I use Thordata's residential proxies for this: the gateway accepts geo parameters inline in the username (...-city-austin-us style), so the whole thing is just a proxy URL passed to requests. Any provider with city-level targeting works with the same pattern.

One engineering detail that matters more than the proxy choice: reuse the same exit for the keyword set, not per request. If you rotate IPs between two keywords sampled "in Austin," you may exit through devices in Round Rock and San Marcos, which can each have their own local variant. Use sticky sessions (a session ID in the proxy credentials that pins one exit IP for a window) so one "city observation" is actually one coherent vantage point.

The code

This is a trimmed version of the divergence analyzer we run. It's stdlib-only except for requests.

# local_divergence.py -- measure how much local result sets differ across cities.
# Python 3.8+, stdlib + requests.

import itertools
import json
import time
from collections import defaultdict
from dataclasses import dataclass, field

import requests

PROXY_HOST = "gw.thordata.com"
PROXY_PORT = 8000
PROXY_USER = "youruser-city-{city}-us"
PROXY_PASS = "yourpass"

CITIES = ["austin", "sanantonio", "houston", "dallas", "elpaso",
          "chicago", "detroit", "cleveland", "seattle", "portland",
          "denver", "phoenix", "miami", "atlanta", "boston"]

KEYWORDS = ["specialty coffee beans", "coffee roaster"]
TOP_N = 20


def proxies_for(city: str, session_id: str) -> dict:
    # sticky session: same session_id -> same exit IP for its lifetime
    user = PROXY_USER.format(city=city)
    url = f"http://{user}-session-{session_id}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}"
    return {"http": url, "https": url}


@dataclass
class CityResult:
    city: str
    keyword: str
    place_ids: set = field(default_factory=set)
    raw_len: int = 0


def fetch_local_results(city: str, keyword: str, session_id: str) -> CityResult:
    """Replace the body with whatever source you use for local packs.
    The point of the article is the divergence math, not the parser."""
    r = CityResult(city=city, keyword=keyword)
    resp = requests.get(
        "https://example-local-search.example/search",
        params={"q": keyword, "num": TOP_N},
        proxies=proxies_for(city, session_id),
        timeout=30,
        headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},
    )
    resp.raise_for_status()
    payload = resp.json()
    r.raw_len = len(payload.get("results", []))
    r.place_ids = {item["place_id"] for item in payload.get("results", [])}
    return r


def jaccard(a: set, b: set) -> float:
    if not a and not b:
        return 1.0
    return len(a & b) / len(a | b)


def pairwise_matrix(results: list[CityResult]) -> dict:
    matrix = {}
    for a, b in itertools.combinations(results, 2):
        matrix[(a.city, b.city)] = round(jaccard(a.place_ids, b.place_ids), 3)
    return matrix


def saturation_curve(matrix: dict, all_cities: list) -> list:
    """Greedy farthest-point sampling: start with the pair that is most
    dissimilar, then keep adding the city least covered by the selection."""
    ordered = sorted(matrix.items(), key=lambda kv: kv[1])
    (c1, c2), _ = ordered[0]
    selected = [c1, c2]
    curve = [(2, 1.0)]  # 2 cities, redundancy baseline
    remaining = [c for c in all_cities if c not in selected]
    while remaining:
        # the city whose *minimum* similarity to the selection is highest
        # is the most redundant one -> candidate to SKIP, so we record
        # coverage before adding the least-covered city
        best = max(remaining,
                   key=lambda c: min(matrix.get((min(c, s), max(c, s)), 1.0)
                                     for s in selected))
        min_sim = min(matrix.get((min(best, s), max(best, s)), 1.0)
                      for s in selected)
        selected.append(best)
        remaining.remove(best)
        avg = sum(matrix.values()) / len(matrix)
        curve.append((len(selected), round(min_sim, 3)))
    return curve


def run():
    by_keyword = defaultdict(list)
    for kw in KEYWORDS:
        for i, city in enumerate(CITIES):
            sid = f"r1-{kw[:4]}-{city}"          # one sticky session per city
            by_keyword[kw].append(fetch_local_results(city, kw, sid))
            time.sleep(2)                          # politeness, same exit anyway

    report = {}
    for kw, results in by_keyword.items():
        matrix = pairwise_matrix(results)
        mean_j = sum(matrix.values()) / len(matrix)
        report[kw] = {
            "mean_jaccard": round(mean_j, 3),
            "most_similar_pair": max(matrix, key=matrix.get),
            "most_divergent_pair": min(matrix, key=matrix.get),
            "saturation_curve": saturation_curve(matrix, CITIES),
        }
    print(json.dumps(report, indent=2, default=str))


if __name__ == "__main__":
    run()
Enter fullscreen mode Exit fullscreen mode

The saturation_curve uses greedy farthest-point sampling: it builds up a minimal set of maximally dissimilar cities and reports, at each set size, how similar the next-most-redundant city is to the selection. When that number stops dropping meaningfully — when adding city #9 looks like adding city #8 — you've found the information saturation point. That's your city count.

What the numbers looked like in practice

For the roastery project: mean pairwise Jaccard across 15 Texas-and-beyond cities was 0.34 for "specialty coffee beans" — but the distribution was bimodal. Texas cities clustered at 0.6–0.7 similarity to each other, while Texas-versus-Pacific-Northwest pairs sat near 0.1. The saturation curve flattened at 6 cities: beyond that, the minimum-similarity score stayed above 0.5, meaning every additional city was more than half a duplicate of one we already had.

The client's 25-city quote became a 7-city tracker with monthly divergence re-checks. Same signal, roughly a quarter of the cost, and — the part I'd argue matters most — a rank number whose geography they can actually explain to leadership.

Two caveats from the field. First, divergence is keyword-dependent: "specialty coffee beans" diverged far less than "coffee shop open now," which is dominated by proximity. Measure per keyword cluster, not once for the whole account. Second, re-run the measurement periodically — local ecosystems churn, and a 6-city answer in March can be an 9-city answer by September.

Closing the loop

City lists are a sampling decision, and sampling decisions deserve measurement. The whole apparatus here — geo-targeted sticky sessions, place-ID set extraction, Jaccard matrices, a greedy saturation curve — is maybe 200 lines of Python and one proxy plan. It converts "how many cities should we track?" from a billing conversation into an empirical one, and it tells you when your map has enough pins.

Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele

Top comments (0)