Scrapyはクローラーとスクレイピングを実装するためのフレームワークだ。サイトのクロールの仕方やデータを抽出する方法をフレームワークのルールに沿って定義してあげれば、そのほかのことはフレームワークがだいたい面倒を見てくれるので、クローラーとスクレイピングの実装が楽になる。
まずはScrapyがどんなものか、チュートリアルをやってみた。日本語のチュートリアルは下記にある。
Scrapyがインストールされていない場合はインストールガイドを参照のこと。
チュートリアルでわかること
はじめに、一通りチュートリアルを実施してわかることをまとめておく。
- Scrapyの一通りの流れがわかる:
プロジェクトを作成して、スパイダーを実装し、データを抽出する一連の流れがわかる。 - スパイダーの実装の仕方がわかる:
Scrapyのスパイダーは、あるURLを起点にWebサイトをクロールしてコンテンツを収集し、収集したコンテンツからデータの抽出まで行う。クロールするWebサイトの定義の仕方やリンクを辿る方法、データの抽出方法の定義の仕方がわかった。 - Scrapyシェルの使い方がわかる:
ScrapyにはScrapyシェルというインタラクティブなシェルが付属していて、これは対話的にデータの抽出を試すことができる。スパイダーを実装する前に、データ抽出の方法を検討するのに使う。
大体こんなものでしょうか。それでは実際にチュートリアルを実施してみよう。
プロジェクトの作成
まず最初にプロジェクトを作成する必要がある。チュートリアルの通り次のコマンドを実行する。
scrapy startproject tutorial
tutorialはプロジェクトの名前だ。実行するとカレントディレクトリに次のようなディレクトリ構造が作成される。ここに自分がやりたい動作を定義していくのだろうが、チュートリアルではほんのちょっとしかいじらない。
tutorial/
├── scrapy.cfg
└── tutorial
├── __init__.py
├── items.py
├── middlewares.py
├── pipelines.py
├── settings.py
└── spiders
└── __init__.py
スパイダーを作成する
プロジェクトを作成したら次にスパイダーを定義する。スパイダーにはWebサイトをどのようにクロールし、取得したコンテンツをどのようにスクレイピングするかを自分で定義する。チュートリアルのほとんどはスパイダーの説明なので一番重要な部分になる。
次のコードはチュートリアルで最初に出てくる最初のスパイダーのコードである。チュートリアルと同様にtutorial/spidersディレクトリにこのコードを記載したquotes_spider.pyというファイルを作成する。
import scrapy
class QuotesSpider(scrapy.Spider):
name = "quotes"
def start_requests(self):
urls = [
'http://quotes.toscrape.com/page/1/',
'http://quotes.toscrape.com/page/2/',
]
for url in urls:
yield scrapy.Request(url=url, callback=self.parse)
def parse(self, response):
page = response.url.split("/")[-2]
filename = 'quotes-%s.html' % page
with open(filename, 'wb') as f:
f.write(response.body)
self.log('Saved file %s' % filename)
コードの詳細を見ていく前に、スパイダーを実行した時の大体の処理の流れを説明しよう。ただし、チュートリアル全体をやってみて大体このようなものだろうと理解した内容なので正確ではない部分もあるかもしれない。しかし、多少正の確性を欠いても全体的な流れがわかっていると理解も早いと思うので説明しておくことにする。
- コマンドラインからスパイダーを起動する。
- Scrapy(フレームワーク)は起動されたスパイダーのstart_requests()メソッドを呼び出してscrapy.Requestオブジェクトを取得する。
- Scrapyは取得したscrapy.RequestオブジェクトのURLに対してリクエストを送信するようにスケジューリングする。
- リクエストが完了してコンテンツを取得したら、Scrapyはscrapy.Requestのコンストラクタに渡されたコールバックメソッド(この例ではcallback=self.parseで渡されている)を呼び出す。つまりスパイダーのparse()メソッドを呼び出す。その際、コールバック関数には第2引数(response)としてリクエストで取得したコンテンツを渡す。
- コールバックメソッド(parseメソッド)は引数として受け取ったコンテンツを処理する。
- スケジューリングされたURLからコンテンツを取得するたびにこれらの処理が繰り返される。
全体の流れも分かったところで、今度はコードの詳細とチュートリアルの説明を見ていこう。
class QuotesSpider(scrapy.Spider):
QuotesSpiderクラスはscrapy.Spiderを継承している。このようにスパイダーはscrapy.Spiderクラスを必ず継承する必要がある。
name = "quotes"
name属性はスパイダーを識別する名前だ。プロジェクトでユニークな任意な名前を設定する。
def start_requests(self):
urls = [
'http://quotes.toscrape.com/page/1/',
'http://quotes.toscrape.com/page/2/',
]
for url in urls:
yield scrapy.Request(url=url, callback=self.parse)
start_requests()メソッドはscrapy.Spiderクラスから継承したメソッドだ。このメソッドはscrapy.Requestオブジェクトのリストかscrapy.Requestを返すジェネレーター関数にする必要があるとのことだ。このコードではオーバーライドしてscrapy.Requestを返すジェネレーター関数として実装している。
Scrapyはstart_requests()から返されたscrapy.RequestオブジェクトのURLに対するリクエストをスケジューリングする。そしてスケジューリングリクエストは(おそらく非同期に)実行されてコンテンツを取得する。
def parse(self, response):
page = response.url.split("/")[-2]
filename = 'quotes-%s.html' % page
with open(filename, 'wb') as f:
f.write(response.body)
self.log('Saved file %s' % filename)
parse()メソッドもscrapy.Spiderクラスから継承したメソッドだ。リクエストで取得したコンテンツを第2引数(response)として受け取り、主にこれを処理する。responseはTextResponseオブジェクトらしい。この例でparse()メソッドは取得したコンテンツをファイルに保存しているだけだ。ここではそれくらいわかっていればこのメソッドについては十分だ。
start_requests()メソッドは必ずしもオーバーライドする必要はないとのこと。それを説明しているチュートリアルのコードは次のものだ。
import scrapy
class QuotesSpider(scrapy.Spider):
name = "quotes"
start_urls = [
'http://quotes.toscrape.com/page/1/',
'http://quotes.toscrape.com/page/2/',
]
def parse(self, response):
page = response.url.split("/")[-2]
filename = 'quotes-%s.html' % page
with open(filename, 'wb') as f:
f.write(response.body)
このコードではstart_requests()メソッドをオーバーライドする代わりに、URLのリストを参照するstart_urlsという属性(変数)を定義している。
start_requests()メソッドのデフォルトの実装(つまり継承した実装)はstart_urlsを参照して、そのURLに対するリクエストをスケジューリングする。そしてコールバック関数にはparse()メソッドを使うとのことだ。
この例のコードのように単純に複数のURLからコンテンツを収集するだけならこれで十分だろう。
スパイダーを実行する
スパイダーを定義したら、次にスパイダーを実行する。実行するにはコマンドラインでプロジェクトのトップレベルディレクトリに移動し、次のコマンドを実行する。quotesは先ほどname属性で定義したスパイダーの名前だ。
scrapy crawl quotes
実際に実行するとquotes-1.htmlとquotes-2.htmlファイルが作成される。
また、コマンドラインにはログが出力される。ログでちょっと興味を引いたのは次の出力だ。
2019-03-03 01:53:17 [scrapy.core.engine] DEBUG: Crawled (404)
<GET http://quotes.toscrape.com/robots.txt> (referer: None)
Scrapyはデフォルトでrobots.txtを検査をしているようだ。
データを抽出する
ここからはチュートリアルのデータを抽出する部分を試していく。チュートリアルではScrapyシェルを使ってデータを抽出するところから始まる。ScrapyシェルはScrapyに付属するシェルで、データの抽出をインタラクティブに試すことができる。
まずは、チュートリアルのScrapyシェル実行する部分から見ていこう。
scrapy shell 'http://quotes.toscrape.com/page/1/'
見たとおりscrapy shellの後に取得したいコンテンツのURLを指定して実行する。URLは&などの文字を含む場合があるので、常に引用符で囲んだほうが無難だ。
実行するとログが表示されたあとに入力待ちを示すプロンプト(>>>)が表示される。
取得したコンテンツはresponseオブジェクトに保存される。Scrapyシェルでこのresponseオブジェクトからデータの抽出を試すことができる。
チュートリアルの通り以下を実行してみる。これはCSSセレクタを使ってresponseオブジェクトからtitle要素を抽出している。
>>> response.css('title')
[<Selector xpath='descendant-or-self::title' data='<title>Quotes to Scrape</title>']
response.css('title')が返すのはSelectorListというリストのようなオブジェクトとのこと。これはCSSセレクタにマッチした全てのXML/HTMLの要素をラップしたSelectorオブジェクトを含んでいる。タイトルは1つしかないので、ここでリストの要素は1つしかない。
title要素のテキストだけ抽出するには次のように::textを追加すればよいらしい。その部分のチュートリアルのコードは次の通りだ。
>>> response.css('title::text').extract()
['Quotes to Scrape']
response.css('title::text')が返すのはやはりSelectorListオブジェクトだ。そしてこれはタイトルのテキストをデータとして持つSelectorオブジェクトを1つ(title::textにマッチするのは1つしかないから)含んでいる。そしてこれに対してさらにextract()メソッドを呼び出している。extract()メソッドは、SelectorListの要素である全てのSelectorオブジェクトのデータ部分を取り出して、Pythonの通常のリストとして返すようだ。したがって先ほどのコードは'Quotes to Scrape'を1つ含むPythonのリストが返される。
しかし、CSSに'title::text'のような記法があるのだろうか?「Selectors Level 4」へのリンクが張ってあったので今度読んで見るとして、とりあえず先に進もう。
extract()メソッドはリストを返すが、この例のようにリストの要素が1つだけと分かっているなら、最初の要素だけを返す次のコードをう使うことができる。
>>> response.css('title::text').extract_first()
'Quotes to Scrape'
>>> response.css('title::text')[0].extract()
'Quotes to Scrape'
どちらもSelectorListオブジェクトの最初の要素(Selectorオブジェクト)のデータを返す。2番目のようにインデックスで指定すると、インデックスで指定したものがなかった場合、IndexError例外が発生する。extract_firstを使うと、最初のものが見つからないときにはNoneが返される。そのため、IndexError例外の発生を避けることができるらしい。
目的の要素を選択するために正規表現を使うメソッドも用意されている。しかし、チュートリアルで詳しくは説明されていないので、チュートリアルのコードを引用するだけにしておく。
>>> response.css('title::text').re(r'Quotes.*')
['Quotes to Scrape']
>>> response.css('title::text').re(r'Q\w+')
['Quotes']
>>> response.css('title::text').re(r'(\w+) to (\w+)')
['Quotes', 'Scrape']
また、ScrapyはCSSセレクタの他にXPathも使える。というかXPathが基礎になっているらしい。CSSセレクタは内部でXPathに変換されるとのことだ。XPathを使ってデータを抽出する方法はチュートリアルで説明されていないので、XPathを使うには別のドキュメントを当たる必要がある。
引用テキストと著者を抽出する
チュートリアルでは次に著名人の名言を引用しているWebサイトから、引用テキスト、著者、そしてそれに付けられたタグを抽出する。その部分をやってみる。
チュートリアルは、まず抽出したいものが実際どのようにマークアップされているかを確認するところから始まる。次のHTMLはチュートリアルのその部分のコードだ。
<div class="quote">
<span class="text">“The world as we have created it is a process of our
thinking. It cannot be changed without changing our thinking.”</span>
<span>
by <small class="author">Albert Einstein</small>
<a href="/author/Albert-Einstein">(about)</a>
</span>
<div class="tags">
Tags:
<a class="tag" href="/tag/change/page/1/">change</a>
<a class="tag" href="/tag/deep-thoughts/page/1/">deep-thoughts</a>
<a class="tag" href="/tag/thinking/page/1/">thinking</a>
<a class="tag" href="/tag/world/page/1/">world</a>
</div>
</div>
データを抽出したい引用テキスト、著者、タグの1セットは上記の通り<pre><code>〜</code></pre>に含まれていることがわかる。そして、引用テキストは<span class="text">〜</span>に、著者は<small class="author">〜</small>、タグは<div class="tags">〜</div>の中の<a class="tag">〜</a>にそれぞれ含まれている。
それでは実際にscrapyシェルでデータを抽出してみよう。それにはチュートリアル通り次のように実行する。
scrapy shell 'http://quotes.toscrape.com'
次のコードはサイトの<div class="quote">〜</div>で囲まれた全ての部分を抽出することができる。
response.css("div.quote")
しかし、scrapyシェルでは1つdiv要素だけ試しに抽出するので次のようにを実行する。
quote = response.css("div.quote")[0]
これでSelectorListの最初の要素であるSelectorオブジェクトが返され変数quoteに代入される。このSelectorオブジェクトに対してさらにクエリを実行する。
次のコードはチュートリアルで実際にデータを抽出している部分のコードである。ここでは引用テキストとその著者を抽出している。
>>> title = quote.css("span.text::text").extract_first()
>>> title
'“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”'
>>> author = quote.css("small.author::text").extract_first()
>>> author
'Albert Einstein'
タグは複数あるのでextract()で、それらすべてをリストで取得している。そのチュートリアルのコードは次の通りだ。
>>> tags = quote.css("div.tags a.tag::text").extract()
>>> tags
['change', 'deep-thoughts', 'thinking', 'World']
これで1つのdiv要素から必要なデータの抽出が出来た。全てのdiv要素からデータを抽出するにはループで同じ処理を繰り返すだけだ。そして抽出したデータを辞書に保存しているチュートリアルのコードは次の通りだ。
>>> for quote in response.css("div.quote"):
... text = quote.css("span.text::text").extract_first()
... author = quote.css("small.author::text").extract_first()
... tags = quote.css("div.tags a.tag::text").extract()
... print(dict(text=text, author=author, tags=tags))
{'tags': ['change', 'deep-thoughts', 'thinking', 'world'], 'author': 'Albert Einstein', 'text': '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”'}
{'tags': ['abilities', 'choices'], 'author': 'J.K. Rowling', 'text': '“It is our choices, Harry, that show what we truly are, far more than our abilities.”'}
... a few more of these, omitted for brevity
>>>
スパイダーでデータを抽出する
Scrapyシェルを使ってのデータの抽出方法はわかったので、スパイダーにこれを組み込む。そのコードは次の通り。
import scrapy
class QuotesSpider(scrapy.Spider):
name = "quotes"
start_urls = [
'http://quotes.toscrape.com/page/1/',
'http://quotes.toscrape.com/page/2/',
]
def parse(self, response):
for quote in response.css('div.quote'):
yield {
'text': quote.css('span.text::text').extract_first(),
'author': quote.css('span small::text').extract_first(),
'tags': quote.css('div.tags a.tag::text').extract(),
}
えっと、yieldが出てきてよくわからなくなってきたのでparseメソッドの処理を1つずつ見ていく。
- response.css('div.quote')で全ての<div class="quote">〜</div>を含むSelectorオブジェクトのSelectorListオブジェクトが出来上がる。
- それをforでループしているのでquoteに<div class="quote">〜</div>を含むSelectorオブジェクトが順に格納されることになるのだろう。
- そして、yieldで返されるのは、キーがそれぞれtext、author、tagsである辞書が返されることになる。値はquoteからさらに抽出されたデータだ。
- parseメソッドはyieldを含むのでジェネレーター関数だ。したがってparseメソッドが1回呼ばれると先ほどの辞書が1つだけ返されて制御はparseメソッドの呼び出し元に戻る。
- ということはparseメソッドはfor文が回りきるまで何回も呼び出されるということなのだろう。ここら辺の詳細はちょっとわからない。
よくわからないところもあるが先に進もう。このスパイダーを実行すると、抽出されたデータをログと共に出力する。
2016-09-19 18:57:19 [scrapy] DEBUG: Scraped from <200 http://quotes.toscrape.com/page/1/>
{'tags': ['life', 'love'], 'author': 'André Gide', 'text': '“It is better to be hated for what you are than to be loved for what you are not.”'}
2016-09-19 18:57:19 [scrapy] DEBUG: Scraped from <200 http://quotes.toscrape.com/page/1/>
{'tags': ['edison', 'failure', 'inspirational', 'paraphrased'], 'author': 'Thomas A. Edison', 'text': "“I have not failed. I've just found 10,000 ways that won't work.”"}
スクレイピングしたデータの保存
お次はちょっと話は変わってデータの保存方法についてだ。Scrapyのフィードエクスポート機能を使うと簡単にデータをJSONなどで保存できるとのこと。フィードエクスポートが何かよくわからなかったが次のようにするとJSONで保存できるらしい。
scrapy crawl quotes -o quotes.json
これでスクレイピングしたデータをシリアル化してquotes.jsonというファイルにJSON形式で保存できるとのこと。
ただし1つ注意点がある。すでにファイルが存在していても、歴史的な理由からScrapyはその内容を上書きしないで、既存ファイルに追記してしまうらしい。そのため、既存ファイルを削除せずにこのコマンドを繰り返し実行すると、破損したJSONファイルが出来上がってしまう。
JSON Lines形式保存することもできる。JSON Lines形式は正直聞くのも初めてだが。。。
scrapy crawl quotes -o quotes.jl
JSON Lines形式はJSON形式と異なり、追記してもJSON Lines形式が破損することはないらしい。ここら辺は詳しくないが、JSON形式は追記していくと2つ以上のJSON形式が1つのファイルに出来上がってしまうが、JSON Lines形式は1行でデータが完結しているため、追記しても大丈夫なのだろうと推測している。
リンクを辿る
さて、今まではコードに記述されたURLのコンテンツだけを扱っていたが、ここからはそのコンテンツからリンクを抽出してそれを辿って行く方法を学ぶ。
まず、コードに記述したURLのページから辿りたいリンクを抽出したいので、Webページの構造を調べるところから始まる。すると、次のページへのリンクがあることがわかる。その部分のHTMLは次のようになっている。
<ul class="pager">
<li class="next">
<a href="/page/2/">Next <span aria-hidden="true">→</span></a>
</li>
</ul>
例によって、Scrapyシェルでリンクを抽出する。
>>> response.css('li.next a').extract_first()
'<a href="/page/2/">Next <span aria-hidden="true">→</span></a>'
これでa要素を抽出できたが、欲しいのはリンク先のURL、つまりhref属性の値だ。それには次のようにすれば良いとのこと。これはCSSの拡張機能の記法とのこどだが、それについてはやっぱりよく知らない。今度調べるとして先に進む。
>>> response.css('li.next a::attr(href)').extract_first()
'/page/2/'
リンク先のURLの抽出方法は分かったので、それをスパイダーに実装するチュートリアルのコードを見てみよう。これは次のページへのリンクを再帰的に辿り、データを抽出して行く。
import scrapy
class QuotesSpider(scrapy.Spider):
name = "quotes"
start_urls = [
'http://quotes.toscrape.com/page/1/',
]
def parse(self, response):
for quote in response.css('div.quote'):
yield {
'text': quote.css('span.text::text').extract_first(),
'author': quote.css('span small::text').extract_first(),
'tags': quote.css('div.tags a.tag::text').extract(),
}
next_page = response.css('li.next a::attr(href)').extract_first()
if next_page is not None:
next_page = response.urljoin(next_page)
yield scrapy.Request(next_page, callback=self.parse)
コードの詳細をみていきたいのだが、parse()メソッドにyieldが2つも出てきている。初めてみた。まずはyieldが2つある場合の動きを理解するために次のコードを実行してする。
>>> def hoge():
... for i in [1, 2, 3]:
... yield i
... for j in ["A", "B", "C"]:
... yield j
...
>>> for i in hoge():
... print(i)
...
1
2
3
A
B
C
なんてことはない。hoge()が呼ばれるたびにyieldで値が返されるだけだ。hoge()はジェネレーター関数なので最後に実行された状態を保持しているので、次に呼び出された時には前回yieldで値を返した後の次の処理から開始される(そしてまたyieldまできたら値を返すの繰り返し)
yieldの動きも分かったので、parse()メソッドのコードを詳しく見てみよう。名言のテキスト、著者、タグを抽出して辞書を返すところは前のコードと同じだ。for文が回り終わったらその後は…
- 次のページへのリンクからhref属性の値を抽出して変数next_pageへ保存する
- next_pageの値がNoneかどうか検査する
- None出なければurljoin()メソッドでnext_pageのURLを絶対URLに変換する
- URL(next_page)とコールバック関数(parse()メソッド自身)からscrapy.Requestオブジェクトを生成して返す
- 以下、next_pageがNoneになるまで同じ処理を繰り返す
コールバックメソッドでRequestオブジェクトを生成すると、Scrapyはそのリクエストが送信されるようにスケジューリングし、そのリクエストが完了ししたときに呼び出されるコールバックメソッドを登録するとのこと。
チュートリアルのもう一つのスパイダーの例
チュートリアルではリンクをたどるもう1つのスパイダー例がある。そのコードは次の通り。
import scrapy
class AuthorSpider(scrapy.Spider):
name = 'author'
start_urls = ['http://quotes.toscrape.com/']
def parse(self, response):
# follow links to author pages
for href in response.css('.author+a::attr(href)').extract():
yield scrapy.Request(response.urljoin(href),
callback=self.parse_author)
# follow pagination links
next_page = response.css('li.next a::attr(href)').extract_first()
if next_page is not None:
next_page = response.urljoin(next_page)
yield scrapy.Request(next_page, callback=self.parse)
def parse_author(self, response):
def extract_with_css(query):
return response.css(query).extract_first().strip()
yield {
'name': extract_with_css('h3.author-title::text'),
'birthdate': extract_with_css('.author-born-date::text'),
'bio': extract_with_css('.author-description::text'),
}
これもparse()メソッドの中身を1つ1つ見ていく。
- response.css('.author+a::attr(href)').extract() で著者のリンク(a要素)のhref属性値をすべて抽出する
- 抽出したhref属性値のリストをfor文で回す
- for文の中ではhref属性値とparse_author()メソッド(コールバックメソッド)からRequestオブジェクトを生成して呼び出し元に返す(このリクエストは送信されるようにScrapyによってスケジューリングされる)
- この後は先ほどの例と同じ。次のページへのリンクを抽出して、そのページへのRequestオブジェクトを生成しれ呼び出し元に返す
さて、3と4でリクエストがスケジューリングされ、リクエストが完了するとコンテンツが取得されるわけだが、そのコンテンツが取得されたときの流れを見ていこう。
まず、3でスケジューリングされたリクエストのコールバックメソッドはparse()メソッドなので、コンテンツはparse()メソッドに渡されて処理される。このparse()メソッドの処理は先ほど見たとおりだ。つまり、コンテンツが取得されparse()メソッドで解析されRequestオブジェクトが生成され、と再帰的に処理が繰り返される。
一方、4でスケジューリングされたリクエストのコールバックメソッドはparse_author()メソッドだ。parse_author()メソッドでは、名前、誕生日、伝記が抽出され、それを含む辞書を返す。
今回の例では、同じ著者の引用が複数あると、同じ著者のページ(URL)へのリクエストが何度も発生するはずである。しかし、Scrapyはデフォルトで、既にアクセスしたURLへの重複したリクエストがおきないようにする発生しないようにするとのこと。これによって、余計な小細工をしないで済むのでコードが簡潔になりそうだ。
おわりに
チュートリアルはもうちょっとだけ続きますが、大体こんなものでしょう。