起因

这个博客最早写文章全靠手工:开编辑器、复制上一篇的 front matter 改日期改标题、图片一张张压缩改名丢进 static 目录、写完了本地跑一遍 hugo 看有没有编译报错。日子久了慢慢攒了几十篇文章,头部格式却越写越乱——键名顺序不统一、日期格式忽而带引号忽而不带、分类名想到什么写什么,同一个方向能冒出三四个不同的分类名。

后来先解决了"人工写"这一半:写了几个 Python 脚本(aipost.py/aiimage.py/fix_frontmatter.py 等)把头部格式和图片入库规则定下来,配一个 Tkinter 小编辑器(hugohelper.py)保存时自动套用约定,格式统一的问题算是解决了。

但真正让整个流程提速的,是把"文章怎么写"这件事也交给 AI 来做。现在的流程是:我把想法、素材(截图、参考资料、别人的文章链接)扔给 Claude Code,告诉它要写什么方向、哪个分类,剩下核实资料、组织结构、调脚本生成文件、跑 hugo 校验这一整套都是 AI 自己完成的。这篇就整理一下这套"Claude + Hugo 博客仓库 + Python 脚本"的写作流程,包括用到的脚本代码——服务器地址这类敏感信息已经去掉。

整体思路:让 AI 遵守约定,而不是重新发明约定

这套流程能跑起来,关键不是 AI 有多聪明,是这个仓库本身先把"规则"用文件的形式固定下来,AI 读了规则文件就知道该怎么干活,不用每次对话重新解释一遍。具体落地成三层:

  1. 约定层:仓库根目录的 README.md 记录了 front matter 格式、12 类固定分类体系、正文结构模板、图片入库规则。这是给人看也是给 AI 看的"施工规范"。
  2. 脚本层tools/ 目录下的 Python 脚本把约定变成可执行的操作——建文章、存图片、查/修头部问题、部署上线,每个脚本都是无 GUI、能被命令行直接调用、有明确的退出码。
  3. 执行层:Claude Code 读 README 和脚本源码理解规则,读用户提供的素材/参考资料,必要时上网核实信息,写好正文调脚本生成文件,最后跑一次 hugo 构建校验有没有格式错误。

三层分开的好处是各司其职:约定改了只改 README,脚本不用大动;AI 换一次对话、换一个模型,只要还认得这个仓库的 README 和脚本,产出的文章格式照样统一。这也是为什么脚本里专门写了 find_blog_root() 这类函数——不管从仓库哪个子目录调用,都能自动定位到博客根目录,AI 不需要精确知道自己"当前在哪"。

核心约定库:blogutil.py

所有脚本共享的规则都收在这一个文件里,不直接运行,被其余脚本 import。核心是三块:12 类分类白名单、front matter 生成/解析函数、图片 MD5 入库函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# -*- coding: utf-8 -*-
"""
blogutil.py — 博客工具公共函数

约定(与当前博客保持一致,参见 tools/README.md):
  - 文章位于 content/posts/<子目录>/YYYY-MM-DD-标题.md
  - 头部键名字母序、块式列表(- 项 顶格不缩进)
  - date: YYYY-MM-DDTHH:MM:SS+08:00(本地时区)
  - url:  /p/YYYY/MM/DD.html
  - 图片: static/uploads/images/<hash>.<ext>,引用为 /uploads/images/<hash>.<ext>
  - author: 软件开发大郭(来自 config.toml 的 [author].name)
"""

import datetime
import hashlib
import os
import re
from pathlib import Path

DEFAULT_AUTHOR = "软件开发大郭"

# 12 类标准分类体系(每篇单一主分类;新文章/编辑器只能从中选择)
CATEGORIES = [
    "嵌入式开发", "操作系统", "C语言", "通信技术", "计算机考古", "建站与运维",
    "AI应用", "开发工具", "应用软件开发", "裸机开发", "工业设备开发", "程序人生",
]


def find_blog_root(start=None):
    """从 start(默认本文件所在目录)向上查找博客根目录(含 config.toml 与 content)。"""
    p = Path(start or Path(__file__).resolve().parent)
    for d in (p, *p.parents):
        if (d / "config.toml").exists() and (d / "content").is_dir():
            return str(d)
    return None


def hugo_now():
    """当前本地时间,Hugo 规范格式:2026-09-06T12:34:56+08:00。"""
    s = datetime.datetime.now().astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
    return s[:-2] + ":" + s[-2:] if len(s) > 5 else s

