Material Book of Statistics

統計、機械学習、プログラミングなどで実験的な試みを書いていきます。

Scrapyで欅坂46とけやき坂46の各メンバーの画像収集する

はじめに

欅坂46の画像で誰が写っているのかを認識させるアプリをディープラーニングで作ろうと思いますので、今回は各メンバーの画像を収集します。
MicrosoftのBing Image SearchやGoogleの画像検索APIを使うことも考えましたが、Yahoo、Bing、Googleでの画像収集事情まとめを読んで「1メンバーあたり1000枚は欲しい」と思いましたので、Yahoo Japanの画像検索(簡易版)をスクレイピングすることに決めました。
このような場合、私は python + Requests + BeautifulSoup で実装してきましたが、勉強のため、以前から気になっていたScrapyを使うことにしました。

実装にあたってはこちらを参考にしました。
Python + Scrapyで画像を巡回取得する

作ったコードはこちら(GitHub)

完成品の実行方法

今回作った画像収集スクリプトは次のコマンドをターミナルに打って実行します。

$ scrapy crawl yahoo_image -a query=石森虹花 -a member_id=1

1回の実行につき1人のメンバーを画像検索にかけて画像を収集します。
queryにはメンバー名、member_idにはディープラーニングで学習させる際に使うラベルを指定します。 member_idの付け方は任意なので、もし「推しメンが1じゃないと嫌だ」という方は変えてもらって大丈夫です。

1回の実行でメンバー全員を処理することもできますが、上手く設計しないと、途中でバッチ処理がコケてしまった場合に、そこから再開させるためにはスクリプトを修正する必要が出てくる可能性があります。
今回採用した方法ならば、最悪でもシェルスクリプトでオプションの値を変えて並べるだけで済むので、再開しやすいです。

Scrapyをインストールする

 ~ $ pip install Scrapy

Projectを作成する

ターミナルに次のコマンドを打ってプロジェクトを作成します。

 ~ $ scrapy startproject image_scraper

すると、カレントディレクトリにimage_scraperディレクトリができています。
このディレクトリの構成は次のようになっているはずです。

~ $ tree image_scraper
image_scraper/
├── scrapy.cfg
└── image_scraper
    ├── __init__.py
    ├── __pycache__
    ├── items.py
    ├── middlewares.py
    ├── pipelines.py
    ├── settings.py
    └── spiders
        ├── __init__.py
        └── __pycache__

4 directories, 7 files

spidersディレクトリには

Spiderを作成する

SpiderにはWebサイトをどのように探索するのかを定義します(下の引用*1を参考)。

Spiders are classes which define how a certain site (or a group of sites) will be scraped, including how to perform the crawl (i.e. follow links) and how to extract structured data from their pages (i.e. scraping items). In other words, Spiders are the place where you define the custom behaviour for crawling and parsing pages for a particular site (or, in some cases, a group of sites).

ここではPython + Scrapyで画像を巡回取得するの「Spiderを用意して実装する」を参考にして ~/image_scraper/spider/yahoo_image_spider.py を作成します。

# -*- coding: utf-8 -*-

import scrapy
from image_scraper.items import ImageScraperItem
from scrapy.spiders import CrawlSpider


class YahooImageSpider(CrawlSpider):

    name = 'yahoo_image'

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.image_id = 0
        self.page_id = 0
        self.image_num_per_page = 20

    def start_requests(self):
        url = ('https://search.yahoo.co.jp/image/search?'
               'p={}&ei=UTF-8&save=0'.format(self.query))
        yield scrapy.Request(url, self.parse, dont_filter=True)

    def parse(self, response):
        self.page_id += 1

        items = ImageScraperItem()
        items['member_id'] = self.member_id
        items['image_ids'] = []
        items['image_urls'] = []

        for src in response.css('#gridlist > div'):
            self.image_id += 1

            url = src.css('div > p > a::attr(href)').extract_first()
            items['image_ids'].append(self.image_id)
            items['image_urls'].append(url)

        yield items

        _next_page = ('https://search.yahoo.co.jp/image/search?fr=top_ga1_sa&'
                      'p={query}&ei=UTF-8&b={start_image}')
        next_page = _next_page.format(
            query=self.query,
            start_image=self.page_id * self.image_num_per_page + 1
        )
        if next_page is not None:
            yield response.follow(next_page, callback=self.parse)

