tomy634.com // ブログ

実録:お問い合わせ対応を自動化してみた ─ Webhook+スプレッドシート+通知【設計〜コード公開】

公開日: 2025-11-18 / 更新日: 2025-11-18 自動化WebhookGASDiscord通知
この記事のゴール
「フォームで送信 → Webhookで受信 → スプレッドシートに保存 → Discordへ通知」を、1時間以内で運用開始できるテンプレに落とし込みます。
最初に GAS単体で完結する方式 を示し、次に Cloudflare Workers / Node.js の代替も紹介します。

0. 全体図(最小で回る構成)

[Browser Form]
     |  fetch(JSON)
     v
[Google Apps Script Web App] --- append ---> [Google Sheets]
     |                                         (問い合わせ台帳)
     +-- UrlFetchApp.fetch() --> [Discord Webhook]
  

この形だと無料枠で運用でき、VPSなし・DBなしで始められます。

1. 受付フォーム(コピペOK)

あなたの既存デザインに合わせて、最低限のバリデーション+Webhook POSTだけ入れます。

<form id="contact">
  <label>返信が必要ですか?</label>
  <label><input type="radio" name="need_reply" value="yes" checked> はい</label>
  <label><input type="radio" name="need_reply" value="no"> いいえ</label>

  <label>返信用メール(必要な場合は必須)</label>
  <input name="email" type="email" placeholder="you@example.com">

  <label>タイトル(任意)</label>
  <input name="title" type="text" maxlength="120">

  <label>本文(必須)</label>
  <textarea name="body" required minlength="5" maxlength="4000"></textarea>

  <!-- スパム用ハニーポット(CSSで非表示) -->
  <input name="website" type="text" autocomplete="off" style="position:absolute;left:-9999px;" tabindex="-1">

  <button type="submit">送信</button>
</form>

<script>
const ENDPOINT = "(この後つくるGASの公開URLを貼る)";
document.getElementById('contact').addEventListener('submit', async (e) => {
  e.preventDefault();
  const f = new FormData(e.target);
  const need = f.get('need_reply') === 'yes';
  const email = f.get('email')?.trim();
  const body  = f.get('body')?.trim();
  if (!body) { alert('本文は必須です'); return; }
  if (need && !email) { alert('返信が必要な場合はメールを入力してください'); return; }
  const payload = {
    need_reply: need,
    email: email || '',
    title: (f.get('title')||'').slice(0,120),
    body: body.slice(0,4000),
    hp: (f.get('website')||'') // ハニーポット
  };
  const res = await fetch(ENDPOINT, {
    method:'POST',
    headers:{'Content-Type':'application/json'},
    body: JSON.stringify(payload)
  });
  if (res.ok) {
    alert('送信しました。内容を確認してご連絡します。');
    e.target.reset();
  } else {
    const t = await res.text();
    alert('送信に失敗しました: ' + t);
  }
});
</script>

2. 受け側(GAS Webアプリ) ─ 保存&通知を“一括”で

Googleドライブで Googleスプレッドシート を作成(例:Contact Inbox)。1行目に次のヘッダーを入れます。

timestamp, need_reply, email, title, body, ip, ua, ref, id

Apps Script(doPost)

スプレッドシート(拡張機能→Apps Script)に以下を貼り付けて保存 → デプロイ > 新しいデプロイ → 種類「ウェブアプリ」 → アクセス権「全員」にして発行URLを取得します。

/** Webhook受け口:JSONを受け取ってシート保存→Discord通知 */
const SHEET_NAME = 'シート1';             // 実際のシート名に合わせる
const WEBHOOK = 'https://discord.com/api/webhooks/xxxx/yyyy'; // あなたのWebhook
const ORIGIN_ALLOW = ['https://tomy634.com','http://localhost:8080'];

function doPost(e){
  try{
    const contentType = e.postData && e.postData.type;
    if (!contentType || contentType.indexOf('application/json') === -1){
      return _res(415, 'content-type must be application/json');
    }
    const data = JSON.parse(e.postData.contents || '{}');

    // ハニーポット・サイズ制限
    if (data.hp) return _res(202,'ok'); // botは無視
    if (!data.body || data.body.length < 5) return _res(400,'body required');

    // 返信要否とメールの整合
    if (data.need_reply && !data.email) return _res(400,'email required when need_reply=true');

    // 受信メタ
    const ip = _ip(e);
    const ua = (e.parameter && e.parameter.ua) || (e.postData && e.postData.contents ? '' : '');
    const ref = (e.parameter && e.parameter.ref) || '';

    // 保存
    const id = Utilities.getUuid().slice(0,8);
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName(SHEET_NAME) || ss.getSheets()[0];
    const now = new Date();
    sheet.appendRow([ now.toISOString(), !!data.need_reply, (data.email||''), (data.title||''), data.body, ip, ua, ref, id ]);

    // Discord通知(長文は畳む)
    const msg = [
      '**お問い合わせ受信**',
      `ID: ${id}`,
      `返信要否: ${data.need_reply ? '必要' : '不要'}`,
      data.email ? `返信先: <${data.email}>` : '',
      data.title ? `件名: ${data.title}` : '',
      '本文:',
      '```',
      String(data.body).slice(0,1500),
      '```',
      `From: ${ip}`
    ].filter(Boolean).join('\n');

    UrlFetchApp.fetch(WEBHOOK, {
      method: 'post',
      contentType: 'application/json',
      payload: JSON.stringify({ content: msg })
    });

    return _res(200, 'ok');
  }catch(err){
    // 失敗も通知(過剰になりすぎないよう簡易)
    try{
      UrlFetchApp.fetch(WEBHOOK, {method:'post',contentType:'application/json',
        payload: JSON.stringify({content: '🚨 エラー: '+ String(err)})});
    }catch(_){}
    return _res(500, String(err));
  }
}

