スプレッドシートとGAS Web Appを画面操作ゼロで作成・公開し、外部アプリからHTTPで1行upsertする
clasp のトークンだけで、スプレッドシート作成→バインドされた Apps Script 作成→コード投入→Web App 公開まで REST で完結させる手順。DBを契約せず0円で「フォーム入力→通知→表に蓄積→月次集計」を実現する。人手は実行権限の承認1回だけ。未承認とポリシー禁止の切り分け、シート作成直後の未フラッシュで初回だけ壊れる罠、CSVエクスポートがread-backに使えない罠、再デプロイでURLとシークレットを変えない状態ファイル設計、後片付けまで含む検証スクリプトの型を実測ベースで収録。
約10.8万トークンの節約 (API料金換算で約160円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「スプレッドシートとGAS Web Appを画面操作ゼロで作成・公開し、外部アプリからHTTPで1行upsertする」は、Google WorkspaceカテゴリのAI指示書(MDファイル)です。clasp のトークンだけで、スプレッドシート作成→バインドされた Apps Script 作成→コード投入→Web App 公開まで REST で完結させる手順。DBを契約せず0円で「フォーム入力→通知→表に蓄積→月次集計」を実現する。人手は実行権限の承認1回だけ。未承認とポリシー禁止の切り分け、シート作成直後の未フラッシュで初回だけ壊れる罠、CSVエクスポートがread-backに使えない罠、再デプロイでURLとシークレットを変えない状態ファイル設計、後片付けまで含む検証スクリプトの型を実測ベースで収録。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約10.8万トークン(API料金換算で約160円)・90%のトークンを節約できます。
- カテゴリ
- Google Workspace
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約12万トークン
- この巻物使用時
- 約1.2万トークン
- 節約量
- 約10.8万トークン (約160円)
- 更新日
- 2026-09-16
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/gas-web-app-http-1-upsert/raw を読み込んで、この指示書どおりに実装して"
中身
この指示書でできること
- Google スプレッドシートと Apps Script Web App を、ブラウザ操作なしで API だけで作成・公開できる。
- 外部アプリから匿名 HTTP POST で「表に1行 upsert(同じキーは追記ではなく上書き)」できる Web App を立てられる。
- デプロイ・検証・後片付けまでを Node.js のスクリプトで完結させ、実行後にテストの痕跡を残さない。
1. 前提と使う認証
1-1. 認証情報のありか
- ローカルで
claspにログイン済みなら、~/.clasprc.jsonのtokens.defaultにclient_id/client_secret/refresh_tokenが入っている。これを使えば OAuth の同意操作をやり直さずに access token を得られる。 - ファイルが無い場合だけ
clasp loginを1回実行する(ブラウザ同意が要る唯一の場面)。
1-2. access token への交換
curl -sS -X POST https://oauth2.googleapis.com/token \
-d client_id=<CLIENT_ID> \
-d client_secret=<CLIENT_SECRET> \
-d refresh_token=<REFRESH_TOKEN> \
-d grant_type=refresh_token
access token の寿命は約1時間。長時間動くスクリプトは毎回取り直すこと。
1-3. スコープを必ず先に確認する
curl -sS "https://oauth2.googleapis.com/tokeninfo?access_token=<ACCESS_TOKEN>"
レスポンスの scope に次の4つがすべて含まれていること。
https://www.googleapis.com/auth/drive.filehttps://www.googleapis.com/auth/script.projectshttps://www.googleapis.com/auth/script.deploymentshttps://www.googleapis.com/auth/script.webapp.deploy
1つでも欠けていたら、以降の手順は必ず失敗する。先にここで止める(後続の 403 を追いかけるより速い)。
1-4. Sheets API は使える前提にしない
clasp の OAuth クライアントが属する Google 側プロジェクトで Sheets API が無効になっていると、403 SERVICE_DISABLED になり、こちら側では有効化できない。したがって表の読み書きは後述の Web App 経由に統一し、Sheets API に依存しない構成にする。
2. 作成手順(4ステップ・すべて REST)
すべて Authorization: Bearer <ACCESS_TOKEN> と Content-Type: application/json を付けて叩く。
2-1. スプレッドシートを作る
POST https://www.googleapis.com/drive/v3/files
body: {"name":"<任意のシート名>","mimeType":"application/vnd.google-apps.spreadsheet"}
レスポンスの id が <スプレッドシートID>。
2-2. スクリプトプロジェクトを作る(シートにバインド)
POST https://script.googleapis.com/v1/projects
body: {"title":"<任意のプロジェクト名>","parentId":"<スプレッドシートID>"}
parentId を付けるとコンテナバインドになり、Web App 側で SpreadsheetApp.getActiveSpreadsheet() が使える。レスポンスの scriptId が <スクリプトID>。
2-3. コードとマニフェストを投入する
PUT https://script.googleapis.com/v1/projects/<スクリプトID>/content
body: {"files":[
{"name":"appsscript","type":"JSON","source":"<マニフェストのJSON文字列>"},
{"name":"Code","type":"SERVER_JS","source":"<Apps Script のコード>"}
]}
マニフェスト(appsscript.json の中身)は次で固定する。
{
"timeZone": "Asia/Tokyo",
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"access": "ANYONE_ANONYMOUS",
"executeAs": "USER_DEPLOYING"
}
}
access: ANYONE_ANONYMOUS… 呼び出し側に Google 認証を要求しない。executeAs: USER_DEPLOYING… 実行はオーナー権限。呼び出し側にシートの共有設定は不要。
2-4. バージョンとデプロイ(URL を変えない)
POST https://script.googleapis.com/v1/projects/<スクリプトID>/versions→versionNumberが返る。- 初回のみ:
POST https://script.googleapis.com/v1/projects/<スクリプトID>/deployments{"deploymentConfig":{"description":"initial","versionNumber":<versionNumber>}}→deploymentIdとentryPoints[].webApp.urlが返る。 - 2回目以降:
PUT https://script.googleapis.com/v1/projects/<スクリプトID>/deployments/<deploymentId>{"deploymentConfig":{"description":"update","versionNumber":<versionNumber>}}
2回目以降は必ず PUT で同じ deploymentId を差し替える。 POST で新規デプロイすると Web App の URL が変わり、呼び出し側の設定を貼り替える必要が出るうえ、章3の権限承認もやり直しになる。
2-5. Node.js 実装例(全工程を1本で)
ファイル構成は次の4つ。Apps Script のコードは gas/Code.js に1か所だけ置き、デプロイスクリプトが読み込んでプレースホルダを置換する。
<プロジェクトのパス>/
gas/appsscript.json … マニフェスト
gas/Code.js … Apps Script のコード(共有シークレットはプレースホルダ)
gas-deploy.js … 作成〜デプロイ〜状態保存
.gas-state.json … 生成物(秘密を含む。.gitignore 対象)
gas-verify.js … 検証(章7)
gas/appsscript.json
{
"timeZone": "Asia/Tokyo",
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"access": "ANYONE_ANONYMOUS",
"executeAs": "USER_DEPLOYING"
}
}
gas/Code.js
/*** 設定 ***/
var SHEET_NAME = 'Data'; // データを入れるシート名
var HEADER = ['key', 'value']; // 1列目が upsert のキー列
var LOCK_WAIT_MS = 30000;
/*** 共有シークレット ***/
// このプレースホルダはデプロイスクリプトが実値へ全置換してから投入する。
// 未置換のまま公開されると「誰でも書ける Web App」になるので、後段で起動を失敗させる。
var EMBEDDED_SECRET = '__GAS_SHARED_SECRET__';
function getSecret_() {
// PropertiesService に値があればそちらを優先する(再デプロイせずローテーションできる)
var fromProps = PropertiesService.getScriptProperties().getProperty('SHARED_SECRET');
if (fromProps) return fromProps;
// 先頭一致で「プレースホルダが残っている」ことを判定する。
// ここでプレースホルダ文字列そのものを書くと、置換が1個目しか効かない場合に
// 2個目が残って判定が壊れるため、書かない。
if (!EMBEDDED_SECRET || EMBEDDED_SECRET.indexOf('__GAS_') === 0) {
throw new Error('shared secret is not embedded');
}
return EMBEDDED_SECRET;
}
function json_(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
// シートを取得(無ければ先頭に作る)
function getSheet_() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(SHEET_NAME);
if (sheet) return sheet;
sheet = ss.insertSheet(SHEET_NAME, 0); // 第2引数 0 = 先頭に挿入
sheet.appendRow(HEADER);
// 新規スプレッドシートには既定の空シートが1枚目に残る。空なら消す。
ss.getSheets().forEach(function (s) {
if (s.getSheetId() !== sheet.getSheetId() && s.getLastRow() === 0) {
ss.deleteSheet(s);
}
});
// insertSheet / appendRow の直後は書き込みが未フラッシュで、
// 同じ実行内の getLastRow() が古い値を返す。必ず flush する。
SpreadsheetApp.flush();
return sheet;
}
// ヘッダ行を除いた全データ行
function rows_(sheet) {
var last = sheet.getLastRow();
if (last < 2) return [];
return sheet.getRange(2, 1, last - 1, sheet.getLastColumn()).getValues();
}
// キー一致行のシート上の行番号(1始まり)。無ければ -1。
function findRow_(rows, key) {
for (var i = 0; i < rows.length; i++) {
if (String(rows[i][0]) === String(key)) return i + 2;
}
return -1;
}
// 当月の合計。キー列が 'YYYY-MM' で始まる行だけを対象にする。
// new Date() に流し込む判定は値の列を日付として解釈して壊れるので使わない。
function monthSum_(rows) {
var ym = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy-MM');
var sum = 0;
for (var i = 0; i < rows.length; i++) {
if (String(rows[i][0]).indexOf(ym) === 0) sum += Number(rows[i][1] || 0);
}
return sum;
}
function handle_(p) {
var sheet = getSheet_();
var action = p.action || 'upsert';
var key = p.key;
if (action === 'ping') {
return json_({ ok: true, sheet: SHEET_NAME, count: rows_(sheet).length });
}
if (action === 'dump') {
var all = rows_(sheet);
if (key === undefined || key === null) {
// キー無しは全行 + 件数(後片付けで 0 行になったことの確認に使う)
return json_({ ok: true, count: all.length, rows: all });
}
var at = findRow_(all, key);
return json_({ ok: true, found: at > 0, row: at > 0 ? all[at - 2] : null, count: all.length });
}
if (action === 'delete') {
var target = findRow_(rows_(sheet), key);
if (target > 0) sheet.deleteRow(target);
SpreadsheetApp.flush();
return json_({ ok: true, action: 'delete', deleted: target > 0, count: rows_(sheet).length });
}
// 既定 = upsert。同じキーは追記ではなく上書き(二重計上を防ぐ)。
var values = p.values || [];
var row = [key].concat(values);
var hit = findRow_(rows_(sheet), key);
if (hit > 0) {
sheet.getRange(hit, 1, 1, row.length).setValues([row]);
} else {
sheet.appendRow(row);
}
SpreadsheetApp.flush(); // 直後に読み直すので必須
var after = rows_(sheet);
return json_({
ok: true,
action: 'upsert',
key: key,
replaced: hit > 0, // true = 上書き、false = 新規追記
monthSum: monthSum_(after), // 呼び出し側が別リクエストを出さずに済む
count: after.length
});
}
// 承認確認・ヘルスチェック用
function doGet() {
return json_({ ok: true, service: 'sheet-upsert', sheet: SHEET_NAME });
}
function doPost(e) {
// 未置換ならここで throw して起動を失敗させる(空トークンで公開しない)
var secret = getSecret_();
var payload;
try {
payload = JSON.parse((e && e.postData && e.postData.contents) || '{}');
} catch (err) {
return json_({ ok: false, error: 'invalid json' });
}
// 共有シークレットは厳密一致。不一致は失敗 JSON を返す(例外にしない)
if (payload.token !== secret) {
return json_({ ok: false, error: 'invalid token' });
}
var lock = LockService.getScriptLock();
try {
if (!lock.tryLock(LOCK_WAIT_MS)) {
return json_({ ok: false, error: 'busy' }); // 直列化:取れなければ失敗を返す
}
return handle_(payload);
} catch (err) {
// 例外を投げずに失敗を返す。エラー文に共有シークレットを混ぜない。
return json_({ ok: false, error: String((err && err.message) || err) });
} finally {
try { lock.releaseLock(); } catch (ignored) {}
}
}
gas-deploy.js
// 使い方: node gas-deploy.js
// 何度実行しても安全(状態ファイルに無いものだけ作り、既存は作り直さない)
import { readFile, writeFile, chmod } from 'node:fs/promises';
import { resolve } from 'node:path';
import { homedir } from 'node:os';
import crypto from 'node:crypto';
const STATE_FILE = resolve(process.cwd(), '.gas-state.json'); // 秘密を含む。.gitignore 対象
const PLACEHOLDER = '__GAS_SHARED_SECRET__';
const SHEET_TITLE = 'upsert-data';
const PROJECT_TITLE = 'upsert-webapp';
const REQUIRED_SCOPES = [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/script.projects',
'https://www.googleapis.com/auth/script.deployments',
'https://www.googleapis.com/auth/script.webapp.deploy',
];
async function getAccessToken() {
const rc = JSON.parse(await readFile(resolve(homedir(), '.clasprc.json'), 'utf8'));
const { client_id, client_secret, refresh_token } = rc.tokens.default;
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ client_id, client_secret, refresh_token, grant_type: 'refresh_token' }),
});
const data = await res.json();
if (!data.access_token) throw new Error(`token exchange failed: ${JSON.stringify(data)}`);
// スコープを必ず先に確認する
const info = await (await fetch(
`https://oauth2.googleapis.com/tokeninfo?access_token=${data.access_token}`
)).json();
const scopes = String(info.scope || '').split(' ');
for (const s of REQUIRED_SCOPES) {
if (!scopes.includes(s)) throw new Error(`missing scope: ${s}`);
}
return data.access_token;
}
async function api(url, method, token, body) {
const res = await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) throw new Error(`API ${res.status} ${url}\n${text}`);
return text ? JSON.parse(text) : {};
}
async function loadState() {
try { return JSON.parse(await readFile(STATE_FILE, 'utf8')); } catch { return {}; }
}
async function saveState(state) {
await writeFile(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
try { await chmod(STATE_FILE, 0o600); } catch { /* Windows では無視される */ }
}
async function main() {
const token = await getAccessToken();
const state = await loadState();
// ① スプレッドシート(未作成なら作る)
if (!state.spreadsheetId) {
const sheet = await api('https://www.googleapis.com/drive/v3/files', 'POST', token, {
name: SHEET_TITLE,
mimeType: 'application/vnd.google-apps.spreadsheet',
});
state.spreadsheetId = sheet.id;
console.log('[1/4] spreadsheet created');
} else {
console.log('[1/4] spreadsheet reused');
}
// ② スクリプトプロジェクト(コンテナバインド / 未作成なら作る)
if (!state.scriptId) {
const proj = await api('https://script.googleapis.com/v1/projects', 'POST', token, {
title: PROJECT_TITLE,
parentId: state.spreadsheetId,
});
state.scriptId = proj.scriptId;
console.log('[2/4] script project created');
} else {
console.log('[2/4] script project reused');
}
// ③ 共有シークレットは「状態ファイルに無いときだけ」生成する。
// 毎回作り直すと、再デプロイのたびに値が変わって既存の呼び出し側が 401 で壊れる。
if (!state.sharedSecret) {
state.sharedSecret = crypto.randomBytes(24).toString('base64url');
console.log('[3/4] shared secret: generated and stored in state file');
} else {
console.log('[3/4] shared secret: reused from state file (unchanged)');
}
const manifest = JSON.parse(await readFile(resolve('gas/appsscript.json'), 'utf8'));
const template = await readFile(resolve('gas/Code.js'), 'utf8');
// 置換は split/join で全置換する。
// String.replace(str, v) は最初の1個しか置換せず、v に $& などがあると特殊解釈されるため使わない。
const code = template.split(PLACEHOLDER).join(state.sharedSecret);
if (code.indexOf(PLACEHOLDER) !== -1) throw new Error('placeholder replacement failed');
await api(
`https://script.googleapis.com/v1/projects/${state.scriptId}/content`,
'PUT', token,
{
files: [
{ name: 'appsscript', type: 'JSON', source: JSON.stringify(manifest) },
{ name: 'Code', type: 'SERVER_JS', source: code },
],
}
);
// ④ バージョン作成 → デプロイ
const version = await api(
`https://script.googleapis.com/v1/projects/${state.scriptId}/versions`,
'POST', token, { description: `auto-deploy-${Date.now()}` }
);
if (!state.deploymentId) {
const deploy = await api(
`https://script.googleapis.com/v1/projects/${state.scriptId}/deployments`,
'POST', token,
{ deploymentConfig: { description: 'initial', versionNumber: version.versionNumber } }
);
state.deploymentId = deploy.deploymentId;
state.webAppUrl = (deploy.entryPoints || [])[0]?.webApp?.url;
console.log('[4/4] deployment created');
} else {
// 同じ deploymentId を差し替える = URL が変わらない = 承認もやり直しにならない
const upd = await api(
`https://script.googleapis.com/v1/projects/${state.scriptId}/deployments/${state.deploymentId}`,
'PUT', token,
{ deploymentConfig: { description: `update-${Date.now()}`, versionNumber: version.versionNumber } }
);
state.webAppUrl = (upd.entryPoints || [])[0]?.webApp?.url;
console.log('[4/4] deployment updated (same id / same URL)');
}
await saveState(state);
// 共有シークレットは標準出力に出さない(CI ログ等に残さないため)。必要なら状態ファイルから読む。
console.log('done. webAppUrl =', state.webAppUrl);
console.log('state file =', STATE_FILE);
}
main().catch((err) => {
console.error('fatal:', err.message || err);
process.exit(1);
});
呼び出し側(外部アプリからの1回の POST)
// 表に1行 upsert する。失敗しても例外を投げない(本筋の処理を止めない)。
async function postToSheet(webAppUrl, sharedSecret, payload, { timeoutMs = 8000 } = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(webAppUrl, {
method: 'POST',
headers: { 'Content-Type': 'text/plain;charset=utf-8' }, // preflight を避ける
body: JSON.stringify({ token: sharedSecret, ...payload }),
redirect: 'follow', // GAS は 302 を返すので明示が必須
signal: ctrl.signal,
});
const text = await res.text();
try {
return JSON.parse(text);
} catch {
return { ok: false, error: `non-json response (HTTP ${res.status})` };
}
} catch (e) {
// 例外を投げず {ok:false,error} を返す。エラー文に共有シークレットを混ぜない。
return { ok: false, error: String((e && e.message) || e) };
} finally {
clearTimeout(timer);
}
}
// 例: 呼び出し側は書き込み失敗で本筋(通知送信など)を止めない
const result = await postToSheet(webAppUrl, sharedSecret, {
action: 'upsert',
key: '2026-01-15',
values: [1200],
});
if (!result.ok) console.warn('sheet write skipped:', result.error);
curl で確認する場合:
curl -sS -L -X POST "<Web App の URL>" \
-H "Content-Type: text/plain;charset=utf-8" \
-d '{"token":"<共有シークレット>","action":"ping"}'
# → {"ok":true,"service":"sheet-upsert","sheet":"Data","count":0}
2-6. 状態ファイルの扱い
.gas-state.json に spreadsheetId / scriptId / deploymentId / webAppUrl / sharedSecret を保存し、再実行時はこれを作り直さない。新規生成するのはファイルに無いときだけ。
- 秘密を含むのでコミットしない(
.gitignoreに入れる)。 - パーミッションは
0600相当にする。Windows ではchmodが効かないので、共有フォルダの外に置く。 - 消すと次回の実行で別のシート・別の URL・別のシークレットが作られ、既存の呼び出し側が全滅する。バックアップもコミットもしないが、消さない。
3. 唯一残る人手=実行権限の承認1回
- Web App はオーナーが1回スクリプトの権限を承認するまで、匿名リクエストを 403 で弾く。OAuth 同意画面は API から代行できない(ここだけは人がブラウザで操作する)。
- 承認手順:
- デプロイしたアカウント(= オーナー)でログイン中のブラウザで、
entryPoints[].webApp.urlを開く。 - 権限確認画面の内容を確認して「許可」する。
- 章7 の検証スクリプトの
pingが通れば完了。
- デプロイしたアカウント(= オーナー)でログイン中のブラウザで、
- 403 の理由を必ず切り分ける(未承認なのか、そもそも匿名公開が禁止なのかで対処が正反対になる)。
# A. 匿名 GET
curl -sS -o /dev/null -w '%{http_code}\n' -L "<Web App の URL>"
# B. オーナーの access token を付けた GET(本文の title を見る。ステータスでは判定できない)
curl -sS -L -H "Authorization: Bearer <ACCESS_TOKEN>" "<Web App の URL>" \
| grep -o '<title>[^<]*</title>' | head -1
| A: 匿名 GET | B: オーナー token 付き GET | 判定 | 対処 |
|---|---|---|---|
| 403 | 200 で、本文の <title> が Authorization needed | 未承認。(B が 200 でも中身が HTML なら未承認) | 上の手順で1回だけ承認する |
| 403 | 403 | 組織ポリシーで匿名公開自体が禁止されている | 承認しても直らない。access を ANYONE_ANONYMOUS 以外にするか、呼び出し側を Google 認証付きに変える |
| 200 | 200(本文が JSON) | 承認済み | 章7 の検証へ進む |
- 作り直さない限り再承認は不要。 章2-4 のとおり PUT で同じ
deploymentIdを差し替える運用なら、更新のたびに承認し直すことはない。逆に POST で新規デプロイすると URL が変わり、承認もやり直しになる。
4. 共有シークレットの扱い
- ScriptProperties は Apps Script API から書けない(
PropertiesServiceに触れるのはスクリプト実行時だけ)。そこで、コード中に__GAS_SHARED_SECRET__のようなプレースホルダを置き、デプロイ時に生成した乱数へ置換して埋め込む。- 生成例:
crypto.randomBytes(24).toString("base64url")
- 生成例:
- コードは
PropertiesServiceに値があればそちらを優先する実装にしておく(後から手で入れ替えられる = コードを触らずにローテーションできる)。実装はgetSecret_()。 - プレースホルダが未置換なら起動を失敗させるガードを入れる。空トークンのまま公開されると誰でも書ける Web App になるため。
- 置換は
template.split(PLACEHOLDER).join(secret)で全置換する。String.replace(str, v)は最初の1個しか置換せず、vに$&などの特殊文字があると意図しない展開をする。 - ガード側でプレースホルダ文字列を再掲すると、その2個目が置換されずに残って判定が壊れる。
EMBEDDED_SECRET.indexOf("__GAS_") === 0のように先頭一致で判定する。
- 置換は
- シークレットは状態ファイル(章2-6)に保存し、既にあるならそれを読み戻して使い回す。 デプロイのたびに作り直すと値が変わり、既存の呼び出し側(サーバレス関数・常駐アプリ等)が再デプロイのたびに 401 で壊れる。章2-5 の実装はこの形になっている。
- 標準出力・ログにも出さない(CI のログに残るため)。
5. Web App 側(doPost)で必ず押さえる点
- 共有シークレットを厳密一致で照合し、不一致は失敗 JSON(
{"ok":false,"error":"invalid token"})を返す。例外は投げない。 LockService.getScriptLock()で同時実行を直列化する(同時 POST で同じキーの行が二重に増えるのを防ぐ)。- キー列で upsert する。 同じキーの再送は追記ではなく上書き(二重計上を防ぐ)。レスポンスの
replacedで上書きか新規かを返すと、呼び出し側が判定に使える。 - 返す JSON に集計値(例: 当月の合計)を含める。呼び出し側が別リクエストを出さずに済む。
- 検証用に
ping/ 指定キーの行を返すdump/ 指定キーの行を消すdeleteの action を用意する。自動テストが自分で後片付けできるようにするため。dumpはキー無しなら全行と件数を返し、「0 行に戻った」ことの確認に使う。 - 集計の月判定は「キー列の文字列が
YYYY-MMで始まる行を合計」のように文字列の前方一致で書く。new Date()に流し込む判定は値の列を日付として解釈してしまい、簡単に壊れる。 - 承認確認・ヘルスチェック用に
doGetを1つ置く。ただし承認前はオーナーの GET でも本文が権限確認ページになるので、判定は章3の表のとおり「本文の<title>」で行う(ステータスコードでは判定できない)。
6. 実測で踏んだ罠(この章が価値の中心)
6-1. 新規シート直後は書き込みが未フラッシュ
- シートを新規作成した直後は書き込みが未フラッシュで、同じ実行内の
getLastRow()が古い値を返す。 - その結果、初回実行だけ「集計が 0」「同じキーでも上書きされず重複行が残る」という症状が出る。
- 対処:
insertSheetの直後と、追記/上書きの直後にSpreadsheetApp.flush()を入れる。 - 見逃しやすい理由: 2回目以降は正常に見える。1回目の実行結果だけが壊れる。
- 切り分け: 「別のキーで append → dump → 同じキーで append → dump」を実測する。1回目と2回目で件数・集計が変わればこの罠。
6-2. read-back に Drive の CSV エクスポートを使わない
export?mimeType=text/csvは1枚目のシートしか出さない。対象シートが2枚目にあると結果は「0行」になる。- すると「保存できていない」のか「読めていないだけ」なのか区別できなくなる。
- 対処: 読み取りは Web App の
dumpaction で行う。
6-3. 既定の空シートが残る
- 新規スプレッドシートには既定の空シートが1枚目に残る。
- 対処:
insertSheet(name, 0)で先頭に作る、または後から先頭へ移動して空の既定シートを削除する(実装はgetSheet_())。
6-4. 302 とタイムアウト
- 呼び出し側は GAS が 302 を返すので
redirect: "follow"を明示する。 AbortControllerでタイムアウトを付ける(付けないと無応答時に永久に待つ)。
6-5. 書き込み失敗で本筋を止めない
- 書き込みが失敗しても本筋の処理(通知など)は止めない設計にする。例外を投げず
{ok:false, error}を返す。 - エラー文に共有シークレットを混ぜない(
payloadを丸ごとJSON.stringifyしてエラーに載せない)。
7. 検証の型
7-1. Web App 単体の検証(1本のスクリプトで通す)
「ping → 誤ったトークンが弾かれる → 追記 → 同じキーで再送して置換される → 集計値が二重計上されない → dump で値一致 → 後片付けで 0 行」までを1本で通し、痕跡を自分で消して終わる。
// file: gas-verify.js
// 使い方: node gas-verify.js
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const STATE_FILE = resolve(process.cwd(), '.gas-state.json');
// テストキーは「当月の YYYY-MM で始まる」形にする。
// (集計の対象に入るが、実在の日付キーとは衝突しない値にする)
const now = new Date();
const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const TEST_KEY = `${ym}-00-VERIFY`;
let failures = 0;
function check(label, ok, detail) {
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail === undefined ? '' : ' ' + JSON.stringify(detail)}`);
if (!ok) failures++;
}
function makeCaller(webAppUrl) {
return async function call(payload, { timeoutMs = 15000 } = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(webAppUrl, {
method: 'POST',
headers: { 'Content-Type': 'text/plain;charset=utf-8' },
body: JSON.stringify(payload),
redirect: 'follow', // GAS は 302 を返すので必須
signal: ctrl.signal,
});
const text = await res.text();
try {
return JSON.parse(text);
} catch {
return { ok: false, error: `non-json response (HTTP ${res.status})` };
}
} catch (e) {
return { ok: false, error: String((e && e.message) || e) };
} finally {
clearTimeout(timer);
}
};
}
async function main() {
const state = JSON.parse(await readFile(STATE_FILE, 'utf8'));
const call = makeCaller(state.webAppUrl);
const token = state.sharedSecret;
// 0. 前回異常終了した場合の残骸を先に消す(冪等にする)
await call({ token, action: 'delete', key: TEST_KEY });
try {
// 1. ping
const ping = await call({ token, action: 'ping' });
check('ping が ok:true', ping.ok === true, ping);
// 2. 誤ったトークンは弾かれる
const bad = await call({ token: token + 'x', action: 'ping' });
check('誤トークンが ok:false で弾かれる', bad.ok === false, bad);
// 3. 基準値
const base = await call({ token, action: 'dump' });
check('dump で基準値が取れる', base.ok === true && typeof base.count === 'number', base);
// 4. 追記(新規キー)
const added = await call({ token, action: 'upsert', key: TEST_KEY, values: [100] });
check('追記で count が +1', added.count === base.count + 1, added);
check('追記で集計に +100', added.monthSum === base.monthSum + 100, added);
check('追記は replaced:false', added.replaced === false, added);
// 5. dump で値一致
const d1 = await call({ token, action: 'dump', key: TEST_KEY });
check('dump が追記した行を返す', d1.found === true && Number(d1.row[1]) === 100, d1);
// 6. 同じキーで再送 → 追記ではなく置換
const replaced = await call({ token, action: 'upsert', key: TEST_KEY, values: [250] });
check('同じキーで count が増えない(重複行なし)', replaced.count === base.count + 1, replaced);
check('集計が二重計上しない(+250 であって +350 ではない)',
replaced.monthSum === base.monthSum + 250, replaced);
check('再送は replaced:true', replaced.replaced === true, replaced);
// 7. dump が置換後の値
const d2 = await call({ token, action: 'dump', key: TEST_KEY });
check('dump が置換後の値 250 を返す', d2.found === true && Number(d2.row[1]) === 250, d2);
// 8. 後片付け
const del = await call({ token, action: 'delete', key: TEST_KEY });
check('delete で消えた', del.deleted === true, del);
check('delete 後 count が基準値に戻る', del.count === base.count, del);
// 9. 0 行に戻ったことを確認
const finalAll = await call({ token, action: 'dump' });
check('dump の件数が基準値に戻る', finalAll.count === base.count, { count: finalAll.count });
const finalKey = await call({ token, action: 'dump', key: TEST_KEY });
check('テストキーの行が残っていない', finalKey.found === false, finalKey);
} finally {
// どこで失敗してもテストキーは必ず消して終わる
await call({ token, action: 'delete', key: TEST_KEY });
}
console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((err) => {
console.error('fatal:', err.message || err);
process.exit(1);
});
7-2. 本番 URL 経由の検証
- 本番の URL に対して行う検証も同じ思想で、自分が作ったテストデータは自分で消して終わる。
- 通知(チャット等)に出したテスト投稿も自分で削除する。削除できない経路(取り消せない通知先)には、そもそもテスト投稿を出さない。
- 検証の最後に「残骸が 0 件であること」を必ず確認する(
dumpの件数、通知先の削除結果)。
よくある失敗と対処
| 症状 | 原因 | 対処 |
|---|---|---|
| 初回実行だけ集計が 0 / 同じキーなのに重複行が残る | 新規シート直後は書き込みが未フラッシュで getLastRow() が古い値を返す | insertSheet 直後と追記/上書き直後に SpreadsheetApp.flush()。切り分けは「別キーで append → dump → 同じキーで append → dump」 |
| read-back が常に 0 行 | Drive の CSV エクスポートが1枚目のシートしか返さない | 読み取りは Web App の dump action で行う |
| 匿名 POST が 403 | 未承認、または組織ポリシーで匿名公開が禁止 | 章3の表で切り分ける。未承認なら承認1回、ポリシーなら access か呼び出し側の認証方式を変える |
| 呼び出しが無応答・タイムアウト | redirect 未指定(GAS は 302)/タイムアウト無し | redirect: "follow" と AbortController を付ける |
| 再デプロイしたら呼び出し側が 401 になった | 共有シークレットをデプロイのたびに作り直している | 状態ファイルから読み戻して使い回す(無いときだけ生成)。章2-5の実装はこの形 |
| 再デプロイしたら URL が変わり、呼び出し側や承認が飛んだ | POST で新規デプロイしている | PUT /deployments/<deploymentId> で同じ ID を新バージョンに差し替える |
Sheets API が 403 SERVICE_DISABLED | 該当プロジェクトで API が無効。こちらでは有効化できない | 表の読み書きを Web App 経由に統一し、Sheets API を使わない |
| 空トークンで誰でも書き込める | プレースホルダが未置換のままデプロイされた | 起動時ガード(EMBEDDED_SECRET.indexOf("__GAS_") === 0 で throw)。置換は split/join で全置換 |
| 同時 POST で行が二重に増える | Lock 未使用 | LockService.getScriptLock() で直列化し、取れなければ失敗 JSON を返す |
| エラー応答に共有シークレットが載る | payload を丸ごとエラー文に入れている | エラー文には error.message だけを載せる。シークレットを混ぜない |
| 2枚目のシートに書いたのに1枚目が空のまま | 既定の空シートが1枚目に残っている | insertSheet(name, 0) で先頭に作る/空の既定シートを削除する |
| 月次集計が常に 0 | new Date() に値を流し込む判定になっている | キー列の文字列を YYYY-MM の前方一致で判定する |
よくある質問
+「スプレッドシートとGAS Web Appを画面操作ゼロで作成・公開し、外部アプリからHTTPで1行upsertする」とは何ですか?
clasp のトークンだけで、スプレッドシート作成→バインドされた Apps Script 作成→コード投入→Web App 公開まで REST で完結させる手順。DBを契約せず0円で「フォーム入力→通知→表に蓄積→月次集計」を実現する。人手は実行権限の承認1回だけ。未承認とポリシー禁止の切り分け、シート作成直後の未フラッシュで初回だけ壊れる罠、CSVエクスポートがread-backに使えない罠、再デプロイでURLとシークレットを変えない状態ファイル設計、後片付けまで含む検証スクリプトの型を実測ベースで収録。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約12万トークンかかりますが、この巻物を使えば約1.2万トークンで済みます。差し引き約10.8万トークン(API料金換算で約160円)・90%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
GAS完全自動化テンプレ — Driveコマンドキュー方式
Google Apps Script の「毎回エディタで▶実行」を根絶。Drive 経由のコマンドキューで、初回1クリック以降は AI がすべての GAS 関数をリモート実行できるようになるテンプレート指示書。
人間の手入力台帳を壊さずに自動更新する — GAS Web App upsert 設計
各PC/各拠点の点検結果を、人間が手運用しているスプレッドシート台帳へ自動反映する。手入力列とコメントを絶対に壊さない突合設計、タブ/列の解決、並行POST対策、配布シークレットの落とし穴まで。
数式まみれの業務スプレッドシートを、Webアプリから壊さずに編集させる型
ArrayFormula と per-row 数式が混在する台帳を、セル単位 allowlist・dry-run 既定・適用前バックアップ・触っていないセルの数式不変検査で安全に書き換える設計手順。列ごとの数式復元と、テストが緑のまま壊れる典型例つき。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア