10分で理解する Scrapy

# [Scrapy](https://scrapy.org/) とは
Scrapy とは Python でクローラーを実装するためのフレームワークです
Python でクローラーというと [BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) や [lxml](https://lxml.de/) などの HTML パーサーがよく使われていますが、 Scrapy はこれらのライブラリと違うレイヤーのもので、クローラーのアプリケーション全体を実装するためのフレームワークです

公式ドキュメントでも、BeautifulSoup と Scrapy を比較するのは、jinja2 と Django を比較しているようなものだと言ってます
> In other words, comparing BeautifulSoup (or lxml) to Scrapy is like comparing jinja2 to Django.

https://doc.scrapy.org/en/latest/faq.html#how-does-scrapy-compare-to-beautifulsoup-or-lxml

# TL;DR
- Scrapy はクローラーを実装・運用するために欲しい機能がいろいろ用意されている
- Items は抽出したいデータ構造のモデル
- Spider は対象サイトへのリクエストとレスポンスのパーサー
- Pipeline は抽出したデータに対する加工・保存 (など)

# 登場人物を整理
とりあえずこの3つを理解しておけばクローラーは書けます
### Spider
クロール対象のサイトへのリクエスト、レスポンスのパース処理を記述します
どのようにサイトを辿って、ページの内容をどうパースするかのロジックが Spider に書かれます

### Items
クロール対象のデータから抽出したいデータ構造を記述するモデルのようなものです
目的に応じて自由な構造を定義できます
Items は Spider で生成され Pipeline に渡されます

### Pipeline
Spider より渡された Items に対する処理を記述します
DB への保存、ファイル出力など目的に応じて自由に処理を記述できます

# インストール
以下のように pip でインストールするのみです

```console
$ pip install scrapy
```

インストールが完了すると `scrapy` コマンドが使えるようになります

```console
$ scrapy version
Scrapy 1.5.1
```

# Scrapy を利用してクローラーを作る
## 0.プロジェクトを作成する
`scrapy startproject <プロジェクト名>` のコマンドで新規の scrapy プロジェクトを作成します
この辺りの設計は Django ととても似ています

```console
$ scrapy startproject ten_min_scrapy
```

以下のようなファイルが生成されます

```console
$ tree ten_min_scrapy/
ten_min_scrapy/
├── scrapy.cfg
└── ten_min_scrapy
    ├── __init__.py
    ├── __pycache__
    ├── items.py
    ├── middlewares.py
    ├── pipelines.py
    ├── settings.py
    └── spiders
        ├── __init__.py
        └── __pycache__

4 directories, 7 files
```

## 1.Spider を実装する
それでは scrapinghub のブログである https://blog.scrapinghub.com/ をクロールしてブログ記事の一覧を取得してくる Spider を実装してみましょう

### 1-1. Items を追加する
プロジェクトを作成すると items.py にサンプルのクラスが記述されています

``` python
import scrapy

class TenMinScrapyItem(scrapy.Item):
    # define the fields for your item here like:
    # name = scrapy.Field()
    pass
```

このクラス名は適当に変えてしまってよいので、ブログ記事のURL、タイトル、公開日をもつ `Post` を Items として定義しましょう

``` python
import scrapy

class Post(scrapy.Item):
    url = scrapy.Field()
    title = scrapy.Field()
    date = scrapy.Field()
```

### 1-2. Spider を追加する
新規の Spider は以下のようにプロジェクトのディレクトリ内で `scrapy genspider <スパイダー名> <クロール対象ドメイン名>` コマンドを実行するとひな型のファイルが生成されます

```console
$ cd ./ten_min_scrapy
$ scrapy genspider scrapy_blog_spider blog.scrapinghub.com
```

ひな型のファイルは以下のような内容になっています
このコマンドを使わずに直接該当のファイルを作成しても問題ないです

``` python
import scrapy

class ScrapyBlogSpiderSpider(scrapy.Spider):
    name = 'scrapy_blog_spider'
    allowed_domains = ['blog.scrapinghub.com']
    start_urls = ['http://blog.scrapinghub.com/']

    def parse(self, response):
        pass
```

`start_urls` に指定したURLからクロールがスタートします
レスポンスを受け取ると `parse()` メソッドが呼び出されます

### 1-3. Spider 内の parse() を追加する
blog.scrapinghub.com のページをブラウザから見ると、以下のようにページを辿っていけばブログ記事の一覧を抽出できそうだとわかります

1. ブログの一覧から記事のURL、タイトル、公開日を抽出する
2. ページ内に "OLDER POST" のリンクがあれば再帰的に辿り 1 の処理を繰り返す
3. ページ内に "OLDER POST" のリンクが表示されなければ処理を終了

これを `parse()` 内に実装すると以下のようになります

``` python
import scrapy
from ten_min_scrapy.items import Post

class ScrapyBlogSpiderSpider(scrapy.Spider):
    name = 'scrapy_blog_spider'
    allowed_domains = ['blog.scrapinghub.com']
    start_urls = ['http://blog.scrapinghub.com']

    def parse(self, response):
        """
        レスポンスに対するパース処理
        """
        # response.css で scrapy デフォルトの css セレクタを利用できる
        for post in response.css('.post-listing .post-item'):
            # items に定義した Post のオブジェクトを生成して次の処理へ渡す
            yield Post(
                url=post.css('div.post-header a::attr(href)').extract_first().strip(),
                title=post.css('div.post-header a::text').extract_first().strip(),
                date=post.css('div.post-header span.date a::text').extract_first().strip(),
            )

        # 再帰的にページングを辿るための処理
        older_post_link = response.css('.blog-pagination a.next-posts-link::attr(href)').extract_first()
        if older_post_link is None:
            # リンクが取得できなかった場合は最後のページなので処理を終了
            return

        # URLが相対パスだった場合に絶対パスに変換する
        older_post_link = response.urljoin(older_post_link)
        # 次のページをのリクエストを実行する
        yield scrapy.Request(older_post_link, callback=self.parse)
```

`response.css()` で scrapy のデフォルトで用意されている CSSセレクタを利用できます
HTMLのパースは基本的にはこれをそのまま使えば困ることはないでしょう

### 1-4. クロールの設定を変更する
#### リクエスト間隔を設定する
`settings.py` を開いて `DOWNLOAD_DELAY` にリクエストを送信する間隔(秒)をセットしましょう
デフォルトではコメントアウトされています
リクエスト間隔が短すぎると DoS攻撃と同等のアクセスとなってしまうので、少なくとも数秒程度は間隔を空けるようにしましょう

``` python
# Configure a delay for requests for the same website (default: 0)
# See https://doc.scrapy.org/en/latest/topics/settings.html#download-delay
# See also autothrottle settings and docs
DOWNLOAD_DELAY = 3
```

#### レスポンスのキャッシュを設定する
Scrapy ではレスポンスのキャッシュ機能も用意されています
Spider の実装をして試行錯誤する時には何度も同じページへのリクエストが実行されてしまうので、特に開発中にはキャッシュを有効にした方がいいです

`settings.py` を開いて `HTTPCACHE_` から始まる以下の項目のコメントを外せばキャッシュが有効になります

``` python
# Enable and configure HTTP caching (disabled by default)
# See https://doc.scrapy.org/en/latest/topics/downloader-middleware.html#httpcache-middleware-settings
HTTPCACHE_ENABLED = True
HTTPCACHE_EXPIRATION_SECS = 0
HTTPCACHE_DIR = 'httpcache'
HTTPCACHE_IGNORE_HTTP_CODES = []
HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.FilesystemCacheStorage'
```

### 1-5. 一度動かしてみる
さて、ここまでで実装した Spider を一度実行してみましょう
プロジェクトのディレクトリで以下を実行すると Spider が実行できます

``` console
$ scrapy crawl scrapy_blog_spider
```

以下のように DEBUGログが出力されていることを確認しましょう
抽出した Item の内容が出力されていますね
ブログ記事のタイトル、URL、公開日が正しく取得できていることがわかります

```
2018-10-08 12:53:46 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://blog.scrapinghub.com> (referer: None) ['cached']
2018-10-08 12:53:46 [scrapy.core.scraper] DEBUG: Scraped from <200 https://blog.scrapinghub.com>
{'date': 'September 27, 2018',
 'title': 'Data Quality Assurance for Enterprise Web Scraping',
 'url': 'https://blog.scrapinghub.com/data-quality-assurance-for-enterprise-web-scraping'}
2018-10-08 12:53:46 [scrapy.core.scraper] DEBUG: Scraped from <200 https://blog.scrapinghub.com>
{'date': 'September 12, 2018',
 'title': 'What I Learned as a Google Summer of Code student at Scrapinghub',
 'url': 'https://blog.scrapinghub.com/what-i-learned-as-a-google-summer-of-code-student-at-scrapinghub'}
2018-10-08 12:53:46 [scrapy.core.scraper] DEBUG: Scraped from <200 https://blog.scrapinghub.com>
{'date': 'July 25, 2018',
 'title': 'GDPR Compliance For Web Scrapers: The Step-By-Step Guide',
 'url': 'https://blog.scrapinghub.com/web-scraping-gdpr-compliance-guide'}
...
```

#### item をファイルに出力する
以下のように `-o` オプションをつけると、抽出した item をファイルに出力できます
これは csv ファイルに出力する例です

```console
$ scrapy crawl scrapy_blog_spider -o data.csv
```

csv の他にもデフォルトで json, json lines, xml に対応しています

詳しくは以下を見てみましょう
https://doc.scrapy.org/en/latest/topics/feed-exports.html#topics-feed-exports


## 2. Pipeline を実装する
さて、 Spider の実装ができたので次に Pipeline を実装して、 SQLite のデータベースにデータを保存してみましょう

プロジェクトを作成した最初の状態では `pipelines.py` の内容は以下のようにひな型が作成されています

``` python
class TenMinScrapyPipeline(object):
    def process_item(self, item, spider):
        return item
```

spider から items が yield されたタイミングで `process_item()` が呼び出されます
ここでデータベースに登録する処理します

### 2-1. `process_item()` を実装する
詳細の説明は省きますがこんな感じで実装しました

``` python
import datetime
import os
import sqlite3


class TenMinScrapyPipeline(object):
    _db = None

    @classmethod
    def get_database(cls):
        cls._db = sqlite3.connect(
            os.path.join(os.getcwd(), 'ten_min_scrapy.db'))

        # テーブル作成
        cursor = cls._db.cursor()
        cursor.execute(
            'CREATE TABLE IF NOT EXISTS post(\
                id INTEGER PRIMARY KEY AUTOINCREMENT, \
                url TEXT UNIQUE NOT NULL, \
                title TEXT NOT NULL, \
                date DATE NOT NULL \
            );')

        return cls._db

    def process_item(self, item, spider):
        """
        Pipeline にデータが渡される時に実行される
        item に spider から渡された item がセットされる
        """
        self.save_post(item)
        return item

    def save_post(self, item):
        """
        item を DB に保存する
        """
        if self.find_post(item['url']):
            # 既に同じURLのデータが存在する場合はスキップ
            return

        db = self.get_database()
        db.execute(
            'INSERT INTO post (title, url, date) VALUES (?, ?, ?)', (
                item['title'],
                item['url'],
                datetime.datetime.strptime(item['date'], '%B %d, %Y')
            )
        )
        db.commit()

    def find_post(self, url):
        db = self.get_database()
        cursor = db.execute(
            'SELECT * FROM post WHERE url=?',
            (url,)
        )
        return cursor.fetchone()
```

#### Pipeline を有効にする
Pipeline の処理を有効にするために `setting.py` の内容を変更します
`ITEM_PIPELINES` の箇所のコメントを外せばOKです


``` python
# Configure item pipelines
# See https://doc.scrapy.org/en/latest/topics/item-pipeline.html
ITEM_PIPELINES = {
    'ten_min_scrapy.pipelines.TenMinScrapyPipeline': 300,
}
```

今回は scrapy がデフォルトで生成してくれた `TenMinScrapyPipeline` をセットしていますが、自分で追加した任意のクラスも同じように dict のキーとして指定すると Pipeline として認識されます
また、値にセットされている数値は処理の優先順で、数値の小さい順に処理が適用されていきます

Pipeline は今回紹介したDBに登録する処理以外にもデータのバリデーションや加工などの処理のために使うことができます

### 2-2. 実行してみる
それでは最後に Spider を実装してみましょう

```console
$ scrapy crawl scrapy_blog_spider
```

実行が完了するとカレントディレクトリに `ten_min_scrapy.db` という SQLite のファイルが作成されていると思います
ここにクロールしたデータが格納されています

``` sql
$ sqlite3 ten_min_scrapy.db 
SQLite version 3.19.3 2017-06-27 16:48:08
Enter ".help" for usage hints.
sqlite> select * from post order by date desc limit 10;
1|https://blog.scrapinghub.com/data-quality-assurance-for-enterprise-web-scraping|Data Quality Assurance for Enterprise Web Scraping|2018-09-27 00:00:00
2|https://blog.scrapinghub.com/what-i-learned-as-a-google-summer-of-code-student-at-scrapinghub|What I Learned as a Google Summer of Code student at Scrapinghub|2018-09-12 00:00:00
3|https://blog.scrapinghub.com/web-scraping-gdpr-compliance-guide|GDPR Compliance For Web Scrapers: The Step-By-Step Guide|2018-07-25 00:00:00
4|https://blog.scrapinghub.com/web-scraping-at-scale-lessons-learned-scraping-100-billion-products-pages|For E-Commerce Data Scientists: Lessons Learned Scraping 100 Billion Products Pages|2018-07-02 00:00:00
5|https://blog.scrapinghub.com/2018/06/19/a-sneak-peek-inside-what-hedge-funds-think-of-alternative-financial-data|A Sneak Peek Inside What Hedge Funds Think of Alternative Financial Data|2018-06-19 00:00:00
6|https://blog.scrapinghub.com/2018/06/07/fitbit-quarterly-revenue-web-scraped-product-data|Want to Predict Fitbit’s Quarterly Revenue? Eagle Alpha Did It Using Web Scraped Product Data|2018-06-07 00:00:00
7|https://blog.scrapinghub.com/2018/05/30/gdpr-compliance-tools-web-scraping-crawlers|How Data Compliance Companies Are Turning To Web Crawlers To Take Advantage of the GDPR Business Opportunity|2018-05-30 00:00:00
8|https://blog.scrapinghub.com/2017/12/31/looking-back-at-2017|Looking Back at 2017|2017-12-31 00:00:00
9|https://blog.scrapinghub.com/2017/11/05/a-faster-updated-scrapinghub|A Faster, Updated Scrapinghub|2017-11-05 00:00:00
10|https://blog.scrapinghub.com/2017/07/07/scraping-the-steam-game-store-with-scrapy|Scraping the Steam Game Store with Scrapy|2017-07-07 00:00:00
```

これで Scrapy を使ったクローラーの基本がわかりましたね！

# Tips
Scrapy を使ってクローラーを実装する上での簡単な Tips を紹介します

## クロールを開始する URL を動的に変えたい
先ほどの例のように `start_urls` で固定の URL を指定するだけだと実際の利用シーンではかなり不便そうですよね

そういう場合は以下のように Spider の `start_requests()` メソッドを実装すれば動的にURLをセットできます
この `start_requests()` は Spider の処理を開始する際に呼び出されます

``` python
    def start_requests(self):
        url = 'https://sardine-system.com/media/'
        yield scrapy.Request(url, callback=self.parse)
```

また、 `scrapy.Request(url, callback=self.parse)` で指定している `callback` はレスポンスを受け取った後にコールバックされる処理です
`start_requests()` を使わない場合は先ほど記載した通り `parse()` が実行されますが、ここを任意の関数に変更することができます

parse の処理を別モジュールに切り出したい時に便利ですね

## Spider に引数で値を渡したい
Spider の外部からクロール先のURLやパラメーターを渡したい時もあると思いますが、
Spider の起動時に `-a key=value` の形式で Spider に引数を渡すことができます

```console
$ scrapy crawl scrapy_blog_spider -a param1=val1
```

ここで指定された値は Spider のコンストラクタの引数に渡されます
Spider の `__init__()` の引数に渡したい引数名を入れておけば対応するものにセットされます
また、`__init__()` の引数に明示されていないものは `kwargs` にセットされます

``` python
    def __init__(self, param1=None, *args, **kwargs):
        super(ScrapyBlogSpiderSpider, self).__init__(*args, **kwargs)

        # 引数に明示していない場合は kwargs から取得できる
        arguments_from_outside = kwargs.get('param2', 0)
```

## BeautifulSoup や他の HTMLパーサーを使いたい場合
BeautifulSoup の方がいい！という方でも大丈夫
 `response.text` にレスポンスボディがセットされているので、BeautifulSoup やその他の HTMLパーサーも以下のようにして自由に利用できます

``` python
def parse(self, response):
    # use lxml to get decent HTML parsing speed
    soup = BeautifulSoup(response.text, 'lxml')
    yield {
        "url": response.url,
        "title": soup.h1.string
    }
```

https://doc.scrapy.org/en/latest/faq.html#can-i-use-scrapy-with-beautifulsoup

# まとめ
さて、今回は Scrapy を利用したクローラーの作り方について書きました
ちゃんとクローラーを作ろうとするとHTMLのパースの処理以外にも、以下のような様々な周辺の処理が必要になってくるはずです

 - データの加工・保存
 - エラー発生時の処理
 - リクエストの間隔制御


Scrapy はそういったクローラーの汎用的なアーキテクチャーと実装を提供してくれているので、
定期的に実行されるクローラを作るにはとても便利なフレームワークです

## 今回実装したソースコード
今回実装したソースコードの一式はこちらに置いています

https://github.com/Chanmoro/ten_min_scrapy_tutorial


※Beautiful Soup と Scrapy の組み合わせ方についても書いたのでぜひ読んでみてください！
[10分で理解する Beautiful Soup](https://qiita.com/Chanmoro/items/db51658b073acddea4ac)