query_idとmember_idは上で紹介した実行時に与えられる値が入っています。
image_idは取得した画像に付けるIDでファイル名として使います。今回は取得した順に付けていきます。
page_idはスクレイプするWebページのIDで、スクレイプを停止するための条件判定で使います。これも取得した順につけていきます。
image_num_per_pageは1ページあたりの画像の枚数で20で設定しています。
これはYahoo Japanの画像検索(簡易版)では1ページに20枚の画像が表示されるためです。

クローラーを次ページへ巡回させる部分はScrapyのチュートリアル*2を参考にしました。

        _next_page = ('https://search.yahoo.co.jp/image/search?fr=top_ga1_sa&'
                      'p={query}&ei=UTF-8&b={start_image}')
        next_page = _next_page.format(
            query=self.query,
            start_image=self.page_id * self.image_num_per_paホットバージョン Vol.152ge + 1
        )
        if next_page is not None:
            yield response.follow(next_page, callback=self.parse)

理由は、Python + Scrapyで画像を巡回取得するでは

    rules = (
        Rule(LinkExtractor(allow=( )), callback="parse_page", follow=True),
    )

で上手く行っていましたが、私の場合は途中でリンクが取得できずにクローラーが終了してしまったためです*3
また、次のページのリンクを抽出するのにCSS Selectorを使っていません。
これはリンクを辿って行くと途中でCSSの構造が変わってしまい、意図通りにURLを取得できなかったためです。
そのため、URLのクエリストリングを更新するスタイルにしました。

ImageScraperItemを実装する

scrapy.Itemを継承したImageScraperItemは、Webページから取得したデータをScrapyの他の機能に渡すためのデータフォーマットを定義します。 item_scraper/items.pyを以下の内容で保存します。

# -*- coding: utf-8 -*-

# Define here the models for your scraped items
#
# See documentation in:
# https://doc.scrapy.org/en/latest/topics/items.html

import scrapy
from scrapy.item import Field


class ImageScraperItem(scrapy.Item):
    # define the fields for your item here like:
    # name = scrapy.Field()
    member_id = Field()
    image_ids = Field()
    image_urls = Field()

image_idsは、spiderの章でも紹介しましたが、画像のIDを格納します。
image_urlsはこれからダウンロードする画像のURLを格納するのに使います。

ImageScraperPipelineを実装する

Item Pipelineはspiderで取得したアイテムの処理を担当するパートです。
典型的な用途としては、HTMLのクレンジングやスクレイピングしたデータの整合性チェック、DBへのインサートを含むデータの保存があります。
今回はスクレイピングしたURLから画像をダウンロードしてローカルに保存するように実装していきます。

公式ドキュメントからの引用 Item Pipeline — Scrapy 1.5.1 documentation

After an item has been scraped by a spider, it is sent to the Item Pipeline which processes it through several components that are executed sequentially.

Each item pipeline component (sometimes referred as just “Item Pipeline”) is a Python class that implements a simple method. They receive an item and perform an action over it, also deciding if the item should continue through the pipeline or be dropped and no longer processed.

Typical uses of item pipelines are:

  • cleansing HTML data
  • validating scraped data (checking that the items contain certain fields)
  • checking for duplicates (and dropping them)
  • storing the scraped item in a database

image_scraper/pipelines.pyを以下の内容で保存します。

# -*- coding: utf-8 -*-

# Define your item pipelines here
#
# Don't forget to add your pipeline to the ITEM_PIPELINES setting
# See: https://doc.scrapy.org/en/latest/topics/item-pipeline.html

import pathlib
import scrapy
from scrapy.pipelines.images import ImagesPipeline
from scrapy.utils.misc import md5sum


