feat(sdk): 身份与环境上下文模块
- identity.ts: 访客/会话 ID(30min 空闲轮换),localStorage 异常统一内存降级不冒泡(spec §4.5 红线) - context.ts: buildEnvContext/parseUtm/sanitizeUrl/pageContext;参数黑名单与 server URL_PARAM_DROP_PATTERNS 同源(整键精确匹配、大小写不敏感、保留空值) - 测试 21 过(含隐私模式降级、Edg 先于 Chrome、微信 UA 判定、iPad-Mobile 规格顺序锁定) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// 与 server/tracker/constants.py 的 URL_PARAM_DROP_PATTERNS 同源:
|
||||
// ("email", "phone", "mobile", "token", "key", "password", "verif"),
|
||||
// 整键精确匹配(大小写不敏感,与服务端 re.I + fullmatch 一致)——SDK 先删一遍,
|
||||
// 服务端 sanitize_url 再删一遍(双重清洗),子串参数(如 monkey 含 key)不误伤。
|
||||
const URL_PARAM_DROP = new Set([
|
||||
"email",
|
||||
"phone",
|
||||
"mobile",
|
||||
"token",
|
||||
"key",
|
||||
"password",
|
||||
"verif",
|
||||
]);
|
||||
|
||||
export interface EnvContext {
|
||||
device_type: string;
|
||||
os: string;
|
||||
browser: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
screen: string;
|
||||
hardware_concurrency?: number;
|
||||
connection?: string;
|
||||
}
|
||||
|
||||
export interface PageContext {
|
||||
path: string;
|
||||
title: string;
|
||||
referrer: string;
|
||||
referrer_domain: string;
|
||||
utm: Record<string, string>;
|
||||
}
|
||||
|
||||
export function sanitizeUrl(pathAndQuery: string): string {
|
||||
const i = pathAndQuery.indexOf("?");
|
||||
if (i === -1) return pathAndQuery;
|
||||
const path = pathAndQuery.slice(0, i);
|
||||
const kept: string[] = [];
|
||||
new URLSearchParams(pathAndQuery.slice(i + 1)).forEach((v, k) => {
|
||||
if (!URL_PARAM_DROP.has(k.toLowerCase())) {
|
||||
kept.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
|
||||
}
|
||||
});
|
||||
return kept.length ? `${path}?${kept.join("&")}` : path;
|
||||
}
|
||||
|
||||
export function parseUtm(search: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
new URLSearchParams(search).forEach((v, k) => {
|
||||
if (k.startsWith("utm_")) out[k.slice(0, 100)] = v.slice(0, 100);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectDeviceType(ua: string): string {
|
||||
if (/Mobile|Android|iPhone/.test(ua)) return "手机";
|
||||
if (/iPad|Tablet/.test(ua)) return "平板";
|
||||
return "PC";
|
||||
}
|
||||
|
||||
function detectOs(ua: string): string {
|
||||
if (/Windows/.test(ua)) return "Windows";
|
||||
if (/Android/.test(ua)) return "Android";
|
||||
if (/iPhone|iPad|iPod/.test(ua)) return "iOS";
|
||||
if (/Macintosh|Mac OS X/.test(ua)) return "Mac";
|
||||
if (/Linux/.test(ua)) return "Linux";
|
||||
return "";
|
||||
}
|
||||
|
||||
function detectBrowser(ua: string): string {
|
||||
// MicroMessenger 必须最先测:微信 WebView 的 UA 内嵌 Chrome/Safari 引擎串,
|
||||
// 与 "Edg 必须先于 Chrome" 同理(后测永远轮不到)。
|
||||
if (/MicroMessenger/.test(ua)) return "WeChat";
|
||||
if (/Edg(e|A|iOS)?\//.test(ua)) return "Edge";
|
||||
if (/Chrome\/|CriOS\//.test(ua)) return "Chrome";
|
||||
if (/Safari\//.test(ua)) return "Safari";
|
||||
if (/Firefox\/|FxiOS\//.test(ua)) return "Firefox";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function buildEnvContext(): EnvContext {
|
||||
const ua = navigator.userAgent;
|
||||
let timezone = "";
|
||||
try {
|
||||
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
|
||||
} catch {
|
||||
timezone = ""; // 老浏览器/禁用 Intl 的环境降级
|
||||
}
|
||||
const ctx: EnvContext = {
|
||||
device_type: detectDeviceType(ua),
|
||||
os: detectOs(ua),
|
||||
browser: detectBrowser(ua),
|
||||
language: navigator.language,
|
||||
timezone,
|
||||
screen: `${screen.width}x${screen.height}`,
|
||||
};
|
||||
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);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function pageContext(): PageContext {
|
||||
let referrer_domain = "";
|
||||
if (document.referrer) {
|
||||
try {
|
||||
referrer_domain = new URL(document.referrer).hostname;
|
||||
} catch {
|
||||
referrer_domain = ""; // urlsplit 语义:解析不了的 referrer 归空
|
||||
}
|
||||
}
|
||||
return {
|
||||
path: sanitizeUrl(location.pathname + location.search),
|
||||
title: document.title,
|
||||
referrer: document.referrer,
|
||||
referrer_domain,
|
||||
utm: parseUtm(location.search),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { uuid } from "./util";
|
||||
|
||||
const K = { vid: "ctk_vid", sid: "ctk_sid", act: "ctk_act" };
|
||||
const IDLE = 30 * 60_000;
|
||||
|
||||
// localStorage 可能抛异常(隐私模式/禁存储,Safari ITP):统一走带内存降级的 store,
|
||||
// 任何路径都不得让异常冒泡到宿主页(spec §4.5 红线)
|
||||
const mem: Record<string, string> = {};
|
||||
const store = {
|
||||
get(k: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(k);
|
||||
} catch {
|
||||
return mem[k] ?? null;
|
||||
}
|
||||
},
|
||||
set(k: string, v: string) {
|
||||
try {
|
||||
localStorage.setItem(k, v);
|
||||
} catch {
|
||||
mem[k] = v;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function getVisitorId(): string {
|
||||
let v = store.get(K.vid);
|
||||
if (!v) {
|
||||
v = uuid();
|
||||
store.set(K.vid, v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function touchActivity() {
|
||||
store.set(K.act, String(Date.now()));
|
||||
}
|
||||
|
||||
export function getSessionId(): string {
|
||||
const last = +(store.get(K.act) || 0);
|
||||
let s = store.get(K.sid);
|
||||
if (!s || Date.now() - last > IDLE) {
|
||||
s = uuid();
|
||||
store.set(K.sid, s);
|
||||
}
|
||||
touchActivity();
|
||||
return s;
|
||||
}
|
||||
Reference in New Issue
Block a user