// CORS(必要に応じて)
function doOptions(e){
  return _res(204,'ok');
}

function _res(code, body){
  const resp = ContentService.createTextOutput(body);
  resp.setMimeType(ContentService.MimeType.TEXT);
  const headers = {
    'Access-Control-Allow-Origin': ORIGIN_ALLOW.join(','),
    'Access-Control-Allow-Methods': 'POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type'
  };
  return { status: code, headers, body: resp };
}

function _ip(e){
  try {
    // Apps ScriptはX-Forwarded-Forをヘッダに持ってくることがある
    const h = e?.parameter || {};
    return h['x-forwarded-for'] || 'unknown';
  } catch(_){ return 'unknown'; }
}

※ 公開範囲「全員」は匿名POSTを許します。必要ならGoogleアカウント限定にして、フロント側はCloudflare Workersなどからプロキシする方法に切り替えてください。

3. Discord通知の見た目(Embed版オプション)

装飾したい場合はEmbedを使います。

// Embed Payload(GAS側のUrlFetchApp.fetchに渡す)
const payload = {
  embeds: [{
    title: "お問い合わせ受信",
    color: 0x74ffcf,
    fields: [
      { name: "ID", value: id, inline: true },
      { name: "返信要否", value: data.need_reply ? "必要" : "不要", inline: true },
      ...(data.email ? [{ name:"返信先", value: data.email, inline: false }] : []),
      ...(data.title ? [{ name:"件名", value: data.title, inline: false }] : []),
      { name: "本文", value: (String(data.body).slice(0,1000) || "-") }
    ],
    footer: { text: new Date().toISOString() }
  }]
};
UrlFetchApp.fetch(WEBHOOK, {method:'post',contentType:'application/json',payload:JSON.stringify(payload)});

4. 代替構成:Workers / Node.js で受ける

A) Cloudflare Workers(無料枠で高速)

export default {
  async fetch(req, env, ctx) {
    if (req.method === 'OPTIONS') return new Response('', {status:204, headers:cors()});
    if (req.method !== 'POST') return new Response('method', {status:405, headers:cors()});
    const data = await req.json().catch(()=>null);
    if (!data || !data.body) return new Response('bad', {status:400, headers:cors()});
    if (data.need_reply && !data.email) return new Response('email required', {status:400, headers:cors()});

    // Discord通知
    const content = `**お問い合わせ**\\n返信要否:${data.need_reply?'必要':'不要'}\\n件名:${data.title||''}\\n本文:\\n` + (data.body||'');
    await fetch(env.WEBHOOK, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({content}) });

    // 保存はGASのエンドポイントへ中継(非公開にしておけば良い)
    await fetch(env.GAS_URL, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(data) });

    return new Response('ok', {status:200, headers:cors()});
  }
};
function cors(){ return {'Access-Control-Allow-Origin':'https://tomy634.com','Access-Control-Allow-Headers':'Content-Type','Access-Control-Allow-Methods':'POST,OPTIONS'}; }

環境変数 WEBHOOK / GAS_URL をダッシュボードで設定。GASを「Googleアカウント限定」にできるのが利点。

B) Node.js(VPS運用)

import express from 'express';
import fetch from 'node-fetch';
const app = express();
app.use(express.json());

app.post('/webhook/contact', async (req, res) => {
  try{
    const {need_reply, email, title, body, hp} = req.body||{};
    if (hp) return res.status(202).send('ok');
    if (!body) return res.status(400).send('body required');
    if (need_reply && !email) return res.status(400).send('email required');

    // Discord
    await fetch(process.env.WEBHOOK, {method:'POST', headers:{'content-type':'application/json'},
      body: JSON.stringify({content: `**お問い合わせ**\\n${email||''}\\n${title||''}\\n${body||''}`})
    });

    // 保存はGAS or DBへ
    await fetch(process.env.GAS_URL, {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(req.body)});

    res.send('ok');
  }catch(e){ res.status(500).send(String(e)); }
});

app.listen(3000);

CORS・RateLimit(express-rate-limit)・HTTPS終端(Nginx/Traefik)を忘れずに。

5. スパム・安全対策(実運用のコツ)

6. 監視と運用

7. よくある詰まり(トラブルシュート)

現象原因対処
415 Unsupported Media TypeContent-TypeがJSONでないfetchのヘッダにapplication/jsonJSON.stringify()で送る
405 Method Not AllowedGETで叩いているPOST限定にする/フォーム側を見直す
GASがタイムアウト通知や外部呼び出しが遅い保存→通知を非同期化/通知のみ別Webhookへ
Discord通知が切れる1メッセージの上限超過本文をslice()で短縮/Embedのfieldsに分割
スパムが多いBotに狙われているTurnstile導入、RateLimit強化、言語フィルタ

8. リリース前チェックリスト


まとめ
GASだけで「保存+通知」を一気に作るのが最短。その後、WorkersやNodeに切り替えて権限とスケールを固めていくのが堅実です。
テスト用のメール受信は広告ゼロの一時メール tomy634.com を使うとスムーズです。
関連: QRコードの安全講座 / AIに任せてはいけない作業 / 1時間で作る個人サイト / 文字数カウンター / パスワード生成