なるべく人間に似せてスクレイピングする方法

なるべく人間と同じ条件でスクレイピングしたいケースがあります。そのための方法をまとめました。

人間度合いの要求レベルがかなり高いケース向けです。要求レベルが低い場合は他の方法を使ったほうが保守性や実装コスト面で良いです。

## 人間度合いの比較

1. 人間
2. ヘッド有りブラウザの外から自動操作
3. ヘッド有りブラウザをChrome拡張機能で操作 <- **この記事の対象はここ以上**
4. ヘッド有りSelenium, Puppeteer
5. ヘッドなしSelenium, Puppeteer
6. PhantomJS
7. Curl

## ベストプラクティスまとめ

- Docker内でVNCを動かし内部でヘッド有りChromeを動かす
- ブラウザ操作はChrome拡張機能で行うのが良い
- VNCはtigervncserverが良い
- VNCはパスワードなしにして、VNC over sshを使うことでセキュリティを担保 -> [dockerイメージ](https://github.com/contribu/ssh_tunnel_server)
- プル型アーキテクチャ
- データ取得ロジックとスクレイピング特有のロジックを分離

## ポイント

人間に近づけようと思うと、ヘッドレスブラウザやSeleniumやPuppeteerは使えません。そうするとどうしても保守性が低下します。

いかに保守性を維持するかがポイントです。

## アーキテクチャー

スクレイピングは、データ取得 -> パース -> データ取得 -> パースという結構複雑な処理が行われます。その間にプロキシやブラウザなどが挟まるので余計に複雑になります。

ポイントは３つです。

### 1. タスクを小分けにする

スクレイピングをラフに書くと、データ取得やパースなどを一連の流れとして書くと思います。すると、プロキシやブラウザや細かいエラー処理で複雑化するので、タスクを小分けにしておきます。

具体的には、データ取得、パース、新規開拓などに分けます。

### 2. 状態を永続化

タスクを小分けにするためには、ラフにやっていたときは状態だと意識していなかったようなものも、状態として永続化しておく必要があります。

具体的には、各タスクの内容(取得先URL、前回取得日時、実行状態)などを、細かく保存します。

### 3. プル型

複雑なものをプッシュ型で実装すると失敗しやすいです。プル型で実装します。

具体的には、ヘッド有りのブラウザが自律的にスクレイピングの中の「データ取得」を担うようにします。どのデータを取得するべきか？をどこかのサーバーから取得し、所望のデータを用意し、どこかのサーバーに送ります。

データ取得はスクレイピングシステム中でもっとも不安定な部分です。予期せぬ理由でエラーが発生しやすいです。自律的に動かすことで、それらの影響を受けづらくなります。落ちても問題ないし、過負荷なら増やせばOKです。

## データ取得の実装

### ヘッド有りのブラウザ環境をかんたんに管理する

https://medium.com/google-cloud/linux-gui-on-the-google-cloud-platform-800719ab27c5
こちらの方法で、Google Compute Engine上でGUI環境を動かせます。Ubuntu 16で行けました。デスクトップ環境を二種類から選べますが、軽量なxfce4で問題ありませんでした。

https://medium.com/@h.taiju/install-google-chrome-on-debian-via-command-line-aba69ea585b7
この方法でChromeをインストールします。

インスタンステンプレートを作っておけば、かんたんにヘッド有りのブラウザをデプロイできます。

### ヘッド有りのブラウザを操作する

SeleniumやPuppeteerを使わずにブラウザを操作する方法は、ブラウザの外からGUIで自動操作したり、Chrome拡張機能を使う方法があります。

Chrome拡張機能を使う方法は、 https://tampermonkey.net/ を使えばかんたんに実装できます。

しかし、Chrome拡張機能はサイトの動作に影響を与えてしまうので、ブラウザの外から自動操作する方法がもっとも人間に近いと思います。(人間も拡張機能はたくさん使っているので、そこまで遠くないですが)

具体的なやり方は考え中です。

### Sikulixを使う方法

https://qiita.com/dzzds/items/728ff14a42bd9f0f8513
こちらの方法でインストールしました。

以下の方法でChromeにURL遷移させられました。

<img width="619" alt="screenshot 2018-12-29 2.05.15.png" src="https://qiita-image-store.s3.amazonaws.com/0/115870/41d8d4c2-4f2a-8f28-c74f-3c361862a17a.png">

名前をつけて保存すれば人間らしくhtmlを取得できます。以下のスクリプトでできます。試したところ、ウェブページ完全で保存すれば元のソースコードではなく、今のDOMが保存されるみたいなのでSPAにも対応できますね。HTMLのみで保存すると元のソースコードが保存されます。

<img width="552" alt="screenshot 2018-12-29 2.17.27.png" src="https://qiita-image-store.s3.amazonaws.com/0/115870/e71075d3-c5a1-8b23-880b-021eebdb906d.png">

### Sikulixスクリプトをコマンドラインから実行する方法

以下のようにコマンドラインから実行できます。これで完全に自動化できますね。キタコレ

```
java -jar sikulix.jar -r test.sikuli
```

### Chromeをコマンドラインから特定位置に起動する方法

https://stackoverflow.com/questions/13436855/launch-google-chrome-from-the-command-line-with-specific-window-coordinates

--window-position=x,yオプションで行けるみたいです。sikulixの画像認識を使わなくても良くなりそうです。

### Chrome拡張機能セットアップの自動化

https://stackoverflow.com/questions/16800696/how-install-crx-chrome-extension-via-command-line
難しいみたいです。ここは頑張って手動でも良いかもしれません。

## 付録

https://gist.github.com/contribu/5eff7f2fa134f992888224abc231ae10

## 追記

Chrome拡張機能をコマンドラインからロードできました。
```
/path/to/google-chrome --load-extension=/path/to/chrome_extension
```

Chrome拡張機能が保守性の面で良さそう。

## 追記2

### GUIをリモートでいじれるようにするベストプラクティス

#### 結論

- ssh tunnel用のサーバー in Docker
- VNCサーバー in Docker

の構成になりました。
環境をコードで管理したかったのと、セキュリティ的に問題ない状態で、GUIをリモートでいじれるようにしたかったからです。

#### セキュリティ

ssh tunnel + VNCが良いみたいです。

#### ssh tunnel用のDocker image

https://hub.docker.com/r/contribu/ssh_tunnel_server
https://github.com/contribu/ssh_tunnel_server

#### どのVNC？

いろいろVNCがありますが、tigervncがDockerで動かす用途にあっていました。-SecurityTypes Noneオプションでパスワードを不要にできるし、-fgオプションでフォアグラウンド起動できるからです。

tigervncインストール方法 for ubuntu 16.04

https://www.stoutpanda.com/2016/10/20/tigervnc-ubuntu-1604-xenial-xerus/
dpkgのインストールが失敗するのを無視しつつ、apt-get install -fで治すのがコツみたいです。-fは--fix-brokenのことです。

tigervncインストール方法 for 他のOS

https://github.com/TigerVNC/tigervnc/releases
こちらからたどります。

以下のリンクに行き、
https://bintray.com/tigervnc/stable/tigervnc/1.9.0

最終的にFilesタブを行くと
https://bintray.com/tigervnc/stable/tigervnc/1.9.0#files

いろいろな環境向けのインストーラーがあります。

#### ssh -XC

リモートですでに起動しているウィンドウを操作したいのに、新しくウィンドウをローカル側に作る感じだったので、やりたいことと違いました。

#### ssh -XC + VNC localhost:5901

ssh -XCをした上で、xvncでvncserverに接続する方法です。
遅くて使い物になりませんでした。

### xdotool

Google Compute EngineのUbuntu 16.04で直に試したときはxdotoolがsegmentation faultになったのですが、Docker内のUbuntu 16.04で試したら、なりませんでした。

### xprop

xpropコマンドを実行しながら、GUI上でウィンドウをクリックすると、そのウィンドウの情報を取得できるツールです。WindowsでよくあるツールのX版です。

### 全自動デプロイ

全自動デプロイをする上で邪魔なのが、初回起動時のウィンドウです。

xpropとxdotoolを駆使して、xfce4とChromeの初回起動時のウィンドウを、Dockerfile内でクリックしました。Dockerfileで仮想的なGUIを作ってクリックすることで状態変化させるってすごいおおげさですよね。でも、ぐぐっても初回起動時のウィンドウを消す方法がわからなかったので、こうしました。

随所にウェイトが必要です。どうせdocker buildは時間がかかるので、安全をとって60秒待つとかで良いと思います。

また、X windowが開きっぱなしになってしまうので、Dockerfile内で終了させる必要があります。

これで全自動デプロイが可能になりました。

xfce4の最初のモーダルを消すスクリプト例 (Dockerfile内に記述する)

```dockerfile
RUN ( \
    echo close xfce4 first prompt \
    && export DISPLAY=:1.0 \
    && /usr/bin/tigervncserver -SecurityTypes None \
    && sleep 10 \
    && xdotool windowactivate $(xdotool search --onlyvisible --classname "Migrate") \
    && xdotool key Return \
    && /usr/bin/tigervncserver -kill :1 \
  )
```

Chromeの最初のモーダルを消す例 (Chromeは一回起動し強制終了すると二回目以降モーダルが消えるのでtimeoutコマンドで終了する)

```Dockerfile
RUN ( \
    echo close Chrome first prompt \
    && export DISPLAY=:1.0 \
    && /usr/bin/tigervncserver -SecurityTypes None \
    && sleep 10 \
    && (timeout 10 google-chrome --no-sandbox || echo killed by timeout command) \
    && /usr/bin/tigervncserver -kill :1 \
  )

```

### 俺が作った最強の「VNCで接続できる仮想GUI環境」Dockerfile

コピペして使ってください。rails用のコードとか入ってますが、そのへんは適宜用途にあわせて置き換えてください。前述のssh tunnelのDockerを組み合わせると、VNC over sshをかんたんに作れると思います。

```Dockerfile
FROM ubuntu:16.04

ENV APP_ROOT /app
ENV RAILS_ENV production
ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
WORKDIR $APP_ROOT

RUN apt-get update \
  && apt-get install -y software-properties-common \
  && add-apt-repository -y ppa:brightbox/ruby-ng \
  && apt-get update \
  && apt-get install -y xfce4 xfce4-goodies xdotool ruby2.5 ruby2.5-dev build-essential wget

RUN ( \
    cd $(mktemp -d) \
    && wget -O tigervnc.deb https://bintray.com/tigervnc/stable/download_file?file_path=ubuntu-16.04LTS%2Famd64%2Ftigervncserver_1.7.0-1ubuntu1_amd64.deb \
    && (dpkg -i tigervnc.deb || echo ignore error) \
    && apt-get install -f -y \
  ) \
  && ( \
    cd $(mktemp -d) \
    && wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
    && (dpkg -i google-chrome*.deb || echo ignore error) \
    && apt --fix-broken install -y \
  ) \
  && gem install bundler \
  && rm -rf /tmp/*

RUN ( \
    echo close xfce4 first prompt \
    && export DISPLAY=:1.0 \
    && /usr/bin/tigervncserver -SecurityTypes None \
    && sleep 10 \
    && xdotool windowactivate $(xdotool search --onlyvisible --classname "Migrate") \
    && xdotool key Return \
    && /usr/bin/tigervncserver -kill :1 \
  )

RUN ( \
    echo close Chrome first prompt \
    && export DISPLAY=:1.0 \
    && /usr/bin/tigervncserver -SecurityTypes None \
    && sleep 10 \
    && (timeout 10 google-chrome --no-sandbox || echo killed by timeout command) \
    && /usr/bin/tigervncserver -kill :1 \
  )

COPY Gemfile $APP_ROOT
COPY Gemfile.lock $APP_ROOT
RUN bundle install --jobs=8 --retry=3

COPY . $APP_ROOT

EXPOSE 5901
CMD /usr/bin/tigervncserver -fg -SecurityTypes None -geometry 1920x1200
```

## プチハマり集

プチと言いつつ、最初遭遇したときはかなりハマってます。

### DISPLAY指定し忘れ (ハマり時間目安: 20分)

X系のアプリケーションは、全て環境変数DISPLAYを参照して、どこにウィンドウを起動させるか決めるらしいです。　なので以下のようにDISPLAYを指定します。

```bash
export DISPLAY=:1.0
xprop
```

例えば、
[1230/163705.833977:ERROR:nacl_helper_linux.cc(310)] NaCl helper process running without a sandbox! 
Most likely you need to configure your SUID sandbox correctly

このエラーはぐぐると以下の--disable-setuid-sandboxオプションにたどりつきますが、DISPLAYの指定し忘れでも出ます。忘れたころにハマります。
https://github.com/Googlechrome/puppeteer/issues/290#issuecomment-322852784

### google-chrome --no-sandboxつけ忘れ (ハマり時間目安: 20分)

google-chromeをrootで実行するときは--no-sandboxを指定しないと起動できないみたいです。

```bash
google-chrome --no-sandbox
```

### vncのパスワードのpermission (ハマり時間目安: 20分)

最終的にはtigervncserverを使うようになったのでパスワードは不要ですが、~/.vnc/passwdは.ssh配下と同じように、permissionをそれらしくしておかないと、起動できません。

### google-chromeが落ちる問題 (ハマり時間目安: 3時間)

#### 解決策結論
- docker起動時に、shm_size: 1gを指定する。理由 -> https://github.com/SeleniumHQ/docker-selenium/issues/79
- dbusはエラーログが出るがクラッシュには影響しない
- NaClはエラーログが出るがクラッシュには影響しない

#### 詳細

以下のエラーで落ちました。Chrome拡張機能のbackgroundページを開くと落ちます。開くこと自体が悪いのが、私の書いたスクリプト特有の問題なのかはわかっていません。推測ですが、なんらかの拡張機能を使うときに、dbusを呼び出すコードまたは、NaCl helperを呼び出すコードが呼ばれ、落ちるのだと思います。dbusとNaClのどちらが原因かはわかっていません。
-> ウィンドウをマウスで動かしただけでも落ちました。

```
[147:541:1231/153645.510766:ERROR:bus.cc(396)] Failed to connect to the bus: Failed to connect to socket /var/run/dbus/system_bus_socket: No such file or directory
Bus error (core dumped)
[1231/153717.833668:ERROR:nacl_helper_linux.cc(310)] NaCl helper process running without a sandbox!
Most likely you need to configure your SUID sandbox correctly
```

質問
https://lists.freedesktop.org/archives/dbus/2011-December/014871.html

回答
>
It is created by dbus-daemon
https://lists.freedesktop.org/archives/dbus/2011-December/014872.html

なるほど、/var/run/dbus/system_bus_socketについては、dbus-daemonを起動すれば良いみたいです。Docker特有の問題ですね。

dbusとは？ -> https://ja.wikipedia.org/wiki/D-Bus

なるほど、dbus-daemonにsubscribe、publishすることで、メッセージをやりとりできる仕組みですね。

実行方法
https://stackoverflow.com/questions/42898262/run-dbus-daemon-inside-docker-container

参考資料 https://dbus.freedesktop.org/doc/dbus-launch.1.html

DBUS_SESSION_BUS_ADDRESSがないとダメみたいです。

以下の感じでやってもうまくいきませんでした。

```bash
export $(dbus-launch)
/usr/bin/tigervncserver -kill :1
/usr/bin/tigervncserver -SecurityTypes None -geometry 1920x1200
```

超絶ハマりましたが、(previledgeをつけないといけないとか、docker外のdbusを使うとか)、最終的に、以下の方法にしました。tmpfsで解決するみたいです。
https://github.com/moby/moby/issues/28614#issuecomment-261724902

次に以下のエラーが出ました。

```
Unable to get session bus: Unknown or unsupported transport 
```

ぐぐったところ、以下で解決できるみたいです。　https://askubuntu.com/questions/1005623/libdbusmenu-glib-warning-unable-to-get-session-bus-failed-to-execute-child

以下の記述をtigervncserverの起動前に入れました。

```
export $(dbus-launch)
```

これでdbus系のエラーは消えました。正確には最初の一回のみ、Failed to connect to the bus: Failed to connect to socket /var/run/dbus/system_bus_socket: No such file or directoryが表示されますが、それ以降のが消えます。このエラーが出たあとに、/var/run/dbus/system_bus_socketが存在するか確認しても存在しません。よくわからないですが、落ちるのが問題であり、このエラーがクラッシュ直前のログから消えたのであれば、これが落ちる原因である可能性は低いので、これ以上深追いしません。

NaClエラーについて調べます。

```
[1231/153717.833668:ERROR:nacl_helper_linux.cc(310)] NaCl helper process running without a sandbox!
Most likely you need to configure your SUID sandbox correctly
```

https://ja.wikipedia.org/wiki/Google_Native_Client
塩化ナトリウムを意識しているみたいですね。

ネイティブコードを安全に実行する仕組みみたいです。
--no-sandboxを指定してgoogle-chromeを起動しているので、意図した結果ですが、これで落ちているのでどうするか？

https://stackoverflow.com/questions/212466/what-is-a-bus-error
なるほど、Bus error (core dumped)はアセンブラ命令の使い方が間違えているときに出るみたいです。推測ですが、NaClがセキュリティのために意図的に出しているのでは？

chrome://sandboxにアクセスした結果です

<img width="597" alt="screenshot 2019-01-01 1.57.07.png" src="https://qiita-image-store.s3.amazonaws.com/0/115870/9be9b623-8f26-ae50-1cbe-9ee9601fc68d.png">

rootで実行しなければ回避可能な気もします。別ユーザーで実行する方法を試してみました。以下のエラーが出るようになりました。

```
Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
```

rootで動かすべきか、--no-sandboxでrootで動かすべきか？、そしてその方法のついて、深い議論がされています。
https://github.com/jessfraz/dockerfiles/issues/65

以下を追加したら、別ユーザーで実行できるようになりましたが、クラッシュします。

```
cap_add:
- SYS_ADMIN
```

また、Failed to connect to the bus: Failed to connect to socket /var/run/d
bus/system_bus_socket: No such file or directoryが再発しています。前述の修正で直ったのが実は直っていなかったか、別ユーザーで実行すると前述の修正が効かないかのどちらかです。

別の仮説で、Dockerをビルドした環境と実行する命令セットが異なり、単純に対応していない命令が実行されたという可能性もあります。他の方法がダメなら疑ってみます。

スタートに戻って考え直してみます。起動してから落ちるまでのログです。

```
google-chrome --load-extension\=/tmp/d20181231-154-1qcb6dy/chromeext --window-position\=128,128 --window-size\=1600
,1024
[161:161:1231/175437.209209:ERROR:browser_dm_token_storage_linux.cc(93)] Error: /etc/machine-id contains 0 characte
rs (32 were expected).
[161:193:1231/175438.719824:ERROR:bus.cc(396)] Failed to connect to the bus: Failed to connect to socket /var/run/d
bus/system_bus_socket: No such file or directory
[161:218:1231/175439.841975:ERROR:object_proxy.cc(621)] Failed to call method: org.freedesktop.Notifications.GetCap
abilities: object_path= /org/freedesktop/Notifications: org.freedesktop.DBus.Error.Spawn.ChildExited: Process org.f
reedesktop.Notifications exited with status 1
[161:161:1231/175440.575230:ERROR:gpu_process_transport_factory.cc(967)] Lost UI shared context.
[161:161:1231/175444.654868:ERROR:x11_input_method_context_impl_gtk.cc(144)] Not implemented reached in virtual voi
d libgtkui::X11InputMethodContextImplGtk::SetSurroundingText(const base::string16 &, const gfx::Range &)
[161:1014:1231/175457.920154:ERROR:bus.cc(396)] Failed to connect to the bus: Failed to connect to socket /var/run/
dbus/system_bus_socket: No such file or directory
[161:1014:1231/175457.920276:ERROR:bus.cc(396)] Failed to connect to the bus: Failed to connect to socket /var/run/
dbus/system_bus_socket: No such file or directory
[161:1014:1231/175457.920357:ERROR:bus.cc(396)] Failed to connect to the bus: Failed to connect to socket /var/run/
dbus/system_bus_socket: No such file or directory
[161:1014:1231/175457.920401:ERROR:bus.cc(396)] Failed to connect to the bus: Failed to connect to socket /var/run/
dbus/system_bus_socket: No such file or directory
[161:1014:1231/175457.920440:ERROR:bus.cc(396)] Failed to connect to the bus: Failed to connect to socket /var/run/
dbus/system_bus_socket: No such file or directory
[161:180:1231/175459.689792:ERROR:crash_handler_host_linux.cc(443)] Failed to write crash dump for pid 936
Cannot upload crash dump: failed to open
--2018-12-31 17:55:00--  https://clients2.google.com/cr/report
Resolving clients2.google.com (clients2.google.com)... 64.233.170.100, 64.233.170.102, 64.233.170.139, ...
Connecting to clients2.google.com (clients2.google.com)|64.233.170.100|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: unspecified [text/html]
Saving to: ‘/dev/fd/4’
```

これらのログの中に、落ちる原因となったログの他に、落ちたから出るログもありそうです。

https://bbs.archlinux.org/viewtopic.php?id=236020
これか？

https://bugs.launchpad.net/ubuntu/+source/chromium-browser/+bug/1563184
これか？

普通に終了させた場合のログはこれ

```
google-chrome --load-extension\=/tmp/d20181231-154-1sgddqu/chromeext --window-position\=128,128 --window-size\=1600,1024
[1288:1288:1231/181857.678365:ERROR:browser_dm_token_storage_linux.cc(93)] Error: /etc/machine-id contains 0 characters (32 were expected).
[1288:1312:1231/181857.790372:ERROR:bus.cc(396)] Failed to connect to the bus: Failed to connect to socket /var/run/dbus/system_bus_socket: No such file or directory
[1325:1325:1231/181858.075092:ERROR:sandbox_linux.cc(364)] InitializeSandbox() called with multiple threads in process gpu-process.
[1288:1343:1231/181858.098217:ERROR:object_proxy.cc(621)] Failed to call method: org.freedesktop.Notifications.GetCapabilities: object_path= /org/freedesktop/Notifications: org.freedesktop.DBus.Error.Spawn.ChildExited: Process org.freedesktop.Notifications exited with status 1
[1288:1288:1231/181858.314987:ERROR:x11_input_method_context_impl_gtk.cc(144)] Not implemented reached in virtual void libgtkui::X11InputMethodContextImplGtk::SetSurroundingText(const base::string16 &, const gfx::Range &)
[1288:1288:1231/181858.464506:ERROR:gpu_process_transport_factory.cc(967)] Lost UI shared context.
[1:8:1231/181858.767616:ERROR:command_buffer_proxy_impl.cc(105)] ContextResult::kTransientFailure: Shared memory region is not valid
```

ログからは原因がわかりませんね。クラッシュでぐぐってみます。面倒だけどクラッシュダンプを見てみるか？

https://bugs.launchpad.net/ubuntu/+source/chromium-browser/+bug/1563184
これか？

youtube動画が埋め込まれていると落ちやすい気がします。拡張機能がなくても落ちるので、拡張機能のせいではない。

https://github.com/SeleniumHQ/docker-selenium/issues/79
これか？ -> **これだ** elgalu神

まじかよ。行く年来る年見てから3時間ハマった

https://github.com/jessfraz/dockerfiles/issues/65#issuecomment-145938346
これに書いてるし・・・

超絶ハマりました。

## 先駆者

さんざんハマってから先駆者を見つけました。Chromeを動かすDockerイメージです。

https://github.com/jessfraz/dockerfiles/blob/master/chrome/stable/Dockerfile

## ライセンス

この記事に記載されたソースコードのライセンスはCC0です。

## 最後に

試行錯誤でものすごく時間を喰ったので、同じことをやる方の時間短縮になったら良いなと思います。
質問があればコメントで教えてください。