• #Chrome拡張機能
  • #Google Sheets API
  • #OAuth2
  • #manifest.json
開発misc-devメモ

Chrome拡張機能にGoogle Sheets API書き込みを追加する

会計ソフトの明細を自動取得するChrome拡張を以前から使っていたが、出力はCSVダウンロードだけだった。取得したデータをそのままGoogle Sheetsに書き込めれば、ダウンロード→手動インポートの手間が消える。OAuth2認証の設定で2回つまずき、API URLで1回つまずき、差分追記のロジックを組んで、最後にページ遷移の待機を直した。半日の作業ログ。


OAuth2認証: クライアントIDのタイプを間違えた

Chrome拡張からGoogle APIを叩くには、chrome.identity.getAuthToken() でOAuth2トークンを取得する。manifest.jsonに oauth2 セクションを追加して、Google Cloud ConsoleのクライアントIDを設定するだけ――のはずだった。

最初のエラー: bad client id

既存のデスクトップアプリ用クライアントIDをそのまま使ったら、getAuthToken()bad client id を返した。

原因はクライアントIDのタイプ。Chrome拡張向けには「Chrome アプリ」タイプで新規作成する必要がある。Google Cloud Consoleの認証情報画面で「OAuth 2.0 クライアント ID の作成」→ アプリケーションの種類で「Chrome アプリ」を選び、拡張機能のIDを登録する。

// manifest.json
{
  "oauth2": {
    "client_id": "xxxx.apps.googleusercontent.com",
    "scopes": ["https://www.googleapis.com/auth/spreadsheets"]
  }
}

「Chrome アプリ」タイプを選ぶと、クライアントIDがChrome拡張のIDと紐づく。デスクトップ用やWeb用のクライアントIDではこの紐づけが存在しないため、chrome.identity がIDを弾く。


host_permissions漏れ: Codexレビューで発覚

manifest.jsonにoauth2セクションを追加した段階で、Codexにレビューを投げた。返ってきた指摘の1つが host_permissions の漏れだった。

{
  "host_permissions": [
    "https://www.googleapis.com/*"
  ]
}

Chrome拡張のservice workerから外部APIへ fetch するには、host_permissions にそのドメインを明示的に追加しなければならない。content scriptからのリクエストとは権限モデルが異なる。これが漏れるとfetchがブロックされて無言で失敗する。

Codexの指摘がなければ、実行時に「なぜか通信できない」と頭を抱えていたはず。レビューを挟む価値を実感した瞬間だった。


API URL: googleapis.com の罠

認証が通り、host_permissionsも設定した。いざシートに書き込もうとして、今度は404が返ってきた。

原因はベースURL。最初に書いたコードでは https://www.googleapis.com/v4/spreadsheets/... を叩いていたが、Sheets API v4の正しいベースURLは https://sheets.googleapis.com/v4/spreadsheets/... だった。

// NG: 404
const url = `https://www.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}`;

// OK
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}`;

Google APIはサービスごとにサブドメインが異なる。www.googleapis.com はDiscovery APIなど一部のエンドポイントで使われるが、Sheets APIは sheets.googleapis.com に住んでいる。公式ドキュメントのサンプルコードをコピーすれば間違えないが、記憶で書くとこういう罠を踏む。

host_permissionsにも https://sheets.googleapis.com/* を追加して解決した。


差分追記: 全上書きではなくdiffで追記する

APIが通ったので、次は書き込みロジック。最初は「毎回全データを上書き」で実装しようとしたが、Google Sheetsのセル編集履歴が全て吹き飛ぶことに気づいて方針を変えた。手動でメモを追記していたセルがあったため、全上書きは使えない。

実装方針

  1. 既存データを spreadsheets.values.get で取得
  2. 新規データの各行について、既存行と全列のJSON.stringify一致で重複判定
  3. 既存に存在しない行だけを spreadsheets.values.append で追記
const isDuplicate = (newRow, existingRows) =>
  existingRows.some(existing =>
    JSON.stringify(existing) === JSON.stringify(newRow)
  );

const rowsToAppend = newRows.filter(row => !isDuplicate(row, existingRows));

JSON.stringifyでの比較は雑に見えるが、会計明細のデータは日付・金額・摘要が全て一致すれば同一行とみなして問題ない。部分一致や曖昧マッチングを入れると逆に誤判定が増える。「全列一致なら重複」というルールで2週間運用して、誤判定は一度も起きていない。


UIの変更: チェックボックスからラジオボタンへ

元々のUIでは「CSVダウンロード」と「Sheets書き込み」を独立したチェックボックスで並べていた。しかしこの2つは排他的な操作で、両方同時に実行する意味がない。チェックボックスだとユーザーが両方ONにできてしまう。

ラジオボタンに変更して、出力先を1つだけ選ぶUIにした。小さな変更だが、操作の迷いが消えた。


ページ遷移の待機ロジックを書き直す

この拡張は複数の口座を順番に切り替えながら明細を取得する。口座切り替え時にページ遷移が発生し、DOMが再構築される。元の実装では固定1.5秒の setTimeout で待機していた。

固定待機の問題

最後の口座に切り替えた直後、フィルタ条件が適用される前のデータを拾ってしまう現象が出た。ネットワークが遅いタイミングで1.5秒では足りず、前の口座のデータがまだ表示されている状態でスクレイピングが走っていた。

修正: 条件ベースの待機

固定秒数の待機を捨てて、2つの条件が揃うまでポーリングする方式に変えた。

  1. テーブル要素が存在する(DOMの再構築完了)
  2. ドロップダウンに対象口座名が表示されている(フィルタ適用完了)
const waitForAccountReady = async (accountName, maxWait = 15000) => {
  const start = Date.now();
  while (Date.now() - start < maxWait) {
    const table = document.querySelector('.data-table');
    const dropdown = document.querySelector('.account-selector');
    if (table && dropdown?.textContent.includes(accountName)) {
      return true;
    }
    await new Promise(r => setTimeout(r, 300));
  }
  throw new Error(`Timeout: ${accountName} の読み込みが${maxWait}ms以内に完了しなかった`);
};

「テーブルが見える」だけでは不十分で、「正しい口座のデータが表示されている」まで確認する必要があった。固定秒数の待機は、遅いときに足りず、速いときに無駄に待つ。条件ベースなら両方解決する。


振り返り

半日で5つのエラーを踏んだ。OAuth2のクライアントIDタイプ、host_permissions漏れ、API URLの違い、全上書きの危険性、固定待機の不安定さ。どれも「動かしてみて初めてわかる」類のもので、ドキュメントを読むだけでは防げなかった。

1つだけ事前に防げたのがhost_permissionsで、これはCodexレビューのおかげだった。自分のコードを別の目で見てもらう仕組みは、たとえAIであっても機能する。

Chrome拡張 + Google Sheets APIの組み合わせは、認証の設定さえ乗り越えれば、スプレッドシートをデータベースのように使える。個人ツールとしてのコスパは高い。