让本地 Agent 自动写教程并发布到掘金:一套可落地的工程实现

22 阅读10分钟

让本地 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 是分类 ID
  • tag_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 / WindowsmacOS / Linux / Windows(Chrome 都有)

边界

  1. 签名墙/content_api/v1/article/detail(读已发布文章原文)和部分 list 接口要 msToken + a_bogus 反爬签名,掘金 page 内 axios wrapper 自动算,普通 fetch 拿不到。影响读已发布文章原文这个场景;接口(article_draft/create / article/publish / article_draft/update)经验证不需要签名。
  2. 完全 headless 服务器:没人登录 Chrome,仍然得用 v0.1 的 .env 路线。
  3. 多账号批量调度: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 反查。