Corredor

ウェブ、プログラミングの勉強メモ。

Scrapy を使ってクローリング・スクレイピングしてみる

Python 製のスクレイピング・ライブラリ「Scrapy」を使ってみる。

Scrapy プロジェクトを作成する

作業ディレクトリを作り、pipenv を使って環境構築し、Scrapy をインストールしていく。

# Pipfile を初期生成する
$ pipenv --python 3.7
# Pipfile、Pipfile.lock、.venv/ が生成される

# Scrapy をインストールする
$ pipenv install scrapy

# 「pipenv run scrapy」と打たずに「scrapy」とコマンドが実行できるようシェルを読み込む
$ pipenv shell

# Scrapy プロジェクトを作成する
$ scrapy startproject my_scrapy .
# scrapy.cfg ファイル、my_scrapy/ ディレクトリが生成される

scrapy startproject 【プロジェクト名】 【作成するパス】 というコマンドで、Scrapy 用のプロジェクトを作る。カレントディレクトリに作るようにすると、以下のようなファイルが自動生成されるはずだ。

【作業ディレクトリ】/
├ scrapy.cfg
└ 【プロジェクト名】/ … 上の例では「my_scrapy/」
   ├ __init__.py
   ├ items.py
   ├ middlewares.py
   ├ pipelines.py
   ├ settings.py
   └ spiders/
      └ __init__.py

それぞれのファイルの意味は順を追って理解していくとする。

クローリングの設定を変更する

まずは settings.py を開き、クローリングの設定を変更していく。やっておきたい設定は以下のとおり。

  • DOWNLOAD_DELAY を設定し、クロール先に連続したアクセスをしないよう秒間を開ける
    • ex. DOWNLOAD_DELAY = 3 (3秒間隔を開ける)
  • HTTPCACHE_ から始まる設定を有効にしておく。コレにより、作業ディレクトリ直下に .scrapy/ というディレクトリができ、その中にキャッシュが保存される
    • キャッシュによって正常にクロールできなくなる場合もあるので、その時は .scrapy/ ディレクトリごと削除してやり直せば OK
    • 参考:10分で理解する Scrapy - Qiita
  • USER_AGENT を設定しておく
  • スクレイピング結果を JSON 出力する際、日本語文字が Unicode エンコーディングになるのを防ぐため、以下の設定を追加する

スパイダーを作成する

スクレイピング関連の一般的な用語整理。

  • Spider スパイダー : クロール先 URL を特定する
  • Crawler クローラー : スパイダーが収集した URL にアクセスして、レスポンス (HTML や REST API の JSON など) を取得する
  • Scraper スクレイパー : クローラーが取得したレスポンスから特定のデータを抽出・加工する

Scrapy では、これらの処理をまるっと「Spider」と呼ぶクラスが担う形になる。Scrapy でいうところの「スパイダー」を作ってみよう。

# 作成した Scrapy プロジェクトに移動する
$ cd my_scrapy/

# 以下のコマンドで Spider を作成する
$ scrapy genspider my_example example.com

scrapy genspider 【Spider 名】 【取得するサイトのドメイン】 と記す。ドメイン部分は、https:// などのプロトコルを付けず、ドメイン配下を付けてはならない。

  • 以下は指定する文字列として悪い例
    • https://example.com
    • https://example.com/index.html
    • example.com/index.html
  • 次のように書くこと
    • example.com

1つの Spider につき、1つのサイトのドメインを扱う形になる。

このようにコマンドを実行すると、./【Scrapy プロジェクト名】/spiders/【指定した Spider 名】.py というファイルができているはずだ。

  • ./my_scrapy/spiders/my_example.py
# -*- coding: utf-8 -*-

import scrapy

class MyExampleSpider(scrapy.Spider):
  name = 'my_example'
  allowed_domains = ['example.com']
  start_urls = ['http://example.com/']
  
  def parse(self, response):
    pass

コレで Spider の雛形が出来たことになる。

