Scrapyウェブスクレイピング: JavaScriptページを扱うスパイダーを構築する
Lead Scraping Automation Engineer
TL;DR:
- Scrapyはクローリングフレームワークであり、HTTPクライアントではありません。 スケジューラー、重複フィルター、非同期ダウンローダー、アイテムパイプラインを提供するため、スパイダーは100ページを移動しても約20行のままです。
- スパイダーは3つの必須要素を持つ1つのクラスです:
name、開始URL、辞書を生成するparseメソッド。その他はすべて設定です。 - ScrapyはJavaScriptを実行しません。 サーバーでレンダリングされたページから10アイテムを返すスパイダーは、クライアントでレンダリングされたページでは0アイテムを返します。なぜなら、到着するHTMLには空のコンテナとスクリプトタグしか含まれていないからです。
- ダウンローダーミドルウェアは、スパイダーに手を触れずにそれを修正します。 ページを上流でレンダリングし、Scrapyに通常の
HtmlResponseを返すことで、parseメソッドはバイト同一のままで10アイテムが復元されました。 - バージョンピンニングは特に重要です。 ScrapyのTLSレイヤーはTwistedとpyOpenSSLの上にあり、特定の2つの組み合わせが証明書に基づくエラーですべてのHTTPSダウンロードを失敗させます。
- 無料で始めましょう。 ピボットで使用されるユニバーサルスクレイピングAPIには無料プランがあるため、この投稿の全比較を有料プランなしで実行できます。
Scrapyは、あなたが気にするクローリングの部分と、そうでない部分を分けます。セレクターを書きます。Scrapyがリクエストキュー、同時実行、重複排除、エンコーディング検出、シリアライゼーションを処理します。
次に、フロントエンドフレームワークで構築されたページを指し示して、空のファイルを得ることになります。
このガイドでは、動作するスパイダーを構築し、意図的にJavaScriptレンダリングページで故障させ、ダウンローダーミドルウェアで修正します — これは、ページの取得方法を変更せずに、解析方法を変更できるScrapyの部分です。
Scrapyがリクエストループにはないものを提供する
Scrapyは構造について意見を持つクローリングエンジンです。URLのリストに対する手動で作られたループは、Scrapyが既に持っているものが必要になるまで機能します。
- 重複フィルター付きのスケジューラー。 リクエストはキューに入れられ、フィンガープリントによって重複除去され、制約付きの同時実行で送信されます。
- 非同期ダウンロードを非同期構文なしで。 ScrapyはTwistedリアクター上で動作するため、多くのリクエストが飛行中で、
parseメソッドは通常の同期コードのように見えます。 - ビルトインのセレクター。
response.css()およびresponse.xpath()はParselから提供されており、CSSおよびXPathセレクターガイドで扱われている同じセレクターライブラリです。 - フィードエクスポート。
-O results.jsonはJSON、JSON Lines、CSV、またはXMLを書き出し、シリアライゼーションコードは不要です。 - 礼儀正しさの設定。
ROBOTSTXT_OBEY、DOWNLOAD_DELAY、およびAUTOTHROTTLE_ENABLEDは、実装するものではなく設定です。それらの最初のものは、ロボット排除プロトコル標準で説明されているファイルを読み取ります。
代償として、Scrapyには学ぶべき形があります。利益はクローリングの3ページ目あたりで訪れます。
Scrapyのインストール
新しい仮想環境にインストールし、TLSスタックを明示的にピンし:
bash
python3 -m venv .venv
source .venv/bin/activate
pip install "scrapy==2.17.0" "twisted==26.4.0" "pyopenssl==25.3.0"
これらの3つのピンは意図的です。ScrapyのHTTPSサポートはTwistedの上に構築されており、TwistedはpyOpenSSLを呼び出します。この投稿の最後で文書化された2つの失敗モードは、Scrapy自体からではなくそのスタックから来るものです。
スパイダーコードを書く前にバージョンを確認します:
bash
python3 -c "import importlib.metadata as m; print(m.version('scrapy'), m.version('twisted'), m.version('pyopenssl'))"
最初のスパイダーを書く
Scrapyスパイダーは、名前、開始URLのリスト、およびレスポンスを受け取りアイテムを生成するparseメソッドを持つクラスです。これをquotes_spider.pyとして保存します:
python
import scrapy
class QuotesSpider(scrapy.Spider):
name = "quotes"
start_urls = ["https://quotes.toscrape.com/"]
def parse(self, response):
for quote in response.css("div.quote"):
yield {
"text": quote.css("span.text::text").get(),
"author": quote.css("small.author::text").get(),
"tags": quote.css("div.tags a.tag::text").getall(),
}
next_page = response.css("li.next a::attr(href)").get()
if next_page:
yield response.follow(next_page, callback=self.parse)
response.css("div.quote")はセレクターリストを返すため、ループは文字列ではなく要素を反復処理します。::textはテキストノード用のScrapyの擬似要素で、.get()は最初の一致を返し、.getall()はすべての一致を返します — これがtagsがリストであり、authorがそうでない理由です。response.followは相対的なhrefを直接受け入れ、現在のURLに対して解決するため、urljoinの呼び出しは不要です。
最後のブロックはページネーションです。parseからリクエストを返すことはスケジューラに戻し、そのコールバックをparseに向けることで全体のリストを歩きます。このパターンがカバーする一般的な形については、ウェブスクレイピングにおけるページネーションのガイドに記載されています。
プロジェクトなしで実行する
scrapy startprojectは設定、パイプライン、およびスパイダーのディレクトリを持つパッケージを生成します。まだそれらは必要ありません。runspiderは単一ファイルを実行し、-sはコマンドラインから任意の設定をオーバーライドします:
bash
scrapy runspider quotes_spider.py -O quotes.json \
-s LOG_LEVEL=ERROR \
-s CLOSESPIDER_PAGECOUNT=3
-Oは出力ファイルを切り捨てますが、-oはそれに追加します — 早期に理解しておく価値のある区別です。CLOSESPIDER_PAGECOUNT=3はクロールを3ページに制限し、チュートリアル実行を礼儀正しく、再現可能に保ちます。
そのコマンドは30アイテムを書き込みました:3ページにわたり各ページに10の引用があり、最初のレコードはAlbert Einsteinで、タグは['change', 'deep-thoughts', 'thinking', 'world']です。
スパイダーの20行、クロールされた3ページ、フォローされたページネーション、ディスク上のJSON。これがScrapyが本当に得意とする部分です。
Scrapyが止まるところ:JavaScriptレンダリングされたページ
同じサイトのクライアントレンダリングされたツインを、以下の1行を変更することで指すことができます:
python
start_urls = ["https://quotes.toscrape.com/js/"]
再実行します:
bash
scrapy runspider quotes_spider.py -O js.json -s LOG_LEVEL=ERROR -s CLOSESPIDER_PAGECOUNT=1
その結果は空の配列です。アイテムはゼロ、エラーなし、終了コード0。
セレクタに問題はありません。ページはHTTP 200を返し、Scrapyはそれを正しく解析しました — 受け取ったマークアップには単にdiv.quote要素が含まれていません。引用は、ロード後にスクリプトによってDOMに書き込まれ、スクリプトの実行はHTML標準のスクリプティングモデルで定義されたブラウザの動作です。ScrapyはHTMLパーサーが付属したHTTPクライアントです。それはバイトを取得します; JavaScriptエンジンは実行しません、そして動的にロードされたコンテンツに関するScrapyのガイドもそのことを直接述べています。
その静かなゼロに注意を払ってください。JavaScriptページのクロールは、出力と終了コードの両方で、何もないページのクロールと同一に見えます。
通常の回答はブラウザを付け加えます:scrapy-playwrightはリクエストごとにChromiumを操作し、Splashはクロールと共にレンダリングサービスを実行します。両方とも機能し、両方ともすべてのリクエストがブラウザのメモリと起動コストを持ち、さらにデプロイするための2番目のランタイムを持つことを意味します。
レンダリングを完全にマシンから外すと、Scrapyのリクエストモデルには影響がありません。
ダウンローダーミドルウェアで上流をレンダリング
ダウンローダーミドルウェアはScrapyのエンジンとダウンローダーの間に位置し、この問題に必要なフックを正確に持っています。この動作は指定されています:process_request()がRequestオブジェクトを返すとき、ダウンローダーミドルウェアのリファレンスは「Scrapyはprocess_request()メソッドを呼び出し続けず、返されたリクエストを再スケジュールします」と述べています。その対になるprocess_response()がその後、Responseを上流に返します。
したがって、ミドルウェアは各送信リクエストをレンダリングエンドポイントへのPOSTに交換し、その返信を元のURLを持つ通常のHtmlResponseに包み直すことができます。スパイダーは何かが起こったことを決して知りません。
これをscrapeless_middleware.pyとして保存します:
python
import json
import os
from scrapy.http import HtmlResponse
UNLOCKER = "https://api.scrapeless.com/api/v2/unlocker/request"
class ScrapelessMiddleware:
"""各リクエストを上流でレンダリングし、その後Scrapyに通常のHtmlResponseを渡します。"""
def __init__(self, token, country):
self.token = token
self.country = country
@classmethod
def from_crawler(cls, crawler):
return cls(
os.environ["SCRAPELESS_API_KEY"],
crawler.settings.get("SCRAPELESS_PROXY_COUNTRY", "US"),
)
def process_request(self, request, spider):
if request.meta.get("scrapeless"):
return None
payload = {
"actor": "unlocker.webunlocker",
"input": {
"url": request.url,
"proxy_country": self.country,
"js_render": True,
},
}
return request.replace(
url=UNLOCKER,
method="POST",
body=json.dumps(payload),
headers={"Content-Type": "application/json", "x-api-token": self.token},
meta={**request.meta, "scrapeless": True, "origin_url": request.url},
dont_filter=True,
)
python
def process_response(self, request, response, spider):
if not request.meta.get("scrapeless"):
return response
rendered = json.loads(response.text)["data"]
return HtmlResponse(
url=request.meta["origin_url"],
body=rendered,
encoding="utf-8",
request=request,
)
request.metaのscrapelessフラグは無限再帰を防ぎます。これがなければ、再スケジュールされたPOSTはprocess_requestに再び入ることになり、再びラップされてしまいます。dont_filter=Trueが必要なのは、すべてのレンダリングされたリクエストが同じエンドポイントURLをターゲットにするためであり、重複フィルタがなければ最初のもの以外はすべて破棄されるためです。
origin_urlは入れ替えを目に見えなくします。HtmlResponseはAPIの代わりにページの実際のアドレスで構築されるため、response.urlが正確であり、response.followが適切なベースに対して相対リンクを解決し続けます。ネットワークに到達するリクエストはPOSTであり、HTTPセマンティクス仕様に従い、スパイダーが見るレスポンスは普通のHTMLドキュメントです。
最後に、APIキーはfrom_crawler内のSCRAPELESS_API_KEY環境変数から読み取られるため、資格情報は設定ファイルに書き込まれません。完全なパラメータドキュメントはUniversal Scraping APIリファレンスにあり、js_renderの背後にあるレンダリング動作はページレンダリングガイドで説明されています。
接続して同じスパイダーを再実行する
キーをエクスポートし、設定でミドルウェアを有効にします。PYTHONPATH=.はrunspiderが作業ディレクトリからモジュールをインポートできるようにします。
bash
export SCRAPELESS_API_KEY="your_api_key"
PYTHONPATH=. scrapy runspider quotes_spider.py -O js_unlocked.json \
-s LOG_LEVEL=ERROR \
-s CLOSESPIDER_PAGECOUNT=1 \
-s 'DOWNLOADER_MIDDLEWARES={"scrapeless_middleware.ScrapelessMiddleware": 543}'
その実行で10アイテムが生成され、最初のレコードは再びアルバート・アインシュタインとタグ['change', 'deep-thoughts', 'thinking', 'world']を読み取ります — これはサーバーレンダリングされたページが無料で提供する同じレコードです。
quotes_spider.pyは、その実行と失敗した実行の間で1行も変更されていません。セレクタ、ページネーション、アイテムの形状、フィードエクスポートは、ページの取得方法が完全に変わってもすべて生き残りました。これは、レンダリングをスパイダーではなくミドルウェアに入れることの理由です。
開始するのにカードは不要です — 無料プランはこのサイズの実行をカバーしています。
1つのスクリプトで3つのケースを証明する
3つの別々のコマンドは不整合に実行するのが容易です。このスクリプトは3つのクロールを1つのプロセスで実行し、比較を印刷するので、上記の主張を一発で確認できます。
python
import json
import os
import scrapy
from scrapy import signals
from scrapy.crawler import CrawlerProcess
from scrapy.http import HtmlResponse
UNLOCKER = "https://api.scrapeless.com/api/v2/unlocker/request"
class ScrapelessMiddleware:
"""各リクエストを上流でレンダリングし、Scrapyに通常のHtmlResponseを渡します。"""
def __init__(self, token, country):
self.token = token
self.country = country
@classmethod
def from_crawler(cls, crawler):
return cls(
os.environ["SCRAPELESS_API_KEY"],
crawler.settings.get("SCRAPELESS_PROXY_COUNTRY", "US"),
)
def process_request(self, request, spider):
if request.meta.get("scrapeless"):
return None
payload = {
"actor": "unlocker.webunlocker",
"input": {"url": request.url, "proxy_country": self.country, "js_render": True},
}
return request.replace(
url=UNLOCKER,
method="POST",
body=json.dumps(payload),
headers={"Content-Type": "application/json", "x-api-token": self.token},
meta={**request.meta, "scrapeless": True, "origin_url": request.url},
dont_filter=True,
)
def process_response(self, request, response, spider):
if not request.meta.get("scrapeless"):
return response
rendered = json.loads(response.text)["data"]
return HtmlResponse(
url=request.meta["origin_url"],
body=rendered,
encoding="utf-8",
request=request,
)
class QuotesSpider(scrapy.Spider):
name = "quotes"
def __init__(self, url, **kwargs):
super().__init__(**kwargs)
ja
self.start_urls = [url]
def parse(self, response):
for quote in response.css("div.quote"):
yield {
"text": quote.css("span.text::text").get(),
"author": quote.css("small.author::text").get(),
"tags": quote.css("div.tags a.tag::text").getall(),
}
class ScrapelessQuotesSpider(QuotesSpider):
name = "quotes-scrapeless"
custom_settings = {"DOWNLOADER_MIDDLEWARES": {ScrapelessMiddleware: 543}}
def main():
import importlib.metadata as md
print(
"scrapy", md.version("scrapy"),
"| twisted", md.version("twisted"),
"| pyopenssl", md.version("pyopenssl"),
)
jobs = [
("静的ページ、プレーンなScrapy", QuotesSpider, "https://quotes.toscrape.com/"),
("JavaScriptページ、プレーンなScrapy", QuotesSpider, "https://quotes.toscrape.com/js/"),
("JavaScriptページ、Scrapelessミドルウェア", ScrapelessQuotesSpider, "https://quotes.toscrape.com/js/"),
]
collected = {label: [] for label, _, _ in jobs}
# Scrapyは信号ハンドラーを弱い参照で保持するため、各ハンドラーへの強い参照を保持します。
handlers = []
def collector(label):
def on_item(item, response, spider):
collected[label].append(item)
handlers.append(on_item)
return on_item
process = CrawlerProcess({
"LOG_LEVEL": "ERROR",
"SCRAPELESS_PROXY_COUNTRY": "US",
})
for label, spider_cls, url in jobs:
crawler = process.create_crawler(spider_cls)
crawler.signals.connect(collector(label), signal=signals.item_scraped)
process.crawl(crawler, url=url)
process.start()
for label, _, _ in jobs:
items = collected[label]
print(f"{label}: {len(items)} アイテム")
if items:
print(f" 最初の著者: {items[0]['author']}")
print(f" 最初のタグ: {items[0]['tags']}")
print("両方のスパイダーに共有されるparseメソッド:", ScrapelessQuotesSpider.parse is QuotesSpider.parse)
if __name__ == "__main__":
main()
実行すると次のように表示されます:
text
scrapy 2.17.0 | twisted 26.4.0 | pyopenssl 25.3.0
静的ページ、プレーンなScrapy: 10 アイテム
最初の著者: アルバート・アインシュタイン
最初のタグ: ['change', 'deep-thoughts', 'thinking', 'world']
JavaScriptページ、プレーンなScrapy: 0 アイテム
JavaScriptページ、Scrapelessミドルウェア: 10 アイテム
最初の著者: アルバート・アインシュタイン
最初のタグ: ['change', 'deep-thoughts', 'thinking', 'world']
両方のスパイダーに共有されるparseメソッド: True
ScrapelessQuotesSpiderはQuotesSpiderをサブクラス化し、custom_settings以外には何も追加していません。そのため、最終行がTrueになっています: 両方のクローラーが同じparse関数オブジェクトを呼び出しました。中央の0はJavaScriptの問題で、下の10はそれを解決したもので、同一のパーサーに対して測定されています。
スパイダーのクラスに対するcustom_settingsは、そのスパイダーに設定をスコープします。これにより、ミドルウェアの有無にかかわらず同じプロセスでクローリングを実行できます。Scrapyはまた、シグナル受信者を弱い参照で保持しているため、インラインで作成されたコレクタクロージャはクローリングが終了する前にガーベジコレクションされてしまい、何も記録しません — handlersリストはそれらを保持するために存在します。
最初にサーバーから送信された内容に対してスパイダーを構築します。セレクタが空で戻ってきた場合、ブラウザに手を伸ばす前に、マークアップがそれらを含んでいたかどうかを確認してください。もし含んでいなかった場合、ミドルウェアを通して上流でレンダリングすることで、クロールを非同期のまま維持し、1つのPythonプロセスに展開を保ち、すでにテスト済みの解析コードをそのままにしておきます。
自分のターゲットに対してこれを実行する準備はできていますか? 無料のScrapelessアカウントを作成し、キーをエクスポートし、既存のスパイダーにミドルウェアを追加してください。Universal Scraping APIの製品ページでレンダリングの表面を確認し、料金ページでプランの制限を確認してください。
FAQ
Q: Scrapyは単独でJavaScriptでレンダリングされたウェブサイトをスクレイピングできますか?
いいえ。ScrapyはHTTP経由でHTMLを取得し、パースしますが、パイプラインにはJavaScriptエンジンがありません。クライアント側でコンテンツを構築するページは、空のコンテナとスクリプトタグが一緒に到着し、セレクタは何も一致しません。レンダリングは別の場所で行う必要があります — クロールに接続されたブラウザ内で、または出力がダウンローダーミドルウェアを通してフィードバックされるレンダリングAPIの上流で。
Q: ダウンローダーミドルウェアとスパイダーミドルウェアの違いは何ですか?
ダウンローダーミドルウェアはエンジンとダウンローダーの間に挟まっているため、送信される前のすべてのリクエストと、パースされる前のすべてのレスポンスを見ることができます — プロキシ、ヘッダー、レンダリングのための適切な場所です。スパイダーミドルウェアはエンジンとスパイダーの間に挟まれており、コールバックが生成するアイテムとリクエストを処理します。ページの取得方法を変更することはダウンローダーミドルウェアで行うべきです。
Q: scrapy startprojectが必要ですか、それとも単一のファイルで十分ですか?
scrapy runspiderで実行される単一のファイルは1つのスパイダーには十分であり、このガイドのすべてのコマンドはそれを使用しています。共有設定、アイテムパイプライン、複数のスパイダー、または展開が必要になったらプロジェクトを作成してください — プロジェクトレイアウトは設定モジュールとPYTHONPATHなしで解決されるインポートパスを提供します。
Q: なぜ私のスパイダーはエラーメッセージなしでゼロアイテムを返すのですか?
空のセレクタマッチはScrapyではエラーではありません。最も一般的な原因は、読み込み後にJavaScriptによって挿入されたコンテンツ、ブラウザのインスペクタが表示するが生のレスポンスには含まれていないマークアップに対して書かれたセレクタ、またはHTTP 200でインタースティシャルページが返されたレスポンスです。len(response.text)を表示し、セレクタが間違っていると仮定する前に、期待する文字列が本体に含まれているかどうかを確認してください。
Q: ミドルウェアはクロールを遅くしますか?
各リクエストは生のものではなく、レンダリングされたフェッチになりますので、リクエストごとのレイテンシが増加します。しかし、Scrapyの同時実行モデルは変更されていません:リクエストは依然として同じスケジューラとCONCURRENT_REQUESTS制限を通して実行され、リクエストごとにブラウザプロセスはありません。ミドルウェアは必要なドメインに対してのみ有効にし、残りはプレーンダウンローダーのままにしてください。
Q: APIキーをコードベースから外すにはどうすればよいですか?
from_crawler内で環境から読み取ります。ここではos.environ["SCRAPELESS_API_KEY"]を使用しています。キーは設定ファイルに表示されず、機密情報がコミットされることはなく、開発環境と本番環境で異なる資格情報に対して同じコードが実行されます。
Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



