Scrapyでけ日記をクローリングする (2. PipelineでPostgreSQLに保存する)

前回に引き続き、Scrapyを使ってこの日記のクローリングを行います。

github.com

今回はクローリングで得られた値を、バリデーションしてPostgreSQLに保存するPipelineを実装します。Spiderの実装は前回の投稿も参考にしてみてください。

ohke.hateblo.jp

こちらの書籍を参考にしてます。

Pythonクローリング&スクレイピング -データ収集・解析のための実践開発ガイド-

Pipeline

ScrapyにおけるPipelineは、Spiderがクローリング・スクレイピングした値に対して、バリデーションチェックや永続化などの後処理を行うための仕組みです。
Spiderが取得した値をItemに詰めて返すと、優先順位に従って複数のタスクが実行されます。

ここでは例として、前回作成したarchive_spiderを使い、取得した記事タイトル・投稿日のフォーマットをチェックするPipelineと、PostgreSQLに保存するPipelineを作ります。以下のフォルダ構造となってます。

f:id:ohke:20180707152304p:plain

また事前にDBとテーブルを作成しておきます。postsテーブルとしておきます。

CREATE DATABASE kenikki

CREATE TABLE public.posts (
    title varchar(1024) NULL,
    posted_at date NULL
)

Itemの実装

最初にItemの継承クラスをitems.pyに記述します。SpiderからPipelineへのスクレイピングした値の受け渡しは、このクラスを使って行われます。
記事タイトルと投稿日を取り出しますので、titleとdateとしてクラス変数を定義してます。

import scrapy

class PostItem(scrapy.Item):
    title = scrapy.Field()  # 記事タイトル
    date = scrapy.Field()  # 投稿日

Spiderの変更

Spiderは上で作成したItemオブジェクトに取得した値を詰めて、ジェネレートします。

  • Itemのフィールドには辞書形式 (item['title']など) でアクセスしてます
import scrapy
from ..items import PostItem

class ArchiveSpider(scrapy.Spider):
    name = 'archive_spider'
    start_urls = [
        'https://ohke.hateblo.jp/archive/2015',
        'https://ohke.hateblo.jp/archive/2016',
        'https://ohke.hateblo.jp/archive/2017',
        'https://ohke.hateblo.jp/archive/2018'
    ]

    def parse(self, response):
        # ページネーションされている場合は次のページにもリクエスト
        next_page_url = response.css('span.pager-next a::attr(href)').extract_first()
        if next_page_url is not None:
            yield scrapy.Request(next_page_url, callback=self.parse)

        # タイトルと投稿日時を取得
        title_list = response.css('a.entry-title-link::text').extract()
        date_list = response.xpath('//time/@datetime').extract()

        # PostItemに詰めてジェネレート
        for title, date in zip(title_list, date_list):
            item = PostItem()
            item['title'] = title
            item['date'] = date
            yield item

Pipelineの実装と設定

最後にPipelineの実装です。pipelines.pyに追記しています。

Pipelineでは3つのメソッドを定義します。

  • open_spider()close_spider()は、Spiderの起動時と終了時に呼び出される
    • PostgresPipelineではDBへのコネクションの開始と終了を行っています
    • 接続文字列はSpiderの設定から取得します
  • process_item()では、Spiderがジェネレートしたアイテムを引数に呼び出される
    • ValidationPipelineでは、値が空でないか、正しいフォーマットかどうかをチェックしています
    • PostgreSQLでは、INSERTを行っています
import scrapy
import psycopg2
import re

# 値のバリデーションチェック
class ValidationPipeline(object):
    def process_item(self, item: scrapy.Item, spider: scrapy.Spider):
        if item['title'] is None or item['title'] == '':
            raise scrapy.exceptions.DropItem('Missing value: title')

        if item['date'] is None or re.match('^(\d{4})-(\d{2})-(\d{2})$', '2017-09-18') is None:
            raise scrapy.exceptions.DropItem('Missing value: date')

        return item

# PostgreSQLへの保存
class PostgresPipeline(object):
    def open_spider(self, spider: scrapy.Spider):
        # コネクションの開始
        url = spider.settings.get('POSTGRESQL_URL')
        self.conn = psycopg2.connect(url)

    def close_spider(self, spider: scrapy.Spider):
        # コネクションの終了
        self.conn.close()

    def process_item(self, item: scrapy.Item, spider: scrapy.Spider):
        sql = "INSERT INTO posts VALUES (%s, %s)"

        curs = self.conn.cursor()
        curs.execute(sql, (item['title'], item['date']))
        self.conn.commit()

        return item

