From 64d5daf921e10ddaa06055d4d28d5cd140905b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 16 Sep 2026 17:09:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20page=5Fview=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=20nav=5Ftype/prerendering=20=E6=9D=A5=E6=BA=90=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=EF=BC=88=E9=A2=84=E6=B8=B2=E6=9F=93=3DAI=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=8C=87=E7=BA=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- sdk/src/events.ts | 17 +++++++++++++- sdk/test/events.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/sdk/src/events.ts b/sdk/src/events.ts index f681472..d0c38f7 100644 --- a/sdk/src/events.ts +++ b/sdk/src/events.ts @@ -2,6 +2,20 @@ import type { Transport } from "./transport"; import { pageContext } from "./context"; import { uuid } from "./util"; +// 来源信号(Navigation Timing L2):startPageTracking 仅 init 时调用一次, +// 读一次 Navigation Entry 即冻结进 page_view——nav_type ∈ navigate/reload/ +// back_forward(浏览器不给则空串);activationStart>0 = 页面由预渲染激活 +// (AI 搜索/聚合器预加载的强指纹)。任何环境异常静默(spec §4.5 红线)。 +function navSignals(): { nav_type: string; prerendering: boolean } { + try { + const nav = performance.getEntriesByType("navigation")[0] as + { type?: string; activationStart?: number } | undefined; + return { nav_type: nav?.type || "", prerendering: (nav?.activationStart ?? 0) > 0 }; + } catch { + return { nav_type: "", prerendering: false }; + } +} + // 有效停留(spec §4.3):页面可见且 30s 内有交互活动才按秒累计 activeMs; // 隐藏(visibilitychange→hidden)或离开(pagehide)时发 page_stay 并 flush(true)。 // close 可能连续触发(hidden 后紧跟 pagehide):activeMs 清零后不再重发 stay。 @@ -43,9 +57,10 @@ export function startPageTracking(t: Transport) { }); }; const pc = pageContext(); + const nav = navSignals(); // init 时读一次冻结,不随时间变 emit( "page_view", - { referrer: pc.referrer, referrer_domain: pc.referrer_domain, utm: pc.utm }, + { referrer: pc.referrer, referrer_domain: pc.referrer_domain, utm: pc.utm, ...nav }, pc ); const close = () => { diff --git a/sdk/test/events.test.ts b/sdk/test/events.test.ts index 623e615..c687848 100644 --- a/sdk/test/events.test.ts +++ b/sdk/test/events.test.ts @@ -41,6 +41,19 @@ async function beaconBodies(siteId: string) { return out as { site_id: string; events: { event_name: string; path: string; properties: Record }[] }[]; } +/** 桩掉 performance.getEntriesByType(happy-dom 原生 navigation 恒 []): + * entry=null 模拟无 Navigation API;throwErr 模拟环境异常。afterEach 的 + * unstubAllGlobals 负责还原;now 透传 Date.now 以防环境内部偶发调用。 */ +function stubNavigation(entry?: { type: string; activationStart: number } | null, throwErr?: boolean) { + vi.stubGlobal("performance", { + now: () => Date.now(), + getEntriesByType: (type: string) => { + if (throwErr) throw new Error("no performance"); + return type === "navigation" && entry ? [entry] : []; + }, + }); +} + describe("events / startPageTracking", () => { beforeEach(() => { localStorage.clear(); @@ -56,12 +69,13 @@ describe("events / startPageTracking", () => { delete (document as { referrer?: string }).referrer; }); - it("page_view 含 path/title/referrer_domain/utm(path 同源清洗)", async () => { + it("page_view 含 path/title/referrer_domain/utm/nav_type/prerendering(path 同源清洗)", async () => { document.title = "落地页"; Object.defineProperty(document, "referrer", { value: "https://news.example/article/1", configurable: true, }); + stubNavigation({ type: "navigate", activationStart: 0 }); window.history.pushState({}, "", "/land?utm_source=ns&utm_medium=cpc&email=a@b.c"); const t = mkTransport("site-pv"); startPageTracking(t); @@ -78,9 +92,45 @@ describe("events / startPageTracking", () => { referrer: "https://news.example/article/1", referrer_domain: "news.example", utm: { utm_source: "ns", utm_medium: "cpc" }, + nav_type: "navigate", + prerendering: false, }); }); + it("来源信号:activationStart>0 → prerendering=true(AI 搜索/聚合器预渲染指纹)", async () => { + stubNavigation({ type: "navigate", activationStart: 100 }); + const t = mkTransport("site-nav-pp"); + startPageTracking(t); + t.flush(); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const pv = body.events.find((e: { event_name: string }) => e.event_name === "page_view"); + expect(pv.properties.nav_type).toBe("navigate"); + expect(pv.properties.prerendering).toBe(true); + }); + + it("来源信号降级:无 Navigation API(空数组)或读取抛异常 → 空串/false,page_view 照发不崩", async () => { + stubNavigation(null); // getEntriesByType 返回 [] + let t = mkTransport("site-nav-none"); + startPageTracking(t); + t.flush(); + let pv = JSON.parse(fetchMock.mock.calls[0][1].body).events.find( + (e: { event_name: string }) => e.event_name === "page_view" + ); + expect(pv.properties.nav_type).toBe(""); + expect(pv.properties.prerendering).toBe(false); + + stubNavigation(undefined, true); // getEntriesByType 抛异常 + t = mkTransport("site-nav-throw"); + startPageTracking(t); + t.flush(); + pv = JSON.parse(fetchMock.mock.calls[1][1].body).events.find( + (e: { event_name: string }) => e.event_name === "page_view" + ); + expect(pv.properties.nav_type).toBe(""); + expect(pv.properties.prerendering).toBe(false); + }); + it("有效停留:可见 + pointerdown 后 3s → close 发 page_stay(active_ms=3000)", async () => { window.history.pushState({}, "", "/p4"); const t = mkTransport("site-t4");