写一篇博文:本站的写作与发布流程

站点改成 Hugo 之后,「发文章」这件事从打开后台点「撰写」变成了写一个 Markdown 文件再跑几条命令。听起来比点鼠标麻烦,但每一步都是一条命令,跑完就知道自己到底改了什么、发出去了什么。

这篇是把流程记下来,给自己看,也给以后想接手的人看。

先交代现状:源码只放在服务器上,本地不留副本。宿主机会定期清空本地磁盘,两份源码还会互相漂移,维护同步成了纯负担。所以新建、写、发布全在服务器终端完成,三条命令。

下面先讲的那套本地流程是迁移时的做法,现在是备用方案——偶尔想用图形编辑器、或者离线改稿时才走。两条路的产物逐字节一致,不会因为换条路就出现两种结果。

一句话版本

ssh ubuntu@211.159.166.158
cd /home/ubuntu/cc/blog

./blog new "文章标题" --tags Hugo,发布    # 建目录、写 front matter
./blog edit 1911                        # 打开正文,写完存盘
./blog build                            # 构造 + 30 项断言
./blog publish                          # 备份 → 同步上线 → 5 条 URL 自检

服务器上的流程细节见下文「在服务器上写」一节。

三条命令,中间那一步是打字,其余全是自动的。

这套流程跑在本地电脑上。现在日常走的是下面这一节,本地这套留作备用。

第一步:建骨架

不要手写 front matter。手写容易出三类错,每一类都要花半小时找原因:

  • post_id 撞车 → 新文章的评论会串到另一篇老文章底下;
  • 固定链接编码写错 → 中文路径和老站风格不一致,或者被 Hugo 重新 urlize 成别的东西;
  • 目录放错 → 内容结构是 content/posts/<年>/<post_id>/index.md,放错层就构建不出来。

所以有一条命令专门干这个:

"$PY" tools/new_post.py --title "文章标题" \
      --date 2026-11-12 \
      --category 程序设计 \
      --tags "Hugo,发布" \
      --cover assets/some-cover.jpg
参数 作用
--title 标题,同时决定默认的 URL 短名(中文保留)
--date 只写 YYYY-MM-DD,脚本自动补 +08:00 时区
--category 可重复,不写默认「随笔」
--tags 英文逗号分隔
--cover 列表卡片的缩略图:本地图片路径,会复制到 static/images/covers/ 并写进 front matter。只在首页、分类页的卡片上出现,不进正文
--slug 想用英文路径时手动指定,默认按标题生成中文路径
--draft 建草稿,构建时不输出
--build 建完立刻构建一次

它会自动分配下一个可用的 post_id(全站唯一、只增不减),生成小写百分号编码的固定链接,并检查重复。跑完会打印出这三行,抄下来有用:

post_id = 1906          <- 评论线程键,永远不要改
固定链接 = /2026/11/...  <- 这就是对外地址
源文件   = site-real/content/posts/2026/1906/index.md

第二步:写正文

打开生成的 index.md,front matter 下面就是正文,纯 Markdown。要点只有几条:

  • 正文从 ## 开始,页面标题已经由 title 渲染成 h1,正文里再写 # 会出现两个一级标题;
  • 代码块用三个反引号并标注语言。当前主题没有配语法配色,代码是统一的深底浅字,标语言主要是为了以后换样式时能区分;
  • 图片放 static/wp-content/uploads/ 或 static/images/,正文里写绝对路径 /images/xxx.png;
  • 不要写裸 HTML 标签。站点关掉了 unsafe 渲染,<div> 之类会被静默丢弃;需要字面尖括号时用反引号包起来;
  • 站内链接写相对路径 /2026/11/xxx/,不要写带域名的绝对地址。

front matter 里几个字段值得单独说:

title: 文章标题
date: '2026-11-12T22:25:00+08:00'   # 带时区,见下面的坑
url: /2026/11/xxx/                   # 固定链接,发布后不要再改
post_id: 1907                        # 评论线程键,发布后绝对不要改
categories: [程序设计]
tags: [Hugo, 发布]
draft: false                         # true 时构建不输出

三条红线:

  1. post_id 不改——它是评论的归属依据,改了等于把评论丢掉;
  2. url 不改——改了就是一条死链,除非你同步加 301;
  3. date 一定带时区——不带时区时,构建期的时区解释一旦和写作时区对不上,就会被当成「未来文章」而整篇不出现。

第三步:本地构建

"$PY" tools/build_site.py --target prod

--target 有三个值,日常只用得到前两个:

target 产物 用途
prod prod-public/ 正式站 blog.zhourunsheng.com,带评论脚本
blog site-real/public/ 纯静态归档,不带评论脚本,换机时用
dev dev-public/ 早期预览站用的,域名已退役,留着备查

