Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 拦截
Expand Down
103 changes: 103 additions & 0 deletions client/public/tracker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*!
* Monolith Analytics Tracker (CF Analytics Engine)
* Adapted from HanAnalytics (MIT) — https://github.com/uxiaohan/HanAnalytics
*
* 用法:在第三方站点的 </body> 前插入:
* <script defer src="https://your-site.pages.dev/tracker.js"
* data-website-id="my-blog"
* data-endpoint="https://your-worker.workers.dev/api/track"></script>
*
* 自有站点已通过 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();
});
Comment on lines +87 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

重复 flush 隐患:pagehide 与 visibilitychange 可能连续触发

在大多数浏览器中页面被切到后台或关闭时,visibilitychange(hidden)与 pagehide 会先后触发,导致同一段 duration 被上报两次(第二次的 Date.now() - enterAt 还会被错误放大)。建议在 flush() 内做幂等保护,发送后清空 enterAt/lastPath

♻️ 建议修复
   function flush() {
     if (!lastPath || enterAt <= 0) return;
     send(payload(lastPath, Date.now() - enterAt), true);
+    enterAt = 0; // 防止 pagehide + visibilitychange 重复上报
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/public/tracker.js` around lines 87 - 102, flush() can be invoked twice
(pagehide + visibilitychange) leading to duplicate/augmented durations; make it
idempotent by computing the duration and calling send(payload(lastPath,
duration), true) only if lastPath and enterAt are valid, then immediately
clear/invalidates the tracking state (e.g. set enterAt = 0 and lastPath = null
or similar) so a subsequent flush() returns early; ensure you compute duration
before clearing and reference the symbols flush, send, payload, lastPath, and
enterAt when applying the change.

})();
7 changes: 7 additions & 0 deletions client/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })));
Expand Down Expand Up @@ -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");
Expand Down
145 changes: 145 additions & 0 deletions client/src/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +77 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

进入 /admin 时丢失上一页的停留时长

/blog/foo 跳转到 /admin/x 会在 Line 80 提前 return,跳过 Line 86-100 的"上报上一页 duration"分支。前一页的停留时长就此丢失,统计偏低。

🛠️ 建议先 flush 再切到 admin 隐私模式
-  // 后台路径不上报,保护管理员隐私 + 减少噪音
-  if (path.startsWith("/admin")) {
-    lastTrackedPath = path;
-    pageEnterAt = Date.now();
-    return;
-  }
-
-  // 上报上一页停留时长
-  if (lastTrackedPath && pageEnterAt > 0) {
+  // 先上报上一页停留时长(即使下一页是 /admin)
+  if (lastTrackedPath && pageEnterAt > 0 && !lastTrackedPath.startsWith("/admin")) {
     send(/* ... */);
   }
+
+  // 后台路径不再上报新 PV,保护管理员隐私
+  if (path.startsWith("/admin")) {
+    lastTrackedPath = path;
+    pageEnterAt = Date.now();
+    return;
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/lib/analytics.ts` around lines 77 - 84, The current trackPageview
function returns early when path startsWith("/admin"), which skips recording the
previous page's duration; update trackPageview to first compute/flush the
previous page's duration (using lastTrackedPath and pageEnterAt and the existing
"report previous page duration" logic) before setting lastTrackedPath = path and
pageEnterAt = Date.now(), then return to preserve admin privacy while still
reporting the prior page's dwell time.


// 上报上一页停留时长
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();
});
}
Comment on lines +119 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

visibilitychange 重复触发会污染 duration 指标

flush() 每次都用 Date.now() - pageEnterAt 计算时长,但 pageEnterAt 在 flush 后没有重置。用户在同一页面内多次切换标签页(hidden ↔ visible)会持续触发 visibilitychange → 每次发送一条 duration 单调递增的事件,最终关闭时 pagehide 再发一条。

服务端 AE 查询使用 AVG(double1)SUM(_sample_interval),重复点会显著拉高平均停留并放大 PV(每条 writeDataPoint 都计入一次 _sample_interval)。简单页面切几次标签就能让指标失真。

🛠️ 推荐修复(任选其一)

方案 A:flush 后重置起点,避免重复累计:

   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,
     );
+    pageEnterAt = 0; // 防止 visibilitychange 多次触发产生重复 duration
   };

方案 B:仅在 pagehide 上 flush,visibilitychange 仅用于"暂停计时"语义(更精确但实现更复杂)。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/lib/analytics.ts` around lines 119 - 145, The flush logic in
bindUnloadTracker (function flush) repeatedly sends duration computed from
Date.now() - pageEnterAt without resetting pageEnterAt, causing
duplicate/increasing duration events when visibilitychange fires multiple times;
fix by making flush idempotent: after a successful send(...) set pageEnterAt = 0
(or another sentinel) and optionally clear lastTrackedPath for admin-exclusion,
so subsequent visibilitychange calls won't resend, and keep pagehide listener as
the final fallback; ensure this change references bindUnloadTracker, flush,
pageEnterAt, lastTrackedPath, pagehide and visibilitychange.

46 changes: 46 additions & 0 deletions client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,52 @@ export async function fetchAnalytics(days = 7): Promise<AnalyticsData> {
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<AEAnalyticsData> {
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;
Expand Down
Loading
Loading