X連投の順番抜けを投稿前に発見|GASでスレッド候補をdry-run監査

X連投の順番抜けを投稿前に発見|GASでスレッド候補をdry-run監査

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

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

Xで複数投稿をつなげる前に、2番目だけ抜けていたり、未承認の行が混ざっていたりすると、公開後の修正が難しくなります。この記事では、投稿処理を一切行わず、スプレッドシート上の連番・承認状態・本文空欄・予約順を行番号だけで確認する無料GASを紹介します。

コードは常にdry-runで動きます。投稿本文、スレッドID、アカウント情報はログへ出さず、問題のある行番号と分類だけを確認できます。

先に知っておきたいこと

  • できること: 同じスレッド候補内の連番抜け、連番重複、1以外からの開始、未承認行、本文空欄、文字数超過候補、予約日時の逆転を確認できます。
  • できないこと: Xへの投稿、予約投稿、削除、編集、ログイン、API連携、実際のスレッド接続は行いません。
  • 安全設計: 元シートへsetValue、deleteRow、UrlFetchApp、外部送信を実行しません。
  • ログ範囲: 行番号とエラー分類だけを記録し、本文やスレッドIDは出力しません。
  • 制約: URLや絵文字を含むX上の厳密な文字数計算、投稿可否、表示結果は判定しません。

この例は学習・検証用です。実運用の表へ適用する前に、必ずシートをコピーし、少量のサンプルで確認してください。

準備するシート

スプレッドシートに「Xスレッド確認」というシートを作り、1行目へ次の見出しを左から並べます。

  • スレッドID
  • 連番
  • 承認状態
  • 本文
  • 予約日時

スレッドIDは、同じ連投候補をまとめるための内部用ラベルです。「launch-01」のような任意の文字列で構いません。公開URLや認証情報は入れないでください。

承認状態は、この記事では「下書き」「確認中」「承認済」の3種類を想定します。投稿候補として問題なしにするには、その行を「承認済」にします。ただし、このコードが承認状態を書き換えることはありません。

連番は1から始め、同じスレッドIDの中で1、2、3のように整数で入力します。予約日時を使わない場合は空欄で構いません。

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

function auditXThreadSequenceFree() {
  const config = {
    sheetName: 'Xスレッド確認',
    headerRow: 1,
    dryRun: true,
    writeSummary: false,
    summarySheetName: 'AuditSummary',
    allowedStatuses: ['下書き', '確認中', '承認済'],
    requiredStatus: '承認済',
    maxRows: 500,
    simpleCharacterLimit: 280
  };

  validateXThreadAuditConfig_(config);

  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = spreadsheet.getSheetByName(config.sheetName);
  if (!sheet) {
    throw new Error('対象シート「' + config.sheetName + '」が見つかりません。');
  }

  const lastRow = sheet.getLastRow();
  const lastColumn = sheet.getLastColumn();
  if (lastRow <= config.headerRow) {
    Logger.log('確認対象は0件です。');
    return { checked: 0, ok: 0, warningRows: 0, issueCount: 0 };
  }

  const targetCount = lastRow - config.headerRow;
  if (targetCount > config.maxRows) {
    throw new Error(
      '確認対象が上限の' + config.maxRows + '行を超えています。' +
      'コピー環境で範囲を分けて確認してください。'
    );
  }

  const values = sheet
    .getRange(config.headerRow, 1, targetCount + 1, lastColumn)
    .getValues();

  const headers = values[0].map(function(value) {
    return String(value).trim();
  });
  const requiredHeaders = ['スレッドID', '連番', '承認状態', '本文', '予約日時'];
  const indexes = {};

  requiredHeaders.forEach(function(header) {
    const index = headers.indexOf(header);
    if (index === -1) {
      throw new Error('必須列「' + header + '」がありません。');
    }
    indexes[header] = index;
  });

  const groups = {};
  const issues = [];

  values.slice(1).forEach(function(row, offset) {
    const rowNumber = config.headerRow + offset + 1;
    const threadId = String(row[indexes['スレッドID']] || '').trim();
    const sequenceRaw = row[indexes['連番']];
    const status = String(row[indexes['承認状態']] || '').trim();
    const body = String(row[indexes['本文']] || '');
    const scheduledAt = row[indexes['予約日時']];
    const rowIssues = [];

    if (!threadId) {
      rowIssues.push('THREAD_ID_EMPTY');
    }

    const sequence = Number(sequenceRaw);
    if (!Number.isInteger(sequence) || sequence < 1) {
      rowIssues.push('SEQUENCE_INVALID');
    }

    if (config.allowedStatuses.indexOf(status) === -1) {
      rowIssues.push('STATUS_INVALID');
    } else if (status !== config.requiredStatus) {
      rowIssues.push('NOT_APPROVED');
    }

    if (!body.trim()) {
      rowIssues.push('BODY_EMPTY');
    } else if (Array.from(body).length > config.simpleCharacterLimit) {
      rowIssues.push('BODY_OVER_SIMPLE_LIMIT');
    }

    const scheduledDate = parseXThreadDate_(scheduledAt);
    if (scheduledAt !== '' && scheduledAt != null && !scheduledDate) {
      rowIssues.push('SCHEDULE_INVALID');
    }

    if (threadId && Number.isInteger(sequence) && sequence >= 1) {
      const groupKey = hashXThreadKey_(threadId);
      if (!groups[groupKey]) groups[groupKey] = [];
      groups[groupKey].push({
        row: rowNumber,
        sequence: sequence,
        scheduledAt: scheduledDate
      });
    }

    rowIssues.forEach(function(code) {
      issues.push({ row: rowNumber, code: code });
    });
  });

  Object.keys(groups).forEach(function(groupKey) {
    const rows = groups[groupKey].slice().sort(function(a, b) {
      return a.sequence - b.sequence || a.row - b.row;
    });
    const sequenceRows = {};

    rows.forEach(function(item) {
      if (sequenceRows[item.sequence]) {
        issues.push({ row: item.row, code: 'SEQUENCE_DUPLICATE' });
        issues.push({ row: sequenceRows[item.sequence], code: 'SEQUENCE_DUPLICATE' });
      } else {
        sequenceRows[item.sequence] = item.row;
      }
    });

    if (rows[0] && rows[0].sequence !== 1) {
      issues.push({ row: rows[0].row, code: 'SEQUENCE_MUST_START_AT_ONE' });
    }

    for (let index = 1; index < rows.length; index += 1) {
      const previous = rows[index - 1];
      const current = rows[index];
      if (current.sequence > previous.sequence + 1) {
        issues.push({ row: current.row, code: 'SEQUENCE_GAP_BEFORE_ROW' });
      }
      if (
        previous.scheduledAt && current.scheduledAt &&
        current.scheduledAt.getTime() < previous.scheduledAt.getTime()
      ) {
        issues.push({ row: current.row, code: 'SCHEDULE_REVERSED' });
      }
    }
  });

  const summary = buildXThreadAuditSummary_(targetCount, issues);

  Logger.log('dry-run: ' + config.dryRun);
  Logger.log('確認行数: ' + summary.checked);
  Logger.log('問題なし行数: ' + summary.ok);
  Logger.log('確認対象行数: ' + summary.warningRows);
  Logger.log('指摘件数: ' + summary.issueCount);
  issues.forEach(function(issue) {
    Logger.log('行' + issue.row + ': ' + issue.code);
  });
  Logger.log('投稿本文・スレッドID・アカウント情報はログへ出していません。');

  if (config.writeSummary === true) {
    writeXThreadAuditSummary_(spreadsheet, config, summary);
  }

  return summary;
}