start_urls 部分でクロール対象の URL を指定する。一般的な用語でいう「スパイダー」の役割は、この start_urls に URL を列挙することで担う。クロール対象 URL 部分を動的に処理して収集していくこともできるが、今回は割愛。予めクロールしたい URL を配列で列挙しておくこととする。

「クロール」処理は、Scrapy が自動的に行い、レスポンスを parse() 関数に渡してくれる。開発者は parse() 関数内で「スクレピング」処理を実装していけば良い、というワケだ。

以降、もう少し詳しく説明していこう。

Item を定義する

スクレイピングしたデータは、最終的に CSV や JSON の形式で出力できる。こうした構造化データを表現するために、Scrapy では Item と呼ばれるクラスを作成しておくことになる。

items.py というファイルがあるので、コレを開き、次のように実装しておこう。

  • ./my_scrapy/items.py
# -*- coding: utf-8 -*-

from scrapy import Field, Item

# Item を定義する
class MyExampleItem(Item):
  # 適当にフィールドを定義する
  page_url      = Field()
  page_title    = Field()
  page_headline = Field()

任意のクラス名で MyExampleItem を作った。class のカッコ部分で Item を渡していて、コレにより scrapy.Item クラスを継承している。

page_urlpage_title といったフィールド名は任意。取得したいモノに合わせてフィールドを定義しよう。そこに scrapy.Field() を代入して、Scrapy 用のフィールドを宣言してあげている。

この書き方はお決まりなので、深く考えずこのように実装する。

ページ遷移を伴う場合や、取得するデータの構造が異なる場合は、複数の Item クラスを作成して良い。Spider のクラスでは、この items.py から、使いたい Item クラスを import して使う形になる。

スクレイピング処理を実装する

スクレイプした結果を詰める Item クラスを作成したので、いよいよスクレイピング処理を実装してみよう。今回は上で見せた Spider クラスのとおり、example.com にアクセスして、そのページのデータを抜き取ってみる。

  • ./my_scrapy/spiders/my_example.py
# -*- coding: utf-8 -*-

import scrapy

# Item を import しておく
from my_scrapy.items import MyExampleItem

class MyExampleSpider(scrapy.Spider):
  name = 'my_example'
  allowed_domains = ['example.com']
  
  # クロール対象の URL を指定する
  start_urls = ['https://example.com/']
  
  def parse(self, response):
    # 結果を詰める Item を作成する
    item = MyExampleItem()
    # フィールドごとにデータを抽出して詰める
    item['page_title']    = response.css('title::text').extract_first()
    item['page_url']      = response.url
    item['page_headline'] = response.css('h1::text').extract_first()
    # Item を出力する
    yield item

こんな感じ。

基本は、parse() 関数が自動的に渡してくれる response オブジェクトから、上手くデータを抽出していくだけ。

  • css() 関数で CSS セレクタを指定できる
    • ::text とすると、指定のセレクタで特定した要素のテキスト部分だけ抽出できる
    • 取得結果は配列になっているので、extract_first() で、セレクタが合致した最初の要素だけを取得している
  • 他に xpath() 関数もあり、XPATH 記法でも指定できる
  • 取得したデータを出力するには、yield Item とする
    • return Item としてしまうとそこで関数が終了してしまうので、データを出力する時は yield、ガード句的に関数を中断したい時に return、と覚える

example.com にアクセスすると分かるが、ページには h1 要素があるので、この要素を特定して、中身のテキストを拾っているのが item['page_headline'] の代入部分。

とにかくひたすらページの構造に合わせて、このように取得を繰り返していくだけなので、後は愚直に作業していく…。

作成した Spider を実行する

Spider が実装できたら、実際に実行してみよう。作成した Scrapy プロジェクトに移動して、次のようにコマンドを実行する。

$ scrapy crawl my_example

すると、コンソールにデバッグログなどが出力され、最終的に yield Item で出力させた Item 情報が確認できるかと思う。

