データ収集の効率を圧倒的に高めるスクレイピングFW【Scrapy】

はじめまして、R&Dに所属している @shimada です。6月より業務委託として、データ分析・収集やマーケティング自動化などをやらせていただいています。

R&Dチームと何をやっているかについては、CTO id:kotamat のエントリーを参照していただければと思います。

R&Dと開発でチームを分けた理由とOKR

一部抜粋

SCOUTER社はSCOUTER、SARDINE共に人材紹介の事業を行っており、「転職者に寄り添う支援活動」を後押しするような機能開発を行っております。 この文脈において、すぐには結果に結びつかないかもしれないが、将来的にはやるべきな機能の検討から調査・開発までをR&Dチームは行っております。具体的には、上記リリースの通り、転職者属性の解析や求人のリコメンド、獲得をAIを用いて解決していくことを行っています。

上記エントリーにあるように、R&Dでは転職者・求人情報の解析やリコメンド・検索エンジンアルゴリズムの改良に取り組み始めているのですが、それらの機能を開発する前の段階として、SCOUTERが所有しているデータ以外にもWeb上から転職者や求人情報を解析するために必要な大量のデータを収集する必要が出てきました。またR&Dでは、データ分析・解析にはPHPではなく主にPythonを使用しています。このような背景から、今回はPython製の強固なスクレイピングフレームワークであるScrapyを採用するに至りました。

Scrapyって?

ScrapyはPython製のフルスタックなスクレイピングフレームワークです。スクレイピングに最低限必要な機能に加えて、スロットリングや非同期実行、リクエスト/レスポンスのフックなど、だいたいなんでもできちゃいます。

Scrapy | A Fast and Powerful Scraping and Web Crawling Framework

また、Djangoにインスパイアされた作りになっており、各コンポーネントの概念さえ把握してしまえば学習コストも高くはありません。

下の図がScrapyのアーキテクチャです。

f:id:tsmd44:20180816000246p:plain

Architecture overview — Scrapy 1.5.1 documentation

  • Engine:各コンポネーントを制御
  • Scheduler:キューイングとスケジューリング
  • Downloader:リクエストとレスポンスオブジェクトの生成
  • Spider:実際のデータ抽出処理を記述する部分、データをItemオブジェクトに詰めてPipelineに渡す
  • Pipeline:CSVやデータベースへの保存など、データの加工・出力を担当

一見複雑そうですが、SchedulerやDownloaderの部分はScrapyがやってくれるので、実際にコードを書くところはSpiderとPipelineぐらいです。

このエントリーでは、SARDINE人材紹介マガジンを例に、Scrapyの基本的な使い方について紹介していきたいと思います。

Scrapyのインストール

Python2.7にも対応していますが、スクレイピングでは特に文字コード問題に悩まされることになるので、3系を使いましょう。最新の3.7でも問題ありません。

$ pip3 install scrapy

今回は、tutorialという名前でプロジェクトを作成します。

$ scrapy startproject tutorial

下記のようなディレクトリが作成されたと思います。 コマンドもディレクトリ構成もDjangoそっくりですね。

tutorial/
    scrapy.cfg
    tutorial/
        __init__.py
        items.py
        middlewares.py
        pipelines.py
        settings.py       
        spiders/
            __init__.py

Spider

サンプルとして人材紹介マガジンのタイトルとURLを取得するSpiderを作成してみます。SARDINE人材紹介マガジンはNuxt.jsで作成されたプロジェクトですが、サーバーサイドでレンダリングされているため通常のサイトと同じようにスクレイピング可能です。

最初に、tutorial/spiders/ の下に magazine.py というファイル名でSpiderを作成します。

import scrapy

class MagazineSpider(scrapy.Spider):
    name = 'magazine'

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

    def parse(self, response):
        for selector in response.css('.main-wrapper .container a'):
            yield {
                'title': selector.css('.main-text::text').extract_first(),
                'url': response.urljoin(selector.css('::attr(href)').extract_first())
            }

上から順番に見ていくと、スクレイピング開始直後に start_requests が呼ばれ、リクエストを作成し yield すると、レスポンス取得後のコールバックとして parse メソッドが呼ばれます。

parse メソッドでは、https://sardine-system.com/media/からのレスポンスを引数として受け取り、.css() メソッドでHTMLをパースする処理を記述しています。

