自動化でいちばん怖いのは、動かないことではありません。

動いているように見えて、何も起きていないことです。

X(@YKStudioLab)に記事の告知を自動投稿する仕組みを作りました。Windowsのタスクスケジューラで1日3回、キューに置いたテキストを1本ずつ消化します。ログは正常。posted/ フォルダには投稿済みとして 19本 がきれいにアーカイブされている。

アカウントを開いたら 0 posts でした。1本も投稿されていませんでした。

仕組み

先に構成を書いておきます。

x_posts/
  queue/     0022_site_launch.txt   ← 連番の若い順に1回1本消化
  posted/    2026-08-30-14-00-01_0021_xxx.txt  ← 投稿後ここへ移動
  _hold/     画像つきの未検証分を退避

Playwrightで既存のChromeプロファイル(ログイン済み)を使い、x.com/compose/post を開いて本文を入力し、送信ボタンを押す。投稿できたら queue/ から posted/ へファイルを移す。それだけの素朴なものです。

素朴すぎたのが問題でした。

原因1: クリックした事実を、送信の証明として扱っていた

最初のコードはこうなっていました。

await postBtn.click({ force: true });
await page.waitForTimeout(4000);
await archivePost(file);   // ← 無条件でアーカイブ

click() が例外を投げなければ成功、という前提です。しかし click() が保証しているのは「その座標にクリックイベントを投げたこと」だけで、Xがそれを受理して送信したかは何も保証していません。

キューは消化される。ファイルは posted/ に移る。次回の実行は次の記事に進む。だから失敗が1度も表面化せず、19本ぶん静かに溶けました。

修正は verifyPosted() を足して、送信を確認できなければキューを消化しないようにしただけです。確認できなければファイルは queue/ に残るので、次のスケジュール実行で自動的に再試行されます。

原因2: 「念のため」のEscapeが、投稿モーダルを閉じていた

入力前に、邪魔なポップアップを閉じる目的で無条件に Escape を押していました。

await page.keyboard.press('Escape');   // モーダル等を閉じる(つもり)

/compose/postそれ自体がモーダルです。このEscapeで投稿ダイアログが閉じ、裏のホームに戻る。そのあと本文の入力先として見つかるのは、ホームのインライン入力欄でした。

つまり毎回、ホームの入力欄に本文を書いて、そのまま放置していたわけです。下書きにすらならない。

入力欄が見つからなかったときだけ押す、に変えました。

原因3: force: true を付けてもボタンが反応しない

Xの送信ボタンは、click({ force: true }) を投げても反応しないことがあります。ここは理屈で追うのを諦めて、フォールバックを足しました。

await postBtn.click({ force: true }).catch(() => {});
// 10秒待って未送信なら、Xの送信ショートカットで再試行
if (!(await postedSoon(page, 10_000))) {
  await page.keyboard.press('Control+Enter');
}

実際に投稿が通ったのは、この Control+Enter のほうでした。

いちばん時間を溶かしたのは、修正のほうのバグ

verifyPosted() の最初の実装は、URLで判定していました。

// これは常に「成功」と言う
return !page.url().includes('/compose/post');

投稿に成功すればモーダルが閉じてURLがホームに戻る、という発想です。一見もっともらしい。

モーダルなので、失敗して閉じてもURLはホームに戻ります。 成功と失敗が、判定に使った値の上で完全に一致していました。この修正を入れた直後のテストは「投稿成功」と表示して、実際には何も投稿していません。同じ穴を、原因を直す側でもう一度掘っていたことになります。

信頼できたのは1つだけでした。

入力欄から本文が消えたか

送信されると入力欄が空になります。成功時は Your post was sent. のトーストも出ますが、消えるのが速く取りこぼすので、主判定は入力欄の中身にしました。

ついでに踏んだDNSの瞬断

8月31日は、9時・14時・20時の3回すべてが net::ERR_NAME_NOT_RESOLVED で失敗していました。

障害かと思って直後に同じプロファイルで診断ツールを回したら、x.com も twitter.com も HTTP 200、認証Cookieも生きている。恒久的な障害ではなく、DNSの瞬断でした。

無人実行なので、瞬断が1回起きるとその回は丸ごと無駄になります。リトライを足しました。

async function gotoWithRetry(page, url, tries = 4) {
  for (let i = 0; i < tries; i++) {
    try { return await page.goto(url, { waitUntil: 'domcontentloaded' }); }
    catch (e) {
      if (!/ERR_NAME_NOT_RESOLVED|ERR_NETWORK|ERR_CONNECTION/.test(String(e))) throw e;
      if (i === tries - 1) throw e;
      await page.waitForTimeout(5000 * (i + 1));   // 5s → 10s → 15s
    }
  }
}

ネットワーク系のエラーだけ再試行し、それ以外は即座に投げます。何でもリトライすると、本当のバグが「たまに失敗する現象」に化けて見えなくなるので、条件は絞りました。

もう1点。ログイン判定にもリトライが要ります。瞬断でログインページの取得に失敗すると「未ログイン」と誤判定し、無人実行では5分待たされたあげく失敗します。判定処理は本処理より先に走るので、そこが弱いと本処理の堅牢化が丸ごと無意味になります。

いま

0022_site_launch を実投稿して、OGPカードの表示まで確認しました。キューには5本残っています。画像つきの2本は _hold/ に退避したままで、メディア添付の経路はまだ実投稿で検証していません。検証していない経路を「たぶん動く」と書かないことにしたので、ここは未検証と書いておきます。

posted/ にある19本は、実際には世に出ていません。内容が古びていなければ再投稿する価値はあります。

教訓

「実行した」と「効果が出た」を別のこととして扱う。 クリック、API呼び出し、ファイルの移動——どれも実行の記録であって、結果の記録ではありません。自動化のキューを消化する条件は、実行ではなく結果の確認に置くべきでした。

そしてもう1つ。成功と失敗で同じ値になるものを、判定に使わない。 URL判定はこれを踏んでいます。書く前に「失敗したときこの値はどうなるか」を一度考えるだけで防げました。


同じ「エラーが出ないまま壊れていた」系では、課金が11日間動いていなかった話のほうが被害は大きかったです。この自動化を含めて、開発も運用もAIエージェントに割り振った体制で回しています。その体制の話はこちらに書きました。