用150行Python手写零依赖静态博客生成器,告别WordPress焦虑
去年冬天,我把用了六年的WordPress 博客彻底关掉了,换成了现在这套由一百多行 Python 脚本生成的静态站。我给这套小工具起了个名字,叫caveman,直译就是“穴居人”。起这个名字不是想装酷,而是想给自己立个规矩:写博客这件事,归根结底只需要一根趁手的棍子和一块靠得住的石头,不需要一个不断膨胀的工具全家桶。
你可能会觉得我在小题大做,但如果你也经历过从 WordPress 换到 Hexo,再从 Hexo 换到 Hugo,最后发现写一篇文章的时间有一大半花在“先让后台跑起来”这件事上,大概就能明白我为什么要走到这一步。这篇东西不是什么大项目复盘,只是想把我做caveman的完整思路、踩过的坑、以及它现在给我带来的真实体验记录下来,给那些同样被博客工具折磨得喘不过气的人一个参考。
1. 从“部落迁徙”说起:我为什么丢掉 WordPress,反而留下了 caveman
先说清楚我原来的处境。我写博客断断续续写了大概六年,中间用过传统动态方案,也追过一轮静态站生成器。听起来好像很折腾,但实际上每一次迁移都不是因为内容变多了,而是因为工具先撑不住了。
1.1 写这篇文章的真实导火索
真正让我下决心的机缘,是又一次插件升级后整个后台白屏。我花了两个多小时查原因,最后发现是某个缓存插件和老主题不兼容。那一刻我忽然问自己:我到底在维护一个博客,还是维护一个随时可能爆炸的软件系统?
WordPress 那条路我走得很深,数据库、插件、主题、子主题、CDN、对象缓存,每一层我都能说出个一二三来。但问题恰恰在这里——你越熟悉,就越容易陷入“配置它”而不是“用它写作”的状态。今天改一下侧边栏,明天调一下字体,后天研究怎么合并 CSS 文件,等把这些折腾完,写文章的热情早就凉透了。
后来我也试过静态站生成器。Hexo 时代,我已经觉得挺轻了,但它的更新频率、主题生态和 Node 依赖链依然会时不时给你来一个“npm install 失败”。Hugo 更夸张,安装包倒是单文件,速度也快,但当我想改一点小逻辑时,必须去读 Go 模板的语法。我渐渐意识到,不是我学不会,而是这些工具对“只想安静写点东西”的人来说,真的太重了。
于是我做了个决定:自己写一个发布系统,只用一种我闭着眼都能改的语言,只依赖标准库,任何第三包都不装。它要做的事情很简单——把一堆 Markdown 文件变成能放到服务器上的 HTML 网页,没了。
1.2 这个项目到底适合谁
如果你符合下面几条,我觉得caveman的思路可能会对你有用:
- 你写内容为主,但已经厌倦了后台转圈、插件互掐、主题升级变样。
- 你有点动手能力,能接受“先写脚本再写作”这层薄薄的代价。
- 你手头有一台最低配的云主机,或者愿意用现成的静态托管服务。
- 你希望十年后网站还能跑,不依赖某个框架是否继续维护。
反过来,如果你需要完整的后台编辑体验、多人协作和实时预览,那活该用现成的管理系统。caveman的前提是你愿意向“纯粹的文字工作流”靠拢,而不是像一个编辑部那样运作。
2. caveman 的设计原则:拒绝依赖,把心智负担砍到最低
当我决定自己写这套系统时,第一件事不是急着写代码,而是先立规则。因为我知道,如果它又变成一个充满抽象概念的大项目,那和之前扔掉的那些工具没有区别。
2.1 零依赖不等于零代码
caveman的第一个原则是零第三方依赖。所有代码只用 Python 标准库,也就是说,任何一台装了 Python 3 的机器都能直接跑,不需要pip install任何东西,不需要维护requirements.txt,更不存在“某个依赖库因为作者退坑而停更”的问题。
有人可能会说,Markdown 解析总得用库吧,总不能自己写吧。我就是自己写的,而且只写了实现最小功能子集的解析器。这不是因为我不信任第三方库,而是我清楚地知道,如果我引入一个功能完整的 Markdown 库,就意味着我要用一个复杂度远超我需求的东西。之后文档、配置、版本兼容性都会变成新的负担。
零依赖带来的好处是即时可见的:换机器、重装系统、从云服务器迁移到家里那台小主机,都只需要把整个目录打包拷走。没有环境变量、没有数据库迁移、没有运行环境版本锁。我在一台旧笔记本上跑,在一台 2015 年的低配小主机上跑,在朋友的电脑上跑,结果一摸一样。
2.2 全部代码控制在 150 行以内
第二原则是代码量必须控制在 150 行以内。这个数字不是随便定的,而是我给自己设的“可读性边界”。一个文件、150 行以内、没有任何隐藏的模块依赖,这意味着我可以在任意时间把整个系统读一遍,知道每一行在干什么。
我见过很多人写自己的工具,一开始很清晰,后来为了支持更多功能不断加代码,最后变成一万多行的怪物,然后新写一套“精简化工具”。我把这条线画死了:功能宁可少,不可代码多。如果某个需求会让代码明显超过 150 行,我就去精简别的写法,或者干脆放弃那个需求。
这带来一个很实际的好处:所有逻辑都透明。我不会在某个深夜改了一处行为后就忘记它在哪,也不会因为“这次升级”导致旧文章显示错乱。caveman至今没有产生过“版本兼容”的焦虑,因为它的逻辑全部摊在眼前。
2.3 内容与表现彻底分离
caveman的内容全部是 Markdown 文件,一个个.md文件躺在content/目录里。页面长相只由template.html控制。我不需要数据库,不需要后台管理面板,不需要评论系统。
评论我暂时是关掉的。如果你想留言,可以事后用一个外部脚本指向链接,或者干脆让读者发邮件。这样做不是反社交,而是为了保住“发布系统极简”这个大前提。每加一类功能,我都要付出额外的维护成本。在没有明显需求之前,把所有的口子都封住才是最舒服的状态。
这种内容与表现完全分离的方式,也让“搬家”变成了一件极低压的事。如果哪天我不想用自建服务器了,只要把content/目录推到任何一个静态托管平台,网站就照常运行。没有任何锁定效应。
3. 核心实现:150 行 Python,把 Markdown 变成静态站点
接下来是具体实现。我尽量把思路讲完整,代码只贴关键片段,因为完整版有点长,而且很多细节要根据你自己的偏好调整。
3.1 目录结构设计成一个最小副本
caveman的目录最初长这样:
caveman/ ├── caveman.py ├── template.html ├── content/ │ ├── 2025-11-02-markdown-guide.md │ ├── 2025-12-18-caveman-project.md │ └── draft/ └── output/content/存放 Markdown 文章,子目录draft/放未发布的草稿。output/是生成结果,每次运行脚本都会先清空再重建。template.html是唯一的页面模板,控制整站的外观。caveman.py是唯一的工具脚本。
这个结构我再解释一下:内容、模板、产物三块完全分开。output/里的东西是纯 HTML,可以直接丢给任何静态服务器。content/里永远只有文本和图片资源,备份起来就是一个目录拷贝。
3.2 最小可用的 Markdown 解析器
写解析器前,我统计了自己以往文章里用的 Markdown 语法,发现常被用到的只有几种:标题、段落、代码块、链接、列表,偶尔用到粗体和斜体。这就够了。
解析的核心是一个简单的逐行扫描函数:
def md_to_html(text): lines = text.splitlines() in_code = False out = [] for line in lines: if line.startswith("```"): in_code = not in_code out.append("<pre><code>" if in_code else "</code></pre>") elif in_code: out.append(html.escape(line)) elif line.startswith("# "): out.append("<h1>" + html.escape(line[2:]) + "</h1>") elif line.startswith("## "): out.append("<h2>" + html.escape(line[3:]) + "</h2>") elif line == "": out.append("") elif line.startswith("- ") or line.startswith("* "): out.append("<li>" + html.escape(line[2:]) + "</li>") elif line.startswith(" "): out.append("<pre>" + html.escape(line[4:]) + "</pre>") else: out.append("<p>" + html.escape(line) + "</p>") return "\n".join(out)这个解析器当然称不上严谨,嵌套列表、引用块、表格都没有。但它对我足够可靠,因为我会刻意按它的能力范围写文章。比如需要代码块就一定用三个反引号包起来,需要列表就连行写,不在代码块里放空行。先把语法面缩窄,写作习惯自然跟着收敛,换来的却是每篇文章的渲染效果完全可控。
对于行内链接和加粗,我会加一个简单的正则替换:
line = html.escape(line) line = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', line) line = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", line)注意顺序:先转义,再加链接。否则链接里的&之类会被吃掉。这个坑后面细说。
3.3 首页与文章页的生成逻辑
每篇文章的文件名里带了日期,但为了保险,我还是在文件头部加了极简元信息,格式如下:
--- title: 你好,洞穴 date: 2025-12-28 ---脚本扫描content/下所有.md文件,解析这段元信息,然后做两件事:
第一,每篇文章生成一个独立的 HTML 页面。文件名我用日期加序号,不以中文标题作为路径,比如20251228-001.html。这样以后改标题,链接也不会坏。
第二,把所有文章按日期倒序排,生成一个index.html,首页只显示标题和日期,点击进入正文。
生成正文页时,模板替换大概长这样:
page_html = template.replace("{{TITLE}}", title) .replace("{{DATE}}", date) .replace("{{CONTENT}}", content_html)首页则是把所有文章的标题列成链接:
items = "".join(f'<li><a href="{url}">{title}</a> <span>{date}</span></li>' for post in posts) index_html = template.replace("{{TITLE}}", "首页") .replace("{{CONTENT}}", f"<ul>{items}</ul>")就这么简单。全文生成时间在一百篇文章的场景下不到一秒,肉眼根本感知不到静态站“生成”这个动作的存在。
4. 部署与发布流程:一台最低配云主机就能跑起来
代码写完以后,真正的考验来了:网站怎么上线,日常怎么更新。这里我选了所有人都会用的老办法,但把它精简到了只要几个键。
4.1 三秒发布:本地生成然后 rsync
我的发布流程到今天仍然是三句话:
cd ~/caveman python3 caveman.py rsync -av --delete output/ user@server:/var/www/caveman/解释一下这几个动作:第一条进目录,第二条重新生成所有页面,第三条把本地output/的内容增量同步到服务器上。这里用--delete是有讲究的——它保证服务器上那些被删除或修改过的旧文件也会同步删除,不会出现“文章都删了,页面还在”的幻影。
为什么选rsync而不是用 Git hook 或者 CI 自动构建?因为我的需求是“可控的本地发布”,不是“每个分支推送都自动部署”。rsync足够直接,传输前会做文件对比,几百个 HTML 实际只传几个新增的,速度快到经常一晃就结束。而且它不依赖任何第三方服务,服务器上只要有一个能 SSH 登录的账号就能用。
4.2 静态服务器的 nginx 配置
服务器上我跑的是 nginx,配置文件短到不需要注释:
server { listen 80; server_name example.com; root /var/www/caveman; index index.html; location / { try_files $uri $uri/ =404; } }try_files这行在我看来是静态站最核心的配置。它首先找精确文件,找不到就找目录下的index.html,再找不到就返回 404,不会把任何请求转交给后端程序。因为caveman产出的都是纯静态文件,这样的配置已经接近理论上的最简了。
静态站的好处在这里体现得最明显:没有数据库进程驻留,没有 PHP-FPM 需要监控,没有 502 错误,没有内存泄漏。一台 1 核 512M 的小主机,跑一个这样的博客,日常负载几乎可以忽略。
如果你不想自己维护服务器,也可以直接把output/推到各种静态托管平台,原理完全一样。我选择自建只是因为喜欢折腾最少,哪怕将来服务器到期,我迁移的成本也只是把文件重新传到一个新 IP 上。
4.3 外出时怎么坚持更新
还有一个实际问题:人不会永远坐在电脑前。我出门时也会想写东西,但我的方案不是去搞一个网页后台,而是在手机上写草稿,同步回电脑再发布。
手机端我只用一个支持 Markdown 的笔记应用,写完存成.md文件,通过 Git 仓库同步到本地。整个过程的目标很明确:手机上只负责产生文字,不负责发布。发布永远回电脑上执行那一条命令。
这个看似“绕路”的方案反而帮我过滤了很多不宜发布的内容。等我回到电脑前,那些草稿往往已经冷静了一晚,我会在发布前再读一遍,删掉废话再生成。这算是个意外收获。
5. 迭代中的三个坑:标题转义、中文文件名、缓存问题
任何工具都是靠踩坑长大的,caveman也不例外。下面这三个问题是我在实际使用中碰到的,每一个都花了时间去排查,把它们写出来,是想让你少走弯路。
5.1 标题里的特殊字符把页面搞乱了
有一次我写了一篇标题叫“Python < 3.10 的兼容性整理”的文章。生成页面后在浏览器里打开,发现整个页面结构都歪了,一部分正文变成了粗体横线,一部分直接消失。
我一开始以为是模板写错了,检查半天才发现问题出在标题里的<字符。它在进入 HTML 模板时没有被转义,浏览器自作主张地把后面一长串内容当成了 HTML 标签,直到碰到另一个>才“关掉”。
排查方法是直接查看浏览器里的“查看页面源代码”,看到标题附近出现了意料之外的标签结构。修复方式就是在所有插入模板的文本上统一调用html.escape()。从那以后我给自己定了规矩:不管内容来自哪里,凡是放进 HTML 的变量,一律先转义。
5.2 中文文件名在服务器上的怪问题
项目早期,我为了让 URL 更好读,直接用中文标题生成文件名,比如你好洞穴.html。在本地跑得好好的,传到 Linux 服务器上就出了一些诡异现象:有的浏览器能打开,有的手机会下载文件而不是打开页面,某些 CDN 还会返回 403。
原因很复杂,不完全是服务器的问题,但最简单的解决办法是别让 URL 依赖中文。现在生成的路径是纯日期加序号,例如20251228-001.html。文件名本身依然可以是中文,这样我在本地管理时还能一眼认出是哪篇文章,但 URL 永远保持 ASCII,彻底避开字符编码和服务器地区的坑。
这个改动也让链接变得非常稳定。以后就算我改文章标题,URL 不会变化,外部引用不会失效。
5.3 重新生成时遇到旧的 HTML 残留
刚上线那会儿,我在本地删了一篇文章,重新运行caveman.py,然后同步到服务器,结果那篇文章的页面还能访问。我一度以为 rsync 没生效,后来才发现是output/目录里还留着旧文件,而 rsync 的--delete只在源端没有该文件时才会删除目标端对应文件,源端里还有残留,自然删不掉。
修复办法很简单:每次生成前先清空output/目录,脚本开头加一行:
shutil.rmtree(OUTPUT_DIR, ignore_errors=True) os.makedirs(OUTPUT_DIR, exist_ok=True)这样每次生成都是从零开始,上一次的产物不可能留到下次。后来我又顺手处理了浏览器缓存问题:在生成的每个 HTML 页面的<head>里加入一段禁止缓存的小标签。不管服务器怎么更新,浏览器总会强行拿到最新版本。
这看起来都是细节,但对一个只想安静写作的人来说,稳定的“所见即所得”特别重要。我不想每次更新完还要担心读者看到的是不是上一版。
6. 用下来的真实感受:caveman 解决的不只是写博客的问题
现在这套系统至少稳定跑了几个月。我不敢说它是史上最优雅的动态站架构,但它给我的体验确实完全不一样了。
6.1 写作效率回到了“打开记事本”的程度
我现在从打开电脑到新文章上线,大概只需要三分钟:写 Markdown,关掉文件,输入那两行脚本命令,再等一秒钟,完事。没有后台等待,没有自动保存冲突,没有插件更新提醒。工具退到了它本该在的位置——一个不起眼但可靠的助手,而不是一个需要你不断讨好、维护的大爷。
这种变化最直观的体现是:我的草稿箱里积压的文章开始变少了,因为脑子里的想法可以直接变成成品,而不是一想起“还要登录后台、排版、插入图片、选分类、填摘要”就索性搁置。
6.2 这套思路还能往哪儿延伸
caveman虽然诞生于写博客的需求,但同样的逻辑完全可以迁移到别处:
- 团队内部知识库:几十个 Markdown 文件,一条命令生成静态站,放到内网服务器上。
- 产品更新日志:每次发版写一个
.md文件,自动按日期生成页面。 - 个人项目主页:一个模板、几个文件,比任何页面生成器都好维护。
数字极简主义的核心不是“什么都没有”,而是每一件剩下东西都必须经得起追问。我留在这套系统里的每一项,都能回答“没有它会怎样”这个问题。
6.3 如果你想自己做一套“穴居人工具”
最后,给你一个最直接的起点。别急着买域名、找服务器、写宏大需求。先把自己写过的一批文章导出成 Markdown 文件,然后打开电脑上的编辑器,模仿上文里的脚本,让这些文件能够变成一个简单的本地网页。只要这一步通了,后面的部署、优化、加功能都只是顺手的事。
你不需要用我的代码,也不需要完全认可我的取舍。caveman真正的价值在于逼你重新思考:你以为你需要的,和你真正需要的,之间的距离到底有多大。想通这一点,比搞定一百套工具都管用。