プログラマーのメモ書き

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

venv 環境を移動してはまった話

結論

  • venv 環境は移動・コピーできません

これをやってしまったために、元に戻すのに苦労したので、その顛末をメモっておきます。

きっかけ

最近、 Python でサンプルを試すことが多くなってきました。まあ、サンプル試してるだけなんでなくなってもいいっちゃいいんですが、ふと何かの時に見たくなることがあるので、いまのところ一応 tmp フォルダの下にそのまま残してあります。

で、これが増えてきたので、 python_samples というフォルダを作って、この下にいろいろと入れるようにしてフォルダを少し整理しました。

これがのちのちのトラブルのきっかけとなることもつゆ知らずに。。。

トラブル発生

しばらくたって、こんなフォルダ整理をしたということも忘れて、Scrapy によるクローリングの記事をまとめるために久しぶりにサンプルのフォルダを VSCode で開いてみました。

動くかな?と軽い気持ちでターミナルを開いて、試しに、

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test/crawling_sample$ scrapy crawl quotes
コマンド 'scrapy' が見つかりません。次の方法でインストールできます:
sudo apt install python3-scrapy
(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test/crawling_sample$ 

としてみると、 scrapy なんてコマンドありません、と表示されます。ここで、一瞬、あれ?と思います。

ですが、ターミナルには

のように venv 環境下にある(と思わせる)プロンプトになっています。ちょっと変だなー、と思いつつも、

pip install scrapy trafilatura

とすると、問題なくインストールが完了します。先ほどのサンプルを動かしてみても問題なく動きます。

よしよし、と思いつつ、何気なく

pip list -v

とすると、

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test/crawling_sample$ pip list -v
Package            Version  Location                                                      Installer
------------------ -------- ------------------------------------------------------------- ---------
attrs              25.3.0   /home/mor/.pyenv/versions/3.13.1/lib/python3.13/site-packages pip
(略)
Scrapy             2.13.3   /home/mor/.pyenv/versions/3.13.1/lib/python3.13/site-packages pip
(略)
(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test/crawling_sample$ 

のようになってます。

あれ? pyenv の環境の下にライブラリが入っています(基本的に pyenv + venv で環境を作ってます)。 venv 環境だと venv で指定したフォルダ(普段は .venv を指定)以下にライブラリは入るはずなので、どういうことだ?

。。。ここで、やっと venv が有効になっていないことに気がつきました。あー、やってしまった。

原因

python 界隈では当たり前なんでしょうか?お察しの通り venv 環境は、移動・コピーすると正しく動かなくなります。

改めて調べてみると、

  • .venv/pyvenv.cfg
  • .venv/bin/activate

など、あちこちに当該フォルダが絶対パスで書かれていました。python のドキュメントでも仮想環境の説明のところ

Not considered as movable or copyable -- you just recreate the same environment in the target location

Google 翻訳だと

移動可能またはコピー可能とはみなされません。ターゲットの場所に同じ環境を再作成するだけです。

と触れらています。

ということで、venvを含んだ環境をコピー(というかフォルダを変更)すると、正しく動かなくなってしまいます。

修復

さて、どうやって修復したものか考えてみます。

手順としては、

  1. pyenv 環境にインストールされたパッケージを削除
  2. venv 環境でインストールしていたパッケージを確認
  3. 新しい venv 環境を作成
  4. 必要なパッケージをインストール

という流れになりそうですね。面倒だけど、一つ一つやっていきます。

pyenv 環境にインストールされたパッケージの削除

(以下作業は中途半端な venv 環境が active になった状態でやってしまっていたのですが、本当であれば一度 deactivate してからやった方が無難だと思います。)

まずは、 ~/.pyenv/versions/バージョン番号/lib/pythonバージョン番号/site-packages 以下にインストールしてしまったパッケージを削除します。

pip uninstall パッケージ名

と地道にやっていってもいいんですが、パッケージの数が多いときは

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test/crawling_sample$ pip freeze | xargs pip uninstall -y

でまとめてアンインストールできます。もしくは、 --path を指定するのもありです。

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test/crawling_sample$ pip freeze --path ~/.pyenv/versions/バージョン番号/lib/pythonバージョン番号/site-packages | xargs pip uninstall -y

実際にパッケージがインストールされているフォルダを見ると、アンインストール前は

mor@DESKTOP-DE7IL4F:~/.pyenv/versions/3.13.1/lib/python3.13/site-packages$ ls
OpenSSL                                        constantly                     itemadapter                   pyasn1                          service_identity-24.2.0.dist-info
PyDispatcher-2.0.7.dist-info                   constantly-23.10.4.dist-info   itemadapter-0.12.2.dist-info  pyasn1-0.6.1.dist-info          setuptools
README.txt                                     cryptography                   itemloaders                   pyasn1_modules                  setuptools-80.9.0.dist-info
__pycache__                                    cryptography-45.0.7.dist-info  itemloaders-1.3.2.dist-info   pyasn1_modules-0.4.2.dist-info  tldextract
_cffi_backend.cpython-313-x86_64-linux-gnu.so  cssselect                      jmespath                      pycparser                       tldextract-5.3.0.dist-info
_distutils_hack                                cssselect-1.3.0.dist-info      jmespath-1.0.1.dist-info      pycparser-2.23.dist-info        twisted
attr                                           defusedxml                     lxml                          pydispatch                      twisted-25.5.0.dist-info
attrs                                          defusedxml-0.7.1.dist-info     lxml-6.0.1.dist-info          pyopenssl-25.1.0.dist-info      typing_extensions-4.15.0.dist-info
attrs-25.3.0.dist-info                         distutils-precedence.pth       packaging                     queuelib                        typing_extensions.py
automat                                        filelock                       packaging-25.0.dist-info      queuelib-1.8.0.dist-info        urllib3
automat-25.4.16.dist-info                      filelock-3.19.1.dist-info      parsel                        requests                        urllib3-2.5.0.dist-info
certifi                                        hyperlink                      parsel-1.10.0.dist-info       requests-2.32.5.dist-info       w3lib
certifi-2025.8.3.dist-info                     hyperlink-21.0.0.dist-info     pip                           requests_file-2.1.0.dist-info   w3lib-2.3.1.dist-info
cffi                                           idna                           pip-25.2.dist-info            requests_file.py                zope
cffi-2.0.0.dist-info                           idna-3.10.dist-info            pkg_resources                 scrapy                          zope.interface-7.2-py3.13-nspkg.pth
charset_normalizer                             incremental                    protego                       scrapy-2.13.3.dist-info         zope.interface-7.2.dist-info
charset_normalizer-3.4.3.dist-info             incremental-24.7.2.dist-info   protego-0.5.0.dist-info       service_identity
mor@DESKTOP-DE7IL4F:~/.pyenv/versions/3.13.1/lib/python3.13/site-packages$

だったのが、アンインストール後は、

mor@DESKTOP-DE7IL4F:~/.pyenv/versions/3.13.1/lib/python3.13/site-packages$ ls
README.txt  pip  pip-25.2.dist-info  zope
mor@DESKTOP-DE7IL4F:~/.pyenv/versions/3.13.1/lib/python3.13/site-packages$

となりました。 zope はフォルダだけが残っていたので、

rm -rf zope

として消しておきました。

venv 環境でインストールしていたパッケージを確認

これ、すでに、 venv 環境が壊れているので pip の操作もできないしどうしたものかと思って、 ChatGpt に聞いてみると、

.venv/bin/python -m pip freeze > requirements.txt

のように、壊れた venv 環境下の pip を呼び出せばよいとのこと。なるほどね。早速、やってみると、問題なく requirements.txt が作られています。

mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test$ cat requirements.txt
attrs==25.3.0
(略)
zope.interface==7.2
mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test$ 

こんな感じでした。

ちなみに、直接 pip を実行しようとすると

mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test$ .venv/bin/pip freeze
bash: .venv/bin/pip: 実行できません: 必要なファイルがありません
mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test$ 

となって呼び出せません。python から呼ぶのがポイントですね。

新しい venv 環境を作成

これは簡単ですね。まず、古い venv 環境を削除します。

mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test$ rm -rf .venv

新しい venv 環境を作ります。

mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test$ python -m venv .venv

必要なパッケージをインストール

venv 環境を有効にしてから、

mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test$ . .venv/bin/activate

さきほど作成した requirements.txt を使ってパッケージをインストールします。

(.venv) mor@DESKTOP-DE7IL4F:~/tmp/python_samples/scrapy_test$ pip install -r requirements.txt 
(略)

これで完了です。

まとめ

venv 環境、コピーしてはいけないなんて、知りませんでした。今回は無事に復旧できたので助かりました。

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 と呼ばれるデータ取得用のクラスがいくつもあるようです。用途によっては直接これらを使うほうが簡単かもしれません。

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

FastAPI でセッションを扱う方法

FastAPI でセッションを扱う方法を調べてみました。 SessionMiddleware というのを使えば、簡単に使うことができるようです。

FastAPIでのHTTPセッション管理について:(Google OAuth 2.0実装例付き) | 知と富の交差点

この記事にも書かれていますが、 SessionMiddleware は署名付きクッキーを使ってセッションを管理してくれるようです。

準備

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

pip install fastapi uvicorn python-dotenv itsdangerous

なお、itsdangerous はサンプルファイルから直接呼んでいないのですが、 SessionMiddleware を使うと内部で使っているようで、必要になります。

設定方法

SessionMiddleware を使うには、

# FastAPI アプリケーションの作成
app = FastAPI()

app.add_middleware(
    SessionMiddleware,
    secret_key=_FASTAPI_SESSION_SECRET_KEY,
    max_age=(int)(datetime.timedelta(minutes=_SESSION_TIMEOUT_MIN).total_seconds()),
)

のようにすれば OK です。ここで、 _FASTAPI_SESSION_SECRET_KEY はセッション情報の署名のための秘密鍵になります。今回の場合は、 .env ファイルから読み込むようにしました。

# 環境変数の読み込み
#   秘密鍵 (セッション管理用)が必要
#   .env ファイルから取得する

load_dotenv()
_FASTAPI_SESSION_SECRET_KEY = os.environ.get("FASTAPI_SESSION_SECRET_KEY")
if not _FASTAPI_SESSION_SECRET_KEY:
    raise ValueError("FASTAPI_SESSION_SECRET_KEY is not set in environment variables.")

_SESSION_TIMEOUT_MIN = 10  # セッション有効期限, 分単位

.env ファイルは

FASTAPI_SESSION_SECRET_KEY = "session_secret_key"

こんな感じです。

セッションの使い方

ネットのサンプルだと認証に絡んだものが多い印象があるので、ここでは何かの API にアクセスした際にセッション情報があれば、それを返し、なければ新規にセッション情報を作成するようなケースを考えます。

まず、この API は POST でアクセスすると想定して、POSTのペイロードを定義しておきます。

# POST のペイロードを定義
class PostPayload(BaseModel):
    question: str = Query(
        min_length=1,
        max_length=1000,
        description="The question to query the FAQ system.",
    )

このあたりは詳しくは FastAPI のドキュメントなどを見てください。

次に API を定義します。

# Depends を使った API
@app.post("/query/")
async def query(payload: PostPayload, session: dict = Depends(_get_session)):
    print(f"session is: {session}")

    # レスポンスの整形
    result = {
        "session_id": session["session_id"],
        "count": session["count"],
        "query": str(payload.question),
    }
    return result

ここで、 _get_session という関数を依存性注入という仕組みを使って渡しています。この関数は、

def _get_session(request: Request):
    print(f"get_session, target is: {request.session}")
    if not request.session:
        request.session["session_id"] = str(uuid.uuid4())
        request.session["count"] = 0
    request.session["count"] += 1
    return request.session

という感じに定義してあり、セッションがなければ session_id を新規に作成し、既存のセッションがあればそれを利用するという処理を行います。また、わかりやすいように、 session_id ごとの呼び出し回数をカウントするようにしています。

テスト

これでテストしてみます。一連の処理を書いたファイルの最後に、

if __name__ == "__main__":
    import uvicorn

    # Start the FastAPI application
    uvicorn.run(app, host="127.0.0.1", port=8000)

と追記して、 VSCode から実行します。

こちらの記事でも触れたように、 FastAPI で定義した API を呼び出してテストするには VSCode の拡張機能である REST Client を使います。test.http というファイルを作成し、

POST http://localhost:8000/query/
Content-Type: application/json

{
    "question": "question 1"
}

のように記述しておきます。この時、『Send Request』と表示されているので、クリックするとコンソールに、

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
get_session, target is: {}
session is: {'session_id': '505185b0-3b07-487f-9591-ff0d56fc3390', 'count': 1}
INFO:     127.0.0.1:49634 - "POST /query/ HTTP/1.1" 200 OK

のように表示されます。初回のリクエストのため、セッションがないことがわかります。また、VSCode 上でウィンドウが開いて、

のように、応答が表示されます。ここで、 set-cookie ヘッダが送られていることも確認できます。

同じAPIを2回目呼び出すと、

get_session, target is: {'session_id': '505185b0-3b07-487f-9591-ff0d56fc3390', 'count': 1}
session is: {'session_id': '505185b0-3b07-487f-9591-ff0d56fc3390', 'count': 2}
INFO:     127.0.0.1:42480 - "POST /query/ HTTP/1.1" 200 OK

のようになり、 session_id は同じだけど、 count が加算されていることがわかります。

なお、REST Client の cookie はどうなっているかも見てみます。REST Client の cookie などの情報は、 ~/.rest-client にあります。

mor@DESKTOP-DE7IL4F:~/.rest-client$ ls -l
合計 20
-rw-r--r-- 1 mor mor  370  92 10:37 cookie.json
-rw-r--r-- 1 mor mor    0  63 12:51 environment.json
-rw-r--r-- 1 mor mor 9243  92 10:37 history.json
drwxr-xr-x 4 mor mor 4096  63 12:51 responses
mor@DESKTOP-DE7IL4F:~/.rest-client$

さきほどの1回目のリクエスト後に cookie を表示してみると

mor@DESKTOP-DE7IL4F:~/.rest-client$ cat cookie.json
{"localhost":{"/":{"session":{"key":"session","value":"eyJzZXNzaW9uX2lkIjogIjUwNTE4NWIwLTNiMDctNDg3Zi05NTkxLWZmMGQ1NmZjMzM5MCIsICJjb3VudCI6IDF9.aLZKUg.YpJ3eJiukxLAcyG3i1XIw24VNAM","maxAge":600,"domain":"localhost","path":"/","httpOnly":true,"extensions":["samesite=lax"],"hostOnly":true,"creation":"2025-09-02T01:37:22.546Z","lastAccessed":"2025-09-02T01:37:22.546Z"}}}}mor@DESKTOP-DE7IL4F:~/.rest-client$
mor@DESKTOP-DE7IL4F:~/.rest-client$

のようになっています。署名付き cookie なので当然内容はわかりませんが。

2回目のリクエストの後だと、

mor@DESKTOP-DE7IL4F:~/.rest-client$ cat cookie.json
{"localhost":{"/":{"session":{"key":"session","value":"eyJzZXNzaW9uX2lkIjogIjUwNTE4NWIwLTNiMDctNDg3Zi05NTkxLWZmMGQ1NmZjMzM5MCIsICJjb3VudCI6IDJ9.aLZLVg.Hvv8fAkOWQWDsyO2eZrLEiSJNoU","maxAge":600,"domain":"localhost","path":"/","httpOnly":true,"extensions":["samesite=lax"],"hostOnly":true,"creation":"2025-09-02T01:37:22.546Z","lastAccessed":"2025-09-02T01:41:42.730Z"}}}}mor@DESKTOP-DE7IL4F:~/.rest-client$
mor@DESKTOP-DE7IL4F:~/.rest-client$

のようになっています。value の値をよく見ると、1回目とは値が異なっています。

これは、多分、セッション情報を使って署名(ハッシュ操作?)を作っているため、 count の値が変わったことで、 cookie の設定値も変化したのだと思われます。

別の API の定義方法

さて、上記の方法は依存性注入というのを使ったのですが、別に無理にこれを使わなくてもセッション情報を扱うことができます。また、 SessionMiddleware を使わず、直接 cookie を操作することでセッション情報を扱うこともできます。

とうことで、これらも簡単に書いておきます。

方法2

SesionMiddleware を使うけど、 依存性注入は使わない方法です。

下記のドキュメントにあるように、POSTのペイロードと一緒に request オブジェクトを受け取ることができるので、これを元にセッションを操作できます。

Using the Request Directly - FastAPI

たとえば、こんな感じに定義できます。

# request で受け取る API
@app.post("/query2/")
async def query2(payload: PostPayload, request: Request):

    session: dict = _get_session(request)

    print(f"query2, session is: {session}")

    # レスポンスの整形
    result = {
        "session_id": session["session_id"],
        "count": session["count"],
        "query2": str(payload.question),
    }
    return result

このサンプルだと、 request を _get_session 関数に渡して処理していますが、 request.session を直接いろいろと操作することができます。

方法3

もっと簡単にやるには、 SessionMiddleware を使わず、直接 Cookie を操作することで実現できます。

今回、自分では試していませんが、例えば、下記の記事のようなやり方です。

🚀 FastAPI 実践入門:第三歩目で学ぶテンプレートとセッション管理

この場合、 cookie の中身を見ることができてしまうので、機密の高いものには使えないですかね。

セッションのクリア

あと、セッションのクリアをするには、最初の方法と方法2であれば、

# セッションのクリア
@app.get("/clear")
async def clear_session(request: Request):
    request.session.clear()
    return {"message": "Session cleared"}

のようにします。

方法3の cookie を直接使う場合は

@app.get("/delete")
async def delete_session(request: Request):
    response = JSONResponse(content={"message": "Session cookie deleted"})
    response.delete_cookie("session")
    return response

のように、cookieを削除する方法で実現できます。ただし、 SessionMiddleware を使う場合はこれだとうまくいきません(request オブジェクトに session が残っていると再度 set-cookie ヘッダが送られているみたいです)のでご注意を。

おまけ

今回のサンプルで使った python ファイルの全体を載せておきます。ご参考までに。

import datetime
import os
import uuid

from dotenv import load_dotenv
from fastapi import Depends, FastAPI, Query, Request
from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import JSONResponse

"""
FastAPI でセッションを使うためのサンプル

SessionMiddleware を使って、セッションを扱う。
セッションは署名付きクッキーを使う。
署名は秘密鍵方式となる(このため、秘密鍵を指定する必要がある)。
"""

# 環境変数の読み込み
#   秘密鍵 (セッション管理用)が必要
#   .env ファイルから取得する

load_dotenv()
_FASTAPI_SESSION_SECRET_KEY = os.environ.get("FASTAPI_SESSION_SECRET_KEY")
if not _FASTAPI_SESSION_SECRET_KEY:
    raise ValueError("FASTAPI_SESSION_SECRET_KEY is not set in environment variables.")

_SESSION_TIMEOUT_MIN = 10  # セッション有効期限, 分単位


# POST のペイロードを定義
class PostPayload(BaseModel):
    question: str = Query(
        min_length=1,
        max_length=1000,
        description="The question to query the FAQ system.",
    )


# FastAPI アプリケーションの作成
app = FastAPI()

app.add_middleware(
    SessionMiddleware,
    secret_key=_FASTAPI_SESSION_SECRET_KEY,
    max_age=(int)(datetime.timedelta(minutes=_SESSION_TIMEOUT_MIN).total_seconds()),
)


def _get_session(request: Request):
    print(f"get_session, target is: {request.session}")
    if not request.session:
        request.session["session_id"] = str(uuid.uuid4())
        request.session["count"] = 0
    request.session["count"] += 1
    return request.session


# 問い合わせ エンドポイント


# Depends を使った API
@app.post("/query/")
async def query(payload: PostPayload, session: dict = Depends(_get_session)):
    print(f"session is: {session}")

    # レスポンスの整形
    result = {
        "session_id": session["session_id"],
        "count": session["count"],
        "query": str(payload.question),
    }
    return result


# request で受け取る API
@app.post("/query2/")
async def query2(payload: PostPayload, request: Request):

    session: dict = _get_session(request)

    print(f"query2, session is: {session}")

    # レスポンスの整形
    result = {
        "session_id": session["session_id"],
        "count": session["count"],
        "query2": str(payload.question),
    }
    return result


# セッションのクリア
@app.get("/clear")
async def clear_session(request: Request):
    request.session.clear()
    return {"message": "Session cleared"}


@app.get("/delete")
async def delete_session(request: Request):
    # これだとうまくセッションが削除できない
    #
    # たぶん、 delete_cookie でクッキーを削除するが、
    # request オブジェクトに残っているセッション情報があるため、
    # 自動的にセッションがもう一度設定されるためと思われる
    response = JSONResponse(content={"message": "Session cookie deleted"})
    response.delete_cookie("session")
    return response


if __name__ == "__main__":
    import uvicorn

    # Start the FastAPI application
    uvicorn.run(app, host="127.0.0.1", port=8000)