Scrapyチュートリアル

f:id:kimoppy126:20181111033229j:plain

概要

毎度おなじみScrapyです。
Scrapyを使ったクローリングツールの作成手順として、Scrapyのチュートリアルでは以下のような手順を辿っています。

  1. Scrapyのプロジェクトを作成。

  2. Spiderと呼ばれる、クローリングを行い、データの抽出を行うためのクラスを作成。

  3. コマンドラインから、データを出力。

  4. Spiderが再帰的にリンクを辿れるように変更を追加。

  5. Spiderが実行時の引数を使用するよう変更を追加。

これはあくまで一例に過ぎませんが、実践的なものとなっており参考になるのでじっくり見ていくことにします。

目指せ!Scrapyマスター!

Scrapyプロジェクトの作成

まずはなんてったってScrapyプロジェクトの作成です。 いつだってここから始めましょう。

コマンドはいたって簡単。

scrapy startproject tutorial

これを実行すると。。

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

こんな感じにScrapyに必要なディレクトリ構成でプロジェクトが立ち上がります。
ファイル名がそのままコンポーネント名になっているので、Scrapyのアーキテクチャを把握した人ならどこになにを書くか大体わかると思います。
補足するなれば、scrapy.cfg はdeployに使用する設定を行います。

Spiderの作成

Spiderはおなじみ、データのスクレイピングを行うコンポーネントになります。
tutorial/spidersディレクトリ内に、quotes_spider.pyというファイル名でSpiderを作成します。

import scrapy


class QuotesSpider(scrapy.Spider):
    name = "quotes"

    def start_requests(self):
        urls = [
            'http://quotes.toscrape.com/page/1/',
            'http://quotes.toscrape.com/page/2/',
        ]
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)

    def parse(self, response):
        page = response.url.split("/")[-2]
        filename = 'quotes-%s.html' % page
        with open(filename, 'wb') as f:
            f.write(response.body)
        self.log('Saved file %s' % filename)

このSpiderを構成する各要素について見ていきましょう。

要素名 説明
name Spiderの名前を定義します。これはSpider内で一意のものにしなければなりません。
start_requestes() クローリングを開始するRequestを定義します。ここでは、iterableなRequestオブジェクト(リスト、若しくはジェネレータ)を返す必要があります。
parse() Responseオブジェクトからデータを抽出し、辞書型で返すためのメソッドです。responseパラメータはTextResponseという、ページの内容とその内容を扱うためのメソッドを含んだインスタンスになっています。

parse()メソッドは一般にResponseをパースするのと同時に、新しく辿るURLを探し、新しいRequestを出します。

$ scrapy crawl quotes

このコマンドによって、quotesと名付けられたSpiderを実行します。
先ほど定義したこのSpiderでは、http://quotes.toscrape.comにRequestを送ります。

実行結果は以下になります。

$ scrapy crawl quotes
2018-11-11 19:03:56 [scrapy.utils.log] INFO: Scrapy 1.5.0 started (bot: tutorial)
・
・
2018-11-11 19:07:17 [scrapy.core.engine] DEBUG: Crawled (404) <GET http://quotes.toscrape.com/robots.txt> (referer: None)

2018-11-11 19:07:20 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/2/> (referer: None)
2018-11-11 19:07:20 [quotes] DEBUG: Saved file quotes-2.html
2018-11-11 19:07:20 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/1/> (referer: None)
2018-11-11 19:07:21 [quotes] DEBUG: Saved file quotes-1.html
2018-11-11 19:07:21 [scrapy.core.engine] INFO: Closing spider (finished)
・
・
・

ここでは、Spiderのstart_requestsメソッドにより返されるscrapy.Requestオブジェクトを、スケジューリングしています。
それらのResponseを受けとると、Scrapyは、Responseオブジェクトのインスタンス化とrequestメソッドに付随するコールバック関数(今回の場合、parse関数)の呼び出しを行います。