分かりにくければ、次のように JSON 形式で結果だけ表示させてみよう。

# JSON 形式でコンソール出力する
$ scrapy crawl my_example -t json -o stdout: --nolog

結果をファイルに出力することもできる。-o オプションで任意のファイル名を指定すると、その拡張子に応じて JSON や CSV 等の形式で Item を出力してくれる。

# JSON ファイルに書き出す
$ scrapy crawl my_example -o my_result.json

コレが基本的な使い方となる。

ページ遷移を伴う・複数 Item を扱う場合

次は少し高度で、実用的な例を紹介する。ココまでだと、単一の URL にアクセスして、そのページ内の情報を引っこ抜くだけだった。だが実際は、エントリポイントとなるページから、リンク先のページに遷移して、そのページの情報を取得したかったりする。今回はそんな例を作成してみよう。

題材

題材は僕のサイト Neo's World にアクセスし、グローバルナビゲーションメニューの項目一つひとつのリンクを踏んで、遷移先のページにある h1 要素のテキストを引っこ抜いてみよう。

トップページにあるグローバルナビゲーションメニューは、次のように実装されていることとする。

<nav id="nav">
  <ul>
    <li><a href="/about/index.html">About</a></li>
    <li><a href="/music/index.html">Music</a></li>
    <li><a href="/games/index.html">Games</a></li>
    <li><a href="/gallery/index.html">Gallery</a></li>
    <li><a href="/etc/index.html">Etc.</a></li>
  </ul>
</nav>

遷移先ページは5ページあるワケだ。

Item を作成する

今回は「トップページの情報」と「遷移先ページの情報」とをまとめて、1つの Item に出力しようと思う。JSON でいうとこんな感じだ。

[
  {
    "index_page": {
      "title": "トップページのタイトル",
      "url"  : "トップページの URL"
    },
    "child_page": {
      "title"   : "遷移先ページのタイトル",
      "url"     : "遷移先ページの URL",
      "headline": "遷移先ページの見出しテキスト"
    }
  },
  // 同じ構成で、あと4つ、合計5行分のデータ
]

先程 HTML で見せたナビゲーションメニューは5項目あったので、最終的に JSON 配列で5つの要素が取れれば OK だ。index_page プロパティに含めるデータは、5つとも同じ情報が入ることになる。index_page.child_page.headline というように、子プロパティに遷移先ページのデータを持たせていくことも不可能ではないが、Item の取り回しが面倒臭くなるので今回は避ける。

こうしたデータに相当する Item を実装しておく。

  • ./my_scrapy/items.py
# -*- coding: utf-8 -*-

from scrapy import Field, Item

# トップページと遷移先ページの両データを持つ、出力用の Item
class NeosWorldItem(Item):
  index_page = Field()
  child_page = Field()

# トップページのデータを格納する Item
class IndexPageItem(Item):
  title = Field()
  url   = Field()

# 遷移先ページのデータを格納する Item
class ChildPageItem(Item):
  title    = Field()
  url      = Field()
  headline = Field()

このように、3つの Item クラスを作成する。IndexPageItemChildPageItem のインスタンスを、NeosWorldItem の各フィールドに持たせて、yield NeosWorldItem と出力する構成だ。

Spider を作成する

新たに Spider を作成する。

$ scrapy genspider neos_world neo.s21.xrea.com

生成された Spider ファイルを開いて以下のように実装する。

  • my_scrapy/spiders/neos_world.py
# -*- coding: utf-8 -*-

import scrapy
from my_scrapy.items import NeosWorldItem, IndexPageItem, ChildPageItem

