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

orangain flavor

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

PythonとかScrapyとか使ってクローリングやスクレイピングするノウハウを公開してみる!

python scraping

2016-12-09追記

Pythonクローリング&スクレイピング」という本を書きました!

2015年6月21日 追記: この記事のクローラーは動かなくなっているので、Scrapy 1.0について書いた新しい記事を参照してください。

2014年1月5日 16:10更新: デメリットを修正しました。


以下の記事が話題になっていたので、乗っかってPythonの話を書いてみたいと思います。

Pythonでのやり方はいろいろあるかと思いますが、この記事では、非常に便利な割に日本語の情報が少ないスクレイピングフレームワークScrapy」を紹介します。

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 を使っていますが、正直RubyNokogiri のほうが直感的に書けると思います。別プロセスであれば、あまり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が良さそうかなと思ってますがまだ試せていません。