Reader 实战:给 Node.js 应用接入网页正文提取

用 ReaderClient 批量处理文章 URL,统一标题、正文与来源字段,保留失败去向,并正确关闭浏览器资源。

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

把几个网页接入搜索或摘要系统,常见做法是每个业务函数都写一遍页面加载和正文清理。结果是输出字段不一致,失败时有的返回空字符串,有的抛异常,浏览器资源也容易漏关。

本文用 @vakra-dev/reader 把明确给出的 URL 整理为统一记录。输出包含标题、Markdown、来源、采集时间与状态,便于下游决定是否继续处理。这里的 Reader 指 vakra-dev/reader,不是名字相近的远程 Reader API。

准备输入地址

先在浏览器找到下列页面并确认内容,再从地址栏复制完整地址。服务或仓库地址从相应账户设置、官方项目的接入说明或克隆菜单取得。不要从示例输出中反推真实地址。

  • DOC_URL_1:Python 教程 introduction.html 页的完整地址。
  • DOC_URL_2:同目录 controlflow.html 页的完整地址。

在执行后续命令的同一个 Bash/WSL 终端中设置:

export DOC_URL_1='<paste-the-actual-doc-url-1>'
export DOC_URL_2='<paste-the-actual-doc-url-2>'

下文 JSON 或任务参数中的 <变量名> 是待替换标记,提交前填写刚复制的实际值;JSON 不会自动展开环境变量。结果示例中的 example.16yun.cn 只表示来源字段的形状,不是本文部署的演示站。

安装并确认依赖边界

准备 Node.js 24。在新目录安装 Reader 0.3.5,并按项目要求准备 Chromium:

mkdir article-reader
cd article-reader
npm init -y
npm install @vakra-dev/reader@0.3.5
npx playwright install chromium

包的 HTTP/浏览器提取逻辑在本地运行,首次使用仍需下载浏览器。安装包成功不代表机器已有可执行 Chromium;浏览器下载失败应先处理安装问题,不要把它计为文章正文为空。

保留锁文件。如果项目已经有自己的 Playwright 依赖,先检查版本和浏览器缓存路径,避免更换运行账户后找不到此前安装的二进制。

约定一条文章记录

下游不应读取多个互相冲突的成功字段。本文统一记录 status,只有正文非空、标题存在、HTTP 状态符合预期时才标为 accepted。失败仍保留输入地址和原因。

标题来自 Reader 的网页元数据,Markdown 来自请求的格式,来源保留请求 URL 与 Reader 提供的 baseUrl。不要把 canonical 链接当成无条件覆盖来源的依据:canonical 可能指向另一版本页面。

一段完整的批量接入代码

创建 read_articles.mts:

import { writeFile } from 'node:fs/promises';
import { ReaderClient } from '@vakra-dev/reader';
 
class ArticleRejected extends Error {
    code: string;
    constructor(code: string) {
        super(code);
        this.name = 'ArticleRejected';
        this.code = code;
    }
}
const urls = process.argv.slice(2);
if (!urls.length) throw new Error('请在命令行提供至少一个文章 URL');
for (const value of urls) {
    if (!['http:', 'https:'].includes(new URL(value).protocol)) {
        throw new Error('只接受 HTTP(S) 页面');
    }
}
const reader = new ReaderClient();
const records: unknown[] = [];
 
try {
    for (const requestedUrl of urls) {
        try {
            const result = await reader.scrape({
                urls: [requestedUrl],
                formats: ['markdown'],
            });
            const item = result.data[0];
            if (!item) throw new ArticleRejected('reader_returned_no_document');
            const title = item.metadata.website.title?.trim();
            const markdown = item.markdown?.trim();
            if (item.metadata.statusCode !== 200) {
                throw new ArticleRejected('unexpected_http_status');
            }
            if (!title || !markdown) throw new ArticleRejected('missing_title_or_body');
            records.push({
                requested_url: requestedUrl,
                source_url: item.metadata.baseUrl,
                title,
                markdown,
                observed_at: item.metadata.scrapedAt,
                engine: item.metadata.engine,
                status: 'accepted',
            });
        } catch (error) {
            records.push({
                requested_url: requestedUrl,
                status: 'failed',
                reason: error instanceof ArticleRejected ? error.code : 'reader_call_failed',
                error_type: error instanceof Error ? error.name : 'UnknownError',
            });
        }
    }
} finally {
    await reader.close();
}
await writeFile('articles.json', JSON.stringify(records, null, 2));
console.log({ inputs: urls.length, records: records.length });

运行时显式提供来源:

node read_articles.mts   "$DOC_URL_1" "$DOC_URL_2"

这是文档页面示例,不承诺目标站始终可访问。你可以替换为自己的文章页。每个输入获得一条 accepted 或 failed 记录,不会因为某页异常而悄悄从结果消失。

代码按顺序处理便于定位问题,同时复用一个 ReaderClient。项目也支持一次 scrape 提交多个 URL 与 batchConcurrency;扩大并发前,先验证输出完整度,并按资源和目标响应调整,而不是把 URL 数量直接当作并发数。

如何检查正文是否可用

articles.json 中的合格记录结构如下,字段值是示意:

{
  "requested_url": "https://example.16yun.cn/articles/storage",
  "source_url": "https://example.16yun.cn/articles/storage",
  "title": "存储使用指南",
  "markdown": "# 存储使用指南\n\n页面正文……",
  "observed_at": "2026-10-28T09:00:00Z",
  "engine": "由 Reader 返回",
  "status": "accepted"
}

先比较输入数和结果数,再人工检查几页:标题是否对应目标文档,正文是否包含主要章节,是否混入菜单或登录提示。示例的非空检查只是入库的最低条件,不是自动事实验收。

对于固定站点,应增加该站正文身份规则,例如明确的文章标题区域、文档版本或预期章节。不要用“正文超过 100 字”替代页面身份判断,错误页也可能很长。

observed_at 使用库返回的开始时间,不代表网页最后修改时间。需要新鲜度判断时,另存页面声明的更新时间,并区分它与本次采集时间。

放进已有服务时怎样管理 ReaderClient

在一个批处理里创建一次客户端,在 finally 关闭。若放到长期运行的 Node 服务中,由应用生命周期持有客户端:启动时初始化,任务期间复用,服务退出时关闭。不要每接收一条 URL 就反复创建客户端,也不要让每个请求都关闭其他请求正在使用的共享客户端。

本文给的是命令行接入示例,没有对外暴露任意 URL 抓取 API。若后续让不受信任用户提交 URL,需要在服务边界控制可访问范围,不能只凭协议检查就允许访问内部网络。

代理配置也只在目标网络出口确实有要求时添加。正文清理失败、空标题或错误元数据,不应先被归因成代理节点不可用。

常见问题与修复

找不到浏览器。 使用运行程序的同一账户重新确认 Chromium 安装与缓存位置。容器内的浏览器与宿主机缓存不是同一个环境,修复后应先能启动页面,再检查提取规则。

没有 data[0]。 查看库的 batchMetadata.errors 和受控日志,判断下载、浏览器或转换阶段是否失败。示例用 reason 区分没有文档、HTTP 状态不符、标题或正文缺失;库调用异常记为 reader_call_failed,并保留 error_type。诊断时可临时查看脱敏细节,不把代理密码或敏感 URL 写进公共日志。

Markdown 有菜单没有正文。 回到原页面确认是否需要登录、正文是否动态加载,以及提取器是否选错区域。必要时先用浏览器取得正确材料,再做内容清理;增加并发不能修复材料缺失。

返回 200 仍是登录提示。 增加目标页面身份检查,修复会话访问条件。不能因为标题非空就把提示页作为文章接入知识库。

程序完成后仍有资源占用。 检查所有退出路径是否经过 reader.close(),以及应用是否还持有其他浏览器会话。不要通过无差别终止全部 Chrome 进程清理当前任务。

重复或旧文混入结果。 检查输入 URL 的语言、版本与参数。保留请求地址和来源字段,按业务文档身份管理版本,不按标题相同就覆盖。

接入正文提取库最有用的设计,是让调用方始终知道处理了哪个输入、拿到了什么内容、哪些输入需要重试。把这些约定建立好,后续换提取器或增加站点时,下游就不必跟着重写。

需要企业代理方案?

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