【Python】Scrapy + Digdag でクローラの定期実行

Scrapy プロジェクトを Digdag でスケジューリングしてみたので導入の備忘録を残しておきます。
環境は MacBook Air (13-inch, Mid 2013), OSX 10.11.6 です。

Scrapy

Scrapy は Web Crawling / Scraping Framework で, mechanize や Beautiful Soup といった特定の機能を提供するライブラリと比べると多機能。 基本的な機能に加えて robots.txtポリシー, クロール間隔設定, リトライ処理, 並行処理, scrapydによるデーモン化 などもサポートしている。

Installation guide 通りで入ると思うが, 自分の環境 (OSX) では pip で上手くインストールできなかった。[1]
依存ライブラリやバージョンの不一致などの理由から conda でインストールを行う。

$ eval "$(pyenv init -)"
$ pyenv versions
* system (set by /Users/x/.pyenv/version)
  2.7.6
  3.4.0
  anaconda3-4.1.0
$ pyenv global anaconda3-4.1.0

conda の場合は以下で Scrapy をインストールする。

$ conda update conda
$ conda install -c conda-forge scrapy

プロジェクトを作成する場合は, scrapy startproject コマンドを使う。 また, spider の生成は scrapy genspider コマンドを使う。

scrapy crawl

Example にある dmoz.org をクローリングしてみる。構成は以下。(このプロジェクトは March 2017 に deprecated となり新しい Example は quotesbot となっている)

$ git clone https://github.com/scrapy/dirbot
$ tree
.
├── README.rst
├── dirbot
│   ├── __init__.py
│   ├── items.py
│   ├── pipelines.py
│   ├── settings.py
│   └── spiders
│       ├── __init__.py
│       └── dmoz.py
├── scrapy.cfg
└── setup.py

最初に, settings.py の中で, ロギングレベル (CRITICAL, ERROR, WARNING, INFO, DEBUG), robots.txtポリシー, クロール間隔, cookieの有効化, UA などの各種設定を書いておく。特に, クロール間隔は対象サーバの負荷を軽くするために忘れないで設定する。また, 見落としがちなのが, ITEM_PIPELINES はデフォルトだとコメントアウトされている点。

DOWNLOAD_DELAY = 3.0
ROBOTSTXT_OBEY = True
LOG_LEVEL = 'INFO'

