スプレッドシートの入力ミスを公開前に発見|GASでルール表をdry-run監査

スプレッドシートの入力ミスを公開前に発見|GASでルール表をdry-run監査

ツールおじ|GoogleアカウントをAIエージェント化

ツールおじ|GoogleアカウントをAIエージェント化

商品公開や申込受付に使う管理表で、「必須欄が空いていた」「状態の表記が増えて集計から漏れた」「日付が文字列になっていた」と公開後に気づくことがあります。この記事では、元シートを書き換えず、別シートに定義した入力ルールと実データを照合する無料GASを紹介します。

値そのものはログへ出さず、行番号・列名・確認理由だけを表示します。まずコピー環境と少量のサンプルで実行し、ルールが実務に合うかを確認してください。

先に知っておきたいこと

  • 対象はGoogleスプレッドシート内の表形式データです。
  • 必須、候補リスト、日付、数値の4種類を確認できます。
  • 元データの修正、削除、並べ替え、入力規則の追加は行いません。
  • メール送信、外部API接続、ファイル共有、トリガー作成も行いません。
  • ログには入力値や管理IDを出さず、件数と位置だけを出します。
  • 「OK」はこの記事で定義した形式条件を満たしたという意味です。公開内容の正確性、法的な表示、価格、契約条件などは判断しません。

準備する2つのシート

同じスプレッドシートに、公開前の作業を並べる公開準備シートと、確認条件を書く入力ルールシートを用意します。実データではなく、最初はコピーしたファイルで試してください。

公開準備シートの1行目は次の見出しにします。

  • 管理ID
  • 項目名
  • 状態
  • 公開予定日
  • 予定価格

入力ルールシートの1行目は次の見出しにします。

  • 列名
  • 種類
  • 必須
  • 候補

2行目以降には、たとえば次のルールを入れます。

  • 項目名 / TEXT / TRUE / 空欄
  • 状態 / LIST / TRUE / 下書き,確認中,公開準備完了
  • 公開予定日 / DATE / TRUE / 空欄
  • 予定価格 / NUMBER / FALSE / 空欄

種類はTEXT、LIST、DATE、NUMBERの4つだけです。LISTの候補は半角カンマで区切ります。必須はTRUEまたはFALSEで入力します。管理IDが空欄の行は未使用行として監査対象から外します。

コピーして使える無料コード

