Edited at

10分で理解する Scrapy


Scrapy とは

Scrapy とは Python でクローラーを実装するためのフレームワークです

Python でクローラーというと BeautifulSouplxml などの HTML パーサーがよく使われていますが、 Scrapy はこれらのライブラリと違うレイヤーのもので、クローラーのアプリケーション全体を実装するためのフレームワークです

公式ドキュメントでも、BeautifulSoup と Scrapy を比較するのは、jinja2 と Django を比較しているようなものだと言ってます


In other words, comparing BeautifulSoup (or lxml) to Scrapy is like comparing jinja2 to Django.


https://doc.scrapy.org/en/latest/faq.html#how-does-scrapy-compare-to-beautifulsoup-or-lxml


TL;DR


  • Scrapy はクローラーを実装・運用するために欲しい機能がいろいろ用意されている

  • Items は抽出したいデータ構造のモデル

  • Spider は対象サイトへのリクエストとレスポンスのパーサー

  • Pipeline は抽出したデータに対する加工・保存 (など)


登場人物を整理

とりあえずこの3つを理解しておけばクローラーは書けます


Spider

クロール対象のサイトへのリクエスト、レスポンスのパース処理を記述します

どのようにサイトを辿って、ページの内容をどうパースするかのロジックが Spider に書かれます


Items

クロール対象のデータから抽出したいデータ構造を記述するモデルのようなものです

目的に応じて自由な構造を定義できます

Items は Spider で生成され Pipeline に渡されます


Pipeline

Spider より渡された Items に対する処理を記述します

DB への保存、ファイル出力など目的に応じて自由に処理を記述できます


インストール

以下のように pip でインストールするのみです

$ pip install scrapy

インストールが完了すると scrapy コマンドが使えるようになります

$ scrapy version

Scrapy 1.5.1


Scrapy を利用してクローラーを作る


0.プロジェクトを作成する

scrapy startproject <プロジェクト名> のコマンドで新規の scrapy プロジェクトを作成します

この辺りの設計は Django ととても似ています

$ scrapy startproject ten_min_scrapy

以下のようなファイルが生成されます

$ tree ten_min_scrapy/

ten_min_scrapy/
├── scrapy.cfg
└── ten_min_scrapy
├── __init__.py
├── __pycache__
├── items.py
├── middlewares.py
├── pipelines.py
├── settings.py
└── spiders
├── __init__.py
└── __pycache__

4 directories, 7 files


1.Spider を実装する

それでは scrapinghub のブログである https://blog.scrapinghub.com/ をクロールしてブログ記事の一覧を取得してくる Spider を実装してみましょう


1-1. Items を追加する

プロジェクトを作成すると items.py にサンプルのクラスが記述されています

class TenMinScrapyItem(scrapy.Item):

# define the fields for your item here like:
# name = scrapy.Field()
pass

このクラス名は適当に変えてしまってよいので、ブログ記事のURL、タイトル、公開日をもつ Post を Items として定義しましょう

class Post(scrapy.Item):

url = scrapy.Field()
title = scrapy.Field()
date = scrapy.Field()


1-2. Spider を追加する

新規の Spider は以下のようにプロジェクトのディレクトリ内で scrapy genspider <スパイダー名> <クロール対象ドメイン名> コマンドを実行するとひな型のファイルが生成されます

$ cd ./ten_min_scrapy

$ scrapy genspider scrapy_blog_spider blog.scrapinghub.com

ひな型のファイルは以下のような内容になっています

このコマンドを使わずに直接該当のファイルを作成しても問題ないです

class ScrapyBlogSpiderSpider(scrapy.Spider):

name = 'scrapy_blog_spider'
allowed_domains = ['blog.scrapinghub.com']
start_urls = ['http://blog.scrapinghub.com/']

def parse(self, response):
pass

start_urls に指定したURLからクロールがスタートします

レスポンスを受け取ると parse() メソッドが呼び出されます


1-3. Spider 内の parse() を追加する

blog.scrapinghub.com のページをブラウザから見ると、以下のようにページを辿っていけばブログ記事の一覧を抽出できそうだとわかります


  1. ブログの一覧から記事のURL、タイトル、公開日を抽出する

  2. ページ内に "OLDER POST" のリンクがあれば再帰的に辿り 1 の処理を繰り返す

  3. ページ内に "OLDER POST" のリンクが表示されなければ処理を終了

