环境:腾讯云轻量服务器 / Rocky Linux 9.4 / Hugo 静态博客
日期:2026-09-11
结论:✅ 首页列表、归档列表、文章详情三处均显示每篇文章的真实阅读量,数据完全自有
一、为什么不蒜子不够用
博客最初接入的是不蒜子(busuanzi),一行 JS 就能在页脚显示「总访问量 X 次」。它的原理:
第三方托管的计数服务。浏览器加载它的脚本,脚本把当前页面 URL 上报给不蒜子服务器,由它累加并返回计数。
这个原理决定了它的天花板:只能统计「当前打开的页面」。我想在首页列表、归档列表里显示每篇文章各自的阅读量——列表页一次展示 N 篇文章的 URL,不蒜子根本无能为力。
要按 URL 精确统计,只能自建。静态博客没有后端?服务器在我自己手里,给它造一个后端就行了。
二、整体架构
1
| 浏览器 JS ──► nginx (/api/counter 反代) ──► Python 计数服务 (127.0.0.1:8901) ──► counter.json
|
三个设计决策:
- Python 而不是重型方案:50 行标准库代码,不装任何依赖,2C4G 的小服务器毫无压力
- 只监听 127.0.0.1:服务不直接暴露公网,由 nginx 反向代理出去,安全又复用已有 HTTPS
- JSON 文件而不是数据库:访问量就是「URL → 数字」的映射,原子写入(写临时文件再 rename)防断电损坏,完全够用
三、计数服务(counter.py 完整实现)
只有两个接口:
GET /?hit=1&url=/posts/x/ → 计数 +1,返回最新值GET /?urls=/a/,/b/ → 批量只读查询(列表页用,不增加计数)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| #!/usr/bin/env python3
import json, os, threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
DB = "/var/www/counter.json"
lock = threading.Lock()
def load():
try:
return json.load(open(DB))
except Exception:
return {}
def save(d):
tmp = DB + ".tmp"
with open(tmp, "w") as f:
json.dump(d, f)
os.replace(tmp, DB) # 原子替换,防写一半断电
class Handler(BaseHTTPRequestHandler):
def _send(self, obj, code=200):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-cache")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
q = parse_qs(urlparse(self.path).query)
with lock:
data = load()
if "urls" in q:
urls = q["urls"][0].split(",")[:50]
self._send({"counts": {u: data.get(u, 0) for u in urls}})
elif "url" in q:
url = q["url"][0][:200]
if "hit" in q:
data[url] = data.get(url, 0) + 1
save(data)
self._send({"count": data.get(url, 0)})
else:
self._send({"error": "bad request"}, 400)
def log_message(self, *args):
pass
HTTPServer(("127.0.0.1", 8901), Handler).serve_forever()
|
systemd 托管(/etc/systemd/system/blog-counter.service):开机自启 + 崩溃 3 秒自动拉起。
1
2
3
4
5
6
7
8
9
10
11
| [Unit]
Description=Blog page view counter
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/blog-counter/counter.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
|
1
| systemctl enable --now blog-counter
|
四、nginx 反向代理
在 443 server 块里加一条(注意是精确匹配 =,避免影响静态文件路由):
1
2
3
4
5
| location = /api/counter {
proxy_pass http://127.0.0.1:8901/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
|
这样前端调 /api/counter?...,走的就是和页面同源的 HTTPS,没有跨域问题。
五、前端接入(三种页面三种策略)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // 详情页:hit 一次(sessionStorage 防刷新刷量)
if (是文章详情页) {
if (!sessionStorage.getItem("pv_hit_" + path)) {
fetch(API + "?hit=1&url=" + path); // 计数 +1
sessionStorage.setItem("pv_hit_" + path, "1");
} else {
fetch(API + "?url=" + path); // 只读取
}
// 把 {"count": N} 渲染到文章元信息行
}
// 列表页(首页/归档/分类/标签):收集所有文章 URL,一次批量查询
fetch(API + "?urls=" + urls.join(","));
// 把每篇文章的计数渲染到对应卡片上
|
防刷考虑:sessionStorage 保证同一会话重复打开/刷新只计一次。它不防「开新标签页恶意刷」,但对个人博客足够了——真要防,服务端按 IP 去重即可,留个升级口子。
六、验证(端到端实测)
1
2
3
4
5
6
7
8
9
| # 详情页计数
curl -s "https://saflyer.cn/api/counter?hit=1&url=/posts/blog-stack-deploy/"
{"count": 1}
curl -s "https://saflyer.cn/api/counter?hit=1&url=/posts/blog-stack-deploy/"
{"count": 2}
# 列表页批量查询
curl -s "https://saflyer.cn/api/counter?urls=/posts/a/,/posts/b/,/posts/c/"
{"counts": {"/posts/a/": 2, "/posts/b/": 1, "/posts/c/": 0}}
|
七、成本与收益
| 项 | 值 |
|---|
| 代码量 | 50 行 Python + 30 行 JS |
| 依赖 | 零(Python 标准库 + nginx 已有) |
| 内存占用 | ~10MB |
| 数据归属 | 自己的服务器,随时可备份/导出/二次开发 |
| 扩展性 | 以后做「热门文章排行」小组件,读同一个 JSON 就行 |
八、总结
静态博客不是「不能有任何后端」,而是「不为建站付后端的钱」。像访问量这种小需求,一个 50 行的边车服务(sidecar)就能解决,比迁就第三方服务的限制舒服得多。
后续计划:热门文章排行、按 IP 去重防刷、计数数据纳入每日 git 备份。