Laradockでの環境構築からSeleniumでお手軽スクレイピング

## 概要
タイトル通りです。

## 対象者
スクレイピングするのに、Seleniumでやりたいんだ！って人。

## Laradockとは
Laravel + Dockerです。

## Laravelとは
Laravelです。

## Dockerとは
Dockerです。

## Seleniumとは
ウェブアプリケーションの自動化を目的としたブラウザ用のテストツール群です。
しかしながら本記事で解説するようにスクレイピングを行うにあたっても利用できます。
指定したウェブサイトへアクセスし、指定したページに遷移し、指定した情報を読み取ってくるなどの動きが出来ます。

## 今回スクレイピングで利用するツールの概要
- Selenium Server  
クライアントとドライバーの中継役として働くサーバーです。Javaのコードで実装されています。
- Chrome Driver               
ブラウザ操作に関する命令を仲介してくれます。
Chromeじゃなくてもいいですが、今回はChromeを使用します。他にもFirefox、IEなどがあります。
- php-webdriver  
Chrome Driverを動かすためのライブラリ。PHPでは公式にドライバーを動かせるツールが無い為、Facebookが作ったらしいです。

## Seleniumを使用したスクレイピングのしくみ
ざっくり説明すると、
プログラム => PHP WeDriver => Selenium Server => Chrome Driver => Chrome => スクレイピング対象Webサイト

みたいな感じですかね！知らんけど。

## 環境構築
### LaravelとDocker
LaravelとDockerの環境構築については私の書いたズボラなこちらの記事を参考にしてください。
https://qiita.com/heiheiyoyo/items/0ff035d018a1ce268c69

### Selenium
LaravelとDockerの環境構築はできたと思うので、早速SeleniumのコンテナをDockerで立ち上げましょう！

Seleniumコンテナの立ち上げについて必要な記述はLaradockのdocker-compose.ymlとDockerfileに既に記載されてますので、以下のコマンドを叩くだけでOKです。

```terminal
$ docker-compose up -d selenium
```

立ち上がったか確認しましょう。
以下のようにSeleniumコンテナが立ち上がっていればOKです。

```terminal
$ docker ps
d05e612e2baf        laradock_selenium    "/opt/bin/entry_poin…"   3 minutes ago       Up 3 minutes        0.0.0.0:4444->4444/tcp                           laradock_selenium_1
以下省略...
```

ちなみLaradock内のSeleniumのDockerfileを見てみると、以下のようになっており、デフォルトでベースのイメージがselenium/standalone-chromeにとされているので、今回の環境構築の手間が省けました。

```dockerfile
FROM selenium/standalone-chrome

LABEL maintainer="Edmund Luong <edmundvmluong@gmail.com>"

EXPOSE 4444
```

### php-webdriver

php-webdriverはまだインストールされていないので、ここで準備しておきます。
プロジェクト直下にあるcomposer.jsonというファイルに追記していきます。
この、composer.jsonというのはインストールしたいパッケージを記述しておき、後からコマンドを叩いた時に、まとめてインストールする為のファイルです。json形式で記述します。
ちなみに、require-devというのは本番に必要のない開発用のパッケージを記述する場所です。
本番でも必要な場合はrequireに記述しましょう。
今回はとりあえず、require-devに記述しておきます。
バージョンの書き方など詳しく知りたければ調べてください。
以下のリンクが参考になります。
Access Japan
https://access-jp.co.jp/blogs/development/256

とりあえず今回は最新版でインストールします。

追記前

```json
"require-dev": {
        "facade/ignition": "^1.4",
        "fzaninotto/faker": "^1.4",
        "mockery/mockery": "^1.0",
        "nunomaduro/collision": "^3.0",
        "phpunit/phpunit": "^8.0"
    },
```

追記後

```json
"require-dev": {
        "facade/ignition": "^1.4",
        "fzaninotto/faker": "^1.4",
        "mockery/mockery": "^1.0",
        "nunomaduro/collision": "^3.0",
        "phpunit/phpunit": "^8.0",
        "php-webdriver/webdriver": "*"
    },
```

composer updateコマンドを実行します。

composer updateはcomposer.jsonの情報を元に各ファイルを最新版にアップデートするらしいです。
これでプロジェクト内のvenderディレクトリにphp-webdriverというディレクトリが出来たと思います。
この中に実際にブラウザを操作するためのファイルがいろいろと入っています。

この中にChromeDriver.phpに定義されているChromeDriverクラスというものがドライバーの起動等を担ってくれるのですが、今回はコイツを使わず、継承元であるRemoteWebDriverというクラスを直接使っていきます。このRemoteWebDriverクラスにはスクレイピングに必要な処理（どのURLにアクセスするとか、どのページのどの要素を読み込むか等々）が色々と定義されています。（正確にはRemoteWebDriverはWebDriverインターフェースを実装しているので、定義自体はインターフェースで行われています。）

```terminal
$ composer update
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 1 install, 0 updates, 1 removal
  - Removing facebook/webdriver (1.7.1)
  - Installing php-webdriver/webdriver (1.7.1): Downloading (100%)         
php-webdriver/webdriver suggests installing ext-SimpleXML (For Firefox profile creation)
Writing lock file
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi
Discovered Package: facade/ignition
Discovered Package: fideloper/proxy
Discovered Package: laravel/tinker
Discovered Package: nesbot/carbon
Discovered Package: nunomaduro/collision
Package manifest generated successfully.
```

ここまでくれば環境構築は終了ですが、最後にプロジェクト内にvendor/autoload.phpのファイルが存在するかをチェックしておいて下さい。なければcomposer install等を実行し、作っておいて下さい。
あとで使いますが、autoload.phpをrequireするだけで、vendor配下のファイルを自動で読み込めるので、便利です。

## 実装
### クローラの作成
さあ実装に入っていきますが、まずはスクレイピングを行うにあたっての本体的な役割にあたるクローラというプログラムを作っていきましょう。

参考
>クローラ（Crawler）とは、ウェブ上の文書や画像などを周期的に取得し、自動的にデータベース化するプログラムである。「ボット（Bot）」、「スパイダー」、「ロボット」などとも呼ばれる。（Wikipedia）

今回は手軽に実装する為、スクレイピングの開始から終了までを一つのファイルにまとめたものを作っていきます。（Laravelのコマンドクラスを使います。）

本格的にクローラを運用するなら、クローラの構成は、クローリングの処理はコマンドクラスとジョブクラスに分け、非同期処理を行う。さらに他クラスでDBの永続化処理を行うというような実装ができますが、それはまた別の機会にでもやりたいと思います。（やらんけど）

### コマンドクラスの作成
以下のコマンドでコマンドクラスを作りましょう。「php artisan hogehoge（例）」 の「hogehoge」に当たる部分を自作していきます。自作したコマンドを叩けば、クローラの処理が実行されるという感じにしていきたいと思います。

デフォルトでapp/Console/Commands/に作成されます。

```terminal
$ php artisan make:command SeleniumTestCommand
Console command created successfully.
```

作成されたコマンドクラスは以下のようになっていると思います。

```php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

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

    /**
     * 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 mixed
     */
    public function handle()
    {
        //
    }
}
```

このコマンドクラスを書き換えていきましょう。

### ソースコード
先ほど作成したコマンドクラスを適当に以下のように書き換えて下さい。

