碎碎念
前几天在 Twikoo 群里,有朋友问能不能做个不用填昵称和邮箱的评论框?我第一反应是:如果不填,怎么区分不同的用户呢?这让我开始思考一个有意思的问题:如何在保护隐私的同时,还能识别不同的访客?
于是我就写了这个小脚本,今天就跟大家分享一下我的思路和实现过程。
功能展示
配置
我首先考虑的是要让这个工具足够灵活,能适应不同的网站。所以定义了一个配置对象 DEFAULT_CONFIG:
js
const DEFAULT_CONFIG = {
storageKey: 'autoIdentity', # 存在 localStorage 里的键名
stableSalt: 'offline-stable-v1', # 加在指纹里的“盐”,增加唯一性
# 理论上来说autoApplyOnLoad和autoInjectAnonymous是二选一的
autoApplyOnLoad: false, # 页面加载后是否自动填充
autoInjectAnonymous: true, # 是否自动插入“匿名评论”按钮
selectors: { # 昵称和邮箱输入框的CSS选择器,你可以将其改为类选择器或者是id选择器
name: 'input[name="nick"]',
email: 'input[name="mail"]',
},
identityMode: 'fingerprint', # 身份模式 fingerprint或random
domainOverride: null, # 强制使用的邮箱域名,null则自动获取
anonymousParentSelector: 'div.tk-row.actions', # 按钮要插入到哪个父容器里
anonymousLabelText: '匿名评论', # 按钮上显示的文字
anonymousInsert: { # 按钮插入的位置
anchorSelector: 'div.tk-row.actions > .el-button:nth-of-type(1)', # 锚点元素
position: 'before', # 插入在锚点的 'before' 或 'after'
},
};
选择身份模式
核心问题来了:怎么生成一个“稳定”的身份?我设计了两种模式:
指纹模式(默认)
当 identityMode 是 fingerprint时,通过collectFingerprint() 函数收集浏览器特征:我将浏览器的 userAgent、语言、屏幕尺寸、时区、域名、Canvas 指纹等特征拼成一个字符串,再加上 stableSalt,用 fnv1a() 哈希函数生成一个唯一的数字。同一台设备、同一个浏览器,这个数字基本是稳定的。
js
# 默认指纹模式
const DEFAULT_CONFIG = {
# ...其他配置
identityMode: 'fingerprint', # ← 指纹模式为默认设置
# ...
};
function normalizeIdentityMode(mode) {
return mode === 'random' ? 'random' : 'fingerprint'; # ← 标准化函数,默认返回fingerprint
}
# ...
# collectFingerprint收集浏览器特征
function collectFingerprint() {
if (fingerprintCache) return fingerprintCache;
const nav = window.navigator || {};
const scr = window.screen || {};
const intl = Intl.DateTimeFormat().resolvedOptions?.() || {};
const uaData = nav.userAgentData;
# 收集userAgent
const ua = uaData?.brands
? uaData.brands.map(({ brand, version }) => `${brand}:${version}`).join(',')
: nav.userAgent;
const platform = uaData?.platform || nav.platform;
# 拼接各种特征
const parts = [
['ua', ua],
['lang', nav.language],
['platform', platform],
['screen', `${scr.width}x${scr.height}`],
['tz', new Date().getTimezoneOffset()],
['tzname', intl.timeZone],
['domain', window.location.hostname || 'offline'],
['salt', CONFIG.stableSalt], # ← 添加stableSalt
];
# 添加Canvas指纹
const canvasHash = canvasFingerprint();
if (canvasHash) parts.push(['canvas', canvasHash]);
fingerprintCache = parts.map(([k, v]) => `${k}:${v ?? ''}`).join('|');
return fingerprintCache;
}
随机模式
有时候用户可能想要完全随机的身份。这时候就用makeRandomSeed()函数生成一个随机数(或者回退到 Math.random()),这样每次都会生成全新的身份。
这一串数字既决定昵称,也参与邮箱前缀的生成,既稳又不需要服务器。
js
# 随机种子生成
function makeRandomSeed() {
try {
const arr = new Uint32Array(1);
window.crypto.getRandomValues(arr); # ← 使用加密安全的随机数
return arr[0];
} catch (err) {
# 如果crypto API不可用,回退到Math.random()
}
return Math.floor(Math.random() * 0xffffffff); # ← 回退方案
}
# 随机模式的身份生成逻辑
function makeIdentity() {
# ...其他代码
const hashSource = CONFIG.identityMode === 'random'
? `${makeRandomSeed()}|${Date.now()}|${CONFIG.stableSalt}` # ← 随机模式
: `${collectFingerprint()}|${window.location.hostname}`;
const hash = fnv1a(hashSource); # ← 生成哈希数字
# ...
}
使用Canvas指纹提高识别稳定性
为了提高指纹的稳定性,我加了一个canvasFingerprint()函数。原理很简单:在不同的设备、浏览器上,Canvas 渲染出来的图像会有微小差异。所以我在离屏canvas上画几块矩形、文字和混合模式,拿到 toDataURL 和局部像素,再哈希一次。如果浏览器不支持 Canvas 或者用户禁用了,这个函数会静默失败,不影响主要功能。
js
function canvasFingerprint() {
# 创建一个离屏 Canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
# 画一些图形和文字
ctx.fillRect(0, 0, 120, 50);
ctx.fillText('auto-id', 8, 10);
# ... 更多绘制操作
# 获取数据并进行哈希
const dataURL = canvas.toDataURL();
canvasHashCache = fnv1a(dataURL).toString(36);
return canvasHashCache;
}
昵称和邮箱的生成
有了指纹数字,接下来就是生成人类可读的身份信息。
昵称生成
我准备了两个词库:adjectives(形容词)和 animals(动物)。通过哈希值选出一个形容词 + 一个动物,再拼一个 100~999 的数字,比如 brisk-otter-428。
js
# 昵称生成
function buildName(hash) { # ← 接收哈希数字作为参数
const adj = pick(adjectives, hash); # ← 用哈希选择形容词
const noun = pick(animals, hash >>> 5); # ← 用哈希移位后选择动物名
const number = (hash % 900) + 100; # ← 用哈希生成数字部分
return `${adj}-${noun}-${number}`;
}
邮箱生成
邮箱要处理域名问题。我写了个getRootDomain()函数,能正确处理像xxx.co.uk这样的二级域名后缀,当然也支持 domainOverride 强行指定。邮箱本地部分用 slugifyName 清洗昵称,再附加哈希的后四位如:brisk.otter.428.1xk9@example.com。这样既可读,又能减少撞名。
js
# 获取根域名
function getRootDomain(hostname) {
if (!hostname) return 'example.com';
const parts = hostname.split('.').filter(Boolean);
if (parts.length <= 1) return hostname;
const tld = parts[parts.length - 1];
const sld = parts[parts.length - 2];
const commonSecond = ['co', 'com', 'net', 'org', 'gov', 'edu', 'ac'];
# 处理像xxx.co.uk这样的二级域名后缀
if (tld.length === 2 && commonSecond.includes(sld) && parts.length >= 3) {
return parts.slice(-3).join('.'); # ← 取后三段,如 xxx.co.uk
}
return parts.slice(-2).join('.'); # ← 正常取后两段
}
# 清洗昵称
function slugifyName(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '.') # ← 非字母数字替换为点
.replace(/\.+/g, '.') # ← 合并多个点
.replace(/^\.+|\.+$/g, ''); # ← 去掉首尾的点
}
# 邮箱前缀生成
function makeIdentity() {
# ...之前代码
const hash = fnv1a(hashSource);
const name = buildName(hash);
const domain = CONFIG.domainOverride || getRootDomain(window.location.hostname);
const shortHash = hash.toString(36).slice(-4); # ← 从哈希中提取短标识
const emailLocal = `${slugifyName(name)}.${shortHash}`; # ← 邮箱本地部分包含哈希
const email = `${emailLocal}@${domain}`;
# ...
}
身份持久化
生成好的身份会通过 safeWrite 存到 localStorage,下次直接复用,确保同一浏览器看到的是同一个“马甲”。
js
function safeWrite(identity) {
try {
if (isValidIdentity(identity)) {
window.localStorage.setItem(CONFIG.storageKey, JSON.stringify(identity));
}
} catch (err) {
# 隐私模式或存储已满时静默失败
}
}
使用DOM操作实现无侵入式注入
这一部分最难的可能就是是如何在不破坏原有页面的情况下,添加“匿名评论”按钮。
找到合适的位置
通过injectAnonymousEntry()函数,我可以在评论表单的操作区域插入按钮:
js
# 查找父容器和锚点元素
function injectAnonymousEntry(options = {}) {
const {
parentSelector = CONFIG.anonymousParentSelector,
# ...其他选项
anchorSelector = CONFIG.anonymousInsert.anchorSelector,
# ...
} = options;
# 查找父容器
const parent = typeof parentSelector === 'string'
? document.querySelector(parentSelector) # ← 通过选择器查找父元素
: parentSelector;
if (!parent) return null;
# 查找锚点元素
const anchor = resolveAnchorChild(parent, anchorSelector); # ← 在父容器内查找锚点
# ...
}
# 解析锚点子元素
function resolveAnchorChild(parent, anchorSelector) {
if (!parent) return null;
if (anchorSelector) {
const el = parent.querySelector(anchorSelector); # ← 通过选择器查找锚点
if (el) return el;
}
return null;
}
克隆现有按钮
为了保持样式一致,我选择克隆页面上已有的按钮,克隆的div的样式会和锚点元素保持一致:
js
# 选择模板节点
function pickTemplateNode(parent, anchor) {
# 优先使用锚点元素本身或内部的按钮/链接
if (anchor) {
if (anchor.tagName === 'BUTTON' || anchor.tagName === 'A') return anchor;
const inside = anchor.querySelector('button, a');
if (inside) return inside;
if (anchor.firstElementChild) return anchor.firstElementChild;
}
# 回退方案:在父容器中查找第一个按钮或链接
const firstButton = parent.querySelector('button, a');
if (firstButton) return firstButton;
# 最后使用父容器的第一个子元素
return parent.firstElementChild;
}
修改文字并绑定事件
js
# 查找文本叶节点
function findTextLeaf(root) {
const queue = [root];
while (queue.length) {
const el = queue.shift();
if (!el || el.nodeType !== 1) continue;
const hasChildren = el.children && el.children.length > 0;
const text = (el.textContent || '').trim();
if (text && !hasChildren) return el; # ← 找到有文本内容且无子元素的叶子节点
if (hasChildren) queue.push(...el.children);
}
return root;
}
const labelEl = findTextLeaf(clone) || clone;
labelEl.textContent = CONFIG.anonymousLabelText; # 改为“匿名评论”
clone.addEventListener('click', (event) => {
event.preventDefault();
const identity = makeIdentity(); # 生成身份
applyIdentity({ identity }); # 填充表单
});
智能插入DIV
为了避免侵入式改动,我做了几个防护:用 findTextLeaf 在克隆的按钮里找到叶子节点,安全地覆盖文案。
pickTemplateNode会尽量挑选按钮/链接作为模板,如果找不到就兜底用第一个子节点。
插入位置支持 before/after,没找到锚点时就插在父节点开头,确保页面不会破坏原有布局。
所有事件都绑定在克隆节点上,原按钮保持原状,样式也继承父元素已有的 CSS。
如何接入
最简单的用法就是在页面里引入脚本(如 index.html 已经做的那样):
html
<script src="./auto-identity.js"></script>
如果需要自定义配置,可以在它前面写配置:
html
<script>
window.AUTO_IDENTITY_CONFIG = {
identityMode: 'random', # 使用随机模式
autoApplyOnLoad: true, # 自动填充(适合纯匿名场景)
selectors: {
name: '#comment-author', # 根据你的页面调整选择器
email: '#comment-email',
},
domainOverride: 'myblog.dev', # 自定义邮箱域名
anonymousLabelText: '隐身评论', # 自定义按钮文字
};
</script>
<script src="./auto-identity.js"></script>
一些设计思考
为什么要在前端生成?
那么首当其冲的是,因为想要保护自己的隐私才会去使用这个随机昵称和邮箱,所以所有信息都在用户浏览器里处理,不发送到服务器;其次是不需要网络请求,瞬间完成,即使断网也能生成身份。
关于指纹的争议
我知道浏览器指纹涉及隐私问题,所以我:提供了随机模式作为替代,所有指纹数据都在本地处理,不上传,用户可以随时清除localStorage,只能说是最大程度的去保护浏览用户的隐私。
写在最后
这段小脚本是为了给 Twikoo 群的群友写的,可以让访客在想匿名留言时多了一个一键入口。实现上尽量保持无侵入、可配置、离线生成,希望能给同样折腾评论区的朋友一点参考。后续如果还有需求(比如生成头像 seed、或接入服务端白名单),再继续迭代吧。
代码下载