X投稿の重複を出す前に止める|GASで投稿キューをdry-run監査

X投稿の重複を出す前に止める|GASで投稿キューをdry-run監査

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

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

昨日書いた投稿を少し直して、今日の候補にも入れた。見た目は違うのに、読み返すとほぼ同じ内容だった。投稿ボタンを押す直前に気づくと、候補をためるほど確認時間が増えてしまいます。

この記事では、Googleスプレッドシートの投稿候補をGASで監査し、同じ文章、未承認、文字数の目安超過を外へ送る前に止めます。Xへの送信処理は含めず、DRY_RUNだけで結果と監査ログを残す無料例です。

この例で守る範囲

  • 投稿候補シートの元データは削除しない
  • approvedがTRUE、statusがREADYの行だけを確認対象にする
  • 投稿済みログと当日の候補内で、正規化後の文章ハッシュを比較する
  • 本文そのものを監査ログへ複製せず、record_idとハッシュだけを残す
  • X API、アクセストークン、自動投稿処理は使わない

この例の「重複」は、文章を完全一致に近い形で比較した結果です。意味が似ている別文章までは判定しません。また、URLや絵文字を含むX上の文字数計算は単純な文字数と異なる場合があるため、最後はXの投稿画面でも確認してください。

準備する3つのシート

投稿候補シートには、次の列を置きます。

  • record_id: 行を識別する重複しないID
  • post_text: 投稿候補の本文
  • approved: 人が確認した行だけTRUE
  • status: 確認対象はREADY
  • check_result: GASが判定結果を書く列
  • checked_at: 最後に確認した日時

投稿済みログには、manual_post後に確定した文章のハッシュを保存します。

  • record_id
  • normalized_hash
  • posted_at

監査ログには、dry-runの結果だけを追記します。

  • checked_at
  • record_id
  • result
  • approx_chars
  • normalized_hash
  • dry_run

コピーして使える最小コード

新しいスプレッドシートの拡張機能からApps Scriptを開き、次のコードを貼り付けます。既存シートがある場合は消去せず、必要な列が揃っているかを先に検査します。

const X_QUEUE_AUDIT_CONFIG = {
  queueSheet: '投稿候補',
  postedLogSheet: '投稿済みログ',
  auditLogSheet: '監査ログ',
  DRY_RUN: true,
  maxApproxChars: 280,
};

function setupXPostQueueAuditFree() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();

  const queue = ensureSheetWithHeaders_(ss, X_QUEUE_AUDIT_CONFIG.queueSheet, [
    'record_id',
    'post_text',
    'approved',
    'status',
    'check_result',
    'checked_at',
  ]);

  ensureSheetWithHeaders_(ss, X_QUEUE_AUDIT_CONFIG.postedLogSheet, [
    'record_id',
    'normalized_hash',
    'posted_at',
  ]);

  ensureSheetWithHeaders_(ss, X_QUEUE_AUDIT_CONFIG.auditLogSheet, [
    'checked_at',
    'record_id',
    'result',
    'approx_chars',
    'normalized_hash',
    'dry_run',
  ]);

  if (queue.getLastRow() === 1) {
    queue.getRange(2, 1, 3, 6).setValues([
      ['post-001', '作業前に入力を確認すると、やり直しを減らしやすくなります。', true, 'READY', '', ''],
      ['post-002', '  作業前に入力を確認すると、やり直しを減らしやすくなります。  ', true, 'READY', '', ''],
      ['post-003', 'まだ確認していない投稿候補です。', false, 'DRAFT', '', ''],
    ]);
  }
}

