做数据采集这些年,我最怕的不是网站反爬,而是“爬下来了却要花三倍时间洗数据”。一个商品页,标题在 h1 里,价格在 meta 里,库存状态又藏在某段 JS 变量中,用 Requests + BeautifulSoup 不是不能抓,而是每次网站改版都要重新拆一遍 HTML,维护成本高得离谱。后来在 GitHub 上刷到 crawl4ai,试了一下午才意识到:AI 爬虫不是花架子,它确实把“爬网页”这件事从“拿 HTML”推进到了“拿结构化数据”。crawl4ai 的核心能力就是让开发者先定义清楚要什么字段,再用策略(CSS 选择器或大模型)自动从页面里提取 JSON,省掉中间一大轮手写解析和清洗。这篇我就从安装到落地,完整记录一下怎么用它把网页变成结构化数据,顺便把我踩过的坑全倒出来。
1. crawl4ai 到底解决什么问题
1.1 传统爬虫的三座大山
早些年写爬虫,技术上其实不复杂,麻烦的是“脏活累活特别多”。我用 Python 爬过电商、新闻、招聘网站,最常见的三个痛苦:
第一是页面结构改版。这周还能跑的 CSS 选择器,下周网站前端加了字体图标、换了 class 命名,代码直接报废。每次出问题都要打开 DevTools 重新定位元素,改选择器、调父节点、再跑一遍看有没有漏字段。这种循环特别消耗耐心。
第二是字段分散。一个商品详情页,看上去是一张卡片,实际数据来源有四五处:标题在<h1>,价格在<meta property="product:price">,库存状态写在<script>里的window.__INITIAL_STATE__对象中,评价数又要去另一个接口拿。传统思路只能分散解析再汇总,代码越长越难维护。
第三是内容本身不够结构化。表格、动态加载的列表、登录后才出现的数据,用普通 HTTP 请求根本拿不到真实 DOM,必须依赖无头浏览器渲染完再抓。而浏览器一渲染,解析逻辑又得重新设计。
这三个问题放在一起,就会得到一段很长、很脆的“胶水代码”。crawl4ai 恰恰把这三座大山一起处理了:它能驱动真实浏览器,能提取主体内容,还能直接按 schema 输出 JSON,相当于把爬虫后半程的脏活标准化了。
1.2 爬取链路里的 AI 到底在哪
很多人一听“AI 爬虫”,脑子里浮现的是“机器人自动看懂网页”,其实 crawl4ai 并没有那么玄学。它的整体架构可以拆成三截:
底层是抓取引擎。crawl4ai 基于异步机制,内置了 HTTP 直连和 Playwright 浏览器渲染两种方式。前者快,适合静态页面;后者稳,适合动态页面。这个设计很务实,不是所有目标站点都需要开浏览器。
中间是内容处理管线。拿到原始 HTML 之后,它会把导航、脚本、CSS、广告等噪音去掉,输出一份干净的 Markdown 或纯文本。这一步对后续 LLM 抽取特别关键,因为喂给大模型的文本越干净,抽取效果越好。
最上面才是“AI”的部分——提取策略。crawl4ai 提供两类策略:一类是传统但高效的JsonCssExtractionStrategy,本质是结构化的 CSS 选择器批量抽取;另一类是LLMExtractionStrategy,把页面主体文本丢给大模型,让模型根据你的字段定义返回 JSON。所以它的“AI 味”主要集中在这层,下层还是实实在在的工程。
打个比方:传统爬虫是你在仓库里挨个翻箱子,找到什么拿什么;crawl4ai 是直接给搬运工一张收货单,告诉他“我要哪些格子、每个格子里装什么”,他翻完就给你装进对应的小盒子。你把小盒子接过来,就是一行一行的结构化数据。
1.3 和 Scrapy、Requests 拉开差距的关键
用传统的 Requests + BeautifulSoup,代码自由度最高,但每个项目都要重新搭一遍解析模板。Scrapy 是更重型的选择,它能管理并发、管道、中间件,但学习曲线陡,而且对于动态页面还是要外挂 Playwright 或 Splash,配置起来并不轻松。
crawl4ai 的突围点在于“开箱即用的结构化提取”。它不像 Scrapy 那样逼你先搭一套爬虫框架,而是给了一个异步 API:AsyncWebCrawler打开上下文,arun跑一个 URL,返回结果里直接带上 HTML、Markdown、Links、Metadata,以及你定义好的结构化 JSON。它关心的是“拿到数据之后怎么办”,而不是让你花一天时间搭项目骨架。
| 方案 | 上手成本 | 动态页面支持 | 结构化输出 | 适合场景 |
|---|---|---|---|---|
| Requests + BS4 | 低 | 弱 | 手写解析 | 一次性静态小任务 |
| Scrapy | 高 | 需要外挂 | 管道自建 | 大规模长期爬虫项目 |
| crawl4ai | 中低 | 内置 Playwright | 策略直接出 JSON | 需要快速把网页转结构化数据 |
我个人的判断是:如果你面对的只是三五个静态页面,用什么差异都不大;但如果你要经常和格式混乱、结构多变的网页打交道,crawl4ai 的提取策略是真的能省时间。
2. 从零上手:先跑通一个最简单的例子
2.1 安装与首次启动
安装本身不复杂,一个 pip 命令就能搞定:
pip install crawl4ai但光是装好包还不能立刻跑,crawl4ai 依赖 Playwright,首次使用一般要初始化浏览器内核。我在 0.4.x 版本上跑的命令是:
crawl4ai-setup这个命令会自动拉取 Chromium 之类的基础浏览器组件。第一次执行会卡在下载进度条上,这是正常的,等一会儿就行。如果网络环境不太好,这里失败的概率不低,解决办法是检查网络后重新执行,已经下载好的部分会被复用。
提示:如果你只抓静态 HTML,不涉及 JS 渲染,理论上可以不启用完整浏览器,直接用轻量模式。但新手阶段建议老实跑完 setup,避免后续被动态页面卡住。
装完之后,可以用crawl4ai --doctor这类命令做一次健康检查,确认浏览器环境没问题。这一步虽然不起眼,但能省掉后面很多“明明代码没问题却跑不通”的排查时间。
2.2 第一个异步爬取任务
crawl4ai 的 API 设计是异步优先,所以哪怕只抓一个页面,也要写一个async函数。最简版本长这样:
import asyncio from crawl4ai import AsyncWebCrawler async def main(): async with AsyncWebCrawler() as crawler: result = await crawler.arun(url="https://example.com") print(result.markdown[:500]) asyncio.run(main())第一次看到这段代码,你可能会疑惑:为什么不是crawler.fetch(url)这种同步写法?因为异步上下文管理器能让底层连接池、浏览器实例在整个会话内复用,做批量采集时性能提升非常明显。你只需要记住:多个 URL 的抓取任务,放在同一个async with块里做并发,效率远高于一个个地开关资源。
result是一个CrawlResult对象,也就是一次抓取的完整“包裹”。它身上挂着这次抓到的所有东西,下一步所有操作都从这里展开。
2.3 返回结果里哪些字段最有用
CrawlResult我实际用得最多的字段是这几个:
result.html:原始 HTML 字符串,适合排查问题时看页面到底长什么样。result.markdown:清洗后的 Markdown 文本,给 LLM 策略喂数据时首选。result.fit_markdown:进一步裁剪后的“主体内容”,去掉了导航、页脚之类的噪音。result.links:解析出来的站内链接和站外链接。result.metadata:标题、描述、关键词等元信息。result.structured_data:按提取策略生成的结构化 JSON 字符串,这是我们的最终目标。result.success:布尔值,标记这次抓取是否成功。
我第一次跑通时直接打印了result.markdown[:200],看到干净文本的时候有点惊讶——一个带导航栏、侧边栏、广告位的页面,能被压成那么干净的正文。这说明中间的内容处理管线确实下了功夫,不只是普通 HTML-to-Markdown 转换。
心得:很多新手一上来就盯
structured_data,但抓不到数据时别死磕策略,先打印result.markdown看内容在不在。如果干净文本里都没有目标字段,说明页面没加载完或 URL 不对,这时候调选择器是无效劳动。
3. 把网页变成结构化数据的核心思路
3.1 先定义 schema,再去爬
这是 crawl4ai 和传统爬虫在思路上的最大分水岭。传统做法是先拿到 HTML,再看怎么解析;crawl4ai 的做法是反向的——在你发出请求前,先定义一份 schema,说清楚“我想从页面里拿哪些字段、每个字段大概长什么样”。这有点像写数据库表结构:你先把列定义好,再来填数据。
一个最普通的 schema 长这样:
schema = { "name": "Product", "baseSelector": "div.product", "fields": [ {"name": "title", "selector": "h2.title", "type": "text"}, {"name": "price", "selector": "span.price", "type": "text"}, {"name": "link", "selector": "a.thumb", "type": "attribute", "attribute": "href"}, ] }schema["name"]是这段结构的名字,baseSelector是循环列表项的根节点,fields是你要提取的字段清单。每个字段的type决定怎么取值:text是拿文本内容,attribute是拿某个属性的值,后续还能用到嵌套对象、正则匹配等更高级的玩法。
为什么要把 schema 放在前面?最直接的好处是“抽取逻辑可复用”。页面改版了,你只要修改选择器,外层的数据落库代码逻辑完全不用动。而传统爬虫分散在代码各处的.find()调用,改起来就像在一锅粥里挑豆子。
3.2 用 CSS 策略实现“表格化”抽取
对应 schema 的第一个落地工具是JsonCssExtractionStrategy。它可以被理解成一个面向列表页的“批量表格化提取器”:你给我根节点和字段选择器,我把根节点下所有满足条件的条目全部提出来,转成一个 JSON 数组。
实际用法是先把 schema 传给策略,再把策略传给爬虫对象:
from crawl4ai.extraction_strategy import JsonCssExtractionStrategy extractor = JsonCssExtractionStrategy(schema, verbose=True) async with AsyncWebCrawler() as crawler: result = await crawler.arun( url="https://example.com/products", extraction_strategy=extractor )跑完之后,result.structured_data里就是一个 JSON 字符串,json.loads()一下就能拿到 Python 列表。
这个策略的核心优势是“零成本、稳定、可预期”。没有大模型调用,没有 token 费用,响应速度接近毫秒级。只要目标页面的 DOM 结构稳定,它是我首选方案,没有之一。
3.3 用大模型策略处理“非标”页面
但现实世界里的网页并不总是规规矩矩的。我遇到过不少页面,同一份列表里有的条目有价格,有的却没有;有的标题直接暴露在 HTML 里,有的却是 JS 渲染出来的富文本。CSS 选择器在这种场景下会顾此失彼,这时就该LLMExtractionStrategy登场。
它的基本思路是:把页面主体文本(通常就是fit_markdown)和一段指令一起丢给大模型,让模型根据你定义的 schema 决定哪些内容对应哪些字段,然后返回 JSON。我常用的参数是这样的:
from crawl4ai.extraction_strategy import LLMExtractionStrategy strategy = LLMExtractionStrategy( provider="openai/gpt-4o-mini", api_token="你的密钥", schema=product_schema, extraction_type="block", instruction="请从页面中提取所有商品的标题、价格和库存状态,返回 JSON 数组。" )要注意,这里的schema和 CSS 策略里的 schema 意义不太一样。CSS 策略里的 schema 是“选择器清单”,LLM 策略里的 schema 更像是“字段定义说明书”,用来告诉模型你要什么结构的输出。字段值哪里找,模型自己理解。
LLM 策略的最大好处是抗页面结构变化。前端随便改 class、挪位置,只要页面语义上还说得通,模型基本都能对上。坏处也很明显:有 token 成本,有网络延迟,还有可能返回格式不稳定的 JSON。所以它最适合的场景是“页面杂乱、没有统一模板、数据量中等”的任务。
3.4 两个策略的取舍
用熟了之后,你会发现两个策略不是竞争关系,而是互补关系。我一般按下面这个逻辑选择:
| 判断条件 | JsonCssExtractionStrategy | LLMExtractionStrategy |
|---|---|---|
| 页面结构 | 固定模板、class 稳定 | 结构混乱、字段位置不固定 |
| 请求量 | 大批量、每天上万页 | 小批量、几十到几百页 |
| 成本预算 | 零成本 | 按 token 计费 |
| 对速度要求 | 毫秒级响应 | 秒级甚至更慢 |
| 容错要求 | 结构一变就出错 | 对结构变化更鲁棒 |
如果页面是后台管理系统、内部文档站这类结构稳定的场景,无脑选 CSS 策略。如果爬的是资讯聚合站、用户生成内容,或者你根本懒得折腾选择器,就用 LLM 策略。
心得:我做过一个混合方案——先用 CSS 策略跑,如果返回的
structured_data是空数组,再用 LLM 策略兜底。这样平时 90% 的流量都在零成本路径上跑,偶尔遇到改版也不会完全断粮。
4. 实例:抓一个博客列表页并生成 JSON
4.1 先画页面结构
空谈概念不如直接来一个完整案例。假设我要抓一个博客站的文章列表页,页面结构大概是下面这样:
<article class="post"> <h2><a href="/post/ai-scraper">AI 爬虫实战</a></h2> <time datetime="2025-01-20">2025-01-20</time> <p class="summary">本文记录 crawl4ai 落地细节...</p> </article>目标字段是四个:文章标题、链接、发布日期、摘要。在写代码之前,我先在浏览器里按 F12,看了一遍真实 DOM,确认选择器无误,然后才开始写 schema。别嫌这一步麻烦,省掉的都是返工时间。
4.2 编写 schema 与执行代码
完整代码如下:
import asyncio import json from crawl4ai import AsyncWebCrawler from crawl4ai.extraction_strategy import JsonCssExtractionStrategy schema = { "name": "ArticleList", "baseSelector": "article.post", "fields": [ {"name": "title", "selector": "h2 a", "type": "text"}, {"name": "link", "selector": "h2 a", "type": "attribute", "attribute": "href"}, {"name": "date", "selector": "time", "type": "text"}, {"name": "summary", "selector": "p.summary", "type": "text"}, ] } async def main(): strategy = JsonCssExtractionStrategy(schema, verbose=True) async with AsyncWebCrawler() as crawler: result = await crawler.arun( url="https://example.com/blog", extraction_strategy=strategy, bypass_cache=True ) if not result.success: print("抓取失败,状态码:", result.status_code) return data = json.loads(result.structured_data) print(json.dumps(data, ensure_ascii=False, indent=2)) asyncio.run(main())运行之后,预期的输出大概是:
[ { "title": "AI 爬虫实战", "link": "/post/ai-scraper", "date": "2025-01-20", "summary": "本文记录 crawl4ai 落地细节..." }, { "title": "Python 结构化数据建模入门", "link": "/post/python-structure", "date": "2025-01-18", "summary": "从数据清洗到 Schema 设计的完整路径..." } ]核心逻辑不复杂:arun负责抓页面,extraction_strategy负责按 schema 提取,structured_data负责把结果以 JSON 字符串形式带回来。只要页面选择器没写错,整条链路一气呵成。
4.3 输出示例与效果
baseSelector的逻辑是按“分组”来走的。页面里有几个article.post,结果就是几个对象;每个对象里的字段再按各自的selector去匹配。这就像做透视图:按行分组,按列取属性,最后形成一张表。
如果某个字段在单个条目里出现多次匹配,type: "text"默认取第一个匹配项的文本,type: "attribute"则取第一个节点的属性值。想取全部文本或全部属性时,需要用更精细的配置。
我实际用下来,几百个页面的列表抓取,结构化输出的成功率在结构稳定时接近百分之百。偶尔出现空字段,大多是源页面自己数据缺失,不是爬虫逻辑问题。
4.4 调试和选择器优化
前面提到过,调试的第一步是看result.markdown。但要是 CSP 策略严格、页面对无头浏览器不友好,Markdown 里可能也没有你要的内容。这时候我会做三件事:
- 打印
result.html,搜目标文本在不在原始 HTML 里。 - 如果 HTML 里有内容,检查 schema 的选择器是否太具体,比如
body > div#app > section > div:nth-child(3) > article.post这种 DevTools 一键复制的选择器,宁可改成article.post。 - 打开
verbose=True,看日志里有没有解析警告。
提示:直接从 DevTools 复制的选择器往往带一堆
:nth-child和#id前缀,看着精确,实际脆弱。我更喜欢用类名做主定位,类名不够再加aria-label或>from crawl4ai import CrawlerRunConfig config = CrawlerRunConfig( wait_for="#content", delay_before_return_html=2, page_timeout=30000 ) async def main(): async with AsyncWebCrawler() as crawler: result = await crawler.arun( url="https://example-spa.com/page", config=config )其中
wait_for是等待页面里出现指定选择器,属于最常用的等待手段。delay_before_return_html是额外等多少秒再取 HTML,适合那些加载完选择器之后还有其他异步刷新的页面。我踩过一个大坑是:明明等到了选择器,也等了 2 秒,数据还是不全。后来发现目标页面是先渲染首屏,再懒加载列表下方的数据。解决办法是把
wait_for指向列表尾部的一个“加载完毕”标记节点,或者干脆等待某个我们没有拿到但必然出现的元素。心得:动态页面的核心不是“会等”,而是“知道在等什么”。不要上来就
sleep(5)硬等,学会找一个标志性节点,用wait_for精准等待。这样既快又稳。5.2 批量采集与缓存控制
crawl4ai 的缓存机制设计得不错。默认情况下,相同 URL 会在一定时间内复用上一次抓取结果,这能大幅降低重复抓取的网络开销。但调试时这个功能会让人很困惑——你改了 schema,跑出来的还是旧数据。
处理方式有两个:一是在
arun里传bypass_cache=True,这是调试期最常用的参数;二是通过更细粒度的CacheMode控制缓存策略,比如只命中内存缓存不命中磁盘缓存。批量抓取时,我一般用
asyncio.gather做并发:async def fetch_one(url, strategy): async with AsyncWebCrawler() as crawler: result = await crawler.arun(url=url, extraction_strategy=strategy) return json.loads(result.structured_data) async def main(): urls = ["https://example.com/blog?page=1", "https://example.com/blog?page=2"] results = await asyncio.gather(*[fetch_one(url, strategy) for url in urls])需要注意,每个
AsyncWebCrawler实例对应一套浏览器资源,如果你在列表推导里一直创建新实例,资源开销会很大。更合理的做法是共用一个实例,或者用asyncio.Semaphore限制并发数。我习惯把并发控制在 5 到 10 之间,既够快,又不会给目标服务器造成太大压力。5.3 数据落库:从 JSON 到 DataFrame
结构化数据拿到手后,下一步通常是落库或对接下游分析。最省事的方案是转成 Pandas,然后再决定是导出 CSV、Excel,还是直接塞进数据库:
import pandas as pd data = [ {"title": "AI 爬虫实战", "date": "2025-01-20"}, {"title": "Python 结构化数据建模入门", "date": "2025-01-18"} ] df = pd.DataFrame(data) df.to_csv("articles.csv", index=False)从
structured_data到 DataFrame 只有一行json.loads的距离,中间不需要任何手工清洗。对于单个字段,我偶尔还会在 schema 里做正则处理,比如把日期里的时区尾巴截掉,或者从链接里抽出 ID。这些都能在 schema 字段配置里完成,没必要回表后再处理。如果项目比较正式,我建议再套一层 Pydantic 做数据校验。爬虫数据并不总是干净的,让校验环节拦截异常字段,比落库之后才发现脏数据要省心得多。
5.4 采集合规与频率控制
这一点必须单独拎出来说。crawl4ai 只是一个工具,工具本身不分好坏,但使用方式要讲究。我在做任何采集任务之前,都会先看目标站点的 robots.txt,再确认服务条款里是否明确禁止自动化访问。公开数据、非登录数据、低频抓取是底线。
频率控制上,
delay_before_return_html只能控制单页等待,控制整体请求频率需要自己做节流。我通常的做法是:import asyncio async def fetch_with_interval(url, interval=1.5): async with AsyncWebCrawler() as crawler: result = await crawler.arun(url=url) await asyncio.sleep(interval) # 给服务器留点余量 return result给目标服务器留余量,既是合规要求,也是更稳的长期策略。宁可抓得慢一点,也别因为请求过猛导致 IP 被临时限制,到时候整个业务都可能停摆。
6. 常见问题与排查技巧实录
6.1 高频报错速查表
我在使用过程中遇到过的、以及身边朋友问过的典型问题,整理成一张速查表:
现象 常见原因 解决思路 structured_data返回空数组选择器没匹配到元素 先看 markdown和html确认内容在不在长时间不返回结果 页面等待条件不满足 检查 wait_for选择器是否始终不存在拿到的是上个版本数据 缓存未清除 调试时加 bypass_cache=TrueLLM 返回 JSON 格式不稳定 指令不够明确、字段描述含糊 简化 schema,把 extraction_type调成block抓取大量页面时内存上涨 浏览器实例未复用 统一管理 AsyncWebCrawler实例,限制并发部分字段提取为 None 源页面字段缺失或选择器过严 用正则回退或设置缺省值 这六类问题覆盖了我在实际项目里九成以上的报错。其中“结构化数据为空”是新手最容易撞上的,根因基本都在选择器。不要急着怀疑 crawl4ai,先用
result.html反向验证。6.2 抓不到数据时的排查路径
如果遇到抓取结果一直不对,我会按下面的顺序排查,基本不跑偏:
第一步,检查基本连接。打印
result.status_code和result.success。如果是403或429,说明被对方限制,降低频率或者调整请求头;如果是200,继续往下看。第二步,检查内容完整度。打印
result.markdown,看看里面有没有目标字段的文本。如果 Markdown 是干净的,但缺内容,大概率是 JS 渲染没完成;如果 Markdown 是乱的,说明页面本身结构特殊,需要进一步清理。第三步,检查选择器。目标文本在 HTML 里,但
structured_data为空,这时把 schema 里的baseSelector单独拿出来,在 DevTools 的 Console 里跑一下document.querySelectorAll(...),立竿见影地看出有没有匹配到节点。第四步,考虑页面懒加载。如果文本一开始没有,要滚动才出现,就得回到第三节提到的
wait_for策略。这套路径帮我解决过很多次“看起来代码没问题”的尴尬时刻。尤其是第三步,简直是把练习时长从半小时压缩到了两分钟。
6.3 性能优化和内存控制
最后一个经验贴士,关于资源和速度的平衡。
首先,尽量复用
AsyncWebCrawler实例,避免反复创建和销毁浏览器进程。一次async with生命周期内要抓几十个页面,是很常见的设计。其次,根据页面是否动态选择抓取模式。纯静态页面没必要每次开完整浏览器,开浏览器的时间往往比请求本身还长。crawl4ai 支持轻量模式,能大幅提升纯静态页面的抓取速度。
最后,善用缓存。对内容更新频率低的站点,缓存机制配合定时任务,可以做到首次全量抓取,之后只抓变更页面。这样既省带宽,也降低服务器压力。
提示:如果目标页面数量非常大,不要把所有 URL 一次性塞进
gather。先跑一个 20 页的小批量验证 schema 稳定,再放开全量。一个错误的baseSelector在全量任务里会静默产生大量空数据,到时候回头清理脏数据才是真的浪费时间。最后再分享一个小体会。crawl4ai 真正让我觉得“值回票价”的不是它某个明星功能,而是它把“定义 schema、抽取字段、输出 JSON”这个流程变成了顺手的事。我第一次调试好一个列表页、看到
structured_data稳稳输出 JSON 的时候,说实话挺有成就感的。后来做类似项目,我都会把 schema 单独抽成一个 JSON 文件存起来,页面改版时只改选择器,外围代码一点不动。这个习惯帮我节省了大量重复劳动。如果你也想上手,我建议不要先背 API,而是找一个真实的、结构稍微乱一点的页面,带着一个具体目标去拆解和定义字段。等你完成第一份结构化数据,整个工具的设计逻辑自然就通了。