Crawl4AI 实战:把文档站采集成可追溯的知识库数据

用 Crawl4AI 0.9.5 完成文档发现、动态正文采集、Markdown 导出和内容变更识别,并复现缓存导致读到旧正文的情况。

亿牛云技术团队2026年10月22日10 分钟阅读

文档能转成 Markdown,还不足以直接进入知识库。你需要知道正文来自哪里、何时获取、是否变化,以及哪些页面没有采到。

这次使用 Crawl4AI 0.9.5,在不调用模型的情况下完成一条小型采集流程。2026 年 10 月 29 日的本地运行得到三篇有效文档;修改示例站后,第二轮得到四篇,识别出新增一篇、修改一篇、未变两篇。

示例源码仓库为 16yun-cn/www-16yun-cn-v3,代码位于 scripts/blog-ai-crawling/。它输出 JSONL 和结果报告,不包含向量数据库、embedding 或问答服务。

先把采集结果定义清楚

每条记录保留以下字段,避免正文脱离来源。

字段用途本例处理
url找回原始页面使用绝对地址,去掉 fragment
title展示和检查文档身份从保留区域首个 h1 获取
fetched_at记录本次获取时间UTC 时间,包含时区
hash比较正文是否变化对最终 Markdown 做 SHA-256
markdown后续清洗、分块的输入保留正文、代码及表格文本

哈希比较的是这条管线生成的 Markdown。如果你更换清洗算法、输出格式或库版本,哈希变化也可能来自处理方式变化。

fetched_at 是采集时间,不应当被解释为作者的文章更新时间。

用可控文档站建立基线

示例站由 Python 标准库 HTTP 服务器提供,无需真实账号。

路径场景应得到的结果
/docs/文档索引,包含重复链接发现 URL,不作为正文入库
/docs/start普通正文、代码块、相对链接一篇有效文档
/docs/table字段说明表格保留表格中的字段及含义
/docs/dynamic延迟插入正文等待业务标记后提取
/docs/emptyHTTP 200,正文为空记录 empty_body
/docs/missingHTTP 404记录 http_failure
/outside文档范围之外不请求

第二轮会修改 /docs/start,并在索引增加 /docs/new。

完整场景在 serve.py 中定义;采集脚本自己启动临时端口并在结束时关闭服务器。你不必先手工运行一个文档服务。

获取与本文一致的示例版本

先准备 Git 和已能访问公开仓库的 GitHub CLI。下面使用仓库标识获取固定版本示例。

gh repo clone 16yun-cn/www-16yun-cn-v3 ai-crawling-examples -- --filter=blob:none --sparse
cd ai-crawling-examples
git checkout --detach bc6828e62c417b1a3459199811fd4e2164137a5f
git sparse-checkout set scripts/blog-ai-crawling

这个固定提交包含本文运行过的脚本和依赖锁;稀疏检出只取运行示例需要的目录及仓库根文件,不要求启动官网。

后文安装命令从这个 checkout 的根目录执行。代码摘录按完整示例的文件和行号标注,省略的 import、变量初始化及清理步骤仍在原文件中。

你可以先阅读代码摘录理解行为,再执行完整脚本;不要将多段摘录当作一个可直接粘贴运行的独立文件。

固定依赖并运行完整示例

本次环境为 Python 3.12.13、Crawl4AI 0.9.5、Playwright 1.63.0。以下命令从本项目源码根目录执行,将依赖和生成文件放在临时目录。

LAB_ENV="$(mktemp -d)"
uv venv --python 3.12 "$LAB_ENV/venv"
uv pip sync --python "$LAB_ENV/venv/bin/python" \
  scripts/blog-ai-crawling/requirements.lock
"$LAB_ENV/venv/bin/python" -m playwright install chromium
CRAWL4_AI_BASE_DIRECTORY="$LAB_ENV/cache" \
  "$LAB_ENV/venv/bin/python" \
  scripts/blog-ai-crawling/crawl_docs.py \
  --output "$LAB_ENV/results"

Linux 主机还需要满足 Playwright 的系统依赖。浏览器安装失败时,应先解决运行环境,不要把缺少浏览器误记成页面抓取失败。

脚本成功结束后,目录中有 first.jsonl、second.jsonl 和 crawl-report.json。断言失败会让进程失败,不会打印一个看似正常的成功统计。