后面还有 save_image()(MD5 入库图片)、unique_post_url()(生成 /p/YYYY/MM/DD.html 并按同日冲突自动加 -HHmm 后缀)、build_front_matter()(拼出规范的 YAML 头)这几个函数,是 aipost.py/aiimage.py 真正调用的部分。

图片交给脚本处理,不是交给 AI 处理

这一步是这套流程里我觉得设计得比较对的地方:插图这件事,从头到尾都是 Python 脚本在干活,AI 只负责告诉脚本"存哪张图",从不接触图片的二进制内容本身。

具体流程是:图片文件(截图、实拍照片)直接扔给 aiimage.py,脚本读文件字节、算 MD5、按哈希存到 static/uploads/images/、打印出 /uploads/images/<hash>.<ext> 这个 URL;AI 拿到这行 URL 文本,往正文里插一行 ![](/uploads/images/xxx.png) 就完事了。整个过程里,图片的像素数据只在脚本和文件系统之间流转,从来不会被编码成文本进大模型的上下文——一张几百 KB 的图片如果非要用 base64 塞进对话上下文,会占掉大量 token,还容易因为长度触发截断或者速率限制。用脚本做这一层"图片 I/O",纯粹是文件路径和一行 URL 字符串的往来,AI 那边几乎不产生额外开销。

去重也是脚本层免费带来的好处:同一张图不管上传几次,MD5 一样就是同一个文件名,不会在 static 目录里堆出一堆内容相同、文件名不同的垃圾文件。这对靠 AI 反复迭代文章、经常"这张图不满意换一张再插一次"的场景尤其有用,不用手动清理废弃图片。

1
2
3
4
# aiimage.py 的核心存储逻辑
def store(data, ext, root):
    _, url = blogutil.save_image(data, root, ext)
    return url
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# blogutil.py 里真正做入库的函数
def save_image(data, root, ext="png"):
    """把图片字节存入 static/uploads/images/<hash>.<ext>,返回 (绝对路径, 站点URL)。

    以内容哈希命名,同一张图重复粘贴不会产生重复文件。
    """
    digest = hashlib.md5(data).hexdigest()
    name = f"{digest}.{ext}"
    dest = Path(root) / "static" / "uploads" / "images" / name
    if not dest.exists():
        dest.parent.mkdir(parents=True, exist_ok=True)
        dest.write_bytes(data)
    return str(dest), f"/uploads/images/{name}"

命令行用法很直接:

1
2
3
python tools/aiimage.py 截图01.png 截图02.png
python tools/aiimage.py --markdown 截图01.png       # 直接输出 ![](/uploads/images/xxx.png)
python tools/aiimage.py --base64 <b64串> --ext jpg  # 传不了二进制文件时用这个(比如纯文本通道)

生成文章:aipost.py

正文写好之后,交给 aipost.py 生成规范的 front matter 并落盘。这一步的关键是分类校验——aipost.py 只认 blogutil.CATEGORIES 里的 12 个分类,写错分类名直接报错退出,不会静默创建一个新分类污染全站的分类体系。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def main():
    ap = argparse.ArgumentParser(description="按博客约定创建文章")
    ap.add_argument("--title", required=True, help="文章标题")
    ap.add_argument("--category", required=True,
                    help=f"主分类({len(blogutil.CATEGORIES)} 类标准体系之一)")
    ap.add_argument("--tags", default="", help="标签,逗号/顿号分隔")
    ap.add_argument("--body", help="正文文件路径(markdown,不含 front matter)")
    ap.add_argument("--stdin", action="store_true", help="从标准输入读正文")
    ap.add_argument("--dry-run", action="store_true", help="只打印生成结果,不写盘")
    args = ap.parse_args()

    if args.category not in blogutil.CATEGORIES:
        print(f"错误:分类“{args.category}”不在 {len(blogutil.CATEGORIES)} 类标准体系中:",
              file=sys.stderr)
        print("  " + "、".join(blogutil.CATEGORIES), file=sys.stderr)
        return 1
    ...

用法很简单,AI 把正文写到一个临时文件,调一次脚本:

1
2
python tools/aipost.py --title "标题" --category AI应用 --tags "标签1,标签2" --body 正文.md
python tools/aipost.py --title "标题" --category C语言 --dry-run   # 先预览生成的头部,不写文件

