Crawl4AI 实战:把文档站采集成可追溯的知识库数据
用 Crawl4AI 0.9.5 完成文档发现、动态正文采集、Markdown 导出和内容变更识别,并复现缓存导致读到旧正文的情况。
文档能转成 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/empty | HTTP 200,正文为空 | 记录 empty_body |
/docs/missing | HTTP 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 改版提取实验,比较页面结构变化后“有返回结果”和“业务数据正确”的差别。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。