从Hexo到自建Python静态站点生成器:个人博客重构实战
changbozhao2 这串字符丢进搜索引擎大概率什么也查不到。它是我给自己的新版个人站起的工程代号里面的 2 表示第二轮全量重构。从决定动手到基本跑通前后拖了两个月中间推倒过两次方案最后沉淀下来的这套流程我觉得还挺值得记录。这篇文章会从旧站的问题说起讲清楚为什么放弃原来的 Hexo转而用一套定制脚本构建静态站点以及目录、模板、构建、部署、Nginx、SEO 这些环节里我踩过的坑和最终采用的解决办法。如果你也维护着一个越改越乱的小博客或者想把手上的内容站重新梳理一遍这篇应该能给你一个可以参照的完整样本。1. 项目概述从能用变成好用1.1 旧站到底哪里不行我原来的站点跑在 Hexo 3.9 上主题用的是一款第三方精简主题用了大概四年。表面看一切都正常文章能发页面能开但只有动手改东西的人才懂那种“能跑但不想碰”的状态。首先是依赖问题Hexo 对 Node 版本比较敏感环境升级后装回 node_modules 的版本对不上经常报错其次是构建越来越慢文章到三百多篇时本地生成一次要二十多秒每次改完标题重新生成特别烦躁。最难受的是主题定制成本高想加一个归档页、换一个代码高亮风格都要去主题源码里找钩子有些还要改 ejs 模板改完还担心主题升级冲突。这些痛点单看都不致命但叠加在一起就形成了一个很尴尬的局面我宁愿手动维护一个 HTML 页面也不想再在那套环境里做改动。后来有一次服务器迁移我把整个目录打包拷过去发现静态资源路径混乱、许多图片散落在不同文件夹、部分旧文章引用的本地图片已经失效。这时候我才意识到问题不只是工具链内容资产的梳理也必须一起做。于是 changbozhao2 的规划就是从这里开始的既要换一套顺手的流水线也要借机会把内容结构彻底整理清楚。1.2 目标极简、可控、可迁移给新方案定目标的时候我写了三条硬性要求。第一生成物是纯静态文件不依赖服务器端解释器放到任何目录用 Nginx 都能跑。第二全套构建逻辑我自己能看懂不引入黑盒依赖最好安装包能一只手数完。第三内容与表现层分离文章全部是 Markdown换主题只动模板和样式不碰内容。这三点听起来像老生常谈但真正执行起来会逼你做很多取舍。比如当时有人推荐我用 Hugo说性能好、生态全。我承认 Hugo 确实优秀但我的需求没有那么复杂而且我已经用了好几年 Python与其去学一门新工具的新语法不如用顺手的东西做一个刚好够用的轮子。另一个备选方案是留在 Hexo 但换新版本不过我在旧站上已经吃过插件兼容性的亏不想再被第三方生态绑架。所以最后我拍板基于 Python Markdown Jinja2 写一个只有两百行核心逻辑的静态站点生成器代号 changbozhao2。2. 技术选型与架构设计2.1 生成器选择Python 脚本为什么比现成框架更合适很多人会觉得“放着成熟工具不用自己造轮子不是找不自在吗”。这个想法我以前也有但在这个项目里我的判断恰好相反。博客站点的需求其实非常固定把 Markdown 变成 HTML按目录聚合套模板传服务器。这套逻辑用脚本写出来很直白而框架反而在抽象的灵活性和插件生态里引入了额外的维护成本。我选择 Python 是因为它对文本处理天然友好标准库里有完善的路径遍历和文件操作再加上 python-markdown 和 Jinja2 两个库就能覆盖绝大部分需求。这里着重说一下依赖控制。最终我只需要三个 Python 包markdown、jinja2、pygments。相比 Hexo 那种几十个依赖的物理这种控制在迁移和部署时简直是一种享受。如果未来某天 Python 环境崩了我只需一条 pip install 就能装回来而且每个包的接口多年来变化很小。我用 pip freeze 锁住版本requirements.txt 只有三行构建时连虚拟环境都可以不用特别复杂。架构上我坚持两个核心原则。第一个是源目录、模板目录、输出目录严格分离互不掺和。第二个是构建过程只负责生成不负责清理源码目录以外的任何文件。这样就算某个环节写错了最多是输出目录里多几个文件不会动到内容源头。目录设计得足够简单后续换部署方式也容易整个站点的生命周期反而比用大型静态站点生成器更长。changbozhao2/ ├── build.py ├── requirements.txt ├── templates/ │ ├── base.html │ ├── index.html │ ├── post.html │ └── tag.html ├── assets/ │ ├── css/ │ └── images/ ├── posts/ │ ├── 2025/ │ │ └── hello-world.md │ └── 2024/ └── output/这个结构一眼就能看懂。posts 下面按年份分目录每个 Markdown 文件就是一篇文章。assets 放样式和图片templates 放 Jinja2 模板build.py 是唯一入口output 是每次构建生成的站点目录放进 Nginx 就能直接跑。2.2 Front Matter 字段怎么设计才够用Markdown 文件里的头部信息我一开始想用 YAML 格式但为了不多引入一个 pyyaml 依赖我最终把这套字段设计成了一种简单的key: value单行格式。日期、分类、标签、摘要、草稿状态全部以字符形式存在文件头。字段不在多能支撑归档、标签聚合、SEO 和文章页渲染就够了。--- title: changbozhao2 重构记从 Hexo 到自建静态站点 date: 2025-03-21 category: 技术 tags: 博客, Python, Nginx, SEO summary: 项目代号 changbozhao2完整记录个人内容站第二版重构过程。 draft: false ---需要注意两个细节。第一个是 tags 不要写成 YAML 数组直接逗号分隔解析会简单很多。第二个是文件名只做 slug不做日期前缀日期完全由 Front Matter 决定。这样将来想重排某篇文章的发布时间不需要改文件名。如果你沿用 Hexo 的2025-03-21-title.md命名习惯也能用但日期字段与文件名重复容易产生不一致我建议二选一。解析逻辑我放在 build.py 里不到三十行。它先判断文件是否以---开头然后从第二个---之前切片逐行拆 key 和 value。日期用datetime.date.fromisoformat转成日期对象方便排序和归档。草稿状态用字符串比较只认 true 不认 True 或 TRUE这样能避免大小写带来的一些隐性行为。2.3 URL 结构与页面拓扑URL 结构在改版前就要想清楚因为上线后再改会被搜索引擎惩罚很久。我在 changbozhao2 里统一使用带 .html 后缀的真实文件路径没有追求短链接或省略扩展名这样 Nginx 不需要做额外 rewrite每一页都对应磁盘上的实际文件结构非常透明。首页/文章页/posts/2025/hello-world.html标签页/tag/python.html归档页/archive.html订阅文件/feed.xml文章页按年份归档主要考虑是文件目录和 URL 结构能对得上后期排查 404 时直接看目录就知道哪一年缺了什么。标签页和归档页全部由构建脚本自动生成不需要手工维护。首页虽然只展示最新内容但它照样是一个模板渲染出来的列表页分页逻辑由脚本控制。3. 核心细节解析与实操要点3.1 Markdown 渲染与代码高亮踩过的坑都在这里Markdown 渲染这步是整个生成器的核心。我用的是 python-markdown配置了 extra、codehilite、toc、tables 这几个扩展。extra 是一组常用扩展合集tables 负责表格codehilite 负责代码高亮。toc 会为文章标题自动生成目录锚点但我不用它自动插入目录只使用id属性方便文章页的阅读导航。import markdown def render_markdown(text: str) - str: return markdown.markdown( text, extensions[extra, codehilite, toc, tables], extension_configs{ codehilite: { css_class: highlight, guess_lang: False, }, toc: {permalink: False}, }, output_formathtml5, )有个小坑codehilite 默认会猜代码语言如果代码块没标注语言它可能瞎猜导致错误高亮。我在配置里把guess_lang设为 False没标注语言的代码块就统一走普通文本样式高亮反而更干净。另外代码高亮样式需要一个 CSS 文件我用 Pygments 的 HtmlFormatter 生成并写到独立 CSS 文件里构建时自动完成不需要手动复制。from pygments.formatters import HtmlFormatter css_content HtmlFormatter().get_style_defs(.highlight)如果你是第一次用 Pygments记得在构建脚本里生成这个 CSS然后在 base.html 引用。常见问题是生成了 CSS 却没有放进输出目录页面 404导致高亮失效。我在构建结束后加了一个检查如果生成的 CSS 文件不存在就中止构建并给出提示。3.2 模板继承与响应式样式移动端阅读体验不能凑合模板层我用 Jinja2 的继承机制base.html 负责整体骨架文章页和列表页只需要重写 content block。这样做的好处是全站统一的头部、底部、导航、统计代码只需要维护一份。为了减少页面加载时的 JavaScript我把暗色模式做成纯 CSS 媒体查询没有切换按钮跟随系统设置。阅读排版上正文区域最大宽度控制在 720px这个宽度对中文阅读最舒服太宽会导致行过长眼睛容易对错行。!doctype html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title{% block title %}{% endblock %}/title meta namedescription content{% block description %}{% endblock %} link relstylesheet href/assets/css/style.css link relstylesheet href/assets/css/pygments.css /head body header a href/ classsite-namechangbozhao2/a /header main {% block content %}{% endblock %} /main footer p© changbozhao2/p /footer /body /html字体栈我推荐写成系统字体优先中文字体用 PingFang SC、Microsoft YaHei、Noto Sans CJK SC 兜底不要把某个字体文件打包进站点因为字体文件会让静态资源体积暴增。行高设成 1.8字号至少 16px代码块单独用等宽字体并允许横向滚动。移动端还要加一句-webkit-text-size-adjust: 100%不然部分浏览器横屏时会自动放大字号干扰阅读节奏。3.3 图片压缩与路径规范化图片是内容站最容易失控的地方。旧站里有些图片放在附件目录有些放在主题的 images 目录还有些直接引用了外链图床时间一长要么失效要么路径混乱。changbozhao2 里我强制规定所有图片统一放assets/images/年份/目录文件名用短横线连接不允许出现空格和中文。图片压缩我没有引入 Python 的 Pillow而是用外部工具批量处理。处理文章配图时我通常先缩放到宽度不超过 1200px再转成 WebP。虽然 WebP 对老浏览器的兼容性稍逊但现代浏览器覆盖已经足够广收益远大于损失。命令行操作大概是find assets/images/2025 -type f -name *.jpg -exec \ convert {} -resize 1200 -quality 82 {}.webp \;这里有个容易忽略的细节文章里引用图片时路径必须写成根路径形式/assets/images/2025/xxx.webp不要写相对路径../assets/images/...。因为文章页可能被订阅器阅读器抓取也可能是从/posts/2025/下的不同层级访问相对路径会因为所在目录不同而解析错误。统一用根路径至少保证站点本身打开是正常的。我还在构建脚本里加了一个图片引用检查解析每篇文章的正文找出所有/assets/images/开头的引用路径然后去磁盘上确认文件是否存在不存在就打印 warning。这个检查看起来很基础但帮我抓住了好几次粘贴文章时漏拷图片的问题。3.4 SEO 与订阅输出静态站点也能做得很细静态站点对 SEO 非常友好前提是把该输出的标签全部输出。每个文章页我都生成独立的 title 和 meta descriptiondescription 优先取 Front Matter 里的 summary 字段没有 summary 就自动截取正文的前 120 个字符去掉 HTML 标签。同时输出 canonical 链接指向本站 URL避免域名带参数或追踪标记造成重复内容。Open Graph 标签我也没有跳过主要用到og:title、og:description、og:type、og:url。文章页没有配图时干脆不写出og:image避免社交平台抓到一个并不存在的图片报错。这个细节很容易被忽略我见过很多博客在分享时抓不到任何描述因为 head 里就只有一行 title。RSS 订阅对于内容站依然有价值我用 Python 标准库手动生成 feed.xml把最新 20 篇文章的基本信息按 XML 格式输出。注意标题和 summary 里的特殊字符要做转义可以用html.escape。sitemap.xml 也是构建脚本自动生成的把所有 .html 页面按url节点列出排除了草稿和分页页码但标签页和归档页会放进去。这样搜索引擎能快速发现新页面。4. 实操过程从代码编写到上线部署4.1 构建脚本核心流程先跑通再优化changbozhao2 的 build.py 没有用设计模式也没有搞抽象类就是一条线从上往下执行。收集文章、渲染文章页、渲染首页、生成标签页、生成归档页、生成 RSS 和 sitemap最后复制静态资源。代码虽然朴素但每一步都清晰可查出了问题用 Python 标准库的 traceback 就能定位。#!/usr/bin/env python3 import datetime from pathlib import Path from collections import defaultdict BASE_DIR Path(__file__).parent POSTS_DIR BASE_DIR / posts OUTPUT_DIR BASE_DIR / output TEMPLATES_DIR BASE_DIR / templates posts [] for md_file in sorted(POSTS_DIR.rglob(*.md)): raw md_file.read_text(encodingutf-8) meta, body parse_front_matter(raw) if meta.get(draft) true: continue html render_markdown(body) posts.append({slug: md_file.stem, path: md_file, html: html, **meta}) posts.sort(keylambda x: x[date], reverseTrue)文章页渲染的核心是把 post 对象传递给 Jinja2 模板然后写入对应年份目录。这个循环我会单独写成一个函数名称就叫render_post_pages。分页列表的生成逻辑可以放在首页和标签页的渲染函数里。一开始不需要追求单文件多任务并行几百篇文章用 Python 循环生成也就几秒完全不需要引入多线程。首页和标签页的模板里有个 pubdate 变量用于显示文章发布日期。我发现模板里直接调用post.date.strftime(%Y-%m-%d)更直观不要把格式化结果提前算进对象里否则模板想换一种日期格式就得重新构建数据。这个设计比较朴素但给后续调整留出了空间。4.2 本地预览与增量构建一个都不能少静态站点本地预览很简单Python 自带 http.server 就能做。每次构建完后在 output 目录里运行python3 -m http.server 8080浏览器打开 localhost:8080 就可以看效果。注意要进入 output 目录再启动服务否则根路径会错。我还加了一个简易的 watch 模式。脚本启动后先构建一次然后每隔两秒检查 posts 和 templates 目录下文件的修改时间如果发现变化就自动重新构建。这个功能不需要第三方库用标准库的 time.sleep 加 os.stat 轮询就行。虽然现代化工具能做得更精准但对我这个个人站足够用了。python3 build.py --watch这个 watch 模式给我写作时省了很大精力。以前用 Hexo 改完文章要手动执行 generate现在只要文件一保存页面自动生成刷新浏览器即可看到效果。构建输出有彩色提示显示一共生成了多少篇文章、多少张标签页耗时多少毫秒。看到这些数字能很快判断改动是不是没被识别。4.3 部署方案rsync 直传还是 GitHub Actions部署这一步我试了两条路径。最开始我用 rsync 从本地直接同步到服务器命令很简单rsync -avz --delete output/ userserver:/var/www/changbozhao2/--delete 参数能保证服务器上的旧文件被清理干净但使用之前一定要 dry-run 检查一下避免误删你手滑放在站点目录里的其他文件。我在首次上线前执行了rsync -avzn预览发现输出目录里多了一个不该同步的临时文件手动清理后才正式同步。如果你不想每次手动构造这条命令可以把脚本存成 deploy.sh。第二条路是推到 Git 远程仓库后由 CI 平台自动构建并部署。GitHub Actions 的 workflow 可以设置成 push 到 main 分支后自动拉代码、安装依赖、执行 build.py、再通过 scp 或 rsync 把 output 目录传到服务器。我把这个流程也写了一份但日常更新还是用本地 rsync因为个人站的发布频率不算高而且本地直接同步能确认构建产物确实无误。如果以后想让更多设备都能发布文章再切到 CI 也不迟。4.4 Nginx 配置与静态资源缓存Nginx 配置是整个上线过程中比较简单但很关键的一环。我的站点需要支持 HTTPS用的是 certbot 申请的证书它会自动修改部分配置我只要手动补充静态资源缓存和 gzip 压缩。以下是我最终使用的核心配置片段server { listen 443 ssl http2; server_name example.com; root /var/www/changbozhao2; index index.html; gzip on; gzip_types text/html text/plain text/css application/xml application/rssxml application/javascript; gzip_min_length 1024; location ~* \.(css|js|webp|jpg|jpeg|png|svg)$ { expires 30d; add_header Cache-Control public, max-age2592000, immutable; } location ~* \.html$ { add_header Cache-Control no-cache; } location /feed.xml { add_header Content-Type application/rssxml; charsetutf-8; } }HTML 页面我故意设置成 no-cache这样文章更新后访问时浏览器总会去服务器确认一次不会因为缓存导致长时间看不到改动。静态资源则设置 30 天强缓存文件后面带不带 hash 都需要考虑。我一开始给 CSS 设了 30 天 immutable后来发现重新构建后 pygments.css 内容变了但文件名没变浏览器还在用旧缓存。后来我在构建脚本里给 pygments.css 文件名加了内容哈希再在模板中引用带哈希的新文件名才算彻底解决。5. 常见问题与排查技巧实录5.1 代码高亮样式不生效到底卡在哪个环节上线后收到不少反馈说代码块没有颜色排查的时候我又踩了一圈坑。第一个排查点是生成的 HTML 文件里代码块的外层 class 到底是什么。codehilite 扩展默认生成code classlanguage-python配合div classhighlight的结构而 Pygments 生成的样式选择器是.highlight .k这类。如果 class 对不上样式自然匹配不到。第二个排查点是浏览器是否真的加载了 pygments.css。刷新页面后打开开发者工具看网络面板有没有这个请求。如果没有多半是模板里没写链接或者链接路径写错。第三个排查点比较隐蔽Nginx 给 CSS 设置了 long cache浏览器用了旧文件。改造策略前面提到过就是给 CSS 文件名加内容哈希。这个经验特别能代表静态站点治理的普遍规律构建产物要尽可能不可变文件名一变缓存问题就少了大半。5.2 中文排版与移动端阅读越简单越稳定中文排版看起来是小事实际上决定了站点的阅读质感。我用了一个很朴素的基线正文 16px行高 1.8段落间距 1.25em标题和正文的字重提升不用太大区分层级主要靠字号和颜色。字体栈不要贪多系统字体优先加载速度最快兼容性最好。很多博客用一套远程字体服务首屏加载反而慢中文页面尤其容易白屏因为字体文件太大。移动端阅读还有一个坑是代码块宽度。代码如果不设置横向滚动会把整个页面撑破。我给代码块容器加上了overflow-x: auto同时保留-webkit-overflow-scrolling: touch让 iOS 滑动更顺畅。代码块内部不要在行号前加太多内边距否则窄屏下每行能看到的代码变少滚动体验会变差。移动端测试一定要用真机Chrome 的设备模拟器有时候不能完全模拟字体渲染。5.3 历史 URL 不兼容与评论功能取舍改版让旧链接大量失效是迁移时最疼的问题。旧站文章链接是/2018/09/hello.html新版变成了/posts/2018/hello.html。我一开始想写 Nginx rewrite 规则统一跳转但旧站年份和月段并不总能对应到新站结构硬写正则很容易把不存在的页面也跳过去。最后我选择在 build.py 里生成一个旧 URL 到新 URL 的映射表然后转成 Nginx 的 302 规则。文章数量不那么庞大时这个办法准确又可控。评论功能我最终没有保留。旧站用的第三方评论系统在国内访问不稳定而且数据不全。与其维护一个随时可能失效的第三方评论不如直接放弃让读者通过邮件或者社交平台跟我联系。如果你希望保留评论区可以考虑自建轻量后端但会引入服务器端应用和数据库这与静态站点极简、免维护的初衷相悖。静态站点的优势就在于部署简单、安全可靠少一个交互功能就少一个需要长期维护的组件。改版过程中我还把全部旧文章重新过了一遍顺手修掉大量失效链接和错误日期这比任何技术选型都值。内容才是站点的真正资产工具链只是用来放资产的架子。如果你想做类似的重构我的建议是先别急着选技术把文章目录、URL 结构和迁移规则想清楚再动手写代码。这套 changbozhao2 的流程可能不是最优解但它解决了我手里真正的痛点而且每一步都踩得扎实。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →