Skip to content

同一メタデータクラスタの検出 — crawl 中の soft cap + 事後 query ビュー #208

Description

@YusukeHirao

同一メタデータクラスタの検出 — 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/querygetDuplicateClusters()
  • 出力: [{ 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/listhttps://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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions