数据抓取很适合 HHY。
一个采集任务天然包含 Seed、HTTP、重试、超时、并发、解析、标准化、去重、失败记录和持久化。它几乎把 HHY 的 Flow、Stream、Effect 和资源边界一次全部拉进真实场景。
但我不想从“HHY 版 Scrapy”开始。
Scrapy 背后有成熟的 URL 调度、Middleware、Downloader、Item Pipeline、缓存、robots.txt 和扩展生态;Playwright 还要承担浏览器进程、JavaScript、页面生命周期和交互状态。如果第一版同时承诺这些能力,语言 Runtime、扩展协议和框架 API 会一起膨胀,最后很难判断哪一层真的稳定。
所以我把项目定义成:
HHY Collector Framework:面向 API 与静态文档的 Flow-first、配置可审计、资源有边界的数据采集框架。
第一个边界:它是 Collector,不是浏览器
当前版本适合 JSON API、CSV/JSON 数据集和静态 HTML。它不执行 JavaScript,不处理登录验证码,不绕过 robots.txt、认证和反爬策略,也不承诺无限响应流。
这不是“以后再补”的免责声明,而是架构边界。HHY 的 HTTP Response Body 当前完整缓冲,默认受 max_http_body 限制;parallel(n) 是有界并发并默认保序。对文档页和普通 API,这些行为带来清楚的资源上限。对视频、归档、海量 NDJSON 或浏览器渲染页面,它们就不是正确工具。
动态页面以后可以接外部 Browser Worker。HHY 负责计划、资源、数据和失败,浏览器负责执行页面;没有必要让 Runtime 自己长成浏览器。
HTML DOM 不应该用 Regex 伪装
项目最初缺少的不是 HTTP,而是 HTML 结构。
split、replace 和 PCRE2 可以处理规则化文本,却不应该承担通用 HTML 抽取。真实页面有容错解析、嵌套、实体、属性和 Selector 语义。为此,我实现了官方 html 进程扩展,底层使用 Lexbor 的 HTML5 Parser 与 CSS Selector。
一个字段抽取配置如下:
{
"root_selector": "section.card",
"max_results": 10,
"schema": {
"title": {
"selector": "h2",
"value": "text",
"required": true
},
"anchor": {
"selector": "h2",
"value": "attr",
"name": "id"
}
}
}
Engine 一次把 HTML、根 Selector、Schema 和结果上限交给扩展:
html.extract(
fetched.value,
root_selector,
schema,
{ max_results: max_results }
)
扩展返回普通 List<Map>,随后继续进入 HHY Stream。
这个 API 是一个有意的取舍。Process Extension Protocol 只能传 Null、Bool、数字、String、List 和 Map,不能传 Opaque DOM Handle 或 Stream。如果模仿浏览器 DOM,写成 parse → node handle → select → text,就必须先扩大协议与对象生命周期。
第一版直接用“HTML + Selector + Schema → List<Map>”,把 DOM 留在扩展进程内部。它牺牲了一部分任意遍历能力,却换来简单的所有权、明确的序列化边界和可以立即验证的业务 API。等真实项目证明需要 Node 逻辑值,再讨论它是否应进入 Core。
Engine 只有一条主路径
crawler.hhy 负责参数、配置、统计和三个原子输出。真正的执行逻辑在 lib/engine.hhy:
export fn crawl(config) {
let user_agent = config.user_agent
let root_selector = config.root_selector
let schema = config.schema
let max_results = config.max_results
return config.seeds
|> stream
|> distinct
|> parallel(config.parallelism) { url ->
crawl_seed(url, user_agent, root_selector, schema, max_results)
}
|> collect
}
Seed 先去重,再进入配置指定的有界并发。HTTP 请求使用可识别的 User-Agent、10 秒 timeout、两次 retry 和 500 ms backoff。这里没有无限 Worker,也没有隐藏的全局线程池。
单个 Seed 有两个 attempt 边界:一个包住 Fetch,一个包住 HTML Extract。
let fetched = attempt { fetch_page(url, user_agent) }
if fetched.ok != true {
return { ok: false, url: url, records: [], error: fetched.error.message }
}
let parsed = attempt {
html.extract(fetched.value, root_selector, schema, { max_results: max_results })
}
if parsed.ok != true {
return { ok: false, url: url, records: [], error: parsed.error.message }
}
网络失败和 Selector/Schema 失败都变成普通结果,不会让同批其他 Seed 丢失。成功记录还会统一加入 source_url,因为采集数据如果不能回到来源,就很难审计、更新和纠错。
三份输出承担三种责任
程序最终写出:
records.json:抽取后的结构化记录;report.json:请求页数、成功页数、失败页数与记录数;failures.json:失败 URL 和结构化错误。
三份文件都使用 atomic: true。部分成功时,成功记录仍然有价值,失败页也不会藏在 stderr 里;但报告 ok=false,进程返回 1,让自动化系统知道这不是完整成功。
这种设计比“第一次错误立即退出”更适合采集,也比“无论如何都返回 0”更诚实。
配置是 Spider 的第一版语言
默认任务抓取 https://hhylang.dev/zh/learn/cli-reference,用 main article h2 抽取二级标题及 id:
{
"project": "HHY Documentation Crawler",
"seeds": ["https://hhylang.dev/zh/learn/cli-reference"],
"parallelism": 2,
"user_agent": "HHY-Collector/1.0 (+https://hhylang.dev)",
"root_selector": "main article h2",
"max_results": 100,
"schema": {
"title": { "selector": "", "value": "text" },
"anchor": { "selector": "", "value": "attr", "name": "id" }
}
}
空 selector 表示字段直接读取当前根节点。字段支持 text、html 和 attr,也可以声明 all。max_results 同时在配置和扩展边界出现,避免错误 Selector 把整页成千上万个节点带回 Runtime。
目前 Spider 是纯数据定义。我刻意没有让配置引用 HHY 函数,因为 JSON 不能可靠表达可执行闭包。等分页、follow request 与 normalize hook 进入框架时,它们更适合由 .hhy Spider 模块导出,而不是把代码字符串塞进 JSON。
扩展安装必须是项目局部的
init.sh 把官方 html 扩展安装到项目自己的 .hhy-extensions,重复运行安全,也不污染用户级 Extension Home。
make
./practical-projects/my-crawler/init.sh
./practical-projects/my-crawler/run.sh
运行时显式设置 HHY_EXTENSION_HOME。这让项目携带自己的扩展版本与配置,避免“我的机器上全局装过,所以可以运行”的隐式依赖。
自测不应该依赖公网
真实任务用于证明 HHY 能访问真实文档,自测则只验证代码契约。
self-test.sh 启动本机 fixture server,动态生成配置,选择两个 section.card,断言结果标题是 First item 和 Second item,Anchor 是 one 和 two,每条记录都有本地 source_url,报告记录数为 2,失败列表为空。
./practical-projects/my-crawler/self-test.sh
外部页面改版不应该让框架回归测试变红;反过来,本地 fixture 通过也不能证明真实站点 Selector 仍然有效。真实 Smoke Test 与确定性测试承担不同责任。
它还不是我最初设想的完整框架
当前实现已经证明“Seed → Fetch → Extract → Provenance → Output”这条最小闭环,但它还没有:
- 分页策略和 Follow Request;
- URL 标准化与相对 URL 解析;
- 域名级限速和 robots.txt;
- 请求指纹与已访问集合;
- 增量 Checkpoint 和失败重放;
- 条件请求、缓存与内容哈希;
- JSON/CSV/数据库多 Storage Adapter;
parallel(..., { ordered: false })这样的非保序完成流。
大部分能力可以继续用 HHY 编写,不需要立刻修改 Runtime。优先级应该由真实 Spider 驱动。我会先增加分页、请求身份、Checkpoint 和域名 Admission,再观察保序并发是否真的成为吞吐瓶颈。
这次实现让我更确信的事
框架的价值不在于拥有最多的概念,而在于把失败、资源和副作用放在可以看见的位置。
HHY Collector Framework 现在只有一个 Engine、一个声明式 Schema 和一个 HTML 扩展,但每个请求有超时,每批并发有上限,每次抽取有结果上限,每条数据有来源,每次运行有失败审计,每份文件是原子输出。
这些边界比先拥有几十个 Middleware 名字更重要。
我没有从复刻 Scrapy 开始,因为 HHY 不需要靠模仿另一个生态证明自己。它更应该先证明:一条采集 Flow 可以足够直接,同时在网络、解析和文件副作用面前仍然可信。