给博客写个本地管理 CLI
一个零依赖的小工具,让你在本地舒服地写文章 / 发动态 / 管友链,一条命令同步到服务器并重建发布。
如果你也用 Firefly(一个基于 Astro 的精美静态博客主题),可能会遇到和我一样的困扰,又不想做后台,但是又想改点内容:进服务器改点内容太麻烦了。
这篇就聊聊我怎么用一个不到 400 行的 Python 脚本把这件事变简单的,以及部署 Firefly 时踩过的几个坑。
一、背景:静态博客的”改个东西好麻烦”
Firefly 是 Astro 静态站点,内容(文章、动态、友链)都是在构建时生成进静态文件的。这意味着:
- 它没有后台管理界面,想加一篇文章,得改
src/content/posts/里的.md源文件; - 改完必须重新构建 Docker 镜像(
docker compose build),否则网站上看不到; - 友链更麻烦——它不是单独文件,而是写死在
src/config/friendsConfig.ts的 TypeScript 数组里。
于是日常变成这样:在服务器上 vim 改文件 → 跑构建 → 等几分钟 → 验证。割裂感很强,写作体验也差。
我想要的是:在自己电脑上用顺手的编辑器写,写完一条命令推上去,可选自动重建。
二、这个工具能做什么
blogmgr 是一个纯 Python、零依赖(只用标准库)的命令行工具,覆盖 Firefly 三种内容的管理:
| 内容类型 | 存储位置 | 工具怎么管 |
|---|---|---|
| 文章 posts | src/content/posts/*.md | 一个 .md = 一篇文章,自动生成 frontmatter |
| 动态 dynamic | src/content/dynamic/*.md | 一个 .md = 一条动态,只需 published 时间 |
| 友链 friends | src/config/friendsConfig.ts 数组 | 本地一个 .json = 一条友链,推送时智能替换数组 |
核心能力:
- 本地写作:
add建好带title/published的空文章,edit自动用记事本打开写正文。 - 一键同步:通过 SMB 网络共享把文件推到服务器(只增 / 改,不删)。
- 可选重建:
rebuild子命令可在服务器上跑docker compose build && up -d(检测到plink时全自动)。 - 安全备份:改友链前会把原文件备份到本地
_backups/,绝不在服务器留临时文件。 - 交互模式:不带参数运行时进入持续可用的命令行(REPL),直接敲
add "标题"、sync、exit即可连续操作,不必每次重复敲命令前缀。
三、特性
- 零依赖:只用 Python 标准库,不用
pip install任何东西,下载即用。 - 跨平台:Windows / macOS / Linux 都能跑(SMB 在三者都通用)。
- 本地真源:友链以本地 JSON 为真源,推送时只替换 TS 数组,完整保留文件里的其它逻辑(
friendsPageConfig、getEnabledFriends函数等)。 - 防呆设计:
--dry-run演练模式、覆盖前自动备份、按权重自动排序。
四、安装
环境要求:
- Python 3.8 或以上(命令行能跑
python或python3)。 - 能访问博客项目的网络共享(如
\\<服务器IP>\opt\Firefly-master),也就是你服务器上挂载的那个目录。
把脚本和配置放进一个目录即可,blogmgr.py 是主程序,blogmgr.json 是配置:
{ "local_posts": "你的本地文章目录", "remote_posts": "\\\\<服务器IP>\\opt\\Firefly-master\\src\\content\\posts", "local_dynamics": "你的本地动态目录", "remote_dynamics": "\\\\<服务器IP>\\opt\\Firefly-master\\src\\content\\dynamic", "local_friends": "你的本地友链目录", "remote_friends_file": "\\\\<服务器IP>\\opt\\Firefly-master\\src\\config\\friendsConfig.ts", "ssh_host": "<服务器IP>", "ssh_user": "<用户名>", "ssh_pass": "<密码>"}五、快速开始
cd 工具目录python blogmgr.py init # 首次:建好本地目录
# 发一篇文章python blogmgr.py add "我的第一篇文章" -d "简介" -t "技术,随笔"python blogmgr.py edit "我的第一篇文章" # 记事本打开写正文python blogmgr.py sync # 推到服务器
# 发一条动态python blogmgr.py dyn-add "今天天气不错"
# 加一条友链(首次先 pull 把现有友链拉下来)python blogmgr.py friend-pullpython blogmgr.py friend-add "朋友博客" -u "https://example.com" -i "https://example.com/avatar.png" -d "描述" -w 5python blogmgr.py friend-sync
# 让改动生效(重建镜像)python blogmgr.py rebuild六、命令一览
文章
add / list / show / edit / delete / status / sync / pull动态
dyn-add / dyn-list / dyn-show / dyn-edit / dyn-delete / dyn-status / dyn-sync / dyn-pull友链
friend-pull / friend-add / friend-list / friend-edit / friend-delete / friend-status / friend-sync通用
init / rebuild每条命令都支持 -h 看帮助。sync / pull 及其动态版都带 --dry-run 可先演练;pull 类命令是从服务器 SMB 共享把内容拉回本地(同名同内容跳过、不同则覆盖、本地独有文件保留),适合换电脑或重新初始化时把线上内容搬下来。
七、友链是怎么被管理的(技术细节)
这是工具里最”巧妙”也最容易出错的部分。友链不是文件,而是 friendsConfig.ts 里的一个数组:
export const friendsConfig: FriendLink[] = [ { title: "..." , imgurl: "...", /* ... */ }, // ...];
// 获取启用的友链并进行排序export const getEnabledFriends = (): FriendLink[] => { /* ... */ };如果直接整文件覆盖,会丢掉 friendsPageConfig 和 getEnabledFriends 这些不能动的逻辑。所以工具的做法是:
- 用正则
friendsConfig:\s*FriendLink\[\]\s*=\s*\[精确定位数组起点(注意FriendLink[]自带方括号,不能简单匹配第一个[); - 只替换数组体,保留 head(数组声明行)和 tail(从
];到文件末尾); - 本地每个友链一个
.json,推送时按weight降序排好,拼回数组。
核心代码就这么几段(全部来自标准库,无需第三方包):
import re, json
def _find_array_span(text): """精确定位 friendsConfig 数组的起止下标,跳过 FriendLink[] 自带的方括号""" m = re.search(r'friendsConfig:\s*FriendLink\[\]\s*=\s*\[', text) if not m: return None b = m.end() - 1 # '[' 的下标 depth = 0 for k in range(b, len(text)): if text[k] == "[": depth += 1 elif text[k] == "]": depth -= 1 if depth == 0: return b, k return None
def split_objects(body): """按 {} 匹配切分对象块,字符串内的括号一律忽略""" objs, depth, start, in_str, esc = [], 0, None, False, False for i, c in enumerate(body): if in_str: if esc: esc = False elif c == "\\": esc = True elif c == '"': in_str = False continue if c == '"': in_str = True elif c == "{": if depth == 0: start = i depth += 1 elif c == "}": depth -= 1 if depth == 0 and start is not None: objs.append(body[start:i + 1]) start = None return objs
def build_friends_ts(friends): """把本地友链 JSON 拼回 TS 对象数组,按 weight 降序已在外层排好""" blocks = [] for d in friends: tags = "[" + ", ".join(f'"{t}"' for t in d.get("tags", [])) + "]" block = ( "\t{\n" f"\t\ttitle: \"{d['title']}\",\n" f"\t\timgurl: \"{d.get('imgurl', '')}\",\n" f"\t\tdesc: \"{d.get('desc', '')}\",\n" f"\t\tsiteurl: \"{d.get('siteurl', '')}\",\n" f"\t\ttags: {tags},\n" f"\t\tweight: {d.get('weight', 5)},\n" f"\t\tenabled: {str(d.get('enabled', True)).lower()},\n" "\t}," ) blocks.append(block) return "\n".join(blocks)推送时先 split_objects 解析服务器现有数组、合并本地改动,再用 build_friends_ts 重新生成数组体,最后只替换 head 与 tail 之间的部分——文件里 friendsPageConfig、getEnabledFriends 等函数和类型声明原封不动。
上面这几段是原理摘录,不能单独保存运行。完整可运行的工具就是一个文件
blogmgr.py(约 400 行,纯标准库),把它连同blogmgr.json(首次运行会自动生成)放在同一目录即可使用,上面这些函数都已包含在内。
八、部署 Firefly 踩过的坑(也是你会遇到的)
写这个工具的过程中,我把 Firefly 在普通服务器上部署的坑基本踩了一遍,记录在这里,省得你重蹈覆辙:
1. Docker 权限
非 root 用户跑 docker 报 permission denied —— 把用户加进 docker 组:sudo usermod -aG docker <用户名>,重登生效。
2. 字体 CDN 超时(构建失败)
Astro 的字体优化默认从 cdn.jsdelivr.net(fontsource)拉字体,国内服务器经常连不上 → 构建 CannotFetchFontFile 失败。
解法:把 src/config/fontConfig.ts 里的 enable 改成 false(代价是改用系统字体,外观略有变化)。
3. 图片路径大小写
文章里引用图片要写 images/xxx.png(复数),写成 image/(单数)会 ImageNotFound 构建失败。这是个手滑就踩的坑。
4. docker-compose v1 vs v2
老版本 docker-compose(连字符,v1)对新镜像元数据会报 KeyError: 'ContainerConfig'。一律用 docker compose(空格,v2)。
5. .bak 备份被当源码编译(最阴险)
最初我把友链备份写成服务器上的 friendsConfig.ts.bak。结果 Astro 构建时把 .bak 也当源码扫描,.bak 被 JS 解析器编译,import type 在 JS 里非法 → PARSE_ERROR。
解法:备份一律放到项目目录之外(本地 _backups/)。教训:任何写进 Astro 源目录的辅助文件都会被构建扫描,别留 .bak/.tmp/.old。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!



