1. 技术文章/

从 Codex 到线上博客:一个只有 240 行的发布流水线是怎么跑起来的

·2279 字·5 分钟

问题是什么 #

我在本地用 Codex 研究 SecurityClaw 源码,学完一个模块后想记录下来。传统流程是:学完→回服务器→写 markdown→hugo 构建→部署。每次切换上下文都很割裂,写出来的文章也停留在「代码笔记」层面,不像一篇能给人看的文章。

理想状态应该是:Codex 那边学了一段代码,说一句「记下来」,几十秒后线上博客自动多出一篇风格统一、有图有分析的技术文章。不要我手动整理、不要我切终端、不要我写 prompt。

这就是 notes_webhook.py 存在的原因——240 行 Python,两条发布路径,一个 HTTP 端点搞定从学习笔记到线上文章的完整流水线。

架构全景 #

flowchart LR
    C[Codex 本地] -->|POST /api/notes| N[notes_webhook.py<br/>FastAPI :7800]
    C -->|POST /api/publish| N
    N -->|Bearer Token| A{鉴权}
    A -->|通过| P{判断路径}
    P -->|同步| H1[写 Hugo markdown]
    P -->|异步| T[后台线程]
    T -->|DeepSeek API| LLM[LLM 扩张]
    LLM --> H2[写 Hugo markdown]
    H1 --> D[deploy.sh]
    H2 --> D
    D --> S[线上博客<br/>tttjhgan.top]

两条路径的区别在于「文章谁来写」:

同步路径 (publish)异步路径 (notes)
端点POST /api/publishPOST /api/notes
谁写文章Codex 已经写好Codex 只给了碎片笔记
响应时间构建完成后返回(~5s)秒回 accepted(~12ms)
后台动作DeepSeek LLM 扩张 + 发布(~60s)
适用场景完整博客、公告、教程学习笔记、碎片观察

核心实现:三条代码链路 #

链路一:鉴权(别让全互联网给你发文章) #

notes_webhook.py:77-81

def verify_token(credentials):
    if not NOTES_TOKEN:
        return
    if credentials is None or credentials.credentials != NOTES_TOKEN:
        raise HTTPException(status_code=401, detail="Invalid or missing token")

这是一个 FastAPI 的 Depends 依赖注入。每个写端点(publish / notes)都会自动调用它。如果没有提供 Authorization 头或者 token 不匹配,直接 401。

有意思的是 if not NOTES_TOKEN 这个判断——如果环境变量里没设 token,整个验权就跳过了。这是为了方便本地调试,但生产环境 ENV 里肯定有值(systemd service 文件里注入)。

链路二:同步发布(我来写,你帮我发布) #

notes_webhook.py:207-232

整个函数不到 30 行:

  1. 生成 slug(title → URL 友好的短标识)
  2. 拼 Hugo frontmatter + body 写文件
  3. 如果不是草稿,跑 deploy.sh
  4. 返回线上 URL

没有任何奇技淫巧,就是机械地搬数据。说实话,这个端点的核心价值不在代码本身,而在于它让 Codex 不用登录服务器 SSH、不用知道 Hugo 目录结构——只发一个 HTTP POST,后端帮你搞定一切。

链路三:异步笔记扩张(这个有意思) #

notes_webhook.py:185-204

@app.post("/api/notes")
async def receive_note(payload: NotePayload, _=Depends(verify_token)):
    num = _next_chapter_num()
    slug = _build_slug(payload.title, chapter)
    url_estimate = f"https://tttjhgan.top/securityclaw-learning/ch{num:02d}-{slug}/"

    def background():
        expanded = _expand_notes(payload.title, payload.content)
        url, ok = _publish_chapter(num, slug, expanded, payload.title, payload.tags)

    threading.Thread(target=background, daemon=True).start()
    return {"status": "accepted", "url": url_estimate, "eta": "~60s"}

设计取舍:为什么用 threading.Thread 而不是 FastAPI 的 BackgroundTasks

坦率的讲,两个原因:一是 BackgroundTasks 在 FastAPI 里跑在同一个事件循环里,如果 LLM 调用卡 90 秒(我设的 timeout),整个事件循环都可能被堵;二是这个项目的并发量就我一个用户,thread 完全够用,不需要上 Celery 或 Redis 队列——那些东西的运维成本比 240 行代码还高。

