駒の顔画像を自動収集する【スクレイピング - Scrapy編】

# はじめに
こんにちは。逆転オセロニアのYouTubeチャンネル「まこちゃんねる」の中の人です。
本稿では、**[公式wiki](https://xn--cckp5c6czi2302avwxa.gamematome.jp/game/964/wiki/%E3%83%88%E3%83%83%E3%83%97%E3%83%9A%E3%83%BC%E3%82%B8)から駒の顔画像を自動収集すること**を目標にします。
今回はPythonの**スクレイピング**フレームワーク、**Scrapy**を利用してみます。

# モチベーション
- オセロニアを題材にしていく上で、画像収集する場面が多いため
- Pythonでスクレイピングを実装したことが無かったので、その練習のため

# スクレイピング（Scraping）とは？
> ウェブサイトから情報を抽出するコンピュータソフトウェア技術のこと。
> [ウェブスクレイピング - Wikipedia ](https://ja.wikipedia.org/wiki/%E3%82%A6%E3%82%A7%E3%83%96%E3%82%B9%E3%82%AF%E3%83%AC%E3%82%A4%E3%83%94%E3%83%B3%E3%82%B0)より

今回であれば、公式wikiから**駒の顔画像及び付随する情報（名称・レア度等）を自動で抽出すること**になります。
<img width="780" alt="ファイル名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2483501/49080013-f97a-66af-9c72-ba8dbf1847df.png">

# 環境
- macOS
- JupyterLab
- Python3.6
- Scrapy

# 実装の流れ
まずはScrapyをインストールし、scrapyコマンドでプロジェクトを作成します。その後、スクレイピングしたデータを格納する為の**Itemクラスの実装**、HTMLをパースする為の**parse関数の実装**、取得したデータを操作する為の**Pipelineを実装**していきます。

1. **Scrapyプロジェクトの作成**
    1. Scrapyをインストールする
    1. プロジェクトを作成する
1. **Spiderの実装**
    1. Itemを実装する
    1. Parse処理を実装する
    1. Pipelineを実装する
1. **スクレイピングの実行**
    1. 実際にデータを取得する

## Scrapyプロジェクトの作成
Scrapyをインストールし、scrapyコマンドでスクレイピングをするために必要なフレームワークを作成していきます。

### Scrapyをインストールする
Scrapyをpipでインストールします。

```shell
$ pip install scrapy
```

### プロジェクトを作成する
`scrapy startproject <プロジェクト名>` で雛形を作成します。今回はプロジェクト名を**scraping_character_data**としています。

```shell
$ scrapy startproject scraping_character_data
```

以下のディレクトリ構造で雛形が作成されます。

```shell
$ tree scraping_character_data/
scraping_character_data
├── scraping_character_data
│   ├── __init__.py
│   ├── items.py
│   ├── middlewares.py
│   ├── pipelines.py
│   ├── settings.py
│   └── spiders
│       └── __init__.py
└── scrapy.cfg
```

## Spiderの実装
スクレイピングの中核となる部分です。コマンドでspiderを作成し、雛形として作成された**items.py**、**pipelines.py**、**spider.py**の中身を実装していきます。

### Itemを実装する
生成されたitems.pyを編集し、HTMLでパースしたデータを保持するフィールドを定義します。今回であれば、**キャラクターの図鑑No**、**顔画像URL**、**名前**、**属性**、**レア度**を定義しておきます。

<img width="780" alt="ファイル名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2483501/bcdf6c8b-6ecf-5b69-2e5e-900f8c30e8c2.png">

```python:items.py
import scrapy


class CharacterDataItem(scrapy.Item):
    no = scrapy.Field()
    face_img_url = scrapy.Field()
    name = scrapy.Field()
    attribute = scrapy.Field()
    rarity = scrapy.Field()
```

### Parse処理を実装する
まずはparse処理を作成するために、scrapyコマンドでspiderを作成します。`scrapy genspider <spiderクラス名> <スクレイピング対象のドメイン名>`で作成することができます。今回はクラス名を**character_data**、ドメイン名を**オセロニア攻略.gamematome.jp**として作成します。

```shell
$ scrapy genspider character_data オセロニア攻略.gamematome.jp
```
spidersディレクトリ直下にcharacter_data.pyが作成されます。

```shell
$ tree scraping_character_data/scraping_character_data/spiders
scraping_character_data/scraping_character_data/spiders
├── __init__.py
└── character_data.py
```

作成されたcharacter_data.pyを編集していきます。**nameは実際にスクレイピングを実行するときに指定する値**になります。**allowed_domainsには実行対象のドメイン名**を指定しておきます。今回であればオセロニア攻略.gamematome.jpが作成時に自動で格納されています。**start_urlsにはスクレイピング開始のURLを指定**しておきます。**ここで注意**なのですが、**日本語のドメイン名はPunycodeへ変換**してから扱うようにしたほうが良さそうです。日本語ドメイン名のまま動かしていたらスクレイピングが全然実行されず、ここでめちゃくちゃハマりました...。

```python:character_data.py
import scrapy
from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor
from scraping_character_data.items import CharacterDataItem


class CharacterDataSpider(CrawlSpider):
    name = 'character_data'
    # Punycodeへ変換しておく
    domain = 'オセロニア攻略.gamematome.jp'.encode('idna').decode("utf-8")
    allowed_domains = [domain]
    start_urls = [f'https://{domain}/game/964/wiki/キャラクター情報']
```

次にスクレイピング時の巡回ルールを指定します。**CrawlSpider**、**Rule**、**LinkExtractor**をインポートします。**LinkExtractorにはどのリンクを巡回するかのルールを指定**し、**callbackにはルールを満たした時に呼ばれる関数を指定**します。今回であれば、start_urlsに指定したURLに含まれるtable要素のidがcontent_block_9に含まれるURLを巡回してスクレイピングを行うことになります。他にもcssのタグや正規表現等といった指定の仕方もすることができます。

<img width="780" alt="ファイル名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2483501/316380cf-3ae7-616e-2030-1090e70556b6.png">

```python:character_data.py
import scrapy
from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor


class CharacterDataSpider(CrawlSpider):

    ...

    rules = (
        Rule(
            LinkExtractor(restrict_xpaths='//table[@id="content_block_9"]'), 
            callback='parse_character_data'
        ),
    )
```

抽出する部分の処理を実装していきます。データ格納先のCharacterDataItemをインポートしておきます。**Ruleで定義したcallback関数（parse_character_data）が呼ばれるので、ここに処理を書いていきます。**responseにはhtml情報が入ってくるので、ChromeのDevToolsで要素を探索しつつコードに起こします。今回であれば、table要素のidがcontent_block_2を探索すれば必要な情報が抜き出せそうです。最後にCharacterDataItemに抽出したデータを詰めてPipelineに処理を渡します。

<img width="780" alt="ファイル名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2483501/bed177ca-0a31-ab7e-b49e-a4ef39f192b3.png">



```python:character_data.py
from scraping_character_data.items import CharacterDataItem

    ...

    def parse_character_data(self, response):
        table = response.xpath("//table[@id='content_block_2']")[0]
        trs = table.xpath("//tr")[1:]
        for tr in trs:
            no = tr.xpath(".//td[1]/text()").get()
            face_img_url = tr.xpath(".//td[2]/a/img/@src").get()
            name = tr.xpath(".//td[3]/a/text()").get()
            attribute = tr.xpath(".//td[4]/text()").get()
            rarity = tr.xpath(".//td[5]/text()").get()
            yield CharacterDataItem(
                no=no,
                face_img_url=face_img_url,
                name=name,
                attribute=attribute,
                rarity=rarity
            )
```

### Pipelineを実装する
pipelines.pyを編集していきます。抽出したデータがPipelineに流れてくるので、ここでデータの保存処理を行います。process_itemの引数itemからデータを取得することができます。今回は取得したデータからファイル名を作り、キャラクターの顔画像を保存することにします。ファイル名は`{5桁の図鑑No}_{属性}_{レア度}_{名前}.png`とします。一応scrapyの場合、画像専用の**ImagesPipeline**があるのですが、**内部で勝手にjpeg変換されてしまっている**ようで、**透過画像として保存できなかったので断念**しました。ここら辺詳しい方、是非コメントで教えてください。


```python:pipelines.py
import scrapy
import requests
import pathlib


class ScrapingCharacterDataPipeline():
    def process_item(self, item, spider):
        filename = '_'.join([item["no"].zfill(5), item["attribute"], item["rarity"], f'{item["name"]}.png'])
        file = pathlib.Path('data', 'face_img', filename)
        r = requests.get(item['face_img_url'])
        if r.status_code == 200:
            with file.open(mode="wb") as f:
                f.write(r.content)
```

## スクレイピングの実行
scrapyコマンドで実行します。`scrapy crawl <定義したname>`で実行できます。

```shell
$ scrapy crawl character_data
```

### データを取得する
実際にデータを取得してみるとこんな感じ。やったね！これでオセロニアを題材に色々と画像処理を試せるぞい！
<img width="780" alt="ファイル名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2483501/d76c0230-1057-2884-bb68-a4d53d2294c9.png">

# おわりに
今回は全キャラクターを対象としましたが、追加された駒だけを対象とするような改修をしたいなと思いました。毎回全件取得していたら、サイトにめちゃくちゃ負担をかけることになりますからね...。

**...って、[キャラクター情報ページ](https://オセロニア攻略.gamematome.jp/game/964/wiki/キャラクター情報)メンテされてないじゃん！No5300までしか乗ってないじゃん！記事記載時ではNo5401〜まであるのに...頼むよkJ〜**

# 参考
https://qiita.com/Chanmoro/items/f4df85eb73b18d902739