moxt

Just another Blog site

コードを雑に読むアプローチでScrapyを入門する

      2017/01/15

scrapy_architecture_02

Scrapyはスクレイピング用フレームワークなので、登場人物多すぎてよく分からない。
彼らの関係性や役割を理解を深めるために『Data flow図』の順にそってコードを読んだ。

ちなみに、前提知識として下記をおさえておくとフレームワークへの理解が早まる。

  • pythonのジェネレーター、yield
  • twistedのDeffered
    • javascriptで言うところ(と、括っていいのかは知らんが)のpromiseみたいなモノ
  • twistedのinlineCallback
    • コード中のあちこちで登場するので理解しておくと混乱せずにコード読める
    • http://skitazaki.appspot.com/translation/twisted-intro-ja/p17.html

The Engine gets the initial Requests to crawl from the Spider.

まず、EngineはSpiderから『どこからクロールを始めるか?』をという情報を得るらしい。

Crawlerというクラスを介して、Spiderが保持する初期リクエストのリストをEngineのopen_spiderメソッドの引数として渡している。

open_spiderメソッド

The Engine schedules the Requests in the Scheduler and asks for the next Requests to crawl.

次に、Engineは初期リクエストのリストをSchedulerに渡しつつ、Schedulerに『次にクローリングすべきリクエストはどれか?』とたずねる。

これは先に登場したopen_spiderメソッドの内部で行われている。

ここから如何にしてSchedulerにリクエストを詰めているのか、パッと見では全く分からない。
リクエストがSchedulerに詰まれるまで複数のメソッドを辿ることになる。

突然だが、CallLaterOnceクラスを見る必要がある。
open_spiderメソッド内ではself._next_requestspiderを渡してnextcallという名でインスタンスを生成している。
後にslot.nextcall.schedule()という形でインスタンスのメソッドが呼び出される。

schedule()は何をやっているのか?
CallLaterOnceクラスの定義を見てみよう。

CallLaterOnceとは?

よく分からないですがscheduleっていうメソッドを呼ぶと、reactor(twisted用語、イベントループみたいなもん)に対して『コールバック(selfなので自インスタンス)をdelay秒後に実行してくれよ。』と依頼します。
このとき、_callにreactor.callLaterの返り値を格納しています。
連続で呼び出されたとしても_callが空じゃないかぎりはスケジューリングが実行されないわけですね。
ここがCallLaterOnceってことなのかな。知らんけど。

で、delay秒後に__call__が呼び出されるわけ。(pythonの文法で、自クラスのインスタンスを関数のように扱うと、定義されたcallが呼ばれるらしいよ)
インスタンス生成時に渡された関数funcを実行するわけですな。

nextcall = CallLaterOnce(self._next_request, spider)

なので

nextcall.schedule(delay=XXX)と呼び出すと、XXX秒後にself._next_request(spider)が実行されるわけです。

self._next_requestとは

engine.pyに戻る。

メソッドを見渡してみると、next(slot.start_requests)で初期リクエスト群を取り出している箇所がありますね。

このリクエストをSchedulerに突っ込むのかな…と思ったら、self.craw(request, spider)呼び出してます。
メソッドを追いかけます。

crawlの中ではscheduleを呼び出して、再びself.slot.nextcall.schedule()が呼び出されています。
少なくともstart_requestsの個数だけself._next_requestが呼び出されることがわかりました。

scheduleの中でSchedulerインスタンスのenqueue_requestを実行してrequestをキューに詰め込んでいます。

ここまでで

The Engine schedules the Requests in the Scheduler and asks for the next Requests to crawl.

のうち、

The Engine schedules the Requests in the Scheduler

が完了しました。

and asks for the next Requests to crawl.

は、どこでたずねているのでしょうか?
_next_requestの下記がそれに該当します。

_needs_backout(spider)が偽である(=取り消す必要がない)限り、無限ループしています。
ループの中では_next_request_from_scheduler(spider)を実行し続けています。
メソッドの名前からして、Schedulerが保持するリクエストキューから次に実行すべきリクエストを取り出しているように見えますね。
で、このメソッドの返り値がFalseだったら無限ループから脱出しています。
おそらく返り値にリクエストの存在の有無を返しているのでしょうか?
次のリクエストが存在しなければ無限ループに滞留する理由はないですね。

_needs_backout(spider)の詳細には立ち入らず、_next_request_from_scheduler(spider)を細かく見ます。

_next_request_from_schedulerとは?

メソッドを見れば一目瞭然ですが、下記の

and asks for the next Requests to crawl.

slot.scheduler.next_request()

で実現されています。

requestが存在しなければreturnしています。
これによって、呼び出し元のif文がFalseとなり無限ループからbreakします。

requestが存在すると、_downloadが実行されます。
これは次に紹介する『EngineがDownloaderにRequestを送りつける(Downloaderにコンテンツのダウンロードを依頼する)』部分の処理になります。

The Engine sends the Requests to the Downloader, passing through the Downloader Middlewares (see process_request()).

Data flow図の通り、RequestをDownloaderのミドルウェアで包んでからDownloaderに送りつけます。

前項の最後に軽く触れた、_downloadメソッドの中身を見ます。

Downloaderクラスのfetchメソッドがそれっぽいですね。
fetchメソッドを見てみると…

ここでmiddlewareが出てきます。
middlewareの実体はDownloaderMiddlewareManagerクラスのインスタンスです。
downloadメソッドを見てみましょう。

ここでやってることは、Data flow図の通りですが下記の3点をdeferredにまとめています。

  • Requestのダウンロード前に、与えられたミドルウェア群のprocess_requestを実行する
  • Requestのダウンロード後に、与えられたミドルウェア群のprocess_responseを実行する
  • Requestのダウンロード中に例外が発生したら、与えられたミドルウェア群のprocess_exceptionを実行する

ここで特に注目すべきはprocess_requestの中身です。

The Engine sends the Requests to the Downloader, passing through the Downloader Middlewares (see process_request()).

は、このprocess_requestの中で行われていることが分かりました。

もう少しだけ深追いして、実際にRequestの内容がダウンロードされる様子を見てみます。
先のdonwload_funcがそれっぽいですね。

download_funcの実体はDownloaderクラスのdownloadメソッドの引数として与えられた…

_enqueue_requestメソッドです。
そこから、詳細には触れませんが_process_queue_downloadと流れていきます。

_download内の初っ端の処理でダウンロードを行っています。

このself.handlers.download_requestってヤツですね。
self.handlersを初期化している箇所を見てみるとDownloadHandlersというクラスが現れます。
このクラスのdownload_requestというメソッドを呼び出しているので定義元を見てみると…

パッと見、リクエストのスキーマに対応するハンドラを取得、そのハンドラのdownload_requestを呼び出してコンテンツをGETする…って感じでしょうか。

scrapyでは標準でいくつかのハンドラを用意してくれています。
ポピュラーなhttp(1.1)ハンドラのdownload_requestメソッドを見てみます。

それっぽいですね。

Once the page finishes downloading the Downloader generates a Response (with that page) and sends it to the Engine, passing through the Downloader Middlewares (see process_response()).

passing through the Downloader Middlewares (see process_response()).

と、書かれてるようにrequestを投げた時と同じく、responseを得るときにもミドルウェアを通します。
例によってDownloaderMiddlewareManagerを見てみると。

request時と同じような構造ですね。
で、各メソッドのreturnを辿ってゆくと、engine.pyに帰ってきます。

具体的には_next_request_from_schedulerです。

The Engine receives the Response from the Downloader and sends it to the Spider for processing, passing through the Spider Middleware (see process_spider_input()).

DownloaderからResponseを受け取って、Spiderに横流ししてスクレイピングさせる。
Downloaderと同様に、ResponseはSpider Middlewareを通してからSpiderに渡される。

Engineは受け取ったResponseを_handle_downloader_outputに渡します。

