開発chrome-extension-xメモ

きっかけ:/make-diaryに「前日ポストレビュー」を統合したかった

朝、Claude Codeに/make-diaryを走らせた流れで「ついでに昨日のXポストを振り返って、今日のポスト案を5個出してくれたら最高だな」と思いついた。出力先は標準出力のみ、公開ファイルにはしない。要件はそれだけ。

問題は「昨日の自分のポストをどうやって取得するか」だった。XのAPIは個人開発者にはほぼ閉じている。手元には数日前に書いたbookmark-exporter.js(X内部のGraphQL APIを直叩きするChrome拡張)があったので、最初は「同じパターンで自分の投稿エクスポート機能を足せばいい」と即決した。


第1案:Chrome拡張に「自分のツイート取得」機能を追加する

既存パターンの流用設計

bookmark-exporter.js は既に Bookmarks GraphQL query を叩いてCSV化する仕組みが動いていた。アーキテクチャはこうなっていた。

popup.html(タブUI: Bookmarks / MyTweets)
  ↓ ユーザーがタブ切替
popup.js(モード分岐: BOOKMARKS / MY_TWEETS)
  ↓ chrome.runtime.sendMessage
background.js
  ↓ GraphQL APIを直叩き(cookieはChromeが自動付与)
  ↓ レスポンスをパース → CSV/JSON出力

実装は機械的だった。

  • popup.html に「自分のツイート」タブを追加
  • background.jsMY_TWEETS_FETCH / MY_TWEETS_EXPORT ハンドラ群を追加
  • popup.jsMY_TWEETS モードに対応

GraphQLのoperation nameは UserTweets、queryIdはX側が頻繁にローテーションするので、Bookmarksと同じく動的取得する設計にした。

Codexレビュー:6点指摘

codex-review-doc スキルでGPT-5.5に計画をレビューさせると、瑣末な指摘を切り捨ててもなお6点が残った。

  • UserTweets レスポンスにはリプライも混ざるので除外条件を明記すること
  • queryIdの動的取得が失敗したときのフォールバック(ハードコードキャッシュ)の取扱いがあいまい
  • スラッシュコマンドが別のスラッシュコマンドをネスト呼び出しする前提になっていて、これは現状動かない
  • Windows PowerShellを前提にしたコマンド例とBash例が混在している
  • popup UIのタブ切替時、進行中フェッチのキャンセル処理が抜けている
  • 出力ファイルのパス命名規則がmake-diaryの規約と微妙にズレている

致命的な4点を計画書に取り込んで再レビュー。Codexは「OK、進めてよし」を返してきた。レビュー通過を見て、コーディングに突入した。


ユーザーの一言で全部巻き戻し

実装の手が止まった瞬間、ユーザーから一行が飛んできた。

「そもそも Chrome 拡張じゃなくて DevTools MCP の evaluate_script で x.com のページコンテキストから直接 GraphQL を叩けば、ログインCookieも使えるし popup 操作も不要、Claude が make-diary の流れの中で完結できるのでは?」

息が止まった。完全に正しい。

  • ChromeのプロファイルにX.comのログインCookieは既に座っている
  • DevTools MCPのevaluate_scriptはそのページコンテキストでJavaScriptを動かせる
  • つまりfetch('/i/api/graphql/...')をページから直接撃てば、Cookieは勝手に乗る
  • popupを開いてタブを切り替えてボタンを押す人間オペレーションが完全にゼロになる
  • /make-diaryの中でClaude自身がevaluate_scriptを呼べば、ファイル受け渡しすら不要

私は「拡張機能パターンに引っ張られすぎていた」と謝罪を打ち、すぐ巻き戻しに入った。前日に動いていたbookmark-exporter.jsの成功体験で「同じパターンで行けるじゃん」が頭を支配して、もっとシンプルな選択肢が視界から消えていた。

巻き戻しの作業ログ

git checkoutで一気に戻すのは怖かった。bookmark-exporter自体は触っていないつもりでも、background.jspopup.jsにbookmark側のリファクタが混ざっている可能性があった。Editツールで該当ブロックだけ慎重に削った。

  • popup.html: タブ追加分のHTMLを削除、CSSも巻き戻し
  • popup.js: MY_TWEETSモード分岐を削除、BOOKMARKS単一モードに戻す
  • background.js: MY_TWEETS_FETCH/MY_TWEETS_EXPORTハンドラを削除、ルーティングテーブルから除去

差分を一行ずつ確認しながら戻したので、bookmark-exporterの動作テストも巻き戻し直後に走らせ、壊れていないことを確かめた。


第2案:DevTools MCPでGraphQLを直叩きする

核心実装:evaluate_scriptで完結

DevTools MCPのevaluate_scriptに流すコードはこれだけ。

