NioShield Lite 改訂版 v2セットアップガイド

GASだけで使える、個人事業主向けの本番Liteフォーム生成ページです。設定パネルのURLコピー・連投基準・手動ブロック/許可リストを復活させた版です。

Lite版の位置づけ: この版は「簡単設置」を優先したGAS直送信の本番Lite版です。ハニーポット、送信速度、JSチェック、名前・メール・URL・HTMLタグ・繰り返し本文の判定を追加しています。ただしHTML内にGAS URLとsecret_tokenが入るため、強固な秘密鍵保護が必要な場合はWorker版を使ってください。

1スプレッドシートの準備

下記のボタンを押すと、新しい白紙のスプレッドシートが開きます。これが問い合わせログになります。

新しいスプレッドシートを作成する

2GAS本体の貼り付け・初期設定

1. スプレッドシートの【拡張機能】>【Apps Script】を開きます。

2. デフォルトのコードを全て削除し、以下のGASコードを丸ごと貼り付けて保存します。

/**
 * NioShield Lite 改訂版 v2 v2 マスターハブ(GAS直送信・本番Lite版)
 * ─────────────────────────────────────────────
 * 【ライセンス表記】
 * このコードの著作権は作成者に帰属します。
 * 第三者への再配布・転売・譲渡を禁じます。
 *
 * 【初回セットアップ】
 * 1. このコードを全部貼り付けて保存
 * 2. デプロイ → ウェブアプリとして公開(アクセスできるユーザー:全員)→ 表示されたURL(.../exec)をコピーしておく
 * 3. スプレッドシートのタブをリロード(または再度開く)
 * 4. メニュー「NioShield 設定」→「設定パネルを開く」(初回は権限の承認が必要)
 * 5. パネルに表示されたsecret_tokenをコピーし、手順2のURLと合わせてフォームコードに貼り付け
 *
 * 【複数サイトを1つのGASでまとめて管理したい場合】
 * 上記4の設定パネルからサイトを追加・編集できます。
 * 転送先1・2(任意)に入力すると複数メールアドレスに同時送信できます。
 * ─────────────────────────────────────────────
 */

// ▼あなたのフォームジェネレーターページの固定URLをここに設定してください(末尾の / なし)
// 未設定(空欄)の場合、設定パネルの「フォーム作成ページを開く」ボタンは表示されません。
var GENERATOR_URL = "https://tiny-sunset-8f31.afuugii.workers.dev";

var SHEET_NAME = "問い合わせログ";
var ERROR_SHEET_NAME = "エラーログ";
var BLOCK_LOG_SHEET_NAME = "ブロックログ";
var LOCK_TIMEOUT_MS = 30000;
var MIN_SECONDS_TO_SUBMIT = 3;
var MAX_URL_COUNT = 2;
var MIN_MESSAGE_LENGTH = 5;
var RATE_LIMIT_MINUTES = 10;
var EMAIL_LIMIT_COUNT = 3;
var IP_LIMIT_COUNT = 5;
var MESSAGE_LIMIT_COUNT = 2;
var TRUST_CLIENT_IP = false;
var DEFAULT_NG_WORDS = ["casino", "viagra", "porn", "seo service", "backlink", "guest post"];
var BLOCKED_SHORT_URL_DOMAINS = ["bit.ly", "tinyurl.com", "t.co", "goo.gl", "is.gd", "buff.ly", "ow.ly", "rebrand.ly", "cutt.ly", "shorturl.at"];
var BLOCKED_URL_TLDS = [".xyz", ".top", ".monster", ".click", ".cam", ".club", ".work"];
var BLOCKED_EMAIL_DOMAINS = ["example.com", "test.com", "aaa.com", "mailinator.com", "tempmail.com", "10minutemail.com", "guerrillamail.com"];
var MIN_NAME_LENGTH = 2;
var MAX_HOURS_TO_SUBMIT = 24;

function onOpen() {
  getOrCreateSheet(SHEET_NAME, ["日時","サイトID","名前","メール","電話","住所","件名","内容","送信元URL","リンク元URL","IPアドレス","管理者テスト"]);
  getOrCreateSheet(ERROR_SHEET_NAME, ["日時","ラベル","エラー内容","コンテキスト"]);
  getOrCreateSheet(BLOCK_LOG_SHEET_NAME, ["日時","理由","詳細","サイトID","メール","名前","本文","IPアドレス"]);
  SpreadsheetApp.getUi().createMenu("NioShield 設定").addItem("設定パネルを開く", "showSettingsDialog").addToUi();
}

function showSettingsDialog() {
  var initData = getDialogInitData();
  var html = HtmlService.createHtmlOutput(buildSettingsHtml(initData)).setWidth(440).setHeight(560);
  SpreadsheetApp.getUi().showModalDialog(html, "NioShield 設定パネル");
}

function ensureSecrets_() {
  var props = PropertiesService.getScriptProperties();
  if (!props.getProperty("API_SECRET_TOKEN")) {
    props.setProperties({
      "API_SECRET_TOKEN": Utilities.getUuid(),
      "ADMIN_TEST_TOKEN": Utilities.getUuid(),
      "CLIENT_CONFIG_JSON": JSON.stringify({ "default": { "email": Session.getActiveUser().getEmail() } })
    });
  }
  return props;
}

function getDialogInitData() {
  var props = ensureSecrets_();
  var siteIds = Object.keys(getClientConfig(props));
  var firstSiteId = siteIds.length > 0 ? siteIds[0] : "default";
  var webAppUrl = props.getProperty("WEB_APP_URL_MANUAL") || "";
  if (!webAppUrl) {
    try { webAppUrl = ScriptApp.getService().getUrl(); } catch (e) { webAppUrl = ""; }
  }
  return { secretToken: props.getProperty("API_SECRET_TOKEN"), webAppUrl: webAppUrl, generatorUrl: GENERATOR_URL, siteIds: siteIds, firstSiteId: firstSiteId, firstSiteConfig: getSiteSettings(props, firstSiteId) , manualLists: getManualLists(props) };
}