Downloaderから帰ってきたResponseの型がRequestだったら再度crawlさせています。
コメントにも書いてあるように、Downloaderミドルウェアを通したら『リダイレクトすべき』と判断されてリダイレクト先のRequestがResponse(紛らわしい)として帰ってきた場合なんかが考えられます。

ここでは、responseはResponse型(紛らわしい)とします。
すると先の処理であるself.scraper.enqueue_scrapeに進むわけです。
Scraperクラスのenqueue_scrapeメソッドを見てみましょう。

slotにscrape対象のresponseとrequestを詰めてます。
実際にスクレイピングしてる処理は_scrape_nextメソッドっぽいですね。

メソッドを辿っていくと_scrape2という不思議な名前のメソッドの中で、spidermw(Spider Middleware)のscrape_responseを呼び出しています。
spidermwの定義元を見てみると、SpiderMiddlewareManagerというクラスのメソッドであることが分かります。

DownloaderMiddlewareManagerと同じ構造ですね。

  • Spiderのスクレイピング前に、与えられたミドルウェア群のprocess_spider_inputを実行する
  • Spiderのスクレイピング後に、与えられたミドルウェア群のprocess_spider_outputを実行する
  • Spiderのスクレイピング中に、与えられたミドルウェア群のprocess_spider_exceptionを実行する

The Spider processes the Response and returns scraped items and new Requests (to follow) to the Engine, passing through the Spider Middleware (see process_spider_output()).

スパイダーはレスポンスを処理して、スクレイピングしたアイテムと新しいRequestをSpider Middlewareを通じてEngineに返す。

process_spider_inputの終わりにscrape_funcを呼んでいます。
これはscrape_responseの引数として与えられたモノです。
呼び出し元を見てみるとcall_spiderというメソッドであることが分かります。

addCallbacksでrequestのcallbackかspiderのparseメソッドのいずれか存在する方をdeferのコールバックとしています。
ここではspider.parseが呼ばれることにします。

parseメソッドの中身はユーザーが好き勝手に書くところです。
例えば、下記のような感じです。

parseメソッドの返り値は上記のようなgeneratorオブジェクトとなります(ユーザーの作り次第で返り値変わるけど)。
最後のiterate_spider_outputはparseメソッドの返り値をすべからくiterableにするための処理です。
これは次に行う処理のための下準備となります。

メソッドを辿ってゆくとこんな感じ。

これらの処理の結果をSpider Middlewareのprocess_spider_outputに渡します。
Downloaderの流れと同じなので詳細は省略します。

これでResponseをSpiderによってパースした結果を得ることができました。

The Engine sends processed items to Item Pipelines, then send processed Requests to the Scheduler and asks for possible next Requests to crawl.

call_spiderの呼び出し元は_scrape2でさらにその呼び出し元は_scrapeでした。
先述の通り、この段階でSpider Middlewareを通したナニカを得ています。
このナニカにはBaseItemというクラスのオブジェクトだったりプレーンなdictだったり、はたまたRequestオブジェクトを含んだジェネレーターやリストだったりします。この辺の中身はSpiderのparseメソッドの中身や、Spider Middlewareに依存します。

例えば、先にあげたQuotesSpiderparseはdictやRequestを返すジェネレーターを返します。

この多様なナニカを処理してゆくのが次のhandle_spider_outputメソッドです。

iter_errbackは与えられたナニカ(iterableなオブジェクト)とエラー処理を結びつけるための関数です。
先述のarg_to_iterはこの辺を円滑にするために必要だったわけですね。

次はparallelです。

Cooperatorの動作がイマイチ理解できてないのでよく分からないですが、引数として与えられたcallableをparallelに実行する(同時に実行する数を制御する、ことが目的?)ための関数のようです。
callableとして_process_spidermw_outputが与えられています。

outputの型に応じて処理が分岐されています。

Request型だったら再びクローリングします。
BaseItemやdictだったらpipelineに渡して処理してもらいます。
Noneだったら何もしない。
それ以外だったらエラーを吐く。

