写一篇博文:本站的写作与发布流程
站点改成 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 时构建不输出
三条红线:
post_id不改——它是评论的归属依据,改了等于把评论丢掉;url不改——改了就是一条死链,除非你同步加 301;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),流程是:
- 访客提交 → 落库为待审状态,只有提交者自己能看到,带一行灰字「评论待审核。」;
- 我进后台
http://<域名>/isso/admin,口令在服务器的/opt/isso/admin-password.txt; 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 或账号。请尽量说点有用的 :)