Pythonでスクレイピングを実装する機会があったので、その中で利用した(もしくは技術検証した)ライブラリについて、特徴やどういう時に利用するかについて個人的な見解を書いていこうと思います。

requests

指定したURLに対してリクエストを投げて、レスポンスを取得することができるシンプルなライブラリです。
JavaScript実行を必要としないような静的なサイトからResponseを取得する目的であればこれで十分です。

特徴

  • HTTPレスポンス(ヘッダー、ステータスコード、HTML)を取得できる
  • リクエストヘッダーやクッキーを指定してリクエストすることができる
  • リダイレクト(301や302など)もしてくれる
  • urllib3.util.retry.Retryと一緒に使えばリトライもできる
  • response.textで本文を取得する際、charsetをよしなに解釈してdecodeして文字列にしてくれる
  • できないこと
    • レスポンスを解析して、特定のHtmlElementを取得することはできない => beautifulsoup4やlxmlで使う
    • レスポンスを受け取って、Javascriptが実行された後のHTMLを取得することはできない => requests-htmlやpyppeteerを使う

インストール

pip install requests
# or
poetry add requests

実装サンプル

単純にリクエスト

import requests
from requests import Response

response: Response = requests.get('http://quotes.toscrape.com/')
response.status_code
# -> 200
response.headers
# -> {'Server': 'nginx/1.14.0 (Ubuntu)', 'Date': 'Tue, 11 Aug 2020 13:00:00 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'X-Upstream': 'spidyquotes-master_web', 'Content-Encoding': 'gzip'}
response.text
# -> <!DOCTYPE html><html lang="en"><head>...</head><body></body></html>

リトライありでリクエスト

import requests
from requests import Response, Session
from requests.exceptions import RequestException, Timeout
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from typing import Dict