function validateXThreadAuditConfig_(config) {
  if (config.dryRun !== true) {
    throw new Error('安全のためdryRunはtrue固定です。');
  }
  if (!config.sheetName || !Array.isArray(config.allowedStatuses)) {
    throw new Error('シート名または承認状態一覧の設定が不正です。');
  }
  if (config.allowedStatuses.indexOf(config.requiredStatus) === -1) {
    throw new Error('requiredStatusが承認状態一覧にありません。');
  }
  if (!Number.isInteger(config.maxRows) || config.maxRows < 1) {
    throw new Error('maxRowsは1以上の整数にしてください。');
  }
}

function hashXThreadKey_(threadId) {
  const digest = Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_256,
    threadId,
    Utilities.Charset.UTF_8
  );
  return digest.map(function(byte) {
    const normalized = byte < 0 ? byte + 256 : byte;
    return ('0' + normalized.toString(16)).slice(-2);
  }).join('');
}

function parseXThreadDate_(value) {
  if (value === '' || value == null) return null;
  if (Object.prototype.toString.call(value) === '[object Date]') {
    return isNaN(value.getTime()) ? null : value;
  }
  if (typeof value !== 'string') return null;
  const text = value.trim();
  if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(text)) return null;
  const parts = text.match(/\d+/g).map(Number);
  const date = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]);
  if (
    date.getFullYear() !== parts[0] ||
    date.getMonth() !== parts[1] - 1 ||
    date.getDate() !== parts[2] ||
    date.getHours() !== parts[3] ||
    date.getMinutes() !== parts[4]
  ) return null;
  return date;
}

function buildXThreadAuditSummary_(checked, issues) {
  const warningRows = {};
  const countsByCode = {};
  issues.forEach(function(issue) {
    warningRows[issue.row] = true;
    countsByCode[issue.code] = (countsByCode[issue.code] || 0) + 1;
  });
  const warningCount = Object.keys(warningRows).length;
  return {
    checked: checked,
    ok: checked - warningCount,
    warningRows: warningCount,
    issueCount: issues.length,
    countsByCode: countsByCode
  };
}

function writeXThreadAuditSummary_(spreadsheet, config, summary) {
  let summarySheet = spreadsheet.getSheetByName(config.summarySheetName);
  if (!summarySheet) {
    summarySheet = spreadsheet.insertSheet(config.summarySheetName);
  }
  summarySheet.clearContents();
  summarySheet.getRange(1, 1, 5, 2).setValues([
    ['確認日時', new Date()],
    ['確認行数', summary.checked],
    ['問題なし行数', summary.ok],
    ['確認対象行数', summary.warningRows],
    ['指摘件数', summary.issueCount]
  ]);
}

実行手順

  1. 元のスプレッドシートをコピーします。
  2. コピー側に「Xスレッド確認」シートを作り、見出しと5〜10行程度のサンプルを入力します。
  3. 拡張機能からApps Scriptを開き、コードを貼り付けて保存します。
  4. auditXThreadSequenceFreeを選び、実行します。
  5. 初回だけ表示される権限画面を読み、対象のスプレッドシートを確認します。
  6. 実行ログで、確認行数、問題なし行数、確認対象行数、行番号とエラー分類を確認します。
  7. 指摘行を手動で直し、もう一度dry-runを実行します。
  8. すべて問題なしでも、このコードからXへは投稿されません。公開操作は別工程として人が確認してください。

writeSummaryの初期値はfalseです。件数だけを別シートへ残したい場合は、コピー環境でtrueへ変更できます。保存するのは集計件数だけで、本文やスレッドIDは保存しません。

エラー分類の読み方

  • THREAD_ID_EMPTY: どの連投候補に属するか分かりません。
  • SEQUENCE_INVALID: 連番が1以上の整数ではありません。
  • STATUS_INVALID: 想定外の承認状態です。
  • NOT_APPROVED: 承認済ではありません。
  • BODY_EMPTY: 投稿本文が空欄です。
  • BODY_OVER_SIMPLE_LIMIT: 単純な文字数が設定値を超えています。
  • SCHEDULE_INVALID: 予約日時の形式が読み取れません。
  • SEQUENCE_DUPLICATE: 同じスレッド候補内で連番が重複しています。
  • SEQUENCE_MUST_START_AT_ONE: 最初の連番が1ではありません。
  • SEQUENCE_GAP_BEFORE_ROW: 直前の連番との間に抜けがあります。
  • SCHEDULE_REVERSED: 後ろの連番の予約日時が、前の連番より早くなっています。

同じ行に複数の問題があれば、複数の分類が表示されます。まず連番と承認状態を直し、その後に本文と予約日時を確認すると整理しやすくなります。

失敗しやすい点と制限

スレッドIDの表記ゆれ

「launch-01」と「launch_01」は別の候補として扱われます。余分な前後空白は除きますが、大文字・小文字や記号は統一しません。入力規則を設定し、候補名を揃えてください。

連番を行順と混同する

シート上の並び順ではなく、連番の値を基準に確認します。行を並べ替えても監査できますが、連番重複や欠番は自動補正しません。

文字数は単純計算

本文の文字数はArray.fromで数えます。X側のURL短縮、絵文字、改行などを含む実際の投稿可能文字数と一致する保証はありません。BODY_OVER_SIMPLE_LIMITは確認を促す目安です。

予約日時とタイムゾーン

シートの日付型、またはYYYY-MM-DD HH:mm形式を想定します。スプレッドシートとApps Scriptのタイムゾーンが一致しているか確認してください。このコードは予約投稿を登録しません。

承認済でも投稿しない

承認済は入力表の状態確認にだけ使います。X API、ブラウザ操作、外部サービスへの送信処理は含みません。実際の投稿前には、アカウント、本文、画像、リンク、公開時刻を人が再確認してください。

実行上限

初期設定では最大500行です。大量データを一度に処理せず、コピー環境で範囲を分けてください。Apps Scriptには実行時間などの上限があります。

公開前チェックリスト

  • コピーしたシートで試した
  • スレッドIDと連番を確認した
  • 未承認行が残っていない
  • 本文空欄と文字数超過候補を確認した
  • 予約日時とタイムゾーンを確認した
  • dryRunがtrueになっている
  • writeSummaryがfalseになっている
  • 投稿本文、スレッドID、アカウント情報をログへ出していない
  • 自動投稿、自動削除、自動更新の処理がない
  • 指摘行を手動で確認した

まとめ

連投候補は、本文を書き終えただけでは安全に公開できません。連番の抜けや重複、未承認行、予約順の逆転をdry-runで先に見つけておけば、投稿操作へ進む前にシート上で修正できます。

承認済みの投稿だけをキューから実行し、危ない行では止まる運用まで整えたい場合は、次の有料Tipsも確認できます。

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

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

※本記事は学習・検証用の例です。Xへの投稿、予約、削除、成果保証は行いません。Google公式・Google公認の商品ではありません。


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

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

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

この記事の販売者

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

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

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

  • Gmailの予約送信ミスを送信前に発見|GASで宛先・日時をdry-run監査

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

    ¥2,980
    1 %獲得
    (29 円相当)
  • スプレッドシートの入力ミスを公開前に発見|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エージェント化