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:
+87
-3
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user