Add font package profiling
This commit is contained in:
+10
-8
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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__")],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user