概要
(PHPで)headless ChromeをDevTools Protocolを使って操作してみようという前回の記事の続きです。
前回の記事作成からおよそ2年経過していますが、2019/12現在でもたまーにストックされたりしているので書きます。
今回はフォーム操作についてです。具体的にはHTML上のtext inputに任意のテキストを入力してsubmitするという内容になります。
今回のお話を元にまとめたものがこちらにあります。
おさらい
細かな部分は前回の記事を参照ください。ざっくりとおさらいすると、
- headlessなChromeの操作はDevTools Protocolという名前で定義されている
- Chromeを起動すると開く特定のendpointに対してWebSocketを使い、JSON形式でやりとりすることによって操作ができる
- WebSocketで通信できれば良いので、実はこの記事の話はPHPに限らない話
- 前回同様、サンプルコードは
textalk/websocket
を使ってます
フォームにテキストを入力する
今回の流れとしては、以下の通りです。
- DOMのルートノードを取得(#document)
- document.querySelector('...')的に対象ノードである
<input>
を取得 - テキストを入力する
-
<form>
をsubmit
DOMのルートノードを取得
ドキュメントを読むと、そのものずばりDOM.getDocument
というメソッドが用意されているのでまるっと使います。今回はrootNodeだけ取れれば十分なのでdepth=0を明示的に指定しました。
idを指定してmethodを呼ぶと、レスポンスは呼び出し時のidを持って返ってくるので、どの呼び出しに対するレスポンスかはidを見て判断することになります。
$client = Client($endpoint);
...(中略)...
// ここまででhttps://google.comに遷移しているものとする
$client->send(json_encode(['id'=>10,'method'=>'DOM.getDocument','params' => ['depth' => 0]));
$rootNode = null;
while($data = json_decode($client->receive()){
if(isset($data->id) && $data->id === 10){
$rootNode = $data->result->root;
break;
}
}
対象ノードを取得
対象ノードの取得は、これまたずばりDOM.querySelector
で大丈夫そうです。パラメータは親にあたるnodeIdとselectorの文字列になります。nodeIdには先程取得したrootNodeのnodeIdを指定してあげます。(jsでいうdocument.querySelector()
のイメージですね)
ちなみに、呼び出し時のidは前回の処理が確実に終わってるのであれば、重複しても問題ないです。
$client->send(json_encode([
'id'=>10,
'method'=>'DOM.querySelector',
[
'nodeId' => $rootNode->nodeId,
'selector' => 'input[name=q]'
]
));
while($data = json_decode($client->receive()){
if(isset($data->id) && $data->id === 10){
print_r($data->result->nodeId); // this is nodeId! yay!
break;
}
}
テキストを入力
テキストの入力はDOM.setAttributeValue
やDOM.setNodeValue
でも出来そうな気配がありますが、せっかくブラウザということもあるので入力をエミュレーションしてみようと思います。とは言っても、1文字1文字キーボードの入力をエミュレーションすると面倒なので、テキスト単位で入力していきます。
※UIテストなどで1文字1文字入力したい場合は、1文字1文この手順を1文字単位で繰り返せば良いはず。厳密性が必要であればInput.dispatchKeyEvent
を使用する
先程のコードに1) focus
, 2) insert text
という手順を足します。
$client->send(json_encode([
'id'=>10,
'method'=>'DOM.querySelector',
'params' => [
'nodeId' => $rootNode->nodeId,
'selector' => 'input[name=q]'
]
));
while($data = json_decode($client->receive()){
if(isset($data->id) && $data->id === 10){
$client->send(json_encode([
'id' => 11,
'method' => 'DOM.focus',
'params' => ['nodeId' => $data->result->nodeId ]
]));
} elseif(isset($data->id) && $data->id === 11){
$client->send(json_encode([
'id' => 12,
'method' => 'Input.insertText',
'params' => ['text' => 'Qiita']
]));
} elseif(isset($data->id) && $data->id === 12){
break; //break while loop
}
}
こうして、id11でノードにフォーカスし、id12でテキストを流し込みました。この状態で、Chromeを落とさずhttp://localhost:9222にアクセスすると、まさにこの状態の様子を見れます。
submitする
submitにもいくつか方法があります。1つはevaluateでform.submit()
を実行してあげる。もう1つはボタンの画面上の座標を取得し、MouseイベントのClickを発火させるという方法です。ただ、画面上の座標を取得するのに結局evaluateでbutton.getBoundingClientRect()
しなければならないので、ここではさくっと前者で実装します。
evaluate
はRuntime.evaluate
にexpression
を送ればよいので非常に簡単です。もとより、focusやinputも全部evaluateでやればいいんじゃないかという話もありますが、調べることに意義があるのです!
$client->send(json_encode([
'id'=>10,
'method'=>'Runtime.evaluate',
'params' => [
'expression' => 'document.querySelector("form[name=f]").submit()'
]
));
while($data = json_decode($client->receive()){
if(isset($data->id) && $data->id === 10){
//submit!
}
if(isset($data->method) && $data->method === Page.frameStoppedLoading){
// ..(中略).. キャプチャするなりタイトル取得するなり
}
}
確認
繰り返しになりますが、あらかじめChromeをheadlessで起動しておき、http://localhost:9222
にアクセスすると状況が確認できますので、無事、検索結果が表示されているのを確認してください。
さいごに
これでDevToolsProtocolを使って、UIテストも出来るようになりましたね!やったァ!
Seleniumやpuppetterがあるのになんでそんな遠回りするか? それは、私にもわかりません。
蛇足
今回のお話までをphpのライブラリにしました。気が向いたら、再び2年後あたりに他の機能も追加していくかもしれません。
Twitterのログインの例
$chrome = new Chrome('/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome');
$page = $chrome->getPage(0);
$page->moveTo('https://twitter.com/login');
$page->type('form.signin input[name="session[username_or_email]"]','username'->type('form.signin input[name="session[password]"]','password')->submit('form.signin');
$page->waitForLoading();
$page->captureTo('./capture3.png');