これだけではPipelineは呼び出されず、settings.pyへ以下のようにITEM_PIPELINEへ2つのクラスを追記する必要があります。
辞書形式となっており、値は優先順位です。0〜1000の値で設定でき、値が小さいほうから順に実行されます (つまりValidationPipeline→PostgresPipelineです)。

ついでにPostgreSQLへの接続文字列も追記しておきます。

# Configure item pipelines
ITEM_PIPELINES = {
   'kenikki.pipelines.ValidationPipeline': 100,
   'kenikki.pipelines.PostgresPipeline': 200
}

# DB
POSTGRESQL_URL = 'postgresql://user:password@localhost:5432/kenikki'

これで実行すると、スクレイピングした値がpostsテーブルに展開されていることがわかります。

$ scrapy crawl archive_spider

f:id:ohke:20180707154729p:plain

まとめ

今回もScrapyを使い、Pipelineを実装することで、スクレイピングで取得した値をDBに残せるようにしました。

参考文献

Pythonクローリング&スクレイピング -データ収集・解析のための実践開発ガイド-

Pythonクローリング&スクレイピング -データ収集・解析のための実践開発ガイド-

Scrapyでけ日記をクローリングする (1. 初めてのSpider作り)

Scrapyを使ってはてなブログ、といいますか、この日記のクローリングを行います。今回はエントリタイトルを取得するSpiderを作ります。

こちらの書籍を参考にしてます。

Pythonクローリング&スクレイピング -データ収集・解析のための実践開発ガイド-

Scrapyとは

Scrapyはクローリング&スクレイピングに特化したPythonのフレームワークです。

Scrapy | A Fast and Powerful Scraping and Web Crawling Framework

http://scrapy-ja.readthedocs.io/ja/latest/index.html

github.com