function saveWebAppUrl(url) {
  url = String(url || "").trim();
  if (!/^https:\/\/script\.google\.com\/macros\/s\/.+\/exec$/.test(url)) {
    throw new Error("正しいウェブアプリURL(.../exec)の形式ではありません。");
  }
  PropertiesService.getScriptProperties().setProperty("WEB_APP_URL_MANUAL", url);
  return "ウェブアプリURLを保存しました。";
}

function getManualLists(props) {
  var blockJson = props.getProperty("MANUAL_BLOCK_JSON");
  var allowJson = props.getProperty("MANUAL_ALLOW_JSON");
  return {
    block: blockJson ? JSON.parse(blockJson) : { emails: [], ips: [] },
    allow: allowJson ? JSON.parse(allowJson) : { emails: [], ips: [] }
  };
}

function parseListLines_(text) {
  return String(text || "").split(/[\n,]/).map(function(s) { return s.trim(); }).filter(Boolean);
}

function saveManualLists(blockEmailsText, blockIpsText, allowEmailsText, allowIpsText) {
  var block = {
    emails: parseListLines_(blockEmailsText).map(normalizeEmail),
    ips: parseListLines_(blockIpsText)
  };
  var allow = {
    emails: parseListLines_(allowEmailsText).map(normalizeEmail),
    ips: parseListLines_(allowIpsText)
  };
  var props = PropertiesService.getScriptProperties();
  props.setProperty("MANUAL_BLOCK_JSON", JSON.stringify(block));
  props.setProperty("MANUAL_ALLOW_JSON", JSON.stringify(allow));
  return "手動リストを保存しました。";
}

function getSiteConfigForUi(siteId) {
  return getSiteSettings(PropertiesService.getScriptProperties(), String(siteId || "default").trim());
}

function saveSiteConfigFromUi(siteId, email, email2, email3, japaneseCheck, ngWordCheck) {
  siteId = String(siteId || "").trim(); email = String(email || "").trim();
  email2 = String(email2 || "").trim(); email3 = String(email3 || "").trim();
  if (!siteId) throw new Error("サイトIDを入力してください。");
  if (!isEmail(email)) throw new Error("メール1の形式が正しくありません。");
  if (email2 && !isEmail(email2)) throw new Error("転送先1の形式が正しくありません。");
  if (email3 && !isEmail(email3)) throw new Error("転送先2の形式が正しくありません。");
  addOrUpdateSite(siteId, { email: email, email2: email2, email3: email3, japaneseCheck: !!japaneseCheck, ngWordCheck: !!ngWordCheck });
  return "「" + siteId + "」の設定を保存しました。";
}

