
这几年我一直在跟博客系统较劲。先后用过 WordPress、Hexo后来切到 Hugo每次迁移都要搭进去一个周末主题要重新配渲染器版本一变老文章里的 Markdown 语法就开始飘。最后我索性自己写了一个老实的静态博客生成器项目代号就叫 caveman——穴居人不借助复杂的现代工具链光靠 Python 标准库和一个脚本把 Markdown 变成一套能直接发布的 HTML 网站。这个项目不打算挑战谁也没有炫技它只做一件小事但做得非常稳。如果你也被“明明只想写点东西结果在配置工具上花的时间比写作还多”折磨过这篇记录多少能给你一点参考。1. Caveman 项目定位被复杂工具链逼出的极简静态博客生成器1.1 我为什么要写一个叫 caveman 的小工具说起来有点不好意思我写 caveman 的初衷不是发现了什么新技术而是被主流静态站生成器折腾到失去耐心。Hugo 是我之前最喜欢的方案编译快、生态也不差。但有一次我把项目从旧版本升到新版本光配置文件就改了整整两天页面里的短代码差不多逐个调整。Hexo 那边也没省心一个主题背后挂几十个 npm 包依赖树稍微不一致渲染出来的页面就缺模块。最让我崩溃的是很多问题不是“改一下就能好”而是要用搜索引擎翻到论坛犄角旮旯里才找得到答案。我明明只是想写几篇文章却被迫成为一个工具链维护工程师。于是我说算了自己写一个。但我很清楚自己的自制力如果一开始就引入一堆库最后肯定会变成第二个 Hexo。所以 caveman 从第一天就定了三条铁律零第三方依赖构建输出纯 HTML源码保持单脚本可读。这个项目因此长得特别像穴居人——没有漂亮盔甲没有复杂装备但拎着石头就能打猎。1.2 项目要解决的三个具体问题第一是解决“十年后可运行”的问题。Python 标准库里的 pathlib、re、shutil 这些东西十年前存在十年后大概率还会在。我不担心哪天某个 npm 包被作者删掉导致整个项目无法安装。第二是解决“环境一致性”的问题。一台只有 Python3 的干净机器不需要npm install不需要下载主题不需要固定构建版本拷贝过去就能跑。这对个人博客、内部文档站、甚至临时搭建的项目主页都非常实用。第三是解决“自己可控”的问题。官方 Markdown 解析器做得再多它能解析的语法我也未必用得上。caveman 只需要标题、段落、代码块、列表、粗体斜体和链接解析逻辑被我攥在手里出问题三分钟就能定位。1.3 一眼看懂Caveman 和其他方案对比很多朋友听到“自己写生成器”第一反应是你这是重复造轮子。我不反对这个判断但要看你想要什么。下面这张表是我实际使用后的真实感受不是跑分结果维度cavemanHugoHexo第三方依赖0单二进制或无大量 npm 包构建速度毫秒级毫秒级秒级学习成本会 Python 就够Hugo 模板语法Node 生态主题生态无自己写 HTML/CSS丰富丰富升级风险几乎为零中高中高产物可移植性任意静态托管任意静态托管任意静态托管看这个表你可能会说生态才是关键。没错如果你需要大量现成主题、插件和社区方案caveman 完全不合适。但如果你像我一样只需要一个“写 Markdown、出页面、传上去”的管道那零依赖就是最大的优点。2. 核心实现拆解Python 标准库完成全部构建逻辑2.1 目录结构提前把边界划清楚项目虽然小目录结构还是认真想过。一个不合理的目录会在加功能时让人抓狂。caveman 目前长这样caveman/ ├── build.py # 唯一入口所有构建逻辑 ├── posts/ # 你的 Markdown 文章 │ ├── 2025-01-01-hello.md │ └── 2025-02-02-caveman.md ├── templates/ # HTML 模板 │ ├── index.html │ └── article.html ├── static/ # CSS、JS 等纯静态资源 │ └── style.css └── output/ # 构建产物生成后直接部署 ├── index.html ├── style.css └── hello/ └── index.htmlposts、templates、static 是三类不同性质的输入内容是写作产物模板是页面骨架静态资源是外貌。output 是生成物我把它写进.gitignore从来不手动改。这样划分的意义在于你每次写文章只会接触 posts 目录改版只会动 templates 和 staticbuild.py 负责把它们拼在一起各司其职。2.2 Markdown 解析器够用就行别贪心你问为什么不用现成的 markdown 库因为库也是一种依赖。既然是 caveman那就用re自己写一个“够用子集”。我按使用频率挑选了解析范围ATX 风格的标题、段落、围栏式代码块、无序列表、行内代码、粗体、斜体、链接。这些已经覆盖我日常写作的 95% 以上的语法。一开始我还想支持表格后来发现自己的文章里一个月都用不了几次反而增加了解析复杂度果断砍掉。核心解析逻辑很简单大致是这样# build.py 局部代码极简 Markdown 解析 import re import html as html_lib def inline(text: str) - str: # 注意先处理行内代码避免 ** 被后面的正则误伤 text re.sub(r([^]), rcode\1/code, text) text re.sub(r\*\*(.?)\*\*, rstrong\1/strong, text) text re.sub(r\*([^*])\*, rem\1/em, text) text re.sub(r\[([^\]])\]\(([^)])\), ra href\2\1/a, text) return text def render_markdown(text: str) - str: lines text.splitlines() out [] in_code False code_lines [] in_list False for line in lines: if line.strip().startswith(): if in_code: escaped html_lib.escape(\n.join(code_lines)) out.append(precode escaped /code/pre) code_lines.clear() in_code False else: in_code True continue if in_code: code_lines.append(line) continue heading re.match(r^(#{1,6})\s(.*), line) if heading: level len(heading.group(1)) out.append(fh{level}{inline(heading.group(2))}/h{level}) continue if not line.strip(): if in_list: out.append(/ul) in_list False continue if re.match(r^\s*[-*]\s, line): if not in_list: out.append(ul) in_list True item re.sub(r^\s*[-*]\s, , line) out.append(fli{inline(item)}/li) continue # 普通段落 out.append(fp{inline(line)}/p) if in_list: out.append(/ul) if in_code: # 文件结尾没有闭合围栏也尽量输出避免文章主体凭空消失 escaped html_lib.escape(\n.join(code_lines)) out.append(precode escaped /code/pre) return \n.join(out)这段代码里有两个细节是踩过坑之后才补的。第一inline函数必须先处理行内代码否则你写**不是粗体**时后面的粗体正则会先把星号替换掉行内代码里就会出现strong标签页面看起来会很奇怪。第二代码块内部要用html_lib.escape转义否则代码里出现div这类内容浏览器会真的把它当成 HTML 标签解析轻则显示异常重则打乱整个页面布局。2.3 模板渲染用字符串替换而不是模板引擎既然零依赖Jinja2 这种模板引擎自然也不能用。caveman 的模板机制简单到有点“原始”模板文件里写{{TITLE}}、{{CONTENT}}这类占位符Python 端读文件后直接replace。有人会问用string.Template不也是标准库吗我试过它在模板里遇到$符号会很敏感。你写 CSS 时到处都是$font-family这种根本不是想搞变量替换结果 Template 直接把后面的字符吃掉很烦。用{{变量}}这种自定义风格几乎不会和 HTML、CSS 语法冲突。模板文件templates/article.html长这样!-- templates/article.html -- !DOCTYPE html html langzh head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title{{TITLE}}/title link relstylesheet href{{BASE}}/style.css /head body headera href{{BASE}}/index.html返回首页/a/header main h1{{TITLE}}/h1 time{{DATE}}/time article{{CONTENT}}/article /main /body /html这里的{{BASE}}是为了解决文章页在子目录、首页在根目录时的 CSS 路径差异。生成首页时BASE设为空字符串生成文章页时设为..三行代码就搞定完全不需要什么 baseUrl 插件。渲染函数也简单def apply_template(filename: str, **kwargs): html (TEMPLATE_DIR / filename).read_text(encodingutf-8) for key, value in kwargs.items(): html html.replace({{ key }}, value) return html看到没就一个方法。想加新模板页复制一个文件再传入参数就行。维护成本近乎为零。2.4 构建入口与产物清理build.py 的主流程是清理旧 output、遍历 posts、解析 Markdown、套模板、写出 HTML、复制静态资源、生成首页列表。代码思路是这样def build(): POSTS_DIR.mkdir(exist_okTrue) if OUTPUT_DIR.exists(): shutil.rmtree(OUTPUT_DIR) OUTPUT_DIR.mkdir() # 1. 复制静态资源 if STATIC_DIR.exists(): shutil.copytree(STATIC_DIR, OUTPUT_DIR / static) (OUTPUT_DIR / style.css).write_bytes((STATIC_DIR / style.css).read_bytes()) # 2. 遍历文章并渲染 posts sorted(POSTS_DIR.glob(*.md), keylambda p: p.name) items [] for post in posts: meta, body parse_post(post) content_html render_markdown(body) page_html apply_template( article.html, TITLEmeta[title], CONTENTcontent_html, DATEmeta.get(date, ), BASE.., ) slug post.stem dest OUTPUT_DIR / slug dest.mkdir(parentsTrue, exist_okTrue) (dest / index.html).write_text(page_html, encodingutf-8) items.append( flia href{slug}/index.html{meta[title]}/a fsmall{meta.get(date, )}/small/li ) # 3. 首页 index_html apply_template( index.html, TITLECaveman 小站, CONTENTul .join(items) /ul, BASE, ) (OUTPUT_DIR / index.html).write_text(index_html, encodingutf-8)我特别注意清理产物这一步。如果 output 里残留旧文件比如某篇文章改名了旧的 HTML 就会被遗留在线上搜索引擎会抓到一堆 404。shutil.rmtree直接重建目录是最原始也最可靠的办法。3. 实操全过程从空目录到发布上线的完整步骤3.1 从零初始化一个站点假设你家里有一台带 Python3 的电脑从零开始五分钟内就能得到一个本地可访问的站点创建目录结构mkdir -p caveman/posts caveman/templates caveman/static把上面的 build.py 放进去补全模板和 CSS在 posts 里放一篇文章运行python3 build.py跑完会看到类似下面的输出提示我没有加框架纯靠 print 简单打印[build] clean output/ [build] render: hello/index.html [build] render: index.html [build] done, 2 files generated然后直接打开output/index.html就是完整的博客首页。不需要开发服务器不需要热更新不需要构建缓存。3.2 写一篇文章并生成首页列表caveman 的文章头部采用最朴素的---分隔 metadata设计得很直白--- title: 你好caveman date: 2025-01-01 tags: python, 极简 --- 这是我的第一篇文章。**caveman** 不是要取代谁而是想回归最朴素的写作体验。 - 零依赖 - 零构建环境 - 零学习成本 bash python3 build.py注意上面 Markdown 里我故意写了一个代码块意思是想说明代码块嵌套在文章里解析器会原样保留不会被编译。构建后打开 output/hello/index.html能看到标题、日期、正文首页也有对应链接。整个流程没有任何意外这正是我想要的——写文章就是写文章不折腾。 ### 3.3 添加归档、标签与 RSS 博客用久了你会想要“标签页”和“RSS 订阅”。caveman 加这些功能也很快因为你可以直接在 build.py 里动刀。比如生成标签页思路是先扫描所有文章的 tags再按 tag 分组 python def collect_tags(): tag_map {} for post in POSTS_DIR.glob(*.md): meta, _ parse_post(post) for tag in meta.get(tags, ).split(,): tag tag.strip() if not tag: continue tag_map.setdefault(tag, []).append(fa href../{post.stem}/index.html{meta[title]}/a) return tag_map def build_tag_pages(tag_map): tag_dir OUTPUT_DIR / tags tag_dir.mkdir(exist_okTrue) tags_html [] for tag, links in tag_map.items(): tags_html.append(fh3{tag}/h3ulli /lili.join(links) /li/ul) apply_and_write(article.html, tag_dir / index.html, TITLE标签归档, CONTENT.join(tags_html), DATE, BASE..)RSS 也同理手动拼 XML 就行def build_rss(): items [] for post in sorted(POSTS_DIR.glob(*.md), keylambda p: p.name, reverseTrue): meta, body parse_post(post) desc render_markdown(body)[:500] items.append(fitem title{html_lib.escape(meta[title])}/title linkhttps://example.com/{post.stem}//link description![CDATA[{desc}]]/description /item) rss_xml ?xml version1.0 encodingUTF-8?\nrss version2.0channeltitleCaveman/title \ .join(items) /channel/rss (OUTPUT_DIR / rss.xml).write_text(rss_xml, encodingutf-8)记得标题里一定要转义不然文章标题里出现或会让整个 RSS 文件变成非法 XML。这是我在生成 feed 之后被校验工具骂过才学会的。3.4 一键构建与部署发布caveman 的产物是纯静态文件部署理论上可以是任何地方Nginx、对象存储、GitHub Pages。我自己的做法是在服务器上放一个 Nginx 配置server { listen 80; server_name example.com; root /var/www/caveman; index index.html; }本地发布时我用一条 rsync 命令把 output 同步过去python3 build.py rsync -avz --delete output/ userserver:/var/www/caveman/--delete参数是关键——它会删除服务器上已不存在的文件保证产物和本地 output 绝对一致。整个发布流程从我敲下命令到网站更新通常不超过十秒。我还为此加过一个超级偷懒的 shell 别名alias pubcd ~/caveman python3 build.py rsync -avz --delete output/ userserver:/var/www/caveman/之后每天写文章的动作就变成写 Markdown保存终端里敲pub完事。这种极简到近乎粗暴的发布体验我用了一年觉得非常解压。4. 常见问题与排查技巧实录我踩过的坑你就不用踩了4.1 Windows 记事本与 UTF-8 BOM 问题如果你在 Windows 下写 Markdown用记事本另存为 UTF-8 时它会在文件开头塞一个\ufeff字符BOM。这个字符肉眼看不见但读进 Python 后会影响标题判断。我遇到的情况是文章标题怎么都无法解析打印出来发现字符串前面莫名多了一个字符。处理方式很简单读取内容后先做个清理。text text.lstrip(\ufeff)别小看这个字符。它曾经让我的首页第一篇文章链接全部失效排查了二十分钟。后来我在 parse_post 里统一处理就再也没出过问题。4.2 文章排序和中文文件名问题Python 的glob返回的文件顺序在不同系统上不一样直接glob()然后按列表顺序展示首页文章顺序会很随机。更稳妥的是用文件名里的日期前缀排序。我现在写文章固定用这样的命名2025-01-01-hello.md。排序时直接sorted(..., keylambda p: p.name)因为字符串按年月日顺序排列时恰好和时间顺序一致。中文文件名不是不能用但 URL 里会有编码问题而且排序会让你头疼。老老实实用英文 slug 加日期前缀最省事。4.3 代码块内容被错误解析或转义这个问题在自写 Markdown 解析器时非常典型。第一次实现时我是在循环里先处理行内语法再判断代码块结果代码块里的**example**被渲染成加粗代码块里的div直接被浏览器解析成标签整个页面布局都崩了。解决办法就是文章第 2.2 节展示的顺序先判断是否在代码块状态代码块内容不做行内解析并且统一用html_lib.escape转义。这个顺序一旦固定以后再加其他语法时也要确保新规则不会破坏代码块优先级。4.4 部署后样式丢失和路径问题文章页和首页不在同一个目录层级CSS 路径自然不一样。第一次部署后我打开文章页发现页面一点样式都没有原因是模板里写死了style.css而文章页实际是在/hello/子目录下。我后来在模板里统一引入{{BASE}}变量首页填空字符串文章页填..。这样搞还有个好处如果整个网站要部署到某个子路径下比如https://example.com/blog/我只需要把BASE变量整体改成项目前缀然后保持模板不变即可。4.5 功能膨胀的心理坑如何守住 Caveman 的边界这个坑没有报错信息也没有技术细节但差点毁了整个项目。caveman 上线一个月后我开始想给它加主题切换、加评论区、加搜索、加深色模式……每一样听起来都很有道理。后来我给自己定了一条“功能准入规则”必须能用三句话讲清楚必须只改一个文件必须不新增任何第三方依赖。不符合就直接砍掉。这条规则拦住我至少五个“妙主意”caveman 也因此一直保持着单个脚本的体量。现在看到别人的博客有评论区、有数据统计我当然也眼馋但回头看自己的需求其实只是安安静静写文章。这个项目叫 caveman就是提醒我别忘记最初那份“赤手空拳”的克制。如果你也想从零写一个自己的小工具我强烈建议你也给自己设一条类似的刹车线否则要不了多久它就会变成你最初想逃离的那种复杂系统。caveman 到现在已经跑了一年中间没出过任何一次构建事故。我最大的体会是工具简单到一定程度你会更愿意用它。写文章就是写文章不需要先升级依赖、再修主题、最后祈祷构建通过。如果你也被复杂的工具链折磨过不一定要照抄我这个方案但可以找个周末把重复做的自动化任务收敛成一个零依赖的小脚本。最后分享一个小技巧把构建和发布的命令固化成一个 shell 别名你会发现自己更新博客的频率明显变高了。保持原始保持可控。