把文章发布到掘金,做成一个可复用的 juejin-skill

很多工具的起点都不宏大,往往只是为了去掉一段反复出现、但没有创造性的劳动。

juejin-skill 也是这样长出来的。我平时习惯在本地写 Markdown,配图和草稿都按项目目录管理。文章完成后,再回到掘金编辑器里补分类、挑标签、上传图片、创建草稿,偶尔做一次问题不大,但只要写作频率稍微稳定一点,这条链路就会迅速变成额外负担。更麻烦的是,文章一旦修改,还得回到网页端重新同步一遍,本地明明已经有完整原稿,却还要重复操作。

把这件事拆开以后,真正需要被自动化的部分其实非常集中:

  • 登录态最好能复用,而不是每次重新处理登录流程。
  • 分类、标签和热榜信息应该可以直接查询,最好还能给出合理推荐。
  • 本地 Markdown 应该能够直接转换成平台草稿。
  • 本地图片需要在发布前完成上传和替换,不能把相对路径带到线上。
  • 已发布文章需要支持基于本地稿件继续更新。
  • 真正公开发布时,应该保留一次明确确认。

于是就有了 juejin-skill。它现在是一套同时面向 Codex 和 Claude Code 的掘金发文 Skill,底层共用一套 Node.js CLI。两个 Agent 的接入方式不同,但最终执行的是同一条发布链路。

这篇文章会按照实现顺序,讲清楚它为什么这样拆、哪些设计后来证明是必要的。出于安全和稳定性的考虑,文中对少量平台内部接口路径、字段名和上传链路细节做了抽象化处理,但整体实现思路与工程结构保持真实。

一、先把能力收敛到 CLI

做 Agent Skill 时,很容易先写提示词,让模型去判断"什么时候该发布、什么时候该更新、参数该怎么组装"。这种方式短期能跑,但随着功能变多,维护成本会上升得很快。更稳妥的做法,是先把确定性的动作收敛到 CLI,再让不同平台的 Skill 文档去调用这套命令。

现在 juejin-skill 的命令大致是这几类:

bash 复制代码
juejin-skill login
juejin-skill verify
juejin-skill categories
juejin-skill tags --category <category-id>
juejin-skill hot --limit 10
juejin-skill publish --file ./article.md --category <category-id>
juejin-skill update --article https://juejin.cn/post/<article-id> --file ./article.md
juejin-skill download --article https://juejin.cn/post/<article-id>

CLI 放在 src/commands/cli.js,职责尽量保持简单:解析参数、读取文件、组装调用、输出结果。真正的业务逻辑都下沉到 src/core/,例如登录、热榜、发布、图片上传、下载等能力都在这一层实现。

这个分层没有复杂设计,价值却非常直接。Codex Skill 和 Claude Code 命令不需要各写一套发布逻辑,只要约定如何调用 CLI 即可。后续修复 bug、补充参数或调整发布流程时,也能保证两个平台的行为保持一致。

二、HTTP 层尽量薄,错误尽量早暴露

掘金的核心操作最终都落在 HTTP 请求上,因此我先写了一个比较薄的 JuejinHttpClient。它主要负责统一请求头、带上本地会话信息,以及处理非成功响应。

js 复制代码
export class JuejinHttpClient {
  constructor({ cookie = "", fetchImpl = fetch } = {}) {
    this.cookie = cookie;
    this.fetchImpl = fetchImpl;
  }

  async post(url, body = {}, { headers = {} } = {}) {
    return this.#request(url, {
      method: "POST",
      headers,
      body: JSON.stringify(body)
    });
  }
}

这里我刻意没有做太厚的封装,比如自动重试、业务错误猜测、静默降级等。和第三方站点打交道时,接口变化、登录失效、参数缺失往往对应完全不同的问题,越早看到状态码和响应片段,排查就越快。如果客户端层过度"聪明",出问题时反而不容易定位。

另外保留 fetchImpl 这个注入点,是为了让测试更干净。单元测试可以直接替换成假的 fetch,只验证请求结构和分支逻辑,不需要真的访问平台。