--dry-run 这个开关在 AI 协作场景里很有用——正式写盘前先打印一遍生成结果,AI(或者我)看一眼头部对不对、分类对不对,确认没问题再去掉这个参数真正落盘,避免格式错了还要再手动改文件。

写完不代表能用:跑一次 hugo 构建校验

AI 生成的 Markdown 文件,格式对不对不能靠"看着像",得让 Hugo 自己编译一遍。这个仓库的头部格式有几个容易踩的坑:type: post 漏写会导致 even 主题不显示标题;categories/tags 用了流式列表或者缩进不对会导致 YAML 解析异常;重复键、悬空列表项直接让 hugo 编译报错退出。这些问题光靠人眼扫一遍正文很容易漏,但 hugo --quiet 编译一遍,有问题立刻能看到报错信息。所以流程里最后一步永远是本地跑一次构建:

1
hugo --quiet

没有报错输出就说明这篇文章的 front matter 和正文 Markdown 语法都合法,可以准备发布。如果头部历史遗留问题比较多,还有专门的巡检脚本先跑一遍干跑模式,确认要改的地方再决定要不要 --write

1
2
3
python tools/fix_frontmatter.py           # 干跑,只看报告
python tools/fix_frontmatter.py --write   # 确认后写回
python tools/checkimages.py --write       # 检查图片丢失

部署:deploy.py(服务器地址已隐去),实际用起来就是一条命令加一次密码

文章确认没问题之后,最后一步是把 hugo build 生成的 public/ 目录同步到服务器。这一步现在对我来说体感上是"一键搞定":命令行敲一条 python tools/deploy.py,脚本自己跑 hugo build、自己算本地和远程的文件差异、自己决定要传哪些要删哪些,我只需要在它连接服务器那一刻手动输一次 SSH 密码,剩下全程不用管。构建、比对、上传、清理空目录,一条命令全包了,不用再手动敲 hugo→打开 FTP 工具→逐个文件对比上传这一套。

脚本用的是"内容 MD5 比较"的增量同步策略,效果上等价于 rsync --checksum --delete:本地和远程按文件内容哈希比较,一样的文件跳过不传,本地已经删掉的文件远程也同步删除,保证线上内容和本地仓库完全一致。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def local_file_hashes(public_dir):
    """返回 {posix 相对路径: md5十六进制}。"""
    result = {}
    base = Path(public_dir)
    for p in base.rglob("*"):
        if not p.is_file():
            continue
        rel = p.relative_to(base).as_posix()
        h = hashlib.md5()
        with open(p, "rb") as f:
            for chunk in iter(lambda: f.read(1024 * 1024), b""):
                h.update(chunk)
        result[rel] = h.hexdigest()
    return result


def remote_file_hashes(ssh, remote_root):
    """远程一次性跑 md5sum 取回所有文件哈希,避免逐个下载比较。"""
    cmd = f"cd {shlex.quote(remote_root)} && find . -type f -print0 | xargs -0 -r md5sum"
    _, stdout, stderr = ssh.exec_command(cmd)
    out = stdout.read().decode("utf-8", errors="replace")
    result = {}
    for line in out.splitlines():
        parts = line.rstrip("\n").split(None, 1)
        if len(parts) != 2:
            continue
        h, path = parts
        if path.startswith("./"):
            path = path[2:]
        result[path] = h
    return result

差异算出来之后再决定上传哪些、删除哪些:

1
2
to_upload = [p for p, h in local_hashes.items() if remote_hashes.get(p) != h]
to_delete = [p for p in remote_hashes if p not in local_hashes]

几个安全细节值得说一下:

  • 密码不落盘、不写进命令行参数:每次运行在控制台手工输入(getpass.getpass),或者临时设置环境变量 DEPLOY_SSH_PASSWORD 跳过交互,但不建议写进 shell 历史
  • 删除操作有二次确认:远程有本地已经没有的文件,会先列出来问一遍要不要删,除非显式传 --yes
  • --dry-run:正式同步前先看一遍会传哪些、删哪些,不做任何实际改动
1
2
3
python tools/deploy.py --dry-run      # 先看会同步/删除哪些文件,不实际改动
python tools/deploy.py                # build + 同步(删除前会二次确认)
python tools/deploy.py --skip-build   # 已经手动 hugo build 过,直接同步 public/

远程主机地址、路径这类信息写在脚本顶部的几个常量里(REMOTE_HOST/REMOTE_USER/REMOTE_ROOT),这篇贴出来的代码去掉了具体值。