```php

<?php

namespace App\Console\Commands;

require base_path(). '/vendor/autoload.php';

use Illuminate\Console\Command;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\WebDriverExpectedCondition;

class SeleniumTestCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'scrape:selenium-test';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Seleniumでスクレイピングをするよ';

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

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        // クロームの機能を管理するクラスのインスタンス化
        $options = new ChromeOptions();
        // クローム起動時のオプション格納
        $options->addArguments([
            '--no-sandbox',
            '--headless'
        ]);

        // Chromeブラウザを起動
        $caps = DesiredCapabilities::chrome();
        $caps->setCapability(ChromeOptions::CAPABILITY, $options);
        // ブラウザを実行するプラットフォームを指定。クロームとのセッションがスムーズになる？？
        $caps->setPlatform("LINUX");
        
        // これはSelenium Serverの置いてあるURLなのかな
        $host = 'http://localhost:4444/wd/hub';

        try {
            // なんかよく起動できずに落ちたので、retry()でくくる
            $driver = retry(3, function () use ($host, $caps) {
                // chrome ドライバーの起動、ウイーーーーーーーーーーーン
                return RemoteWebDriver::create($host, $caps, 60000, 60000);
            }, 1000);


            // Y◯hoo!さんのニュースサイトに潜入します
            $driver->get('https://news.yahoo.co.jp');

            dump($driver->getCurrentUrl());

            // ページタイトル「Yahoo!ニュース」が現れるまで待ちます
            $driver->wait()->until(
                WebDriverExpectedCondition::titleIs('Yahoo!ニュース')
            );

            // トップページのトピックをリンクを取得していきます
            $topics_counts = count($driver->findElement(WebDriverBy::className('topicsList_main'))
                                          ->findElements(WebDriverBy::className('topicsListItem')));

            // リンク集
            $links = [];

            for ($i=0; $i <= $topics_counts -1; $i++) {
                $links[] = $driver->findElement(WebDriverBy::className('topicsList_main'))
                                  ->findElements(WebDriverBy::className('topicsListItem'))[$i]
                                  ->findElement(WebDriverBy::tagName('a'))
                                  ->getAttribute('href');
            }

            // リンクの数だけアクセス
            foreach ($links as $link) {

                // リンクが取得できているか
                dump($link);

                // URLにアクセス
                $driver->get($link);

                // ページタイトルに「Yahoo!ニュース」が含まれるものが現れるまで待ちます
                $driver->wait()->until(WebDriverExpectedCondition::titleContains('Yahoo!ニュース'));

                // 記事のタイトルをクローリングします
                $article_title = $driver->findElement(WebDriverBy::className('pickupMain_articleTitle'))
                                        ->getText();

                // 記事のタイトルが取得できているか
                dump($article_title);
            }

            // 処理終了
            return;
        } catch (\Exception $e) {
            echo 'エラーによりスクレイピングが失敗しました。ERROR MESSAGE : '.$e->getErrorMessage().' TRACE : '.$e->getTraceAsString();
        } finally {
            $driver->quit();
        }
    }
}
```

### 実行
以下のコマンドで実行しましょう。

```terminal
$ php artisan scrape:selenium-test
```

うまくいけば、ターミナル常にdumpの出力がされて

```terminal
サイトトップページURL
記事のページのURL
記事のタイトル
記事のページのURL
記事のタイトル
記事のページのURL
記事のタイトル
...
```

みたいになると思います。

うまくいかなければ、、、
てへぺろ。



### 解説

※後半から畳み掛けるように雑になると思います。

#### ChromeOptionsクラスのインスタンス化
ChromeOptionsクラスはChromeの機能を管理するクラスです。

```php
$options = new ChromeOptions（）;
```

#### Chrome起動時に渡すコマンド引数の設定

以下のように指定します。headlessはヘッドレスモードで起動する設定で、no-sandboxはセキュリティを下げ、スリルを味わってスクレイピングしたい人向けの設定です。
addArguments()でChromeOptionsクラスのクラス変数argumentsにarrayで引数を渡しています。
argumentsはChromeの起動時に適用されます。

```php
$options->addArguments([
            '--no-sandbox',
            '--headless'
        ]);
```

**参考オプション一覧**

 * user-data-dir...
ユーザープロファイルの設定。
Chromeは複数のユーザーを使い分けられるらしいので、ユーザー毎にブックマーク、履歴、パスワードなどをプロファイル毎に管理できるのだと思います。
 * --proxy-server...
プロキシサーバーの設定。
プロキシサーバーはクライアントとWebサイトの中継役を行います。セキュリティの向上などが見込まれます。
 * --headless...
ヘッドレスモード。ブラウザのUIなしで起動できます。
 * --no-sandbox...
サンドボックスの外でプロセスを動作させる。
 * window-size...
画面幅指定。
 *  --start-maximized...
画面幅を最大化して起動します。
 * --user-agent...
他のブラウザや端末に偽装できます。（なりすませます。）
 * --incognito...
シークレットモードで起動。履歴とかが残らない。アダルトサイトのスクレイピングとかにいいんじゃないっすかね。（誰がやんねん）
 * --single-process...
シングルプロセスで起動します。通常はタブ、サイト毎のマルチプロセスで起動するっぽいのですが、場合によってはメモリの使用量とか負担になるので使用するといいかもしれません。
 * --disable-javascript...
JavaScriptを無効にします。
 * --disable-popup-blocking...
ポップアップブロックを無効にします。コード内でアラート等に対応する必要がなくなりそうなので、便利ですね。
 * --enable-logging...
ログ出力を有効化します。ファイルはChrome\Application\chrome_debug.logで作成されるみたいです。
 * --log-level...
ログレベルを設定できます。
 * --dump-histograms-on-exit...
終了時に各種統計情報をログファイルへ出力するという設定です。

オプションについては以下のサイトがよくまとめられており、参考になります。
起動オプション - Google Chrome まとめWiki
http://chrome.half-moon.org/43.html#y3a4f50e