这一条会做四件事:Hugo 构建、把字面 %XX 目录重命名成 UTF-8(nginx 才能命中)、把正文里的站内绝对链接本地化、跑一遍断言。断言不通过会直接退出并打印原因,常见的有:

报错 原因
history_urls_all_hit 失败 有历史链接对不上,通常是改了 url 或重命名了目录
pagination_* 失败 分页数量和实际文章数不一致,检查是否有 draft 或未来日期
isso_thread_id_decoupled 失败 评论区模板丢了 data-isso-id

构建产物在 prod-public/。想先看效果,可以起一个本地静态服务器指过去,或者直接看 prod-public/2026/11/ 下有没有生成对应的目录。

第四步:发布

"$PY" tools/build_prod_deploy.py    # 组装部署包(带校验和)
"$PY" tools/deploy_static.py        # 备份 → 上传 → 校验 → 解压 → 自检

deploy_static.py 只动静态文件目录,不碰 nginx 配置、不碰评论服务、不碰旧 WordPress 目录。它做了一件容易被忽略但很重要的事:覆盖之后清掉新包里已经不存在的旧文件。

原因是早期版本只做覆盖,结果删掉的文章在线上仍然能访问——旧 HTML 一直躺在那儿。加上清理之后,删文章才真正生效。wp-content/uploads/ 单独豁免,图片一旦上线就不会因为某次构建缺失而消失。

发布完可以跑一遍回归:

"$PY" tools/verify_site_http.py     # 16 项回归:173 条历史链接、301 抽样、
                                   # 强制跳转与三条例外、评论与图片

在服务器上写(现在的日常)

上面那一套是在自己电脑上跑:要有 Hugo、有 Python、有部署脚本,改完再压缩上传。换台电脑、借用别人的机器,或者像现在这样——本地磁盘存不住东西——这条路就不合适了。

服务器上能不能把「新建 → 写 → 构建 → 上线」一条龙跑完?能,而且产出的页面和本地逐字节一致。代价是第一次要把环境搭起来,之后日常就只剩几条命令。

一次性搭建

三件事:把源码传上去、装一个版本对的 Hugo、准备一个 blog 命令。

源码放 /home/ubuntu/cc/blog,和服务器上其它小工具放一起,结构和本地保持一致:site-real/ 是站点源文件,tools/ 是构建脚本,deploy/prod-hugo.toml 是生产配置,out-real/url-manifest.csv 是 173 条历史链接清单。不含 site-real/public 那份旧产物,一共 4.1 MB、312 个文件(另有 archive/ 3 MB 的原始导出)。

Hugo 不能用 apt install hugo:Ubuntu 22.04 源里是 0.92.2,而本站用了 pagination.pagerSize、disablePathToLower 这些较新的配置项,低版本构建会直接失败。要装和本地同版本的 extended 构建:

mkdir -p ~/cc/blog/.bin && cd /tmp
curl -sSL --retry 3 -o hugo.tgz \
  https://gh-proxy.com/https://github.com/gohugoio/hugo/releases/download/v0.169.0/hugo_extended_0.169.0_linux-amd64.tar.gz
tar -xzf hugo.tgz hugo && mv hugo ~/cc/blog/.bin/hugo && chmod +x ~/cc/blog/.bin/hugo
~/cc/blog/.bin/hugo version
# hugo v0.169.0-...-extended linux/amd64

两个坑:GitHub 直连下载会在几十兆处断掉,报 SSL routines::unexpected eof,加个镜像前缀就稳了;Hugo 装进项目的 .bin/ 而不是 /usr/local/bin,以后换版本不用动系统里的东西。

然后是 blog 命令,放在 ~/cc/blog/blog,~/.bashrc 里加一行 export PATH="$HOME/cc/blog:$PATH"。它把几条命令包了一层:

命令 做什么
blog new "标题" --tags a,b 调 new_post.py 分配 post_id、生成固定链接和骨架
blog edit 1909 用 $EDITOR 打开那篇的 index.md
blog build 跑 build_site.py --target prod,30 项断言
blog publish 备份 → rsync 同步 → 改权限 → 5 条 URL 自检
blog backup 把源码打成 1.2 MB 的包放 /www/backup/
blog snapshot 全量快照:源码 + WXR 原件 + 1323 张图 + 评论库 + 配置 + hugo,57 MB
blog doctor 体检:关键资产齐不齐、上次快照多少天前
blog status 篇数、产物、线上文件数、上次构建与发布时间

日常就三行

blog new "服务器上写的第一篇" --tags 运维
blog edit 1909          # 1909 是上一步打印出来的 post_id,vim 里写完 :wq
blog build && blog publish

blog new 的输出和本地完全一致,照样把三行关键信息打出来(post_id、固定链接、源文件路径),只是最后的提示换成了 blog build / blog publish——脚本看目录里有没有 blog 这个文件来决定提示哪套流程。

