検証環境
- Chrome 126(Manifest V3 / Content Scripts)
- TypeScript 5.4 + Vite 5(拡張ビルド)
- Playwright 1.44(再現・突き合わせ用)
- Node.js 20.12
- 対象: とある求人・企業検索サイトの一覧ページ(DOM は
data-testid付き)
きっかけ: 「別業種が混ざる」という一通の連絡
Chrome拡張で、検索結果ページのDOMから企業一覧を抽出してCSVに吐く機能を作っていました。あるユーザーから「IT業種で検索したのに、建設・物流の企業が混ざる」と報告が来ました。
ここで最初に判断すべきは、この混入が自作の抽出ロジックのバグなのか、検索エンジン側がそう返しているのかです。切り分けずに「仕様です」と返して、実は自作バグだった場合、信用を失います。逆に「バグです」と謝って、実はプラットフォーム側の仕様だった場合も、無駄な修正コミットを積むことになります。
切り分けは「手作業の1回再現」から入る
結論から書くと、やったことはシンプルです。
- 拡張を無効化した状態のChromeで、同じキーワード・同じフィルタ条件・同じページ番号を開く
- 標準の検索画面に表示されている業種名を、目視で10〜20件テキストに書き出す
- 拡張が出力したCSVの業種列と突き合わせる
標準検索が別業種を返しているなら、由来はプラットフォーム側と確定できます。この時点で「仕様です」と返してよい根拠が手元に揃います。逆に標準検索が返していない業種がCSVにだけ存在するなら、抽出コードのスコープが広すぎる疑いが濃厚です。
併せて確認する3点
- 抽出コードが結果コンテナ(結果を並べる枠)に限定されているか
- おすすめ枠・広告枠・サイドバーまで走査していないか
- 遅延読み込み(無限スクロール)で、DOM上の件数と表示上の件数がずれていないか
実際に今回の案件では、標準検索でも建設業の企業が混ざっていました。つまりプラットフォーム側の関連業種サジェストでした。ただし突き合わせの過程で、こちらのコードにも別の問題(おすすめ枠の混入)が見つかりました。片方だけ直して終わりにしないのがポイントです。
実装: コンテナに限定した抽出
スコープ漏れを防ぐには、document.querySelectorAll を直接使わず、必ず結果コンテナを起点にします。
const RESULT_SELECTOR = '[data-testid="search-results"]';
const CARD_SELECTOR = '[data-testid="result-card"]';
type Item = { title: string; industry: string; isSponsored: boolean };
function extractItems(root: ParentNode): Item[] {
const container = root.querySelector<HTMLElement>(RESULT_SELECTOR);
if (!container) return [];
// :scope で結果コンテナ配下に限定。おすすめ枠・サイドバーを巻き込まない
return Array.from(
container.querySelectorAll<HTMLElement>(`:scope ${CARD_SELECTOR}`)
).map((el) => ({
title: el.querySelector('[data-testid="title"]')?.textContent?.trim() ?? '',
industry: el.querySelector('[data-testid="industry"]')?.textContent?.trim() ?? '',
// 広告・おすすめは closest で祖先まで見て判定する
isSponsored: el.closest('[data-testid="sponsored"]') !== null,
}));
}
isSponsored を持たせておくと、後段で「混入は広告枠由来か、検索結果本体由来か」を切り分けられます。抽出時点でメタ情報を付けるのが大事です。
突き合わせを自動化する
手作業の再現は1回で十分ですが、リグレッション検出のために自動化しておきます。正規化した上で集合差分とJaccard係数を見ます。
export function diffIndustries(manual: string[], ext: string[]) {
const norm = (xs: string[]) =>
new Set(xs.map((s) => s.normalize('NFKC').replace(/\s+/g, '').trim()));
const m = norm(manual);
const e = norm(ext);
const onlyInExt = [...e].filter((x) => !m.has(x));
const onlyInManual = [...m].filter((x) => !e.has(x));
const union = new Set([...m, ...e]);
const jaccard = union.size === 0 ? 1 : [...m].filter((x) => e.has(x)).length / union.size;
return { onlyInExt, onlyInManual, jaccard };
}
onlyInExt が空なら「拡張は余計なものを拾っていない」、onlyInManual が空なら「取りこぼしなし」と読めます。全角半角や異体字を normalize('NFKC') で潰しておかないと、偽陽性だらけになります。
再現スクリプトはPlaywrightで回します。
npm i -D playwright@1.44.0
npx playwright install chromium
npx tsx scripts/verify-search.ts --keyword "Webエンジニア" --out artifacts/2024-06-01
--out に DOMスナップショットとスクリーンショットを保存しておくと、後から「その時プラットフォームが何を返していたか」を証跡として出せます。問い合わせ対応では、この証跡の有無がそのまま信頼になります。
ハマりポイント
-
仮想スクロール: 表示中の数十件しかDOMに存在しない。
scrollTo+MutationObserverで全件ロードを待つ必要がある -
iframe / Shadow DOM:
querySelectorは境界を越えない。shadowRootを明示的に降りる実装が別途必要 -
A/Bテスト: 同一URLでもユーザーセグメントでDOM構造が変わる。
data-testidが無い場合は構造セレクタが即死する -
おすすめ枠の位置: 結果リストの「中」に挿入されることがあり、コンテナ限定だけでは防げない。
isSponsoredのような明示フラグで弾く
症状別の切り分け表
| 症状 | 疑う場所 | 切り分け方法 |
|---|---|---|
| 別カテゴリが混ざる | プラットフォームの関連サジェスト / おすすめ枠 | 標準検索で手作業再現 |
| 件数が表示より少ない | 仮想スクロール / 遅延読み込み | DOM件数とUI件数を比較 |
| 特定ユーザーだけ再現 | A/Bテスト / ログイン状態 | シークレットウィンドウで再現 |
| 稀にセレクタが空 | 描画タイミング |
waitForSelector の導入 |
| 表記ゆれで差分が出る | 正規化漏れ | NFKC + 空白除去 |
FAQ
Q. 手作業再現は毎回やるべき?
A. 原因を断定して返信する前には必ず1回。自動化済みならそのログで代替できます。
Q. プラットフォーム側の仕様だった場合、修正は不要?
A. 仕様でも、ユーザーが不要と感じるならフィルタ機能として吸収する価値があります。今回は業種の完全一致フィルタを追加しました。
Q. スクレイピングの利用規約は?
A. 対象サイトの規約とrobots.txtを必ず確認してください。抽出範囲を結果コンテナに限定するのは、負荷と誤抽出の両方を抑える意味でも有効です。
まとめ
「精度が低い」という報告に対して、いきなりコードを読み始めるのは順序が逆です。同じ条件を手作業で1回再現する。それだけで、プラットフォーム由来か自作バグかが確定し、返すべき言葉が変わります。切り分けは手作業の再現から入る派です。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中