seo-local (M19)
Local pages live and die on consistent, machine-readable place data. Conditional module: only runs when the page is classified local-business (storefront, service-area, multi-location, contact/location page). Reference: references/schema-tier1.md (LocalBusiness row).
Inputs
Work from the PageSnapshot named in your dispatch envelope: read parsed from <run_dir>/pages/<slug>.json (jsonld[], text_sample, iframes for maps) and vertical from the envelope; Grep pages/<slug>.html for verbatim evidence; site artifacts live in <run_dir>/site/{robots.json,sitemaps.json,discovery.json}. Deterministic findings already emitted by audit.mjs are listed in <run_dir>/findings.deterministic.json — do not re-emit those ids; add model-judged findings only. If invoked directly with a URL/path and no snapshot exists, first run node "${CLAUDE_PLUGIN_ROOT}/scripts/snapshot.mjs" <target> --out "${CLAUDE_PLUGIN_DATA}/runs" and use the printed snapshot path.
Audits
Working from the PageSnapshot (parsed_rendered when render.used is not none, else parsed), only for local-business pages:
- LocalBusiness schema: a
LocalBusiness (or correct subtype, e.g. Restaurant, Dentist) JSON-LD block with required props — name, address (PostalAddress), telephone.
- NAP consistency: visible Name / Address / Phone match across the page (header, footer, contact block) and match the schema values — no formatting drift, no stale numbers.
- Geo coordinates:
geo (GeoCoordinates with latitude/longitude) present and plausible for the stated address.
- Opening hours:
openingHoursSpecification present, structured (dayOfWeek, opens, closes), and agreeing with any visible hours.
- Service-area: for service-area businesses,
areaServed declared rather than (or alongside) a PostalAddress storefront.
- GBP alignment (advisory): NAP, categories, and hours should match the Google Business Profile — the tool cannot read GBP, so this is advisory only.
Fixes
- AUTO: generate a complete, valid
LocalBusiness JSON-LD block from user-confirmed NAP, hours, geo, and service-area, with a stable @id. The block is a diff for fix.
- NAP / hours drift: PROPOSED — surface the mismatch and a draft correction for per-item accept (the tool cannot know which value is canonical).
- GBP alignment: ADVISORY — never written by the tool.
Never fabricate an address, phone, coordinates, or hours — ask the user or leave a clearly-marked
TODO placeholder they fill before injection.
Verification
- Offline:
node "${CLAUDE_PLUGIN_ROOT}/scripts/validate-jsonld.mjs" --snapshot <pages/<slug>.json> (or --url <u>) (schema_validator) — JSON validity + required LocalBusiness props; dom_assert confirms visible NAP matches schema.
- Tier 1: confirm rich-result eligibility with Google Rich Results Test / schema.org validator. When the data tier (or GBP) is unavailable, status is
needs_api — never a false pass.
Findings
Emit findings per schema/finding.schema.json. On a non-local page these are not_applicable (severity 0); on local pages severity is 4, axis both. Severity policy: 5 is reserved for catastrophic, eligibility-killing facts at site/template scope; 4 major · 3 moderate · 2 minor · 1 cosmetic · 0 informational — and only an established severity-5 fail in an active category can cap a score (references/scoring-model.md). Examples:
M19.localbusiness.missing — local page with no LocalBusiness schema (status fail, severity 4, fixable: auto, axis both, confidence established). evidence.observed quotes the visible address/phone with no matching ld+json; verification.reproduce runs the validator above.
M19.nap.inconsistent — footer phone differs from schema telephone (status warn, severity 4, fixable: proposed, axis both, confidence directional). evidence.observed quotes both strings; verified by dom_assert.
M19.hours.missing — no openingHoursSpecification despite visible hours (status warn, severity 4, fixable: auto, axis both, confidence established).
Each finding: evidence.observed quotes the page; expected_impact is banded + confidence-tagged (no naked %).
Honesty
- Consistent NAP + structured place data are established signals for local pack/maps and AI place answers; raw on-page proximity/ranking gains are not promised here — the dominant local-pack factors (proximity, GBP signals, reviews) sit outside the page DOM and are advisory.
- Adding
geo/openingHoursSpecification will not by itself move local rankings without an accurate, verified GBP; never ship that as a guaranteed win.
- Do not invent coordinates from an address via an unverified geocode and present them as fact — mark unconfirmed values
TODO.
1---2name: seo-local3description: Audit and generate Local SEO signals for local-business pages — verify LocalBusiness JSON-LD, NAP consistency, geo coordinates, openingHoursSpecification, and service-area, advise on Google Business Profile alignment, and produce ready-to-inject LocalBusiness JSON-LD. Module M19 (conditional). Feeds both the Search SEO and AI Visibility scores.4---56# seo-local (M19)78Local pages live and die on consistent, machine-readable place data. **Conditional module**: only runs when the page is classified local-business (storefront, service-area, multi-location, contact/location page). Reference: `references/schema-tier1.md` (LocalBusiness row).910## Inputs11Work from the PageSnapshot named in your dispatch envelope: read `parsed` from `<run_dir>/pages/<slug>.json` (`jsonld[]`, `text_sample`, `iframes` for maps) and `vertical` from the envelope; Grep `pages/<slug>.html` for verbatim evidence; site artifacts live in `<run_dir>/site/{robots.json,sitemaps.json,discovery.json}`. Deterministic findings already emitted by `audit.mjs` are listed in `<run_dir>/findings.deterministic.json` — do not re-emit those ids; add model-judged findings only. If invoked directly with a URL/path and no snapshot exists, first run `node "${CLAUDE_PLUGIN_ROOT}/scripts/snapshot.mjs" <target> --out "${CLAUDE_PLUGIN_DATA}/runs"` and use the printed snapshot path.1213## Audits14Working from the PageSnapshot (`parsed_rendered` when `render.used` is not `none`, else `parsed`), only for local-business pages:151. **LocalBusiness schema**: a `LocalBusiness` (or correct subtype, e.g. `Restaurant`, `Dentist`) JSON-LD block with required props — `name`, `address` (`PostalAddress`), `telephone`.162. **NAP consistency**: visible Name / Address / Phone match across the page (header, footer, contact block) and match the schema values — no formatting drift, no stale numbers.173. **Geo coordinates**: `geo` (`GeoCoordinates` with `latitude`/`longitude`) present and plausible for the stated address.184. **Opening hours**: `openingHoursSpecification` present, structured (`dayOfWeek`, `opens`, `closes`), and agreeing with any visible hours.195. **Service-area**: for service-area businesses, `areaServed` declared rather than (or alongside) a `PostalAddress` storefront.206. **GBP alignment (advisory)**: NAP, categories, and hours should match the Google Business Profile — the tool cannot read GBP, so this is advisory only.2122## Fixes23- **AUTO**: generate a complete, valid `LocalBusiness` JSON-LD block from **user-confirmed** NAP, hours, geo, and service-area, with a stable `@id`. The block is a diff for `fix`.24- **NAP / hours drift**: PROPOSED — surface the mismatch and a draft correction for per-item accept (the tool cannot know which value is canonical).25- **GBP alignment**: ADVISORY — never written by the tool.26**Never fabricate** an address, phone, coordinates, or hours — ask the user or leave a clearly-marked `TODO` placeholder they fill before injection.2728## Verification29- Offline: `node "${CLAUDE_PLUGIN_ROOT}/scripts/validate-jsonld.mjs" --snapshot <pages/<slug>.json>` (or `--url <u>`) (`schema_validator`) — JSON validity + required LocalBusiness props; `dom_assert` confirms visible NAP matches schema.30- Tier 1: confirm rich-result eligibility with Google Rich Results Test / schema.org validator. When the data tier (or GBP) is unavailable, status is `needs_api` — never a false `pass`.3132## Findings33Emit findings per `schema/finding.schema.json`. On a non-local page these are `not_applicable` (severity 0); on local pages severity is 4, axis `both`. **Severity policy**: 5 is reserved for catastrophic, eligibility-killing facts at site/template scope; 4 major · 3 moderate · 2 minor · 1 cosmetic · 0 informational — and only an `established` severity-5 `fail` in an active category can cap a score (`references/scoring-model.md`). Examples:34- `M19.localbusiness.missing` — local page with no LocalBusiness schema (status `fail`, severity 4, `fixable: auto`, axis `both`, confidence `established`). `evidence.observed` quotes the visible address/phone with no matching `ld+json`; `verification.reproduce` runs the validator above.35- `M19.nap.inconsistent` — footer phone differs from schema `telephone` (status `warn`, severity 4, `fixable: proposed`, axis `both`, confidence `directional`). `evidence.observed` quotes both strings; verified by `dom_assert`.36- `M19.hours.missing` — no `openingHoursSpecification` despite visible hours (status `warn`, severity 4, `fixable: auto`, axis `both`, confidence `established`).37Each finding: `evidence.observed` quotes the page; `expected_impact` is banded + confidence-tagged (no naked %).3839## Honesty40- Consistent NAP + structured place data are **established** signals for local pack/maps and AI place answers; raw on-page **proximity/ranking gains are not promised** here — the dominant local-pack factors (proximity, GBP signals, reviews) sit outside the page DOM and are advisory.41- Adding `geo`/`openingHoursSpecification` will **not** by itself move local rankings without an accurate, verified GBP; never ship that as a guaranteed win.42- Do not invent coordinates from an address via an unverified geocode and present them as fact — mark unconfirmed values `TODO`.