function buildSettingsHtml(initData) {
  var d = initData || {};
  return `
<style>
  body { font-family: 'Helvetica Neue', Arial, sans-serif; padding: 14px; font-size: 14px; color:#1e293b; }
  .field { margin-bottom:16px; }
  label { display:block; font-weight:bold; margin-bottom:4px; font-size:12px; color:#334155; }
  select, input[type=text], input[type=email], textarea { width:100%; box-sizing:border-box; padding:8px; border:1px solid #cbd5e1; border-radius:4px; font-size:14px; }
  textarea { min-height: 64px; resize: vertical; }
  .readonly-row { display:flex; gap:6px; }
  .readonly-row input { background:#f1f5f9; color:#475569; }
  .btn-copy-mini { background:#e2e8f0; border:none; border-radius:4px; padding:0 12px; font-size:12px; cursor:pointer; flex-shrink:0; }
  .btn-copy-mini:hover { background:#cbd5e1; }
  .section-title { font-size:11px; font-weight:bold; color:#64748b; margin:18px 0 8px; text-transform:uppercase; letter-spacing:0.03em; }
  .toggle-row { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
  .toggle-label { font-weight:bold; font-size:13px; }
  .toggle-desc { font-size:11px; color:#64748b; margin-top:2px; }
  .switch { position:relative; display:inline-block; width:44px; height:24px; flex-shrink:0; margin-left:10px; }
  .switch input { opacity:0; width:0; height:0; }
  .slider { position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#cbd5e1; transition:.2s; border-radius:24px; }
  .slider:before { position:absolute; content:""; height:18px; width:18px; left:3px; bottom:3px; background-color:white; transition:.2s; border-radius:50%; }
  input:checked + .slider { background-color:#10b981; }
  input:checked + .slider:before { transform:translateX(20px); }
  .btn-save { width:100%; padding:10px; background:#2563eb; color:white; border:none; border-radius:6px; font-weight:bold; font-size:14px; cursor:pointer; margin-top:6px; }
  .btn-save:disabled { background:#cbd5e1; cursor:not-allowed; }
  .status { margin-top:10px; font-size:13px; text-align:center; min-height:18px; line-height:1.5; }
  .new-site-row { margin-top:8px; }
  .hint { font-size:11px; color:#94a3b8; margin-bottom:14px; line-height:1.6; }
  .note { background:#fff7ed; color:#9a3412; border:1px solid #fed7aa; border-radius:8px; padding:10px 12px; font-size:12px; line-height:1.6; margin-bottom:12px; }
  .rate-note { background:#f8fafc; color:#64748b; border:1px solid #e2e8f0; border-radius:8px; padding:10px 12px; font-size:12px; line-height:1.7; margin:12px 0 16px; }
  .ip-tool { background:#ecfdf5; border:1px solid #86efac; color:#166534; border-radius:8px; padding:10px 12px; font-size:12px; line-height:1.6; margin-bottom:12px; }
  .ip-tool button { margin-top:8px; background:#10b981; color:white; border:none; border-radius:6px; padding:7px 10px; font-weight:bold; cursor:pointer; }
  .ip-tool button:disabled { background:#94a3b8; cursor:not-allowed; }
</style>

<div class="note">
  Lite版では、このGAS URLとsecret_tokenをフォームHTMLに入れて使います。設置は簡単ですが、強固な秘密鍵保護が必要な場合はStandard版を使ってください。
</div>

<div class="section-title">接続情報(フォーム作成で使う値)</div>

<div class="field">
  <label>ウェブアプリURL(.../exec)</label>
  <div class="readonly-row">
    <input type="text" id="urlField" value="" placeholder="https://script.google.com/macros/s/.../exec">
    <button class="btn-copy-mini" onclick="saveUrlField()" style="background:#6366f1;color:white;">保存</button>
  </div>
  <div class="hint" style="margin-top:4px;">※【デプロイを管理】画面に表示されている、現在アクティブなURLを貼り付けて「保存」を押してください。</div>
  <div id="currentUrlRow" style="font-size:11px; color:#94a3b8; margin-top:6px; display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
    <span>
      現在保存されている値:
      <span id="currentUrlText" style="color:#334155;">${d.webAppUrl || '(未設定:先にGASをデプロイし、上に貼り付けてください)'}</span>
    </span>
    <button class="btn-copy-mini" id="currentUrlCopyBtn" onclick="copyCurrentUrl()" style="padding:1px 10px; ${d.webAppUrl ? '' : 'display:none;'}">コピー</button>
  </div>
  <div id="urlSaveStatus" style="font-size:11px; margin-top:4px;"></div>
</div>

<div class="field">
  <label>secret_token(フォーム作成で使う値)</label>
  <div class="readonly-row">
    <input type="text" id="tokenField" readonly value="${d.secretToken || ''}">
    <button class="btn-copy-mini" onclick="copyField('tokenField')">コピー</button>
  </div>
</div>

<div class="section-title">サイトごとのスパム判定設定</div>

<div class="field">
  <label for="siteSelect">サイトを選択</label>
  <select id="siteSelect"></select>
  <div class="new-site-row" id="newSiteRow" style="display:none;">
    <input type="text" id="newSiteId" placeholder="新しいサイトID(半角英数字・ハイフン・アンダーバー)">
  </div>
</div>

<div class="field">
  <label for="emailInput">送信先メールアドレス(メール1)</label>
  <input type="email" id="emailInput" placeholder="example@example.com">
</div>

<div class="field">
  <label for="emailInput2">転送先1(任意)</label>
  <input type="email" id="emailInput2" placeholder="クライアントのアドレスなど">
</div>

<div class="field">
  <label for="emailInput3">転送先2(任意)</label>
  <input type="email" id="emailInput3" placeholder="管理者用など">
</div>

<div class="toggle-row">
  <div>
    <div class="toggle-label">日本語必須チェック</div>
    <div class="toggle-desc">本文に日本語が1文字もない場合はブロック</div>
  </div>
  <label class="switch"><input type="checkbox" id="toggleJapanese"><span class="slider"></span></label>
</div>

<div class="toggle-row">
  <div>
    <div class="toggle-label">NGワード判定</div>
    <div class="toggle-desc">スパムによく使われる単語が含まれる場合はブロック</div>
  </div>
  <label class="switch"><input type="checkbox" id="toggleNgWord"><span class="slider"></span></label>
</div>

<button class="btn-save" id="saveBtn">保存する</button>
<div class="status" id="statusMsg"></div>

<div class="section-title">連投ブロックの基準(共通・自動)</div>
<div class="rate-note">
  10分以内に同じメールアドレスから3通目の送信があると、自動的にブロックされます。<br>
  また、同じ本文が短時間に繰り返し送られた場合もブロックされます。<br>
  Lite版ではIPアドレスはフォーム側の自己申告扱いになるため、IP判定は弱めです。強いIP判定が必要な場合はStandard版を使ってください。<br>
  これはスパムだけでなく、人間による嫌がらせ連投の抑止にも使えます。
</div>

<div class="section-title">手動ブロックリスト</div>
<div class="hint">自動判定には引っかからないが、受け取りたくない相手を指定できます。1行に1件、またはカンマ区切りで入力してください。</div>

<div class="field">
  <label>拒否したいメールアドレス</label>
  <textarea id="blockEmailsField" rows="3" placeholder="spam@example.com&#10;迷惑な相手@example.com"></textarea>
</div>

<div class="field">
  <label>拒否したいIPアドレス</label>
  <textarea id="blockIpsField" rows="2" placeholder="123.45.67.89"></textarea>
</div>

<div class="section-title">手動許可リスト(ホワイトリスト)</div>
<div class="hint">誤ってブロックされてしまった相手や、管理者テスト用のメール/IPを指定すると、以後すべてのスパム判定をスキップして必ず受信します。</div>

<div class="field">
  <label>常に許可するメールアドレス</label>
  <textarea id="allowEmailsField" rows="3" placeholder="important-client@example.com"></textarea>
</div>

<div class="field">
  <label>常に許可するIPアドレス</label>
  <textarea id="allowIpsField" rows="2" placeholder="123.45.67.89"></textarea>
</div>

<div class="ip-tool">
  管理者テストが弾かれる場合は、現在のIPアドレスを許可IPへ追加できます。<br>
  ※Lite版ではIPは強い本人確認には使えません。補助機能として使ってください。<br>
  <button type="button" id="addCurrentIpBtn" onclick="addCurrentIpToAllowList()">現在のIPを取得して許可IPへ追加</button>
  <div id="currentIpStatus" style="margin-top:6px;"></div>
</div>

<button class="btn-save" id="saveManualListsBtn" style="background:#6366f1;">手動リストを保存する</button>
<div class="status" id="manualListStatusMsg"></div>

<script>
  var SITE_NEW_VALUE = "__new__";

  function copyField(id) {
    document.getElementById(id).select();
    document.execCommand('copy');
  }

  function copyCurrentUrl() {
    var el = document.getElementById('currentUrlText');
    if (!el) return;

    var value = el.textContent.trim();
    if (!value || value.indexOf('未設定') !== -1) {
      alert('コピーできるURLがまだ保存されていません。');
      return;
    }

    var temp = document.createElement('textarea');
    temp.value = value;
    temp.style.position = 'fixed';
    temp.style.opacity = '0';
    document.body.appendChild(temp);
    temp.select();
    try { document.execCommand('copy'); } catch(e) {}
    document.body.removeChild(temp);
    alert('URLをコピーしました。');
  }

  function saveUrlField() {
    var url = document.getElementById('urlField').value.trim();
    var statusEl = document.getElementById('urlSaveStatus');

    statusEl.style.color = '#64748b';
    statusEl.textContent = '保存中...';

    google.script.run
      .withSuccessHandler(function(msg) {
        statusEl.style.color = '#10b981';
        statusEl.textContent = '✔ ' + msg + '(次回開いたときも、この値が使われます)';

        var textEl = document.getElementById('currentUrlText');
        if (textEl) {
          textEl.textContent = url;
          textEl.style.color = '#334155';
        }

        var copyBtn = document.getElementById('currentUrlCopyBtn');
        if (copyBtn) copyBtn.style.display = '';
      })
      .withFailureHandler(function(err) {
        statusEl.style.color = '#ef4444';
        statusEl.textContent = '❌ ' + (err && err.message ? err.message : err);
      })
      .saveWebAppUrl(url);
  }

  function appendUniqueLine(textareaId, value) {
    var el = document.getElementById(textareaId);
    var v = String(value || '').trim();
    if (!v) return;

    var lines = String(el.value || '').split(/[\\n,]/).map(function(s) { return s.trim(); }).filter(Boolean);

    if (lines.indexOf(v) === -1) {
      lines.push(v);
    }

    el.value = lines.join('\\n');
  }

  function addCurrentIpToAllowList() {
    var btn = document.getElementById('addCurrentIpBtn');
    var status = document.getElementById('currentIpStatus');

    btn.disabled = true;
    status.style.color = '#64748b';
    status.textContent = 'IPを取得中...';

    fetch('https://api.ipify.org?format=json')
      .then(function(r) { return r.json(); })
      .then(function(d) {
        var ip = d && d.ip ? d.ip : '';
        if (!ip) throw new Error('IPを取得できませんでした');

        appendUniqueLine('allowIpsField', ip);
        status.style.color = '#10b981';
        status.textContent = '✔ 現在のIPを許可IPに追加しました:' + ip + ' 最後に「手動リストを保存する」を押してください。';
        btn.disabled = false;
      })
      .catch(function(err) {
        status.style.color = '#ef4444';
        status.textContent = '❌ IPを取得できませんでした。必要なら手入力してください。';
        btn.disabled = false;
      });
  }

  function applyInitData(data) {
    var select = document.getElementById('siteSelect');
    select.innerHTML = '';

    data.siteIds.forEach(function(id) {
      var opt = document.createElement('option');
      opt.value = id;
      opt.text = id;
      select.appendChild(opt);
    });

    var newOpt = document.createElement('option');
    newOpt.value = SITE_NEW_VALUE;
    newOpt.text = '+ 新しいサイトを追加';
    select.appendChild(newOpt);

    select.value = data.firstSiteId;
    fillSiteForm(data.firstSiteConfig);

    if (data.manualLists) {
      document.getElementById('blockEmailsField').value = (data.manualLists.block.emails || []).join('\\n');
      document.getElementById('blockIpsField').value = (data.manualLists.block.ips || []).join('\\n');
      document.getElementById('allowEmailsField').value = (data.manualLists.allow.emails || []).join('\\n');
      document.getElementById('allowIpsField').value = (data.manualLists.allow.ips || []).join('\\n');
    }
  }

  function fillSiteForm(cfg) {
    document.getElementById('emailInput').value = cfg.email || '';
    document.getElementById('emailInput2').value = cfg.email2 || '';
    document.getElementById('emailInput3').value = cfg.email3 || '';
    document.getElementById('toggleJapanese').checked = !!cfg.japaneseCheck;
    document.getElementById('toggleNgWord').checked = !!cfg.ngWordCheck;
  }

  document.getElementById('siteSelect').addEventListener('change', function() {
    var val = this.value;
    var newRow = document.getElementById('newSiteRow');

    if (val === SITE_NEW_VALUE) {
      newRow.style.display = 'block';
      document.getElementById('newSiteId').value = '';
      fillSiteForm({ email:'', email2:'', email3:'', japaneseCheck:true, ngWordCheck:true });
    } else {
      newRow.style.display = 'none';
      google.script.run.withSuccessHandler(fillSiteForm).getSiteConfigForUi(val);
    }
  });

  document.getElementById('saveBtn').addEventListener('click', function() {
    var select = document.getElementById('siteSelect');
    var siteId = select.value === SITE_NEW_VALUE ? document.getElementById('newSiteId').value.trim() : select.value;
    var email = document.getElementById('emailInput').value.trim();
    var email2 = document.getElementById('emailInput2').value.trim();
    var email3 = document.getElementById('emailInput3').value.trim();
    var japaneseCheck = document.getElementById('toggleJapanese').checked;
    var ngWordCheck = document.getElementById('toggleNgWord').checked;
    var statusMsg = document.getElementById('statusMsg');
    var btn = this;

    btn.disabled = true;
    statusMsg.style.color = '#64748b';
    statusMsg.innerText = '保存中...';

    google.script.run
      .withSuccessHandler(function(msg) {
        statusMsg.style.color = '#10b981';
        statusMsg.innerText = msg;
        btn.disabled = false;
        google.script.run.withSuccessHandler(applyInitData).getDialogInitData();
      })
      .withFailureHandler(function(err) {
        statusMsg.style.color = '#ef4444';
        statusMsg.innerText = 'エラー: ' + (err && err.message ? err.message : err);
        btn.disabled = false;
      })
      .saveSiteConfigFromUi(siteId, email, email2, email3, japaneseCheck, ngWordCheck);
  });

  document.getElementById('saveManualListsBtn').addEventListener('click', function() {
    var statusMsg = document.getElementById('manualListStatusMsg');
    var btn = this;

    btn.disabled = true;
    statusMsg.style.color = '#64748b';
    statusMsg.innerText = '保存中...';

    google.script.run
      .withSuccessHandler(function(msg) {
        statusMsg.style.color = '#10b981';
        statusMsg.innerText = msg;
        btn.disabled = false;
      })
      .withFailureHandler(function(err) {
        statusMsg.style.color = '#ef4444';
        statusMsg.innerText = 'エラー: ' + (err && err.message ? err.message : err);
        btn.disabled = false;
      })
      .saveManualLists(
        document.getElementById('blockEmailsField').value,
        document.getElementById('blockIpsField').value,
        document.getElementById('allowEmailsField').value,
        document.getElementById('allowIpsField').value
      );
  });

  applyInitData(${JSON.stringify(d)});
</script>
`;
}

