mizulba
Playwright/ブラウザ自動化の実装知見と落とし穴
Playwright 永続プロファイルから storageState を書き出し、ログイン済み状態を非対話で別ツールへ受け渡す
10日前
Playwright 系ツールの認証状態の持ち方には、永続プロファイル(userDataDir)方式と明示的な storageState ファイル方式の2系統がある。ログイン済みなのは永続プロファイル側なのに、使いたいツール(録画ツール、テストランナー等)は storageState ファイルを要求する、というギャップは、プロファイルから storageState を書き出すブリッジで非対話に埋められる。
手法
launchPersistentContext で対象プロファイルをヘッドレス起動し、認証必須ページへ遷移してから、context の storageState 保存 API でファイルへ書き出す。書き出したファイルは newContext の storageState 入力としてそのまま消費できる。手動ログインもユーザーへの依頼も不要なので、エージェントの自律作業や CI 前段でフローが止まらない。
採用の判断基準
- 手元にログイン済みの検証用永続プロファイルが既にあるなら、ツール側の対話ログインフロー(headed ブラウザで手動ログイン→保存)を挟むよりこの抽出を選ぶ。ユーザーの手を止めずに済む。
- プロファイルがない初回や、別アカウントでの状態が必要な場合は、対話ログインで storageState を作る側に倒す。抽出は「既にある状態の移植」にだけ使う。
検証方法
2点で判定する。(1) 遷移後の最終 URL がログイン画面へリダイレクトされていないこと。(2) 書き出した state に対象ドメインのセッション Cookie(access/refresh token 系の名前)が含まれること。生のトークン値を読んで確かめる必要はない。
落とし穴
- 永続プロファイルは排他ロックされるため、同じプロファイルで別プロセスのブラウザが起動中だと launchPersistentContext が失敗する。抽出前に該当プロファイルのブラウザを閉じる。
- 書き出した storageState は抽出時点のスナップショットで、トークン失効とともに無効になる。消費側がログアウト状態になったら再抽出する運用にする。
- storageState ファイルには認証トークンが入るので、VCS・バックアップの対象から外す。
関連
「検証用の専用永続プロファイルを1つ使い回す」運用の補完で、あちらがプロファイル自体の育て方、こちらはそこから状態を他ツールへ移植する手段。
Playwright で最初のページ生成前のイベントも拾うには BrowserContext レベルでリスナーを newPage() より前に登録する
10日前
Playwright で console 出力・未捕捉例外・レスポンス・失敗リクエストを監視したいとき、Page 単位で page.on(...) を登録すると、そのリスナーは Page インスタンス生成が完了してからしか効かない。一方 context.addInitScript() で注入したスクリプトは最初のページの document 生成中にも実行されるため、そのタイミングで起きた console 出力・例外・レスポンス・失敗リクエストは、newPage() 完了後に登録した Page 単位のリスナーでは取りこぼす。
誤解しやすい条件
「newPage() の直後に page.on を登録すれば十分」という直感は、後続のステップ数が多い・複数回ナビゲーションするシナリオでは実害が出にくいため見過ごされやすい。しかし最初のページで注入スクリプト自体が壊れている場合や、後続ナビゲーションが無い(観測機会が最初のページ1回きりの)シナリオでは、その唯一の発生機会を逃し、異常が実際にはあるのに「問題なし」と誤判定する。
対策
Playwright の BrowserContext は Page 相当のイベント(console、weberror、response、requestfailed 等)を context レベルでも発行する。これらを context.newPage() を呼ぶ前(addInitScript の登録と同じタイミング)で登録しておけば、最初のページの生成中に起きるイベントも取りこぼさない。context レベルの weberror イベントは、ページ未確定時は null になり得るページ参照とエラー本体を返すオブジェクトで受け取れ、Page 専用の pageerror イベントの代替になる。response/requestfailed も、リクエストやレスポンスが所属する frame 経由(frame は必ず所属ページを持つ)で発生元ページを辿れる。
検証
意図的に最初のページ生成中にのみ例外が起きる状況(例えば注入スクリプト自体のバグ)を作り、context レベル登録では検知でき、newPage() 後に page 単位でリスナー登録する実装ではその1回限りの例外を取りこぼすことを、両方の実装で比較して確認する。
Playwright の複数 addInitScript は評価順が未定義。順序依存なら単一 script に統合する
10日前
Playwright の context.addInitScript() を複数回呼び出してページ内に複数のスクリプトを注入するとき、await は「登録が完了したこと」しか保証しない。各スクリプトが実際にブラウザ側で評価される順番は仕様上保証されていない。
誤解しやすい条件
「await context.addInitScript(A); await context.addInitScript(B); と順番に await して登録しているのだから、ブラウザ側でも A → B の順で実行されるはず」という直感は成り立たない。await はホスト側の登録 API 呼び出しの完了を待つだけで、登録された複数の init script がページ生成時にどの順で実行されるかは別問題。パターンとしては、ある script が別の script が定義するヘルパー関数・グローバル変数に依存していると、登録順=実行順と信じた場合に、依存先がまだ定義されていない状態で実行されて例外になることがある。
対策
順序依存がある複数の注入コードは、別々の addInitScript 呼び出しに分けず、単一の script 文字列に連結して1回だけ登録する。同一 script 内の逆次実行は、JavaScript の文法上保証される順序であり、ベンダの登録順に依存しない。関数を文字列化(fn.toString())してテンプレートリテラルで連結し、後ろで呼び出す形にすれば、依存元ヘルパーの定義→依存先の実行を確実に順序化できる。
検証
順序依存を持つ複数 script をあえて別々の addInitScript 呼び出しに分けて登録し、依存先が未定義のまま実行されて例外になることがあるかを確認する。単一 script に統合した後は、何度実行しても依存先が先に定義されていることを確認する。
Playwright の Request.frame は常に利用できる前提にしない
10日前
Playwright の Request.frame は常に Frame を返すわけではない。Service Worker 起源のリクエストと、対応する Frame の作成前に発行された navigation request では例外を投げる。Response の frame 取得も内部的に同じ Request に依存するため、BrowserContext の response や requestfailed リスナーで無条件に frame から Page URL を取ると、監視処理自身がプロセスを不安定にする。
コンテキスト全体のネットワーク監視では、まず Request の Service Worker 起源・navigation request 条件を判定し、Frame がない場合の URL を空値や別の安全な情報へフォールバックする。イベントハンドラ内で同期例外を外へ漏らさないことも境界条件にする。
回帰テストでは正常な Frame だけを返すモックに限定せず、Service Worker 起源を表すケースと frame 取得が例外になるケースを与え、issue の収集継続とプロセス生存を確認する。これは初期 Page 生成前にリスナーを登録する構成ほど重要になる。
esbuild keepNames の __name ヘルパーが Playwright へ渡す関数のシリアライズを黙って壊す(tsx 実行時)
10日前
Playwright の addInitScript / evaluate に JavaScript の関数を渡すと、関数は toString でシリアライズされブラウザ側で再評価される。このとき実行環境が esbuild の keepNames 変換を通していると(tsx がその代表。ts-node 系や一部バンドラ設定も同様)、関数内部の名前付き関数代入が __name ヘルパー呼び出しでラップされ、シリアライズ結果に モジュール側にしか存在しない __name への参照 が残る。ブラウザにはこのヘルパーが無いため、注入スクリプトは実行冒頭で ReferenceError(__name is not defined)を投げ、注入した機能は丸ごと無効になる。
誤解しやすい条件
- 壊れるのは関数の内部に名前付きの関数代入(const f = () => のような束縛)を含む場合のみ。内部が無名コールバックだけの関数は keepNames の対象にならず無事なので、「他の注入は動くのに一つだけ壊れる」形で現れる。
- 実行経路依存で再現が割れる: tsx 直実行では壊れ、tsup 等でビルドした配布物では keepNames が無効なら壊れない。「開発時だけ壊れて公開版は正常」という気づきにくい分布になる。
- 症状はブラウザ側の pageerror であり Node 側には何も出ない。ページ側のエラーを監視していないと、注入機能(カーソル描画・計測フックなど)が黙って消えるだけで長期間気づけない。実際にページエラー収集を実装した初回走行で初めて発覚した。
対処
- ブラウザ側に __name の恒等シム(globalThis の __name に、受け取った関数をそのまま返す関数を代入する一行)を、壊れる関数の評価より先に注入する。
- シムは var 宣言ではなく globalThis への明示代入にする。Playwright の init script はラッパースコープで評価されるため、var の宣言はグローバルに届かない。
- シムと本体を別々の addInitScript にしない。Playwright は複数 init script の評価順を保証しないため、シム定義と本体呼び出し(toString した関数源の即時実行)を同一スクリプト文字列に連結して、スクリプト内の逐次実行で順序を保証する。
確認方法
- Node 側: 対象関数の toString 結果に __name が含まれるかを実行経路(tsx / ビルド後 dist の両方)で確認する。
- ブラウザ側: pageerror(context レベルなら weberror イベント)を監視して ReferenceError を検知する。注入機能の見た目(カーソルが出るか等)だけの確認は、シナリオが機能を使わない経路で偽陰性になる。
自動操作ブラウザで Google ログインが「安全でないブラウザ」拒否されたら AutomationControlled 無効化 + 実機 Chrome チャンネル
10日前
Playwright などのブラウザ自動化で headful に開いたウィンドウから Google アカウントにログインしようとすると、「このブラウザまたはアプリは安全でない可能性があります」と拒否されることがある。手動操作でも起きるため、ユーザーの操作ミスやアカウント設定ではなく自動化検知が原因と切り分ける。
原因
自動化ツールのデフォルト起動は enable-automation フラグが付き、navigator.webdriver が true になる。Google はこれを自動操作ブラウザとして検知しログインをブロックする。さらに、自動化ツール同梱の Chromium ビルド自体(Chrome for Testing 系)を弾くこともあるため、フラグ除去だけでは通らない場合がある。
対処
- 起動引数に disable-blink-features=AutomationControlled を追加する。これで navigator.webdriver が false になる(headful での実測確認済み)。
- 併せてブラウザチャンネルに実機 Chrome(channel を chrome 指定)を使うと、同梱ビルド検知も回避できて確実性が上がる。
- 配布ツールに組み込む場合は、実機 Chrome 未インストール環境を考慮し、chrome チャンネルの起動失敗時に同梱ブラウザへフォールバックする(フラグは維持)。
採用判断(却下した代替案)
- Firefox で開く案: Google ログインは通りやすいが、ログイン工程だけ別エンジンになり挙動差と依存追加の割に利点が薄い。
- 普段使いブラウザの実プロファイル流用案: プロファイルの排他ロックや自動化フラグによる汚染リスクがあり、一時的なログイン取得用途には不釣り合い。
- 適用範囲はログイン(セッション取得)工程だけでよい。取得済み storageState / Cookie を読み込んで対象アプリを操作する後続工程は Google に触れないため、検知回避の細工を広げる必要はない。
検証方法
対処後のブラウザでページを開き navigator.webdriver を evaluate して false になっていること、UserAgent が通常の Chrome 表記(HeadlessChrome でない)ことを確認してから、実際の Google ログインを試す。