[Browser Form]
| fetch(JSON)
v
[Google Apps Script Web App] --- append ---> [Google Sheets]
| (問い合わせ台帳)
+-- UrlFetchApp.fetch() --> [Discord Webhook]
この形だと無料枠で運用でき、VPSなし・DBなしで始められます。
あなたの既存デザインに合わせて、最低限のバリデーション+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>
Googleドライブで Googleスプレッドシート を作成(例:Contact Inbox)。1行目に次のヘッダーを入れます。
timestamp, need_reply, email, title, body, ip, ua, ref, id
スプレッドシート(拡張機能→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などからプロキシする方法に切り替えてください。
装飾したい場合は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)});
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アカウント限定」にできるのが利点。
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)を忘れずに。
| 現象 | 原因 | 対処 |
|---|---|---|
| 415 Unsupported Media Type | Content-TypeがJSONでない | fetchのヘッダにapplication/json、JSON.stringify()で送る |
| 405 Method Not Allowed | GETで叩いている | POST限定にする/フォーム側を見直す |
| GASがタイムアウト | 通知や外部呼び出しが遅い | 保存→通知を非同期化/通知のみ別Webhookへ |
| Discord通知が切れる | 1メッセージの上限超過 | 本文をslice()で短縮/Embedのfieldsに分割 |
| スパムが多い | Botに狙われている | Turnstile導入、RateLimit強化、言語フィルタ |