feat(seo-data): W1 — surface rich_results, the data inspect already threw away

google_seo.py:129 read only indexStatusResult out of the URL Inspection
response and discarded the rest. richResultsResult was already on the wire:
same call, same OAuth scope (webmasters.readonly), same quota. Google's own
structured-data verdict on the live indexed URL was being downloaded and
binned.

Plan correction: the TODO said "richresults verb". Wrong — a new verb means
a second POST to the same endpoint for a payload already received, on a
per-site quota, and nobody wants rich results without index status. Extended
inspect() instead; fetch.sh unchanged, no new verb, no new scope.

Design driven by the published schema, not by guesswork — two details I
would have got wrong:
- richResultsResult is OMITTED when Google detects none ("absent if none
  found"). Surfaced as synthetic verdict ABSENT rather than a missing key: a
  caller cannot tell an absent key from a check that never ran. ABSENT means
  "none detected", never "invalid". The KeyError path is the real risk here,
  so it has its own fixture dir (fixtures-norich/) and its own tests.
- PARTIAL is "Reserved, unused" per the API docs. The draft emitted it. It
  never emits it now, and a test asserts the absence.

issues[] deduped (the same issueMessage repeats across every affected item),
errors/warnings count instances — scale from the counter, cause from the
message.

seo-analyzer STEP 4 consumes it as the system's only programmatic JSON-LD
validation, bounded honestly: index:inspect is per-URL, quota'd, and needs a
verified property, so its reach is the STEP 9 COVERAGE ratio, not the site.
Replacing a fake validator with a fake coverage promise would be no better.

This is what beats claude-seo: their README's "dual validator (Rich Results
Test + Schema Markup Validator)" is two hyperlinks a human clicks — grep of
their .py finds zero calls. This is Google's verdict, via auth already held.

Note: the new dedupe assertion trips SC2015 (A && B || C), same as the
pre-existing line 27; ok() ends on an assignment so it cannot fail. Kept for
house-style consistency — lib/seo-data/*.sh is outside the lib/*.sh
shellcheck glob anyway.

Verified: seo-data 85 -> 95 pass, 0 fail; both paths exercised end-to-end
and output inspected by hand; make test 35 GREEN / 0 RED; py_compile clean.
This commit is contained in:
Bastien Chanot
2026-07-16 20:51:30 +02:00
parent fe93b7945b
commit a6d423b940
6 changed files with 115 additions and 5 deletions
+16 -1
View File
@@ -80,9 +80,24 @@ fetch.sh queries --account client-a --property sc-domain:ex.com [--days 90] [--d
→ {"status":"degraded","reason":"no_credentials"|"token_revoked"|"network_error"|"rate_limited"}
fetch.sh inspect --account client-a --property … --url https://ex.com/page
→ {"status":"ok","source":"gsc","indexed":true,"coverage":"…","last_crawl":"…"}
→ {"status":"ok","source":"gsc","indexed":true,"coverage":"…","last_crawl":"…",
"rich_results":{"verdict":"PASS|FAIL|NEUTRAL|VERDICT_UNSPECIFIED|ABSENT",
"types":[{"type":"FAQ","items":2,"errors":2,"warnings":1,
"issues":["Missing field 'acceptedAnswer'"]}]}}
→ {"status":"degraded","reason":"…"}
rich_results rides the SAME URL-Inspection response — Google already sends
it, `inspect` used to discard it. No extra call, quota or OAuth scope.
It is the only programmatic structured-data validation in the system.
• verdict PARTIAL is never emitted — the API reserves it as unused.
• verdict ABSENT is SYNTHETIC (not a Google enum): the API omits
richResultsResult entirely when it detects no rich results. Surfaced
as a value rather than a missing key, because a caller cannot tell an
absent key apart from a check that never ran. ABSENT = "none
detected", never "invalid".
• errors/warnings count issue INSTANCES; issues[] is deduped — the same
issueMessage repeats across every affected item.
fetch.sh forget --label client-a
→ {"status":"ok","removed":true|false} # false = label wasn't in the store