Claude CodeからGoogleスプレッドシートを読み書きする|サービスアカウントで無人運用

アイキャッチ Google Workspace・GAS

わたしは記事と記事案の管理を、1枚のGoogleスプレッドシートに寄せました。書くのは人間だけではなく、Claude Codeと、5分おきに動く同期スクリプトも書きます。

結論から書きます。

  • 認証はサービスアカウント。人のログインを使わないので、PCの前に誰もいなくても動く
  • 呼び出しは Node 18以上の標準機能だけ(crypto.sign でJWTに署名 → fetch でトークン取得 → Sheets API v4)。npmのパッケージは入れていない
  • スプレッドシートは、サービスアカウントのメールアドレスに編集者で共有する。この共有の操作が、AIの自動モードでは「権限の付与」として止められた。人間が承認して再開した

以下、わたしがやった順に書きます。

何を作ったか

今日(2026-09-26)作ったのは、次の6つのタブを持つスプレッドシートです。

タブ 1行 =
柱 発信テーマの柱1つ
シリーズ マガジン・カテゴリ1つ
記事 公開済み(または作成中)の1記事
記事案 アイデア1つ(スコアと優先度つき)
変更履歴 人間・AI・画面からの変更1件
README 列の意味と運用ルール

初期データとして、柱4行・シリーズ14行・記事30行・記事案23行を流し込みました。そのあと、わたしの作業用ダッシュボードと5分おきに同期させています。ダッシュボードで編集した内容は、次の同期でシートに反映され、変更履歴に1行残ります。

なぜサービスアカウントにしたか

AIにシートを触らせる方法はいくつかあります。わたしがサービスアカウントを選んだ理由は1つで、無人で動かしたいからです。

人のGoogleアカウントでOAuthの同意を取る方法は、トークンの更新やログインの切れ目で止まります。5分おきに動く処理がそこで止まると、気づくまで同期がずれたままになります。サービスアカウントは鍵ファイルだけで認証できるので、ログインという人間の操作が挟まりません。

代わりに、サービスアカウントは「そのシートを見る権利」を最初は持っていません。シートの共有相手に、サービスアカウントのメールアドレスを追加する必要があります。ここが今回詰まったところです(後述)。

手順1: サービスアカウントを作り、Sheets APIを有効にする

Google Cloud のコンソールで行います。

1. プロジェクトを選ぶ(なければ作る)
2. 「APIとサービス」で Google Sheets API を有効にする
3. 「IAMと管理」→「サービスアカウント」で新しく作る。ロールは付けなくてよい(シートへの権限は共有で渡すため)
4. 作ったサービスアカウントの「鍵」から、JSON形式の鍵を作ってダウンロードする

鍵のJSONには client_email(サービスアカウントのメールアドレス。〇〇@プロジェクト名.iam.gserviceaccount.com の形)と private_key が入っています。この2つだけを使います。

手順2: 鍵を置く場所を1か所に決める

わたしは鍵の置き場所を、先に決めてから作業しています。ルールは次のとおりです。

  • 鍵はPCのユーザーフォルダの下、隠しフォルダに1か所だけ置く
  • 作業フォルダ(リポジトリ)と、チームで共有しているクラウドのフォルダには置かない
  • コードには鍵の中身を書かない。読み込む場所だけを書く

作業フォルダに置くと、うっかりコミットしたり、フォルダごと誰かに共有したりしたときに、鍵も一緒に出ていきます。共有フォルダも同じで、フォルダの共有範囲が広がった瞬間に鍵を渡したことになります。サービスアカウントの鍵は、持っている人が誰でもそのアカウントとして振る舞える「合鍵」なので、置き場所を増やさないのがいちばん効きます。

この記事のコード例では、鍵の場所を環境変数 SA_KEY_FILE で渡す形にしています。

手順3: シートをサービスアカウントに共有する

スプレッドシートの「共有」から、サービスアカウントのメールアドレスを編集者で追加します。読むだけなら閲覧者で足ります。通知メールは届く先がないので、送らない設定でかまいません。

共有していない状態でAPIを呼ぶと、403が返ります。わたしは別のスクリプトで、共有が済んでいない間は「読み込みは空を返す・書き込みは何もせず警告だけ出す」ようにしています。共有待ちの間も、ほかの定期処理を止めないためです。

