feat(seo-data): H2 — drift baseline; regressions vs changes, not prose

seo-analyzer.md:1365 keeps history as "date + score + key changes" — prose the
LLM writes about its own previous prose. Lossy, unreproducible, and
machine-uncomparable, so "the redesign silently dropped 40 canonicals" is
invisible unless someone happens to notice.

drift snapshots title/description/canonical/robots/h1_count/jsonld_types per
URL and diffs them. Stdlib only, no auth.

The classification IS the feature: LOSING a signal is a regression, CHANGING
one is a change that may well be intended. The engine says which kind; the
agent judges. A reworded title is not an alert; an evaporated canonical is.

Runs over the WHOLE sitemap, never a sample — caught while designing: a drift
computed over a sample that changes between runs compares nothing.

NOT rank tracking. That is the common misread of this same feature elsewhere;
positions come from GSC `queries`. This is on-page regression detection.

Also caught in my own draft before testing: _capture reused
sm._mock("page.html"), the exact single-fixture flaw I had already fixed in
linkgraph — one fixture cannot express a multi-page snapshot, every URL would
read identical. Now pages.json, same convention.

Proved on a planted failure rather than a happy path — two clean sites would
look identical to a detector that always returns []:
  v1 -> v2: canonical lost on /a, h1 + jsonld lost on /, title reworded,
  /gone removed, /neuve added
  → 3 regressions, 1 change, gone/new both detected, title correctly NOT a
    regression.

Store is ~/.claude/seo-data/drift/<host>.json, 0700, written via os.replace so
a crash never leaves a half-written baseline; a corrupt store degrades to
"first run" instead of killing the audit.

Verified: seo-data 144 -> 155 pass, 0 fail; full suite green.
This commit is contained in:
Bastien Chanot
2026-07-17 13:25:45 +02:00
parent d6b8edc8ea
commit f69cfc5cb4
8 changed files with 256 additions and 1 deletions
+21
View File
@@ -193,6 +193,27 @@ fetch.sh linkgraph --url https://ex.com/sitemap.xml [--max 500]
• Mock is pages.json ({url: html}), not a single page.html: one fixture
cannot express a graph — every node would carry identical links.
fetch.sh drift --url https://ex.com/sitemap.xml [--max 500]
→ {"status":"ok","baseline":true,"captured":"…","pages":24,"store":"…"}
→ {"status":"ok","baseline":false,"since":"…","gone":[…],"new":[…],
"regressions":[{"url":…,"field":"canonical","was":"…","now":null}],
"changes":[{"url":…,"field":"title","was":"…","now":"…"}]}
On-page drift between audits. seo-analyzer.md:1365 keeps only "date + score
+ key changes" as PROSE the LLM writes about its own previous prose: lossy,
unreproducible, machine-uncomparable. So "the redesign silently dropped 40
canonicals" stays invisible. This snapshots title/description/canonical/
robots/h1_count/jsonld_types per URL and diffs them.
• NOT rank tracking (the common misread of this feature elsewhere).
Positions come from GSC `queries`. This is regression detection.
• Runs over the WHOLE sitemap, never a sample: a drift over a sample that
changes between runs compares nothing.
• LOSING a signal = regression. CHANGING one = change, possibly intended —
the agent judges that, the engine only says which kind it is.
• Store: ~/.claude/seo-data/drift/<host>.json, 0700, written via
os.replace — never a half-written baseline. Corrupt store → treated as
a first run rather than crashing the audit.
fetch.sh forget --label client-a
→ {"status":"ok","removed":true|false} # false = label wasn't in the store