feat(seo-data): C2 — cannibalisation from Google's own data, one param away

The inventory called this "no duplicate-content / cannibalisation detection".
Splitting that into its two halves shows one is free and the other is a trap.

CANNIBALISATION — free, and the data was already reachable. Search Analytics
has always accepted several dimensions at once ("no limit to the number of
dimensions that you can group by"); this engine only ever sent
`"dimensions": [dim]` and _norm_queries only ever read `keys[0]`. So
query+page — the pairing that exposes the conflict — was one parameter away
and nobody asked. Same shape of win as W1.

  fetch.sh cannibal → queries where 2+ of OUR pages compete, ranked by total
  impressions, strongest page first inside each. Same auth, same quota family,
  no new scope. `capped` reports a full row window rather than presenting a
  truncated list as exhaustive — same rule as COVERAGE and the sitemap caps.

Grouping happens in the engine, deterministically: asking an LLM to group
1000 rows by query is arithmetic it should never be handed.

Backward compatible: rows gained `keys` (the list the API actually returns);
`key` stays as keys[0], so the single-dim quick-wins consumer is untouched.
A test pins both.

30/70 DUPLICATION — deliberately NOT built, and this is the honest half.
Measuring it needs main-content extraction (strip nav/header/footer). Without
that, comparing two same-template pages returns ~95% similar for every site —
a confident false positive, which is exactly the failure class the rest of
this branch exists to remove. It stays an explicit LLM judgement over the >=3
same-family pages C1c now samples for it, labelled as judgement, never quoting
a similarity percentage nobody computed. A wrong number would be worse than
the current honest gap.

The two must not be merged in the report either: cannibalisation is a SERP
fact Google measured; 30/70 is a content question. The spec now says so.

Verified: fixture with 3 pages on one query, 2 on another, 1 on a third →
2 conflicts, correct ranking, single-page query excluded; live dispatch
degrades cleanly with no account; seo-data 110 -> 119 pass, 0 fail; full suite
green; shellcheck + py_compile clean.
This commit is contained in:
Bastien Chanot
2026-07-17 11:55:00 +02:00
parent 04ccc5ad9b
commit 3a15643c2c
6 changed files with 139 additions and 7 deletions
+22
View File
@@ -98,6 +98,28 @@ fetch.sh inspect --account client-a --property … --url https://ex.com/page
• errors/warnings count issue INSTANCES; issues[] is deduped — the same
issueMessage repeats across every affected item.
fetch.sh cannibal --account client-a --property … [--days 90] [--rows 1000]
→ {"status":"ok","source":"gsc","days":90,"rows_scanned":1000,"capped":true,
"conflict_count":12,
"conflicts":[{"query":"plombier paris","pages":3,"total_impressions":2400,
"urls":[{"url":…,"clicks":…,"impressions":…,"position":…}]}]}
→ {"status":"degraded","reason":"…"} # no account → NOT auditable
Keyword cannibalisation from Google's own data: queries where 2+ of OUR
pages compete. Groups query+page rows; conflicts ranked by total
impressions, and within each the strongest page first. `capped:true` means
the row window was full — more conflicts exist past the cut, say so.
Same auth, same quota family, no new scope: the API always accepted several
dimensions at once, this engine only ever asked for one.
• NOT the 30/70 duplication rule. This is a SERP fact Google measured.
30/70 is content similarity, which has no data source here — doing it
naively (compare two same-template pages without stripping nav/footer)
returns ~95% similar for every site, a confident false positive. It stays
an LLM judgement, labelled as one.
• `queries` now takes `--dim query,page` (comma-separated) and `--rows`.
Rows gained a `keys` list; `key` stays as keys[0], so the single-dim
consumer is untouched.
fetch.sh sitemap --url https://ex.com/sitemap.xml
→ {"status":"ok","source":"sitemap","index":false,"count":86,"dropped":0,
"urls":["https://ex.com/", …]}