start_requests()の関数の省略

実はstart_requests()関数は実装する必要はなく、start_urls変数を定義するだけで勝手にそのリスト内で定義されたURLに対し、Requestを送り、Responseオブジェクトが返る度に続けてparse()関数を実行してくれます。
これは、parse()関数が、デフォルトのコールバック関数として、Scrapyで登録されているためです。

つまり今回の例で言うと、

class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = [
        'http://quotes.toscrape.com/page/1/',
        'http://quotes.toscrape.com/page/2/',
    ]

    def parse(self, response):
        page = response.url.split("/")[-2]
        filename = 'quotes-%s.html' % page
        with open(filename, 'wb') as f:
            f.write(response.body)

は、

class QuotesSpider(scrapy.Spider):
    name = "quotes"

    def start_requests(self):
        urls = [
            'http://quotes.toscrape.com/page/1/',
            'http://quotes.toscrape.com/page/2/',
        ]
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)

    def parse(self, response):
        page = response.url.split("/")[-2]
        filename = 'quotes-%s.html' % page
        with open(filename, 'wb') as f:
            f.write(response.body)

と同じ実装ということになります。

データの抽出

データの抽出には、インタラクティブなシェルを使用することができます。
スクレイピングしたいURLを引数として、以下のように実行します。

scrapy shell 'http://quotes.toscrape.com/page/1/'

例えば、CSSセレクタでtitleタグのついた要素を取得する際には以下のように実行します。

>>> response.css('title')

と、ここで問題発生。
以下のような予期せぬアウトプットが、何もせずとも出てきてしまう。。

DEBUG:parso.python.diff:diff parser calculated
DEBUG:parso.python.diff:diff: line_lengths old: 1, new: 1
DEBUG:parso.python.diff:diff replace old[1:1] new[1:1]
DEBUG:parso.python.diff:parse_part from 1 to 1 (to 0 in part parser)
DEBUG:parso.python.diff:diff parser end

なんじゃこりゃ。。

以下に同様の症例が報告されていました。
Unexpected logging outputs after setting log level to debug. · Issue #10946 · ipython/ipython · GitHub

どうやらipythonのバージョン依存バグみたいです。。

以下のコマンドで、ipythonのバージョンを6.2.1から最新版の7.1.1にアップデート。

pip install -U ipython

すると、

In [4]: response.css('title')
   ...:
Out[4]: [<Selector xpath='descendant-or-self::title' data='<title>Quotes to Scrape</title>'>]

正常に動いているようです。よかった。。

response.css('title')で取得できるのは組み込み型ListのサブクラスであるSelectorListという型になります。
これは、XML/HTMLを含んだSelectorオブジェクトのリストになっています。
このオブジェクトからはさらにデータを抽出することができて、例えば、text要素を取得するには以下のように行います。

>>> response.css('title::text').extract()
['Quotes to Scrape']

ここでは、リスト形式で出力されています。
extract_first()メソッドを使用すると、該当する要素の最初の1つだけ抽出することができます。
今回はresponse.css('title::text')に該当する要素が一つだけなので、extract_first()メソッドを使用しましょう。

>>> response.css('title::text').extract_first()
'Quotes to Scrape'

SelectorListに対して第1要素を抽出する際に、.extract_first()を使う場合と[0].extract()を使う場合とでは挙動が同じように見られますが、.extract_first()では、要素がない場合にNoneを返すため、IndexErrorが生じるのを避けることができたりします。

また、.reメソッドを使用することで正規表現による要素の抽出を行うこともできます。

>>> response.css('title::text').re(r'Quotes.*')
['Quotes to Scrape']
>>> response.css('title::text').re(r'Q\w+')
['Quotes']
>>> response.css('title::text').re(r'(\w+) to (\w+)')
['Quotes', 'Scrape']

Xpathの利用

要素の抽出法として、CSSセレクタだけでなくXpathを使用することもできます。

