GASで問い合わせフォームの
スパム投稿をAIで自動判定して弾く方法
NGワードのブラックリストでは防ぎきれない宣伝・営業スパムを、Claude API(文章を理解して応答するAIのAPI)に文脈ごと判定させ、0〜100のスコアで自動的に弾く方法を、動くGASコードで解説します。
Table of Contents
NGワード方式の限界とAI判定の考え方
問い合わせフォームには、SEO営業や被リンク勧誘などのスパムが日々届きます。 従来の「NGワードを含んだら弾く」方式は、単純ゆえに突破されやすく、逆に正当な問い合わせを誤ってブロックしがちです。 AIに文章全体の文脈を判定させると、言い回しを変えられても見抜きやすく、誤検知も抑えられます。
NGワード方式の弱点
「無料」を「0円」に変えるだけで通過する。正当な文章に含まれると誤ブロックする。
AI判定の強み
宣伝目的か・本文と無関係なURLか・不自然な日本語かを文脈で総合判断できる。
スコアで扱う
白黒ではなく0〜100のスコアで受け取り、しきい値を運用に合わせて調整できる。
隔離という発想
誤判定に備え、削除せず別シートへ隔離。中間スコアは人が最終確認する。
全体の処理フローと準備
Googleフォームとリンクしたスプレッドシートをベースにします。フォームが送信されると回答シートに1行追加されるので、その行の本文をClaude APIへ渡してスコアを受け取り、しきい値を超えたら「スパム」シートへ隔離する、という流れです。
フォーム送信 → 回答シートに行追加(送信トリガー発火)
→ 本文をClaude APIに送りスコアを受け取る
→ スコアと理由を回答シートに記録
→ しきい値以上なら「スパム」シートへ隔離
事前準備として、Anthropicの管理画面でClaude APIのAPIキーを取得し、Googleフォームの回答先スプレッドシートを用意しておきます。 回答シートの列は「タイムスタンプ / お名前 / メール / 本文」を想定し、判定結果を書き込むE〜G列を空けておきます。
APIキーを安全に保管する
APIキーはコードに直接書かず、スクリプトプロパティ(GASのプロジェクトに紐づく非公開の設定領域)に保存します。 こうしておけば、コードを共有・公開してもキーが漏れません。
// スクリプトプロパティにAPIキーを保存(初回に一度だけ実行)
function saveApiKey() {
PropertiesService.getScriptProperties()
.setProperty("ANTHROPIC_API_KEY", "sk-ant-xxxxxxxx");
}
// APIキーを取り出すヘルパー
function getApiKey() {
const key = PropertiesService.getScriptProperties()
.getProperty("ANTHROPIC_API_KEY");
if (!key) throw new Error("APIキーが未設定です。saveApiKeyを先に実行してください。");
return key;
}1件をスコアで判定する中核コード
判定の心臓部です。system(AIへの役割・判定基準の指示)でスパムの定義を固定し、 「必ずJSONだけを返す」と指定してスコアと理由を受け取ります。 迷ったら正当な問い合わせ側に倒すよう指示し、正当な相談を誤って弾かないようにしています。
// 1件の投稿をスパム判定する。戻り値は { score, isSpam, reason }
function judgeSpam(entry) {
const apiKey = getApiKey();
const system = [
"あなたは日本語の問い合わせフォームのスパム判定担当です。",
"以下の投稿がスパムかどうかを判定してください。",
"スパムとみなす例:",
"- SEO対策・被リンク・集客などの営業/宣伝",
"- 本文と無関係な外部URLや連絡先の羅列",
"- 意味の通らない文字列や機械翻訳的な不自然な日本語",
"- 同一内容の大量送信を狙った定型文",
"正当な問い合わせ(見積もり相談・仕事の依頼・質問など)はスパムではありません。",
"迷った場合は正当な問い合わせ側に倒し、スコアを低めにしてください。",
"必ず次のJSONだけを返す: " +
'{"score": 0〜100の整数, "reason": "判定理由を20字程度で"}',
"scoreはスパムらしさ。100に近いほどスパムの確信が高い。",
].join("\n");
const userText =
"お名前: " + (entry.name || "(空)") + "\n" +
"メール: " + (entry.email || "(空)") + "\n" +
"本文:\n" + (entry.body || "(空)");
const payload = {
model: "claude-sonnet-4-5",
max_tokens: 200,
system: system,
messages: [{ role: "user", content: userText }],
};
const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", {
method: "post",
contentType: "application/json",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
payload: JSON.stringify(payload),
muteHttpExceptions: true,
});
const json = JSON.parse(res.getContentText());
const text = json.content[0].text;
const parsed = JSON.parse(text);
const score = Math.max(0, Math.min(100, Number(parsed.score)));
return {
score: score,
isSpam: score >= 70, // しきい値は運用に合わせて調整
reason: String(parsed.reason || ""),
};
}Math.maxとMath.minでスコアを0〜100に収め、想定外の値が返っても壊れないようにしています。 しきい値の70は目安です。運用しながら自社に合う値へ調整してください。
フォーム送信時に自動判定する
フォーム送信トリガーから呼ばれる関数です。送信イベントeから回答値を取り出し、判定結果を同じ行のE〜G列に書き込みます。列番号はフォームの質問順に合わせて調整してください。
// フォーム送信トリガーから呼ぶ。回答シートに判定列を書き込む
function onFormSubmit(e) {
const sheet = SpreadsheetApp.getActive().getSheetByName("回答");
const row = e.range.getRow();
// フォームの質問順に合わせて列番号を調整する
const entry = {
name: e.values[1], // B列: お名前
email: e.values[2], // C列: メール
body: e.values[3], // D列: お問い合わせ本文
};
const result = judgeSpam(entry);
// E列にスコア、F列に判定、G列に理由を記録
sheet.getRange(row, 5).setValue(result.score);
sheet.getRange(row, 6).setValue(result.isSpam ? "スパム" : "正常");
sheet.getRange(row, 7).setValue(result.reason);
if (result.isSpam) {
moveToSpamSheet(sheet, row);
}
}スパムは削除せず隔離する
AIの判定には誤りもあり得ます。スパム判定された行はいきなり消さず、「スパム」シートへ移して受信箱をきれいに保ちます。 後から見返して誤判定を救済できるよう、元データを丸ごと退避させるのがポイントです。
// スパム判定された行を「スパム」シートへ隔離する(削除はしない)
function moveToSpamSheet(sheet, row) {
const ss = SpreadsheetApp.getActive();
let spam = ss.getSheetByName("スパム");
if (!spam) spam = ss.insertSheet("スパム");
const lastCol = sheet.getLastColumn();
const values = sheet.getRange(row, 1, 1, lastCol).getValues();
spam.appendRow(values[0]);
// 元シートからは行を削除して受信箱をきれいに保つ
sheet.deleteRow(row);
}過去分をまとめて再判定するバッチ処理
導入前に溜まった回答をまとめて判定したいときは、10件ずつ1回のリクエストにまとめると、API呼び出し回数と実行時間を大きく減らせます。 まず未判定の行を集め、10件ごとにバッチ判定します。
// 過去の回答をまとめて再判定する。10件ずつ1回のリクエストで送る
function rejudgeAllPending() {
const sheet = SpreadsheetApp.getActive().getSheetByName("回答");
const data = sheet.getDataRange().getValues();
const rows = data.slice(1); // ヘッダー除く
const targets = [];
rows.forEach((r, i) => {
if (r[5]) return; // F列(判定)が空の行だけ処理
targets.push({ rowNo: i + 2, name: r[1], email: r[2], body: r[3] });
});
for (let i = 0; i < targets.length; i += 10) {
const chunk = targets.slice(i, i + 10);
const results = judgeSpamBatch(chunk);
results.forEach((result, j) => {
const rowNo = chunk[j].rowNo;
sheet.getRange(rowNo, 5).setValue(result.score);
sheet.getRange(rowNo, 6).setValue(result.score >= 70 ? "スパム" : "正常");
sheet.getRange(rowNo, 7).setValue(result.reason);
});
Utilities.sleep(1000); // レート制限対策に少し待つ
}
}バッチ判定の本体です。複数件をまとめて渡し、index付きのJSON配列で結果を受け取ります。件数と結果がずれても崩れないよう、indexで突き合わせて元の順番に整列させます。
// 複数件を1回のリクエストで判定し、配列で受け取る
function judgeSpamBatch(entries) {
const apiKey = getApiKey();
const system =
"各投稿がスパムか判定し、次のJSON配列だけを返す: " +
'[{"index":番号,"score":0〜100,"reason":"20字程度"}]。' +
"SEO営業・宣伝・無関係なURL羅列・不自然な日本語はスパム。" +
"正当な相談や依頼はスパムではない。迷えば低スコアにする。";
const list = entries
.map((e, i) =>
"#" + i + " 名前:" + (e.name || "空") +
" 本文:" + String(e.body || "").slice(0, 400)
)
.join("\n");
const payload = {
model: "claude-sonnet-4-5",
max_tokens: 1024,
system: system,
messages: [{ role: "user", content: list }],
};
const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", {
method: "post",
contentType: "application/json",
headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
payload: JSON.stringify(payload),
muteHttpExceptions: true,
});
const text = JSON.parse(res.getContentText()).content[0].text;
const arr = JSON.parse(text);
// index順に整列して返す(欠けた場合はスコア0=正常扱い)
return entries.map((_, i) => {
const hit = arr.find((a) => Number(a.index) === i);
const score = hit ? Math.max(0, Math.min(100, Number(hit.score))) : 0;
return { score: score, reason: hit ? String(hit.reason || "") : "判定なし" };
});
}トリガーで自動実行する
フォーム送信トリガーを設定すれば、回答が届くたびに自動でスパム判定が走ります。 取りこぼしに備えて、未判定行を毎日まとめて再判定する時間主導トリガーも用意しておくと安心です。
// フォーム送信トリガーを設定(初回に一度だけ実行)
function createFormTrigger() {
const ss = SpreadsheetApp.getActive();
ScriptApp.newTrigger("onFormSubmit")
.forSpreadsheet(ss)
.onFormSubmit()
.create();
}
// 取りこぼしの再判定を毎日実行したい場合
function createDailyRejudgeTrigger() {
ScriptApp.newTrigger("rejudgeAllPending")
.timeBased()
.everyDays(1)
.atHour(4)
.create();
}実務での注意点
1. 誤判定を前提に「削除」ではなく「隔離」する
どんなに精度を上げても、正当な問い合わせをスパムと誤判定する可能性はゼロにできません。 自動削除は避け、隔離シートへ退避させて、定期的に中身を見返す運用にしましょう。
2. しきい値は幅を持たせる
「70以上は隔離、40〜69は要確認、39以下は正常」のように中間帯を設けると、判断の難しい投稿を人が確認でき、取りこぼしと誤ブロックの両方を減らせます。
3. reCAPTCHAなど入口対策と併用する
機械的なbot投稿は入口のreCAPTCHAで、人が書いた宣伝スパムはAI判定で、と役割を分けた二層構成にすると全体の精度が上がります。
4. APIエラーとコストに備える
通信失敗やJSON解析エラーに備え、判定できなかった投稿は「要確認」として残す設計が安全です。 1件あたりの入力は短いためコストは小さめですが、大量投稿時はバッチ処理で呼び出し回数を抑えましょう。
まとめ
GASとClaude APIを組み合わせると、NGワードでは防ぎきれない宣伝・営業スパムを、文脈込みのスコア判定で弾けます。 ポイントは、白黒ではなくスコアで受け取ること、削除せず隔離すること、中間帯は人が確認することの3つです。 フォーム送信トリガーと定期トリガーを組み合わせれば、人手をかけずに問い合わせ受信箱をきれいに保てます。
よくある質問
「無料」「稼げる」などのNGワードを並べる方式は、少し言い回しを変えられるだけで通過してしまい、逆に正当な問い合わせを誤ってブロックしがちです。AIは文章全体の文脈から「宣伝目的か」「日本語として不自然か」「本文と関係のない外部リンクを含むか」を総合的に判断できるため、表記を変えられても見抜きやすく、誤検知も抑えられます。
おすすめしません。AIの判定にも誤りはあるため、スパム判定された投稿は削除せず「スパム」シートへ隔離する運用が安全です。スコアが高い(明らかにスパム)ものだけ隔離し、スコアが中間の投稿は人が最終確認する二段構えにすると、正当な問い合わせを失うリスクを下げられます。
運用初期は「70以上を自動隔離、40〜69は要確認、39以下は正常」のように幅を持たせるのがおすすめです。実際の投稿を見ながら、正当な問い合わせが隔離されていないか、スパムが通過していないかを確認し、自社の傾向に合わせてしきい値を調整します。
フォーム送信のたびに1件処理する使い方なら1回のAPI呼び出しで十分速く、問題になりません。過去分をまとめて再判定するような場合は、10件ずつまとめて1回のリクエストで送るバッチ処理にすると、API呼び出し回数と実行時間を大きく減らせます。本記事では両方の書き方を紹介しています。
併用をおすすめします。reCAPTCHAは機械的なbot投稿を入口で防ぐのが得意で、AI判定は人が書いた宣伝・営業スパムの文面を見抜くのが得意です。役割が違うため、入口でbotを弾き、通過してきた投稿の内容をAIで判定する二層構成にすると、全体の精度が高まります。
AI×GASで問い合わせ対応を
自動化する。
スパム判定だけでなく、問い合わせの自動分類・緊急度判定・返信下書き生成まで、Claude APIとGASを組み合わせた業務自動化をご相談いただけます。