Chrome拡張MF連携 - 事業者管理とCTI年度切替の仕組み
マネーフォワード(MF)の明細取得Chrome拡張に「事業者・年度の切替」機能を追加した。MFは複数の法人・個人事業主を1アカウントで管理でき、年度ごとにデータが分かれている。これまでは手動でブラウザ上の切替リンクをクリックしていたが、拡張側からプログラムで切替えられるようにした。CTIとTIDの関係を掘り当てるところから始まり、Rails UJSのPATCH送信を再現し、Chrome DevTools MCPでデバッグし、最後にエクスポート/インポート機能まで載せた一日の記録。
CTIとTIDの関係を発見する
MFの画面をDevToolsで眺めていて、URLやリクエストにctiというパラメータが頻繁に登場することに気づいた。ctiは「現在選択中の事業者ID」で、セッション単位で保持される。一方、事業者一覧の/officesページには年度切替リンクがあり、そこにtidパラメータが埋まっている。
ここで手が止まった。tidとctiは別物なのか、それとも関係があるのか。
切替リンクのHTMLを読むと答えが見えた。officesページの切替リンクに含まれるtidが、そのまま切替先のctiになる。つまりtidは「年度ID」だが、MFのセッション管理では年度IDがそのまま事業者コンテキストIDとして使われる。
/offices?tid=12345 → 切替後のセッションCTI = 12345
この発見で、officesページを1回fetchするだけで全事業者・全年度のCTIを一括取得できることがわかった。HTMLをパースして切替リンクからtidを抜き出せば、個別にページを巡回する必要がない。
年度切替はPATCHリクエストが必須だった
CTIの一覧が取れたので、次は実際に年度を切り替えるリクエストを送る。MFの切替リンクをクリックしたときのネットワークログを見ると、単純なGETではなくPOSTが飛んでいた。しかもリクエストボディに_method=patchが入っている。
これはRailsの定番パターンだ。HTMLフォームはGETとPOSTしかサポートしないため、Railsは_methodパラメータでPATCH/PUT/DELETEを擬似的に実現する(Rails UJS)。加えてauthenticity_tokenというCSRFトークンも必須。
fetchで素朴にPATCHを送って失敗
最初はfetchで直接PATCHメソッドを指定して送信した。
// 失敗したコード
await fetch(url, {
method: 'PATCH',
headers: { 'X-CSRF-Token': token }
});
422が返ってきた。RailsはHTTPメソッドとしてのPATCHも受け付けるはずだが、MFのサーバー側は_method=patchをボディに含むPOSTでないと通さないようだった。
Rails UJS方式に切り替えて成功
ブラウザが実際に送っているリクエストを忠実に再現する方針に変えた。
const formData = new URLSearchParams();
formData.append('_method', 'patch');
formData.append('authenticity_token', token);
await fetch(url, {
method: 'POST',
body: formData,
credentials: 'include'
});
これで年度切替が通った。authenticity_tokenはofficesページのHTMLからmeta[name="csrf-token"]を抜き出して取得する。
セッションCTIとURLのCTIがずれる問題
年度切替が動くようになったが、切替後に取得したデータが期待と違う事業者のものだった。ログを追うと、セッションに保持されているCTIと、URLパラメータに渡しているCTIがずれていた。
原因は、切替リクエストのレスポンスを待たずに次のAPI呼び出しを走らせていたこと――ではなかった。そもそもCTIの取得元が間違っていた。URLバーに表示されているCTIをハードコードしていたが、年度切替後はセッション側のCTIが変わるので、切替リンク中のctiパラメータから実際のセッションCTIを取得するように修正した。
切替のレスポンスヘッダにSet-Cookieは見えないが、セッション情報はサーバー側で書き換わっている。切替リクエスト→officesページ再fetchという順序で、常に最新のCTIを取得するフローに落ち着いた。
事業者一括取得機能をChrome拡張に組み込む
CTI解析と年度切替の仕組みが固まったので、Chrome拡張のUIに「全事業者取得」ボタンを追加した。ボタンを押すとofficesページをfetchし、HTMLから全事業者・全年度の情報(事業者名、年度、CTI)を抽出してリストに表示する。
chrome.storage.localに保存する
取得した事業者一覧はどこに保存するか。最初は拡張のdefaults.jsonに書き込もうとしたが、Chrome拡張のパッケージ内ファイルは読み取り専用で書き込めない。chrome.runtime.getURL()で取得できるのは読み取り用パスだけ。
代わりにchrome.storage.localを使った。
await chrome.storage.local.set({
offices: officeList,
lastFetched: Date.now()
});
chrome.storage.localはChrome拡張に組み込みのkey-valueストレージで、拡張のアンインストールまで永続する。容量上限は約5MBだが、事業者一覧のJSONなら数KBで収まる。
エクスポート/インポート機能の追加
事業者設定を別PCに移したい、あるいはバックアップを取りたいというケースに備えて、JSON形式のエクスポート/インポート機能を追加した。
エクスポートはchrome.storage.local.get()で全設定を取得し、Blob→URL.createObjectURL()→chrome.downloads.download()でJSONファイルをダウンロードする。インポートは<input type="file">でJSONを読み込み、chrome.storage.local.set()で書き戻す。
defaults.jsonに書けない制約があるからこそ、ポータブルなエクスポート/インポートが必要になった。制約が機能を生んだ形。
Chrome DevTools MCPでデバッグした経験
開発中、Chrome DevTools MCPを使ってブラウザ上の動作をClaude Codeから直接確認しようとした。--remote-debugging-port=9223でChromeを起動し、MCPサーバー経由でDOMの取得やネットワークログの確認を行う。
CDPポートが管理ポリシーでブロックされる
最初の起動で接続に失敗した。Chromeが--remote-debugging-portを無視しているように見える。調べると、Windows環境でChrome管理ポリシー(レジストリのHKLM\SOFTWARE\Policies\Google\Chrome)が設定されていると、CDP(Chrome DevTools Protocol)のポート開放がブロックされることがある。
回避策として、--user-data-dirで一時プロファイルを指定し、通常使用のChromeとは別プロセスとして起動した。
"C:/Program Files/Google/Chrome/Application/chrome.exe" \
--remote-debugging-port=9223 \
--user-data-dir="C:/Users/numbe/AppData/Local/Temp/chrome-claude-profile"
別プロファイルで起動すると管理ポリシーの影響を受けにくくなり、CDPポートが開いた。ここからMCPでDOMを取得し、officesページのHTML構造やCSRFトークンの位置を確認しながら実装を進めた。
振り返り
officesページのHTMLを1回読んだだけで全事業者・全年度のCTIが手に入るという発見が、この日の作業全体を軽くした。最初はAPI一覧を探したり、個別ページを巡回するスクレイピングを考えていたが、切替リンクのtidがそのままctiになるという構造を見つけた瞬間に、やることがシンプルに絞られた。
Rails UJSのPATCH擬似リクエストは、知っていれば一瞬だが知らないと「なぜ422が返るのか」で時間を溶かす。ブラウザのネットワークタブで実際のリクエストボディを見る習慣が、ここでも効いた。