ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零手写极简静态网站生成器:只用Python标准库从Markdown到HTML

从零手写极简静态网站生成器:只用Python标准库从Markdown到HTML

caveman 是我最近折腾的一个小项目——一个不带任何框架依赖的静态网站生成器。名字听起来原始,实际上它干的事也足够原始:给我一堆 markdown 文件,我把它变成一堆 html 文件,扔到服务器上就能访问。没有 node_modules,没有构建链,没有动态渲染,甚至连 npm install 都不用跑。这个项目解决的是我自己的一个痛点:我就想写点东西发到网上,不想为了一个博客跑三四个框架,更不想每次写完还要等构建脚本跑完才看到效果。如果你也打算做个个人站点、项目文档站,或者单纯想把本地笔记变成可分享的网页,这套思路应该能帮上忙——尤其是当你对速度和可维护性有要求、又不想被工具牵着走的时候。

1. 项目背后的设计思路

1.1 "caveman" 这名字到底想表达什么

起这个名字不是因为它和史前文化有什么关系,纯粹是因为这套工具的哲学就是"像个穴居人一样做事":手里有什么就用什么,不折腾复杂的装备,把食物烤熟能吃就行。

放在网站生成这件事上,caveman 的理念就是:只用 Python 标准库解决 90% 的问题,剩下的 10% 用手写模板补上。我见过太多个人项目死在了过度设计上——装了个 React 生态,结果半年没写几篇文章,光升级依赖就花了一整天。caveman 反着来,它假设你只需要三种东西:markdown 文档、一个模板目录、一个输出目录。构建过程就是"读取 -> 转换 -> 渲染 -> 写出",中间没有增量编译、没有热更新、没有插件系统。

这不是技术上的退步,反而是可控性的提升。当整个构建脚本只有几百行、所有逻辑一眼能看透的时候,你根本不需要"调试构建过程"这回事。你对最终产物的每一个字符负责,而不是把一个黑盒构建工具链的报错信息交给搜索引擎。

1.2 为什么不用现成的 Hexo 或 Hugo

我在做 caveman 之前是认真用过主流静态站点生成器的。Hugo 快是真快,但模板语法和配置项的学习成本不低;Hexo 胜在生态全,可 node_modules 动辄几百兆,插件之间的版本冲突也够喝一壶的。最让我难受的是:为了改一个我不满意的小细节,得先搞懂整个框架的生命周期。

caveman 不是要替代它们,它只服务于一类场景:站点的结构非常简单、内容以文章为主、搜索引擎和页面加载速度是刚需。这种场景下,用纯 Python 手写一个生成器反而更舒服:

  • 全部代码加起来不超过 500 行,任何人都能读完、改写;
  • 没有第三方依赖,换一台电脑 clone 下来就能跑;
  • 构建时间在转瞬之间,几十篇文章也是秒级完成;
  • 输出的是纯静态文件,部署到哪里都行。

选择 Python 而不是 Shell 脚本,是因为 Python 处理文本编码、路径拼接和正则替换更顺手。SHELL 的字符串处理在这种多文件批量转换的场景里会变成一坨难以维护的胶水,而 Python 的标准库刚好够用,不需要引入任何外部包。

1.3 这个项目解决的实际痛点

用 caveman 这种极简工具解决的,本质上是三个层面的问题:

第一,内容与展示分离。文章就是 markdown 源文件,样式就是一套模板。换主题就是换模板目录,而不是在一个几十万行的框架里找配置项。

第二,长期维护低成本。静态网站没有数据库、没有运行时漏洞、没有后台登录页面,自然也不需要天天打补丁。五年后回来看,这套构建脚本大概率还能原样跑起来。

第三,环境迁移无忧。只要目标机器有 Python 3.8 以上的解释器,不需要 pip install 任何东西。这个特性在服务器迁移、临时搭建演示站的时候极其省心。

2. 工具链选型与项目结构

2.1 为什么只用 Python 标准库

写 caveman 之前我给自己立了个规矩:能不装依赖就不装依赖。原因很现实——依赖是"熵增"的源头,每多一个第三方库,就多一层环境断裂的风险。有的库只在特定 Python 版本下编译,有的库安全补丁要追着更新,这跟"极简、可控"的目标完全背道而驰。

实际用到的标准库主要就这几个:

模块用途
pathlib处理所有文件路径,跨平台兼容
markdown对应的实现下面专门说
html处理 HTML 转义,防止内容注入和渲染错乱
datetime解析文章日期、生成时区无关的时间戳
re提取元信息、做字符串替换
xml.etree生成 RSS 2.0 的 XML 结构

