Playwrightで7サービスを自動操作して詰まった箇所(実運用の記録)

Playwrightで7サービスを自動操作している中で、実際に詰まった箇所だけを記録しました。headlessでだけログイン画面に飛ばされる件、has-text() の部分一致が起こした事故、成功で終わるのに1件も処理されていない状態など、エラーにならない失敗が中心です。

何を自動化しているか

Playwrightで、note・X・Instagram・Bluesky・Tumblr・Amazonアソシエイト・YouTube の操作を自動化しています。以下は、この構成を運用する中で実際に踏んだ問題の記録です。一般論ではなく、自分のログに残っているものだけを書きます。

1. headless=True のときだけログイン画面に飛ばされた

Amazonアソシエイトの成果レポート取得が、4回連続で失敗しました。ログには毎回『ログインが切れています』と出ていました。

ところが、実際にはログインは切れていませんでした。同じブラウザプロファイルのまま headless=False にすると、成果ページがそのまま開いたのです。つまり、切れていたのではなく、headless=True のときだけログイン画面に強制送還されていたということになります。

失敗したこと

ログのメッセージ(『ログインが切れています』)をそのまま信じて、認証まわりを疑い続けました。原因は認証状態ではなく、headlessかどうかでした。

なぜheadlessだと弾かれるのか、その判定の中身までは分かっていません。分かっているのは「プロファイルは同じなのに、headlessの有無で結果が変わる」という事実だけです。切り分けの第一手は headless=False で同じ操作を再現すること、これは今後も最初にやります。

2. has-text() は部分一致だった

Playwrightの has-text() は部分一致です。これで2件、実害が出ました。

  • button:has-text('保存')『下書き保存』 を掴んだ
  • has_text('追加')『追加済』 を掴み、再実行で登録を解除してしまった
注意

「追加」で「追加済」を掴むと、押した瞬間に逆の操作になります。冪等なつもりで再実行したら、前回の登録が解除されていました。

では :text-is() で完全一致にすればいいかというと、こちらは子要素の文字を見ないため、アイコン付きボタンでは使えません。アイコンとテキストが別要素になっているボタンにはマッチしませんでした。

落ち着いた方式

結局、候補を列挙して inner_text().strip() で完全一致を取る方式に落ち着きました。セレクタ一発で決めるのをやめて、取得してからPython側で判定する形です。

方法 問題
has-text() 部分一致。『保存』が『下書き保存』を掴む
:text-is() 子要素の文字を見ない。アイコン付きボタンで使えない
列挙 + inner_text().strip() 現在これを使用中

3. 押せているのに解除されない(3日間気づけなかった)

noteのフォロー解除が、確認ダイアログからドロップダウンメニューに仕様変更されました。その結果、ボタンは押せているのに解除されない状態になりました。

厄介なのは、エラーにならないため処理は成功で終わることです。例外も出ず、リトライも走らず、正常終了します。

実測

3日間 0件
エラーゼロのまま、1件も解除されていなかった

気づけなかった直接の原因は、ログの書き方でした。『対象なし』とだけ書いていたため、0件が異常なのか正常なのか区別できなかったのです。0件を数字で残していれば気づけました。

ポイント

「対象なし」は文章です。「0件」は数字です。数字で残っていれば、3日連続で同じ0が並んでいることに気づけます。ログは人間が読む前提ではなく、並べて比較できる形で書くべきでした。

4. 無限スクロールの終了条件を間違えた

無限スクロールのページで、終了条件を『ページの高さが変わらなくなったら終了』にしていました。結果、2時間の実行上限まで走り続けました

これを『新しい対象が一定回数見つからなかったら終了』に直したところ、36分で正常終了しました。

実測

2時間上限 → 36分
終了条件を「高さ」から「新しい対象の有無」に変えただけ

高さは、こちらが欲しいものと直接関係のない指標でした。終了条件は「ページの状態」ではなく「自分が探しているものが増えたか」で判定するのが正解でした。

5. プロファイル706MBを運ぶのをやめた

ブラウザプロファイルは706MBありました。別サーバーへ運ぶには重すぎます。

storage_state を使えば、CookieとlocalStorageだけを4KBのJSONで書き出せます。

うまくいったこと

706MB のプロファイル一式ではなく、storage_state の 4KB JSON を持ち運ぶ形に変更。CookieとlocalStorageだけで済むケースはこれで足ります。

ただし、これで headless の問題まで解決するわけではありません。1. で書いたAmazonアソシエイトの件は、同じプロファイルでも headless の有無で結果が変わっていたので、認証情報の持ち運び方とは別の問題です。そこは分けて考えています。

共通していたこと

  • エラーが出ない失敗が一番遅れて発覚する(noteの3日間)
  • ログのメッセージが原因を誤誘導する(『ログインが切れています』)
  • 「対象なし」のような文章ログは、異常を隠す
  • headless=False で同じ操作を再現して切り分ける
  • テキスト一致は列挙して inner_text().strip() で判定する
  • 件数は必ず数字でログに残す
  • 終了条件は「新しい対象が見つかったか」で書く
まとめ

  • Amazonアソシエイトの成果レポート取得は4回連続で失敗し、ログは『ログインが切れています』だったが、実際は headless=True のときだけログイン画面に送られていた。headless=False で成果ページはそのまま開いた
  • has-text() は部分一致で、『保存』が『下書き保存』を、『追加』が『追加済』を掴んで登録解除まで起きた。:text-is() は子要素の文字を見ずアイコン付きボタンで使えないため、候補を列挙して inner_text().strip() の完全一致に変更した
  • noteのフォロー解除は確認ダイアログからドロップダウンメニューに仕様変更され、押せているのに解除されない状態に。エラーが出ないので成功扱いとなり、3日間0件に気づけなかった。原因は『対象なし』とだけログに書いていたこと
  • 無限スクロールの終了条件を「高さが変わらなくなったら」から「新しい対象が一定回数見つからなかったら」に直し、2時間の実行上限まで走っていたものが36分で正常終了した
  • 706MBのブラウザプロファイルは運べないが、storage_state ならCookieとlocalStorageを4KBのJSONで書き出せる
  • headless で弾かれる判定の中身は分かっていない