以下のように、大きなエコシステムとなってますが( https://doc.scrapy.org/en/1.5/topics/architecture.html より抜粋)、今回はScrapyの肝となるSpiderに絞って実装していきます。

Spiderは大きく2つのことを行います。

  • レスポンスをパースして必要な値を得る
  • パースした値やリンクから新たなリクエストを実行する

インストールとプロジェクトの作成

まずはScrapyをインストールします。

$ pip install Scrapy

scrapyコマンドでScrapy用のプロジェクトを新規作成します。名前は"kenikki"とします。

$ scrapy startproject kenikki

作成後のプロジェクトディレクトリは以下の構造になります。

マナー

生成されたsettings.pyにScrapyのパラメータを設定します。

その中でも、マナーとして、以下だけは設定しておきます。

  • ROBOTSTXT_OBEY : robots.txtに従うかどうか(robots.txtについては、こちらを参考)
  • DOWNLOAD_DELAY : ダウンロード間隔(秒)
  • CONCURRENT_REQUESTS_PER_DOMAINCONCURRENT_REQUESTS_PER_IP : それぞれドメインごと・IPごとのリクエスト並行数
# Obey robots.txt rules
ROBOTSTXT_OBEY = True

# 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 = 60
# The download delay setting will honor only one of:
CONCURRENT_REQUESTS_PER_DOMAIN = 1
CONCURRENT_REQUESTS_PER_IP = 1

指定したURLからtitleを取得するSpider

それではこのブログのエントリ(例えば https://ohke.hateblo.jp/entry/2018/06/23/230000 )からtitleタグの値を取り出して、テキストファイルに保存するSpiderを実装します。

以下では、scrapy.Spiderを継承したクラスを作成してます。

  • start_requests()では、2つのURLへのリクエストを行っています
    • scrapy.Requestオブジェクトを返すジェネレータとなってます
  • parse()では、得られたレスポンス(HTML)をパースして、titleタグで囲まれた値を取得して、ファイルに書き込んでます
    • scrapy.Response継承クラスのオブジェクトが引数として渡されます
    • css()で、CSSセレクタライクにDOMを指定・取得できます (scrapy.selector.SelectListオブジェクトが返されます)
      • さらにextract()extract_first()で文字列として取得できます
  • nameはこのあとのシェルコマンドからの実行で必要
import scrapy


class EntrySpider(scrapy.Spider):
    name = 'entry_spider'

    filename = 'entry_title_list.txt'
    
    def start_requests(self):
        urls = [
            'https://ohke.hateblo.jp/entry/2018/06/23/230000',
            'https://ohke.hateblo.jp/entry/2018/06/16/230000'
        ]
        
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)
            
    def parse(self, response):
        with open(self.filename, 'a') as f:
            f.write(response.css('title::text').extract_first() + '\n')

ちなみにscrapy shellコマンドを使うと、指定したURLからHTMLをダウンロードされますので、その場でセレクタの動作確認などができます。終了するときはquitで抜けられます。

$ scrapy shell 'https://ohke.hateblo.jp/entry/2018/06/23/230000'

・・・

In [1]: response.css('title')
Out[1]: [<Selector xpath='descendant-or-self::title' data='<title>Python: set
にlistやtupleを追加する - け日記'>]

In [2]: response.css('title').extract_first()
Out[2]: '<title>Python: setにlistやtupleを追加する - け日記</title>'

scrapy crawlコマンドで、作成したSpiderクラスのname(ここでは"entry_spider")を指定することで、そのSpiderを起動することができます。

$ scrapy crawl entry_spider

entry_title_list.txtファイルが作成され、2つの記事タイトルが格納されていることがわかります。

Python: setにlistやtupleを追加する - け日記
NumPyを使って線形モデルのパラメータを最小二乗法で推定する - け日記

ページングされたURLを再帰的に巡回するSpider

もう少し難しいこととして、このブログの全エントリのタイトルを取得してみましょう。

先程はエントリのURLを全て指定していましたが、2つの問題点を抱えてます。

  • エントリと同じ数だけリクエストが発行される
  • 全エントリのURLを事前に知っている必要がある

そこで、各年のアーカイブ(例えば https://ohke.hateblo.jp/archive/2018 )からエントリのタイトルを取得するようにします。

  • 1ページ30エントリが表示されて、リクエスト数を最も少なくできると期待できる
  • URLも予測可能で、1年に1つしか増えない

ただし、31エントリ以上ある年はページングされます。例えば2017年はhttps://ohke.hateblo.jp/archive/2017https://ohke.hateblo.jp/archive/2017?page=2の2つに分かれてます。これに上手く対応する必要があります。

ここまで踏まえて、Spiderを実装していきます。

  • start_requests()を実装せずに、代わりにstart_urlsにURLリストを与えてます
    • こうするとURLを1つずつ巡回してparse()をコールバックしてくれます (上のstart_requests()の実装と同じことをやってくれます)
  • parse()では、次ページへのリンクが有る場合、同じparse()をコールバックにしたRequestオブジェクトをジェネレートし、次ページのURLにも再帰的にparse()を実行するようにしてます
    • 各アーカイブページでは、エントリタイトルを取得してファイルに書き出してます
import scrapy


class ArchiveSpider(scrapy.Spider):
    name = 'archive_spider'
    start_urls = [
        'https://ohke.hateblo.jp/archive/2015',
        'https://ohke.hateblo.jp/archive/2016',
        'https://ohke.hateblo.jp/archive/2017',
        'https://ohke.hateblo.jp/archive/2018'
    ]

    filename = 'entry_title_list.txt'

    def parse(self, response):
        next_page_url = response.css('span.pager-next a::attr(href)').extract_first()
        if next_page_url is not None:
            yield scrapy.Request(next_page_url, callback=self.parse)

        with open(self.filename, 'a') as f:
            f.write('\n'.join(response.css('a.entry-title-link::text').extract()) + '\n')

こちらもscrapy crawlコマンドで実行することで、全エントリタイトルがentry_title_list.txtに出力されます。

$ scrapy crawl entry_spider

まとめ

Scrapyで簡単なSpiderを実装し、エントリタイトルを取得してファイルに保存するところまでできるようになりました。

参考文献

Pythonクローリング&スクレイピング -データ収集・解析のための実践開発ガイド-

Pythonクローリング&スクレイピング -データ収集・解析のための実践開発ガイド-

Python: setにlistやtupleを追加する

Pythonでsetとlistを使う時のtipsです。

listやtupleを引数にsetを作ることができます。

set_a = set(['a', 'b', 'c', 'a'])
# {'a', 'b', 'c'}

set_b = set(('a', 'b', 'c', 'a'))
# {'a', 'b', 'c'}

addメソッドで1要素を追加することはできますが、listやtupleを引数にすることはできません。

set_a.add('d')
# {'a', 'b', 'c', 'd'}

# 下の2つはTypeError
#set_a.add(set(['a', 'e']))
#set_a.add(['a', 'e'])

そこでsetを作って、和集合 | を取ることで追加できます。

set_a |= set(['a', 'e'])
# {'a', 'b', 'c', 'd', 'e'}

set_b |= set({'a', 'e'})
# {'a', 'b', 'c', 'e'}