4.1 清洗与结构化:Go 管道与 DSL
场景:数据在库里,业务说用不了
平台跑稳之后的第二周,业务方提了一个需求:能不能把每个详情页的标题、作者、发布时间、正文字数单独给他们。我们指着 MySQL 里的 raw_html 列说都在这里面了,对方看了一眼:这坨 HTML 你让我怎么用?
这件事让我们认清采集与交付之间的距离。原始 HTML 对机器是富矿,对业务是垃圾。第一版清洗脚本我们是临时写的 Go 程序,规则硬编码在代码里,每加一个字段就要改代码、跑测试、发版本。站点一多,这个循环根本转不过来:五个站点同时要改规则,开发排期排到下周。
更难受的是规则修改的责任归属。清洗规则的细节本质上是业务知识(什么算正文、什么算广告、列表里的价格要不要税费),这些知识在业务方脑子里,却要开发写进代码。沟通成本高,出错率高,每次改完都要互相确认一遍。
最后我们定了一个方向:清洗规则必须配置化,让规则本身成为数据,开发只维护引擎,业务方维护规则文件。这就是这一节要讲的清洗管道与规则 DSL。
方案:管道加规则 DSL
清洗的流水线我们拆成五个固定阶段,每个阶段职责单一、可独立替换:
| 阶段 | 做什么 | 输入输出 | 可配置 |
|---|---|---|---|
| 解码 | 编码检测与转换 | bytes → string | 否 |
| 解析 | HTML DOM 树构建 | string → goquery | 否 |
| 抽取 | 按规则取字段 | DOM → map | 是 |
| 规整 | 类型转换与标准化 | map → struct | 是 |
| 校验 | 必填与格式断言 | struct → struct | 是 |
前两个阶段是引擎的机械部分,写死在代码里;后三个阶段由规则 DSL 驱动,规则文件单独存、单独改、单独发版。引擎与规则的边界就划在 DOM 树上:DOM 之前是程序员的活,DOM 之后是规则的活。
DSL 的形态我们选 JSON,不选自造语法。一是解析零成本,二是规则文件本身可以被控制台读写、被版本管理跟踪,三是新人不用学新语法。一个精简后的规则长这样(真实规则字段更多,这里只保留骨架):
{
"site_id": "site-07",
"fields": [
{"name": "title", "sel": "h1.article-title", "attr": "", "type": "string"},
{"name": "author", "sel": "span.author", "attr": "", "type": "string"},
{"name": "publish_at", "sel": "time", "attr": "datetime", "type": "datetime"},
{"name": "content", "sel": "div.article-body", "attr": "", "type": "html"}
]
}
每条字段声明四个要素:字段名、CSS 选择器、取属性还是取文本、目标类型。type 为 html 的字段额外再做一次正文清洗(去广告节点、去脚本)。这套 DSL 的能力边界很克制:不支持复杂的逻辑分支,需要复杂逻辑的站点直接写 Go 插件,走规则引擎之外的特殊通道。克制换来的是可读性和可维护性,九成站点用 DSL 就够了。
规整阶段的类型转换也统一约定,踩坑最多的就在这里:
| 类型 | 规则 | 例子 |
|---|---|---|
| string | 去首尾空白、合并换行 | 标题与作者 |
| datetime | 先按站点配置的格式解析,失败再尝试公共格式 | 2024-03-15 |
| int | 去千分位、去单位词 | 1,234 评论数 |
| html | 去 script/style/广告节点 | 正文 |
配置与代码:管道的实现
引擎的核心是一个 Cleaner 接口加一个pipeline 组合器:
type Stage interface {
Run(ctx context.Context, in *Doc) (*Doc, error)
}
type Pipeline struct {
stages []Stage
}
func (p *Pipeline) Run(ctx context.Context, d *Doc) (*Doc, error) {
for _, s := range p.stages {
var err error
if d, err = s.Run(ctx, d); err != nil {
return nil, err
}
if d.Drop {
break
}
}
return d, nil
}
Doc 结构体在阶段之间流动,Drop 字段让任何阶段都能主动丢弃一篇文档(比如校验失败、正文为空)。抽取阶段按 DSL 执行,核心就这么几行:
func (e *ExtractStage) Run(ctx context.Context, d *Doc) (*Doc, error) {
for _, f := range e.rule.Fields {
sel := d.DOM.Find(f.Sel)
if sel.Length() == 0 {
if f.Required {
d.Drop = true
return d, nil
}
continue
}
v := sel.Text()
if f.Attr != "" {
v = sel.AttrOr(f.Attr, "")
}
d.Fields[f.Name] = strings.TrimSpace(v)
}
return d, nil
}
清洗结果写入一张与原始快照分离的结果表 item,两表用快照键关联。清洗是可重跑的:同一份快照,换一套规则就能产出不同结构的结果。这个能力后来成了平台最被业务方夸奖的设计,回放快照重新清洗的戏码几乎每周都在上演。
整个清洗程序是独立进程,从 COS 拉快照、跑管道、写结果,与采集进程完全解耦。采集负责把泥运回来,清洗负责把泥变成砖,职责分开之后两边可以各自迭代、各自扩容。
踩坑:清洗规则的五个坑
第一个坑是正文选择器命中广告位。某个站点的详情页改版,正文容器多嵌了一层 div,原选择器捞到了页面底部的推荐区。 DSL 规则上线前要有回归集:每个站点保留十几条样例快照,改规则先跑样例,结果与上次不一致就报警。
第二个坑是 HTML 实体与转义。正文里偶尔有 & 或 / 一类的实体,直接落库之后业务方看到的就是原始符号。统一在规整阶段跑一次 html.UnescapeString,再来一次去标签残留。
第三个坑是多值字段。有的站点一个页面有多个作者,早期规则只取第一个。后来 DSL 加了 multi 标记,多值字段落成一个 JSON 数组列,业务端按需取用,而不是丢掉次要数据。
第四个坑是日期格式。有些站的发布时间是 3 小时前这种相对写法,清洗时统一换算成绝对时间,时区一律转 UTC 存储、出口层再按用户时区展示。时间数据不在入库时统一,后面每一层都要再处理一遍。
第五个坑是重跑覆盖人工修订。清洗重跑很常见,但结果表偶尔有业务方手工改过的值。我们在结果表加了一列 manual_mark,被打标的记录重跑时跳过。自动化与人工修正必须留一个避让机制,否则自动化迟早帮你把人工成果清零。
小结
清洗这一层做对的关键是把规则从代码里剥离出来。管道模式让流程固定、DSL 让规则可配、快照让清洗可重跑。三者合在一起,采集平台才真正从抓数据的工具变成了产数据的平台。规则 DSL 的形态不必复杂,JSON 四要素已经覆盖九成场景,剩下的一成留给插件通道。下一节讲清洗结果怎么走出去:API、CSV 与 webhook,继续看 4.2。
清洗规则上线流程再补一个细节。回归集我们每个站点存二十条快照,其中五条专门挑边界样本:正文为空的、含多级实体转义的、结构改版前夕的、带广告位的、超长正文的。边界样本比随机样本有效得多,随机样本九成长一个样,只能测出换行差异。这套回归集在两次站点改版中都提前报出了异常,为人工接管赢得了时间。测试数据的选取水平,决定自动化测试的上限。另外回归集与规则文件一起进版本库,规则的每次改动都能一键回放对照。版本库里我们还放了一份规则变更说明的模板:改了哪条选择器、为什么改、影响哪些站点,三行话写清。半年后回头翻某条规则的演化历史,每一笔都有来龙去脉,不用再去问当年经手的人。规则资产化,就从这三行说明开始。
