起因:自己写博客已经习惯了 Markdown + 本地编辑器,但每次想发新文章都得打开浏览器、登录后台、复制粘贴、等图片上传。真正高效的工作流是:本地写完
.md,一条命令发出去。这篇文章讲一个完整的端到端实现:服务端加认证 API + 本地 CLI 工具 + Markdown 渲染。代码用 Python(标准库 + Django 3.x),CLI 侧用 Node.js 做 Markdown 渲染(复用网站编辑器的同一引擎)。
整套方案不绑死任何特定技术栈——后端可以是 Django/Flask/FastAPI,CLI 可以是 Python/Go/Rust,Markdown 引擎可以换。设计原则是通用的。
CLI 自动做这些事:读 Markdown 文件 → 调本地 Node 引擎渲染成 HTML(跟网页编辑器输出格式一致)→ 通过 HTTPS 调服务端 API(Bearer token 鉴权)→ 写库 + 返回 URL。
新加的 API 不做鉴权 → 任何人知道 URL 就能以任意身份发文。最常见的灾难。所以 CLI 一上来优先级就是:
不复用网站已暴露的、未鉴权的发文 endpoint
单独开一个新 endpoint,强制要求认证
用 Bearer token(无状态、易集成、CLI 友好),不要用 session cookie
Token 携带的不是密码本身,而是密码的某种编码形式——这样 CLI 文件、bashrc、log 里出现 token 不会直接泄露明文密码
最终方案:base64(column_name:column_password) 作为 token,服务端解码后跟数据库里的列做比对。跟原有"作者用账号密码登录"的机制完全一致,不引入新的用户体系。
新 API 的请求体/响应体格式要对齐网站后端已经在用的字段——title author summary md html isprivate title_color。别发明新字段。原因:
同一作者的同一篇文章,通过 Web 编辑器改和通过 CLI 改,结果应该完全一样
数据库 schema 一行不动
CLI 工具就像"另一种 UI",没特殊地位
这是最容易被忽略的点。Web 编辑器输出的 HTML 通常带很多专有属性(data-sign、class="cherry-xxx"、自定义代码块 wrapper),是用来在前端做交互、定位、还原的。如果 CLI 用通用 Markdown 库(Python markdown)渲染,输出是普通 <h1><p>,浏览端样式会崩。
正确做法:CLI 端用跟网页编辑器同款的渲染引擎。
实现细节:Cherry 是浏览器库,要在 Node 跑需要用 jsdom polyfill window / document。这里有个容易踩的坑——ESM 的 import 是 hoisted 的,如果把 import cherry-markdown 写在 polyfill 之前,模块加载时就 ReferenceError: window is not defined。解法:用动态 await import(...) 推迟到 polyfill 之后。
CLI 端渲染失败 网络挂了 服务端返回 4xx,每一步都要给清晰的报错,不能自动降级到"用 Markdown 原文当 HTML 存"——前端模板用 mark_safe 渲染 HTML 字段,原文进去就显示成字面文本 # 标题,看起来"发布成功了"但实际是垃圾。
最小化原则——一个函数搞定,不要继承、混入、装饰器嵌套。鉴权、字段校验、写库全在一个 view 里。最小骨架(生产代码会处理边界情况、update/create 分支等):
新加一行:path('api/cli/publish_article', views.cli_publish_article)。注意放在 urlpatterns = [...] 里面(我踩过写到外面 404 的坑)。
两件容易漏的事:
WSGIPassAuthorization On 必须显式开启。mod_wsgi 默认会剥掉 Authorization 头(CGI/WSGI 协议冲突)。不开的话,CLI 端发了 Bearer,服务端拿到的 header 是空的,永远 401。
代码热加载:mod_wsgi 5.0 daemon mode 下,touch wsgi.py 就够触发 worker 重新加载,不需要 systemctl restart apache2。但要真实验证——别听文档一面之词。
数据库 TEXT 字段上限 65,535 字节。md html 在数据库里是 base64 编码的(跟网页编辑器一致),所以原始字节 × 4/3(base64 膨胀率)≤ 65,535,原始 md html ≤ 48,000 字节。title 250、author 250、summary 65,500。所有这些限制在服务端显式校验——不要等到 OperationalError(1406) 才在 log 里看错。
CLI 主程序只需要 std-lib:argparse + json + urllib.request + subprocess + base64。不引入 requests/httpx/click——CLI 工具越少依赖越好分发。
CLI 端调 Node 渲染这一步是整个工具的精髓——用 subprocess.run() 启 node render.mjs,stdin 灌 md,stdout 收 HTML。出错时把 stderr 抓出来,打印一行清晰报错,整个进程非零退出。
ESM import 是 hoisted 的——顶层 import cherry-markdown 会在 polyfill 之前执行,模块顶层立即抛 ReferenceError: window is not defined。必须用 await import(...) 把导入推迟到 polyfill 之后。
新开 terminal 就自动可用。注意:bash -c "command" 不会读 ~/.bashrc(bash 设计),cron / 脚本里要用 bash -lic "command" 或显式 source ~/.bashrc。
CLI 跑通不算完,必须走完整流程验证服务端 + 前端真的能用:
| 步骤 | 验证什么 | 怎么验 |
|---|---|---|
| 1. CLI dry-run | 字段、长度、格式对 | --dry-run 打印请求体 |
| 2. 真实发布 | 鉴权、写库、返回 ID | --quiet 拿 URL |
| 3. 服务端校验 | md 和 html 不同 | mysql 查 md != html 且 html 长度 > md |
| 4. HTML 内容正确 | 真渲染不是 markdown 原文 | data-sign、cherry-list-item 等引擎签名在 html 里 |
| 5. 浏览器渲染 | 前端能正常显示 | 用浏览器工具打开 URL,看 DOM 结构是不是 <h1>/<ul>/<pre>/<a> 都有 |
| 6. 清理 | 测试数据不留 | 软删 / 硬删 |
最容易漏的是第 4、5 步——光看 200 OK 不够,前端的渲染逻辑可能因为各种原因没正确把 HTML 字段展示出来。我自己在做完 CLI 之后才意识到,之前的版本 CLI 把 md 原文塞进 html 字段,curl 看返回 200、JSON 也对,但浏览器打开就是字面 markdown——这就是缺了第 4、5 步的代价。
症状:CLI 带了 Bearer,服务端拿到 HTTP_AUTHORIZATION=None。原因:mod_wsgi 默认遵循老 CGI 协议,剥掉 Authorization 头。解:vhost 加 WSGIPassAuthorization On。
window is not defined症状:import cherry-markdown 立即 ReferenceError: window is not defined。原因:浏览器库,模块顶层就访问 window(内部 sanitizer 用)。解:jsdom polyfill 完整 DOM;用动态 await import(...) 而不是顶层 import。
症状:polyfill 在 import 之前写不了——import 是 hoisted 的。解:await import(...) 推迟到 polyfill 之后。
path() 写到 urlpatterns = [...] 外面症状:URL 404。解:自己 diff 检查。
bash -c 当 shell script 入口症状:cron 跑 CLI,token 找不到,401。原因:bash -c 是 non-interactive shell,不读 ~/.bashrc。解:cron 用 bash -lic "source ~/.bashrc && command"。
症状:CLI 通、服务端 200、JSON 有 id,但浏览器打开 URL 显示字面 markdown。原因:html 字段存了 md 原文没渲染。解:分别用 mysql 查字段、浏览器开发者工具看 DOM、curl ... | jq .html 看返回内容——三处都查了才能定位。
症状:渲染失败 stderr 倒 700KB 字符串。原因:cherry-markdown ESM bundle 自带 source map。解:CLI 只取第一行 Error: 报错。
OperationalError(1406)症状:CLI 看似成功,服务端 Data too long for column 'md',入库失败但 CLI 已退出。解:view 里主动校验所有字段长度。
症状:CLI 报 html too long (68681 > 48000 bytes)。原因:cherry 给每个元素都加 data-sign / data-lines,HTML 体积是 md 原文 3-4 倍。解:精简 Markdown(删冗余代码示例);或用 MEDIUMTEXT 改 schema(影响所有现有文章,慎用)。我选了精简。
按性价比排序:
--id 触发更新——已经支持,传 --id 123 就是更新而不是新建
图片自动上传——CLI 检测 Markdown 里的本地图片,multipart 上传到现有 endpoint,URL 替换后再渲染
草稿/定时发布——服务端加 publish_at 字段,CLI 发的时候设未来时间
都别在第一版做——先把"本地写完一行命令发出去"的核心循环做稳,再考虑别的。
整套方案的核心思路其实是 3 句话:
复用现有的一切——auth 机制、数据库 schema、Markdown 引擎、Web 路由,CLI 只是另一种入口
别发明新概念——token 用 base64(name:password),不复用 session;字段名跟 Web 表单一致;HTML 跟编辑器输出 byte-compatible
fail-fast 不要兜底——每个环节失败都给清晰报错,CLI 宁可死掉也不发一篇渲染不正常的文章
整个项目代码量:服务端 ~150 行、CLI ~250 行、render.mjs ~40 行。一晚上搞定。真正耗时间的是验证——尤其是 mod_wsgi、jsdom polyfill、ESM hoisting 这种"在文档上看似简单、一跑就崩"的环境细节。
最后的小贴士:开发完 CLI 之后,第一件事是去浏览器实测一篇文章的渲染——别只看 CLI 输出"published: id=544"就以为搞定了。
附录:项目结构最终样子