火种与行业 KnowHow

这个仓库做的事可以一句话说清:每天从信源收资料,模型先筛一遍再独立打两次分,挑出值得看的改写成中文标题和摘要,不同来源说的同一件事聚成一个事件,按多少人在说排热度,第二天早上出一份日报。开源出去的不是某个站点,而是跑这条链路的引擎:网站与后台、精选流程、聚簇与热度算法,以及全部提示词原文和入选门槛。

为什么开源,得看作者的处境。他是设计师出身,自己明说不是专业开发者,代码是和模型一起重写的。他不懂法律、HR、金融、贵金属这些行业,但这些行业恰恰都想要一个自己的热点站。于是开源的是一份"火种"——换信源、换精选标准,就变成你的行业热点站。行业判断沉淀在提示词和门槛里,改标准不用改代码。

六步流水线

采集、预筛、两次评分、写作、聚簇、热点与成刊。

采集侧支持六种信源:RSS、网页列表、JSON 接口、X 账号、微信公众号、自建脚本推送。信源本身分三级:官方一手、官方账号(准官方)、媒体与个人,三级的入选门槛不同,抓取频率按实际产出自动调整。

预筛之前先判重。有一个细节省得很聪明:X 上的帖子如果没有正文、只有一个链接,就不调模型,直接保留原链接的图片。

两次独立评分

真正有意思的是评分环节:同一份标准,让模型独立打两次分,过门槛且不重复的才进精选。单次打分受上下文顺序、采样随机性和措辞暗示的影响很大,同一条内容换个位置就可能是另一个分。打两遍再用规则合并分歧,比反复调提示词措辞有效得多,代价只是多一次调用。

下面这段是自写的伪代码,复刻"两次独立评分 + 写入前复核"这个思路,不是仓库原文:

// 同一份标准跑两遍,只看是否都过门槛,避免单次采样的偶然性
async function scoreTwice(item: FeedItem, rule: SelectionRule): Promise {
  const a = await llm.score(rule.prompt, item);
  const b = await llm.score(rule.prompt, item);

  if (a.score  rule.maxGap) return { pass: true, needsHuman: true };

  return { pass: true, score: (a.score + b.score) / 2 };
}

// 聚簇写入前再复核一次;复核允许单独换模型,不占用主模型的预算
async function reviewBeforeWrite(cluster: Cluster): Promise {
  const model = process.env.GROUP_REVIEW_MODEL ?? DEFAULT_MODEL;
  const verdict = await llm.with(model).judge(cluster.titles, cluster.summaries);
  return verdict.sameEvent || verdict.isFollowUp;
}

两次评分解决的是"单次判断不可靠",复核解决的是"合并错了比漏合并更贵"——前者容忍分歧,后者宁可多花一次调用。

聚簇与复核

聚簇的做法是先用标题和摘要的向量找候选,没有向量服务时退回文字重合度,范围限定在最近两周。拿到候选后交给模型判断三种情况:同一件事、后续进展、两件事。拿不准的就合并,写入之前再复核一遍,复核这一步可以单独换模型(GROUP_REVIEW_MODEL)。人工改过的归属不会被覆盖,运营的纠正要能压过算法的反复。

事件级热度

热度按事件算,不按文章算。规则是:48 小时内每个独立来源只计一次,24 小时内的部分减半;一家媒体就同一件事发十篇,只算一次;X 上的讨论也计入。再和 6 小时前对比,涨得快的标上升,新出现的标新。

下面这段是自写的衰减计算,用来说明"按来源去重再按时间衰减"和"按文章计数"的差别:

// 同一来源在窗口内只贡献一次,时间越久权重越低
function heatByEvent(event: { sources: { id: string; at: number }[] }, now: number) {
  const seen = new Map();
  for (const s of event.sources) {
    const age = now - s.at;
    if (age > 48 * 3600_000) continue;
    const weight = age  sum + w, 0);
}

按事件聚合之后,"一家媒体刷屏"就刷不出热度了,这是信息聚合类产品的核心指标问题。

提示词与门槛

