2015年3月3日火曜日

PythonとかScrapyとか使ってクローリング

言葉の定義

まず初めに言葉の定義をしておきます。私はあまり意識せずに使ってしまうこともありますが、「クローリング」と「スクレイピング」はそれぞれ以下のように異なる作業を意味する言葉です。

  • クローリング:Webページのハイパーリンクを辿って次々にWebページをダウンロードする作業。
  • スクレイピング:ダウンロードしたWebページから必要な情報を抜き出す作業。

また、クローリングを行うロボットをクローラーあるいはスパイダーと呼びます。

Scrapyとは

今回紹介する Scrapy はクローリングとスクレイピングの両方を行うフレームワークです。単なるライブラリではなくフレームワークなので、以下のような面倒な作業を肩代わりしてくれます。このためユーザーは本質的なところに集中でき、とても便利です。

  • Webページからのリンクの抜き出し
  • robots.txtのパース
  • XML Sitemapのパース
  • ドメインごと/IPごとのクロール時間間隔の調整
  • 並行処理
  • 重複するURLのクロール防止
  • エラー時の回数制限付きのリトライ
  • クローラーのデーモン化とジョブの管理

便利な分やや重量級なので、使い捨てのスクリプトを書く場合よりも、複数のサイトを継続的にクロールする場合に向いています。

対応するPythonのバージョンは2.7のみで、ライセンスはBSD Licenseです。

インストール

以下のコマンドでScrapyをインストールできます。執筆時点では0.20.2がインストールされました。依存関係として lxml がインストールされるので、libxml2やlibxsltの開発用ファイルのインストールが必要かもしれません。

pip install scrapy

適当なフォルダで以下のコマンドを実行し、プロジェクトを作成します。

scrapy startproject helloscrapy 

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

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

以降で説明するファイルやフォルダ名は、特に断りのない限り、helloscrapy/helloscrapyディレクトリを基準とします。

とりあえずsettings.pyに以下の設定を追加しておきましょう。この設定により、平均3秒のクロール間隔が空き、robots.txtに従うようになります。

DOWNLOAD_DELAY = 3  ROBOTSTXT_OBEY = True   

データの出力

スクレイプしたデータは、デフォルトではJSONlines形式(1行ごとにJSONがあるテキストファイル)で出力されます。とりあえずはこれで問題ないでしょう。

データベースに格納したい場合は、Item Pipelineと呼ばれるクラスが必要です。(後述します)

出力するデータ構造を定義するため、items.pyに以下のクラスを作っておきます。Itemはフィールドを定義してdictライクに扱えるクラスです。

class NewsItem(Item):      title = Field()      body = Field()      time = Field()  

例1:XML Sitemapが存在するサイト

それでは早速Scrapyでクロールしてみましょう。

XML Sitemapが存在するサイトであれば、SitemapSpider  を使うことで簡単にクロールできます。

例として、BBC を取り上げます。 spiders/bbc.pyを以下の内容で作成します。*1

# coding: utf-8    from datetime import datetime    from scrapy.contrib.spiders import SitemapSpider  from scrapy.selector import Selector    from helloscrapy.items import NewsItem      class BBCSpider(SitemapSpider):      name = 'bbc'      allowed_domains = ['www.bbc.co.uk']      sitemap_urls = [          # ここにはrobots.txtのURLを指定してもよいが、          # 無関係なサイトマップが多くあるので、今回はサイトマップのURLを直接指定する。          'http://www.bbc.co.uk/news/sitemap.xml',      ]      sitemap_rules = [          # 正規表現 '/news/' にマッチするページをparse_newsメソッドでパースする          (r'/news/', 'parse_news'),      ]        def parse_news(self, response):          item = NewsItem()            sel = Selector(response)          item['title'] = sel.xpath('//h1[@class="story-header"]/text()').extract()[0]          item['body'] = u'\n'.join(              u''.join(p.xpath('.//text()').extract()) for p in sel.css('.story-body > p'))          item['time'] = datetime.strptime(              u''.join(sel.xpath('//span[@class="story-date"]/span/text()').extract()),              u'%d %B %YLast updated at %H:%M GMT')            yield item  

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

scrapy crawl bbc -o items-bbc.jl

例2:XML Sitemapが存在しないサイト

XML Sitemapが存在しないサイトでは、CrawlSpider が便利です。 開始ページ start_urls と、辿っていくリンクの正規表現 rules を書くことで簡単にクロールできます。

例として、元記事で取り上げられていた、CNET News をクロールします。

spiders/cnet.pyを以下の内容で作成します。 *2

# coding: utf-8    from datetime import datetime    from scrapy.contrib.spiders import CrawlSpider, Rule  from scrapy.contrib.linkextractors.sgml import SgmlLinkExtractor  from scrapy.selector import Selector    from helloscrapy.items import NewsItem      class CNetSpider(CrawlSpider):      name = 'cnet'      allowed_domains = ['news.cnet.com']      start_urls = [          'http://news.cnet.com/8324-12_3-0.html',      ]      rules = [          # 正規表現 'begin=201312' にマッチするリンクを辿る          Rule(SgmlLinkExtractor(allow=(r'begin=201312', ), restrict_xpaths=('/html', ))),          # 正規表現 '/[\d_-]+/[^/]+/$' にマッチするリンクをparse_newsメソッドでパースする          Rule(SgmlLinkExtractor(allow=(r'/[\d_-]+/[^/]+/$', ), restrict_xpaths=('/html', )),               callback='parse_news'),      ]        def parse_news(self, response):          item = NewsItem()            sel = Selector(response)          item['title'] = sel.xpath('//h1/text()').extract()[0]          item['body'] = u'\n'.join(              u''.join(p.xpath('.//text()').extract()) for p in sel.css('#contentBody .postBody p'))          item['time'] = datetime.strptime(              sel.xpath('//time[@class="datestamp"]/text()').extract()[0].strip()[:-4],              u'%B %d, %Y %I:%M %p')            yield item  

