You own a shop. The pages rank fine, and the phone number is right in the footer. But the person who typed "best {service} {city}" never reaches the phone call, and you have no idea which of the hundred local SEO details is the one missing. Local search is also the only part of SEO where the actual ranking itself lives somewhere you cannot see: the map pack, the Google Business Profile panel, the AI answer that name-drops your competitor.
This article gives you a Codex skill that runs local SEO diagnosis from a single URL. It answers four questions in about three seconds: what business type is this page (brick-and-mortar, service-area, or hybrid)? Which industry vertical is it speaking? Does the page actually carry local evidence - NAP, LocalBusiness schema, review data, citation platforms? And what does the five-dimensional scorecard say? Nothing to install beyond python3.
Why local search behaves differently from everywhere else
Local SEO has its own math because the ranking machine is different. The map pack weighs proximity and your Google Business Profile signals plus reviews; the organic side adds your site's local on-page work and authority; and the AI side reads, mostly, the citation layer. The benchmark table below is the condensed version of the research the source project aggregates - every row is a fact from the source's own citation stack (Whitespark, Sterling Sky, BrightLocal, Seer, Search Atlas):
What matters | Sizes up to | What it means for you |
|---|---|---|
GBP signals share of map pack weight | 32% | the Google Business Profile is not optional |
Proximity share of ranking variance | 55% | being close physically beats being good on paper |
Review signals share (up from 16%) | ~20% | reviews are a ranking factor with a cliff |
Mobile "near me" searches -> visit within 24h | 76% | local page quality has a same-day payoff |
ChatGPT used for local recommendations (up from 6%) | 45% | the AI answer engine is now a second local channel |
ChatGPT local conversion rate vs Google organic | 15.9% vs 1.76% | a 9x difference per referral |
Local pack ad coverage (Jan 2025 -> Jan 2026) | 1% -> 22% | organic slots in the pack are shrinking |
Two details change how you read every score below: the 18-day rule (review momentum - a ranking cliff after roughly three weeks without a new review) and the fact that 3 of the top 5 AI-visibility factors are citation-related. Local is the one discipline where your reputation on other platforms is literally your ranking signal.
Install the skill
Two files. First the folder:
mkdir -p ~/.codex/skills/codex-seo-local/scriptsSave this as ~/.codex/skills/codex-seo-local/SKILL.md:
---
name: codex-seo-local
description: Use when the user asks about local SEO - Google Business Profile, GBP, map pack, local pack rankings, NAP consistency, citations, reviews, service-area business, local schema, location pages, or multi-location websites. Run from one URL; nothing to install beyond python3.
---
# Local SEO Scan
Analyze one URL for local-search evidence. The scanner detects business type
(brick-and-mortar / service area / hybrid) and industry vertical, extracts
NAP and LocalBusiness schema, then scores five dimensions. When a page has
no city, address, NAP, map or review signals, print NO LOCAL SIGNALS instead
of a score: text keywords alone do not make a page local.
## Run
```bash
python3 ~/.codex/skills/codex-seo-local/scripts/local_scan.py <url> [--json]
```
`--json` prints the full result set for the next step (analysis, list, or
fix queue).
## Business type detection
| Signal on page | Type |
|---|---|
| Street address (visible or in schema) | brick-and-mortar |
| Street address + service-area language | hybrid |
| "we come to you", "service area", "home visits", "serving X Y" only | service-area business (SAB) |
| None of the above | not-detected |
SABs skip embedded-map checks and get `areaServed` recommendations instead
of physical-address requirements.
## Industry vertical detection
| Vertical | Detected by | Schema subtype to require |
|----------|-------------|---------------------------|
| Restaurant | menu, reservations, cuisine, dine-in/takeout | `Restaurant`, not plain `LocalBusiness` |
| Healthcare | appointment, insurance accepted, HIPAA, physician | `MedicalClinic`, `Dentist`, `Hospital` |
| Legal | attorney, practice areas, free consultation | `LegalService` (do not use deprecated `Attorney`) |
| Home services | free estimate, licensed and insured, 24/7 | subtype + `areaServed` + `Service` |
| Real estate | MLS, listings, open house, brokerage | `RealEstateAgent` + listings |
| Automotive | VIN, test drive, dealership, service department | `AutoDealer` (not `VehicleListing`) |
## The five dimensions
| Dimension | Weight | 3 strongest signals | Common loss |
|-----------|--------|---------------------|-------------|
| Locality on page | 25% | city in title + H1, NAP visible | generic title, no click-to-call |
| Local schema | 20% | correct subtype, address, geo (5+ decimals) | plain `LocalBusiness`, no geometry |
| Reviews | 15% | aggregateRating, >=4.0 stars, >=10 count | no rating in schema, stale reviews |
| Service pages | 20% | one page per service, /locations/ links | doorway pattern (swap-test fails) |
| AI readiness | 20% | Yelp/TripAdvisor/BBB/Reddit presence, local FAQ | no platform mentions |
**Score gate:** print NO LOCAL SIGNALS when the page has no address, no
schema, no map and no tel link, regardless of keyword hits. A blog post can
say "reviews" without being a local business page.
## Review thresholds (benchmarks)
- 10 reviews = magic threshold (one-day visibility step).
- 18-day rule: rankings can cliff after ~3 weeks without a new review.
- 31% of consumers use only 4.5+ rating businesses; 68% use only 4+.
- 74% care only about reviews from the last 3 months.
- 88% would use a business that responds to reviews.
## Citation tiers
Tier 1: Google Business Profile, Yelp, BBB, Facebook.
Tier 2: Bing Places (it powers ChatGPT, Copilot, Alexa), Apple Maps.
Tier 3: Data aggregators - Data Axle, Foursquare, Neustar/TransUnion.
For AI engines: ChatGPT does not read GBP directly; it sources from Bing
index, Yelp, TripAdvisor, BBB and Reddit. 3 of the top 5 AI-visibility
factors are citation-related.
## Multi-location quality gates
| Volume | Gate |
|--------|------|
| Up to 30 location pages | validate >60% unique content per page |
| 30+ pages | WARNING: enforce the 60% unique rule |
| 50+ pages | HARD STOP: require justification before continuing |
Swap test: if swapping the city name changes nothing, it is a doorway page.
Distinct content per location page (local photos, area testimonials, local
FAQs) is a requirement, not an enhancement.
## Output contract
Produce `LOCAL-SEO-ANALYSIS-{domain}.md` with: score card (5 dimensions),
business type, vertical, GBP checklist, review snapshot, NAP audit, citation
status, schema status, location-page quality, top 10 prioritized actions,
and a limitations disclaimer (what could NOT be assessed: real-time local
pack position, GBP Insights, full backlinks).
## Errors
| Scenario | Action |
|----------|--------|
| URL unreachable | report the error; do not guess site content |
| NO LOCAL SIGNALS | confirm the site is a local business, get the GBP listing URL, re-run |
| Address absent but schema present | the schema may carry the full address; extract it before flagging |
| Vertical unclear | present the top two detected verticals and ask before applying vertical rules |
| 50+ location pages | HARD STOP per the quality gate above |Save this as ~/.codex/skills/codex-seo-local/scripts/local_scan.py:
#!/usr/bin/env python3
"""Local SEO scan from one URL. Detects business type (brick-and-mortar,
service area, hybrid) and industry vertical, extracts NAP and LocalBusiness
schema, then scores five dimensions. Reports NO LOCAL SIGNALS instead of a
score when no local evidence exists at all.
Standard library only. Usage:
python3 local_scan.py <url> [--json]
"""
import json
import re
import sys
import urllib.error
import urllib.request
TIMEOUT = 12
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 "
"codex-seo-local/1.0")
VERTICALS = {
"restaurant": ["menu", "reservations", "cuisine", "dine-in", "takeout",
"table for", "hours of operation"],
"healthcare": ["appointment", "insurance accepted", "hipaa", "patient",
"physician", "dentist", "clinic"],
"legal": ["attorney", "lawyer", "practice areas", "bar admission",
"free consultation", "case results"],
"home_services": ["free estimate", "licensed and insured", "24/7",
"emergency service", "service area"],
"real_estate": ["mls", "listings", "for sale", "open house",
"brokerage", "sq ft"],
"automotive": ["vin", "test drive", "service department",
"certified pre-owned", "dealership", "inventory"],
}
ADDR_RE = re.compile(
r"\b\d{2,5}\s+[A-Z][a-zA-Z.]*(?:\s+[A-Z][a-zA-Z.]*){0,3}?"
r"\s+(?:St|Street|Ave|Avenue|Blvd|Boulevard|Rd|Road|Dr|Drive|Ln|Lane|"
r"Way|Ct|Court|Pl|Place|Trail|Parkway)\b")
ZIP_RE = re.compile(r"\b\d{5}(?:-\d{4})?\b|\b[A-Z]{2}\s+\d{5}\b")
MAP_RE = re.compile(r"maps\.google(?:\.com|usercontent)")
SERVICE_RE = re.compile(
r"(we come to you|on-site service|service area|home visits|"
r"serving [A-Z][a-z]+ [A-Z][a-z]+)", re.I)
LOCAL_KINDS = {"LocalBusiness", "Restaurant", "Dentist", "LegalService",
"AutoDealer", "RealEstateAgent", "MedicalBusiness",
"MedicalClinic", "Hospital", "BarOrPub", "CafeOrCoffeeShop"}
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
resp = urllib.request.urlopen(req, timeout=TIMEOUT)
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, ""
except Exception as e:
return "ERR:" + str(e)[:60], ""
def find_jsonld(html):
out = []
for m in re.finditer(
r'<script[^>]*type=["\']?application/ld\+json["\']?[^>]*>(.*?)'
r'</script>', html, re.S | re.I):
raw = m.group(1).strip()
if not raw:
continue
try:
data = json.loads(raw)
except Exception:
continue
out.extend(walk_nodes(data))
return out
def walk_nodes(node):
if isinstance(node, dict):
yield node
for v in node.values():
yield from walk_nodes(v)
elif isinstance(node, list):
for v in node:
yield from walk_nodes(v)
def types_of(node):
t = node.get("@type")
if isinstance(t, list):
return set(str(x) for x in t)
return {str(t)} if t else set()
def business_type(html, schemas):
street = bool(ADDR_RE.search(html)) or any(
s.get("address", {}).get("streetAddress") for s in schemas)
map_embed = bool(MAP_RE.search(html))
svc = bool(SERVICE_RE.search(html))
if street and svc:
return "hybrid"
if street:
return "brick-and-mortar"
if svc:
return "sab"
return "not-detected"
def detect_vertical(html):
low = html.lower()
hits = {k: sum(v in low for v in ws) for k, ws in VERTICALS.items()}
top = sorted(hits.items(), key=lambda kv: -kv[1])
return [(k, v) for k, v in top if v >= 2][:2]
def main():
if len(sys.argv) < 2:
print("local_scan.py - local SEO scan from one URL")
print(" python3 local_scan.py <url> [--json]")
return
url = sys.argv[1]
status, html = fetch(url)
if status != 200:
print("fetch failed: %s" % status)
return
schemas = [n for n in find_jsonld(html) if types_of(n) & LOCAL_KINDS]
btype = business_type(html, schemas)
verticals = detect_vertical(html)
city = phone = street = ""
typ = agg = addr = geo = s0 = None
if schemas:
s0 = schemas[0]
typ = sorted(types_of(s0) & LOCAL_KINDS)[0]
addr = s0.get("address") or {}
geo = s0.get("geo") or {}
agg = s0.get("aggregateRating") or {}
street = addr.get("streetAddress", "")
city = addr.get("addressLocality", "")
phone = s0.get("telephone", "")
def tag_text(tag):
m = re.search(r"<%s[^>]*>(.*?)</%s>" % (tag, tag), html, re.S | re.I)
return m.group(1) if m else ""
title_meta = re.search(r"<title>(.*?)</title>", html, re.S | re.I)
title = title_meta.group(1) if title_meta else ""
h1 = tag_text("h1")
tel = bool(re.search(r'href=["\']?tel:', html, re.I))
maps = bool(MAP_RE.search(html))
nap_visible = bool(street or ZIP_RE.search(html))
low = html.lower()
s_locality = 0
if city and city.lower() in title.lower():
s_locality += 5
if city and city.lower() in h1.lower():
s_locality += 5
if nap_visible:
s_locality += 5
if tel:
s_locality += 5
if maps:
s_locality += 5
s_schema = 0
if typ:
s_schema += 6
if typ != "LocalBusiness":
s_schema += 4
if addr and street:
s_schema += 4
if geo and len(str(geo.get("latitude", "")).split(".")[-1]) >= 5:
s_schema += 3
if s0 and (s0.get("openingHours")
or s0.get("openingHoursSpecification")):
s_schema += 3
s_reviews = 0
if agg:
s_reviews += 8
try:
if agg and float(agg.get("ratingValue", 0)) >= 4.0:
s_reviews += 3
except ValueError:
pass
if re.search(r"\breviews?\b", low):
s_reviews += 2
try:
if agg and int(agg.get("reviewCount", 0)) >= 10:
s_reviews += 2
except ValueError:
pass
s_service = 0
if len(re.findall(r'<a\b[^>]*href=', html, re.I)) >= 20:
s_service += 8
kw_hits = [k for k in ("menu", "services", "locations", "gallery")
if re.search(r"\b%s\b" % k, low)]
s_service += min(9, 3 * len(kw_hits))
if re.search(r"/locations/|store locator|find a shop", low):
s_service += 3
s_ai = 0
ai_platforms = [x for x in ("yelp", "tripadvisor", "bbb.org", "reddit")
if x in low]
if ai_platforms:
s_ai += 10
if re.search(r"best (of|restaurant|service)|award|the most loved", low):
s_ai += 5
if re.search(r"\bfaq", low):
s_ai += 5
total = s_locality + s_schema + s_reviews + s_service + s_ai
# Local SEO evidence = NAP/schema/map/tel. Text keywords are supporting
# context, never enough by themselves to claim a local business page.
no_signals = (not schemas and not street and not maps and not tel)
out = {"url": url, "status": status, "business_type": btype,
"vertical": [v for v, _ in verticals] or ["not detected"],
"city": city, "phone": phone, "schema_type": typ or "none",
"dimensions": {"locality_on_page": s_locality, "local_schema": s_schema,
"reviews": s_reviews, "service_pages": s_service,
"ai_readiness": s_ai},
"total": "NO LOCAL SIGNALS" if no_signals else total,
"no_local_signals": no_signals}
if "--json" in sys.argv:
print(json.dumps(out, default=str, indent=2))
return
print("local scan %s" % url)
print(" status %s business type: %s vertical: %s" % (
status, btype, ", ".join(v for v, _ in verticals) or "not detected"))
print(" schema : %s (%d blocks) city: %s phone: %s" % (
typ or "none", len(schemas), city or "(unknown)", phone or "(none)"))
if no_signals:
print(" result: NO LOCAL SIGNALS")
print(" No city, address, NAP, map, review or platform signals were found.")
print(" Confirm the site serves a local business and re-run with the GBP URL.")
return
MAXES = {"locality_on_page": 25, "local_schema": 20, "reviews": 15,
"service_pages": 20, "ai_readiness": 20}
for name, v in out["dimensions"].items():
print(" %-18s %3d/%d" % (name, v, MAXES[name]))
print(" TOTAL: %d/100" % total)
if __name__ == "__main__":
main()Run
python3 ~/.codex/skills/codex-seo-local/scripts/local_scan.py https://your-site.com
python3 ~/.codex/skills/codex-seo-local/scripts/local_scan.py https://your-site.com --jsonA real scan: one Chicago pizza shop
I ran the scanner on the Pequod's Pizza site, a well-known Chicago deep-dish restaurant with Lincoln Park and Morton Grove locations, on the day I wrote this article. Literal output:
local scan https://www.pequodspizza.com
status 200 business type: brick-and-mortar vertical: restaurant
schema : Restaurant (2 blocks) city: Chicago phone: +1-773-327-1512
locality_on_page 20/25
local_schema 14/20
reviews 2/15
service_pages 17/20
ai_readiness 20/20
TOTAL: 73/100Read it like a diagnosis, dimension by dimension:
- locality_on_page 20/25. City in the title and an H1, NAP visible in the page, click-to-call present. The 5 missing points are the embedded map: no
maps.googleiframe on this page, so the geographic reinforcement signal is absent. - local_schema 14/20. Two
Restaurantblocks, correct subtype - this is the right way. 14 of 20 with the subtype correct means: an address present, butgeocoordinates missing or short, and noopeningHoursSpecificationfound in the scanned block. Adding coordinates with five decimals and hours spec would add 6 points. - reviews 2/15. This is the weak spot, and it is the honest one. No
aggregateRatingin the schema (0 of 8 points), and the page only carries the word "reviews" somewhere (2 of the text-based points). The business itself has plenty of real reviews across platforms - but its own page and schema do not surface them. That is exactly the kind of loss that matters for both pack rankings (the ~20% review factor) and AI answers. - service_pages 17/20. Site-level signals: plenty of internal links, the "menu" and "gallery" keywords, and a
locationlink chain. It says a site that looks like a single-location restaurant but links its location pages. - ai_readiness 20/20. Yelp, TripAdvisor, BBB and Reddit presence, plus best-of/award language on the page. This restaurant is famous enough to be mentioned - and the scanner confirms the page itself repeats the mentions.
The score's takeaway: this page is not broken, it is under-surfaced. NAP and schema subtype are right; the fixes are coordinate precision, opening hours, and surfacing review evidence (aggregateRating block on the page, review platform links) - plus an embedded map. All of it is an afternoon of work, and none of it is a redesign.
The control run: a site that is not a local business
Run the scanner on a digital-only site and you get the gate that makes this skill trustworthy:
local scan https://auspia.ai
status 200 business type: sab vertical: restaurant
schema : none (0 blocks) city: (unknown) phone: (none)
result: NO LOCAL SIGNALS
No city, address, NAP, map, review or platform signals were found.
Confirm the site serves a local business and re-run with the GBP URL.Notice what happened before the gate fired: the text detector said "sab" and "restaurant" - this blog reviews tools, and the pages say "service" and "menu" often enough to fool a keyword matcher. Without the gate, this run would have produced a nonsense local score. Instead the machine correctly refused to score: it found no address, no schema, no map, no tel link. That is the rule embedded in the SKILL.md - text keywords alone cannot make a page local, and the same rule is why this skill is safe to run on any URL you give it.
Reading the reference tables
The SKILL.md carries the reference material that the scanner points at. Two of them decide everything:
- Business type changes the rules. A brick-and-mortar page: full NAP + map + hours. A service-area business: no fake street address,
areaServedwith named cities instead. A hybrid: both. The scanner's first line is the type, not the score - ratings without a type are meaningless. - Schema subtype is the machine-readable kind. The vertical table's third column is the real point: a restaurant page should say
Restaurant(notLocalBusiness), legal pagesLegalService(theAttorneysubtype was deprecated), automotiveAutoDealer(notVehicleListing). Wrong subtype = a valid-looking schema that describes nothing.
The multi-location gate is the table to respect before scaling: validate >60% unique content per page below 30 location pages, WARNING at 30+, HARD STOP at 50+. The old pattern of one template swapped per city can cost most of a site's local rankings, so the gate interrupts, not advises.
Where the blind spots hide
- The map signal is unverifiable on JS maps embeds. Lazy-load or JS-injected maps hide the iframe from raw HTML. A missing map check is "not detected from raw HTML", not "no map". Check with a browser if the page is authoritative about its geography.
- aggregateRating in schema is a claim, not proof. The schema may say 4.8 stars and 1,200 reviews while the GBP says otherwise. Use the schema as a "this business surfaces its rating" signal, and compare it to the GBP listing for the real number.
- "Best of" language is both a signal and a marketing phrase. The ai_readiness dimension scores it because it is a genuine AI-citation factor; but the phrase appears on many pages without any actual award. Read it as "the page claims authority", not "the authority exists".
- A 44% locality score may simply mean the page is a homepage. The scanner fetches one URL. The local evidence for a multi-location site lives on location pages under a
locations/URL; scan those pages individually.
Troubleshooting table
What you see | Meaning | Action |
|---|---|---|
| No address/schema/map/tel on the page | Confirm it is a local business page; use the GBP listing URL or a location page |
| Bot-blocking or geo-blocking | Try the homepage first, then a location page; a browser fetch may be needed |
Vertical | Text-keyword false positive | Read business type and schema lines; the gate and schema are the authority |
| No addressLocality in schema | If the NAP is visible in text, the scanner still needs the schema; add the missing field to win those points |
44-style partial score on one URL | You scanned a hub page | Scan each location page; the per-page outcome is the point |
50+ location pages | The HARD STOP gate | Stop and justify before scaling; enforce the swap test on every page |
Install this skill by pasting to Codex
Copy this paragraph into Codex together with the two code blocks above:
Read the two code blocks in the current message. Create~/.codex/skills/codex-seo-local/SKILL.md(markdown block) and~/.codex/skills/codex-seo-local/scripts/local_scan.py(python block) exactly as written. Then runpython3 ~/.codex/skills/codex-seo-local/scripts/local_scan.py <your-location-page-or-business-url> --json, report the business type, the five dimension scores, the total, and the top five fixes in priority order.
FAQ
Is Google Business Profile included in this scan? No. The scanner reads one web page; GBP is its own panel whose data is only readable by the owner. This is the honest limitation the SKILL.md's disclaimer names: rankings from search-engine-side data (GBP Insights, real-time local pack position) cannot be assessed from the URL. For GBP-level work, log into the Business Profile panel itself, and use the scanner for the site-side half.
Why would a fictional 20% review weight matter to a small shop? Reviews are the only local factor a small shop can fully own: a year of steady review asks can move the 18-day cliff bar, and review momentum is a factor in both pack rankings and AI suggestions. The scorecard shows shops where the fix is effort (the 2/15 above) rather than money.
The scanner says "restaurant" but it's a food blog. Is the scan wrong? The vertical detection is keyword evidence only. The business type line, the schema, and the gate are the authoritative layers. In the run above, the text detector guessed wrong ("restaurant" on the blog) - and the gate still refused to score because there was no schema or address. That ordering is the design.
Does local schema actually help? It is not a direct ranking factor, per Google's own guidance. It is worth 20% in this model because: it enables rich results, machine systems parse it reliably, and the richness it requires (address, geo, hours, ratings) is exactly the density GPS/apps/AI engines need. Treat schema as the machine-readable summary of your local facts, not a ranking lever.
What about Apple Maps and Bing Places for the AI answer engines? Claim them, and the scanner's ai_readiness dimension will credit platform mentions it detects. Bing Places directly powers ChatGPT, Copilot and Alexa answers; Apple Maps claims are the basis for Siri guidance. Both are free and both are sources AI engines actually read - the highest-effort-per-payoff items in local SEO.
Next in the series (post 11 of 20): [How to Set Up Codex for Google Search Data (Full SKILL.md Included)](https://auspia.ai/blog/codex-seo-google) - pagespeed/crux real field data, plus the gsc csv path.
Previous in the series: How to Diagnose a Backlink Profile with Codex (Full SKILL.md Included). The full series roadmap lists all 20 posts.
Author: Ethan Marlowe, GEO Measurement Lead Across 500+ Prompts at Auspia. Ethan writes about visibility measurement, local pack tracking, and where AI engines source their local recommendations.
Based on the open-source claude-seo project by AgriciDaniel (MIT license, GitHub). This series adapts it for the Codex runtime: rewritten methodology, a Codex-native SKILL.md, and new evidence collectors written from scratch for this series.