items.py の中でデータ構造を定義し, spiders/*.py の start_urls で指定した url をクロールし, コールバック関数の parse() で得られた HTML から必要なデータを抜き出す。複雑な処理は非同期で呼ばれる pipelines.py に記述する方が良い。

selector は CSS か Xpath で指定できる。
取りたいデータが CSS selector だけで取得できると楽だけど, XPath での指定方法も知っておくと役立つ。

xpath(), css() 共にマッチした要素が list で返ってくるので, for in の中で extract() で unicode string を取得して itemオブジェクト に追加していく。また, 正規表現を使ってマッチする場合は re() を使う。

from scrapy.spiders import Spider
from scrapy.selector import Selector

from dirbot.items import Website


class DmozSpider(Spider):
    name = "dmoz"
    allowed_domains = ["dmoz.org"]
    start_urls = [
        "https://www.dmoz.org/Computers/Programming/Languages/Python/Books/",
        "https://www.dmoz.org/Computers/Programming/Languages/Python/Resources/",
    ]

    def parse(self, response):
        """
        The lines below is a spider contract. For more info see:
        https://doc.scrapy.org/en/latest/topics/contracts.html

        @url https://www.dmoz.org/Computers/Programming/Languages/Python/Resources/
        @scrapes name
        """
        sel = Selector(response)
        sites = sel.xpath('//ul[@class="directory-url"]/li')
        items = []

        for site in sites:
            item = Website()
            item['name'] = site.xpath('a/text()').extract()
            item['url'] = site.xpath('a/@href').extract()
            item['description'] = site.xpath('text()').re('-\s[^\n]*\\r')
            items.append(item)

        return items

items.py の Item オブジェクトは収集したデータを整理するためのオブジェクトである。

from scrapy.item import Item, Field


class Website(Item):

    name = Field()
    description = Field()
    url = Field()

pipelines.py には Field の整形や Item オブジェクトの DB への格納など様々な処理を書く。
spider ごとに pipelines の処理を切り替えたい場合は, spider.name で分ける方法がある。

from scrapy.exceptions import DropItem


class FilterWordsPipeline(object):
    """A pipeline for filtering out items which contain certain words in their
    description"""

    # put all words in lowercase
    words_to_filter = ['politics', 'religion']

    def process_item(self, item, spider):
        for word in self.words_to_filter:
            if word in item['description'].lower():
                raise DropItem("Contains forbidden word: %s" % word)
        else:
            return item

scrapy crawl [spider名] でクローリングが開始される。

$ scrapy list
dmoz

$ scrapy crawl dmoz
2016-08-26 22:44:11 [scrapy] INFO: Scrapy 1.1.1 started (bot: scrapybot)
2016-08-26 22:44:11 [scrapy] INFO: Overridden settings: {'DEFAULT_ITEM_CLASS': 'dirbot.items.Website', 'SPIDER_MODULES': ['dirbot.spiders'], 'NEWSPIDER_MODULE': 'dirbot.spiders'}
2016-08-26 22:44:11 [scrapy] INFO: Enabled extensions:
2016-08-26 22:44:11 [scrapy] INFO: Enabled downloader middlewares:
2016-08-26 22:44:11 [scrapy] INFO: Spider opened
2016-08-26 22:44:11 [scrapy] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2016-08-26 22:44:12 [scrapy] DEBUG: Crawled (200)  (referer: None)
2016-08-26 22:44:12 [scrapy] DEBUG: Crawled (200)  (referer: None)
2016-08-26 22:44:12 [scrapy] INFO: Closing spider (finished)
2016-08-26 22:44:12 [scrapy] INFO: Dumping Scrapy stats:
...
2016-08-26 22:44:12 [scrapy] INFO: Spider closed (finished)

scrapy shell

クローラ開発の初期段階では試行錯誤を伴うので, 対話的な環境を提供する scrapy shell を使うと便利。 fetch() で HTML を取得し, response オブジェクトを更新する。 (2018-06-24 追記)

$ scrapy version
Scrapy 1.5.0

$ scrapy shell
...
2018-06-24 09:44:41 [scrapy.middleware] INFO: Enabled item pipelines:
[]
2018-06-24 09:44:41 [scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:6023
2018-06-24 09:44:42 [root] DEBUG: Using default logger
2018-06-24 09:44:42 [root] DEBUG: Using default logger
[s] Available Scrapy objects:
[s]   scrapy     scrapy module (contains scrapy.Request, scrapy.Selector, etc)
[s]   crawler    
[s]   item       {}
[s]   settings   
[s] Useful shortcuts:
[s]   fetch(url[, redirect=True]) Fetch URL and update local objects (by default, redirects are followed)
[s]   fetch(req)                  Fetch a scrapy.Request and update local objects
[s]   shelp()           Shell help (print this help)
[s]   view(response)    View response in a browser
In [1]:

In [1]: fetch("https://www.google.co.jp/")
2018-06-24 09:45:50 [scrapy.core.engine] INFO: Spider opened
2018-06-24 09:45:50 [scrapy.core.downloader.tls] WARNING: Remote certificate is not valid for hostname "www.google.co.jp"; u'*.google.com'!=u'www.google.co.jp'
2018-06-24 09:45:50 [scrapy.core.engine] DEBUG: Crawled (200)  (referer: None)

In [2]: response
Out[2]: <200 https://www.google.co.jp/>

In [3]: response.url
Out[3]: 'https://www.google.co.jp/'

In [4]: response.css('.lst::attr(title)')
Out[4]: [<Selector xpath=u"descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' lst ')]/@title" data=u'Google \u691c\u7d22'>]

In [5]: search_title = response.css('.lst::attr(title)').extract_first()

In [6]: print(search_title)
Google 検索

Scrapy は FW なので手軽にクローラを作れるが, JavaScript 実行が必要な場合は Splash を使う scrapy-splash あるいは Selenium を使うことになる。[4]

Digdag でスケジューリング

クローラを定期実行する場合 Cron や Jenkins といった選択肢があると思うが, 今回は Distributed Workflow Engine の Digdag を使ったスケジューリングを試してみた。Digdag を使うことで, タスクのステータス管理ができフロー制御が簡単にできそう。また, スケジューラではないが, scrapyd を使うと HTTP JSON API でクローラを動かせる。

インストールは Getting started そのままで jar を配置するだけ。必要な場合は先に JDK8 をインストールしておく。

$ curl -o /usr/local/bin/digdag --create-dirs -L "https://dl.digdag.io/digdag-latest"
$ chmod +x /usr/local/bin/digdag

digdag init で dig ファイルが生成される。

$ digdag init dmoz
$ cd dmoz
$ tree
.
├── dmoz.dig
└── query.sql

0 directories, 2 files

先に作った Scrapy プロジェクトを Digdag でスケジューリングしてみる。適当に下記のような構成とした。

$ tree -I *.pyc -I __pycache__
.
├── dirbot
│   ├── README.rst
│   ├── dirbot
│   │   ├── __init__.py
│   │   ├── items.py
│   │   ├── pipelines.py
│   │   ├── settings.py
│   │   └── spiders
│   │       ├── __init__.py
│   │       └── dmoz.py
│   ├── scrapy.cfg
│   └── setup.py
├── dmoz.dig
└── tasks
    └── scrapy_dirbot.sh

4 directories, 11 files

Scheduling workflow を参考にして, 毎時30分に実行するように設定する。
スケジューリングは他にも cron形式 でも指定できる。dig ファイルは基本的には YAML の構文で, operators は shell 以外にも Ruby, Python なども使える。複数タスクの並列実行もできる。

timezone: "Asia/Tokyo"

schedule:
  hourly>: 30:00

+scrapy_dirbot:
  sh>: tasks/scrapy_dirbot.sh

digdag scheduler コマンドで定期実行する。または digdag run *.dig で一度だけ実行する。

#!/bin/sh

scrapy crawl dmoz

セッション情報は .digdag 以下に保存され, デフォルト (–session last) の場合成功したタスクは次回スキップされる。そのため, 最初から毎回実行したい場合は –rerun を指定して実行する。

今回は cron を置き換える使い方しかしていないが, Digdag を使うとより柔軟で高度なフローを組み立てることができそう。

Mackerel で監視して LINE Notify で通知を受け取る

VPS でクローラを動かしているが, 契約プランによっては 100GB 以下とディスク容量が小さい場合がある。対策としては Amazon S3 に転送するなどの方法はある。
ローカルに貯める場合では蓄積したデータがディスク容量を超えないか心配になる。そこで, サーバ管理・監視に Mackerel を導入し, 例えば ディスク容量が70% を超えたら LINE Notify で通知を受け取る設定を試してみた。設定は非常に簡単で事前に対策が打てるので便利だった。

終わりに

Scrapyの基本的な使い方や, Webスクレイピングの基礎は『PythonによるWebスクレイピング』が参考になります。どちらかと言うと内容は初学者向けな印象です。

また, スクレイピングで得られた Webデータ あるいは Webサービス提供側 が持つデータに対する主なタスク (バースト検出, 評判分類, 意味表現, グラフデータ) やその分析手法について『ウェブデータの機械学習』が参考になります。

Setup on Ubuntu 14.04

Ubuntu 14.04 での環境構築の例。

# Install Anaconda
$ wget https://repo.continuum.io/archive/Anaconda3-4.1.1-Linux-x86_64.sh
$ bash Anaconda3-4.1.1-Linux-x86_64.sh

# Install Scrapy
$ conda install -c scrapinghub scrapy
$ scrapy version
Scrapy 1.1.0

# Install JDK8
$ sudo apt-get install software-properties-common python-software-properties
$ sudo apt-add-repository ppa:openjdk-r/ppa
$ sudo apt-get update
$ sudo apt-get install openjdk-8-jdk

# Digdag is the same as OSX.

また, Ubuntu 14.04 では pip でもインストールできた。

$ pip install scrapy


[1] Scrapyをインストールするときのエラー対処。 を参考にさせて頂き, Xcode command line tools を最新にしたりしてみたが原因は別のようだった。
[2] PythonとかScrapyとか使ってクローリングやスクレイピングするノウハウを公開してみる!
[3] AN INTRODUCTION TO XPATH: HOW TO GET STARTED
[4] 【Python】 WebDriverでWebスクレイピング 【Selenium】
[5] HOW TO CRAWL THE WEB POLITELY WITH SCRAPY
[6] Workflow Engine をつくろう! Part 1(Task の依存関係の解決)
[7] Digdag schedulerで定期実行するプロジェクトを作ってみた。
[8] digdag-introduction
[9] Digdagのセッションについて調査メモ