以下のコマンドでクローラーを実行します。

scrapy crawl cnet -o items-cnet.jl

説明は省略しますが、辿るリンクをDOMから見つけ出す処理を自分で書くことももちろんできます。最初はそのほうがScrapyの仕組みがわかりやすいかもしれません。 以下のサイトが参考になるでしょう。

pythonのフレームワークでサクッとクローラをつくる。"Python Framework Scrapy" - 忘れないようにメモっとく

もっと拡張したい

Scrapyは以下のようなアーキテクチャになっており、様々な拡張ポイントで拡張することができます。

f:id:mi_kattun:20140104221104j:plain

Architecture overview — Scrapy 0.21.0 documentation 

同じくPython製のWebアプリケーションフレームワーク Django にインスパイアされている箇所が多くあるため、Djangoの利用経験がある方は馴染みやすいのではないかと思います。

データの永続化

上で述べた通り、データベース(RDB、NoSQL)への永続化は Item Pipeline で行います。なお、RDBへの保存は推奨されていません 。噂は色々聞きますが、私はまだ苦しめられた記憶がないのでMongoDBを使っています。

スクレイピング結果をMongoDBに保存する簡単なItem Pipelineは以下のようになります。pipelines.pyに作りましょう。なお、実行には PyMongo のインストールが必要です。

# Define your item pipelines here  #  # Don't forget to add your pipeline to the ITEM_PIPELINES setting  # See: http://doc.scrapy.org/en/latest/topics/item-pipeline.html    import pymongo      class MongoPipeline(object):        @classmethod      def from_settings(cls, settings):          return cls(              settings.get('MONGO_URL', 'mongodb://localhost:27017/'),              settings.get('MONGO_DATABASE', 'scrapy'),          )        def __init__(self, mongo_url, mongo_database):          client = pymongo.MongoClient(mongo_url)          self.db = client[mongo_database]        def process_item(self, item, spider):          collection = self.db[spider.name]          collection.save(dict(item))          return item   

settings.pyに以下のような設定を追加する必要があります。

ITEM_PIPELINES = {      'helloscrapy.pipelines.MongoPipeline': 800,  }    # 必要に応じて  # MONGO_URL = 'mongodb://mongohost:27017/'  # MONGO_DATABASE = 'news'  

ちなみにわざわざ自分で書かなくても scrapy-mongodb というライブラリが存在します。

スクレイピングの機能とScrapyのデメリット

ここまで、Scrapyのスクレイピングの機能(parse_newsのようなメソッド)にはほとんど触れてきませんでした。それは、Scrapyのスクレイピング機能が正直使いにくい*3という理由もありますが、一番大きいのはアーキテクチャの問題です。

Scrapyは便利なのですが、ダウンロードしたタイミングでスクレイピングするアーキテクチャになっているのは、あまりよろしいと思えません。ダウンロードした生のHTMLは一度データベースに保存しておき、オフラインでスクレイピングを行うべきです。

生のHTMLを保存しておかないと、スクレイピングの度に相手先のサーバーに負荷をかけてしまいます。また、スクレイパーに問題が見つかったときに問題を再現できない可能性があります。 *4

ディスク容量が不安になるかもしれませんが、ディスクの安さと圧縮の効果を考えればある程度まではなんとかなるでしょう。*5

以上を踏まえ実運用では、単純にHTMLをMongoDBに保存するだけのItem Pipelineと、ジョブキューサーバーにジョブを追加するだけのItem Pipelineを作成し、スクレイピングは別プロセスで行っています。スクレイピングには lxml を使っていますが、正直Rubyの Nokogiri のほうが直感的に書けると思います。別プロセスであれば、あまりPythonにこだわる必要はありません。

その他、以下のことはデフォルトではできないため、必要であれば自分で実装しなくてはなりません。

  • JavaScriptの実行はできない。
  • 以前のプロセスでクロール済みのURLを再クロールしないようにできない。 HttpCacheMiddleware を使えばキャッシュできます。
  • 異なるプロセス同士でダウンロード間隔の調整ができない。

またPython 3には未対応である点も注意が必要です。

まとめ

Scrapy を使うと短いコードでスクレイピングできて非常に便利なので、是非使ってみてください。

説明に利用したソースコードは GitHub に置いています。

冒頭で紹介した2つのサイトでも言われているように、クローラースクレイパー疎結合になっているべきです。スクレイパーをうまく切り出せるようなフレームワークがあると良いと思います。

参考サイト

*1:スクレイパーは適当に書いたので、先頭に動画があるページは正しくパースできません。パース時にエラーが起きてもそのページが無視されるだけなので気にしないでください。

*2: restrict_xpaths=('/html', ) という引数は、SgmlLinkExtractorがXHTMLに対応していない問題を回避するためのバッドノウハウです。もっと良いやり方をご存じの方は是非教えて下さい。

*3:extract()でlistが返ってくるとか、CSS Selectorではテキストを取得できないとか。

*4:バッドデータハンドブックの第5章にもそのような話が載っていました。

*5:クロールしたHTMLを空間効率良くディスクに保存できるストレージをご存知でしたら、是非教えて頂けるとありがたいです。 HBaseが良さそうかなと思ってますがまだ試せていません。

0 件のコメント:

コメントを投稿