プログラマーのメモ書き

伊勢在住のプログラマーが気になることを気ままにメモったブログです

Web ページのデータをクローリングで取得する

いま、こちらの記事で試している RAG アプリケーションはテキストファイル(json)として与えているデータを元にしています。せっかくなので今後知識として与えるデータを広げていきたいと思います。

そのためにまずは Web ページをクローリングして必要なデータをファイルに出力する方法を試してみたので、メモっておきます。

scrapy の基本

Python でクローリング処理を簡単にできないものが調べてみると Scrapy というライブラリを使うと結構簡単にできるようです。

ちなみに『スクレイピー』と読むのかと思っていたら、

公式では『スクレイパイ』風の読み方になってました。

さて、以前(こちらの記事など参照)、Python でスクレイピング処理を行ったときは Selenium を使ってました。動的ページの場合は Selenium がいいんですが、今回の用途だとサイトの静的な Web ページの情報を集めてくる(いわゆるクローリング)のが主体なので、こちらの scrapy のほうがよさそうです。

ということで、下記の記事が Scrapy のチュートリアルを日本語で説明してくれているのでこれを試して、その後自分でもいろいろとやってみました。

Scrapyチュートリアル #Scrapy - Qiita

準備

まずは準備します。venv 環境を作ってから scrapy をインストールしておきます。

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy$ pip install scrapy

プロジェクトを生成

さきほどのチュートリアルでも紹介してますが、 scrapy を使う場合、まずはプロジェクトを作った方が簡単です。 Python のスクリプトから、 scrapy のプロジェクトで作ったものを呼び出すこともできるので、まずはこれから手を付けます。

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy$ scrapy startproject crawling_sample
New Scrapy project 'crawling_sample', using template directory '/home/mor/tmp/python_samples/scrapy/.venv/lib/python3.13/site-packages/scrapy/templates/project', created in:
    /home/mor/tmp/python_samples/scrapy/crawling_sample

You can start your first spider with:
    cd crawling_sample
    scrapy genspider example example.com
