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 + diff --git a/wiki/main.js b/wiki/main.js index b895dbec..be5d7e93 100644 --- a/wiki/main.js +++ b/wiki/main.js @@ -112,8 +112,41 @@ function initHeaderScroll() { window.addEventListener("scroll", sync, { passive: true }); } +function formatTrafficCount(n) { + n = Number(n) || 0; + if (n < 1000) return String(n); + return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}`.replace(/\.0$/, "") + "K"; +} + +// Render the footer traffic counter from /api/stats (agent vs human visitors to +// this site, counted server-side by the Pages middleware, plus PyPI installs). +// The block stays hidden until real data arrives, so it never shows dashes. +function initTraffic() { + const block = document.getElementById("footer-traffic"); + if (!block) return; + fetch("/api/stats") + .then((r) => (r.ok ? r.json() : null)) + .then((stats) => { + if (!stats) return; + const set = (id, value) => { + const el = document.getElementById(id); + if (el) el.textContent = value; + }; + if (stats.web) { + set("vt-agents", formatTrafficCount(stats.web.agent)); + set("vt-humans", formatTrafficCount(stats.web.human)); + } + if (stats.pypi) set("vt-installs", formatTrafficCount(stats.pypi.last_month)); + block.hidden = false; + }) + .catch(() => { + /* stats unavailable — leave the block hidden */ + }); +} + initTheme(); initStars(); initInstallTabs(); initHeaderScroll(); applyLocale(); +initTraffic(); diff --git a/wiki/wrangler.toml b/wiki/wrangler.toml new file mode 100644 index 00000000..85f83c0f --- /dev/null +++ b/wiki/wrangler.toml @@ -0,0 +1,15 @@ +# Cloudflare Pages config for vibetrading.wiki. +# +# The site is static; these settings only wire the analytics Pages Functions +# (functions/) to their D1 counter. The production binding is also set at the +# project level so it applies to CI deploys. Used directly when deploying or +# previewing from this directory: `cd wiki && wrangler pages dev` / `pages deploy`. + +name = "vibetrading-wiki" +compatibility_date = "2026-01-01" +pages_build_output_dir = "." + +[[d1_databases]] +binding = "DB" +database_name = "vibetrading-analytics" +database_id = "3c1eca42-91e1-45e9-adcf-400559ba1b99"