让本地 Agent 自动写教程并发布到掘金:一套可落地的工程实现
很多人说“让 Agent 自动发博客”,听起来像一句产品口号。真正落地时,问题会立刻变得具体:
- Agent 做完一件事,怎么把过程整理成文章?
- 文章怎么保存成可审查、可复用的 Markdown?
- 怎么调用掘金接口创建草稿并发布?
- Cookie 这种登录凭证怎么保护?
- 怎么避免中文乱码、重复发布、接口失败后不可追踪?
这篇不讲概念,直接讲一套可以跑起来的本地实现:让电脑端 Agent 把自己刚完成的工作整理成教程文章,然后自动发布到掘金。
最终效果
我们要实现一个本地发布器,它能完成这条链路:
Agent 完成一个任务
-> 生成技术教程 Markdown
-> 保存到本地 docs 目录
-> 读取掘金 Cookie
-> 创建掘金草稿
-> 发布文章
-> 输出文章链接
-> 再做一次发布结果校验
注意,这里没有依赖任何聊天平台。它就是电脑本地 Agent 自己的发布能力。
项目结构
最小实现可以这样组织:
agent-blog-publisher/
.env
docs/
article.md
publisher/
config.py
juejin.py
publish_article.py
.env 负责保存掘金登录态和默认分类标签。
docs/article.md 是 Agent 写出来的文章。
publisher/juejin.py 负责封装掘金接口。
publisher/publish_article.py 是发布入口。
第一步:配置登录态
掘金创作者接口需要浏览器登录态。最简单稳定的方式,是从已登录掘金的浏览器请求中复制完整 Cookie。
.env 示例:
JUEJIN_COOKIE=sessionid=xxx; uid_tt=xxx; sid_guard=xxx; ...
JUEJIN_AID=2608
JUEJIN_UUID=你的 uuid
JUEJIN_CSRF_TOKEN=
JUEJIN_DEFAULT_CATEGORY_ID=6809637771511070734
JUEJIN_DEFAULT_TAG_IDS=6809640448827588622,6809640403516522504,6809640375880253447
这里有几个注意点:
JUEJIN_COOKIE不要提交到 Git- 不要把 Cookie 发给大模型
- 不要在日志里打印 Cookie
JUEJIN_UUID最好从浏览器请求 URL 中复制JUEJIN_CSRF_TOKEN不是每个账号都必需,遇到校验错误时再补
第二步:读取配置
先写一个简单的配置加载器。
# publisher/config.py
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
def load_dotenv(path: str = ".env") -> None:
env_path = Path(path)
if not env_path.exists():
return
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip())
def csv_env(name: str) -> list[str]:
return [item.strip() for item in os.getenv(name, "").split(",") if item.strip()]
@dataclass(frozen=True)
class JuejinConfig:
cookie: str
aid: str
uuid: str
csrf_token: str
default_category_id: str
default_tag_ids: list[str]
def load_config() -> JuejinConfig:
load_dotenv()
return JuejinConfig(
cookie=os.getenv("JUEJIN_COOKIE", ""),
aid=os.getenv("JUEJIN_AID", "2608"),
uuid=os.getenv("JUEJIN_UUID", ""),
csrf_token=os.getenv("JUEJIN_CSRF_TOKEN", ""),
default_category_id=os.getenv("JUEJIN_DEFAULT_CATEGORY_ID", ""),
default_tag_ids=csv_env("JUEJIN_DEFAULT_TAG_IDS"),
)
这个文件只做一件事:把敏感配置从 .env 读进程序。不要在这里打印 Cookie。
第三步:封装掘金文章模型
我们先定义一个文章结构,避免后面到处传散乱参数。
# publisher/juejin.py
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class JuejinArticle:
title: str
content: str
description: str = ""
category_id: str = ""
tag_ids: list[str] = field(default_factory=list)
cover_image: str = ""
其中:
title是掘金标题content是 Markdown 正文description是摘要category_id是分类 IDtag_ids是标签 ID 列表
第四步:实现掘金客户端
掘金发布通常分两步:
article_draft/create -> article/publish
也就是先创建草稿,再发布草稿。
下面是一个最小可用客户端。
# publisher/juejin.py
import json
import re
import ssl
from typing import Any
from urllib import error, request
JUEJIN_API = "https://api.juejin.cn"
JUEJIN_POST_URL = "https://juejin.cn/post/{article_id}"
class JuejinClient:
CREATE_DRAFT_PATH = "/content_api/v1/article_draft/create"
PUBLISH_ARTICLE_PATH = "/content_api/v1/article/publish"
def __init__(
self,
cookie: str,
aid: str = "2608",
uuid: str = "",
csrf_token: str = "",
default_category_id: str = "",
default_tag_ids: list[str] | None = None,
timeout: int = 60,
):
self.cookie = cookie
self.aid = aid
self.uuid = uuid
self.csrf_token = csrf_token
self.default_category_id = default_category_id
self.default_tag_ids = default_tag_ids or []
self.timeout = timeout
def publish_article(self, article: JuejinArticle) -> dict[str, Any]:
draft = self.create_draft(article)
draft_id = draft["data"]["id"]
published = self.publish_draft(draft_id)
article_id = published.get("data", {}).get("article_id", "")
return {
"draft_id": draft_id,
"article_id": article_id,
"link": JUEJIN_POST_URL.format(article_id=article_id) if article_id else "",
}
def create_draft(self, article: JuejinArticle) -> dict[str, Any]:
payload = {
"title": article.title,
"mark_content": article.content,
"brief_content": article.description or brief_from_markdown(article.content),
"category_id": article.category_id or self.default_category_id,
"tag_ids": article.tag_ids or self.default_tag_ids,
"cover_image": article.cover_image,
"edit_type": 10,
"html_content": "deprecated",
}
return self._post(self.CREATE_DRAFT_PATH, payload)
def publish_draft(self, draft_id: str) -> dict[str, Any]:
payload = {
"draft_id": draft_id,
"sync_to_org": False,
"column_ids": [],
"theme_ids": [],
}
return self._post(self.PUBLISH_ARTICLE_PATH, payload)
def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
url = self._url(path)
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = request.Request(
url,
data=body,
headers=self._headers(),
method="POST",
)
try:
with request.urlopen(req, timeout=self.timeout, context=ssl.create_default_context()) as resp:
data = json.loads(resp.read().decode("utf-8"))
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Juejin HTTP error: {exc.code} {detail}") from exc
if data.get("err_no", 0) != 0:
raise RuntimeError(f"Juejin API error: {data.get('err_no')} {data.get('err_msg')}")
return data
def _url(self, path: str) -> str:
params = []
if self.aid:
params.append(f"aid={self.aid}")
if self.uuid:
params.append(f"uuid={self.uuid}")
query = "?" + "&".join(params) if params else ""
return f"{JUEJIN_API}{path}{query}"
def _headers(self) -> dict[str, str]:
headers = {
"Cookie": self.cookie,
"Content-Type": "application/json; charset=utf-8",
"Accept": "*/*",
"Origin": "https://juejin.cn",
"Referer": "https://juejin.cn/",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0 Safari/537.36"
),
}
if self.csrf_token:
headers["x-secsdk-csrf-token"] = self.csrf_token
return headers
def brief_from_markdown(content: str, max_length: int = 100) -> str:
text = re.sub(r"```.*?```", " ", content, flags=re.DOTALL)
text = re.sub(r"[#>*_`\[\]()>-]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text[:max_length]
这里最重要的是这一行:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
如果这里处理不好,中文就可能变成问号,或者 Markdown 中的换行被错误处理。
第五步:让 Agent 生成 Markdown 文件
Agent 完成任务后,最稳的方式不是把中文正文直接塞进命令行,而是先写入 UTF-8 Markdown 文件。
例如:
from pathlib import Path
def save_article(path: str, title: str, body: str) -> Path:
article = f"# {title}\n\n{body.strip()}\n"
file_path = Path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(article, encoding="utf-8")
return file_path
我强烈建议:发布器只从文件读取文章,不从命令行参数读取长篇中文正文。
原因很简单:Windows 终端、PowerShell、不同 Python 启动方式之间,编码行为不完全一致。正文越长,越容易踩坑。
第六步:发布入口
现在写一个 publish_article.py,读取 Markdown 文件并发布。
# publisher/publish_article.py
from __future__ import annotations
import re
from pathlib import Path
from publisher.config import load_config
from publisher.juejin import JuejinArticle, JuejinClient, brief_from_markdown
def title_from_markdown(content: str) -> str:
match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
if not match:
raise ValueError("Markdown 缺少一级标题")
return match.group(1).strip()
def publish_markdown(path: str) -> dict:
cfg = load_config()
content = Path(path).read_text(encoding="utf-8")
title = title_from_markdown(content)
article = JuejinArticle(
title=title,
content=content,
description=brief_from_markdown(content),
category_id=cfg.default_category_id,
tag_ids=cfg.default_tag_ids,
)
client = JuejinClient(
cookie=cfg.cookie,
aid=cfg.aid,
uuid=cfg.uuid,
csrf_token=cfg.csrf_token,
default_category_id=cfg.default_category_id,
default_tag_ids=cfg.default_tag_ids,
)
return client.publish_article(article)
if __name__ == "__main__":
result = publish_markdown("docs/article.md")
print("draft_id:", result["draft_id"])
print("article_id:", result["article_id"])
print("link:", result["link"])
运行:
python -m publisher.publish_article
如果成功,会输出:
draft_id: xxx
article_id: xxx
link: https://juejin.cn/post/xxx
第七步:发布后做校验
自动发布不要只看接口返回成功,最好再调用创作者文章列表做一次校验。
至少检查:
- 文章 ID 是否存在
- 标题里有没有
? - 摘要里有没有
? - Markdown 正文长度是否异常
示例:
def verify_article(client: JuejinClient, article_id: str) -> None:
data = client._post(
"/content_api/v1/article/list_by_user",
{"page_no": 1, "page_size": 10, "keyword": ""},
)
for row in data.get("data", []):
info = row.get("article_info", row)
if str(info.get("article_id")) != str(article_id):
continue
title = info.get("title") or ""
brief = info.get("brief_content") or ""
if "?" in title or "?" in brief:
raise RuntimeError("发布后检测到疑似中文乱码")
print("verify ok:", article_id)
return
raise RuntimeError("发布后没有在文章列表中找到目标文章")
这一步很有必要。接口返回成功,只说明平台收到了请求,不代表内容一定符合预期。
第八步:Agent 工作流如何串起来
一个本地 Agent 可以这样工作:
def agent_publish_workflow(task_summary: str, implementation_notes: str) -> dict:
title = "让本地 Agent 自动写教程并发布到掘金:一套可落地的工程实现"
body = generate_tutorial_markdown(task_summary, implementation_notes)
article_path = save_article(
path="docs/article.md",
title=title,
body=body,
)
result = publish_markdown(str(article_path))
return result
真实项目里,generate_tutorial_markdown 可以来自大模型,也可以来自模板。
比如模板版:
def generate_tutorial_markdown(task_summary: str, implementation_notes: str) -> str:
return f"""
## 背景
{task_summary}
## 实现思路
{implementation_notes}
## 关键代码
这里放核心实现,而不是只讲概念。
## 踩坑记录
- Cookie 只放本地环境变量
- 正文从 UTF-8 文件读取
- 发布后必须做乱码检测
- 失败时保留草稿 ID,方便追踪
## 总结
这套流程把 Agent 的工作产物变成了可发布、可复查、可持续沉淀的技术文章。
""".strip()
关键工程经验
这套系统真正值得注意的不是“调用了哪个接口”,而是这些工程细节:
1. 正文必须走 UTF-8 文件
不要把长篇中文 Markdown 直接塞进 shell 命令。
更稳的方式是:
Agent -> 写入 docs/article.md -> Python 读取文件 -> 发布
2. Cookie 不能进入模型上下文
Cookie 是账号登录凭证,只应该存在 .env 或本地密钥管理里。
不要让模型读取它,不要在日志里打印它。
3. 发布前最好保留草稿模式
第一次联调建议先只创建草稿。
JUEJIN_DRAFT_ONLY=true
确认分类、标签、正文格式都没问题后,再开启正式发布。
4. 发布动作要幂等
如果 Agent 接收到重复任务,应该能识别“这篇文章已经发布过”,避免重复发文。
可以用本地状态文件记录:
{
"published": {
"docs/article.md": {
"article_id": "xxx",
"published_at": "2026-05-05T12:00:00"
}
}
}
5. 发布失败要保留上下文
失败时至少要记录:
- 草稿 ID
- 文章标题
- 分类 ID
- 标签 ID
- API 错误码
- 错误信息
这样下次可以继续更新草稿,而不是从头再发。
总结
让 Agent 自动发布掘金文章,不是简单地“调一个发布接口”。
一套真正可用的实现,应该包含:
- 本地 Markdown 生成
- UTF-8 文件落盘
- Cookie 本地读取
- 草稿创建
- 正式发布
- 发布后校验
- 错误追踪
- 幂等控制
这样,Agent 才不只是一个会聊天的助手,而是一个能把自己的工作过程自动沉淀成技术资产的本地生产系统。
真正有价值的自动化,不是让机器替你乱发内容,而是让每一次工程实践都能低成本变成可复用、可传播、可积累的知识。
2026-05-11 v0.2 升级补遗
发完原文后我把这套思路又重写了一版,目标是把"手抓 6 个 credential 写 .env"这一步也省掉。
核心思路:让 Chrome extension(jackwener/OpenCLI 的 Browser Bridge)在已经登录的 juejin.cn page context 里跑 fetch:
fetch('/content_api/v1/article_draft/create', {
method: 'POST',
credentials: 'include', // ← 关键
headers: {'content-type': 'application/json'},
body: JSON.stringify({
title, mark_content, brief_content,
category_id, tag_ids,
edit_type: 10, html_content: 'deprecated'
})
})
因为是同源请求 + credentials: 'include':
- HttpOnly sessionid 浏览器自动带(同源规则)
- csrf 从 cookie 自动带(如果服务端从 cookie 读)
- 完全不需要 .env,连 cookie/AID/UUID/CSRF/CATEGORY_ID 都不用手抓
唯一前置条件:用户已经在 Chrome 里登录过掘金 —— 这本来就是必须的。
对比:
| 维度 | v0.1(urllib + .env) | v0.2(OpenCLI + page-context fetch) |
|---|---|---|
| 首次配置 | 抓 6 个 credential | 装 Chrome extension(30 秒) |
| 跨机迁移 | 重抓 6 个 | 装一次 extension |
| cookie 过期 | 重抓 cookie | 浏览器重登一次 |
| 平台兼容 | macOS / Linux / Windows | macOS / Linux / Windows(Chrome 都有) |
边界:
- 签名墙:
/content_api/v1/article/detail(读已发布文章原文)和部分 list 接口要msToken+a_bogus反爬签名,掘金 page 内 axios wrapper 自动算,普通 fetch 拿不到。影响读已发布文章原文这个场景;写接口(article_draft/create/article/publish/article_draft/update)经验证不需要签名。 - 完全 headless 服务器:没人登录 Chrome,仍然得用 v0.1 的 .env 路线。
- 多账号批量调度:v0.1 一份 .env 一个账号管理更简单;v0.2 适合个人单账号。
完整实现:PsChina/web-publish — MIT 开源。本段就是用 v0.2 通过 fetch 直接更新到这篇文章的:先 list_by_user 拿到本文的 draft_id,再 article_draft/update 改 mark_content,最后 article/publish 重新发布 —— 全程 3 个同源 fetch,0 个 DOM 操作。
如果你只发新文章不修改老的,比 v0.1 少 6 个 credential 多 1 个 extension;如果你要 update 已发布文章,比 v0.1 少 6 个 credential,多 1 个 extension + 1 次 list_by_user 反查。