わたしは記事と記事案の管理を、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アソシエイトのリンク(広告)です。紹介しているのは実際に読んだ本だけです(アフィリエイトについて)。


コメント