はじめに
- Django の良い使い方やアプリケーションのキレイな作り方に関する理解をさらに深めたいので、手頃なサイズのオープンソースプロジェクトを読み進めてみる
- 前回は Web アプリケーション全体の部分で参考になる箇所を見ていった
- 今回はクローリング部分を読んでいく
全体を俯瞰する
- いくつかの.py ファイルに分割されて処理が記載されている
- management/commands/crawl.py
- Django ではバッチ処理は management/commands/配下に記載する
- こうすることで manage.py でコマンドを呼び出せるようになる
- 呼び出されるのは Command クラスの handle メソッド
- crawlers.py
- クローラーが定義されている
- この次に出てくる wrappers.py(client)を内包して利用するような構成
- クローラーでは client を利用したり、service モデルや story モデルを呼び出して DB に更新をかけたりする
- ちなみに service:クローリングするサイト、story:クローリングしたサイトの記事、という理解ができる
- wrappers.py
- クローラーが呼び出して使うクライアント
- サイトにアクセスして該当する箇所から特定の情報を削って返す機能を良く client と呼ぶよう
- なのでこのファイルの中におなじみの requests ライブラリや BeautifulSoup ライブラリを import する記載がある
それではもう少し細かく見ていきます。
バッチ処理を発動するコマンド
Command クラスを見ていきます。
/woid/woid/apps/services/management/commands/crawl.py
class Command(BaseCommand):
help = 'Crawl external services to update top stories'
...省略...
def handle(self, *args, **kwargs):
service_slugs = kwargs['service_slug']
for slug in service_slugs:
...省略...
crawler_class = self.get_crawler_class(slug)
if crawler_class is not None:
crawler = crawler_class()
start_time = time.time()
crawler.run()
elapsed_seconds = round(time.time() - start_time, 2)
self.stdout.write(self.style.SUCCESS('%s crawler executed in %s seconds') % (slug, elapsed_seconds))
else:
self.stdout.write(self.style.WARNING('Crawler with slug "%s" not found.') % slug)
- service_slugs = kwargs[‘service_slug’]
とあるので、複数のサービス分のバッチ処理を一度のコマンドで実行できる。ので、例えば cron では下記のような記載になる
*/5 * * * * /home/woid/venv/bin/python /home/woid/woid/manage.py crawl reddit hn producthunt >> /home/woid/logs/cron.log 2>&1
- crawler_class = self.get_crawler_class(slug)
の部分で上記コマンドで指定された service の文字列から該当するクラスを取得する - crawler = crawler_class()
の部分で上記で取得したクラスに()をつけてインスタンスを作成する。こんな書き方ができるのは知らなかった。 - crawler.run()
の部分で実際の処理を行っている様子。
クローラー
一旦該当するファイルの中身を見てみます。
/woid/woid/apps/services/crawlers.py
- 例として HackerNewsCrawler というクラスを見ていく
- このクラスは HackerNews をクローリングするときに使うクラス
class AbstractBaseCrawler:
def __init__(self, slug, client):
self.service = Service.objects.get(slug=slug)
self.slug = slug
self.client = client
def run(self):
try:
self.service.status = Service.CRAWLING
self.service.last_run = timezone.now()
self.service.save()
self.update_top_stories()
self.service.status = Service.GOOD
self.service.save()
except Exception:
self.service.status = Service.ERROR
self.service.save()
class HackerNewsCrawler(AbstractBaseCrawler):
def __init__(self):
super().__init__('hn', wrappers.HackerNewsClient())
def update_top_stories(self):
try:
stories = self.client.get_top_stories()
i = 1
for code in stories:
self.update_story(code)
i += 1
if i > 100:
break
except Exception:
logger.exception('An error occurred while executing `update_top_stores` for Hacker News.')
raise
def update_story(self, code):
try:
story_data = self.client.get_story(code)
if story_data and story_data['type'] == 'story':
...省略...
story.save()
except Exception:
logger.exception('Exception in code {0} HackerNewsCrawler.update_story'.format(code))
それでは、crawler = crawler_class()の部分を深掘ってみます。
- HackerNewsCrawler は AbstractBaseCrawler を継承している
- HackerNewsCrawler は初期化時に AbstractBaseCrawler の init に対してどの service なのかという slug と HackerNewsClient()インスタンスを渡す
- AbstractBaseCrawler の init ではそれらをインスタンス変数に登録する
- このタイミングで wrappers.py の定義によりクライアントの初期化処理が起きるが、これはそんなに多くの処理は起きない
続いて、crawler.run()の部分を深掘ってみます。
- try except 文で囲っていて、処理が失敗すると service にエラーを登録して DB を更新し、後で気づけるようにしてある
- self.update_top_stories() 部分で HackerNewsCrawler のメソッドを呼んでいる
- ここではさらに client の関数を呼びに行っている。ここが実際に対象のサイトにアクセスして対象の story を取得する処理
- そして self.update_story(code)内でさらに HackerNewsCrawler のメソッドを呼んで各 story から該当する箇所を削り取る処理を行っている
最後にクライアントの中身を軽く見てみます。
クライアント
/woid/woid/apps/services/wrappers.py
class AbstractBaseClient:
def __init__(self):
self.headers = {'user-agent': 'woid/1.0'}
class HackerNewsClient(AbstractBaseClient):
base_url = 'https://hacker-news.firebaseio.com'
def request(self, endpoint):
r = requests.get(endpoint, headers=self.headers)
result = r.json()
return result
def get_top_stories(self):
endpoint = '%s/v0/topstories.json' % self.base_url
return self.request(endpoint)
def get_story(self, code):
endpoint = '%s/v0/item/%s.json' % (self.base_url, code)
return self.request(endpoint)
def get_max_item(self):
endpoint = '%s/v0/maxitem.json' % self.base_url
return self.request(endpoint)
- HackerNewsClientはAbstractBaseClientを継承している
- AbstractBaseClientのinitではrequestsでhttpリクエストを飛ばすときのヘッダ情報を定義している
- HackerNewsClientには各種メソッドが定義されている。HackerNewsはどうやらAPIを叩いて情報を取得する類のものなのでBeautifulSoupをつかってがりがりスクレイピング、みたいなことはやっていないが、他のクライアントクラスにはそういった記述も見られる。