面向LLM的爬虫:用crawl4ai打造干净的Markdown数据管道
1. 为什么我给LLM写爬虫时放弃了传统方案做RAG检索增强生成和Agent类项目的人迟早会撞上同一个问题喂给大模型的资料应该长什么样我之前一直用 requests BeautifulSoup 自己写抓取逻辑一套流程敲下来其实也不复杂发请求、解析DOM、抽正文、清洗、存库。但真正做起来才发现传统爬虫的输出形态和大模型的消费习惯之间隔着一道很宽的沟。你爬下来的是HTML里面充斥着divspanscript标签、广告位、弹窗代码、埋点脚本还有一个占了半屏的导航栏。你把这段东西原封不动塞进LLM的上下文里先不说token浪费模型的理解精度也会被大量噪音拉低——它得自己先学会在一堆HTML里猜哪里是正文这本身就是一件容易出错的事。后来我注意到 crawl4ai 这个开源项目unclecode 出品的定位很明确面向LLM的爬虫。它最吸引我的点不是说抓取性能有多逆天而是它把从网页到模型可读数据这条链路完整打通了。你给它一个URL它默认吐给你的是干净的 Markdown 文本甚至是按自然语言语义组织好的 JSON 结构基本上不需要你再做二次清洗。如果你正在给 LLM 应用搭数据管道或者被各种能够提取正文的付费API的报价吓到了那么这篇文章里讲的东西应该对你有用。我会从原理讲到实战再列一些我踩过的坑尽量做到看完能直接上手用。2. crawl4ai的底层逻辑它不是爬虫而是网页到数据的处理器2.1 抓取流水线拆解crawl4ai 的整个流程不是一个单步操作而是由多个阶段串联起来的。你可以把它理解成一条小型流水线目标分析拿到 URL 后先做合法性校验和去重判断如果过去一段时间内已经抓过同一个地址可以直接走缓存。渲染阶段根据配置决定用纯 HTTP 请求拉取静态 HTML还是启动无头浏览器执行 JavaScript 后拿渲染完成后的 DOM。大部分现代网站都有动态内容所以这一步很关键。内容清理把 HTML 里的script、style、nav、footer等无关信息识别出来并剔除。这个环节是 crawl4ai 做得比较精细的地方它会保留表格、代码块、图片链接这些有信息量的结构但删掉对大模型没有意义的装饰性元素。结构化输出把清理后的内容转换成 Markdown、纯文本或者按照你配置的提取策略生成 JSON。这个 JSON 不是简单的键值对而是带语义说明的自然语言格式直接可以和向量化流程衔接。提示很多人一上来就把它当普通爬虫用其实 crawl4ai 更适合被定义成网页内容处理管道爬取只是最前面的一环。2.2 三大核心模块怎么协作我在读源码和实际跑通之后发现它内部的核心模块可以归纳成三块BrowserManager浏览器管理器——负责维护 Chromium 实例的完整生命周期。它支持复用浏览器内核而不是每抓一个页面就重新启动一次无头浏览器这个设计对大批量采集非常重要可以省掉好几倍的资源开销。ContentExtractor内容提取器——负责从渲染好的页面里精准取出你想要的字段。它提供好几种策略纯抽取NoExtractionStrategy、CSS 选择器抽取、XPath 抽取、JSON 路径抽取还有基于LLM的智能抽取。前几种都是确定性的规则速度快、成本低LLM 抽取则是把页面内容交给模型去理解并返回结构化字段适合字段结构不固定、需要一定推理的场景。OutputGenerator输出生成器——负责格式化输出。默认生成清洗过的 Markdown这是直接给大模型阅读用的也支持纯文本模式、原始 HTML 模式以及自然语言格式模式。自然语言格式是打包成 JSON 的里面每个字段都会额外带上一句语义上下文描述RAG 索引阶段可以直接用。2.3 为什么输出 Markdown这一步这么重要这里我要多说一句因为它直接关系到为什么 crawl4ai 在LLM圈子里能火起来。大模型本身在预训练阶段已经见过海量 Markdown 格式的语料对这个格式的信息密度和结构语义特别敏感。你给它一段 Markdown它知道#是标题、|是表格、-是列表它不需要额外推理这段文字在页面里是什么角色。反过来你给它一段附带各种标签的HTML它要花额外精力去过滤标签同时还要忍受header和footer里跟正文无关的噪音——token 成本高回答质量还下降。我做过一个对比测试拿同一篇技术博客文章分别用传统爬虫抓下的 HTML 和 crawl4ai 生成的 Markdown 作为上下文让大模型总结核心观点。前者的有效信息密度明显偏低总结时偶尔会把页面侧边栏的相关文章标题也当成正文内容来引用后者则干净得多几乎不需要额外的提示词约束。这个测试做下来我就决定把所有新项目的数据采集层统一切到 crawl4ai 上了。3. 从安装到跑通第一个Demo环境准备与最小示例3.1 依赖安装的细节crawl4ai 是基于 Python 的所以第一步肯定是要有 Python 环境实测 3.9 以上都行建议 3.10避免某些类型注解兼容问题。用 pip 安装pip install crawl4ai安装完成之后还有一步很容易被忽略——它内部要调起 Chromium 做动态页面渲染所以需要先安装浏览器内核crawl4ai-setup这个命令会下载对应版本的 Chromium 到本机。如果你是在服务器里跑不要遗漏这一步否则后面只要碰到动态渲染的页面就会报浏览器启动失败的错误。国内网络环境下这一步耗时可能比较长耐心等就好。注意如果之前装过旧版本升级后一定要重新跑一次crawl4ai-setup否则浏览器内核和代码版本不匹配会莫名其妙报协议错误。3.2 最简单的爬取代码装好之后一个最小可运行案例长这样import asyncio from crawl4ai import AsyncWebCrawler async def main(): async with AsyncWebCrawler() as crawler: result await crawler.arun( urlhttps://example.com, outputmarkdown ) print(result.markdown[:500]) if __name__ __main__: asyncio.run(main())这段代码做了什么AsyncWebCrawler()相当于申请了一个经纪人它负责管理 Chromium 实例和网络请求arun()是执行单次抓取的核心方法传入一个 URL在渲染完成后把结果放回result对象最后直接打印result.markdown就拿到了干净的文本。outputmarkdown是默认值所以也可以不写。如果你只想要纯文本改成outputtext想要拿到原始 HTML 调试用改成outputhtml。执行完这段代码你会看到输出的内容里没有htmlbody之类的标签只有分类清晰的文本段落。对于内容型网站这一步拿到的结果已经可以直接入库了。3.3 同步与异步怎么选crawl4ai 同时提供了同步接口WebCrawler和异步接口AsyncWebCrawler。我的建议是写测试脚本、快速调试用同步接口代码更直观做正式的数据管道、要并发抓取成百上千个页面用异步接口。异步接口的优势是可以在一个事件循环里同时发起多个抓取任务整体吞吐量高很多。但要注意异步模式下每个并发任务都会占用内存和网络连接配置不当容易把机器跑爆后面我会专门讲这个坑。4. 三个能直接用起来的实战场景4.1 场景一批量采集新闻页面的正文内容新闻类网站结构相对规整普遍是标题 发布时间 正文 面包屑其中正文部分是我们真正想要的。用 crawl4ai 的默认输出模式其实就够了因为它内置的清理器会优先保留正文类标签中的内容。我实际跑过的一个案例抓取某科技资讯站最近一周的文章标题和正文import asyncio from crawl4ai import AsyncWebCrawler URLS [ https://news.example.com/post/1001, https://news.example.com/post/1002, https://news.example.com/post/1003, ] async def crawl_one(crawler, url): result await crawler.arun(urlurl, outputmarkdown) return { url: url, title: result.metadata.get(title, ), content: result.markdown, } async def main(): async with AsyncWebCrawler() as crawler: tasks [crawl_one(crawler, url) for url in URLS] results await asyncio.gather(*tasks) for item in results: print(f标题: {item[title]}) print(f正文长度: {len(item[content])}) print(---) asyncio.run(main())这里有个非常省心的点result.metadata里面已经帮你解析好了页面的 title、meta description、页面语言等基础信息不用再单独写一个解析函数去处理head里的标签了。这也再次印证了它面向 LLM 的数据处理器这个定位——它给你的不是原始的 DOM而是整理过的、结构化的信息包。4.2 场景二从产品列表中提取结构化字段要采集商品列表页时页面里通常有价格、评分、品牌、库存状态等信息并且每个商品对应的是一个卡片结构的 DOM。这种场景就不能只用默认的 Markdown 输出了因为 Markdown 只保留文本结构不会给你吐出规整的 JSON 字段。这时应该用 CSS 提取策略import asyncio from crawl4ai import AsyncWebCrawler from crawl4ai.extraction_strategy import CSSExtractionStrategy async def main(): css_strategy CSSExtractionStrategy( css_selector{ name: .product-name, price: .product-price, rating: .star-rating, stock: .availability, }, typejson ) async with AsyncWebCrawler() as crawler: result await crawler.arun( urlhttps://shop.example.com/products, extraction_strategycss_strategy, ) print(result.extracted_content) asyncio.run(main())CSSExtractionStrategy做的事情说白了就是帮你把常用正则、XPATH、选择器提取的工作全部封装起来你告诉它每个字段对应哪个 CSS 选择器它批量执行并组装成 JSON 返回。这里有个经验之谈写 CSS 选择器前最好先在浏览器开发者工具里确认一下目标节点是不是唯一匹配、是不是嵌套在 iframe 里避免拿到的字段全是空值。4.3 场景三深度爬取一个文档站为 RAG 构建知识库知识库建设往往需要一个站点下所有相关的子页面而不是只抓首页。比如你要把某个开源项目的官方文档全部抓下来作为 RAG 索引如果手动一个个去翻 URL 会疯掉。crawl4ai 提供深度爬取模式自动从当前页面解析出同域名下的子链接然后按广度优先或深度优先策略一层层往下抓。下面的代码可以做到从根页面出发抓取最多两层深度的子页面并限制总页面数不超过 20import asyncio from crawl4ai import AsyncWebCrawler from crawl4ai.deep_crawling import BFSDeepCrawlStrategy async def main(): strategy BFSDeepCrawlStrategy( max_depth2, include_externalFalse, max_pages20 ) async with AsyncWebCrawler(deep_crawl_strategystrategy) as crawler: results await crawler.arun( urlhttps://docs.example.com/, outputmarkdown ) # 深度爬取模式下结果可能在 crawler.results 里 if hasattr(crawler, results): for page in crawler.results: print(f抓取: {page.url}, 长度: {len(page.markdown)}) else: print(results.markdown[:200]) asyncio.run(main())include_externalFalse这个参数很关键——它保证爬虫只在当前域名内走不会跑到外链网站上去那些外链通常是社交分享、友情链接之类的。我实际构建知识库时还会在深度爬取之后对每个页面的 Markdown 做一次段落切分再送到向量数据库里索引这样检索出来的片段颗粒度更合适。这个流程后面会展开说。5. 实测踩过的坑完整的排查链路与解法工具再顺手实际用起来也难免出问题。我把这段时间遇到的四个典型问题整理一下每一个都按现象 → 排查 → 解决的链路写方便你遇到类似情况时照着思路走。5.1 动态渲染页面始终拿不到内容现象用默认配置抓某个 Vue/React 搭建的网站返回的 markdown 是空的或者只有登录框之前的几句模板文案。排查我在浏览器里手动打开页面发现正文内容在渲染完成后才插入 DOM。于是我在代码里加了一行调试把result.html后面的 1000 个字符打印出来看了一下发现返回的 HTML 里确实没有正文节点说明内容是在页面加载后通过 JavaScript 异步请求接口填充的爬虫默认的等待策略没等到这个异步动作完成。解决crawl4ai 支持自定义浏览器的等待行为。最简单的方案是配置wait_untilnetworkidle表示等页面上的网络请求全部结束后才返回。如果页面有轮询接口、网络请求始终不断就用固定等待比如wait_untildomcontentloaded加上js_code注入一段setTimeout逻辑增加等待时间。result await crawler.arun( urlhttps://app.example.com/dashboard, wait_untilnetworkidle, page_timeout30000, )这个改动之后正文内容就能正常抓到了。核心逻辑是无头浏览器虽然能执行 JS但前提是你得给它一个合理的触发异步完成的信号否则它拿到初始 HTML 就交差了。5.2 登录态和 Cookie 注入失败现象目标站点有免费阅读配额未登录只能看到全文的前部分。我尝试把自己的 Cookie 复制进配置但还是拿不到完整内容。排查先把 Cookie 字符串打印出来核对它的域名属性发现我复制 Cookie 的时候带上了HttpOnly标记。这个标记在请求头里没影响但如果通过LocalStorage或某些自动化注入机制去设置就可能失败。另外有些页面有两个域名www和裸域名Cookie 的 domain 属性区分得很细注入的 Cookie 和实际请求的域名对不上服务器就不会识别。解决最稳妥的做法是直接在浏览器上下文层面加 Cookie代码长这样from crawl4ai.browser_manager import BrowserConfig config BrowserConfig( user_data_dir./profile, # 指定一个持久的浏览器用户目录 headlessTrue, ) crawler AsyncWebCrawler(configconfig)第一次运行的时候手动登录一次把headless临时设为False浏览器会把登录态写入用户目录之后再改成 headless 模式运行页面就能直接以登录状态访问了。这个方法比硬塞 Cookie 字符串稳定太多是我强烈推荐的方式。5.3 输出内容里混入大量导航和推荐位文本现象某个门户类网站抓出来的 Markdown前面一大半是顶部菜单栏和热门推荐正文被挤到很后面导致喂给 LLM 后回答质量明显下降。排查打开result.cleaned_html检查发现页面在 HTML 语义化上做得比较差顶部推荐区块用的不是nav标签而是div classhot-articles因此默认的清洗器没有识别为导航区块。这个问题不是 crawl4ai 的 bug而是站点结构不规范导致了通用规则失效。解决用 CSS 策略先做一次预清理。我手动指定content_selector让它只提取主体容器内的内容result await crawler.arun( urlhttps://portal.example.com/article/12345, outputmarkdown, content_selector.article-content, #main-body, )content_selector会在页面清洗之前先做一次区域裁剪把整个页面直接缩小到我们关心的 DOM 子集里后面所有清洗逻辑都只在这个范围内执行效果和速度都比先抓全页再抽取好。5.4 并发数一拉高机器内存直接爆掉现象我一开始图快把并发数调到了 20跑了不到 3 分钟服务器内存报警进程直接被系统杀掉。排查首先想到的是并发数太高于是下调到 5发现内存还是慢慢往上涨说明不是单纯的并发问题。我注意到每一次AsyncWebCrawler()实例都调起了独立的 Chromium 进程多个实例同时存在时系统里挂着好多浏览器进程每个都吃几百 MB 内存。解决不要重复创建 crawler 实例全应用只维护一个实例通过控制任务队列来管理并发。crawl4ai 内部已经对单个实例做了浏览器并发复用的优化这是一个开箱即用的特性前提是你别自己再造多个实例。import asyncio from crawl4ai import AsyncWebCrawler URLS [fhttps://example.com/page/{i} for i in range(50)] async def main(): semaphore asyncio.Semaphore(5) # 控制同时进行的爬取任务数 async with AsyncWebCrawler() as crawler: async def bounded_crawl(url): async with semaphore: result await crawler.arun(urlurl) return result.markdown[:100] tasks [bounded_crawl(url) for url in URLS] results await asyncio.gather(*tasks) print(f成功抓取 {len(results)} 个页面) asyncio.run(main())用信号量把并发限制在 5 以内实测内存占用非常平稳。这个模式是并发批量爬取的标准写法切记不要每个任务单独AsyncWebCrawler()一把梭。6. 从导出数据到建立数据管道进阶调优思路6.1 与 RAG 流程衔接的完整链路单次抓取只是第一步。真正做知识库项目时我的标准处理链路是用 crawl4ai 拿到页面的干净 Markdown按标题层级把 Markdown 切成多个语义块heading 2/3 作为天然切分点每个语义块转成 embedding 向量写入向量库查询时先做向量检索再把命中的原始 Markdown 片段拼进 Prompt。crawl4ai 输出的 Markdown 有个额外的优势它保留了网站在排版上的标题层级这个层级对文本切分特别友好。相比之下某些付费API吐出来的纯文本是完全扁平的切分时还得用长度硬切效果差很多。6.2 自定义提取逻辑的正确打开方式如果你觉得自带的几个提取策略都不够用可以继承官方给出的基类实现自己的处理逻辑。核心思路是先拿result.cleaned_html作为输入用自己熟悉的解析方式比如正则、lxml、或者干脆再调一次 LLM去抽取目标内容。因为cleaned_html已经经过一轮清洗噪音比原始 HTML 少很多这种自定义方式的成功率也比直接拿原始 HTML 解析要高不少。我在一个价格对比项目里就踩过类似场景某个商品页的价格是动态多段拼接的单纯两个选择器都拿不到完整价格。最后我写了个自定义提取器先用一个选择器拿总价区间再用另一个选择器拼接具体分期价最后再补一段字符串拼接逻辑才搞定。这种场景用固定的策略表达式是覆盖不了的必须留一个自定义的口子。6.3 资源规划与缓存策略如果你要把 crawl4ai 放到生产环境里常态化跑有几件事值得注意磁盘缓存crawl4ai 支持页面缓存对于更新频率不高的站点可以显著减少重复抓取对目标服务器带来的压力也能大幅提速。配置一个cache_mode参数把缓存目录指到固态硬盘上实测二次抓取速度提升非常明显。抓取频率设置合理的间隔时间别把目标站点当成你的私有数据库频繁轰炸。这个既有职业道德的因素也有现实的技术原因——激进的频率很容易触发对方反爬策略最终你的 IP 会被临时封禁得不偿失。输出内容落库建议直接存 Markdown 原文同时单独保存一份 JSON 格式的元数据抓取时间、URL、标题、页面语言等。Markdown 文件可以放在目录里方便人工查阅元数据进数据库方便后续管理和增量更新。7. 写在最后的一点心得把 crawl4ai 用起来之后我对爬虫这件事的认知改变了很多。以前觉得爬虫就是这个页面怎么解、那个接口怎么破现在更愿意把它看作内容管道的入口重点思考的是数据拿回来之后要用什么形状流转到下一个环节。如果你只是偶尔抓几个页面用它可能有点大炮打蚊子但如果你和我一样经常要批量采集页面、给大模型应用准备语料、或者构建自己的知识库那么它带来的免清洗能力真的能省下大量时间。另外有一点我每次都要提醒自己不管工具多方便采集数据时还是要尊重目标网站的 robots 协议和服务条款控制好频率做一个有节制的爬虫使用者。稳定、合规、可持续比任何花哨的抓取技巧都重要。