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

## 概要
（PHPで）headless ChromeをDevTools Protocolを使って操作してみようという[前回の記事](https://qiita.com/sarumonera/items/63a14f34e19f1cb96e87)の続きです。
前回の記事作成からおよそ2年経過していますが、2019/12現在でもたまーにストックされたりしているので書きます。

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

今回のお話を元にまとめたものが[こちら](https://github.com/nearprosmith/php-headless-chrome)にあります。

## おさらい

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

- headlessなChromeの操作は[DevTools Protocol](https://chromedevtools.github.io/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を見て判断することになります。

```php
$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は前回の処理が確実に終わってるのであれば、重複しても問題ないです。

```php
$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`という手順を足します。

```php
$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でやればいいんじゃないかという話もありますが、調べることに意義があるのです！


```php
$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のログインの例

```php
$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');
```