これを parse() 内に実装すると以下のようになります

class ScrapyBlogSpiderSpider(scrapy.Spider):

name = 'scrapy_blog_spider'
allowed_domains = ['blog.scrapinghub.com']
start_urls = ['http://blog.scrapinghub.com']

def parse(self, response):
"""
レスポンスに対するパース処理
"""

# response.css で scrapy デフォルトの css セレクタを利用できる
for post in response.css('.post-listing .post-item'):
# items に定義した Post のオブジェクトを生成して次の処理へ渡す
yield Post(
url=post.css('div.post-header a::attr(href)').extract_first().strip(),
title=post.css('div.post-header a::text').extract_first().strip(),
date=post.css('div.post-header span.date a::text').extract_first().strip(),
)

# 再帰的にページングを辿るための処理
older_post_link = response.css('.blog-pagination a.next-posts-link::attr(href)').extract_first()
if older_post_link is None:
# リンクが取得できなかった場合は最後のページなので処理を終了
return

# URLが相対パスだった場合に絶対パスに変換する
older_post_link = response.urljoin(older_post_link)
# 次のページをのリクエストを実行する
yield scrapy.Request(older_post_link, callback=self.parse)

response.css() で scrapy のデフォルトで用意されている CSSセレクタを利用できます

HTMLのパースは基本的にはこれをそのまま使えば困ることはないでしょう


1-4. クロールの設定を変更する


リクエスト間隔を設定する

settings.py を開いて DOWNLOAD_DELAY にリクエストを送信する間隔(秒)をセットしましょう

デフォルトではコメントアウトされています

リクエスト間隔が短すぎると DoS攻撃と同等のアクセスとなってしまうので、少なくとも数秒程度は間隔を空けるようにしましょう

# Configure a delay for requests for the same website (default: 0)

# See https://doc.scrapy.org/en/latest/topics/settings.html#download-delay
# See also autothrottle settings and docs
DOWNLOAD_DELAY = 3


レスポンスのキャッシュを設定する

Scrapy ではレスポンスのキャッシュ機能も用意されています

Spider の実装をして試行錯誤する時には何度も同じページへのリクエストが実行されてしまうので、特に開発中にはキャッシュを有効にした方がいいです

settings.py を開いて HTTPCACHE_ から始まる以下の項目のコメントを外せばキャッシュが有効になります

# Enable and configure HTTP caching (disabled by default)

# See https://doc.scrapy.org/en/latest/topics/downloader-middleware.html#httpcache-middleware-settings
HTTPCACHE_ENABLED = True
HTTPCACHE_EXPIRATION_SECS = 0
HTTPCACHE_DIR = 'httpcache'
HTTPCACHE_IGNORE_HTTP_CODES = []
HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.FilesystemCacheStorage'


1-5. 一度動かしてみる

さて、ここまでで実装した Spider を一度実行してみましょう

プロジェクトのディレクトリで以下を実行すると Spider が実行できます

$ scrapy crawl scrapy_blog_spider

以下のように DEBUGログが出力されていることを確認しましょう

抽出した Item の内容が出力されていますね

ブログ記事のタイトル、URL、公開日が正しく取得できていることがわかります

2018-10-08 12:53:46 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://blog.scrapinghub.com> (referer: None) ['cached']

2018-10-08 12:53:46 [scrapy.core.scraper] DEBUG: Scraped from <200 https://blog.scrapinghub.com>
{'date': 'September 27, 2018',
'title': 'Data Quality Assurance for Enterprise Web Scraping',
'url': 'https://blog.scrapinghub.com/data-quality-assurance-for-enterprise-web-scraping'}
2018-10-08 12:53:46 [scrapy.core.scraper] DEBUG: Scraped from <200 https://blog.scrapinghub.com>
{'date': 'September 12, 2018',
'title': 'What I Learned as a Google Summer of Code student at Scrapinghub',
'url': 'https://blog.scrapinghub.com/what-i-learned-as-a-google-summer-of-code-student-at-scrapinghub'}
2018-10-08 12:53:46 [scrapy.core.scraper] DEBUG: Scraped from <200 https://blog.scrapinghub.com>
{'date': 'July 25, 2018',
'title': 'GDPR Compliance For Web Scrapers: The Step-By-Step Guide',
'url': 'https://blog.scrapinghub.com/web-scraping-gdpr-compliance-guide'}
...


