X投稿の重複を出す前に止める|GASで投稿キューをdry-run監査
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の仕様変更により、手順や動作を見直す場合があります。
