Scrapyでスクレイピング（その4 Spider実行編）

Scrapyではスクレイピングの主な処理をSpiderクラスに実装します。

**今回の記事ではSpiderクラスの実装方法とScrapyの実行方法を説明します。**

# 今までの記事

* [Scrapyでスクレイピング（その1 準備編）](https://qiita.com/ezotaka/items/f32f8fca8526bd137253)
* [Scrapyでスクレイピング（その2 事前調査編）](https://qiita.com/ezotaka/items/4840a7ca2f70c653a9c8)
* [Scrapyでスクレイピング（その3 Scrapy Shell編）](https://qiita.com/ezotaka/items/961d4e5ac061d6c6dd81)

# まずは結論から

* **``scrapy crawl``コマンドをターミナルから実行してスクレイピングを開始する**
* **ScrapyがWebページをダウンロードすると``scrapy.Spider``クラスの``parse``メソッドが呼ばれる**
* **``parse``メソッドの引数に渡される``scrapy.http.HtmlResponse``オブジェクトから目的の情報を抽出する**
* **``scrapy.Request``オブジェクトを``yield``すると別のWebページをダウンロードできる**
* **相対パスを簡単に処理するために``HtmlResponse.follow``メソッドを使用する**


# 開発環境
* Ubuntu 18.04.5 LTS (Bionic Beaver)
* bash
* Python 3.8.1
* PyCharm 2020.3.2 (Professional Edition)

# Scrapyプロジェクトのディレクトリ構成
まだScrapyプロジェクトを作成していない場合は、[こちらの手順](https://qiita.com/ezotaka/items/f32f8fca8526bd137253)で作成しましょう。

この記事では以下のようなディレクトリ構成を想定しています。

 ```
$ cd <プロジェクトルートディレクトリ>
$ tree -L 3
.
├── scrapy_cats
│   ├── __init__.py
│   ├── items.py
│   ├── middlewares.py
│   ├── pipelines.py
│   ├── settings.py
│   └── spiders
│       └── __init__.py
├── scrapy.cfg
└── venv
(略)         
```

また、以降の作業はすべて仮想環境内で行いますのでアクティベートしておきましょう。


```
$ source venv/bin/activate
(venv) $
```

# 最低限行うべきScrapyの設定
Scrapyを実行する前に、最低限でもダウンロード間隔の設定はしておきましょう。
デフォルトのままでは同じWebサイトに最大で同時16アクセスしてしまう設定になっており、アクセス先に負荷をかけすぎてしまう可能性があります。

settings.pyに以下の行を追加します。

```python:scrapy_cats/settings.py
# 同じWebサイトから連続してページをダウンロードする際の待機時間（秒）
DOWNLOAD_DELAY = 1
```

# 写真一覧ページのダウンロード
まずはスクレイピングの起点となる写真一覧ページ（[https://photohito.com/dictionary/猫/](https://photohito.com/dictionary/猫/)）をダウンロードする処理を実装して実行します。

重要なポイントは以下のとおりです。

* **``scrapy.Spider``クラスを継承してSpiderを実装する**
* **``Spider.name``プロパティにスパイダー名を指定する**
* **``Spider.start_urls``プロパティに最初にダウンロードするページを指定する**
* **ページがダウンロードされたらコールバックとして``parse``メソッドが呼ばれる**
* **``parse``メソッドの引数に``scrapy.http.HtmlResponse``オブジェクトが渡されるのでそれを処理する**
* **``scrapy crawl``コマンドにスパイダー名を指定して実行すればスクレイピングが開始される**

```python:scrapy_cats/spiders/cats_spider.py
from scrapy import Spider
from scrapy.http import HtmlResponse


class CatsSpider(Spider):
    name = 'cats'  # スパイダー名。クロールコマンド実行時に指定する
    start_urls = ['https://photohito.com/dictionary/猫/']  # 最初にダウンロードするページのURL。

    # parseメソッドは取得したWebページを処理するためにScrapyから呼ばれるコールバック関数
    def parse(self, response: HtmlResponse):
        print('ダウンロードしたURLは', response.url)

```

以下のコマンドでスクレイピングを実行します。

```
(venv) $ scrapy crawl cats
```

デバッグ用のログがたくさん表示される中に、さきほど実装した出力が確認できると思います。

```
2021-03-07 00:01:56 [scrapy.utils.log] INFO: Scrapy 2.4.1 started (bot: scrapy_cats)
...
2021-03-07 00:01:57 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://photohito.com/dictionary/%E7%8C%AB/> (referer: None)
ダウンロードしたURLは https://photohito.com/dictionary/%E7%8C%AB/
2021-03-07 00:01:58 [scrapy.core.engine] INFO: Closing spider (finished)
...
```

# 写真詳細ページのURLを抽出してダウンロード
では写真一覧ページにある各写真の詳細ページをダウンロードしてみましょう。
ページ内から必要な情報を抽出する方法は[前回の記事](https://qiita.com/ezotaka/items/961d4e5ac061d6c6dd81)で検討済みです。

``parse``メソッドを修正し、``parse_photo``メソッドを追加します。

ここでの重要なポイントは``parse``メソッド最終行の``yield``文です。

* **``scrapy.Request``オブジェクトを``yield``するとWebページをダウンロードできる**
* **``Request``にはURLとレスポンス処理用コールバックメソッドを指定する**


```python:scrapy_cats/spiders/cats_spider.py
from scrapy import Spider, Request
from scrapy.http import HtmlResponse


class CatsSpider(Spider):
    name = 'cats'
    start_urls = ['https://photohito.com/dictionary/猫/']

    def parse(self, response: HtmlResponse):
        """写真一覧ページから各写真の詳細ページへのリンクを見つけてたどる"""

        # 写真詳細ページのURL一覧
        urls = [response.urljoin(href) for href
                in response.css('.imgholder a::attr(href)').getall()]

        for url in urls[:2]:  # TODO: 最初からすべてたどらず、2つだけ試す
            # urlをダウンロードしたレスポンスをparse_photoメソッドに渡すようにリクエストする
            yield Request(url=url, callback=self.parse_photo)

    def parse_photo(self, response: HtmlResponse):
        """写真詳細ページを処理する"""
        print(response.url)

```

実行すると、以下のように2つの写真詳細ページがダウンロードされ、そのURLが表示されるのが確認できると思います。

```
...
2021-03-07 01:21:20 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://photohito.com/photo/10089292/> (referer: https://photohito.com/dictionary/%E7%8
C%AB/)
https://photohito.com/photo/10089292/
2021-03-07 01:21:21 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://photohito.com/photo/10128468/> (referer: https://photohito.com/dictionary/%E7%8
C%AB/)
https://photohito.com/photo/10128468/
...

```

上のソースコードではダウンロードするURLを絶対パスとして扱いましたが、相対パスを簡単に処理することもできます。
**``response``オブジェクトの``follow``メソッドを使えば、``parse``メソッドを以下のように簡潔に書くことができます。**

```python
    def parse(self, response: HtmlResponse):
        # urlは相対パス
        for url in response.css('.imgholder a::attr(href)').getall():
            yield response.follow(url, self.parse_photo)
```

# 写真詳細ページで目的の情報を抽出
目的の情報を抽出するために``parse_photo``メソッドを実装していきましょう。

今回の記事では情報を抽出するところまでしか行いません。
画像ダウンロード処理、データベース登録処理の実装は次回以降の記事で行います。

[前回の記事](https://qiita.com/ezotaka/items/961d4e5ac061d6c6dd81)でScrapy Shellで行った検討結果をほぼそのまま使っているだけですので、特に重要なポイントはありません。


```python:scrapy_cats/spiders/cats_spider.py
from scrapy import Spider
from scrapy.http import HtmlResponse
from pprint import pprint


class CatsSpider(Spider):
    name = 'cats'
    start_urls = ['https://photohito.com/dictionary/猫/']

    def parse(self, response: HtmlResponse):
        """写真一覧ページから各写真の詳細ページへのリンクを見つけてたどる"""

        for url in response.css('.imgholder a::attr(href)').getall()[:2]:  # TODO: 最初からすべてたどらず、2つだけ試す
            yield response.follow(url, self.parse_photo)

    def parse_photo(self, response: HtmlResponse):
        """写真詳細ページを処理する"""

        # 写真画像のダウンロード
        photo_url = response.css('#photo_view img::attr(src)').re_first(r'https://.*')
        print('この写真画像をダウンロード:', photo_url)  # TODO: ダウンロード処理は次回以降に実装する

        # 撮影情報、EXIFデータの取得
        photo_data = self.get_table_data(response, '#photo_data_area')
        exif_data = self.get_table_data(response, '#exif_area')
        pprint(photo_data)  # TODO: データーベース登録処理は次回以降に実装する
        pprint(exif_data)

    def get_table_data(self, response: HtmlResponse, table_id):
        """
        id=table_idであるノードの子孫のtableから、
        ヘッダーをキーとしデータを値とするディクショナリを取得する
        """
        keys = response.css(f'{table_id} th::text').re(r'\w+')
        values = list(map(lambda x: ' '.join(x.css('::text').re(r'^[^\n].*')),
                          response.css(f'{table_id} td')))
        return dict(zip(keys, values))

```

実行すると、以下のように情報が正しく抽出できているのが確認できると思います。

```
...
2021-03-07 02:29:22 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://photohito.com/photo/10089292/> (referer: https://photohito.com/dictionary/%E7%8
C%AB/) 
この写真画像をダウンロード: https://photohito.k-img.com/...jpg
{'カメラ': 'SONY ILCE-7M3', 'レンズ': '---', 'レンズタイプ': '---', '対応マウント': '---'}
{'ISO感度': '800',
 'イメージサイズ': '6000 x 4000',
 'ソフトウェア': 'Adobe Photoshop Lightroom Classic 10.1 (Windows)',
 'フラッシュ': 'ストロボ発光せず, 強制非発光モード',
 'ホワイトバランス': 'Auto',
 '撮影日時': '2020:12:20 16:17:18',
 '焦点距離': '200 mm',
 '絞り': 'f/2.8',
 '露光補正値': '1.7 EV',
 '露出時間': '0.001 (1/800) 秒'}
2021-03-07 02:29:23 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://photohito.com/photo/10128468/> (referer: https://photohito.com/dictionary/%E7%8
C%AB/)
この写真画像をダウンロード: https://photohito.k-img.com/...jpg
{'カメラ': 'CANON Canon EOS 6D',
 'レンズ': 'TAMRON SP AF 90mm F/2.8 MACRO1:1 (ｷﾔﾉﾝ用)',
 'レンズタイプ': 'マクロ',
  '対応マウント': 'キヤノンEFマウント系'}
{'ISO感度': '100',
 'イメージサイズ': '4608 x 3072',
 'ソフトウェア': 'Digital Photo Professional',
 'フラッシュ': 'ストロボ発光せず, 強制非発光モード',
 'ホワイトバランス': 'Auto',
 '撮影日時': '2021:01:17 09:53:25',
 '焦点距離': '90 mm',
 '絞り': 'f/2.8',
 '露光補正値': '0 EV',
 '露出時間': '0.003 (1/400) 秒'}
...
```

# まとめ
今回の記事ではスクレイピング処理をSpiderクラスに実装し、それを実行してWebページをダウンロードして目的の情報の抽出を行いました。

以下、重要なポイントです。

* **``scrapy crawl``コマンドをターミナルから実行してスクレイピングを開始する**
* **ScrapyがWebページをダウンロードすると``scrapy.Spider``クラスの``parse``メソッドが呼ばれる**
* **``parse``メソッドの引数に渡される``scrapy.http.HtmlResponse``オブジェクトから目的の情報を抽出する**
* **``scrapy.Request``オブジェクトを``yield``すると別のWebページをダウンロードできる**
* **相対パスを簡単に処理するために``HtmlResponse.follow``メソッドを使用する**


次回は画像ダウンロード処理とデータベース登録処理を実装します。

ご精読ありがとうございました。


# 書籍紹介
この書籍が非常にわかりやすく、クローリング・スクレイピングを行うのに必要な知識がひととおり学べます。
[Pythonクローリング&スクレイピング[増補改訂版]](https://www.amazon.co.jp/gp/product/4297107384/ref=as_li_tl?ie=UTF8&camp=247&creative=1211&creativeASIN=4297107384&linkCode=as2&tag=ezotaka-22&linkId=ac36b579de9951ba628a826875d9d424)
![Pythonクローリング&スクレイピング[増補改訂版]](https://ws-fe.amazon-adsystem.com/widgets/q?_encoding=UTF8&MarketPlace=JP&ASIN=4297107384&ServiceVersion=20070822&ID=AsinImage&WS=1&Format=_SL250_&tag=ezotaka-22)

# 免責事項
* コンテンツや情報において、必ずしも正確性を保証するものではありません。また合法性や安全性なども保証しません。
* 掲載された内容によって生じた損害等の一切の責任を負いかねますので、ご了承ください。