item をファイルに出力する

以下のように -o オプションをつけると、抽出した item をファイルに出力できます

これは csv ファイルに出力する例です

$ scrapy crawl scrapy_blog_spider -o data.csv

csv の他にもデフォルトで json, json lines, xml に対応しています

詳しくは以下を見てみましょう

https://doc.scrapy.org/en/latest/topics/feed-exports.html#topics-feed-exports


2. Pipeline を実装する

さて、 Spider の実装ができたので次に Pipeline を実装して、 SQLite のデータベースにデータを保存してみましょう

プロジェクトを作成した最初の状態では pipelines.py の内容は以下のようにひな型が作成されています

class TenMinScrapyPipeline(object):

def process_item(self, item, spider):
return item

spider から items が yield されたタイミングで process_item() が呼び出されます

ここでデータベースに登録する処理します


2-1. process_item() を実装する

詳細の説明は省きますがこんな感じで実装しました

import datetime

import os
import sqlite3

class TenMinScrapyPipeline(object):
_db = None

@classmethod
def get_database(cls):
cls._db = sqlite3.connect(
os.path.join(os.getcwd(), 'ten_min_scrapy.db'))

# テーブル作成
cursor = cls._db.cursor()
cursor.execute(
'CREATE TABLE IF NOT EXISTS post(\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
url TEXT UNIQUE NOT NULL, \
title TEXT NOT NULL, \
date DATE NOT NULL \
);')

return cls._db

def process_item(self, item, spider):
"""
Pipeline にデータが渡される時に実行される
item に spider から渡された item がセットされる
"""

self.save_post(item)
return item

def save_post(self, item):
"""
item を DB に保存する
"""

if self.find_post(item['url']):
# 既に同じURLのデータが存在する場合はスキップ
return

db = self.get_database()
db.execute(
'INSERT INTO post (title, url, date) VALUES (?, ?, ?)', (
item['title'],
item['url'],
datetime.datetime.strptime(item['date'], '%B %d, %Y')
)
)
db.commit()

def find_post(self, url):
db = self.get_database()
cursor = db.execute(
'SELECT * FROM post WHERE url=?',
(url,)
)
return cursor.fetchone()


Pipeline を有効にする

Pipeline の処理を有効にするために setting.py の内容を変更します

ITEM_PIPELINES の箇所のコメントを外せばOKです

# Configure item pipelines

# See https://doc.scrapy.org/en/latest/topics/item-pipeline.html
ITEM_PIPELINES = {
'ten_min_scrapy.pipelines.TenMinScrapyPipeline': 300,
}

今回は scrapy がデフォルトで生成してくれた TenMinScrapyPipeline をセットしていますが、自分で追加した任意のクラスも同じように dict のキーとして指定すると Pipeline として認識されます

また、値にセットされている数値は処理の優先順で、数値の小さい順に処理が適用されていきます

Pipeline は今回紹介したDBに登録する処理以外にもデータのバリデーションや加工などの処理のために使うことができます


2-2. 実行してみる

それでは最後に Spider を実装してみましょう

$ scrapy crawl scrapy_blog_spider

実行が完了するとカレントディレクトリに ten_min_scrapy.db という SQLite のファイルが作成されていると思います

ここにクロールしたデータが格納されています

$ sqlite3 ten_min_scrapy.db 