response.css('.main-wrapper .container a') では、下の画像の部分のHTMLを抽出しています。

f:id:tsmd44:20180816000644j:plain

Devツールではこんな感じ

f:id:tsmd44:20180816000703j:plain

.css() メソッドでリターンされるのは、 Selector オブジェクト(のリスト)です。Selector オブジェクトから、さらに .css() メソッドをチェーンして、最終的に .extract() でテキストとして抽出するのが基本的な流れです。

Selectorの詳しい使い方については公式ドキュメントを参照してください。

Selectors — Scrapy 1.5.1 documentation

HTMLからのデータ抽出は、XPathで記述する方法もありますが、CSSの方が簡単です。普段CSSを書くのと同じようにクラス名やタグを指定すればOKです。

プロジェクトルートで下記のコマンドを入力すると、out.csv にタイトルとURLの一覧が出力されていると思います。

$ scrapy crawl magazine -o out.csv

簡単ですね!

Scrapy Shell

Python系のライブラリの強みは、iPythonやJupyter notebookでコードをテスト出来るところだと思います。いちいち確認のためにテストやプログラムを実行する必要はありません。

ScrapyにもDjango Shell(Laravelで言うところのtinkerみたいなやつ)のようなシェルが組み込まれており、iPython環境で気軽にターゲットのXPathCSSのテストを行うことができます。

下記のようにコマンドを入力すると、シェルが立ち上がり、https://sardine-system.com/media/ からのレスポンスが response オブジェクトに自動で格納されます。

$ scrapy shell 'https://sardine-system.com/media/'

試しに、最新記事の作成日を取得してみましょう。

In [x]: pub_date = response.css('.main-wrapper .container a time::text').extract_first()
In [x]: pub_date
Out [x]: '2018/08/09'

また、新しいリクエストオブジェクトを作成し、fetch することでshell上で新しいページに遷移することができます。

In [x]: latest_page = response.css('.main-wrapper .container a::attr(href)').extract_first()
In [x]: r = scrapy.Request(response.urljoin(latest_page))
In [x]: fetch(r)
In [x]: response.url
Out [x]: 'https://sardine-system.com/media/posts/p180809'

取得したCookieも自動で送信してれるので、ログイン後に保護されたページをテストしたい場合でも問題ありません。

Pipeline

スクレイピングしたデータは、CSVJSONよりデータベースに保存したい場合の方が多いと思います。Scrapyでは、Pipelineを作成し settings.py に追加することで複数のデータソースに出力することが可能です。

例として、MySQL保存用のPipelineを作成してみたいと思います。 最初にテスト用のテーブルを作成してください。

create database scrapy;
create table scrapy.magazine (
  `guid` varchar(32) not null primary key,
  `title` text null,
  `url` text null,
  `created` datetime default CURRENT_TIMESTAMP not null,
  `updated` datetime default CURRENT_TIMESTAMP not null
);

pipelines.pyMySQLPipeline という名前の新しいPipelineを作成します。

from datetime import datetime
import hashlib
import logging
from twisted.enterprise import adbapi

class MySQLPipeline(object):
    def __init__(self, db_pool):
        self.db_pool = db_pool

    @classmethod
    def from_settings(cls, settings):
        db_args = {
            'host': settings['DB_HOST'],
            'db': settings['DB_NAME'],
            'user': settings['DB_USER'],
            'passwd': settings['DB_PASSWORD'],
            'charset': 'utf8',
            'use_unicode': True
        }
        db_pool = adbapi.ConnectionPool('MySQLdb', **db_args)
        return cls(db_pool)

    def process_item(self, item, spider):
        d = self.db_pool.runInteraction(self._upsert, item, spider)
        d.addErrback(self._handle_error, item, spider)
        d.addBoth(lambda _: item)
        return d

    def _upsert(self, conn, item, spider):
        guid = self.get_guid(item['url'])
        now = datetime.utcnow().replace(microsecond=0).isoformat(' ')

        conn.execute("""
            SELECT EXISTS(
                SELECT 1 FROM magazine WHERE guid = %s
            )
        """, (guid,))
        ret = conn.fetchone()[0]

        if ret:
            conn.execute("""
                UPDATE magazine
                SET title=%s, updated=%s
                WHERE guid=%s
            """, (item['title'], now, guid))
        else:
            conn.execute("""
                INSERT INTO magazine (
                    guid, title, url, created, updated
                ) VALUES (
                    %s, %s, %s, %s, %s
                )
            """, (guid, item['title'], item['url'], now, now))

        spider.log('Saved!')

    def _handle_error(self, failure, item, spider):
        spider.log(failure, logging.ERROR)

    def get_guid(self, url):
        return hashlib.md5(url.encode('utf-8')).hexdigest()