(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy$ 

プロジェクト名を crawling_sample として与えました。

作成されたフォルダ構成はこんな感じになってました。

spider を記述

今回サンプルがクローリングの対象としているサイトは、

こんな感じのページで、有名な言葉と誰の言葉か、タグが複数ページにわたってまとめらています。ここから、必要な情報を取得します。

クローリング処理は crawling_sample/crawling_sample/spiders の下に、スパイダーというのを定義していけばよいそうです。元ネタの記事とほぼ同じですが、こんな感じの処理になります。

import scrapy

# spider のサンプル
# https://qiita.com/goay/items/15a9fc119df5e6734ddd

class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = ["https://quotes.toscrape.com/page/1"]

    def parse(self, response):
        for quote in response.css("div.quote"):
            yield {
                    "text": quote.css("span.text::text").get(),
                    "author": quote.css("span small::text").get(),
                    "tags": quote.css("div.tags a.tag::text").getall(),
            }

        next_page = response.css("li.next a::attr(href)").get()
        if next_page is not None:
            yield response.follow(next_page, self.parse)

ファイル名は quotes_spider.py としていますが、どのようなファイル名でも大丈夫です。実行時にスパイダーを指定する方法は

  • スパイダー名(上記のクラス中の name)
  • クラス名

などで行います。

動作としては start_urls からwebページを読み込んだら、順次 parse メソッドが呼び出されます。 parse メソッドでは、 response オブジェクトからセレクタで要素を抜き出して、必要な要素があれば、 yield で結果を出力します。

最後の

        next_page = response.css("li.next a::attr(href)").get()
        if next_page is not None:
            yield response.follow(next_page, self.parse)

の部分で、次ページへのリンクがあれば、リンク先も読み込むようにリクエストしています。この際、リクエストが完了した際に呼ばれるコールバック関数として self.parse を登録しています。

このあたりの動作については、公式のチュートリアル(日本語)の説明がわかりやすいです。

実行

スパイダーが実装できたら実行してみます。まずはコマンドラインでの実行です。作成したプロジェクトディレクトリに移動して、

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy$ cd crawling_sample/

次のコマンドをスパイダー名 quotes と共に実行すると

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy/crawling_sample$ scrapy crawl quotes
2025-09-11 09:31:52 [scrapy.utils.log] INFO: Scrapy 2.13.3 started (bot: crawling_sample)
2025-09-11 09:31:52 [scrapy.utils.log] INFO: Versions:
{'lxml': '5.4.0',
(略)
2025-09-11 09:31:56 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/page/1/>
{'text': '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”', 'author': 'Albert Einstein', 'tags': ['change', 'deep-thoughts', 'thinking', 'world']}
2025-09-11 09:31:56 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/page/1/>
{'text': '“It is our choices, Harry, that show what we truly are, far more than our abilities.”', 'author': 'J.K. Rowling', 'tags': ['abilities', 'choices']}
(略)
 'start_time': datetime.datetime(2025, 9, 11, 0, 31, 52, 894278, tzinfo=datetime.timezone.utc)}
2025-09-11 09:32:06 [scrapy.core.engine] INFO: Spider closed (finished)
(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy/crawling_sample$ 

こんな感じにデバッグ出力と共に、画面にクローリングした結果を出力します。

クローリングした結果をファイルに保存したい場合は、

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy/crawling_sample$ scrapy crawl quotes -o test.json
(略)

とすると、コマンドを実行したディレクトリに test.json というファイルが作成され、

こんな感じで出力されているのがわかります。意外と簡単ですね。

もし、複数のスパイダーを定義している場合は、引数に渡すスパイダー名を替えることで、どれを実行するのか切り替えることができます。

応用

応用というほどではないですが、上記以外に scrapy を使ったいろいろを試してみます。

プロジェクトの設定変更

プロジェクトを作成すると、 settings.py というファイルが自動的に作成されます。こちらを変更することで実行時の設定を制御することもできます。

たとえば、ページを取得するときの待機時間を変更したければ、

DOWNLOAD_DELAY = 1

DOWNLOAD_DELAY = 3

のように変更すれば、ページ読み込み時の待機時間を 3 秒に変更することができます。その他にも、いろいろと設定を行うことができますので、詳しくはこちらのリファレンスをご覧ください。

スパイダーの定義方法

上記の例では、 Spider クラスを直接継承していましたが、 scrapy には用途別に定義された 汎用のスパイダークラスがいくつかあります。これらを使うとクローリングが簡単になるかもしれません。

ここでは CrawlSpider を使った例を示します。 CrawlSpider はリンクをたどりながらクローリングを行うのに適したスパイダーだそうです。

たとえば、下記のようなスパーダ―を定義できます。

from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from trafilatura import extract

class MoriSoftSpider(CrawlSpider):
    name = "mori-soft"
    allowed_domains = ["www.mori-soft.com"]
    start_urls = ["https://www.mori-soft.com"]

    # about 以下のみを取得する
    #rules = (Rule(LinkExtractor(allow=(r"about/")), callback="parse_item", follow=True),)
    #rules = (Rule(LinkExtractor(allow=r"about/"), callback="parse_item", follow=True),)
    # すべてのページを取得する
    #rules = (Rule(LinkExtractor(allow=r".*"), callback="parse_item", follow=True),)
    rules = (Rule(callback="parse_item", follow=True),)

    def parse_item(self, response):
        item = {}
        #item["domain_id"] = response.xpath('//input[@id="sid"]/@value').get()
        #item["name"] = response.xpath('//div[@id="name"]').get()
        #item["description"] = response.xpath('//div[@id="description"]').get()
        item["title"] = response.xpath('//head/title/text()').get()
        item["url"] = response.url
        item["content"] = extract(response.text)

        return item

これは start_urls からクローリングを開始して、 rules にリンクをたどるルールを記述することで、順次サイト内のリンクをたどっていくことができます。レスポンスを渡すコールバック関数もルール毎に指定できます(複数の Rule があれば、異なるものを指定できますし、指定しなければそのページは出力対象外になります)。また、 follow=False とすれば特定の条件を満たす場合はリンクをたどらないという処理もできます。

もちろん rules は複数定義することができます。ただし、複数定義した場合は先頭から順番に適用されるのでその点は注意が必要です。

また、上記の例ではコメントアウトしていますが、 LinkExtractor を利用すると正規表現を使って URL ごとにルールを設定することができます。

あと、このサンプルでは特定のページから特定の情報を取得する(スクレイピング)というより、巡回した各ページのコンテンツを収集する(クローリング)ことを想定して、ページ全体のテキストを取得するようにしています。 response.text をそのまま使うという手もありますが、これにはHTMLタグなどもすべて含まれているので、ちょっと不適切です。

どうしたものかと思ってネットを調べると trafilatura という Python ライブラリを使うと不要なタグを除いてほしいテキスト部分だけを簡単に抽出できそうです。

まずはパッケージをインストールしておきます。

pip install trafilatura

使う場合は

from trafilatura import extract

として、

    def parse_item(self, response):
        item = {}
        item["title"] = response.xpath("//head/title/text()").get()
        item["url"] = response.url
        item["content"] = extract(response.text)
(略)

のように、 extract に response.text を渡すだけです。

ただ、実際の抽出結果を見ると残念ながらヘッダやフッタといった各ページで共通に使っている要素のテキストも含まれたりします。とりあえずは割り切って使うつもりですが、 trafilatura をもう少し詳しく調べるといい方法が見つかるかもしれません。

なお、 CrawlSpider の例としては下記なども参考にしてください。

Scrapyでクローリング+スクレイピング【CrawlSpider】 #Python - Qiita

別の Python ファイルから実行

scrapy のプロジェクトを別の Python ファイルから呼び出す方法も試しました。

crawler_process.py

from scrapy.crawler import CrawlerProcess
from scrapy.utils.project import get_project_settings

settings = get_project_settings()
process = CrawlerProcess(settings)

process.crawl("quotes")
process.start()

process.crawl の引数 quotes がスパイダー名になります。

このファイル crawler_process.py をプロジェクトディレクトリ直下(scrapy.cfg と同じディレクトリ)に保存しておきます。 get_project_settings は scrapy のプロジェクトを作成した際に作られる settings.py の内容を取り込むためのものです。

この python ファイルを実行すれば、 scrapy が動作します。

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy/crawling_sample$ python crawler_process.py 
(略)

これだと、画面にそのまま出力されるので、ファイルへ保存したければ、

settings = get_project_settings()
settings.set("FEEDS", '{"test3.json": {"format": "json"}}')

のように、保存先ファイル名を settings に追加してやれば OK です。

また、画面に出ているデバッグログをファイルに保存したければ、

settings.set("LOG_FILE", "scrapy.log")

のような設定を追加すれば OK です。

別の方法も使えます。 CrawlerRunner クラスを使えば、より細かい制御ができるとのことです。

from scrapy.crawler import CrawlerRunner
from scrapy.utils.log import configure_logging
from scrapy.utils.project import get_project_settings
from scrapy.utils.reactor import install_reactor

settings = get_project_settings()
settings.set("FEEDS", '{"test4.json": {"format": "json"}}')

install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")
from twisted.internet import reactor

settings.set("LOG_FILE", "scrapy.log")
configure_logging(settings)

runner = CrawlerRunner(settings)

d = runner.crawl("quotes")
d.addBoth(lambda _: reactor.stop())
reactor.run()

runner.crawl の処理が完了した後に、 reactor.stop() を呼び出す必要があるので、それをコールバックとして登録しておく必要があります。

また、このコードで install_reactor を呼び出していますが、 この呼び出しより先に

from twisted.internet import reactor

を呼ぶと、内部的に reactor がインストールされてしまうため、実行時にエラーになってしまいます。詳しくは下記のドキュメントなどをご覧ください。

asyncio — Scrapy 2.13.3 documentation

複数のスパイダーを実行

この CrawlerProcess や CrawlerRunner を使うと複数のスパイダーを実行させることもできます。

runner = CrawlerRunner(settings)

d = runner.crawl("quotes")
d = runner.crawl("mori-soft")
d.addBoth(lambda _: reactor.stop())
reactor.run()

デフォルトでは、スパイダーは同時実行になります。実際、ログを見ると、2つのサイトを同時に巡回している様子がわかります。

ただ、同一サイトを対象にした複数のスパイダーがあるような場合(例えば、巡回する対象のコンテンツが大きく異なるような場合、データを取得する処理を書くためスパイダーを分けることも考えられます)、同時にクローリングするとクローリング先に負荷がかかってしまいます。これを避けるために、逐次実行を行うこともできます。

この場合は先ほどの CrawlerRunner を使って

from scrapy.crawler import CrawlerRunner
from scrapy.utils.log import configure_logging
from scrapy.utils.project import get_project_settings
from scrapy.utils.reactor import install_reactor
from twisted.internet import defer


@defer.inlineCallbacks
def _crawl(runner: CrawlerRunner):
    yield runner.crawl("quotes")
    yield runner.crawl("mori-soft")
    reactor.stop()


settings = get_project_settings()
settings.set("FEEDS", '{"test6.json": {"format": "json"}}')

install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")
from twisted.internet import reactor

settings.set("LOG_FILE", "scrapy_multi_sequence.log")
configure_logging(settings)

runner = CrawlerRunner(settings)
_crawl(runner)
reactor.run()

のように実行します。

ちなみに、複数のスパイダーを呼び出す場合に、ファイルへ出力しようとすると、

[
{"text": "“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”", "author": "Albert Einstein", "tags": ["change", "deep-thoughts", "thinking", "world"]},
(略)
{"text": "“... a mind needs books as a sword needs a whetstone, if it is to keep its edge.”", "author": "George R.R. Martin", "tags": ["books", "mind"]}
][
{"title": "小さなシステム開発なら小回りが利く森ソフトへ", "url": "https://www.mori-soft.com/", "content": ~
(略)
]

のように、正しい json の形式になりません。そのため、ファイル名に

settings.set("FEEDS", '{"test7_%(name)s.json": {"format": "json"}}')

のように、スパイダー名を含めるようにすると、それぞれのファイルで正しい json 形式として保存されます。

対話実行

あと、 scrapy は対話的に操作することができます( shell と呼ぶようです)。なので、処理した結果が予想外のものになった場合は、この対話であれこれ試すのがよさそうです。scrapy の shell を試してみます。

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy/crawling_sample$ scrapy shell
2025-09-11 16:00:57 [scrapy.utils.log] INFO: Scrapy 2.13.3 started (bot: crawling_sample)
(略)
[s] Available Scrapy objects:
[s]   scrapy     scrapy module (contains scrapy.Request, scrapy.Selector, etc)
[s]   crawler    <scrapy.crawler.Crawler object at 0x727deb84ee40>
[s]   item       {}
[s]   settings   <scrapy.settings.Settings object at 0x727dec15dd10>
[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
>>> 

のようにプロンプトが表示されます。ここで

>>> fetch("https://quotes.toscrape.com/page/1")
2025-09-11 16:05:33 [scrapy.core.engine] DEBUG: Crawled (404) <GET https://quotes.toscrape.com/robots.txt> (referer: None)
2025-09-11 16:05:34 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (308) to <GET http://quotes.toscrape.com/page/1/> from <GET https://quotes.toscrape.com/page/1>
2025-09-11 16:05:36 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/page/1/> (referer: None)

とすれば、ページが取得されます。ページが取得できれば、 response オブジェクトを操作することができます。

>>> response.url
'http://quotes.toscrape.com/page/1/'
>>> response.css("div.quote")
[<Selector query="descendant-or-self::div[@class and contains(concat(' ', normalize-space(@class), ' '), ' quote ')]" data='<div class="quote" itemscope itemtype...'>,
(略)
>>> 

こういう感じで、スパイダー内部の処理を対話的に確認することができます。

所感

クローリングを行うために Scrapy を試してみました。思ったより簡単に使えて、しかもいろんなことができそうなのでいい感じです。

また、 RAG 用途ということであれば、 Llamaindex にも Reader と呼ばれるデータ取得用のクラスがいくつもあるようです。用途によっては直接これらを使うほうが簡単かもしれません。

なんにしても、まだまだ試す必要がありそうですね。