评分标准写在 industry/prompts/selection-score.md,入选门槛在 industry/selection.ts。校准流程也给了:用自己标注的样本在 SelectBench 上跑 scripts/eval-selection.ts 评估。

写作阶段产出的是中文标题、答案先行的摘要、推荐理由、外文全文翻译、分类标签,新闻事实单独抽取,并且明确防着模型把原文没提的公司写进标题——这类幻觉在资讯改写里是最常见的事故。

成刊默认时间是日报 08:00、周报周一 10:00、月报每月 1 日 10:30,配置在 site/site.ts 的 EDITION_TIMES。一件事只占一条,报过的只在有新进展时跟进,成刊过程不调模型(周报和月报的总述、导读除外)。

信源层与 SSRF 防护

自建脚本推送接口有限流 INGEST_RATE_LIMIT,compose 默认 10。内网自建服务可以靠 PRIVATE_FEED_HOSTS 放行,但只对 fetchListing 生效;配套还有整站开关 ALLOW_PRIVATE_NETWORK_FETCH 和出口代理 EGRESS_PROXY_URL。链路本地地址和云元数据地址(169.254.0.0/16、fd00:ec2::254)始终拒绝,不受开关影响。这三个配置凑在一起,才是一套完整的 SSRF 处理:默认拒绝、按需白名单、危险网段硬编码屏蔽。

部署

部署是三步,默认接 DeepSeek;换别家模型改 .env 里的 LLM_BASE_URL、LLM_MODEL、LLM_EXTRA_JSON,推理模型另设 LLM_REASONING_TOKENS:

# 1) 生成 .env,--llm-key 之后填自己的 key(默认 DeepSeek)
node scripts/init-env.ts --llm-key 

# 2) 换模型就改 .env:LLM_BASE_URL / LLM_MODEL / LLM_EXTRA_JSON
#    推理类模型再补一个 LLM_REASONING_TOKENS

# 3) 起服务,默认监听 3000 端口;后台在 /admin,密码取 ADMIN_PASSWORD
docker compose up -d --build

# 改完行业后至少跑这三步再上线
npm run typecheck && npm test && node scripts/smoke.ts

后台 /admin 用 ADMIN_PASSWORD 登录,能管信源与试抓、做内容诊断和人工评分更正、跑精选评测、给每一步单独换模型,还有运行记录与告警。改成自己行业的推荐路径是把仓库交给 Agent,配上官方提示词(大意是"请读 AGENTS.md 和 docs/customize.md,把这个站改成 XX 行业的热点站")。命令里最后那三行是改造之后的自检,跑不过就别上线。

可复用的部分

几条不绑定这个项目也能用的经验:让模型做判断时打两遍,比改提示词措辞收益更高;判断标准与代码分离,换行业只动提示词和阈值;热度按实体聚合而不是按文档计数;除了给人看,也留了 RSS、MCP、llms.txt 这类给 Agent 的入口。

目录分层也清楚:industry/ 放行业相关(分类、主题、信源、提示词、门槛),site/ 和 modules/ 放站点专属,引擎本体在 apps/ 与 packages/。技术栈是 Node.js 24(最低 24.22.1)+ TypeScript + React Router(SSR)+ Fastify + PostgreSQL 17 + pg-boss + Tailwind CSS + Docker Compose;sanitize-html 锁在 2.18.1 修 GHSA-c9rx-x947-fp3h,fastify 5.12.7、undici 8.12.3。

边界与局限

几条得说在前面。仓库和线上跑的是同一份引擎代码,它不是为了给别人复用而重新打磨过的通用框架。AIHOT 专属的模型榜、Codex 重置监控、大事记已经移出框架(PR #92,公开接口升到 4.0.0,迁移 0053 删了相关表)。仓库不带 AIHOT 自己的信源名单和运营数据,只放 18 个公开海外 AI 资讯源做示范,也不允许使用 AIHOT 的名字和 Logo。

作者非专业开发者这一点会直接体现在代码质量上,把它当参考实现读可以,当生产底座要自己评估。