Scrapyを使ってスクレイピング（画像ダウンロードもあるよ！）

最近仕事で既存のサイトから情報を取って来たいという欲求に駆られたので、pythonのscrapyを勉強したのでアウトプット。
当方、pythonの開発は初心者なので、もっといいい書き方があれば、ご指摘いただけますと幸いです！

## 前提
- 普段使う言語はJavascript, PHP
- pythonは機械学習がしたくて、本を読んで写経をしたことがある程度
- 今回スクレイピングするのはBooks to Scrapeの[Fantasyカテゴリー](https://books.toscrape.com/catalogue/category/books/fantasy_19/index.html)
- 作業ディレクトリは`books_to_scrape`
- プロジェクト名は`books_to_scrape`

## プロジェクトの作成

作業ディレクトリに移動してscrapyのプロジェクトを開始します。

```
# 作業ディレクトリに移動
$ cd books_to_scrape

# scrapyのプロジェクトを開始
$ scrapy startproject books_to_scrape
New Scrapy project 'books_to_scrape', using template directory 
     ：
     ：
You can start your first spider with:
    cd books_to_scrape
    scrapy genspider example example.com
```

## spider作成
プロジェクト作成で出てきたコマンドを使ってspiderを作成します。

```
$ cd books_to_scrape 

$ scrapy genspider books_to_scrape books.toscrape.com
Created spider 'books_to_scrape' using template 'basic' in module:
  books_to_scrape.spiders.books_to_scrape
```

## setting.pyを変更
スクレイピングに必要な設定を追加していきます。

``` setting.py
DOWNLOAD_DELAY = 3 # アンコメント

# キャッシュ設定。
# 勉強中は何度もリクエストを投げるので、相手のサイトに迷惑をかけないようにキャッシュを有効にする
HTTPCACHE_ENABLED = True # アンコメント
HTTPCACHE_EXPIRATION_SECS = 0 # アンコメント
HTTPCACHE_DIR = 'httpcache' # アンコメント
HTTPCACHE_IGNORE_HTTP_CODES = [] # アンコメント
HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.FilesystemCacheStorage' # アンコメント
```

## items.pyに取得する対象を記述

items.pyに取得する対象を`{対象} = scrapy.Field()`で追加
まずはタイトル（title）を取得します

``` items.py
class BooksToscrapeItem(scrapy.Item):
    title = scrapy.Field() # 追加
```

## books_to_scrape.pyを編集

[Fantasyカテゴリー](https://books.toscrape.com/catalogue/category/books/fantasy_19/index.html)のページ内に存在する本のタイトルを取得します。


``` books_to_scrape.py
import scrapy
from books_to_scrape.items import BooksToScrapeItem # 追加


class BooksToScrapeSpider(scrapy.Spider):
    name = 'books_to_scrape'
    allowed_domains = ['books.toscrape.com']
    start_urls = ['https://books.toscrape.com/catalogue/category/books/fantasy_19/index.html'] # スクレイピング対象URLを指定

    def parse(self, response):
        for book in response.css('section .row li'): # 取得したい要素が含まれる親をCSSのセレクタで指定
            item = BooksToScrapeItem()
            item['title'] = book.css('h3 a::text').get()# 取得したい要素をCSSのセレクタで指定

            yield item
```

記述してコマンドラインでscrapyを実行して、本のタイトルが取得できていれば成功です。

```
$ scrapy crawl books_to_scrape
   ：
   ：
2023-03-05 07:50:00 [scrapy.core.scraper] DEBUG: Scraped from <200 https://books.toscrape.com/catalogue/category/books/fantasy_19/index.html>
{'title': 'Unicorn Tracks'} ←取得できてる！
2023-03-05 07:50:00 [scrapy.core.scraper] DEBUG: Scraped from <200 https://books.toscrape.com/catalogue/category/books/fantasy_19/index.html>
{'title': 'Saga, Volume 6 (Saga ...'}
2023-03-05 07:50:00 [scrapy.core.scraper] DEBUG: Scraped from <200 https://books.toscrape.com/catalogue/category/books/fantasy_19/index.html>
   :
   :

```

## 取得結果をCSVに保存
取得したデータをCSVに保存します。
CSV保存はscrapy実行時に`-o {保存先/ファイル名.csv}`でできます。

```
# scraoy実行前
$ ls -al
ls -l
total 8
drwxr-xr-x  9 murae  wheel  288  3  4 18:32 books_to_scrape
-rw-rw-r--  1 murae  wheel  273  3  4 18:31 scrapy.cfg

# scrapy実行
$ scrapy crawl books_to_scrape -o ./out/result.csv

# 確認
$ ls -l
total 8
drwxr-xr-x  9 murae  wheel  288  3  4 18:32 books_to_scrape
drwxr-xr-x  3 murae  wheel   96  3  5 07:58 out ←追加されてる
-rw-rw-r--  1 murae  wheel  273  3  4 18:31 scrapy.cfg

$ ls -l out
total 8
-rw-r--r--  1 murae  wheel  573  3  5 07:58 result.csv

$ cat out/result.csv
title
Unicorn Tracks
"Saga, Volume 6 (Saga ..."
Princess Between Worlds (Wide-Awake ...
Masks and Shadows
Crown of Midnight (Throne ...
    ：
    ：
```

ただ、毎回`-o {保存先/ファイル名.csv}`をつけて実行するのはしんどいのでデフォルトでCSVを出力するように変更します。

```  setting.py

{末尾に追加}
# CSVファイル保存に関する設定
FEED_EXPORT_ENCODING='utf-8'
FEED_FORMAT='csv'
FEED_URI = './out/result.csv'
```

この状態でscrapyを実行してみます
一度outディレクトリを削除するとわかりやすいです。

```
# 一度outディレクトリを削除
$ rm -r out

# 確認
$ ls -l
total 8
drwxr-xr-x  9 murae  wheel  288  3  4 18:32 books_to_scrape
-rw-rw-r--  1 murae  wheel  273  3  4 18:31 scrapy.cfg

# scrapy実行
$ scrapy crawl books_to_scrape

# 確認
$ ls -l
total 8
drwxr-xr-x  9 murae  wheel   288B  3  4 18:32 books_to_scrape
drwxr-xr-x  3 murae  wheel    96B  3  5 08:08 out ←できてる
-rw-rw-r--  1 murae  wheel   273B  3  4 18:31 scrapy.cfg

$ cat out/result.csv
title
Unicorn Tracks
"Saga, Volume 6 (Saga ..."
Princess Between Worlds (Wide-Awake ...
Masks and Shadows
Crown of Midnight (Throne ...
    ：
    ：
```

## 複数ページをクロールしてスクレイピング
ここまでで1ページをスクレイピングすることができましたが、実際には複数ページにまたがってスクレイピングしたいのがほとんどだと思います。
そこで、複数ページを再起的にスクレイピングする方法を説明します。

``` books_to_scrape.py
    :
    :
    def parse(self, response):
        for book in response.css('section .row li'):
            item = BooksToScrapeItem()
            item['title'] = book.css('h3 a::text').get()

            yield item

        next_page = response.css('.next a::attr(href)') # 次のページへのリンクが含まれる要素をCSSで指定
        next_page = response.urljoin(next_page.get()) # リンクを絶対パスに変更
        if next_page:
            yield response.follow(url=next_page, callback=self.parse) # next_pageが存在していれば、自身を再起的に呼び出す
```

ここまでできたらscrapyを実行してみます。
[最後のページ](https://books.toscrape.com/catalogue/category/books/fantasy_19/page-3.html)にある最後の本のタイトルは「Myriad (Prentor #1)」なので、出力したCSVの最後が「Myriad (Prentor #1)」であれば成功です。

```
$ scrapy crawl books_to_scrape

$ cat out/result.csv
title
Unicorn Tracks
"Saga, Volume 6 (Saga ..."
Princess Between Worlds (Wide-Awake ...
Masks and Shadows
Crown of Midnight (Throne ...
    ：
    ：
Origins (Alphas 0.5)
One Second (Seven #7)
Myriad (Prentor #1) # 成功！
```

## 本の詳細を取得する
出力されたCSVを見ると、タイトルが省略されているものがあります。
取得したいものは大抵の場合、詳細画面に記載されていることがほとんどなので、詳細画面からデータを抽出します。

### items.pyに抽出する対象を追加
新たに 本の説明（Product Description）とURLを取得します。

``` items.py
class BooksToScrapeItem(scrapy.Item):
    title = scrapy.Field()
    product_description = scrapy.Field() # 追加
    url = scrapy.Field() # 追加
```

### spiderを変更
先ほどは一覧に存在する要素を抽出していましたが、詳細画面のデータを抽出できるように変更します。

``` books_to_scrape.py
    def parse(self, response):
        for book in response.css('section .row li'):
            item = BooksToScrapeItem()
            link = response.urljoin(book.css('h3 > a::attr(href)').get()) # 詳細画面のURLを取得
            yield scrapy.Request(link,callback=self.parse_detail, meta={'item':item} # 詳細画面を呼び出し parse_detail　を呼び出す
            )

        next_page = response.css('.next a::attr(href)')
        next_page = response.urljoin(next_page.get())
        if next_page:
            yield response.follow(url=next_page, callback=self.parse)

    def parse_detail(self, response):
        item = response.meta['item'] # parseからの情報を受け取る
        item['title'] = response.css('h1::text').get()
        item['product_description'] = response.css('#product_description + p::text').get() # 追加
        item['url'] = response.url # 追加

        yield item
```

scrapyを実行してみます。

```
$ scrapy crawl books_to_scrape

$ cat out/result.csv
product_description,title,url
"After a savage attack drives her from her home, sixteen-year-old Mnemba finds a place in her cousin Tumelo’s successful safari 
{中略}
 ...more",King's Folly (The Kinsman Chronicles #1),https://books.toscrape.com/catalogue/kings-folly-the-kinsman-chronicles-1_473/index.html
```

省略されていないタイトルと、本の説明、URLが取得できました。
ただ、よく見るとCSVのカラムの順番が、本の詳細→タイトル→URLとなっています。
順番をタイトル→URL→本の詳細と並べ替えてみましょう。
カラムの順番はsetting.pyで変更できます。

``` setting.py
    :
    :
# CSVファイル保存に関する設定
FEED_EXPORT_ENCODING='utf-8'
FEED_FORMAT='csv'
FEED_EXPORT_FIELDS = ["title","url","product_description"] # 追加
FEED_URI = './out/result.csv'
```

再度scrapyを実行してみます。
setting.pyに指定した順で出力されていれば成功です。

```
$ scrapy crawl books_to_scrape

$ cat out/result.csv
title,url,product_description
King's Folly (The Kinsman Chronicles #1),https://books.toscrape.com/catalogue/kings-folly-the-kinsman-chronicles-1_473/index.html,"After a savage attack drives her from her home, sixteen-year-old Mnemba finds a place in her cousin Tumelo’s successful safari 
{中略}
 ...more"
```

## 画像を抽出する

画像も抽出してみましょう。
scrapyでも画像ダウンロードはできますが、画像ファイル名がハッシュ化されてしまいます。
私にはちょっと使い勝手が悪かったので、ファイル名、ディレクトリを保持してダウンロードできるようにします。

``` books_to_scrape.py
import scrapy, os, urllib # os、urlliを追加
from books_to_scrape.items import BooksToScrapeItem


class BooksToScrapeSpider(scrapy.Spider):
    name = 'books_to_scrape'
    allowed_domains = ['books.toscrape.com']
    start_urls = ['https://books.toscrape.com/catalogue/category/books/fantasy_19/index.html']
    dest_dir = './out/images' # ダウンロード先ディレクトリ
    base_url = 'https://books.toscrape.com/' # 後ほど画像のパスを調整するための変数を指定

    def parse(self, response):
        for book in response.css('section .row li'):
            item = BooksToScrapeItem()
            link = response.urljoin(book.css('h3 > a::attr(href)').get())
            yield scrapy.Request(link,callback=self.parse_detail, meta={'item':item}
            )

        next_page = response.css('.next a::attr(href)')
        next_page = response.urljoin(next_page.get())
        if next_page:
            yield response.follow(url=next_page, callback=self.parse)

    def parse_detail(self, response):
        item = response.meta['item']
        item['title'] = response.css('h1::text').get()
        item['product_description'] = response.css('#product_description + p::text').get()
        item['url'] = response.url

        # 画像ダウンロード
        image_url = response.css('.thumbnail img::attr(src)').get().strip() # imgのsrcを取得
        image_full_url = response.urljoin(image_url) # srcを絶対パスに変更
        file_name = image_url[image_url.rfind('/') + 1:] # 画像ファイル名を取得
        image_path = image_full_url.replace(self.base_url, '') # 画像が保存されているパスを取得{画像の絶対パス} - {base_url}
        dest_dir = self.dest_dir + '/' + image_path # 画像を保存するパス
        if not os.path.exists(dest_dir):
            os.makedirs(dest_dir) # もし画像保存ディレクトリがローカルに存在しなければ作成
        urllib.request.urlretrieve(image_full_url, os.path.join(dest_dir, file_name)) # 画像を元サイトのディレクトリ構成を維持して./out/imagesディレクトリに保存

        yield item
```

ここまでできたらscrapyを実行してみましょう。
画像がディレクトリを保持してダウンロードできていれば成功です。


## おわりに
テキストのスクレイピングは結構情報がたくさんあったのですが、画像ダウンロード、特にファイル名、ディレクトリ構成を保持してダウンロードというのはあまり見つけられませんでした。
なんとか実装できたので、もしかしたら需要あるかも？と思って投稿しました。
まだまだ改善の余地はたくさんあると思うので、ご指摘いただければ幸いです。

画像ダウンロードに`urlretrieve`を使っています。[urlopenを使うべきだという意見](https://qiita.com/Kanahiro/items/dd585599f36a77540439)もありますが、残念ながら、そこまで作れませんでした。。次はここを作ってみようかな？

リポジトリはここに置いときますね。

https://github.com/kmurae/books_to_scrape