function setup() { ensureSecrets_(); }
function showMyConfig() {
  var props = PropertiesService.getScriptProperties();
  console.log("secret_token: " + props.getProperty("API_SECRET_TOKEN"));
  console.log("admin_token: " + props.getProperty("ADMIN_TEST_TOKEN"));
  console.log(props.getProperty("CLIENT_CONFIG_JSON"));
}

function addOrUpdateSite(siteId, options) {
  var props = PropertiesService.getScriptProperties();
  var json = props.getProperty("CLIENT_CONFIG_JSON");
  var configMap = json ? JSON.parse(json) : { "default": { "email": "" } };
  configMap[siteId] = configMap[siteId] || {}; options = options || {};
  if (typeof options.email === "string") configMap[siteId].email = options.email;
  if (typeof options.email2 === "string") configMap[siteId].email2 = String(options.email2 || "").trim();
  if (typeof options.email3 === "string") configMap[siteId].email3 = String(options.email3 || "").trim();
  if (typeof options.japaneseCheck === "boolean") configMap[siteId].japaneseCheck = options.japaneseCheck;
  if (typeof options.ngWordCheck === "boolean") configMap[siteId].ngWordCheck = options.ngWordCheck;
  if (options.extraNgWords) configMap[siteId].extraNgWords = options.extraNgWords;
  if (options.ngWordExceptions) configMap[siteId].ngWordExceptions = options.ngWordExceptions;
  props.setProperty("CLIENT_CONFIG_JSON", JSON.stringify(configMap));
}