手順4: トークンを取る(パッケージなし)

ここからコードです。記事用に書き下ろした最小の例で、Node 18以上ならそのまま動く形にしています。

// sheets-min.js(Node 18+。追加パッケージなし)
const fs = require('fs');
const crypto = require('crypto');

const b64u = (x) => Buffer.from(x).toString('base64')
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

async function getToken() {
  // 鍵はリポジトリの外に置き、場所だけを環境変数で渡す
  const sa = JSON.parse(fs.readFileSync(process.env.SA_KEY_FILE, 'utf8'));
  const now = Math.floor(Date.now() / 1000);
  const header = b64u(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
  const claim = b64u(JSON.stringify({
    iss: sa.client_email,
    scope: 'https://www.googleapis.com/auth/spreadsheets',
    aud: 'https://oauth2.googleapis.com/token',
    iat: now,
    exp: now + 3600,
  }));
  const unsigned = `${header}.${claim}`;
  const signature = b64u(crypto.sign('RSA-SHA256', Buffer.from(unsigned), sa.private_key));

  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      assertion: `${unsigned}.${signature}`,
    }),
  });
  const j = await res.json();
  if (!j.access_token) throw new Error(`トークン取得に失敗 (${res.status})`);
  return j.access_token;
}

やっていることは3つだけです。

1. 「誰が(iss)・何の権限で(scope)・いつまで(exp)」をJSONにして、Base64URLにする
2. 鍵の private_key で署名する(crypto.sign が標準で持っている)
3. できたJWTをGoogleのトークン窓口に渡し、1時間有効なアクセストークンをもらう

わたしがパッケージを入れなかったのは、無人で動く処理の依存を減らしたかったからです。数十行で済むなら、中身が見えるほうが直しやすいと判断しました。

手順5: 読む・足す・書き換える

トークンが取れれば、あとはSheets API v4を fetch で呼ぶだけです。

const SHEET_ID = process.env.SHEET_ID; // URLの /d/ と /edit のあいだの文字列
const API = `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}`;