class ImageScraperPipeline(ImagesPipeline):

    def get_media_requests(self, item, info):
        member_id = item['member_id']
        for img_id, url in zip(item['image_ids'], item['image_urls']):
            meta = {
                'member_id': member_id,
                'image_id': img_id
            }
            yield scrapy.Request(url, meta=meta)

    def image_downloaded(self, response, request, info):
        checksum = None
        for path, image, buf in self.get_images(response, request, info):
            if checksum is None:
                buf.seek(0)
                checksum = md5sum(buf)
            width, height = image.size
            suffix = pathlib.Path(path).suffix
            path = '{member_id:03d}/{image_id:04d}{suffix}'.format(
                member_id=int(request.meta['member_id']),
                image_id=request.meta['image_id'],
                suffix=suffix
            )
            self.store.persist_file(
                path=path,
                buf=buf,
                info=info,
                meta={'width': width, 'height': height}
            )
        return checksum

プロジェクトを作成したままの状態ですと

def process_item(self, item, spider):
    pass

がありますが、これを残したままにしておくと画像がダウンロードされません*4ので、削除して下さい。
ダウンロードした画像は~/image_scraper/download/{メンバーID}/{画像ID}.{拡張子}に保存されます。
例えば、石森虹化のメンバーIDを1として、取得順序が1番目のJPEG画像は~/image_scraper/download/001/0001.jpgに保存されています。 画像保存場所にdownloadディレクトリを指定している部分は次章のsettings.pyを変更することで行っています。

Scrapyの設定を変更する

pipelineの設定

プロジェクトを作成したままでもITEM_PIPELINESは次のように有効になっているはずです。

ITEM_PIPELINES = {
    'image_scraper.pipelines.ImageScraperPipeline': 1,
}

この下に画像の保存先の設定としてIMAGES_STORE = './download'を加えます。
これで画像は~/image_scraper/download以下に保存されるようになります。

spiderの停止条件

次にspiderが規定以上のItem数になったら停止するように設定します。
Scrapyはデフォルト状態では並行処理する設定になっている*5ので、50と設定してもそれ以上の数の画像をダウンロードします。
今回の場合、メンバーにもよりますが、1人あたり平均して500枚程度の画像を取得できます。

EXTENSIONS = {
    'scrapy.extensions.closespider.CloseSpider': 1,
}
CLOSESPIDER_ITEMCOUNT = 50

Bot判定を回避するための設定

以下の2つを設定します。
この設定は公式ドキュメントの Avoiding getting banned を参考にしました。

COOKIES_ENABLED = False
DOWNLOAD_DELAY = 3

全メンバー分をバッチ実行するシェルスクリプトを作成する

まず漢字とひらがなのメンバーリストmember_list.csvを用意します。

$ head -n 5 member_list.csv 
member_id,member_name
1,石森虹花
2,今泉佑唯
3,上村莉菜
4,尾関梨香

このリストに記載されているメンバー全員についてscrapyを実行するシェルスクリプトexecute_crawl.shを以下の内容で作成します。 実行時のログをあとで確認できるようにファイルとして保存しておくため、オプションに--logfile log/yahoo_image_${member_id}.logと設定しています。

#!/bin/sh

csv_file="member_list.csv"

for line in `cat ${csv_file}`
do
    member_id=`echo ${line} | cut -d ',' -f 1`
    member_name=`echo ${line} | cut -d ',' -f 2`
    echo "processing member_id ${member_id}, member_name ${member_name}"
    scrapy crawl yahoo_image -a query=$member_name -a member_id=$member_id --logfile log/yahoo_image_${member_id}.log
done

実際に画像を収集する

先ほど作ったexecute_crawl.shを実行します。

~/image_scraper$ chmod 744 execute_crawl.sh
~/image_scraper$ ./execute_crawl.sh

私の環境では3時間ほどでクローリングが終了しました。

実際に画像が何枚ダウンロードできたのかを集計する

実際にダウンロードした画像が枚数だったのかを知るために次の4項目を集計してみます。

  1. 各メンバーごとの画像枚数
  2. 欅坂46けやき坂46のメンバー数
  3. 全メンバーで合計した画像枚数
  4. 1メンバーあたり平均の画像枚数

