同一メタデータクラスタの検出 — crawl 中の soft cap + 事後 query ビュー
背景
一部の CMS は「一覧ページを親 path として持ち、そこから未来方向・過去方向・数値パラメータ方向にページャリンクを生成し続ける」パターンを持つ。ページャの値バリデーションが緩いと、以下のような anchor が発生して crawler が延々と食い続ける:
/list/date/{year}/ の year が単調増加し、境界を超えて Number.MAX_SAFE_INTEGER を突破 → 科学表記 (1.5e+32 など) がそのまま URL に混入
- 全ページが同じ HTML テンプレートを返し、
title / description / og:* が完全一致
og:url はページ自身ではなく親一覧を指したまま
- HTTP status は 200
--exclude で pattern を渡せば逃げられるが、これは オペレーターがクロール後の archive を見て初めて気付く後手対応であり、初回 crawl 時に大量のゴミページを取り込んでしまう問題は残る。汎用検出が要る。
単純な HTML hash 一致で reject する案は却下: 同一テンプレを使う legit ケース(同一商品の色違いバリアント、hreflang による多言語相互リンクの初期状態など)を潰す。
提案
以下の 2 機能を 1 セットで導入する。共通の「meta signature」概念を crawler / query 両側で共有する。
1. crawl 中の同一クラスタ soft cap(opt-in フラグ)
crawler の書き込みキュー投入時に、各ページの以下タプルを signature 化する:
sig = sha1(parentPath + '\0' + title + '\0' + description + '\0' + ogTitle + '\0' + ogUrl)
parentPath は URL path の最終セグメントを削ぎ落としたもの。crawler は in-memory Map で sig -> count を保持し、閾値を超えた以降は 同 sig を持つ新規 anchor を queue に投入しない (既に走ってしまったページは記録として残す)。
- CLI フラグ:
--dedupe-cap <N>(デフォルト無効。opt-in)
- 推奨閾値: 100(100 件同型を見た時点で「これは trap」と判定)
- 補強シグナル:
og:url がページ自身の URL と canonical 相当で一致しない場合、count のインクリメント幅を大きくする(trap の確度を上げるスコアリング)
- キャップに達したことは archive の audit log(
inventory_runs に類似の別テーブル、または crawl_errors に kind=dedupe-cap として)に記録し、事後ビューから可視化できるようにする
false-positive の抑止:
- signature に
parentPath を必ず含める → サイト全体で同一 title のページが分散していても cap がかからない
title が空 / og:* が全て null のページ(外部リンクなど)は signature 対象外
- cap 適用は internal only、external は対象外
2. 事後解析用の "duplicate cluster" query / viewer ビュー
既存 archive に対して以下のレポートを出す:
- API:
query duplicate-clusters サブコマンド + @nitpicker/query に getDuplicateClusters()
- 出力:
[{ parentPath, signature, count, samplePages: [...], metaSignatureFields: {title, description, ogTitle, ogUrl} }] を count 降順
- viewer: 左メニュー "Duplicate Clusters"(
/duplicate-clusters)。各行から親 path のページリストにドリルダウンできる
- 判定条件:
count >= N(デフォルト N=10、UI で変更可)+ sameTitleRatio == 1.0
- 補足ソート: 「
og:url != page url かつ同 signature」を上位に
共通の core: signature 定義と cluster 検出ロジックは @nitpicker/query に 1 関数として置き、crawler の soft cap ロジックからも import する。二重実装を避ける。
3. signature 定義の明文化とクエリパラメータ trap への対応
クエリパラメータ側の trap(?page={n}, ?page={science-notation}, ?session={random} 等が延々と anchor に出続けるパターン)も同じ signature 仕組みで自然に捕まる。ただし parentPath の定義を厳密にしておかないと衝突する。
parentPath の定義:
- URL の path 部分全体(query 除去、末尾 slash 正規化、fragment 除去)
- 例:
https://host/list?page=1 → /list、https://host/list/2024?p=5 → /list/2024
クエリパラメータ trap の場合、path 全部が同一 + meta 全部一致 → 同一 sig に畳まれて cap 発火。session/tracking ID で URL が毎回 unique になる pattern も同じ機構で捕まる(URL は違うが sig は同一)。逆に query を signature に含めてはいけない(含めると trap を見逃す)。
legit ページャの false-positive 抑止スコアリング:
素朴に title / description / ogTitle / ogUrl の完全一致だけで cap を撃つと、まっとうなページャでも各ページの meta が同じ CMS では false-positive が起きうる。以下を trap スコアの加減点として使う:
| シグナル |
判定 |
og:url がページ自身の URL と一致 |
canonical: 減点 |
<link rel="canonical"> がページ自身 |
canonical: 減点 |
og:url が親一覧の URL を指す |
trap: 加点 |
title 末尾に序数(ページ N / Page N / - N) |
legit ページャ: 減点 |
<link rel="prev"> / <link rel="next"> が存在 |
legit ページャ: 減点 |
素朴 count とスコアを組み合わせて cap を撃つ(例: base count が閾値の 30% で trap 加点シグナルが 1 個でもあれば発火、legit シグナルがあれば閾値を 5 倍に引き上げる)。
4. メモリ設計(30 万ページ規模での解放機構)
素朴な Map<sig, count> は以下 3 つの理由で破綻する:
- 通常サイトで unique sig が線形増加(30 万ページ × ~200 bytes/entry ≈ 60 MB を puppeteer/beholder のヒープに上乗せ)
- 敵対的 CMS が毎ページで一意な title を吐くと unique sig が爆発(cap にも引っかからない)
- crawl は long-running なので GC 圧が慢性的に効く
以下 4 層の解放機構を 必須要件 とする:
(a) cap 到達 sig の "免疫化"
- cap 到達した瞬間、その sig の count / age メタは不要
sticky: Set<sig> に immigrate し、元の Map<sig, count> から drop
- 以降
if (sticky.has(sig)) rejectAnchor() の O(1) 判定のみ
- trap クラスタ数 × 40 bytes に畳まれる
(b) age-based eviction(trap は連続発火する性質を利用)
- 各 sig に
firstSeenAnchorIndex を持たせる
- 現 anchor index が
firstSeenAnchorIndex + WINDOW(例: 5000)を超えても cap に達していなければ「これは trap ではない」と確定させて drop
- 素朴 LRU と違い、trap は必ず window 内で加速する性質を狙い撃ちできる → legit ケースの false-positive を構造的に防げる
- 「離れた場所で同 sig が再登場する legit ケース」はゼロカウントで再スタート → cap 早発なし
(c) parent path バケットの完了検知 drop
Map<parentPath, Map<sig, {count, firstSeenAnchorIndex}>> の二層構造
- crawler の frontier で「この parent path 配下の pending が 0 になった」を検知したら bucket ごと drop
- BFS ベースなら同 parent path の anchor は近接して frontier に載るため、比較的自然に発火する
(d) 最終防衛の hard cap
- Map 全体のエントリ上限を CLI フラグで(例
--dedupe-map-cap 100000)
- 溢れたら LRU eviction
- (a)(b)(c) が効いていれば (d) は稀にしか発火しないが、pathological ケースの保険として置く
CLI フラグ:
--dedupe-cap <N> — cluster 検出閾値(機能自体の opt-in を兼ねる)
--dedupe-age-window <N> — (b) の window(デフォルト 5000)
--dedupe-map-cap <N> — (d) の hard cap(デフォルト 100000)
スコープ外(別 issue 候補)
- URL のパスセグメントに対する sanity check(科学表記等)→ 既存の
--exclude で対処可能なため見送り
- HTML 本文の hash 比較による重複検出 → cost が高く、meta signature でカバーできない稀な false-negative を追う価値が現時点で見えない
受け入れ基準
crawl --dedupe-cap 100 で meta 完全一致クラスタが 100 件を超えると以降の同型 anchor が queue に入らないこと(e2e で確認)
og:url != page url のケースで cap がより早く効くこと(unit + e2e)
query duplicate-clusters が既存 archive に対して count 降順で cluster を返すこと
- viewer の Duplicate Clusters ビューから各 cluster の代表ページと汚染ページ全リストにナビゲートできること
- soft cap を発火させた事実が archive の audit log に残り、viewer から一覧できること
- 既存 archive フォーマットとの互換性: audit テーブルは migration で後付け(read-only 接続では作らない、書き込み側で作る)
- クエリパラメータ trap(
?page={n} / ?session={random} 等)を dedupe-cap 有効時に捕捉できること(e2e)
- 30 万 unique sig を投入する pathological シナリオで RSS が線形増加せず、
--dedupe-map-cap 以内に収まること(bench)
- cap 到達 sig が sticky Set に immigrate され、元 Map から drop されていること(unit)
- age-based eviction が
--dedupe-age-window を超えた未 cap sig を drop していること(unit)
- legit ページャシグナル(
og:url === page URL / rel="prev|next" / title 末尾序数)を持つページで cap が発火しにくいこと(unit + e2e)
同一メタデータクラスタの検出 — crawl 中の soft cap + 事後 query ビュー
背景
一部の CMS は「一覧ページを親 path として持ち、そこから未来方向・過去方向・数値パラメータ方向にページャリンクを生成し続ける」パターンを持つ。ページャの値バリデーションが緩いと、以下のような anchor が発生して crawler が延々と食い続ける:
/list/date/{year}/の year が単調増加し、境界を超えてNumber.MAX_SAFE_INTEGERを突破 → 科学表記 (1.5e+32など) がそのまま URL に混入title/description/og:*が完全一致og:urlはページ自身ではなく親一覧を指したまま--excludeで pattern を渡せば逃げられるが、これは オペレーターがクロール後の archive を見て初めて気付く後手対応であり、初回 crawl 時に大量のゴミページを取り込んでしまう問題は残る。汎用検出が要る。単純な HTML hash 一致で reject する案は却下: 同一テンプレを使う legit ケース(同一商品の色違いバリアント、hreflang による多言語相互リンクの初期状態など)を潰す。
提案
以下の 2 機能を 1 セットで導入する。共通の「meta signature」概念を crawler / query 両側で共有する。
1. crawl 中の同一クラスタ soft cap(opt-in フラグ)
crawler の書き込みキュー投入時に、各ページの以下タプルを signature 化する:
parentPathは URL path の最終セグメントを削ぎ落としたもの。crawler は in-memory Map でsig -> countを保持し、閾値を超えた以降は 同 sig を持つ新規 anchor を queue に投入しない (既に走ってしまったページは記録として残す)。--dedupe-cap <N>(デフォルト無効。opt-in)og:urlがページ自身の URL と canonical 相当で一致しない場合、count のインクリメント幅を大きくする(trap の確度を上げるスコアリング)inventory_runsに類似の別テーブル、またはcrawl_errorsに kind=dedupe-capとして)に記録し、事後ビューから可視化できるようにするfalse-positive の抑止:
parentPathを必ず含める → サイト全体で同一 title のページが分散していても cap がかからないtitleが空 /og:*が全て null のページ(外部リンクなど)は signature 対象外2. 事後解析用の "duplicate cluster" query / viewer ビュー
既存 archive に対して以下のレポートを出す:
query duplicate-clustersサブコマンド +@nitpicker/queryにgetDuplicateClusters()[{ parentPath, signature, count, samplePages: [...], metaSignatureFields: {title, description, ogTitle, ogUrl} }]を count 降順/duplicate-clusters)。各行から親 path のページリストにドリルダウンできるcount >= N(デフォルト N=10、UI で変更可)+sameTitleRatio == 1.0og:url != page urlかつ同 signature」を上位に共通の core: signature 定義と cluster 検出ロジックは
@nitpicker/queryに 1 関数として置き、crawler の soft cap ロジックからも import する。二重実装を避ける。3. signature 定義の明文化とクエリパラメータ trap への対応
クエリパラメータ側の trap(
?page={n},?page={science-notation},?session={random}等が延々と anchor に出続けるパターン)も同じ signature 仕組みで自然に捕まる。ただし parentPath の定義を厳密にしておかないと衝突する。parentPath の定義:
https://host/list?page=1→/list、https://host/list/2024?p=5→/list/2024クエリパラメータ trap の場合、path 全部が同一 + meta 全部一致 → 同一 sig に畳まれて cap 発火。session/tracking ID で URL が毎回 unique になる pattern も同じ機構で捕まる(URL は違うが sig は同一)。逆に query を signature に含めてはいけない(含めると trap を見逃す)。
legit ページャの false-positive 抑止スコアリング:
素朴に
title / description / ogTitle / ogUrlの完全一致だけで cap を撃つと、まっとうなページャでも各ページの meta が同じ CMS では false-positive が起きうる。以下を trap スコアの加減点として使う:og:urlがページ自身の URL と一致<link rel="canonical">がページ自身og:urlが親一覧の URL を指すページ N/Page N/- N)<link rel="prev">/<link rel="next">が存在素朴 count とスコアを組み合わせて cap を撃つ(例: base count が閾値の 30% で trap 加点シグナルが 1 個でもあれば発火、legit シグナルがあれば閾値を 5 倍に引き上げる)。
4. メモリ設計(30 万ページ規模での解放機構)
素朴な
Map<sig, count>は以下 3 つの理由で破綻する:以下 4 層の解放機構を 必須要件 とする:
(a) cap 到達 sig の "免疫化"
sticky: Set<sig>に immigrate し、元のMap<sig, count>から dropif (sticky.has(sig)) rejectAnchor()の O(1) 判定のみ(b) age-based eviction(trap は連続発火する性質を利用)
firstSeenAnchorIndexを持たせるfirstSeenAnchorIndex + WINDOW(例: 5000)を超えても cap に達していなければ「これは trap ではない」と確定させて drop(c) parent path バケットの完了検知 drop
Map<parentPath, Map<sig, {count, firstSeenAnchorIndex}>>の二層構造(d) 最終防衛の hard cap
--dedupe-map-cap 100000)CLI フラグ:
--dedupe-cap <N>— cluster 検出閾値(機能自体の opt-in を兼ねる)--dedupe-age-window <N>— (b) の window(デフォルト 5000)--dedupe-map-cap <N>— (d) の hard cap(デフォルト 100000)スコープ外(別 issue 候補)
--excludeで対処可能なため見送り受け入れ基準
crawl --dedupe-cap 100で meta 完全一致クラスタが 100 件を超えると以降の同型 anchor が queue に入らないこと(e2e で確認)og:url != page urlのケースで cap がより早く効くこと(unit + e2e)query duplicate-clustersが既存 archive に対して count 降順で cluster を返すこと?page={n}/?session={random}等)を dedupe-cap 有効時に捕捉できること(e2e)--dedupe-map-cap以内に収まること(bench)--dedupe-age-windowを超えた未 cap sig を drop していること(unit)og:url === page URL/rel="prev|next"/ title 末尾序数)を持つページで cap が発火しにくいこと(unit + e2e)