mysite/content/posts/view_count.md

400 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 didnt. Heres 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 sitewide 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”**
Thats “domain name too long, disabled.” Wait, what?
I checked my hostname: **`blog.alipourimjourneys.ir`**. I counted: **27 characters**. Busuanzis 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 Busuanzistyle dropin without the 22char 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 Vercounts:
```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 perpost 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, theyll populate.
### Postmortem checklist (a.k.a. things I learned)
- **If the script loads but you get blanks**, its not always IDs or caching. Sometimes its 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.
- **Dont mix providers** on the same page. Load exactly one script (Busuanzi *or* Vercount).
- **CSP and blockers matter.** If you run a strict ContentSecurityPolicy 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 youll 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: Selfhosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)
This is the **selfhost** part I ended up with: Docker for GoatCounter, Cloudflare (orangecloud) 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”)
Selfhost `count.js` so the onion mirror never fetches a thirdparty 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 (PaperModlike). 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 wont 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 dont 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` (sameorigin).
Thats 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 >}}