Feat: Count ?i= and ?src= in GoatCounter image hits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 15:32:33 +00:00
co-authored by Claude Opus 5.5
parent cd604838bd
commit fb3635dd47
2 changed files with 9 additions and 5 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ Queries and IP addresses may be included in alerts sent to the maintainer throug
We use [GoatCounter](https://www.goatcounter.com), a privacy-focused analytics service that doesn't use cookies.
- The homepage loads GoatCounter's script, which records the visit, the referring page, and your screen width.
- Each image request is reported to GoatCounter from our servers, including the query, the referring page (for example, the site embedding the image), your user agent, and your IP address.
- Each image request is reported to GoatCounter from our servers, including the query and any `?i=` or `?src=`, the referring page (for example, the site embedding the image), your user agent, and your IP address.
- GoatCounter uses your IP address and user agent to work out your country, browser, and operating system, and to count unique visitors. It [doesn't store the IP address or user agent itself](https://www.goatcounter.com/help/sessions).
## Newsletter
+8 -4
View File
@@ -65,7 +65,9 @@ export async function onRequest(context) {
if (!i || (site && !src) || extra) return asset("bad.webp");
if (i > MAX_INDEX[site]) return asset("max-i.webp");
context.waitUntil(countHit(env, request, query, site));
// Only the ?i= and ?src= actually given are counted (normalized, e.g. ?i=02 → ?i=2), so bare queries stay one path
const counted = new URLSearchParams(Object.entries({ i, src }).filter(([k]) => url.searchParams.has(k))).toString();
context.waitUntil(countHit(env, request, query, site, counted));
// Main-site i=1 keeps the bare query so existing cache entries stay valid.
// Other keys use uppercase prefixes, which can't collide with (always lowercased) queries.
@@ -353,15 +355,17 @@ async function notify(env,{ title, message, tags, priority }) {
} catch {}
}
// site is "free", "ai" or "" (main site); each is counted under its own path prefix
async function countHit(env, request, query, site) {
// site is "free", "ai" or "" (main site); each is counted under its own path prefix, with params (e.g. "i=2&src=flux")
// after the query. ? and # in the query are escaped so GoatCounter doesn't split the path there.
async function countHit(env, request, query, site, params) {
if (!env.GOATCOUNTER_URL || !env.GOATCOUNTER_TOKEN) return;
const h = k => request.headers.get(k) || "";
const path = `${site ? `/${site}` : ""}/${query.replace(/ /g, "+").replace(/[?#]/g, encodeURIComponent)}${params && `?${params}`}`;
try {
await fetch(`${env.GOATCOUNTER_URL}/api/v0/count`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${env.GOATCOUNTER_TOKEN}` },
body: JSON.stringify({ no_sessions: true, hits: [{ path: `${site ? `/${site}` : ""}/${query.replace(/ /g, "+")}`, title: query, ref: h("referer"), user_agent: h("user-agent"), ip: h("cf-connecting-ip") }] }),
body: JSON.stringify({ no_sessions: true, hits: [{ path, title: query, ref: h("referer"), user_agent: h("user-agent"), ip: h("cf-connecting-ip") }] }),
signal: AbortSignal.timeout(5000)
});
} catch {}