这里有一个细节值得展开:Python 标准库本身不带 markdown 转 HTML 的能力,所以我一开始面临两个选择——一个是pip install markdown这个库,另一个是自己写一个极简的转换器。最终我选择了折中方案:解析器用自己写的最小实现,只支持标题、段落、列表、引用、代码块、链接和图片这几类语法,不支持表格、脚注、数学公式。

为什么敢这么简化?因为我的使用场景是个人博客,文章排版最多用到上面那几种元素。而且自定义解析器的好处是,我可以控制输出 HTML 的结构,比如给代码块包一层带复制按钮的容器,给图片自动加上懒加载属性,这些在标准 markdown 库里反而要绕路。等哪天我真的需要表格了,再花半小时补上也不迟——这就是"够用就好"的实践。

2.2 目录结构设计

caveman 的目录结构刻意保持扁平,一眼就能看懂每个文件夹是干什么的:

caveman/ ├── content/ # 所有 markdown 文章,按年份归类 │ ├── 2024/ │ ├── 2025/ │ └── _drafts/ # 草稿区,构建时不处理 ├── templates/ # HTML 模板 │ ├── base.html # 页面骨架,所有页面共用 │ ├── index.html # 首页列表 │ └── post.html # 文章页 ├── static/ # CSS、JS、图片等静态资源 ├── output/ # 构建产物,这个目录可以随时删掉重建 └── build.py # 整个生成器的主体脚本

这样一个结构的核心设计在于:content和templates是"源文件",output是"产物",两者严格分开。构建前清空output,保证每次生成的站点都是全新状态,不会残留旧文件导致线上出现脏数据。

2.3 关于模板引擎的选择

我这里没用 Jinja2,连 Jinja2 都没用,而是用了 Python 自带的string.Template加上少量字符串格式化。

很多朋友看到这里会问:模板引擎不是越强越好吗?实际上对于一个静态博客来说,Jinja2 的能力你根本用不到十分之一,反而会带来两个问题:

  • 模板上下文变量的传递关系不透明,经常调试半天发现是变量名写错;
  • 一旦用了自定义过滤器、宏定义,这些逻辑就把模板"变重"了,看模板的人被迫去理解一套额外的语法。

string.Template的语法就是$title和${post.title}这种形式,简单到不用看文档。配合 Python 的format方法做轻微的逻辑控制,比如循环生成列表项,就足够完成整个站点的输出了。更重要的是,模板本身也是纯文本,可以用任意编辑器打开修改,没有任何魔法。

3. 核心实现:从 Markdown 到静态站点的全过程

3.1 文章的元信息解析

每一篇 markdown 文章的开头需要一段 YAML 风格的元信息块,我用正则表达式提取。虽然 YAML 严格解析有点复杂,但我只取三个字段:标题、日期、标签。格式约定好就够用:

--- title: caveman 项目实战记录 date: 2025-06-01 tags: [python, static-site, diy] --- 这里是正文内容...

解析的时候,先把---之间的内容切出来,再用re.findall按字段提取。这里有个易踩的坑:不要在正文里随便写---作为分隔线,因为我的切分逻辑按第一个---为起点、第二个---为终点,正文中的分隔线会让切分位置错乱。解决办法很粗暴:在解析前先检查文档开头必须是---,否则直接按"无元信息"处理。

日期字段还要做一次规范,因为文章排序需要按时间倒序。我用datetime.strptime把字符串转成时间对象,统一使用%Y-%m-%d格式,不接受其他格式——格式统一可以减少数不清的边界问题。

3.2 从 Markdown 到 HTML 的转换实现

转换器是整个项目最核心的部分,我把它拆成了按行扫描的状态机。

基本思路是:逐行读取文本,根据当前行内容判断"我正处于什么状态"。比如遇到#开头就是一级标题,遇到```就是进入代码块状态,遇到-就是列表项。状态评估完毕后,再调用对应的渲染函数生成 HTML 行。

关键代码示意如下:

def convert_markdown(text): lines = text.split("\n") html_lines = [] in_code = False for line in lines: if line.startswith("```"): in_code = not in_code html_lines.append("<pre><code>" if in_code else "</code></pre>") continue if in_code: html_lines.append(line) continue # 标题 if line.startswith("# "): html_lines.append(f"<h1>{line[2:]}</h1>") elif line.startswith("## "): html_lines.append(f"<h2>{line[3:]}</h2>") # 其他块级元素... elif line.strip() == "": html_lines.append("") # 普通段落做行内处理 else: html_lines.append(f"<p>{render_inline(line)}</p>") return "\n".join(html_lines)