with Session() as session:
    url: str = 'http://quotes.toscrape.com/'
    headers: Dict[str, str] = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36', 'Accept': '*/*'}  # noqa
    retries: Retry = Retry(total=5,  # リトライ回数
                            backoff_factor=3,  # リトライ間隔。例えば2を指定すると 2秒 => 4秒 => 8秒 => 16秒のようになる
                            status_forcelist=[500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],  # リトライ対象のステータスコード
                            raise_on_status=False  # `status_forcelist`のステータスコードでリトライ終了した場合にエラーraiseするかどうか。FalseだとResponseを返す
                            )

    session.mount(url[0:url.find('//') + 2], HTTPAdapter(max_retries=retries))

    try:
        response: Response = session.get(url, headers=headers)
        response.status_code
        # -> 200
        response.headers
        # -> {'Server': 'nginx/1.14.0 (Ubuntu)', 'Date': 'Tue, 11 Aug 2020 13:00:00 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'X-Upstream': 'spidyquotes-master_web', 'Content-Encoding': 'gzip'}
        response.text # strとして取得
        # -> <!DOCTYPE html><html lang="en"><head>...</head><body></body></html>
        response.content # bytesとして取得
        # -> b'<!DOCTYPE html><html lang="en"><head>...</head><body></body></html>'

    except (RequestException, ConnectionError, Timeout) as e:
        print(f'possible error occurred. {e}')
  • リトライ設定の詳細はこちらを参照
  • コネクションエラーなどネットワーク関係のエラーはデフォルトでリトライされる。今回はそれ以外にstatus_forcelistで500系のステータスコードが返ったらリトライする設定にしてある
  • raise_on_statusにTrueを設定するとstatus_forcelistで設定したステータスの場合でもエラーをraiseする。FalseにするとResponseが返り、status_codeに500系が設定される
  • Connectionエラーなどの想定されるエラーは、except (RequestException, ConnectionError, Timeout)でまとめてキャッチして握りつぶし、それ以外のRuntimeErrorなどはそのままraiseしている

Beautiful Soup

Beautiful SoupはHTMLやXMLを解析してデータを抽出するライブラリです。requestsなどで目的のページのHTMLを取得して、そのHTMLを解析する際に利用することが多いと思います。

特徴

  • HTML文字列を解析してエレメントを取得することができる。大きく三つの方法がある
    • タグをたどっていく方法
    • find、find_allなどで対象のタグを探す方法
    • CSSセレクターを使って対象のタグを取得する方法
  • 対象のエレメントの属性を取得することができる
  • HTMLパーサーは、デフォルトだとPython標準ライブラリのパーサーを使うが、HTMLパーサーを変更することもできる
    • lxmlの方が爆速らしいので、大量のデータをパースする必要がある場合はlxmlを使う方が良さそう
    • soup: BeautifulSoup = BeautifulSoup(response.text, 'lxml') のように指定するだけ

インストール

pip install beautifulsoup4
pip install lxml # HTMLパーサーをlxmlに変更する場合

実装サンプル

ソースコード内のコメントを見ればだいたいわかると思いますので、解説は省きますが、これぐらい知っておけば十分な感じがしてます。

import requests
from requests import Response
from bs4 import BeautifulSoup

def main():
    response: Response = requests.get('http://quotes.toscrape.com/')
    soup: BeautifulSoup = BeautifulSoup(response.text)

    # == タグをたどってエレメントを取得する ==
    soup.title
    # -> <title>Quotes to Scrape</title>
    soup.title.parent
    # -> <head><meta charset="utf-8"/><title>Quotes to Scrape</title><link href="/static/bootstrap.min.css" rel="stylesheet"/><link href="/static/main.css" rel="stylesheet"/></head>  # noqa
    soup.body.footer.div.p.a
    # -> <a href="https://www.goodreads.com/quotes">GoodReads.com</a>

    # == find, find_allでタグを探してエレメントを取得する ==
    soup.find("title")
    # -> <title>Quotes to Scrape</title>
    soup.find_all("a")
    # -> [<a href="/" style="text-decoration: none">Quotes to Scrape</a>, <a href="/login">Login</a>, <a href="/author/Albert-Einstein">(about)</a>, ... , <a href="https://scrapinghub.com">Scrapinghub</a>]  # noqa

    # == CSSセレクタでエレメントを取得する ==
    soup.select("body .container .row .quote small.author")
    # -> [<small class="author" itemprop="author">Albert Einstein</small>, <small class="author" itemprop="author">J.K. Rowling</small>, ... , <small class="author" itemprop="author">Steve Martin</small>]  # noqa
    soup.select("body .container .row .quote:first-child small.author")
    # -> [<small class="author" itemprop="author">Albert Einstein</small>]
    soup.select("body .container .row div.quote:last-of-type small.author")
    # -> [<small class="author" itemprop="author">Steve Martin</small>]

    # == エレメントの属性情報を取得する ==
    soup.title.string
    # -> Quotes to Scrape
    soup.title.name
    # -> title
    soup.body.footer.div.p.a["href"]
    # -> https://www.goodreads.com/quotes

    # == 正規表現でテキストを抽出する ==
    import re
    soup.find_all(text=re.compile("^“A "))
    # -> ["“A woman is like a tea bag; you never know how strong it is until it's in hot water.”", '“A day without sunshine is like, you know, night.”'] # noqa

if __name__ == "__main__":
    main()

pyppeteer

pyppeteerは、npmモジュールであるpuppeteerをpythonに移植したものです。

ヘッドレスブラウザ(chromium)を開いて、実際にブラウザ内でページを読み込むのでJavaScriptが実行されます。また、CSSセレクタでエレメントを探してクリックしたり、画面遷移を待ったり、指定したJavaScriptコードを実行したりすることが出来ます。

JavaScriptが必要で、エレメントクリックによる画面遷移をしたいケースではこちらを利用するのが良いと思います。

ちなみに、オリジナルのgithubリポジトリは現在Archivedになっており、こちらのリポジトリで開発が継続されているようです。
ほぼ同等のことができるSeleniumもあるのですが、JavaScript世界ではpuppeteerが圧倒的な人気なので、あまり深く考えずpythonでもpyppeteerを採用しました。

特徴

  • ヘッドレスブラウザ上で実際に画面を読み込んで、画面上でボタンクリック=>画面遷移を繰り返すこともできる
  • 画面遷移が完了を、一定時間待つ or 特定の要素の出現を待つ or 指定したJavaScript関数がTrueを返すまで待つ のように複数の方法で待ち合わせることができる
  • セレクタを使って特定のHTMLエレメントを取得することもできる
  • HTTPレスポンス(ヘッダー、ステータスコードなど)を取得できる
  • マルチプロセスで実行もできるし、Linux上でも動かせる
  • マイナス要素
    • chromiumをダウンロードして実行するので、Linux上で動かす時に多少ハマりポイントがある(解決可能)

詳細

長くなったので、別で記事を起こしました。興味があればこちらを参照ください。
pyppeteerの使い方

requests-html

requests-html、requests・Pyppeteer・PyQuery・BeautifulSoupをラップして一つのAPIとして提供してくれているライブラリです。

なんでもできるようですが、特徴のマイナス要素の所に書いているように、ヘッドレスブラウザを使ってがっつりスクレイピングするようなケースでは正直ものたりないです。その用途ならpyppeteerの方がいいと思います。

requests+Beautiful Soupの代替としてはアリだけど、その用途なら元々難しいところはないし、あえて乗り換える必要はないかなぁという印象でした。

特徴

  • フルJavaScriptサポート
    • requestsで取得したResponseをヘッドレスブラウザで描画して、JavaScriptを実行する
  • CSS Selectorsを使ったエレメント選択
  • コネクションプールおよび永続的なcookieサポート
  • マイナス要素
    • Reponseを取得する際にリトライ機能がない(公式のドキュメントから発見できず)
    • ボタンクリックをしたい時、element.click()みたいな簡単な方法は提供されておらず、ボタンクリックするjavascriptをrenderする必要がある
    • 画面遷移を待つ方法が、時間指定だけ(pyppeteerは、selectorが現れるまでとか、JavaScript関数がtrueを返すまで、とかできる)
    • 画面遷移を伴うボタンクリックを二回実行する方法がない(自分の理解不足かもしれません)
    • ブラウザを表示して実行する方法がないのでデバッグしにくい(自分の理解不足かもしれません)

実装サンプル

ヘッドレスブラウザを使わないケース

ヘッドレスブラウザを使わず以下のケースを実行してみました。

  • Responseオブジェクトを取得する
  • CSSセレクターを使ってエレメント抽出する
  • エレメントの属性情報を取得する
  • ページ内のリンクを全て取得する
from requests_html import HTMLSession
from requests import Response

def main():
    session: HTMLSession = HTMLSession()
    response: Response = session.get('http://quotes.toscrape.com/')

    # == Responseオブジェクトを取得する ==
    response.status_code
    # -> 200
    response.headers
    # -> {'Server': 'nginx/1.14.0 (Ubuntu)', 'Date': 'Tue, 11 Aug 2020 13:11:10 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'X-Upstream': 'spidyquotes-master_web', 'X-Content-Encoding-Over-Network': 'gzip'}  # noqa
    response.text
    # -> <!DOCTYPE html><html lang="en"><head>...</head><body>...</body></html>

    # == CSSセレクターを使ってエレメント抽出する ==
    # 全て抽出
    response.html.find("body .container .row .quote small.author")
    # -> [<Element 'small' class=('author',) itemprop='author'>, <Element 'small' class=('author',) itemprop='author'>, <Element 'small' class=('author',) itemprop='author'>, ... , <Element 'small' class=('author',) itemprop='author'>]  # noqa
    response.html.find("body .container .row div.quote:first-child small.author")
    # -> [<Element 'small' class=('author',) itemprop='author'>]
    response.html.find("body .container .row div.quote:last-of-type small.author")
    # -> [<Element 'small' class=('author',) itemprop='author'>]

    # 最初の1件抽出
    response.html.find("body .container .row .quote small.author", first=True)
    # -> <Element 'small' class=('author',) itemprop='author'>

    # == エレメントの属性情報を取得する ==
    response.html.find("body .container .row .quote a", first=True).attrs['href']
    # -> /author/Albert-Einstein
    response.html.find("body .container .row .quote small.author", first=True).text
    # -> Albert Einstein

    # == ページ内のリンクを全て取得する ==
    response.html.absolute_links
    # -> {'http://quotes.toscrape.com/author/Jane-Austen', 'http://quotes.toscrape.com/author/Steve-Martin', 'http://quotes.toscrape.com/tag/obvious/page/1/', ... , 'http://quotes.toscrape.com/tag/friendship/'}  # noqa
    response.html.links
    # -> {'/tag/live/page/1/', '/author/Jane-Austen', '/tag/aliteracy/page/1/', '/page/2/', '/tag/choices/page/1/', '/tag/reading/', '/', ... , '/author/Thomas-A-Edison'}  # noqa

if __name__ == "__main__":
    main()

特に難しい点はありませんでしたが、session.get()する際にretryする機能が提供されてなのが少し残念で、Retryしたかったら自前で対応する必要がありそうです。

ヘッドレスブラウザを使うケース

ヘッドレスブラウザを使って、以下を試しました。

  • Response HTMLをヘッドレスブラウザで読み込む
  • CSSセレクタで要素を抽出する
  • JavaScriptを実行して、ボタンをクリックして画面遷移する
  • 画面遷移後のHTMLから要素を抽出する
from requests_html import HTMLSession
from requests import Response

def main():
    session: HTMLSession = HTMLSession()
    response: Response = session.get('https://qiita.com/')

    # == ヘッドレスブラウザで読み込み ==
    # レスポンスHTMLをヘッドレスブラウザで読み込み5秒待つ
    response.html.render(sleep=5)

    # == ヘッドレスブラウザで描画されれたHTMLを取得 ==
    response.html.raw_html
    # -> b'<!DOCTYPE html><html><head><meta charset="utf-8"><title>Qiita</title>...</iframe></div></div></body></html>'

    # == ヘッドレスブラウザで描画されれたHTMLからCSSセレクタで要素を検索 ==
    response.html.find('.p-home_aside div[data-hyperapp-app="TagRanking"] .ra-TagList_content .ra-Tag_name a', first=True)
    # -> <Element 'a' href='/tags/python'>

    # == スクリプトを実行 ==
    # ユーザランキング週間一位をクリックして画面遷移
    response.html.render(script="""
            () => { document.querySelector('div[data-hyperapp-app="UserRanking"] .ra-UserList_content .ra-User_name > a').click() }
        """, sleep=5)

    # 画面遷移後のHTMLから要素(コントリビューションの件数)を抽出
    response.html.find('a[href$="contributions"] > p[class^="UserCounterList__UserCounterItemCount"]', first=True).text
    # -> 60598

if __name__ == "__main__":
    main()

ハマったポイントも共有しておきます。

まず、response.html.render()の際にsleepをする必要があります。
これを設定しないとpyppeteer.errors.NetworkError: Protocol error (Runtime.callFunctionOn): Cannot find context with specified idが発生します。

次に、こちらが致命的なのですが、一回JavaScriptでボタンクリックして画面遷移した後にもう一度JavaScriptで画面遷移しようと思いましたが、うまく動きませんでした。

response.html.render(script="""
        () => { document.querySelector('a[href$="contributions"]').click() }
    """, sleep=5)

こちらの処理を上記サンプルの最後に追加した場合、本来コントリビューション一覧が表示されることが期待されるのですが、pyppeteer.errors.ElementHandleError: Evaluation failed: TypeError: Cannot read property 'click' of nullというエラー発生しました。どうやら、対象の要素が存在しないことになっているようです。
試しにページのタイトルをJavaScriptで出力してみたところ、画面遷移前のHTMLの値がログに出力されました。どうやら、JavaScriptをrenderするのは最初にrenderしたHTMLに対してのみっぽいのです。(ドキュメントを読んでみたのですがこの部分に関する説明は発見できてませんので、少し怪しいです)。自分の調査では、requests_htmlではヘッドレスブラウザを使って、実際の画面を描画しつつ二回画面遷移を行う方法がなさそうです。

scrapy

Scrapy は、Webサイトをクロールし、ページから構造化データを抽出するために使用されるWebスクレイピングフレームワークです。静的なサイトのクローリングであれば一通りなんでもできるけど、学習コスト高めという印象です。

個人的には、LinkExtractorやSitemapSpiderのように目的にばっちり合致するケースではScrapyを利用するけど、そうじゃなければ、requests+Beautiful Soupやrequests-htmlを利用すると思います。

特徴

  • リクエストを送信して、レスポンスを解析・加工して、アウトプットするまでの一連の処理をまとめて統合的に管理できる
  • Item Pipelineを使えば、あまり考えなくても、WEBリクエスト関係と結果の解析・加工関係を役割分担して実装することができる(ただし実装量は増えるし、Pipeline定義も必要なので初見で何やってるかはわかりにくくなる)
  • LinkExtractor(リンクをたどってURLを列挙)やSitemapSpider(sitemap.xmlやrobots.txtからURLを列挙)など、良くある機能をデフォルトで提供してくれている
  • マイナス要素
    • 学習コストは高め。ソースだけ追っても理解できないことが多々ある
    • LinkExtractorやSitemapSpiderは便利だけど、途中までで中断して再開することはできない。数万URLあるようなサイトだと何日も実行にかかるが途中でエラーが発生したら最初からやり直し
    • LinkExtractorやSitemapSpiderはサーバ内の並列化もたぶんうまく動いてない(CONCURRENT_REQUESTSあたりの設定を色々変えても特に早くならない)
    • マルチサーバでの並列化はできない

詳細

長くなったので、記事を起こしました。興味があれば参照ください。
Scrapyの使い方

参考リポジトリ

今回検証したソースコードは全てgithubにあげてありますので、必要があればこちらを参照ください。

結局どれを使う?

pythonでスクレイピングする際のライブラリの使い分けですが、個人的には

  • JavaScriptの実行が必要 => pyppeteer
  • JavaScriptの実行が不要
    • リンクを辿ってorサイトマップからURL抽出(ただし対象ページ数が1万件ぐらいまで?) => Scrapy
    • それ以外 => requests-html or requests+Beautiful Soup

という感じがいいんじゃないかと思いました。