给博客写个本地管理 CLI

2064 字
10 分钟
给博客写个本地管理 CLI

一个零依赖的小工具,让你在本地舒服地写文章 / 发动态 / 管友链,一条命令同步到服务器并重建发布。

如果你也用 Firefly(一个基于 Astro 的精美静态博客主题),可能会遇到和我一样的困扰,又不想做后台,但是又想改点内容:进服务器改点内容太麻烦了

这篇就聊聊我怎么用一个不到 400 行的 Python 脚本把这件事变简单的,以及部署 Firefly 时踩过的几个坑。


一、背景:静态博客的”改个东西好麻烦”#

Firefly 是 Astro 静态站点,内容(文章、动态、友链)都是在构建时生成进静态文件的。这意味着:

  1. 它没有后台管理界面,想加一篇文章,得改 src/content/posts/ 里的 .md 源文件;
  2. 改完必须重新构建 Docker 镜像docker compose build),否则网站上看不到;
  3. 友链更麻烦——它不是单独文件,而是写死在 src/config/friendsConfig.ts 的 TypeScript 数组里。

于是日常变成这样:在服务器上 vim 改文件 → 跑构建 → 等几分钟 → 验证。割裂感很强,写作体验也差。

我想要的是:在自己电脑上用顺手的编辑器写,写完一条命令推上去,可选自动重建。


二、这个工具能做什么#

blogmgr 是一个纯 Python、零依赖(只用标准库)的命令行工具,覆盖 Firefly 三种内容的管理:

内容类型存储位置工具怎么管
文章 postssrc/content/posts/*.md一个 .md = 一篇文章,自动生成 frontmatter
动态 dynamicsrc/content/dynamic/*.md一个 .md = 一条动态,只需 published 时间
友链 friendssrc/config/friendsConfig.ts 数组本地一个 .json = 一条友链,推送时智能替换数组

核心能力:

  • 本地写作add 建好带 title/published 的空文章,edit 自动用记事本打开写正文。
  • 一键同步:通过 SMB 网络共享把文件推到服务器(只增 / 改,不删)。
  • 可选重建rebuild 子命令可在服务器上跑 docker compose build && up -d(检测到 plink 时全自动)。
  • 安全备份:改友链前会把原文件备份到本地 _backups/,绝不在服务器留临时文件。
  • 交互模式:不带参数运行时进入持续可用的命令行(REPL),直接敲 add "标题"syncexit 即可连续操作,不必每次重复敲命令前缀。

三、特性#

  • 零依赖:只用 Python 标准库,不用 pip install 任何东西,下载即用。
  • 跨平台:Windows / macOS / Linux 都能跑(SMB 在三者都通用)。
  • 本地真源:友链以本地 JSON 为真源,推送时只替换 TS 数组,完整保留文件里的其它逻辑(friendsPageConfiggetEnabledFriends 函数等)。
  • 防呆设计--dry-run 演练模式、覆盖前自动备份、按权重自动排序。

四、安装#

环境要求:

  • Python 3.8 或以上(命令行能跑 pythonpython3)。
  • 能访问博客项目的网络共享(如 \\<服务器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": "<密码>"
}

五、快速开始#

Terminal window
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-pull
python blogmgr.py friend-add "朋友博客" -u "https://example.com" -i "https://example.com/avatar.png" -d "描述" -w 5
python 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[] => { /* ... */ };

如果直接整文件覆盖,会丢掉 friendsPageConfiggetEnabledFriends 这些不能动的逻辑。所以工具的做法是:

  1. 用正则 friendsConfig:\s*FriendLink\[\]\s*=\s*\[ 精确定位数组起点(注意 FriendLink[] 自带方括号,不能简单匹配第一个 [);
  2. 只替换数组体,保留 head(数组声明行)和 tail(从 ]; 到文件末尾);
  3. 本地每个友链一个 .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 之间的部分——文件里 friendsPageConfiggetEnabledFriends 等函数和类型声明原封不动。

上面这几段是原理摘录,不能单独保存运行。完整可运行的工具就是一个文件 blogmgr.py(约 400 行,纯标准库),把它连同 blogmgr.json(首次运行会自动生成)放在同一目录即可使用,上面这些函数都已包含在内。


八、部署 Firefly 踩过的坑(也是你会遇到的)#

写这个工具的过程中,我把 Firefly 在普通服务器上部署的坑基本踩了一遍,记录在这里,省得你重蹈覆辙:

1. Docker 权限#

非 root 用户跑 dockerpermission 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


文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

给博客写个本地管理 CLI
https://vtdd.vip/posts/给博客写个本地管理-cli/
作者
Zero
发布于
2026-07-28
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Zero
有三件事人类都要经历:出生、生活和死亡。他们出生时无知无觉,死到临头,痛不欲生,活着的时候却又怠慢了人生。——拉布吕耶尔
分类
标签
最新动态
站点统计
文章
26
动态
6
分类
8
标签
30
总字数
12,871
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.14.3
文章许可
CC BY-NC-SA 4.0