diff --git a/.gitignore b/.gitignore
index 5e9f9e5e..a31f9b6b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,7 @@ wiki/dist/
wiki/.astro/
wiki/.vite/
wiki/.wrangler/
+.wrangler/
wiki/.cache/
.vibe-dev/
.vibe-record/
diff --git a/wiki/README.md b/wiki/README.md
index f3648117..7e87e44f 100644
--- a/wiki/README.md
+++ b/wiki/README.md
@@ -25,4 +25,10 @@ Direct docs URLs such as `/docs/latest/getting-started/vibe-trading-overview` ar
- Output directory: `.`
- Custom domain: `vibetrading.wiki`
-The site is intentionally static. No server, database, or build step is required.
+The site is static and needs no build step. The only dynamic piece is a small
+analytics layer in `functions/` (Cloudflare Pages Functions): `_middleware.js`
+classifies each page request as AI-agent / bot / human by User-Agent and counts
+it into a D1 database (`vibetrading-analytics`, binding `DB`), and
+`api/stats.js` serves the footer's aggregate counts alongside public PyPI and
+GitHub numbers. The counter is anonymous and first-party — no cookies, no
+per-visitor identifier, no IP retention.
diff --git a/wiki/functions/_middleware.js b/wiki/functions/_middleware.js
new file mode 100644
index 00000000..d042bbf1
--- /dev/null
+++ b/wiki/functions/_middleware.js
@@ -0,0 +1,73 @@
+// In-path traffic classifier for vibetrading.wiki (Cloudflare Pages Function).
+//
+// Runs on every request, server-side, and counts each HTML page load as
+// "agent" (AI crawler / agent tool), "bot" (generic SEO/monitor crawler), or
+// "human". Because it runs in the request path — not from page JavaScript — it
+// also counts AI crawlers that never execute JS (GPTBot, ClaudeBot, …).
+//
+// Trust posture: first-party, aggregate-only. No per-visitor identifier, no
+// cookie, no IP retention — three integers per day in D1. Bulletproof by
+// construction: every path is wrapped so a counting failure can never break
+// page serving (always returns next()).
+
+const AI_AGENT = new RegExp(
+ [
+ "GPTBot", "ChatGPT-User", "OAI-SearchBot",
+ "ClaudeBot", "Claude-User", "Claude-SearchBot", "anthropic-ai",
+ "PerplexityBot", "Perplexity-User", "Google-Extended", "GoogleOther",
+ "Bytespider", "CCBot", "cohere-ai", "Meta-ExternalAgent", "meta-externalagent",
+ "Amazonbot", "Applebot-Extended", "YouBot", "Diffbot", "DuckAssistBot",
+ "MistralAI", "Timpibot", "ImagesiftBot",
+ "python-requests", "aiohttp", "\\bhttpx\\b", "Go-http-client", "node-fetch",
+ "axios", "okhttp", "Scrapy", "\\bcurl\\b", "Wget",
+ "HeadlessChrome", "Headless", "PhantomJS", "Playwright", "Puppeteer", "Selenium",
+ ].join("|"),
+ "i",
+);
+
+const GENERIC_BOT = new RegExp(
+ [
+ "Googlebot", "bingbot", "YandexBot", "DuckDuckBot", "Baiduspider",
+ "AhrefsBot", "SemrushBot", "DotBot", "MJ12bot", "PetalBot",
+ "UptimeRobot", "Pingdom", "StatusCake", "facebookexternalhit",
+ "Twitterbot", "Slackbot", "Discordbot", "TelegramBot", "WhatsApp",
+ "\\bbot\\b", "\\bcrawler\\b", "\\bspider\\b", "\\bslurp\\b",
+ ].join("|"),
+ "i",
+);
+
+function classify(ua) {
+ if (!ua) return "bot"; // a real browser always sends a User-Agent
+ if (AI_AGENT.test(ua)) return "agent";
+ if (GENERIC_BOT.test(ua)) return "bot";
+ return "human";
+}
+
+export async function onRequest(context) {
+ const { request, env, next } = context;
+ try {
+ const url = new URL(request.url);
+ const accept = request.headers.get("accept") || "";
+ const isPageView =
+ request.method === "GET" &&
+ accept.includes("text/html") &&
+ !url.pathname.startsWith("/api/");
+
+ if (isPageView && env.DB) {
+ const klass = classify(request.headers.get("user-agent") || "");
+ const day = new Date().toISOString().slice(0, 10); // UTC YYYY-MM-DD
+ context.waitUntil(
+ env.DB.prepare(
+ "INSERT INTO visits (day, klass, n) VALUES (?1, ?2, 1) " +
+ "ON CONFLICT(day, klass) DO UPDATE SET n = n + 1",
+ )
+ .bind(day, klass)
+ .run()
+ .catch(() => {}),
+ );
+ }
+ } catch {
+ // Analytics must never break the page. Fall through to serving the asset.
+ }
+ return next();
+}
diff --git a/wiki/functions/api/stats.js b/wiki/functions/api/stats.js
new file mode 100644
index 00000000..a26fc4c2
--- /dev/null
+++ b/wiki/functions/api/stats.js
@@ -0,0 +1,84 @@
+// GET /api/stats — aggregate usage numbers for the wiki footer.
+//
+// Combines three trust-safe sources, all anonymous / public:
+// web : agent-vs-human page views to vibetrading.wiki (last 30 days),
+// counted server-side by _middleware.js into D1.
+// pypi : install counts for `vibe-trading-ai` (pypistats public API).
+// github : repository stars (GitHub public API).
+//
+// Never throws to the client: on any failure it returns zeros so the footer
+// degrades gracefully.
+
+const WINDOW_DAYS = 30;
+
+async function fetchPypi() {
+ try {
+ const resp = await fetch(
+ "https://pypistats.org/api/packages/vibe-trading-ai/recent",
+ { headers: { "User-Agent": "vibetrading-wiki" }, cf: { cacheTtl: 1800 } },
+ );
+ if (!resp.ok) return null;
+ const data = await resp.json();
+ return {
+ last_day: data?.data?.last_day ?? 0,
+ last_week: data?.data?.last_week ?? 0,
+ last_month: data?.data?.last_month ?? 0,
+ };
+ } catch {
+ return null;
+ }
+}
+
+async function fetchGithub() {
+ try {
+ const resp = await fetch("https://api.github.com/repos/HKUDS/Vibe-Trading", {
+ headers: { "User-Agent": "vibetrading-wiki", Accept: "application/vnd.github+json" },
+ cf: { cacheTtl: 1800 },
+ });
+ if (!resp.ok) return null;
+ const repo = await resp.json();
+ return { stars: repo?.stargazers_count ?? 0 };
+ } catch {
+ return null;
+ }
+}
+
+export async function onRequest(context) {
+ const { env } = context;
+ const headers = {
+ "Access-Control-Allow-Origin": "*",
+ "Cache-Control": "public, max-age=60",
+ };
+
+ const web = { human: 0, agent: 0, bot: 0, window_days: WINDOW_DAYS };
+ try {
+ if (env.DB) {
+ const since = new Date(Date.now() - WINDOW_DAYS * 86400000)
+ .toISOString()
+ .slice(0, 10);
+ const { results } = await env.DB.prepare(
+ "SELECT klass, SUM(n) AS total FROM visits WHERE day >= ?1 GROUP BY klass",
+ )
+ .bind(since)
+ .all();
+ for (const row of results || []) {
+ if (row.klass in web) web[row.klass] = Number(row.total) || 0;
+ }
+ }
+ } catch {
+ // leave zeros
+ }
+
+ const [pypi, github] = await Promise.all([fetchPypi(), fetchGithub()]);
+
+ return Response.json(
+ {
+ web,
+ pypi,
+ github,
+ note: "anonymous · first-party · aggregate sample",
+ generated_at: new Date().toISOString(),
+ },
+ { headers },
+ );
+}
diff --git a/wiki/home/index.html b/wiki/home/index.html
index e1f48084..644550e3 100644
--- a/wiki/home/index.html
+++ b/wiki/home/index.html
@@ -208,6 +208,11 @@ vibe-trading
+