[[Scrapy]]を使って[[スクレイピング]]する経験をしてみたかったのでやってみた。
## 対象
今は凍結している私の前ブログを対象とする。
<div class="link-card">
<div class="link-card-header">
<img src="https://avatars1.githubusercontent.com/u/9500018?s=460&v=4" class="link-card-site-icon"/>
<span class="link-card-site-name">MAMANのITブログ</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<div>
<p class="link-card-title">MAMANのITブログ</p>
</div>
<div class="link-card-description">
Webエンジニアによる エンジニアリング/業務効率化/ガジェット に関するブログ
</div>
</div>
<img src="https://avatars1.githubusercontent.com/u/9500018?s=460&v=4" class="link-card-image" />
</div>
<a href="https://blog.mamansoft.net/"></a>
</div>
# 1からプロジェクト作成
## プロジェクトの作成
[[Scrapy]]のバージョンは2.8.0。
```console
poetry new scrapy-sample
cd scrapy-sample
poetry add scrapy
poetry add --dev black[d]
```
[[Python]]のバージョンは3.11.0。
```console
$ poetry run python --version
Python 3.11.0
```
## サンプルコードで動作確認
公式にあるサンプルコードを参考にする。
<div class="link-card">
<div class="link-card-header">
<img src="https://scrapy.org/favicons/favicon-192x192.png" class="link-card-site-icon"/>
<span class="link-card-site-name">scrapy.org</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<div>
<p class="link-card-title">Scrapy | A Fast and Powerful Scraping and Web Crawling Framework</p>
</div>
<div class="link-card-description">
</div>
</div>
</div>
<a href="https://scrapy.org/"></a>
</div>
`scrapy_sample/myspider.py`を作成。
```python
import scrapy
class BlogSpider(scrapy.Spider):
name = 'blogspider'
start_urls = ['https://www.zyte.com/blog/']
def parse(self, response):
for title in response.css('.oxy-post-title'):
yield {'title': title.css('::text').get()}
for next_page in response.css('a.next'):
yield response.follow(next_page, self.parse)
```
実行する。
```console
poetry run scrapy runspider scrapy_sample/myspider.py
```
ツラツラと情報を取得している風なログが表示されればOK。
## ブログから情報を取得する
[[MAMANのITブログ]]、トップページのファーストビューから、タイトルと更新日時の一覧を取得するようにコードを書き換える。
ページの[[DOM]]を解析してみると、以下の部分を取れればいい。
![[Pasted image 20230503220558.png]]
以下のように取得する。
1. `<artile>`の要素をiterateする
2. `<h1>`配下の`<a>`からタイトルを、`<time>`から日付をとる
### タイトルの取得
まずはタイトルから。見様見真似でコードを書いてみる。
```python
import scrapy
class BlogSpider(scrapy.Spider):
name = "blogspider"
start_urls = ["https://blog.mamansoft.net/"]
def parse(self, response, **kwargs):
for article in response.css("article"):
yield {"title": article.css("h1 > a::text").get()}
```
実行すると取得はできていそうだが、改行とか空白が邪魔。
```console
2023-05-03 22:15:08 [scrapy.core.scraper] DEBUG: Scraped from <200 https://blog.mamansoft.net/>
{'title': '\n MAMANのITブログ運営終了のお知らせ\n '}
```
[[str.strip]]を使うと綺麗になる。
```python
import scrapy
class BlogSpider(scrapy.Spider):
name = "blogspider"
start_urls = ["https://blog.mamansoft.net/"]
def parse(self, response, **kwargs):
for article in response.css("article"):
yield {"title": article.css("h1 > a::text").get().strip()}
```
```console
2023-05-03 22:23:17 [scrapy.core.scraper] DEBUG: Scraped from <200 https://blog.mamansoft.net/>
{'title': 'MAMANのITブログ運営終了のお知らせ'}
```
### 日付の取得
同じ要領で実装を拡張する。ただ、日付はテキストよりも`datetime`の[[属性値]]を使った方が良さそう。
```python
import scrapy
class BlogSpider(scrapy.Spider):
name = "blogspider"
start_urls = ["https://blog.mamansoft.net/"]
def parse(self, response, **kwargs):
for article in response.css("article"):
yield {
"title": article.css("h1 > a::text").get().strip(),
"datetime": article.css("time::attr(datetime)").get(),
}
```
実行するとタイトルとあわせてしっかり取得できている。
```console
2023-05-03 22:39:04 [scrapy.core.scraper] DEBUG: Scraped from <200 https://blog.mamansoft.net/>
{'title': 'MAMANのITブログ運営終了のお知らせ', 'datetime': '2022-02-17T19:53:00+09:00'}
2023-05-03 22:39:04 [scrapy.core.scraper] DEBUG: Scraped from <200 https://blog.mamansoft.net/>
{'title': 'oh-my-poshをv2からv3へ移行してみた', 'datetime': '2021-04-11T15:06:44+09:00'}
```
## 結果をファイルに保存する
`-O`オプションで出力ファイル名を指定すればOK。
> [!note]
> 移行は`poetry run`を省略して実行コマンドを記載する。
```console
scrapy runspider scrapy_sample/myspider.py -O result.csv
```
[[JSON]]がよければ以下。
```console
scrapy runspider scrapy_sample/myspider.py -O result.json
```
ただし、これでは日本語が化けてしまう。
```txt
{"title": "MAMAN\u306eIT\u30d6\u30ed\u30b0\u904b\u55b6\u7d42\u4e86\u306e\u304a\u77e5\u3089\u305b", "datetime": "2022-02-17T19:53:00+09:00"},
```
[[FEED_EXPORT_ENCODING]]を[[UTF-8]]にすることで解決する。
```console
scrapy runspider scrapy_sample/myspider.py -O result.json -s FEED_EXPORT_ENCODING=utf-8
```
# コマンドからプロジェクト作成
[[Scrapy]]には`scrapy startproject`というコマンドがあり、そちらから作成した方が後々良さそうな気がしている。公式のチュートリアルでもそう紹介されているし。
<div class="link-card">
<div class="link-card-header">
<img src="https://docs.scrapy.org/favicon.ico" class="link-card-site-icon"/>
<span class="link-card-site-name">docs.scrapy.org</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<div>
<p class="link-card-title">Scrapy Tutorial — Scrapy 2.8.0 documentation</p>
</div>
<div class="link-card-description">
</div>
</div>
</div>
<a href="https://docs.scrapy.org/en/latest/intro/tutorial.html"></a>
</div>
## プロジェクト作成
プロジェクト作成コマンドを実行するためグローバルインストールする。
```console
pip install scrapy
```
そしてプロジェクトを作成。
```console
scrapy startproject scrapy_sample
cd scrapy_sample
python -m venv .venv
.venv/Scripts/activate.ps1
```
[[requirements.txt]]を追加。
```txt
scrapy
black[d]
```
依存関係のインストール。
```console
pip install -r requirements.txt
```
## [[スパイダー]]の作成
[[Scrapy]]のコマンドを使う。
```console
scrapy genspider blog blog.mamansoft.net
```
`spiders`配下に以下のような`blog.py`ファイルができる。
```python
import scrapy
class BlogSpider(scrapy.Spider):
name = "blog"
allowed_domains = ["blog.mamansoft.net"]
start_urls = ["http://blog.mamansoft.net/"]
def parse(self, response):
pass
```
`parse`には先ほどのコードをそのまま使う。
```python
import scrapy
class BlogSpider(scrapy.Spider):
name = "blog"
allowed_domains = ["blog.mamansoft.net"]
start_urls = ["https://blog.mamansoft.net/"]
def parse(self, response, **kwargs):
for article in response.css("article"):
yield {
"title": article.css("h1 > a::text").get().strip(),
"datetime": article.css("time::attr(datetime)").get(),
}
```
## 実行
`scrapy crawl`コマンドを使う。
```console
scrapy crawl blog
```
これで`scrapy runspider`のような結果が出る。出力ファイルも指定する。
```console
scrapy crawl blog -O result.json
```
先ほどのように文字化けしない。
```json
[
{"title": "MAMANのITブログ運営終了のお知らせ", "datetime": "2022-02-17T19:53:00+09:00"},
{"title": "oh-my-poshをv2からv3へ移行してみた", "datetime": "2021-04-11T15:06:44+09:00"},
{"title": "Obsidianでオートコンプリートプラグインを作ってみた", "datetime": "2021-02-14T18:21:24+09:00"},
{"title": "2021年冬のテレワーク日記@自宅", "datetime": "2021-01-24T20:45:41+09:00"},
{"title": "ナレッジ管理をObsidianに移行してみた", "datetime": "2021-01-16T22:17:59+09:00"},
{"title": "2020年の振り返り", "datetime": "2021-01-01T13:39:12+09:00"},
{"title": "2020年12月4週 Weekly Report", "datetime": "2020-12-28T10:10:31+09:00"},
{"title": "Svelteとffmpeg.wasmでメディア変換サイトを作ってみた", "datetime": "2020-12-27T19:52:32+09:00"},
{"title": "2020年12月3週 Weekly Report", "datetime": "2020-12-21T09:55:50+09:00"},
{"title": "Playwrightでe2eテストを書いてみた", "datetime": "2020-12-20T23:27:43+09:00"}
]
```
これは`scrapy.cfg`にてデフォルト設定に`scrapy_sample.settings`が指定されており、`scrapy_sample/settings.py`が読み込まれているため。
```toml
[settings]
default = scrapy_sample.settings
```
`settings.py`は末尾に以下の一文を持っている。
```python
FEED_EXPORT_ENCODING = "utf-8"
```
## 各記事の詳細情報を取得
各記事の詳細情報を取得するように改良する。具体的には各ページのタグ情報を取得する。以下の赤枠部分。
![[Pasted image 20230504164131.png]]
### トップページから各記事へのURL一覧を取得
`<article>`直下の`<a href>`を取得すればいい。
```python
import scrapy
class BlogSpider(scrapy.Spider):
name = "blog"
allowed_domains = ["blog.mamansoft.net"]
start_urls = ["https://blog.mamansoft.net/"]
def parse(self, response, **kwargs):
for article in response.css("article"):
yield {
"title": article.css("h1 > a::text").get().strip(),
"datetime": article.css("time::attr(datetime)").get(),
"url": article.css("a::attr(href)").get(),
}
```
### 各urlに遷移して更にその中の情報を取得
別ページにリクエストするとき、第2引数のcallbackだけではなく、他の引数も利用する。
<div class="link-card">
<div class="link-card-header">
<img src="https://docs.scrapy.org/favicon.ico" class="link-card-site-icon"/>
<span class="link-card-site-name">docs.scrapy.org</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<div>
<p class="link-card-title">Requests and Responses — Scrapy 2.8.0 documentation</p>
</div>
<div class="link-card-description">
</div>
</div>
</div>
<a href="https://docs.scrapy.org/en/latest/topics/request-response.html#topics-request-response-ref-request-callback-arguments"></a>
</div>
`response.follow`の`cb_kwargs`では辞書型のキーワードが渡せるので、`title`と`datetime`を渡す。受ける側は`**kwargs`と展開したsignatureになっているため、名前を指定すれば動的に渡ってくる。
```python
import scrapy
class BlogSpider(scrapy.Spider):
name = "blog"
allowed_domains = ["blog.mamansoft.net"]
start_urls = ["https://blog.mamansoft.net/"]
def parse(self, response, **kwargs):
for article in response.css("article"):
title = article.css("h1 > a::text").get().strip()
datetime = article.css("time::attr(datetime)").get()
url = article.css("a::attr(href)").get()
yield response.follow(
url, self.parse_page, cb_kwargs={"title": title, "datetime": datetime}
)
def parse_page(self, response, title, datetime):
tags = response.css(".post-footer-tags > a.tag::text").getall()
yield {
"title": title,
"datetime": datetime,
"tags": tags,
}
```
結果は以下のようになる。COOOOL!!
```json
[
{"title": "Playwrightでe2eテストを書いてみた", "datetime": "2020-12-20T23:27:43+09:00", "tags": ["test", "togowl", "playwright", "puppeteer", "cypress", "jest", "idea", "javascript", "typescript", "nuxt", "github"]},
{"title": "Obsidianでオートコンプリートプラグインを作ってみた", "datetime": "2021-02-14T18:21:24+09:00", "tags": ["obsidian", "typescript", "codemirror", "show-hint", "tiny-segmenter"]},
{"title": "2020年12月4週 Weekly Report", "datetime": "2020-12-28T10:10:31+09:00", "tags": []},
{"title": "2021年冬のテレワーク日記@自宅", "datetime": "2021-01-24T20:45:41+09:00", "tags": ["テレワーク"]},
{"title": "ナレッジ管理をObsidianに移行してみた", "datetime": "2021-01-16T22:17:59+09:00", "tags": ["obsidian", "vim", "autohotkey", "scrapbox", "hugo"]},
{"title": "2020年12月3週 Weekly Report", "datetime": "2020-12-21T09:55:50+09:00", "tags": []},
{"title": "2020年の振り返り", "datetime": "2021-01-01T13:39:12+09:00", "tags": []},
{"title": "Svelteとffmpeg.wasmでメディア変換サイトを作ってみた", "datetime": "2020-12-27T19:52:32+09:00", "tags": ["svelte", "ffmpeg", "wasm", "typescript", "vercel", "prettier", "idea", "slack"]},
{"title": "oh-my-poshをv2からv3へ移行してみた", "datetime": "2021-04-11T15:06:44+09:00", "tags": ["windows", "power-shell", "powerline", "terminal"]},
{"title": "MAMANのITブログ運営終了のお知らせ", "datetime": "2022-02-17T19:53:00+09:00", "tags": []}
]
```
## トップページからページングする
今のままでは1ページ目の記事情報しか取得できない。各記事の情報を一通り取得した最後に、nextのURLが存在する場合のみ継続して情報を取得するようにする。
```python
import scrapy
class BlogSpider(scrapy.Spider):
name = "blog"
allowed_domains = ["blog.mamansoft.net"]
start_urls = ["https://blog.mamansoft.net/"]
def parse(self, response, **kwargs):
for article in response.css("article"):
title = article.css("h1 > a::text").get().strip()
datetime = article.css("time::attr(datetime)").get()
url = article.css("a::attr(href)").get()
yield response.follow(
url, self.parse_page, cb_kwargs={"title": title, "datetime": datetime}
)
next_url = response.css(".pagination-next > a::attr(href)").get()
if next_url:
yield response.follow(next_url, self.parse)
def parse_page(self, response, title, datetime):
tags = response.css(".post-footer-tags > a.tag::text").getall()
yield {
"title": title,
"datetime": datetime,
"tags": tags,
}
```
## 特定期間の情報のみ取得する
すべての情報は必要ないため期間を絞る。ここでは2022年12月以降の記事のみ情報を取得するようにする。
2020年12月1日より前の記事にはアクセスせず、そのような記事が1つでも一覧に含まれたら、更に古い記事にはアクセスせず処理を終わらせるようにした。
```python
import scrapy
from datetime import datetime, timezone, timedelta
JST = timezone(timedelta(hours=+9), "JST")
class BlogSpider(scrapy.Spider):
name = "blog"
allowed_domains = ["blog.mamansoft.net"]
start_urls = ["https://blog.mamansoft.net/"]
def parse(self, response, **kwargs):
need_not_next = False
for article in response.css("article"):
title = article.css("h1 > a::text").get().strip()
_datetime = article.css("time::attr(datetime)").get()
url = article.css("a::attr(href)").get()
if datetime.fromisoformat(_datetime) < datetime(2020, 12, 1, tzinfo=JST):
need_not_next = True
continue
yield response.follow(
url, self.parse_page, cb_kwargs={"title": title, "_datetime": _datetime}
)
if need_not_next:
return True
next_url = response.css(".pagination-next > a::attr(href)").get()
if next_url:
yield response.follow(next_url, self.parse)
def parse_page(self, response, title, _datetime):
tags = response.css(".post-footer-tags > a.tag::text").getall()
yield {
"title": title,
"datetime": _datetime,
"tags": tags,
}
```
## 画像もダウンロードする
最後に、画像もダウンロードしてみる。
まずは[[Pillow]]のインストールが必要。
```console
pip install pillow
```
`settings.py`で[[Images Pipeline]]の設定を追加する。
```python
# 画像のダウンロードを有効化
ITEM_PIPELINES = {"scrapy.pipelines.images.ImagesPipeline": 1}
# 画像のダウンロード先ディレクトリを設定
IMAGES_STORE = "."
```
あとは[[スパイダー]]で`image_urls`に画像のURLを返却するようにするだけ。
```python
from urllib.parse import urljoin
import scrapy
from datetime import datetime, timezone, timedelta
JST = timezone(timedelta(hours=+9), "JST")
class BlogSpider(scrapy.Spider):
name = "blog"
allowed_domains = ["blog.mamansoft.net"]
start_urls = ["https://blog.mamansoft.net/"]
def parse(self, response, **kwargs):
need_not_next = False
for article in response.css("article"):
title = article.css("h1 > a::text").get().strip()
_datetime = article.css("time::attr(datetime)").get()
url = article.css("a::attr(href)").get()
if datetime.fromisoformat(_datetime) < datetime(2020, 12, 1, tzinfo=JST):
need_not_next = True
continue
yield response.follow(
url, self.parse_page, cb_kwargs={"title": title, "_datetime": _datetime}
)
if need_not_next:
return True
next_url = response.css(".pagination-next > a::attr(href)").get()
if next_url:
yield response.follow(next_url, self.parse)
def parse_page(self, response, title, _datetime):
tags = response.css(".post-footer-tags > a.tag::text").getall()
image_url = response.css(".cover-image > img::attr(src)").get()
yield {
"title": title,
"datetime": _datetime,
"tags": tags,
"image_urls": [urljoin(response.url, image_url)] if image_url else [],
}
```