function doPost(e) {
  var lock = LockService.getScriptLock(); var gotLock = false;
  try {
    if (!e || !e.postData || !e.postData.contents) return textResponse("No data received");
    gotLock = lock.tryLock(LOCK_TIMEOUT_MS);
    if (!gotLock) return textResponse("Busy");
    var data;
    try { data = JSON.parse(e.postData.contents); } catch (err) { logBlock("Invalid JSON", {}, "JSON解析失敗"); return textResponse("Invalid data"); }
    var props = PropertiesService.getScriptProperties();
    var apiSecret = props.getProperty("API_SECRET_TOKEN");
    if (!apiSecret || data.secret_token !== apiSecret) { logBlock("Forbidden", data, "secret_token不一致"); return textResponse("Forbidden"); }
    var adminToken = props.getProperty("ADMIN_TEST_TOKEN");
    var isAdmin = !!adminToken && data.admin_token === adminToken;
    var now = new Date();
    var siteIdRaw = normalizeSiteId(data.site_id || "default");
    var siteId = sanitizeForSheet(siteIdRaw || "default");
    var userName = sanitizeForSheet(String(data.name || "未入力").trim());
    var userPhone = sanitizeForSheet(String(data.phone || "未入力").trim());
    var userAddress = sanitizeForSheet(String(data.address || "未入力").trim());
    var userSubject = sanitizeForSheet(String(data.subject || "").trim() || "未入力");
    var userMessageRaw = String(data.message || "").trim();
    var userMessage = sanitizeForSheet(userMessageRaw || "未入力");
    var locationUrl = sanitizeForSheet(String(data.location_url || "").trim() || "不明");
    var referrerUrl = sanitizeForSheet(String(data.referrer_url || "").trim() || "(直接アクセス/不明)");
    var userEmailRaw = String(data.email || "").trim();
    var isValidEmail = isEmail(userEmailRaw);
    var userEmail = isValidEmail ? userEmailRaw : "";
    var displayEmail = sanitizeForSheet(userEmailRaw || "未入力");
    var clientIpRaw = String(data.client_ip || "取得失敗").trim();
    var clientIp = sanitizeForSheet("(自己申告/未検証) " + clientIpRaw);
    var siteSettings = getSiteSettings(props, siteIdRaw);
    var recipients = buildRecipientList(siteSettings, siteId);
    if (recipients.length === 0) throw new Error("送信先メールが未設定 site_id: " + siteIdRaw);
    if (!isAdmin) {
      var spamResult = checkSpam(data, { siteId: siteId, userEmailRaw: userEmailRaw, userEmail: userEmail, userMessageRaw: userMessageRaw, clientIpRaw: clientIpRaw, now: now }, siteSettings);
      if (spamResult.blocked) { logBlock(spamResult.reason, data, spamResult.detail); return textResponse("Success"); }
    }
    var targetEmail = recipients.join(",");
    var sheet = getOrCreateSheet(SHEET_NAME, ["日時","サイトID","名前","メール","電話","住所","件名","内容","送信元URL","リンク元URL","IPアドレス","管理者テスト"]);
    sheet.appendRow([now, siteId, userName, displayEmail, userPhone, userAddress, userSubject, userMessage, locationUrl, referrerUrl, clientIp, isAdmin ? "YES" : ""]);
    try { lock.releaseLock(); gotLock = false; } catch (e) {}
    var mailTitle = "【Webサイト新着】お問い合わせ(" + userName + "様)";
    if (isAdmin) mailTitle = "【管理者テスト】" + mailTitle;
    var mailBody = "ウェブサイトからお問い合わせがありました。\n\n" +
      "■サイトID: " + siteId + "\n■送信元URL: " + locationUrl + "\n■リンク元URL: " + referrerUrl + "\n■件名: " + userSubject + "\n" +
      "■お名前: " + userName + "\n■メール: " + displayEmail + "\n■電話番号: " + userPhone + "\n" +
      "■ご住所: " + userAddress + "\n■内容:\n" + userMessage + "\n\n■IPアドレス: " + clientIp + "\n\n" +
      "※このメールに返信すると、お客様へ直接返信できます。";
    if (!isValidEmail) mailBody += "\n\n※入力されたメールアドレスの形式が無効なため、返信先は設定されていません。";
    try { var mailOptions = {}; if (isValidEmail) mailOptions.replyTo = userEmail; GmailApp.sendEmail(targetEmail, mailTitle, mailBody, mailOptions); }
    catch (err) { logError("メール送信失敗", err, { siteId: siteId, targetEmail: targetEmail }); return textResponse("Error"); }
    return textResponse("Success");
  } catch (error) { logError("doPost エラー", error, {}); return textResponse("Error"); }
  finally { if (gotLock) { try { lock.releaseLock(); } catch (e2) {} } }
}