行内处理render_inline负责把**加粗**、[链接](url)、`代码`这类行内语法转换成对应的 HTML 标签。注意顺序很重要:必须先处理行内代码,避免把代码中的星号或方括号误判成 markdown 语法。这个顺序问题是我实际踩过的坑之一,后面在问题排查里会细说。

3.3 模板渲染与整站生成

当一个 markdown 文件被转换成 HTML 后,我需要把它塞进页面的骨架里。caveman 只有一个基础模板base.html加上一个文章页模板post.html,两者的关系很简单:base.html定义了<html>、<head>、页头和页脚,post.html进一步继承它,往内容区插入正文。

我用一个字典把所有要渲染的变量打包:

context = { "title": post_meta["title"], "date": post_meta["date"], "content": post_html, "tags": post_meta["tags"], }

然后用模板字符串的format_map做替换。注意,如果模板中出现了context里不存在的变量,直接替换会抛KeyError。为了避免边写边崩,我在调试阶段做了个简单的保护,把缺失变量替换成空串。等站点稳定后,又把报错改回来了——因为静默失败会掩盖模板拼写错误,这对维护非常不利。

3.4 首页列表、归档页与 RSS 的生成

文章页生成之后,还得有入口页面。首页的逻辑是把所有文章按日期倒序排列,渲染成文章标题和摘要的列表。摘要也从正文里提取,规则是:取正文转成纯文本后的前 150 个字符作为摘要,直接写入首页。这个规则比让用户手动填 summary 字段省事多了,而且效果足够好。

归档页则按年份分组,每篇文章显示在一个时间轴下。RSS 使用xml.etree.ElementTree构建,生成feed.xml。RSS 的功能虽然现在很多人不用了,但有个好处是让内容能被各种阅读器和订阅服务抓取,我建议保留。

这三样页面都共享同一个base.html,只是中间的内容区不同。到这里,"从 markdown 到完整站点"的主流程也就跑通了,整个build.py从 0 到 1 只花了大半天的时间。

3.5 静态资源与部署细节

static目录负责存放 CSS 和 JavaScript。构建脚本会把这个目录原样复制到output,不做任何压缩合并——因为对一个小站点来说,压缩合并带来的收益远小于它带来的复杂度。CSS 文件本身控制在几十 KB 内,加载压力很小。

部署我直接用 Nginx 指到output目录,配合 rsync 上传:

rsync -avz --delete output/ user@server:/var/www/caveman/

注意--delete参数,它保证远端目录和本地output严格一致,本地删掉的文件远端也会删除,避免残留页面出现在线上。这个参数对自动化部署特别重要,但用的时候要小心确认目录写对了,不然可能会误删文件。

4. 实操中踩过的坑与排查记录

4.1 中文文件名与 URL 编码问题

最开始我喜欢用中文标题直接当文件名,例如content/2025/如何使用caveman.md。本地构建没问题,但浏览器访问时 URL 会变得很长很难看,而且某些老旧软件对非 ASCII 路径处理不好,复制链接粘贴到聊天工具里还可能出现转义问题。

后来我改成了一套规范:文件名全部使用英文短横线命名,例如how-to-use-caveman.md。文章标题仍显示中文,但链接永远保持简洁。如果已经有一批中文文件名的旧文章,可以做个重定向规则,把编码后的 URL 映射到新地址,不过这属于一次性清理工作,我现在立项时就会约定好命名规范。

4.2 行内代码里的特殊字符被误解析

这个坑想起来还觉得好笑。我在代码块之外写了个句子,内容是"在终端输入python3 --version",结果用加号让系统报错了。原因是我行内代码里的--version没有特殊含义,问题出在另一个场景:当我在行内代码里写*args时,星号会被render_inline当成加粗的起始标记,于是*args*就变成了斜体而非代码。

解决办法就是在render_inline里保证先处理行内代码标记,把反引号之间的内容直接提取出来并标记为"已处理",后续所有正则替换都跳过这些已保护的区域。实现上相当于一个简易的"先占位、后还原"机制,很值得推荐。

4.3 文章日期与时区混乱

因为个人站点不需要太复杂的时区逻辑,我最初直接用了datetime.now()给没有日期的文章打时间戳。但这就导致了两个问题:一是在不同时区的电脑上构建,同一篇文章的发布时间会不一样;二是手动指定日期后,有的文章时区是对的、有的没带时区,排序时对不齐。

