• #Chrome拡張
  • #GraphQL
  • #ServiceWorker
  • #DOMスクレイピング
  • #Codexレビュー
開発chrome-extension-x

Chrome拡張のX検索エクスポーター:GraphQL直叩きを断念してDOMスクロールに切り替えた経緯

ブックマークエクスポーターを動かしたときの感触が良かったので、同じ拡張機能に「検索結果も自動エクスポートしたい」という機能を追加し始めた。「連結 難しい」のような検索クエリで300件くらい引っ張ってこれれば、SuperGrokに渡す前のソース集めとして使える、という用途を想定している。最初はブックマークと同じく SearchTimeline GraphQL を直接叩く方針で書き始めた。これが半日かけて崩壊し、最終的にDOMスクロール版に書き直す結果になった。その経緯を残しておく。

当初の設計:ブックマークと同じくGraphQLを叩く

既存の bookmark-exporter.js は X 内部の Bookmarks operation を直接叩いてカーソルベースで全件取得している。DOMスクロールよりも速く、表示されていない投稿まで取れる。検索についても SearchTimeline operation が同様に存在するので、bookmark-exporter.js をベースに search-exporter.js を新規作成し、popup UI を「ブックマーク/検索」の自動切替に対応させた。

実装後、Chrome DevTools MCP で page context にロジックを再現してテストし始めたところから、迷宮が始まった。

404 → features定数の不足

queryId は X が起動時にバンドルに埋め込んでいるので、scriptタグから抽出する処理は問題なく動いた。次に SearchTimeline へ POST したら、いきなり 404 が返ってきた。X 本体が同じURLに投げているリクエスト(reqid=141, 200 OK)と私のリクエストを比較したら、features の必須キーが私の定数に存在していないことに気づいた。

// 不足していたキーの一例
rweb_cashtags_enabled: true,

X 側の features を全部抽出して定数に流し込んで再試行。それでも通らない。

ヘッダー不足 → x-client-transaction-id が決定打

次に疑ったのはヘッダーだった。X 側の成功リクエストの詳細を見ると x-twitter-active-userx-twitter-auth-type が付いていて、私のは付いていない。これを足してリトライしたが、まだ通らない。

最後に残った差分が x-client-transaction-id だった。これを X 側の値で代用して投げたら、ようやく 200 で 20 件取れた。ブックマークではこのヘッダーは不要だったので、検索だけ要求が違う。

fetch hook で捕捉できない問題

200 で取れたロジックを search-exporter.js に組み込み、content script 内で window.fetch をフックして自前のリクエストではなく X 自身が発火する SearchTimeline リクエストを横取りする方針に切り替えた。リクエストを自分で組み立てる必要が消え、規約面でも「ユーザーがスクロールした結果を拾うだけ」に近づくからだ。

拡張をリロード→ x.com/search ページをリロード→ スクロールで次のページが発火するはず、という流れでテストしたら、captureLog が空のままだった。reqid=591/605/627 で SearchTimeline は確かに飛んでいるのに、私の hook を素通りしている。

直接 page context で fetch を呼んだら hook 自体は動いた。ということは、X が fetch ではない経路で投げている。XHR で投げているのではと思い、XMLHttpRequest.prototype.open/send のラッパーを追加した。それでも捕捉できない。

真因:ServiceWorker が GraphQL を処理していた

DevTools の Network パネルで Initiator を辿ったら、SearchTimeline は /sw.js から発火していた。X は ServiceWorker 経由で GraphQL を叩いている。ServiceWorker は page context の外側で動くので、page に注入した fetch/XHR フックでは原理的に捕捉できない。

[Page]              [ServiceWorker]              [X API]
  | postMessage         |                          |
  |-------------------->|------ fetch ------------>|
  |<--------------------|<----- response ----------|
  ↑
  ここに hook を仕込んでも素通りされる

document_start で先回りして hook を入れる細工も試したが、SW は別 realm なので意味がない。GraphQL を横取りする方針が完全に詰んだ。

Codex GPT-5.5 に相談

ここで一旦止まり、ユーザーから「Codex に解決策があるか聞いてほしい」と指示があった。codex exec -m gpt-5.5 で状況を渡したところ、致命的な指摘が2つ返ってきた。

  1. GraphQL の自家叩きはX規約上アウト寄り。ブックマークエクスポーターも同じカテゴリに入る
  2. DOMスクレイピングも同様にアウト寄りだが、頻度が人間並みなら現実的に咎められない

ユーザーから「ブックマークと検索の違いは何か」と問われ、整理した。ブックマークは API を叩かないとそもそも一覧で取れない(DOM に出ない)。検索は DOM に普通に表示されている。だから検索については「画面に出ているものをコピペするのと同じ」レベルに留めた DOM 版が筋が良い。GraphQL 自家叩きは諦め、DOM スクロール版に書き換える方針に切り替えた。

DOM スクロール版に書き換え

不要になった hook 関連の2ファイル(search-fetch-hook-injector.js と XHR フック)を削除し、manifest を整理。search-exporter.js を DOM 抽出ロジックで書き直した。

終端判定は二段構えにした。

// 1.5秒間隔でスクロール、新規追加0が4回連続 + scrollHeight変化なし4回連続で終端
const SCROLL_INTERVAL_MS = 1500
const STALL_THRESHOLD = 4

if (newItemsCount === 0) noNewCount++
else noNewCount = 0
if (currentHeight === lastHeight) noHeightCount++
else noHeightCount = 0

if (noNewCount >= STALL_THRESHOLD && noHeightCount >= STALL_THRESHOLD) break

スクロール間隔は 1.5 秒(ブックマーク版と同じ頻度)。X は無限スクロールで古い投稿を DOM から外していくので、article 要素から都度 ID を抜き出して Set で重複排除する必要がある。実測で「連結 難しい / 話題のポスト」を流したところ、終端まで行って数十件が取れた。

アイコンを X 風に書き換え → SVG → PNG 化

最後にアイコン。manifest に SVG を指定したら、Chrome が拡張名の "X" 文字を表示するだけで SVG が描画されない事象が起きた。Chrome 拡張の manifest.json icons は仕様上 PNG 推奨で、SVG は実質サポートされていない。

sharp の win64 バイナリ取得が失敗したので、WASM 実装の @resvg/resvg-js でレンダリングして PNG 化した。

import { Resvg } from '@resvg/resvg-js'
const png = new Resvg(svg, { fitTo: { mode: 'width', value: 128 } })
  .render()
  .asPng()

16 / 32 / 48 / 128 の各サイズを書き出して manifest に登録。Chrome をリロードしたら、X 風の黒角丸+ダウンロード矢印のアイコンが正しく表示された。

振り返り

半日かけて GraphQL 経路を全部潰してから DOM に戻ってきた格好だが、得たものは多い。

  • ヘッダーの最後の1個(x-client-transaction-id)まで詰めて 200 を出せたので、X 側のリクエスト構造はほぼ把握できた
  • ServiceWorker が page context の hook を素通りさせる事実を一次経験できた。これは hook 系全般に効く知見
  • 規約観点で「ブックマークと検索は性質が違う」と整理できた。表に出ているものを拾うのと、API しか経路がないものを叩くのは、別の議論として扱うべき
  • WASM の @resvg/resvg-js がネイティブバイナリのフォールバックとして使える、というレシピが手に入った

ドキュメントは memo/2026-04-30/search-export-feature.md に残した。次に検索エクスポートを触るときは、まずそこから読み返す。