现在的完整流程长什么样

拼起来,一篇文章从"我有个想法"到"发布上线",现在走的是这条链路:

  1. 我把想法、素材(截图、参考链接、别人的文章、自己的研究笔记)扔给 Claude Code,说清楚想写什么方向
  2. AI 读 README.mdtools/ 下的脚本理解格式约定,读我提供的素材,需要核实的地方自己上网搜证据(比如某个芯片型号、某个产品的官方参数),不确定的地方会用问题的方式跟我确认(比如该归哪个分类、某个没有来源的说法要不要收进去)
  3. 图片用 aiimage.py 入库拿 URL,正文写好存临时文件
  4. aipost.py 生成规范 front matter 并落盘,打印文件路径和 url
  5. hugo --quiet 跑一遍构建校验,删掉临时文件
  6. 确认没问题后跑一条 python tools/deploy.py,输一次密码,博客更新到线上——部署这一步也是一键搞定,不用再手动传文件

以前这六步里,第 1~4 步全靠我自己一个字一个字敲;现在变成我给方向和素材,AI 处理组织结构、资料核实、格式生成这些体力活,我看结果、拍最终的分类/取舍决定就行——写一篇有实测数据、有参考链接核实过的技术文章,从想法到能发布,压缩到一次对话的时间。

解决的另一个老问题:写一半烂尾

平时工作忙,博客写一半放着的情况不少见——开了个头,起因写完了,正准备写技术细节,一忙就是好几周,回头再看已经忘了当时想表达什么,索性放弃。这个仓库甚至专门有个脚本在跟踪这类烂尾文章:

1
2
3
python tools/checkempty.py             # 干跑:只报告
python tools/checkempty.py --write     # 正文不足 100 字符的文章,头部打 stub: true 标记
grep -rl "stub: true" content          # 快速定位待补文章

现在跑一下这个脚本,仓库里还有 26 篇文章带着 stub: true 标记——这些都是当年开了头没写完的坑。有了现在这套流程之后,这类烂尾基本不会再发生了:我不需要憋出完整的思路和文字,只要给一个大概的方向和几个关键词,一次对话内就能落地成一篇结构完整、有理有据的文章。前面几篇(电子价签改造、闲鱼算力板、GPT+KiCad)都是这么来的——起因就是几句话的念头,剩下组织结构、核实资料、写完整篇的活儿都在一次对话里完成,不存在"改天有空再写"这个中间状态,自然也就不会烂尾。

额外收获:技术文章顺带成了整理好的开发日志

用下来还有一个没预料到的好处:AI 写出来的东西,条理天然比我自己手写清楚。 排查一个问题、折腾一套方案的时候,人写记录很容易图省事——想到哪写到哪,踩了三个坑混在一段话里,最后"解决办法"和"当时走过的弯路"分不清楚。让 AI 来整理这些素材就不一样:它会很自然地把内容拆成"现象是什么"“根因是什么"“解决办法是什么"这样结构化的小节,同一类信息聚在一起,而不是按时间线原样堆叠。

这篇文章本身其实就是个例子。前面几篇(电子价签改造、闲鱼算力板、GPT+KiCad)在整理过程中,素材是我零散给的截图、卖家描述、参考文章链接,AI 组织出来的是"起因→识别方法→操作步骤→踩坑记录→总结"这样分好类目的结构,遇到卖家说法和实测不一致的地方(比如价签芯片型号、KiCad 演示的具体出处),也是先分门别类核实完再收进对应小节,不是原样照抄谁说了什么。这个整理能力放到日常开发记录上同样好用:调试笔记、方案对比、踩坑经历,交给 AI 整理一遍,比自己手写的版本更容易过后回头查。

总结

这套流程能跑起来的核心不是"AI 会写文章”,是这个仓库先把格式约定、图片规则、分类体系用 README.md 和几个 Python 脚本固化下来,AI 只是照着这套规则干活的执行者。图片处理这类纯 I/O 操作交给脚本,靠内容 MD5 去重、不占用 AI 的上下文 token;生成文章、部署上线也是脚本收口,AI 负责调度和产出内容本身。这种"AI 负责判断和内容,脚本负责确定性操作"的分工,是这条流程好用、稳定、还不烧 token 的关键。

参考链接

  • 仓库根目录 README.md——博客的完整写作约定:front matter 格式、12 类分类体系、正文结构模板
  • 仓库 tools/README.md——全部脚本清单与参数说明