X の返信と予約投稿を、API ではなく専用ブラウザ(CDP)から出す仕組みで、投稿が2日近く時々落ち続けました。結論は「投稿の小窓(intent/post)を通るのをやめ、ホームと返信先ページの入力欄に直接入れる」です。途中で探査が1回通ったのを見て「直った」と書き、1時間後の本番で否定されています。
画面の自動操作で「急に部品が見つからなくなった」とき、セレクタを直す前に疑う場所が2つあります。この記事はその切り分け方の記録です。
この記事で分かること:
- 投稿ボタンを押さない探査スクリプトで、ページの状態を数えて切り分ける方法
- 「ページが起動していない」と「部品の中身が描かれていない」を分けて数える理由
- intent の小窓を使わずに投稿・返信する構成と、押す前・押した後の確認
- 1回通っただけで原因を決めると何が起きるか
前提
- X の API の従量課金を避けるため、返信と予約投稿は専用ブラウザを CDP(Chrome DevTools Protocol)で操作して出している
- ブラウザはログイン済みの状態で常駐しており、x.com のタブが複数開いていることがある
- 投稿は2種類
- 予約投稿(長文・画像つきもある。落ちた本番は1,301字・画像3枚)
- 返信(返信先のポストに対して)
- 症状が出る前は、どちらも
intent/postの小窓を開いて文面を入れ、投稿ボタンを押していた
症状
11/2 19:16 から次の状態になりました。
- 返信: 「投稿画面に文面が入らない・投稿ボタンが無い」で全部落ちる
- 予約投稿: 3回に1回ほどしか通らない
エラーは出ません。スクリプト側から見えるのは「入力欄が見つからない」「ボタンが見つからない」だけです。この状態でセレクタを書き換え始めると、原因が分からないまま数日溶けます。
切り分けの手順
1. 投稿ボタンを押さない探査スクリプトを用意する
本番のジョブを何度も回すと、通った回に本当に投稿されてしまいます。押す処理を持たない探査用スクリプトを別に作り、裏のタブで intent/post を開いて DOM を数えるだけにしました。
数えたのは次の3つです。
-
#react-rootの中の要素数(ページが起動しきったか) -
role=dialogの中身(小窓の枠だけか、入力欄まで描かれているか) - ホームの入力欄
tweetButtonInlineが出ているか(比較用)
探査で評価した式の形は次のとおりです(簡略化しています)。
// Runtime.evaluate で評価する式の形(簡略化)
(() => {
const root = document.querySelector('#react-root');
const dialog = document.querySelector('[role="dialog"]');
return {
rootCount: root ? root.querySelectorAll('*').length : -1,
hasDialog: !!dialog,
dialogChildren: dialog ? dialog.querySelectorAll('*').length : -1,
inline: !!document.querySelector('[data-testid="tweetButtonInline"]'),
};
})()
2. 「起動しているか」と「描かれているか」を別々に見る
数えた結果、見えた現象は2つありました。
現象①: ページが起動しきらない
-
#react-rootの中が18要素のまま増えない - 画面は黒地にロゴだけの起動画面
- x.com の古いタブを閉じると起動した
現象②: 起動はするが、小窓の中身が描かれない
- 投稿の小窓が
role=dialogの枠(maskだけ)のまま中身を描かない - x.com のほかのタブが無い状態でも起きた
- 20秒待っても起きた
- 短い文でも起きた
- エラーも失敗した通信も出ない
共通点: ホームの入力欄(tweetButtonInline)はどちらのときも出ていた
①と②は「入力欄が無い」という同じ症状に見えますが、①はページ全体の起動の問題、②は小窓という部品の描画の問題で、別物です。要素数を1つ見るだけでは区別できません。
3. 比較用の部品を同時に数える
tweetButtonInline を一緒に数えていたことで、「ログインが切れた」「x.com 全体が壊れた」ではなく、「小窓(intent)経由のときだけ描かれない」まで絞れました。切り分けでは、壊れている部品と同じページにある、壊れていない部品を1つ持っておくと判断が速くなります。
落とし穴: 1回通ったので「直った」と書いた
現象①を見た直後、x.com の古いタブを閉じてから探査を回すと2回続けて通りました。そこで次のように判断しています。
- 「固まった x.com のタブと共有ワーカーが原因」と決めた
- 申し送りと手順書にそう書いた
- ジョブ同士の排他の鍵を入れた(タブの取り合いを防ぐため)
1時間後、本番の予約投稿(1,301字・画像3枚)が現象②で落ちました。②は x.com のほかのタブが無い状態でも起きるため、「古いタブと共有ワーカー」の見立てでは説明できません。見立ては否定されました。
②の条件はまだ分かっていません。分からないものは分からないまま書いておき、構成のほうで避けることにしました。
外した原因を振り返ると次の2点です。
- 現象①と②を区別せずに「起動しない」とまとめていた
- 探査の2回は本番と条件が違う(短い文・画像なし)のに、本番で確かめる前に原因を書いた
対処: 小窓を通らずに、その場の入力欄へ入れる
方針
- intent の小窓は使わない
- 本投稿は ホーム を、返信は 返信先のポストのページ を直接開く
- そのページにもともとある入力欄に入れる
両方の現象に共通して「ホームの入力欄は出ていた」ので、そちらを使えば②に当たらずに済む、という判断です。
入力欄の絞り方
返信先のページなどでは入力欄らしき部品が複数ある場合があります。触る部品は次の条件で1つに絞ります。
- 小窓(
role=dialog)の外にある -
tweetButtonInlineと同じ入れ物の中にある入力欄
// 入力欄の絞り込みの形(簡略化)
(() => {
const buttons = [...document.querySelectorAll('[data-testid="tweetButtonInline"]')]
.filter((b) => !b.closest('[role="dialog"]'));
// ボタンと同じ入れ物の中にある入力欄だけを候補にする
// (入れ物のたどり方はページ構造に合わせて調整)
return buttons.length;
})()
文面の入れ方
文面は CDP の Input.insertText で入れます。入力欄にフォーカスを当ててから送ります。
// 入力欄にフォーカスしたあと
await cdp.send('Input.insertText', { text: body });
押す前の確認
投稿ボタンは、次の2つが両方そろったときだけ押します。どちらかがずれていたら押さずに失敗として記録します。
- 入力欄の本文が、入れたかった文面と 全部一致 している
- 添付された画像の枚数が、予定の枚数と 一致 している
// 押す前の判定の形
const ok = (actualText === body) && (actualImageCount === expectedImageCount);
if (!ok) {
throw new Error('入力内容が一致しないため投稿しない');
}
// ここで初めて投稿ボタンを押す
長文と画像つきの投稿で落ちていたので、「途中まで入った状態」「画像が足りない状態」で投稿されるのを防ぐための確認です。
押した後の確認(返信)
返信は、投稿後の応答に含まれる in_reply_to_status_id_str を見て、返信先と違っていたら消します。返信先のページで入力欄を取り違えると、別のポストへの返信や単独の投稿として出てしまうためです。
// 返信の後始末の判定の形
if (res.in_reply_to_status_id_str !== targetStatusId) {
// 返信先が違うので、出たポストを消す
}
まとめ
- 「部品が見つからない」ときは、セレクタを直す前に「ページが起動しきったか(要素数)」と「部品の中身が描かれているか(中身のある入れ物か)」を分けて数える
- 壊れた部品と同じページにある、壊れていない部品(今回は
tweetButtonInline)を比較用に一緒に数えると、原因の範囲を早く絞れる - 探査で1回通っても「直った」とせず、本番と同じ条件の1本で確かめてから原因を書く
- 条件の分からない不具合は、原因を決めつけずに、その経路を通らない構成で避ける
- 自動で押すボタンは「押す前に全部一致を確かめる」「押した後に結果を確かめて違えば消す」の2段で守る