Crawlee for Python: キュー、重複排除、そして実際のクローリングをレンダリングする
Senior Web Scraping Engineer
TL;DR:
- Python用のCrawleeは、リクエストキュー、自動URL重複排除、
enqueue_links()、およびデータセットライターを提供するため、ページネーションされたクローラーは1つのハンドラ関数で実装できます。 - Crawleeのストレージは、プロセスごとに共有され、クローラーごとには共有されません。
purge_on_startはTrueですが、1つのスクリプト内の2つのクローラーを隔離することはできません。 - 測定された結果:1つのスクリプト内に
max_requests_per_crawl=2で2つの同一クローラーが存在しました。最初のクローラーは2つのリクエストを完了し、20件を記録しました;2番目のクローラーは3つのリクエストを完了し、5ページにわたって50件を報告しました。 BeautifulSoupCrawlerはJavaScriptを実行しません。クライアントレンダリングされたページではリクエストが終了し、0件を生成しました。- Crawleeの
HttpClientベースクラスは4つのメソッドで構成されています。Scrapeless Universal Scraping APIを呼び出すメソッドを実装したところ、同じページから10件のアイテムが返されましたが、ルーターハンドラは変更されていません。 - Scrapelessの無料プランはこのガイドのすべてのリクエストをカバーしています。
Python用のCrawleeは、他に自分で書くことになるスクレイパーの一部です:URLを保持するキュー、同じものを2回取得するのを防ぐセット、同時処理制限、そしてディスクに結果を保存するライターです。解析されたページを受け取るハンドラを提供します。
Crawleeはキューとストレージを所有しているため、そのデフォルトが結果の見た目を決定します — そしてそのうちの2つは、例外で教えてくれない形で間違った数値を生成します。
このガイドでは、ライブサイトに対して動作するクローラーを構築し、ストレージのデフォルトが2番目のクローラーに与える影響を測定し、最後に運送を交換して同じハンドラがブラウザでレンダリングされたページで機能するようにします。
Crawleeが提供するもの
Crawleeは、共通のインターフェースを持ついくつかのクラスのクローラーを提供します。選択するクローラーによってページの解析方法が決まります:
BeautifulSoupCrawlerおよびParselCrawlerはHTTP経由で取得し、ハンドラに解析されたツリーを渡します。HttpCrawlerは、生のレスポンスを返し、解析は行いません。PlaywrightCrawlerおよびAdaptivePlaywrightCrawlerは実際のブラウザを操作します。
すべてのクローラーは同じルータ、同じ同時処理設定、同じストレージを受け入れます。それらの間でスワップすると、ハンドラが受け取るコンテキストオブジェクトが変わるため、HTTPクローラーからブラウザクローラーへの移行はワンライナーの変更ではありません。
インストール
bash
pip install 'crawlee[beautifulsoup]'
追加の部分が重要です — ベースのcrawleeパッケージはBeautiful Soupを引き込むことはありません。検証実行は、Python 3.12でcrawlee 1.9.0とbeautifulsoup4 4.15.0を使用しました。
最初のクローラー
クローラーはクラスとデコレーター付きのハンドラーを組み合わせたものです。ハンドラーは、解析されたページ、リクエスト、およびデータをプッシュし、さらにURLをキューイングするためのメソッドを持つコンテキストを受け取ります。
python
def build(*, storage_dir: str | None = None, http_client=None, max_requests: int = 3):
crawler = BeautifulSoupCrawler(
http_client=http_client,
max_requests_per_crawl=max_requests,
concurrency_settings=ConcurrencySettings(desired_concurrency=2, max_concurrency=2),
configuration=Configuration(storage_dir=storage_dir) if storage_dir else None,
)
@crawler.router.default_handler
async def handler(context: BeautifulSoupCrawlingContext) -> None:
for quote in context.soup.select("div.quote"):
await context.push_data({
"text": quote.select_one("span.text").get_text(strip=True),
"author": quote.select_one("small.author").get_text(strip=True),
"url": context.request.url,
})
await context.enqueue_links(selector="li.next a")
return crawler
context.soupはBeautiful Soupオブジェクトであるため、既存のセレクターはそのまま使用できます。context.push_data()はデータセットに追加します。context.enqueue_links(selector=...)は、そのセレクターに一致するアンカーを見つけ、基準となるURLルールを使用して各項目を現在のページに対して解決し、結果をキューに追加します — すでに重複排除されており、訪問済みのページを指す「次」リンクはコストがかかりません。
ConcurrencySettingsは、max_concurrencyがdesired_concurrencyよりも低い場合に拒否しますので、これを下げる際には両方を設定してください。
ライブの引用サイトの3ページに対して実行した結果:
text
static
requests finished : 3
dataset items : 30
distinct pages : 3
first quote : “私たちが作り上げた世界は、私たちの思考の過程です”
first author : アルバート・アインシュタイン
3つのリクエスト、各リクエストに10件の引用、3つの異なるソースURL。ハンドラはURLを構築したり、訪問済みのセットを追跡したりしませんでした。
データの行き先
push_data()は、./storage以下のデータセットに書き込まれ、crawler.get_data()はそれを読み戻します:
python
async def report(label, crawler, start_url):
await crawler.run([start_url])
data = await crawler.get_data()
print(f" {label}")
print(f" requests finished : {crawler.statistics.state.requests_finished}")
print(f" dataset items : {data.count}")
print(f" 異なるページの数 : {len({i['url'] for i in data.items})}")
return data
crawler.statistics.state.requests_finishedはCrawleeが実際に完了したリクエストの数で、データセットの数の隣に印刷する価値があります。この二つが予想と異なる場合、その理由は通常次のセクションにあります。
ストレージはクローラーより長く存続する
Configuration().purge_on_startはTrueです。これは、すべての実行が空のデータセットと空のキューから始まるという保証のように読み取れますが、そうではありません。パージはプロセス内でストレージが最初に開かれたときに一度だけ行われるため、同じスクリプト内で構築された二番目のクローラーは、最初のクローラーが残していったストレージに参加します。
同じ関数によって構築された二つのクローラーがあり、どちらも二つのリクエストに制限されており、同じURLから始まります:
python
await report("クローラー A", build(max_requests=2), "https://quotes.toscrape.com/")
await report("クローラー B", build(max_requests=2), "https://quotes.toscrape.com/")
text
クローラー A
完了したリクエスト : 2
データセットの項目 : 20
異なるページの数 : 2
クローラー B
完了したリクエスト : 3
データセットの項目 : 50
異なるページの数 : 5
クローラーBは二つのリクエストに設定されていましたが、三つ完了しました。データセットは50のアイテムが5つの異なるページにわたって報告されており、これはクローラーAが書いたすべてを含んでいます。何のエラーも発生せず、両方の実行は成功として記録されました。
クローラーBに与えられた開始URLはすでに訪問されていたため、重複排除により捨てられましたが、クローラーAがキューに登録し到達できなかったページはまだ待機中でした。クローラーが報告する制限とデータセットはどちらも共有ストレージの特性で、特定のクローラーの特性ではありません。
プロセスを共有する場合は、各クローラーに独自のストレージディレクトリを与えます。それが、上記のビルダーが正にこの理由で受け取る一つの引数です:
python
configuration=Configuration(storage_dir=storage_dir) if storage_dir else None,
各クローラーごとにstorage_dirを設定して同じ3段階を再実行すると、カウントは設定したものになります。プロセスごとのクローラーは別の答えで、実運用にはよりシンプルな方法です。
ページがブラウザでレンダリングされるとき
https://quotes.toscrape.com/js/はJavaScriptの配列からDOMを構築します。BeautifulSoupCrawlerは何の文句もなくそれを取得します:
text
javascript
完了したリクエスト : 1
データセットの項目 : 0
異なるページの数 : 0
一つのリクエストが完了しましたが、アイテムはゼロです。Crawleeが受け取ったマークアップにはdiv.quote要素が含まれておらず、HTTPクローラーにはそれを生成するものはありません。
文書化された答えはPlaywrightCrawlerで、これはブラウザの依存関係や異なるコンテキストオブジェクト、ハンドラの解析の書き直しを意味します。狭い変更はBeautifulSoupCrawlerを保持し、輸送方法のみを置き換えることで、Crawleeはhttp_clientパラメータを通じてそれをサポートします。
HttpClientは4つのメソッドを持ち、そのうちの2つだけが実際の作業を必要とします。CrawleeのHttpResponse構造体タイプを満たすレスポンスオブジェクト—これはPythonのタイププロトコル仕様の意味でのプロトコルです—はレンダリングされたHTMLを包んでいます。それはステータスコードとヘッダーを公開する必要があります、なぜならCrawleeはそれらをHTTPセマンティクス仕様が定義する方法で扱うからです:
python
class RenderedResponse:
"""レンダリングされたHTML文字列をCrawleeのHttpResponseプロトコルに適合させる。"""
def __init__(self, body: bytes, status_code: int = 200) -> None:
self._body = body
self._status_code = status_code
@property
def http_version(self) -> str:
return "HTTP/1.1"
@property
def status_code(self) -> int:
return self._status_code
@property
def headers(self) -> HttpHeaders:
return HttpHeaders({"content-type": "text/html; charset=utf-8"})
async def read(self) -> bytes:
return self._body
async def read_stream(self) -> AsyncIterator[bytes]:
raise RuntimeError("ストリーミングはこのクライアントではサポートされていません")
yield b""
クライアント自体は<あ href="https://www.scrapeless.com/ja/product/universal-scraping-api?utm_source=website&utm_medium=blog&utm_campaign=universalscrapingapi&utm_term=crawlee-python-web-scraping">Scrapeless Universal Scraping API</あ>を通じてすべてのCrawleeリクエストをルーティングします。そのエンドポイントはページをレンダリングし、HTMLを文字列として返します。ブロッキングコールはasyncio.to_threadを通じて行われ、<あ href="https://docs.python.org/3/library/asyncio-task.html" rel="nofollow">asyncioタスクのドキュメント</あ>が説明するイベントループを停止することはありません:
python
class ScrapelessHttpClient(HttpClient):
"""すべてのCrawleeリクエストをUniversal Scraping APIを通じてルーティングします。"""
def __init__(self, *, proxy_country: str = "US") -> None:
super().__init__()
self._proxy_country = proxy_country
self._token = os.environ["SCRAPELESS_API_KEY"]
def _render(self, url: str) -> bytes:
ペイロード = {
"actor": "unlocker.webunlocker",
"input": {"url": url, "proxy_country": self._proxy_country, "js_render": True},
}
リクエスト = urllib.request.Request(
UNLOCKER,
data=json.dumps(ペイロード).encode(),
headers={"Content-Type": "application/json", "x-api-token": self._token},
)
with urllib.request.urlopen(リクエスト, timeout=180) as レスポンス:
return json.loads(レスポンス.read().decode())["data"].encode("utf-8")
async def crawl(self, リクエスト, *, session=None, proxy_info=None, statistics=None,
timeout: timedelta | None = None) -> HttpCrawlingResult:
本文 = await asyncio.to_thread(self._render, リクエスト.url)
return HttpCrawlingResult(http_response=RenderedResponse(本文))
async def send_request(self, url, *, method="GET", headers=None, ペイロード=None,
session=None, proxy_info=None, timeout=None) -> HttpResponse:
本文 = await asyncio.to_thread(self._render, url)
return RenderedResponse(本文)
def stream(self, url, **kwargs):
raise NotImplementedError("このクライアントはストリーミングしません")
async def cleanup(self) -> None:
return None
同じクローラーに渡して同じページを実行します:
```text
javascript+api
リクエスト完了 : 1
データセット項目 : 10
異なるページ : 1
最初の引用 : “私たちが創造した世界は、私たちの思考のプロセスです
ゼロを生成したページからのアイテム。ルーターハンドラー、セレクター、データセット呼び出し、enqueue_linksはすべて untouched — Crawleeのキューと重複排除は正常に機能し続けます。バイトを取得するオブジェクトだけが交換されました。環境にキーをSCRAPELESS_API_KEYとして保持します;Universal Scraping APIの入門ガイドが他のリクエストパラメータをリストしています。デフォルトのHTTPクライアントのためにプロキシルーティングが必要な場合は、Crawleeプロキシガイドがその構成をカバーしています。
始めるのに1分かかります — 無料のScrapelessアカウントを作成すると、無料プランですべてをカバーしています。
実行
bash
export SCRAPELESS_API_KEY="your-api-key"
python3 crawlee_demo.py
検証実行の完全な出力:
text
crawlee 1.9.0 | beautifulsoup4 4.15.0
purge_on_start デフォルト: True
--- 静的サイト、デフォルトのHTTPクライアント、分離ストレージ ---
静的
リクエスト完了 : 3
データセット項目 : 30
異なるページ : 3
最初の引用 : “私たちが創造した世界は、私たちの思考のプロセスです
最初の著者 : アルベルト・アインシュタイン
--- javascriptサイト、デフォルトのHTTPクライアント、分離ストレージ ---
javascript
リクエスト完了 : 1
データセット項目 : 0
異なるページ : 0
--- javascriptサイト、ScrapelessHttpClient、分離ストレージ ---
javascript+api
リクエスト完了 : 1
データセット項目 : 10
異なるページ : 1
最初の引用 : “私たちが創造した世界は、私たちの思考のプロセスです
--- 2つのクローラー、1つのプロセス、デフォルトのストレージ ---
クローラーA
リクエスト完了 : 2
データセット項目 : 20
異なるページ : 2
クローラーB
リクエスト完了 : 3
データセット項目 : 50
異なるページ : 5
トラブルシューティング
データセットにはこの実行で生じたよりも多くのアイテムがあります。 ストレージはプロセスごとに共有されています。クローラーごとにConfiguration(storage_dir=...)を設定するか、実行の間に./storageを削除するか、1つのプロセスごとに1つのクローラーを実行してください。
desired_concurrencyはmax_concurrencyを超えることはできません。 ConcurrencySettingsは構築時にペアを検証します。max_concurrencyだけを下げるとエラーが発生します;desired_concurrencyを一致させるように設定してください。
ModuleNotFoundError: No module named 'bs4'。 基本パッケージにはパーサーがありません。crawlee[beautifulsoup]またはcrawlee[parsel]をインストールしてください。
HttpHeadersのImportError。 これはサブモジュールからではなく、トップレベルのcrawleeパッケージからエクスポートされています。
ゼロアイテムと1件の完了したリクエスト。 ページはクライアント側でレンダリングされています。await context.http_response.read()を印刷し、ページに表示される値を検索してください;その値がない場合、セレクターはそれを見つけられません。
結論
Crawleeの価値は、ハンドラーの周囲の機械です:キュー、重複排除、制限された同時実行、およびデータセット。それは状態を保持しているため、監視すべきものでもあります。このガイドの2つの測定値は、その事実から来ています — 1つのプロセス内の2番目のクローラーが50項目を報告しているときに、はるかに少ない項目を取得しており、クライアントレンダリングされたページがクリーンなゼロを返しています。
両方は1行で診断可能です。データセットのカウントと共にrequests_finishedを毎回印刷してください; それが設定と一致しない場合は、セレクターの前にストレージを確認してください。そして、マークアップが空で到着したためにカウントがゼロの場合、最小の修正はトランスポートを変更し、ハンドラーはそのままにしておくことです。
試してみますか? Scrapelessの無料プランから始める そして 現在の価格 を確認してください。
FAQ
Q: どのCrawleeクローラークラスから始めればよいですか?
提供されたHTMLにデータがある場合はBeautifulSoupCrawlerから始めてください。ページごとに1回のHTTPリクエストがかかり、馴染みのあるパース済みツリーが手に入ります。XPathを好む場合はParselCrawlerに移行し、生のバイトを取得したい場合はHttpCrawlerを使用し、ページが本当にブラウザを必要とする場合のみPlaywrightクローラーを使用してください。
Q: Crawleeは自分でループを書くのとどう違いますか?
Crawleeはリクエストキュー、URLの重複排除、制限付き同時実行性、データセットの永続性を提供します。上記の実行では、enqueue_links(selector="li.next a")が1つのURLも構築せず、どのページを見たかの追跡なしに3ページを移動しました。
Q: なぜ私のデータセットに以前の実行の結果が含まれていますか?
Crawleeのストレージがプロセスごとに共有され、purge_on_startはストレージが最初に開かれたときに1回のみ発火するためです。1つのスクリプト内の2つのクローラーはデータセットとリクエストキューを共有します。それぞれにConfiguration(storage_dir=...)を与えるか、プロセスごとにクローラーを1つ実行してください。
Q: JavaScriptのページに対してPlaywrightCrawlerに切り替える必要がありますか?
いいえ。PlaywrightCrawlerは1つのオプションですが、クローラークラスとハンドラーが受け取るコンテキストが変わります。CrawleeのHttpClientインターフェースを実装することは、バイトの取得方法だけを変更します。これが、このガイドのハンドラーが編集なしで0アイテムから10アイテムに増えた理由です。
Q: Crawleeは出力をどこに書き込みますか?
デフォルトでは./storageの下に、データセットはstorage/datasets/にあります。crawler.get_data()は同じプロセス内でデータセットを再取得し、Configuration(storage_dir=...)は全体のツリーを別の場所に移動します。
Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