>>> response.xpath('//title')
[<Selector xpath='//title' data='<title>Quotes to Scrape</title>'>]
>>> response.xpath('//title/text()').extract_first()
'Quotes to Scrape'

Xpathはとても強力な記法で、実はScrapyでは、CSSセレクタで指定した際にも内部的にXpathに変換されて要素が取得されています。
このことは、Selectorオブジェクトにxpath=の項があることからも明らかです。
Xpathは、CSSセレクタよりも一般的ではないにせよ、要素の取得に関してより強力な方法(例えば、“Next Page”と記載された要素の取得等)を取ることができるので 要素の抽出記法として迷う際はXpathを選ぶのがオススメです。

quotes, authorsの抽出

http://quotes.toscrape.com/から、quotes、authorsの情報を抽出するためのセレクタを作成します。
HTMLは以下のようになっています。

<div class="quote">
    <span class="text">“The world as we have created it is a process of our
    thinking. It cannot be changed without changing our thinking.”</span>
    <span>
        by <small class="author">Albert Einstein</small>
        <a href="/author/Albert-Einstein">(about)</a>
    </span>
    <div class="tags">
        Tags:
        <a class="tag" href="/tag/change/page/1/">change</a>
        <a class="tag" href="/tag/deep-thoughts/page/1/">deep-thoughts</a>
        <a class="tag" href="/tag/thinking/page/1/">thinking</a>
        <a class="tag" href="/tag/world/page/1/">world</a>
    </div>
</div>

1つ1つのquoteは、response.css("div.quote")でリスト形式で取得することができます。

>>> quote = response.css("div.quote")[0]

1つめのタグ内に含まれている要素について確認してみると、

>>> quote.extract()
Out[42]: '<div class="quote" itemscope itemtype="http://schema.org/CreativeWork">\n        <span class="text" itemprop="text">“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”</span>\n        <span>by <small class="author" itemprop="author">Albert Einstein</small>\n        <a href="/author/Albert-Einstein">(about)</a>\n        </span>\n        <div class="tags">\n            Tags:\n            <meta class="keywords" itemprop="keywords" content="change,deep-thoughts,thinking,world"> \n            \n            <a class="tag" href="/tag/change/page/1/">change</a>\n            \n            <a class="tag" href="/tag/deep-thoughts/page/1/">deep-thoughts</a>\n            \n            <a class="tag" href="/tag/thinking/page/1/">thinking</a>\n            \n            <a class="tag" href="/tag/world/page/1/">world</a>\n            \n        </div>\n    </div>'

quoteの内容のほかに、authorの内容も含まれていることがわかります。
このSelectorオブジェクトからさらにCSSセレクタを使って情報を抽出することができます。

>>> title = quote.css("span.text::text").extract_first()
>>> title
'“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”'
>>> author = quote.css("small.author::text").extract_first()
>>> author
'Albert Einstein'
>>> tags = quote.css("div.tags a.tag::text").extract()
>>> tags
['change', 'deep-thoughts', 'thinking', 'world']

上記で行ったことを全てのquoteについて行うには、for構文を使ってiterationを回します。

>>> for quote in response.css("div.quote"):
...     text = quote.css("span.text::text").extract_first()
...     author = quote.css("small.author::text").extract_first()
...     tags = quote.css("div.tags a.tag::text").extract()
...     print(dict(text=text, author=author, tags=tags))
{'tags': ['change', 'deep-thoughts', 'thinking', 'world'], 'author': 'Albert Einstein', 'text': '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”'}
{'tags': ['abilities', 'choices'], 'author': 'J.K. Rowling', 'text': '“It is our choices, Harry, that show what we truly are, far more than our abilities.”'}
    ... a few more of these, omitted for brevity

Spiderの実装にデータの抽出を適用

Spiderの実装に上記で見出したデータ抽出を適用すると、以下のようなSpiderが作成できます。

import scrapy


