Laravel 8.x/PHP7.4 環境で Laravel Dusk の ChromeDriver を使用して Web スクレイピングしてみた

# 概要
Laravel でヘッドレスブラウザを使用して Web スクレイピングをしたいのですが、 Selenium(php-webdriver) や php-phantomJS などより、Laravel 公式パッケージでブラウザテストの自動化およびテスティング API の [Laravel Dusk](https://github.com/laravel/dusk) を使用してWebスクレイピングできないか調べてみました。

現在の環境が PHP7.4 環境ですので、必然的にLaravel のバージョンは 8.x となります。PHP と Laravel のバージョン対応表は [こちら](https://readouble.com/laravel/10.x/ja/releases.html)

# Laravel 8.x プロジェクトの作成
~~~php:
> composer create-project laravel/laravel=8.* scraping
> cd scraping
~~~

# Laravel Dusk のインストール
プロジェクトに Laravel/Dusk Composer 依存パッケージを追加します。
Laravel が 8.x のため dusk 7.x 系はインストールされず、v6.25.2 がインスールされました。
~~~php:
> composer require --dev laravel/dusk
Cannot use laravel/dusk's latest version v7.12.3 as it requires php ^8.0 which is not satisfied by your platform.

> composer show -i | findstr dusk
You are using the deprecated option "installed". Only installed packages are shown by default now. The --all option can be used to show all packages.
laravel/dusk                       v6.25.2  Laravel Dusk provides simple end-to-end testing and browser automation.
~~~
    
Laravel dusk をインストールします。
~~~php:
> php artisan dusk:install  
~~~

ChromeDriver をインストールします。
~~~php:
> php artisan dusk:chrome-driver
ChromeDriver binaries successfully installed for version 114.0.5735.90.
~~~

# ChromeDriver サイトの更新停止
ChromeDriver のバージョン 114 がインストールされたのですが、残念ながら dusk 6.x のままでは ChromeDriver サイトの更新停止により 115 以降に更新できません。現在のクライアントの Chrome ブラウザのバージョンが 122 ですのでこのままではバージョンの不一致により ChromeDriver エラーとなり正常に動作しません。

[ChromeDriver - WebDriver for Chrome - Downloads](https://chromedriver.chromium.org/downloads)

![chromedriver.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237317/6cf1a0f8-496d-c6f6-9aee-c195e33bd33d.png)

 この赤文字の情報から新たなダウンロードサイトは [JSON endpoints](https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json) になります。ちなみに dusk7.x はこの [JSON endpoints](https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json) での更新をサポートしています。

~~~php:
  < 115 : https://chromedriver.storage.googleapis.com/index.html
 >= 115 : https://googlechromelabs.github.io/chrome-for-testing/
~~~

# dusk7.x → dusk6.x クラスの差し替え
dusk6.x 環境でもなんとか ChromeDriver を最新に上げたいなーとソースを斜め読みしながら dusk 6.x と dusk 7.x のソースと比較してみたところ ChromeDriverCommand クラスが ChromeDriver の更新処理を担っていることがわかりました。 

dusk 7.x の ChromeDriverCommand クラスとこのクラスが依存する OperatingSystem クラスを dusk 6.x のクラスと差し替えます。
~~~php:ChromeDriverCommand.php, OperatingSystem.php
dusk7.x → dusk6.x
  /vendor/laravel/dusk/src/Console/ChromeDriverCommand.php
  /vendor/laravel/dusk/src/OperatingSystem.php
~~~

### throw 式構文のダウングレード
差し替えにあたって、dusk7.x の ChromeDriverCommand クラスは PHP7.4 では記述できなかった PHP8.0 以降の throw 式構文による Null 合体演算子で記述されているため、4 ケ所ほど以下のようにダサーく修正します。
~~~diff_php:dusk7.x - ChromeDriverCommand.php
@@ -138,2 +138,4 @@
-        return $milestones['milestones'][$version]['version']
-            ?? throw new Exception('Could not determine the ChromeDriver version.');
+        if (is_null($milestones['milestones'][$version]['version'])) {
+            throw new Exception('Could not determine the ChromeDriver version.');
+        }
+        return $milestones['milestones'][$version]['version'];
~~~

### 再度 ChromeDriver の更新
再度 ChromeDriver をアップデートしたところ、最新の ChromeDriver 122 にすることができました。オリジナルのソース改変はあまりやりたくないのですが、dusk6.x はもう更新されないだろうと汗。
~~~php:
> php artisan dusk:chrome-driver
ChromeDriver binary successfully installed for version 122.0.6261.57.
~~~

# Google Chrome for Testing ブラウザのダウンロード
Chrome ブラウザは Windows にインストール済の Chrome は使用せず、[JSON endpoints](https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json) からポータブルな Google Chrome for Testing ブラウザを[ダウンロード](https://storage.googleapis.com/chrome-for-testing-public/122.0.6261.57/win64/chrome-win64.zip)し適当なディレクトリに展開し、ChromeDriver と Google Chrome for Testing ブラウザとでバージョンを合わせ固定します。
以下の設定を追加することにより、ポータブルな Google Chrome for Testing ブラウザを起動できます。
~~~php
// Google Chrome for Testing ブラウザ
$options->setBinary(storage_path() . '/chrome/chrome-win64/chrome.exe');
~~~
# 使い方
dusk6.x の ChromeDriver を使い Web スクレイピングしてみます。

Command クラスの雛型を生成します。
~~~php
> php artisan make:command ScrapingCommand
Console command created successfully.
~~~

試しに Yahoo! Japan トップページの主要ニュースをスクレイピングしてみましょう！
（利用規約に該当する場合は速やかに訂正いたします。見た感じ大丈夫のようですが）

![yahoo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237317/7613d1a2-7f77-f3c6-d821-2c3fcd7fbc03.png)

~~~php:ScrapingCommand.php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\WebDriverExpectedCondition;
use Facebook\WebDriver\WebDriverBy;
use Laravel\Dusk\Chrome\ChromeProcess;
use Exception;

/**
 * ScrapingCommand class
 */
class ScrapingCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'command:scraping';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        $targetUrl = "https://www.yahoo.co.jp/";

        $process = (new ChromeProcess())->toProcess();
        try {
            if ($process->isStarted()) {
                $process->stop();
            }
            $process->start();

            $options = (new ChromeOptions())->addArguments([
                '--disable-gpu',
                '--headless',
                '--window-size=1200,1000',
                '--no-sandbox'
            ]);

            // Google Chrome for Testing
            $options->setBinary(storage_path() . '/chrome/chrome-win64/chrome.exe');

            $capabilities = DesiredCapabilities::chrome()->setCapability(ChromeOptions::CAPABILITY, $options);

            $driver = retry(5, function () use ($capabilities) {
                return RemoteWebDriver::create('http://localhost:9515', $capabilities, 50000, 60000);
            }, 5000);

            // scraping start
            $driver->get($targetUrl);

            // waiting for 'footer' id load
            $driver->wait(10, 1000)->until(
                WebDriverExpectedCondition::visibilityOfElementLocated(
                    WebDriverBy::id('footer')
                )
            );

            // Yahoo! top topics
            $topics = $driver->findElements(
                WebDriverBy::cssSelector('#tabpanelTopics1 ul a')
            );
            foreach ($topics as $topic) {
                $url =  $topic->getAttribute("href");
                $title = $topic->findElement(
                    WebDriverBy::cssSelector('h1 > span')
                )->getText();

                var_dump($url . " : " . $title);
            }
        } catch (Exception $e) {
            Log::error($e->getMessage() .  "\n");
        } finally {
            $process->stop();
        }
    }
}
~~~

Laravel のコマンド実行をします。
~~~php:execute
> php artisan command:scraping

string(86) "https://news.yahoo.co.jp/pickup/6492295 : 松野・高木氏ら5人 政倫審出席へ"
string(85) "https://news.yahoo.co.jp/pickup/6492279 : 1月の貿易収支 1兆7583億円の赤字"
string(88) "https://news.yahoo.co.jp/pickup/6492292 : 外務省 元徴用工訴訟巡り厳重抗議"
string(88) "https://news.yahoo.co.jp/pickup/6492294 : 西山ファーム 元副社長の身柄確保"
string(85) "https://news.yahoo.co.jp/pickup/6492290 : 榊容疑者 映画監督の立場悪用か"
string(88) "https://news.yahoo.co.jp/pickup/6492289 : 罰則ないカスハラ防止条例 効果は"
string(88) "https://news.yahoo.co.jp/pickup/6492293 : ペット死体巡り批判 市が対応変更"
string(88) "https://news.yahoo.co.jp/pickup/6492299 : 仲里依紗 激変も「今の私が自分」"
~~~

# おわりに
最近はHTMLソースを見ただけではなにをやってるかわからない、ヘッドレスブラウザによるスクレイピングじゃないとNGな動的サイトが増えてきていますね。

次は Chrome DevTools Protocol(CDP) でやってみたいですね。