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。

清洗规则上线流程再补一个细节。回归集我们每个站点存二十条快照,其中五条专门挑边界样本:正文为空的、含多级实体转义的、结构改版前夕的、带广告位的、超长正文的。边界样本比随机样本有效得多,随机样本九成长一个样,只能测出换行差异。这套回归集在两次站点改版中都提前报出了异常,为人工接管赢得了时间。测试数据的选取水平,决定自动化测试的上限。另外回归集与规则文件一起进版本库,规则的每次改动都能一键回放对照。版本库里我们还放了一份规则变更说明的模板:改了哪条选择器、为什么改、影响哪些站点,三行话写清。半年后回头翻某条规则的演化历史,每一笔都有来龙去脉,不用再去问当年经手的人。规则资产化,就从这三行说明开始。

延伸阅读