news 2026/9/28 7:05:01

面向LLM的爬虫:用crawl4ai打造干净的Markdown数据管道

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
面向LLM的爬虫:用crawl4ai打造干净的Markdown数据管道

1. 为什么我给LLM写爬虫时放弃了传统方案

做RAG(检索增强生成)和Agent类项目的人,迟早会撞上同一个问题:喂给大模型的"资料"应该长什么样?

我之前一直用 requests + BeautifulSoup 自己写抓取逻辑,一套流程敲下来其实也不复杂:发请求、解析DOM、抽正文、清洗、存库。但真正做起来才发现,传统爬虫的输出形态和大模型的消费习惯之间,隔着一道很宽的沟。你爬下来的是HTML,里面充斥着<div><span><script>标签、广告位、弹窗代码、埋点脚本,还有一个占了半屏的导航栏。你把这段东西原封不动塞进LLM的上下文里,先不说token浪费,模型的理解精度也会被大量噪音拉低——它得自己先学会在一堆HTML里"猜"哪里是正文,这本身就是一件容易出错的事。

后来我注意到 crawl4ai 这个开源项目,unclecode 出品的,定位很明确:面向LLM的爬虫。它最吸引我的点不是说抓取性能有多逆天,而是它把"从网页到模型可读数据"这条链路完整打通了。你给它一个URL,它默认吐给你的是干净的 Markdown 文本,甚至是按"自然语言语义"组织好的 JSON 结构,基本上不需要你再做二次清洗。

如果你正在给 LLM 应用搭数据管道,或者被各种"能够提取正文"的付费API的报价吓到了,那么这篇文章里讲的东西应该对你有用。我会从原理讲到实战,再列一些我踩过的坑,尽量做到看完能直接上手用。

2. crawl4ai的底层逻辑:它不是"爬虫",而是"网页到数据的处理器"

2.1 抓取流水线拆解

crawl4ai 的整个流程不是一个单步操作,而是由多个阶段串联起来的。你可以把它理解成一条小型流水线:

  1. 目标分析:拿到 URL 后,先做合法性校验和去重判断,如果过去一段时间内已经抓过同一个地址,可以直接走缓存。
  2. 渲染阶段:根据配置决定用纯 HTTP 请求拉取静态 HTML,还是启动无头浏览器执行 JavaScript 后拿渲染完成后的 DOM。大部分现代网站都有动态内容,所以这一步很关键。
  3. 内容清理:把 HTML 里的<script>、<style>、<nav>、<footer>等无关信息识别出来并剔除。这个环节是 crawl4ai 做得比较精细的地方,它会保留表格、代码块、图片链接这些有信息量的结构,但删掉对大模型没有意义的装饰性元素。
  4. 结构化输出:把清理后的内容转换成 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( url="https://example.com", output="markdown" ) print(result.markdown[:500]) if __name__ == "__main__": asyncio.run(main())

这段代码做了什么?AsyncWebCrawler()相当于申请了一个经纪人,它负责管理 Chromium 实例和网络请求;arun()是执行单次抓取的核心方法,传入一个 URL,在渲染完成后把结果放回result对象;最后直接打印result.markdown就拿到了干净的文本。

output="markdown"是默认值,所以也可以不写。如果你只想要纯文本,改成output="text";想要拿到原始 HTML 调试用,改成output="html"。

执行完这段代码,你会看到输出的内容里没有<html><body>之类的标签,只有分类清晰的文本段落。对于内容型网站,这一步拿到的结果已经可以直接入库了。

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(url=url, output="markdown") 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", }, type="json" ) async with AsyncWebCrawler() as crawler: result = await crawler.arun( url="https://shop.example.com/products", extraction_strategy=css_strategy, ) print(result.extracted_content) asyncio.run(main())

CSSExtractionStrategy做的事情说白了就是帮你把常用正则、XPATH、选择器提取的工作全部封装起来:你告诉它每个字段对应哪个 CSS 选择器,它批量执行并组装成 JSON 返回。这里有个经验之谈:写 CSS 选择器前最好先在浏览器开发者工具里确认一下目标节点是不是唯一匹配、是不是嵌套在 iframe 里,避免拿到的字段全是空值。

4.3 场景三:深度爬取一个文档站,为 RAG 构建知识库

知识库建设往往需要一个站点下所有相关的子页面,而不是只抓首页。比如你要把某个开源项目的官方文档全部抓下来作为 RAG 索引,如果手动一个个去翻 URL 会疯掉。crawl4ai 提供深度爬取模式,自动从当前页面解析出同域名下的子链接,然后按广度优先或深度优先策略一层层往下抓。

下面的代码可以做到:从根页面出发,抓取最多两层深度的子页面,并限制总页面数不超过 20:

import asyncio from crawl4ai import AsyncWebCrawler from crawl4ai.deep_crawling import BFSDeepCrawlStrategy async def main(): strategy = BFSDeepCrawlStrategy( max_depth=2, include_external=False, max_pages=20 ) async with AsyncWebCrawler(deep_crawl_strategy=strategy) as crawler: results = await crawler.arun( url="https://docs.example.com/", output="markdown" ) # 深度爬取模式下,结果可能在 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_external=False这个参数很关键——它保证爬虫只在当前域名内走,不会跑到外链网站上去,那些外链通常是社交分享、友情链接之类的。我实际构建知识库时,还会在深度爬取之后对每个页面的 Markdown 做一次段落切分,再送到向量数据库里索引,这样检索出来的片段颗粒度更合适。这个流程后面会展开说。

