Creating a Useless Plugin for My Blog

相关代码已发布在 Github
https://github.com/icecliffs/hexo-live-mouse

闲来无事浏览时,我在 V 圈发现了这样一个网站:它用于记录部分虚拟主播的相关数据,内容较为私人化,不过本期的主题并非该网站,而是下图中红色箭头所标出的鼠标位置。这一设计颇为有趣,可以看到他人的鼠标正显示在网页的哪个位置

image-20260202142718002

技术实现

坦白而言,这一实现并无多少技术含量,本质上只是一次 WebSocket 交互。不过这也是我第一次基于 Hexo 框架编写插件,在此简单记录一下:首先需要准备一个 Node 项目,然后编写一个 index.js 脚本和一个后端脚本。后端脚本十分简单,可以随意部署在任意 serverless 平台上运行,其中需要用到 socket.io

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
"use strict";
const PORT = process.env.PORT || 3000;
const MAX_CONNECTIONS = 2000;
const MAX_PER_IP = 50;
const MOVE_INTERVAL = 40;
const IDLE_TIMEOUT = 60000;
const connectionCount = new Map();
const lastMoveCache = new Map();
const lastActive = new Map();
const io = require("socket.io")(PORT, {
cors: {
origin: ["http://localhost", "https://iloli.moe"],
methods: ["GET", "POST"]
},
maxHttpBufferSize: 1024,
pingTimeout: 20000,
pingInterval: 25000
});
io.use((socket, next) => {
try {
const ip = socket.handshake.address || "unknown";
if (io.engine.clientsCount > MAX_CONNECTIONS) {
return next(new Error("server full"));
}
const count = connectionCount.get(ip) || 0;
if (count > MAX_PER_IP) {
return next(new Error("too many connections"));
}
connectionCount.set(ip, count + 1);
socket._ip = ip;
next();
} catch {
next(new Error("auth fail"));
}
});
io.on("connection", (socket) => {
console.log("握握手", socket.id);
lastActive.set(socket.id, Date.now());
socket.on("mouse_move", (data) => {
try {
const now = Date.now();
const lastMove = lastMoveCache.get(socket.id) || 0;
if (now - lastMove < MOVE_INTERVAL) return;
if (!data) return;
if (typeof data.x !== "number") return;
if (typeof data.y !== "number") return;
if (data.x < 0 || data.x > 1) return;
if (data.y < 0 || data.y > 1) return;
lastMoveCache.set(socket.id, now);
lastActive.set(socket.id, now);
socket.broadcast.volatile.emit("update_mouse", {
id: socket.id,
x: data.x,
y: data.y
});
} catch {}
});
socket.on("disconnect", () => {
try {
console.log("握握双手", socket.id);
lastMoveCache.delete(socket.id);
lastActive.delete(socket.id);
const ip = socket._ip;
if (ip && connectionCount.has(ip)) {
const c = connectionCount.get(ip) - 1;
if (c <= 0) connectionCount.delete(ip);
else connectionCount.set(ip, c);
}
io.emit("user_left", socket.id);
} catch {}
});
});
setInterval(() => {
const now = Date.now();
for (const [id, time] of lastActive.entries()) {
if (now - time > IDLE_TIMEOUT) {
const socket = io.sockets.sockets.get(id);
if (socket) socket.disconnect(true);
}
}
}, 30000);
console.log("hexo-live-mouse-secure:", PORT);

整个服务端的逻辑不过是用户接入时握手、用户离开时再次握手并清理会话,此外还做了一些安全限制。接着编写一个客户端,这部分就是纯粹的 Hexo 插件,实现并不复杂,相关文档可参考官方文档

https://hexo.io/zh-cn/docs/plugins

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
"use strict";
const scriptContent = `
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script>
(function() {
const socket = io('http://127.0.0.1:3000', {
transports: ['websocket'],
reconnectionAttempts: 5,
timeout: 5000
});
const otherMice = new Map();
const MAX_USERS = 100;
document.addEventListener('mousemove', function(e) {
socket.emit('mouse_move', {
x: e.clientX / window.innerWidth,
y: e.clientY / window.innerHeight
});
});
socket.on('update_mouse', function(data) {
if (!data) return;
if (typeof data.x !== 'number' || typeof data.y !== 'number') return;
if (data.x < 0 || data.x > 1 || data.y < 0 || data.y > 1) return;
if (typeof data.id !== 'string' && typeof data.id !== 'number') return;
const safeId = String(data.id).slice(0, 64);
if (!otherMice.has(safeId) && otherMice.size > MAX_USERS) return;
let el = otherMice.get(safeId);
if (!el) {
el = document.createElement('div');
el.style.cssText = \`
width:20px;
height:20px;
position:fixed;
z-index:9999999;
pointer-events:none;
background-repeat:no-repeat;
background-size:contain;
\`;
el.style.backgroundImage = "url('data:image/svg+xml;utf8,\
<svg xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\">\
<path fill=\\"black\\" d=\\"M3 2L3 22L8 17L12 22L15 20L11 15L20 15Z\\"/>\
</svg>')";
document.body.appendChild(el);
otherMice.set(safeId, el);
}
const x = data.x * window.innerWidth;
const y = data.y * window.innerHeight;
el.style.transform = \`translate3d(\${x}px, \${y}px, 0)\`;
el.style.left = '0';
el.style.top = '0';
});
socket.on('user_left', function(id) {
const safeId = String(id || '').slice(0, 64);
if (otherMice.has(safeId)) {
otherMice.get(safeId).remove();
otherMice.delete(safeId);
}
});
})();
</script>
`;
hexo.extend.injector.register('body_end', scriptContent, 'default');

最终效果

image-20260202150916596

安全问题

目前尚无结论

Support via Solana

Solana

Solana

Solana Pay

Solana Pay

WeChat

WeChat