feat: 移动端设备信息最大化采集(型号/UA-CH/视口/方向/触点/内存/网络/深浅色)

SDK:buildEnvContext 同步扩展 12 键(dpr/viewport/orientation/maxTouchPoints/
deviceMemory/languages/colorScheme/standalone/network downlink·rtt·saveData/
微信版本),逐键 try/catch 静默缺则不挂;新增 collectHighEntropyHints(UA-CH
model/platformVersion/architecture/bitness),init 后 Promise.race 2s 超时
异步并入 __ctk_ctx——批次 context flush 时读取,不阻塞首屏。

后端:Visitor 加 device_model/env_extra(迁移 0005);ingest 首批快照
EXTENDED_ENV_KEYS 白名单(值截 100),非首批不覆盖、唯 device_model 空且
本批有值时补填一次(晚到 hints);列表/详情 API 随行输出。

前端:详情访问环境卡设备行升级显示型号(手机 · Pixel 7),追加像素比/
视口/屏幕方向/触点/内存/网络质量/深浅色/微信版本行(env_extra 逐键判空,
旧数据整块不显示);列表设备列主行拼型号(手机·Pixel 7)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-16 19:12:20 +08:00
parent 7f2a746f66
commit 22d89a3f42
14 changed files with 484 additions and 14 deletions
+87 -3
View File
@@ -21,6 +21,19 @@ export interface EnvContext {
screen: string;
hardware_concurrency?: number;
connection?: string;
// ---- 移动端设备信息最大化采集(可选键:能力缺失/取不到时不挂) ----
device_pixel_ratio?: number;
viewport?: string;
orientation?: string;
max_touch_points?: number;
device_memory?: number;
languages?: string;
color_scheme?: string;
standalone?: string;
network_downlink?: number;
network_rtt?: number;
save_data?: string;
wechat_version?: string;
}
export interface PageContext {
@@ -81,6 +94,20 @@ function detectBrowser(ua: string): string {
return "";
}
/** 单表达式安全取值:任何异常(老浏览器禁 API/沙箱 iframe 等)→ undefined,缺则不挂键 */
function safe<T>(fn: () => T): T | undefined {
try {
return fn();
} catch {
return undefined;
}
}
/** matchMedia 查询(matchMedia 不可用的环境返回 false,调用方以 hasMatchMedia 区分) */
function mm(q: string): boolean {
return safe(() => window.matchMedia(q).matches) === true;
}
export function buildEnvContext(): EnvContext {
const ua = navigator.userAgent;
let timezone = "";
@@ -99,12 +126,69 @@ export function buildEnvContext(): EnvContext {
};
const hw = navigator.hardwareConcurrency;
if (typeof hw === "number") ctx.hardware_concurrency = hw;
const conn = (navigator as { connection?: { effectiveType?: string } }).connection
?.effectiveType;
if (conn) ctx.connection = String(conn);
const conn = (navigator as {
connection?: { effectiveType?: string; downlink?: number; rtt?: number; saveData?: boolean };
}).connection;
if (conn?.effectiveType) ctx.connection = String(conn.effectiveType);
// ---- 移动端设备信息最大化采集:逐键 try/catch 静默(safe),缺则不挂 ----
const dpr = safe(() => window.devicePixelRatio);
if (typeof dpr === "number") ctx.device_pixel_ratio = dpr;
const viewport = safe(() => `${window.innerWidth}x${window.innerHeight}`);
if (viewport) ctx.viewport = viewport;
const orient = safe(
() => (screen as Screen & { orientation?: { type?: string } }).orientation?.type,
);
if (orient) ctx.orientation = String(orient).slice(0, 32);
const hasMM = safe(() => typeof window.matchMedia === "function") === true;
if (!ctx.orientation && hasMM)
ctx.orientation = mm("(orientation: portrait)") ? "portrait" : "landscape";
const mtp = safe(() => navigator.maxTouchPoints);
if (typeof mtp === "number") ctx.max_touch_points = mtp;
const mem = safe(() => (navigator as Navigator & { deviceMemory?: number }).deviceMemory);
if (typeof mem === "number") ctx.device_memory = mem;
const langs = safe(
() => (navigator as Navigator & { languages?: readonly string[] }).languages,
);
if (langs && langs.length) ctx.languages = langs.join(",").slice(0, 100);
if (hasMM) ctx.color_scheme = mm("(prefers-color-scheme: dark)") ? "dark" : "light";
if (hasMM) ctx.standalone = mm("(display-mode: standalone)") ? "1" : "0";
if (conn) {
if (typeof conn.downlink === "number") ctx.network_downlink = conn.downlink;
if (typeof conn.rtt === "number") ctx.network_rtt = conn.rtt;
if (typeof conn.saveData === "boolean") ctx.save_data = conn.saveData ? "1" : "0";
}
const wechat = safe(() => ua.match(/MicroMessenger\/([\d.]+)/));
if (wechat) ctx.wechat_version = wechat[1].slice(0, 32);
return ctx;
}
/** UA-CH 高熵提示(设备型号等的来源):仅 Chromium 系支持,缺失/拒绝/超时由调用方降级 */
export async function collectHighEntropyHints(): Promise<Record<string, string>> {
try {
const uad = (navigator as {
userAgentData?: {
getHighEntropyValues?(hints: string[]): Promise<Record<string, unknown>>;
};
}).userAgentData;
if (!uad?.getHighEntropyValues) return {};
const v = await uad.getHighEntropyValues([
"model",
"platformVersion",
"architecture",
"bitness",
]);
const out: Record<string, string> = {};
if (v.model) out.device_model = String(v.model).slice(0, 64);
if (v.platformVersion) out.platform_version = String(v.platformVersion).slice(0, 32);
if (v.architecture) out.architecture = String(v.architecture).slice(0, 16);
if (v.bitness) out.bitness = String(v.bitness).slice(0, 8);
return out;
} catch {
return {};
}
}
export function pageContext(): PageContext {
let referrer_domain = "";
if (document.referrer) {
+14 -1
View File
@@ -2,7 +2,7 @@ import { uuid } from "./util";
import { mergeConfig } from "./config";
import { Transport } from "./transport";
import { startPageTracking } from "./events";
import { buildEnvContext, pageContext } from "./context";
import { buildEnvContext, collectHighEntropyHints, pageContext } from "./context";
import type { CtkConfig } from "./config";
// IIFE 打包入口(build.mjs):模块加载即接管全局 ctk 命令队列并重放。
@@ -29,6 +29,19 @@ function dispatch(cmd: string, arg?: unknown) {
utm: pc.utm,
path: pc.path,
};
// UA-CH 高熵提示异步并入(2s 超时降级,不阻塞 init):批次 context 是 flush 时读取的,
// 首个 15s flush 窗口内到达即可并入首批;hints 到达前发生的 flush 正常发送(降级可接受)。
Promise.race([
collectHighEntropyHints(),
new Promise<Record<string, string>>((resolve) => setTimeout(() => resolve({}), 2000)),
])
.then((hints) => {
if (Object.keys(hints).length && g.__ctk_ctx)
Object.assign(g.__ctk_ctx as Record<string, unknown>, hints);
})
.catch(() => {
/* 静默:绝不冒泡到宿主页(spec §4.5) */
});
transport = new Transport(cfg);
transport.push({
event_id: uuid(),
+148 -2
View File
@@ -1,5 +1,11 @@
import { describe, it, expect, afterEach } from "vitest";
import { buildEnvContext, parseUtm, sanitizeUrl, pageContext } from "../src/context";
import { describe, it, expect, afterEach, vi } from "vitest";
import {
buildEnvContext,
collectHighEntropyHints,
parseUtm,
sanitizeUrl,
pageContext,
} from "../src/context";
// userAgent 是 Navigator.prototype 上的 accessor;实例上 defineProperty 覆盖,delete 还原
function stubUA(ua: string) {
@@ -149,6 +155,146 @@ describe("buildEnvContext", () => {
});
});
describe("buildEnvContext 扩展采集(移动端设备信息最大化)", () => {
const restore: Array<() => void> = [];
/** 实例属性覆盖 + 注册还原(navigator/screen/window 各 API 均 accessor,defineProperty 覆盖) */
const stub = (obj: object, key: string, value: unknown) => {
Object.defineProperty(obj, key, { value, configurable: true });
restore.push(() => delete (obj as Record<string, unknown>)[key]);
};
/** 按查询串精确命中的 matchMedia 桩(orientation/color_scheme/standalone 共用) */
const stubMatchMedia = (matched: string[] = []) => {
vi.stubGlobal(
"matchMedia",
(q: string) => ({ matches: matched.includes(q), media: q }),
);
};
afterEach(() => {
restore.splice(0).forEach((fn) => fn());
vi.unstubAllGlobals();
});
it("同步扩展键全量采集(dpr/视口/方向/触点/内存/语言/深浅色/standalone/网络/微信版本)", () => {
stubUA(UA_ANDROID_WECHAT);
stub(window, "devicePixelRatio", 3);
stub(window, "innerWidth", 390);
stub(window, "innerHeight", 844);
stub(screen, "orientation", { type: "portrait-primary" });
stub(navigator, "maxTouchPoints", 5);
stub(navigator, "deviceMemory", 8);
stub(navigator, "languages", ["zh-CN", "en", "ja"]);
stub(navigator, "connection", {
effectiveType: "4g",
downlink: 10,
rtt: 50,
saveData: true,
});
stubMatchMedia(["(prefers-color-scheme: dark)"]); // orientation 走 screen.orientation,深色走桩
const env = buildEnvContext();
expect(env.device_pixel_ratio).toBe(3);
expect(env.viewport).toBe("390x844");
expect(env.orientation).toBe("portrait-primary"); // screen.orientation.type 优先
expect(env.max_touch_points).toBe(5);
expect(env.device_memory).toBe(8);
expect(env.languages).toBe("zh-CN,en,ja");
expect(env.color_scheme).toBe("dark");
expect(env.standalone).toBe("0"); // 桩未命中 standalone 查询
expect(env.network_downlink).toBe(10);
expect(env.network_rtt).toBe(50);
expect(env.save_data).toBe("1");
expect(env.wechat_version).toBe("8.0.49"); // UA 的 MicroMessenger/8.0.49
});
it("screen.orientation 缺失 → matchMedia 兜底 portrait/landscape", () => {
stubMatchMedia(["(orientation: portrait)"]);
expect(buildEnvContext().orientation).toBe("portrait");
stubMatchMedia([]); // portrait 不匹配 → landscape
expect(buildEnvContext().orientation).toBe("landscape");
});
it("languages 超 100 截断;saveData 布尔转 1/0", () => {
stub(navigator, "languages", ["a".repeat(60), "b".repeat(60)]);
stub(navigator, "connection", { effectiveType: "3g", saveData: false });
const env = buildEnvContext();
expect(env.languages).toHaveLength(100);
expect(env.save_data).toBe("0");
});
it("能力缺失环境:可选键不挂、不崩溃(无 matchMedia/触点/内存/连接)", () => {
stubUA(UA_WIN_CHROME); // 无 MicroMessenger → 无 wechat_version
stub(window, "matchMedia", undefined); // matchMedia 不可用
stub(navigator, "maxTouchPoints", undefined);
stub(navigator, "languages", undefined);
const env = buildEnvContext();
expect("orientation" in env).toBe(false); // screen.orientation 与 matchMedia 双缺
expect("color_scheme" in env).toBe(false);
expect("standalone" in env).toBe(false);
expect("max_touch_points" in env).toBe(false);
expect("languages" in env).toBe(false);
expect("device_memory" in env).toBe(false);
expect("network_downlink" in env).toBe(false);
expect("save_data" in env).toBe(false);
expect("wechat_version" in env).toBe(false);
// 基础键照常(老环境只是缺扩展信号,主采集不受影响)
expect(env.device_type).toBe("PC");
expect(env.os).toBe("Windows");
});
});
describe("collectHighEntropyHints(UA-CH 高熵提示)", () => {
const restore: Array<() => void> = [];
const stubUAD = (impl: (hints: string[]) => Promise<Record<string, unknown>>) => {
Object.defineProperty(navigator, "userAgentData", {
value: { getHighEntropyValues: impl },
configurable: true,
});
restore.push(() => delete (navigator as { userAgentData?: unknown }).userAgentData);
};
afterEach(() => restore.splice(0).forEach((fn) => fn()));
it("getHighEntropyValues → device_model/platform_version/architecture/bitness", async () => {
stubUAD(async () => ({
model: "Pixel 7",
platformVersion: "13.0.0",
architecture: "arm",
bitness: "64",
fullVersionList: [], // 未请求的键忽略
}));
expect(await collectHighEntropyHints()).toEqual({
device_model: "Pixel 7",
platform_version: "13.0.0",
architecture: "arm",
bitness: "64",
});
});
it("型号超长截断(64/32/16/8 各档)", async () => {
stubUAD(async () => ({
model: "M".repeat(80),
platformVersion: "P".repeat(40),
architecture: "A".repeat(20),
bitness: "B".repeat(10),
}));
const hints = await collectHighEntropyHints();
expect(hints.device_model).toHaveLength(64);
expect(hints.platform_version).toHaveLength(32);
expect(hints.architecture).toHaveLength(16);
expect(hints.bitness).toHaveLength(8);
});
it("空值键不挂(model 空串等)", async () => {
stubUAD(async () => ({ model: "", platformVersion: undefined, architecture: null }));
expect(await collectHighEntropyHints()).toEqual({});
});
it("无 userAgentData / getHighEntropyValues 抛异常 → 空对象不崩", async () => {
expect(await collectHighEntropyHints()).toEqual({}); // happy-dom 默认无 userAgentData
stubUAD(() => Promise.reject(new Error("denied")));
expect(await collectHighEntropyHints()).toEqual({});
});
});
describe("pageContext", () => {
it("path 清洗 + utm 提取 + title 读取", async () => {
await (window as any).happyDOM.setURL("http://localhost/p?token=1&utm_source=g");
+34
View File
@@ -141,6 +141,40 @@ describe("index 入口", () => {
]);
});
it("UA-CH hints 异步并入 __ctk_ctx:晚于 init 到达,flush 时随批次 context 发出", async () => {
// 1s 后才 resolve 的高熵桩(fake timers 控制):保证 init 同步完成时 hints 确实未到
(navigator as { userAgentData?: unknown }).userAgentData = {
getHighEntropyValues: () =>
new Promise((resolve) => setTimeout(() => resolve({ model: "Pixel 7", bitness: "64" }), 1000)),
};
await loadSdk([["init", { siteId: "site-hints", endpoint: "/e", flushMs: 60000 }]]);
expect(g.__ctk_ctx!.device_model).toBeUndefined(); // hints 未到(1s < 已流逝 0ms)
await vi.advanceTimersByTimeAsync(1000); // hints 到达(先于 2s 超时)
expect(g.__ctk_ctx!.device_model).toBe("Pixel 7");
expect(g.__ctk_ctx!.platform_version).toBeUndefined(); // 未提供的键不挂
g.ctk!("flush");
const bodies = bodiesOf("site-hints");
expect(bodies).toHaveLength(1);
expect(bodies[0].context.device_model).toBe("Pixel 7"); // 并入后 flush 的批次携带
});
it("无 userAgentData 环境:hints 降级为空,__ctk_ctx 不挂键不崩(未捕获拒绝为零)", async () => {
const unhandled: unknown[] = [];
const onRej = (e: unknown) => unhandled.push(e);
const proc = (globalThis as { process?: { on(ev: "unhandledRejection", cb: (e: unknown) => void): void; off(ev: "unhandledRejection", cb: (e: unknown) => void): void } }).process;
proc?.on("unhandledRejection", onRej);
await loadSdk([["init", { siteId: "site-nohints", endpoint: "/e", flushMs: 60000 }]]);
await vi.advanceTimersByTimeAsync(2500); // 超过 2s 降级窗口
proc?.off("unhandledRejection", onRej);
expect("device_model" in (g.__ctk_ctx ?? {})).toBe(false);
expect(unhandled).toEqual([]);
g.ctk!("flush"); // 降级批次照常发送
expect(bodiesOf("site-nohints")).toHaveLength(1);
});
it("init 前队列重放:排队的 track 事件入队,g.ctk 已替换为 dispatch", async () => {
const trackEv = {
event_id: "e-custom",