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:
1 parent
1cd4ba19df
commit
25b6ee5d72
7 files changed
+224
-1
No files matched your search
@@ -26,6 +26,7 @@ wiki/dist/
|
||||
wiki/.astro/
|
||||
wiki/.vite/
|
||||
wiki/.wrangler/
|
||||
.wrangler/
|
||||
wiki/.cache/
|
||||
.vibe-dev/
|
||||
.vibe-record/
|
||||
|
||||
+7
-1
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -208,6 +208,11 @@ vibe-trading</code></pre>
|
||||
</section>
|
||||
</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">
|
||||
<span>Vibe-Trading Wiki</span>
|
||||
<a href="/docs/">Docs</a>
|
||||
@@ -216,6 +221,12 @@ vibe-trading</code></pre>
|
||||
<a href="/research-lab/">Research Lab</a>
|
||||
<a href="https://github.com/HKUDS/Vibe-Trading">GitHub</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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user