// x.comのページコンテキストで実行される
async function fetchYesterdayTweets(userId, queryId) {
  const variables = {
    userId,
    count: 40,
    includePromotedContent: false,
    withQuickPromoteEligibilityTweetFields: false,
    withVoice: false,
  };
  const features = { /* X側の必須featureフラグ群 */ };

  const url =
    `/i/api/graphql/${queryId}/UserTweets` +
    `?variables=${encodeURIComponent(JSON.stringify(variables))}` +
    `&features=${encodeURIComponent(JSON.stringify(features))}`;

  const res = await fetch(url, {
    headers: {
      'x-csrf-token': document.cookie.match(/ct0=([^;]+)/)[1],
      'authorization': 'Bearer ' + (window.__INITIAL_STATE__?.bearerToken
        ?? 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAA...'),  // 公開Bearer
      'content-type': 'application/json',
    },
    credentials: 'include',
  });

  const json = await res.json();
  // リプライ・RT・引用RTを除外して、昨日分だけに絞る
  return extractOriginalTweets(json).filter(isYesterday);
}

ポイントは3つ。

  • credentials: 'include'でログインCookieが自動で乗る(ここが拡張機能を不要にする決め手)
  • x-csrf-tokenct0クッキーから抜く(X.comの内部API共通の作法)
  • queryIdはページ初回ロード時のJSバンドルから動的に取得(ハードコード禁止)

user_idの解決

UserTweets queryには数値のuserIdが必要。スクリーンネーム(@handle)から数値IDを引くために、もう1段階GraphQLを噛ませる(UserByScreenName)。これも同じevaluate_scriptパターンで叩ける。

動作確認

  • queryId動的取得 → 成功
  • user_id解決 → 成功
  • 1ページ40件取得 → 成功
  • リプライ・RT・引用RTの除外フィルタ → 期待通り原投稿のみ残る
  • /review-yesterday-tweets 2026-05-01の実行テスト → 5月1日のポストが揃って取得できた

スラッシュコマンドの実装も、拡張機能呼び出しを前提にしていた構造をDevTools MCP前提に全面書き直した。make-diary.mdのステップ9(前日ポストレビュー段)も同じ流れに合わせて書き換えた。これで/make-diaryの中で完結する。


二度目のやらかし:ポスト案で「税理士事務所の仕事」を下げてしまった

/review-yesterday-tweets 2026-05-01を走らせて出力されたポスト案5個を眺めていたら、ユーザーから3行が飛んできた。

「税理士事務所の仕事を避けるような言い方をやめてください」 「他の職業や誰かを下げることで自分を上げる方法は絶対に使わないでください」 「誰も傷つけないでください」

確認すると、生成したポスト案の1つに「事務員仕事に追われずに〜」という、税理士事務所スタッフ全般を一段下に置く表現が混ざっていた。本人にそのつもりがなくても、その職に就いている人が読めば確実に削られる言い方だった。

short-video-strategyスキルへの明文化

ショート動画戦略スキル(short-video-strategy)を読み返すと、「誰も傷つけない」原則は明示的には書かれていなかった。一方でスキル内のホテル再建事例を見直すと、ネガティブフックは常に「自分(ホテル)を下げる」構造で組まれていて、他者を下げる例は1つも入っていない。暗黙原則として動いていたが、明文化されていなかった

明文化されていない原則は、生成のたびに必ずどこかで漏れる。漏れたら誰かを傷つける。スキルに以下3点を追記した。

  • 誰も傷つけない原則:ネガティブフックは「自分を下げる」構造のみ許可。他者・他職業・他業種を下げる構造は禁止
  • 事務員前提禁止:「事務員仕事から解放」「単純作業の人」のような、特定の職務を一段下に置く言い回しを禁止
  • 税理士事務所下げ禁止:自分が税理士業界の隣にいる立場上、業界内の特定職を下げる言い方は二重に有害

スキルに書いた瞬間、次回以降の生成は最初からこの原則を踏まえて走る。これがスキル化の本当の効用だと改めて思い知った。「気をつけよう」では再現しない。テキストに書き起こして仕組みに食わせて初めて、明日の自分がしくじらない。


振り返り:今日の2つの教訓

教訓1:成功した直近パターンは思考を支配する

bookmark-exporterが昨日まで気持ちよく動いていたから、「同じ拡張パターンで行ける」が即座に頭を占めた。Codexレビューまで通したのに、実は「もっとシンプルな選択肢」を最初の30秒で潰していた。次に類似タスクが来たときの自問を1つ増やす。

  • 「この設計は、最近うまくいったパターンに引っ張られていないか?」
  • 「ブラウザに既にあるもの(Cookie・ログインセッション・DevTools MCP)でできないか?」

教訓2:暗黙原則は必ず漏れる。スキルに書く

ホテル再建事例という良いお手本があったのに、生成側は「他者を下げない」を一度も明示的に与えられていなかった。スキルに3行追記しただけで、明日の生成からこの失敗は構造的に潰される。お手本だけでは継承されない。原則は文字にして仕組みに渡す


今日のまとめ

  • /make-diaryに「前日ポストレビュー+ポスト案5個生成」を統合する依頼から始まった
  • 第1案:bookmark-exporter流用のChrome拡張機能 → Codexレビュー6点指摘 → 4点修正 → 通過 → 実装着手
  • ユーザー指摘:「DevTools MCPのevaluate_scriptで完結できる」 → 完全同意 → 巻き戻し
  • 第2案:DevTools MCPでX内部GraphQLを直叩き → ログインCookie自動利用、popup操作ゼロ
  • 動作確認:queryId動的取得・user_id解決・40件取得・リプライRT除外まで全部通った
  • 生成したポスト案で他職業を下げる表現が混入 → ユーザー指摘
  • short-video-strategyスキルに「誰も傷つけない原則」「事務員前提禁止」「税理士事務所下げ禁止」を明文化

/make-diaryの中で前日ポストを取りに行ける配管が通った。明日からは朝の日記生成のついでに、前日の自分のポストを並べて眺められる。