2. 浏览器端基础信息采集:User-Agent、屏幕分辨率、时区、语言、浏览器插件列表
设备指纹的第一步,说白了就是「你是谁、你在哪、你用啥设备」。这些信息浏览器自己就暴露出来了,我们只需要去拿。但拿归拿,怎么拿得稳、拿得全,这里头有不少门道。
我个人习惯把这类信息分成两组:静态标识和环境特征。静态标识像 User-Agent,基本不变;环境特征像分辨率、时区,可能会随用户操作变化。两者组合起来,才能形成稳定的指纹基底。
核心原则:采集不是目的,稳定性和唯一性才是。宁可少采一个字段,也不要采一个每次都不一样的数据。
2.1 User-Agent:最老牌的指纹因子
User-Agent(简称 UA)是浏览器在 HTTP 请求头里自报家门的字符串。它包含操作系统、浏览器名称、版本号、渲染引擎等信息。我刚开始做指纹时,觉得 UA 太容易被伪造,差点把它扔了。后来发现——UA 的价值不在于它说了什么,而在于它没说什么。
举个例子:
// 采集 UA
const ua = navigator.userAgent;
console.log(ua);
// 输出示例:
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
这段字符串里,你能提取出:
- 操作系统:Windows 10
- 架构:x64
- 浏览器:Chrome 120
- 内核:WebKit/Blink
但注意,UA 是可以被修改的。Chrome 的 DevTools 里就能直接覆盖。那怎么办?我的做法是:把 UA 和其他字段交叉验证。比如 UA 说自己是 Chrome,但 navigator.plugins 里全是 Firefox 的插件——那就有问题了。
避坑指南:我曾经遇到一个用户,UA 显示是 iPhone,但屏幕分辨率是 1920×1080。iPhone 哪有这种分辨率?明显是伪造的。所以 UA 一定要和分辨率、设备像素比一起看。
2.2 屏幕分辨率与设备像素比
屏幕分辨率是个容易被低估的字段。你以为它只是宽高?其实它能反映很多信息。
// 采集屏幕信息
const screenInfo = {
width: screen.width, // 屏幕宽度
height: screen.height, // 屏幕高度
availWidth: screen.availWidth, // 可用宽度(排除任务栏)
availHeight: screen.availHeight, // 可用高度
colorDepth: screen.colorDepth, // 色彩深度
pixelRatio: window.devicePixelRatio // 设备像素比
};
console.log(screenInfo);
这里有个关键点:设备像素比(DPR)。它决定了物理像素和 CSS 像素的比例。Retina 屏的 DPR 是 2 或 3,普通屏是 1。我见过一个案例:两台同样 1920×1080 的显示器,一台 DPR 是 1,另一台是 1.25(缩放 125%)。指纹一下子就区分开了。
| 设备类型 | 典型分辨率 | DPR | 指纹区分度 |
|---|---|---|---|
| iPhone 14 Pro | 393×852 | 3 | 高 |
| MacBook Pro 16" | 1728×1117 | 2 | 高 |
| Windows 台式机 | 1920×1080 | 1 | 中 |
| Windows 台式机(缩放 125%) | 1536×864 | 1.25 | 高 |
注意:屏幕分辨率在用户切换显示器、调整缩放比例时会变化。如果你的指纹系统把分辨率作为唯一标识,那用户换个显示器就认不出来了。我建议把分辨率作为辅助因子,权重不要超过 10%。
2.3 时区:比你想的更稳定
时区信息来自 Intl.DateTimeFormat 的 resolvedOptions() 方法。它返回的是 IANA 时区名称,比如 Asia/Shanghai、America/New_York。
// 采集时区
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
console.log(timeZone);
// 输出: "Asia/Shanghai"
// 还可以采集时区偏移(分钟)
const offset = new Date().getTimezoneOffset();
console.log(offset);
// 输出: -480 (表示 UTC+8)
时区为什么重要?因为它和 IP 地理位置可以交叉验证。如果 IP 显示在北京,时区却是 America/Los_Angeles——嗯,要么是 VPN,要么是伪造。我在项目中遇到过不少这种情况,时区不一致往往是异常行为的早期信号。
但注意:时区偏移(getTimezoneOffset)不如 IANA 时区稳定。夏令时切换时,偏移会变,但 IANA 时区不变。比如 America/New_York 在冬夏令时偏移不同,但名称始终一样。所以我建议优先采集 IANA 时区。
2.4 语言:浏览器说了算
语言信息来自 navigator.language 和 navigator.languages。前者是首选语言,后者是用户设置的语言列表(按优先级排序)。
// 采集语言
const langInfo = {
primary: navigator.language, // 首选语言
all: navigator.languages, // 语言列表
systemLang: navigator.language || '' // 系统语言(部分浏览器支持)
};
console.log(langInfo);
// 输出示例:
// { primary: "zh-CN", all: ["zh-CN", "en-US", "zh"], systemLang: "zh-CN" }
语言列表的排列顺序其实很有信息量。比如一个用户的语言列表是 ["en-US", "zh-CN", "ja"],说明他可能是个常驻美国的华人,或者经常用日文网站。这种偏好特征,在用户画像里很有价值。
小技巧:我习惯把语言列表转成字符串后做哈希,作为指纹的一部分。因为语言列表的排列顺序相对稳定,而且不同用户的排列差异很大。
2.5 浏览器插件列表:逐渐消失的宝藏
插件列表曾经是设备指纹的黄金字段。以前通过 navigator.plugins 可以拿到所有已安装的插件名称、版本、描述。但 Chrome 57 之后,navigator.plugins 返回的内容大幅减少,Firefox 也在收紧。
// 采集插件列表
const plugins = [];
for (let i = 0; i < navigator.plugins.length; i++) {
const p = navigator.plugins[i];
plugins.push({
name: p.name,
filename: p.filename,
description: p.description,
version: p.version || ''
});
}
console.log(plugins);
// 现代浏览器输出可能只有: [ { name: "PDF Viewer", filename: "internal-pdf-viewer", ... } ]
为什么会这样?因为浏览器厂商出于隐私考虑,限制了插件信息的暴露。现在主流浏览器基本只暴露 PDF 查看器。但别急着放弃——插件列表虽然变少了,但「少」本身也是一种特征。如果某个用户的插件列表里出现了非默认插件,那这个指纹的区分度反而更高。
另外,我建议同时采集 navigator.mimeTypes,它和插件列表是关联的。有些插件会注册自己的 MIME 类型,通过这个可以侧面验证。
2.6 把这些信息串起来
好了,单个字段讲完了。怎么把它们组合成一个稳定的指纹?我画了一张图,你看完就明白了。
你看,整个流程是分层的。每一层只做一件事,层与层之间通过标准化接口传递数据。这样做的好处是:如果某个采集字段变了,你只需要改采集层,后面的逻辑不用动。
我的建议:把这五个字段的采集封装成一个函数,返回一个统一的对象。后续的指纹生成、存储、比对都基于这个对象。这样代码好维护,也方便扩展——比如以后要加 WebGL 指纹,加一个字段就行。
2.7 避坑总结
最后,我把这些年踩过的坑列一下,你写代码时注意避开:
- 不要依赖单个字段:UA 可以伪造,分辨率可以调,时区可以改。至少三个字段交叉验证,才能确认设备身份。
- 注意隐私模式:Safari 的隐私模式下,部分 API 会返回空值或默认值。采集前先做能力检测。
- 处理异常值:我遇到过
screen.width返回 0 的情况(某些浏览器插件导致)。代码里要加 try-catch 和默认值。 - 不要过度采集:只采你需要的字段。采集越多,隐私合规风险越大。GDPR 和《个人信息保护法》可不是闹着玩的。
- 版本兼容:
navigator.plugins在部分移动浏览器上不支持。用之前先判断typeof navigator.plugins !== 'undefined'。
嗯,基础信息采集就讲到这里。这些字段虽然简单,但组合起来威力不小。下一层我们要讲的是 Canvas 指纹和 WebGL 指纹——那才是真正拉开差距的地方。