做AI Agent开发这段时间,我遇到一个特别头疼的问题:很多实时信息,传统搜索引擎根本搜不到,或者说搜到的已经晚了。竞品凌晨更新了官网,舆情平台下午才有结果,等到普通搜索能查到时,黄金操作窗口基本已经没了。这个痛点催生了一批“面向时效性的信息抓取工具”,Last30Days-skill就是其中一个很典型的开源项目。它以“技能包”的形式把最近30天的信息发现、抓取、评分和输出串成一条完整流水线,核心目标就是跨越传统搜索引擎的信息壁垒,把那些尚未被主流索引覆盖的新鲜内容提前拉到你的桌面上。我花了两周时间啃源码,又跑了几个真实case,这篇文章把它的架构设计、信号评分体系和实战用法一次说透,适合AI Agent开发者、舆情监测工程师,以及所有被“搜索时效性”折磨过的人。
1. 项目概览与设计初衷
1.1 为什么偏偏是“最近30天”?
传统搜索引擎的收录逻辑决定了它对“新内容”天然不友好。一个网页从发布到被爬虫抓取、清洗、入库、排序,中间往往隔着几个小时甚至好几天;就算进入了索引,新页面在PageRank之类的长期权威信号上也几乎拿不到什么权重。所以你会发现,想查“上周发生的事”,搜索结果里翻来覆去都是老文章、综述页和SEO农场。
信息传播本身有一条很清晰的周期曲线:事件发生后的几分钟到几小时内,只有原始信源在更新,比如官网新闻、社交平台账号、Telegram频道、RSS源;几个小时后,垂直媒体和论坛开始讨论;再过一两天,部分新闻聚合站会跟进;而搜索引擎开始给出像样的结果,通常是24小时到72小时之后,长尾页面可能要更久。这条曲线决定了“最近30天”正好是传统搜索最薄弱的窗口,也是信息不对称最严重的阶段。
Last30Days-skill把这个窗口做成了一等公民。它不试图替代搜索引擎,而是专注解决一个问题:在信息刚出现、尚未被大范围索引的时候,把这些信号尽可能完整地捞回来。30天不是一个拍脑袋的数字,它是“足够覆盖绝大多数业务场景”的时间边界。竞品动态监控、开源社区新技术发布、漏洞情报跟踪、行业政策变化,这些场景的信息有效期往往就在几天到几周之间,抓30天数据再按评分截断,既不会漏掉长尾重要事件,也不会被无限期的历史信息干扰。
1.2 Last30Days-skill 到底解决了什么问题?
说直白一点,它解决的是“传统搜索看不见新东西”的问题,但手段不是去跟搜索引擎抢排序,而是换一条路:绕过索引层,直接去信源端捞数据。这个项目以技能包的形式存在,意味着它可以很方便地接入到AI Agent的工作流里,让大语言模型不再只会调用普通搜索API,而是拥有一个专门的“时效性雷达”。
我试用下来的感受是,它和传统搜索的差异非常明显。普通搜索返回的是按权威度排序的全网混合结果,而Last30Days-skill返回的是按时间衰减和信号强度排序的近期信息,字段更结构化,包括标题、URL、发布时间、来源类型、互动数据和综合评分。对Agent来说,这种结构化输出比一段搜索引擎结果摘要更容易做推理和筛选。
这个项目还特别适合两类人。第一类是Agent开发者,你不需要自己写一堆RSS解析器和去重逻辑,直接把它作为工具注册进去就能用;第二类是舆情和情报分析工程师,你需要的是“新鲜度优先”的信息流,而不是“权威度优先”的搜索结果。当然,如果你想用最少的代码搭一个自己的新消息提醒系统,它同样是个很好的参考模板。
2. 项目整体架构拆解
2.1 五层架构:从信号源到输出
Last30Days-skill的整体架构并不复杂,但分层做得非常清楚。整个系统从下往上分成调度层、采集层、解析层、评分引擎和存储输出层,每层之间通过定义好的数据结构通信,互相不耦合。我最初看代码时有点意外,这种小型技能项目通常喜欢在一个文件里把所有逻辑写完,但这个项目明显是按“可维护”的标准设计的。
各层职责和关键设计如下表:
| 架构层 | 主要职责 | 关键组件 | 设计要点 |
|---|---|---|---|
| 调度层 | 定时触发抓取任务,控制频控与去重 | 任务队列、时间轮调度器 | 支持按信源设置独立抓取频率,避免高优源被低优源拖累 |
| 采集层 | 从不同信源获取原始数据 | Source Adapter、异步HTTP客户端 | 每种信源一个适配器,RSS/API/页面抓取统一出口 |
| 解析层 | 抽取正文、标准化时间、识别实体 | 正文提取器、实体识别模块 | 时区统一转为UTC,页面型信源走可读性提取 |
| 评分引擎 | 计算时效分、权威分、互动分、内容质量分、相关度 | 信号评分器、归一化模块 | 权重可配置,所有信号先归一化再加权 |
| 存储与输出层 | 保存结果并提供查询接口 | SQLite/Redis、CLI、Webhook | 默认本地零配置,也支持通过Redis做增量状态同步 |
解析层是最容易被低估的一块。很多做类似项目的人,把大量时间花在配置信源上,却忽略了页面结构会变、时区会坑人、时区解析错误会导致评分直接失效。Last30Days-skill在解析层做了一个很聪明的决策:凡是支持RSS或官方API的信源,优先走结构化数据;只有结构化数据缺失时,才回退到HTML正文提取。这样既保证了解析效率,又顺便降低了反爬风险。
2.2 数据流向与关键机制
整个系统的数据流是一条清晰的单向管道,理解它你就理解了项目的核心设计。调度器在每个时间片触发任务后,采集层对应适配器开始抓取原始数据,得到HTML或JSON后交给解析层;解析层把数据规范化为统一的Document结构,并抽取正文和发布时间;随后进入去重模块,再交给评分引擎打分,最后写入存储,并触发Webhook通知。
去重是这个项目里很值得研究的一个点。它没有用简单的URL去重,而是采用“URL指纹 + 内容SimHash + 时间窗”三重判断。为什么不用精确哈希?因为同一件事在不同信源上有不同表述,标题可能改了,正文可能换了一段,但核心句子还是相似的。SimHash能把这种近似重复识别出来,配合URL指纹可以快速排除大量转载内容。我给一个生活化类比:URL指纹相当于认身份证号,SimHash相当于看脸,两者结合才能拦住“换了身份证号但脸没变”的搬运号。
存储层在这个项目里被刻意做轻了。它不依赖Elasticsearch这类重型搜索引擎,默认就是SQLite,按时间倒排,关键词过滤用简单索引。这个选择背后的逻辑很现实:30天窗口的数据量本质上很小,如果每个信源每小时的更新量只有几十条,一天下来也就几万条,SQLite完全可以胜任。引入太重的外部组件反而会让部署门槛陡增,违背了“skill包随手就能跑起来”的初衷。
2.3 为什么“轻松跑起来”如此重要?
我见过不少开源项目,功能很强,但环境依赖能把人劝退。Last30Days-skill把存储和调度都做成“可以零配置启动”的设计,是它能在实战中快速落地的重要原因。项目默认SQLite,调度不用Celery,而是用进程内的时间轮调度器。这意味着你在一台普通服务器上装好Python依赖,就能立刻跑起来做验证,而不需要先搭一套分布式系统。
当然,设计者们也留了后路。如果你有多个实例同时采集,或者需要共享去重状态,可以切换到Redis后端,把调度状态和增量状态集中管理。这种“先用简单方案跑通,再按需升级”的思路,我特别认同。很多时候开发者容易一上来就想着高可用、分布式,结果复杂度和收益完全不成比例。先用单体把流程跑通,观察实际数据量,再决定要不要加Redis,这才是更务实的路线。
3. 信号评分体系:让“新鲜”变得可度量
3.1 五个核心评分维度拆解
信号评分体系是整个项目最有含金量的部分。它解决一个核心问题:同一时间窗口内抓到的信息可能有很多条,到底哪一条更值得被优先看到?如果只按时间排序,那些互动高、来源可靠但稍早一点的信息会被完全淹没;如果只按来源排序,又会让权威网站刷屏。项目用五个维度加权,试图在“新”“准”“热”“优”之间找一个平衡点。
默认配置下,五个维度大致如下:
| 评分维度 | 默认权重范围 | 信号来源 | 说明 |
|---|---|---|---|
| 时效性 | 0.30 - 0.40 | 发布时间与抓取时间的差值 | 时间越短分越高,采用指数衰减 |
| 来源可信度 | 0.15 - 0.20 | 信源类型、域名历史、人工标注 | 官方网站和一手信源基础分高 |
| 互动热度 | 0.15 - 0.25 | 点赞、转发、评论、浏览计数 | 做对数归一化,防止极端值垄断 |
| 内容质量 | 0.10 - 0.15 | 正文长度、结构化数据、标题规范 | 压制低质拼接和标题党 |
| 相关性 | 0.10 - 0.20 | 与查询关键词/兴趣画布的语义匹配度 | 默认用BM25,可扩展embedding |
这个体系最聪明的地方,是它没有把“时效性”简单当作一个0/1特征,而是建模成连续衰减函数。我在很多类似项目里见过一种错误的做法:只筛选最近30天的数据,然后按时间倒序排序,完全不考虑内容质量。结果就是每天抓到一堆垃圾。Last30Days-skill的高分条目,通常不是最新的一条,而是“足够新且足够有信号”的一条。
3.2 衰减模型与归一化:为什么是指数衰减?
项目的时效性计算公式是:
fresh_score = base_score * exp(-lambda * age_hours)其中base_score是满分100,age_hours是条目发布后经过的小时数,lambda是衰减系数。默认场景下,lambda会根据内容领域自动调整:事件型、突发型内容lambda取0.02左右,意思是经过35小时时效分跌到一半;常规技术内容lambda取0.005,意味着几天前的优质文章依然能保持较高分数。
为什么选指数衰减而不是线性衰减?因为信息价值的下降并不是匀速的。一个突发新闻发出后,前几个小时价值极高,之后快速贬值,一两周后基本只剩考古意义;而一篇教程的价值下降则慢得多,它可能在一个月内仍被持续阅读。指数衰减正好能拟合这种“先陡后缓”的曲线。线性衰减会让“昨天的事件”和“十天前的事件”差距不够大,产生误判。
互动热度归一化也值得一提。项目没有直接用原始点赞数参与加权,而是用log(1+x) / log(1+max_n) * 100,把互动量压在0到100区间。为什么要取对数?因为社交网络的数据极度偏态:绝大多数内容互动量在个位数,少数爆款是几万甚至几十万。如果用线性归一化,普通内容的互动分会被压到1以内,等于这个维度废掉了;取对数之后,0到2000之间的差异仍然清晰,2000到20万的差异被压缩,这更符合人对热度的感知。
3.3 一次完整的评分计算演示
只看公式还是不够直观,我拿一个实际case来演算。假设查询主题是“AI Agent”,抓到一个条目:来自某科技媒体,发布时间是12小时前,来源可信度85分,转发35次,评论8条,正文长度800字,语义相关度0.7。喂给评分引擎,默认权重取时效0.35、来源0.20、互动0.20、内容质量0.15、相关性0.10。
时效分先算:100 * exp(-0.02 * 12) = 78.7,加权后贡献27.5分。来源分85直接乘0.20,贡献17分。互动数据合并为43次,假设当前时间窗内最高互动是200次,那么互动分是log(44)/log(201) * 100 ≈ 62.9,加权后贡献12.6分。内容质量分是78分,乘0.15贡献11.7分。相关度70分,乘0.10贡献7分。最终综合分是76.8分。
这个76.8分看起来是“还不错”的水平,但光看总分还不够,项目会把各维度分项也输出。为什么?因为Agent消费这些数据时,不同业务关注点不同:做舆情预警的,更看重时效性和互动热度的组合;做技术调研的,更看重来源可信度和内容质量。把分项全部暴露出来,比只给一个综合分要灵活得多。
3.4 评分反馈回路与调参技巧
评分体系不是一锤子买卖。项目内置了一条简单的反馈回路:如果用户对某条结果做了“收藏”或“点击查看原文”,系统会记为正样本,并小幅上调该来源和该关键词相关权重;如果“忽略”,则不做调整或微降。这种线上反馈机制,让评分引擎能在运行一段时间后逐渐贴近你的真实偏好。
我实际调参时发现了一个很重要的技巧:不要急着改权重,先看数据。把一周跑出来的结果导出成表格,人工标记哪些条目是真正有价值的,然后反推当前权重错在哪里。如果高分条目集中在“高转发但低质量”的爆款上,那就调低互动热度权重,并在内容质量维度增加标题夸张度惩罚;如果权威站的老文章排名一直下不来,那就加大时效性权重。权重调整的本质,不是追求一个绝对正确的公式,而是让排序结果符合你所在领域的业务直觉。
4. 实战使用指南:快速跑通Last30Days-skill
4.1 环境准备与安装
我推荐在干净的Python 3.10以上环境里运行,最好先用venv建一个虚拟环境,避免和系统依赖冲突。项目仓库clone下来后,安装依赖非常直接,只需要执行pip install -r requirements.txt,它会自动装好异步HTTP客户端、RSS解析库、HTML正文提取库和YAML配置解析库。
初次启动建议不要急着连Redis。先用SQLite模式跑起来,因为配置最简单,不依赖外部服务,也方便随时看数据。项目默认会生成一个data/目录,里面存放SQLite数据库和抓取状态文件。等确认整个流程能正常工作、数据量也开始增长之后,再考虑把状态存储切到Redis,实现多实例间的去重和调度协调。
环境上还有一个要注意的点:抓取境外信源时,服务器的网络位置会直接影响抓取成功率和延迟。这不是项目本身的问题,而是网络环境问题。我的做法是在部署节点选择上尽量贴近目标信源所在区域,同时把单个信源的请求频率控制在几十秒一次,这样对目标站点比较友好,抓取稳定性也会高很多。合规方面,只采集公开信息、遵守目标站点robots声明和服务条款,是底线。
4.2 核心配置:一份可以直接抄的YAML
项目的核心配置集中在config.yml。我把自己用得比较顺的一份配置贴出来,并解释每一块的含义:
time_window: days: 30 sources: - type: rss name: techcrunch url: https://techcrunch.com/feed/ enabled: true - type: hackernews name: hn max_items: 50 enabled: true - type: github_releases name: gh_releases repos: - openai/evals enabled: true scoring: timeout_decay: 0.01 weights: freshness: 0.35 authority: 0.20 engagement: 0.20 quality: 0.15 relevance: 0.10 storage: backend: sqlite path: ./data/last30.db state_file: ./data/state.json notify: webhook_url: "" min_score: 70time_window.days是主窗口,项目所有评分和去重都围绕这个窗口展开;sources里每个信源都有独立的启用开关,方便临时下线故障源;scoring.timeout_decay对应前面公式里的lambda;notify.min_score默认设为70,意思是只有综合分超过70的条目才会通过Webhook推出去,避免Agent被低质量信息刷屏。
这里有一个容易踩坑的点:time_window.days不是越大越好。窗口越大,去重集合越大,评分时的计算量也越大;而且老条目累积多了,会影响“新鲜内容”的占比。我自己的经验,除非要做趋势分析,否则日常监控保持在15到30天就够。真需要长周期数据,可以另外做离线聚合,而不是让实时抓取的窗口无限膨胀。
4.3 命令行与Agent接入
项目提供了一条简单的CLI命令来触发一次手动抓取:
last30days run --query "AI Agent" --hours 72 --format json --min-score 60这个命令的含义是:抓取最近72小时内与“AI Agent”相关的信息,只输出综合分超过60分的JSON结果。手动触发在调试信源时非常有用,你可以随时跑一次,看看新配的源能不能正常解析,评分是否合理,而不需要等定时调度。
输出结果是标准的JSON数组,每条记录包含title、url、published_at、source、score,以及各个维度的分项分数。字段设计得相对精简,目的是方便Agent直接消费。比如你可以把系统提示词写成“当用户需要了解AI Agent的最新动态时,优先调用last30days工具,并基于返回的score字段判断信息可信度”。
接入AI Agent的方式也很灵活。项目支持把抓取结果通过Webhook推送到你的Agent事件流里,也支持作为类似MCP协议的工具被动态调用。我的做法是注册成功一个工具函数,LLM在回答时效性问题时,自动触发一次实时抓取,再把结果和原始URL一起交给LLM做摘要。这样既保证了信息新鲜,又让LLM可以引用来源,减少幻觉。
4.4 自定义信号源:半小时接入一个新的数据源
项目最让我满意的是扩展信号的流程非常规范。每个数据源都继承同一个Adapter基类,你只需要实现两个方法:一个是拉取原始数据,另一个是把原始数据转换成统一的Document结构。
class MySourceAdapter(SourceAdapter): source_type = "my_source" async def fetch(self, params): response = await http_client.get(self.config["url"]) return response.json() def parse(self, raw): return Document( title=raw["title"], url=raw["link"], published_at=normalize_time(raw["published"]), content=raw["summary"], metadata={"source": "my_source"} )就这么简单。最难的部分反而是理解目标信源的数据结构。比如有的RSS源把时间写成RFC822格式,有的JSON API返回的是Unix时间戳,还有的HTML页面需要你先观察一下正文节点再写提取规则。只要把原始数据看明白、把时间标准化处理好,新适配器半小时内就能跑通。我在接入GitHub Releases时,整个过程包括写适配器、跑测试、调解析,约半小时;接入一个结构比较奇葩的论坛源,第一版用了两小时,主要时间花在处理类Unix时间戳的时区偏移上。
5. 踩坑记录与排查技巧
5.1 搜不到“最近30天”内容的三种原因
我刚开始用的时候,最困惑的问题是:明明信源里有内容,为什么项目输出空白?后来排查下来,原因往往不是抓取失败,而是三个隐藏问题。
第一是时区处理错误。很多信源返回的时间不是UTC,而是本地时间。如果适配器没有做时区标准化,项目会把你本地的“晚上8点”当成UTC的“晚上8点”,然后往前推8小时,导致发布时间被算成未来时间,于是被时效性过滤掉。排查办法很简单:随便找一条原始数据,对照它在信源页面上显示的时间和数据落库的时间,差8小时就是时区没处理对。
第二是去重模块误伤。同一个事件被不同信源转载,SimHash相似度超过阈值后会被当作重复内容丢弃。这在大多数情况下是好事,但如果主信源被屏蔽或者内容被删,前一个版本已经入库,后一个质量更高的版本就没机会露脸。我的做法是把“详情URL”也做为输出候选,即使内容重复,也保留最新抓到的URL。
第三是评分阈值卡得太高。如果min_score设成80,但当前信源的互动数据普遍很低,几乎所有条目都达不到阈值。这时候要先观察一下原始分项,看看是哪个维度在拖后腿。如果所有源都低分,大概率是时效衰减系数设得太陡,或者互动归一化的分母(当前窗口最大互动数)被某个爆款抬得太高。
5.2 信源限流、页面结构调整与稳定性
反爬和限流是采集类项目躲不开的问题。项目自带限速队列和随机User-Agent,但当你把多个信源放在同一个进程里跑的时候,某些网站还是会在短时间密集请求后返回403或者验证码页。我遇到过一次HackerNews接口被限流,原因是调度间隔设成了1分钟,而对方允许的配额是每分钟30次请求,虽然没超过,但因为和其他源共享IP,还是触发了风控。
处理这类问题,我的经验是“优先生态友好型数据源”。优先配RSS、官方API、GitHub Releases、arXiv这类提供结构化接口的信源,它们的限流策略清晰,解析也稳定。对必须抓HTML的信源,一是把请求间隔调到5秒以上,二是做好指数退避重试,三是对每个信源做独立健康检查。如果连续三次抓取失败,就自动暂停该源并告警,避免整个任务卡死。
还要预防页面结构调整。HTML正文提取依赖CSS选择器或XPath,站点一旦改版,选择器可能一夜之间失效。目前项目的处理方式是:如果正文提取结果为空,会自动降级到通用可读性算法;如果通用算法也拿不到,就把整段HTML截断后作为标题备注输出。这样至少不会漏掉URL,只是内容质量分下降,等到你更新解析规则后再次抓取就能恢复。
5.3 评分总是不准?先调数据,再调权重
评分不准是使用过程中最常见的抱怨,但大多数时候问题不在权重公式,而在输入数据不干净。比如某个源把发布时间错填成“文章最后一次修改时间”,导致一篇三年前的老文章每次被编辑都会获得“新时间”,时效分虚高。这个坑特别隐蔽,我在抓某个开源项目的Release时遇到过,版本历史页面显示的时间其实是上架时间,不是当前版本的发布时间,最后只能靠“对同一URL的历史评分做差分”才发现异常。
如果确认数据没问题,再考虑调权重。爆款标题党分数偏高时,调低互动热度权重,同时增加内容质量维度的“标题夸张度”惩罚项;权威源旧文章一直压制新信息时,把时效性权重拉到0.4左右,把来源可信度上限从100调到95。每次只调一个维度,观察一天数据,不要同时改三个权重,不然你根本无法判断是哪个改动起的作用。
6. 扩展思路与个人经验
6.1 从30天窗口扩展到长周期趋势
既然评分体系已经把时间衰减做成连续函数,把窗口从30天扩展到90天甚至更长,其实只需要调整配置。但需要注意,长周期下“互动热度”的意义会变得不一样:一个发布后一小时互动500的内容和一个发布后一周互动5000的内容,价值完全不同。项目目前的处理办法是,在保存评分时同时记录“抓取时的分项分数”,而不是只存一个最终分。这样你就能按历史分回溯,看一条内容在不同时期的新鲜度变化,用来做趋势分析。
我自己做过一个试验:把某个技术关键词的评分记录存了90天,然后按月聚合每天的最高分条目,最后得到了一条“这个话题什么时候开始变热”的曲线,跟人工翻阅的结果基本吻合。这说明评分体系不仅能用来做实时筛选,也能用来反推信息的时间线。
6.2 我自己的几条部署体会
最后分享几个纯经验层面的东西。第一,不要一上来就接几十个信源。先用三到五个高质量信源跑一周,把解析、评分、通知流程全部走顺,再慢慢加源。源越多,出问题的概率越高,排障成本也越高。第二,SQLite模式真的够用。我在日均抓取两万条左右的情况下,SQLite查询和写入都没有成为瓶颈,真正需要优化的反而是抓取调度的频控逻辑。第三,Webhook通知比轮询好用得多。给Agent加一个“有新消息才唤醒”的机制,比让它每隔几分钟主动拉一次要节省大量token和时间。
这类工具最终的价值,不在于抓了多少条数据,而在于筛出了几条真正值得看的信息。评分体系的本质是在“新”和“有用”之间反复折中,而这个折中只能靠真实数据来校正,没有任何一个默认配置能适配所有场景。花几天时间把日志、评分分项、人工判断关联起来看,远比临时改权重更有效。这大概是这个项目给我最大的启发。