7
9

More than 3 years have passed since last update.

Scrapyでスクレイピング(その3 Scrapy Shell編)

Last updated at Posted at 2021-02-10

前回の記事では、猫の写真画像データとその撮影情報を収集するためにどのサイトをスクレイピングするのがよいかの事前調査を行いました。
そして、PHOTO HITOというサイトをスクレイピングすることに決めました。

今回の記事では以下のことを行います。

  • Scrapy Shellの使い方を簡単に説明します。
  • Scrapy Shellを使用して目的情報の抽出方法を検討する際の流れを、具体的なコードを踏まえて説明します。

まずは結論から

  • プログラムを実装する前にScrapy Shellで試行錯誤をすることで、デバッグにかかる時間を減らすことができます。
  • 情報抽出のためのCSSセレクター(XPath)は普遍性・汎用性の高いものにしましょう。
  • Scrapy Shellの出力結果をそのまま参照して動作確認を行うようにしましょう。
  • Scrapy Shellの入力と出力結果をメモとして残しておくとプログラム実装時に役立ちます。
  • <table>形式でまとまっている情報をスクレイピングする際は、<th>の情報をキー、<td>の情報を値とするディクショナリを作成するのをおすすめします。

Scrapy Shellとは

Pythonコードを逐次実行できる対話型シェルで、Scrapyの一部機能を簡単に使える仕組みが組み込まれています。
スクレイピングプログラムの事前調査やデバッグをするのに非常に便利です。

事前準備

Scrapyをインストールし、Scrapyプロジェクトを作成しておきましょう。
手順はこちらの記事です。
今回の例ではプロジェクト名をscrapy_catsにしています。

またScrapy Shellで自動補完(オートコンプリート)を使用できると楽なので、IPythonもインストールしておきます。1

仮想環境にIPythonをインストール
$ cd YOUR_PATH/scrapy_cats
$ . venv/bin/activate
$ pip install ipython

Scrapy Shellの起動と動作確認

以下のコマンドライン引数を渡してScrapy Shellを起動します。

  • ログレベル変更設定 (※自動補完時のデバッグログが邪魔なので)1
  • 最初にクロールするURL

この記事ではPHOTO HITOというサイトの猫の写真集ページを起点としたスクレイピングを行います。

$ scrapy shell -s LOG_LEVEL='INFO' https://photohito.com/dictionary/%E7%8C%AB/

コマンドを実行すると下記のように起動ログが表示され、IPythonコンソールの入力待機状態になります。

2021-02-08 22:18:44 [scrapy.utils.log] INFO: Scrapy 2.4.1 started (bot: scrapy_cats)
...
2021-02-08 22:18:44 [scrapy.extensions.telnet] INFO: Telnet console listening on 127.0.0.1:6023
2021-02-08 22:18:44 [scrapy.core.engine] INFO: Spider opened
[s] Available Scrapy objects:
[s]   scrapy     scrapy module (contains scrapy.Request, scrapy.Selector, etc)
[s]   crawler    <scrapy.crawler.Crawler object at 0x7fe98c744190>
[s]   item       {}
[s]   request    <GET https://photohito.com/dictionary/%E7%8C%AB/>
[s]   response   <200 https://photohito.com/dictionary/%E7%8C%AB/>
[s]   settings   <scrapy.settings.Settings object at 0x7fe98c740df0>
[s]   spider     <DefaultSpider 'default' at 0x7fe98bcea8e0>
[s] Useful shortcuts:
[s]   fetch(url[, redirect=True]) Fetch URL and update local objects (by default, redirects are followed)
[s]   fetch(req)                  Fetch a scrapy.Request and update local objects 
[s]   shelp()           Shell help (print this help)
[s]   view(response)    View response in a browser
In [1]: 

表示されたヘルプにも書かれているように、Scrapy Shellを実行するとよく使用するScrapyオブジェクトの変数がすでに定義されています。
軽く動作確認してみましょう。