SQLite version 3.19.3 2017-06-27 16:48:08
Enter ".help" for usage hints.
sqlite> select * from post order by date desc limit 10;
1|https://blog.scrapinghub.com/data-quality-assurance-for-enterprise-web-scraping|Data Quality Assurance for Enterprise Web Scraping|2018-09-27 00:00:00
2|https://blog.scrapinghub.com/what-i-learned-as-a-google-summer-of-code-student-at-scrapinghub|What I Learned as a Google Summer of Code student at Scrapinghub|2018-09-12 00:00:00
3|https://blog.scrapinghub.com/web-scraping-gdpr-compliance-guide|GDPR Compliance For Web Scrapers: The Step-By-Step Guide|2018-07-25 00:00:00
4|https://blog.scrapinghub.com/web-scraping-at-scale-lessons-learned-scraping-100-billion-products-pages|For E-Commerce Data Scientists: Lessons Learned Scraping 100 Billion Products Pages|2018-07-02 00:00:00
5|https://blog.scrapinghub.com/2018/06/19/a-sneak-peek-inside-what-hedge-funds-think-of-alternative-financial-data|A Sneak Peek Inside What Hedge Funds Think of Alternative Financial Data|2018-06-19 00:00:00
6|https://blog.scrapinghub.com/2018/06/07/fitbit-quarterly-revenue-web-scraped-product-data|Want to Predict Fitbits Quarterly Revenue? Eagle Alpha Did It Using Web Scraped Product Data|2018-06-07 00:00:00
7|https://blog.scrapinghub.com/2018/05/30/gdpr-compliance-tools-web-scraping-crawlers|How Data Compliance Companies Are Turning To Web Crawlers To Take Advantage of the GDPR Business Opportunity|2018-05-30 00:00:00
8|https://blog.scrapinghub.com/2017/12/31/looking-back-at-2017|Looking Back at 2017|2017-12-31 00:00:00
9|https://blog.scrapinghub.com/2017/11/05/a-faster-updated-scrapinghub|A Faster, Updated Scrapinghub|2017-11-05 00:00:00
10|https://blog.scrapinghub.com/2017/07/07/scraping-the-steam-game-store-with-scrapy|Scraping the Steam Game Store with Scrapy|2017-07-07 00:00:00

これで Scrapy を使ったクローラーの基本がわかりましたね!


Tips

Scrapy を使ってクローラーを実装する上での簡単な Tips を紹介します


クロールを開始する URL を動的に変えたい

先ほどの例のように start_urls で固定の URL を指定するだけだと実際の利用シーンではかなり不便そうですよね

そういう場合は以下のように Spider の start_requests() メソッドを実装すれば動的にURLをセットできます

この start_requests() は Spider の処理を開始する際に呼び出されます

    def start_requests(self):

url = 'https://sardine-system.com/media/'
yield scrapy.Request(url, callback=self.parse)

また、 scrapy.Request(url, callback=self.parse) で指定している callback はレスポンスを受け取った後にコールバックされる処理です

start_requests() を使わない場合は先ほど記載した通り parse() が実行されますが、ここを任意の関数に変更することができます

parse の処理を別モジュールに切り出したい時に便利ですね


Spider に引数で値を渡したい

Spider の外部からクロール先のURLやパラメーターを渡したい時もあると思いますが、

Spider の起動時に -a key=value の形式で Spider に引数を渡すことができます

$ scrapy crawl scrapy_blog_spider -a param1=val1

ここで指定された値は Spider のコンストラクタの引数に渡されます

Spider の __init__() の引数に渡したい引数名を入れておけば対応するものにセットされます

また、__init__() の引数に明示されていないものは kwargs にセットされます

    def __init__(self, param1=None, *args, **kwargs):

super(ScrapyBlogSpiderSpider, self).__init__(*args, **kwargs)

# 引数に明示していない場合は kwargs から取得できる
arguments_from_outside = kwargs.get('param2', 0)


BeautifulSoup や他の HTMLパーサーを使いたい場合

BeautifulSoup の方がいい!という方でも大丈夫

response.text にレスポンスボディがセットされているので、BeautifulSoup やその他の HTMLパーサーも以下のようにして自由に利用できます

def parse(self, response):

# use lxml to get decent HTML parsing speed
soup = BeautifulSoup(response.text, 'lxml')
yield {
"url": response.url,
"title": soup.h1.string
}

https://doc.scrapy.org/en/latest/faq.html#can-i-use-scrapy-with-beautifulsoup


まとめ

さて、今回は Scrapy を利用したクローラーの作り方について書きました

ちゃんとクローラーを作ろうとするとHTMLのパースの処理以外にも、以下のような様々な周辺の処理が必要になってくるはずです


  • データの加工・保存

  • エラー発生時の処理

  • リクエストの間隔制御

Scrapy はそういったクローラーの汎用的なアーキテクチャーと実装を提供してくれているので、

定期的に実行されるクローラを作るにはとても便利なフレームワークです


今回実装したソースコード

今回実装したソースコードの一式はこちらに置いています

https://github.com/Chanmoro/ten_min_scrapy_tutorial