class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = [
        'http://quotes.toscrape.com/page/1/',
        'http://quotes.toscrape.com/page/2/',
    ]

    def parse(self, response):
        for quote in response.css('div.quote'):
            yield {
                'text': quote.css('span.text::text').extract_first(),
                'author': quote.css('small.author::text').extract_first(),
                'tags': quote.css('div.tags a.tag::text').extract(),
            }

これを走らせると、以下のようになります。

2018-11-12 01:47:43 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/page/1/>
{'text': '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”', 'author': 'Albert Einstein', 'tags': ['change', 'deep-thoughts', 'thinking', 'world']}
2018-11-12 01:47:43 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/page/1/>
{'text': '“It is our choices, Harry, that show what we truly are, far more than our abilities.”', 'author': 'J.K. Rowling', 'tags': ['abilities', 'choices']}
2018-11-12 01:47:43 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/page/1/>
{'text': '“There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.”', 'author': 'Albert Einstein', 'tags': ['inspirational', 'life', 'live', 'miracle', 'miracles']}

ログから、データの抽出がうまくいっていることがわかります。

ファイル出力

JSON形式で出力するには、

scrapy crawl quotes -o quotes.json

とします。
ファイル出力を行う際、既に出力ファイルが存在すると、既存のファイルに追記することとなってしまうので注意が必要です。

スクレイピングにより得たデータに対して、より複雑な処理を行いたい際には、Item Pipelineを使用することになります。
startprojectコマンドによるプロジェクト作成時には、Item Pipeline用のファイルが生成されますが(今回だとtutorial/pipelines.py)、今回のように単にデータを抽出し、保存したい場合は、Item Pipelineを実装する必要はありません。

クローリング機能の追加

次に、単に1ページのスクレイピングにとどまらず、ウェブサイト内すべてのページのデータに関して該当するデータを抽出したい場合を考えていきましょう。

この場合、以下のHTMLで表記されている次ページへのリンクを辿る必要が出てきます。

<ul class="pager">
    <li class="next">
        <a href="/page/2/">Next <span aria-hidden="true">&rarr;</span></a>
    </li>
</ul>

shellでこの要素についてCSSセレクタで抽出するには、以下のように指定します。

>>> response.css('li.next a').extract_first()
'<a href="/page/2/">Next <span aria-hidden="true">→</span></a>'

hrefの値だけを抽出したい場合、CSSセレクタattr()を使用して以下のように行います。

>>> response.css('li.next a::attr(href)').extract_first()
'/page/2/'

これをもとに、先ほど作成したSpiderについて再帰的にページを辿るように変更を加えると以下のようになります。

import scrapy


class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = [
        'http://quotes.toscrape.com/page/1/',
    ]

    def parse(self, response):
        for quote in response.css('div.quote'):
            yield {
                'text': quote.css('span.text::text').extract_first(),
                'author': quote.css('small.author::text').extract_first(),
                'tags': quote.css('div.tags a.tag::text').extract(),
            }

        next_page = response.css('li.next a::attr(href)').extract_first()
        if next_page is not None:
            next_page = response.urljoin(next_page)
            yield scrapy.Request(next_page, callback=self.parse)

上記Spiderを実行した際に行われることは、以下の通りです。

  1. start_urlの1ページについてquoteをすべて抽出すると、次ページへ移動するためのaタグを取得し、urljoin()で次ページへのリンクを作成します。
  2. このリンクについて、新しくコールバック関数としてparse()関数を使用するRequestオブジェクトを作成しています。
  3. これが次ページへのリンクが作成できなくなるまで再帰的に行われます。

こうすることですべてのリンクについてスクレイピングを実行することができるのです。

Request生成のためのショートカット

first_requestを実装する代わりに、start_urlsを定義するだけで最初のRequestの生成が行われるように指定できたように、 次ページへのRequest生成に関しても、ショートカットが使えます。

先ほどのコードは、以下のように書けます。

import scrapy


