零依赖Python静态站点生成器Caveman:极简搭建个人博客
说实话,第一次想到用 “caveman” 给项目起名,是因为我实在受够了当前各种工具链的折腾。你会发现,如今想搭一个小网站,动辄就要初始化一个几GB的Node工程,装两三百个依赖,光是把开发环境跑起来就要五分钟,改一个标题还得等热更新转圈。而我真正想要的,是像穴居人一样,用最简单的工具,把内容干净利落地呈现出去。于是这个叫 Caveman 的项目就诞生了——一个零第三方依赖、只有一个Python脚本的静态站点生成器。它适合个人博客、项目文档、学习笔记分享,也适合那些需要在一台旧服务器上快速搭站、又不想被框架绑死的人。这篇文章就是它的完整复盘,包括设计思路、核心实现、完整搭建流程和那些只有实战才会踩到的坑。
1. Caveman 到底解决什么问题
1.1 为什么我会写一个“原始人”生成器
Caveman 这个词的本意是穴居人、原始人。在开发圈里,它经常被人戏称为“返祖”或“回到原始”。我写这个项目,说到底就是一次主动“返祖”。
我自己维护了一个折腾了很多年的技术博客,最早用 WordPress,后来换成 Hexo,再后来试过 Hugo。每换一次,都要学习一套新的配置语法,维护主题依赖,升级时还要提心吊胆。更让我难受的是,很多静态博客生成器本身就是一个巨型工程:Hugo 是编译好的单二进制文件,Hexo 背后是庞大的 Node 模块树,Jekyll 要装 Ruby 和一堆 gem。它们功能丰富,但对一个只需要写文章、放代码、贴图片的站点来说,实在有点杀鸡用牛刀。
Caveman 的定位非常朴素:只要操作系统自带 Python 3,就能直接跑。它不装任何第三方包,不需要 package.json,不需要 Gemfile,没有 node_modules。生成一个全新站点,整个过程消耗的时间在1秒左右,占用的磁盘空间不超过100KB。它就是那种“回到洞穴”之后,反而让你觉得舒服的极简工具。
1.2 适用场景与目标用户
我在设计 Caveman 时,并没有想做一个全能产品,它的适用边界非常明确。
首先是个人博客和内容网站。如果你和我一样,主要用 Markdown 写作,需要一个像样的静态页面,而且希望目录结构一眼能看明白,Caveman 很顺手。其次是产品文档和内部知识库,团队里如果有一台长期不维护的服务器,不想在上面装 Node,只靠一个 Python 脚本就能把文档站跑起来,运维成本会低很多。最后是学习演示项目,很多初学者想理解“静态站点生成”的原理,但又不想被复杂框架的抽象概念劝退,Caveman 就是个不错的解剖样例。
如果你需要的是复杂主题、插件生态、动态路由、评论系统、搜索服务,那 Caveman 显然不合适。它不是一个高大全的平台,而是刻意保持“原始”的个人工具。我认为这个取舍恰恰是它的价值,工具只要够用,复杂就是负担。
2. 技术选型与项目架构
2.1 为什么坚持零第三方依赖
在做技术选型时,我反复问自己一个问题:什么样的工具,能在十年后依然跑得起来?
十年后,Node 可能已经换了 N 个大版本,Ruby 生态也可能翻天覆地,但 Python 3 大概率还在。更核心的是,如果我依赖了某个第三方库,那这个库的作者一旦停止维护,我的构建工具就变成了一颗定时炸弹。与其这样,不如只依赖 Python 标准库。
我用 Python 3.8+ 的pathlib处理路径,用re做正则匹配,用html做转义,用datetime处理日期,用http.server做本地预览。标准库里的这些模块已经足够稳定,而且 Python 本身自带的“电池”能覆盖九成需求。我还特意写了一个--doctor参数,用来检查当前环境的 Python 版本和相关依赖是否可用,确保老服务器也能平滑运行。
对比之后,优劣势就清楚了:
| 方案 | 依赖数量 | 安装复杂度 | 构建速度 | 可维护性 |
|---|---|---|---|---|
| Caveman | 0 | 只需 Python 3 | 毫秒级 | 一个脚本,容易理解 |
| Hugo | 0 | 下载二进制 | 极快 | 功能强但学配置有成本 |
| Hexo | 上百个 npm 包 | node_modules 体积大 | 中等 | 依赖树复杂,升级易坏 |
| Jekyll | 一堆 gem | Ruby 环境要求多 | 中等 | 版本兼容问题常见 |
我选择“原始”,并不是排斥现代工具,而是希望在“功能”和“可控”之间找到一个平衡点。当构建工具完全由自己控制时,出了问题你能直接打开脚本排查,而不是去搜索引擎里翻某个依赖的 issue。
2.2 目录结构设计与构建流程
Caveman 的目录结构,刻意简化到一眼能看懂的程度:
caveman/ ├── caveman.py ├── content/ │ ├── index.md │ ├── blog.md │ ├── about.md │ └── posts/ │ ├── 2025-01-01-first-post.md │ └── 2025-01-10-second-post.md ├── assets/ │ ├── style.css │ └── logo.png └── build/content目录里放所有 Markdown 源文件,index.md对应的就是首页,posts子目录放博客文章。assets目录放样式和图片,构建时会原样复制到输出目录。build是最终生成的静态网站,整站只有 HTML、CSS、图片,任何静态服务器都能直接托管。
构建流程我用一条非常原始的链路来完成:扫描所有.md文件,解析 front matter,将 Markdown 内容转成 HTML,套进内置模板,生成叶子页面和列表页面,最后复制assets。整个过程没有目录监听,没有增量构建,没有缓存,每次都是全量生成。虽然听起来“笨”,但对于几十篇文章的博客,全量构建根本不存在性能问题。
这样的设计还有一个好处:任何人拿到这个项目,不需要阅读长文档,只看一眼目录和流程,就能明白自己在做什么。这在我后来维护的过程中省了很多心力。
3. 从零实现核心功能
3.1 读取内容与解析 front matter
每个 Markdown 文件的开头,可以包含一小段元信息,我用最接近 YAML 的格式,但实现上只支持最简单的键值对:
--- title: 第一篇文章 date: 2025-01-01 tags: [python, web] --- 这里是正文。解析这一块,不需要引入 YAML 库,一个正则匹配就够:
def parse_front_matter(text): meta = {} match = re.match(r"^---\s*\n(.*?)\n---\s*\n?", text, re.DOTALL) if not match: return meta, text block = match.group(1) for line in block.splitlines(): if ':' not in line: continue key, value = line.split(':', 1) key = key.strip().lower() value = value.strip().strip('"\'') if key == 'tags': value = [tag.strip() for tag in value.strip('[]').split(',')] else: meta[key] = value return meta, text[match.end():]这里有几个细节值得注意。日期的格式我统一要求成YYYY-MM-DD,这样在排序时直接用字符串比较即可,不需要转成时间对象。标签只支持用逗号分隔,写入时别花哨,解析代码就能保持简单。如果正文开头没有---,那就说明这个文件没有元数据,按纯正文处理,title就会退化为文件名。这个设计在容错和简洁之间取得了平衡。
3.2 轻量 Markdown 转换
我并没有照着完整 CommonMark 规范去实现 Markdown 解析,那是一个无底洞。Caveman 只需要覆盖我写作时最常用的几个语法:标题、段落、无序列表、引用、行内代码、代码块、粗体和链接。
def md_inline(text): text = html.escape(text, quote=False) text = re.sub(r"\*\*(.*?)\*\*", r"<strong>\1</strong>", text) text = re.sub(r"`(.*?)`", r"<code>\1</code>", text) text = re.sub(r"\[(.*?)\]\((.*?)\)", r'<a href="\2">\1</a>', text) text = re.sub(r"!\[(.*?)\]\((.*?)\)", r'<img alt="\1" src="\2">', text) return text块级的解析,我按行扫描,遇到#开头的行生成标题,遇到-开头的连续行合并成一个列表,遇到空行结束当前段落。代码块的判定则更原始一些:当遇到```python这样的围栏时,直接收集后续内容,直到再次遇到```为止。
def parse_blocks(text): lines = text.splitlines() html = [] i = 0 while i < len(lines): line = lines[i] if line.startswith('```'): code = [] i += 1 while i < len(lines) and not lines[i].strip().startswith('```'): code.append(lines[i]) i += 1 html.append('<pre><code>' + html.escape('\n'.join(code)) + '</code></pre>') elif line.startswith('#'): level = len(line) - len(line.lstrip('#')) content = md_inline(line.lstrip('#').strip()) html.append(f'<h{level}>{content}</h{level}>') elif line.startswith('- '): items = [] while i < len(lines) and lines[i].startswith('- '): items.append('<li>' + md_inline(lines[i][2:]) + '</li>') i += 1 html.append('<ul>' + ''.join(items) + '</ul>') elif line.startswith('> '): quote = [] while i < len(lines) and lines[i].startswith('> '): quote.append(md_inline(lines[i][2:])) i += 1 html.append('<blockquote>' + '<br>'.join(quote) + '</blockquote>') elif line.strip() == '': i += 1 else: para = [] while i < len(lines) and lines[i].strip() != '': para.append(md_inline(lines[i])) i += 1 html.append('<p>' + ' '.join(para) + '</p>') return '\n'.join(html)这样写出来的解析器,既有“手写轮子”的乐趣,又足够应对我真实写作场景。它没有做嵌套语法支持,比如代码块里的粗体不会被解析,因为正则只作用在普通段落里。这个实现也许不优雅,但它可控。
3.3 页面生成与样式处理
Caveman 的页面生成逻辑位于一个render_page函数中。它接收title、content_html、nav_html和一份页脚信息,然后输出完整的 HTML 字符串:
def render_page(title, content_html, nav_html): return f"""<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{html.escape(title)}</title> <link rel="stylesheet" href="assets/style.css"> </head> <body> <header> <a href="/" class="logo">Caveman</a> <nav>{nav_html}</nav> </header> <main>{content_html}</main> <footer>Powered by Caveman</footer> </body> </html>"""首页和文章列表页都会生成一份包含所有文章链接的导航。我在构建时会维护一个articles列表,每一项包含title、url、date。然后按日期倒序,生成一个简单的索引块:
def render_index(articles): items = [] for art in articles: items.append( f'<li><a href="{art["url"]}">' f'{html.escape(art["title"])}</a>' f'<span class="date">{art["date"]}</span></li>' ) return '<h2>文章列表</h2><ul>' + '\n'.join(items) + '</ul>'样式处理上,我没有用任何 CSS 框架,只写了一个不到 80 行的style.css。为了让页面在手机和电脑上都不难看,我重点设置了max-width: 760px、line-height: 1.7、padding: 16px,以及代码块的黑底白字样式。生成的整个站点没有 JavaScript,阅读体验反而非常安静。
4. 完整实操:把你的站点搭起来
4.1 环境准备与项目初始化
在动手之前,先确认一下 Python 环境。绝大多数 Linux 发行版和 macOS 都自带 Python 3,Windows 用户去官网装一个就行。
python3 --version只要输出的版本号大于等于 3.8,就可以直接使用。然后创建项目目录,把 Caveman 的核心脚本放进去:
mkdir -p caveman/content/posts mkdir -p caveman/assets cd caveman # 假设 caveman.py 已经创建好 touch caveman.py我建议把脚本文件放在项目根目录,并保持零依赖。不要创建虚拟环境,也不要用 pip 安装任何东西,这样项目真的能随便拷贝。为了让生成结果可预测,我还会在脚本里固定输出目录为build,并且默认忽略文章内容里的绝对路径,方便之后部署。
4.2 写文章、调样式、生成站点
初始化后,第一件正事就是写首页。我用一个简单的content/index.md:
--- title: 欢迎来到 Caveman date: 2025-01-01 --- 这是一个用极简脚本生成的静态博客。 - 没有依赖 - 没有后台 - 没有数据库 写起来很安心。再写一篇文章,放在content/posts/2025-01-10-first-post.md:
--- title: 我为什么要回到原始工具 date: 2025-01-10 tags: [思考, 极简] --- 工具的价值在于节省时间,而不是占有注意力。 **Caveman** 就是这种思考的产物。随后执行构建命令:
python3 caveman.py build这时你会发现build目录下出现了index.html、posts/2025-01-10-first-post.html,以及复制过来的assets/style.css。要本地预览,直接起一个简易 HTTP 服务器:
python3 -m http.server 8000 --directory build浏览器打开http://localhost:8000就能看到完整页面。
我在调整样式时有一个心得:尽量只改一个style.css文件,不要让模板和样式各自为政。因为静态站点的调试链路很短,你改一行 CSS,刷新页面就能看到效果,不需要等任何编译。
4.3 部署到 GitHub Pages 或自己的服务器
部署这件事,Caveman 也不复杂。如果你想部署到 GitHub Pages,可以先在 GitHub 上新建一个仓库,然后执行:
git init git add . git commit -m "first build" git branch -M main git remote add origin git@github.com:你的用户名/你的仓库.git git push -u origin main之后开启仓库的 GitHub Pages 功能,把分支设置为main,根目录指向build,就能获得一个公网地址。由于 Caveman 生成的页面全部是静态文件,GitHub Pages 完全不需要任何额外配置。
如果是部署到自己买的服务器,我更推荐用 Nginx 来做:
sudo mkdir -p /var/www/caveman sudo cp -r build/* /var/www/caveman/然后在 Nginx 配置里把root指向/var/www/caveman,就完成了。整个过程没有任何后台进程,也不用担心安全漏洞,因为压根没有可执行代码在服务器上跑,只有静态文件。
5. 我在使用过程中踩过的坑
5.1 高频故障速查表
任何工具都是在真实使用中才能打磨好,Caveman 也不例外。下面是我多次重建站点时遇到的几个高频问题,整理成了速查表:
| 故障现象 | 常见原因 | 解决办法 |
|---|---|---|
| 页面样式丢失 | 没有复制assets目录 | 构建脚本中固定shutil.copytree或cp -r |
| 中文文件名乱码 | 文件保存编码不是 UTF-8 | 编辑器统一设置 UTF-8,脚本内打开文件时指定encoding="utf-8" |
| front matter 解析失败 | 冒号后忘记加空格 | 写元数据时统一使用key: value,冒号后加一个空格 |
| 日期排序不对 | 日期格式不一致 | 只用YYYY-MM-DD,不要混合2025/01/01 |
| 文章链接 404 | 文件名含空格或特殊符号 | 文件名只使用小写字母、数字和短横线 |
| 本地预览图片不显示 | 图片用的是绝对路径/assets/... | 改为相对路径assets/...或../assets/... |
这个表格看着简单,每一条背后都是一次痛苦的调试。尤其是图片路径问题,在本地预览一切正常,推到线上就 404,排查了半天,最后发现是我在 Markdown 里写了/assets/logo.png,部署到子目录后根路径对不上。现在我在写作规范里明确要求:所有资源引用一律使用相对路径。
5.2 几条独家经验
除了故障排查,我还总结了几条能让工具更长效运行的独家经验。
第一,内容文件永远比工具重要。Caveman 只是一个渲染器,真正有价值的是你写在content目录里的文章。所以我后来每次重新搭建站点,都先把content和assets用 Git 管理起来,caveman.py甚至可以随时修改。只要内容还在,工具坏了也能快速重写。
第二,构建脚本要写得足够“笨”。我特意避免了任何花哨的抽象,一个函数只做一件事,变量名全部用完整的英文单词。代码量虽然多了几十行,但半年之后再打开,依然能一分钟内看懂每一个函数在干什么。这种可读性,是长期维护中最大的红利。
第三,定期生成一次完整快照。我会在每次生成后,把整个build目录压缩备份到一个指定文件夹。这样万一改了内容后不小心破坏了构建,还能马上从快照恢复线上站点。因为静态站点的恢复速度非常快,这种原始但可靠的手段,反而比复杂的发布系统更让人安心。
说到最后,我想聊聊这个项目给我带来的改变。很多人觉得用现代框架是天经地义的事,但 Caveman 让我重新体会到,在技术与技术之间,选择“能解决问题”的那个,才是真正的自由。每当我写一篇新文章,执行一次几毫秒的构建,把生成的文件夹丢到服务器上,那种没有被层层抽象遮挡的掌控感,是那些需要等待编译和打包的框架给不了的。
如果你也受够了工具链带来的焦虑,不妨试着做一个属于自己的 “caveman”。它不需要多完善,只需要帮你把内容干净地送到读者面前。相信我,当你第一次只用系统自带的命令就完成整个网站发布时,生活都变得轻盈很多。