Crawlee for Python:队列、去重和渲染真实爬虫
Senior Web Scraping Engineer
TL;DR:
- Python 的 Crawlee 提供请求队列、自动 URL 去重、
enqueue_links()和数据集写入器,因此一个分页爬虫只需一个处理函数。 - Crawlee 的存储是按 进程 共享的,而不是按爬虫共享。
purge_on_start为True但仍无法隔离在同一脚本中的两个爬虫。 - 测试:在一个脚本中两个相同的爬虫,
max_requests_per_crawl=2。第一个完成了 2 个请求并写入了 20 个项目;第二个完成了 3 个请求并报告了在 5 个页面中的 50 个项目。 BeautifulSoupCrawler不执行 JavaScript。在客户端呈现的页面上,它完成了请求并产生了 0 个项目。- Crawlee 的
HttpClient基类有四个方法。实现一个调用 Scrapeless Universal Scraping API 的方法,从同一页面返回了 10 个项目,路由处理程序保持不变。 - Scrapeless 的免费计划涵盖本指南中的每个请求。
Python 的 Crawlee 是您自己可能编写的爬虫的一部分:URL 的队列、阻止您重复获取的集合、并发限制器,以及将结果写入磁盘的写入器。您提供一个接收已解析页面的处理函数。
因为 Crawlee 拥有队列和存储,它的默认值决定了您的结果是什么样的——其中两个产生的数字以异常无法告知的方式是错误的。
本指南构建了一个针对实时网站的工作爬虫,测量存储默认值对第二个爬虫的影响,然后更换传输,使同一处理程序可以在浏览器呈现的页面上工作。
Crawlee 为您提供的功能
Crawlee 提供几个爬虫类,它们共享一个接口。您选择的类决定页面如何解析:
BeautifulSoupCrawler和ParselCrawler通过 HTTP 获取,并将解析树传递给您的处理程序。HttpCrawler提供原始响应而不进行解析。PlaywrightCrawler和AdaptivePlaywrightCrawler驱动真实浏览器。
所有爬虫都接受相同的路由器、相同的并发设置和相同的存储。在它们之间切换会更改您的处理程序接收的上下文对象,这就是为什么从 HTTP 爬虫到浏览器爬虫的转换不是一行代码可完成的原因。
安装
bash
pip install 'crawlee[beautifulsoup]'
额外的内容很重要——基本的 crawlee 包没有引入 Beautiful Soup。验证运行使用的是 crawlee 1.9.0 与 beautifulsoup4 4.15.0 在 Python 3.12 上。
您的第一个爬虫
爬虫是一个类和一个装饰的处理程序。处理程序接收一个上下文,带有已解析的页面、请求以及推送数据和排队更多 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=...) 查找与该选择器匹配的锚点,使用 WHATWG URL 标准 中的基础 URL 规则解析每一个,并将结果添加到队列中——已经去重,因此指向已访问页面的“下一个”链接不会产生成本。
ConcurrencySettings 拒绝 max_concurrency 低于其 desired_concurrency 的设置,因此在降低时请同时设置这两者。
针对一个实时名言网站的三个页面进行运行:
text
static
requests finished : 3
dataset items : 30
distinct pages : 3
first quote : “我们创造的世界是我们思维的过程
first author : 阿尔伯特·爱因斯坦
三次请求,每次十个引用,三个不同的源 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}")
python
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 被配置为进行两个请求并完成了三个。它的数据集报告显示在 5 个唯一页面上有 50 个项目,这包括爬虫 A 写入的所有内容。没有抛出异常,并且两个运行都记录为成功。
爬虫 B 获得的起始 URL 已经被访问过,所以重复数据删除将其丢弃,而爬虫 A 已排队但未到达的页面仍在等待。请求限制和爬虫报告的数据集都是共享存储的属性,而不是该爬虫的属性。
当它们共享同一个进程时,为每个爬虫分配自己的存储目录。这正是上面构建器需要的一个参数:
python
configuration=Configuration(storage_dir=storage_dir) if storage_dir else None,
使用每个爬虫单独设置 storage_dir 重新运行同三个阶段,计数将成为你配置的值。每个进程一个爬虫是另一个答案,而且是更简单的生产解决方案。
当页面在浏览器中渲染时
https://quotes.toscrape.com/js/ 从一个 JavaScript 数组构建其 DOM。BeautifulSoupCrawler 没有抱怨地获取它:
text
javascript
请求完成数 : 1
数据集项目数 : 0
唯一页面数 : 0
一个请求完成,零个项目。Crawlee 收到的标记不包含任何 div.quote 元素,而 HTTP 爬虫没有任何会创建它们的内容。
文档中的答案是 PlaywrightCrawler,这意味着依赖浏览器、不同的上下文对象、以及重写处理程序的解析。更狭窄的改变是保留 BeautifulSoupCrawler 并仅替换其传输,这通过 http_client 参数得到了 Crawlee 的支持。
HttpClient 有四个方法,只有两个需要实际工作。一个满足 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""
该客户端本身调用 Scrapeless Universal Scraping API。该端点渲染页面并返回 HTML 字符串。阻塞调用通过 asyncio.to_thread 进行,以免阻塞 asyncio 任务文档 所描述的事件循环:
python
class ScrapelessHttpClient(HttpClient):
"""通过通用抓取 API 路由每个 Crawlee 请求。"""
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:
zh
payload = {
"actor": "unlocker.webunlocker",
"input": {"url": url, "proxy_country": self._proxy_country, "js_render": True},
}
request = urllib.request.Request(
UNLOCKER,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "x-api-token": self._token},
)
with urllib.request.urlopen(request, timeout=180) as response:
return json.loads(response.read().decode())["data"].encode("utf-8")
async def crawl(self, request, *, session=None, proxy_info=None, statistics=None,
timeout: timedelta | None = None) -> HttpCrawlingResult:
body = await asyncio.to_thread(self._render, request.url)
return HttpCrawlingResult(http_response=RenderedResponse(body))
async def send_request(self, url, *, method="GET", headers=None, payload=None,
session=None, proxy_info=None, timeout=None) -> HttpResponse:
body = await asyncio.to_thread(self._render, url)
return RenderedResponse(body)
def stream(self, url, **kwargs):
raise NotImplementedError("此客户端不支持流式传输")
async def cleanup(self) -> None:
return None
将其传递给相同的爬虫并运行相同的页面:
text
javascript+api
请求完成 : 1
数据集项目 : 10
不同页面 : 1
第一条引用 : “我们创造的世界是我们思维的过程
页面上产生零项的十个项目。路由处理程序、选择器、数据集调用和 enqueue_links 都未更改 — Crawlee 的队列和去重功能正常工作,因为只有获取字节的对象被替换。将密钥保留在环境中,命名为 SCRAPELESS_API_KEY; 通用抓取API入门指南 列出了其他请求参数。如果您需要为默认 HTTP 客户端配置代理路由,Crawlee 代理指南 涵盖了该配置。
开始需要一分钟 — 创建一个免费的 Scrapeless 账户,免费计划涵盖了这里的所有内容。
运行它
bash
export SCRAPELESS_API_KEY="your-api-key"
python3 crawlee_demo.py
验证运行的完整输出:
text
crawlee 1.9.0 | beautifulsoup4 4.15.0
启动时清除默认设置: True
--- 静态网站,默认 HTTP 客户端,独立存储 ---
静态
请求完成 : 3
数据集项目 : 30
不同页面 : 3
第一条引用 : “我们创造的世界是我们思维的过程
第一作者 : 阿尔伯特·爱因斯坦
--- javascript 网站,默认 HTTP 客户端,独立存储 ---
javascript
请求完成 : 1
数据集项目 : 0
不同页面 : 0
--- javascript 网站,ScrapelessHttpClient,独立存储 ---
javascript+api
请求完成 : 1
数据集项目 : 10
不同页面 : 1
第一条引用 : “我们创造的世界是我们思维的过程
--- 两个爬虫,一个进程,默认存储 ---
爬虫 A
请求完成 : 2
数据集项目 : 20
不同页面 : 2
爬虫 B
请求完成 : 3
数据集项目 : 50
不同页面 : 5
故障排除
数据集中有比此运行生成的更多项目。 存储是按进程共享的。为每个爬虫设置 Configuration(storage_dir=...),或在运行之间删除 ./storage,或每个进程运行一个爬虫。
desired_concurrency 不能大于 max_concurrency。 ConcurrencySettings 在构造时验证这对关系。单独降低 max_concurrency 会引发错误;设置 desired_concurrency 以匹配。
ModuleNotFoundError: No module named 'bs4'。 基本包没有解析器。安装 crawlee[beautifulsoup] 或 crawlee[parsel]。
ImportError 在 HttpHeaders。 它是从顶级 crawlee 包导出的,而不是从子模块。
零项和一项请求完成。 页面在客户端渲染。打印 await context.http_response.read() 并在其中搜索您可以在页面上看到的值;如果值缺失,则没有选择器可以找到它。
结论
Crawlee 的价值在于围绕您的处理程序的机械装置:队列、去重、有界并发和数据集。那个机械装置也是需要关注的事,因为它持有存活于爬虫对象的状态。本指南中的两个测量实际上都源于这一事实 — 一个进程中的第二个爬虫报告 50 项,而它实际上获取的要少得多,和一个客户端渲染的页面返回干净的零。
两者都可以在一行中诊断。在每次运行时,打印requests_finished和数据集计数;当它们与您的配置不一致时,请查看选择器之前的存储。当计数为零,因为标记为空时,最简单的解决方法是更改传输而保持处理程序不变。
准备好尝试了吗?从Scrapeless免费计划开始,查看当前定价以满足更高的需求。
常见问题
问:我应该从哪个Crawlee爬虫类开始?
如果数据在提供的HTML中,则从BeautifulSoupCrawler开始,因为它每页需要一个HTTP请求,并且为您提供一个熟悉的解析树。如果您更喜欢XPath,则可以转到ParselCrawler,如果您想要原始字节,则选择HttpCrawler,仅在页面确实需要浏览器时使用Playwright爬虫。
问:Crawlee与我自己写循环有什么不同?
Crawlee提供请求队列、URL去重、限制并发性和数据集持久性。在上面的运行中,enqueue_links(selector="li.next a")在没有处理程序构造单个URL或跟踪它已见过哪些页面的情况下,走过了三页。
问:为什么我的数据集中包含早期运行的结果?
因为Crawlee的存储是在每个进程中共享的,并且purge_on_start在存储首次打开时只触发一次,而不是每个爬虫。一个脚本中的两个爬虫共享一个数据集和请求队列。给每个爬虫一个Configuration(storage_dir=...),或者每个进程运行一个爬虫。
问:我是否必须切换到PlaywrightCrawler来处理JavaScript页面?
不。PlaywrightCrawler是一个选项,但它更改了爬虫类和处理程序接收的上下文。实现Crawlee的HttpClient接口只改变了字节的获取方式,这就是为什么本指南中的处理程序在没有编辑的情况下从0项变为10项。
问:Crawlee将输出写在哪里?
默认情况下在./storage下,数据集在storage/datasets/中。crawler.get_data()在同一进程中读取数据集,而Configuration(storage_dir=...)会将整个树移动到其他地方。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



