技术札记

实战 HHY Collector Framework:我没有从复刻 Scrapy 开始

用 HHY 的 HTTP、Stream、有界并发与官方 HTML 扩展,构建一个配置可审计、资源有边界的静态文档采集框架。

HOUHUIYANG.COM

扫码继续阅读

正在生成…

实战 HHY Collector Framework:我没有从复刻 Scrapy 开始

houhuiyang.com/zh/notes/building-hhy-collector-framework

数据抓取很适合 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、配置可审计、资源有边界的数据采集框架。

HHY Collector Framework 的实现架构

第一个边界:它是 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 结构。

splitreplace 和 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。

单个 Seed 从请求到输出的执行 Flow

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,因为采集数据如果不能回到来源,就很难审计、更新和纠错。

三份输出承担三种责任

程序最终写出:

三份文件都使用 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 表示字段直接读取当前根节点。字段支持 texthtmlattr,也可以声明 allmax_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 itemSecond item,Anchor 是 onetwo,每条记录都有本地 source_url,报告记录数为 2,失败列表为空。

./practical-projects/my-crawler/self-test.sh

外部页面改版不应该让框架回归测试变红;反过来,本地 fixture 通过也不能证明真实站点 Selector 仍然有效。真实 Smoke Test 与确定性测试承担不同责任。

它还不是我最初设想的完整框架

当前实现已经证明“Seed → Fetch → Extract → Provenance → Output”这条最小闭环,但它还没有:

大部分能力可以继续用 HHY 编写,不需要立刻修改 Runtime。优先级应该由真实 Spider 驱动。我会先增加分页、请求身份、Checkpoint 和域名 Admission,再观察保序并发是否真的成为吞吐瓶颈。

这次实现让我更确信的事

框架的价值不在于拥有最多的概念,而在于把失败、资源和副作用放在可以看见的位置。

HHY Collector Framework 现在只有一个 Engine、一个声明式 Schema 和一个 HTML 扩展,但每个请求有超时,每批并发有上限,每次抽取有结果上限,每条数据有来源,每次运行有失败审计,每份文件是原子输出。

这些边界比先拥有几十个 Middleware 名字更重要。

我没有从复刻 Scrapy 开始,因为 HHY 不需要靠模仿另一个生态证明自己。它更应该先证明:一条采集 Flow 可以足够直接,同时在网络、解析和文件副作用面前仍然可信。

参考

返回技术札记