自動化でいちばん怖いのは、動かないことではありません。
動いているように見えて、何も起きていないことです。
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エージェントに割り振った体制で回しています。その体制の話はこちらに書きました。