読者です 読者をやめる 読者になる 読者になる

orangain flavor

じっくりコトコト煮込んだみかん2。知らないことを知りたい。

Scrapy 1.0が公開されました

Pythonの有名なWebスクレイピングフレームワークScrapyがバージョン1.0になりました。*1

f:id:mi_kattun:20150621115515p:plain

0.24からの主要な変更点は下記のとおりです。

  • SpiderでItemの代わりにdictを返せるようになった
  • Spiderごとにsettingsを設定できるようになった
  • Twistedのloggingの代わりにPythonのloggingを使うようになった
  • CrawlerのコアAPIリファクタリングされた
  • いくつかのモジュール配置場所が変更された

他にも数多くの変更点がリリースノートに記載されています。

Scrapy 1.0の感想

大きな機能の追加よりも、APIの整理と安定性の向上がメインのようです。これまではバージョンを重ねるごとに便利になっていくものの、あまりAPIが安定していない印象でしたが、APIを安定させた区切りのリリースと言えるでしょう。1.0というメジャーバージョンに到達したことで、安心して使えるようになったと思います。

Itemの代わりにdictが使えるようになったのはありがたいです。Itemの存在意義は正直謎でしたので。 個人的に気になっていた、単一の要素を取得したい場合でもlistを返すextract()しかなくて使いにくい問題も、extract_first()メソッドが追加されたことで解決したので嬉しいです。

1.0では依然としてPython 2.7のみの対応ですが、1.1のマイルストーンにはPython 3のサポートが含まれています。実際、Google Summer of Code 2015のテーマとして採択されており、この夏の成果が楽しみです。

それでは早速Scrapy 1.0を使ってクローラーを書いてみましょう。

例1:1ファイルのシンプルなクローラー

Scrapyは大規模なクローリングを得意とするフレームワークですが、1ファイルから構成されるシンプルなクローラーも書けます。

ScrapyのWebサイトのトップに表示されている、Scrapinghubのブログをクロールするクローラーを動かしてみます。ちなみにScrapinghubはScrapyの開発者が立ちあげた会社です。

表示されているコードをそのままターミナルに貼り付けて実行してもよいのですが、ちょっと手を加えて解説も加えておきます。

1. Scrapyをインストールする

Python 2.7が必要です。 --upgradeをつけることでインストール済みの場合はアップグレードします。

$ pip install --upgrade scrapy

2. service_identityもインストールしておく

service_identityはMITMを防ぐモジュールであり、これがインストールされていないと警告が出ます。

$ pip install service_identity

3. Spiderを作成する

myspider.pyという名前で以下の内容のファイルを作成します。 日本語のコメントを付けたので、1行目にエンコーディングを指定しています。ファイルはUTF-8で保存してください。

# coding: utf-8
import scrapy


# scrapy.Spiderを継承してBlogSpiderを定義する
class BlogSpider(scrapy.Spider):
    name = 'blogspider'
    start_urls = ['http://blog.scrapinghub.com']

    def parse(self, response):
        # トップページをパースするメソッド。
        # URLに /yyyy/mm/ を含むアーカイブページへのリンクを抽出してクロールする。
        # それらのページはparse_titles()メソッドでパースする。
        for url in response.css('ul li a::attr("href")').re(r'.*/\d\d\d\d/\d\d/$'):
            yield scrapy.Request(response.urljoin(url), self.parse_titles)

    def parse_titles(self, response):
        # アーカイブページからエントリーのタイトルを取得する
        for post_title in response.css('div.entries > ul > li a::text').extract():
            yield {'title': post_title}

4. クローラーを実行する

以下のコマンドで実行できます。

$ scrapy runspider myspider.py
2015-06-20 22:14:05 [scrapy] INFO: Scrapy 1.0.0 started (bot: scrapybot)
2015-06-20 22:14:05 [scrapy] INFO: Optional features available: ssl, http11
2015-06-20 22:14:05 [scrapy] INFO: Overridden settings: {}
2015-06-20 22:14:05 [scrapy] INFO: Enabled extensions: CloseSpider, TelnetConsole, LogStats, CoreStats, SpiderState
2015-06-20 22:14:05 [scrapy] INFO: Enabled downloader middlewares: HttpAuthMiddleware, DownloadTimeoutMiddleware, UserAgentMiddleware, RetryMiddleware, DefaultHeadersMiddleware, MetaRefreshMiddleware, HttpCompressionMiddleware, RedirectMiddleware, CookiesMiddleware, ChunkedTransferMiddleware, DownloaderStats
2015-06-20 22:14:05 [scrapy] INFO: Enabled spider middlewares: HttpErrorMiddleware, OffsiteMiddleware, RefererMiddleware, UrlLengthMiddleware, DepthMiddleware
2015-06-20 22:14:05 [scrapy] INFO: Enabled item pipelines:
2015-06-20 22:14:05 [scrapy] INFO: Spider opened
2015-06-20 22:14:05 [scrapy] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2015-06-20 22:14:05 [scrapy] DEBUG: Telnet console listening on 127.0.0.1:6023
2015-06-20 22:14:06 [scrapy] DEBUG: Crawled (200) <GET http://blog.scrapinghub.com> (referer: None)
2015-06-20 22:14:07 [scrapy] DEBUG: Crawled (200) <GET http://blog.scrapinghub.com/2012/07/> (referer: http://blog.scrapinghub.com)
2015-06-20 22:14:07 [scrapy] DEBUG: Crawled (200) <GET http://blog.scrapinghub.com/2011/11/> (referer: http://blog.scrapinghub.com)
...

実行するとアーカイブページを30ページほどクロールして、タイトルを取得できます。

-o titles.jl という引数をつけると、取得したタイトルがtitles.jlという名前のファイルにJSONlines形式で書き込まれます。