class NeosWorldSpider(scrapy.Spider):
  name = 'neos_world'
  allowed_domains = ['neo.s21.xrea.com']
  # クロール対象の URL を指定する
  start_urls = ['http://neo.s21.xrea.com/']
  
  # トップページ (start_urls) のスクレイピング処理
  def parse(self, response):
    # トップページ用 Item を作成する
    index_page_item = IndexPageItem()
    index_page_item['title'] = response.css('title::text').extract_first()
    index_page_item['url']   = response.url
    
    # ナビゲーションメニューのリンク要素を取得する
    nav_link_elems = response.css('#nav > ul > li > a')
    
    # 万が一、ナビゲーションメニューが1つも存在しない場合は、異常終了とする
    if(not nav_link_elems):
      # 異常時は IndexPageItem だけを格納した結果出力用 Item を出力して終了する
      neos_world_item = NeosWorldItem()
      neos_world_item['index_page_item'] = index_page_item
      yield neos_world_item
      return
    
    # (リンク要素が正常に見つかれば) リンク要素を1つずつ処理する
    for nav_link_elem in nav_link_elems:
      # 1つのリンクの href 属性値を取得する
      nav_href = nav_link_elem.css('a::attr(href)').extract_first()
      # 遷移元 URL と href 属性値をかけあわせて、遷移先 URL のフルパスを構成する
      next_url = response.urljoin(nav_href)
      
      # 遷移先ページにアクセスし、parse_child() 関数に後続の処理を行わせる
      yield scrapy.Request(next_url, callback = self.parse_child, meta = {
        'index_page_item': index_page_item
      })
  
  # 遷移先ページのスクレイピング処理
  def parse_child(self, response):
    # meta で送信されたトップページ用 Item を引き出しておく
    index_page_item = response.meta['index_page_item']
    
    # 遷移先ページ用 Item を作成する
    child_page_item = ChildPageItem()
    child_page_item['title']    = response.css('title::text').extract_first()
    child_page_item['url']      = response.url
    child_page_item['headline'] = response.css('h1::text').extract_first()
    
    # 結果出力用 Item を作成する
    neos_world_item = NeosWorldItem()
    neos_world_item['index_page_item'] = index_page_item
    neos_world_item['child_page_item'] = child_page_item
    # Item を出力する
    yield neos_world_item

少し長くなったがこんな感じ。いくつかポイントがあるので押さえておこう。

  • response.css()xpath() の結果は配列だ。よって if 文で配列要素の存在チェックをしておき、「思っていたとおりのデータが存在しなかった場合」という処理ができる。コレが if(not nav_link_elems): というコードの部分。
    • 異常時は、その場で Item を構築し、yield Item したら return を呼んで、parse() 関数を終了している
    • ほとんどの場合はこの if 文に合致せず、後続の for 文に繋がる
  • response.urljoin() 関数を使うと、遷移元 URL と引数の相対パスをかけ合わせたフルパスが生成できる
  • scrapy.Request() 関数で、さらなるクローリングが実行できる
    • リクエスト処理は yield で扱う
    • callback の引数で、スクレイピング処理を行う関数を指定できる (callback = self.parse_child)
    • meta 引数に、コールバック関数に渡したいデータを指定できる。コレを利用して、IndexPageItem を引き回している
  • 自作した parse_child() 関数も、デフォルトで実装されている parse() 関数と同様の構成で実装すれば良い
  • meta 引数で引き回したプロパティは response.meta['index_page_item'] のように取得できる

parse() 以降で呼ぶ yield が分かりづらいかもしれないが、

  • yield Item を呼ぶと、その Item を1行の結果データとして出力する
  • yield scrapy.Request(next_url, callback, meta) を呼ぶと、引数で指定した callback 関数に処理が移行する (meta データも渡せる)

という動きをするので、この2つの動きをベースに、最終的にどんな Item を出力したいか、というところをイメージしながら、コールバック関数を作っていくことになる。

入れ子の Item を出力しようとしたら…

少し前に index_page.child_page.headline というような入れ子関係を作るのが大変、といったが、コレをやろうとすると、こんな実装になるだろう。

  • ./my_scrapy/items.py
from scrapy import Field, Item

# トップページと、遷移先ページのデータを格納する Item
class IndexPageItem(Item):
  title = Field()
  url   = Field()
  child_pages = Field()  # 配列で ChildPageItem を格納していく

