[[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 [], } ```