Help us understand the problem. What is going on with this article?

headlessなChromeをPHPで操作する(2)

概要

(PHPで)headless ChromeをDevTools Protocolを使って操作してみようという前回の記事の続きです。
前回の記事作成からおよそ2年経過していますが、2019/12現在でもたまーにストックされたりしているので書きます。

今回はフォーム操作についてです。具体的にはHTML上のtext inputに任意のテキストを入力してsubmitするという内容になります。

今回のお話を元にまとめたものがこちらにあります。

おさらい

細かな部分は前回の記事を参照ください。ざっくりとおさらいすると、

  • headlessなChromeの操作はDevTools Protocolという名前で定義されている
  • Chromeを起動すると開く特定のendpointに対してWebSocketを使い、JSON形式でやりとりすることによって操作ができる
  • WebSocketで通信できれば良いので、実はこの記事の話はPHPに限らない話
  • 前回同様、サンプルコードはtextalk/websocketを使ってます

フォームにテキストを入力する

今回の流れとしては、以下の通りです。

  1. DOMのルートノードを取得(#document)
  2. document.querySelector('...')的に対象ノードである<input>を取得
  3. テキストを入力する
  4. <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.setAttributeValueDOM.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()しなければならないので、ここではさくっと前者で実装します。

evaluateRuntime.evaluateexpressionを送ればよいので非常に簡単です。もとより、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年後あたりに他の機能も追加していくかもしれません。

https://github.com/nearprosmith/php-headless-chrome

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');
Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away