爬虫-Crawl4AI
这篇讲什么
- Crawl4AI 0.9.5 在 Python 3.14.10 上逐条实测:它是一个面向 LLM 时代的异步抓取器——抓完直接给你 markdown,
AsyncWebCrawler.arun()一次调用就把「起浏览器、等 JS、滚动、抽正文、转 markdown」全做完。 - 本篇把每个参数面都按
inspect.signature打全并逐个实测:BrowserConfig44 个参数、CrawlerRunConfig100 个参数、CrawlResult29 个字段、CacheMode5 种、深度抓取策略与过滤器链、四种结构化抽取、markdown 生成器与两种内容过滤器、会话与 cookie、反检测的三个适配器。 - 三条只有实测才知道的坑:默认
cache_mode=BYPASS(不是「启用缓存」);它自带一个结构性反爬判据,小页面会被误判成「被拦截」(success=False但正文其实抓到了,还会顺手打一行云广告);robots.txt默认完全不遵守,check_robots_txt=True才拒绝。 - 与系列其它篇的口径差别:抓的靶站沿用 ssr1/spa1(外加本机靶站做协议级演示),但抓取对象是「一页 → 一段 markdown」,没有 Scrapy 的 Pipeline、也没有 Crawlee 的队列存储;要断点续爬得自己接
cache_mode与session_id。
装哪一种
官方 quick-start 只给一句 pip install crawl4ai,然后补一句 crawl4ai-setup 装浏览器;全功能写法是 pip install "crawl4ai[all]"。这两种装法在这台机器上差了 22 个包和 800 MB,段代码里把两个环境各跑一遍取数:
1 | # -*- coding: utf-8 -*- |
1 | 最小装 版本 0.9.5 | 发行包 97 个 | site-packages 0.70 GB |
- 最小装 97 个发行包、site-packages 0.70 GB;
[all]是 119 个、1.55 GB。多出来的 22 个包基本就是 torch 全家桶:torch、transformers、sentence-transformers、scikit-learn、safetensors、mpmath、sympy,加上selenium、trio、pysocks、typer、pypdf这些零头。也就是说[all]那 800 MB 是为了语义抽取(CosineStrategy)和 PDF 解析准备的。 - 常规抓取不需要
[all]:最小装已经带上了playwright、patchright、aiohttp、shapely/alphashape、tiktoken、trimesh(这些是必需依赖,不是 extras),所以「装哪个」的判断依据是你要不要跑语义模型。 - 两个环境的
crawl4ai版本完全一致(0.9.5),说明 extras 只影响可选依赖,不影响核心版本。装之前建议先跑一次 pip 的 --dry-run 看它会不会动核心包——这个系列里已经吃过几次 pip 静默降级的亏。
版本号怎么取
这一条只有一行代码,但踩中的人不少:crawl4ai.__version__ 不是版本号字符串,而是一个子模块对象。
1 | # -*- coding: utf-8 -*- |
1 | import crawl4ai; crawl4ai.__version__ 的类型 -> module |
- 打印
crawl4ai.__version__会得到<module 'crawl4ai.__version__' from '…\__version__.py'>,写日志或做版本断言时这是废的。 - 正确写法有两条:
from crawl4ai.__version__ import __version__(拿到0.9.5),或者importlib.metadata.version("Crawl4AI")。注意包名大小写:PyPI 上的规范名是Crawl4AI,version("crawl4ai")在有些环境里也能过(distribution 名大小写不敏感),但按官方写法用Crawl4AI更稳。 - 上面那段代码没有
repr全量打印,只打了前 9 个字符——因为完整的 repr 里带 site-packages 绝对路径,换一台机器就不一样了,写进文章就没法复现。
四个命令行
包里带了四个可执行入口,官方文档把它们描述成「装好之后的自检三板斧」。这段把三个跑完(第四个用离线保护跑,避免真下大模型),退出码与关键输出原样贴出:
1 | # -*- coding: utf-8 -*- |
1 | $ crawl4ai-setup -> 退出码 0 |
crawl4ai-setup不是「只在第一次装浏览器」。它内部的命令是playwright install --with-deps --force chromium加patchright install --with-deps --force chromium,--force意味着每次都重下浏览器(约 2 × 118 MiB 再加 headless shell)。上面那次跑加了CRAWL4AI_MODE=api,才走成「只建家目录 + 初始化 SQLite + 跑迁移」这条快路,日志里Skipping Playwright browser installation就是证据。crawl4ai-doctor要联网。它不是静态检查浏览器目录,而是真的起浏览器抓一次https://crawl4ai.com,抓通才打✅ Crawling test passed!。所以离线环境里 doctor 失败不代表你装坏了。crawl4ai-migrate会先备份再迁移,备份文件名带时间戳(输出里已归一化成<时间戳>),跑一次多一个文件,别惊讶家目录里为什么躺了一排crawl4ai.db.backup_*。crawl4ai-download-models会去 HuggingFace 拉文本分类器,网络受限时退出码 1、报huggingface_hub.errors.LocalEntryNotFoundError。这里用HF_HUB_OFFLINE=1复现,没有真的下载模型。- 附带一条:库和 CLI 默认会往输出里打「Crawl4AI Cloud is live」的广告横幅,这个横幅一天只打一次、看家目录里的标记文件,所以对可复现输出是有害的——本篇所有块都在脚本开头写了
os.environ.setdefault("CRAWL4AI_NO_CLOUD_NOTICE", "1")(认1/true/yes)。
浏览器这一层
文档说 crawl4ai 支持 chromium/firefox/webkit,还提了 patchright 那条undetected 路线。实际默认走谁、本机装了些什么、browser_type 写错会怎样,这段全部量出来:
1 | # -*- coding: utf-8 -*- |
1 | 适配器名字 = ['BrowserAdapter', 'PlaywrightAdapter', 'StealthAdapter', 'UndetectedAdapter'] |
- 默认引擎是 playwright,不是 patchright:源码里写死
self.adapter = browser_adapter or PlaywrightAdapter(),只有显式传UndetectedAdapter()才会use_undetected=True去 importpatchright.async_api。好消息是 patchright 与 playwright 的browsers.json是同一个 revision 1248,切 undetected 不需要再下一次浏览器。 - 本机浏览器缓存目录里只有
chromium-1248、chromium_headless_shell-1248、ffmpeg-1013、winldd-1007四项,firefox/webkit 根本不在(crawl4ai-setup只装 chromium)。 browser_type写错的表现分两种,这条必须记住:firefox/webkit抛的是「浏览器可执行文件不存在」那句 Executable doesn’t exist,而safari这种根本不存在的取值却success=True——它不校验取值,悄悄按 chromium 跑了。channel="chrome"、channel="msedge"同理,能跑但跑的是本机那个引擎。- 所以:想确认「我真的用了想要的浏览器」,唯一可靠的办法是回页面里读指纹(
navigator.userAgent、navigator.plugins.length),本篇「反检测那三层」一节就是这么做的。
本机靶站
后面的章节大量使用一个进程内起的小靶站(robots.txt、服务端计数、表单回显、懒加载、SPA、嵌套链接都在里面)。协议级的行为只能在能自控的靶站上测:在线练习站不会给你 Disallow: /private/,也不会告诉你「这一枪到底到没到服务端」。先把靶站自己验一遍,后面所有输出的判据都在这里:
1 | # -*- coding: utf-8 -*- |
1 | /list -> 200 <!doctype html><html><head><meta charset='utf-8'><ti |
各端点的分工:
/list、/d/N:列表页与详情页,结构化抽取一节用它们的class="name"、.price、<table>。/count:服务端计数,缓存那一节就是靠它判「第二次到底读没读缓存」——不看耗时,看服务端被打了几次。URL 上带?mode=<模式名>,让五种CacheMode各用各的缓存键,免得前一种写完、后一种直接命中。/robots.txt与/private/:robots.txt写Disallow: /private/,/private/里有PRIVATE-SECRET-BODY这串指纹,遵守与否一眼就能判。/echo:do_POST回显请求体,表单与 POST 那一节用它。/lazy:必须滚动才追加 20 条目的页面;/spa:延时 200ms 的 JS 才渲染 8 行的页面。这两个是「等待类参数」的实验台。/table:一张真表格,表格抽取用;/lvl1/a → /lvl2/* → /lvl3/* → /lvl4/e:链式四层,深度抓取用它数max_depth。/ua、/fp:把请求头里的 UA 和页面里的navigator.webdriver、navigator.plugins.length、innerWidth×innerHeight回显出来,UA/视口/反检测三节共用。- 判「请求真的到了」的口径:
HITS字典由靶站自己按路径计数,最后sorted(HITS.items())打印;/count的响应体是 JSON,小页面会被 crawl4ai 的结构性判据误判成「被拦截」(下一节专门讲),所以那些地方只把它当服务端计数源用,不看success。 - 每个块结尾都
srv.shutdown(),并且各块用不同端口(8060 起),不给下一块留残留监听——Windows 上HTTPServer.allow_reuse_address=1会让两个进程同时绑一个端口把请求偷走,这个亏已经吃过。
抓一页给什么
官方最小示例只有四行:async with AsyncWebCrawler() as crawler: result = await crawler.arun(url=...)。这段照抄官方写法,但把判读顺序按文档说的 success → status_code → error_message → markdown 排一遍,并且用两种引擎各抓一次 ssr1 与 spa1:
1 | # -*- coding: utf-8 -*- |
1 | 服务端渲染 ssr1 浏览器:状态 200,抓到片名 10 个,markdown 行数 74 |
- ssr1(服务端渲染)两种引擎拿到的东西一模一样:浏览器抓是 74 行 markdown、10 个片名;不执行 JS 的 HTTP 策略也是 74 行、10 个片名。也就是说对服务端渲染的站点,开浏览器是纯浪费。
- spa1(前端渲染)差别就在这儿:浏览器抓到 3 行 markdown(页面骨架 + 一点提示),HTTP 策略只有 1 行而且
success=False——它连骨架都没给过判据。片名两种都是 0 个,因为 spa1 的数据要靠接口(见《爬虫-Requests》里limit/offset那条线)。 - 片名的取法要记一下:
<a href="/detail/1" class="name">里套<h2 class="m-b-sm">,class="name"在<a>上、不在<h2>上,写<h2 class="name">一个都取不到;能用的是class="name">\s*<h2[^>]*>([^<]+)。 - 判读顺序不能颠倒:
result.success是布尔、status_code是 HTTP 状态、error_message是文字。crawl4ai 有一类「状态 200 但 success=False」的情况(下一节),只看状态码会漏。
结果对象的字段
CrawlResult 是 Pydantic 模型(不是 dataclass),所以 dir() 出来的公开成员几乎全是 Pydantic 自带方法,真正该看的是 model_fields:
1 | # -*- coding: utf-8 -*- |
1 | model_fields 总数 = 29 |
- 29 个字段,按用途分四组:内容是
html、cleaned_html、fit_html、markdown、extracted_content、tables、media、links、mhtml;状态是success、status_code、redirected_status_code、error_message、cache_status、cached_at;来源与元信息是url、redirected_url、metadata、response_headers、ssl_certificate、head_fingerprint、session_id;附加产物是screenshot、pdf、downloaded_files、console_messages、network_requests、js_execution_result、crawl_stats、dispatch_result。 - 文档里若干页面写的
result.meta在本机 0.9.5 会直接抛AttributeError,正确字段是metadata(实抓 ssr1 拿到{'title': 'Scrape | Movie', 'description': None, 'keywords': None, 'author': None})。报错信息还会友好地提示Did you mean: 'media'?,别照着它抄。 - 必填只有三个:
url、html、success。用CrawlResult(url=..., html=..., success=...)能构造出来,只给url会抛ValidationError(报错里数得出来缺 2 个字段)——这意味着你可以自己造CrawlResult喂给后续流程,做「先离线解析再入库」的解耦。 markdown的类型默认是str;传了markdown_generator之后是StringCompatibleMarkdown(一个「字符串兼容」类,所以str(...)、切片、in都照旧能用),要看fit_markdown这些产物得直接调生成器,见「markdown 是怎么生成的」。dir(obj)与model_fields的差别(本机是 31 对 29)就是 Pydantic 方法名混进来了,以后查任何 Pydantic 模型的字段都走model_fields。
不开浏览器的路径
不是所有站点都需要浏览器。crawl4ai 提供 AsyncHTTPCrawlerStrategy + HTTPCrawlerConfig,用 aiohttp 直接发请求,接口保持和浏览器路径一致(同样返回 CrawlResult):
1 | # -*- coding: utf-8 -*- |
1 | HTTPCrawlerConfig 的参数 7 个 = method, headers, data, json, follow_redirects, verify_ssl, downloads_path |
HTTPCrawlerConfig只有 7 个参数:method、headers、data、json、follow_redirects、verify_ssl、downloads_path。它没有viewport_width这类浏览器参数(hasattr是False),这是最容易误会的地方:换策略不是换配置对象的名字,而是换一整套参数面。- 抓 SPA 页面时
渲染行 1 | markdown 行数 3的含义要说清:那 1 处是 JS 源码里的字符串字面量,渲染结果 0 行——HTTP 路径不执行 JS,setTimeout那段永远不会跑。这也是为什么「不执行 JS」这一路只能验服务端渲染的站点。 - 抓在线 ssr1 走 HTTP 策略:片名 10 个都在,而浏览器抓到的 markdown 也是 74 行,两边内容等价。所以「值不值得开浏览器」的判据就是这一条:正文已经在 HTML 里就别开。
headers/verify_ssl/follow_redirects都实测「参数被接受且请求照发」(本机靶站没有 HTTPS,verify_ssl的实际效果只能在自签证书上看,那条在《爬虫-基本入门》的四种 verify 里测过)。POST 表单的正路见「JS 注入与表单抓取」一节。
没被拦却报被拦
这一节解释上一节里那句「success=False 但状态码 200」。crawl4ai 在返回结果前会跑一个 antibot_detector.is_blocked(状态码, html),本机 0.9.5 的判据是这样的:
1 | # -*- coding: utf-8 -*- |
1 | 光壳子(有 script 没内容) 长度 74 is_blocked -> (True, 'Near-empty content (74 bytes) with HTTP 200') |
- 判据是分层的:状态码 403/429 配上正文就判「被拦」;小于 50 KB 的 HTML 还要过一道结构性检查——没有
<body>直接判死;否则看三个信号:可见文字少于 50 字(minimal_text)、没有任何语义内容标签(no_content_elements,判据是<p|h1-h6|article|section|li|td|a|pre>)、有 script 但没内容(script_heavy_shell)。两个信号就判死;只有一个信号但页面小于 5000 字节也判死。 - 上面的输出把这把尺子量出来了:74 字节的空壳判死、126 字节但有正文的小页不判死、56 KB 的大页完全不检查;403 + 有正文判死、429 + 空正文判死。
- 于是就有了那条容易误读的实测:抓
/private/(一个 304 字节的说明页)时success=False、error_message='Blocked by anti-bot protection: Structural: no_content_elements on small page (304 bytes, 91 chars visible)',但html里PRIVATE-SECRET-BODY确实在——它不是被拦,是被自己的启发式误判。「抓到了却告诉你没抓到」比反过来更难查,所以判读顺序要加上一步:success=False时先看error_message是不是Blocked by anti-bot protection,是就回去核对html与status_code。 - 这条对写代码的直接影响:靶站/测试页要够大。本篇后来把靶站的导航区和页脚写成有实际文字的长句(见「本机靶站」一节的输出),否则每个块都会被这层判据污染,
success一路 False,测什么都像在测反爬。 - 附带一条:判定成「被拦」时库会顺手打一行云广告(
Crawl4AI Cloud is live …,一天一次、看家目录里的标记文件)。想让输出可复现,就在脚本开头设CRAWL4AI_NO_CLOUD_NOTICE=1。
BrowserConfig 参数面
BrowserConfig管的是「浏览器实例长什么样」,一共 44 个参数。下面这段把签名连默认值原样打出来,然后逐个用页面回读验证它到没到得了页面里:
1 | # -*- coding: utf-8 -*- |
1 | 参数总数 = 44 |
默认值里几个要记住的:browser_type='chromium'、headless=True、browser_mode='dedicated'、use_persistent_context=False、viewport_width/height=1080×600、viewport=None、user_agent 是一个 Linux x86_64 的 Chrome/116 固定串、enable_stealth=False、verbose=True。
实测出来的四件事:
- UA 是能被覆盖的:
user_agent="MyCrawler/1.0"之后页面里回读到的就是它。但别忘了enable_stealth=False时 UA 与真实浏览器的其它指纹位并不匹配,覆盖 UA 只是「看起来像」,这条在「反检测那三层」里量化。 - 视口有两个入口,只有一条真生效路径:
viewport_width/height=480,320改得动(回读V=480x320);viewport={"width": 320, "height": 240}这个 dict 也改得动(回读V=320x240),所以两条都能用——这与本机 0.9.5 的实现有关,之前有资料写「dict 不生效」,实测证伪了。要判生效只能用页面里的innerWidth×innerHeight,别看配置对象的属性。 java_script_enabled=False时页面回读是空的:那行-> []就是证据——JS 没跑,写document.getElementById(...).textContent的那段自然不会留下任何文字。这一条是「怎么确认自己关掉了 JS」的最省事办法。headless=False(有头)能观察到插件:PL=5,而无头模式是PL=0。这是无头浏览器最容易被识别的位之一,也是本篇「反检测」一节里唯一稳定可观察的差别。- 还有一条默认值很容易被忽略:
BrowserConfig.verbose与CrawlerRunConfig.verbose的默认值都是True,两者管的是不同的日志行(见「本机靶站」那节末尾关于静音的说明)。要可复现的输出,两个都得关。
代理与 UA
代理这一路的参数名最近改过版,实测的报错原文最能说明问题:
1 | # -*- coding: utf-8 -*- |
1 | ProxyConfig 的参数 = ['server', 'username', 'password', 'ip'] |
proxy参数已经废弃但仍能用,每次用都打一句UserWarning: The 'proxy' parameter is deprecated and will be removed in a future release. Use 'proxy_config' instead.。正主是proxy_config,它接ProxyConfig(server=..., username=..., password=..., ip=...)——这四个字段就是签名原文。- 名字写错不会被放过:
proxy_cfg=直接TypeError: BrowserConfig.__init__() got an unexpected keyword argument 'proxy_cfg'. Did you mean 'proxy_config'?(这个Did you mean提示本身是对的,别照着网上抄proxy_cfg)。 - 格式错在构造期就报:
proxy="not-a-url"→ValueError: Invalid proxy string format: not-a-url。 - 指向一个没人听的端口时不抛异常,而是
success=False、状态是None,错误信息是库内部那句以 Unexpected error in _crawl_web 开头、带它自己行号的话。也就是说代理挂了你要自己看error_message,不能只靠捕获异常。 - 对照项:不带代理时同一个 URL
success=True、状态 200。
cookie 与登录态
cookie 有三条入口,本段把三条都实测一遍,判据是靶站把 document.cookie 写回页面:
1 | # -*- coding: utf-8 -*- |
1 | 同一次会话:服务器 Set-Cookie 之后 -> ['COOKIE=cookiesEnabled=true; probe_c4a=1'] |
- 服务端
Set-Cookie之后,同一个爬虫实例(同一个浏览器上下文)里的后续请求会自动带上:COOKIE=cookiesEnabled=true; probe_c4a=1。新开一个实例就没了(只剩cookiesEnabled=true,那是浏览器自己写的)。 - 想把 cookie 带进新会话,两条正路:
BrowserConfig(cookies=[{"name": ..., "value": ..., "url": ...}]),或者BrowserConfig(storage_state=<playwright 状态文件>)。两种都被接受并真的带到了页面里(输出里两行分别回显from_config=1与from_state=1)。 storage_state的 JSON 就是 playwright 的标准格式:{"cookies": [{name, value, domain, path, expires, httpOnly, secure, sameSite}], "origins": []},字段名不能省(expires给-1表示会话 cookie,sameSite只能是Strict/Lax/None)。- 参数面上要记住两点:
CrawlerRunConfig里没有任何 cookie 参数(试探结果是空列表),cookie 只归BrowserConfig管;而文档里出现过的cookies_file参数在本机 0.9.5 不存在("cookies_file" in bnames是False),BrowserConfig里 cookie/state 相关的只有storage_state与cookies两个。
session_id 管到哪
session_id 常被当成「会话保持」来用,但它到底管到哪一层必须实测。这段把同实例/新实例/同 session_id/换 session_id/持久化目录/新进程六种情况摆在一起,判据仍然是靶站回显的 document.cookie:
1 | # -*- coding: utf-8 -*- |
1 | ① 同一个爬虫实例里再打一次 -> ['COOKIE=cookiesEnabled=true; probe_c4a=1'] |
结论逐条对照输出:
- cookie 只活在「同一个爬虫实例」里(① 有、② 没有)。
session_id不会把 cookie 跨实例带过去:同一个session_id在新实例里读不到(③),换一个也读不到(④);先带session_id=bind-x存一次、再用它读,还是没有(⑤),换个号也一样(⑥)。所以session_id不是「保持登录态」的开关,它是给缓存/调度侧做分组用的标识符(CrawlerRunConfig里会话相关的参数只有session_id与三个proxy_session_*)。use_persistent_context=True+ 同一个user_data_dir这次也没带过来(⑦COOKIE=NONE),而且目录里非空但没有Cookies相关项——说明上下文没被真正落盘/复用。跨进程的两个探测(⑧⑨)同样读不到。- 于是要跨进程复用登录态,本篇实测可用的只有
storage_state(上一节),不是session_id、也不是use_persistent_context的默认用法。 - 顺带把
AsyncWebCrawler的方法面钉住:公开方法只有arun、arun_many、start、close、amap_domain、aprocess_html、aseed_urls、nullcontext八个,文档里出现过的crawl_pages与session()在本机 0.9.5 都不存在。想「一组 URL 共享一个会话」,能做的就是给它们同一个session_id(共享的是缓存/统计口径,不是浏览器上下文)。
1 | # -*- coding: utf-8 -*- |
1 | AsyncWebCrawler 的公开方法 8 个: |
这一段的输出把上面最后一条钉死了:公开方法八个、crawl_pages/session 都不在、hook_* 相关参数是空列表(本机 0.9.5 的 CrawlerRunConfig 没有 hook 系列参数,文档 Advanced 页里那些 hook_request_pre 之类要另行核对版本)。注意最后四行:同一个爬虫实例里先 /setcookie 再 /cookie,cookie 在;带同一个 session_id 也是同一实例,所以照样在——这正好说明「留得住」和「session_id」无关。
CrawlerRunConfig 参数面
CrawlerRunConfig 是这台机器上真正的中枢:100 个参数。这段把参数名全列出来(分行打印),再把常用的默认值逐个贴出,并验证两个已删的老参数:
1 | # -*- coding: utf-8 -*- |
1 | 参数总数 = 100 |
默认值里这几条是「和文档/直觉不一样」的:
cache_mode默认是CacheMode.BYPASS,也就是默认不读缓存、抓完覆盖写。别按「默认会缓存」来理解第一次慢、第二次快的错觉。word_count_threshold=1、page_timeout=60000、wait_until='domcontentloaded'、wait_for=None、magic=False、scan_full_page=False、check_robots_txt=False、only_text=False、verbose=True、excluded_tags=None、css_selector=None、deep_crawl_strategy=None、session_id=None、screenshot=False、pdf=False、table_extraction=None。- 参数面上有四个「看起来像开关但其实不是」的点:
excluded_tags/css_selector/target_elements是 None(不是空列表),所以传[]和省略不传是两回事;bypass_cache/disable_cache/no_cache_read/no_cache_write这四个老参数名仍在签名里但已经不管用(下面这条实测);dispatcher不在这个类的参数里,它属于arun_many(见批量抓取一节)。 - 老写法实测:
keep_html=True直接TypeError: ... got an unexpected keyword argument 'keep_html';bypass_cache=True更妙,它在赋值时就抛AttributeError: Setting 'bypass_cache' is deprecated. Instead, use cache_mode=Cach...——这个类把废弃参数做成了 property setter,所以「传了不报错但没效果」的坑在这里反而不会发生。 keep_html没了但原始 html 仍然在结果里(html与cleaned_html都有),因为 crawl4ai 默认就把这几份产物挂在CrawlResult上,不需要开关。
缓存的五种模式
缓存这段的关键是别看耗时,看服务端被打了几次。靶站的 /count 每被请求一次就自增,URL 上带 ?mode=<模式名> 让五种模式各用各的缓存键:
1 | # -*- coding: utf-8 -*- |
1 | ENABLED 第1次 miss (服务端被打 1 次) 第2次 hit (服务端累计 1 次) |
- 五种模式的实测语义:
ENABLED第一次 miss(服务端被打 1 次)、第二次 hit(服务端累计还是 1 次)——这是唯一一个「第二次不访问服务端」的模式;DISABLED、READ_ONLY、WRITE_ONLY、BYPASS第二次都还是 miss、服务端累计 2 次。 - 和文档表格对照,有两处要在意:文档说
READ_ONLY是「只读缓存,未命中不抓」,但本机实跑第一次 miss、第二次仍 miss 且服务端被打了两次——在冷缓存条件下它与BYPASS的表现一样(这条只在本机这组条件下成立,别当成「READ_ONLY 无意义」)。WRITE_ONLY的表现是每次都抓,与文档「只写不读」一致。 - 默认
BYPASS(上一节)意味着:不加cache_mode=CacheMode.ENABLED,重试同一 URL 就是真的重复打服务端。 - 缓存落在哪:输出最后一行是临时家目录里的内容,
crawl4ai.db与cache、html_content、markdown_content、extracted_content、cleaned_html、screenshots等目录都在里面。把CRAWL4_AI_BASE_DIRECTORY指到一个临时目录,才能保证「每次跑都是干净的第一次」——注意库启动时会打四行「建备份 + 跑迁移」的日志,备份文件名带时间戳,这段按行把它们过滤掉了,否则输出无法复现。
markdown 怎么裁
excluded_tags、excluded_selector、css_selector、target_elements、only_text、word_count_threshold 都作用在「进 markdown 之前」的那一步。靶站页面里埋了 NAV 与 FILLER 两个标记,判据就是它们在不在:
1 | # -*- coding: utf-8 -*- |
1 | 默认 行数 10 字符 322 | success=True | NAV True | FILLER True | 片名 5 个 |
excluded_tags=["div"]把两个 div 都拿掉(NAV False | FILLER False,10 行变 8 行);excluded_selector=".filler"只拿掉页脚(9 行);css_selector=".items"与target_elements=[".items"]都只保留列表区,两者的行数与字符数完全一致(7 行、202 字符)——一个是 CSS 作用域、一个是「目标元素列表」,在这个用例里等价。only_text=True与word_count_threshold=200在靶站这页上看不出变化(只有 322 字符、词数很少),所以把同一参数换到在线的 ssr1 上试:word_count_threshold=0/300/3000三档的 markdown 都是 74 行、3015 字符。这说明word_count_threshold不是「整页字数下限」,而是「文本块级别太短就丢掉」的阈值,用它来裁整页是没有效果的(要裁整页用css_selector或markdown_generator(content_filter=...))。- 默认 markdown 的开头也能读出结构:先是导航区那句长文字,然后
# 列表页,再是* [片名1](…/d/1)1.5这样的条目——标题、链接、正文价格粘连在一起。markdown 是给人和 LLM 读的,不是给解析用的,要结构化数据请看「结构化抽取四路」。
markdown 生成器
markdown_generator 传的是策略对象。这段把 DefaultMarkdownGenerator 的参数面、options 的三种开关、两种内容过滤器,以及「产物到底在哪」都跑了一遍:
1 | # -*- coding: utf-8 -*- |
1 | DefaultMarkdownGenerator 的参数 3 个 = content_filter, options, content_source |
DefaultMarkdownGenerator只有 3 个参数:content_filter、options、content_source。include_links/include_images/citations不是构造参数,要写进options={...}——直接当关键字传会TypeError。实例上的属性是content_filter、content_source、options、verbose四个。- 传了生成器之后
result.markdown不再是str,而是StringCompatibleMarkdown(字符串兼容类:len()、切片、in都还能用,但.fit_markdown取不到——本例打印出它的类型是str,也就是取到的是那个兼容层给的同名属性而不是产物)。要拿raw_markdown/fit_markdown/references_markdown这些分开的产物,就直接调generate_markdown(html),它返回MarkdownGenerationResult,字段是raw_markdown、markdown_with_citations、references_markdown、fit_markdown、fit_html五个。 - 本例里
citations=True之后markdown_with_citations中没有[^标记、references_markdown是 10 行——即引用表被生成但正文里没插引用号,因为这块靶站页面只有一个链接来源,不足触发引用。别把citations当「一定有引用」的开关用,要用就同时核对两个字段。 - 两个内容过滤器都能构造并接进去(
PruningContentFilter(threshold=0.48)、BM25ContentFilter()),但PruningContentFilter这条路会打一句DeprecationWarning:BeautifulSoup 版的PruningContentFilter已废弃,官方改推 lxml 版(PruningContentFilterLXML,快约 10 倍、输出相同)。这是本篇实测到的唯一一个「文档还没改、库已经改」的迁移点,写新代码直接用 lxml 版。 LLMarkdownGenerationStrategy这个名字在顶层与async_configs里都找不到(hasattr是False)——文档里那套「用 LLM 生成 markdown」的入口在本机 0.9.5 不叫这个;RequestCache同样不在顶层。这两个名字别再照抄。
懒加载与等待
这一节把「等」这件事拆成四个参数,判据是靶站里两个专门设计的页面:/lazy 要滚动才追加 20 条,/spa 要等 200ms 的 JS 才渲染 8 行。
1 | # -*- coding: utf-8 -*- |
1 | 懒加载页 / 默认(不滚) 滚动条目 1 | markdown 行数 3 | success=True |
- 数值的含义先说清:
滚动条目 1是「只看到脚本源码里那个字符串」,也就是 JS 没跑出来任何条目;滚动条目 21是 1 处源码 + 20 条真实渲染。渲染行 9= 1 处源码 + 8 行渲染。 scan_full_page=True是把 20 条滚出来的那个开关(21);只传virtual_scroll_config不打开scan_full_page时什么也不会发生(还是 1)。VirtualScrollConfig的真实参数是container_selector, scroll_count, scroll_by, wait_after_scroll——网上流传的step_factor/max_steps/wait_factor在本机 0.9.5 会TypeError。- SPA 页面这边:
wait_for=".row"是唯一决定性的那一下(9 行都出来了);wait_for_timeout=800反而没用(还是 1)——它不是「再等 800ms」的意思,别把它当 sleep 用。 wait_for等一个永远不出现的元素时,success=False、markdown 空、渲染行 0,不会抛异常。所以wait_for的失败是靠success判的,写批量任务时要给这一类页面留重试。magic默认就是True(CrawlerRunConfig.magic的默认值在参数面那节打印过),所以「什么都不传」时 crawl4ai 已经在做模拟滚动/等待一类的事了;上面magic=False那两行才是裸行为。
JS 注入与表单
js_code 是在页面里执行一段 JS;表单抓取这一路 0.9.5 换了写法,必须实测才知道老参数已经全没了:
1 | # -*- coding: utf-8 -*- |
1 | js_code 注入之后 metadata.title = 被JS改过 |
js_code="document.title = '被JS改过';"的效果直接体现在metadata.title上(被JS改过vs 不注入时的列表)——这是验证「JS 到底跑没跑」最省事的手法,比读html靠谱。- 表单/POST 这一层的实测结论比较硬:
CrawlerRunConfig里没有data、form、post_url、form_data任何一个,传进去全是TypeError: ... got an unexpected keyword argument ...。文档里那种post_url + form_data的写法在 0.9.5 已经退役。 - 真正能发 POST 的是上一节那条轻量 HTTP 策略:
HTTPCrawlerConfig(method="POST", data=...)。三种体写法都跑通了:data=b"name=probe"与data={"name": "probe"}靶站都回显POSTED name=probe(表单编码),json={"name": "probe"}回显POSTED {"name": "probe"}(JSON 原文)。 - 浏览器那条路要 POST,实测的可行做法是把逻辑写进
js_code(在页面里fetch)或者干脆用 API 接口——后者见《爬虫-Requests》《爬虫-多线程爬虫》的接口线。
robots 默认不遵守
这一点必须单独一节,因为它是 crawl4ai 与 Scrapy/Crawlee 的口径差别所在。靶站的 robots.txt 写着 Disallow: /private/,而 /private/ 里有 PRIVATE-SECRET-BODY 这串指纹:
1 | # -*- coding: utf-8 -*- |
1 | 靶站自报的 robots.txt = 'User-agent: *\nDisallow: /private/\n' |
- 默认配置照样把
/private/抓回来了(success=True、抓到 PRIVATE-SECRET-BODY=True),check_robots_txt的默认值就是False(参数面那节已验证)。 - 显式
check_robots_txt=True之后是success=False、status_code=403、error_message='Access denied by robots.txt',而且请求根本没到/private/(靶站那一侧的命中数在批量块里可以复算)。 - 对照口径:Scrapy 是
ROBOTSTXT_OBEY(默认也是False,但生态里常开),Crawlee 是robots.txt参与调度。crawl4ai 的差别在于它把这件事做成了一次「抓取前置检查」,命中就直接给 403 而不是跳过该链接,所以判error_message就能区分「被 robots 挡」和「被反爬判据误伤」。 - 一句风险提示(这条不在实测范围内,但必须写在笔记里):默认不遵守不等于可以随便抓,
robots.txt之外还有站点条款与频率问题,《爬虫-基本入门》那篇「法律与道德边界」一节讲过边界。
失败长什么样
四类失败各打一次,输出把 crawl4ai 的报错风格说清楚了:大多数失败不抛异常,而是把 success=False 和一句内部错误文本还给你。
1 | # -*- coding: utf-8 -*- |
1 | page_timeout=300 success=False 状态 None error='Unexpected error in _crawl_web at line 779 in _crawl_web' |
page_timeout=300打一个要 2.5 秒的端点:success=False、status_code=None、错误是Unexpected error in _crawl_web at line 779——行号是库自己的行号,随版本变,别拿它做判据;能用的判据是「状态为空 + 超时」。- 同一端点用默认
page_timeout=60000:抓到了(状态 200),但success=False,原因是「没被拦却报被拦」那节的结构性判据(这个端点的页面只有 300 字节)。这就是把两类失败混在一个字段里的代价:判读时必须先看status_code有没有、再看error_message是不是以Blocked by anti-bot protection开头。 - 500 页面:
状态 500+Blocked by anti-bot protection: Structural: minimal_text…,即服务端错误也走同一层判定,不会给你「HTTP 500」这种直白的话。想要直白的状态码,判r.status_code == 500而不是读error_message。 - 协议名写错(
htp://…)与端口没人听:都是success=False、status_code=None,错误文本里带库内部行号或crawl (E:\…的路径片段。这两种情况不抛 Python 异常,只有空字符串 URL 才抛ValueError: Invalid URL, make sure the URL is a non-empty string——也就是说try/except只能兜住空串这一种。 - 顺带把「为什么这一节的输出只有 6 行」交代掉:靶站的
handle_error被关掉了,客户端超时断开时 socketserver 会打整段 traceback(里面是临时端口号),那不是结论。
批量与调度
arun_many 是批量入口,调度器(dispatcher)是它的第三个参数,不是 CrawlerRunConfig 的参数——这一点最容易记错:
1 | # -*- coding: utf-8 -*- |
1 | 默认调度:返回 20 条,成功 20 条,覆盖 20 个 URL |
- 20 个 URL 默认调度:返回 20 条、成功 20 条、覆盖 20 个 URL,靶站的
/d/路径正好被命中 20 次,没有丢也没有重复。 - 顶层没有
DispatcherType枚举(文档里那种dispatcher=DispatcherType.MEMORY_ADAPTIVE的写法本机不存在),可用调度器只有BaseDispatcher / MemoryAdaptiveDispatcher / SemaphoreDispatcher三个。构造参数实测分别是:SemaphoreDispatcher(semaphore_count, max_session_permit, rate_limiter, monitor)、MemoryAdaptiveDispatcher(memory_threshold_percent, critical_threshold_percent, recovery_threshold_percent, check_interval, max_session_permit)。传实例给arun_many(urls, config=..., dispatcher=...),两种都能跑(各返回 20 条、成功 20 条)。 - 最后那行是判读顺序的示范:一批里混一个没人听的端口,返回结果里那条是
False、其余三条True——arun_many不会因为个别 URL 失败而整体抛错,必须逐条看success。 - 打印里刻意没有「返回顺序」这一项:实测发现
arun_many的返回顺序与请求顺序不一致(本机出现过1, 13, 12, 11, 10, 14这种),而每次调度结果都可能不同,所以想要顺序只能自己按result.url重排,也不能把这个数字写进可复现的输出里。
深度抓取
深度抓取现在的写法是「传一个策略对象给 deep_crawl_strategy」。靶站是链式的四层:/lvl1/a → /lvl2/{a,b} → /lvl3/{c,d} → /lvl4/e:
1 | # -*- coding: utf-8 -*- |
1 | BFS max_depth=1 抓到 3 条 -> ['/lvl1/a', '/lvl2/a', '/lvl2/b'] |
max_depth的语义被这组数字钉死了:BFS max_depth=1抓到 3 条(起点 + 第二层两个),=2抓到 5 条(再加第三层),=3抓到 6 条(到第四层那个叶子)。max_depth数的是「往下几层」,不含起点自身那一层。DFS与BFS在同样max_depth=2下抓到的集合完全相同(5 条),只是访问顺序不同;所以选型看的是内存与早停需求,不是覆盖率。- 参数名这条要划重点:
CrawlerRunConfig里 deep 相关的参数只有deep_crawl_strategy,文档里那种deep_walk_strategy在本机不存在(传进去TypeError: ... unexpected keyword argument 'deep_walk_strategy')。 - 过滤器链是能用的:
FilterChain与URLPatternFilter/DomainFilter都在顶层存在;但URLPatternFilter的参数是patterns, use_glob, reverse(不是url_pattern,写错直接TypeError)。把FilterChain([URLPatternFilter(patterns=["*/lvl2/a"])])传给BFSDeepCrawlStrategy(filter_chain=...)之后,深度 2 的结果从 5 条缩到 2 条(只放行了/lvl2/a)——过滤器是在入队前生效的,被滤掉的链接根本不会被抓。 - 老参数
deep_crawl、bypass_cache、disable_cache都不再是策略参数(bypass_cache现在会给一句AttributeError: Setting 'bypass_cache' is deprecated,见缓存那节)。
结构化抽取四路
markdown 给人读,结构化数据要另走一条路。这一段把四种抽取方式打在同一个靶站上(/list 有 5 个条目、/table 有一张三行表格):
1 | # -*- coding: utf-8 -*- |
1 | JsonCss 三个字段 条数 5 第一条 {'title': '片名1', 'href': '/d/1', 'price': '1.5'} |
JsonCssExtractionStrategy的 schema 就是「baseSelector+fields」,字段可以是type="text"或type="attribute"(配attribute="href"):5 条都拿到了,第一条是{'title': '片名1', 'href': '/d/1', 'price': '1.5'}。这是最推荐的一条路,schema 是纯字典,可以直接存配置文件。JsonXPathExtractionStrategy用 XPath 版 schema(baseSelector="//li"、selector="./a"),同样 5 条,只是表达式换成 XPath。两条路的字段命名规则完全一致,选哪个取决于你熟哪套选择器(见《爬虫-XPath》与《爬虫-BeautifulSoup》)。RegexExtractionStrategy有个大坑:pattern不是字符串,是位标志枚举(成员是EMAIL=1、URL=8、IPV4=16…NOTHING=2097152这一套内置片段)。传字符串会TypeError: unsupported operand type(s) for &: 'int' and 'str';传pattern=_B.URL | _B.EMAIL能跑但本机靶站正文里没有邮箱和绝对 URL,结果是[];自定义正则要写进custom={"movie": r"片名(\d+)"},这样 5 条都出来了,且每条带url/label/value/span四个键(span是命中位置,做实体定位时很有用)。TableExtractionStrategy是抽象类,直接实例化会TypeError: Can't instantiate abstract class ...——但表格抽取本身是自动做的:不配任何策略时result.tables已经是[{"headers": ["片名", "年份"], "rows": [["片名1", "1991"], …], "caption": "", "summary": "", …}]。反过来显式传table_extraction=True之后tables变成空列表——这个开关在本机 0.9.5 是「关掉自动抽取」的效果,别按字面理解成「打开」。- 语义路线(
CosineStrategy、LLMExtractionStrategy)本篇没有实测抓取效果:前者要下载 HuggingFace 文本分类模型(见命令行那节的离线失败),后者要 API 密钥。这里如实标注,不给结论。
结果里的附加产物
截图、PDF、链接、媒体、控制台、网络请求这几个字段都是「默认不开、开了给你」的东西:
1 | # -*- coding: utf-8 -*- |
1 | screenshot 类型 = str 长度 = 18732 |
screenshot=True给的是 base64 字符串(本机这一页 18732 字符,以data:image/…形式内嵌,所以不能按图片二进制处理);pdf=True给的是 bytes(37898 字节)。两者不开时都是None。links是{'internal': [...], 'external': [...]}这个形状的字典(本例这张表格里没有链接,所以计数都是 0);media的键固定是audios/images/videos三个。console_messages与network_requests默认都是 0 条:要它们得分别开capture_console_messages=True、capture_network_requests=True。输出里那行「注入 JS 之后 console_messages = None」就是证据——js_code里的console.log不会自动被收集,js_execution_result同样是None(那是给「带返回值执行 JS」用的字段,需要配合专门的参数)。redirected_url在没有重定向时等于原 URL,response_headers只有 4 个键(本机靶站只发 Content-Type/Content-Length/Date/Server)。- 一句话总结这一节:crawl4ai 把「附加产物」都放在同一个结果对象上,但每一项都要显式打开;写排查脚本时先确认开关,再怀疑抓不到。
反检测那三层
文档给了三层反检测:magic、enable_stealth、以及 patchright 那条 undetected 路线。本机用一个回显 navigator.webdriver|plugins|innerWidth|UA 的页面把它们量出来:
1 | # -*- coding: utf-8 -*- |
1 | 默认(PlaywrightAdapter) -> ['WD=false|PL=0|V=1080x600|UA=Mozilla/5.0 (X11; Linux x8'] |
navigator.webdriver四种情况下都是false,包括默认的PlaywrightAdapter。现代 Chromium 在无头模式下已经不写这个位了,所以「检测webdriver」这一招对 crawl4ai 无效,它不是区分三种配置的指标。- 真正有差别的是
navigator.plugins.length:默认无头是 0,enable_stealth=True变成 3,而换StealthAdapter()/UndetectedAdapter()仍是 0(这两个适配器在本机走的是别的补丁路径)。也就是说enable_stealth改的是插件表这类"环境位",不是webdriver。 - 顺带解释了为什么「有头模式」
PL=5(上一节的输出):真实 Chrome 自带 5 个插件,无头是 0,这是最朴素的差别。 StealthAdapter与UndetectedAdapter的构造参数都是空(不接受任何参数),换适配器靠的是AsyncPlaywrightCrawlerStrategy(browser_adapter=...),而这个类不在顶层导出里,要从crawl4ai.async_crawler_strategy导入——这也是本篇实测到的一处文档漂移:Advanced 页写的顶层导入路径本机拿不到。- 最后要提醒的是判据本身:本机靶站是自己写的页面,只能证明「指纹位变了没有」,不能证明「某个真实站点会不会放行」。真实反爬的验证要靠多次采样,不适合写进可复现笔记。
落盘与 crwl
crawl4ai 会把东西写进用户目录的 .crawl4ai,还有一个 crwl 命令行。这段列出目录内容并真跑一次命令行:
1 | # -*- coding: utf-8 -*- |
1 | ~/.crawl4ai 里的固定项 13 个: |
- 家目录里的固定项是 13 个:
crawl4ai.db(缓存与元数据的 SQLite)、cache、html_content、markdown_content、extracted_content、cleaned_html、screenshots、models、crawler.log、version.txt,外加两个云广告标记.cloud-notice-shown/.cloud-notice-blocked和一层.crawl4ai子目录(CRAWL4_AI_BASE_DIRECTORY指过去时生成的)。 crawl4ai.db.backup_<时间戳>每跑一次迁移就多一个,所以这里只报「有没有」,不报个数——不然这段输出每次都不一样。crwl --help里的选项是一串短横(-B,、-C,、-f,、-e,、-j,、-s,、-b,、-c,、-o,、-O,、-bc,、-q,),真正的子命令是crwl crawl URL。- 本段那次
crwl <url> --verbose false的退出码是 2,报错是Error: Got unexpected extra argument (false)——--verbose是布尔开关,不接受false这个值。想安静就得去掉它,或者用库里的verbose=False(那两个 verbose 的分工见「本机靶站」一节)。 LiteLLM那条Failed to fetch remote model cost map告警被过滤掉了(它带时间戳且每次相同前缀不同后缀);本篇所有块的开头都设了CRAWL4AI_NO_CLOUD_NOTICE=1,这也是过滤规则的一部分。
与 requests 抓同一页
最后一个对照:同一个在线靶站、同一个解析规则,requests 与 crawl4ai(不开浏览器的那条 HTTP 策略)各抓一次。requests 侧基线:
1 | # -*- coding: utf-8 -*- |
1 | requests 状态 200 |
crawl4ai 侧,把取法与打印口径写成完全一样:
1 | # -*- coding: utf-8 -*- |
1 | crawl4ai 状态 200 |
两份输出除了第一行的库名,其余逐字相同:状态 200、41667 字节、Content-Type: text/html; charset=utf-8、片名 10 个、前三个是「霸王别姬 / 这个杀手不太冷 / 肖申克的救赎」。注意 crawl4ai 这边没有 resp.content,字节数是把 result.html 按 UTF-8 编码之后量的,Content-Type 取自 response_headers。
这条对照的用意是把选型说清楚:只要拿正文,requests 就够了,crawl4ai 的 HTTP 路径不会给你更多内容。它的价值在后面的加工——把 HTML 变成 markdown(「抓一页给什么」)、把表格与字段抽成结构化 JSON(「结构化抽取四路」)、以及在必须执行 JS 时把浏览器那一层的成本花得值(同一节里 spa1 只有 3 行)。
三篇新框架的分工(与《爬虫-DrissionPage》《爬虫-curl_cffi》同口径):
| 需求 | 该用谁 |
|---|---|
| 只要 HTTP 客户端、要浏览器级 TLS 指纹 | curl_cffi(见《爬虫-curl_cffi》) |
| 要操控浏览器、像人一样点与滚、还要 requests 风格的 API | DrissionPage(见《爬虫-DrissionPage》) |
| 要「抓完直接给 markdown / 结构化 JSON」,批量喂给 LLM 或索引 | crawl4ai(本篇) |
| 要工程化的队列、重试、代理轮换、续爬 | Crawlee(见《爬虫-Crawlee》)、Scrapy(见《爬虫-Scrapy》) |
常见坑速查
把本篇各节里最容易踩的收在一张表上。
| 现象 | 真正的原因 | 出处 |
|---|---|---|
crawl4ai.__version__ 打出来不是版本号 |
它是个子模块对象 | 版本号怎么取 |
| 第二次抓同一页还是打了服务端 | cache_mode 默认 BYPASS |
缓存的五种模式 |
success=False 但内容明明抓到了 |
结构性反爬判据误判小页面 | 没被拦却报被拦 |
| 输出里混进云广告横幅 | 判据命中时会打广告,一天一次 | 没被拦却报被拦 |
BrowserConfig(proxy_cfg=...) 报 TypeError |
正名是 proxy_config,proxy 已废弃并打警告 |
代理与 UA |
post_url=/form_data= 全部 TypeError |
0.9.5 已删,POST 要走 HTTP 策略的 method/data/json |
JS 注入与表单 |
deep_walk_strategy= 报错 |
参数名是 deep_crawl_strategy |
深度抓取 |
result.meta 抛 AttributeError |
字段名是 metadata |
结果对象的字段 |
include_links= 传给生成器报错 |
这些开关要写进 options={} |
markdown 生成器 |
| 表格抽取「打开了反而没了」 | table_extraction=True 实为关掉自动抽取 |
结构化抽取四路 |
wait_for_timeout=800 等不到 SPA 内容 |
它不等元素,要用 wait_for |
懒加载与等待 |
| 虚拟滚动没滚 | 必须同时开 scan_full_page=True |
懒加载与等待 |
console.log 没被收集 |
要 capture_console_messages=True |
结果里的附加产物 |
session_id 换实例就丢 cookie |
session_id 不共享浏览器上下文 |
session_id 管到哪 |
browser_type="safari" 竟然成功 |
不校验取值,悄悄按 chromium 跑 | 浏览器这一层 |
dispatcher= 传给 RunConfig 报错 |
dispatcher 是 arun_many 的参数 |
批量与调度 |
crwl … --verbose false 退出码 2 |
--verbose 是布尔开关,不带值 |
落盘与 crwl |
| 每次跑输出都不同的四样东西 | 耗时、内存地址、随机 id、带时间戳的备份名 | 各节的归一化处理 |
参考
- Crawl4AI 官方文档(核心 + Advanced 两大块)
- unclecode/crawl4AI 仓库
- crawl4ai.com(
crawl4ai-doctor联网自检时抓的就是这个站) - Playwright BrowserType.launch 的错误文案——
browser_type写错时报的就是它 - 在线练习站 scrape.center(ssr1 服务端渲染、spa1 前端渲染)