function auditInputRulesFree() {
  const config = {
    dataSheetName: '公開準備',
    ruleSheetName: '入力ルール',
    idHeader: '管理ID',
    maxRows: 200,
    maxIssues: 50,
    dryRun: true,
  };

  if (config.dryRun !== true) {
    throw new Error('安全のためdryRun=trueで実行してください。');
  }

  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const dataSheet = ss.getSheetByName(config.dataSheetName);
  const ruleSheet = ss.getSheetByName(config.ruleSheetName);
  if (!dataSheet || !ruleSheet) {
    throw new Error('公開準備または入力ルールシートが見つかりません。');
  }

  const dataLastRow = dataSheet.getLastRow();
  const dataLastColumn = dataSheet.getLastColumn();
  const ruleLastRow = ruleSheet.getLastRow();
  const ruleLastColumn = ruleSheet.getLastColumn();
  if (dataLastRow < 1 || dataLastColumn < 1) {
    throw new Error('公開準備シートに見出しがありません。');
  }
  if (ruleLastRow < 2 || ruleLastColumn < 4) {
    throw new Error('入力ルールシートに確認条件がありません。');
  }
  if (dataLastRow - 1 > config.maxRows) {
    throw new Error('対象が上限を超えています。コピー環境で分割してください。');
  }

  const data = dataSheet
    .getRange(1, 1, dataLastRow, dataLastColumn)
    .getValues();
  const ruleData = ruleSheet
    .getRange(1, 1, ruleLastRow, ruleLastColumn)
    .getValues();

  const dataHeaders = normalizeHeaders_(data[0]);
  const ruleHeaders = normalizeHeaders_(ruleData[0]);
  assertUniqueHeaders_(dataHeaders, '公開準備');
  assertUniqueHeaders_(ruleHeaders, '入力ルール');

  const requiredRuleHeaders = ['列名', '種類', '必須', '候補'];
  const missingRuleHeaders = requiredRuleHeaders.filter(
    header => !ruleHeaders.includes(header)
  );
  if (missingRuleHeaders.length > 0) {
    throw new Error('入力ルールの見出し不足: ' + missingRuleHeaders.join(','));
  }

  const idColumn = dataHeaders.indexOf(config.idHeader);
  if (idColumn < 0) {
    throw new Error('管理ID列が見つかりません。');
  }

  const ruleColumn = Object.fromEntries(
    requiredRuleHeaders.map(header => [header, ruleHeaders.indexOf(header)])
  );
  const rules = [];
  const seenColumns = new Set();

  for (let rowIndex = 1; rowIndex < ruleData.length; rowIndex++) {
    const row = ruleData[rowIndex];
    const columnName = String(row[ruleColumn['列名']] ?? '').trim();
    const type = String(row[ruleColumn['種類']] ?? '').trim().toUpperCase();
    const requiredText = String(row[ruleColumn['必須']] ?? '').trim().toUpperCase();
    const optionText = String(row[ruleColumn['候補']] ?? '').trim();
    if (!columnName && !type && !requiredText && !optionText) continue;

    if (!columnName || !type || !['TRUE', 'FALSE'].includes(requiredText)) {
      throw new Error('入力ルールの' + (rowIndex + 1) + '行目を確認してください。');
    }
    if (!['TEXT', 'LIST', 'DATE', 'NUMBER'].includes(type)) {
      throw new Error('未対応の種類です: ' + type);
    }
    if (seenColumns.has(columnName)) {
      throw new Error('入力ルールの列名が重複しています: ' + columnName);
    }

    const dataColumn = dataHeaders.indexOf(columnName);
    if (dataColumn < 0) {
      throw new Error('公開準備に列がありません: ' + columnName);
    }

    const options = optionText
      ? optionText.split(',').map(value => value.trim()).filter(Boolean)
      : [];
    if (type === 'LIST' && options.length === 0) {
      throw new Error('LISTには候補が必要です: ' + columnName);
    }
    if (new Set(options).size !== options.length) {
      throw new Error('候補が重複しています: ' + columnName);
    }

    rules.push({
      columnName,
      dataColumn,
      type,
      required: requiredText === 'TRUE',
      options,
    });
    seenColumns.add(columnName);
  }

  if (rules.length === 0) {
    throw new Error('有効な入力ルールがありません。');
  }

  const issues = [];
  let checkedRows = 0;
  let skippedBlankRows = 0;

  for (let rowIndex = 1; rowIndex < data.length; rowIndex++) {
    const row = data[rowIndex];
    const idValue = row[idColumn];
    if (isBlank_(idValue)) {
      skippedBlankRows++;
      continue;
    }
    checkedRows++;

    for (const rule of rules) {
      const value = row[rule.dataColumn];
      const reason = validateValue_(value, rule);
      if (reason) {
        issues.push({
          row: rowIndex + 1,
          column: rule.columnName,
          reason,
        });
      }
    }
  }

  const output = {
    dryRun: true,
    checkedRows,
    skippedBlankRows,
    ruleCount: rules.length,
    issueCount: issues.length,
    issues: issues.slice(0, config.maxIssues),
    truncated: issues.length > config.maxIssues,
  };
  console.log(JSON.stringify(output));
  return output;
}

function validateValue_(value, rule) {
  if (isBlank_(value)) {
    return rule.required ? '必須欄が空です' : '';
  }
  if (rule.type === 'TEXT') {
    return String(value).trim() ? '' : '文字列として確認できません';
  }
  if (rule.type === 'LIST') {
    return rule.options.includes(String(value).trim())
      ? ''
      : '候補にない値です';
  }
  if (rule.type === 'DATE') {
    return value instanceof Date && !Number.isNaN(value.getTime())
      ? ''
      : '日付として確認できません';
  }
  if (rule.type === 'NUMBER') {
    return typeof value === 'number' && Number.isFinite(value)
      ? ''
      : '数値として確認できません';
  }
  return '未対応の種類です';
}

function normalizeHeaders_(headers) {
  return headers.map(value => String(value ?? '').trim());
}

function assertUniqueHeaders_(headers, sheetName) {
  if (headers.some(header => !header)) {
    throw new Error(sheetName + 'の見出しに空欄があります。');
  }
  if (new Set(headers).size !== headers.length) {
    throw new Error(sheetName + 'の見出しが重複しています。');
  }
}

function isBlank_(value) {
  return value === '' || value === null || value === undefined;
}

実行手順

  1. 対象スプレッドシートをコピーし、公開準備と入力ルールの2シートを作ります。
  2. 上の見出しと数行のサンプルを入れます。状態には候補内の値と候補外の値を1件ずつ用意すると確認しやすくなります。
  3. 拡張機能からApps Scriptを開き、コードを貼り付けて保存します。
  4. 関数一覧からauditInputRulesFreeを選びます。
  5. 初回実行時は、対象ファイルと実行アカウントを確認してから必要な権限だけを許可します。身に覚えのない権限要求や警告が出た場合は進めません。
  6. 実行ログのcheckedRows、issueCount、issuesを確認します。issuesには値そのものではなく、行番号・列名・理由だけが表示されます。
  7. 入力ルールまたは元データを手動で直し、もう一度実行します。issueCountが意図した件数になるまで繰り返します。

