効率アップのトリック：Webクローラー速度改善の新たな一歩

本記事は、[intimatemerger Advent Calendar 2023](https://qiita.com/advent-calendar/2023/intimatemerger) 5 日目記事です。

世の中の情報は現在すべてインターネットを通じて多くの方に届けられていると言っても過言ではありません。
世の中の動向を調査する際、ネットの記事や SNS の反応などを調査することがあると思います。
そうした中、日々膨大な情報を人の手で見るのは難しいです。なので機械的に調査する方法が必要になってきます。

単純なコードでも時間とコストをかければ望む規模で調査することができます。
しかし、残念なことに時間とコストは限られている場合は望んだ調査ができないかもしれません。
今回の話は、膨大な情報(URL)をどうすれば効率的に調査することができるのかというのに焦点を当てていきます。

## crawler

クローラーは、ウェブ上の情報を収集するためのプログラムまたはスクリプトです。
今回は、Python で使用される Scrapy を使用してクローラーの速度改善を進めていきます。

## 処理したい規模と現状の速度の話

クローラーは、外部のサーバと通信し処理を進めていく性質上どうしても速度が遅くなってしまうことがあります。
下手に scrapy の設定を決めると相手サーバに対して攻撃的なリクエストと見なされる可能性があります。
そのため、慎重に設定を決める必要があります。

### 問題となる速度

速度に関する意見は様々ですが、今回の状況として以下の設定を考えます。

- 1 日の新規 URL が、1,000,000 件
- 現状は、1 時間に 3,000 件
- クローラーは１台

### 設定

```py
# settings.py　抜粋

# robots.txtを尊重するかどうかの設定
ROBOTSTXT_OBEY = True
# ダウンロードのタイムアウト設定（秒）
DOWNLOAD_TIMEOUT = 3
DOWNLOAD_DELAY = 0.5
RANDOMIZE_DOWNLOAD_DELAY = True
RETRY_ENABLED = False

# 同時に処理するリクエストの数
CONCURRENT_REQUESTS = 32

# 同じドメインへの同時リクエスト数
# 多く設定すると高速になりますが、サーバーの負荷が大きくなります
CONCURRENT_REQUESTS_PER_DOMAIN = 1
```

## 速度改善

### 同時リクエスト数を上げる

CONCURRENT_REQUESTS の値を 100 に変更します。
これで、1 時間あたり 25,000 件 に改善しました。

### 余計な URL を除去

全ての URL がクロール可能ではないため、ログインが必要なものや有用な情報が含まれない URL を除外します。
また、多くの domain が存在するため、特に数が大きなものに対して除外設定をクローラに追加します。
以下は、除外する path の例です。

```json
{
  "www.hogehoge.com": [
    "/cart",
    "/buy/",
    "/api/",
    "/bookmark",
    "/inquiry-reply",
    "/purchase"
  ]
}
```

もしかすると中には、クローラ対策されており取得ができない domain があると思うのでそれも除外すると良いと思います

この設定で僕の環境で動作させた時の速度です。

```bash
Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
Crawled 2620 pages (at 2620 pages/min), scraped 1549 items (at 1549 items/min)
Crawled 4915 pages (at 2295 pages/min), scraped 3145 items (at 1596 items/min)
Crawled 6666 pages (at 1751 pages/min), scraped 4312 items (at 1167 items/min)
Crawled 8260 pages (at 1594 pages/min), scraped 5413 items (at 1101 items/min)
Crawled 9962 pages (at 1702 pages/min), scraped 6586 items (at 1173 items/min)
```

1 時間で約 50,000〜60,000 件ほどに改善できました。

### IP ブロック対策

同じ相手に対して常に高速で動く場合、相手にブロックされる可能があります。
現状の設定は以下になります。

```py
DOWNLOAD_DELAY = 0.5
RANDOMIZE_DOWNLOAD_DELAY = True
```

https://docs.scrapy.org/en/latest/topics/settings.html#std-setting-RANDOMIZE_DOWNLOAD_DELAY

> If enabled, Scrapy will wait a random amount of time (between 0.5 _ DOWNLOAD_DELAY and 1.5 _ DOWNLOAD_DELAY) while fetching requests from the same website.

なので、0.25~0.75 の値で DOWNLOAD_DELAY が設定されます。
相手のサーバが高負荷であるときもこの頻度でリクエストが続くと迷惑です。

なので、下記の設定を有効にしていきましょう

```py
AUTOTHROTTLE_ENABLED = True
# The initial download delay
AUTOTHROTTLE_START_DELAY = 5
# The maximum download delay to be set in case of high latencies
AUTOTHROTTLE_MAX_DELAY = 60
```

> This is an extension for automatically throttling crawling speed based on load of both the Scrapy server and the website you are crawling.

これで、自動的に速度を調整されていきます

### AUTOTHROTTLE で速度が落ちる

```bash
Crawled 1178 pages (at 1178 pages/min), scraped 650 items (at 650 items/min)
Crawled 2303 pages (at 1125 pages/min), scraped 1362 items (at 712 items/min)
Crawled 3644 pages (at 106 pages/min), scraped 2255 items (at 75 items/min)
Crawled 3989 pages (at 345 pages/min), scraped 2474 items (at 219 items/min)
Crawled 4662 pages (at 673 pages/min), scraped 2934 items (at 460 items/min)
Crawled 5431 pages (at 769 pages/min), scraped 3459 items (at 525 items/min)
Crawled 5833 pages (at 402 pages/min), scraped 3741 items (at 282 items/min)
```

ここまで、設定していくと`AUTOTHROTTLE`の設定で速度が落ちることがあります。
原因としては、以下が挙げられます。

1. AUTOTHROTTLE
2. CONCURRENT_REQUESTS_PER_DOMAIN が１
3. キューの積み方
4. 特定の domain のレスポンスが遅い

1,2 はこちらが意図的に設定しています。
3 は様々な考えがあり仮に適切な順番にすれば速度は改善すると思いますがかなり難しいです。
4 これが根幹の原因であるため対処していきます。

対処に関して、URL の整理などが終わっている場合でも速度が低下している場合は、最終手段として「`もう1つクローラーを作る`」ことが考えられます。
なぜなら、対象の domain はレスポンスが常に悪く、尚且つ大量に存在するためです。
その domain 専用にもう一つクローラーを作成することで、他の多くの URL をスムーズに処理できるからです。

### middleware

処理中に速度が低下した状況が続くことがあります。
これには様々な原因が考えられますが、もし特定のドメインで 400 番台のエラーが多発している場合は、
対処法として middleware を使用してエラーをキャッチし、リクエストの振る舞いを制御することができるかと思います。
こうすることで、scrapy 内で処理を制御することができれば慢性的な速度低下を防ぐことができると考えられます。

## クエリパラメータ

今回の仮定では新規の URL と設定しましたが、実際のシステムでは、同じ URL も含まれてきます。
弊社では、キャッシュを利用して、一定期間の同じ URL を処理させないなど、フィルタリングを実施しています。
パラメータが変わるとシステムが新規 URL として誤認識する可能性があります。
そのため、URL の整理に加えて、パラメータの整理も進めることで、クロール対象の URL を削減することができます。

色々なサイトがあるためこのパラメータ削除すればいいというのは難しいですが、広告関連のパラメータなどはサイトの振る舞いに直接関係がないと考えられるため、これらを削除しても問題ないでしょう。

削除を進めると、キャッシュヒット率が向上し、クローラが処理する URL が減少する効果が期待できます。

## スクレイピングに向いていないサイト

様々なウェブサイトをスクレイピングしていく中で、特定のサイトが適していないと感じました。
取得したデータの利用用途によりますが、情報量に対して取得コストが見合わないものは、キャッシュ戦略を変えたり、フィルタリングをしたりなどを実施しています。

- EC サイト

  - 商品数のページが多く、新たに登録される商品数もあるため、その数によっては、取得できる情報量に対して、クロールのコストが上がります。

- 過去の投稿が今なお多く閲覧されるサイト
  - 記事が更新されない割に、キャッシュの効果が落ちるため、ボリュームが増えた場合に、クロールのコストが上がります。

## まとめ

クローラは相手サーバと通信するため、ある程度マナーを守る必要があります。
マナーを守りつつ速度を出すにはどうすればいいのか考えました。
データの整理やクローラーの増設することである程度速度を出すことができるようになります。
個人的に URL やパラメータの整理が一番パフォーマンスに関わってきます。
こちらが最終的な結果になります。(１台のクローラ)

| 評価                     | 結果      |
| ------------------------ | --------- |
| 1 日のクロール総数       | 1,245,105 |
| 1 時間でのクロール数     | 76,357    |
| １分間での中央値         | 1,316     |
| １分間での平均クロール数 | 1,272     |
| １分間での最大クロール数 | 3,142     |
| １分間での最低クロール数 | 1         |

1,000,000 件の URL をクロールすることに達成できました！

次は、「Kustomize の patches の話」についてです！
ぜひ、明日の記事も読んでいただきたいです。

https://qiita.com/advent-calendar/2023/intimatemerger