这是什么
「每天一份日报」这件事整条链交出去,分两段:前半段把散在 12 家源里的信息压成一份报纸 HTML,后半段把这份 HTML 校验、提交、推到网上。人只在它出错的时候才需要看一眼。
出口固定在 reports/latest.html,首页右边那张「今日日报」卡一直指着它——所以日报换新的时候,首页一个字都不用改。
只保留最新一期:覆盖同名文件、不归档。换来的是地址恒定,书签和分享出去的链接永远不会失效。
一 · 生成链路
重点在这一段:19 个节点、两次 AI 调用,其余全是本地计算。难点不是抓取,而是取舍——哪条上头版、哪条当大稿、同一件事被五家报道时怎么只留一条。
开机触发(Webhook,由开机脚本调一次)
↓
抓取源(12 家 RSS 并发)
↓
解析合并(解析 / 去重 / 算兜底分)
↓
条数校验(不足 10 条直接报错,不出残报)
↓
查天气(wttr.in → ipwho.is + open-meteo 兜底)
↓
组装排序请求 → AI 打分(0~100)
↓
解析打分(合分 + 板块配额 + 单源上限)
↓
组装候选池 → AI 整理(译题 / 摘要 / 概念分组)
↓
解析 AI 输出
↓
渲染 HTML(38000 字符纯 JS,一次产出整份报纸)
↓
转附件(邮件发送默认关闭)节点数看着多,其实只有三类活:抓、洗、排。抓和洗是确定性代码,只有「哪条重要」和「怎么把它写成一句人话」交给 AI。
| 阶段 | 做什么 | 出问题会怎样 |
|---|---|---|
| 抓取源 | 12 家 RSS 并发,单源失败不拖垮整轮 | 那个源当天缺席,其余照跑 |
| 解析合并 | 解析字段、按标题去重、给每条算一个兜底分 | 兜底分接住 AI 缺位 |
| 条数校验 | 少于 10 条直接报错停下 | 不出残报,线上仍是上一期 |
| 查天气 | wttr.in 为主,失败走 ipwho.is + open-meteo | 三源全挂,日期区留空占位 |
| AI 打分 | 每条 0~100,判断今天值不值得上版面 | 退回规则分,rankDegraded 为真 |
| 解析打分 | 合分、板块配额、单源上限 | 全在本地算,不依赖外部 |
| AI 整理 | 译题、写摘要、把同一事件归成概念组 | 保留原题与原始候选,degraded 为真 |
| 渲染 HTML | 38000 字符纯 JS,一次产出整份报纸 | 缺标题、缺摘要、板块抓空都有兜底 |
| 转附件 | HTML 转成邮件附件 | 默认关闭,不影响出报 |
取舍权给 AI,版面权给规则:AI 只回答「这条多重要」,不决定谁当大稿。哪条占通栏由 assign_weights() 按固定规则算——板块首条必为大稿,条数 ≥ 8 时中点再给一条。所以每天的骨架完全一致,只有内容在变;AI 就算传了重点,也会被规则覆盖。
同一件事被多家报道时,条目上带一个 cluster 标记,同值自动并成一组、组内第一篇当主稿。版面上表现为「一个大稿 + 若干来源」,而不是五条内容重复的短讯。
每一环都有降级,宁可出残报也不出空:天气三源全挂就留空占位,AI 两步全挂就退回规则分与原始候选,概念组缺失就退化成单条通排。代价是失败不报错——判断质量只能看 rankDegraded / degraded 这类业务字段,不能看节点执行状态是不是绿色。
二 · 发布链路
开机 触发(Webhook;守卫判今天没跑过才继续)
↓
取数整理(n8n 节点:抓取 + 摘要)
↓
排版(拼成自包含 HTML,样式内联、零外链)
↓
写盘(\out\daily.html)
↓
校验(存在 / 大小 / 是不是 HTML)
↓
覆盖(reports/latest.html)
↓
提交推送(git add → commit → push)
↓
上线(GitHub Pages 构建完成即可访问) 发布这一段是一条直链,没有分支,做的事只有「把这个文件送上线」。中间那层校验是关键:生成失败时上游经常仍然写出一个 200 字节的错误页,直接覆盖就会把首页那张卡指向一页报错信息。
写盘和推送现在接在同一条工作流的尾部:生成节点跑完,接着写盘、调 publish.py、写回当日日期,一条链走到底。
| 节点 | 关键设置 |
|---|---|
| 开机触发(Webhook) | POST /webhook/daily-brief-run,由开机脚本在 n8n 就绪后调一次;responseMode 用 onReceived,立即返回不等结果 |
| 查上次运行 / 今天跑过吗 | 读 .last-run 里的日期,等于今天就整链结束——零点几秒,不抓网也不调 AI。一天开几次机都只出一期 |
| 写日报文件 | Read/Write Files from Disk,固定写 ;路径必须在写盘白名单里,否则报「Access to the file is not allowed」 |
| 推送上线 | Execute Command 调 publish.py;路径写死,不用 {{ $json.xxx }} 拼。n8n 2.x 默认封禁这个节点,要 NODES_EXCLUDE=[] 它才存在 |
| 记下今天 | 推送成功后才写日期。失败的那次不算「已跑」,下次开机自动重试 |
几个取舍
- 渲染器是纯 JS,内联在节点里:不
require、不读盘、不起子进程、不联网,只用Date / Math / Set / Map / JSON和Buffer。早期版本是 Execute Command 调本机 Python 脚本,换一台机器就断在那里;现在整条链的外部依赖只剩「能上外网」和两个凭据。 - 降级写在链里,不是写在文档里:天气、AI 打分、概念组三处都定义了失败时的替代值,所以 AI 两步全挂也能把报纸出出来。代价是不报错,判断质量得看业务字段。
- 不用 GitHub Actions 定时:Actions 的 cron 按 UTC 算,换算容易错;境外 runner 抓国内源经常被限流;仓库 60 天没提交还会自动停用。而且它没法反过来触发本机的 n8n。
- 生成放在本机:数据源、登录态、代理都在这台机器上,抓取成功率高;出问题能直接翻日志,不用等云端构建。
- GitHub 只做接收端:一个仓库、一个分支,Pages 自己发布,仓库里不需要任何 workflow 文件。
- 只留最新一期:覆盖同名文件意味着链接恒定,代价是 Pages 有缓存,偶尔会看到上一期——刷新或等几分钟就好。
发布脚本
scripts/publish.py 一次做完四件事:校验 → 覆盖 → 提交 → 推送。stdout 只输出一行 JSON,n8n 可以直接解析结果。
这段不在上面那个仓库里:仓库只收生成链(19 个节点的工作流 JSON、渲染器模板、构建脚本),publish.py 是站点这边的本地脚本,跟着站点走。
python "<站点目录>\scripts\publish.py" "<站点目录>\_incoming\daily.html"| status | 退出码 | 含义 |
|---|---|---|
| published | 0 | 已发布,附带 commit 短 SHA |
| no_change | 0 | 内容与仓库一致,跳过提交(不算失败) |
| bad_args | 2 | 没传源文件路径 |
| missing_src | 3 | 源文件不存在 |
| too_small | 4 | 不足 512 字节,疑似生成失败 |
| not_html | 5 | 开头没有 <html> 或 <body> |
| no_repo | 6 | 站点目录还不是 git 仓库 |
| no_remote | 7 | 仓库没配远端 |
| git_add_failed | 8 | 暂存失败 |
| commit_failed | 9 | 提交失败 |
| push_failed | 10 | 推送失败,多半是凭据或网络 |
路径里有中文时,n8n 的 Execute Command 很容易把引号吃掉,报「系统找不到指定的路径」。先确认引号还在;仍不行就把整个站点挪到纯英文路径下——Pages 不关心本地路径长什么样。
失败会怎样
- 抓到的条数不够:少于 10 条在「条数校验」就停下,宁可不发也不出残报,线上仍是上一期。
- AI 那两步降级:报纸照出,只是摘要没润色、没有跨源概念组。
rankDegraded/degraded会为真——节点是绿的,也可能走了降级。 - 天气全挂:日期区留空占位,不影响其余版面。
- 生成失败:内容不足 512 字节或者根本不是 HTML,脚本停在
too_small/not_html,线上仍然是上一期。 - 仓库没绑:
no_repo直接退出,一个文件都不改,不会留下半成品提交。 - 推送失败:
push_failed;本地已经提交好了,网络恢复后手动 push 一次即可。 - 内容没变:
no_change,当成成功处理——每天的内容本来就有可能几乎一样。
常见问题
怎么确认 AI 那两步真的干活了?
看「解析打分」输出的 rankDegraded 和「解析 AI 输出」的 degraded。两个节点都设了 onError: continueRegularOutput,报错被静默吞掉,执行状态照样是绿的——只看绿色会误以为 AI 生效了,实际跑的是降级分支。
改版式要动几个文件?
两个真源:渲染器模板 render_brief.js.tpl 管版式与 CSS,工作流 JSON 管 19 个节点的代码与参数。构建脚本有一道硬闸——模板主体必须与节点里那段代码逐字节相同,只改一边会直接中止并打印差异位置,所以不会出现「模板和线上跑的代码悄悄分叉」。
日报换新了,首页那张卡会跟着变吗?
卡上不写具体日期,只写「每日一期」。卡片永远指向同一个地址,所以不会过期;变的是点进去的那一页。
为什么不把每一期都归档?
地址恒定优先。要归档就得让文件名带日期,卡片也就得跟着改,等于每天动一次首页;现在这样是拿「翻不到旧刊」换「链接永不失效」。
n8n 里要存 GitHub token 吗?
不用。走本机 git push,账号由 Windows 凭据管理器记着,n8n 只负责发一条命令,不碰任何密钥。前提是手动 push 成功过一次,凭据已记住。
站点还不是 git 仓库的时候会发出去吗?
不会。publish.py 检查到没有 .git 或没有远端,就以 no_repo / no_remote 停下,源文件连复制都不会发生。
更新记录
- 生成链 v0.4.5 — AI 打分排序接进链路,节点定稿为 19 个;取舍权归 AI,版面权归规则
- v1.0 — 校验、覆盖、提交、推送合成一个脚本,退出码分流给 n8n 判断
- v0.6 — 从 Execute Command 里直接
copy+git,改成调用独立脚本 - v0.3 — 首页日报卡与
reports/latest.html拆开,地址固定不变