function checkSpam(data, info, siteSettings) {
  var manual = getManualLists(PropertiesService.getScriptProperties());
  var emailNorm = normalizeEmail(info.userEmailRaw);
  var ipRaw = String(info.clientIpRaw || "").trim();

  if ((emailNorm && manual.allow.emails.indexOf(emailNorm) !== -1) || (ipRaw && manual.allow.ips.indexOf(ipRaw) !== -1)) {
    return { blocked: false, reason: "", detail: "" };
  }

  if ((emailNorm && manual.block.emails.indexOf(emailNorm) !== -1) || (ipRaw && manual.block.ips.indexOf(ipRaw) !== -1)) {
    return { blocked: true, reason: "Manual Block", detail: "手動ブロックリストに一致" };
  }


  var emailNorm = normalizeEmail(info.userEmailRaw);
  var nameRaw = String(data.name || "").trim();
  var message = String(info.userMessageRaw || "").trim();
  var subject = String(data.subject || "").trim();
  var joinedText = [nameRaw, subject, message].join("\n");

  // JSが動いているフォームから送られているか確認
  // 雑なbotはHTMLだけ見て直接POSTするため、ここで落とせることがあります。
  if (String(data.js_enabled || "") !== "yes") {
    return { blocked: true, reason: "JS Check Failed", detail: "js_enabledなし" };
  }

  // ハニーポット
  if (data.website_dummy && String(data.website_dummy).trim() !== "") {
    return { blocked: true, reason: "Honeypot", detail: "隠し項目に値あり" };
  }

  // 送信時間チェック:早すぎ・古すぎ
  var startedAt = Number(data.form_started_at || 0);
  var nowMs = new Date().getTime();
  if (startedAt > 0) {
    var elapsed = (nowMs - startedAt) / 1000;
    if (elapsed >= 0 && elapsed < MIN_SECONDS_TO_SUBMIT) {
      return { blocked: true, reason: "Too Fast", detail: elapsed + "秒" };
    }
    if (elapsed > MAX_HOURS_TO_SUBMIT * 60 * 60) {
      return { blocked: true, reason: "Too Old", detail: Math.round(elapsed / 3600) + "時間" };
    }
  }

  // 名前チェック
  var nameResult = checkNameSpam(nameRaw);
  if (nameResult.blocked) {
    return nameResult;
  }

  // メールチェック
  var emailResult = checkEmailSpam(info.userEmailRaw);
  if (emailResult.blocked) {
    return emailResult;
  }

  // 本文の最低文字数
  if (message.length < MIN_MESSAGE_LENGTH) {
    return { blocked: true, reason: "Too Short", detail: "本文が短すぎます" };
  }

  // 日本語チェック
  if (siteSettings.japaneseCheck && !/[ぁ-んァ-ン一-龥]/.test(message)) {
    return { blocked: true, reason: "No Japanese", detail: "日本語なし" };
  }

  // URL数チェック
  var urlCount = countUrls(message);
  if (urlCount > MAX_URL_COUNT) {
    return { blocked: true, reason: "Too Many URLs", detail: "URL数: " + urlCount };
  }

  // 短縮URL・怪しいTLD
  var urlRisk = checkUrlRisk(joinedText);
  if (urlRisk.blocked) {
    return urlRisk;
  }

  // HTML/スクリプト系
  var htmlRisk = checkDangerousHtml(joinedText);
  if (htmlRisk.blocked) {
    return htmlRisk;
  }

  // 同じ文字・同じ単語の繰り返し
  var repeatRisk = checkRepeatedText(message);
  if (repeatRisk.blocked) {
    return repeatRisk;
  }

  // NGワード
  if (siteSettings.ngWordCheck) {
    var ngWordList = DEFAULT_NG_WORDS
      .filter(function(w) { return siteSettings.ngWordExceptions.indexOf(w) === -1; })
      .concat(siteSettings.extraNgWords || []);
    var ngResult = containsNgWord(message, ngWordList);
    if (ngResult.hit) {
      return { blocked: true, reason: "NG Word", detail: ngResult.word };
    }
  }

  // 連投チェック
  var rateResult = checkRateLimit(info);
  if (rateResult.blocked) {
    return rateResult;
  }

  return { blocked: false, reason: "", detail: "" };
}

