每天早上醒来,几十个 RSS 未读小红点、几个信息流 App 的推送、微信群里的转帖,全部铺在面前。真正值得我花时间读的,常常不超过十条。这个痛点让我下了个决定:让 Jev 替我刷信息流。Jev 是我一直在用的一个本地化智能代理模型——它可以被命令行或 API 调用,擅长归纳、抽取和生成结构化输出。我让它每天自动拉取科技、开源社区和行业商业三个方向的信息源,做去重、压缩、排序,早上八点前生成一份三源新闻日报。这篇博客就是把整套系统的实现过程完整拆给你们:从信息源怎么选、脚本怎么写、prompt 怎么调,到踩过的坑和修复方案,全部写出来。适合每天要读大量信息、但又不想被信息流绑架的开发者、产品经理和技术博主参考。
1. 整体设计与思路拆解
1.1 为什么是“三源”而不是全量抓取
信息源不是越多越好,而是越结构化越好。刚开始我也试过一股脑接十几个源,结果日报比信息流本身还长,失去了“替你刷”的意义。后来压到三个方向,每个方向一个主源加一个备用源,总共控制在六到八个 URL 以内。
三源的好处有三个:第一,领域互补。科技类解决“发生了什么新技术”,开源社区解决“有什么可以动手玩的东西”,行业商业解决“这些技术哪些真正落地赚钱了”。三个方向交叉起来,恰好能覆盖我日常需要的信息结构。第二,去重压力小。来源之间有重叠但不多,重复率大概在 10% 到 20%,靠标题归一化加正文指纹就能解决大部分。第三,调度简单。每轮抓取请求数量少,被限流的概率低,重试策略也更好写。
如果你是自己做日报,我建议先从三个源起步,跑两周看效果,再决定要不要加源。很多失败的信息聚合项目,死因不是缺源,而是源太多导致摘要质量失控。
1.2 为什么让 Jev 参与,而不是纯脚本抓取
纯脚本能做的事情很明确:抓 RSS、拉文章、提取正文,但做不了排序和摘要。要么把所有标题堆在一起,读者还是得自己扫;要么写一堆关键词规则,遇到新话题立刻失效。
Jev 在这场流程里扮演的是“编辑”角色。它需要做到四件事:把同一条新闻在不同源里的重复报道合并;把标题里的营销话术剥掉,还原成一句事实描述;按我的兴趣权重对条目打分排序;把每条新闻压缩成 50 字以内的导读,并给出“为什么值得看”的判断。
这类任务不是简单字符串处理,而是需要对语义有一定理解。用 Jev 或同类模型来做,相当于在抓取管线和最终阅读之间加了一个人工编辑,只是这个编辑跑在本地,不会困,不会漏,也不会被公关稿带节奏。现在很多人在讲 agent 工作流,三源日报其实就是一次很典型的 agent 化改造:让模型负责判断,让代码负责搬运。
1.3 系统架构与执行链路
整体链路非常简单,所有组件都可以跑在一台低配 Linux 服务器上,甚至是树莓派:
定时触发(cron / workflow) ↓ 信息源抓取(RSS + JSON API + HTML 解析) ↓ 正文提取与清洗(去标签、去导航残留) ↓ 去重与归一化(标题 hash + 语义指纹) ↓ Jev 汇总与摘要(结构化输出 JSON) ↓ 模板渲染 → Markdown 日报 → 投递这个架构没有引入消息队列,也没有数据库,刚开始完全没必要。全部用 Python 脚本串起来,状态只保存在本地 JSON 文件里。等将来某个环节成为瓶颈,再说拆分的事。过度设计是个人项目最常见的失败原因,能跑起来比跑得优雅重要得多。
2. 核心细节解析与实操要点
2.1 信息源选择的三个标准
选信息源时我会用三个标准卡,缺一个就放弃:有稳定的非 Web 端接口优先、内容授权允许聚合、更新频率真空期短。
稳定接口这条最重要。RSS/Atom 是最好的选择,其次是官方 JSON API,最后才是写正则扒 HTML。RSS 不存在网页改版导致抓取失败的痛苦,而且天然带时间戳、标题、链接、作者这些结构化字段。我在科技方向选的 Hacker News 的 Algolia API 和某技术社区 RSS,开源方向选的是 GitHub Trending 的公开接口加一个中文开源周报的 RSS,行业商业方向用的是两个科技媒体的 RSS。前两类 JSON 和 RSS 都极其稳定,HTML 解析那条线我放在备用兜底位置。
内容授权也需要注意。有些网站条款里明确禁止聚合转载全文,所以日报里只放链接和导读,不搬运原文。我在代码里强制截断正文到 800 字符以内,只让 Jev 基于这部分生成摘要,既控制了 token 消耗,也规避了版权风险。自用项目没人在意,但你要是打算发到公网,这个习惯要养成。
更新频率我踩过坑。有些周报型源一周才更新一次,放进日更日报里纯属浪费;反过来,有些 24 小时滚动更新的源,抓三次就是三次重复内容。最好选日更到半日更的源,再配合后文讲的增量抓取策略,才不会让日报看起来像“旧闻合集”。
2.2 抓取层的规范化处理
三源系统里的“源”类型不同,进来的时候字段格式参差不齐。规范化层的作用就是把它们全部转换成同一种结构:
{ "source": "hn", "title": "...", "url": "...", "published_at": "2025-05-18T08:00:00Z", "summary": "...", "content": "清洗后的正文片段" }这一步我用统一的 dataclass 来做。RSS 用 feedparser 解析,JSON API 直接按字段映射,HTML 的放到底层函数里用 BeautifulSoup 把正文区域抠出来,去掉 script、style、nav、footer 这些噪音。清洗函数是所有环节里 bug 率最高的,因为网站在改版。我的经验是把清洗规则写保守一点:宁可多留一些无关文本,也不要删掉正文里的有效段落。Jev 在摘要阶段会自行忽略无关内容,但如果正文被截断了,模型再怎么聪明也补不回来。
2.3 去重:两层策略组合
日报如果出现两条一模一样的新闻,这份日报就是失败的。我用两层去重,效果不错。
第一层是硬去重,基于 URL 和标题归一化。URL 去掉 UTM 参数、保留协议前路径,标题做小写、去标点、去多余空格,然后计算哈希。这层能干掉同一个链接被不同源转载 90% 的情况。
第二层是语义去重,用来处理“不同链接、同一事件”的场景,比如一条新闻在一个源里是原创报道,在另一个源里是转载改写,标题和 URL 都变了,但讲的是同一个发布会。这层我用 Jev 来做:把候选标题组丢给它,让它返回哪些条目是在描述同一事件,只保留原始度最高的那个。
每次执行时我把这两层的结果存成一个seen.json,下次抓取增量传入。所谓增量,就是只对“没见过的条目”做摘要调用,既能省 token,又能避免日报连续几天内容雷同。
2.4 让 Jev 输出结构化摘要的 prompt 设计
不喂 prompt 就让模型总结,得到的往往是散文体,没法稳定解析。我在第一天就被教育了。后来我把 Jev 的输出格式强制为 JSON:
你是我的新闻编辑。给你一批今天抓取的文章,请完成以下工作: 1. 删除重复报道同一事件的条目; 2. 对保留条目做 50 字以内的导语,导语必须陈述事实,禁止使用标题党表达; 3. 按“与我所在行业相关度”从高到低排序,并在 reason 字段里给出排序理由; 4. 输出严格 JSON 数组,每个元素包含 title、url、digest、reason 四个字段。 不允许输出 JSON 以外的任何解释。关键技巧有三个:第一,要求 JSON 中建一个reason字段,这会强迫模型给出判断依据,大幅降低随口编排的概率。第二,在 prompt 里写明“陈述事实,禁止标题党”,否则日报里会出现“震惊!”“重磅!”这种被营销稿带跑的表达。第三,一次只给它一天的增量内容,而不是整个历史库,这样输出更聚焦,token 也省得多。
3. 实操过程与核心环节实现
3.1 环境准备与依赖清单
我用一台 2 核 4G 的旧服务器,跑 Ubuntu 22.04。Python 版本 3.11。依赖很少,核心就是这几个:
pip install feedparser httpx beautifulsoup4 python-dateutil jinja2Jev 的调用方式我走的是它的本地 API 接口,兼容 OpenAI 格式,端口默认监听在 127.0.0.1。如果你用的是别家的模型,只要支持 function calling 或者单纯的 JSON 输出,这套代码基本不用改,替换 base_url 和 model 名就行。
注意:如果你打算把日报定时推送,要确认服务器时区设置正确。我一开始没注意,cron 按 UTC 跑,每天日报都是在下午三点“准时”到达,完全错过了早上读新闻的时间窗口。
3.2 抓取与清洗实现
RSS/API 抓取主流程大概是这个样子,我简写关键部分:
import feedparser import httpx def fetch_hn(): url = "https://hn.algolia.com/api/v1/search_by_date?tags=story&hitsPerPage=30" r = httpx.get(url, timeout=20) data = r.json() items = [] for hit in data["hits"]: items.append({ "source": "hn", "title": hit["title"], "url": hit["url"] or f"https://news.ycombinator.com/item?id={hit['objectID']}", "published_at": hit["created_at"], "summary": hit.get("story_text", "")[:500], }) return items def fetch_rss(feed_url, source_name): parsed = feedparser.parse(feed_url) items = [] for entry in parsed.entries: items.append({ "source": source_name, "title": entry.title, "url": entry.link, "published_at": entry.get("published", entry.get("updated", "")), "summary": entry.get("summary", "")[:500], }) return items这里有个坑:feedparser.parse默认不会验证 SSL 证书是否完整,遇到某些自签名证书或跳转源,会出现“解析到空列表但请求其实失败了”的静默问题。所以我要求所有入口返回前用httpx先请求一次头信息确认状态码,再交给 feedparser 解析。代价是多一次网络请求,但换来的是出错时可观测性。
清洗函数我用 BeautifulSoup 抓正文主区域。为了简单,我直接选取<article>标签,取不到就退回<body>去噪音。清洗后只保留前 800 字符,这既是给 Jev 省 token,也是避免转载版权踩线。
3.3 调用 Jev 生成日报的核心代码
Jev 摘要这一步是系统的灵魂。我封装了一个summarize_items函数:
import openai client = openai.OpenAI( base_url="http://127.0.0.1:11434/v1", # 本地兼容层地址 api_key="local" ) SYSTEM_PROMPT = """ 你是我的新闻编辑。给你一批今天抓取的文章,请完成以下工作: 1. 删除重复报道同一事件的条目; 2. 对保留条目做 50 字以内的导语,导语必须陈述事实,禁止使用标题党表达; 3. 按“与软件工程师日常决策相关度”从高到低排序,并在 reason 字段里给出排序理由; 4. 输出严格 JSON 数组,每个元素包含 title、url、digest、reason 四个字段。 不允许输出 JSON 以外的任何解释。 """ def summarize_with_jev(items, source): resp = client.chat.completions.create( model="jev", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": json.dumps(items, ensure_ascii=False)} ], temperature=0.3, response_format={"type": "json_object"} ) content = resp.choices[0].message.content return json.loads(content)temperature我固定为 0.3。太高会有发挥,太低会让导读变得干瘪,0.3 是平衡点。另外在批量抓到的条目超过 40 条时,我会把它们先按发布时间切成 20 条一组分别送进 Jev,再把多组结果合并做二次排序。这样做是为了避免单次请求超出模型上下文窗口,也避免条目太多时模型“注意力被稀释”,前几条写得好、后几条草草收场。
3.4 定时任务与投递配置
抓取脚本和摘要脚本写完以后,用 cron 做一个时间链:
0 7 * * * cd /opt/news-daily && python fetch_all.py 15 7 * * * cd /opt/news-daily && python summarize.py 30 7 * * * cd /opt/news-daily && python render_report.py三个脚本之间用 JSON 文件传递状态:fetch_all.py写入raw_items.json,summarize.py读取后写入report.json,render_report.py把 report 渲染成 Markdown。为什么要拆成三个任务而不是一个?我试过合成一个,结果是任何一步报错都会让整个链路静默死掉,不好排查。拆开后,我可以先看有没有抓到,再看模型有没有跑完,最后看渲染有没有出问题。
投递我最早用邮件,后来改成了推送到一个私有 Telegram Channel。投递脚本核心就调用一次消息 API,把 Markdown 转成 HTML 文本发送。如果你不用 Telegram,写进本地文件然后用坚果云同步也完全可行。重点是:日报的落点要离你的阅读入口近,落得越远,你越容易不看。
3.5 一份真实的输出样例
某次运行的摘录如下:
三源新闻日报 · 2025-05-18 ================================ 1. 某开源数据库发布 5.0 版本,内置向量检索能力 导读:官方称查询性能提升 2 倍,支持混合检索。 排序理由:与你日常技术选型直接相关,建议纳入评估。 2. 某代码托管平台调整私有仓库免费额度 导读:免费额度从 500 降至 200,现有用户不受影响。 排序理由:影响团队协作成本,值得提前关注。 3. 某硬件开发板推出离线语音识别套件 导读:基于 ESP32-S3 与 ES8311,官方 SDK 已支持本地唤醒。 排序理由:与你最近关注的嵌入式语音方向吻合。可以看出来,Jev 的排序并不是简单地按时间或按热度,而是按我写在 system prompt 里的兴趣画像来排序。这也是这套系统“替你刷信息流”的核心价值:只放大值得看的东西,其他的静默归档。
4. 常见问题与排查技巧实录
4.1 信息源反爬与超时
RSS 一般没有反爬,但 HTML 解析源很容易遇到 403 或者 503。我的处理方式是给所有 HTTP 请求加统一的 UA 头和 Referer,并且对每个源设置独立的超时时间。日报类的超时设为 10 秒,API 类的 20 秒。再给每个源配置连续失败三次就自动停用该源并告警,而不是让整个作业崩溃。
遇到过最诡异的情况是某个源只在凌晨四点到六点之间返回 500,过完六点自动恢复。排查了很久才发现是对方服务器定时任务把数据库锁住了。这种问题无解,只能靠“失败重试 + 下次周期再抓”容忍。
4.2 摘要输出不是合法 JSON
这是调用 Jev 最常出的事故。节省思路有两个:一是在请求里加response_format={"type": "json_object"},这能保证顶层是一个 JSON 对象;二是写一个修复函数,把常见的模型错误——比如多余的前后引号、字符串里的反斜杠没转义——用正则和 json5 库兜底解析。我还把 parse 失败后的原始输出写进err_output.log,方便复盘。
排查的经验是:模型偶尔会因为输入文本里包含孤立的反引号或特殊 Unicode 导致输出 JSON 损坏。这时候我会在传给模型前先把文本里的反引号替换成普通引号,效果立竿见影。
4.3 摘要出现事实偏差
模型总结不能保证 100% 准确,尤其是当输入正文被截成 800 字符时。出现过一次:某公司发布了 A 产品,Jev 在导语里写成“发布了 A 产品的升级版”,其实原文没说这是升级版。这是模型脑补。
我采取的缓解手段是 prompt 中明确加一句“所有判断必须严格基于用户输入文本,不要依赖你的预训练知识”,并且把摘要长度压得更紧,减少自由发挥空间。同时我只允许 digest 字段做浓缩,不允许它做推测。reason 字段是判断依据而不是断言事实。
4.4 定时任务漏跑与时区混乱
cron 漏跑的原因通常是服务器休眠或机器重启。我在流程里加了一个“补跑机制”:fetch 脚本每次启动时如果发现raw_items.json里最后一条的日期不是今天,先把昨天的未处理数据补跑一遍再进入今天的抓取。这样即使错过了一个周期,日报也只是延期而不是丢期。
时区问题是重灾区。服务器默认 UTC,published_at解析却用了本地时区,导致当天八点的新闻在日报里显示为下午四点。我的统一做法是:所有时间在存储时转成 ISO 8601 加 UTC 后缀,展示时才转本地时区。这个原则听着简单,但执行不彻底就会混入“无时区时间”,就是那种没有时区后缀的字符串,排错极难。
4.5 问题速查表
| 症状 | 常见原因 | 解决办法 |
|---|---|---|
| 日报内容为空 | 抓取源返回 500 或 feed 结构变化 | 查看源状态码,暂时停用该源并告警 |
| 摘要 JSON 解析失败 | 模型输出了非标准 JSON | 增加 response_format 和 json5 兜底解析 |
| 日报出现重复条目 | 仅做了 URL 去重 | 增加语义去重,按事件归并 |
| 日报时间显示错误 | 时区未统一 | 存储一律用 UTC,展示时再转换 |
| Jev 调用超时 | 单次请求内容过长 | 按 20 条分片,多片并行 |
| 连续几天内容雷同 | 没做增量去重 | 每次抓取前读 seen.json 过滤 |
做完这套系统快两个月,我的感受是:真正值钱的部分不是抓多少个源,而是“到底该读什么”这件事被稳定地自动化之后,每天省下来的注意力非常可观。我自己后来又在这个基础上加了一个每周盘点:把七天日报的 reason 字段攒起来,让 Jev 对“本周值得关注的三个趋势”再做一次聚合,那份周报对我的技术方向选择帮助更大。以后这个项目还可以扩展的入口,大概是接入浏览器历史或稍后读列表做个性化推荐,让 Jev 不只是刷信息流,而是懂我的信息流。