前言

博客计数器是博客一项基本的统计功能,至少从表面上能大概看出有多少人多少次浏览过自己的博客。于是就想为自己的博客添加该项功能,期间踩了许多坑,最后决定直接用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 . "&nbsp;·&nbsp;" | 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>&copy; {{ 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>

至此,即完成了自定义的计数器添加。