# 遷移先ページのデータを格納する Item
class ChildPageItem(Item):
  title    = Field()
  url      = Field()
  headline = Field()
  • my_scrapy/spiders/neos_world.py
# -*- coding: utf-8 -*-

import scrapy
from my_scrapy.items import IndexPageItem, ChildPageItem

class NeosWorldSpider(scrapy.Spider):
  name = 'neos_world'
  allowed_domains = ['neo.s21.xrea.com']
  start_urls = ['http://neo.s21.xrea.com/']
  
  def parse(self, response):
    # トップページ用 Item を作成する (同様の処理なので省略)
    index_page_item = IndexPageItem()
    # 遷移先ページの ChildPageItem を持たせるプロパティを初期化しておく
    index_page_item['child_pages'] = []
    
    # 遷移先 URL の配列を作る
    child_urls = []
    for nav_link_elem in response.css('#nav > ul > li > a'):
      child_url = response.urljoin(nav_link_elem.css('a::attr(href)').extract_first())
      child_urls.append(child_url)
    
    # 遷移先 URL を配列から1つ取り出し (配列からは取り出した要素が消える)、スクレイピングを行う
    next_url = child_urls.pop()
    yield scrapy.Request(next_url, callback = self.parse_child, meta = {
      'index_page_item': index_page_item,
      'child_urls'     : child_urls
    })
  
  # 遷移先ページのスクレイピング処理
  def parse_child(self, response):
    # meta で送信されたトップページ用 Item を引き出しておく
    index_page_item = response.meta['index_page_item']
    # 遷移先ページの URL 配列も引き出しておく
    child_urls = response.meta['child_urls']
    
    # 遷移先ページ用 Item を作成する (同様の処理なので省略)
    child_page_item = ChildPageItem()
    # スクレイピングしたデータを IndexPageItem に追加する
    index_page_item['child_pages'].append(child_page_item)
    
    # 遷移先ページの URL 配列が空になっていたら、IndexPageItem を出力して終了する
    if not child_urls:
      yield index_page_item
      return
    
    # 遷移先 URL を配列から1つ取り出し (配列からは取り出した要素が消える)、スクレイピングを行う
    next_url = child_urls.pop()
    yield scrapy.Request(next_url, callback = self.parse_child, meta = {
      'index_page_item': index_page_item,
      'child_urls'     : child_urls
    })

一気に関数の実行順序が複雑になったのが分かるだろうか。

  • 最終的に出力するのは yield index_page_item と記している1箇所のみ
  • この index_page_itemchild_pages プロパティに、複数の ChildPageItem が配列で格納されている
  • このような配列での格納を実現するために、遷移先 URL の配列を meta で引き回し、pop() を使って1つずつ取り出す、という操作を行っている
  • parse() 関数で呼ぶ yield Request() と、parse_child() 関数で呼ぶ yield Request() とは同じ実装になっていて、parse_child() 関数は自分自身をコールバック関数として呼ぶ作りになっている

なかなか理解するのが難しいと思うので、やってみたい方は print デバッグしながら動きを追ってみると良いだろう。

child_page よりさらに先のページにも遷移して、3階層の入れ子を作りたい、と考え始めるともっとしんどくなるので、Item の入れ子はしない方が良いだろう。

以上

今回はココまで。pipelines.py を実装すれば、収集した Item を DB 投入するところまで一気に実行できたりとか、Scrapy には他にも色々な機能がたくさんあるので、ぜひ活用していってもらいたい。

Pythonクローリング&スクレイピング[増補改訂版] -データ収集・解析のための実践開発ガイド

Pythonクローリング&スクレイピング[増補改訂版] -データ収集・解析のための実践開発ガイド

  • 作者:加藤 耕太
  • 出版社/メーカー: 技術評論社
  • 発売日: 2019/08/10
  • メディア: 単行本(ソフトカバー)