dry-runで守っている範囲

このコードはgetValuesで内容を読み取りますが、setValue、setValues、clear、delete、メール送信、外部通信は使いません。結果シートも自動作成しません。入力値をログへ出さないため、問題箇所の値は対象シートを開いて人が確認します。

対象が200行を超える場合は処理を止めます。上限をむやみに上げず、コピー環境で対象期間や商品単位に分けてください。再実行してもシート状態は変わらないため、同じ入力なら同じ監査結果になります。

失敗しやすい点と制限

  • 見出しの前後空白は取り除きますが、全角・半角や別名までは同一視しません。
  • LIST候補は完全一致です。「確認中」と「確認 中」は別の値として扱います。
  • DATEはスプレッドシートが日付として保持しているセルを対象にします。見た目が日付でも文字列なら確認対象になります。
  • NUMBERは数値セルを対象にします。数字だけに見える文字列は確認対象になります。
  • 必須FALSEの空欄は許可しますが、値が入っている場合は形式を確認します。
  • 管理IDが空欄の行は未使用行として除外します。管理ID自体の重複はこの例では判定しません。
  • 数式の妥当性、リンク先、画像、価格の正しさ、公開ページの表示、法的な必須項目は判定しません。
  • Apps Scriptの利用上限や組織の管理設定により実行できない場合があります。

公開前チェックリスト

  • 元ファイルではなくコピー環境で試した
  • dryRunがtrueのままになっている
  • 対象シート名と見出しを確認した
  • ルールの列名と種類に重複や空欄がない
  • LIST候補に表記ゆれや重複がない
  • ログへ入力値や個人情報が出ていない
  • issueCountだけで公開可否を自動判断していない
  • 人が対象行を確認し、必要な修正だけを行った
  • 修正後に再実行して差分を確認した

まとめ

入力ミスは、公開後に直すより、公開準備表の段階で位置と理由を絞るほうが安全です。この無料コードは、必須・候補リスト・日付・数値をルール表から確認し、元データを変更せずに問題候補だけを知らせます。

まずは少量のサンプルでルールの意味を確かめてください。監査結果は機械的な形式確認であり、公開内容や契約条件の正しさを保証するものではありません。

公開前のタスク、サムネイル、納品ファイル、導線なども商品ごとに整理したい方向け

GasおじのTips商品一覧を見る

※学習・検証用の例です。実データへ適用する前にコピー環境で確認してください。Google公式・Google公認の商品ではありません。


あなたも記事の投稿・販売を
始めてみませんか?

Tipsなら簡単に記事を販売できます!
登録無料で始められます!

Tipsなら、無料ですぐに記事の販売をはじめることができます Tipsの詳細はこちら
 

この記事の販売者

ツールおじ|GoogleアカウントをAIエージェント化

おう、Gasおじだ。 AIツールとSaaSに散々突っ込んで遠回りした結果、最後に強かったのは「今あるGoogleアカウント」だった。 今ではAIエージェントであらゆるタスクを自動化して無双してるぞ。 GAS、スプレッドシート、フォーム、Gmail、AIエージェントを組み合わせれば、個人でも小さな会社でも、手作業だらけの業務はかなり自動化できる。 ここでは机上のAI論じゃなく、現場で動くテンプレ、コード、手順だけを置いていくぞ。 高いツールを増やす前に、まずお前さんのGoogleアカウントを仕事するエージェントに変えようじゃないか。

この販売者が書いた他の記事

  • Gmailの定型連絡を「送信せず」まとめて下書き化する|Gmail下書きテンプレGAS Pro

    ¥2,980
    1 %獲得
    (29 円相当)
  • 毎朝のToDoを区分・優先度・期限つきでまとめる Morning ToDo Mail Pro

    ¥2,980
    1 %獲得
    (29 円相当)
  • Gmailの自動ラベルが競合する前に確認|GASで仕分けルールをdry-run監査

関連のおすすめ記事

  • Gmailの定型連絡を「送信せず」まとめて下書き化する|Gmail下書きテンプレGAS Pro

    ¥2,980
    1 %獲得
    (29 円相当)
    ツールおじ|GoogleアカウントをAIエージェント化

    ツールおじ|GoogleアカウントをAIエージェント化

  • USBメモリで持ち運べるLinux Mint 22.1と永続的な書き込み領域の作成法【Rufus だけで作成する方法を初心者向けに徹底解説】

    ¥500
    1 %獲得
    (5 円相当)
    SmartSenior

    SmartSenior

  • 毎朝のToDoを区分・優先度・期限つきでまとめる Morning ToDo Mail Pro

    ¥2,980
    1 %獲得
    (29 円相当)
    ツールおじ|GoogleアカウントをAIエージェント化

    ツールおじ|GoogleアカウントをAIエージェント化