作成したスクリプトは次の通りです。

# -*- coding: utf-8 -*-

import pathlib
import pandas as pd

member_list = pd.read_csv('member_list.csv')
download_dir = pathlib.Path('./download')

# ---------------------------------------------
# Aggregating number of images at each member
# ---------------------------------------------

member_id = []
member_name = []
image_count = []
for _, (_id, _name) in member_list.iterrows():
    _dir = download_dir / '{:03d}'.format(_id)
    image_path_list = [p for p in _dir.iterdir()]
    member_id.append(_id)
    member_name.append(_name)
    image_count.append(len(image_path_list))

download_image_count = pd.DataFrame(
    data={
        'member_id': member_id,
        'member_name': member_name,
        'image_count': image_count
    }
)
column_order = ['member_id', 'member_name', 'image_count']
download_image_count = download_image_count[column_order]

# ---------------------------------------------
# Display summaries of downloaded images
# ---------------------------------------------

print('Number of downloaded images at each member:')
print(download_image_count)
print()

number_of_members = member_list.shape[0]
print('Number of members: {}'.format(number_of_members))

total_image_count = download_image_count.image_count.sum()
print('Total number of images: {}'.format(total_image_count))

avg_image_count = download_image_count.image_count.mean()
print('Average number of images: {}'.format(avg_image_count))

上を実行した結果は以下の通りです。

$ python ./summary_download.py 
Number of downloaded images at each member:
    member_id member_name  image_count
0           1        石森虹花          484
1           2        今泉佑唯          481
2           3        上村莉菜          475
3           4        尾関梨香          523
4           5        織田奈那          451
5           6        小池美波          508
6           7        小林由依          497
7           8       齋藤冬優花          540
8           9        佐藤詩織          466
9          10        志田愛佳          476
10         11        菅井友香          449
11         12        鈴本美愉          512
12         13       長沢菜々香          478
13         14        土生瑞穂          464
14         15         原田葵          496
15         16       平手友梨奈          532
16         17         守屋茜          478
17         18       米谷奈々未          439
18         19        渡辺梨加          453
19         20        渡邉理佐          494
20         21        井口眞緒          402
21         22        潮紗理菜          450
22         23        柿崎芽実          436
23         24        影山優佳          444
24         25        加藤史帆          443
25         26        齊藤京子          465
26         27       佐々木久美          388
27         28       佐々木美玲          433
28         29        高瀬愛奈          437
29         30        高本彩花          446
30         31        長濱ねる          460
31         32        東村芽依          465
32         33        金村美玖          399
33         34        河田陽菜          359
34         35        小坂菜緒          340
35         36        富田鈴花          423
36         37        丹生明里          372
37         38       濱岸ひより          346
38         39        松田好花          379
39         40        宮田愛萌          371
40         41        渡邉美穂          445

Number of members: 41
Total number of images: 18399
Average number of images: 448.7560975609756

まとめ

スクレイピングフレームワークのScrapyを使って欅坂46けやき坂46のメンバーの画像を収集しました。
1メンバーあたり1000枚取得を目標にしていましたが、実際には450枚弱しか取得できませんでした。
CLOSESPIDER_ITEMCOUNTの値を増やして実行してもいいのですが、目的はメンバーの顔を認識するアプリの作成なので、ひとまずこれで画像の収集を終わりにしたいと思います。
精度が出なかった場合はさらに画像をダウンロードすることを検討します。

*1:https://docs.scrapy.org/en/latest/topics/spiders.html

*2:https://docs.scrapy.org/en/latest/intro/tutorial.html#following-links

*3:目的に適した自前のLinkExtractorを定義することもできるようですが、今回は理解しやすかったチュートリアルの例を基に実装しました。

*4:これに気づくのに結構時間が掛かりました...(汗)

*5:https://stackoverflow.com/questions/44006672/scrapy-closespider-itemcount-setting-not-working-as-expected