[{"data":1,"prerenderedAt":597},["ShallowReactive",2],{"content-/2026-04-30-x-search-exporter-graphql-to-dom":3,"all-pages-for-dir":595,"og-image-/2026-04-30-x-search-exporter-graphql-to-dom":596},{"id":4,"title":5,"body":6,"category":577,"description":578,"extension":579,"meta":580,"navigation":275,"path":581,"project_name":582,"published":583,"publishedAt":584,"seo":585,"stem":586,"tags":587,"todo":593,"updatedAt":593,"__hash__":594},"pages/2026-04/2026-04-30/x-search-exporter-graphql-to-dom.md","Chrome拡張のX検索エクスポーター：GraphQL直叩きを断念してDOMスクロールに切り替えた経緯",{"type":7,"value":8,"toc":566},"minimark",[9,13,22,27,48,51,55,62,98,101,105,116,123,127,137,144,158,162,169,177,183,187,194,210,213,217,227,230,384,391,395,402,412,525,528,531,534,555,562],[10,11,5],"h1",{"id":12},"chrome拡張のx検索エクスポーターgraphql直叩きを断念してdomスクロールに切り替えた経緯",[14,15,16,17,21],"p",{},"ブックマークエクスポーターを動かしたときの感触が良かったので、同じ拡張機能に「検索結果も自動エクスポートしたい」という機能を追加し始めた。「連結 難しい」のような検索クエリで300件くらい引っ張ってこれれば、SuperGrokに渡す前のソース集めとして使える、という用途を想定している。最初はブックマークと同じく ",[18,19,20],"code",{},"SearchTimeline"," GraphQL を直接叩く方針で書き始めた。これが半日かけて崩壊し、最終的にDOMスクロール版に書き直す結果になった。その経緯を残しておく。",[23,24,26],"h2",{"id":25},"当初の設計ブックマークと同じくgraphqlを叩く","当初の設計：ブックマークと同じくGraphQLを叩く",[14,28,29,30,33,34,37,38,40,41,43,44,47],{},"既存の ",[18,31,32],{},"bookmark-exporter.js"," は X 内部の ",[18,35,36],{},"Bookmarks"," operation を直接叩いてカーソルベースで全件取得している。DOMスクロールよりも速く、表示されていない投稿まで取れる。検索についても ",[18,39,20],{}," operation が同様に存在するので、",[18,42,32],{}," をベースに ",[18,45,46],{},"search-exporter.js"," を新規作成し、popup UI を「ブックマーク／検索」の自動切替に対応させた。",[14,49,50],{},"実装後、Chrome DevTools MCP で page context にロジックを再現してテストし始めたところから、迷宮が始まった。",[23,52,54],{"id":53},"_404-features定数の不足","404 → features定数の不足",[14,56,57,58,61],{},"queryId は X が起動時にバンドルに埋め込んでいるので、scriptタグから抽出する処理は問題なく動いた。次に SearchTimeline へ POST したら、いきなり 404 が返ってきた。X 本体が同じURLに投げているリクエスト（reqid=141, 200 OK）と私のリクエストを比較したら、",[18,59,60],{},"features"," の必須キーが私の定数に存在していないことに気づいた。",[63,64,69],"pre",{"className":65,"code":66,"language":67,"meta":68,"style":68},"language-js shiki shiki-themes vitesse-light vitesse-light","// 不足していたキーの一例\nrweb_cashtags_enabled: true,\n","js","",[18,70,71,80],{"__ignoreMap":68},[72,73,76],"span",{"class":74,"line":75},"line",1,[72,77,79],{"class":78},"sxvE3","// 不足していたキーの一例\n",[72,81,83,87,91,95],{"class":74,"line":82},2,[72,84,86],{"class":85},"senZ8","rweb_cashtags_enabled",[72,88,90],{"class":89},"shFtX",":",[72,92,94],{"class":93},"sHkkW"," true",[72,96,97],{"class":89},",\n",[14,99,100],{},"X 側の features を全部抽出して定数に流し込んで再試行。それでも通らない。",[23,102,104],{"id":103},"ヘッダー不足-x-client-transaction-id-が決定打","ヘッダー不足 → x-client-transaction-id が決定打",[14,106,107,108,111,112,115],{},"次に疑ったのはヘッダーだった。X 側の成功リクエストの詳細を見ると ",[18,109,110],{},"x-twitter-active-user","、",[18,113,114],{},"x-twitter-auth-type"," が付いていて、私のは付いていない。これを足してリトライしたが、まだ通らない。",[14,117,118,119,122],{},"最後に残った差分が ",[18,120,121],{},"x-client-transaction-id"," だった。これを X 側の値で代用して投げたら、ようやく 200 で 20 件取れた。ブックマークではこのヘッダーは不要だったので、検索だけ要求が違う。",[23,124,126],{"id":125},"fetch-hook-で捕捉できない問題","fetch hook で捕捉できない問題",[14,128,129,130,132,133,136],{},"200 で取れたロジックを ",[18,131,46],{}," に組み込み、content script 内で ",[18,134,135],{},"window.fetch"," をフックして自前のリクエストではなく X 自身が発火する SearchTimeline リクエストを横取りする方針に切り替えた。リクエストを自分で組み立てる必要が消え、規約面でも「ユーザーがスクロールした結果を拾うだけ」に近づくからだ。",[14,138,139,140,143],{},"拡張をリロード→ x.com/search ページをリロード→ スクロールで次のページが発火するはず、という流れでテストしたら、",[18,141,142],{},"captureLog"," が空のままだった。reqid=591/605/627 で SearchTimeline は確かに飛んでいるのに、私の hook を素通りしている。",[14,145,146,147,150,151,153,154,157],{},"直接 page context で ",[18,148,149],{},"fetch"," を呼んだら hook 自体は動いた。ということは、X が ",[18,152,149],{}," ではない経路で投げている。XHR で投げているのではと思い、",[18,155,156],{},"XMLHttpRequest.prototype.open/send"," のラッパーを追加した。それでも捕捉できない。",[23,159,161],{"id":160},"真因serviceworker-が-graphql-を処理していた","真因：ServiceWorker が GraphQL を処理していた",[14,163,164,165,168],{},"DevTools の Network パネルで Initiator を辿ったら、SearchTimeline は ",[18,166,167],{},"/sw.js"," から発火していた。X は ServiceWorker 経由で GraphQL を叩いている。ServiceWorker は page context の外側で動くので、page に注入した fetch/XHR フックでは原理的に捕捉できない。",[63,170,175],{"className":171,"code":173,"language":174},[172],"language-text","[Page]              [ServiceWorker]              [X API]\n  | postMessage         |                          |\n  |-------------------->|------ fetch ------------>|\n  |\u003C--------------------|\u003C----- response ----------|\n  ↑\n  ここに hook を仕込んでも素通りされる\n","text",[18,176,173],{"__ignoreMap":68},[14,178,179,182],{},[18,180,181],{},"document_start"," で先回りして hook を入れる細工も試したが、SW は別 realm なので意味がない。GraphQL を横取りする方針が完全に詰んだ。",[23,184,186],{"id":185},"codex-gpt-55-に相談","Codex GPT-5.5 に相談",[14,188,189,190,193],{},"ここで一旦止まり、ユーザーから「Codex に解決策があるか聞いてほしい」と指示があった。",[18,191,192],{},"codex exec -m gpt-5.5"," で状況を渡したところ、致命的な指摘が2つ返ってきた。",[195,196,197,205],"ol",{},[198,199,200,204],"li",{},[201,202,203],"strong",{},"GraphQL の自家叩きはX規約上アウト寄り","。ブックマークエクスポーターも同じカテゴリに入る",[198,206,207],{},[201,208,209],{},"DOMスクレイピングも同様にアウト寄りだが、頻度が人間並みなら現実的に咎められない",[14,211,212],{},"ユーザーから「ブックマークと検索の違いは何か」と問われ、整理した。ブックマークは API を叩かないとそもそも一覧で取れない（DOM に出ない）。検索は DOM に普通に表示されている。だから検索については「画面に出ているものをコピペするのと同じ」レベルに留めた DOM 版が筋が良い。GraphQL 自家叩きは諦め、DOM スクロール版に書き換える方針に切り替えた。",[23,214,216],{"id":215},"dom-スクロール版に書き換え","DOM スクロール版に書き換え",[14,218,219,220,223,224,226],{},"不要になった hook 関連の2ファイル（",[18,221,222],{},"search-fetch-hook-injector.js"," と XHR フック）を削除し、manifest を整理。",[18,225,46],{}," を DOM 抽出ロジックで書き直した。",[14,228,229],{},"終端判定は二段構えにした。",[63,231,233],{"className":65,"code":232,"language":67,"meta":68,"style":68},"// 1.5秒間隔でスクロール、新規追加0が4回連続 + scrollHeight変化なし4回連続で終端\nconst SCROLL_INTERVAL_MS = 1500\nconst STALL_THRESHOLD = 4\n\nif (newItemsCount === 0) noNewCount++\nelse noNewCount = 0\nif (currentHeight === lastHeight) noHeightCount++\nelse noHeightCount = 0\n\nif (noNewCount >= STALL_THRESHOLD && noHeightCount >= STALL_THRESHOLD) break\n",[18,234,235,240,257,270,277,304,317,339,350,355],{"__ignoreMap":68},[72,236,237],{"class":74,"line":75},[72,238,239],{"class":78},"// 1.5秒間隔でスクロール、新規追加0が4回連続 + scrollHeight変化なし4回連続で終端\n",[72,241,242,246,250,253],{"class":74,"line":82},[72,243,245],{"class":244},"stQ0i","const",[72,247,249],{"class":248},"s4oTP"," SCROLL_INTERVAL_MS",[72,251,252],{"class":89}," =",[72,254,256],{"class":255},"sM54T"," 1500\n",[72,258,260,262,265,267],{"class":74,"line":259},3,[72,261,245],{"class":244},[72,263,264],{"class":248}," STALL_THRESHOLD",[72,266,252],{"class":89},[72,268,269],{"class":255}," 4\n",[72,271,273],{"class":74,"line":272},4,[72,274,276],{"emptyLinePlaceholder":275},true,"\n",[72,278,280,283,286,289,292,295,298,301],{"class":74,"line":279},5,[72,281,282],{"class":93},"if",[72,284,285],{"class":89}," (",[72,287,288],{"class":248},"newItemsCount",[72,290,291],{"class":244}," ===",[72,293,294],{"class":255}," 0",[72,296,297],{"class":89},")",[72,299,300],{"class":248}," noNewCount",[72,302,303],{"class":244},"++\n",[72,305,307,310,312,314],{"class":74,"line":306},6,[72,308,309],{"class":93},"else",[72,311,300],{"class":248},[72,313,252],{"class":89},[72,315,316],{"class":255}," 0\n",[72,318,320,322,324,327,329,332,334,337],{"class":74,"line":319},7,[72,321,282],{"class":93},[72,323,285],{"class":89},[72,325,326],{"class":248},"currentHeight",[72,328,291],{"class":244},[72,330,331],{"class":248}," lastHeight",[72,333,297],{"class":89},[72,335,336],{"class":248}," noHeightCount",[72,338,303],{"class":244},[72,340,342,344,346,348],{"class":74,"line":341},8,[72,343,309],{"class":93},[72,345,336],{"class":248},[72,347,252],{"class":89},[72,349,316],{"class":255},[72,351,353],{"class":74,"line":352},9,[72,354,276],{"emptyLinePlaceholder":275},[72,356,358,360,362,365,368,370,373,375,377,379,381],{"class":74,"line":357},10,[72,359,282],{"class":93},[72,361,285],{"class":89},[72,363,364],{"class":248},"noNewCount",[72,366,367],{"class":89}," >=",[72,369,264],{"class":248},[72,371,372],{"class":244}," &&",[72,374,336],{"class":248},[72,376,367],{"class":89},[72,378,264],{"class":248},[72,380,297],{"class":89},[72,382,383],{"class":93}," break\n",[14,385,386,387,390],{},"スクロール間隔は 1.5 秒（ブックマーク版と同じ頻度）。X は無限スクロールで古い投稿を DOM から外していくので、",[18,388,389],{},"article"," 要素から都度 ID を抜き出して Set で重複排除する必要がある。実測で「連結 難しい / 話題のポスト」を流したところ、終端まで行って数十件が取れた。",[23,392,394],{"id":393},"アイコンを-x-風に書き換え-svg-png-化","アイコンを X 風に書き換え → SVG → PNG 化",[14,396,397,398,401],{},"最後にアイコン。manifest に SVG を指定したら、Chrome が拡張名の \"X\" 文字を表示するだけで SVG が描画されない事象が起きた。Chrome 拡張の ",[18,399,400],{},"manifest.json icons"," は仕様上 PNG 推奨で、SVG は実質サポートされていない。",[14,403,404,407,408,411],{},[18,405,406],{},"sharp"," の win64 バイナリ取得が失敗したので、WASM 実装の ",[18,409,410],{},"@resvg/resvg-js"," でレンダリングして PNG 化した。",[63,413,415],{"className":65,"code":414,"language":67,"meta":68,"style":68},"import { Resvg } from '@resvg/resvg-js'\nconst png = new Resvg(svg, { fitTo: { mode: 'width', value: 128 } })\n  .render()\n  .asPng()\n",[18,416,417,444,505,516],{"__ignoreMap":68},[72,418,419,422,425,428,431,434,438,441],{"class":74,"line":75},[72,420,421],{"class":93},"import",[72,423,424],{"class":89}," {",[72,426,427],{"class":248}," Resvg",[72,429,430],{"class":89}," }",[72,432,433],{"class":93}," from",[72,435,437],{"class":436},"sMJiu"," '",[72,439,410],{"class":440},"sdGka",[72,442,443],{"class":436},"'\n",[72,445,446,448,451,453,456,458,461,464,467,469,473,475,477,480,482,484,487,490,492,495,497,500,502],{"class":74,"line":82},[72,447,245],{"class":244},[72,449,450],{"class":248}," png",[72,452,252],{"class":89},[72,454,455],{"class":244}," new",[72,457,427],{"class":85},[72,459,460],{"class":89},"(",[72,462,463],{"class":248},"svg",[72,465,466],{"class":89},",",[72,468,424],{"class":89},[72,470,472],{"class":471},"sz8Xr"," fitTo",[72,474,90],{"class":89},[72,476,424],{"class":89},[72,478,479],{"class":471}," mode",[72,481,90],{"class":89},[72,483,437],{"class":436},[72,485,486],{"class":440},"width",[72,488,489],{"class":436},"'",[72,491,466],{"class":89},[72,493,494],{"class":471}," value",[72,496,90],{"class":89},[72,498,499],{"class":255}," 128",[72,501,430],{"class":89},[72,503,504],{"class":89}," })\n",[72,506,507,510,513],{"class":74,"line":259},[72,508,509],{"class":89},"  .",[72,511,512],{"class":85},"render",[72,514,515],{"class":89},"()\n",[72,517,518,520,523],{"class":74,"line":272},[72,519,509],{"class":89},[72,521,522],{"class":85},"asPng",[72,524,515],{"class":89},[14,526,527],{},"16 / 32 / 48 / 128 の各サイズを書き出して manifest に登録。Chrome をリロードしたら、X 風の黒角丸+ダウンロード矢印のアイコンが正しく表示された。",[23,529,530],{"id":530},"振り返り",[14,532,533],{},"半日かけて GraphQL 経路を全部潰してから DOM に戻ってきた格好だが、得たものは多い。",[535,536,537,543,546,549],"ul",{},[198,538,539,540,542],{},"ヘッダーの最後の1個（",[18,541,121],{},"）まで詰めて 200 を出せたので、X 側のリクエスト構造はほぼ把握できた",[198,544,545],{},"ServiceWorker が page context の hook を素通りさせる事実を一次経験できた。これは hook 系全般に効く知見",[198,547,548],{},"規約観点で「ブックマークと検索は性質が違う」と整理できた。表に出ているものを拾うのと、API しか経路がないものを叩くのは、別の議論として扱うべき",[198,550,551,552,554],{},"WASM の ",[18,553,410],{}," がネイティブバイナリのフォールバックとして使える、というレシピが手に入った",[14,556,557,558,561],{},"ドキュメントは ",[18,559,560],{},"memo/2026-04-30/search-export-feature.md"," に残した。次に検索エクスポートを触るときは、まずそこから読み返す。",[563,564,565],"style",{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .sHkkW, html code.shiki .sHkkW{--shiki-default:#1E754F;--shiki-dark:#1E754F}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}html pre.shiki code .sM54T, html code.shiki .sM54T{--shiki-default:#2F798A;--shiki-dark:#2F798A}html pre.shiki code .sMJiu, html code.shiki .sMJiu{--shiki-default:#B5695977;--shiki-dark:#B5695977}html pre.shiki code .sdGka, html code.shiki .sdGka{--shiki-default:#B56959;--shiki-dark:#B56959}html pre.shiki code .sz8Xr, html code.shiki .sz8Xr{--shiki-default:#998418;--shiki-dark:#998418}",{"title":68,"searchDepth":82,"depth":82,"links":567},[568,569,570,571,572,573,574,575,576],{"id":25,"depth":82,"text":26},{"id":53,"depth":82,"text":54},{"id":103,"depth":82,"text":104},{"id":125,"depth":82,"text":126},{"id":160,"depth":82,"text":161},{"id":185,"depth":82,"text":186},{"id":215,"depth":82,"text":216},{"id":393,"depth":82,"text":394},{"id":530,"depth":82,"text":530},"dev","X検索結果の自動エクスポート機能を追加する過程で、SearchTimeline GraphQLが404→200で通った後にfetch hookで捕捉できず、ServiceWorkerが原因と判明。Codexレビューを経てDOMスクロール版に書き換えた一日の記録。","md",{},"/2026-04-30-x-search-exporter-graphql-to-dom","chrome-extension-x",false,"2026-04-30T00:00:00.000Z",{"title":5,"description":578},"2026-04/2026-04-30/x-search-exporter-graphql-to-dom",[588,589,590,591,592],"Chrome拡張","GraphQL","ServiceWorker","DOMスクレイピング","Codexレビュー",null,"S1KprvKqOp3UX5dgzBkrNvgNSvN8z1Y_iS98qnd1DnI",[],"https://log.eurekapu.com/favicon.svg",1777617050604]