2014年6月17日
asyncioを用いたpythonの高速なスクレイピング
本記事は、原著者の許諾のもとに翻訳・掲載しております。
ウェブスクレイピングについては、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
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa