我把 Claude Code 发掘金的 token 成本砍到 1/50:web-publish 设计实录
让 AI 写完文章直接发掘金听起来很美。实操中 Claude 会把整篇 markdown 反复 base64 进出 context,一次发文烧 5--14k tokens。本文拆解 web-publish 用 Python CLI + 双 Backend 把这个数字压到 ~300 tokens 的设计、取舍与踩坑。
📦 项目地址(开源 MIT):github.com/PsChina/web... ------ 觉得有用欢迎 Star,平台 adapter 也欢迎 PR。
读这篇文章你能拿到的:
- 一个真实的 Agent token 经济学案例------不是"理论上能省",是从 14k 降到 300 的实测数据
- CLI vs JS eval 两种"让 AI 干活"的路线对比,及为什么 CLI 路线更可持续
- urllib vs 浏览器同源 fetch 的实战取舍------为什么我们一开始选错了 default
- 平台 API adapter 的 yaml 驱动设计,以及如何用 list 接口绕开签名墙
- Agent + CLI 职责分层:Claude 做什么、Python 做什么的边界
如果你也在给 Claude Code / Cursor / Cline 这类 Agent 写工具集成,希望能少走一些弯路。
一、痛点:让 Claude 发文章,为什么"看起来很 trivial 实际烧爆 token"?
掘金的发文 API 不复杂------一个 article_draft/create,一个 article/publish,几个固定字段。让 Claude 直接调用看起来一句话就能搞定:
把
/tmp/article.md发到掘金,标题 X,tag Y。
v0.2 我们就是这么干的,让 Claude 现场生成 fetch JS 字符串塞给 OpenCLI 让浏览器 eval。能用,但每次发文会触发以下事情:
| 阶段 | 进入 Claude context 的内容 | 估算 token |
|---|---|---|
| Read markdown | 整篇 markdown 全文(中文每字 ~2 token) | 3,000 -- 6,000 |
| 生成 JS payload | 把 markdown base64 拼到 JS 模板字符串里 | 5,000 -- 8,000 |
| OpenCLI eval 返回 | API response 里包含发布后的 mark_content 全文 | 3,000 -- 7,000 |
| 验证 + 解析 | Claude 再 grep 一遍找 article_id |
几百 |
| 合计 | ~5,000 -- 14,000 tokens / 篇 |
如果文章长一点(5000 字),单次发布烧到 14k tokens 一点不夸张。写得越多,发得越贵。
更糟的是 update 已发文章场景:要拿原文做拼接,掘金 article_draft/detail 返回里也带 mark_content,又是一次全文进出 context。一次"在文末加一段"轻松 12k+ tokens。
按 Claude Sonnet 4.6 的输入价钱($3/MTok),单篇 14k tokens 约 ¥0.3 。看起来不多,但你试一下每周发 5 篇、每篇被 update 3 次......月成本就上去了,而且这只是调用工具消耗,文章本身的写作、优化、审稿那些 token 还没算。
更本质的问题不是钱,是上下文污染。Claude 的 context 里挤进去几万 token 的 markdown 原文 + base64 + JSON response,后续对话每轮都要带这些垃圾,回答质量肉眼可见地下降。
二、设计目标:把 Agent 当客户端,而不是当 API 调用器
v0.3 重新审视这个问题的角度:Agent 不擅长干的事,就别让它干。
| 任务 | 该谁做 | 为什么 |
|---|---|---|
| 文章内容优化(标题字数、tag 推荐、80 字摘要) | Claude | 需要中文 SEO 直觉、平台特性理解 |
| HTTP 请求、JSON 编解码、cookie 管理 | Python CLI | 确定性高,没必要让 LLM 推理 |
| API endpoint / 字段 schema / 分类 ID 表 | YAML adapter | 平台改 API 时只改 yaml,不动代码 |
| 长 markdown 读盘 / 发送 | CLI 内部 | 文件路径进 Agent context 就够,全文别进 |
落地成这个分层:
scss
┌─────────────────────────────────────────────┐
│ Claude(Agent 视角) │
│ - Read markdown 一次(做 SEO 决策) │
│ - Bash 调一行 web-publish publish ... │
│ - 解析 JSON stdout(~300 tokens) │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ web-publish CLI(Python) │
│ - argparse 解析 flags │
│ - 内部读 markdown 全文(不进 Agent) │
│ - YAML adapter 拿 endpoint / 分类 / tag │
│ - 调 Backend.post(...) 拿 data │
│ - 输出单行 JSON │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Backend(两种实现,同一协议) │
│ - UrllibBackend (.env + urllib) │
│ - OpenCLIBridgeBackend (浏览器同源 fetch) │
└─────────────────────────────────────────────┘
Backend 协议本身极简(一个 post 方法),但承担了"屏蔽鉴权差异"的核心职责:
python
class Backend(Protocol):
name: str
def post(self, path: str, payload: dict,
query_params: dict | None = None) -> dict:
"""POST 到平台 API,返回 data dict,err_no != 0 抛 BackendError"""
JuejinClient 持有一个 backend,完全不感知 backend 是 urllib 还是浏览器同源 fetch------这是后面 dual backend 能干净切换的基础。
落到 token 账本:
| 路径 | 每次 publish 烧 Claude tokens | Agent 看到的 |
|---|---|---|
| v0.1(用户旧文 raw urllib) | ~1,000 | 用户级 Python 库调用,半结构化 |
| v0.2(Claude 直生成 JS) | 5,000 -- 14,000 | Read + eval JS + response data |
| v0.3(Python CLI 包装) | ~300 | 1 行 Bash + 1 行 JSON |
~50× 的成本降幅 ,关键不是技术上做了什么牛逼的事,而是把不该让 LLM 处理的字节挡在 context 之外。
三、Dual Backend:为什么 default 选 urllib,不选"零配置"那个
v0.3 第一版选了 opencli-bridge 当 default------通过 OpenCLI 浏览器扩展让 Chrome 在已登录的掘金页面里跑 fetch,cookie 是 same-origin 自动带的,用户连 .env 都不用配。
听起来美好,实测打脸:
| 失效场景 | urllib | opencli-bridge |
|---|---|---|
| Chrome 没开 | ✅ work | ❌ extension not connected |
| Chrome 开着但用户切去别的 tab | ✅ work | ⚠️ 需 background tab,opencli v1.7.16 超时 |
| Chrome 重启后 extension service worker 睡了 | ✅ work | ❌ doctor reports not connected |
| 服务器 / CI / headless 环境 | ✅ work | ❌ 完全跑不了 |
Active tab 在 about:blank / chrome:// |
✅ work | ❌ 相对 URL fetch 解析失败 |
| 长期可移植性(OpenCLI / Chrome / extension API 升级) | ✅ Python stdlib 零依赖 | ⚠️ 多层依赖链单点失败叠加 |
"零配置"的代价是脆。 用户体感是"昨天还好的,今天怎么不行了"------而原因可能在 Chrome 重启、extension 自动更新、操作系统休眠......一长串无法预测的因素。
urllib 的唯一额外成本是用户跑一次 web-publish setup 用编辑器粘 cookie(~30 秒),从此 forever stable。
最终设计:
urllib= default,覆盖 90% 场景(个人桌面 / 服务器 / CI)opencli-bridge=--backend opencli-bridge显式选项,留给"我就想一次性发个文章不想抓 cookie"的临时场景
教训:给 AI Agent 写的工具,可靠性 > 零配置。Agent 不像人会读"重启一下 Chrome 试试"------它会傻乎乎重试,把 context 烧穿。
四、Cookie 引导状态机:不让 AI 烦用户
但 urllib backend 总要让用户配一次 cookie。这是 dialog 设计的硬骨头:
- 第一次发文:当然要引导用户配 cookie,否则 .env 不存在直接报错
- 第二次发文:用户上次配好了,应该静默走 urllib
- 第二次发文,但用户上次拒绝了:不能再问,他知道有这个选项
- 第二次发文,用户上次说"先发吧晚点配"------10 分钟过去了:他显然忘了,但不要再 block 他发文章
- 第十次发文,用户从未配过:彻底放弃引导,永远 fallback
把这堆条件写在 SKILL 里让 Claude 推理?等着翻车。
web-publish 把这逻辑用一个状态机命令封装:
bash
$ web-publish state
{
"version": 1,
"publish_count": 0,
"setup_completed": false,
"cookie_setup_started_at": null,
"cookie_skip_count": 0,
"cookie_dismissed": false,
"last_publish_at": null,
"last_backend_used": null,
"_env_exists": true,
"_recommendation": {
"action": "use_urllib",
"reason": ".env 在 (旁路 setup)"
}
}
关键是 _recommendation.action,6 种可能:
| action | Claude 行为 |
|---|---|
use_urllib |
静默走 urllib,不问问题 |
guide_setup_first_time |
提示用户 30 秒配 cookie,记录"已引导" |
use_opencli_after_timeout |
引导过但用户拖了 10 分钟,静默用 opencli-bridge 不再 block |
use_opencli_skipped |
用户明确说"先发吧",本次用 opencli,不重复引导 |
use_opencli_dismissed |
skip 3 次,永远不再问,静默 opencli |
ask_after_publish |
publish 成功后温和问一次"要不要配 cookie 之后更稳" |
web-publish state record-skip 和 record-setup-started 是 Claude 调的副作用命令,让状态机推进。
设计要点:别把"如果......就......"的 if-else 写在 Prompt 里让 LLM 推理 ------把它写成代码,Agent 只读 action 字段执行。这条经验跨工具普适:状态决策能用代码表达就用代码,Prompt 不是逻辑容器。
五、Adapter YAML:从代码里拽出"会过期的东西"
平台 API 会改。endpoint 改路径、字段加 required、新签名机制------半年到一年一定会被打脸一次。
如果把这些写在 Python 里,每次平台改版都要发新版本、用户都要升级。所以单独抽 adapters/juejin.yaml:
yaml
platform: juejin
api_base: https://api.juejin.cn
endpoints:
# ✓ 不需签名,credentials:include 同源带 cookie 即可
list_by_user: POST /content_api/v1/article/list_by_user
draft_detail: POST /content_api/v1/article_draft/detail
draft_create: POST /content_api/v1/article_draft/create
draft_update: POST /content_api/v1/article_draft/update
publish: POST /content_api/v1/article/publish
# ✗ 需 msToken + a_bogus 反爬签名(page 内 axios wrapper 自动算)
article_detail: POST /content_api/v1/article/detail
categories:
后端: "6809637769959178254"
前端: "6809637767543259144"
Android: "6809635626879549454"
人工智能: "6809637773935378440"
开发工具: "6809637771511070734"
代码人生: "6809637776263217160"
known_tags:
AI编程: "7467857238494020000"
OpenAI: "6809641073527226000"
AIGC: "7197380506562871000"
content_rules:
title_max: 80
title_recommended_range: [20, 30]
brief_max: 100
tags_max: 5
这份 yaml 是 SDK 的"协议存档"。它告诉你:
- 哪些 endpoint 可以裸 fetch 用(写操作 + list 接口)
- 哪些 endpoint 撞签名墙 (
article_detail------掘金给已发布文章详情加了msToken + a_bogus反爬,普通 fetch 拿不到) - 分类 / tag ID 在哪儿查表(不调网络,纯内存映射)
- 优化规则数字(Claude 做 SEO 决策时直接读这些数字)
平台改 endpoint?改 yaml 重发布 adapter,不动 Python 代码。
5.1 实战:用 list 接口绕开签名墙
掘金 article/detail 接口需要 msToken + a_bogus 反爬签名,这个签名是页面内 axios wrapper 在请求拦截器里现算的。我们的 backend 是裸 fetch,算不出来。
但 update 已发文章的流程需要"拿原 mark_content 拼接"------怎么办?
答案在 yaml 注释里:
article_detail: 用list_by_user+draft_detail组合绕开
观察到 list_by_user 返回每篇文章的 metadata 里带了 draft_id ,而 article_draft/detail(草稿详情)不需要签名------掘金没把反爬装在草稿接口上。所以 update 流程变成:
python
def find_draft_id(self, article_id):
for entry in self.list_by_user(page_size=100):
info = entry.get("article_info", {})
if str(info.get("article_id")) == str(article_id):
return info.get("draft_id")
return None
# update 流程
draft_id = client.find_draft_id(article_id) # ← 用 list 反查
cur = client.get_draft(draft_id) # ← 草稿接口拿原 markdown
new_content = cur["mark_content"] + supplement
client.update_draft(draft_id=draft_id, mark_content=new_content, ...)
client.publish(draft_id)
绕了一圈但完全没碰签名墙 。这也是为什么 list_by_user 接口被设计成 SDK 的核心 primitive 而不是辅助功能。
教训:API adapter 设计时认真区分"哪些接口要签名、哪些不要",能优雅绕开就别去逆向签名算法------后者维护成本极高,平台一改你就废。
六、Agent 视角:发一篇文章到底烧了多少 token
把上面所有设计串起来,看一次完整的 /publish juejin /tmp/article.md 在 Claude 视角是什么样:
bash
Claude:
Read /tmp/article.md ← 仅这一次进 context(用于 SEO 决策)
Bash:
web-publish state ← 1 行 JSON in,决策 action
Bash:
web-publish publish juejin /tmp/article.md \
--title "我把 Claude 发掘金的 token 成本砍到 1/50:web-publish 设计实录" \
--brief "..." \
--tag-ids "AI编程,OpenAI,AIGC" \
--category "开发工具"
→ {"platform":"juejin", "article_id":"...", "post_url":"https://juejin.cn/post/..."}
Output:
✅ 已发布到掘金(审核中)
URL: https://juejin.cn/post/...
token 账:
- 文章 Read 一次:~3k--6k(无法避免------Claude 要看内容才能做 SEO 决策)
web-publish stateJSON:~150web-publish publish命令行 + JSON 返回:~200- 总 overhead(Read 之外):~350 tokens
vs v0.2 的 5k--14k overhead------把 markdown 本身从"反复进出 context 的中间产物"变成"只读一次的决策依据"。
更关键的是 markdown 永远不进 Bash 字符串------文件路径作为位置参数传给 CLI,CLI 自己读盘。这避免了:
- 把 markdown 嵌入 shell 命令导致的 quoting 灾难(中文、引号、反引号、
$) - markdown 体积放大上下文
- 二次 Read 浪费
七、一个值得展开的细节:subprocess 启动 + JSON 双重 stringify
OpenCLIBridgeBackend 用 subprocess.run 调 opencli browser eval <js>。OpenCLI 把 JS 的 return value 输出到 stdout。
第一次实测发现 stdout 是 "{\"err_no\": 0, ...}"------多了一层引号。原因是我们的 JS 做了:
javascript
return JSON.stringify(await r.json());
而 OpenCLI 把这个返回值又 stringify 了一次包成 JSON string。所以 backend 解析时要 robust 一点:
python
data = json.loads(raw)
if isinstance(data, str):
# opencli 双重 stringify,再解一层
data = json.loads(data)
教训:跨 subprocess + JSON 边界要假设有"包装层" ------能 wrap 一次的工具未必不会 wrap 两次。isinstance(data, str) 这个分支看似冗余,但实测必要。
八、还没解决的
- CSDN / 知乎 adapter:endpoint schema 不同,写过 PoC 但还没固化(v0.4 计划)
- 撞反垃圾 :同账号短时间发"高度相似内容"会被掘金拦
err_no=2,目前只能让用户改内容或等几分钟 - 审核状态轮询 :
spost_url→post_url的跳转目前不做自动检测,给用户的 URL 是post_url(生效后能访问) - 多账号调度:urllib backend 实际上已经支持,但 SDK 上层还没暴露
- 多平台一键发:故意没做------平台特性差异(标题字数、tag 规则、分类)让"一键多平台"是个伪需求;用户应该明确选哪个平台
九、可以借走的方法论
如果你也在做 AI Agent 工具集成,几条可复用的经验:
- 算 token 账,比算性能账更重要。Agent 工具的瓶颈不是延迟,是 context 污染------每次工具调用都问"什么字节必须进 context,什么不必须"
- 把不该让 LLM 处理的事推回代码。Cookie 引导状态机、API endpoint 表、字段 schema------能写成确定性代码就别让 Prompt 推理
- adapter / config / endpoint 表分离。平台 API 改版的频率比你预期的高,把易变的东西从代码里拽出来
- default 选可靠的,不选漂亮的。"零配置"听起来很美,但脆。给 Agent 用的工具尤其要 boring
- 状态机命令优于 Prompt 中的 if-else。状态决策能用代码表达就用代码
试试看
项目地址 :github.com/PsChina/web...(MIT 开源,欢迎 Star ⭐)
一行装好:
bash
curl -sSL https://raw.githubusercontent.com/PsChina/web-publish/main/curl-install.sh | bash
web-publish setup # 配一次 cookie(30 秒)
然后在 Claude Code 里:
bash
/publish juejin ./你的文章.md
这篇博客本身就是用 web-publish 发的------你正在读的版面、tag、分类,全是 Claude 调 web-publish publish 一行命令产出的。一个螺旋的成就感时刻 🌀。
参与贡献
仓库地址:github.com/PsChina/web...
最想要的 PR 方向:
- CSDN / 知乎 / 思否 / 博客园 adapter :照着
adapters/juejin.yaml抄一份,跑通你账号的 endpoint 就行 - Backend 新实现:除了 urllib / opencli-bridge,也欢迎 Playwright / Selenium backend
- 多平台一键发 / 草稿同步 / 文章迁移:基础 SDK 已经稳定,上层组合应用大有空间
- Issue 反馈:哪个 endpoint 失效了?哪个平台你想要?提 Issue 直接说
如果你觉得这个工具救过你的 token 账单 / 让你写博客更轻松,点个 Star 是对作者最直接的鼓励。也欢迎在掘金评论区聊"AI Agent 工具集成"的更多踩坑------大家一起把 AI 工具链的可靠性提上来。