5. 实测踩过的坑:完整的排查链路与解法

工具再顺手,实际用起来也难免出问题。我把这段时间遇到的四个典型问题整理一下,每一个都按"现象 → 排查 → 解决"的链路写,方便你遇到类似情况时照着思路走。

5.1 动态渲染页面始终拿不到内容

现象:用默认配置抓某个 Vue/React 搭建的网站,返回的 markdown 是空的,或者只有登录框之前的几句模板文案。

排查:我在浏览器里手动打开页面,发现正文内容在渲染完成后才插入 DOM。于是我在代码里加了一行调试,把result.html后面的 1000 个字符打印出来看了一下,发现返回的 HTML 里确实没有正文节点,说明内容是在页面加载后通过 JavaScript 异步请求接口填充的,爬虫默认的等待策略没等到这个异步动作完成。

解决:crawl4ai 支持自定义浏览器的等待行为。最简单的方案是配置wait_until="networkidle",表示等页面上的网络请求全部结束后才返回。如果页面有轮询接口、网络请求始终不断,就用固定等待,比如wait_until="domcontentloaded"加上js_code注入一段setTimeout逻辑,增加等待时间。

result = await crawler.arun( url="https://app.example.com/dashboard", wait_until="networkidle", page_timeout=30000, )

这个改动之后,正文内容就能正常抓到了。核心逻辑是:无头浏览器虽然能执行 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", # 指定一个持久的浏览器用户目录 headless=True, ) crawler = AsyncWebCrawler(config=config)

第一次运行的时候手动登录一次(把headless临时设为False),浏览器会把登录态写入用户目录;之后再改成 headless 模式运行,页面就能直接以登录状态访问了。这个方法比硬塞 Cookie 字符串稳定太多,是我强烈推荐的方式。

5.3 输出内容里混入大量导航和推荐位文本

现象:某个门户类网站抓出来的 Markdown,前面一大半是顶部菜单栏和热门推荐,正文被挤到很后面,导致喂给 LLM 后回答质量明显下降。

排查:打开result.cleaned_html检查,发现页面在 HTML 语义化上做得比较差,顶部推荐区块用的不是<nav>标签而是<div class="hot-articles">,因此默认的清洗器没有识别为导航区块。这个问题不是 crawl4ai 的 bug,而是站点结构不规范导致了通用规则失效。

解决:用 CSS 策略先做一次"预清理"。我手动指定content_selector,让它只提取主体容器内的内容:

result = await crawler.arun( url="https://portal.example.com/article/12345", output="markdown", 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 = [f"https://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(url=url) 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 流程衔接的完整链路

单次抓取只是第一步。真正做知识库项目时,我的标准处理链路是:

  1. 用 crawl4ai 拿到页面的干净 Markdown;
  2. 按标题层级把 Markdown 切成多个语义块(heading 2/3 作为天然切分点);
  3. 每个语义块转成 embedding 向量,写入向量库;
  4. 查询时先做向量检索,再把命中的原始 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 协议和服务条款,控制好频率,做一个有节制的爬虫使用者。稳定、合规、可持续,比任何花哨的抓取技巧都重要。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 7:04:19

SWD协议详解:从双线链路到寄存器操作与调试时序

SWD这个词&#xff0c;几乎所有做过ARM开发的人都在调试器日志里见过。但真到了板子连不上、固件烧不进、调试器报错的时候&#xff0c;能沉下心把SWD协议、寄存器操作和时序图三者串起来排查问题的&#xff0c;少之又少。我自己也是从“会用J-Link点一下下载”到“被产线设备逼…

作者头像 李华
网站建设 2026/9/28 7:03:36

Claude Code实战指南:安装配置、模型接入与效率技巧全解析

Claude Code 是我今年在终端里用得最多的 AI 编程工具&#xff0c;没有之一。它是 Anthropic 官方推出的命令行编程助手&#xff0c;直接跑在项目目录里&#xff0c;能读你的代码、改文件、执行命令、跑测试&#xff0c;配合 Claude 系列模型&#xff0c;相当于给终端请了一个随…

作者头像 李华
网站建设 2026/9/28 7:03:19

当AI Agent开始自我进化,普通人如何用TaoToken管好配置与密钥?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 7:03:02

PPG与ECG信号预处理与特征提取:从原始波形到可用特征的完整流水线

简介&#xff1a;面向生物医学信号处理与可穿戴设备数据分析场景&#xff0c;这套资料整合了PPG与ECG同步采集数据及可一键运行的Python预处理与特征提取代码。基于NeuroKit2库完成两类信号的去噪&#xff0c;进而提取潮波幅值比h2/h1、重搏波幅值比h4/h1、收缩面积比S1/S、舒张…

作者头像 李华
网站建设 2026/9/28 7:02:14

TC3XX CAN硬件连接与MCAL配置实战指南

1. 这不是教科书&#xff0c;是我在TC3XX项目现场拆下来的“活电路”你手上正拿着一块英飞凌TC3XX系列芯片——可能是TC375、TC397&#xff0c;也可能是刚流片回来的TC387。它被焊在一块四层PCB上&#xff0c;旁边贴着标签&#xff1a;“VCU主控板V2.3”。你打开调试器&#xf…

作者头像 李华