blog build 的断言也是同一套。实测同一份源码、同一版本 Hugo,Windows 与 Linux 两次构建的产物逐字节相同:685 个文件、13,933,634 字节,逐文件 sha256 比对全部一致。所以「在服务器上构建」不是降级方案,它和本地构建出的是同一个东西。

顺带修了一个只在 Windows 上出现的问题:链接本地化那一步用默认模式写文件,Windows 会把换行写成 \r\n,产物比 Linux 大 20 KB。改成显式 newline="\n" 之后两边才对齐。

发布这一步反而更简单

本地路径要「压缩成包 → base64 分块上传 → 解压 → 覆盖」,因为文件得从另一台机器送过来。在服务器上构建完,产物就在同一台机器,一条 rsync 就够了:

sudo rsync -a --delete --exclude '/wp-content/' prod-public/ /www/wwwroot/blog-static/public/

三个细节:

  • --delete 是为了让删掉的文章真正下线,不然旧 HTML 一直躺在目录里,链接还能打开;
  • --exclude '/wp-content/' 保住 1323 张历史图片——构建产物里没有这个目录,不排除的话第一次发布就会把它们全删掉;
  • 发布前先 tar 一份当前线上目录到 /www/backup/blog-public-<时间戳>.tar.gz,出了问题一条命令回滚。

原先那个预览站域名在正式切换后改成了 301 跳到 blog,只留下 /mp-weixin/ 那一段 PHP——微信小程序后台填的域名不好随便改,把它跟着域名一起下线,小程序就断了。

自检也从「首页能开」扩到 5 条:首页、style.css、归档、搜索,以及最新一篇的固定链接。只验证首页的话,正文页 404 是看不出来的。

两条路怎么选

本地构建 + 上传(备用) 服务器上(日常)
源码位置 拉下来一份,改完还要同步回去 服务器那一份就是唯一权威
首次成本 装 Hugo、Python,拉一份源码 已搭好,blog new 就能写
每次发布 三条命令,含一次上传 两条命令,无上传
换电脑 要重装环境 有 SSH 就能写
预览 本地浏览器直接看 blog serve 加端口转发
编辑器 任意 vim / nano
备份 得自己记得传回去 blog snapshot(见换服务器手册)
产物一致性 与服务器逐字节一致(已验证) —

现在是只走服务器这条。本地那套流程留着是因为它有值——离线、图形编辑器、或者哪天服务器连不上时,它是唯一的替代路径。

发布之后:评论区与审核

新文章不需要单独开评论区,模板会自动带上。评论线程的键是 post_id,所以同一篇文章不管从哪个 URL 进来,评论都是同一份。

站点开了审核([moderation] enabled = true),流程是:

  1. 访客提交 → 落库为待审状态,只有提交者自己能看到,带一行灰字「评论待审核。」;
  2. 我进后台 http://<域名>/isso/admin,口令在服务器的 /opt/isso/admin-password.txt;
  3. Pending 页签里找到那条,点 Validate 放行,其他人才能看见;点 Delete 就是删掉。

后台搜索框按线程键搜,也就是 post_id 那个数字,不是文章网址——这点跟 WordPress 的习惯不一样,第一次用会找不到文章。

三个要提前知道的地方:没有邮件提醒(没配 SMTP),待审不会主动通知,得自己去看;没有 IP 白名单,谁能猜到口令谁就能进;后台是明文口令比对,配置里必须写字面口令,写哈希会永远登不进去。

常见故障

现象 原因与处理
文章构建了但首页没有 draft: true,或 date 是未来时间、没带时区
文章页 404,其它页正常 url 里的百分号编码和目录名不一致,重跑构建
评论区空白 该 target 没开 enableIsso;或 nginx 少了 /isso/ 反代
评论提交成功但别人看不见 审核开着,去后台 Pending 放行
评论在 A 链接有、B 链接没有 线程键不一致,检查模板里的 data-isso-id
删掉的文章还能访问 部署时没清空目标目录,重跑 deploy_static.py
blog publish 报「源文件比产物新」 改完正文忘了构建,先跑 blog build

一句话总结

建骨架用脚本、写正文用 Markdown、发布跑三条命令(在服务器上就是 blog build 和 blog publish 两条)、评论去后台点放行。全流程没有浏览器后台,没有数据库;改错了,把 site-real/content 换回改动前的备份目录就行——这大概就是当初决定静态化的全部理由。


这个系列共三篇:迁移实录 · 写作与发布流程 · 换服务器手册

加入讨论

新评论 — 条

评论保存在本站自托管服务里,无需注册登录,填个昵称就能留言;不依赖任何第三方 Cookie 或账号。请尽量说点有用的 :)