从地图翻车到事件聚类:用 RSSHub + Hexo 做一个全球新闻速览站
引子
手上有个关于 RSSHub 的小项目原型,做得不满意——于是推倒重来,目标很明确:
做一个全球新闻速览站:从 RSSHub 抓新闻,按事件归类,非中文的翻译成中文,GitHub Pages 部署,30 分钟自动更新一次。
听起来不复杂,实际做下来踩的坑挺有代表性:方案被自己推翻一次、翻译 API 选型要靠真机探测、聚类算法在”合并过度”和”拆分过度”之间来回横跳。这篇把整个过程记下来,重点写最有价值的部分——那两个方向相反的坑,以及最后怎么把”验证”做得可信。
一、第一版:世界地图方案,上线即否决
最初的设想很带感:一张世界地图,把新闻按发生地点标注成圆点,用颜色表示影响范围。
技术上一路是通的:
- 采集在 GitHub Actions 的海外 runner 上跑(这点很关键,绕开本地网络限制);
- 用内置地理词典(国家 + 城市 + 中英别名 → 坐标)做地点标注,不依赖在线 geocoding;
- 影响范围做四级(本地/区域/全国/国际),阈值与词表全部落在配置文件里;
- 前端 Leaflet + OSM 瓦片,纯静态。
跑通之后上线,真实数据也进来了:28 个源里 17 个可用,抓回 379 条新闻,地点分布看着很合理。
但看实际效果就知道方向错了:地图上是一堆散点,看新闻的人并不关心”这条新闻发生在哪个坐标”——他关心”发生了什么“。地图把”新闻”降维成了”地理位置”,信息密度反而变低。
于是推翻重来。这一版的教训是:可视化形式要从用户的问题出发,不是从数据的属性出发。数据里有地理字段,不代表地图就是好的呈现方式。
二、第二版:Hexo 静态卡片站 + 30 分钟增量
新方向很朴素——像 Google News 那样的卡片流:顶部类别栏(时政/国际/财经/新媒体),主体是白底卡片(来源 · 类别 · 相对时间 · 标题 · 摘要 · 地点 + 影响级别色点),点击新标签打开原文。
技术上最大的决策是数据怎么存。
2.1 为什么不用”一条新闻一篇文章”
静态站的自然做法是把每条新闻生成一篇 post。但新闻是高流动、高重复的数据:一天几百条、三天就过时。这么做的后果是仓库迅速膨胀、每次全量构建越来越慢,而且没有任何价值——新闻详情页没人看。
2.2 数据不进 git,用 Actions cache 做跨 run 状态
真正需要”增量”的是采集:不重复展示旧新闻、只追加新的。这就要求跨 run 保存状态(已见过的新闻指纹、已翻译的结果、已聚合的事件)。而 GitHub Actions 的 runner 每次都是全新的,状态必须有地方放。
两个选项:
| 方案 | 问题 |
|---|---|
| 把数据 commit 回仓库 | 数据每天变化几十次,git 历史迅速膨胀(几十 MB/天),还得处理”数据提交触发 workflow”的递归 |
用 actions/cache 持久化 db/ 目录 |
需要一点技巧:cache 的 key 要每次唯一才能保存成功,同时用 restore-keys 前缀命中上一次的缓存 |
最终选了后者:
1 | - name: 恢复增量缓存(跨 run 状态) |
缓存里放三样东西:已见指纹(去重)、翻译结果(省 API 配额)、事件聚合结果(增量聚类)。缓存万一被清掉也不致命——采集会退化成一轮全量重建,页面内容依然正确,只是少了”新增”的语义。
2.3 Hexo 只做”渲染器”
站点用 Hexo 生成,但没有走”文章”模型:写了一个自定义 generator,直接读数据 JSON,把事件/条目渲染成静态 HTML(首页、分类页、详情页、全量列表),再交给 actions/deploy-pages 部署。
好处是:页面是服务端渲染的纯 HTML,没有前端框架、没有数据请求,秒开、SEO 友好;数据与展示彻底解耦。
三、第三版:事件归类 + 中文翻译
这一版加两个能力,也是这个项目真正的价值所在。
3.1 翻译 API:先真机探测,再选型
本地网络(大陆)访问不了主流翻译服务,所以选型必须在真实的 Actions runner 上做。我专门起了个临时 workflow 探测,结果很反直觉:
| 端点 | 实测结果 | 结论 |
|---|---|---|
translate.googleapis.com(常见的非官方端点) |
HTTP 429(数据中心 IP 被限流) | ❌ |
translate.google.com/...client=webapp |
HTTP 403 | ❌ |
clients5.google.com/translate_a/t?client=dict-chrome-ex |
连续 12/12 成功,长文本、日文/阿拉伯文 → 中文都正常 | ✅ 主力 |
MyMemory(api.mymemory.translated.net) |
稳定 200,匿名 5000 词/天 | ✅ 兜底 |
| LibreTranslate 公共实例 | 400 / 502 | ❌ |
结论:不要凭经验猜哪个 API 能用,要在真实环境里探测。 同一个 Google 域名,gtx 端点被限流,clients5 端点却跑得好好的。
策略上做三件事:
- 双 provider 链:主力失败自动降级兜底,都失败就保留原文(不阻塞流水线);
- 结果持久化缓存:同一条新闻只翻一次,跨 run 复用(省钱、省时间);
- 两阶段 + 限流:先翻所有标题(最可见),再翻摘要;单轮上限 180 条,连续失败就提前停手。
页面上译文条目会带一个「译」小标,原文标题以小字保留在下方——不丢原意。
3.2 事件聚类:没有模型,也能做出可用的归类
要求是”把同一件事的新闻聚在一起”。不引第三方依赖(CI 里装包越少越稳),用纯标准库实现:
- 文本表示:中文取字符二元组(bigram,不需要分词库),英文取单词;标题权重高于摘要;先剥离”财联社X月X日电”这类固定电头;
- 相似度:TF-IDF 加权余弦,IDF 由当前窗口内全部条目动态计算;
- 聚类:增量单遍聚类——新条目与所有活跃事件比相似度,超过阈值就归入,否则新建事件;
- 护栏:时间窗 72 小时、地点不同提高门槛(防”北京暴雨”误并”纽约暴雨”)、语言不同提高门槛、事件规模上限。
四、上线之后:两个方向相反的坑
这部分是全文最值得写的。第一版聚类上线后,真实数据给出了非常直接的反馈。
4.1 坑一:滚雪球——一个事件吞并了 461 条新闻
首轮生产数据:1034 条新闻聚出 254 个事件,规模分布如下:
1 | [461, 70, 67, 27, 18, 18, 10, 10, 10, 8, ...] |
排在第一位的事件有 461 个成员,点进去一看:阿拉伯语的战争新闻、娱乐八卦、财经快讯、领导人会见,全都混在一起。它的”关键词”是 月 / 美国 / 日 / 中国 / 美元 / 日电 / 财联——全是泛词。
根因有三条:
- 质心平均向量漂移(主因):判定”是否同一事件”时,我把候选新闻和事件的**成员平均向量(质心)**做比较。成员一多,质心就被高频泛词填充成”通用词袋”,于是和任何新闻都相似——合并越多 → 质心越泛 → 更容易合并,正反馈滚雪球。
- 快讯固定前缀:真实语料里”财联社9月10日电”这种电头出现 42 条,几乎相同的开头制造了虚假相似度。
- 多话题汇总条目当吸引子:”XX早餐 | 9月10日”这类罗列型条目,词汇覆盖面极广,天然与一切相似。
修复(v3.1):
- 相似度改成”与事件代表成员的最大值”,彻底弃用质心判定(质心只保留做关键词展示);
- 先剥离电头/署名/括注再做分词;
- 摘要型条目单独隔离,不参与任何合并;
- 加事件规模硬上限,满员另开事件。
4.2 坑二:矫枉过正——同题新闻只并上四分之一
改完滚雪球,最大事件从 461 降到 4,看上去很干净。但进一步量化后发现另一个极端:
跨来源、标题高度相似(bigram Jaccard ≥ 0.8)的 31 对新闻里,只有 30% 被合并。
其中”两市融资余额减少31.93亿元”这条,三家媒体一字不差,却只并上了两对。
也就是说,用户最想要的”各平台报道聚合”,实际上没做成。
排查过程很有意思,走了一条弯路:先怀疑”稀有词共建”门槛太严——却发现两条同题新闻余弦相似度高达 0.99、共享稀有词 9 个,本该合并。于是继续往下追,终于抓到真凶:
1 | 真实语料 1034 条中被判定为"摘要型条目"的:411 条(40%!) |
我为了防”多话题汇总条目当吸引子”,写了一条摘要启发式:摘要超过 120 字且分句数 ≥ 6 → 视为汇总条目。结果财联社那种多句式快讯摘要(134 字)全部踩线——40% 的普通新闻被判定为”汇总条目”,直接隔离、永不合并。
修复(v3.2):摘要型判定只看标题关键词(早餐/早报/盘点/要闻/Top stories…),摘要维度退化为极严的兜底(≥500 字、≥12 分句、≥3 个话题块);同时给”高相似度”开直通——余弦 ≥ 0.75 直接判为同一事件,不再要求共享稀有词。
效果:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 跨来源同题合并率 | 25% | 96.8% |
| 最大事件成员数 | 461 → 4 | 4(合理) |
| “摘要型”误判 | 411 | 10(全是真·汇总条目) |
| 事件总数 | 977(过拆) | 912 |
4.3 坑三:算法修好了,线上却没变
修完推送、CI 跑绿,我兴冲冲去看线上——那个 461 条的大事件还在。
原因:聚类是”增量”的,新版算法会读取上一轮的聚合结果继续用。旧算法产出的错误事件,被新算法原样继承了下来。
修复:给聚合结果打上算法版本 + 规则指纹(聚类配置文件的哈希)。版本或任一阈值变化时,直接忽略旧结果、全量重建:
1 | if old_events and (meta.get("algo_version") != ALGO_VERSION |
这行小改动的价值被严重低估:任何”派生数据 + 增量计算”的系统,都必须有失效机制,否则修复永远不生效。
五、让”验证”可信
这个项目全程是”人(我)定方案 + AI 编码代理执行 + 我独立验收”的模式。这类协作里最容易出问题的是:“看起来完成了”和”实际正确”之间的距离。所以验证做了三层:
第一层:预埋已知答案的用例。 我手工构造小米级的测试集,正例(3 组同事件、措辞不同)和硬负例(相同电头但话题完全不同、真·汇总条目、跨语言同事件、无关条目)都有明确期望输出,算法必须逐组通过。
第二层:真实语料回归(关键)。 小样本会骗人——我那 9 条用例全过,真实语料却有 40% 的条目被误判。所以我把线上的真实数据导出来做基准:通过一个临时 workflow 恢复 CI 缓存、把数据打包成 artifact 下载下来,得到 1000+ 条真实新闻,然后在本地跑算法、量化指标:
1 | 事件总数 ≥ 500 | 最大事件 ≤ 20 | 同题合并率 ≥ 90% | 摘要型误判 ≤ 30 | 负例不误并 | 幂等 |
这套指标后来成了每次改聚类的”回归测试”,两个坑都是它揪出来的。
第三层:线上逐页核对。 部署完挨个 curl:首页事件卡、事件详情页(各平台报道 + 译标)、分类页、全量列表页,检查页头统计是”1085 个事件 / 1248 条报道”而不是把事件数当报道数(这个 bug 也真实发生过)。
六、现在的样子
- 线上:一个 30 分钟自动更新的新闻聚合站,约 1200+ 条新闻 / 1085 个事件;
- 事件卡:首页按事件展示,标注”N 家媒体报道”,点进去看各平台报道对照;
- 翻译:非中文新闻自动转中文,带「译」标与原文;
- 源健康:给每个源维护健康矩阵,个别源失败不影响整体流水线;
- 数据零依赖:纯 Python 标准库实现的采集、翻译调度与聚类,没有第三方包。
七、可以迁移的经验
- 可视化形式要匹配用户问题,不是匹配数据字段——有地理字段不代表该上地图。
- 外部服务选型要在真实运行环境探测,同一域名的不同端点可能一个限流一个畅通。
- 启发式阈值必须用真实规模语料校准。小样本会让你的”全过”变成幻觉:411/1034 的误判率,在 9 条用例里完全看不出来。
- 派生数据必须有版本指纹,否则算法修好、线上照旧。
- 两个方向的错误都要测:只测”该合并的合了吗”会漏掉”不该合并的并了吗”(滚雪球),反之亦然。正例 + 硬负例 + 真实语料才是完整的三件套。
- 自动化流水线要”看得见”:每轮日志里打印关键计数(源成功数、新增条数、事件数、翻译条数),出问题时第一眼就能定位到是哪一环。
整个项目从”地图翻车”到”事件聚类可用”,一共四轮迭代。回头看,真正花时间的不是写功能,而是把”效果不对”翻译成”可量化的指标”,再定位到具体那一行代码——这大概也是做数据类项目最实在的功夫。