LLM 扩张的核心在 _expand_notes 函数(notes_webhook.py:118-137)。它做的事情:

  1. 拼接 system prompt(一段 20 行的写作风格指令,卡兹克风格) + user message(标题+原始笔记)
  2. POST 到 DeepSeek /v1/chat/completions
  3. 90 秒超时
  4. 失败时降级:直接返回原始笔记,不会导致文章丢失

我刚开始也纳闷为什么 temperature 设 0.3 而不是更高——后来发现 DeepSeek 在这种低温度下输出最稳定,不会凭空编造 SecurityClaw 里没有的函数名。对于「扩张已有内容」而不是「创造新内容」的任务,低温度反而是对的。

链路四:章节编号(怎么知道下一个是 ch 几) #

notes_webhook.py:103-110

def _next_chapter_num() -> int:
    existing = [p.name for p in CHAPTERS_DIR.glob("ch*.md")]
    max_num = 0
    for f in existing:
        m = re.match(r"ch(\d+)-", f)
        if m:
            max_num = max(max_num, int(m.group(1)))
    return max_num + 1

很简单但很可靠的设计:扫描目录里所有 chNN-xxx.md 文件名,取最大的数字 +1。不依赖数据库、不依赖全局状态、不需要分布式协调——因为只有一个 writer,就是这台服务器上的这一个进程。

生命线:systemd 保活 #

[Service]
Type=simple
Restart=always
RestartSec=5
Environment=NOTES_PORT=7800
Environment=NOTES_TOKEN=***
Environment=DEEPSEEK_API_KEY=sk-***
ExecStart=.../venv/bin/python notes_webhook.py

关键点:

  • Restart=always — 进程挂了 5 秒后自动重启
  • 环境变量在 service 文件里注入,不依赖 shell profile
  • 用 SecurityClaw 项目的 venv(因为有 requests 等依赖)

说实话这块可以改进——最好给 notes_webhook 自己建一个独立 venv,不要蹭 SecurityClaw 的环境。但现在的做法最大的优点是简单:少一个 venv 就少一个维护成本,而且 SecurityClaw 的依赖集本来就很稳。

安全边界 #

  1. Token 鉴权:所有写操作(publish/notes)都需要 Bearer token
  2. 读操作无需鉴权/api/health/ 根路径是公开的,方便 UptimeRobot 等监控
  3. LLM prompt 指令注入:Expansion prompt 里硬编码了一条「删除密钥、主机地址、内部路径」的指令
  4. 无凭证落盘:笔记原始内容不进数据库,只临时在内存里传给 LLM,生成完文章后写入 Hugo 目录

验证命令 #

# 检查服务状态
systemctl status notes-webhook

# 查看最近日志
journalctl -u notes-webhook -f

# 手动触发健康检查
curl http://localhost:7800/api/health

# 提交一篇笔记(需要 token)
curl -X POST http://localhost:7800/api/notes \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "测试", "content": "笔记内容", "chapter": "test", "tags": ["test"]}'

Codex 侧配置:hermesagent-publish-notes skill #

Codex 这边不是直接 curl——有一个专门的 skill 负责对接这个 webhook:

https://github.com/tttjhgan/skills/tree/main/hermesagent-publish-notes

这个 skill 给 Codex 注册了两个工具:

Tool 1: publish_blog → POST /api/publish

  • 传 title、content、tags、categories
  • 用于发布已完成的博客文章

Tool 2: submit_learning_note → POST /api/notes

  • 传 title、chapter、content、tags
  • 用于提交碎片笔记,后端 LLM 扩张

两个工具都通过 Authorization: Bearer *** 鉴权。skill 文件里的 token 是 Codex 本地的,不会暴露到仓库(.gitignore 里过滤了)。

每次学完一个 SecurityClaw 模块,在 Codex 里说一句「把这段笔记发到博客,章节选 xxx」,Codex 调 submit_learning_note,文章几秒后就在线上了。

下一步 #

  • 给 notes_webhook 建独立 venv(不过不急,现在跑得好好的)
  • 给 /api/notes 加一个可选参数 skip_expand,跳过 LLM 直接发布
  • 给文章生成后发一个通知(Telegram bot)

但这三条都属于「有了更好,没有也行」的优化。当前这个 240 行的 webhook 已经稳定运行了三周,处理了几十篇笔记,唯一一次出问题是 DeepSeek API 超时——降级逻辑兜住了。


这篇博客就是通过这个 webhook 发布上线的。自己吃自己的狗粮。