Featured image of post 建站运维 03:50 行 Python 给静态博客加访问量统计(自建计数器全记录)

建站运维 03:50 行 Python 给静态博客加访问量统计(自建计数器全记录)

不蒜子只能统计当前页面?用 50 行 Python + nginx 反代自建计数服务,让列表页和详情页都显示每篇文章的真实阅读量。

环境:腾讯云轻量服务器 / 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

三个设计决策:

  1. Python 而不是重型方案:50 行标准库代码,不装任何依赖,2C4G 的小服务器毫无压力
  2. 只监听 127.0.0.1:服务不直接暴露公网,由 nginx 反向代理出去,安全又复用已有 HTTPS
  3. 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 备份。

使用 Hugo 构建
主题 Stack 由 Jimmy 设计