複雑なクローラーの作成

続いて、1年半前の記事と同様にBBCとCNET Newsを対象として、もう少し複雑なクローラーを書いてみます。

以前の記事に書いたクローラーは、Webサイト側の変更によって既に2つとも動かなくなっています。Webスクレイピングの無常さを感じますが、めげずに書き直します。 当時のバージョンは0.20.2でしたが、1.0の機能を使うとよりシンプルに書けます。

スクレイピングした後にデータベースに保存するなどの処理を行ったり、設定を共有した複数のSpiderを動かしたりするなど、1ファイルでは収まらないクローラーを作成するときには、プロジェクトを作るのがScrapyの流儀です。

以下のコマンドで、helloscrapyという名前のプロジェクトを作成します。

$ scrapy startproject helloscrapy

以下のファイルが生成されます。

$ tree helloscrapy
helloscrapy
├── helloscrapy
│   ├── __init__.py
│   ├── items.py
│   ├── pipelines.py
│   ├── settings.py
│   └── spiders
│       └── __init__.py
└── scrapy.cfg

プロジェクトのディレクトリにcdしておきます。

$ cd helloscrapy/helloscrapy

以降では、このディレクトリ(settings.pyが存在するディレクトリ)を基準とします。

とりあえず、settings.pyに以下の設定を追加しておきます。Webページのダウンロード間隔として3秒空け、Webサイトのrobots.txtに従うようになります。

DOWNLOAD_DELAY = 3
ROBOTSTXT_OBEY = True

例2:XML Sitemapを持つサイトのクローリング

XML Sitemapを持つWebサイトをクロールするにはSitemapSpiderが便利です。

例として、CNET Newsを取り上げます。以前の記事ではXML Sitemapを持たないサイトとして紹介しましたが、リニューアルしてXML Sitemapが提供されていました。

spiders/cnet.pyを以下の内容で作成します。scrapy genspider cnet www.cnet.comを実行するとSpiderの雛形が生成されるので、これを変更しても構いません。

# coding: utf-8
from datetime import datetime

import scrapy


# SitemapSpiderを継承する
class CNETSpider(scrapy.spiders.SitemapSpider):
    name = "cnet"
    allowed_domains = ["www.cnet.com"]
    sitemap_urls = (
        # ここにはrobots.txtのURLを指定してもよいが、
        # 無関係なサイトマップが多くあるので、今回はサイトマップのURLを直接指定する。
        'http://www.cnet.com/sitemaps/news.xml',
    )
    sitemap_rules = (
        # 正規表現 '/news/' にマッチするページをparse_news()メソッドでパースする
        (r'/news/''parse_news'),
    )

    def parse_news(self, response):
        yield {
            # h1要素の文字列を取得する
            'title': response.css('h1::text').extract_first(),
            # div[itemprop="articleBody"]の直下のp要素以下にある全要素から文字列を取得して結合する
            'body'''.join(response.css('div[itemprop="articleBody"] > p ::text').extract()),
            # time[itemprop="datePublished"]のclass属性にUTCの時刻が格納されているので、パースする
            'time': datetime.strptime(
                response.css('time[itemprop="datePublished"]::attr(class)').extract_first(),
                '%Y-%m-%d %H:%M:%S'
            ),
        }

以下のコマンドでクローラーを実行します。しばらくするとitems-cnet.jlスクレイピング結果が出力されていきます。

$ scrapy crawl cnet -o items-cnet.jl

例3:XML Sitemapを持たないサイトのクローリング

XML Sitemapを持たないサイトのクローリングにはCrawlSpiderが便利です。

例として、BBCを取り上げます。以前の記事ではXML Sitemapを持つサイトとして紹介しましたが、ニュース用のXML Sitemapが404になっていて使用できなかったので、XML Sitemapを使わずにクロールします。

spiders/bbc.pyを以下の内容で作成します。scrapy genspider -t crawl bbc www.bbc.comを実行するとCrawlSpiderを使ったSpiderの雛形が生成されるので、これを変更しても構いません。

# coding: utf-8
from datetime import datetime

from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule


# CrawlSpiderを継承する
class BBCSpider(CrawlSpider):
    name = "bbc"
    allowed_domains = ["www.bbc.com"]
    start_urls = (
        'http://www.bbc.com/news',
    )
    rules = (
        # /news/world/*** というカテゴリページを辿る
        Rule(LinkExtractor(allow=r'/news/world/'), follow=True),
        # /news/world-*** というニュースページはparse_news()メソッドでパースする
        Rule(LinkExtractor(allow=r'/news/world-'), callback='parse_news'),
    )

    def parse_news(self, response):
        yield {
            # h1要素の文字列を取得する
            'title': response.css('h1::text').extract_first(),
            # .story-body__innerの直下のp要素文字列を取得して改行で結合する
            'body''\n'.join(response.css('.story-body__inner > p::text').extract()),
            # .story-body .dateのdata-seconds属性にタイムスタンプが格納されているので時刻に変換する
            'time': datetime.fromtimestamp(int(
                response.css('.story-body .date::attr("data-seconds")').extract_first())),
        }

以下のコマンドでクローラーを実行します。しばらくするとitems-bbc.jlスクレイピング結果が出力されていきます。たまにパースに失敗するページもありますが、無視してください。

$ scrapy crawl cnet -o items-bbc.jl

まとめ

Scrapy 1.0になり、すっきりと書けるようになったことがわかるかと思います。 使いやすくなり安定したScrapyでクローリングしていきましょう。 今後のPython 3対応も楽しみです。

*1:2015-06-21 13:00時点で公式サイトは0.24の表記のままですが、メーリングリストでアナウンスがありました。