钥匙到手,第一件事不是让它陪聊,是让它干活。
我给 Hermes 派的第一个真实任务,叫「各家 AI 动态日报」:每天盯着六家 AI 公司的动静,国内国外都算上,把发布整理成一张能直接看的网页。这篇就是把这张网页从零跑出来的全程实录——收集、汇总、出网页,三步端到端,中间踩了三个真坑。
就这三步,一条线串到底。这一路会踩三个真坑,两个出在数据源:RSS 是全量存档,OpenAI 一个源抓到 1156 条;智谱和豆包是 JS 渲染的 SPA,静态抓取拿不到条目。第三个出在 Hermes 自己身上——等它第一次开口汇报,你自己看。
载体项目:各家 AI 动态日报
先交代这个项目为什么值得当第一个任务。
需求就三句话:
- 每天 6:00 收集国内外 AI 动态——智谱、Kimi、字节跳动三家国内,Anthropic、OpenAI、Google 三家国外;
- 汇总去重,同一件事只讲一遍;
- 生成一个网页,本地双击就能看。
本期先手动跑一轮,调度(每天 6:00 自动跑)留到 H7,跨天去重(昨天报过的今天不重复)留到 下一篇,现在接着往下看:
项目 README 里,端到端就是三条命令:
python collect.py# 多源抓取 → items.json(标题/链接/来源/日期)python curate.py# 调 Hermes 汇总 → digest.md(今日概览 + 国内/国外分组)python render.py# 渲染 → report.html(本地浏览器打开即可查看)选它当第一个任务,是因为它把 Hermes 系列后面要碰的能力全串上了:多源收集、汇总、记忆去重、技能沉淀、定时调度、多渠道推送。这一篇先立骨架,后面每篇都是往骨架上装一块。
第一步 收集:先摸清数据源在哪
写收集器之前,先搞清楚一件事:这六家 AI 公司的动态,到底从哪拿?
动手前先探测一遍,每个源发个 HTTP 请求看状态码。这是本机的探测实录:
200 https://www.anthropic.com/news 404 https://www.anthropic.com/feed.xml 403 https://openai.com/news/ # HTML 页被反爬,但 RSS 可用 200 https://openai.com/news/rss.xml 200 https://blog.google/technology/ai/rss/ 200 https://www.kimi.com/blog/ # 月之暗面博客(moonshot.cn/news 跳转至此) 200 https://www.zhipuai.cn/rss.xml # 假 RSS:返回 Next.js HTML SPA 200 https://www.doubao.com/news # JS 渲染,静态抓取无条目探测结果跟直觉差得远,四类情况:
- OpenAI / Google:有 RSS。
openai.com/news这个 HTML 页 403 被反爬,但openai.com/news/rss.xml是 200,RSS 反而畅通。Google 同理。这是最省事的源; - Anthropic / Kimi:只有 HTML 锚文本。Anthropic 的
feed.xml404,没有 RSS,/news/页面的链接锚文本里带着分类+日期+标题;Kimi 的博客也是,锚文本就是标题; - 智谱:假 RSS。
zhipuai.cn/rss.xml返回 200,但内容是一坨 Next.js 的 HTML SPA——名字叫 rss.xml,实际是个空壳页面,解析不出任何条目; - 豆包:JS 渲染。
doubao.com/news是 200,可静态抓取拿不到条目,页面内容全是浏览器里跑 JS 渲染出来的。
所以能用的可靠源一共 4 个:两个 RSS + 两个 HTML 锚文本。智谱和豆包这两个 JS 源,静态抓取这条路走不通。
其实:**数据源是现实世界,不是文档。**文档会告诉你「该有 RSS」,现实是有的源没有、有的源伪装成有、有的源要浏览器渲染。写爬虫的人一半的活,是跟「源不如预期」周旋。
遇到 JS 源怎么办?我的做法是优雅降级:先不硬刚,跳过它,在输出里记一笔「这个源暂时抓不到」,等后面有浏览器适配器再补。任务不因一个源卡死——宁可少两个源,也不要整条管线死在半路。
这个结构叫「源适配器」:每个源一个适配器,差异收在适配器里,抓取主流程不用改。
JS 源在架构里也占一个位置,只是本期适配器还没写——位置先留着,后面补浏览器适配器时直接填进去。
写收集器 collect.py:把「抓得到」变成「抓得对」
收集器collect.py,纯标准库:urllib 抓页面、xml 解析 RSS、re 提链接,装了 Python 就能跑,不引第三方库。
核心是四组_adapter_*函数,每组对应一个源:
_adapter_openai/_adapter_google:解析 RSS 里的item;_adapter_anthropic:正则提/news/链接,从锚文本里拆分类+日期+标题;_adapter_kimi:提kimi.com/blog/文章链接,锚文本即标题。
每个源抓完,都产出同样形状的记录:title、url、date、summary。形状统一,后面汇总、渲染才好处理。title从这里就开始进文档——这是渲染器能生成标题链接的源头。
源清单在SOURCES里:
SOURCES=[{"key":"openai","name":"OpenAI","cat":"国外","type":"rss","url":"https://openai.com/news/rss.xml"},{"key":"google","name":"Google AI","cat":"国外","type":"rss","url":"https://blog.google/technology/ai/rss/"},{"key":"anthropic","name":"Anthropic","cat":"国外","type":"html_links","url":"https://www.anthropic.com/news"},{"key":"kimi","name":"Kimi(月之暗面)","cat":"国内","type":"html_links","url":"https://www.kimi.com/blog/"},# 智谱 / 字节豆包 为 JS 渲染 SPA,静态抓取拿不到条目列表,本期跳过(待浏览器适配器)]每行一个源:type决定走哪类适配器,cat是国内还是国外,url是抓哪。最后两行注释掉的智谱和豆包不是忘了——是本期适配器还没写,占着位置等浏览器适配器。
初版我栽了个坑,就在「抓多少」这件事上。
第一版写得很天真:把每个源抓到的条目全部收进来。一跑,OpenAI 抓到 1156 条。RSS 是全量存档,不是当天的新闻流——openai.com/news/rss.xml把这几年发的所有新闻全吐出来了。要是全收,今天日报里一半是三个月前的旧闻。
修法是两个约束,写在一个函数里:
RECENT_DAYS=2# RSS 源只保留最近 N 天内的条目MAX_PER_SOURCE=8# 每个源最多取前 N 条,保证日报可读def_slice_source_items(raw:list,src_type:str)->list:"""每源剪裁:RSS 按最近 N 天过滤,HTML 源取最新 N 条(列表即最新)。"""ifsrc_type=="rss":cutoff=datetime.date.today()-datetime.timedelta(days=RECENT_DAYS)recent=[itforitinrawifit["date"]andit["date"]>=cutoff.isoformat()]recent.sort(key=lambdax:x["date"],reverse=True)returnrecent[:MAX_PER_SOURCE]returnraw[:MAX_PER_SOURCE]RSS 源按日期切:最近 2 天内的才要,再按日期倒序取前 8 条;HTML 源列表本身就是最新在前,直接取前 8 条。RECENT_DAYS = 2、MAX_PER_SOURCE = 8,两个常量写在文件顶部,一眼能调。
去重放在collect()里,按 URL 去重——同一条新闻多个源都报时,保留先抓到的:
# 按 URL 去重(同一条新闻多个源都报时保留先抓到的)seen_url,deduped=set(),[]foritinall_items:ifit["url"]inseen_url:continueseen_url.add(it["url"])deduped.append(it)跑一遍(命令与输出逐字取自本机取证):
cd/d/CF-AICoding/hermes-lab/daily-report&&PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe collect.py收集完成:共 24 条,写入 items.json - OpenAI: 抓到 1156 条,取最近 7 条 - Google AI: 抓到 20 条,取最近 1 条 - Anthropic: 抓到 11 条,取最近 8 条 - Kimi(月之暗面): 抓到 9 条,取最近 8 条 [2026-08-28] OpenAI|Supporting Thailand's next generation of AI startu...24 条,四个源按各自的规则收敛:OpenAI 1156 条里留 7 条、Google 20 留 1、Anthropic 11 留 8、Kimi 9 留 8。Google 只留 1 条,因为最近两天它就发了一条——过滤不是「每源灌满」,是「每源只留最近」。「抓到 1156 条,取最近 7 条」这句话本身,就是 RSS 存档坑的体检报告。
写到这,第一步收工。items.json里 24 条,每条带着 title/url/date/summary/source/category,标题已经进文档。下一步,把这些原料交给 Hermes 整理成日报正文。
第二步 汇总:Hermes 当汇总官
第二步是把 24 条原料变成一篇人能读的日报。这步我用 Hermes。
对应的是curate.py,核心动作就一个:调hermes chat,非交互地把 24 条塞进提示词,让它整理成日报正文。命令在main()里:
cmd=[HERMES,"chat","-q",prompt,"--provider",PROVIDER,"-m",MODEL,"-Q"]r=subprocess.run(cmd,capture_output=True,encoding="utf-8",errors="replace")-q给问题、--provider deepseek走 H1 配的 provider、-m deepseek-v4-flash指定模型、-Q安静模式——不带交互提示符,适合程序化调用。这就是「脚本/定时任务调 Hermes 的标准姿势」:一次性、非交互、把结果拿回来。
提示词由build_prompt拼出来,先给要求再给条目:
defbuild_prompt(data:dict)->str:lines=[f"今天是{data['generated_at']}。下面是我用脚本抓到的各家 AI 动态共{data['count']}条,请整理成一份日报正文:","要求:① 按【国内】/【国外】分组;② 每组里每条给一句话要点(基于摘要,摘要为空的按标题转述,不要编造);","③ 开头给一句今日概览;④ 结尾给一条『值得关注』;⑤ 中文输出,不客套,不加标题以外的废话。","条目列表:",]fori,itinenumerate(data["items"],1):summary=(it.get("summary")or"").strip()s=summaryiflen(summary)<=60elsesummary[:57]+"…"lines.append(f"{i}. [{it.get('source')}] ({it.get('date')or'近期'}){it.get('title')}——{s}")return"\n".join(lines)要求五条:分国内/国外、每条一句话要点、开头今日概览、结尾值得关注、中文不客套。条目列表带上来源、日期、标题和摘要——摘要超过 60 字截断,提示词别太长。
跑一遍:
PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe curate.py原始输出 21143 字符 → 截出正文 1108 字符,返回码 0 今日概览:Anthropic 集中发布 Claude Opus 5、Sonnet 5 并披露多项治理与研究计划,月之暗面批量更新 K3、K2.6 等模型与评测基准,OpenAI 则继续在教育、国际扩张与安全议题上推进。 【国内】- 月之暗面:发布新一代模型 Kimi K3。... 【国外】- OpenAI:与泰国 MHESI 联合推出为期八周的加速器。...这里先按下不表:**Hermes 第一次汇总,出了个意外。**21143 字符里,正文只占 1108,剩下全是它「想」出来的东西。怎么想的、怎么把正文捞出来,下一节讲。
推理模型的第一个坑:输出混着 reasoning_content
问题出在deepseek-v4-flash身上——它是推理模型,思考模式默认开。
什么是思考模式?模型在给出正式答案前,先自己过一遍脑子。这条链路里,它的思考不是悄悄进行的——hermes chat把思考过程整个打印出来了,混在正式输出里。而且它不止想了一版:正文 108 行、142 行、257 行、294 行各起草一次,多版草稿都留在输出里,真正的日报正文藏在最后一次「今日概览」和session_id:之间。
一句话:21143 字符原始输出 = 整段英文思考 + 四版草稿 + 最后的正文。它把草稿纸也一起交上来了。
这是推理模型接 harness 的第一个真实问题——也是系列第 2 篇埋的伏笔。那篇探测 DeepSeek 时,响应里有个reasoning_content字段,正文content反而是空的,当时留了句「这个字段后面是整篇的伏笔」。现在兑现:当 harness 把模型输出原样打回时,推理模型会把思考过程一并吐出来,程序要自己把正文从思考里捞出来。
curate.py的解法在extract_report里:
defextract_report(out:str)->str:"""从 Hermes 原始输出里截出日报正文。 推理模型(deepseek-v4-flash 思考模式默认开)会把整段英文思考/多版草稿 一起吐出来,正文在最后一段「今日概览」与 session_id 之间。 这是推理模型接 harness 的第一个真实坑:输出混着 reasoning_content。 """end=out.find("session_id:")ifend==-1:end=len(out)start=out.rfind("今日概览")ifstart==-1:start=out.rfind("每日概览")body=out[start:end].strip()ifstart!=-1elseout[:end].strip()# 去掉草稿里穿插的英文残留(如 "...one sentence...")body=re.sub(r"今日概览[::]\s*\.\.\.one sentence\.\.\.","",body)returnbody两个定位点,一前一后把正文夹出来:
rfind("今日概览"):从后往前找「今日概览」,拿到的是最后一次出现的位置——正文的开头;find("session_id:"):从前往后找session_id:,那是 Hermes 会话编号——正文的结尾;- 两者之间,就是正文。
为什么用rfind不用find?因为「今日概览」在草稿里也出现过,find会拿到第一版草稿的概览,rfind才拿得到真正正文那版。多版草稿这个坑,用「取最后一次」绕过去。
除了截取,还要剥装饰。Hermes 输出带着 ANSI 转义和┌─ Reasoning ─┐装饰框,strip_ansi把它们去掉:
defstrip_ansi(text:str)->str:"""去掉 ANSI 转义与 Reasoning 装饰框,只留纯文本内容。"""text=re.sub(r"\x1b\[[0-9;]*m","",text)lines=[lnforlnintext.splitlines()ifnotre.match(r"^[\s]*[┌└│┐┘─]+",ln)]return"\n".join(lines).strip()截取 + 剥装饰,21143 字符压成 1108 字符干净正文。流程画出来:
这步的价值不在正则写得多妙,在定位了「推理模型接 harness」这个坑本身:模型给的不只是答案,还有它的思考过程;程序要的只是答案,就得学会把两者分开。后面 H3/H4/H5 只要还调推理模型,这个截取逻辑都得跟着走。
第三步 出网页:render.py 生成静态 HTML
原料齐了:items.json(24 条)加digest.md(Hermes 汇总的日报正文)。第三步把它们渲染成一个网页。
render.py,纯标准库生成静态 HTML。两份输入,一个输出:items.json+digest.md→report.html。
做日报时我定了一条硬规矩:**标题必须进文档。**日报不能只是「机器能读的 JSON」,得是「人打开就能看」的网页——浏览器标签页要有标题、页面顶部要有可见大标题、每条新闻的标题要作为链接写进正文。这条规矩落下来,就是item_links函数:
defitem_links(items:list)->str:"""按国内/国外分组渲染条目标题链接(标题写进文档)。"""groups={"国内":[],"国外":[]}foritinitems:groups.setdefault(it.get("category","其他"),[]).append(it)blocks=[]forcatin("国内","国外","其他"):arr=groups.get(cat)ifnotarr:continuerows=[]foritinarr:title=html_mod.escape(it.get("title","(无标题)"))url=it.get("url","")date=it.get("date")or"近期"link=f'<a href="{html_mod.escape(url)}">{title}</a>'ifurlelsetitle rows.append(f'<li><span class="date">{date}</span>{link}'f' <span class="src">{html_mod.escape(it.get("source",""))}</span></li>')blocks.append(f'<h2>{cat}·{len(arr)}条</h2><ul class="items">{"".join(rows)}</ul>')return"\n".join(blocks)24 条按category分成国内/国外两组,每条渲染成<li>:日期 + 标题链接 + 来源。标题从items.json的title字段来——收集器那步把title写进items.json,这里把它写进 HTML。源头在这里闭合。
页面结构在main()里(节选):
html_doc=f"""<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport"content="width=device-width, initial-scale=1"><title>{title}·{today}</title><style>...<body><h1>{title}</h1><divclass="meta">报告日期:{today}| 抓取时间:{data.get('generated_at','')}| 共{data.get('count',0)}条 | 来源:{', '.join(n.split(':')[0]fornindata.get('notes',[]))}</div><h2>今日概览(Hermes 汇总)</h2><divclass="digest">{md_to_html(digest)}</div><h2>全部条目</h2>{item_links(data.get('items',[]))}<title>{title} · {today}</title>是浏览器标签,<h1>{title}</h1>是页面顶部可见大标题,正文先放 Hermes 汇总的今日概览,再放全部条目链接。标题进文档的三个落点:浏览器标签、可见 h1、每条标题链接。
md_to_html是一个极简 Markdown→HTML 转换,覆盖日报会用的#、**、-列表,不引第三方模板。
跑一遍:
PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe render.py已生成 report.html(7321 字符),本地浏览器打开即可查看校验结果:<title>各家 AI 动态日报 · 2026-08-28</title>、页面顶部<h1>各家 AI 动态日报</h1>、24 条标题链接、国内/国外分组 h2,HTML 标签闭合校验无错误。7321 字符一个静态页,双击report.html就能看。
端到端复盘:Hermes 的定位与边界
三条命令串起来,就是开头那张全景图:
collect.py → items.json → curate.py(Hermes) → digest.md → render.py → report.html本地打开就是一张日报:顶部是 Hermes 写的今日概览,往下是国内/国外两组 24 条标题链接。
到这里,先把 Hermes 的定位说清楚。这一期里它扮演的角色是汇总官——纯对话,把 24 条原料整理成正文。它没有调工具:抓取是 collect.py 干的,渲染是 render.py 干的,Hermes 只在中间做「理解并整理」这一步。
结论:骨架立起来了
回到开头那句话:第一个真实任务不是让 Hermes 陪聊,是让它干活。现在它干完了一轮——24 条原料进来,一张网页出去,中间 Hermes 当汇总官,把 24 条整理成 1108 字符的日报正文。
真装真跑的价值,就藏在这三个坑里。文档不会告诉你 RSS 是全量存档、不会告诉你有些源伪装成 RSS、更不会告诉你推理模型会把草稿纸一起交上来——这些只有跑一遍才知道。
但这才是个开始。这张日报今天记得住,明天就忘了——昨天报过的 OpenAI 那条,今天可能又报一遍。这是 H3 要解决的事:让 Hermes 记住昨天报过什么,跨天去重。骨架立起来了,后面 H3 记忆、H4 学习闭环、H5 技能固化、H6 多渠道推送、H7 每天 6:00 无人值守,都是往这根骨架上装肉。
你的 agent 第一个真实任务是什么?卡在哪一步?是跟我一样卡在「源比文档刁钻」,还是卡在把模型输出整理成程序能用的样子,还是压根没想好让 agent 干什么?评论区聊聊~
下一篇会接着聊:让 agent 记住昨天的事。想看跨天去重怎么做的,关注别走丢。