端点和配置统一收敛在 src/core/config.js。这点看起来普通,但对这类依赖网页端实现细节的工具非常重要。平台调整接口时,只改配置层,维护成本会低很多。

三、登录交给浏览器,会话交给本地文件

发布、更新和上传图片都依赖登录态。对用户来说,最自然的体验不是把账号密码交给工具,而是用浏览器完成正常登录,然后让程序复用登录结果。

juejin-skilllogin 命令会用 Playwright 启动浏览器,打开登录页,并在登录成功后提取当前站点所需的会话 Cookie,最终保存到:

text 复制代码
~/.juejin-skill/session.json

后续命令都从这个文件读取会话信息。这样做有几个很实际的好处:

  • 工具本身不接触账号密码,也不需要处理扫码、验证码等登录细节。
  • 用户登录一次后,发布、更新和上传图片都可以复用同一份本地会话。
  • 会话失效时,只需要重新执行 login,不必改动其他逻辑。
  • 如果想撤销本地登录状态,删除 session.json 就可以。

我还补了一个 verify 命令,用当前会话去校验登录状态。这个命令在实际使用里非常有价值,因为不少"发布失败"表面看像参数问题,最后往往只是会话已经过期。

四、把分类、标签和热榜纳入同一条工作流

发文章不是只把正文上传出去那么简单。真正落地时,往往还要先确认当前有哪些分类、某个分类下有哪些推荐标签、最近热门内容是什么。把这些辅助能力放进同一个工具里,才能让发文流程更顺手。

juejin-skill 目前支持:

  • categories:查询平台分类列表。
  • tags:查询指定分类下的推荐标签,并支持关键词过滤。
  • hot:查询全站或指定分类下的热门文章。

这里有一个很典型的工程经验:平台表面上"能用"的接口,不一定是最适合自动化调用的接口。早期标签能力使用过另一条数据来源,但在发布场景里稳定性不够,后来我对照网页端当前网络请求,切换到了更接近实际产品逻辑的一条接口。做这类工具时,经常需要顺着真实页面行为反推更可靠的数据入口。

如果用户在发布时没有显式指定分类,CLI 会根据标题和正文里的关键词给出推荐。比如命中 AndroidKotlinCompose,就更偏向 Android;命中 ReactVueVite,就更偏向前端。

这套推荐规则并不复杂,但我很喜欢它的两个特点:可解释、可测试。Agent 可以直接告诉用户"为什么推荐这个分类",而不是给出一个黑盒结论。分类会影响文章的受众,因此我没有让工具默默替用户做最终决定,而是把推荐结果作为辅助信息交给作者确认。

五、发布默认只创建草稿

真正的发布逻辑集中在 ArticlePublisher.publishMarkdown()。整体流程大致如下:

  1. 读取 Markdown 文件。
  2. 提取第一个一级标题作为文章标题。
  3. 从正文中移除标题行,避免平台正文重复出现标题。
  4. 扫描本地图片,完成上传并替换成可公开访问的地址。
  5. 生成摘要,除非用户显式传入 --brief
  6. 将 Markdown 渲染为 HTML,并同步整理平台所需的图片元数据。
  7. 创建文章草稿。
  8. 只有在明确确认后,才执行公开发布。

默认行为是"创建草稿而不是直接公开"。要真正发布,必须显式传入:

bash 复制代码
--publish --confirm-publish

也可以通过环境变量开启明确确认:

bash 复制代码
JUEJIN_CONFIRM_PUBLISH=1

这个边界我设得比较坚决。自动化非常适合承担重复劳动,例如读取原稿、上传图片、创建草稿;但公开发布意味着内容会对外可见,工具最好只在作者明确授权后才继续推进。

标题提取也只认第一个一级标题。这样本地 Markdown 仍然可以保持常见写法:

md 复制代码
# 我的文章标题

正文从这里开始。

对作者来说,本地文稿依旧完整;对平台来说,标题和正文则被正确拆分到不同字段中。

六、本地图片上传,是整条链路最容易出问题的部分

本地 Markdown 里经常会有这样的图片引用:

md 复制代码
![架构图](./images/architecture.png)