function auditXPostQueueFree() {
  if (X_QUEUE_AUDIT_CONFIG.DRY_RUN !== true) {
    throw new Error('この無料例はDRY_RUN: trueでのみ実行できます。');
  }

  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const queue = requireSheet_(ss, X_QUEUE_AUDIT_CONFIG.queueSheet);
  const postedLog = requireSheet_(ss, X_QUEUE_AUDIT_CONFIG.postedLogSheet);
  const auditLog = requireSheet_(ss, X_QUEUE_AUDIT_CONFIG.auditLogSheet);

  const queueValues = queue.getDataRange().getValues();
  if (queueValues.length < 2) {
    throw new Error('投稿候補がありません。少量のサンプルを入れてください。');
  }

  const queueHeaders = queueValues[0].map(String);
  validateHeaders_(queueHeaders, [
    'record_id',
    'post_text',
    'approved',
    'status',
    'check_result',
    'checked_at',
  ]);

  const postedHashes = readPostedHashes_(postedLog);
  const seenInCurrentRun = new Set();
  const now = new Date();
  const resultValues = [];
  const checkedAtValues = [];
  const auditRows = [];

  queueValues.slice(1).forEach((row) => {
    const item = rowToObject_(queueHeaders, row);
    const recordId = String(item.record_id || '').trim();
    const rawText = String(item.post_text || '');
    const normalizedText = normalizePostText_(rawText);
    const normalizedHash = normalizedText ? sha256Text_(normalizedText) : '';
    const approxChars = Array.from(normalizedText).length;
    const result = validateQueueRow_({
      recordId,
      rawText,
      normalizedText,
      normalizedHash,
      approxChars,
      approved: item.approved,
      status: item.status,
      postedHashes,
      seenInCurrentRun,
    });

    if (normalizedHash) {
      seenInCurrentRun.add(normalizedHash);
    }

    resultValues.push([result]);
    checkedAtValues.push([now]);
    auditRows.push([
      now,
      recordId || '(IDなし)',
      result,
      approxChars,
      normalizedHash,
      true,
    ]);
  });

  const resultCol = queueHeaders.indexOf('check_result') + 1;
  const checkedAtCol = queueHeaders.indexOf('checked_at') + 1;
  queue.getRange(2, resultCol, resultValues.length, 1).setValues(resultValues);
  queue.getRange(2, checkedAtCol, checkedAtValues.length, 1).setValues(checkedAtValues);
  auditLog.getRange(auditLog.getLastRow() + 1, 1, auditRows.length, auditRows[0].length).setValues(auditRows);
}

function validateQueueRow_(input) {
  if (!input.recordId) {
    return 'BLOCK: record_idが空です';
  }

  if (!normalizeBool_(input.approved)) {
    return 'WAIT: approvedがTRUEではありません';
  }

  if (String(input.status || '').trim().toUpperCase() !== 'READY') {
    return 'WAIT: statusがREADYではありません';
  }

  if (!input.normalizedText) {
    return 'BLOCK: post_textが空です';
  }

  if (input.approxChars > X_QUEUE_AUDIT_CONFIG.maxApproxChars) {
    return `BLOCK: 文字数の目安を超えています (${input.approxChars})`;
  }

  if (input.postedHashes.has(input.normalizedHash)) {
    return 'BLOCK: 投稿済みログと同じ本文です';
  }

  if (input.seenInCurrentRun.has(input.normalizedHash)) {
    return 'BLOCK: 今回の候補内に同じ本文があります';
  }

  return 'OK: dry-run監査を通過しました。投稿画面で最終確認してください';
}

function readPostedHashes_(sheet) {
  const values = sheet.getDataRange().getValues();
  if (values.length === 0) return new Set();

  const headers = values[0].map(String);
  validateHeaders_(headers, ['record_id', 'normalized_hash', 'posted_at']);
  const hashIndex = headers.indexOf('normalized_hash');
  return new Set(
    values.slice(1)
      .map((row) => String(row[hashIndex] || '').trim())
      .filter(Boolean)
  );
}

function normalizePostText_(value) {
  return String(value || '')
    .normalize('NFKC')
    .replace(/\s+/g, ' ')
    .trim();
}

function sha256Text_(value) {
  const bytes = Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_256,
    value,
    Utilities.Charset.UTF_8
  );
  return bytes.map((byte) => (byte + 256) % 256)
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');
}

function rowToObject_(headers, row) {
  const item = {};
  headers.forEach((header, index) => {
    item[header] = row[index];
  });
  return item;
}

function normalizeBool_(value) {
  if (value === true) return true;
  return String(value || '').trim().toUpperCase() === 'TRUE';
}

function validateHeaders_(actual, required) {
  const missing = required.filter((header) => !actual.includes(header));
  if (missing.length) {
    throw new Error(`必要な列がありません: ${missing.join(', ')}`);
  }
}