先にあげたQuoteSpiderのparseメソッドを例にoutputがどんな感じになるか見てみます。

ご覧の通り、parseメソッドはgeneratorを返しています。
で、このgeneratorをparallelを通じて逐次処理してゆくわけです。

最初のyieldではtextやauthorを含んだdictを返しています。
で、dictを取りきった後は『次のページのURL』を抱えたRequestオブジェクトを返していますね。

[dict, dict, dict, … , Request]

リストでイメージすると↑のような感じでしょうか(このイメージが適切じゃないかもしれないが)
で、_process_spidermw_outputを逐次やっていく。って感じです。

ここではoutputがBaseItemやdictであるとします。

BaseItemやdictの場合はpipelineを通して加工するなり…とにかく、何かしらします。
piplineの管理を行っているのはItemPipelineManagerクラスです。(デフォの設定を見ると分かります。)

このItemPipelineManagerのprocess_itemメソッドを呼んでいます。
クラスの定義を見てみます。

質素。
MiddlewareManager_process_chainを見ると…

で、process_chainを見ると…

callbacks(ItemPipelineたち)をdeferredに詰めて、itemを引数にcallbackを呼んでItemPipelineたちの処理を発火してやる。
dにはItemPipelineを経由したナニカが格納されてそうですね。

次の処理はScraperに書かれています。

_itemproc_finishedの中身はどうなってるでしょうか。

outputがFailure(失敗)だったらエラーメッセージを表示します。
それ以外だったらoutputに関するメッセージを表示しています。

The process repeats (from step 1) until there are no more requests from the Scheduler.

Schedulerの中身が空になるまで一連の処理をやり続けます。

終わりに

大体の流れが分かった。
deferredだらけの世界なのでメソッドを潜っては浮上して、再び潜る…の繰り返しでコードを追うのが大変。
twistedにベッタリ依存した実装なので、twistedのボキャブラリーを知らないと意味不明になっちゃう。

 - プログラミング

  • このエントリーをはてなブックマークに追加
  • follow us in feedly

  関連記事

no image
意識低いRuby on Rails再入門4

下記の内容を読んでテスト系の処理をすっ飛ばしたメモ。 http://railstutorial.jp/chapters/sign-in-sign-out?version=4.0#top …

Chef::Exceptions::ChecksumMismatch:というエラーの対処

今頃になってChefの話。 チェックサムの形式をミスってる可能性がある。 …

no image
フロントエンド開発のメモ

最近のフロントエンド開発ではビルドランナーを使うのが常識になってきてるみたいなので。 jspm的なもっと進んだやり方でも良いんだけど、pluginが少ない、文献が少ない、自身の技術力不足、ということでビルドランナーなやり方でやる。 …

no image
『Tutorial & Hackathon #1』をやってみる

https://pydata.tokyo/news/pydata.tokyo-tutorial-hackathon-1 タイタニックの乗客データから生存者の推定モデルを作成してる。 …

ReactなComponent同士を連携させたい

実践的なサンプルに塗れてなんとなく使ってると破綻する。 分かってること、分かってないことを整理しておきたい。 …

no image
Macでdocker系のコマンドが使えなくなったら確認すること

OSXではdockerは使えないため、別にVMを立ち上げ、そこでdockerを動かしてる。 macからdockerコマンドを使うためにboot2dockerというコマンドを使う。 …

no image
SeleniumでChromeを自動操作したい

Seleniumという便利なソフトウェアがあります。 これはブラウザ上の操作をスクリプト化し自動化することを目的としています。 …

no image
NginxとPHP-FPMを使っていたらcurl_init()が無いとエラーが出た

参考リンク 解決策 …

no image
gitであまり使わないけど知らないと困るコマンド一覧

随時追加 originのURLを変更したい …

no image
Rubyのモジュール機能とRailsのHelperについて考える

Moduleとは 参考サイトを見ながら思ったことをメモ …