[{"data":1,"prerenderedAt":635},["ShallowReactive",2],{"content-/chrome-extension-bookmark-exporter":3,"all-pages-for-dir":633,"og-image-/chrome-extension-bookmark-exporter":634},{"id":4,"title":5,"body":6,"category":613,"description":614,"extension":615,"meta":616,"navigation":288,"path":617,"project_name":618,"published":619,"publishedAt":620,"seo":621,"stem":622,"tags":623,"todo":630,"updatedAt":631,"__hash__":632},"pages/2026-04/2026-04-05/chrome-extension-bookmark-exporter.md","Chrome拡張機能でXブックマークをGoogle Sheetsに自動エクスポートする設計と実装",{"type":7,"value":8,"toc":591},"minimark",[9,13,17,20,25,30,38,139,146,149,157,159,163,167,174,178,181,184,204,208,222,233,368,370,374,377,380,404,410,414,417,431,434,436,439,446,504,507,509,512,515,530,533,541,543,546,549,574,576,579,584,587],[10,11,5],"h1",{"id":12},"chrome拡張機能でxブックマークをgoogle-sheetsに自動エクスポートする設計と実装",[14,15,16],"p",{},"Xのブックマークが300件を超えたあたりで、過去に保存したツイートを探すのに毎回スクロールを繰り返していた。ブラウザのスクロールバーが米粒ほどに縮んでいくのを眺めながら、「これ全部CSVに抜けたら一発で検索できるのに」と手が動いた。会計ソフトA用に作ったChrome拡張のOAuth2認証パターンがそのまま使えそうだったので、流用する前提で設計を始めた。",[18,19],"hr",{},[21,22,24],"h2",{"id":23},"設計フェーズ-既存の認証パターンを流用する","設計フェーズ: 既存の認証パターンを流用する",[26,27,29],"h3",{"id":28},"会計ソフトa拡張のoauth2パターン","会計ソフトA拡張のOAuth2パターン",[14,31,32,33,37],{},"会計ソフトA用に作ったChrome拡張では、",[34,35,36],"code",{},"chrome.identity.getAuthToken()","でGoogleアカウントのOAuth2トークンを取得している。この仕組みをそのまま横展開する。",[39,40,45],"pre",{"className":41,"code":42,"language":43,"meta":44,"style":44},"language-javascript shiki shiki-themes vitesse-light vitesse-light","// chrome.identity.getAuthToken() で Google OAuth2 トークンを取得\nconst token = await chrome.identity.getAuthToken({\n  interactive: true,\n  scopes: ['https://www.googleapis.com/auth/spreadsheets']\n});\n","javascript","",[34,46,47,56,93,109,133],{"__ignoreMap":44},[48,49,52],"span",{"class":50,"line":51},"line",1,[48,53,55],{"class":54},"sxvE3","// chrome.identity.getAuthToken() で Google OAuth2 トークンを取得\n",[48,57,59,63,67,71,75,78,81,84,86,90],{"class":50,"line":58},2,[48,60,62],{"class":61},"stQ0i","const",[48,64,66],{"class":65},"s4oTP"," token",[48,68,70],{"class":69},"shFtX"," =",[48,72,74],{"class":73},"sHkkW"," await",[48,76,77],{"class":65}," chrome",[48,79,80],{"class":69},".",[48,82,83],{"class":65},"identity",[48,85,80],{"class":69},[48,87,89],{"class":88},"senZ8","getAuthToken",[48,91,92],{"class":69},"({\n",[48,94,96,100,103,106],{"class":50,"line":95},3,[48,97,99],{"class":98},"sz8Xr","  interactive",[48,101,102],{"class":69},":",[48,104,105],{"class":73}," true",[48,107,108],{"class":69},",\n",[48,110,112,115,117,120,124,128,130],{"class":50,"line":111},4,[48,113,114],{"class":98},"  scopes",[48,116,102],{"class":69},[48,118,119],{"class":69}," [",[48,121,123],{"class":122},"sMJiu","'",[48,125,127],{"class":126},"sdGka","https://www.googleapis.com/auth/spreadsheets",[48,129,123],{"class":122},[48,131,132],{"class":69},"]\n",[48,134,136],{"class":50,"line":135},5,[48,137,138],{"class":69},"});\n",[14,140,141,142,145],{},"manifest.jsonの",[34,143,144],{},"oauth2","セクションにスコープを追加するだけで、Sheets APIへのアクセス権が手に入る。Chrome拡張のOAuth2認証は、WebアプリのようにリダイレクトURIを設定する手間がなく、Chromeが認証フローを丸ごと管理してくれる。",[26,147,148],{"id":148},"全体アーキテクチャ",[39,150,155],{"className":151,"code":153,"language":154},[152],"language-text","popup.html（UI）\n  ↓ ユーザーが出力先を選択（CSV / Google Sheets）\ncontent-script.js\n  ↓ Xのブックマークページをスクレーピング\nbackground.js\n  ↓ データ整形 + 差分判定（tweet_idベース）\n  ├→ CSV: Blob生成 → chrome.downloads\n  └→ Sheets: chrome.identity → Sheets API\n","text",[34,156,153],{"__ignoreMap":44},[18,158],{},[21,160,162],{"id":161},"codexレビュー-致命的指摘2点への対応","Codexレビュー: 致命的指摘2点への対応",[26,164,166],{"id":165},"codex-cliが動かない-codex-review-docスキルに切り替え","Codex CLIが動かない → codex-review-docスキルに切り替え",[14,168,169,170,173],{},"最初は",[34,171,172],{},"codex exec","コマンドで計画書を直接レビューしようとした。しかしCLIがトークン認証エラーで弾かれ、ターミナルにエラーが返ってくるだけだった。環境変数を確認しても問題なさそうで、ここで時間を溶かすのは避けたかった。codex-review-docスキル経由に切り替えたところ、すんなりレビューが通った。",[26,175,177],{"id":176},"指摘1-認証設計の明記不足","指摘1: 認証設計の明記不足",[14,179,180],{},"Codexが「OAuth2認証フローの詳細が計画書に書かれていない。manifest.jsonのスコープ定義、トークンのリフレッシュ戦略、失効時のエラーハンドリングが抜けている」と指摘してきた。",[14,182,183],{},"確かに「chrome.identity使う」とだけ書いて、トークン失効時にどうするかを一行も書いていなかった。以下を計画書に追記した。",[185,186,187,194,201],"ul",{},[188,189,141,190,193],"li",{},[34,191,192],{},"oauth2.scopes","にSheets APIスコープを明記",[188,195,196,197,200],{},"トークン失効時は",[34,198,199],{},"chrome.identity.removeCachedAuthToken()","で古いトークンを破棄し、再取得を試みる",[188,202,203],{},"再取得にも失敗した場合はポップアップにエラーメッセージを表示し、手動で再認証を促す",[26,205,207],{"id":206},"指摘2-差分判定アルゴリズムの曖昧さ","指摘2: 差分判定アルゴリズムの曖昧さ",[14,209,210,211,214,215,218,219,221],{},"「差分取得の基準が",[34,212,213],{},"created_at","なのか",[34,216,217],{},"tweet_id","なのか曖昧。タイムゾーン問題を考えると",[34,220,213],{},"は罠が多い」という指摘。これは的を射ていた。",[14,223,224,226,227,229,230,232],{},[34,225,213],{},"はXのAPIがUTCで返すが、ユーザーのローカル時間と混在するとバグの温床になる。",[34,228,217],{},"はSnowflake IDで単調増加が保証されている。比較も数値の大小だけで済む。差分判定を",[34,231,217],{},"ベースに統一した。",[39,234,236],{"className":41,"code":235,"language":43,"meta":44,"style":44},"// 前回エクスポート時の最大tweet_idを保存\nconst lastExportedId = await chrome.storage.local.get('lastMaxTweetId');\n\n// 新規ブックマークのみ抽出\nconst newBookmarks = allBookmarks.filter(\n  b => BigInt(b.tweet_id) > BigInt(lastExportedId || '0')\n);\n",[34,237,238,243,284,290,295,315,363],{"__ignoreMap":44},[48,239,240],{"class":50,"line":51},[48,241,242],{"class":54},"// 前回エクスポート時の最大tweet_idを保存\n",[48,244,245,247,250,252,254,256,258,261,263,266,268,271,274,276,279,281],{"class":50,"line":58},[48,246,62],{"class":61},[48,248,249],{"class":65}," lastExportedId",[48,251,70],{"class":69},[48,253,74],{"class":73},[48,255,77],{"class":65},[48,257,80],{"class":69},[48,259,260],{"class":65},"storage",[48,262,80],{"class":69},[48,264,265],{"class":65},"local",[48,267,80],{"class":69},[48,269,270],{"class":88},"get",[48,272,273],{"class":69},"(",[48,275,123],{"class":122},[48,277,278],{"class":126},"lastMaxTweetId",[48,280,123],{"class":122},[48,282,283],{"class":69},");\n",[48,285,286],{"class":50,"line":95},[48,287,289],{"emptyLinePlaceholder":288},true,"\n",[48,291,292],{"class":50,"line":111},[48,293,294],{"class":54},"// 新規ブックマークのみ抽出\n",[48,296,297,299,302,304,307,309,312],{"class":50,"line":135},[48,298,62],{"class":61},[48,300,301],{"class":65}," newBookmarks",[48,303,70],{"class":69},[48,305,306],{"class":65}," allBookmarks",[48,308,80],{"class":69},[48,310,311],{"class":88},"filter",[48,313,314],{"class":69},"(\n",[48,316,318,321,324,327,329,332,334,336,339,342,344,346,349,352,355,358,360],{"class":50,"line":317},6,[48,319,320],{"class":65},"  b",[48,322,323],{"class":69}," =>",[48,325,326],{"class":88}," BigInt",[48,328,273],{"class":69},[48,330,331],{"class":65},"b",[48,333,80],{"class":69},[48,335,217],{"class":65},[48,337,338],{"class":69},")",[48,340,341],{"class":69}," >",[48,343,326],{"class":88},[48,345,273],{"class":69},[48,347,348],{"class":65},"lastExportedId",[48,350,351],{"class":61}," ||",[48,353,354],{"class":122}," '",[48,356,357],{"class":126},"0",[48,359,123],{"class":122},[48,361,362],{"class":69},")\n",[48,364,366],{"class":50,"line":365},7,[48,367,283],{"class":69},[18,369],{},[21,371,373],{"id":372},"phase-2-google-sheets-api連携","Phase 2: Google Sheets API連携",[26,375,376],{"id":376},"スプレッドシートの新規作成と既存シートへの差分追記",[14,378,379],{},"出力先として2つのモードを用意した。",[381,382,383,394],"ol",{},[188,384,385,389,390,393],{},[386,387,388],"strong",{},"新規作成モード",": Sheets APIの",[34,391,392],{},"spreadsheets.create","で新しいスプレッドシートを作り、ヘッダー行とデータを一括で書き込む",[188,395,396,399,400,403],{},[386,397,398],{},"追記モード",": 既存のスプレッドシートIDを指定し、",[34,401,402],{},"spreadsheets.values.append","で末尾にデータを追加する",[14,405,406,407,409],{},"追記モードでは、前回エクスポート時の",[34,408,278],{},"をchromeストレージに保存しておき、それより大きいIDのブックマークだけをappendする。",[26,411,413],{"id":412},"popuphtmlの出力先選択ui","popup.htmlの出力先選択UI",[14,415,416],{},"ポップアップに出力先選択のラジオボタンを追加した。",[185,418,419,425],{},[188,420,421,424],{},[386,422,423],{},"CSV",": ローカルにCSVファイルをダウンロード。オフラインでも使える",[188,426,427,430],{},[386,428,429],{},"Google Sheets",": 認証フローを経てスプレッドシートに出力。新規作成 or 既存シートIDを入力",[14,432,433],{},"Google Sheetsを選んだ場合のみ、スプレッドシートIDの入力欄が表示される。空欄なら新規作成、IDを入力すれば追記モードになる。",[18,435],{},[21,437,438],{"id":438},"スクレーピング部分の設計メモ",[14,440,441,442,445],{},"Xのブックマークページは無限スクロールで読み込まれる。content-scriptで",[34,443,444],{},"IntersectionObserver","を使い、最下部に到達したら自動スクロールを繰り返す。各ツイートのDOM要素から以下を抽出する。",[447,448,449,462],"table",{},[450,451,452],"thead",{},[453,454,455,459],"tr",{},[456,457,458],"th",{},"フィールド",[456,460,461],{},"取得方法",[463,464,465,473,481,489,496],"tbody",{},[453,466,467,470],{},[468,469,217],"td",{},[468,471,472],{},"ツイートリンクのURLパスから抽出",[453,474,475,478],{},[468,476,477],{},"author",[468,479,480],{},"ユーザー名のテキスト要素",[453,482,483,486],{},[468,484,485],{},"content",[468,487,488],{},"ツイート本文",[453,490,491,493],{},[468,492,213],{},[468,494,495],{},"time要素のdatetime属性",[453,497,498,501],{},[468,499,500],{},"url",[468,502,503],{},"ツイートへのパーマリンク",[14,505,506],{},"DOMの構造はXの更新で変わる可能性がある。セレクタが壊れたときに検知できるよう、抽出結果が0件のときは「セレクタが古い可能性があります」とポップアップに警告を出す設計にした。",[18,508],{},[21,510,511],{"id":511},"試行錯誤の記録",[26,513,514],{"id":514},"うまくいったこと",[185,516,517,524],{},[188,518,519,520,523],{},"会計ソフトA拡張のOAuth2パターンがほぼコピペで動いた。",[34,521,522],{},"chrome.identity","の設計がChrome拡張同士でポータブルなのを体感した",[188,525,526,527,529],{},"Codexの指摘で差分判定を",[34,528,217],{},"に統一したことで、タイムゾーン周りの複雑さが消えた",[26,531,532],{"id":532},"手間取ったこと",[185,534,535,538],{},[188,536,537],{},"Codex CLIのトークン認証エラーで15分ほど詰まった。結局codex-review-docスキル経由に逃げた形だが、原因は未調査のまま",[188,539,540],{},"Xのブックマークページのセレクタ調査に時間がかかった。Reactのclass名がハッシュ化されていて、属性セレクタやdata属性を頼りに辿る必要があった",[18,542],{},[21,544,545],{"id":545},"明日への引き継ぎ",[14,547,548],{},"Googleタスクに以下を登録した。",[185,550,553,562,568],{"className":551},[552],"contains-task-list",[188,554,557,561],{"className":555},[556],"task-list-item",[558,559],"input",{"disabled":288,"type":560},"checkbox"," content-scriptのスクレーピング部分を実装し、実際のXブックマークページで動作確認する",[188,563,565,567],{"className":564},[556],[558,566],{"disabled":288,"type":560}," Sheets APIへの書き込みをテスト用スプレッドシートで検証する",[188,569,571,573],{"className":570},[556],[558,572],{"disabled":288,"type":560}," エラーハンドリング（認証失効、スクレーピング失敗、API制限）のUI表示を作り込む",[18,575],{},[21,577,578],{"id":578},"学びメモ",[14,580,581,583],{},[34,582,36],{},"を2つ目のプロジェクトで使ったことで、Chrome拡張のOAuth2認証がプロジェクトを跨いで再利用できるパターンとして手元に定着した。manifest.jsonにスコープを足すだけでAPIアクセスが広がるのは、Webアプリのリダイレクト設定と比べると手数が半分以下で済む。",[14,585,586],{},"Codexレビューで「tweet_idに統一しろ」と言われたとき、最初は「created_atでも別にいけるだろう」と思った。しかし実際にタイムゾーン変換のコードを頭の中で組み立て始めた瞬間、UTCとJSTの変換ミスで重複取得するシナリオが3つ浮かんで、手が止まった。Snowflake IDの単調増加を信頼する方が、コードもテストもシンプルになる。レビューに救われた場面だった。",[588,589,590],"style",{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}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 .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 pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .sz8Xr, html code.shiki .sz8Xr{--shiki-default:#998418;--shiki-dark:#998418}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 .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);}",{"title":44,"searchDepth":58,"depth":58,"links":592},[593,597,602,606,607,611,612],{"id":23,"depth":58,"text":24,"children":594},[595,596],{"id":28,"depth":95,"text":29},{"id":148,"depth":95,"text":148},{"id":161,"depth":58,"text":162,"children":598},[599,600,601],{"id":165,"depth":95,"text":166},{"id":176,"depth":95,"text":177},{"id":206,"depth":95,"text":207},{"id":372,"depth":58,"text":373,"children":603},[604,605],{"id":376,"depth":95,"text":376},{"id":412,"depth":95,"text":413},{"id":438,"depth":58,"text":438},{"id":511,"depth":58,"text":511,"children":608},[609,610],{"id":514,"depth":95,"text":514},{"id":532,"depth":95,"text":532},{"id":545,"depth":58,"text":545},{"id":578,"depth":58,"text":578},"dev","X(Twitter)のブックマークをスクレーピングしてCSV/Google Sheetsに出力するChrome拡張機能の設計・実装ログ。OAuth2認証パターンの流用、Codexレビューでの致命的指摘への対応、Phase 2のSheets API連携まで","md",{},"/chrome-extension-bookmark-exporter","chrome-extension-x",false,"2026-04-05T00:00:00.000Z",{"title":5,"description":614},"2026-04/2026-04-05/chrome-extension-bookmark-exporter",[624,625,626,627,628,629],"Chrome拡張機能","X","Google Sheets API","OAuth2","Claude Code","Codex","memo",null,"kFqZ0qFBpSCI060gc1_ubUSmI9lgtKqPuHtl0T6asrQ",[],"https://log.eurekapu.com/favicon.svg",1775511574827]