400 lines
16 KiB
Markdown
400 lines
16 KiB
Markdown
---
|
||
title: "A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)"
|
||
date: 2025-10-18T10:45:00
|
||
slug: papermod-views-debugging-story
|
||
summary: "I tried to add a simple 'views' counter to my PaperMod blog. It worked—until it didn’t. Here’s the story of blank numbers, a mysterious Chinese message, and the final fix."
|
||
tags: ["Hugo", "PaperMod", "Analytics", "Debugging", "Busuanzi", "Vercount", "GoatCounter"]
|
||
categories: ["Stories"]
|
||
draft: false
|
||
---
|
||
|
||
I only wanted a tiny **“views”** badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.
|
||
|
||
The thing is, I don't think the counter even works correctly.
|
||
Or maybe I'm undermining my blog popularity!
|
||
But regardless, it was a small stiching task that I did on this lovely Friday evening.
|
||
The day before my next term of German class starts.
|
||
The day before playing deadface CTF that I've been counting days for.
|
||
|
||
|
||
### The neat footer row
|
||
|
||
First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:
|
||
|
||
```html
|
||
<div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
|
||
<a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
|
||
class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
|
||
{ partial "svg.html" (dict "name" "rss") }
|
||
<span>RSS</span>
|
||
</a>
|
||
|
||
<span aria-hidden="true">·</span>
|
||
|
||
<span id="busuanzi_container_site_pv">
|
||
Site views: <span id="busuanzi_value_site_pv">—</span>
|
||
</span>
|
||
<span aria-hidden="true">·</span>
|
||
<span id="busuanzi_container_site_uv">
|
||
Visitors: <span id="busuanzi_value_site_uv">—</span>
|
||
</span>
|
||
</div>
|
||
```
|
||
|
||
So far, so good. The labels showed up. The numbers, though? **Nothing.** Just an em dash. I shrugged—maybe it was a caching thing.
|
||
|
||
### The first round of checks
|
||
|
||
I added the script site‑wide in `layouts/partials/extend_head.html`:
|
||
|
||
```html
|
||
<script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>
|
||
```
|
||
|
||
Refreshed. Still blank.
|
||
|
||
**DevTools time.** Network tab: the script **does** load:
|
||
- **Request URL:** `https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js`
|
||
- **Request Method:** GET
|
||
- **Status Code:** 304 Not Modified
|
||
- **Referrer Policy:** strict-origin-when-cross-origin
|
||
- **User-Agent:** a normal Chromium build (nothing exotic)
|
||
|
||
No red flags. Just a perfectly normal 304.
|
||
|
||
Maybe my IDs were wrong? Busuanzi has a couple of flavors: `busuanzi_value_*` (with container wrappers) or shorthand like `busuanzi_site_pv`. I tried **both**, carefully, one at a time. Still… **—**.
|
||
|
||
### The odd message
|
||
|
||
Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:
|
||
|
||
> **“域名过长,已被禁用 views”**
|
||
|
||
That’s “domain name too long, disabled.” Wait, what?
|
||
|
||
I checked my hostname: **`blog.alipourimjourneys.ir`**. I counted: **27 characters**. Busuanzi’s limit? **22.** Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.
|
||
|
||
### Two ways out
|
||
|
||
At this point I had two clean options:
|
||
|
||
1. **Keep Busuanzi** and move the site to the **apex domain** (short enough).
|
||
2. Keep my beloved `blog.` subdomain and swap in **Vercount**, a Busuanzi‑style drop‑in without the 22‑char limit.
|
||
|
||
I liked the subdomain (habit, mostly), so I tried **Vercount**.
|
||
|
||
### The swap (five-minute fix)
|
||
|
||
I removed the Busuanzi script and added Vercount instead:
|
||
|
||
```html
|
||
<!-- extend_head.html -->
|
||
<script defer src="https://events.vercount.one/js"></script>
|
||
```
|
||
|
||
I kept the same tidy footer row, but switched the IDs to Vercount’s:
|
||
|
||
```html
|
||
<div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
|
||
<a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
|
||
class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
|
||
{ partial "svg.html" (dict "name" "rss") }
|
||
<span>RSS</span>
|
||
</a>
|
||
|
||
<span aria-hidden="true">·</span>
|
||
|
||
<span>Site views: <span id="vercount_value_site_pv">—</span></span>
|
||
<span aria-hidden="true">·</span>
|
||
<span>Visitors: <span id="vercount_value_site_uv">—</span></span>
|
||
</div>
|
||
```
|
||
|
||
For per‑post counts (in the post meta), I added:
|
||
|
||
```html
|
||
<span id="vercount_value_page_pv">—</span> views
|
||
```
|
||
|
||
Deploy. Refresh. **Numbers.**
|
||
|
||
### But if you want to stay with Busuanzi…
|
||
|
||
If your apex domain is short enough, pointing the site there works fine. The gist:
|
||
|
||
- Set `baseURL` in Hugo to the apex:
|
||
|
||
```toml
|
||
baseURL = "https://alipourimjourneys.ir/"
|
||
```
|
||
|
||
- Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).
|
||
- Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.
|
||
|
||
### Post‑mortem checklist (a.k.a. things I learned)
|
||
|
||
- **If the script loads but you get blanks**, it’s not always IDs or caching. Sometimes it’s a **service rule**.
|
||
- **Busuanzi refuses long hostnames** (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
|
||
- **Don’t mix providers** on the same page. Load exactly one script (Busuanzi *or* Vercount).
|
||
- **CSP and blockers matter.** If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.
|
||
|
||
### Happy ending
|
||
|
||
Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.
|
||
|
||
If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.
|
||
|
||
### Final notes
|
||
Yea... I just felt this feature was missing, and it's good to have it there.
|
||
I hope I stop adding things to the blog, and just keep writing in it.
|
||
I've spent too much time on it, and it feels like wasted time.
|
||
|
||
|
||
---
|
||
|
||
|
||
## Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)
|
||
|
||
This is the **self‑host** part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my `.onion` mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.
|
||
|
||
---
|
||
|
||
### 1) Clean up old counters (optional, once)
|
||
```bash
|
||
# From your Hugo site root
|
||
grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .
|
||
```
|
||
Remove any matching `<script>` and leftover `*_value_*` spans you no longer want.
|
||
|
||
---
|
||
|
||
### 2) Run GoatCounter with Docker Compose
|
||
Minimal `docker-compose.yml` in `~/goatcounter`:
|
||
```yaml
|
||
services:
|
||
goatcounter:
|
||
image: arp242/goatcounter:2.6
|
||
container_name: goatcounter
|
||
ports:
|
||
- "127.0.0.1:8081:8080" # bind only to localhost; nginx will proxy
|
||
volumes:
|
||
- goatcounter-data:/home/goatcounter/goatcounter-data
|
||
restart: unless-stopped
|
||
|
||
volumes:
|
||
goatcounter-data: {}
|
||
```
|
||
|
||
Start:
|
||
```bash
|
||
docker compose pull && docker compose up -d
|
||
```
|
||
|
||
Initialize the site (replace email if needed):
|
||
```bash
|
||
docker compose exec goatcounter goatcounter db create site -vhost=statsblog.alipourimjourneys.ir -user.email=iman.alip2001@gmail.com
|
||
```
|
||
|
||
> If you ever see “no site at this domain” on localhost: use the exact **vhost** above.
|
||
|
||
---
|
||
|
||
### 3) Cloudflare + Origin certificate (CF → origin TLS)
|
||
- DNS: `statsblog.alipourimjourneys.ir` → server IP (**proxied/orange**).
|
||
- SSL/TLS mode: **Full (strict)**.
|
||
- Issue a **Cloudflare Origin Certificate** for `statsblog.alipourimjourneys.ir` and save on the server:
|
||
|
||
```bash
|
||
sudo mkdir -p /etc/ssl/cloudflare
|
||
sudo nano /etc/ssl/cloudflare/origin.crt # paste PEM
|
||
sudo nano /etc/ssl/cloudflare/origin.key # paste key
|
||
sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
|
||
sudo chmod 644 /etc/ssl/cloudflare/origin.crt
|
||
sudo chmod 600 /etc/ssl/cloudflare/origin.key
|
||
```
|
||
|
||
---
|
||
|
||
### 4) Nginx vhost for the stats subdomain
|
||
```nginx
|
||
# /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
|
||
server {
|
||
listen 80;
|
||
server_name statsblog.alipourimjourneys.ir;
|
||
return 301 https://$host$request_uri;
|
||
}
|
||
|
||
server {
|
||
listen 443 ssl http2;
|
||
server_name statsblog.alipourimjourneys.ir;
|
||
|
||
ssl_certificate /etc/ssl/cloudflare/origin.crt;
|
||
ssl_certificate_key /etc/ssl/cloudflare/origin.key;
|
||
|
||
# Proxy to the container on localhost
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
|
||
location / { proxy_pass http://127.0.0.1:8081; }
|
||
}
|
||
```
|
||
Enable & reload:
|
||
```bash
|
||
sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
|
||
sudo nginx -t && sudo systemctl reload nginx
|
||
```
|
||
|
||
---
|
||
|
||
### 5) Hugo integration (clearnet + onion, singular/plural + “0 views”)
|
||
Self‑host `count.js` so the onion mirror never fetches a third‑party script:
|
||
- Save the official `count.js` as `static/js/count.js` in your Hugo repo.
|
||
|
||
Then add this to `layouts/partials/extend_head.html` **(loader + helper)**:
|
||
|
||
```html
|
||
<!-- Loader: choose endpoint based on hostname -->
|
||
<script>
|
||
(function () {
|
||
var ONION = /\.onion$/i.test(location.hostname);
|
||
window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
|
||
var s = document.createElement('script');
|
||
s.async = true; s.src = '/js/count.js';
|
||
document.head.appendChild(s);
|
||
})();
|
||
</script>
|
||
|
||
<!-- Helper: inline meta counts (post pages + lists) and totals -->
|
||
<script>
|
||
(function () {
|
||
const ONION = /\.onion$/i.test(location.hostname);
|
||
const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
|
||
const SING='view', PLUR='views';
|
||
const nf = new Intl.NumberFormat();
|
||
|
||
function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
|
||
function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
|
||
async function fetchCount(path){
|
||
try {
|
||
const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
|
||
if (r.status === 404) return 0;
|
||
if (!r.ok) throw 0;
|
||
const j = await r.json();
|
||
const n = Number(String(j.count).replace(/,/g,''));
|
||
return Number.isFinite(n) ? n : 0;
|
||
} catch { return null; }
|
||
}
|
||
const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);
|
||
|
||
ready(async function () {
|
||
// A) Single post: add to existing meta row + small bottom
|
||
const post = document.querySelector('.post-single');
|
||
if (post) {
|
||
const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
|
||
const path = gcPath();
|
||
const n = await fetchCount(path);
|
||
if (meta) {
|
||
let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
|
||
span.textContent = label(n ?? 0);
|
||
}
|
||
const after = post.querySelector('.post-content') || post;
|
||
let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
|
||
bottom.textContent = label(n ?? 0);
|
||
}
|
||
|
||
// B) Lists (/posts, home, sections): show “0 views” instead of hiding
|
||
const cards = document.querySelectorAll('article.post-entry');
|
||
await Promise.all(Array.from(cards).map(async (card) => {
|
||
const meta = card.querySelector('.entry-footer, .post-meta');
|
||
const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
|
||
if (!meta || !link) return;
|
||
try {
|
||
const u = new URL(link.getAttribute('href'), location.origin);
|
||
if (u.origin !== location.origin) return;
|
||
let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
|
||
const n = await fetchCount(u.pathname);
|
||
span.textContent = label(n ?? 0);
|
||
} catch {}
|
||
}));
|
||
|
||
// C) Site totals (optional placeholders)
|
||
const pvEl = document.getElementById('vercount_value_site_pv');
|
||
if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
|
||
});
|
||
})();
|
||
</script>
|
||
```
|
||
|
||
Style so meta items stay inline with middle dots (PaperMod‑like). Put this in `assets/css/extended/goatcounter.css`:
|
||
```css
|
||
.post-single .post-header .post-meta,
|
||
.post-single .post-header .entry-meta,
|
||
.post-entry .entry-footer,
|
||
.post-entry .post-meta {
|
||
display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
|
||
}
|
||
.post-single .post-header .post-meta > *,
|
||
.post-single .post-header .entry-meta > *,
|
||
.post-entry .entry-footer > *,
|
||
.post-entry .post-meta > * {
|
||
display:inline-flex; align-items:center; white-space:nowrap;
|
||
}
|
||
.post-single .post-header .post-meta > * + *::before,
|
||
.post-single .post-header .entry-meta > * + *::before,
|
||
.post-entry .entry-footer > * + *::before,
|
||
.post-entry .post-meta > * + *::before {
|
||
content:"·"; margin:0 .5rem; opacity:.6;
|
||
}
|
||
.post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }
|
||
```
|
||
|
||
---
|
||
|
||
### 6) Count Tor (.onion) visits **into the same site**
|
||
In the **.onion** server block, proxy GoatCounter endpoints to the same site by forcing the Host header:
|
||
|
||
```nginx
|
||
# inside: server { ... } for fjthpp2h3mj2r....onion
|
||
|
||
location = /count {
|
||
proxy_pass http://127.0.0.1:8081/count;
|
||
proxy_set_header Host statsblog.alipourimjourneys.ir;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto http; # onion is http
|
||
}
|
||
|
||
location /counter/ {
|
||
proxy_pass http://127.0.0.1:8081/counter/;
|
||
proxy_set_header Host statsblog.alipourimjourneys.ir;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto http;
|
||
add_header Cache-Control "no-store";
|
||
proxy_no_cache 1; proxy_cache_bypass 1;
|
||
}
|
||
```
|
||
|
||
Because the loader above sets `window.goatcounter.endpoint` to `/count` on `.onion`, both **hits** and **reads** stay on the onion origin and are tallied into the same GoatCounter site.
|
||
|
||
> **Note on DNT/Tor:** Tor Browser sends `DNT: 1`. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.
|
||
|
||
---
|
||
|
||
### 7) Troubleshooting quick notes
|
||
- **400 “no site at this domain”** → create the site with `-vhost=statsblog.alipourimjourneys.ir` or fix your proxy `Host` header.
|
||
- **Counts don’t change on onion** → verify Tor path via `torsocks`, watch logs:
|
||
```bash
|
||
docker compose logs -f goatcounter
|
||
# look for “ignored: bot” (curl UA), or DNT being respected
|
||
```
|
||
- **DevTools sanity** → on clearnet you should see `https://statsblog…/count` and `…/counter/*.json`; on onion you should see `/count` and `/counter/*.json` (same‑origin).
|
||
|
||
That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.
|
||
|
||
|
||
Honestly, self-hosting GoatCounter wasn't as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it's done and dusted, and I'm a happy puppy! :D
|
||
|
||
---
|
||
|
||
{{< sigdl >}}
|