在本地预览时它完全正常,但只要把这段 Markdown 直接提交到平台,线上读者就无法访问作者电脑里的相对路径。也正因为如此,图片上传几乎是整条发文链路里最容易出问题、也最必须做好的部分。

0.1.2 版本之后,我补上了完整的本地图片处理。逻辑分成两步。

第一步,识别 Markdown 中的图片链接。工具只处理本地文件路径,远程图片、data: 图片和其他非本地资源会直接跳过。相对路径统一按 Markdown 文件所在目录解析;同一张图片多次出现时,只上传一次,后续复用上传结果。

第二步,走平台当前网页编辑器所使用的媒体上传链路。这里我不展开具体接口细节,只保留实现层面的关键抽象:先拿一次临时授权,再申请上传会话,上传二进制文件,最后提交会话并换取可用于正文的正式资源地址。

这部分实现时有两个容易踩坑的点。

一个是媒体链路里通常存在平台侧资源标识和上传会话标识,它们对最终提交格式有约束,不能只看见"上传成功"就结束。另一个是正文内容和图片元数据往往要同时更新,编辑器能显示图片,并不代表文章提交后的内容结构就是完整的。

所以在实现里,prepareJuejinContent() 不只是简单替换 Markdown 图片地址,它还会同步整理 HTML 内容和平台要求的图片元数据,确保草稿、编辑器预览和最终文章使用的是同一组资源描述。做完这一层之后,发布流程才算真正稳定。

七、更新文章时,尽量不要碰用户没让你碰的东西

文章发布之后,本地稿件继续修改是常态。于是 update 命令对应解决的是另一个高频问题:如何让本地 Markdown 安全地同步回线上文章。

bash 复制代码
juejin-skill update \
  --article https://juejin.cn/post/<article-id> \
  --file ./article.md

默认行为依旧是更新草稿。如果要进一步同步到公开文章,还需要显式确认:

bash 复制代码
juejin-skill update \
  --article https://juejin.cn/post/<article-id> \
  --file ./article.md \
  --publish \
  --confirm-publish

更新流程比新建草稿多了一层:程序会先根据文章链接获取文章详情,再找到与之关联的编辑上下文,随后更新对应草稿。这里最重要的不是"把正文换掉",而是"尽量保留原文章的其他信息"。

分类、标签、封面、摘要、主题、原创状态这类元信息,理论上都应该继承原值。否则用户只是想修一张图、改一个段落,最后却把封面清空了、标签弄丢了,体验会非常差。

另外,平台里某些标识字段的数值范围已经很大,更新逻辑里如果处理不谨慎,容易遇到精度问题。所以实现上我优先沿用原始字符串形式,而不是把所有字段都当作普通数字处理。

我对这部分能力的要求一直很明确:少做多余的事。正文可以更新,图片可以重传,但用户没有显式要求修改的元信息,不应该被工具顺手"重置"。

八、下载能力补齐了内容闭环

除了发布和更新,项目里还补了一组下载命令:

bash 复制代码
juejin-skill download --article https://juejin.cn/post/<article-id>
juejin-skill download-user --user https://juejin.cn/user/<user-id> --max-count 20

下载单篇文章时,程序会优先尝试获取平台返回的 Markdown 内容;如果拿不到,再退回到浏览器抓取页面正文 HTML,并通过 Turndown 转回 Markdown。

如果带上 --download-images,远程图片会一并下载到本地目录,Markdown 里的地址也会被改写成本地相对路径。

这个能力一开始只是"顺手做了",后来反而让我觉得它很重要。它让文章内容形成了闭环:线上文章可以回收到本地 Markdown,本地 Markdown 也可以重新发布和继续更新。对长期写技术文章的人来说,本地原稿、图片和历史版本始终掌握在自己手里,是非常有价值的事情。

九、Codex 和 Claude Code 共用一套运行时

juejin-skill 同时服务 Codex 和 Claude Code,但我没有为两个平台各维护一套逻辑,而是让它们共用同一套 Node.js CLI。

Codex 侧主要依赖 SKILL.md,用于描述何时触发、有哪些命令、默认的安全边界是什么;Claude Code 侧则在 .claude/commands/ 下定义命令文档,例如 juejin-publish.mdjuejin-hot.mdjuejin-login.md

