juejin-skills 系列 ②:看掘金热榜
本系列 ① 我把「自动发文章到掘金」做成了一个 Agent 技能:juejin-skills ② 本篇:给技能包加上「掘金热榜」(v1.4.0) ③ 预告:F3 掘金沸点发布
🔗 仓库地址:github.com/liuleihang/... (MIT,Node ≥ 18,发布脚本零依赖)🔗 workbuddy链接:www.workbuddy.cn/share/asset...

上一篇发出去时,文末留了一张 F1--F6 的待办表,F2「掘金热榜」标着 📋 规划中。
这篇把它划掉。v1.4.0 里,你在 Agent 里说一句「看看今天掘金前端热榜」,它就会返回排名、标题、作者、热度、浏览 / 点赞 / 收藏 / 评论和文章链接------全程不需要登录。
一、为什么先做热榜
先看一眼这两个功能的复杂度差:
| F1 文章发布 | F2 掘金热榜 | |
|---|---|---|
| 鉴权 | 需要 Cookie,登录态会过期 | 公开接口,无需登录 |
| 链路 | 会话体检 → 标签缓存 → 图片上传 → 建草稿 → 二次确认发布 | 一次 GET → 字段映射 → 输出 |
| 风险 | 对外写操作,误发不可逆 | 只读,无副作用 |
热榜是整张待办表里唯一不需要登录的能力,也是唯一没有危险动作的能力。
这里有个常被忽略的判断:这个需求真的需要 Agent 吗? 不需要。一次 HTTP GET、一次字段映射、一次格式化输出,是标准的确定性固定工作流------没有决策分支,不需要自主循环,不需要记忆,更不需要停止条件。给这种任务套一个"自主 Agent",只会凭空引入不确定性。
所以实现上只加了一个 CLI 脚本 scripts/hot.mjs,SKILL.md 里补充触发词和命令说明就够了。Agent 在这里的角色是把自然语言翻译成一条命令,不是替你做决策。
能被一次调用解决的问题,别包装成 Agent 的自主决策。技能包不是越"智能"越好,越可预测越好。
二、接口不能凭记忆硬编
掘金没有官方公开 API 文档,网上能搜到的端点与字段、包括各种博客里的示例,都可能已经过期。第一步不是写代码,是核实:
- 多个独立来源交叉比对,找出候选端点;
- 直接发一次请求,把返回结构逐字段看清楚,再决定映射;
- 把核实结果(含日期)写进文档,而不是记在脑子里。
最终确认的是:
bash
GET https://api.juejin.cn/content_api/v1/content/article_rank
?category_id={id}&type=hot&aid=2608&spider=0
| 项 | 实测结果(2026-11-11) |
|---|---|
| 鉴权 | 不传 Cookie 返回 err_no=0,公开可读 |
| 返回 | {err_no:0, err_msg:"success", data:[...]},综合榜 50 条 |
| 字段 | author.name、content.{content_id,title}、content_counter.{hot_rank,view,like,collect,comment_count} |
| 链接 | https://juejin.cn/post/{content_id} |
| 分类 | query_category_briefs 返回 8 类(后端 / 前端 / Android / iOS / 人工智能 / 开发工具 / 代码人生 / 阅读) |
| 综合榜 | category_id=1 是榜单约定值,并不在那 8 个分类里 |
| 分类筛选 | 实测生效(前端 id 返回前端文章 20 条) |
两个容易想当然的地方:author_counter 里的计数全是 0,没有使用价值;综合榜的 1 不能从分类列表里查出来,得单独做别名映射。
三、先把规格里那句「摘要」删掉
原来的 SPEC 里,F2 的输出列是这样写的:
文章列表:标题、作者、点赞 / 浏览数、评论数、文章链接、摘要
实测接口一看:根本没有摘要字段。
于是 SPEC 的输出列改成了实际存在的字段,并在文档里显式加了一句「接口不返回摘要,不得向用户承诺或编造摘要」。
这件事值得单独写一节,因为它是最典型的一类错误:把"应该有"写成"接口有" 。规格里多写一个不存在的字段,下游会照着去取 undefined;更糟的情况是,如果这段规格喂给了 AI,它可能为了"把字段凑齐"直接编一段摘要出来,而读者完全看不出那是编的。
同样处理的还有 type=new:这次请求返回的是 Internal Server Error。参数入口保留了,但文档标注「待核实、不作能力承诺」,而不是含糊地写成"支持热门 / 最新两种榜单"。
不确定就标出来,不要编。宁可少写一个能力,也不要多写一个幻觉。
四、hot.mjs 怎么写的
脚本分成几个小函数:parseArgs(参数与边界校验)、fetchCategories(拉分类)、resolveCategory(名称 → id)、fetchHotList(请求 + 字段映射)、formatText(人类可读输出)。
核心的字段映射是这样:
js
const GENERAL_ID = '1'; // 综合榜约定值,不在 query_category_briefs 返回列表中
const POST_BASE = 'https://juejin.cn/post/';
// 2026-11-11 实机核实:公开 GET,不传 Cookie
// data[] = { author:{name}, content:{content_id,title},
// content_counter:{hot_rank,view,like,collect,comment_count} }
export async function fetchHotList({ categoryId, type, limit } = {}) {
const params = new URLSearchParams({
category_id: categoryId || GENERAL_ID,
type: type || 'hot',
aid: '2608',
spider: '0',
});
const r = await api(`${ENDPOINTS.articleRank}?${params}`);
if (!r || r.err_no !== 0) {
throw new Error(`热榜获取失败:err_no=${r && r.err_no} err_msg=${r && r.err_msg}(非官方接口,可能已变更)`);
}
const list = Array.isArray(r.data) ? r.data : [];
return list.slice(0, limit || CFG.hot.maxLimit).map((x, i) => {
const c = x.content || {};
const cc = x.content_counter || {};
return {
rank: i + 1,
title: c.title || '(无标题)',
author: (x.author && x.author.name) || '(未知)',
articleId: String(c.content_id || ''),
url: c.content_id ? `${POST_BASE}${c.content_id}` : '',
hotRank: cc.hot_rank ?? 0,
view: cc.view ?? 0,
like: cc.like ?? 0,
collect: cc.collect ?? 0,
comment: cc.comment_count ?? 0,
};
});
}
几个设计取舍:
- GET 优先、失败降级 POST :分类列表端点对方法敏感,沿用
tags.mjs里已经验证过的双保险; - 分类名两级匹配 :先精确匹配,再兜底包含匹配;
综合/全部/all统一归一到category_id=1; --limit卡在 1--50:接口一次最多给 50 条,与其让脚本静默截断,不如把边界写进参数校验;- 文本 +
--json双输出 :文本给人看,JSON 给 Agent 和管道消费,后者输出{category, categoryId, type, fetchedAt, items}; isMain守卫 :export出去给别的脚本复用时,不能因为被import就把 CLI 跑一遍;- 不落缓存 :榜单是实时的。实测两次相邻请求,同一篇文章的热度从
2862涨到2880、浏览从4738到4773------缓存反而会给出过期答案。
跑起来是这样:
text
$ node scripts/hot.mjs --limit 3
掘金热榜 · 综合 · 热门 · 3 条
1. 永别了,程序员!(转)
XCaptain 热度2826 浏览4662 点赞45 收藏18 评论37
https://juejin.cn/post/7705987138957328427
2. AI 写页面像说明书,修 Bug 像贴膏药?把这两句话写进 AGENTS.md
ikoala 热度891 浏览1443 点赞22 收藏25 评论5
https://juejin.cn/post/7705724831249432591
3. AI 正在蚕食我们的编码能力
石小石Orz 热度765 浏览1335 点赞12 收藏8 评论7
https://juejin.cn/post/7705961716425490447
6 条冒烟(综合榜 / 分类筛选 / JSON 管道 / 分类列表 / 非法参数拦截 / import 无副作用)全部跑通,SKILL.md 也过了平台的格式校验。
五、一个真实的坑:默认配置被整体覆盖
新增功能时,我在 config.mjs 的 DEFAULTS 里加了一个 hot 分组:
js
hot: {
type: 'hot',
defaultLimit: 20,
maxLimit: 50,
},
看起来就够了?不够。loadUserConfig() 是这么合并用户配置的:
js
const merged = { ...DEFAULTS, ...raw };
merged.defaults = { ...DEFAULTS.defaults, ...(raw.defaults || {}) };
merged.imageUpload = { ...DEFAULTS.imageUpload, ...(raw.imageUpload || {}) };
merged.tagCache = { ...DEFAULTS.tagCache, ...(raw.tagCache || {}) };
每个嵌套分组都单独做了一次浅合并 。如果新增 hot 时漏掉这一行,用户只要在 ~/.juejin/config.json 里写一句 {"hot":{"defaultLimit":5}},type 和 maxLimit 就会被整体冲掉变成 undefined------后果有两个:--limit 的上限校验形同虚设(5 > undefined 恒为 false),且未显式传条数时 fetchHotList 会拿到 slice(0, undefined),直接返回空列表。
补上这一行即可:
diff
merged.tagCache = { ...DEFAULTS.tagCache, ...(raw.tagCache || {}) };
+ merged.hot = { ...DEFAULTS.hot, ...(raw.hot || {}) };
这个坑的隐蔽之处在于:默认路径完全测不出来 。只有用户显式覆盖了新分组,才会暴露。所以每次给 DEFAULTS 加嵌套分组,都要同步检查合并逻辑------配置结构的扩展点,比新增一个字段更值得写进 checklist。
六、现在能问什么
| 你说 | 它跑 |
|---|---|
| 「看看今天掘金热榜前 10 篇文章」 | node scripts/hot.mjs --limit 10 |
| 「前端分类最近最火的文章有哪些」 | node scripts/hot.mjs 前端 --limit 5 |
| 「掘金有哪些分类」 | node scripts/hot.mjs --categories |
| 需要结构化数据时 | node scripts/hot.mjs --json --limit 20 |
输出字段固定为:排名、标题、作者、文章 id、文章链接、热度值、浏览数、点赞数、收藏数、评论数。没有摘要------接口不给,脚本不编。
七、待办表更新
| # | 功能 | 状态 |
|---|---|---|
| F1 | 文章发布(含本地图片上传) | ✅ 系列 ① |
| F2 | 掘金热榜 | ✅ 系列 ②(本篇) |
| F3 | 掘金沸点发布 | 📋 下一篇:短内容 + 话题 + 配图,复用 ImageX 链路,发布前回显预览确认 |
| F4 | 掘金签到 | 📋 规划中 |
| F5 | 文章下载(单篇) | 📋 规划中 |
| F6 | 文章批量下载 | 📋 规划中 |
🔗 仓库:github.com/liuleihang/... (MIT,Node ≥ 18,发布脚本零依赖)
🔗 workbuddy链接:www.workbuddy.cn/share/asset...
本项目为社区工具,与掘金官方无关。文中接口均为社区抓包 + 实测核实,非官方接口随时可能变更;
err_no != 0请原样上报,勿盲目重试