け日記

最近はPythonでいろいろやってます

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

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

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を実装し、エントリタイトルを取得してファイルに保存するところまでできるようになりました。