最终我定下规则:文章时间只精确到天,格式YYYY-MM-DD,不处理小时和分钟。RSS 里的pubDate统一用YYYY-MM-DD加固定时区偏移来格式化,比如2025-06-01T00:00:00+08:00。把粒度变粗之后,排序逻辑就干净了。

4.4 部署后样式和图片丢失

本地打开output/index.html一切正常,上传到服务器之后 CSS 全部失效。排查发现是我在模板里用了绝对路径/static/style.css,但当时站点是放在服务器子目录/var/www/html/caveman/下,根路径访问自然找不到。

后来的做法是:在base.html里用相对路径./static/style.css,这样不管站点是部署在域名根目录还是某个子目录,页面和资源之间的相对关系都不变。当然要留意,相对路径会导致不同层级的页面引用资源时写法略不同,所以我在模板渲染时统一做了一次"相对路径前缀"处理,根据当前页面所属目录深度自动补齐../,这个问题也就彻底解决了。

下面是我整理的常见问题速查表:

问题现象可能原因解决方案
首页文章顺序颠倒日期解析失败,全部按默认值排序检查每篇文章的date字段格式
页面显示纯文本,没有样式CSS 路径用了绝对路径改用相对路径或部署时放到对应目录
正文中 markdown 符号没转换缩进或空行导致块级判断错误确认段落行没有多余空格前缀
标签页打不开标签归档逻辑没有生成对应 HTML构建脚本里补充标签集合扫描
构建时中文乱码文件未按 UTF-8 读写打开文件时显式指定encoding="utf-8"

4.5 增量构建与缓存带来的额外坑

早期我给构建脚本加过一层"只处理变更文件"的逻辑,希望通过记录文件的 mtime 来节省时间。事实证明这就是没事找事——因为全量构建本来也只要几百毫秒,增量逻辑却引入了大量状态管理的坑,例如清理失效文章时旧文件不会被自动删除,导致线上出现幽灵页面。

现在我已经把增量逻辑整个删掉了,每次构建都是全量生成,然后用 rsync--delete保持线上和本地一致。对小型站点而言,"全量构建 + 同步删除"永远比"增量构建 + 记住历史状态"简单可靠。这也是 caveman 从一开始就坚守的原则:如果需要用一个复杂机制来解决一个简单问题,那一定是问题定义错了。

5. 后续扩展方向与个人使用心得

5.1 值得做的三个扩展

caveman 虽然极简,但扩展空间其实不小。我自己主要补了三个功能:

第一,全文搜索。因为内容是纯静态的,我可以把每篇文章的纯文本连同标题、链接一起生成一个search.json。前端放个输入框,用 JavaScript 做关键词匹配,不需要任何后端服务,每天访问量几千的站点完全够用。

第二,图片懒加载。在render_inline里处理图片标签时,自动把loading="lazy"和width、height给加上,避免页面加载时图片撑版、造成布局偏移。这个改动对移动端的体验提升非常明显。

第三,文章草稿与发布分离。我在content下放了一个_drafts目录,构建时默认跳过这个目录,需要发布时手动把文件移动到日期目录。比引入一个"发布状态"字段要直观得多。

5.2 构建脚本改进思路备忘

随着文章变多,我发现"标签体系"还是需要单独生成一个标签聚合页面,于是补了一段逻辑:扫描全部文章,提取所有标签,按标签名聚合文章列表,生成tags/<tag>/index.html。这个功能没有改主流程,只是最后加了一个"二次扫描"循环。

如果想要更丰富的页面,还可以在 base 模板里加入面包屑导航。不过面包屑对个人博客的意义不大,我评估后还是没有加——功能取舍的标准就一条:这个功能是否每周都会用,如果只是"偶尔有用",就先不做。

5.3 一点实在的体会

最后说点心里话。caveman 这个项目最大的收获,不是"我自己写了一个静态站点生成器"这件事本身,而是让我重新理解了工具和内容的关系。

用现成框架时,我时常被框架牵着走:框架支持的格式我才用,框架默认的主题我懒得改,框架升级了我就得跟着升级。caveman 反过来了,它完全被我定义——我决定支持哪些 markdown 语法,我决定页面长什么样,我决定构建流程怎么走。这种掌控感,是用了无数框架之后反而找不回来的东西。

如果你也觉得自己写个博客太复杂、用别人的框架又总觉得哪里别扭,我建议找个周末,仿照这个思路从零写一个小工具。不用急着加功能,先把"markdown 进、html 出"跑通,再一点点按自己的习惯去打磨。你会发现,事情远没有想象中那么难。

返回列表