遭遇した 短命な<input type="file">
ブラウザ操作自動化(Webスクレイピング)では通常、
要素を探す
↓
要素を取得する
↓
操作する
という流れを考えます。
ところが最近のSPAなどの動的Webアプリでは、この前提が通用しないことがあります。
今回遭遇したのは、ボタンをクリックした瞬間だけ生成され、ファイル選択後すぐ削除される 短命な <input type="file"> でした。
これはDiscord内の会話が発端でしたが、事象を再現したサンプルは以下のとおりです。
事例サンプル
概念的には次のような実装です。
button.addEventListener("click", () => {
const input = document.createElement("input");
input.type = "file";
input.addEventListener("change", () => {
// ファイルを処理
input.remove();
});
document.body.appendChild(input);
input.click();
});
クリック前に、
document.querySelector('input[type="file"]')
を実行しても何も見つかりません。
つまり、
FindElement
↓
SendKeys
という通常のファイルアップロードでは捕まえられません。
私はSeleniumVBAを拡張してWebDriver BiDiも使えるツールを公開しているため、BiDiを使えば問題が解決できるのではないかと、真っ先に考えました。
BiDiには、まさにこのための仕組みがある
Discord内でも助言をいただきましたが、現在のWebDriver BiDiには、
input.fileDialogOpened
イベントがあります。
さらにイベントには、<input type="file"> から開かれたpickerであれば、その要素を示す SharedReference が含まれます。仕様上 input.setFiles は、その SharedReference を使ってファイルを設定できます。
したがって理屈上は、
ボタンをクリック
↓
短命 input が生成
↓
input.fileDialogOpened
↓
element.sharedId を取得
↓
input.setFiles
とできます。
これは非常にきれいです。
事前にinputを探す必要すらありません。
実験1:inputが消えてもSharedReferenceは使えるのか
最初に疑ったのは、要素の寿命でした。
そこで、
input生成
↓
click
↓
inputをDOMから即削除
↓
input.fileDialogOpened
↓
待機
↓
input.setFiles
という実験を行いました。
待機時間は、
0 ms
100 ms
1,000 ms
5,000 ms
10,000 ms
まで伸ばしました。実験コード自体も、まさにこの「即detach後のSharedReference寿命」を測定する構成です。
結果は予想外でした。
今回テストしたEdge/Chromium環境では、DOMから削除されたfile inputでも、取得済みの sharedId を使った input.setFiles が10秒後まで成功しました。
つまり今回の問題は、
短命すぎてinputを操作できない
ことではありませんでした。
BiDiのイベントとSharedReferenceは、短命inputを捕まえるという点ではかなり強力だったのです。
本当の問題はWindowsのファイルpickerだった
次に問題になったのが、OSネイティブのファイル選択画面です。
WebDriver BiDiの現在の仕様には userPromptHandler.file があり、
accept
dismiss
ignore
を指定できます。
accept / dismiss はpickerを閉じ、ignore はpickerを開いたままにする、と仕様に定義されています。
そこで file=ignore にして実験しました。
すると、
input.fileDialogOpened
↓
sharedId取得
↓
session.status
↓
input.setFiles
は実行できます。
つまり、ネイティブpickerが開いていてもBiDi通信そのものは止まりませんでした。
input.setFiles も成功します。実験コードでも、pickerを意図的に開いたまま session.status と input.setFiles を送っています。
しかし――
Windowsのファイル選択画面は残ったままです。
これでは無人自動化としては困ります。
ではpickerをdismissすればよい?
当然、
file=dismiss
も試したくなります。
しかし今度はWebアプリ側に「ユーザーがファイル選択をキャンセルした」という意味が伝わる可能性があります。
つまり、
pickerを残す
↓
ファイル設定はできる
↓
しかしWindows画面が残る
と、
pickerをdismiss
↓
Windows画面は消える
↓
しかしcancelの意味がアプリ側に入る
というトレードオフになります。
ここまで来て分かったのは、
短命DOMの問題はBiDiで越えられた。しかしネイティブUIの抑止が残った
ということでした。
そこでCDPを最小限だけ使う
Chromium CDPには、
Page.setInterceptFileChooserDialog
があります。
これを有効にすると、Chromiumはネイティブfile chooserを表示せず、制御をプロトコルクライアントへ渡します。
特に今回重要だったのが、
enabled = true
cancel = false
です。
CDP仕様でも、interceptionを有効にするとネイティブpickerを表示せず Page.fileChooserOpened を発生させ、cancel=false ならキャンセルイベントを追加で発生させないことが定義されています。
そこで最終的に、
CDP:
Page.setInterceptFileChooserDialog
(enabled=true, cancel=false)
↓
BiDi trusted click
↓
短命 <input type=file> 生成
↓
BiDi:
input.fileDialogOpened
↓
element.sharedId
↓
BiDi:
input.setFiles
↓
CDP interception解除
という構成にしました。
重要なのは、ファイルアップロード処理をCDPへ移したわけではないことです。
CDPの仕事は、
ネイティブpickerを表示させない
という一点だけです。
短命inputの検出、要素の識別、ファイル設定、クリック、完了待機はBiDi側に残しました。実装上もCDPの役割はnative UI境界に限定されています。
私はこの構成を、
BiDi-first, CDP-assisted
と考えています。
実験結果
このハイブリッド方式では、
ExecuteSetFileSelectionViaDialog ... SUCCESS
input.setFiles ... success
cancelCount = 0
changeCount = 1
selectedVisible = True
fileName matches = PASS
となりました。
つまり、
Windows pickerを出さない
+
cancelを発生させない
+
短命inputをBiDiで取得
+
input.setFilesを成功させる
+
changeを正常発火
を同時に実現できました。
そしてPlaywrightの実装を調べてみた
ここで興味が湧きました。
Playwrightなら、この問題をどう処理しているのだろう?
PlaywrightのChromiumバックエンドを確認すると、file chooser interceptionで実際に、
Page.setInterceptFileChooserDialog
を送信しています。
さらにChromiumの、
Page.fileChooserOpened
を監視してfile inputを取得しています。
そしてファイル設定には、
DOM.setFileInputFiles
というCDPコマンドを使用しています。
つまりPlaywrightのChromium実装でも、この領域ではCDPが使われています。
ここは表現に注意が必要です。
「PlaywrightはCDPでファイルアップロードしている」と一般化するのではなく、
PlaywrightのChromiumバックエンドでは、file chooserのinterceptionとfile inputへのファイル設定にCDPを利用している
というのが正確です。
これを知り、私は「勝手に」ライバルと思っているPlaywrightに対抗すべくCDPの利用に大きく傾くことになりました。
興味深い違い
今回のSeleniumVBA BiDi版とPlaywright Chromiumを並べると、違いが見えてきます。
Playwright Chromiumでは
Playwright / Chromium
CDP Page.setInterceptFileChooserDialog
↓
CDP Page.fileChooserOpened
↓
CDP DOM.setFileInputFiles
一方、BiDi版では、
SeleniumVBA BiDi
CDP Page.setInterceptFileChooserDialog
↓
BiDi input.fileDialogOpened
↓
BiDi SharedReference
↓
BiDi input.setFiles
です。
CDPを完全に避けることはできませんでした。
しかし、CDP依存をネイティブpicker抑止という一点に閉じ込めることはできました。
「BiDiではできない」という話ではない
ここは重要です。
現在のWebDriver BiDi仕様にはすでに、
input.fileDialogOpenedinput.setFilesuserPromptHandler.file
が定義されています。
したがって、
WebDriver BiDi規格ではfile chooserを扱えない
という結論ではありません。
今回言えるのは、
今回テストしたEdge/Chromium環境では、短命file inputの検出とファイル設定はBiDiで解決できたが、無人自動化に必要なネイティブpickerの抑止まで含めると、CDPの補助が最も実用的だった
ということです。
仕様の進化やブラウザ実装によって、将来このCDP補助が不要になる可能性もあります。
まとめ
最初は、
短命な
<input type="file">が消えてしまうから操作できない
のだと思っていました。
実験してみると、違いました。
短命input
↓
BiDiイベントで捕捉できた
DOMから削除
↓
SharedReferenceで操作できた
10秒待機
↓
input.setFilesできた
ネイティブpicker
↓
ここが最後まで残った
そしてその最後の一点を、
CDP Page.setInterceptFileChooserDialog
で補いました。
さらに調べてみると、PlaywrightのChromium実装も同じCDP機能を利用していました。
今回の実験から得た結論は、
標準規格かブラウザ固有機能か、二者択一にする必要はない
ということです。
基本はWebDriver BiDi。
そして現時点で標準だけでは埋めにくいブラウザ固有の境界については、CDPを限定的に使う。
BiDi-first, CDP-assisted.
短命なfile inputとの格闘は、結果的にWebDriver BiDiとCDPの役割分担を考える良い実験になりました。