Playwright でブラウザを操作して、自分の投稿を決まった時刻に送る処理を回しています。送る直前に「画面が想定どおりの状態か」を文字で確かめる検査を入れていたのですが、本文にたまたま特定の語が入った日だけ、検査が「異常」と判定して送信を止めていました。この記事は、その原因と直し方の手順です。
起きたこと
投稿画面(モーダル)には「今すぐ送る」ボタンと「予約設定」のアイコンがあります。前回の操作で予約設定が残っていると、ボタンが「予約設定」に変わり、押すと即時ではなく予約として送られます。これを防ぐため、送信前にこう判定していました。
const dialog = page.locator('[role="dialog"]').filter({ has: page.locator('[data-testid="tweetTextarea_0"]') }).first();
const btn = dialog.locator('[data-testid="tweetButton"]').first();
const label = (await btn.innerText()).trim();
// 元の検査
if (/予約|Schedule/i.test(label)
|| await dialog.locator('[data-testid="unsentButton"]').count() && /予約/.test(await dialog.innerText())) {
console.error(`FAIL 送信ボタンが即時投稿でない(${label})。予約設定が残っている`);
process.exit(6);
}
ある日、ログにこう出て投稿されませんでした。
入力照合 OK (123字)
FAIL 送信ボタンが即時投稿でない(ポストする)。予約設定が残っている
ボタンの文字は「ポストする」(即時投稿)なのに、予約が残っていると判定しています。
原因:判定に使った文字に、入力した本文が含まれていた
2つ目の条件が原因でした。
-
unsentButton(下書きボタン)は、予約の有無に関係なくいつもモーダルにある -
dialog.innerText()は、モーダルの中の文字を入力欄の本文ごと返す
その日の本文には「予約番号」という語が入っていました(携帯の乗り換え手続きの話でした)。そのため、「下書きボタンがある」かつ「モーダルの文字に『予約』がある」が成り立ち、予約が残っていると誤判定しました。ほかの日に問題が出なかったのは、本文に「予約」が無かったからです。
予約の残りを防ぐための検査が、本文の内容しだいで黙って送信を止めていた、というのが今回の不具合です。
手順1:まず、空の画面で何が見えているかを確かめる
直す前に、何も入力していない状態のモーダルを読み出しました。
const r = await page.evaluate(() => {
const d = [...document.querySelectorAll('[role="dialog"]')]
.find(x => x.querySelector('[data-testid="tweetTextarea_0"]'));
return {
text: d.innerText,
btn: d.querySelector('[data-testid="tweetButton"]').innerText,
ids: [...new Set([...d.querySelectorAll('[data-testid]')].map(e => e.getAttribute('data-testid')))],
};
});
console.log(JSON.stringify(r, null, 1));
結果は text: "下書き\n全員\nいまどうしてる?\n\n\n全員が返信できます\nポストする" で、「予約」はどこにもありませんでした。unsentButton は常にあることも確かめられます。画面側の文字には「予約」が無く、本文を入れたときだけ出てくる、と切り分けられました。
手順2:入力欄を取り除いた写しから文字を探す
判定に使う文字から、ユーザーが入力した部分を取り除きます。元の DOM は触らず、cloneNode(true) した写しから入力欄を消してから textContent を取ります。
const label = (await btn.innerText().catch(() => '')).trim();
// 本文の入力欄を除いた、画面側の文字だけ
const uiText = await dialog.evaluate(d => {
const c = d.cloneNode(true);
c.querySelectorAll('[data-testid^="tweetTextarea_0"]').forEach(e => e.remove());
return c.textContent || '';
}).catch(() => '');
if (/予約|Schedule/i.test(label)
|| await dialog.locator('[data-testid="scheduledConfirmationPrimaryAction"]').count()
|| /予約|Will send/i.test(uiText)) {
console.error(`FAIL 送信ボタンが即時投稿でない(${label})。予約設定が残っている`);
process.exit(6);
}
変えた点は3つです。
-
本文を除いた文字(
uiText)で探す。入力欄はdata-testidがtweetTextarea_0で始まる要素なので、前方一致でまとめて消します -
「いつもある要素」を条件から外す。
unsentButtonは予約の有無を示さないので、判定に使いません - 予約が残っているときにだけ出る要素や文言を条件にする(予約の確認ボタンや、画面側に出る「予約」の文字)
手順3:「止めるべきでないもの」が通ることを確かめる
検査を直したら、止めるべきでないものが通ることを必ず確かめます。弾きすぎはエラーとして表に出ず、「投稿が出ていない」という形でしか見えないからです。
送信だけしない確認モード(--check)を作っておき、問題になった本文そのもの(「予約番号」を含む)で通しました。
送信元: ... @アカウント名
入力照合 OK (123字)
CHECK OK: 送信元・重複なし・入力照合・送信ボタン有効。送信はしていない
手順4:止まった日を、あとから見えるようにする
この不具合は、ログに FAIL が1行出るだけで、何日も気づかれない作りでした。投稿ごとのログに FAIL があり、完了の印(.done)が無い日を、毎日のレポートの「要確認」に出すようにしました。
import glob, re
from pathlib import Path
for f in sorted(glob.glob("texts/*.log")):
m = re.match(r"(\d{4}-\d{2}-\d{2})_(x|threads|bluesky)\.log$", Path(f).name)
if not m or Path(f).with_suffix(".done").exists():
continue
fail = next((ln for ln in Path(f).read_text(encoding="utf-8").splitlines() if "FAIL" in ln), "")
print(f"予定投稿が出ていない: {m.group(1)} {m.group(2)}({fail[:80]})")
まとめ
- 画面の状態を文字で判定するときは、ユーザーが入力した本文を除いた文字だけを見る。
innerTextは入力欄の中身も返す - 「いつもある要素」を条件に入れない。その状態のときにだけ出る要素を探す
- 検査を直したら、**止めるべきでない入力(今回の本文そのもの)**で通ることを確かめる
- 検査で止めた日は、ログの中だけでなく、人が毎日見る場所に出す