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/.astro/
|
||||||
wiki/.vite/
|
wiki/.vite/
|
||||||
wiki/.wrangler/
|
wiki/.wrangler/
|
||||||
|
.wrangler/
|
||||||
wiki/.cache/
|
wiki/.cache/
|
||||||
.vibe-dev/
|
.vibe-dev/
|
||||||
.vibe-record/
|
.vibe-record/
|
||||||
|
|||||||
+7
-1
@@ -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.
|
||||||
@@ -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>
|
</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>
|
||||||
@@ -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();
|
||||||
@@ -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