function checkRateLimit(info) {
  var sheet = getOrCreateSheet(SHEET_NAME, ["日時","サイトID","名前","メール","電話","住所","件名","内容","送信元URL","リンク元URL","IPアドレス","管理者テスト"]);
  var lastRow = sheet.getLastRow(); if (lastRow <= 1) return { blocked: false };
  var now = info.now || new Date(); var since = new Date(now.getTime() - RATE_LIMIT_MINUTES * 60 * 1000);
  var startRow = Math.max(2, lastRow - 299); var numRows = lastRow - startRow + 1;
  if (numRows <= 0) return { blocked: false };
  var values = sheet.getRange(startRow, 1, numRows, 12).getValues();
  var emailCount = 0; var ipCount = 0; var msgCount = 0;
  var curSiteId = String(info.siteId || "").trim();
  var curEmail = normalizeEmail(info.userEmailRaw);
  var curIp = String(info.clientIpRaw || "").trim();
  var curMsgHash = simpleHash(String(info.userMessageRaw || "").trim());
  for (var i = 0; i < values.length; i++) {
    var rowDate = values[i][0]; if (!(rowDate instanceof Date) || rowDate < since) continue;
    if (String(values[i][1] || "").trim() !== curSiteId) continue;
    var rowEmail = normalizeEmail(values[i][3]);
    var rowMsg = String(values[i][7] || "").trim();
    var rowIp = String(values[i][10] || "").replace("(自己申告/未検証) ", "").trim();
    if (curEmail && rowEmail && curEmail === rowEmail) emailCount++;
    if (TRUST_CLIENT_IP && curIp && rowIp && curIp === rowIp) ipCount++;
    if (curMsgHash && simpleHash(rowMsg) === curMsgHash) msgCount++;
  }
  if (curEmail && emailCount >= EMAIL_LIMIT_COUNT - 1) return { blocked: true, reason: "Email Rate Limit", detail: "連投" };
  if (TRUST_CLIENT_IP && curIp && ipCount >= IP_LIMIT_COUNT - 1) return { blocked: true, reason: "IP Rate Limit", detail: "連投" };
  if (curMsgHash && msgCount >= MESSAGE_LIMIT_COUNT - 1) return { blocked: true, reason: "Message Rate Limit", detail: "連投" };
  return { blocked: false };
}

function getSiteSettings(props, siteIdRaw) {
  var configMap = getClientConfig(props); var def = configMap["default"] || {}; var site = configMap[siteIdRaw] || {};
  return {
    email: site.email || def.email || "", email2: site.email2 || def.email2 || "", email3: site.email3 || def.email3 || "",
    japaneseCheck: typeof site.japaneseCheck === "boolean" ? site.japaneseCheck : (typeof def.japaneseCheck === "boolean" ? def.japaneseCheck : true),
    ngWordCheck: typeof site.ngWordCheck === "boolean" ? site.ngWordCheck : (typeof def.ngWordCheck === "boolean" ? def.ngWordCheck : true),
    extraNgWords: site.extraNgWords || def.extraNgWords || [], ngWordExceptions: site.ngWordExceptions || def.ngWordExceptions || []
  };
}
function getClientConfig(props) { var json = props.getProperty("CLIENT_CONFIG_JSON"); if (!json) return { "default": { "email": "" } }; try { return JSON.parse(json); } catch (e) { return { "default": { "email": "" } }; } }
function buildRecipientList(siteSettings, siteId) {
  var recipients = []; if (isEmail(siteSettings.email)) recipients.push(siteSettings.email);
  [siteSettings.email2, siteSettings.email3].forEach(function(addr, idx) { if (!addr) return; if (isEmail(addr)) recipients.push(addr); else logError("転送先メール形式不正", new Error("invalid"), { siteId: siteId, field: idx === 0 ? "email2" : "email3", value: addr }); });
  return recipients;
}
function getOrCreateSheet(name, headerRow) { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName(name); if (!sheet) { sheet = ss.insertSheet(name); sheet.appendRow(headerRow); } return sheet; }
function sanitizeForSheet(value) { var str = String(value); if (/^[=+\-@]/.test(str)) return "'" + str; return str; }
function isEmail(value) { var email = String(value || "").trim(); if (!email) return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }
function normalizeEmail(value) { var email = String(value || "").trim().toLowerCase(); if (email.charAt(0) === "'") email = email.substring(1); return email; }
function countUrls(text) { var matches = String(text || "").match(/https?:\/\/|www\./gi); return matches ? matches.length : 0; }
function containsNgWord(text, ngWords) { var str = String(text || ""); for (var i = 0; i < ngWords.length; i++) { var word = String(ngWords[i] || "").trim(); if (!word) continue; if (new RegExp("\\b" + escapeRegExp(word) + "\\b", "i").test(str)) return { hit: true, word: word }; } return { hit: false, word: "" }; }
function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function simpleHash(text) { var str = String(text || "").trim(); if (!str) return ""; var hash = 0; for (var i = 0; i < str.length; i++) { hash = ((hash << 5) - hash) + str.charCodeAt(i); hash = hash & hash; } return String(hash); }

function normalizeSiteId(value) {
  var siteId = String(value || "default").trim();
  siteId = siteId.replace(/[^a-zA-Z0-9_-]/g, "");
  if (!siteId) siteId = "default";
  return siteId.slice(0, 50);
}

function checkNameSpam(name) {
  var n = String(name || "").trim();

  if (n.length < MIN_NAME_LENGTH) {
    return { blocked: true, reason: "Name Too Short", detail: "名前が短すぎます" };
  }

  if (/https?:\/\/|www\./i.test(n)) {
    return { blocked: true, reason: "Name Contains URL", detail: "名前欄にURL" };
  }

  if (/^[a-zA-Z0-9_.-]+$/.test(n) && !/[ぁ-んァ-ン一-龥]/.test(n)) {
    return { blocked: true, reason: "Name Looks Random", detail: "英数字のみの名前" };
  }

  if (/(.)\1{5,}/.test(n)) {
    return { blocked: true, reason: "Name Repeated Characters", detail: "名前欄に同一文字連続" };
  }

  return { blocked: false, reason: "", detail: "" };
}

