79 lines
5.2 KiB
Markdown
79 lines
5.2 KiB
Markdown
# 0008 — Local Rank Tracking: Location Data & Search
|
||
|
||
How city/region-level rank tracking stores and searches DataForSEO's location
|
||
registry, and why it works the way it does. Shipped July 2026; future
|
||
directions are listed at the end.
|
||
|
||
## The feature
|
||
|
||
Rank tracking configs take an optional `location_name` — a canonical DataForSEO
|
||
location string like `Enid,Oklahoma,United States`. Null means the existing
|
||
country-level behavior, unchanged. Uniqueness is enforced by two partial
|
||
indexes: one config per (project, domain, country) for national trackers, one
|
||
per (project, domain, country, location) for local ones.
|
||
|
||
When set, the location flows through verbatim:
|
||
|
||
- **SERP checks** (live and queued task-post) send `location_name` instead of
|
||
`location_code`, so positions reflect what a searcher in that city sees.
|
||
SERP pricing is location-independent, so cost estimates are unchanged.
|
||
- **Keyword metrics** come city-scoped: volume / CPC / competition from Google
|
||
Ads `search_volume` (the only DataForSEO source that accepts sub-country
|
||
geotargets), merged per keyword with national KD / intent from Labs (which
|
||
is country-only). This matters: "rv storage near me" is 135K/mo nationally
|
||
but 70/mo in Pittsburgh — a national number on a local tracker overstates
|
||
demand by orders of magnitude. Keywords Google Ads collapses away get
|
||
explicit nulls rather than a leaked national value; the UI column reads
|
||
"Local volume" and exports name the city. Adds ~$0.09 per metrics refresh.
|
||
- **The picker** is a debounced combobox in the config modal, searching the
|
||
country's registry and storing the selected canonical name. Local mode
|
||
requires a selection; switching country clears it.
|
||
|
||
## Location data: how search works
|
||
|
||
The registry endpoint (`/v3/serp/google/locations/{iso}`) is free but has no
|
||
search parameter and returns the full country list per call — 9.5 MB / 60k
|
||
entries for the US. Slimmed to the five types users target (City, County,
|
||
Municipality, DMA Region, Region) it is ~23k entries / 1.5 MB. The data
|
||
changes roughly quarterly (Google geotarget updates).
|
||
|
||
The search path: combobox (350 ms debounce) → `searchSerpLocations` server fn
|
||
→ per-country list from **KV** (`serp-locations:{iso}`, 30-day
|
||
`expirationTtl`, hot reads edge-cached with `cacheTtl: 86400`) → substring
|
||
filter, top 10. A KV miss triggers the origin fetch + slim + store, with
|
||
concurrent cold fills coalesced in-isolate so the prewarm and a fast first
|
||
keystroke can't both download the 9.5 MB payload.
|
||
|
||
Selecting **Local** in the modal fires `prewarmSerpLocations` (a `useQuery`
|
||
keyed on country, `staleTime: Infinity`), so the one slow cold fill (~3 s)
|
||
usually happens before the first keystroke. Warm searches are tens of ms.
|
||
|
||
## Why KV
|
||
|
||
| Alternative | Why not |
|
||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| Workers Cache API layer | Documented no-op on workers.dev (self-hosters), per-colo only, no persistence guarantees. KV's `cacheTtl` provides the same hot-read caching, managed. |
|
||
| R2 (+ cache in front) | Works (an earlier iteration shipped it) but needs a second layer for read latency; KV is one primitive with the same profile. |
|
||
| D1 / Postgres table | Schema + migrations on two providers for quarterly-static reference data; revisit if server-side validation or MCP location search justify a real table. |
|
||
| Durable Object per country | Pins to its first-request region forever; new binding for self-host; buys coordination this read-only data doesn't need. |
|
||
| Static assets, client search | Refresh requires redeploy — self-hosters would be pinned to release-time snapshots. |
|
||
| "Just accept zip codes" | Doesn't avoid the registry: DataForSEO only accepts canonical values, and zips are registry entries themselves (~32k for the US). |
|
||
|
||
Cost is noise either way: KV bills per operation, so storage for all supported
|
||
countries is ~$0.02/month and each search read is fractions of a cent.
|
||
|
||
## Future directions (not built)
|
||
|
||
- **Picker quality**: prominence-ranked results (offline GeoNames/Census tier
|
||
table — the registry has no population data, so "Portland" currently ranks
|
||
the Maine DMA above Portland, OR), cities-first with a type filter,
|
||
recently-used/suggested locations (must come from our own config history —
|
||
GSC has no city dimension), and a selection-confirmation line.
|
||
- **ZIP fast path**: numeric queries search a separate cached Postal Code
|
||
blob; useful for sub-metro service areas inside large cities.
|
||
- **Multi-city fan-out**: multi-select in the picker creating one config per
|
||
city with a shared keyword set — the agency 3–5-metro workflow.
|
||
- **Server-side `location_name` validation** at config save, closing the gap
|
||
where a hand-crafted request can store an arbitrary string (fails at
|
||
DataForSEO at cost 0 today, so client-side validation suffices).
|