Scrapyでは twisted という非同期ライブラリを使用しているため、ここではDBへの保存もノンブロッキングにしています。twistedのadbapi というモジュールを使っています。

Twisted RDBMS support — Twisted 15.3.0 documentation

settings.py に作成したPipelineを追加して、もう一度Crawlしてみましょう。

ITEM_PIPELINES = {
    'tutorial.pipelines.MySQLPipeline': 100
}
$ scrapy crawl magazine -o out.csv

out.csvmagazine テーブルにデータが出力されていれば完成です!

SPAサイトをスクレイピング

最後にScrapyでSPAサイトをスクレイピングする方法を簡単に紹介したいと思います。

前提として、SPAサイトに限らずJavascriptで動的にデータを取得している場合などは、seleniumを使ってブラウザ経由の(Javascript実行後の)HTMLを解析することになります。しかし、seleniumは本来Webアプリケーションのテスト自動化のためのツールであり、スクレイピングをする上で使い勝手がいい訳ではありません。

動的なサイトのスクレイピングを行う場合、Scrapyと同じscrapinghub社によって開発されたSplashというヘッドレスブラウザがおすすめです。

Splash

Splashは、高速なレンダリング以外にも、複数ページの非同期取得・カスタムJavasriptの実行など、スクレイピングに便利な多くの機能を備えています。

Splash - A javascript rendering service

また、"Javascript rendering service" と記述されるように、Javascriptレンダリング後のHTMLを返すAPIインターフェースを備えた "サービス" であるため、単なるブラウザと表現するのは正しくないのかもしれません。

インストールは、公式から提供されているdockerfileでコンテナを作成する方法が簡単です。

$ docker pull scrapinghub/splash
$ docker run -p 8050:8050 scrapinghub/splash

これで render.html エンドポイントに下のようなリクエストを投げることで、Javascriptレンダリング後のHTMLを取得することができるようになりました。

$ curl 'http://localhost:8050/render.html?url=https://sardine-system.com/media&wait=1.0'

ScrapyからSplashのAPIを使ってスクレイピング

Scrapyから直接APIを叩いてもいいのですが、いくつか問題があるようなので、scrapy-splash というプラグインが推奨されています。

pipでインストール

$ pip3 install scrapy-splash

scrapy-splash settings.pyミドルウェアとして追加します。

DOWNLOADER_MIDDLEWARES = {
    'scrapy_splash.SplashCookiesMiddleware': 723,
    'scrapy_splash.SplashMiddleware': 725,
    'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}

SPLASH_URL = 'http://localhost:8050/'

HttpProxyMiddleware の優先度が750に設定されているため、scrapy_splashの優先度は750以下になるよう注意してください。

後はspiderをちょっと変更するだけで動的ページに対応です!

import scrapy
from scrapy_splash import SplashRequest

class MagazineSpider(scrapy.Spider):
    name = 'magazine'

    def start_requests(self):
        url = 'https://sardine-system.com/media/'
        yield SplashRequest(url,
                            callback=self.parse, 
                            endpoint='render.html',
                            args={'wait': 1.0})

    def parse(self, response):
        for selector in response.css('.main-wrapper .container a'):
            yield {
                'title': selector.css('.main-text::text').extract_first(),
                'url': response.urljoin(selector.css('::attr(href)').extract_first())
            }

終わりに

今回はScrapyの紹介的な内容で終わってしまいましたが、機会があればスクレイピングをする上で実際の業務でつまずいた点やインフラを交えた話もできればなと思います。

Scrapyは、Pythonを技術スタックとして置いていない企業でも採用する価値のあるフレームワークです。もし業務で(趣味でも)クローリング・スクレイピングを行う機会が出てきたら是非Scrapyを検討してみてください。