做 RAG 知识库、给 Agent 备语料、给大模型洗数据,最近这些活儿里网页采集始终是绕不开的第一环。很多人一开始直接上爬虫,我自己也一样,Scrapy 写过、Playwright 也配过,但跑几个月下来你会发现,真正费时间的不是"抓下来",而是"维护抓取"。页面结构调整要改选择器,反爬策略升级要换方案,动态渲染页还得养着无头浏览器。后来我换了个思路:把"读取网页正文"这件事直接交给 Reader API——给我一个 URL,返回干净的 Markdown。团队一个知识库项目的数据采集层整体切换之后,采集效率提升非常明显,维护成本也降了一个量级。这篇文章把我实际用下来的 4 种"网页数据秒转 AI 语料"技巧完整梳理一遍,从单链接转换到批量建库、定制提取、持续管道,附带代码和排坑记录,给正在搭知识库或者搞语料采集的同学一个可以直接抄作业的参考。
1. 先弄明白:Reader API 凭什么替掉爬虫,核心思路拆解
1.1 传统爬虫的三个痛点
先说结论:爬虫没有完全过时,但在"公开网页转语料"这个细分场景里,它确实正在被更轻的方案取代。过去我们写爬虫,本质上是在解决三个问题——下载 HTML、解析正文、处理站点差异。
下载那一步最烦。反爬技术年年升级,UA 检测、频率限制、JS 指纹、验证码轮番上阵,为了稳定抓取,你需要维护代理 IP 池、Cookie 池,甚至专门写一套绕过逻辑,这已经接近一场军备竞赛。解析那一步也很脆弱,你用 CSS 选择器或者 XPath 定位正文节点,页面改版一次,选择器就废一批,得重新对着开发者工具慢慢调。还有一类页面是前端渲染出来的,源码里根本没有正文,你还得上 Playwright 这种无头浏览器模拟用户访问,慢不说,并发一高服务器内存先扛不住。
这三件事叠加在一起,让一个简单的"从网页拿点文字"需求变得异常沉重。知识库项目里的页面来源往往很多,几十个站点,每个站点的结构和反爬策略都不一样,用爬虫维护一套采集系统,人力成本远超预期。
1.2 Reader API 的读取原理与定位
Reader API 的核心思路很直接:它把"提取网页正文"做成了一个标准服务,你不需要关心目标站点的 HTML 结构,只需要把 URL 传给它,它返回给你的是已经去掉导航、广告、侧栏、评论区这些噪音的干净内容,通常以 Markdown 格式返回。
说人话就是:你在手机浏览器里点开"阅读模式",网页瞬间只剩标题和正文,Reader API 相当于把同一件事做成了机器可调用的接口。只不过阅读模式服务的是人眼,它服务的是大模型和知识库管道。
这个定位决定了它和爬虫的取舍。爬虫是"我给你一套规则,你去解析 HTML";Reader API 是"我替你把页面读好,你只管拿结果"。前者灵活但脆弱,后者省事但定制性弱。对知识库建设来说,我们大多数时候要的就是正文本身,并不需要保留页面的复杂 DOM 结构,所以 Reader 的产出形态天然适合直接当语料。
1.3 适用边界:什么场景适合换,什么场景别硬换
判断一个数据源能不能用 Reader API,我一般看三个条件:公开可访问、无需登录态、以正文内容为主。满足这三条,基本可以放心切过去。
反过来,如果目标站点需要登录后才能看、数据更新频率极高且需要分钟级同步、或者你抓的是结构化的接口数据而不是页面正文,那 Reader API 未必合适。比如内部系统的详情页、需要带着 Session 才能访问的会员页面,这种还是得老老实实走爬虫。另外,如果站点条款明确禁止抓取,或者本身有完善的开放 API,优先走官方渠道,这是合规底线,不值得为了省事去碰红线。
2. 接入前必读:Reader API 的调用方式与关键参数
2.1 一次标准调用的完整姿势
不同团队的 Reader 服务接入方式会有差异,有的部署在公司内部,有的用现成服务,但大方向一致:给一个 URL,拿回正文。下面我以一套常见的接口契约为例子,字段名可能和你们手上的服务略有出入,但思路通用。
正常调用就是一条 HTTP GET 请求:
curl "https://reader.example.com/read?url=https%3A%2F%2Fexample.com%2Fdocs%2Fgetting-started&format=markdown"目标 URL 先做一次 URL 编码,避免?、&这些字符干扰参数解析。返回的 JSON 结构大致是这样:
{ "title": "Getting Started", "content": "# Getting Started\n\n欢迎阅读...", "url": "https://example.com/docs/getting-started", "timestamp": "2025-01-12T10:00:00Z" }content字段就是可以直接进入知识库的 Markdown 语料,title可以留作元数据,url是溯源信息,一定不要丢,之后做引用和去重都用得上。
2.2 最常用的 5 个参数与选型对照
接入 Reader API,真正需要花心思的是理解几个核心参数。我按实际使用频率排了个序:
| 参数 | 作用 | 我的建议 |
|---|---|---|
url | 目标网页地址 | 必须 URL 编码 |
format | 返回格式:markdown/html/text | 喂大模型选markdown,结构信息比纯文本有用 |
max_length | 内容截断上限,单位通常是 token 或字符 | 大页面按需限制,防止单条语料过长 |
links | 是否保留正文里的链接 | 正文场景建议保留,引用资料能溯源 |
selector | 自定义 CSS 选择器,只提取指定区域 | 页面杂乱的站点,用它缩小提取范围 |
还有一类参数是控制渲染行为的,比如是否等待动态内容加载完成、是否执行页面里的脚本。遇到前端渲染的页面,这类参数是关键,下面第三节和第六节里会具体讲。
2.3 环境准备与基础封装
我习惯在 Python 环境里干活,因为后续分块、向量化、入库的生态都在 Python。基础依赖就两个,requests用来发请求,核心库按知识库方案选。先把转换逻辑封装成一个函数,后面所有技巧都复用这一层:
import requests READER_ENDPOINT = "https://reader.example.com/read" def url_to_markdown(url: str, **kwargs) -> dict: params = {"url": url, "format": "markdown", **kwargs} resp = requests.get(READER_ENDPOINT, params=params, timeout=60) resp.raise_for_status() data = resp.json() return { "url": url, "title": data.get("title", ""), "content": data.get("content", ""), }封装这一层有几个好处:第一,后续不管单页转换还是批量任务,都调用同一个函数,出问题只改一处;第二,可以在函数里加统一的超时、重试和日志;第三,将来换 Reader 服务商,接口变化只影响这一个函数。
注意:
timeout不要设太短。有些页面本身响应慢,加上 Reader 服务还要做提取和渲染,60 秒是我的底线,批量任务里遇到慢页面宁愿让它超时重试,也不要整体卡死。
3. 技巧一:单链接秒转 Markdown,临时语料随取随用
3.1 适用场景
第一种技巧最朴素,也是最常用的:手上有一个具体的网页链接,想快速把它转成可用的语料。典型场景是写 Agent 的时候,用户丢来一个参考文档链接,你需要把它压缩成上下文喂给模型;或者查资料时看到一篇高质量文章,想立刻扔进自己的知识库。
这个场景的特点是一次性、单页面、即时性。不需要批量调度,不需要复杂的参数配置,核心诉求是"快"和"干净"。
3.2 代码示例
直接复用上面的url_to_markdown函数即可:
def quick_convert(url: str) -> str: result = url_to_markdown(url) print(f"标题: {result['title']}") print(f"正文长度: {len(result['content'])} 字符") return result["content"] # 临时处理一个链接 content = quick_convert("https://example.com/blog/rag-practice")如果只是临时用一下,不想写程序,命令行也能干。要留意的是带上常见的浏览器 User-Agent,部分 Reader 服务会校验基本的请求身份:
curl -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)" \ "https://reader.example.com/read?url=https://example.com/blog/rag-practice&format=markdown"3.3 三个容易踩的细节
第一,URL 编码。直接拼接链接经常把&带进参数里,导致解析错乱,这个在前面已经提过,代码里用params字典传参就能自动处理。第二,短内容过滤。有些页面转换出来只有几十个字,可能是跳转页、验证页或者内容被拦截了,单页场景不明显,批量场景一定要过滤,否则知识库里全是垃圾条目。第三,检查返回值里的url和请求是否一致。有些站点做了跳转,最终内容来自另一个地址,保留原始入口没问题,但元数据里最好同时记下最终地址。
4. 技巧二:解析 Sitemap 批量并发转换,一口气建几千条语料
4.1 链接发现的两种方式
单页转换解决不了知识库的"冷启动"问题。第一次搭建时,往往有几百上千个页面要入库,这时候第一步其实是链接发现——找到站点上有哪些页面值得转换。
我优先推荐解析sitemap.xml。绝大多数正规站点都有站点地图,里面按 XML 格式列出了所有公开页面地址,这是最标准、最全的入口。没有现成 sitemap 的站点,可以退而求其次,抓首页和栏目页,用链接提取规则找出正文页,但这种方式需要人工维护入口列表,成本高一些。
解析 sitemap 不需要复杂工具,Python 标准库就行:
import xml.etree.ElementTree as ET SITEMAP_NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"} def fetch_sitemap_links(sitemap_url: str) -> list: resp = requests.get(sitemap_url, timeout=30) resp.raise_for_status() root = ET.fromstring(resp.content) urls = [] for loc in root.findall("sm:url/sm:loc", SITEMAP_NS): if loc.text and loc.text.strip(): urls.append(loc.text.strip()) return urls拿到链接列表后,统一交给转换层。这里有个容易漏的坑:有些站点的 sitemap 是"索引型"的,根 sitemap 里嵌套了一堆子 sitemap 的地址,而不是直接列页面。解析时要先判断<loc>指向的是 sitemap 还是普通页面,递归处理,否则只能拿到一小部分链接。
4.2 批量转换的代码与调度
批量转换最忌讳的就是同步一个个抓,几十个页面还好,几百上千个页面会等到怀疑人生。我用线程池做并发,控制在 4 到 8 个并发,既能把速度提起来,又不会把 Reader 服务打到限流。下面是实际项目里摘出来的核心逻辑:
import concurrent.futures as cf def batch_convert(urls: list, concurrency: int = 5) -> list: results = [] def worker(url): try: data = url_to_markdown(url) return {"status": "ok", "data": data} except Exception as exc: return {"status": "failed", "url": url, "error": str(exc)} with cf.ThreadPoolExecutor(max_workers=concurrency) as executor: futures = {executor.submit(worker, url): url for url in urls} for future in cf.as_completed(futures): outcome = future.result() if outcome["status"] == "ok": results.append(outcome["data"]) else: print(f"[失败] {outcome['url']}: {outcome['error']}") return results links = fetch_sitemap_links("https://example.com/sitemap.xml") all_docs = batch_convert(links, concurrency=5)跑完以后一定要把结果落盘。我的习惯是每个页面保存成一个独立的 Markdown 文件,文件名用 URL 的哈希值,内容头部用元数据注释记录来源地址和抓取时间。这样做的原因是:批量任务不可能一次全成功,落盘后可以随时断点续跑;以后增量更新也只需要对比哪几个哈希不存在。如果一开始就想着全量塞进向量库,失败重跑的成本会高很多。
4.3 成本与限速估算
批量转换前,要算一笔账。假设一个页面平均正文 5000 字符,大概是 2000 到 3000 个 token,一千个页面就是 200 万到 300 万的 token。这还只是"读取"阶段的量,后续分块、做向量化、存知识库,每一步都在消耗资源。所以批量之前先抽样转换 10 个左右的页面,统计平均长度,用来估算总成本,这一步非常值得做。
并发和限速是另一个必须考虑的点。短时间大量请求对 Reader 服务本身有压力,很多服务会限制单位时间内的调用次数。我的经验是并发 5 同时把每个请求间隔控制在 100 毫秒以上,返回429或503时指数退避重试,初始等 2 秒,最多重试 5 次。宁可慢一点,也不要一次打爆服务导致整体被限流。
5. 技巧三:按站点结构定制提取,让输出更贴近知识库字段
5.1 为什么"整页正文"并不总是够用
用 Reader API 最顺手的场景是文章类页面,"整页正文"开箱即用。但知识库里经常混着别的页面类型:产品参数页、数据表格页、文档 API 说明页、论坛帖子聚合页。全页正文一股脑拿回来,结构信息丢失,表格内容混在段落里,大模型检索的时候很难定位。
举个具体例子,某个产品的技术规格页,正文里既有介绍文字又有参数表格。如果直接把整页转成 Markdown,表格还能保留一部分结构,但如果你只需要"电源参数"这一节,全页语料就显得臃肿。这时候要用到 Reader 的参数化能力,告诉它"只提取我划定的区域"。
5.2 参数组合:选择器、区块、链接模式
不同 Reader 服务的参数名不一样,但普遍支持"选择器"或者"区块"这类概念。我常用的组合是:selector指定目标区块的 CSS 选择器,links控制链接是否展开,format固定为 Markdown。
操作思路是:先在浏览器开发者工具里定位目标区块,比如表格章节的容器是div#spec-table,那就在调用时传selector=div#spec-table。这样返回内容只包含该区域,干净利落。对于文档站,正文容器通常有稳定的类名如article、.doc-content,比对整个站点所有子页面逐一调选择器可靠得多。
还有一种情况是页面自带"阅读全文"之类的折叠机制,默认只显示摘要。Reader 服务如果没法自动展开,可以看看有没有"展开全部"相关的参数,部分实现支持模拟点击,实在不行就配合页面本身的打印版或者纯文本版 URL,比如 URL 后面加?print=true,很多站点会输出全量内容。
5.3 一个把表格章节结构化输出的例子
模拟一段定制提取的调用,目标站点是一个数据库文档页,我们只要"性能参数"那一节:
params = { "url": "https://example.com/db/benchmark", "format": "markdown", "selector": "#performance-section", "links": "keep", } data = url_to_markdown("https://example.com/db/benchmark", **params) print(data["content"])输出的 Markdown 里表格会被保留成 Markdown 表格语法,大模型和向量检索对 Markdown 表格的理解比对混乱 HTML 好得多。如果 Reader 服务支持直接返回 JSON 结构,那更合适,比如把表格封装成一个数组字段,入库时直接映射成知识库的属性值。我遇到过有的服务支持structured=true这类选项,输出就是字段化的 JSON,做结构化知识库非常好用。
提醒:定制提取是四种技巧里最依赖站点结构的一种,选择器换了页面也要跟着调整,所以只对高频更新的核心页面做定制即可,长尾页面全量正文转换就够了。
6. 技巧四:搭一套"网页 → 知识库"持续更新管道,自动跑起来
6.1 管道整体设计
前三种技巧解决的是"怎么把网页转出来",第四种解决的是"怎么让这件事持续自动发生"。知识库不是建一次就完了,源站会更新内容,新文章会不断发布,如果不能增量同步,知识库很快就过时了。
我搭的管道分五段:
- 定时触发:每隔几小时或每天跑一次,用简单的定时任务调度。
- 增量发现:拉取目标站点的 sitemap,和上一轮已处理的 URL 集合做差集,只转换新增页面。
- 内容转换:复用
url_to_markdown,批量并发抓取新增页面。 - 分块与清洗:去掉无用空行和广告残留,按策略切块。
- 向量化入库:把分块后的语料做 embedding,写入向量库,并在元数据里保留来源 URL 和更新时间。
这套管道里最容易被忽略的是去重。不仅要做 URL 级去重,还要做内容级去重——同一个文章如果改了 URL 重新发布,或者不同页面引用同一份正文,转换结果可能重复。我用的是简单的内容指纹方案:取正文前 500 个字符算一个哈希,每条语料入库前检查哈希是否已存在,存在就跳过。
6.2 分块策略与向量化入库
语料进向量库之前必须分块。不分块直接把一整篇几千字的文章丢给 embedding 模型,检索效果会很差——相关性被平均稀释,回答问题时大模型拿到的是整篇历史文章,而不是最相关的那一段。分块策略我试过两种:
按标题结构分块,效果最好但依赖内容格式。Markdown 里有层级标题,把##、###作为切分边界,每块文本量控制在 800 到 1200 个 token 之间,超长的再按固定窗口二次切分,并保留少量重叠避免语义断崖。没有清晰标题的页面,退回到固定窗口切分,窗口大小按 token 数算,重叠率 10% 到 20%。
一个简化版本的分块函数长这样:
def simple_chunk(markdown_content: str, max_chars: int = 1500, overlap: int = 150) -> list: chunks = [] current = "" for line in markdown_content.splitlines(): current += line + "\n" if len(current) >= max_chars and line.strip() == "": chunks.append(current.strip()) current = current[-overlap:] # 保留尾部,避免断句 if current.strip(): chunks.append(current.strip()) return chunks向量化入库时,顺带把标题、来源 URL、分块序号一起写进元数据,这样将来检索到某一段,能直接定位到原文出处,做引用和人工复核都方便。
6.3 增量更新与质量审核
增量更新的核心是维护好已采集集合的状态。我会把每一轮任务的清单和结果存成一张表,字段包括:URL、内容指纹、采集时间、状态、重试次数。下一轮任务开始前先读这张表,已经成功的 URL 直接跳过,失败超过三次的单独标记出来人工看。
质量审核这步很多人不做,但我吃过亏。Reader 返回的内容不一定全部干净,有些页面会带着站内推荐、面包屑文字、甚至是"相关阅读"这种噪音块。我加了一道轻量过滤:统计每块语料里关键词频率,如果出现"推荐阅读""热门文章""点击查看"这类高频率模板词,就降低该块的入库权重或者直接拦截,交给人工确认。初期跑两个月,每天都会有几十条这种脏数据被拦下来,少了它们,知识库的准确率能明显上一个台阶。
7. 实战排坑:Reader API 常见的 7 个问题和排查实录
7.1 问题速查表
把这一年多在 Reader API 上遇到的典型问题整理成表,方便直接对照:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 返回 429 / 403 | 请求太频繁,被限流或封禁 | 降低并发,增加指数退避重试,检查 UA 和 Token |
| 返回内容为空 | 页面是 JS 动态渲染,默认没等到内容 | 开启渲染等待,或者传更高的超时参数 |
| 内容乱码 | 源站编码识别错误 | 检查响应编码,手动指定 UTF-8 或 GBK |
| 返回内容与页面不符 | 页面做了跳转,Reader 拿到的是落地页 | 对比返回的url和请求 URL,记录最终地址 |
| 正文被截断 | 超过内容长度上限 | 调整max_length,或按章节分段请求 |
| 批量任务反复失败 | 个别 URL 格式不合法 | 批量前做 URL 归一化,过滤空值和非法字符 |
| 语料里有大量导航文字 | 页面主区域识别失败 | 改用selector指定正文容器 |
7.2 三个最典型的排查过程
挑三个我实际踩过且最有代表性的展开说说。
第一个是限流问题。有一次我用并发 20 去跑一个几百页的站点,跑了不到 5 分钟,后续请求全部返回 429。一开始以为是代理挂了,后来看响应头里的Retry-After才发现是单位时间内的配额被打满了。当时立刻做的三件事是:并发降到 4、每次请求之间加 200 毫秒延时、写死一个最多 5 次的退避重试。跑完之后再不敢上来就开大并发,宁可慢半小时,也不要被拉黑。
第二个是动态渲染页面。某产品文档站是典型的前后端分离架构,源码里只有一个空的div容器,正文全靠 JS 加载。直接把 URL 丢给 Reader,返回的 Markdown 只有几行标题和默认文案。排查后发现需要在调用参数里开启"渲染模式",让 Reader 等待页面脚本执行完再提取。开启后原本空白的内容正常出来了,代价是单页耗时从 2 秒涨到 8 秒左右,但至少内容是对的。
第三个是编码问题。一个老牌网站页面声明的是 UTF-8,实际内容里混着 GBK 编码的段落,转换出来的文本里全是乱码方块。这个比较难在 Reader 端解决,我当时的处理是在后面清洗管道里加一步编码探测,用字符集检测库自动判断,乱码比例高的块直接重抓一次。重抓时换一个 Reader 服务节点,很多时候换个节点就好了,因为不同节点的解码逻辑不完全一致。
8. 我的实操体会:什么场景真的可以"让爬虫下岗"
最后聊点个人判断。我并没有把团队所有爬虫都拆掉,而是把架构分成了两层:知识库的内容语料层统一走 Reader API,因为这一层的特点是页面多、站点杂、要求输出干净文本,正好是 Reader 的强项;另外一套保留爬虫的,是针对特定业务的接口级数据采集,需要频繁抓取状态变化,且依赖登录态和复杂参数,这种场景 Reader API 替代不了。
走 Reader API 这条路半年多,最大的感受是关注点变了。以前每天在跟选择器更新和反爬策略较劲,现在把精力放在管道设计、分块策略和知识库质量上,产出价值高多了。如果你也在搭知识库,我建议的第一步不是写爬虫,而是拿几十个真实页面试一下 Reader 的转换效果,抽样看看内容的干净程度和耗时,大部分以文章、文档为主的站点,效果都会让你意外。最后一个小建议:无论用哪种方案,都要保留每个语料的来源 URL 和采集时间,这两条元数据在知识库出问题的时候,是唯一能快速定位和追溯的依据。