先检查输出,再扩展采集范围

下面摘录首轮 first.jsonl 中 /docs/start 的来源字段,正文省略。为方便阅读将一行 JSON 展开;实际文件每行是一条完整记录。

{
  "url": "http://localhost:41113/docs/start",
  "title": "Getting started",
  "fetched_at": "2026-10-29T04:57:58.015308+00:00",
  "hash": "5ba66cbac633b66b572e9fc22ead198eacc663554ee018d3c0c58d1b101bf445"
}

41113 是这次运行分配的端口。你复跑时会得到另一个端口和获取时间,不应把它们写成固定断言。

正文包含解析后的绝对链接,因此不同运行的随机端口也可能影响内容哈希。本例只在同一服务器实例的两轮采集中比较哈希,没有宣称不同临时实例之间的哈希必须相等。

输出逻辑见 scripts/blog-ai-crawling/crawl_docs.py:77-80。可以用标准库工具打开本次报告,核对计数与失败项。

"$LAB_ENV/venv/bin/python" -m json.tool "$LAB_ENV/results/crawl-report.json"

优先查看 first_records、second_records、changes 和两个 failures 字段。报告存在并不等于任务通过,还需要命令正常结束以及脚本中的业务断言没有失败。

发现 URL,同时限制采集范围

索引中的重复链接不能简单照单发送。下面是 crawl_docs.py 的发现逻辑摘录,变量 base 是脚本创建的本地服务地址。

对应示例:scripts/blog-ai-crawling/crawl_docs.py:20-24。以下仅摘录相关语句,已为阅读展开换行。

urls = set()
for link in index.links.get("internal", []):
    url = urldefrag(urljoin(base + "/docs/", link["href"]))[0]
    if (
        urlsplit(url).netloc == urlsplit(base).netloc
        and urlsplit(url).path.startswith("/docs/")
    ):
        urls.add(url)

这里有三项独立处理:解析相对链接、去掉页内定位、限制同源文档路径。

本次请求记录中没有 /outside,重复的 start 链接也没有生成重复文档。这个规则适用于示例站;它没有实现查询参数归一化、canonical 合并或重定向后的域名策略。

真实站点若存在多种 URL 指向同一内容,应先定义 URL 身份规则,再扩展这一段。

等正文出现,再做提取

文档加载完成时,JS 插入的正文可能还不存在。示例用 data-ready=yes 表示正文已准备好。

对应示例:scripts/blog-ai-crawling/crawl_docs.py:27-33。以下仅摘录相关语句,已为阅读展开换行。

config = CrawlerRunConfig(
    cache_mode=CacheMode.BYPASS,
    css_selector="main",
    word_count_threshold=0,
    wait_for="css:[data-ready=yes]" if url.endswith("/dynamic") else None,
)
result = await crawler.arun(url, config=config)

word_count_threshold=0 是为了保留这个小型示例中的短文档,不是建议所有生产采集都关闭正文长度筛选。

动态页的断言检查最终 Markdown 中存在 DYNAMIC_READY。只检查 HTTP 状态,无法发现“响应成功但正文尚未渲染”的漏采。

生产页面没有这个标记时,应找与业务数据对应的稳定条件,例如目标列表已经出现,避免直接照搬示例标记。

缩小 HTML 范围会影响标题来源

最初的标题读取方案依赖 result.metadata。实际运行发现,设置 css_selector="main" 后,返回 HTML 只包含命中的区域及包装元素,原始 head 没有保留,示例的 metadata 是空字典。

源码依据是 unclecode/crawl4ai 提交 888299f 的 crawl4ai/async_crawler_strategy.py:1096-1119:该分支读取匹配元素的 outerHTML,然后组装返回 HTML。

本例的标题在 main 内,所以从保留内容读取 h1。

对应示例:scripts/blog-ai-crawling/crawl_docs.py:42。以下是便于说明的等价或精简写法。

title = fromstring(result.html).xpath("string((//h1)[1])").strip()

脚本核对三个预期标题,避免空标题悄悄进入输出。若目标站标题只在 head 中,应调整采集策略或单独保留标题,不能假定 metadata 永远完整。

将失败留在报告中

示例把抓取失败与空正文分别记录。

