Scrapyでリスタートする

WebサイトのクロールなどをするPythonのフレームワークであるScrapyはリスタート、つまり、実行中に中断して、その後再開時に前回の続きから実行できる機能があります。  
大量のページにアクセスし、時間がかかるスクレイピングをする際に役立ちます。

下記は公式のドキュメントです。  
[Jobs: pausing and resuming crawls](https://docs.scrapy.org/en/latest/topics/jobs.html)

## 機能の概略

機能を試すために次のようなスパイダーを用意しました。http://quotes.toscrape.com を6
ページダウンロードして、内容をログ出力するだけです。

```python:toscrape-restart.py
import scrapy
import json
import time


class QuotesSpider(scrapy.Spider):
    name = "toscrape-restart"

    custom_settings = {
        # 並列でリクエストさせない
        "CONCURRENT_REQUESTS": 1,
        # 中断しやすくするためにリクエストに間隔を設定
        "DOWNLOAD_DELAY": 10,
        # http://quotes.toscrape.com にrobots.txtは存在しないため取得しない
        "ROBOTSTXT_OBEY": False,
    }

    def start_requests(self):
        # バッチ間で状態を保持する（後述）
        self.logger.info(self.state.get("state_key1"))
        self.state["state_key1"] = {"key": "value"}
        self.state["state_key2"] = 0

        urls = [
            "http://quotes.toscrape.com/page/1/",
            "http://quotes.toscrape.com/page/2/",
            "http://quotes.toscrape.com/page/3/",
            "http://quotes.toscrape.com/page/4/",
            "http://quotes.toscrape.com/page/5/",
            "http://quotes.toscrape.com/page/6/",
        ]
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)

    def parse(self, response):
        self.logger.info(
            "first quote author: " + response.css("small.author::text").get()
        )

```

上記のスパイダーは次のコマンドで起動できます。

```
scrapy crawl toscrape-restart
```

これだと通常の実行になります。  
リスタート可能な実行にするためには次のようにJOBDIRを設定します。

```
scrapy crawl toscrape-restart -s JOBDIR=crawls/restart-1
```

このように実行するとリスタートのための情報を格納する`crawls/restart-1`ディレクトリが作成され、再実行できるようになります。（ディレクトリはなければScrapyが作るので前もって用意する必要はありません）  
前述のコマンドで起動して、実行中に`Ctrl-C`で中断させます。たとえば、1ページ目取得直後に停止させると次のような出力になります。

```shell-session
$ scrapy crawl toscrape-restart -s JOBDIR=crawls/restart-1

（前略）

2020-03-24 14:43:04 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/1/> (referer: None)
2020-03-24 14:43:04 [toscrape-restart] INFO: first quote author: Albert Einstein
^C2020-03-24 14:43:06 [scrapy.crawler] INFO: Received SIGINT, shutting down gracefully. Send again to force 
2020-03-24 14:43:06 [scrapy.core.engine] INFO: Closing spider (shutdown)
2020-03-24 14:43:18 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/2/> (referer: None)
2020-03-24 14:43:18 [toscrape-restart] INFO: first quote author: Marilyn Monroe
2020-03-24 14:43:19 [scrapy.statscollectors] INFO: Dumping Scrapy stats:

（後略）

```

2ページ目を取得したところで中断しました。
このように中断した後、最初と同じコマンドを実行することで再開できます。

```shell-session
$ scrapy crawl toscrape-restart -s JOBDIR=crawls/restart-1

（前略）

2020-03-24 14:46:07 [scrapy.dupefilters] DEBUG: Filtered duplicate request: <GET http://quotes.toscrape.com/page/1/> - no more duplicates will be shown (see DUPEFILTER_DEBUG to show all duplicates)
2020-03-24 14:46:10 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/3/> (referer: None)
2020-03-24 14:46:10 [toscrape-restart] INFO: first quote author: Pablo Neruda
2020-03-24 14:46:21 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/4/> (referer: None)
2020-03-24 14:46:21 [toscrape-restart] INFO: first quote author: Dr. Seuss
2020-03-24 14:46:35 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/5/> (referer: None)
2020-03-24 14:46:35 [toscrape-restart] INFO: first quote author: George R.R. Martin
2020-03-24 14:46:47 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/6/> (referer: None)
2020-03-24 14:46:47 [toscrape-restart] INFO: first quote author: Jane Austen
2020-03-24 14:46:47 [scrapy.core.engine] INFO: Closing spider (finished)
2020-03-24 14:46:47 [scrapy.statscollectors] INFO: Dumping Scrapy stats:

（後略）

```

1、2ページ目は`Filtered duplicate request`と表示されて取得されませんでした。その後、中断前に取得しなかった3ページ目以降は普通に取得されました。

## バッチ間で状態を保持する

Scrapyのリスタートには`state`を使って起動間で情報を受け渡す機能があります。  
spiderのstateに情報を格納し、次回起動時に参照できます。  
具体的には最初の`toscrape-restart.py`にあった次の様な使い方で格納できます。

```python
self.state["state_key1"] = {"key": "value"}
self.state["state_key2"] = 0
```

`state`は`dict型`なので、辞書に対して行える操作をできます。上記の例では`state_key1`というキーに`{"key": "value"}`という値、`state_key2`というキーに`0`という値を格納しています。  
実行してみると次のようになります。

```shell-session
$ scrapy crawl toscrape-restart -s JOBDIR=crawls/restart-1

（前略）

2020-03-24 15:19:54 [toscrape-restart] INFO: None
2020-03-24 15:19:55 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/1/> (referer: None)
2020-03-24 15:19:55 [toscrape-restart] INFO: first quote author: Albert Einstein
^C2020-03-24 15:19:56 [scrapy.crawler] INFO: Received SIGINT, shutting down gracefully. Send again to force 
2020-03-24 15:19:56 [scrapy.core.engine] INFO: Closing spider (shutdown)
2020-03-24 15:20:06 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/2/> (referer: None)
2020-03-24 15:20:07 [toscrape-restart] INFO: first quote author: Marilyn Monroe
2020-03-24 15:20:07 [scrapy.statscollectors] INFO: Dumping Scrapy stats:

（後略）

```

1行目に`None`のINFOログが出力されています。これは`self.logger.info(self.state.get("state_key1"))`が出力したもので、最初の起動時には何も`state`に格納されていないため、何も出力されません。  
続く処理で、`state`に情報を格納し、その後中断しました。  
この後、再実行します。

```shell-session
$ scrapy crawl toscrape-restart -s JOBDIR=crawls/restart-1

（前略）

2020-03-24 15:29:31 [toscrape-restart] INFO: {'key': 'value'}
2020-03-24 15:29:31 [scrapy.dupefilters] DEBUG: Filtered duplicate request: <GET http://quotes.toscrape.com/page/1/> - no more duplicates will be shown (see DUPEFILTER_DEBUG to show all duplicates)
2020-03-24 15:29:31 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/3/> (referer: None)
2020-03-24 15:29:32 [toscrape-restart] INFO: first quote author: Pablo Neruda
2020-03-24 15:29:42 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/4/> (referer: None)
2020-03-24 15:29:42 [toscrape-restart] INFO: first quote author: Dr. Seuss
2020-03-24 15:29:56 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/5/> (referer: None)
2020-03-24 15:29:56 [toscrape-restart] INFO: first quote author: George R.R. Martin
2020-03-24 15:30:10 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/6/> (referer: None)
2020-03-24 15:30:10 [toscrape-restart] INFO: first quote author: Jane Austen
2020-03-24 15:30:10 [scrapy.core.engine] INFO: Closing spider (finished)
2020-03-24 15:30:10 [scrapy.statscollectors] INFO: Dumping Scrapy stats:

（後略）

```

再起動すると1行めに`{'key': 'value'}`のINFOログが出力されています。  
中断前に格納した情報が再実行時に参照できていることがわかります。

## その他気づいたこと

概略はここまで述べたとおりですが、その他自分で検証して気づいたことをメモとして残しておきます。

### JOBDIRの役割

リスタート可能な起動の仕方をすると起動時に引数として渡した名前のディレクトリが作成されます。このディレクトリの中には`requests.queue`というディレクトリと`requests.seen`と`spider.state`というファイルがあります。  
この内、`requests.queue`は何に使われているか調べていません……。

`spider.state`は前節の`state`が保存されているファイルです。pickleファイルであり、次のようなコマンドで中身を確認できます。

```
python -m pickle spider.state
```

前節の例のパターンならば、次のような出力になり、情報が確かに格納されていることがわかります。

```
{'state_key1': {'key': 'value'}, 'state_key2': 0}
```

一方、`requests.seen`にはハッシュ化された文字列が記載されています。これもあまり正確に調べていませんが、どうやら`seen`という名前どおりで、実行中に情報を取得したサイトのURLなどが記録されているようです。ここに記録のあるサイトは再実行時にスキップする、という仕組みになっていると思われます。

### 完了後の再実行

実行中に中断せず、最後までスクレイピングを完了した場合、再実行するとすべてのURLが取得済みということで何もせずに終了します。  
JOBDIRを上書きするなど気を利かせてはくれないようです。JOBDIRを削除するか、引数を変える必要があります。