feat(sdk): page_view 增加 nav_type/prerendering 来源信号(预渲染=AI搜索指纹)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+16
-1
@@ -2,6 +2,20 @@ import type { Transport } from "./transport";
|
|||||||
import { pageContext } from "./context";
|
import { pageContext } from "./context";
|
||||||
import { uuid } from "./util";
|
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;
|
// 有效停留(spec §4.3):页面可见且 30s 内有交互活动才按秒累计 activeMs;
|
||||||
// 隐藏(visibilitychange→hidden)或离开(pagehide)时发 page_stay 并 flush(true)。
|
// 隐藏(visibilitychange→hidden)或离开(pagehide)时发 page_stay 并 flush(true)。
|
||||||
// close 可能连续触发(hidden 后紧跟 pagehide):activeMs 清零后不再重发 stay。
|
// close 可能连续触发(hidden 后紧跟 pagehide):activeMs 清零后不再重发 stay。
|
||||||
@@ -43,9 +57,10 @@ export function startPageTracking(t: Transport) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
const pc = pageContext();
|
const pc = pageContext();
|
||||||
|
const nav = navSignals(); // init 时读一次冻结,不随时间变
|
||||||
emit(
|
emit(
|
||||||
"page_view",
|
"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
|
pc
|
||||||
);
|
);
|
||||||
const close = () => {
|
const close = () => {
|
||||||
|
|||||||
+51
-1
@@ -41,6 +41,19 @@ async function beaconBodies(siteId: string) {
|
|||||||
return out as { site_id: string; events: { event_name: string; path: string; properties: Record<string, unknown> }[] }[];
|
return out as { site_id: string; events: { event_name: string; path: string; properties: Record<string, unknown> }[] }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 桩掉 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", () => {
|
describe("events / startPageTracking", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -56,12 +69,13 @@ describe("events / startPageTracking", () => {
|
|||||||
delete (document as { referrer?: string }).referrer;
|
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 = "落地页";
|
document.title = "落地页";
|
||||||
Object.defineProperty(document, "referrer", {
|
Object.defineProperty(document, "referrer", {
|
||||||
value: "https://news.example/article/1",
|
value: "https://news.example/article/1",
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
stubNavigation({ type: "navigate", activationStart: 0 });
|
||||||
window.history.pushState({}, "", "/land?utm_source=ns&utm_medium=cpc&email=a@b.c");
|
window.history.pushState({}, "", "/land?utm_source=ns&utm_medium=cpc&email=a@b.c");
|
||||||
const t = mkTransport("site-pv");
|
const t = mkTransport("site-pv");
|
||||||
startPageTracking(t);
|
startPageTracking(t);
|
||||||
@@ -78,9 +92,45 @@ describe("events / startPageTracking", () => {
|
|||||||
referrer: "https://news.example/article/1",
|
referrer: "https://news.example/article/1",
|
||||||
referrer_domain: "news.example",
|
referrer_domain: "news.example",
|
||||||
utm: { utm_source: "ns", utm_medium: "cpc" },
|
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 () => {
|
it("有效停留:可见 + pointerdown 后 3s → close 发 page_stay(active_ms=3000)", async () => {
|
||||||
window.history.pushState({}, "", "/p4");
|
window.history.pushState({}, "", "/p4");
|
||||||
const t = mkTransport("site-t4");
|
const t = mkTransport("site-t4");
|
||||||
|
|||||||
Reference in New Issue
Block a user