class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = [
        'http://quotes.toscrape.com/page/1/',
    ]

    def parse(self, response):
        for quote in response.css('div.quote'):
            yield {
                'text': quote.css('span.text::text').extract_first(),
                'author': quote.css('span small::text').extract_first(),
                'tags': quote.css('div.tags a.tag::text').extract(),
            }

        next_page = response.css('li.next a::attr(href)').extract_first()
        if next_page is not None:
            yield response.follow(next_page, callback=self.parse)

着目すべきはresponse.follow関数です。
ここでは、urljoinを呼ぶことなく次ページへのリンクを生成し、Requestオブジェクトの生成までが行われています。

response.follow関数を使えば、わざわざ次ページへのURLの文字列を抽出せずとも、CSSセレクタを渡すだけでhrefの値の抽出、次ページへのリンク抽出を自動的に行ってくれます。

つまり、

next_page = response.css('li.next a::attr(href)').extract_first()
if next_page is not None:
    yield response.follow(next_page, callback=self.parse)

の部分は、以下のように書けます。

for a in response.css('li.next a'):
    yield response.follow(a, callback=self.parse)

重複するRequestについて

以下のようなSpiderを回すことを考えます。

import scrapy


class AuthorSpider(scrapy.Spider):
    name = 'author'

    start_urls = ['http://quotes.toscrape.com/']

    def parse(self, response):
        # follow links to author pages
        for href in response.css('.author + a::attr(href)'):
            yield response.follow(href, self.parse_author)

        # follow pagination links
        for href in response.css('li.next a::attr(href)'):
            yield response.follow(href, self.parse)

    def parse_author(self, response):
        def extract_with_css(query):
            return response.css(query).extract_first().strip()

        yield {
            'name': extract_with_css('h3.author-title::text'),
            'birthdate': extract_with_css('.author-born-date::text'),
            'bio': extract_with_css('.author-description::text'),
        }

このSpiderはメインページからスタートし、authorのページを辿りながらコールバック関数としてparse_authorを実行し、著者の情報を集めます。

ここで着目すべきは、同じauthorを持つような異なるquoteが存在することです。この場合、Spiderは同じページを何度もスクレイピングしてしまうといった事態に陥りそうですが、 なんとScrapyでは、デフォルトで重複するRequestは辿らないようになっています。
この設定はDUPEFILTER_CLASS環境変数で変更することができます。
詳しくはこちらへ。

引数の使用

Scrapyでは、実行時に指定したコマンドライン引数をSpider内で使用することができます。
例えば、以下のように実行すると、self.tag = 'humor'がクラスの初期化メソッド中で定義されることになります。

scrapy crawl quotes -o quotes-humor.json -a tag=humor

これを活用すると、与える引数により挙動が変わる以下のようなSpiderを作成することができます。

import scrapy


class QuotesSpider(scrapy.Spider):
    name = "quotes"

    def start_requests(self):
        url = 'http://quotes.toscrape.com/'
        tag = getattr(self, 'tag', None)
        if tag is not None:
            url = url + 'tag/' + tag
        yield scrapy.Request(url, self.parse)

    def parse(self, response):
        for quote in response.css('div.quote'):
            yield {
                'text': quote.css('span.text::text').extract_first(),
                'author': quote.css('small.author::text').extract_first(),
            }

        next_page = response.css('li.next a::attr(href)').extract_first()
        if next_page is not None:
            yield response.follow(next_page, self.parse)

このSpiderでは、与えた引数によりクローリング対象のURLを変更することができます。
例えばtag=humorという引数を渡したとすると、humorタグを持ったquoteだけに絞った http://quotes.toscrape.com/tag/humorというURLでクローリングが行われることになります。

以上。

ざっくりとのっぺりと見ていきました。
Scrapyがいかに強力なフレームワークかわかっていただけましたでしょうか。
基本いじるのはSpiderだけです。それだけで高機能なクローラが作れるなんて、Pythonは罪な言語ですね。