async function api(method, url, body) {
  const res = await fetch(url, {
    method,
    headers: { Authorization: `Bearer ${await getToken()}`, 'Content-Type': 'application/json' },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`Sheets API ${res.status}: ${text.slice(0, 200)}`);
  return text ? JSON.parse(text) : {};
}

const range = (tab, a1 = '') => encodeURIComponent(`'${tab}'${a1 ? '!' + a1 : ''}`);

// 読む: タブ全体を2次元配列で
const read = (tab) => api('GET', `${API}/values/${range(tab)}?valueRenderOption=UNFORMATTED_VALUE`);

// 足す: 最終行の下に行を追加
const append = (tab, rows) => api('POST',
  `${API}/values/${range(tab, 'A1')}:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS`,
  { values: rows });

// 書き換える: 複数セルを1回で
const setCells = (cells) => api('POST', `${API}/values:batchUpdate`, {
  valueInputOption: 'RAW',
  data: cells.map((c) => ({ range: c.range, values: [[c.value]] })),
});

// 注意: 下の2行はシートを書き換える。試すときは、本番とは別のテスト用シートで
(async () => {
  await append('記事案', [['I-999', '2026-09-26', 'AI', 'テスト用の記事案']]);
  await setCells([{ range: "'記事案'!D2", value: '書き換えたタイトル' }]);
  console.log((await read('記事案')).values?.length, '行');
})();

使い分けは次のとおりです。

やりたいこと 使うもの
タブの中身を読む values/{範囲}(GET)
行を足す values/{範囲}:append
決まったセルを書き換える values:batchUpdate(何セルでも1回で送れる)
タブの追加・見出しの固定・プルダウン スプレッドシート本体の :batchUpdate

5分おきの同期では、セルを1つずつ書くとAPIの呼び出し回数が増えます。変更をまとめて values:batchUpdate 1回で送るようにしています。計測値の取り込みのように頻繁に変わらないものは、30分に1回に間引いています。

手順6: プルダウンもAPIで付ける

「状態」や「柱」のように決まった値しか入らない列には、プルダウン(入力規則)を付けました。これも本体の :batchUpdate で送れます。

// sheetId はタブの数値ID(本体をGETすると sheets[].properties.sheetId にある)
const dropdown = (sheetId, col, values) => ({
  setDataValidation: {
    range: { sheetId, startRowIndex: 1, endRowIndex: 1000, startColumnIndex: col, endColumnIndex: col + 1 },
    rule: {
      condition: { type: 'ONE_OF_LIST', values: values.map((v) => ({ userEnteredValue: v })) },
      strict: false,
      showCustomUi: true,
    },
  },
});

// 上の api() と同じファイルの async 関数の中で呼ぶ(CommonJSのファイル直下では await が使えない)
(async () => {
  await api('POST', `${API}:batchUpdate`, {
    requests: [dropdown(0, 5, ['ストック', '執筆中', '審査中', '公開済', '見送り'])],
  });
})();

strict: false にしているのは、人間が一時的に規則外の値を入れても弾かないためです。値の正しさは、書き込む側のスクリプトでも確かめています(次の節)。

AIに書かせない列は、コードで拒否する

シートにはAIが書いてよい列と、書いてはいけない列があります。わたしの場合、価格と、記事案を「見送り」に確定する操作は人間の領域です。

これを「AIへの指示」だけで守らせるのはやめました。指示は読み飛ばされることがあるからです。代わりに、書き込み関数の入口で弾いています。

const AI_FORBIDDEN = new Set(['価格']);

function check(tab, col, value, by) {
  if (col === 'ID') return 'IDは変更できません';
  if (by === 'AI' && AI_FORBIDDEN.has(col)) return `${col}は人間の領域です`;
  if (by === 'AI' && tab === '記事案' && col === '状態' && value === '見送り')
    return '見送りの確定は人間の領域です(メモ欄に提案を書く)';
  return null; // null なら書いてよい
}

書き込みが通ったら、「日時・誰が・どのタブ・ID・列・前・後」を変更履歴のタブに1行足します。AIが何を書き換えたかを、あとからシートだけで追えるようにするためです。

詰まったところ: 共有の操作が、自動モードで止まった

いちばん止まったのは、コードではなく手順3でした。

わたしはこの作業をClaude Codeの自動モードで進めていました。シートを作り、見出しを入れ、サービスアカウントに共有する——というところで、共有の操作が権限の付与として止められました。外部のアカウントに編集権限を渡す操作なので、AIが自分の判断だけで実行してよい範囲の外、という扱いです。

止まった瞬間は「手順の途中で切れた」と感じました。ただ、振り返ると止まって正しい操作だったと思います。サービスアカウントに編集者を渡すのは、そのシートの合鍵を1本増やすのと同じです。わたしは共有する相手とシートが合っていることを人間が確認し、承認してから再開しました。そこから先(初期データの投入、同期の設定)は止まらずに進みました。

わたしが学んだのは、無人で回す仕組みでも、最初の権限の受け渡しだけは人間の手を通るということです。これは避ける対象ではなく、手順の中に最初から「ここは人間」と書いておく対象でした。

結果

項目 実測
作ったタブ 6(柱・シリーズ・記事・記事案・変更履歴・README)
初期データ 柱4行・シリーズ14行・記事30行・記事案23行
同期の間隔 5分おき(計測値の取り込みは30分に1回)
追加したnpmパッケージ 0
人間の承認が必要だった操作 1(サービスアカウントへの共有)
作業全体の所要時間 未計測

次にやること

今は同期をPCの定期タスクで回しているので、PCが寝ている間は止まります。同じ処理をGoogle Apps Scriptの時間トリガーへ移せば、PCに依存しなくなります。移すときは、鍵をスクリプトのプロパティに入れる形になるはずです。それができたら、また記録します。

関連する記録

Webアプリ側で利用者を識別できなかった経緯は、GAS Webアプリの認証とgetActiveUserが空になる時の判断表にまとめました。

複数のAIへ作業を任せた時の分担は、Claude CodeとChatGPT(Codex)の使い分けの記録に書いています。

Claude Codeで迷ったときに読んだ本(実践Claude Code入門―現場で活用するためのAIコーディングの思考法ほか1冊)は、「Claude Code入門で読んだ本2冊|VS Codeの壁と、AIに任せる範囲の壁」にまとめています。

※上記はAmazonアソシエイトのリンク(広告)です。紹介しているのは実際に読んだ本だけです(アフィリエイトについて)。

コメント

タイトルとURLをコピーしました