Python: Scrapy と BeautifulSoup4 を使った快適 Web スクレイピング
前回 BeautifulSoup4 を単独で使ってスクレイピングする方法について記事を書いた。 Web スクレイピングは主にクローリングとスクレイピングの二つのパートに分かれていて、BeautifulSoup4 は後者に特化したパッケージだった。 今回は、Scrapy というフレームワークを使って、前者のクローリングも含めてやってみよう。
まずは今回使用する Scrapy と BeautifulSoup4 をインストールする。 ちなみに Scrapy は現時点で Python 3 対応が完了していないので、ここからの作業は全て Python 2.x 系の環境が必要になる。 また、今回の内容には不要だけど、いちいち出る警告を消したいので service_identity も一緒にインストールしておく。
$ pip install scrapy beautifulsoup4 service_identity
インストールできたら scrapy コマンドが使えるようになる。
$ scrapy -h Scrapy 0.24.5 - project: helloworld Usage: scrapy [options] [args] Available commands: bench Run quick benchmark test check Check spider contracts crawl Run a spider deploy Deploy project in Scrapyd target edit Edit spider fetch Fetch a URL using the Scrapy downloader genspider Generate new spider using pre-defined templates list List available spiders parse Parse URL (using its spider) and print the results runspider Run a self-contained spider (without creating a project) settings Get settings values shell Interactive scraping console startproject Create new project version Print Scrapy version view Open URL in browser, as seen by Scrapy Use "scrapy -h" to see more info about a command
scrapy コマンドには、プロジェクトのひな形を作るためのサブコマンドが用意されている。 まずは helloworld という名前でプロジェクトを作成しよう。
$ scrapy startproject helloworld
サブコマンド startproject によって、以下のようなファイルツリーが作られる。
$ find helloworld helloworld helloworld/helloworld helloworld/helloworld/__init__.py helloworld/helloworld/items.py helloworld/helloworld/pipelines.py helloworld/helloworld/settings.py helloworld/helloworld/spiders helloworld/helloworld/spiders/__init__.py helloworld/scrapy.cfg
今回は、このブログ自体をスクレイピングの題材にしてみよう。 まずは、このブログを探索するためのクローラ (Scrapy では Spider と呼ぶ) を作ろう。 先ほどと同様、こちらも scrapy コマンドのサブコマンド genspider でひな形を作ることができる。 第一引数が Spider の名前、第二引数がクロール対象のドメインだ。
$ cd helloworld $ scrapy genspider myblog momijiame.tumblr.com
コマンドを実行すると、指定した名前で Spider のスクリプトファイルができる。
$ find helloworld -name "myblog.py" helloworld/spiders/myblog.py
初期の内容はこのようになっている。 parse() メソッドが、クロールした Web サイトをスクレイピングするためのエントリポイントだ。 クローラは、まず始めに start_urls に記述された URL から HTML を取得して、その内容で parse() メソッドを呼び出す。
$ cat helloworld/spiders/myblog.py # -*- coding: utf-8 -*- import scrapy class MyblogSpider(scrapy.Spider): name = "myblog" allowed_domains = ["momijiame.tumblr.com"] start_urls = ( 'http://www.momijiame.tumblr.com/', ) def parse(self, response): pass
ちょいちょいと直して、まずはブログの各ページを辿れるようにしてみよう。 クローラが次のページを辿るようにするには、scrapy.Request クラスのインスタンスを parse() メソッドが返すようにすれば良い。 parse() メソッドはジェネレータとして作ることで、一つのページから複数のページを辿れるようにできる。 ページのスクレイピングには前回同様 BeautifulSoup4 を使った。 尚、BeautifulSoup4 を使わない場合には lxml を直接使ってスクレイピングすることになるが、はっきり言ってこの API の書式を覚えるのは時間の無駄なので止めたほうが良い。
$ cat helloworld/spiders/myblog.py #!/usr/bin/env python # -*- coding: utf-8 -*- import scrapy from bs4 import BeautifulSoup class MyblogSpider(scrapy.Spider): name = 'myblog' allowed_domains = ['momijiame.tumblr.com'] start_urls = ( 'http://momijiame.tumblr.com/', ) def parse(self, response): soup = BeautifulSoup(response.body) # 次のページへのリンクが入った <li> を取得する next_page = soup.find('li', {'class': 'next'}) # <li> の中に入った <a> を取り出す next_page_link = next_page.a # 次のページがあるか確認する if 'href' not in next_page_link.attrs: # 次のページが見つからなかったので終了 yield # 次のページがあるときは URL を組み立てる baseurl = 'http://momijiame.tumblr.com' path = next_page_link['href'] url = '{baseurl}{path}'.format(baseurl=baseurl, path=path) print(url) # scrapy.Request を返すと次にクロールするページの指定になる next_crawl_page = scrapy.Request(url) yield next_crawl_page
スクレイピングを実行するには scrapy コマンドの crawl サブコマンドを実行する。 先ほどの Spider の名前を指定しよう。
$ scrapy crawl myblog ...(省略)... 2015-03-22 21:07:54+0900 [myblog] DEBUG: Crawled (200) <GET http://momijiame.tumblr.com/> (referer: None) 2015-03-22 21:07:55+0900 [myblog] DEBUG: Crawled (200) <GET http://momijiame.tumblr.com/page/2> (referer: http://momijiame.tumblr.com/) 2015-03-22 21:07:55+0900 [myblog] DEBUG: Crawled (200) <GET http://momijiame.tumblr.com/page/3> (referer: http://momijiame.tumblr.com/page/2) 2015-03-22 21:07:56+0900 [myblog] DEBUG: Crawled (200) <GET http://momijiame.tumblr.com/page/4> (referer: http://momijiame.tumblr.com/page/3) ...(省略)...
各ページを順番に辿っていることがわかる。
次は各ページにあるブログポストから、タイトルと本文内にあるリンクを抽出してみることにしよう。 そこで、まずは抽出した内容を格納するためのオブジェクトを作る。 このオブジェクトはひな形が items.py に既に作られている。
$ cat helloworld/items.py # -*- coding: utf-8 -*- # Define here the models for your scraped items # # See documentation in: # http://doc.scrapy.org/en/latest/topics/items.html import scrapy class HelloworldItem(scrapy.Item): # define the fields for your item here like: # name = scrapy.Field() pass
こちらもちょいちょいと直してタイトルとリンクの配列を格納できるようにしておこう。 といっても、基本的に格納したい名前で scrapy.Field のメンバを羅列するだけだ。
$ cat helloworld/items.py #!/usr/bin/env python # -*- coding: utf-8 -*- import scrapy class MyblogLinkItem(scrapy.Item): title = scrapy.Field() links = scrapy.Field()
各ページのスクレイピングは、先ほどとは別の Spider で定義することにしよう。 先ほどの myblog.py に、個別のブログポストを処理するための MyblogPostSpider を追加する。 追加した MyblogPostSpider は各ブログポストの ID を指定すると、そのポストの内容からタイトルとリンクを抽出して MyblogLinkItem を生成して返す。
$ cat helloworld/spiders/myblog.py #!/usr/bin/env python # -*- coding: utf-8 -*- import scrapy from bs4 import BeautifulSoup from helloworld.items import MyblogLinkItem class MyblogSpider(scrapy.Spider): name = 'myblog' allowed_domains = ['momijiame.tumblr.com'] start_urls = ( 'http://momijiame.tumblr.com/', ) def parse(self, response): soup = BeautifulSoup(response.body) # 次のページへのリンクが入った <li> を取得する next_page = soup.find('li', {'class': 'next'}) # <li> の中に入った <a> を取り出す next_page_link = next_page.a # 次のページがあるか確認する if 'href' not in next_page_link.attrs: # 次のページが見つからなかったので終了 yield # 次のページがあるときは URL を組み立てる baseurl = 'http://momijiame.tumblr.com' path = next_page_link['href'] url = '{baseurl}{path}'.format(baseurl=baseurl, path=path) # scrapy.Request を返すと次にクロールするページの指定になる next_crawl_page = scrapy.Request(url) yield next_crawl_page class MyblogPostSpider(scrapy.Spider): name = 'myblogpost' allowed_domains = ['momijiame.tumblr.com'] def __init__(self, post_id, *args, **kwargs): super(MyblogPostSpider, self).__init__(*args, **kwargs) self.post_id = post_id url = 'http://momijiame.tumblr.com/post/{post_id}'.format( post_id=self.post_id, ) self.start_urls = [url] def parse(self, response): soup = BeautifulSoup(response.body) content = soup.find('div', {'class': 'body'}) links = content.find_all('a') if len(links) < 1: # 記事にハイパーリンクが含まれていないので終了 return # ハイパーリンクがあったらタイトルと共にアイテムを返す title_div = soup.find('div', {'class': 'title'}) title = title_div.a.text link_urls = [link['href'] for link in links] item = MyblogLinkItem(title=title, links=link_urls) return item
例えば以下のブログポストであれば、112779388536 の部分がブログポストの ID になる。
http://momijiame.tumblr.com/post/112779388536/python-k
では、実際に Spider を実行してみよう。 -a オプションで Spider を作成する際の引数が指定できる。 ここではブログポストの ID を渡している。 また、今回は先ほどとは異なり実際にページをスクレイピングして内容を抽出 、scrapy.Item として返している。 そこで、得られた内容を JSON 形式でファイルに保存するために -t オプションで出力フォーマットを、-o オプションで保存先ファイルを指定している。
$ scrapy crawl myblogpost -a post_id=112779388536 -t jsonlines -o result.json
実行すると、以下のようにタイトルとリンクが JSON 形式で得られたことがわかる。 しかし、どうやらマルチバイト文字が Unicode 形式でエスケープされてしまっているようだ。
$ cat result.json {"links": ["http://momijiame.tumblr.com/post/112690753481/python", "http://momijiame.tumblr.com/post/112222402816/python-iris-k-leave-one-out"], "title": "Python: K-\u5206\u5272\u4ea4\u5dee\u691c\u8a3c\u3092\u8a66\u3059"}
エスケープされていると扱いづらいので、そのまま出したい。 そこで、scrapy.Item を各形式に出力するための Exporter に細工しよう。 先ほど使った jsonlines フォーマットに対応する Exporter を、Unicode エスケープしないように拡張したものを作ってみる。
$ cat helloworld/exporters.py #!/usr/bin/env python # -*- coding: utf-8 -*- from scrapy.contrib.exporter import JsonLinesItemExporter class NonEscapeJsonLinesItemExporter(JsonLinesItemExporter): def __init__(self, filepath, **kwargs): super(NonEscapeJsonLinesItemExporter, self).__init__( filepath, ensure_ascii=False )
作成した Exporter は Scrapy の設定ファイル settings.py で読み込ませることができる。 以下はデフォルトの settings.py だ。
$ cat helloworld/settings.py # -*- coding: utf-8 -*- # Scrapy settings for helloworld project # # For simplicity, this file contains only the most important settings by # default. All the other settings are documented here: # # http://doc.scrapy.org/en/latest/topics/settings.html # BOT_NAME = 'helloworld' SPIDER_MODULES = ['helloworld.spiders'] NEWSPIDER_MODULE = 'helloworld.spiders' # Crawl responsibly by identifying yourself (and your website) on the user-agent #USER_AGENT = 'helloworld (+http://www.yourdomain.com)'
ここに、FEED_EXPORTERS という変数名で、拡張した Exporter を登録してやる。
# -*- coding: utf-8 -*- # Scrapy settings for helloworld project # # For simplicity, this file contains only the most important settings by # default. All the other settings are documented here: # # http://doc.scrapy.org/en/latest/topics/settings.html # BOT_NAME = 'helloworld' SPIDER_MODULES = ['helloworld.spiders'] NEWSPIDER_MODULE = 'helloworld.spiders' # Crawl responsibly by identifying yourself (and your website) on the user-agent #USER_AGENT = 'helloworld (+http://www.yourdomain.com)' FEED_EXPORTERS = { 'nejl': 'helloworld.exporters.NonEscapeJsonLinesItemExporter', 'nejsonlines': 'helloworld.exporters.NonEscapeJsonLinesItemExporter', }
一旦先ほどの実行結果を削除した上で、拡張した Exporter を使って再度クローラを実行してみよう。
$ rm result.json $ scrapy crawl myblogpost -a post_id=112779388536 -t nejsonlines -o result.json
$ cat result.json {"links": ["http://momijiame.tumblr.com/post/112690753481/python", "http://momijiame.tumblr.com/post/112222402816/python-iris-k-leave-one-out"], "title": "Python: K-分割交差検証を試す"}
では最後に、先ほど作った個別のブログポストを処理する Spider と、各ページを辿る Spider を連携させよう。 各ページを辿る MyblogSpider に、各ブログポスト部分を抽出する処理を加える。 そして、抽出した箇所の処理は個別のブログポストを処理する MyblogPostSpider に任せてやる。
#!/usr/bin/env python # -*- coding: utf-8 -*- try: # Python 2 import urlparse as parse except ImportError: # Python 3 from urllib import parse import scrapy from bs4 import BeautifulSoup from helloworld.items import MyblogLinkItem class MyblogSpider(scrapy.Spider): name = 'myblog' allowed_domains = ['momijiame.tumblr.com'] start_urls = ( 'http://momijiame.tumblr.com/', ) def parse(self, response): soup = BeautifulSoup(response.body) # 各ブログポストの <div> を取得する posts = soup.find_all('div', {'class': 'title'}) for post in posts: post_url = post.a['href'] # URL から記事の ID を取得する parse_result = parse.urlparse(post_url) path_segments = parse_result.path.split('/') post_id = path_segments[2] # 記事の ID を指定して個別のポストを処理する Spider を生成する spider = MyblogPostSpider(post_id) # リクエストを処理するコールバックに上記の Spider のメソッドを指定する req = scrapy.Request(url=post_url, callback=spider.parse) yield req # 次のページへのリンクが入った <li> を取得する next_page = soup.find('li', {'class': 'next'}) # <li> の中に入った <a> を取り出す next_page_link = next_page.a # 次のページがあるか確認する if 'href' not in next_page_link.attrs: # 次のページが見つからなかったので終了 yield # 次のページがあるときは URL を組み立てる baseurl = 'http://momijiame.tumblr.com' path = next_page_link['href'] url = '{baseurl}{path}'.format(baseurl=baseurl, path=path) # scrapy.Request を返すと次にクロールするページの指定になる next_crawl_page = scrapy.Request(url) yield next_crawl_page class MyblogPostSpider(scrapy.Spider): name = 'myblogpost' allowed_domains = ['momijiame.tumblr.com'] def __init__(self, post_id, *args, **kwargs): super(MyblogPostSpider, self).__init__(*args, **kwargs) self.post_id = post_id url = 'http://momijiame.tumblr.com/post/{post_id}'.format( post_id=self.post_id, ) self.start_urls = [url] def parse(self, response): soup = BeautifulSoup(response.body) content = soup.find('div', {'class': 'body'}) links = content.find_all('a') if len(links) < 1: # 記事にハイパーリンクが含まれていないので終了 return # ハイパーリンクがあったらタイトルと共にアイテムを返す title_div = soup.find('div', {'class': 'title'}) title = title_div.a.text link_urls = [link['href'] for link in links] item = MyblogLinkItem(title=title, links=link_urls) return item
$ rm result.json $ scrapy crawl myblog -t nejsonlines -o result.json
実行結果は以下の通り。 ページを順番にクロールしながら、各ページに存在するブログポストをスクレイピングできていることがわかる。
$ cat result.json {"links": ["http://momijiame.tumblr.com/post/109872767881/python-pulp", "http://ja.wikipedia.org/wiki/%E9%AD%94%E6%96%B9%E9%99%A3"], "title": "Python: PuLP で魔方陣を解く"} {"links": ["https://chrome.google.com/webstore/detail/livereload/jnihajbhpnppcggbcgedagnkighmdlei"], "title": "タスクランナー Gulp を使って Sphinx の執筆作業を捗らせる"} {"links": ["https://github.com/momijiame/angular-route-sample", "https://github.com/momijiame/angular-route-sample.git"], "title": "AngularJS: ngRoute を使ってリクエスト毎にページを出し分ける"} {"links": ["http://momijiame.tumblr.com/post/96190247656/python-sqlalchemy-activerecord"], "title": "Python: SQLAlchemy で DataMapper パターンを試してみる"} {"links": ["https://www.sdcard.org/downloads/formatter_4/eula_mac/SDFormatter_4.00B.pkg", "https://www.sdcard.org/downloads/formatter_4/eula_mac", "http://ftp.jaist.ac.jp/pub/raspberrypi/raspbian/images/raspbian-2014-01-09/2014-01-07-wheezy-raspbian.zip"], "title": "Raspberry PI で NAT ルータを作る"} ...(省略)...