[{"data":1,"prerenderedAt":688},["ShallowReactive",2],{"content-/mf-cti-year-switching":3,"all-pages-for-dir":686,"og-image-/mf-cti-year-switching":687},{"id":4,"title":5,"body":6,"category":668,"description":669,"extension":670,"meta":671,"navigation":317,"ogImage":672,"path":673,"project_name":674,"published":675,"publishedAt":676,"seo":677,"stem":678,"tags":679,"todo":684,"unpublished":675,"updatedAt":672,"__hash__":685},"pages/2026-03/2026-03-23/mf-cti-year-switching.md","Chrome拡張 会計サービス連携 - 事業者管理とCTI年度切替の仕組みを解析して実装した",{"type":7,"value":8,"toc":652},"minimark",[9,14,18,21,26,45,54,71,81,88,90,94,101,112,117,124,221,227,231,234,382,392,394,398,405,415,418,420,424,427,431,446,453,517,522,524,528,531,557,562,564,568,575,579,590,597,634,637,639,642,645,648],[10,11,13],"h1",{"id":12},"chrome拡張-会計サービス連携-事業者管理とcti年度切替の仕組み","Chrome拡張 会計サービス連携 - 事業者管理とCTI年度切替の仕組み",[15,16,17],"p",{},"クラウド会計サービスの明細取得Chrome拡張に「事業者・年度の切替」機能を追加した。この会計サービスは複数の法人・個人事業主を1アカウントで管理でき、年度ごとにデータが分かれている。これまでは手動でブラウザ上の切替リンクをクリックしていたが、拡張側からプログラムで切替えられるようにした。CTIとTIDの関係を掘り当てるところから始まり、Rails UJSのPATCH送信を再現し、Chrome DevTools MCPでデバッグし、最後にエクスポート/インポート機能まで載せた一日の記録。",[19,20],"hr",{},[22,23,25],"h2",{"id":24},"ctiとtidの関係を発見する","CTIとTIDの関係を発見する",[15,27,28,29,33,34,36,37,40,41,44],{},"会計サービスの画面をDevToolsで眺めていて、URLやリクエストに",[30,31,32],"code",{},"cti","というパラメータが頻繁に登場することに気づいた。",[30,35,32],{},"は「現在選択中の事業者ID」で、セッション単位で保持される。一方、事業者一覧の",[30,38,39],{},"/offices","ページには年度切替リンクがあり、そこに",[30,42,43],{},"tid","パラメータが埋まっている。",[15,46,47,48,50,51,53],{},"ここで手が止まった。",[30,49,43],{},"と",[30,52,32],{},"は別物なのか、それとも関係があるのか。",[15,55,56,57,67,68,70],{},"切替リンクのHTMLを読むと答えが見えた。",[58,59,60,61,63,64,66],"strong",{},"officesページの切替リンクに含まれる",[30,62,43],{},"が、そのまま切替先の",[30,65,32],{},"になる","。つまり",[30,69,43],{},"は「年度ID」だが、会計サービスのセッション管理では年度IDがそのまま事業者コンテキストIDとして使われる。",[72,73,78],"pre",{"className":74,"code":76,"language":77},[75],"language-text","/offices?tid=12345  →  切替後のセッションCTI = 12345\n","text",[30,79,76],{"__ignoreMap":80},"",[15,82,83,84,87],{},"この発見で、",[58,85,86],{},"officesページを1回fetchするだけで全事業者・全年度のCTIを一括取得できる","ことがわかった。HTMLをパースして切替リンクからtidを抜き出せば、個別にページを巡回する必要がない。",[19,89],{},[22,91,93],{"id":92},"年度切替はpatchリクエストが必須だった","年度切替はPATCHリクエストが必須だった",[15,95,96,97,100],{},"CTIの一覧が取れたので、次は実際に年度を切り替えるリクエストを送る。会計サービスの切替リンクをクリックしたときのネットワークログを見ると、単純なGETではなくPOSTが飛んでいた。しかもリクエストボディに",[30,98,99],{},"_method=patch","が入っている。",[15,102,103,104,107,108,111],{},"これはRailsの定番パターンだ。HTMLフォームはGETとPOSTしかサポートしないため、Railsは",[30,105,106],{},"_method","パラメータでPATCH/PUT/DELETEを擬似的に実現する（Rails UJS）。加えて",[30,109,110],{},"authenticity_token","というCSRFトークンも必須。",[113,114,116],"h3",{"id":115},"fetchで素朴にpatchを送って失敗","fetchで素朴にPATCHを送って失敗",[15,118,119,120,123],{},"最初は",[30,121,122],{},"fetch","で直接PATCHメソッドを指定して送信した。",[72,125,129],{"className":126,"code":127,"language":128,"meta":80,"style":80},"language-javascript shiki shiki-themes vitesse-light vitesse-light","// 失敗したコード\nawait fetch(url, {\n  method: 'PATCH',\n  headers: { 'X-CSRF-Token': token }\n});\n","javascript",[30,130,131,140,165,189,215],{"__ignoreMap":80},[132,133,136],"span",{"class":134,"line":135},"line",1,[132,137,139],{"class":138},"sxvE3","// 失敗したコード\n",[132,141,143,147,151,155,159,162],{"class":134,"line":142},2,[132,144,146],{"class":145},"sHkkW","await",[132,148,150],{"class":149},"senZ8"," fetch",[132,152,154],{"class":153},"shFtX","(",[132,156,158],{"class":157},"s4oTP","url",[132,160,161],{"class":153},",",[132,163,164],{"class":153}," {\n",[132,166,168,172,175,179,183,186],{"class":134,"line":167},3,[132,169,171],{"class":170},"sz8Xr","  method",[132,173,174],{"class":153},":",[132,176,178],{"class":177},"sMJiu"," '",[132,180,182],{"class":181},"sdGka","PATCH",[132,184,185],{"class":177},"'",[132,187,188],{"class":153},",\n",[132,190,192,195,197,200,202,205,207,209,212],{"class":134,"line":191},4,[132,193,194],{"class":170},"  headers",[132,196,174],{"class":153},[132,198,199],{"class":153}," {",[132,201,178],{"class":177},[132,203,204],{"class":181},"X-CSRF-Token",[132,206,185],{"class":177},[132,208,174],{"class":153},[132,210,211],{"class":157}," token",[132,213,214],{"class":153}," }\n",[132,216,218],{"class":134,"line":217},5,[132,219,220],{"class":153},"});\n",[15,222,223,224,226],{},"422が返ってきた。RailsはHTTPメソッドとしてのPATCHも受け付けるはずだが、会計サービスのサーバー側は",[30,225,99],{},"をボディに含むPOSTでないと通さないようだった。",[113,228,230],{"id":229},"rails-ujs方式に切り替えて成功","Rails UJS方式に切り替えて成功",[15,232,233],{},"ブラウザが実際に送っているリクエストを忠実に再現する方針に変えた。",[72,235,237],{"className":126,"code":236,"language":128,"meta":80,"style":80},"const formData = new URLSearchParams();\nformData.append('_method', 'patch');\nformData.append('authenticity_token', token);\n\nawait fetch(url, {\n  method: 'POST',\n  body: formData,\n  credentials: 'include'\n});\n",[30,238,239,260,291,313,319,333,349,361,377],{"__ignoreMap":80},[132,240,241,245,248,251,254,257],{"class":134,"line":135},[132,242,244],{"class":243},"stQ0i","const",[132,246,247],{"class":157}," formData",[132,249,250],{"class":153}," =",[132,252,253],{"class":243}," new",[132,255,256],{"class":149}," URLSearchParams",[132,258,259],{"class":153},"();\n",[132,261,262,265,268,271,273,275,277,279,281,283,286,288],{"class":134,"line":142},[132,263,264],{"class":157},"formData",[132,266,267],{"class":153},".",[132,269,270],{"class":149},"append",[132,272,154],{"class":153},[132,274,185],{"class":177},[132,276,106],{"class":181},[132,278,185],{"class":177},[132,280,161],{"class":153},[132,282,178],{"class":177},[132,284,285],{"class":181},"patch",[132,287,185],{"class":177},[132,289,290],{"class":153},");\n",[132,292,293,295,297,299,301,303,305,307,309,311],{"class":134,"line":167},[132,294,264],{"class":157},[132,296,267],{"class":153},[132,298,270],{"class":149},[132,300,154],{"class":153},[132,302,185],{"class":177},[132,304,110],{"class":181},[132,306,185],{"class":177},[132,308,161],{"class":153},[132,310,211],{"class":157},[132,312,290],{"class":153},[132,314,315],{"class":134,"line":191},[132,316,318],{"emptyLinePlaceholder":317},true,"\n",[132,320,321,323,325,327,329,331],{"class":134,"line":217},[132,322,146],{"class":145},[132,324,150],{"class":149},[132,326,154],{"class":153},[132,328,158],{"class":157},[132,330,161],{"class":153},[132,332,164],{"class":153},[132,334,336,338,340,342,345,347],{"class":134,"line":335},6,[132,337,171],{"class":170},[132,339,174],{"class":153},[132,341,178],{"class":177},[132,343,344],{"class":181},"POST",[132,346,185],{"class":177},[132,348,188],{"class":153},[132,350,352,355,357,359],{"class":134,"line":351},7,[132,353,354],{"class":170},"  body",[132,356,174],{"class":153},[132,358,247],{"class":157},[132,360,188],{"class":153},[132,362,364,367,369,371,374],{"class":134,"line":363},8,[132,365,366],{"class":170},"  credentials",[132,368,174],{"class":153},[132,370,178],{"class":177},[132,372,373],{"class":181},"include",[132,375,376],{"class":177},"'\n",[132,378,380],{"class":134,"line":379},9,[132,381,220],{"class":153},[15,383,384,385,387,388,391],{},"これで年度切替が通った。",[30,386,110],{},"はofficesページのHTMLから",[30,389,390],{},"meta[name=\"csrf-token\"]","を抜き出して取得する。",[19,393],{},[22,395,397],{"id":396},"セッションctiとurlのctiがずれる問題","セッションCTIとURLのCTIがずれる問題",[15,399,400,401,404],{},"年度切替が動くようになったが、切替後に取得したデータが期待と違う事業者のものだった。ログを追うと、",[58,402,403],{},"セッションに保持されているCTIと、URLパラメータに渡しているCTIがずれていた","。",[15,406,407,408,414],{},"原因は、切替リクエストのレスポンスを待たずに次のAPI呼び出しを走らせていたこと――ではなかった。そもそもCTIの取得元が間違っていた。URLバーに表示されているCTIをハードコードしていたが、年度切替後はセッション側のCTIが変わるので、",[58,409,410,411,413],{},"切替リンク中の",[30,412,32],{},"パラメータから実際のセッションCTIを取得する","ように修正した。",[15,416,417],{},"切替のレスポンスヘッダにSet-Cookieは見えないが、セッション情報はサーバー側で書き換わっている。切替リクエスト→officesページ再fetchという順序で、常に最新のCTIを取得するフローに落ち着いた。",[19,419],{},[22,421,423],{"id":422},"事業者一括取得機能をchrome拡張に組み込む","事業者一括取得機能をChrome拡張に組み込む",[15,425,426],{},"CTI解析と年度切替の仕組みが固まったので、Chrome拡張のUIに「全事業者取得」ボタンを追加した。ボタンを押すとofficesページをfetchし、HTMLから全事業者・全年度の情報（事業者名、年度、CTI）を抽出してリストに表示する。",[113,428,430],{"id":429},"chromestoragelocalに保存する","chrome.storage.localに保存する",[15,432,433,434,437,438,441,442,445],{},"取得した事業者一覧はどこに保存するか。最初は拡張の",[30,435,436],{},"defaults.json","に書き込もうとしたが、",[58,439,440],{},"Chrome拡張のパッケージ内ファイルは読み取り専用","で書き込めない。",[30,443,444],{},"chrome.runtime.getURL()","で取得できるのは読み取り用パスだけ。",[15,447,448,449,452],{},"代わりに",[30,450,451],{},"chrome.storage.local","を使った。",[72,454,456],{"className":126,"code":455,"language":128,"meta":80,"style":80},"await chrome.storage.local.set({\n  offices: officeList,\n  lastFetched: Date.now()\n});\n",[30,457,458,483,495,513],{"__ignoreMap":80},[132,459,460,462,465,467,470,472,475,477,480],{"class":134,"line":135},[132,461,146],{"class":145},[132,463,464],{"class":157}," chrome",[132,466,267],{"class":153},[132,468,469],{"class":157},"storage",[132,471,267],{"class":153},[132,473,474],{"class":157},"local",[132,476,267],{"class":153},[132,478,479],{"class":149},"set",[132,481,482],{"class":153},"({\n",[132,484,485,488,490,493],{"class":134,"line":142},[132,486,487],{"class":170},"  offices",[132,489,174],{"class":153},[132,491,492],{"class":157}," officeList",[132,494,188],{"class":153},[132,496,497,500,502,505,507,510],{"class":134,"line":167},[132,498,499],{"class":170},"  lastFetched",[132,501,174],{"class":153},[132,503,504],{"class":157}," Date",[132,506,267],{"class":153},[132,508,509],{"class":149},"now",[132,511,512],{"class":153},"()\n",[132,514,515],{"class":134,"line":191},[132,516,220],{"class":153},[15,518,519,521],{},[30,520,451],{},"はChrome拡張に組み込みのkey-valueストレージで、拡張のアンインストールまで永続する。容量上限は約5MBだが、事業者一覧のJSONなら数KBで収まる。",[19,523],{},[22,525,527],{"id":526},"エクスポートインポート機能の追加","エクスポート/インポート機能の追加",[15,529,530],{},"事業者設定を別PCに移したい、あるいはバックアップを取りたいというケースに備えて、JSON形式のエクスポート/インポート機能を追加した。",[15,532,533,534,537,538,541,542,541,545,548,549,552,553,556],{},"エクスポートは",[30,535,536],{},"chrome.storage.local.get()","で全設定を取得し、",[30,539,540],{},"Blob","→",[30,543,544],{},"URL.createObjectURL()",[30,546,547],{},"chrome.downloads.download()","でJSONファイルをダウンロードする。インポートは",[30,550,551],{},"\u003Cinput type=\"file\">","でJSONを読み込み、",[30,554,555],{},"chrome.storage.local.set()","で書き戻す。",[15,558,559,561],{},[30,560,436],{},"に書けない制約があるからこそ、ポータブルなエクスポート/インポートが必要になった。制約が機能を生んだ形。",[19,563],{},[22,565,567],{"id":566},"chrome-devtools-mcpでデバッグした経験","Chrome DevTools MCPでデバッグした経験",[15,569,570,571,574],{},"開発中、Chrome DevTools MCPを使ってブラウザ上の動作をClaude Codeから直接確認しようとした。",[30,572,573],{},"--remote-debugging-port=9223","でChromeを起動し、MCPサーバー経由でDOMの取得やネットワークログの確認を行う。",[113,576,578],{"id":577},"cdpポートが管理ポリシーでブロックされる","CDPポートが管理ポリシーでブロックされる",[15,580,581,582,585,586,589],{},"最初の起動で接続に失敗した。Chromeが",[30,583,584],{},"--remote-debugging-port","を無視しているように見える。調べると、Windows環境でChrome管理ポリシー（レジストリの",[30,587,588],{},"HKLM\\SOFTWARE\\Policies\\Google\\Chrome","）が設定されていると、CDP（Chrome DevTools Protocol）のポート開放がブロックされることがある。",[15,591,592,593,596],{},"回避策として、",[30,594,595],{},"--user-data-dir","で一時プロファイルを指定し、通常使用のChromeとは別プロセスとして起動した。",[72,598,602],{"className":599,"code":600,"language":601,"meta":80,"style":80},"language-bash shiki shiki-themes vitesse-light vitesse-light","\"C:/Program Files/Google/Chrome/Application/chrome.exe\" \\\n  --remote-debugging-port=9223 \\\n  --user-data-dir=\"C:/Users/numbe/AppData/Local/Temp/chrome-claude-profile\"\n","bash",[30,603,604,613,620],{"__ignoreMap":80},[132,605,606,609],{"class":134,"line":135},[132,607,608],{"class":149},"\"C:/Program Files/Google/Chrome/Application/chrome.exe\"",[132,610,612],{"class":611},"snbK4"," \\\n",[132,614,615,618],{"class":134,"line":142},[132,616,617],{"class":611},"  --remote-debugging-port=9223",[132,619,612],{"class":611},[132,621,622,625,628,631],{"class":134,"line":167},[132,623,624],{"class":611},"  --user-data-dir=",[132,626,627],{"class":177},"\"",[132,629,630],{"class":181},"C:/Users/numbe/AppData/Local/Temp/chrome-claude-profile",[132,632,633],{"class":177},"\"\n",[15,635,636],{},"別プロファイルで起動すると管理ポリシーの影響を受けにくくなり、CDPポートが開いた。ここからMCPでDOMを取得し、officesページのHTML構造やCSRFトークンの位置を確認しながら実装を進めた。",[19,638],{},[22,640,641],{"id":641},"振り返り",[15,643,644],{},"officesページのHTMLを1回読んだだけで全事業者・全年度のCTIが手に入るという発見が、この日の作業全体を軽くした。最初はAPI一覧を探したり、個別ページを巡回するスクレイピングを考えていたが、切替リンクのtidがそのままctiになるという構造を見つけた瞬間に、やることがシンプルに絞られた。",[15,646,647],{},"Rails UJSのPATCH擬似リクエストは、知っていれば一瞬だが知らないと「なぜ422が返るのか」で時間を溶かす。ブラウザのネットワークタブで実際のリクエストボディを見る習慣が、ここでも効いた。",[649,650,651],"style",{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}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 .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}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);}html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html pre.shiki code .snbK4, html code.shiki .snbK4{--shiki-default:#A65E2B;--shiki-dark:#A65E2B}",{"title":80,"searchDepth":142,"depth":142,"links":653},[654,655,659,660,663,664,667],{"id":24,"depth":142,"text":25},{"id":92,"depth":142,"text":93,"children":656},[657,658],{"id":115,"depth":167,"text":116},{"id":229,"depth":167,"text":230},{"id":396,"depth":142,"text":397},{"id":422,"depth":142,"text":423,"children":661},[662],{"id":429,"depth":167,"text":430},{"id":526,"depth":142,"text":527},{"id":566,"depth":142,"text":567,"children":665},[666],{"id":577,"depth":167,"text":578},{"id":641,"depth":142,"text":641},"dev","クラウド会計サービスのCTI（事業者ID）とTID（年度ID）の関係を解析し、年度切替PATCHリクエスト、事業者一括取得、chrome.storage.localへの保存、エクスポート/インポート機能をChrome拡張に実装した記録","md",{},null,"/mf-cti-year-switching","misc-dev",false,"2026-03-23T00:00:00.000Z",{"title":5,"description":669},"2026-03/2026-03-23/mf-cti-year-switching",[680,681,682,182,683],"Chrome拡張機能","クラウド会計","Rails UJS","chrome.storage","memo","MP77wl9a0H2x4w9kQiB2UTIcYcKlk_vl34iOriDbDXw",[],"https://log.eurekapu.com/og/blog/mf-cti-year-switching.png?v=2026-03-23T00%3A00%3A00.000Z&title=Chrome%E6%8B%A1%E5%BC%B5%20%E4%BC%9A%E8%A8%88%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%E9%80%A3%E6%90%BA%20-%20%E4%BA%8B%E6%A5%AD%E8%80%85%E7%AE%A1%E7%90%86%E3%81%A8CTI%E5%B9%B4%E5%BA%A6%E5%88%87%E6%9B%BF%E3%81%AE%E4%BB%95%E7%B5%84%E3%81%BF%E3%82%92%E8%A7%A3%E6%9E%90%E3%81%97%E3%81%A6%E5%AE%9F%E8%A3%85%E3%81%97%E3%81%9F&author=Kei%20Komatsu&sig=2abf29316756bb90",1782528820217]