feat(wiki): add agent-vs-human traffic counter to vibetrading.wiki

First-party, anonymous analytics for the public wiki — no client-side telemetry in the pip package. Pages Function middleware classifies each page request (AI agent / bot / human) by User-Agent server-side and counts it into D1; /api/stats serves the footer's aggregate counts alongside public PyPI install and GitHub star numbers.
This commit is contained in:
Haozhe Wu committed 2026-06-05 14:03:17 +08:00
1 parent 1cd4ba19df
commit 25b6ee5d72
7 files changed
+224 -1

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ wiki/dist/
wiki/.astro/ wiki/.astro/
wiki/.vite/ wiki/.vite/
wiki/.wrangler/ wiki/.wrangler/
.wrangler/
wiki/.cache/ wiki/.cache/
.vibe-dev/ .vibe-dev/
.vibe-record/ .vibe-record/
+7 -1
View File
@@ -25,4 +25,10 @@ Direct docs URLs such as `/docs/latest/getting-started/vibe-trading-overview` ar
- Output directory: `.` - Output directory: `.`
- Custom domain: `vibetrading.wiki` - 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.
+73
View File
@@ -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();
}
+84
View File
@@ -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 },
);
}
+11
View File
@@ -208,6 +208,11 @@ vibe-trading</code></pre>
</section> </section>
</main> </main>
<style>
.footer-traffic { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem 1.25rem; margin-top: .75rem; font-size: .85rem; opacity: .8; }
.footer-traffic .ft-item strong { font-variant-numeric: tabular-nums; font-weight: 700; }
.footer-traffic .ft-note { font-size: .72rem; opacity: .6; }
</style>
<footer class="site-footer"> <footer class="site-footer">
<span>Vibe-Trading Wiki</span> <span>Vibe-Trading Wiki</span>
<a href="/docs/">Docs</a> <a href="/docs/">Docs</a>
@@ -216,6 +221,12 @@ vibe-trading</code></pre>
<a href="/research-lab/">Research Lab</a> <a href="/research-lab/">Research Lab</a>
<a href="https://github.com/HKUDS/Vibe-Trading">GitHub</a> <a href="https://github.com/HKUDS/Vibe-Trading">GitHub</a>
<a href="https://pypi.org/project/vibe-trading-ai/">PyPI</a> <a href="https://pypi.org/project/vibe-trading-ai/">PyPI</a>
<div class="footer-traffic" id="footer-traffic" hidden>
<span class="ft-item">🤖 <strong id="vt-agents">–</strong> AI agents</span>
<span class="ft-item">👤 <strong id="vt-humans">–</strong> humans</span>
<span class="ft-item">📦 <strong id="vt-installs">–</strong> installs/mo</span>
<span class="ft-note">visitors to this site · anonymous · first-party</span>
</div>
</footer> </footer>
</body> </body>
</html> </html>
+33
View File
@@ -112,8 +112,41 @@ function initHeaderScroll() {
window.addEventListener("scroll", sync, { passive: true }); 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(); initTheme();
initStars(); initStars();
initInstallTabs(); initInstallTabs();
initHeaderScroll(); initHeaderScroll();
applyLocale(); applyLocale();
initTraffic();
+15
View File
@@ -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"