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(),