function ensureSheetWithHeaders_(ss, name, headers) {
  const sheet = ss.getSheetByName(name) || ss.insertSheet(name);
  if (sheet.getLastRow() === 0) {
    sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
    sheet.setFrozenRows(1);
  } else {
    validateHeaders_(sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String), headers);
  }
  return sheet;
}

function requireSheet_(ss, name) {
  const sheet = ss.getSheetByName(name);
  if (!sheet) {
    throw new Error(`${name}がありません。先にsetupXPostQueueAuditFreeを実行してください。`);
  }
  return sheet;
}

実行手順

1. 元データではなく、まずコピーしたスプレッドシートで試します。

2. setupXPostQueueAuditFreeを1回実行し、3つのシートとサンプル行を作ります。

3. 投稿候補へ少量のデータを入れ、approvedとstatusを人が確認します。

4. auditXPostQueueFreeを実行します。

5. check_resultがOKの行も、そのまま外へ送らずXの投稿画面で本文、リンク、画像、公開先を確認します。

6. 手動投稿が完了した後だけ、監査ログのnormalized_hashを投稿済みログへ記録します。

初回実行ではGoogleスプレッドシートへのアクセス許可が求められます。表示される権限範囲を確認し、不要になったApps ScriptやトリガーはGoogleアカウント側で見直してください。

判定結果の読み方

  • OK: dry-run上の入力条件を通過。外部送信済みという意味ではない
  • WAIT: 未承認またはREADY前なので、確認対象から外した
  • BLOCK: IDなし、空本文、文字数の目安超過、同一本文を検出した

サンプルのpost-001とpost-002は、前後の空白だけが違います。正規化すると同じ文章になるため、後の行が候補内重複としてBLOCKになります。

失敗しやすい点

  • record_idを使い回すと、どの行を確認したか追いにくくなる
  • 投稿済みログを投稿前に更新すると、まだ出していない行まで重複扱いになる
  • 意味が似ているだけの文章は、この完全一致型チェックでは止まらない
  • URL短縮や絵文字を含む場合、Xの画面と単純な文字数が一致しないことがある
  • 時間主導トリガーを付けても、このコードは自動投稿しない。監査結果を書くだけ
  • 投稿本文に個人情報や認証情報を入れない。監査ログにも秘密情報を残さない

公開前チェックリスト

  • [ ] DRY_RUNがtrueのままか
  • [ ] approvedを人が確認したか
  • [ ] READYの意味をチーム内で統一したか
  • [ ] 投稿済みログは実際の手動投稿後だけ更新したか
  • [ ] BLOCKの理由を直してから再監査したか
  • [ ] Xの投稿画面で文字数、リンク、画像、公開先を最終確認したか
  • [ ] Apps Scriptの実行ログに認証情報が出ていないか

まとめ

投稿候補を増やすほど大切になるのは、作成速度より「まだ出してはいけない行」を見つけられることです。この無料例では、文章を正規化してハッシュで比べ、元データを消さず、外部送信せず、dry-runの監査記録だけを残します。

承認、重複、停止条件を含むX投稿キュー全体を運用したい方は、関連する有料Tipsをご確認ください。

Gasおじ式 X投稿キュー自動実行GAS Proを見る

GasおじのTips商品一覧も確認できます。

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

※学習・検証用の例です。実データへ適用する前にコピー環境で確認してください。Google公式・Google公認・X公式のツールではありません。投稿結果、売上、反応増加、トラブル防止を保証するものではありません。Google、X、Tipsの仕様変更により、手順や動作を見直す場合があります。


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

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

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

この記事のライター

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

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

このライターが書いた他の記事

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

    ¥2,980
    1 %獲得
    (29 円相当)
  • GASのtry-catch入門|元データを守るエラー処理とログの実装例

  • Gasおじ式 X投稿キュー自動実行GAS Pro|危ない時だけ止まる

    ¥2,980
    1 %獲得
    (29 円相当)

関連のおすすめ記事

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

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

    SmartSenior

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

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

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

  •  Let's noteにChromeOS 135を搭載!内蔵メモリ8GBのChromebookを構築 Windows10との共存環境

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

    SmartSenior