凌晨三点多,我放在床头柜的手机嗡嗡震了一下。屏幕亮起来,是buzz推送的一条提醒:昨天下午还在十名开外的一个词条,短短十五分钟蹿到了榜单前五。我揉着眼睛打开后台,热度曲线几乎是垂直向上的。那一瞬间我很确信,这个叫buzz的小项目,真的把“盯热点”这件事从人肉活变成了机器活。
buzz是我写的一个轻量热词异动监听工具:定时抓取公开榜单数据,对每个词条做热度建模,识别出“起爆”的瞬间,然后通过看板WebSocket推送、浏览器蜂鸣声、通用Webhook把消息递给该收到的人。整套代码一开始不到一千行Python,部署在单台服务器上,跑了大半年,成了我们内容团队离不开的选题雷达。下文就是一次完整的项目复盘,从需求、数据源、异动模型到通知链路的细节和踩坑,一次性讲清楚。
1. 为什么做buzz:凌晨三点,热点不等运营打卡
1.1 一个不起眼的词条,可能从深夜开始爆发
先说最初的场景。做内容运营的朋友应该都有同感:真正有流量价值的网络热词,往往不是白天出现在热搜榜上的那些,而是深夜悄悄上榜、凌晨突然起量的小众词条。等第二天早上大家打开电脑刷榜单,它的热度往往已经翻了十几倍,第一批跟进的内容早就吃完了流量红利。
我之前带的小团队就是靠人工盯榜的。白天排班还好,晚上就只能靠值班的人每隔半小时刷一次。说实话,熬过夜的人都明白,凌晨三四点盯榜效率有多差——人困、手慢、消息滞后。有一回一个词条从五十名开外一路爬到前十,我们愣是错过了最黄金的三个小时。那次之后我就下定决心,必须做一套自动监听系统,让机器替人去盯那些“嗡嗡作响”的苗头。
1.2 市面上的舆情工具又重又贵,我只想要一个闹钟
做之前我调研过不少舆情监测平台。它们的卖点是全:全网声量、情感分析、负面预警、竞品对比、PDF报告……功能确实多,但对我们这种只需要“知道哪个词该跟进了”的小团队来说,属于杀鸡用牛刀。而且这些平台通常按关键词数量、数据量阶梯收费,年费动辄几万,接口响应还不一定够快。
我的真实需求其实特别朴素:每天自动抓几次公开热榜,把新出现的、快速升温的词条筛出来,在有苗头的时候提醒我一声。不需要情感分析,不需要客户肖像,不需要精美报表。buzz的定位从一开始就很清晰——它不是一个舆情平台,而是一个“热词闹钟”。
1.3 我给自己划的三条设计边界
为了避免做着做着又变成一个大而全的东西,我开工前给自己立了三条规矩:
- 只服务“异动提醒”这一件事,不做长期趋势报告,不做情感分析。
- 只用公开可访问的榜单数据和趋势接口,遵守数据源的使用条款、robots规则与合理的请求频率,不做用户级数据追踪。
- 部署形态保持简单,单机Docker Compose能跑通就不上K8s,SQLite能撑住就不硬上PostgreSQL。
这三条边界在后续开发里救了我很多次,至少让我少写了至少一半没必要的功能。工具越小,越容易被真正用起来。
2. 数据源与采集:先解决“盯什么”,再谈“怎么盯”
2.1 数据源选型:结构稳定、公开、可低频率抓取
buzz要盯的对象是“网络热词”,而热词的主要载体就是各大平台的热榜页面。我选数据源时列了几个硬指标:
- 访问成本低:不需要登录就能拿到榜单数据,或者有公开、稳定的趋势接口。
- 结构稳定:接口字段或者页面结构在几个月内不会大变,否则维护成本撑不住。
- 更新频率合理:榜单本身半小时到一小时更新一次,那我采集频率定在2到5分钟就够了。
- 能提供热度数值:哪怕只是一个相对热度值,也比单纯排名要丰富得多。
最终我接了三个公开来源,覆盖综合资讯、短视频热榜和科技垂直社区。每个来源写一个对应的采集器类,统一输出成相同的结构,方便上层模型消化。这个过程花了大概两天,踩的最大的坑是接口的隐性限流——明明文档说没限制,抓了二十几分钟之后突然开始返回空数组。后来我用httpx加随机抖动和重试解决,具体细节放到第五章讲。
# pluckers/base.py import httpx from tenacity import retry, stop_after_attempt, wait_exponential class BasePlucker: """所有榜单采集器的基类,统一异常处理和重试策略""" source_name = "base" def __init__(self, client: httpx.AsyncClient, endpoint: str): self.client = client self.endpoint = endpoint @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=10)) async def pluck(self) -> list[dict]: resp = await self.client.get(self.endpoint, timeout=10) resp.raise_for_status() data = resp.json() return self._parse(data) def _parse(self, data: dict) -> list[dict]: raise NotImplementedError每个来源只实现一个_parse方法,把来源特有的字段映射成统一的内部结构。我当时给每条记录定义了五个字段:title(词条)、rank(排名)、heat(来源给出的热度值,可能为空)、source_name(来源)、observed_at(观测时间,用ISO时间字符串)。字段一旦统一,后面所有模型逻辑就能无视来源差异直接跑。
2.2 热词实体对齐:别把“同一个词”拆成两半
采集做出来之后,我很快遇到了第二个问题:同一个概念在不同榜单里写法不一样。同一时刻,一个榜叫“人工智能眼镜”,另一个榜叫“AI眼镜”,还有一个榜叫“智能眼镜”。在人的眼里这明显是同一个话题,但在字符串层面它们是三个完全不同的词条。
如果不做对齐,热度模型就会把它们当成三个独立词条分别计算,每个看起来都不温不火,真实的大热度被拆没了。所以我在写入存储前加了一层实体归一化:
# aliases.py import unicodedata ALIASES = { "ai眼镜": "智能眼镜", "人工智能眼镜": "智能眼镜", "chatgpt": "gpt系列", "chat gpt": "gpt系列", "gpt-4o": "gpt系列", } def normalize_word(raw: str) -> str: word = raw.strip().lower() word = unicodedata.normalize("NFKC", word) word = word.replace(" ", "").replace(" ", "") return ALIASES.get(word, word)这个函数会在每个词条入库前跑一遍。别小看这一步,它把后续所有分析的数据质量往上拉了一大截。不过它也带来一个副作用:如果两个不同话题的别名恰好撞了,会把热度错误合并。所以我在维护别名表时非常克制,只有确信是两个词条指向同一概念时才会加进去。
3. 热词异动模型:从“排名”到“加速度”的三层判断
3.1 只看排名,会漏掉真正重要的“起爆”信号
很多人在做热词监测时习惯直接盯排名变化:昨天第50名,今天第10名,上升40位,看起来很猛。但排名有个天然缺陷——它是相对值,只反映“在榜上的位置”,不反映热度本身涨了还是跌了。一个榜有50个坑位,可能整体热度都在涨,某词条排名没动但热度翻倍了;也可能前面几个词条突然掉榜,后面所有词条被动上升,排名看着在涨,实际讨论量没什么变化。
所以我坚持拿“热度数值”而不是“排名变化”做主判断依据。有的来源直接给了热度值,我就直接用;有的来源只有排名,我用一个简单的公式做换算:
RANK_SCORE_BASE = 100.0 def rank_score(rank: int) -> float: if rank <= 0: return 0.0 return max(0.0, RANK_SCORE_BASE / rank)第一名100分,第十名10分,第五十名2分。这个公式有意识地压低尾部排名权重,避免大量长尾词条贡献太多噪声。如果某来源同时给了排名和热度值,我会这样合并:
weighted_heat = 0.7 * normalized_heat + 0.3 * rank_score(rank)normalized_heat是该来源热度值的最大最小归一化结果,这样不同来源的量纲差异就被抹平了。整条链路下来,我得到的就不再是“排名跳跃”,而是一个相对平滑的“词条热度读数”。
3.2 滑动窗口:一小时内的热度历史都存起来
有了热度读数,紧接着要解决的是“怎么描述升温”。单点采样的值没有比较意义,我需要每个词条在时间轴上的热度序列。实现上我用Redis的ZSet存滑动窗口:
- 以词条归一化名为key,例如
buzz:trend:智能眼镜 - member记为时间戳,score记为该时刻的加权热度
- 每次写入后,把一小时前的旧数据清理掉
ZADD buzz:trend:智能眼镜 1736006400 78.4 ZREMRANGEBYSCORE buzz:trend:智能眼镜 -inf 1736002800 ZRANGE buzz:trend:智能眼镜 0 -1 WITHSCORES选Redis而不是PostgreSQL,主要看重两个点:一是ZSet天然支持按score范围裁剪和按时间排序,窗口逻辑几乎不需要业务代码;二是单机Redis处理这种高频小写入非常从容。等后面要上更重的分析再迁数据库也不迟,当前阶段省事就是胜利。
3.3 加速度识别:速度与加速度双条件,才叫“起爆”
窗口数据有了,异动检测就水到渠成。我设了三个连续的观察窗口:前一个时间窗口、当前时间窗口、以及窗口之间的变化率。核心逻辑用这段伪代码描述:
velocity_now = (heat_now - heat_prev) / interval velocity_prev = (heat_prev - heat_before) / interval accel = (velocity_now - velocity_prev) / interval if velocity_now > velocity_threshold and accel > accel_threshold: trigger_buzz_alert(word)为什么必须同时看速度和加速度?因为只盯速度,很多“温水煮青蛙”式的词条会常年达标——它们缓慢但持续地升温,速度一直是正数,但不代表此刻有什么值得跟进的事发生。加速度代表的是“升温的升温”,是曲线开始变陡的信号。真正的爆款热词通常是先安静一阵,然后速度突然从3跳到20,同时加速度从负转正,这时候才值得推送提醒。
我拿两周的历史数据做过一次回测,把判断条件从“只看速度”改成“速度+加速度”之后,误报数量减少了将近七成。这个收益大到让我反思:很多花哨的AI预测模型,在明确业务目标面前,可能真不如一条精心定义的物理公式靠谱。
3.4 噪声过滤:广告词、黑名单与提醒冷却
模型能识别出“异常升温”后,真正的噪声才显形。我处理了三类:
第一类是广告词和无效词条。有些榜单会出现带链接前缀或明显水军刷出来的词条,它们的热度曲线经常突然拉满,但没过多久就销声匿迹。我维护了一份黑名单,匹配到就跳过。
第二类是重复触发。同一个词条在五分钟内两次都满足速度+加速度条件,第二次其实是冗余的。我的做法是用Redis的SET NX EX做一个冷却键,冷却时间默认10分钟:
SET buzz:cooldown:智能眼镜 1 NX EX 600EX 600表示600秒后自动过期。冷却键存在时,即便词条再次异动也不会重复推送。
第三类是冷启动问题。一个新词条刚进榜时,手里只有一个数据点,根本无法计算窗口内的速度和加速度。我在检测前加了一个前置条件:窗口内至少要有3个样本点,少于一概不判断。这个条件挡住了大量的“首见即触发”,代价是极个别真的瞬间爆发的词条会延迟一个采样周期才提醒,但整体利远大于弊。
4. 让buzz真正“响”起来:WebSocket推送与本地蜂鸣提醒
4.1 内部看板:FastAPI + WebSocket,三分钟搭好实时通道
检测到异动之后,重要的是让消息立刻出现在人面前。buzz的看板是一张极简的网页,服务端用FastAPI,实时通信走WebSocket。选FastAPI而不是Django有一个非常实际的理由:它原生的WebSocket支持足够干净,不需要额外装第三方库,项目里本来就要用它的异步能力来跑采集任务,一套事件循环通吃。
推送管理器的实现很朴素:
# ws_manager.py from fastapi import WebSocket class BuzzConnectionManager: def __init__(self): self.connections: list[WebSocket] = [] async def connect(self, ws: WebSocket): await ws.accept() self.connections.append(ws) def disconnect(self, ws: WebSocket): self.connections.remove(ws) async def broadcast(self, message: dict): stale = [] for conn in self.connections: try: await conn.send_json(message) except RuntimeError: stale.append(conn) for conn in stale: self.disconnect(conn)这里有一个值得注意的运维细节:当客户端断线时,send_json会抛RuntimeError,此时要把失效连接从列表里移除,否则下次广播会重复报错。放到生产环境后这个列表还需要定期清理,不然浏览器标签页开着不管,连接对象会堆积。
4.2 让浏览器真的发出“buzz”声
名字都叫buzz了,声音提醒自然不能少。我在看板前端写了一个蜂鸣函数,用Web Audio API生成一段短促的锯齿波。为什么用锯齿波而不是正弦波?锯齿波高频成分更丰富,穿透力更强,在不可能一直盯着屏幕的运营办公室里更容易被注意到。
function playBuzz() { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sawtooth'; osc.frequency.value = 440; gain.gain.setValueAtTime(0.25, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.8); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.8); }网上能搜到很多类似的蜂鸣实现,但真正落地时有个绕不开的坑:浏览器的自动播放策略会挂起AudioContext,用户还没有和页面产生交互时,调ctx.resume()也没有用。我的解决方案是:在页面加载时先创建一个AudioContext并立刻挂起,等用户第一次点击页面任意位置时主动调用resume(),之后蜂鸣调用才能正常出声。这个细节测试了挺久才摸透。
4.3 通用Webhook:把告警递给钉钉、飞书还是企业微信,只看用户想要什么
蜂鸣只对坐在电脑前的人有效。很多时候运营同事根本不在看板旁边,需要一个能推到手机上的通道。我不想给每个聊天工具单独写对接逻辑,而是设计了一个通用Webhook出口:buzz在检测到异动时,把告警事件按统一JSON格式POST到用户在配置文件里填写的URL上。
{ "event": "buzz_alert", "word": "智能眼镜", "heat": 78.4, "velocity": 32.1, "accel": 6.8, "rank": 12, "source": "demo_trend", "ts": "2025-01-05T08:12:00+08:00" }钉钉、飞书、企业微信这类工具的机器人接口,基本都是“往一个Webhook地址POST一段JSON”的玩法,区别只在于外层包装的字段名和格式。我在buzz里把统一事件发送出去,再提供几个转发模板做字段适配。这样新增一个渠道只需要写一个几十行的模板,不用去动检测主流程。第一批上线只做了钉钉和飞书两个适配,已经基本覆盖了团队的实际使用场景。
5. 部署与踩坑:容器化运行中的真实教训
5.1 Docker Compose 一键起服务
buzz的部署清单一共三个容器:应用本身、Redis、一个专门跑定时采集的worker。应用和worker共用同一份代码镜像,通过不同的启动命令区分角色。为什么把采集任务单独拆成worker而不是放在应用进程里?因为采集任务里全是网络IO和重试逻辑,放在Web进程里很容易被请求阻塞拖累,分开之后两边各跑各的,互不干扰。
下面是核心的docker-compose片段,app和worker构建同一个镜像,用command区分启动方式:
services: redis: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 app: build: . command: uvicorn buzz.app:app --host 0.0.0.0 --port 8000 ports: - "8000:8000" environment: REDIS_URL: redis://redis:6379/0 TZ: Asia/Shanghai depends_on: redis: condition: service_healthy worker: build: . command: python -m buzz.worker environment: REDIS_URL: redis://redis:6379/0 TZ: Asia/Shanghai depends_on: redis: condition: service_healthy5.2 定时任务调度:APScheduler与asyncio的真实摩擦
采集worker里的定时任务,我用的是APScheduler的AsyncIOScheduler。第一次实现时我犯了一个典型的错误:用了BackgroundScheduler而不是AsyncIOScheduler,导致采集函数在独立的线程池里跑,每两分钟和主事件循环抢资源,日志里全是奇怪的计时抖动。
后来换成AsyncIOScheduler,并用max_instances=1和coalesce=True限制同一任务的重叠执行:
from apscheduler.schedulers.asyncio import AsyncIOScheduler scheduler = AsyncIOScheduler() scheduler.add_job( collect_all, trigger="interval", minutes=2, id="collector", max_instances=1, coalesce=True, ) scheduler.start()max_instances=1保证如果上一次采集因为网络超时拖了四分钟,下一次调度不会又开一个并发实例;coalesce=True则把积压的触发合并成一次执行。这两个参数几乎是定时采集类任务的基础配置,但新手往往忽略,直到并发写Redis把事务搞乱了才回头补上。另外我在worker里不写time.sleep,一律用asyncio.sleep,否则两分钟的轮询调度会被一个10秒的同步阻塞直接拖垮。
5.3 一次提醒风暴的完整排查链路
上线第三周,某天下午我们的手机突然开始狂响。同一个词条在半小时内提醒了快二十次,冷却机制看起来完全失灵。
我的排查过程是这样的。第一步先确认现象:看板推送记录里,确实每隔几分钟就有一条相同词条的告警。第二步看冷却键是否存在:直接连Redis执行EXISTS buzz:cooldown:某某词,返回是0,说明冷却键确实被写进去了,但在检测逻辑里没有被正确读取。第三步查代码,发现我写检测时用的是GET buzz:cooldown:word,如果键不存在,Redis返回nil;而检测逻辑里我用的是if cooldown_key:来判断,nil在Python里是None,为假值,应该会跳过推送才对。
到这里我才发现真正的问题:冷却键的写入使用了SET NX EX 600,但我在写代码时误把过期时间写成了EX 60,一分钟就过期了。而榜单每两分钟一抓,词条持续升温,于是每隔两次采集就会有一半的窗口落在冷却过期之后,表现出的现象就是“半小时内响了一大串”。把EX改成600之后,推送风暴立刻消失。
这场排查的过程让我养成一个习惯:凡是涉及“窗口”“冷却”“过期”的配置,启动时必须打日志把实际参数打印出来。代码一眼看上去是对的,运行时参数往往是错的。
5.4 采集礼貌性限流:给爬虫装个“呼吸节奏”
数据源方面最大的坑就是摸不到规律的限流。正常抓了二十多分钟后,接口突然开始返回空数组,或者长时间超时。后来我发现,这些公开接口虽然没有文档写明“每分钟N次”,但后台显然有基于IP的滑窗限流。
解决方式不是头铁增加重试,而是主动降低抓取频率并加上随机抖动。我把采集间隔从“固定2分钟”改成“2分钟加上0到20秒的随机偏移”,每个来源的抓取任务之间再加至少几秒的间隔。这样流量曲线不再是脉冲式的,更像是带呼吸节奏的平稳波流。
import asyncio import random async def collect_all(): for plucker in pluckers: try: items = await plucker.pluck() await ingest(items) except Exception as exc: logger.warning("pluck failed: %s %s", plucker.source_name, exc) await asyncio.sleep(random.uniform(5, 15))上线一个月后,没有再触发过数据源侧的空数据响应,这条经验我可以直接抄给后来做任何公开数据采集项目的人。
6. 阈值调参经验与buzz的下一个版本
6.1 别拍脑袋定阈值:拉两周历史,画分位线
调参是buzz上线后占用时间最多的工作,没有之一。一开始我拍脑袋把速度阈值设成15、加速度阈值设成2.5,结果头两天安静得可怕,一个提醒都没有。后来我把历史数据里的速度和加速度全算出来,看分布,发现大部分词条即使在上榜时,速度也就稳定在3到10之间,极少超过15。把速度阈值降到8、加速度阈值降到1.5之后,提醒数量才恢复到每天个位数的合理水平。
我把调参步骤固化成了一套流程:先收集至少两周的检测指标快照,再画分位数曲线,最后按“希望每天收到几个提醒”倒推阈值。下表是我当前跑得比较稳的参数,不同来源、不同业务目标会有差异,但作为起点足够参考:
| 参数 | 默认值 | 调参方向 |
|---|---|---|
| 窗口大小 | 900秒 | 数据源更新越慢,窗口越长 |
| 最少样本数 | 3 | 防止冷启动误报 |
| 速度阈值 | 8 | 想多收到提醒就调低 |
| 加速度阈值 | 1.5 | 想过滤缓涨词条就调高 |
| 冷却时间 | 600秒 | 提醒太吵就加大 |
6.2 下一步:相似话题聚类与周报摘要
buzz目前的功能基本达到我当初的预期。下一步有两个方向值得投入:一是用向量Embedding做语义级的话题聚类,让“智能眼镜”“AI眼镜”“AR眼镜”这类概念能自动归并,而不是靠手工维护别名表;二是按天生成一份“buzz摘要”,把当天触发过异动的词条、二次爆发的词条、持续时间最长的词条汇总成一段简报,直接推送内部群。前者解决的是别名表维护越来越吃力的问题,后者解决的是“提醒太多没空看”的最后一公里。
从目前跑了大半年的状态来看,这个项目最大的价值不是少写了多少行代码,而是把团队对热点的响应方式从“人肉巡逻”变成了“定点狙击”。我个人的体会是:这类小工具,最忌一上来就想着全面分析、深度学习、全网监控。先把一个具体、高频、痛感强烈的需求做透,等真实数据积累起来,再去想加料的事,一切自然会水到渠成。