爬虫-Crawlee
这篇讲什么
- Crawlee for Python 1.11.1 在 Python 3.14.10 上逐条实测:HTTP 类爬虫(Basic/Http/Parsel/BeautifulSoup/FileDownload)、浏览器爬虫(Playwright/AdaptivePlaywright)、
RequestQueue与Dataset的本地存储、enqueue_links四种策略、重试与错误处理、并发伸缩、代理与会话与指纹、robots.txt、停止与续爬。抓取对象沿用系列惯例:ssr1.scrape.center(服务端渲染)与spa1.scrape.center(前端渲染),协议级用本机靶站。 - 它是异步优先的框架:所有 handler 都是
async def,本地存储是storage/目录下的 JSON 文件,没有 Scrapy 的 Pipeline/中间件那套,也没有 feapder 的任务表与批次表。 - 装的时候
extras决定你能用哪些类(裸装只有HttpCrawler,报错原文见下一节),跑起来还有三个只有实测才知道的坑:默认队列会记住处理过的 URL,同一个 URL 第二次跑什么都不抓;enqueue_links(strategy=...)只认四个小写值,写错不校验、把请求重试到失败;ConcurrencySettings(max_concurrency=1)单独写会ValueError,因为desired_concurrency默认是 10。
装哪种 extras
官方 quick-start 给的安装命令是一句 python -m pip install 'crawlee[all]',用到浏览器爬虫再补一句 playwright install。文档页还列了按需要的装法:
1 | python -m pip install crawlee |
先记两个数:crawlee[all] 在这台机器上要装 127 个包(scipy、scikit-learn、grpcio、stagehand、pydantic-ai 全在里面),裸装 crawlee 只有 21 个包。所以别一上来就 [all],先看裸装缺什么——下面这段在一个只装了 crawlee 的干净虚拟环境里跑:
1 | # -*- coding: utf-8 -*- |
1 | crawlee 1.11.1 |
- 报错信息是设计过的:
Install the optional 'parsel' extra to use it: pip install 'crawlee[parsel]',照着补装即可;这类导入是延迟到取属性时才炸,所以import crawlee.crawlers本身不会失败。 - 唯一一句没告诉你怎么办的是
DefaultFingerprintGenerator:它抛ModuleNotFoundError: No module named 'apify_fingerprint_datapoints',而这个包不在httpx/playwright之外的任何单装路径里——要指纹生成就装crawlee[adaptive-crawler]或crawlee[all]。 pip install 'crawlee[sql_sqlite]'与'crawlee[sql-sqlite]'两种写法实测都能装(文档里写的是下划线那版,PyPI 元数据里是连字符),pip 会做名字规范化;但uv/poetry 未必兜得住,抄文档时留意。
本机这套实测环境的版本清单(importlib.metadata 现场读):
1 | import sys |
1 | python : 3.14.10 |
第一个爬虫
官方最小示例的骨架就三段:建爬虫、注册默认 handler、crawler.run(种子列表)。这里把它落到本系列的靶站上,抓 ssr1.scrape.center 第一页:
1 | # -*- coding: utf-8 -*- |
1 | 卡片数: 10 |
- 每个 handler 都是
async def,crawler.run()也是协程,所以最外面要asyncio.run(main());整篇没有一处同步写法可抄。 context.push_data(列表)会把列表里的每一项当一条记录推进数据集,跑完在storage/datasets/default/里能看到一条条 JSON。max_requests_per_crawl=1是「这次最多抓几个请求」的保险丝,调试时先设小,别一上来就顺着链接抓全站。
把同一个例子放大成 10 页 100 条,顺便看数据集读写与导出:
1 | # -*- coding: utf-8 -*- |
1 | 第 1 页 -> 10 条 |
- 页与页之间没有先后:并发 10 跑完 10 页,
LOG里按页码排好序才打印,实际到达顺序是乱的。 export_to(key, content_type='csv')不是写文件,而是把结果存进键值存储,要落盘得自己把值取出来写文件(上面就是这么做的),返回的类型是str不是 bytes。
爬虫类与上下文
crawlee.crawlers 里能取到的爬虫类比文档列的多,实测一共这些:BasicCrawler、AbstractHttpCrawler、HttpCrawler、ParselCrawler、BeautifulSoupCrawler、PlaywrightCrawler、AdaptivePlaywrightCrawler、FileDownloadCrawler、PydanticAiCrawler、StagehandCrawler。继承关系是:HTTP 一族挂在 AbstractHttpCrawler <- BasicCrawler 下面,浏览器一族直接挂 BasicCrawler。
1 | import inspect |
1 | ===== BasicCrawler (crawlee.crawlers._basic._basic_crawler) |
子类几乎不新增参数(HttpCrawler 只有 **kwargs),真正要记的是 BasicCrawler 这 31 个参数与默认值:
1 | import inspect |
1 | ===== BasicCrawler.__init__ 全部参数(默认值原样打印) |
上下文对象(handler 唯一的入参)决定你能拿什么数据。三种 HTTP 类上下文的公开属性实测如下:
1 | import asyncio |
1 | ===== HttpCrawlingContext 的公开属性/方法(18 个) |
- 公共的 18 个里,常用的就六个:
request、http_response、parsed_content、push_data、enqueue_links、add_requests;session、proxy_info、use_state、get_snapshot是进阶。 ParselCrawlingContext多的是selector,BeautifulSoupCrawlingContext多的是soup——没有parse_with_parsel这个方法(那是旧文档/JS 版的说法),要 parsel 就换ParselCrawler。- 两个上下文都带
extract_form_requests与html_to_text,填表与正文抽取不用自己写解析。
解析与默认请求头
同一个页面,三种拿法的差别只在解析器:
1 | # -*- coding: utf-8 -*- |
1 | HttpCrawler : 字节 41316,parsed_content 类型 bytes |
HttpCrawler的parsed_content是bytes,要自己解码;ParselCrawler给selector(parsel,和 Scrapy 同一套 CSS/XPath 写法),BeautifulSoupCrawler给soup。- 站点返回 41316 字节,卡片 10 张,两个解析器取到的第一个片名与评分完全一致。
「我的爬虫长得像不像浏览器」这件事,取决于实际发出去的请求头。让本机靶站把收到的头原样回过来:
1 | # -*- coding: utf-8 -*- |
1 | 默认(不带任何配置) 客户端=ImpitHttpClient 站点收到 11 个头: ['accept', 'accept-encoding', 'accept-language', 'host', 'priority', 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site', 'sec-fetch-user', 'upgrade-insecure-requests', 'user-agent'] |
- 默认客户端是
ImpitHttpClient(Rust 写的impit),裸跑就带 11 个头,User-Agent是 Firefox 128 on Linux X11,还带sec-fetch-*与priority——比requests/httpx默认值像样得多。 - 换成
HttpxHttpClient()只剩 6 个头,但它默认挂一个HeaderGenerator,User-Agent每次随机抽一个浏览器(所以这一行故意不打印抽到的是谁)——想固定就得自己传 headers,别指望它稳定。 CurlImpersonateHttpClient()是 17 个头,多了sec-ch-ua、http2-settings、cache-control,User-Agent是 Chrome 152——TLS/HTTP2 指纹层面也一起伪装,这是对付「只看指纹不看头」的站的手段。- 最后一组是中文 UA 的下场:HTTP 头按 latin-1 走,
笔记测试/1.0到服务端变成ç¬\x94è®°æµ\x8bè¯\x95/1.0。要自定义头就用 ASCII,别指望框架帮你编码。 - 顺带一条构造规矩:
Request(url=...)直接构造会ValidationError: uniqueKey Field required,得写Request.from_url(url, ...)。
RequestQueue 与去重
crawlee 的去重不靠过滤器配置,而是队列本身的性质:每条请求有 uniqueKey,同一个队列里处理过的 URL 不会再出现第二次。这一点最容易在调试时踩到——代码没变、只是第二次跑,就什么都不抓:
1 | import asyncio |
1 | 第 1 次(默认队列): {'requests_total': 1, 'requests_finished': 1, 'requests_failed': 0} |
- 第 2 次
requests_total=0:默认队列storage/request_queues/default/里那条请求已经是 handled 状态。 - 第 3 次换一个名字的队列(
RequestQueue.open(name=...))就又能抓一遍——这也是「重跑一次采集」最省事的办法。 - 队列 API 实测在
RequestQueue上的有:add_request、add_requests、fetch_next_request、mark_request_as_handled、reclaim_request、get_request、get_total_count、get_handled_count、is_empty、is_finished、purge、drop、to_tandem。注意名字是mark_request_as_handled,不是文档里常见的markHandled。 - 想每次都从头抓,两个开关:
Configuration(purge_on_start=True)(默认就是 True,但它清的是存储目录,同一进程内已经打开的队列状态还在),或者干脆每次换队列名。
enqueue_links 与路由
enqueue_links 是从当前页面里抽链接、按策略塞回队列的函数,参数全是关键字参数。注意它在上下文上是个绑定方法,inspect.signature 看到的默认值全是 None——不传 selector/attribute 时行为等价于 a 标签的 href,不传 strategy 时按 same-hostname 过滤(上面第一组就是证据):
1 | # -*- coding: utf-8 -*- |
1 | 默认(不写 strategy) 抓到 6 个页: ['/', '/a', '/a1', '/a2', '/b', '/b1'] |
- 四种策略的差别在「链接的哪一部分要和当前页比」:
same-hostname比主机名(localhost与127.0.0.1算两台机器,上面第 1 组就没跟过去)、same-domain比注册域、same-origin比协议+主机+端口、all全收。 - 策略名只认这四个小写值,写成
All不会在参数检查时报错,而是在真正过滤时撞上assert_never,抛AssertionError: Expected code to be unreachable, but got: 'All',然后按普通错误重试 3 次才失败——排查时很容易被误判成「站点有问题」。上面第 6 组里 handler 进了 4 次就是这个原因。 limit是每次调用的上限,不是全站上限:strategy='all', limit=2时每个页面各只放 2 条出去,所以总数照样能到 6。- 按标签分流时,
@crawler.router.handler("名字")注册的是request.label;种子请求必须自己带标签(Request.from_url(url, label='list')),否则报RuntimeError: No handler matches labelNoneand no default handler is configured。上面最后两组就是对照。 - 最后一组是
transform_request_function:它拿到的不是Request对象而是 dict(键是url、label、user_data、enqueue_strategy),返回字符串skip表示这条不入队,改request["url"]就能改写入队的地址。上面/b整条支路消失、剩下的都带?from=transform,就是这个函数干的。上面最后一组是extract_links:同样的 selector、同样的策略过滤,只把Request列表还给你,队列里total还是 1,链接一条都没进去——「先看看会抓到哪些链接」用它,别真发请求。
重试与错误处理
状态码和异常在 crawlee 里是同一套通道:默认 4xx 与 5xx 都会抛,但只有 5xx 会重试。本机靶站按状态码回话,实测:
1 | # -*- coding: utf-8 -*- |
1 | == A 默认(500) |
retry_histogram的下标是「重试到第几次死的」:500 是[0, 0, 0, 1](重试 3 次后失败,max_request_retries默认 3),404 是[1](一次都不重试)。- 两种异常类名不一样:服务端错误是
HttpStatusCodeError,客户端错误是HttpClientStatusCodeError。 additional_http_error_status_codes=[404]能把 404 也变成错误(报错文案变成Error status code (user-configured) returned.);ignore_http_error_status_codes=[500]则让 500 直接进 handler,此时context.http_response.status_code还是 500,要自己判。abort_on_error=True是「一出错整个爬虫立刻收摊」:上面 G 组里failed_request_handler一次都没进,统计全是 0。- handler 里抛任何异常都按同样规则重试;
@crawler.failed_request_handler是重试耗尽后的兜底,在里面把失败 URL 记下来最实用。 request_handler_timeout默认 60 秒,超时会当成 handler 异常处理(asyncio.TimeoutError),慢页面要么调大要么拆请求。
日志与统计
日志默认是彩色的、INFO 级,跑完打一张统计表。想改等级,三种配法的实测效果完全不同(每种都另起进程,避免互相污染):
1 | # -*- coding: utf-8 -*- |
1 | default 有没有 INFO:True,WARN 行 0 |
CRAWLEE_LOG_LEVEL=ERROR最可靠:进程启动前设好,整段安静。configure_logging=False也能安静,但它是「不配置日志」,不是「关掉日志」——同一个进程里只要先跑过一个正常配置的爬虫,后面这个开关就不起作用了。- 给爬虫传
Configuration(log_level='ERROR')不管用(INFO 一条没少),而且WARN 行 1就是那句No event manager set...——显式传configuration反而多一条警告。 - 统计表里的
request_avg_failed_duration这类均值只有秒级小数,重跑会抖;要程序化取数就用crawler.run(...)的返回值,它是个FinalStatistics,to_dict()出来是 JSON 友好的字典,to_table()就是那张表。
并发与伸缩
并发不由 thread_count 这类参数决定,而是由 ConcurrencySettings + 自动伸缩池决定。默认 min=1 / desired=10 / max=100 / max_tasks_per_minute=inf。本机靶站每个请求故意睡 0.25 秒,20 个 URL 实测:
1 | # -*- coding: utf-8 -*- |
1 | 默认(desired=10,max=100) 20 个请求(每个 0.25 秒)耗时约 1 秒(四舍五入到整秒,抖动在 ±0.05 秒级) |
- 默认配置下 20 个请求一秒内跑完(输出里的耗时四舍五入到整秒,避免抖动):串行要 5 秒的活儿压缩到 1 秒,靠的就是默认
desired_concurrency=10。 - 只写
ConcurrencySettings(max_concurrency=1)会当场ValueError: desired_concurrency cannot be greater than max_concurrency,因为desired还是默认的 10。要降并发必须三个一起写。 min=desired=max=20与默认配置的耗时看不出差别(都是 1 秒):这批 URL 太少,10 和 20 都够快,看不出差别是正常的;要压出差别得加长延迟或加请求数。max_tasks_per_minute=30是真正的限流(礼貌抓取该用它):同样 20 个请求被拉到 40 秒,是「不限流」那组的四十倍。- 关掉自动伸缩没有单独开关,把
min/desired/max三个写成同一个数就等于定并发。
robots.txt 与礼貌抓取
respect_robots_txt_file 默认是 False,也就是说 crawlee 默认不看 robots.txt:
1 | # -*- coding: utf-8 -*- |
1 | 默认 respect_robots_txt_file=False 进 handler: ['/private/one', '/private/secret', '/public'] 站点收到: ['/private/one', '/private/secret', '/public'] |
- 打开之后,
Disallow: /private/下的两条根本没发到站点(靶站只收到/public和/robots.txt两次请求),也不是「请求后被丢弃」——是在入队前就被filter_url拦掉,统计里连 failed 都不算。 - 想按 robots 抓但只对自己生效的目录,用
context.use_state或自己维护黑名单;框架这层只提供 robots 一个开关。 - 礼貌抓取三件套:
max_tasks_per_minute限流、max_requests_per_crawl限量、respect_robots_txt_file=True守规矩。
浏览器渲染那一档
数据是 JS 生成到 DOM 里的,HttpCrawler 就只能拿到空壳。同一个 URL 两个爬虫各跑一次(spa1 是前端渲染靶站):
1 | # -*- coding: utf-8 -*- |
1 | [HttpCrawler] 原始 HTML 长度 = 952,class="el-card 出现 0 次 |
- 原始 HTML 里 0 张卡片,
PlaywrightCrawler渲染完才有 10 张——差别不在 crawlee,在于有没有真的跑 JS。 - handler 里必须自己等:
await context.page.wait_for_selector("div.el-card")。crawlee 只保证导航完成(goto返回),不保证 Vue 把数据填上。 - 两个爬虫在同一个进程里跑时,
request_manager要各给一个具名队列,否则第二个会因为「URL 已经处理过」直接跳过(见 RequestQueue 那节)。
PlaywrightCrawler 的构造参数、以及文档里那些老写法在本机 1.11.1 的下场:
1 | # -*- coding: utf-8 -*- |
1 | PlaywrightCrawler.__init__ 的真实参数(逐个一行,* 之后都是关键字参数): |
- 真实参数只有 10 个,视口、UA、locale 都塞在
browser_new_context_options里(Playwright 的new_context参数原样透传)。 viewport、launch_options、save_cookies_into_context、max_concurrent_crawling这些文档站上的名字在 1.11.1 直接TypeError——那批示例是 JS 版或旧版的,别照抄。- 屏蔽静态资源用
@crawler.pre_navigation_hook里的context.block_requests(url_patterns=[".png"]):靶站一次都没收到图片请求。不给 patterns 时用它的默认黑名单,里面连.css都拦,样式敏感的单页应用可能因此渲染不出来。 fingerprint_generator默认是'default',它会覆盖你在browser_new_context_options里写的user_agent;想用自己的 UA 就把它设成None(上面第一段就是这么跑的)。get_snapshot()在本机版本不接受任何参数,返回PageSnapshot(html=..., screenshot=...);文档里get_snapshot({ screenshot: true })的写法会TypeError。
浏览器侧要并发,改的是 concurrency_settings(max_concurrent_crawling 已经不存在):
1 | from crawlee._types import ConcurrencySettings |
代价很直接:4 并发就是 4 份页面内存,伸缩那条链路上限是 100(并发那节有实测),浏览器爬虫要自己把 max_concurrency 压到机器扛得住的数。
SPA 怎么拿全量
分页控件长什么样,决定了 enqueue_links 有没有用。spa1 的分页是 <li>,没有 href;而它的列表接口能直接按 offset 翻:
1 | # -*- coding: utf-8 -*- |
1 | 浏览器侧: /: 10 条,第一条「霸王别姬 - Farewell My Concubine」 | /page/2: 10 条,第一条「V字仇杀队 - V for Vendetta」 |
- 浏览器侧:
/和/page/2都能渲染出 10 条,说明可以直接手工下发/page/N;但分页控件里<a>是 0 个,enqueue_links一条也发现不了。 - 接口侧:先打一次
/api/movie?limit=10&offset=0问出count=104,再在 handler 里用context.add_requests递推下一页的 offset,11 个请求拿满 104 条。 - 结论:能走接口就别开浏览器。这一站用 HttpCrawler 打接口是 11 个请求、几百字节 JSON;用 PlaywrightCrawler 是 2 个完整页面外加渲染。
- 接口返回的字段名以实际响应为准(本机这个站是
count/results,不是items),写代码前先看一眼真实 JSON。
自适应爬虫
AdaptivePlaywrightCrawler 想解决的就是上一条那个抉择:静态能拿到就别开浏览器。它的选择逻辑在 rendering_type_predictor 里,可以换成你自己的:
1 | # -*- coding: utf-8 -*- |
1 | AdaptivePlaywrightCrawler.__init__ 的参数(逐个一行): |
RenderingType只有'static'和'client only'两个值;工厂方法with_parsel_static_parser(...)/with_beautifulsoup_static_parser(...)是官方推荐入口,直接__init__时static_parser是必填的。- 用固定 predictor 能看清两条路的差别:静态解析对 ssr1 拿 10 张卡、对 spa1 拿 0 张;走浏览器时 ssr1 也是 10 张,而 spa1 的
parsed_content仍是 0 张——自适应的浏览器分支不会替你等渲染,得在 handler 里自己wait_for_selector。 - 默认的
DefaultRenderingTypePredictor(detection_ratio=0.1)是逻辑回归 + 随机探测,所以同一个 URL 在探测期间 handler 会被跑两遍(browser 一遍、static 一遍),上面第 3 段就是两行。写副作用(入库、发消息)时要按「至少一次」来想。 - 官方文档说的「先 HTTP 不行才上浏览器」不准确:实际是预测器给概率、按
detection_ratio随机抽样本再学习。想彻底可控,就自己实现RenderingTypePredictor的两个方法。
下载文件的爬虫
FileDownloadCrawler 是专门存二进制的一个类,stream 参数决定整包缓冲还是逐块写:
1 | # -*- coding: utf-8 -*- |
1 | 靶站上的文件: 2097152 字节,sha256 前 12 位 4c7511b61ebd |
- 两种模式产物完全一致(同一个 sha256),差别只在内存:
stream=False时整份文件先进内存再落盘,大文件就吃stream=True。 stream=True时 handler 必须消费read_stream(),不消费就等于跳过下载。- 这个上下文的
get_snapshot()给的html/screenshot都是None:二进制流不可重读,快照对它没意义。
AI 抽取的两个类
crawlee 1.11.1 里还有两个 experimental 的爬虫类,本机装了依赖但没配模型密钥,所以只测构造与接口,不测抓取效果:
1 | # -*- coding: utf-8 -*- |
1 | == PydanticAiCrawler 继承: AbstractHttpCrawler <- BasicCrawler <- ABC <- Generic |
PydanticAiCrawler底层是 Parsel(上下文里同时有selector和 AI 的extract),构造当场就要模型密钥,报pydantic_ai.exceptions.UserError——它不是「跑到一半才失败」,是建对象就失败。StagehandCrawler是PlaywrightCrawler的子类,多了stagehand_options;handler 里的context.page是 Stagehand 包装过的对象,act/extract/observe三个方法都在。- 两个类的 docstring 都明写 experimental、公开 API 未来会变,学习笔记级别的用法先记住「有这两个东西、要模型密钥、别上生产」就够了。
代理怎么配
代理在 crawlee 里是一个独立对象 ProxyConfiguration,塞给任何爬虫的 proxy_configuration 参数就行。它不在 crawlee 顶层导出里,要从 crawlee.proxy_configuration 导入。先把它的三个入口和取代理的方法看清:
1 | # -*- coding: utf-8 -*- |
1 | ProxyConfiguration 在不在 crawlee 顶层导出里: False |
- 三个参数
proxy_urls/tiered_proxy_urls/new_url_function三选一(docstring 原话),都给会当场报错。 proxy_urls是 round-robin,列表里放一个None就表示「这一发直连」,第 3 次取到的就是 None。- 同一个
session_id会固定命中同一个代理——这就是「一个会话绑一个出口 IP」的实现方式;不给 session_id 才会在池子里乱转。 new_url_function是位置参数(session_id, request)调用,写错签名不会给你TypeError而是被包成ValueError: The provided "new_url_function" did not return a valid URL——函数内部自己抛的异常也会被这句话盖掉,排查时先单独跑一下这个函数。- 分档代理(
tiered_proxy_urls)按域名记账:低档在这个域名上记过错,下一次就升档;ProxyInfo.proxy_tier告诉你当前是第几档。
光看 API 不够,本机起了一个转发代理和一个回显源站,真走一遍:
1 | # -*- coding: utf-8 -*- |
1 | 1) 两个普通代理轮转(URL 里带序号方便对照): |
- 源站看到的
Via/X-Forwarded-For是代理加的,context.proxy_info拿到的就是这一发实际用的代理。 - 第二个例子故意不套代理(把源站自己填进
proxy_urls),Via就是None——判断「代理到底走没走」,看源站收到的头最靠谱,别只看代码里配了。 - 第三个例子里 tier0 那个代理专门返回 403,于是
proxy_tier变成 1、由 tier1 成功:这就是「这批 IP 被站点拉黑就自动换下一批」的机制。 - 代理 URL 里的密码在
ProxyInfo的 repr 里被掩成***,日志里不会泄出来。
会话与封禁
use_session_pool=True 是默认开的。会话对象负责记账:什么状态码算被封、错误分涨到多少就退役。
1 | # -*- coding: utf-8 -*- |
1 | Session.__init__ 的默认值: |
- 默认认为
401 / 403 / 429是「这个会话被封了」,ignore_http_error_status_codes能把某个码从这份名单里摘掉。 mark_bad一次加 1 分,超过max_error_score=3.0就is_blocked;一旦不可用,crawlee 还会顺手retire()再加 3 分,所以分数是 1 → 2 → 6 → 10 这样跳的,不是每次都 +1。- 碰上 403 时默认会换会话重试,一共
max_session_rotations=10次,最后报SessionError;关掉会话池就变成一次性的HttpClientStatusCodeError。这两句话是同一个现象的两种表现,调「要不要换号」时看这个差别。 - 404 不在重试名单里:
HttpClientStatusCodeError属于 4xx,默认不重试(重试那节有对照)。 SessionPool的max_pool_size=1000、persist_state_key='CRAWLEE_SESSION_POOL_STATE',默认persistence_enabled=False,也就是说会话池的状态默认不入库。
HTTP 客户端与指纹
crawlee 自己不发请求,它包了三个客户端:impit(默认)、httpx、curl_cffi。三个客户端的参数和它们实际发出的头:
1 | # -*- coding: utf-8 -*- |
1 | ImpitHttpClient 的参数: |
- 默认客户端是
ImpitHttpClient,它的browser默认值就是'firefox',所以裸跑也带 11 个 Firefox 的头(第一节的默认请求头是同一个实验的另一种问法)。 browser=None只剩 3 个头(accept、accept-encoding、host),这种「干净得反常」的请求反而最容易被拦。CurlImpersonateHttpClient是唯一能伪装 TLS/HTTP2 指纹的客户端,impersonate的名字要写firefox133这种(firefox_120直接ImpersonateError),名字清单在curl_cffi.requests.BrowserType里。HttpxHttpClient会默认挂一个HeaderGenerator,所以它自己也能补出浏览器头;header_generator=None就退回 httpx 原生那 5 个。- 指纹这套(
DefaultFingerprintGenerator、HeaderGenerator)真正接进爬虫的地方只有PlaywrightCrawler(fingerprint_generator=...);HTTP 爬虫没有这个参数,要伪装就只能自己往Request.headers里塞生成的头。
uniqueKey 与存储结构
去重的本体是 uniqueKey:crawlee 先对 URL 做归一化(去 fragment、尾部斜杠、查询参数排序),再拿它当主键。这份归一化规则、以及「跑完一次磁盘上到底留下了什么」,都在下面这段里:
1 | # -*- coding: utf-8 -*- |
1 | 1) uniqueKey 怎么算(同一个 key 就是同一个请求,会被去重): |
- 归一化会吃掉这些差异:
/one与/one/、/two与/two#frag、?a=2&a=1与?a=1&a=2、大小写与默认端口,POST 的 body 默认不参与去重(同 URL 不同 payload 是同一个请求)。 - 想按 payload 区分,构造时给
use_extended_unique_key=True,key 变成POST|<payload 摘要>|<headers 摘要>|<url>。 - 因为 6 条种子只归并成 3 个请求,靶站确实只被打 3 次,队列里
total=handled=3,数据集 3 条——去重发生在入队时,不是抓完之后丢弃。 - 目录是三类存储各一个文件夹:
datasets/default/000000001.json(序号固定 9 位)、key_value_stores/default/(爬虫统计__CRAWLER_STATISTICS_0和队列状态__RQ_STATE_<队列 id>都在这)、request_queues/<队列名>/<uniqueKey 摘要>.json。 - 队列名只允许小写字母、数字和中间的连字符,
my_queue会ValueError(见报错速查那节)。
配置与环境变量
Configuration 是一个 pydantic BaseSettings,一共 15 个字段,每个字段都能用 CRAWLEE_* 或 APIFY_* 环境变量给:
1 | # -*- coding: utf-8 -*- |
1 | 1) Configuration 的全部字段(pydantic BaseSettings,字段名对应大写环境变量): |
- 环境变量名不是「字段名大写」这么简单:
persist_state_interval吃的是CRAWLEE_PERSIST_STATE_INTERVAL_MILLIS,storage_dir吃的是CRAWLEE_STORAGE_DIR(APIFY 那个叫APIFY_LOCAL_STORAGE_DIR)——表格里第二列是逐个实测出来的。 - 时间类字段要 ISO8601 时长写法(
PT60S),直接写60会time_delta_parsing报错;带_MILLIS后缀的变量收毫秒。 - 非法值(
CRAWLEE_LOG_LEVEL=NOPE)是构造时报错,不是静默忽略。 - 而
CRAWLEE_DEDUPLICATION_POLICY、CRAWLEE_MAX_CRAWLING_TIME、CRAWLEE_PROXY_URLS这些 JS 版才有的环境变量在 Python 版被完全忽略:Python 的 Configuration 只有这 15 个字段,多余的环境变量不报错也不生效。去重、限时、代理都得在代码里写。 purge_on_start(默认 True)只清默认存储,具名存储(例子里的keepme)不会被清——这就是「用RequestQueue.open(name=...)做增量爬」能成立的根据。- 最后一段是个坑:
purge_on_start=False时,__CRAWLER_STATISTICS_0这条统计记录也留着,于是第二次进程一睁眼就以为自己已经跑满了max_requests_per_crawl,一个请求都不发(靶站被打 0 次)。要靠「不清存储 + 限流」做定时增量的话,记得别配max_requests_per_crawl,或者每次换个队列名。
停一停再续爬
长跑任务中途要停,两种手段:crawler.stop()(体面收尾)和 max_requests_per_crawl(限量)。停了之后能不能接着爬,看队列里请求的状态:
1 | # -*- coding: utf-8 -*- |
1 | 1) 爬到第 3 条调用 crawler.stop():已处理 ['/item/1', '/item/2', '/item/3'] |
- 第 1 段爬到第 3 条调用
crawler.stop():total=22(列表页 + 20 条详情 + 一个外域链接)、handled=4、pending=18,没抓的 18 条原样躺在队列里。 - 第 2 段换新爬虫、同一个队列名、
CRAWLEE_PURGE_ON_START=false再run([]):不重新下发种子,直接消费队列里剩下的 18 条,最后handled=22。 - 「续爬」不会重放列表页(
/index命中数 0),因为它是 handled 状态;也不会重复入库(数据集 21 条无重复)。 - 第 4 段是「取出来没标 handled」的下场:
is_empty=True但is_finished=False,同进程里再run([])不会碰它,必须显式reclaim_request才重新排队。真实崩溃时新进程的队列客户端会把上次 in-progress 的请求回收重试,所以同一条内容可能被抓两次——入库要靠uniqueKey或业务主键兜底。
内存存储与脚手架
本地跑测试不想产生 storage/ 目录,可以整体换成内存存储;起新项目则可以用官方 CLI 生成骨架:
1 | # -*- coding: utf-8 -*- |
1 | 1) MemoryStorageClient:跑完磁盘上什么都没有 |
storage_client=MemoryStorageClient()之后磁盘上一个文件都没有,数据只活在这个client对象里:同一个 client 再open一次还在,换一个 client 就等价于重开进程(本机上第二个 client 里读到的还是 1 条,因为Dataset.open()不带 client 时读的是全局默认 client——真要每次干净,就连 client 一起新建)。crawlee这个命令由pip install 'crawlee[cli]'带出来,只有一个子命令create;crawlee run/check/purge这些(JS 版的)命令在 Python 版不存在。create的五个选项能全程非交互:--crawler-type(beautifulsoup/parsel/adaptive-/playwright-)、--http-client(impit/httpx/curl-impersonate)、--package-manager(poetry/pip/uv)、--start-url、--apify/--no-apify,不给就会进交互式选择。- 生成的是
uv工程(pyproject.toml里依赖写成crawlee[parsel,httpx])、代码切成main.py+routes.py,还带一个Dockerfile(基镜像apify/actor-python:3.13)。跑起来是uv sync然后uv run python -m demo_parsel。
三个框架怎么选
Scrapy、feapder、crawlee 三个都在本机装过、跑过,写法上的差别是这样:
| 要做的事 | Scrapy | feapder | Crawlee |
|---|---|---|---|
| 装 | pip install scrapy |
pip install feapder |
pip install 'crawlee[parsel]' |
| 起步 | 建工程 + items + pipelines + settings 四处 | 建 Spider 子类 + Item | 一个 ParselCrawler + 一个 handler |
| 解析 | selector(CSS/XPath) | 自己选(正则/selector) | 三种:bytes / parsel selector / soup |
| 跟链接 | CrawlSpider 的 rule 或 response.follow_all |
start_requests 里自己拼 |
context.enqueue_links(strategy=...) |
| 去重 | 默认 RFPDupeFilter,只记指纹 |
任务表 / Redis 游标 | 队列主键 uniqueKey(归一化 URL) |
| 断点续爬 | 靠 JOBDIR + dupefilter 落盘 |
MySQL/Redis 任务表 | storage/ 目录里的队列 + purge_on_start=False |
| 并发 | CONCURRENT_REQUESTS 定值 |
SPIDER_THREAD_COUNT 定值 |
ConcurrencySettings 三个数 + 自动伸缩 |
| 浏览器 | 要接 scrapy-playwright |
有 BrowserSpider(Selenium/Playwright 两套) |
原生 PlaywrightCrawler,还有自适应版 |
| 代理 | ProxyMiddleware + ROTATING_PROXY_LIST |
自带代理池(Redis) | ProxyConfiguration,支持分档自动升档 |
| 入库 | Item Pipeline(写几行代码) | Item Pipeline + 批量化接口 | 默认只进 storage/datasets,外部库自己写 |
选谁的建议:
- 要「入库、去重、定时、报警」这一整套调度,feapder 更省事,它把任务表和批量化做进了框架(见《爬虫-Feapder》)。
- 要高度定制的管道、中间件生态、大量现成扩展,Scrapy 仍是首选(见《爬虫-Scrapy》),代价是工程结构固定,改起来要动 settings。
- 要「一个脚本就能跑」+ 静态/浏览器自动切换 + 反封禁(代理分档、会话轮换、TLS 指纹伪装),crawlee 最顺手,它是 asyncio 一路写到底,API 都在对象上而不是配置项里。
- crawlee 是
requests/httpx这一路的「框架化」版本,学习曲线比 Scrapy 平;但它默认不写数据库,落地环节要自己接(context.push_data只写到本地数据集)。 - 依赖体积也是实际差别:
crawlee裸装 21 个包就够用,装[all]会拉到 127 个包,Playwright 那条路还要再playwright install chromium。
常见报错速查
这一节全是本机复现过的报错,原文照抄。看懂这几句基本就能定位问题:
1 | # -*- coding: utf-8 -*- |
1 | 1) 直接 Request(url=...) 构造: |
排查顺序建议:
- 报
unexpected keyword argument,先怀疑自己抄的是 JS 版或旧版文档;用inspect.signature(类.__init__)打一遍参数最快(本文各处都是这么打的)。 - 报
No handler matches label,是标签路由:要么种子带上label,要么配@crawler.router.default_handler兜底。 - 什么都不抓、统计里
requests_total=0,八成是默认队列记得这些 URL:换个具名队列,或删掉storage/。 requests/httpx习惯里的headers=、verify=在爬虫构造函数上都不存在,前者写进Request.from_url(..., headers=...),后者属于 HTTP 客户端。
参考
- Crawlee for Python 仓库(本文所有 API 以本机
crawlee 1.11.1的inspect.signature为准) - 官方文档 · 写爬虫
- 官方文档 · API 参考
- 官方文档 · 常见问题
- 靶站 Scrape Center(
ssr1服务端渲染、spa1前端渲染、proxypool代理池) - impit(crawlee 默认 HTTP 客户端的底层库)
- curl_cffi(TLS 指纹伪装)