48 小时窗口、24 小时半衰期:AIHOT 热度算法为什么比人工榜单更讲武德
【免费下载链接】AIHOT一个自己找热点、自己写日报的网站框架。把信源和精选标准换成你的,它就是你的行业热点站。项目地址: https://gitcode.com/gh_mirrors/ai/AIHOT
热点榜是内容产品里最容易"翻车"的地方。人工编辑排榜,难免带口味与立场;用点击量排榜,又被标题党和水军牵着走。AIHOT 的做法是把热度计算交给一套完全确定性的数学规则:不靠模型打分,不靠编辑拍脑袋,只用"过去 48 小时、24 小时半衰期、独立来源去重"这三个参数,就筛出了"真正有很多人在说的事"。这套逻辑没有一处魔法,全部落在仓库的 packages/backend/src/events/hot.ts 一个文件里,规则版本号甚至写死在代码中:heat-v1-48h-halflife24h。
一、热度公式拆解:三个参数撑起一套排序
先看核心常量,它们就定义在 hot.ts 的第 57–60 行:
export const HOT_RULE_VERSION = "heat-v1-48h-halflife24h"; const WINDOW_HOURS = 48; const HALF_LIFE_HOURS = 24; const MIN_PARTICIPANTS = 2;规则命名里自带参数:48 小时时间窗口、24 小时半衰期、最少 2 个参与者。理解这三条,整个榜单的脾气就摸清了。
第一,热度按"事件"算,不按"文章"算。这是整套设计的地基。AIHOT 先把不同来源说同一件事的报道聚成一个事件(聚簇逻辑见 README.md 的"聚簇与热点"一节),然后热度只在这个事件维度上累计。官网发一篇、媒体转十篇、X 上吵一天,读者看到的是同一个事件条目,热度也是这个事件的热度。
第二,48 小时窗口决定"谁有资格说话"。事件证据的观测时间必须落在now - 48h到now之间才会进入统计。第 140–141 行用 SQL 生成两个窗口起点——当前时刻的窗口起点,以及 6 小时前的窗口起点:
nowFrom = at::timestamptz - make_interval(hours => 48) prevFrom = prev::timestamptz - make_interval(hours => 48) -- prev = at - 6h48 小时意味着榜单只反映"最近两天大家在讨论什么",一周前再轰动的新闻,过了窗口就自动下榜,不需要编辑手动"换血"。
第三,24 小时半衰期决定"发言的权重怎么衰减"。这是整个公式最漂亮的部分。一个参与者对热度的贡献不是固定的 1 分,而是按他最后一次发言距离现在的时间做指数衰减:
decayNow = power(0.5, extract(epoch FROM (at - last_at)) / 3600.0 / 24)翻译成白话:每过 24 小时,这条发言的贡献减半;发言越新鲜,贡献越大。公式里用的是power(0.5, ...),这是 SQL 标准函数,没有浮点累积误差,也没有任何近似。同样的衰减逻辑在 heatSeries(事件页的逐小时热度曲线)里也用 JS 复刻了一遍:
heat += 0.5 ** ((hour - last) / 3600_000 / HALF_LIFE_HOURS);半衰期和窗口的配合很妙。一个 48 小时窗口内、恰好卡在窗口边缘发言的参与者,其贡献经 24 小时半衰期衰减后只剩 1/4;而在 6 小时前刚发言的参与者,贡献几乎打满。于是榜单天然偏向"刚刚还在发生的讨论",而不是"48 小时前热闹过一下"的旧闻。最近 6 小时新增参与者的数量还单独被统计为recent6h,为下面的"飙升"判断提供弹药。
第四,最小参与者数把关。事件入围榜单至少需要 2 个独立参与者、且其中至少 1 个是报道型(editorial)来源,见第 185 行:
const rows = (await heatRows(at, behind)).filter( (r) => Number(r.participants) >= MIN_PARTICIPANTS && Number(r.editorial_participants) >= 1 );一条孤零零的自媒体爆料、一个没有旁证的传闻,连榜都上不了——这从规则层面就堵死了"一家之言刷屏"。
热度计算完后,展示层还要做一个10 倍缩放:heatIndex把内部热度四舍五入后 ×10 展示(Math.round(heat * 100) / 10),榜单顶部限 10 条(if (entries.length >= 10) break;)。所以你在页面上看到的"热度值 ×10"是展示层约定,底层权重逻辑完全不受影响。这一整套参数最终和条目一起以 JSON 形式落库(hot_rankings表),evidence字段里还存着windowHours、halfLifeHours、minParticipants供审计——每次榜单是怎么算出来的,都可追溯。
二、入围规则与趋势标签:"新 / 上升 / 飙升"的判定全是数学
榜单不只是一堆热度数字,每个条目还挂着趋势标签。这些标签的判定同样不含任何模型推理,全部是确定的阈值规则,集中在 packages/backend/src/events/hot.ts 的第 217–238 行。
先看"和谁比"的问题。趋势是跟 6 小时前比(prev = at - 6h)。但直接拿当前热度减 6 小时前热度是不公平的——因为两次统计的 48 小时窗口有 6 小时的重叠差异,而且有些信源的采集可能落后,导致"没看到"最近的新证据。代码的应对是引入"可比组"(comparable):只有那些在 6 小时前窗口开始前就已存在、且采集没有落后的参与者,才进入前后对比(heat_obs/heat_prev_obs)。第 220–222 行:
const [cur, prev] = Number(r.uncomparable) > 0 ? [heatIndex(Number(r.heat_obs)), heatIndex(Number(r.heat_prev_obs))] : [heat, prevAll]; const pct = prev > 0 ? (cur - prev) / prev : null;翻译过来:如果存在"采集落后"或"窗口开始后才加入"的参与者,就把它们从变化率计算里剔除,只用两边都完整观测到的同一批人算百分比。这样"涨了 30%"说的是同一批信源口径下的真实变化,而不是被新接入的信源数量"虚抬"上去的。
然后是三档标签的判定逻辑(第 223–229 行):
const isNew = at.getTime() - firstAt.getTime() < 6 * 3600 * 1000; const surge = Number(r.recent6h) >= 3 && Number(r.recent6h) / Number(r.participants) >= 0.5; const badges: HotEntry["badges"] = []; if (surge) badges.push("surge"); if (isNew) badges.push("new"); if (!surge && pct !== null && pct > 0.15) badges.push("rising");- "新":事件首次出现距现在不足 6 小时(
isNew)。注意它用的是first_report_at而非当前时间窗口,一个 5 小时前刚冒头的事件会立刻带上"新"标。 - "飙升/爆":最近 6 小时新加入的参与者 ≥ 3 个,且占当前总参与者的比例 ≥ 50%(
surge)。这个条件极其苛刻——既要绝对数量,又要相对密度,防止"老事件被慢慢拱上去"也误标成爆。 - "发酵中/上升":剔除"爆"之后,变化率 pct > 15% 才标"上升"(
rising)。0.15 这个阈值意味着必须有实质性的讨论增长,微涨不算。
趋势方向(trend)另有 ±10% 的阈值判定(第 237 行):pct > 0.1是上升、pct < -0.1是下降、之间是持平;无法比较时标unknown,前端会诚实显示"暂不比较",而不是硬编一个数。前端把这些标签渲染成"爆 / 新 / 发酵中"三个徽章和 ↑/↓ 百分比,定义在 apps/web/app/routes/hot.tsx 里:
const BADGES = { surge: { label: "爆", tone: "hot", hint: "讨论快速增加" }, new: { label: "新", tone: "accent", hint: "首报 6 小时内" }, rising:{ label: "发酵中", tone: "amber", hint: "讨论仍在增加" }, };每个徽章都带 tooltip 说明判定依据——读者能看懂,审核能复核,这是"讲武德"的另一个侧面:规则全公开,没有任何黑箱。
三、对刷量与同源重复报道的天然防御力
人工榜单最怕水军,而 AIHOT 这套算法从数据结构上就对两种最常见的作弊手段免疫。
防御一:重复抓取、重复报道不加分——"一个参与者每个窗口只算一次"。
热度统计的粒度是participant_key(参与者键),而不是文章条数。在 currentSignals 这个 SQL 视图里,参与者身份的判定非常讲究:
CASE WHEN s.id = ANY(COMMUNITY_FEEDS.dev) THEN 'dev:account:' || lower(substring(a.url ...)) WHEN s.id = ANY(COMMUNITY_FEEDS.hn) THEN 'hn:account:' || substring(a.author ...) WHEN s.signal_group_id IS NOT NULL THEN 'group:' || s.signal_group_id WHEN s.owner_entity_id IS NOT NULL THEN 'owner:' || s.owner_entity_id ELSE 'source:' || s.id END AS participant_key层层去重的含义是:
- 同一家媒体的官网、公众号、X 账号如果挂了同一个
owner_entity_id,它们算一个参与者——一家公司发十篇也只算一次; - 一个信号组(
signal_group_id)整体算一个参与者; - 而在 dev.to、Hacker News 这类社区信源上,每个发帖账号算一个独立参与者(
dev:account:*/hn:account:*)——几十个不同的人发帖,才真正叠加热度; - 更狠的是,聚合层面用
GROUP BY story_id, participant_key把同一参与者在窗口内的最新一条作为代表(max(observed_at)),前面重复抓取的那几篇自动被吸收,不会重复计权。
这一设计在 tests/hot.test.ts 里有专门测试:"one company's channels are one participant; every community author is one"——同一实体的多渠道测试、社区账号独立测试都直接写进了回归用例。还有一个反向用例值得注意:归属修复后,同一参与者的多条证据会合并成一条。测试里一个聚合器来源的文章被修正归属到官方 publisher 后,参与者数从 2 降回 1,因为"同一个 publisher 只算一个参与者"。
防御二:48 小时窗口天然冷却刷量。刷量要持续 48 小时不断有新账号发言,成本陡增;而 24 小时半衰期让"昨天买的量"今天只剩一半权重。更重要的是,事件级聚合把"同一件事的十篇水文"压成一条,刷子即便发一百篇,也只算一个参与者的一次发言。
防御三:撤回和改归属实时生效。热度证据是"按当前状态实时计算"的,不是快照缓存。被撤回的报道(visibility = 'withdrawn')立即从统计中剔除;信源角色从 editorial 改成 signal、或者被标记为 isolated(不参与热度),下一轮计算就自动消失。甚至管理员修正一篇文章的归属,热度会立刻反映新归属。测试 "withdrawn reports and changed roles are counted as they stand now" 验证的正是这一点。这意味着舆论反转、假新闻撤回后,热度会自己"消下去",不需要人工去删榜。
防御四:采集落后的信源不会造成"假冷"或"假热"。榜单计算时会检查每个信源的上次成功抓取时间(sourceClocks),落后超过三个抓取间隔的信源被标记为behind。这些信源的新证据可能缺失,所以它们会被排除出"变化率对比"(但不能排除出绝对热度)。同理,逐小时热度快照(story_heat_hourly表)里"采集未完整"的小时会被标记complete = false,前端图表宁可留缺口,也不把缺失画成零——见 Sparkline.tsx 的注释"Hours without a comparable snapshot break the curve rather than being drawn as zero"。
四、这套规则最终长什么样
把上面的机制拼起来,AIHOT 热点榜的完整链路是:
- 采集层持续抓取信源,判重去重后入库;
- 归组层把同一件事的报道聚成一个事件(向量 + 模型判断,复核可换模型);
- 热度层用纯 SQL 计算每个事件在 48 小时窗口内的独立参与者热度,按 24 小时半衰期衰减;
- 排序取前 10,配上"新 / 上升 / 飙升 / 持平 / 暂不比较"的标签和 24 小时逐小时热度曲线(HeatChart.tsx 展示事件页趋势);
- 榜单整体以
hot_rankings快照落库,公开读取层(publication/hot.ts)在展示前还会复核代表报道是否仍是有效证据,防止归组变动后出现"死链"。
值得一提的是,这套热度数据不止喂给网页。公开 API v1 会输出每个条目的sourceCount、participantCount、sourceNames(见 publication/stories.ts 的v1HotTopics),MCP 工具和 RSS 也走同一份公开读取层——机器看到的榜单和人类看到的完全一致,且热度数值只对网站展示,机器出口只给排名和来源数,避免数值被滥用。
从社区反响看,AIHOT 凭借"自动采集热点、编排行业日报"的能力在开源社区快速积累热度,GitHub 已获约 5400 Star,并多次登上 GitHub 日榜。但真正让它在同类项目里立住的,恰恰不是大模型的部分——大模型负责"判断这件事值不值得看",而"有多少人在说"这件事,AIHOT 交给了 48 小时窗口、24 小时半衰期和独立参与者计数这套确定性算法。模型会幻觉,编辑会疲惫,但power(0.5, ...)不会。把情绪排除在热度之外,也许正是它敢把全部规则开源的原因。
【免费下载链接】AIHOT一个自己找热点、自己写日报的网站框架。把信源和精选标准换成你的,它就是你的行业热点站。项目地址: https://gitcode.com/gh_mirrors/ai/AIHOT
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考