実際のWebページを表示してみる

以下のように入力して実行すると、デフォルトのWebブラウザーが起動してクロール済みのページが表示されます。

クロール済みのWebページの表示
In [1]: view(response)
Out[1]: True

ちなみにIPythonコンソールではTabキーを押すことでメソッド名や変数名などの自動補完ができますので、上記の入力はvi → [Tab] → (res → [Tab] → [Tab] → ) → [Enter]というふうに一部省略できます。

HTMLの内容をすべて表示する

responseオブジェクトにはHTTPレスポンスに関する様々なデータや処理が含まれています。
例えばresponse.textにはページ全体のHTMLテキストが保存されています。

ページ全体のHTMLテキストの表示
In [2]: response.text
Out[2]: '<!DOCTYPE html>\n<html>\n<head>\n    <title>猫の写真(画像)・写真集 - 写真共有サイト:PHOTOHITO</title>\n
...
if(typeof _satellite !== "undefined"){\n        _satellite.pageBottom();\n    }\n</script>\n<!-- DTM Tag -->\n\n\n</body>\n</html>

Scrapy Shellでよく使用する機能

スクレイピングの調査の目的でScrapy Shellを使用する場合、やることは限られています。

  • HTMLツリーの中から特定のノードを絞り込み、テキストを抽出する。
  • テキストに不要な情報が含まれていたら、それを除去する。
  • 別ページをクロールする。

ノードの絞り込み・テキストの抽出

ノードの絞り込みにはXPathとCSSセレクターが利用できます。
今回の例ではより簡潔に記述できるCSSセレクターを使用します。

ページタイトルを取得する例
# <title>ノードのオブジェクト
In [3]: response.css('title')
Out[3]: [<Selector xpath='descendant-or-self::title' data='<title>猫の写真(画像)・写真集 - 写真共有サイト:PHOTOHI...'>]

# <title>のテキスト(HTMLタグ含む)
In [4]: response.css('title').get()
Out[4]: '<title>猫の写真(画像)・写真集 - 写真共有サイト:PHOTOHITO</title>'

# <title>の中のテキスト
In [5]: response.css('title::text').get()
Out[5]: '猫の写真(画像)・写真集 - 写真共有サイト:PHOTOHITO'

# <title>の中のテキストに含まれる言葉のリスト
In [6]: response.css('title::text').re(r'\w+')
Out[6]: ['猫の写真', '画像', '写真集', '写真共有サイト', 'PHOTOHITO']
機能 説明
response.css() CSSセレクターで抽出したノードを含むSelectorListオブジェクトを取得します。
.get() 一番目のノードのテキストを取得します。
::text ノードの子孫のテキストノードを取得します。Scrapy独自のCSS拡張機能のひとつです。
.re() ノードの中で正規表現にマッチする部分の文字列のリストを取得します。

別ページのクロール

fetchメソッドで任意のURLをクロールできます。

ページ内の適当なリンク先ページをクロールする例
# ページ内の<a href="パス">のすべてのパス
In [7]: response.css('a::attr(href)').getall()
Out[7]: 
['/',
 '#',
 '/trend/',  # 3番目のリンクパス(相対パス)
 ...
 'https://twitter.com/photohito']

# ページ内で3番目にリンクされているページの取得 
In [8]: fetch(response.urljoin(_[2]))

# 取得済みページのURL確認
In [9]: response.url
Out[9]: 'https://photohito.com/trend/'

# 実際に表示して確認
In [10]: view(response)
Out[10]: True
機能 説明
::attr() ノードの属性の値を取得します。Scrapy独自のCSS拡張機能のひとつです。
.getall() すべてのノードのテキストのリストを取得します。
fetch() 新しいページをクロールしてrequestやresponseを上書きします。
response.urljoin() 相対パスを絶対パスに変換します。
_[2] _ はIPythonコンソールの直前の出力結果オブジェクトへのショートカットです。この例ではリストだったのでその3番目の要素を表しています。

