Add font package profiling
CI / Frontend test and build (push) Has been cancelled
CI / Rust test (push) Has been cancelled
CI / Docker build (push) Has been cancelled

This commit is contained in:
Test User
2026-07-04 16:21:10 +08:00
parent 5152086668
commit 1b12ad8451
5 changed files with 243 additions and 29 deletions
+10 -8
View File
@@ -58,7 +58,7 @@ const MESSAGES = {
},
items: {
"browser-apis": "语言、时区、屏幕、硬件档位、触控能力、浏览器厂商等基础 API",
"fingerprint-surface": "Canvas、WebGL、Audio、字体和插件数量等高熵指纹面",
"fingerprint-surface": "Canvas、WebGL、Audio、字体包、插件数量等高熵指纹面",
"storage-state": "Cookie、LocalStorage、IndexedDB、Do Not Track、GPC 等状态",
"server-request": "来源地址、代理头、Cloudflare 头、IP 数据库城市/ASN 命中",
"webrtc-available": "WebRTC/STUN 可能暴露候选地址,现代浏览器常用 mDNS 降低本地地址暴露",
@@ -127,7 +127,7 @@ const MESSAGES = {
other: "其他线索",
timezone: "系统时区",
language: "浏览器语言",
fonts: "区域字体",
fonts: "字体包画像",
intlLocale: "Intl 区域",
timezoneOffset: "时区偏移",
emoji: "系统风格",
@@ -346,8 +346,8 @@ const MESSAGES = {
detail: "检查 navigator.languages 的排序,用于判断语言偏好和区域设置是否一致。",
},
fonts: {
name: "区域字体",
detail: "用 canvas 宽度差异探测常见区域字体,字体组合会增加设备可识别度。",
name: "字体包画像",
detail: "用 Canvas 宽度差异探测常见系统、中文、开发、办公和设计字体包;界面只展示分组摘要。",
},
intlLocale: {
name: "Intl 区域设置",
@@ -487,6 +487,7 @@ const MESSAGES = {
ua: { label: "User-Agent 摘要", inference: "可推断浏览器、系统和部分设备信息。" },
screen: { label: "屏幕与缩放", inference: "可推断设备档位、外接屏和窗口状态。" },
hardware: { label: "硬件档位", inference: "CPU 线程和内存档位可辅助设备画像。" },
"font-packages": { label: "字体包画像", inference: "常见字体包会暴露系统、语言环境和工具链线索。" },
touch: { label: "触控能力", inference: "可推断是否为移动或触屏设备。" },
cookies: { label: "Cookie", inference: "Cookie 可用性会影响追踪和会话保持。" },
dnt: { label: "Do Not Track", inference: "可观察用户是否显式表达反追踪偏好。" },
@@ -598,7 +599,7 @@ const MESSAGES = {
},
items: {
"browser-apis": "Language, timezone, screen, hardware tier, touch capability, browser vendor, and similar APIs",
"fingerprint-surface": "High-entropy surfaces such as Canvas, WebGL, Audio, fonts, and plugin counts",
"fingerprint-surface": "High-entropy surfaces such as Canvas, WebGL, Audio, font packages, and plugin counts",
"storage-state": "Cookie, LocalStorage, IndexedDB, Do Not Track, and Global Privacy Control states",
"server-request": "Source address, proxy headers, Cloudflare headers, local IP database city/ASN matches",
"webrtc-available": "WebRTC/STUN can expose candidate addresses; modern browsers often use mDNS to reduce local address exposure",
@@ -667,7 +668,7 @@ const MESSAGES = {
other: "Other signals",
timezone: "System timezone",
language: "Browser languages",
fonts: "Regional fonts",
fonts: "Font packages",
intlLocale: "Intl locale",
timezoneOffset: "Timezone offset",
emoji: "System style",
@@ -886,8 +887,8 @@ const MESSAGES = {
detail: "Checks navigator.languages order to compare language preference with regional settings.",
},
fonts: {
name: "Regional fonts",
detail: "Uses Canvas width differences to probe regional font sets; font combinations add identifiability.",
name: "Font package profile",
detail: "Uses Canvas width differences to probe common system, CJK, developer, office, and design font families; the UI only shows grouped summaries.",
},
intlLocale: {
name: "Intl locale",
@@ -1027,6 +1028,7 @@ const MESSAGES = {
ua: { label: "User-Agent summary", inference: "Can infer browser, OS, and some device details." },
screen: { label: "Screen and scaling", inference: "Can infer device tier, external display, and viewport state." },
hardware: { label: "Hardware tier", inference: "CPU threads and memory tier help build a device profile." },
"font-packages": { label: "Font package profile", inference: "Installed font families can reveal OS, language environment, and toolchain clues." },
touch: { label: "Touch capability", inference: "Can infer mobile or touch-screen usage." },
cookies: { label: "Cookie support", inference: "Cookie availability affects tracking and session continuity." },
dnt: { label: "Do Not Track", inference: "Shows whether the user explicitly expresses an anti-tracking preference." },
+178 -21
View File
@@ -46,6 +46,121 @@ const TRADITIONAL_CHINESE_FONTS = [
"Noto Sans CJK TC",
];
const FONT_PACKAGE_GROUPS = [
{
id: "cjk",
label: "中文/CJK",
weight: 0.32,
fonts: [
...SIMPLIFIED_CHINESE_FONTS,
...TRADITIONAL_CHINESE_FONTS,
"Noto Sans CJK",
"Noto Serif CJK",
"Source Han Serif CN",
"Source Han Serif SC",
"Source Han Serif TC",
],
},
{
id: "developer",
label: "开发字体",
weight: 0.2,
fonts: [
"JetBrains Mono",
"Fira Code",
"Cascadia Code",
"Cascadia Mono",
"SF Mono",
"Menlo",
"Monaco",
"Consolas",
"Source Code Pro",
"Hack",
"IBM Plex Mono",
"Inconsolata",
],
},
{
id: "office",
label: "办公字体",
weight: 0.14,
fonts: [
"Aptos",
"Calibri",
"Cambria",
"Candara",
"Corbel",
"Arial",
"Times New Roman",
"Georgia",
"Trebuchet MS",
],
},
{
id: "design",
label: "设计字体",
weight: 0.18,
fonts: [
"Adobe Garamond Pro",
"Myriad Pro",
"Minion Pro",
"Helvetica Neue",
"Avenir",
"Futura",
"DIN Alternate",
"Gill Sans",
],
},
{
id: "linux",
label: "Linux/开源",
weight: 0.14,
fonts: [
"Noto Sans",
"Noto Serif",
"DejaVu Sans",
"DejaVu Serif",
"Liberation Sans",
"Liberation Serif",
"Ubuntu",
"Cantarell",
"Inter",
],
},
{
id: "apple",
label: "Apple系统",
weight: 0.14,
fonts: [
"SF Pro Display",
"SF Pro Text",
"New York",
"San Francisco",
"PingFang SC",
"PingFang TC",
"Hiragino Sans",
"Hiragino Sans GB",
"Songti SC",
],
},
{
id: "windows",
label: "Windows系统",
weight: 0.14,
fonts: [
"Segoe UI",
"Microsoft YaHei",
"Microsoft YaHei UI",
"Microsoft JhengHei",
"SimSun",
"SimHei",
"DengXian",
"Consolas",
"Cambria",
],
},
];
export const SIGNALS = [
{
id: "timezone",
@@ -67,8 +182,8 @@ export const SIGNALS = [
id: "fonts",
weight: 20,
icon: "type",
name: "区域字体",
detail: "用 canvas 宽度差异探测常见区域字体,字体组合会增加设备可识别度。",
name: "字体包画像",
detail: "用 canvas 宽度差异探测常见系统、中文、开发、办公和设计字体包。",
detect: () => scoreFontSignal(),
},
{
@@ -185,6 +300,63 @@ export function scoreEmojiSignal(userAgent, platform) {
return { raw: `${family} style`, score: scores[family] };
}
export function detectFontPackagesFromList(availableFonts = []) {
const normalized = new Map();
for (const font of availableFonts) {
const name = String(font || "").trim();
if (!name) continue;
normalized.set(name.toLowerCase(), name);
}
const matchedFontNames = new Set();
const groups = FONT_PACKAGE_GROUPS.map((group) => {
const matches = group.fonts
.filter((font) => normalized.has(font.toLowerCase()))
.map((font) => normalized.get(font.toLowerCase()));
for (const match of matches) matchedFontNames.add(match);
return {
id: group.id,
label: group.label,
count: matches.length,
sample: matches.slice(0, 3),
scoreWeight: group.weight,
};
}).filter((group) => group.count > 0);
const matchedFonts = [...matchedFontNames];
const detectedCount = matchedFonts.length;
const weightedScore = groups.reduce(
(sum, group) => sum + group.scoreWeight * Math.min(1, group.count / 3),
0,
);
const score =
groups.length === 0
? 0
: Math.min(1, 0.16 + weightedScore + Math.min(0.24, detectedCount * 0.035));
const groupSummary = groups.map((group) => `${group.label}=${group.count}`).join(" ");
const sample = matchedFonts.length
? ` sample=${matchedFonts.slice(0, 4).join(", ")}${matchedFonts.length > 4 ? "..." : ""}`
: "";
return {
groups,
detectedCount,
raw: groupSummary ? `${groupSummary}${sample}` : "none detected",
score,
};
}
export function detectFontPackageSignal() {
const context = globalThis.document?.createElement?.("canvas")?.getContext?.("2d");
if (!context) {
return { raw: "canvas unavailable", score: 0, groups: [], detectedCount: 0 };
}
const detectedFonts = fontCandidates().filter((font) => fontIsAvailable(font, context));
return detectFontPackagesFromList(detectedFonts);
}
export function buildSubmitPayload(result) {
return {
score: clampInteger(result.score, 0, 100),
@@ -255,26 +427,11 @@ function resolveLanguages() {
}
function scoreFontSignal() {
const context = document.createElement("canvas").getContext("2d");
if (!context) {
return { raw: "canvas unavailable", score: 0 };
}
return detectFontPackageSignal();
}
const simplified = SIMPLIFIED_CHINESE_FONTS.filter((font) => fontIsAvailable(font, context));
const traditional = TRADITIONAL_CHINESE_FONTS.filter((font) => fontIsAvailable(font, context));
const found = [...simplified, ...traditional];
let score = 0;
if (simplified.length > 0) {
score = Math.min(1, 0.75 + simplified.length * 0.08);
} else if (traditional.length > 0) {
score = 0.5;
}
return {
raw: found.length > 0 ? `${found.slice(0, 4).join(", ")}${found.length > 4 ? "..." : ""}` : "none detected",
score,
};
function fontCandidates() {
return [...new Set(FONT_PACKAGE_GROUPS.flatMap((group) => group.fonts))];
}
function fontIsAvailable(font, context) {
+16
View File
@@ -1,4 +1,5 @@
import { getMessages, translateFact } from "./i18n.mjs";
import { detectFontPackageSignal } from "./scoring.mjs";
const SENSITIVITY_ORDER = ["low", "medium", "high", "critical"];
const SENSITIVITY_LABELS = {
@@ -39,6 +40,7 @@ export async function collectPassiveFacts() {
fact("ua", "device", "User-Agent 摘要", summarizeUserAgent(nav.userAgent), "可推断浏览器、系统和部分设备信息。", "high"),
fact("screen", "device", "屏幕与缩放", screenValue(screen, win), "可推断设备档位、外接屏和窗口状态。", "medium"),
fact("hardware", "device", "硬件档位", hardwareValue(nav), "CPU 线程和内存档位可辅助设备画像。", "medium"),
fontPackageFact(),
fact("touch", "device", "触控能力", `${nav.maxTouchPoints || 0} 点触控`, "可推断是否为移动或触屏设备。", "low"),
fact("cookies", "privacy", "Cookie", nav.cookieEnabled ? "可用" : "不可用", "Cookie 可用性会影响追踪和会话保持。", "medium"),
fact("dnt", "privacy", "Do Not Track", nav.doNotTrack || "未声明", "可观察用户是否显式表达反追踪偏好。", "low"),
@@ -374,6 +376,20 @@ function hardwareValue(nav) {
return `${cores} 线程, ${memory}`;
}
function fontPackageFact() {
const detected = detectFontPackageSignal();
const sensitivity = detected.score >= 0.55 ? "high" : detected.score > 0 ? "medium" : "low";
return fact(
"font-packages",
"device",
"字体包画像",
detected.raw,
"常见字体包会暴露系统、语言环境和工具链线索。",
sensitivity,
);
}
function storageValue() {
const checks = [
["localStorage", () => storageWorks(globalThis.localStorage, "__fp_local__")],
+16
View File
@@ -91,6 +91,22 @@ describe("ui language", () => {
assert.equal(fact.value, "Ask every time");
assert.equal(fact.inference, "This only checks the passive permission state and does not open a permission prompt.");
});
it("translates font-package facts for the staged profile report", () => {
const fact = translateFact(
{
id: "font-packages",
label: "字体包画像",
value: "中文/CJK=2 开发字体=1",
inference: "常见字体包会暴露系统、语言环境和工具链线索。",
sensitivity: "high",
},
getMessages("en"),
);
assert.equal(fact.label, "Font package profile");
assert.equal(fact.inference, "Installed font families can reveal OS, language environment, and toolchain clues.");
});
});
describe("analytics normalization", () => {
+23
View File
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
import {
bandForScore,
buildSubmitPayload,
detectFontPackagesFromList,
scoreLanguageSignal,
scoreTimezoneSignal,
} from "../src/scoring.mjs";
@@ -32,6 +33,28 @@ describe("scoring", () => {
assert.equal(scoreLanguageSignal(["en-US"]).score, 0);
});
it("summarizes installed font packages by category", () => {
const summary = detectFontPackagesFromList([
"Microsoft YaHei",
"PingFang SC",
"JetBrains Mono",
"Fira Code",
"Aptos",
"Adobe Garamond Pro",
"Noto Sans",
]);
assert.equal(summary.groups.find((group) => group.id === "cjk")?.count, 2);
assert.equal(summary.groups.find((group) => group.id === "developer")?.count, 2);
assert.equal(summary.groups.find((group) => group.id === "office")?.count, 1);
assert.equal(summary.groups.find((group) => group.id === "design")?.count, 1);
assert.equal(summary.groups.find((group) => group.id === "linux")?.count, 1);
assert.ok(summary.score > 0.55);
assert.match(summary.raw, /中文\/CJK=2/);
assert.match(summary.raw, /开发字体=2/);
assert.match(summary.raw, /sample=/);
});
it("builds a compact anonymous payload", () => {
const payload = buildSubmitPayload({
score: 62,