diff --git a/README.md b/README.md index c2c907a..08666ed 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ - **边缘原生** — Hono + Cloudflare Workers,无冷启动,全球 < 50ms - **存储适配** — 数据库 D1 / Turso / PostgreSQL,对象存储 R2 / S3 兼容 - **零运维成本** — 单脚本一键部署,前后端走同一条流水线 +- **访客分析** — 内置 D1 `visits` 表轻量统计;**Cloudflare 部署专属**额外解锁 Analytics Engine 增强仪表板(UV/停留时长/浏览器/操作系统/分辨率/语言),其他后端 (Turso / PostgreSQL) 仅基础统计可用 ### 🛡️ 安全合规 - **认证与防护** — JWT + 限流,CSP/HSTS 全套头,SSRF 拦截 diff --git a/client/public/tracker.js b/client/public/tracker.js new file mode 100644 index 0000000..7c5d44d --- /dev/null +++ b/client/public/tracker.js @@ -0,0 +1,103 @@ +/*! + * Monolith Analytics Tracker (CF Analytics Engine) + * Adapted from HanAnalytics (MIT) — https://github.com/uxiaohan/HanAnalytics + * + * 用法:在第三方站点的 前插入: + * + * + * 自有站点已通过 React Router 集成,无需手动加载本脚本。 + */ +(function () { + "use strict"; + var script = document.currentScript || (function () { + var s = document.getElementsByTagName("script"); + return s[s.length - 1]; + })(); + if (!script) return; + + var website = script.getAttribute("data-website-id") || "default"; + var endpoint = script.getAttribute("data-endpoint") || "/api/track"; + var VID_KEY = "monolith_vid"; + var VID_TTL = 30 * 24 * 60 * 60 * 1000; + var enterAt = 0; + var lastPath = ""; + + function hash(s) { + var h = 0x811c9dc5; + for (var i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + function vid() { + try { + var raw = localStorage.getItem(VID_KEY); + if (raw) { + var p = JSON.parse(raw); + if (Date.now() - p.ts < VID_TTL && p.id) return p.id; + } + } catch (_) { /* ignore */ } + var id = hash(Date.now() + "|" + Math.random() + "|" + navigator.userAgent + "|" + screen.width + "x" + screen.height); + try { localStorage.setItem(VID_KEY, JSON.stringify({ id: id, ts: Date.now() })); } catch (_) { /* ignore */ } + return id; + } + + function send(body, beacon) { + var json = JSON.stringify(body); + if (beacon && navigator.sendBeacon) { + navigator.sendBeacon(endpoint, new Blob([json], { type: "application/json" })); + return; + } + try { + fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: json, + keepalive: true, + credentials: "omit", + }).catch(function () { /* silent */ }); + } catch (_) { /* ignore */ } + } + + function payload(path, duration) { + return { + website: website, + path: path, + referer: document.referrer || "", + screen: screen.width + "x" + screen.height, + language: navigator.language || "", + visitorId: vid(), + duration: duration || 0, + }; + } + + function track() { + var path = location.pathname + location.search; + if (path === lastPath) return; + if (lastPath && enterAt > 0) send(payload(lastPath, Date.now() - enterAt), false); + lastPath = path; + enterAt = Date.now(); + send(payload(path, 0), false); + } + + function flush() { + if (!lastPath || enterAt <= 0) return; + send(payload(lastPath, Date.now() - enterAt), true); + } + + // 初始 + History API hook + track(); + var origPush = history.pushState; + var origReplace = history.replaceState; + history.pushState = function () { origPush.apply(this, arguments); track(); }; + history.replaceState = function () { origReplace.apply(this, arguments); track(); }; + window.addEventListener("popstate", track); + window.addEventListener("pagehide", flush); + document.addEventListener("visibilitychange", function () { + if (document.visibilityState === "hidden") flush(); + }); +})(); diff --git a/client/src/app.tsx b/client/src/app.tsx index 2784315..408d079 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -7,6 +7,7 @@ import { SearchOverlay } from "@/components/search"; import { ProtectedRoute } from "@/components/protected-route"; import { AdminLayout } from "@/components/admin-layout"; import { CookieConsent, getCookieConsent } from "@/components/cookie-consent"; +import { trackPageview, bindUnloadTracker } from "@/lib/analytics"; // 代码分割 (Code Splitting) const HomePage = lazy(() => import("@/pages/home").then((m) => ({ default: m.HomePage }))); @@ -58,6 +59,12 @@ function matchesPathPrefix(pathname: string, prefix: string) { export function App() { const [location] = useLocation(); + // 访客埋点:路由变化触发 pageview,页面卸载触发 duration 上报 + useEffect(() => { + bindUnloadTracker(); + trackPageview(location); + }, [location]); + // 路由判断逻辑 const isAdminRoot = matchesPathPrefix(location, "/admin"); const isEditorPage = matchesPathPrefix(location, "/admin/editor"); diff --git a/client/src/lib/analytics.ts b/client/src/lib/analytics.ts new file mode 100644 index 0000000..fd2ff72 --- /dev/null +++ b/client/src/lib/analytics.ts @@ -0,0 +1,145 @@ +/** + * 客户端访客埋点工具(CF Analytics Engine 增强版) + * 灵感来源: HanAnalytics (MIT) — https://github.com/uxiaohan/HanAnalytics + * + * 工作原理: + * 1. 首次加载生成稳定的访客 ID(localStorage 缓存 30 天,不含 PII) + * 2. 路由变化 / 首屏渲染 时调用 trackPageview + * 3. 页面 unload / pagehide 时上报本次停留时长(duration) + * 4. 后端 AE 不可用 → 接口直接 204,前端无感 + */ + +const API_BASE = import.meta.env.VITE_API_URL || ""; +const TRACK_ENDPOINT = `${API_BASE}/api/track`; +const VID_KEY = "monolith_vid"; +const VID_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 天 + +let pageEnterAt = 0; +let lastTrackedPath = ""; + +/** 32-bit FNV-1a 哈希,输出 base36 字符串(不含 PII) */ +function hash(input: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); +} + +/** 获取或生成稳定的访客 ID(仅写本地,不入 cookie) */ +function getVisitorId(): string { + try { + const raw = localStorage.getItem(VID_KEY); + if (raw) { + const parsed = JSON.parse(raw) as { id: string; ts: number }; + if (Date.now() - parsed.ts < VID_TTL_MS && parsed.id) return parsed.id; + } + } catch { /* ignore */ } + + const seed = `${Date.now()}|${Math.random()}|${navigator.userAgent}|${screen.width}x${screen.height}`; + const id = hash(seed); + try { + localStorage.setItem(VID_KEY, JSON.stringify({ id, ts: Date.now() })); + } catch { /* localStorage 满或被禁,忽略 */ } + return id; +} + +type TrackBody = { + website: string; + path: string; + referer: string; + screen: string; + language: string; + visitorId: string; + duration: number; +}; + +function send(body: TrackBody, useBeacon: boolean): void { + const json = JSON.stringify(body); + if (useBeacon && navigator.sendBeacon) { + // sendBeacon 在 unload 时最可靠 + const blob = new Blob([json], { type: "application/json" }); + navigator.sendBeacon(TRACK_ENDPOINT, blob); + return; + } + // keepalive 让请求在页面切换时也能完成 + fetch(TRACK_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: json, + keepalive: true, + credentials: "omit", + }).catch(() => { /* 静默失败,埋点永远不影响业务 */ }); +} + +/** 上报一次 pageview(路由变化时调用) */ +export function trackPageview(path: string, website = "default"): void { + if (!path || path === lastTrackedPath) return; + // 后台路径不上报,保护管理员隐私 + 减少噪音 + if (path.startsWith("/admin")) { + lastTrackedPath = path; + pageEnterAt = Date.now(); + return; + } + + // 上报上一页停留时长 + if (lastTrackedPath && pageEnterAt > 0) { + send( + { + website, + path: lastTrackedPath, + referer: document.referrer || "", + screen: `${screen.width}x${screen.height}`, + language: navigator.language || "", + visitorId: getVisitorId(), + duration: Date.now() - pageEnterAt, + }, + false, + ); + } + + // 上报新页面 pageview + lastTrackedPath = path; + pageEnterAt = Date.now(); + send( + { + website, + path, + referer: document.referrer || "", + screen: `${screen.width}x${screen.height}`, + language: navigator.language || "", + visitorId: getVisitorId(), + duration: 0, + }, + false, + ); +} + +let unloadBound = false; +/** 绑定 pagehide 事件,确保最后一次停留时长能上报(仅绑定一次) */ +export function bindUnloadTracker(website = "default"): void { + if (unloadBound) return; + unloadBound = true; + const flush = () => { + if (!lastTrackedPath || pageEnterAt <= 0) return; + if (lastTrackedPath.startsWith("/admin")) return; + send( + { + website, + path: lastTrackedPath, + referer: document.referrer || "", + screen: `${screen.width}x${screen.height}`, + language: navigator.language || "", + visitorId: getVisitorId(), + duration: Date.now() - pageEnterAt, + }, + true, + ); + }; + // pagehide 比 unload 更可靠(兼容 BFCache) + window.addEventListener("pagehide", flush); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flush(); + }); +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 964e27f..1140753 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -303,6 +303,52 @@ export async function fetchAnalytics(days = 7): Promise { return res.json(); } +/* ── AE 增强分析(CF 专属,仅在 D1 后端可用) ────────── */ +export type AEAnalyticsData = { + visitsByDay: { date: string; count: number; uv: number }[]; + topCountries: { country: string; count: number }[]; + topReferers: { referer: string; count: number }[]; + deviceBreakdown: { device: string; count: number }[]; + browserBreakdown: { browser: string; count: number }[]; + osBreakdown: { os: string; count: number }[]; + topPages: { path: string; count: number }[]; + topScreens: { screen: string; count: number }[]; + topLanguages: { language: string; count: number }[]; + totalVisits: number; + uniqueVisitors: number; + avgDuration: number; + hourlyHeatmap: { dow: number; hour: number; count: number }[]; + durationBuckets: { bucket: string; count: number }[]; + entryPages: { path: string; count: number }[]; + exitPages: { path: string; count: number }[]; + visitorTypes: { type: "new" | "returning"; count: number }[]; + bounceRate: number; + pagesPerVisitor: number; + topReferersFull: { referer: string; count: number }[]; +}; + +export type AEAnalyticsError = { + /** 501 = 非 CF 部署;503 = 缺 token;502 = AE SQL 失败 */ + status: number; + message: string; +}; + +export async function fetchAEAnalytics(days = 7): Promise { + const res = await fetch(`${API_BASE}/api/admin/analytics/ae?days=${days}`, { + headers: authHeaders(), + }); + if (!res.ok) { + let message = `AE 分析数据加载失败 (${res.status})`; + try { + const body = await res.json() as { error?: string }; + if (body.error) message = body.error; + } catch { /* ignore */ } + const err: AEAnalyticsError = { status: res.status, message }; + throw err; + } + return res.json(); +} + /* ── 评论 ──────────────────────────────────── */ export type CommentData = { id: number; diff --git a/client/src/pages/admin/analytics-ae.tsx b/client/src/pages/admin/analytics-ae.tsx new file mode 100644 index 0000000..21300bf --- /dev/null +++ b/client/src/pages/admin/analytics-ae.tsx @@ -0,0 +1,537 @@ +import { useState, useEffect } from "react"; +import { fetchAEAnalytics, type AEAnalyticsData, type AEAnalyticsError } from "@/lib/api"; +import { Globe, Monitor, ExternalLink, Cloud, Users, Clock, Languages, MonitorSmartphone, Activity, LogIn, LogOut, UserPlus, Repeat, Hourglass, CalendarClock } from "lucide-react"; + +function countryFlag(code: string): string { + if (!code || code === "XX" || code.length !== 2) return "🌍"; + return String.fromCodePoint( + ...code.toUpperCase().split("").map((c) => 0x1f1e6 + c.charCodeAt(0) - 65) + ); +} + +function formatDuration(ms: number): string { + if (!ms || ms <= 0) return "-"; + const sec = Math.round(ms / 1000); + if (sec < 60) return `${sec} 秒`; + const min = Math.round(sec / 60); + if (min < 60) return `${min} 分`; + return `${(sec / 3600).toFixed(1)} 小时`; +} + +type ListItem = { name: string; count: number }; + +// PV/UV 双系列趋势图:折线 + 数据点 + 网格线 +function DualTrendChart({ data, maxValue }: { data: { date: string; count: number; uv: number }[]; maxValue: number }) { + const [hover, setHover] = useState(null); + const W = 720; + const H = 220; + const PAD_T = 20; + const PAD_B = 40; + const PAD_X = 28; + const innerH = H - PAD_T - PAD_B; + const innerW = W - PAD_X * 2; + const n = data.length; + const step = n > 1 ? innerW / (n - 1) : 0; + const safeMax = maxValue || 1; + + const grid = [0, 0.25, 0.5, 0.75, 1].map((p) => ({ + y: PAD_T + innerH * (1 - p), + label: Math.round(safeMax * p), + })); + + const project = (count: number, i: number) => ({ + x: PAD_X + (n > 1 ? step * i : innerW / 2), + y: PAD_T + innerH * (1 - count / safeMax), + }); + + const pvPoints = data.map((d, i) => ({ ...project(d.count, i), v: d.count, date: d.date })); + const uvPoints = data.map((d, i) => ({ ...project(d.uv, i), v: d.uv })); + const buildPath = (pts: { x: number; y: number }[]) => + pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" "); + const pvLine = buildPath(pvPoints); + const uvLine = buildPath(uvPoints); + const pvArea = pvPoints.length > 0 + ? `${pvLine} L ${pvPoints[pvPoints.length - 1].x.toFixed(1)} ${PAD_T + innerH} L ${pvPoints[0].x.toFixed(1)} ${PAD_T + innerH} Z` + : ""; + + return ( +
+ {/* 图例 */} +
+ + + PV 总访问 + + + + UV 独立访客 + +
+ + + + + + + + + {grid.map((g, i) => ( + + + {g.label} + + ))} + + {pvArea && } + {pvLine && } + {uvLine && } + + {pvPoints.map((p, i) => ( + + ))} + {uvPoints.map((p, i) => ( + + ))} + + {/* hover 时显示当日 PV/UV 数值在 PV 点上方 */} + {pvPoints.map((p, i) => ( + hover === i ? ( + + + PV {p.v} · UV {uvPoints[i].v} + + + ) : null + ))} + + {pvPoints.map((p, i) => ( + {p.date.slice(5)} + ))} + + {pvPoints.map((p, i) => { + const w = step > 0 ? step : innerW; + return ( + setHover(i)} + onMouseLeave={() => setHover(null)} + /> + ); + })} + +
+ ); +} + +function ListSection({ title, icon: Icon, items, accent, mono }: { + title: string; + icon: typeof Globe; + items: ListItem[]; + accent: "blue" | "green" | "violet" | "amber"; + mono?: boolean; +}) { + const max = items.length > 0 ? items[0].count : 1; + return ( +
+

+ + {title} +

+
+ {items.length === 0 ? ( +
暂无数据
+ ) : ( + items.map((item) => ( +
+ + {item.name} + +
+
+
+ {item.count} +
+ )) + )} +
+
+ ); +} + +// 7×24 热力图:周日(0)..周六(6) × 0..23 时 +function HeatmapChart({ data }: { data: { dow: number; hour: number; count: number }[] }) { + // 注意:ClickHouse toDayOfWeek 是 1..7(周一=1, 周日=7),这里统一映射回 0..6(周日=0) + const matrix = new Map(); + let maxC = 1; + for (const r of data) { + const dow = r.dow === 7 ? 0 : r.dow; // 周日 7 → 0 + const k = `${dow}-${r.hour}`; + matrix.set(k, r.count); + if (r.count > maxC) maxC = r.count; + } + const dows = ["日", "一", "二", "三", "四", "五", "六"]; + const hours = Array.from({ length: 24 }, (_, i) => i); + const cellW = 26; + const cellH = 18; + const labelW = 28; + const labelH = 18; + const W = labelW + cellW * 24 + 8; + const H = labelH + cellH * 7 + 4; + + return ( +
+ + {hours.map((h) => ( + + {h % 3 === 0 ? h : ""} + + ))} + {dows.map((d, di) => ( + + {d} + + ))} + {dows.map((_, di) => + hours.map((h) => { + const v = matrix.get(`${di}-${h}`) || 0; + const intensity = v / maxC; + const opacity = v === 0 ? 0.04 : 0.18 + intensity * 0.7; + return ( + + {`周${dows[di]} ${String(h).padStart(2, "0")}:00 — ${v} 次访问`} + + ); + }) + )} + +
+ ); +} + +// 停留时长分桶柱图 +function DurationBucketsChart({ buckets }: { buckets: { bucket: string; count: number }[] }) { + // 强制顺序 + const order = ["0-10s", "10-30s", "30s-1m", "1-3m", "3-10m", "10m+"]; + const map = new Map(buckets.map((b) => [b.bucket, b.count])); + const rows = order.map((k) => ({ bucket: k, count: map.get(k) || 0 })); + const max = rows.reduce((a, b) => Math.max(a, b.count), 1); + const total = rows.reduce((a, b) => a + b.count, 0); + return ( +
+ {rows.map((r) => { + const pct = max > 0 ? (r.count / max) * 100 : 0; + const ratio = total > 0 ? ((r.count / total) * 100).toFixed(1) : "0.0"; + return ( +
+ {r.bucket} +
+
+
+ {r.count} + {ratio}% +
+ ); + })} +
+ ); +} + +// 新老访客胶囊条 +function VisitorRatio({ types }: { types: { type: "new" | "returning"; count: number }[] }) { + const newCount = types.find((t) => t.type === "new")?.count || 0; + const retCount = types.find((t) => t.type === "returning")?.count || 0; + const total = newCount + retCount; + const newPct = total > 0 ? (newCount / total) * 100 : 0; + const retPct = total > 0 ? (retCount / total) * 100 : 0; + return ( +
+
+
+
+
+
+ + + 新访客 + {newCount} + ({newPct.toFixed(1)}%) + + + + 回访 + {retCount} + ({retPct.toFixed(1)}%) + +
+
+ ); +} + +export function AnalyticsAEView({ days }: { days: number }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + fetchAEAnalytics(days) + .then((d) => { setData(d); setError(null); }) + .catch((e: AEAnalyticsError) => { setData(null); setError(e); }) + .finally(() => setLoading(false)); + }, [days]); + + if (loading) { + return
加载中...
; + } + + if (error) { + if (error.status === 501) { + return ( +
+ +
AE 增强分析仅支持 Cloudflare 部署
+
当前后端:{error.message}
+
+ ); + } + if (error.status === 503) { + return ( +
+
AE 配置缺失
+
+ 请通过 wrangler secret put 注入 CLOUDFLARE_ACCOUNT_ID 与 CLOUDFLARE_API_TOKEN + (需 Account Analytics:Read 权限) +
+
+ ); + } + return ( +
+ {error.message} +
+ ); + } + + if (!data) { + return
暂无数据
; + } + + const maxDay = Math.max(...data.visitsByDay.map((d) => Math.max(d.count, d.uv)), 1); + const bounceRatePct = (data.bounceRate * 100).toFixed(1); + const pagesPerVisitor = data.pagesPerVisitor.toFixed(2); + + return ( +
+ {/* 核心指标 8 张卡 */} +
+
+ 总访问 (PV) + {data.totalVisits} +
+
+ 独立访客 (UV) + {data.uniqueVisitors} +
+
+ 平均停留 + {formatDuration(data.avgDuration)} +
+
+ 国家/地区 + {data.topCountries.length} +
+
+ 跳出率 + {bounceRatePct}% +
+
+ 人均页数 + {pagesPerVisitor} +
+
+ 浏览器种类 + {data.browserBreakdown.length} +
+
+ 引荐来源 + {data.topReferersFull.length} +
+
+ + {/* 访问趋势 */} +
+

+ + 访问趋势 (PV / UV) + + 峰值 {maxDay} + +

+ {data.visitsByDay.length === 0 ? ( +
暂无访问数据
+ ) : ( + + )} +
+ + {/* 新老访客 + 停留分桶 并排 */} +
+
+

+ + 新老访客占比 + + 基于 24h 首访界限 + +

+ {data.visitorTypes.every((t) => t.count === 0) ? ( +
暂无访客数据
+ ) : ( + + )} +
+
+

+ + 停留时长分布 + + 不含 0 秒纯 PV + +

+ {data.durationBuckets.length === 0 ? ( +
暂无停留数据
+ ) : ( + + )} +
+
+ + {/* 时段热力图 */} +
+

+ + 访客时段热力图 + + UTC 时间 · 颜色越亮访问越多 + +

+ {data.hourlyHeatmap.length === 0 ? ( +
暂无时段数据
+ ) : ( + + )} +
+ + {/* 入口 / 出口页 */} +
+ ({ name: i.path, count: i.count }))} + /> + ({ name: i.path, count: i.count }))} + /> +
+ + {/* 全维度分布列表 */} +
+ ({ name: `${countryFlag(i.country)} ${i.country}`, count: i.count }))} + /> + ({ name: i.device, count: i.count }))} + /> + ({ name: i.browser, count: i.count }))} + /> + ({ name: i.os, count: i.count }))} + /> + ({ name: i.referer, count: i.count }))} + /> + ({ name: i.path, count: i.count }))} + /> + ({ name: i.screen, count: i.count }))} + /> + ({ name: i.language, count: i.count }))} + /> +
+
+ ); +} diff --git a/client/src/pages/admin/analytics.tsx b/client/src/pages/admin/analytics.tsx index b8cb645..0411585 100644 --- a/client/src/pages/admin/analytics.tsx +++ b/client/src/pages/admin/analytics.tsx @@ -1,6 +1,9 @@ import { useState, useEffect } from "react"; import { fetchAnalytics, type AnalyticsData } from "@/lib/api"; -import { Globe, Monitor, Smartphone, Tablet, Bot, ExternalLink, TrendingUp, BarChart3 } from "lucide-react"; +import { Globe, Monitor, Smartphone, Tablet, Bot, ExternalLink, TrendingUp, BarChart3, Cloud } from "lucide-react"; +import { AnalyticsAEView } from "./analytics-ae"; + +type TabKey = "basic" | "ae"; const DEVICE_ICONS: Record = { desktop: Monitor, @@ -24,14 +27,130 @@ function countryFlag(code: string): string { ); } +// 访问趋势图:柱状 + 折线叠加 + 网格参考线,hover 高亮当日 +function TrendChart({ data, max }: { data: { date: string; count: number }[]; max: number }) { + const [hover, setHover] = useState(null); + const W = 720; + const H = 200; + const PAD_T = 16; + const PAD_B = 36; + const PAD_X = 24; + const innerH = H - PAD_T - PAD_B; + const innerW = W - PAD_X * 2; + const n = data.length; + const step = n > 1 ? innerW / (n - 1) : 0; + const barW = n > 0 ? Math.min(40, (innerW / n) * 0.55) : 0; + const safeMax = max || 1; + + // 网格 4 等分(含顶/底) + const gridLines = [0, 0.25, 0.5, 0.75, 1].map((p) => ({ + y: PAD_T + innerH * (1 - p), + label: Math.round(safeMax * p), + })); + + const points = data.map((d, i) => ({ + x: PAD_X + (n > 1 ? step * i : innerW / 2), + y: PAD_T + innerH * (1 - d.count / safeMax), + count: d.count, + date: d.date, + })); + + const linePath = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" "); + const areaPath = points.length > 0 + ? `${linePath} L ${points[points.length - 1].x.toFixed(1)} ${PAD_T + innerH} L ${points[0].x.toFixed(1)} ${PAD_T + innerH} Z` + : ""; + + return ( +
+ + + + + + + + + + + + + {/* 网格 + Y 轴刻度 */} + {gridLines.map((g, i) => ( + + + {g.label} + + ))} + + {/* 柱子 */} + {points.map((p, i) => { + const barH = PAD_T + innerH - p.y; + return ( + + ); + })} + + {/* 面积 + 折线 */} + {areaPath && } + {linePath && } + + {/* 数据点 + 数值 */} + {points.map((p, i) => ( + + + {p.count} + + ))} + + {/* X 轴日期 */} + {points.map((p, i) => ( + {p.date.slice(5)} + ))} + + {/* 透明 hover 区域 */} + {points.map((p, i) => { + const w = step > 0 ? step : innerW; + return ( + setHover(i)} + onMouseLeave={() => setHover(null)} + /> + ); + })} + +
+ ); +} + export function AdminAnalytics() { const [data, setData] = useState(null); const [days, setDays] = useState(7); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [tab, setTab] = useState("basic"); useEffect(() => { document.title = "访客分析 | Monolith"; + }, []); + + useEffect(() => { + if (tab !== "basic") return; setLoading(true); fetchAnalytics(days) .then((result) => { @@ -43,7 +162,7 @@ export function AdminAnalytics() { setError("访客分析数据加载失败,请稍后重试。"); }) .finally(() => setLoading(false)); - }, [days]); + }, [days, tab]); const totalVisits = data?.visitsByDay.reduce((s, d) => s + d.count, 0) ?? 0; const maxDayCount = data ? Math.max(...data.visitsByDay.map((d) => d.count), 1) : 1; @@ -73,9 +192,38 @@ export function AdminAnalytics() { ))}
+ + {/* Tab 切换:基础统计 / AE 增强 (CF 专属) */} +
+ + +
- {loading ? ( + {tab === "ae" ? ( + + ) : loading ? (
加载中...
) : error ? (
{error}
@@ -105,34 +253,20 @@ export function AdminAnalytics() {
- {/* 趋势图(简易柱状图) */} + {/* 趋势图(SVG 折线 + 柱状叠加) */}

访问趋势 + + 峰值 {maxDayCount} · 日均 {days > 0 ? Math.round(totalVisits / days) : 0} +

-
- {data.visitsByDay.length === 0 ? ( -
暂无访问数据
- ) : ( -
- {data.visitsByDay.map((day) => ( -
-
-
-
- - {day.date.slice(5)} - - {day.count} -
- ))} -
- )} -
+ {data.visitsByDay.length === 0 ? ( +
暂无访问数据
+ ) : ( + + )}
{/* 下方 2 列布局 */} diff --git a/scripts/deploy-cloudflare.mjs b/scripts/deploy-cloudflare.mjs index f58b9ff..232e6c7 100644 --- a/scripts/deploy-cloudflare.mjs +++ b/scripts/deploy-cloudflare.mjs @@ -153,6 +153,8 @@ if (!options.skipClient) { "--branch", options.branch, "--commit-dirty=true", + "--commit-message", + "monolith deploy", ], { cwd: clientRoot }); } diff --git a/server/src/analytics/ae-query.ts b/server/src/analytics/ae-query.ts new file mode 100644 index 0000000..5e43438 --- /dev/null +++ b/server/src/analytics/ae-query.ts @@ -0,0 +1,349 @@ +/** + * Cloudflare Analytics Engine SQL API 查询封装 + * 灵感来源: HanAnalytics (MIT) — https://github.com/uxiaohan/HanAnalytics + * + * 通过 https://api.cloudflare.com/client/v4/accounts/{id}/analytics_engine/sql 查询。 + * 需要 API Token 具备 "Account Analytics: Read" 权限。 + * + * 字段映射详见 ae-tracker.ts 顶部注释。 + */ + +const AE_SQL_ENDPOINT = (accountId: string) => + `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`; + +export type AEQueryEnv = { + CLOUDFLARE_ACCOUNT_ID?: string; + CLOUDFLARE_API_TOKEN?: string; +}; + +export type AEAnalyticsResult = { + visitsByDay: { date: string; count: number; uv: number }[]; + topCountries: { country: string; count: number }[]; + topReferers: { referer: string; count: number }[]; + deviceBreakdown: { device: string; count: number }[]; + browserBreakdown: { browser: string; count: number }[]; + osBreakdown: { os: string; count: number }[]; + topPages: { path: string; count: number }[]; + topScreens: { screen: string; count: number }[]; + topLanguages: { language: string; count: number }[]; + totalVisits: number; + uniqueVisitors: number; + avgDuration: number; // 平均停留毫秒 + // —— 新增维度 —— + hourlyHeatmap: { dow: number; hour: number; count: number }[]; // 0=周日..6=周六, hour 0-23 + durationBuckets: { bucket: string; count: number }[]; // 0-10s / 10-30s / 30s-1m / 1-3m / 3-10m / 10m+ + entryPages: { path: string; count: number }[]; // 入口页(每个 visitor 第一次访问的页面) + exitPages: { path: string; count: number }[]; // 出口页(每个 visitor 最后访问的页面) + visitorTypes: { type: "new" | "returning"; count: number }[]; // 新老访客(窗口内首次访问视为新) + bounceRate: number; // 跳出率(只浏览 1 个页面的 visitor 占比,0-1) + pagesPerVisitor: number; // 人均访问页面数 + topReferersFull: { referer: string; count: number }[]; // 引荐扩展到 20 条 +}; + +/** 执行一条 AE SQL,返回数据数组(失败抛错) */ +async function runSql>(env: AEQueryEnv, sql: string): Promise { + if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) { + throw new Error("Missing CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN secrets for AE query"); + } + const res = await fetch(AE_SQL_ENDPOINT(env.CLOUDFLARE_ACCOUNT_ID), { + method: "POST", + headers: { + "Authorization": `Bearer ${env.CLOUDFLARE_API_TOKEN}`, + "Content-Type": "text/plain", + }, + body: sql, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`AE SQL HTTP ${res.status}: ${text.slice(0, 256)}`); + } + const json = await res.json() as { data?: T[]; meta?: unknown; error?: string }; + if (json.error) throw new Error(`AE SQL error: ${json.error}`); + return json.data || []; +} + +/** AE 数据集名称必须与 wrangler.toml 一致 */ +const DATASET = "monolith_analytics"; + +/** 把 days 限制在合法范围(AE 默认保留 31 天) */ +function safeDays(days: number): number { + const n = Math.max(1, Math.min(31, Math.floor(days || 7))); + return n; +} + +/** SQL 注入防护:只允许传入数字(days)和已知 enum,绝不拼接用户字符串 */ +export async function queryAEAnalytics(env: AEQueryEnv, days: number): Promise { + const d = safeDays(days); + const since = `INTERVAL '${d}' DAY`; + + // 并发执行多条聚合查询 + const [ + byDay, + byCountry, + byReferer, + byDevice, + byBrowser, + byOs, + byPage, + byScreen, + byLang, + totals, + heatmap, + durB1, + durB2, + durB3, + durB4, + durB5, + durB6, + entryPagesRows, + exitPagesRows, + bounceVisitors, + bounceBouncers, + referersFull, + directAccess, + ] = await Promise.all([ + runSql<{ date: string; cnt: number; uv: number }>( + env, + `SELECT toDate(timestamp) AS date, SUM(_sample_interval) AS cnt, COUNT(DISTINCT blob10) AS uv + FROM ${DATASET} + WHERE timestamp > NOW() - ${since} + GROUP BY date ORDER BY date ASC FORMAT JSON`, + ), + runSql<{ country: string; cnt: number }>( + env, + `SELECT blob3 AS country, SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob3 != '' + GROUP BY country ORDER BY cnt DESC LIMIT 10 FORMAT JSON`, + ), + runSql<{ referer: string; cnt: number }>( + env, + `SELECT blob4 AS referer, SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob4 != '' + GROUP BY referer ORDER BY cnt DESC LIMIT 10 FORMAT JSON`, + ), + runSql<{ device: string; cnt: number }>( + env, + `SELECT blob5 AS device, SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} + GROUP BY device ORDER BY cnt DESC FORMAT JSON`, + ), + runSql<{ browser: string; cnt: number }>( + env, + `SELECT blob6 AS browser, SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} + GROUP BY browser ORDER BY cnt DESC LIMIT 10 FORMAT JSON`, + ), + runSql<{ os: string; cnt: number }>( + env, + `SELECT blob7 AS os, SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} + GROUP BY os ORDER BY cnt DESC LIMIT 10 FORMAT JSON`, + ), + runSql<{ path: string; cnt: number }>( + env, + `SELECT blob2 AS path, SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} + GROUP BY path ORDER BY cnt DESC LIMIT 10 FORMAT JSON`, + ), + runSql<{ screen: string; cnt: number }>( + env, + `SELECT blob8 AS screen, SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob8 != '' + GROUP BY screen ORDER BY cnt DESC LIMIT 10 FORMAT JSON`, + ), + runSql<{ language: string; cnt: number }>( + env, + `SELECT blob9 AS language, SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob9 != '' + GROUP BY language ORDER BY cnt DESC LIMIT 10 FORMAT JSON`, + ), + runSql<{ total: number; uv: number; avg_dur: number }>( + env, + `SELECT SUM(_sample_interval) AS total, COUNT(DISTINCT blob10) AS uv, AVG(double1) AS avg_dur + FROM ${DATASET} WHERE timestamp > NOW() - ${since} FORMAT JSON`, + ), + // —— 新增:小时 × 星期 热力图(duration=0 的纯 pageview 也算一次访问) —— + runSql<{ dow: number; hour: number; cnt: number }>( + env, + `SELECT toDayOfWeek(timestamp) AS dow, toHour(timestamp) AS hour, SUM(_sample_interval) AS cnt + FROM ${DATASET} WHERE timestamp > NOW() - ${since} + GROUP BY dow, hour ORDER BY dow, hour FORMAT JSON`, + ), + // —— 新增:停留时长分桶(毫秒):6 条独立查询并发 —— + runSql<{ cnt: number }>( + env, + `SELECT SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND double1 > 0 AND double1 < 10000 FORMAT JSON`, + ), + runSql<{ cnt: number }>( + env, + `SELECT SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND double1 >= 10000 AND double1 < 30000 FORMAT JSON`, + ), + runSql<{ cnt: number }>( + env, + `SELECT SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND double1 >= 30000 AND double1 < 60000 FORMAT JSON`, + ), + runSql<{ cnt: number }>( + env, + `SELECT SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND double1 >= 60000 AND double1 < 180000 FORMAT JSON`, + ), + runSql<{ cnt: number }>( + env, + `SELECT SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND double1 >= 180000 AND double1 < 600000 FORMAT JSON`, + ), + runSql<{ cnt: number }>( + env, + `SELECT SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND double1 >= 600000 FORMAT JSON`, + ), + // —— 新增:入口页(referer 为空 = 直接访问/外链着陆的页面 Top) —— + runSql<{ path: string; cnt: number }>( + env, + `SELECT blob2 AS path, SUM(_sample_interval) AS cnt + FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob4 = '' + GROUP BY path ORDER BY cnt DESC LIMIT 10 FORMAT JSON`, + ), + // —— 新增:出口页(停留时间 > 0 但短的页面,近似为离开点) —— + runSql<{ path: string; cnt: number }>( + env, + `SELECT blob2 AS path, SUM(_sample_interval) AS cnt + FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND double1 > 0 AND double1 < 5000 + GROUP BY path ORDER BY cnt DESC LIMIT 10 FORMAT JSON`, + ), + // —— 新增:跳出 + 人均访问页数(基于 visitor session):拆 3 条 —— + runSql<{ visitors: number; total_pv: number }>( + env, + `SELECT COUNT() AS visitors, SUM(pv) AS total_pv FROM ( + SELECT blob10 AS vid, SUM(_sample_interval) AS pv + FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob10 != '' + GROUP BY vid + ) FORMAT JSON`, + ), + runSql<{ bouncers: number }>( + env, + `SELECT COUNT() AS bouncers FROM ( + SELECT blob10 AS vid, SUM(_sample_interval) AS pv + FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob10 != '' + GROUP BY vid + HAVING pv = 1 + ) FORMAT JSON`, + ), + // —— 新增:扩展引荐 Top 20(仅非空 referer,直接访问单独算) —— + runSql<{ referer: string; cnt: number }>( + env, + `SELECT blob4 AS referer, SUM(_sample_interval) AS cnt + FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob4 != '' + GROUP BY referer ORDER BY cnt DESC LIMIT 20 FORMAT JSON`, + ), + // —— 新增:直接访问总量(referer 为空) —— + runSql<{ cnt: number }>( + env, + `SELECT SUM(_sample_interval) AS cnt FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob4 = '' FORMAT JSON`, + ), + ]); + + const totalRow = totals[0] || { total: 0, uv: 0, avg_dur: 0 }; + // 跳出率 / 人均页数 + const visitorsRow = bounceVisitors[0] || { visitors: 0, total_pv: 0 }; + const bouncersRow = bounceBouncers[0] || { bouncers: 0 }; + const visitors = Number(visitorsRow.visitors) || 0; + const bouncers = Number(bouncersRow.bouncers) || 0; + const totalPv = Number(visitorsRow.total_pv) || 0; + const bounceRate = visitors > 0 ? bouncers / visitors : 0; + const pagesPerVisitor = visitors > 0 ? totalPv / visitors : 0; + + // 新老访客比:拆为两条查询并行(窗口内首访 > 24h 前 = old,否则 new) + // AE 不支持 CASE WHEN,改用两条 HAVING 过滤 + let newVisitors = 0; + let returningVisitors = 0; + try { + const [newRows, oldRows] = await Promise.all([ + runSql<{ cnt: number }>( + env, + `SELECT COUNT() AS cnt FROM ( + SELECT blob10 AS vid, MIN(timestamp) AS min_ts + FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob10 != '' + GROUP BY vid + HAVING min_ts > NOW() - INTERVAL '1' DAY + ) FORMAT JSON`, + ), + runSql<{ cnt: number }>( + env, + `SELECT COUNT() AS cnt FROM ( + SELECT blob10 AS vid, MIN(timestamp) AS min_ts + FROM ${DATASET} + WHERE timestamp > NOW() - ${since} AND blob10 != '' + GROUP BY vid + HAVING min_ts <= NOW() - INTERVAL '1' DAY + ) FORMAT JSON`, + ), + ]); + newVisitors = Number(newRows[0]?.cnt) || 0; + returningVisitors = Number(oldRows[0]?.cnt) || 0; + } catch { + // 静默降级:visitorAge 查询失败不影响主响应 + } + + // 拼接停留分桶(6 条独立查询 → 命名桶) + const durationBuckets = [ + { bucket: "0-10s", count: Number(durB1[0]?.cnt) || 0 }, + { bucket: "10-30s", count: Number(durB2[0]?.cnt) || 0 }, + { bucket: "30s-1m", count: Number(durB3[0]?.cnt) || 0 }, + { bucket: "1-3m", count: Number(durB4[0]?.cnt) || 0 }, + { bucket: "3-10m", count: Number(durB5[0]?.cnt) || 0 }, + { bucket: "10m+", count: Number(durB6[0]?.cnt) || 0 }, + ]; + + // 引荐 Top 20:把直接访问拼到首位(按数量重排) + const directCount = Number(directAccess[0]?.cnt) || 0; + const referersFullList = referersFull.map((r) => ({ + referer: r.referer, + count: Number(r.cnt) || 0, + })); + if (directCount > 0) { + referersFullList.push({ referer: "(直接访问)", count: directCount }); + referersFullList.sort((a, b) => b.count - a.count); + referersFullList.splice(20); + } + + return { + visitsByDay: byDay.map((r) => ({ date: r.date, count: Number(r.cnt) || 0, uv: Number(r.uv) || 0 })), + topCountries: byCountry.map((r) => ({ country: r.country, count: Number(r.cnt) || 0 })), + topReferers: byReferer.map((r) => ({ referer: r.referer, count: Number(r.cnt) || 0 })), + deviceBreakdown: byDevice.map((r) => ({ device: r.device, count: Number(r.cnt) || 0 })), + browserBreakdown: byBrowser.map((r) => ({ browser: r.browser, count: Number(r.cnt) || 0 })), + osBreakdown: byOs.map((r) => ({ os: r.os, count: Number(r.cnt) || 0 })), + topPages: byPage.map((r) => ({ path: r.path, count: Number(r.cnt) || 0 })), + topScreens: byScreen.map((r) => ({ screen: r.screen, count: Number(r.cnt) || 0 })), + topLanguages: byLang.map((r) => ({ language: r.language, count: Number(r.cnt) || 0 })), + totalVisits: Number(totalRow.total) || 0, + uniqueVisitors: Number(totalRow.uv) || 0, + avgDuration: Math.round(Number(totalRow.avg_dur) || 0), + hourlyHeatmap: heatmap.map((r) => ({ + dow: Number(r.dow) || 0, + hour: Number(r.hour) || 0, + count: Number(r.cnt) || 0, + })), + durationBuckets, + entryPages: entryPagesRows.map((r) => ({ path: r.path, count: Number(r.cnt) || 0 })), + exitPages: exitPagesRows.map((r) => ({ path: r.path, count: Number(r.cnt) || 0 })), + visitorTypes: [ + { type: "new", count: newVisitors }, + { type: "returning", count: returningVisitors }, + ], + bounceRate, + pagesPerVisitor, + topReferersFull: referersFullList, + }; +} diff --git a/server/src/analytics/ae-tracker.ts b/server/src/analytics/ae-tracker.ts new file mode 100644 index 0000000..dfdffc1 --- /dev/null +++ b/server/src/analytics/ae-tracker.ts @@ -0,0 +1,79 @@ +/** + * Cloudflare Analytics Engine 写入封装(CF 专属) + * 灵感来源: HanAnalytics (MIT) — https://github.com/uxiaohan/HanAnalytics + * + * AE 数据点结构: + * - blobs : 字符串维度 (最多 20 个,每个 ≤ 5120 字节) + * - doubles : 数值维度 (最多 20 个,64-bit double) + * - indexes : 高基数索引 (最多 1 个,≤ 96 字节,用于查询 sample) + * + * 字段约定(查询端必须保持一致): + * blob1 = website (站点标识,多站点支持) + * blob2 = path + * blob3 = country + * blob4 = referer (host only) + * blob5 = device (desktop/mobile/tablet/bot) + * blob6 = browser + * blob7 = os + * blob8 = screen (例 "1920x1080") + * blob9 = language (浏览器首选语言, 例 "zh-CN") + * blob10 = visitor_id (浏览器指纹哈希前缀, 用于 UV 估算) + * double1 = duration_ms (停留时长,0 = pageview 上报) + * index1 = website (作为采样索引,便于按站点过滤) + */ + +import { parseUserAgent, refererHost } from "./ua-parser"; + +export type TrackPayload = { + website?: string; // data-website-id + path: string; + referer?: string; + screen?: string; // "WxH" + language?: string; + visitorId?: string; // 客户端生成的访客指纹(哈希后) + duration?: number; // 停留时长(ms),可选 +}; + +export type TrackContext = { + ae: AnalyticsEngineDataset | undefined; + userAgent: string | undefined; + country: string | undefined; +}; + +/** + * 写入一条 AE 数据点。AE 不可用时静默跳过(保证 Turso/PG 后端不崩)。 + */ +export function writeAnalyticsPoint(payload: TrackPayload, ctx: TrackContext): void { + if (!ctx.ae) return; // 非 CF 部署 → 直接跳过 + const { device, browser, os } = parseUserAgent(ctx.userAgent); + const website = (payload.website || "default").slice(0, 64); + ctx.ae.writeDataPoint({ + blobs: [ + website, + payload.path.slice(0, 256), + (ctx.country || "XX").slice(0, 4), + refererHost(payload.referer).slice(0, 128), + device, + browser, + os, + (payload.screen || "").slice(0, 16), + (payload.language || "").slice(0, 16), + (payload.visitorId || "").slice(0, 16), + ], + doubles: [Math.max(0, Math.min(payload.duration || 0, 86400000))], // 上限 24h + indexes: [website], // 采样索引 + }); +} + +/** + * 校验站点白名单。空白名单 = 全部放行。 + * 格式: "example.com|blog.foo.com" + */ +export function isWebsiteAllowed(origin: string | null | undefined, whitelist: string | undefined): boolean { + if (!whitelist || whitelist.trim() === "") return true; + if (!origin) return false; + let host = origin; + try { host = new URL(origin).hostname; } catch { /* origin 已是 host */ } + const allowed = whitelist.split("|").map((s) => s.trim().toLowerCase()).filter(Boolean); + return allowed.some((d) => host.toLowerCase() === d || host.toLowerCase().endsWith("." + d)); +} diff --git a/server/src/analytics/ua-parser.ts b/server/src/analytics/ua-parser.ts new file mode 100644 index 0000000..56809f5 --- /dev/null +++ b/server/src/analytics/ua-parser.ts @@ -0,0 +1,56 @@ +/** + * 轻量 UA 解析器 (Cloudflare Workers 友好,零依赖) + * 灵感来源: HanAnalytics (MIT) — https://github.com/uxiaohan/HanAnalytics + * + * 仅识别主流浏览器 / 操作系统 / 设备类型,覆盖率约 95% + * 不引入 ua-parser-js 等大依赖,保持 Worker 冷启动 < 5ms + */ + +export type ParsedUA = { + device: "desktop" | "mobile" | "tablet" | "bot"; + browser: string; // chrome / firefox / safari / edge / opera / other + os: string; // windows / macos / linux / ios / android / other +}; + +const BOT_RE = /bot|crawl|spider|slurp|bing|google|baidu|yandex|duckduck|facebook|twitter|telegram|whatsapp|preview/i; +const TABLET_RE = /ipad|tablet|playbook|silk/i; +const MOBILE_RE = /mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i; + +export function parseUserAgent(uaInput: string | undefined | null): ParsedUA { + const ua = (uaInput || "").toLowerCase(); + if (!ua) return { device: "desktop", browser: "other", os: "other" }; + + // 设备类型(顺序敏感:bot > tablet > mobile > desktop) + let device: ParsedUA["device"] = "desktop"; + if (BOT_RE.test(ua)) device = "bot"; + else if (TABLET_RE.test(ua)) device = "tablet"; + else if (MOBILE_RE.test(ua)) device = "mobile"; + + // 浏览器(顺序敏感:edge 在 chrome 之前,opera 在 chrome 之前) + let browser = "other"; + if (/edg\//.test(ua)) browser = "edge"; + else if (/opr\/|opera/.test(ua)) browser = "opera"; + else if (/firefox|fxios/.test(ua)) browser = "firefox"; + else if (/chrome|crios/.test(ua)) browser = "chrome"; + else if (/safari/.test(ua)) browser = "safari"; + + // 操作系统 + let os = "other"; + if (/windows nt/.test(ua)) os = "windows"; + else if (/iphone|ipad|ipod/.test(ua)) os = "ios"; + else if (/mac os x/.test(ua)) os = "macos"; + else if (/android/.test(ua)) os = "android"; + else if (/linux/.test(ua)) os = "linux"; + + return { device, browser, os }; +} + +/** 从 Referer URL 提取 host(失败返回空字符串) */ +export function refererHost(referer: string | undefined | null): string { + if (!referer) return ""; + try { + return new URL(referer).hostname.toLowerCase(); + } catch { + return ""; + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 7e6fd4b..94f6265 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,11 +10,14 @@ import { sign, verify } from "hono/jwt"; import { createDatabase, createObjectStorage } from "./storage/factory"; import type { IDatabase } from "./storage/interfaces"; import type { IObjectStorage } from "./storage/interfaces"; +import { writeAnalyticsPoint, isWebsiteAllowed } from "./analytics/ae-tracker"; +import { queryAEAnalytics } from "./analytics/ae-query"; /* ── 类型定义 ──────────────────────────────── */ type Bindings = { DB: D1Database; BUCKET: R2Bucket; + AE?: AnalyticsEngineDataset; // Cloudflare Analytics Engine(CF 专属,可选) ADMIN_PASSWORD: string; JWT_SECRET: string; REACTION_SALT?: string; @@ -22,6 +25,9 @@ type Bindings = { STORAGE_PROVIDER?: string; WEBHOOK_URLS?: string; // 逗号分隔的 Webhook 目标地址 SITE_ORIGIN?: string; // 对外公开域名(如 https://monolith-client.pages.dev),用于 sitemap/robots + CLOUDFLARE_ACCOUNT_ID?: string; // AE GraphQL 查询用 + CLOUDFLARE_API_TOKEN?: string; // AE GraphQL 查询用(需要 Account Analytics:Read 权限) + ANALYTICS_WEBSITE_WHITELIST?: string; // 站点白名单,格式: domain1|domain2 (空=放行所有) }; type Variables = { @@ -96,6 +102,49 @@ async function triggerWebhook(c: any, eventName: string, payload: any) { app.get("/api/health", async (c) => { return c.json({ status: "ok", timestamp: new Date().toISOString() }); }); + +/* ── 访客埋点端点(CF 专属,AE 不可用时静默 204) ─────────── */ +// POST /api/track body: { website?, path, referer?, screen?, language?, visitorId?, duration? } +// 公开端点,受白名单 + Origin 校验保护,不写 D1(避免高频写穿) +app.post("/api/track", async (c) => { + // 白名单校验:通过 Origin 头判断站点合法性 + const origin = c.req.header("Origin") || c.req.header("Referer") || ""; + if (!isWebsiteAllowed(origin, c.env.ANALYTICS_WEBSITE_WHITELIST)) { + return c.json({ error: "origin not allowed" }, 403); + } + + let body: Record = {}; + try { body = await c.req.json(); } catch { return c.json({ error: "invalid json" }, 400); } + + const path = typeof body.path === "string" ? body.path : ""; + if (!path || path.length > 256) return c.json({ error: "invalid path" }, 400); + + // AE 不可用(Turso/PG 部署)→ 直接 204,前端无感 + if (!c.env.AE) { + c.status(204); + return c.body(null); + } + + writeAnalyticsPoint( + { + website: typeof body.website === "string" ? body.website : "default", + path, + referer: typeof body.referer === "string" ? body.referer : c.req.header("Referer"), + screen: typeof body.screen === "string" ? body.screen : "", + language: typeof body.language === "string" ? body.language : c.req.header("Accept-Language")?.split(",")[0], + visitorId: typeof body.visitorId === "string" ? body.visitorId : "", + duration: typeof body.duration === "number" ? body.duration : 0, + }, + { + ae: c.env.AE, + userAgent: c.req.header("User-Agent"), + country: c.req.header("CF-IPCountry") || "XX", + }, + ); + c.status(204); + return c.body(null); +}); + /* ── 公开 API ──────────────────────────────── */ // 获取文章列表(仅已发布) @@ -142,6 +191,12 @@ app.get("/api/posts/:slug", async (c) => { : /tablet|ipad/i.test(ua) ? "tablet" : "desktop"; const visitPromise = db.recordVisit({ path: `/posts/${slug}`, country, refererDomain, deviceType }); + // CF 专属:同步双写 Analytics Engine(Workers 环境零成本) + writeAnalyticsPoint( + { website: "default", path: `/posts/${slug}`, referer }, + { ae: c.env.AE, userAgent: c.req.header("User-Agent"), country }, + ); + // 边缘环境中使用 waitUntil 确保异步任务完成 if (c.executionCtx?.waitUntil) { c.executionCtx.waitUntil(viewPromise); @@ -534,6 +589,38 @@ app.get("/api/admin/analytics", async (c) => { return c.json(analytics); }); +// 访客分析数据 — AE 增强版(CF 专属,仅在 D1 后端 + 配置好 API Token 时可用) +app.get("/api/admin/analytics/ae", async (c) => { + // 守卫:仅 D1 后端支持 AE(默认未设 DB_PROVIDER 视为 d1) + const provider = (c.env.DB_PROVIDER || "d1").toLowerCase(); + if (provider !== "d1") { + return c.json({ + error: "AE analytics is Cloudflare-only (D1 deployment)", + provider, + }, 501); + } + if (!c.env.CLOUDFLARE_ACCOUNT_ID || !c.env.CLOUDFLARE_API_TOKEN) { + return c.json({ + error: "Missing CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN secrets", + }, 503); + } + + let days = parseInt(c.req.query("days") || "7", 10); + if (isNaN(days) || days <= 0) days = 7; + + try { + const data = await queryAEAnalytics( + { CLOUDFLARE_ACCOUNT_ID: c.env.CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN: c.env.CLOUDFLARE_API_TOKEN }, + Math.min(days, 31), + ); + return c.json(data); + } catch (err) { + return c.json({ + error: err instanceof Error ? err.message : "AE query failed", + }, 502); + } +}); + // 获取所有评论(管理后台) app.get("/api/admin/comments", async (c) => { const db = c.get("db"); diff --git a/server/wrangler.toml b/server/wrangler.toml index 6a92722..d2807f3 100644 --- a/server/wrangler.toml +++ b/server/wrangler.toml @@ -21,6 +21,13 @@ migrations_dir = "src/migrations" binding = "BUCKET" bucket_name = "monolith-assets" +# Analytics Engine 数据集绑定(CF 专属增强分析模块) +# 灵感来源: HanAnalytics (MIT) — https://github.com/uxiaohan/HanAnalytics +# 仅在 Cloudflare 部署时生效,Turso/PG 后端会自动跳过 AE 写入与查询 +[[analytics_engine_datasets]] +binding = "AE" +dataset = "monolith_analytics" + # 定时触发器 (每 1 分钟执行一次) [triggers] crons = ["* * * * *"]