asyncioを用いたpythonの高速なスクレイピング
「HackerNews翻訳してみた」が POSTD (ポスト・ディー) としてリニューアルしました!この記事はここでも公開されています。
Original article: Fast scraping in python with asyncio by Georges Dubus
ウェブスクレイピングについては、pythonのディスカッションボードなどでもよく話題になっていますよね。いろいろなやり方があるのですが、これが最善という方法がないように思います。本格的なscrapyのようなフレームワークもあるし、mechanizeのように軽いライブラリもあります。自作もポピュラーですね。requestsやbeautifulsoup、またpyqueryなどを使えばうまくできるでしょう。
どうしてこんなに様々な方法があるかというと、そもそも「スクレイピング」が複数の問題解決をカバーしている総合技術だからなのです。数百ものページからデータを抽出するという行為と、ウェブのワークフローの自動化(フォームに入力してデータを引き出すといったもの)に、同じツールを使う必要はないわけですから。私は自作派で、それは融通が利くからですが、大量のデータを抽出する時に自作はふさわしくありません。requestsは同期でリクエストを行うので、大量のリクエストが行われると待ち時間が長くなってしまうからです。
このブログ記事ではrequests
の代わりに、最新のasyncioライブラリをベースにした案を紹介しましょう。aiohttpです。これで小さなスクレイパーを書いてみましたが、とても高速なものができました。どうやったかお見せしましょう。
asyncioの基本
asyncioは、python3.4で導入された非同期I/Oライブラリです。Python3.3のpypiからも入手できます。なかなか複雑なので詳細までは触れませんが、このライブラリを使って非同期コードを書くために必要な部分についてのみ説明します。もっと詳しく知りたい人は、ドキュメントを読んでくださいね。
簡単に言えば、知っておくべきことは2つ。コルーチンとイベントループです。コルーチンは関数に似ていますが、任意の箇所で一旦処理を中断したあと、処理を再開することができます。例えば、I/Oを待っている間(HTTPリクエストなど)コルーチンは一旦中断し、他の作業を実行させます。コルーチンを再開させるには、戻り値が必要だと宣言するキーワードyield from
を用います。イベントループはコルーチンの実行を制御するために用います。
Asyncioについて学ぶことはたくさんありますが、当面はこれで十分でしょう。読んだだけでは分かりづらいかもしれませんから、実際にコードを見てみましょう。
aiohttp
aiohttpは、asyncioと連携するために設計されたライブラリです。requestsのAPIに似ています。現状では、あまりいいドキュメントがないのですが、役に立つ事例がいくつかあります。まずは基本的な使い方について説明しましょう。
最初にコルーチンを定義してページを取得し、出力します。asyncio.coroutine
を用いて、関数をデコレートしてコルーチンとします。aiohttp.request
はコルーチンの一種で、read
メソッドでもあります。ですから、これらを呼ぶときはyield from
を使う必要がありますが、そういう注意点を除けば、コードはとても分かりやすいものです。
@asyncio.coroutine def print_page(url): response = yield from aiohttp.request('GET', url) body = yield from response.read_and_close(decode=True) print(body)
ご覧の通り、yield from
を用いれば、1つのコルーチンから新たな別のコルーチンを発生させることもできます。同期コードからコルーチンを発生させるには、イベントループが必要です。asyncio.get_event_loop()
から基準となるコルーチンを取得して、run_until_complete()
メソッドを用いてそのコルーチンを実行させればよいのです。元のコルーチンを実行させるには、ただ次のように記述します。
loop = asyncio.get_event_loop() loop.run_until_complete(print_page('http://example.com'))
asyncio.wait
という便利な関数があります。いくつかのコルーチンをリストとして取り出し、リスト内すべてのコルーチンを含有するひとつのコルーチンとして返してくれます。このようになります。
loop.run_until_complete(asyncio.wait([print_page('http://example.com/foo'), print_page('http://example.com/bar')]))
もうひとつ別の便利な関数としてはasyncio.as_completed
があります。こちらはコルーチンのリストを取り出し、処理が完了した順にコルーチンを再開するイテレータを返します。つまりこのイテレータを実行すると、それぞれの結果が出次第すぐに順次入手できるということです。
スクレ―ピング
さて、非同期HTTPリクエストのやり方が分かったところで、スクレイパーを書いてみましょうか。残っているのは、htmlを読み込む部分です。今回はbeautifulsoupを使ってみました。他の選択肢としてはpyqueryやlxmlなどがあります。
例題として、パイレート·ベイで配布されているlinuxのソフトウエア群の中からトレントリンクを取得する小さいスクレイパーを書いてみましょう。
まず、get requestsを処理するヘルパーコルーチンです。
@asyncio.coroutine def get(*args, **kwargs): response = yield from aiohttp.request('GET', *args, **kwargs) return (yield from response.read_and_close(decode=True))
解析部分。この記事の目的はbeautifulsoup について掘り下げることではありませんから、シンプルにダンプ出力に留めておきます。ページの最初のmagnetリストを取得します。
def first_magnet(page): soup = bs4.BeautifulSoup(page) a = soup.find('a', title='Download this torrent using magnet') return a['href']
そしてコルーチンです。下記のurlについて、結果はシーダーの数でソートされます。つまり、リストの一番目が最もシードされているということになります。
@asyncio.coroutine def print_magnet(query): url = 'http://thepiratebay.se/search/{}/0/7/0'.format(query) page = yield from get(url, compress=True) magnet = first_magnet(page) print('{}: {}'.format(query, magnet))
最後に、これらすべてをコールするコードはこのようになります。
distros = ['archlinux', 'ubuntu', 'debian'] loop = asyncio.get_event_loop() f = asyncio.wait([print_magnet(d) for d in distros]) loop.run_until_complete(f)
まとめ
これで非同期の小規模スクレイパーができあがりました。様々なページが同時にダウンロードできます。requestsを使った同じコードよりも3倍も速く処理することができました。これで読者のみなさんも、自分独自のスクレイパーを書くことができますね。
このgistに、「おまけ」の分も含めた最終的なコードが掲載されています。
慣れてきたら、asyncioについてのドキュメントや、aiohttpのexamplesなども見てみるといいですよ。asyncioでどんなことができるか、いろいろな例が記載されています。
このアプローチの制約は(実際のところ、自作の場合すべてに当てはまるのですが)フォームを処理するためのスタンドアロンライブラリが見当たらない、という点です。Mechanize とscrapy にはいいヘルパー関数があって、簡単にフォームを送信できますが、その2つを使わない場合は自分で何とかしなくてはなりません。これは結構面倒なので、いつか自作でライブラリを書いてしまうかもしれません…(期待はしないでくださいね)。
おまけ:サーバをいじめないで
リクエストを一度に3つこなせるのはクールですが、5000となると話は別です。一度にあまりにも多いリクエストをしようとすると、やがて接続が切れてしまったり、そのウェブサイトにアクセスできなくなってしまったりするかもしれないからです。
このような事態を避けるためにsemaphoreを使います。これは同期ツールで、ある時点で使われるコルーチンの数を制限するのに使います。ループの前にsemaphoreをクリエイトして、同時に最大いくつまでリクエストを処理するかを引数で渡してやればいいのです。
sem = asyncio.Semaphore(5)
ここを入れ替えます。
page = yield from get(url, compress=True)
機能は同じですが、semaphoreによって保護されています。
with (yield from sem): page = yield from get(url, compress=True)
これで、最大でも同時に5つまでのリクエストしか処理されなくなりました。
おまけ:プログレスバー
もうひとつおまけです。tqdmはプログレスバーを生成してくれるステキなライブラリです。このコルーチンはasyncio.wait
と同様の動きをしますが、コルーチンの処理完了を示すプログレスバーを表示してくれます。
@asyncio.coroutine def wait_with_progress(coros): for f in tqdm.tqdm(asyncio.as_completed(coros), total=len(coros)): yield from f