前言
博客计数器是博客一项基本的统计功能,至少从表面上能大概看出有多少人多少次浏览过自己的博客。于是就想为自己的博客添加该项功能,期间踩了许多坑,最后决定直接用cloudflare自己的数据库来保存页面次数即可,然后借助gemini来帮写相关的js文件,多次调试后通过,以下记录一下步骤。
创建数据库
在cloudflare的面板中,选择以下D1数据库,创建一个免费的SQLite数据库,我创建的是my-blog-db,

点击数据库 my-blog-db,切换到 控制台 (Console) 标签页。 执行以下 SQL 语句,创建用于存放阅读量的表格 pv_table:
CREATE TABLE pv_table ( url TEXT PRIMARY KEY, pv INTEGER DEFAULT 0 )
Cloudflare 后台关键绑定提醒:
进入 Cloudflare Pages 项目设置 -> 函数 (Functions) -> D1 数据库绑定。
添加绑定,变量名称必须严格填写全大写的 DB,数据库选择你的 my-blog-db。

编写 Pages Functions 后端接口
在 Hugo 博客根目录下,创建一个名为 functions 的文件夹,并在其中创建 pv.js 文件,写入以下后端逻辑:
// functions/pv.js
export async function onRequest(context) {
const { request, env } = context;
// 统一配置跨域头(即使同源也加上,确保万无一失)
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Content-Type": "application/json",
};
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const url = new URL(request.url);
let pageUrl = url.searchParams.get("url");
const action = url.searchParams.get("action");
if (!pageUrl) {
return new Response(JSON.stringify({ error: "Missing url" }), { status: 400, headers: corsHeaders });
}
// 解码前端传过来的路径(处理中文路径和全局标识符)
pageUrl = decodeURIComponent(pageUrl);
// ==========================================================
// 核心逻辑:无论是普通文章,还是全局总暗号 "__TOTAL_SITE_PV__",一视同仁
// ==========================================================
if (action !== "get") {
// 只有不是 "get"(即新访客详情页)时,才触发“无则插入1,有则累加1”
await env.DB.prepare(`
INSERT INTO pv_table (url, pv)
VALUES (?, 1)
ON CONFLICT(url) DO UPDATE SET pv = pv + 1
`).bind(pageUrl).run();
} else {
// 如果是列表页、冷却期、或者是主页拉取总访问量(带有 action=get)
// 确保这条记录在数据库存在(无则初始化为0,有则什么都不动),做到绝对“只读”
await env.DB.prepare(`
INSERT OR IGNORE INTO pv_table (url, pv) VALUES (?, 0)
`).bind(pageUrl).run();
}
// 统一读取最新数据并返回
const row = await env.DB.prepare("SELECT pv FROM pv_table WHERE url = ?").bind(pageUrl).first();
const currentPv = row ? row.pv : 0;
return new Response(JSON.stringify({ pv: currentPv }), { status: 200, headers: corsHeaders });
} catch (err) {
// 线上如果后端报错,直接把错误吐给前端,方便在页面上直观排查
return new Response(JSON.stringify({ pv: "后端报错了: " + err.message }), { status: 200, headers: corsHeaders });
}
}
修改前端 post_meta.html 坑位
为了让列表页和详情页都能认准同一条“文章路径”,我们让 Hugo 在编译时直接把文章相对路径硬编码进 HTML 中。
修改 layouts/_partials/post_meta.html,给阅读量标签加上 data-url 属性:
{{- $scratch := newScratch }}
{{- if not .Date.IsZero -}}
{{- $scratch.Add "meta" (slice (printf "<span title='%s'>%s</span>" (.Date) (.Date | time.Format (default ":date_long" site.Params.DateFormat)))) }}
{{- end }}
{{- if (.Param "ShowReadingTime") -}}
{{- $scratch.Add "meta" (slice (printf "<span>%s</span>" (i18n "read_time" .ReadingTime | default (printf "%d min" .ReadingTime)))) }}
{{- end }}
{{- if (.Param "ShowWordCount") -}}
{{- $scratch.Add "meta" (slice (printf "<span>%s</span>" (i18n "words" .WordCount | default (printf "%d words" .WordCount)))) }}
{{- end }}
{{- if not (.Param "hideAuthor") -}}
{{- with (partial "author.html" .) }}
{{- $scratch.Add "meta" (slice (printf "<span>%s</span>" .)) }}
{{- end }}
{{- end }}
{{/* Combine all meta information into a single string with separators and render it as HTML.*/}}
<div class="post-meta-wrapper" style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<div>
{{- with ($scratch.Get "meta") }}
{{- delimit . " · " | safeHTML -}}
{{- end -}}
</div>
{{/* 完美右对齐:阅读量 (附加 data-url 属性) */}}
{{- if .IsPage -}}
<div class="post-pv" style="color: var(--secondary); font-size: 14px;">
阅读量: <span id="pv-value" data-url="{{ .RelPermalink }}">...</span> 次
</div>
{{- end }}
</div>
升级前端 footer.html 批量群发脚本
在 layouts/_partials/footer.html 的最底部,注入以下强健的异步群发脚本,全脚本如下:
{{- if not (.Param "hideFooter") }}
<footer class="footer">
{{- if not site.Params.footer.hideCopyright }}
{{- if site.Copyright }}
<span>{{ site.Copyright | markdownify }}</span>
{{- else }}
<span>© {{ now.Year }} <a href="{{ "" | absLangURL }}">{{ site.Title }}</a></span>
{{- end }}
{{- print " · "}}
{{- end }}
{{- with site.Params.footer.text }}
{{ . | markdownify }}
{{- print " · "}}
{{- end }}
<span>
Powered by
<a href="https://gohugo.io/?utm_source=papermod" rel="noopener" target="_blank">Hugo</a> &
<a href="https://github.com/adityatelange/hugo-PaperMod/" rel="noopener" target="_blank">PaperMod</a>
</span>
<div class="site-total-pv" style="font-size: 13px; color: var(--secondary); text-align: center; margin-top: 5px;">
✨ 本站已被访问 <span id="global-site-pv">...</span> 次
</div>
</footer>
{{- end }}
{{- if (not site.Params.disableScrollToTop) }}
<a href="#top" id="top-link" class="top-link hidden" aria-label="go to top" title="Go to Top (Alt + G)" accesskey="g">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevrons-up">
<polyline points="17 11 12 6 7 11"></polyline>
<polyline points="17 18 12 13 7 18"></polyline>
</svg>
</a>
{{- end }}
{{- partial "extend_footer.html" . }}
<script>
let menu = document.getElementById('menu');
if (menu) {
const scrollPosition = localStorage.getItem("menu-scroll-position");
if (scrollPosition) {
menu.scrollLeft = parseInt(scrollPosition, 10);
}
menu.onscroll = function () {
localStorage.setItem("menu-scroll-position", menu.scrollLeft);
}
}
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
var id = this.getAttribute("href").substr(1);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView({
behavior: "smooth"
});
} else {
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView();
}
if (id === "top") {
history.replaceState(null, null, " ");
} else {
history.pushState(null, null, `#${id}`);
}
});
});
</script>
{{- if (not site.Params.disableScrollToTop) }}
<script>
var toplink = document.getElementById("top-link");
window.onscroll = function () {
const scrollThreshold = window.innerHeight;
if (document.body.scrollTop > scrollThreshold || document.documentElement.scrollTop > scrollThreshold) {
toplink.classList.remove("hidden");
} else { toplink.classList.add("hidden");
}
};
</script>
{{- end }}
{{- if (not site.Params.disableThemeToggle) }}
<script>
document.getElementById("theme-toggle").addEventListener("click", () => {
const html = document.querySelector("html");
if (html.dataset.theme === "dark") {
html.dataset.theme = 'light';
localStorage.setItem("pref-theme", 'light');
} else {
html.dataset.theme = 'dark';
localStorage.setItem("pref-theme", 'dark');
}
})
</script>
{{- end }}
{{- if (and (eq .Kind "page") (ne .Layout "archives") (ne .Layout "search") (.Param "ShowCodeCopyButtons")) }}
<script>
document.querySelectorAll('pre > code').forEach((codeblock) => {
const container = codeblock.parentNode.parentNode;
const copybutton = document.createElement('button');
copybutton.classList.add('copy-code');
copybutton.innerHTML = '{{- i18n "code_copy" | default "copy" }}';
function copyingDone() {
copybutton.innerHTML = '{{- i18n "code_copied" | default "copied!" }}';
setTimeout(() => {
copybutton.innerHTML = '{{- i18n "code_copy" | default "copy" }}';
}, 2000);
}
copybutton.addEventListener('click', (cb) => {
if ('clipboard' in navigator) {
navigator.clipboard.writeText(codeblock.textContent);
copybutton.innerHTML = '{{- i18n "code_copied" | default "copied!" }}';
setTimeout(() => {
copybutton.innerHTML = '{{- i18n "code_copy" | default "copy" }}';
}, 2000);
return;
}
const range = document.createRange();
range.selectNodeContents(codeblock);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
try {
document.execCommand('copy');
copyingDone();
} catch (e) { };
selection.removeRange(range);
});
if (container.classList.contains("highlight")) {
container.appendChild(copybutton);
} else if (container.parentNode.firstChild == container) {
} else if (codeblock.parentNode.parentNode.parentNode.parentNode.parentNode.nodeName == "TABLE") {
codeblock.parentNode.parentNode.parentNode.parentNode.parentNode.appendChild(copybutton);
} else {
codeblock.parentNode.appendChild(copybutton);
}
});
</script>
{{- end }}
{{/* ─── 统一统计脚本:外层不再设 Hugo 过滤,交给内部 JS 精确分流 ─── */}}
<script>
document.addEventListener("DOMContentLoaded", function() {
const isLocal = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1";
const now = Date.now();
const cooldown = 30 * 60 * 1000; // 30分钟防刷冷却
// 1. 【普通文章页/列表页的矩阵计数器】(修复版:列表页绝不偷跑加1)
const pvElements = document.querySelectorAll('[data-url]');
if (pvElements.length > 0) {
const isSectionPage = {{ if or (eq .Kind "section") (eq .Kind "home") }}true{{ else }}false{{ end }}; // 👈 升级判定:把主页(home)和列表页(section)连线结盟
pvElements.forEach(function(el) {
const pageUrl = el.getAttribute("data-url");
if (!pageUrl) return;
if (isLocal) {
el.innerText = "999(本地)";
return;
}
let workerUrl = "/pv?url=" + encodeURIComponent(pageUrl);
// 核心分流:
if (isSectionPage) {
// 💪 规则一:只要当前在【主页】或【列表页】,对所有文章一律强行加上只读免战牌
workerUrl += "&action=get";
} else {
// 💪 规则二:只有点进【文章详情页】,才启用防刷手环判定
const storageKey = "visited_" + pageUrl;
const lastVisited = localStorage.getItem(storageKey);
if (lastVisited && (now - lastVisited < cooldown)) {
workerUrl += "&action=get"; // 冷却期内,只读
} else {
localStorage.setItem(storageKey, now); // 新访客,盖章并允许递增
}
}
// 发送精准请求
fetch(workerUrl)
.then(res => res.json())
.then(data => { el.innerText = data.pv; })
.catch(err => { el.innerText = "失败"; });
});
}
// 2. 【全站总访问量计数器】(动态判断,彻底解冻)
const globalPvEl = document.getElementById("global-site-pv");
if (globalPvEl) {
if (isLocal) {
globalPvEl.innerText = "8888";
} else {
// 给全站总访问量绑定一个独立的防刷手环钥匙
const globalStorageKey = "visited_global_total_pv";
const lastGlobalVisited = localStorage.getItem(globalStorageKey);
let globalWorkerUrl = "/pv?url=__TOTAL_SITE_PV__";
if (lastGlobalVisited && (now - lastGlobalVisited < cooldown)) {
// 如果当前电脑在30分钟内刚来过,走只读通道
globalWorkerUrl += "&action=get";
} else {
// 如果是换了电脑、新设备,或者是过了30分钟,盖章并允许递增计数
localStorage.setItem(globalStorageKey, now);
}
fetch(globalWorkerUrl)
.then(res => res.json())
.then(data => { globalPvEl.innerText = data.pv; })
.catch(err => { globalPvEl.innerText = "暂无"; });
}
}
});
</script>
至此,即完成了自定义的计数器添加。