两边文档格式不同,但最终调用的都是同一个入口:

bash 复制代码
node ./bin/juejin-skill.js ...

构建脚本会生成两套分发目录:

text 复制代码
dist/codex/juejin-skill
dist/claude/juejin-skill

这种结构的好处很直接:功能只实现一次,测试也围绕同一套核心代码展开,平台适配层只负责"怎么接",而不负责"怎么做"。

十、既然打算复用,就把工程化一起补齐

如果一个 Skill 只能在作者自己的机器上工作,它本质上还只是个人脚本。既然想让它被复用,就必须把版本发布和交付路径也做完整。

现在推送 v* 标签后,GitHub Actions 会自动完成这些事:

  1. 安装依赖和浏览器运行环境。
  2. 运行 lint 和测试。
  3. 构建 Codex、Claude 两套产物。
  4. 生成版本说明。
  5. 打包并上传 Release 附件。

这一层我也踩过一个很典型的 CI 坑:自动创建 Release 时返回权限错误。最后定位下来,并不是脚本逻辑有问题,而是 workflow token 缺少写入发布内容所需的权限。

这类问题并不复杂,却很容易被忽略。尤其当一个工具要交付给别人安装和使用时,构建产物、版本说明、Release 附件、安装入口这些环节都必须跑通,否则代码虽然写完了,使用体验仍然是不完整的。

十一、测试守住的是我们自己的边界

这个项目的测试覆盖了认证、标签、图片上传、发布更新、下载和分类推荐等核心能力。

我没有试图在测试里证明"平台永远不会变"。第三方接口会调整,页面结构会变,登录策略也可能改变。测试真正适合守住的,是我们自己定义的那些边界和规则。

例如:

  • 同一张本地图片重复出现时,只上传一次。
  • 远程图片和 data: 图片不会被误判为本地文件。
  • 更新文章时尽量保留原有分类、标签、封面和主题信息。
  • 没有明确确认时,发布流程只停留在草稿阶段。
  • 标识字段尽量沿用字符串表示,降低精度风险。

这些规则稳定之后,排查问题会快很多。出故障时,我们能够更快区分:到底是登录态失效、平台接口变化、媒体链路异常,还是我们自己的参数组织出了偏差。

十二、最后想强调的,其实是边界感

到这里,juejin-skill 已经不再只是一个"帮我把文章发出去"的脚本,而是一条相对完整的内容工作流:查分类、查标签、读 Markdown、上传图片、创建草稿、更新文章、下载归档,都被串进了同一套工具里。

我比较喜欢它现在的边界。重复操作交给工具,关键决策仍然留给作者。分类可以推荐,图片可以自动上传,草稿可以自动创建;但要不要公开发布、要不要覆盖线上内容,依然需要明确确认。

如果你也习惯在本地写 Markdown,又经常把文章同步到掘金,不妨试试这个 Skill:Moosphan/juejin-skill。它未必是最复杂的工具,但我希望它足够克制、足够稳定,也足够接近真实写作者每天会用到的那条路径。

相关推荐
AI原来如此9 小时前
我用AI Agent做产品设计,省了20小时原型时间
人工智能·ai·大模型·ai编程
AI小百科9 小时前
端到端AI编程的核心原理
ai编程
Loli_Wolf10 小时前
AI 原生研发闭环:从提需到线上监测,再自动回到提需
人工智能·深度学习·算法·microsoft·ai·ai编程·harness
麦哲思科技任甲林10 小时前
软件开发中的三次法则
重构·ai编程·优化·重复·三次法则
ftpeak10 小时前
AI开发~OpenAI专家之路:构建企业级AI应用(第三部分·上)
人工智能·ai·ai编程
ZengLiangYi10 小时前
ChatCrystal大量对话导入时的内存优化
sql·ai编程
溪言10 小时前
【Claude基础】10.插件开发与生产部署:构建可分发的能力包
ai编程
monkeyhlj10 小时前
Harness理解学习
java·人工智能·python·学习·ai编程