1. 为什么我放弃了 WordPress,转头用 Flask + SQLite 手搓了一个日更站
去年年底我给自己定了个目标:每天写一篇行业观察,坚持一年。最开始我图省事,直接上了 WordPress,主题一装、插件一堆,看着挺美。结果第三周就出问题了——插件更新把页面搞崩了,数据库被某个统计插件拖慢到打开一篇文章要等四秒。我那时候才意识到,对于一个只想安安静静日更的个人站来说,WordPress 这套重型装备其实是负担。
后来我把目光转向了 Flask + SQLite 这套组合。原因很直接:Flask 足够轻,一个app.py就能跑起来;SQLite 是单文件数据库,备份就是复制一个.db文件,迁移服务器的时候直接拖走就行。整套东西没有后台管理面板的臃肿,也没有插件生态的不可控,每一行代码我都知道它在干什么。这篇文章就是我把这套站从零搭起来、并且真的做到日更的完整实操记录,包括踩过的坑、参数怎么调、数据怎么管,以及日更这件事在技术层面到底需要哪些支撑。
如果你也是那种"不想被平台绑架、想自己掌控内容"的人,或者你正在学 Flask 想找个真实项目练手,那这篇内容应该对你有用。我会把每一步的选择理由讲清楚,而不是只丢一堆代码让你抄。毕竟建站这件事,抄代码只能跑通一次,理解逻辑才能跑通一年。
2. 技术选型:Flask、SQLite 和 WorkBuddy 各自扮演什么角色
2.1 为什么是 Flask 而不是 Django 或 FastAPI
很多人一提到 Python 建站就想到 Django,但 Django 的 ORM、Admin、中间件这套东西对个人日更站来说太重了。我算过一笔账:Django 项目初始化后光是自带的表就有十张,而我整个站真正需要的表只有三张——文章表、标签表、访问记录表。用 Django 就像开卡车去菜市场买菜,能拉但没必要。
FastAPI 我也试过,它的异步性能确实好,但它的强项在 API 服务,模板渲染这块生态不如 Flask 成熟。我要的是一个能直接返回 HTML 页面的传统网站,Flask 的 Jinja2 模板加上render_template就是最顺手的方案。而且 Flask 的扩展机制很克制,需要什么装什么,不需要就不装,这种"按需加载"的哲学特别适合个人项目。
具体到版本,我用的是 Flask 3.0.x。这个版本对 Python 3.8 以上都支持,我本机是 Python 3.11,装的时候直接pip install flask就行。这里有个小细节:如果你用的是 Windows,建议在虚拟环境里装,别直接装到全局,不然以后多个项目依赖冲突会很头疼。
python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate pip install flask2.2 SQLite 在日更场景下的真实表现
SQLite 经常被误解成"玩具数据库",但实际上它在读多写少的场景下表现非常稳。我的站每天写一篇文章,写入操作一天就一次,但读取操作可能有几百上千次(包括我自己预览、爬虫抓取、读者访问)。这种读写比例下,SQLite 完全够用,甚至比 MySQL 还快,因为它没有网络开销,直接读本地文件。
不过有几个参数必须调,不然并发一上来就会遇到database is locked的错误。我在app.py里加了这段配置:
import sqlite3 def get_db(): conn = sqlite3.connect('blog.db', timeout=10) conn.execute('PRAGMA journal_mode=WAL') conn.execute('PRAGMA synchronous=NORMAL') conn.row_factory = sqlite3.Row return connjournal_mode=WAL是关键,它把写操作和读操作分离到不同的文件,读的时候不会被写阻塞。timeout=10是等锁的超时时间,默认 5 秒有时候不够。row_factory设成sqlite3.Row之后,查询结果可以像字典一样用列名访问,写模板的时候方便很多。
2.3 WorkBuddy 在这个流程里到底帮我做了什么
WorkBuddy 这类工具的核心价值不是替你写代码,而是帮你把重复性的操作流程固化下来。我日更的流程是这样的:早上想到一个选题,打开编辑器写 Markdown,然后需要把 Markdown 转成 HTML、插入数据库、生成标签、更新首页列表。这一套动作如果手动做,每天要花十五分钟。用 WorkBuddy 把这些步骤串成一个自定义指令之后,我只需要把 Markdown 文件丢进去,剩下的它自动跑完。
我配置的指令大概长这样:读取指定目录下的.md文件,解析 front matter 里的标题和标签,调用 Python 脚本写入 SQLite,然后触发一次静态页面重新生成。整个过程不需要我打开终端敲命令。这就是工具的意义——把"我知道怎么做但懒得每天做"的事情自动化掉。
3. 从空目录到能访问的站点:环境搭建的完整链路
3.1 Python 环境与依赖安装的坑
Python 安装本身没什么好说的,官网下载安装包一路下一步就行。但有两个地方容易出问题:一是 Windows 上安装时记得勾选 "Add Python to PATH",不然后面在命令行里敲python会提示找不到命令;二是如果你电脑里已经有好几个 Python 版本,建议用py -3.11这种带版本号的方式调用,避免装错地方。
依赖方面,我整个项目只用了三个包:Flask、Markdown(把 Markdown 转 HTML)、python-frontmatter(解析文章头部的元信息)。用requirements.txt管理:
Flask==3.0.0 Markdown==3.5.1 python-frontmatter==1.0.0安装命令就是pip install -r requirements.txt。这里提醒一句:如果你在国内网络环境下装包慢,可以换用国内镜像源,加上-i https://pypi.tuna.tsinghua.edu.cn/simple参数,速度会快很多。
3.2 项目目录结构怎么设计才不乱
我见过很多人建站建到一半,文件到处乱放,最后自己都找不到东西。我的目录结构是这样的:
myblog/ ├── app.py # 主程序 ├── blog.db # SQLite 数据库 ├── requirements.txt ├── content/ # Markdown 源文件 │ ├── 2024-01-01.md │ └── 2024-01-02.md ├── templates/ # Jinja2 模板 │ ├── base.html │ ├── index.html │ └── post.html ├── static/ # CSS、JS、图片 │ └── style.css └── scripts/ # 辅助脚本 └── import_posts.pycontent目录放原始 Markdown,这是内容的"真相来源"。数据库只是索引和缓存,万一数据库坏了,我随时可以从 Markdown 重新生成。这种设计的好处是内容永远不会丢,而且 Markdown 文件可以直接用 Git 管理,每次更新都有记录。
3.3 数据库表结构:三张表撑起整个站
我的数据库只有三张表,设计得很克制:
CREATE TABLE posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, summary TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL ); CREATE TABLE post_tags ( post_id INTEGER, tag_id INTEGER, PRIMARY KEY (post_id, tag_id), FOREIGN KEY (post_id) REFERENCES posts(id), FOREIGN KEY (tag_id) REFERENCES tags(id) );slug是文章的 URL 标识,我用日期加拼音生成,比如2024-01-01-workbuddy-build-site。用 slug 而不是 id 做 URL,一是对搜索引擎友好,二是以后换数据库的时候链接不会失效。post_tags是多对多关系表,一篇文章可以有多个标签,一个标签也可以对应多篇文章。
注意:SQLite 默认不强制外键约束,需要在连接时执行
PRAGMA foreign_keys=ON才会生效。这个坑我踩过,删文章的时候标签关联没清掉,导致标签页出现空链接。
4. 日更流程的自动化:从写 Markdown 到页面更新
4.1 Markdown 文件的格式约定
为了让脚本能自动解析,我给每篇 Markdown 定了统一的头部格式:
--- title: 用 WorkBuddy 从零建站并日更实操记录 date: 2024-01-01 tags: [Flask, SQLite, 建站] summary: 记录我用 Flask 和 SQLite 搭建个人站并坚持日更的完整过程 --- 正文内容从这里开始...这个头部就是 front matter,用---包起来。python-frontmatter这个库可以直接把它解析成字典,我拿title和tags就能用,不需要自己写正则去匹配。日期用YYYY-MM-DD格式,排序的时候直接字符串比较就行,不用转成时间对象。
4.2 导入脚本的核心逻辑
导入脚本做的事情很线性:遍历content目录,解析每个 Markdown 文件,检查数据库里是否已存在同 slug 的记录,存在就更新,不存在就插入。核心代码大概三十行:
import frontmatter import markdown import sqlite3 import os from datetime import datetime def import_posts(content_dir='content', db_path='blog.db'): conn = sqlite3.connect(db_path) conn.execute('PRAGMA foreign_keys=ON') for filename in sorted(os.listdir(content_dir)): if not filename.endswith('.md'): continue filepath = os.path.join(content_dir, filename) post = frontmatter.load(filepath) slug = filename.replace('.md', '') title = post.get('title', 'Untitled') tags = post.get('tags', []) summary = post.get('summary', '') html_content = markdown.markdown(post.content) # 插入或更新文章 conn.execute(''' INSERT INTO posts (slug, title, content, summary) VALUES (?, ?, ?, ?) ON CONFLICT(slug) DO UPDATE SET title=excluded.title, content=excluded.content, summary=excluded.summary, updated_at=CURRENT_TIMESTAMP ''', (slug, title, html_content, summary)) # 处理标签 post_id = conn.execute( 'SELECT id FROM posts WHERE slug=?', (slug,) ).fetchone()[0] for tag_name in tags: conn.execute( 'INSERT OR IGNORE INTO tags (name) VALUES (?)', (tag_name,) ) tag_id = conn.execute( 'SELECT id FROM tags WHERE name=?', (tag_name,) ).fetchone()[0] conn.execute( 'INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)', (post_id, tag_id) ) conn.commit() conn.close()ON CONFLICT(slug) DO UPDATE是 SQLite 3.24 以上支持的语法,相当于"有则更新无则插入",比先查再判断要简洁。标签用INSERT OR IGNORE避免重复插入报错。
4.3 WorkBuddy 自定义指令的配置思路
WorkBuddy 的指令配置本质上是把一串操作定义成一个可复用的动作。我配的指令包含三个步骤:第一步执行python scripts/import_posts.py,第二步检查执行结果有没有报错,第三步如果有新文章就输出提示。这样我每天写完 Markdown 保存后,只需要触发一次指令,数据库就更新好了。
这里有个经验:指令里最好加上错误处理。比如脚本执行失败的时候,WorkBuddy 应该把错误信息完整显示出来,而不是只报一个"执行失败"。我在脚本里加了 try-except,把异常信息打印到标准输出,这样在 WorkBuddy 的日志里就能看到具体是哪一行出的问题。
5. 页面渲染与前端交互:让 Flask 把数据吐到网页上
5.1 Jinja2 模板的继承机制
Flask 用的是 Jinja2 模板引擎,它的继承机制能省掉大量重复代码。我建了一个base.html作为所有页面的骨架:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>{% block title %}我的日更站{% endblock %}</title> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> </head> <body> <header> <h1><a href="/">我的日更站</a></h1> </header> <main> {% block content %}{% endblock %} </main> <footer> <p>坚持日更的第 {{ day_count }} 天</p> </footer> </body> </html>然后index.html和post.html只需要继承这个骨架,填充content块就行。url_for('static', filename='style.css')是 Flask 生成静态文件 URL 的标准方式,这样即使以后改了静态文件路径,模板也不用动。
5.2 首页列表与文章详情页的数据查询
首页需要展示文章列表,按时间倒序排列,每篇显示标题、摘要和日期。查询语句很简单:
@app.route('/') def index(): conn = get_db() posts = conn.execute(''' SELECT slug, title, summary, created_at FROM posts ORDER BY created_at DESC LIMIT 20 ''').fetchall() conn.close() return render_template('index.html', posts=posts)文章详情页需要根据 slug 查单篇,同时把标签也查出来:
@app.route('/post/<slug>') def post_detail(slug): conn = get_db() post = conn.execute( 'SELECT * FROM posts WHERE slug=?', (slug,) ).fetchone() if post is None: abort(404) tags = conn.execute(''' SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ? ''', (post['id'],)).fetchall() conn.close() return render_template('post.html', post=post, tags=tags)abort(404)是 Flask 内置的,当文章不存在时直接返回 404 页面,比手动构造响应要规范。
5.3 标签页和归档页的扩展
标签页的逻辑是:先列出所有标签,点击某个标签后展示该标签下的所有文章。归档页则是按月份分组展示。这两个页面我一开始没做,后来发现读者需要按主题找文章,才补上的。实现方式就是多写两个路由和对应的查询语句,没有太复杂的地方。
这里分享一个 SQLite 的小技巧:查标签下文章数量的时候,用GROUP BY加COUNT一次查出来,比循环里逐条查要快得多:
SELECT t.name, COUNT(pt.post_id) as count FROM tags t LEFT JOIN post_tags pt ON t.id = pt.tag_id GROUP BY t.id ORDER BY count DESC6. 部署上线:让站点真正能被别人访问
6.1 开发服务器不能直接用于生产
Flask 自带的app.run()是开发服务器,单线程、性能差、没有安全防护,绝对不能直接暴露到公网。我一开始图省事用开发服务器跑了两天,结果有次访问量稍微大一点就卡死了。后来换成 Gunicorn 才稳定下来。
Gunicorn 的启动命令:
gunicorn -w 4 -b 127.0.0.1:8000 app:app-w 4是开四个工作进程,一般设成 CPU 核心数的两倍。-b指定绑定地址,这里绑到本地 8000 端口,前面再用 Nginx 做反向代理。
6.2 Nginx 反向代理配置
Nginx 负责处理静态文件、转发动态请求、做 HTTPS 终止。配置大概是这样:
server { listen 80; server_name yourdomain.com; location /static/ { alias /path/to/myblog/static/; expires 30d; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }静态文件交给 Nginx 直接返回,不走 Flask,这样能省下不少进程资源。expires 30d让浏览器缓存静态文件三十天,减少重复请求。
6.3 数据库备份与迁移的实操
SQLite 的备份简单到令人发指——直接复制.db文件就行。但要注意,如果站点正在运行,直接复制可能会拿到不一致的状态。正确做法是用 SQLite 自带的备份命令:
sqlite3 blog.db ".backup backup/blog_$(date +%Y%m%d).db"这个命令会在事务层面保证备份的一致性。我设了个定时任务,每天凌晨三点自动备份一次,保留最近三十天的备份文件。迁移服务器的时候,把.db文件和content目录一起打包带走,新服务器上装好依赖、配好 Nginx,十分钟就能恢复。
7. 日更一年后,我总结出的几条硬经验
7.1 内容与展示分离是长期维护的关键
我最大的体会是:Markdown 文件是内容的唯一真相来源,数据库和网页都只是它的投影。这个原则让我在一年里换了两次服务器、改了三版模板,但内容从来没有丢过。每次迁移只需要把content目录和数据库文件搬过去,重新跑一遍导入脚本,所有文章就都回来了。
如果你把内容直接存在数据库里,那数据库一坏就全完了。而 Markdown 文件是纯文本,用任何编辑器都能打开,用 Git 能追踪每一次修改,这种安全感是数据库给不了的。
7.2 自动化要适度,别为了自动化而自动化
我一开始想把所有事情都自动化,包括自动抓取热点、自动生成摘要、自动配图。后来发现这些"智能"功能带来的维护成本远大于收益。自动摘要经常抓错重点,自动配图出来的东西跟内容不搭,最后我还得手动改一遍,反而更费时间。
现在我保留的自动化只有两件事:Markdown 导入数据库、静态文件缓存。其他的都手动做。手动写摘要虽然花两分钟,但质量可控;手动选配图虽然花五分钟,但至少不会出错。自动化的边界应该是"重复且确定"的操作,而不是"需要判断"的操作。
7.3 性能优化先看查询,再看缓存
站点跑起来之后,我做过一次性能排查。用 Chrome 的开发者工具看,首页加载要 800 毫秒,其中 600 毫秒花在数据库查询上。我一开始想加 Redis 缓存,后来发现真正的问题是首页查询没有加索引。posts表的created_at字段没有索引,每次排序都要全表扫描。加了一个索引之后,查询时间从 600 毫秒降到 20 毫秒。
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);这个经历告诉我:优化要先定位瓶颈,别一上来就上重型方案。SQLite 的EXPLAIN QUERY PLAN命令可以看查询执行计划,能清楚看到有没有走索引。
7.4 日更的技术支撑其实是"低摩擦"
坚持日更一年,我发现最大的敌人不是没灵感,而是"写完之后还要做一堆操作才能发布"这件事带来的心理摩擦。如果发布流程要花十五分钟,那我下班累了就很容易拖到明天。但如果发布流程只需要点一下,那顺手就做了。
所以技术上的所有优化,最终目标都是降低发布摩擦。Markdown 导入自动化、WorkBuddy 一键触发、Nginx 静态缓存,这些加起来把发布流程压缩到了两分钟以内。摩擦小了,坚持就容易了。这可能是建站这件事里最不技术、但最重要的一条经验。
7.5 关于 WorkBuddy 和类似工具的定位
最后说一句工具选择。WorkBuddy 这类工具适合的是"你已经知道怎么做,只是不想每天重复做"的场景。它不能替你决定写什么,也不能替你判断文章质量,但它能把那些机械性的操作打包成一个按钮。如果你还在探索阶段,流程本身还没定型,那先别急着上工具,手动跑通几遍再说。等流程稳定了,再把重复的部分交给工具,这时候收益才最大。
我见过有人一上来就配一堆自动化指令,结果流程改了三次,指令全废了。工具是给稳定流程加速的,不是给混乱流程擦屁股的。这个顺序搞反了,工具越多越乱。