Scrapy 1.0が公開されました
Pythonの有名なWebスクレイピングフレームワークのScrapyがバージョン1.0になりました。*1
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対応も楽しみです。