#### その他Chromeの設定
今回は設定していませんが、例えば何かしらのファイルをスクレイピングで取得したいとします。
その場合はダウンロードしたファイルの保存先を以下のように設定します。download.default_directoryはデフォルトのダウンロードディレクトリを指定するオプションです。
ちなみにsetExperimentalOption()はChromeOptions APIを介してまだ公開されていないChromeDriverオプションを試す為に使用します。（実験的なオプションを指定する的な。）
prefは起動時の引数とは異なり、起動後？の設定画面の項目のことを指しています。

```php
$options->setExperimentalOption('prefs', [
            'download.default_directory' => "任意のパス",
        ]);
```

#### 設定の反映〜ドライバー立ち上げ
まずは、ドライバーを起動させる際の設定をしています。

```php
// Chromeブラウザを起動
$caps = DesiredCapabilities::chrome();
$caps->setCapability(ChromeOptions::CAPABILITY, $options);
// ブラウザを実行するプラットフォームを指定。クロームとのセッションがスムーズになる？？
$caps->setPlatform("LINUX");
        
// これはSelenium Serverの置いてあるURLなのかな
$host = 'http://localhost:4444/wd/hub';
```

次にドライバーを起動させます。
このタイミングで設定情報を渡してあげて下さい。
ちなみに原因不明ですが、```Curl error thrown for http POST to... ```みたいなエラーを吐き捨てられることがよくあったので、リトライでくくっています。（要らないかも）

```php
// なんかよく起動できずに落ちたので、retry()でくくる
$driver = retry(3, function () use ($host, $caps) {
    // chrome ドライバーの起動、ウイーーーーーーーーーーーン
    return RemoteWebDriver::create($host, $caps, 60000, 60000);
}, 1000);
```

#### いざ、クローリング開始
ドライバーの立ち上げに成功したら、Webサイトへアクセスします。
今回は天下のY◯hoo!さんのニュースサイトにお邪魔しようと思います。
トップページにアクセスして話題になっているトピックのページに遷移し、記事のタイトルを読み取ってくるという流れでいきます。

```php
// Y◯hoo!さんのニュースサイトに潜入します
$driver->get('https://news.yahoo.co.jp');

// ページタイトル「Yahoo!ニュース」が現れるまで待ちます
$driver->wait()->until(
    WebDriverExpectedCondition::titleIs('Yahoo!ニュース')
);
```

トップページの左上部あたりに注目のニュース？的なトピックがあるので、その数をカウントしています。

```php
// トップページのトピックをリンクを取得していきます
$topics_counts = count($driver->findElement(WebDriverBy::className('topicsList_main'))
                              ->findElements(WebDriverBy::className('topicsListItem')));
```

classNameの名前を指定し、トピックの数だけ該当するaタグのhrefを配列に格納していきます。

```php
// リンク集
$links = [];

for ($i=0; $i <= $topics_counts -1; $i++) {

$links[] = $driver->findElement(WebDriverBy::className('topicsList_main'))
                  ->findElements(WebDriverBy::className('topicsListItem'))[$i]
                  ->findElement(WebDriverBy::tagName('a'))
                  ->getAttribute('href');
}
```

リンクが集まれば、リンクの数だけそのページにアクセスしていきましょう。
さらにリンク先に記事のタイトル文章が記載されているので、それを読み取ってきます。

```php
// リンクの数だけアクセス
foreach ($links as $link) {

// リンクが取得できているか
dump($link);

// URLにアクセス
$driver->get($link);

// ページタイトルに「Yahoo!ニュース」が含まれるものが現れるまで待ちます
$driver->wait()->until(WebDriverExpectedCondition::titleContains('Yahoo!ニュース'));

// 記事のタイトルをクローリングします
$article_title = $driver->findElement(WebDriverBy::className('pickupMain_articleTitle'))
                        ->getText();

// 記事のタイトルが取得できているか
dump($article_title);
}
```

大部分の処理はこれで終了です。

#### 補足

ChromeDriverを起動させましたが、場合によっては処理が終了したにも関わらず、プロセスが残り続けることがあるみたいです。

なんか嫌なので、quit()で終了させるようにしましょう。
finallyの中に組み込めば必ず実行されると思います。

```php
} finally {
    $driver->quit();
}
```


<h1>はい！<br>ということでお手軽にスクレイピングができました！<br>現場からは以上です！</h1>