对应示例:scripts/blog-ai-crawling/crawl_docs.py:35-38。以下仅摘录相关语句,已为阅读展开换行。

if not result.success or result.status_code != 200:
    failures.append({
        "url": url, "reason": "http_failure", "status": result.status_code,
    })
elif not markdown:
    failures.append({
        "url": url, "reason": "empty_body", "status": result.status_code,
    })

这是教学站约定的 HTTP 200 成功条件。真实站点对重定向、其他状态码和错误页的处理,需要依据自己的外部接口约定。

空文档不进入有效记录;失败项保留地址和原因,后续才有条件决定是否重试。示例没有把 404 自动改写为默认正文,也没有无限重试。

第二轮采集:发现和内容都要更新

修改服务器内容后,脚本重新读取索引,并使用 CacheMode.BYPASS 获取文档,然后对照上一轮的 URL 和哈希。

项目实际结果
首轮有效文档3 篇
第二轮有效文档4 篇
新增/docs/new
修改/docs/start
未变/docs/dynamic、/docs/table
每轮失败空正文 200 一项、404 一项

正文检查还覆盖代码内容、表格中的 Original source 文本,以及相对链接转换后的绝对地址。这些检查证明了示例内容被保留,没有证明所有 Markdown 格式都能无损转换。

此处的增量是“完整重新获取后的差异分类”,尚未实现只下载变化页面、删除同步或持久化调度。

差异分类依据是什么

URL 相同且 Markdown 哈希变化,才被归为修改;第一次见到的 URL 归为新增。这段是 scripts/blog-ai-crawling/crawl_docs.py:71-75 的等价展开。

old = {row["url"]: row["hash"] for row in first}
changes = {"new": [], "changed": [], "unchanged": []}
for row in second:
    if row["url"] not in old:
        state = "new"
    elif row["hash"] == old[row["url"]]:
        state = "unchanged"
    else:
        state = "changed"
    changes[state].append(urlsplit(row["url"]).path)

第二轮失败的页面不会自动归为删除。要实现删除同步,还需要明确“本轮没发现”“访问失败”和“确实下线”之间的区别。

本例没有解决这个业务规则,所以不会输出一个未经验证的 deleted 列表。

缓存命中不等于内容仍然新鲜

脚本单独用 CacheMode.ENABLED 读取 /docs/start,修改服务器后再次读取。两次 Markdown 一样,仍然是缓存中的旧内容。

这是本例未启用 freshness 验证时的观察,不是说所有启用缓存的配置都会忽略更新。相关缓存读取逻辑见同一提交的 crawl4ai/async_webcrawler.py:265-300。

你可以根据站点更新频率选择绕过缓存、设置明确的新鲜度策略,或使用服务端提供的更新信号。本例只实际验证了 ENABLED 与 BYPASS 的差异。

按现象排查,不要混用处理方法

看到的现象优先检查本例处理
浏览器尚不能启动Playwright 版本、浏览器安装和系统依赖使用锁文件及匹配的浏览器
动态页缺少正文目标区域是否已经就绪等待 data-ready,再断言正文标记
标题为空,但正文存在css_selector 是否移除了 head从保留区域读取并核对 h1
HTTP 200,正文仍为空内容区域是否真的有文本保留 empty_body 失败项
索引链接返回 404地址和服务端响应保留 http_failure,不生成默认正文
服务器修改后正文不变是否从缓存读取对照 ENABLED 与 BYPASS 的结果

先区分环境、获取和内容问题,再选择修复动作。浏览器缺失时修改 CSS 选择器没有帮助;正文读取过早时,换一个输出格式也不能补齐数据。

对真实文档站,建议保留一小组带预期的页面,用来判断配置变化是否改善了原问题。本文的代码块、表格和动态正文检查就是这种最小基线。

从示例迁移到自己的文档站

先选少量已知页面,核对正文和来源,再扩展 URL 发现范围。保留失败报告,并在清洗规则变化时重新评估内容哈希。

代理可以解决访问路径问题,但不会补回错误的选择器、过早读取的正文或缓存中的旧版本。本次没有验证商业代理出口、反爬效果和大规模并发。

下一步可读 Scrapling 改版提取实验,比较页面结构变化后“有返回结果”和“业务数据正确”的差别。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。