function checkEmailSpam(email) {
  var raw = String(email || "").trim().toLowerCase();

  if (!raw) {
    return { blocked: true, reason: "Email Missing", detail: "メール未入力" };
  }

  if (!isEmail(raw)) {
    return { blocked: true, reason: "Invalid Email", detail: "メール形式不正" };
  }

  var parts = raw.split("@");
  var local = parts[0] || "";
  var domain = parts[1] || "";

  if (local.length < 3) {
    return { blocked: true, reason: "Email Local Too Short", detail: "メールの@前が短すぎます" };
  }

  if (BLOCKED_EMAIL_DOMAINS.indexOf(domain) !== -1) {
    return { blocked: true, reason: "Blocked Email Domain", detail: domain };
  }

  if (/^(test|admin|info|sample|aaa|asdf|qwerty|no-reply|noreply)$/i.test(local)) {
    return { blocked: true, reason: "Suspicious Email Local", detail: local };
  }

  if (/(.)\1{5,}/.test(local)) {
    return { blocked: true, reason: "Email Repeated Characters", detail: local };
  }

  return { blocked: false, reason: "", detail: "" };
}

function checkUrlRisk(text) {
  var s = String(text || "").toLowerCase();

  for (var i = 0; i < BLOCKED_SHORT_URL_DOMAINS.length; i++) {
    var domain = BLOCKED_SHORT_URL_DOMAINS[i];
    var re = new RegExp("(^|[^a-z0-9.-])" + escapeRegExp(domain) + "([^a-z0-9.-]|$)", "i");
    if (re.test(s)) {
      return { blocked: true, reason: "Short URL", detail: domain };
    }
  }

  for (var j = 0; j < BLOCKED_URL_TLDS.length; j++) {
    var tld = BLOCKED_URL_TLDS[j];
    if (s.indexOf(tld + "/") !== -1 || s.indexOf(tld + " ") !== -1 || s.slice(-tld.length) === tld) {
      return { blocked: true, reason: "Suspicious URL TLD", detail: tld };
    }
  }

  return { blocked: false, reason: "", detail: "" };
}

function checkDangerousHtml(text) {
  var s = String(text || "").toLowerCase();

  var patterns = [
    "<script",
    "</script",
    "<iframe",
    "</iframe",
    "javascript:",
    "onclick=",
    "onerror=",
    "onload=",
    "<a href",
    "<img",
    "document.cookie"
  ];

  for (var i = 0; i < patterns.length; i++) {
    if (s.indexOf(patterns[i]) !== -1) {
      return { blocked: true, reason: "Dangerous HTML", detail: patterns[i] };
    }
  }

  return { blocked: false, reason: "", detail: "" };
}

function checkRepeatedText(text) {
  var s = String(text || "").trim();

  if (!s) {
    return { blocked: false, reason: "", detail: "" };
  }

  if (/(.)\1{7,}/.test(s)) {
    return { blocked: true, reason: "Repeated Characters", detail: "同一文字が連続" };
  }

  var normalized = s.replace(/\s+/g, "");
  if (normalized.length >= 12) {
    var chars = {};
    for (var i = 0; i < normalized.length; i++) {
      chars[normalized.charAt(i)] = true;
    }
    var uniqueCount = Object.keys(chars).length;
    if (uniqueCount <= 3) {
      return { blocked: true, reason: "Low Variety Text", detail: "文字の種類が少なすぎます" };
    }
  }

  var repeatWordMatch = s.match(/(.{2,10})\1{3,}/);
  if (repeatWordMatch) {
    return { blocked: true, reason: "Repeated Phrase", detail: repeatWordMatch[1] };
  }

  return { blocked: false, reason: "", detail: "" };
}

function textResponse(text) { return ContentService.createTextOutput(text).setMimeType(ContentService.MimeType.TEXT); }
function logError(label, error, context) { try { console.error(label, String(error), JSON.stringify(context || {})); } catch (e) {} try { var sheet = getOrCreateSheet(ERROR_SHEET_NAME, ["日時","ラベル","エラー内容","コンテキスト"]); sheet.appendRow([new Date(), label, String(error), JSON.stringify(context || {})]); } catch (e2) {} }
function logBlock(reason, data, detail) { try { var sheet = getOrCreateSheet(BLOCK_LOG_SHEET_NAME, ["日時","理由","詳細","サイトID","メール","名前","本文","IPアドレス"]); sheet.appendRow([new Date(), reason, detail || "", sanitizeForSheet(data.site_id || ""), sanitizeForSheet(data.email || ""), sanitizeForSheet(data.name || ""), sanitizeForSheet(data.message || ""), sanitizeForSheet(data.client_ip || "")]); } catch (e) {} }

3. 【デプロイ】>【新しいデプロイ】をクリックします。

4. 種類は「ウェブアプリ」、アクセスできるユーザーは「全員」にしてデプロイします。

5. 発行されたウェブアプリURL(.../exec)をコピーしておきます。

重要:コード変更後は「新しいデプロイ」ではなく、「デプロイを管理」→鉛筆アイコン→「新バージョン」で更新してください。

3設定パネルで送信先を設定

1. スプレッドシートのタブをリロードします。

2. 上部メニューの「NioShield 設定」>「設定パネルを開く」を押します。

3. ウェブアプリURLsecret_tokenを確認します。

4. 送信先メールアドレス、転送先、日本語チェック、NGワード判定などを設定して保存します。

設定パネルの「フォーム作成ページを開く」から来た場合は、GAS URLとsecret_tokenが下のジェネレーターに自動入力されます。

4フォームジェネレーター

GAS URLとsecret_tokenを入力し、フォームを生成してください。

※URLに ?site_id=xxx がある場合は、その値を優先できます。

フォームのカスタマイズ

#10b981
#ffffff