収集したい情報の抽出方法検討

収集したい情報の確認

その2 事前調査編で検討したように、PHOTO HITOというサイトの2種類のページから以下の情報を収集します。

Scrapy Shellでの検討

では、実際にスクレイピングで収集したい情報の抽出方法を検討していきましょう。

Scrapy Shellの入力と出力結果をメモとして残しておくことを強くおすすめします。
後のプログラム実装時に参照したくなることがきっとあるはずです。

写真一覧ページの検討

写真一覧ページ(https://photohito.com/dictionary/猫/)をChromeで開き、開発者ツールを表示しておきましょう。
Screenshot from 2021-02-09 22-57-20.png

前回の事前調査で以下のことがわかっています。


写真一覧ページ内の<div class=imgholder>要素をすべて抽出し、その子要素の<a href="相対パス">のリンク先にアクセスすれば各写真の詳細情報にたどり着ける


各写真の詳細ページのURLの抽出方法検討
$ scrapy shell -s LOG_LEVEL='INFO' https://photohito.com/dictionary/%E7%8C%AB/
 ...

 # class属性がimgholderであるノードの一覧(長さ30のSelectorList)
In [1]: response.css('.imgholder')
Out[1]:[<Selector xpath="descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' imgholder ')]" data='<div class="imgholder">\n             ...'>,
 ... 

# class属性がimgholderであるノードの子孫のaタグhref属性のテキスト(最初に見つかったもの)
In [2]: response.css('.imgholder a::attr(href)')[0].get()
Out[2]: '/photo/10128468/'

# すべての写真詳細ページへのリンクの絶対パスを一覧表示
In [3]: [response.urljoin(href) for href in response.css('.imgholder a::attr(href)').getall()]
Out[3]:
['https://photohito.com/photo/10128468/',
...
 'https://photohito.com/photo/9045111/']

# 1番目のリンク先ページをクロール
In [4]: fetch(_[0])

In [5]: response.url
Out[5]: 'https://photohito.com/photo/10128468/'

# 実際に表示して確認
In [6]: view(response)
Out[6]: True

写真一覧ページから、各写真の詳細ページへのリンクを取得する方法がわかりました。

写真詳細ページ(写真画像ダウンロードURL)の検討

写真画像ダウンロードURLについては以下のことがわかっています。


写真の詳細ページの<div id="photo_view">要素内にある<img src="画像URL">の画像URLにアクセスして写真画像をダウンロードする


Screenshot from 2021-02-09 23-58-23.png

Chromeの開発者ツールで該当部分を見ると、imgタグが2つ並んでいて、しかも目的としている2番目の方にはclass属性やid属性がついていないので、抽出方法に少し悩みます。

写真画像ダウンロードURLの抽出方法検討
# 前のコンソールからの続き

In [7]: response.url
Out[7]: 'https://photohito.com/photo/10128468/'

# id属性がphoto_viewであるノードの子孫のimgタグsrc属性のすべてのテキスト
In [8]: response.css('#photo_view img::attr(src)').getall()
Out[8]: 
['/images/spacer.gif',
 'https://photohito.k-img.com/.../a74398e62afc56d59d644a144b379ba9_l.jpg']

# <案1> 2番目のimgタグを抽出
In [9]: response.css('#photo_view img::attr(src)')[1].get()
Out[9]: 'https://photohito.k-img.com/.../a74398e62afc56d59d644a144b379ba9_l.jpg'

# <案2> 'https://'で始まる正規表現にマッチする最初のテキストを抽出
In [10]: response.css('#photo_view img::attr(src)').re_first(r'https://.*')
Out[10]: 'https://photohito.k-img.com/.../a74398e62afc56d59d644a144b379ba9_l.jpg'

# 画像のダウンロード確認
# viewメソッドは画像非対応なのでwebbrowser.openを使う
In [11]: import webbrowser

In [12]: webbrowser.open(_)
Out[12]: True

2つの抽出案が出ましたがどちらがいいでしょうか?

私だったら以下の理由で<案2>の正規表現の方を採用します。

  • <img class="spacer" src="/images/spacer.gif">が他の写真のときにも必ずあるのかどうかわからない
  • 写真画像URLがhttps://で始まるのはすべての写真で共通していると考えられる
  • 該当箇所にhttps://で始まるリンクが複数並ぶ可能性も低そう
  • そもそもノードの順番に依存するコードはWebページの仕様変更の影響を受けやすい

このように抽出方法の判断に悩んだときには、以下のような基準で判断していけば、汎用性・継続性の高いよい仕組みづくりができると思います。

  • 収集データごとに変化する可能性がある情報を避ける
  • 今後Webページの仕様変更があったとしても変わらなそうな情報を使う

写真詳細ページ(テーブルデータ)の検討

撮影情報とEXIFデータは以下のように取得できます。


<section id="photo_data_area">および<section id="exif_area">の子要素のtable内のtdのテキストから、撮影情報とEXIFデータを取得する。


Screenshot from 2021-02-10 01-12-59.png

テーブルにまとまっている情報を収集するときには、ひとまず<th>の情報をキーとし、<td>の情報を値とするディクショナリを作った方が処理しやすいです。
今回はディクショナリの作成を目標にします。

カメラやレンズの<td>内では、ブランド名と商品名が複雑な構成で記述されていますが、とりあえず「ブランド名 商品名」というテキストを抽出したいと思います。

撮影情報の抽出方法検討
# 前のコンソールからの続き

### キーのリストの抽出

# コロンが邪魔、、、
In [13]: response.css('#photo_data_area th::text').getall()
Out[13]: ['カメラ:', 'レンズ:', 'レンズタイプ:', '対応マウント:']

# コロンを除去
In [14]: response.css('#photo_data_area th::text').re(r'\w+')
Out[14]: ['カメラ', 'レンズ', 'レンズタイプ', '対応マウント']

### 値のリストの抽出

# td内のテキストを単純に取得しようとしてもうまくいかない
In [15]: response.css('#photo_data_area td::text').getall()
Out[15]: 
['\n                            ',
 '\n                        ',
 '\n                                ',
 '\n                            ',
 'マクロ',
 'キヤノンEFマウント系']

# うまくいかない最初のtdに絞って検討
# td内のすべてのaのテキストだけを抽出すればいい感じにはなった
In [16]: response.css('#photo_data_area td')[0].css('a::text').getall()
Out[16]: ['CANON', 'Canon EOS 6D']

# ただし3番目のtdの子孫にはaがないのでうまくいかず汎用性がない
In [17]: response.css('#photo_data_area td')[2].css('a::text').getall()
Out[17]: []

# aに限定しなければ3番目のtdでもうまくいく
In [18]: response.css('#photo_data_area td')[2].css('::text').getall()
Out[18]: ['マクロ']

# そうすると今度は1番目のtdに余計なものが含まれてしまう
In [19]: response.css('#photo_data_area td')[0].css('::text').getall()
Out[19]: 
['CANON',
 '\n                            ',
 'Canon EOS 6D',
 '\n                        ']

# CSSセレクターのみでのこれ以上の対応は難しいので別の方法を組み合わせる
# 正規表現で「先頭が\nではない」文字列だけマッチさせればうまくいかないか?

# いい感じになった
In [21]: response.css('#photo_data_area td')[0].css('::text').re(r'^[^\n].*')
Out[21]: ['CANON', 'Canon EOS 6D']

# 他のtdですべてうまくいくのを確認
In [22]: response.css('#photo_data_area td')[1].css('::text').re(r'^[^\n].*')
Out[22]: ['TAMRON', 'SP AF 90mm F/2.8 MACRO1:1 (キヤノン用)']

In [23]: response.css('#photo_data_area td')[2].css('::text').re(r'^[^\n].*')
Out[23]: ['マクロ']

In [24]: response.css('#photo_data_area td')[3].css('::text').re(r'^[^\n].*')
Out[24]: ['キヤノンEFマウント系']

# すべてのtdにに対して.css('::text').re(r'^[^\n].*')を適用し、複数文字列は空白で結合
In [25]: list(map(lambda x: ' '.join(x.css('::text').re(r'^[^\n].*')), response.css('#photo_data_area td')))
Out[25]: 
['CANON Canon EOS 6D',
 'TAMRON SP AF 90mm F/2.8 MACRO1:1 (キヤノン用)',
 'マクロ',
 'キヤノンEFマウント系']

### まとめ
# 以下の手順で目標とするディクショナリが完成
In [26]: keys = response.css('#photo_data_area th::text').re(r'\w+')

In [26]: values = list(map(lambda x: ' '.join(x.css('::text').re(r'^[^\n].*')), response.css('#photo_data_area td')))

In [27]: dict(zip(keys, values))
Out[27]: 
{'カメラ': 'CANON Canon EOS 6D',
 'レンズ': 'TAMRON SP AF 90mm F/2.8 MACRO1:1 (キヤノン用)',
 'レンズタイプ': 'マクロ',
 '対応マウント': 'キヤノンEFマウント系'}

まあまあ苦労しましたが、なんとか目標達成できました。

EXIFデータのテーブルも似たような構造なので、撮影情報のロジックがそのまま使えるかもしれません。
試してみましょう。

EXIFデータの抽出方法検討
# 前のコンソールからの続き

# キーの方は問題なさそう(id属性値を変えただけ)
In [28]: response.css('#exif_area th::text').re(r'\w+')
Out[28]: 
['撮影日時',
 'ISO感度',
 '露出時間',
 '露光補正値',
 '絞り',
 '焦点距離',
 'ホワイトバランス',
 'フラッシュ',
 'イメージサイズ',
 'ソフトウェア']

# 値の方も大丈夫そう(id属性値を変えただけ)
In [29]: list(map( lambda x: ' '.join(x.css('::text').re(r'^[^\n].*')), response.css('#exif_area td')))
Out[29]: 
['2021:01:17 09:53:25',
 '100',
 '0.003 (1/400) 秒',
 '0 EV',
 'f/2.8',
 '90 mm',
 'Auto',
 'ストロボ発光せず, 強制非発光モード',
 '4608 x 3072',
 'Digital Photo Professional']

プログラム実装時には、id属性値を引数としてテーブル情報のディクショナリを返す関数を定義するとよさそうですね。

まとめ

今回の記事では、Scrapy Shellを使ってHTMLツリー内から目的とする情報を抽出するためのCSSセレクターや正規表現の検討を行いました。

  • プログラムを実装する前にScrapy Shellで試行錯誤をすることで、デバッグにかかる時間を減らすことができます。
  • 情報抽出のためのCSSセレクター(XPath)は普遍性・汎用性の高いものにしましょう。
  • Scrapy Shellの出力結果をそのまま参照して動作確認を行うようにしましょう。
  • Scrapy Shellの入力と出力結果をメモとして残しておくとプログラム実装時に役立ちます。
  • <table>形式でまとまっている情報をスクレイピングする際は、<th>の情報をキー、<td>の情報を値とするディクショナリを作成するのをおすすめします。

長文になってしまいましたが、最後までご精読ありがとうございました。

書籍紹介

この書籍が非常にわかりやすく、クローリング・スクレイピングを行うのに必要な知識がひととおり学べます。
Pythonクローリング&スクレイピング[増補改訂版]
Pythonクローリング&スクレイピング[増補改訂版]

免責事項

  • コンテンツや情報において、必ずしも正確性を保証するものではありません。また合法性や安全性なども保証しません。
  • 掲載された内容によって生じた損害等の一切の責任を負いかねますので、ご了承ください。
7
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
9