网页插件:虎绿林消息浏览器推送
效果预览


通过浏览器推送,接收虎绿林的@消息和内信通知。
支持显示消息内容详情,支持点击通知跳转到消息对应页面。
使用方式
- 导入网页插件
- 页面顶部会请求通知权限,点击授权
(如未显示,需在“网站设置”中手动授权)
- 保持 hu60.cn 任意一个标签页开启,即可接收@消息和内信通知
(只会显示在线期间收到的通知,历史未读和离线期间的消息不会显示)
- 支持断线自动重连
- 如遇问题,可在浏览器控制台查看推送服务日志
(如打开多个标签页,只有一个标签页会运行推送服务并显示日志)
支持的浏览器
Windows:Chrome、FireFox
Android:Chrome、FireFox(受省电机制等限制,后台时可能收不到通知)
其它平台和浏览器暂未测试。
如何测试推送效果
- 先关闭 hu60.cn 下的所有标签页
- 打开 hu60.cn 首页,此时消息推送服务会运行在这个标签页中
- 再新建一个标签页,打开 hu60.cn,然后在第二个标签页中,在任意帖子、聊天室里@自己,或给自己发内信,测试消息推送效果
(只打开一个标签页时,无法通过@自己和给自己发内信测试推送效果,因为当你发帖和发内信时,页面会刷新,且由于没有其他 hu60.cn 下的标签页打开,因此当前页的推送消息服务会随页面刷新重启,无法收到你刚刚发给自己的推送)
导入网页插件
导入网页插件:虎绿林消息浏览器推送(当前用户:1,总安装次数:2)
<script src="/q.php/api.webplug-file/17528_public_webplug_push_noti/dist.js"></script>
更新日志
- 版本 2(2025-11-26):实现 Service Worker,新增支持 Android 版 Chrome 浏览器。
- 版本 1(2025-11-25):基于虎绿林 WebSocket 消息推送服务实现。
源码
包含两个文件:插件主文件 dist.js 和 Service Worker 文件 service_worker.js。
---
dist.js 文件:
// 虎绿林消息提醒浏览器推送,版本 2,2025-11-26
(function () {
'use strict';
const WS_URL = "wss://" + location.host + "/ws/msg";
const SERVICE_WORKER_PATH = "https://hu60.cn/q.php/api.webplug-file/17528_public_webplug_push_noti/service_worker.js";
// 选主锁,在开启多个标签页时仅维持一个 WebSocket 连接
const LOCK_NAME = "hu60_websocket_leader_lock";
const userNameCache = new Map();
let socket = null;
let keepAliveTimer = null;
let swRegistration = null;
// 获取用户名
function getUserName(uid) {
if (userNameCache.has(uid)) {
return userNameCache.get(uid);
}
const fetchPromise = fetch(`/q.php/user.info.${uid}.json`)
.then(res => res.json())
.then(json => {
return (json && json.name) ? json.name : uid;
})
.catch(err => {
console.error(`[UserInfo] 获取 uid=${uid} 失败`, err);
return uid;
});
userNameCache.set(uid, fetchPromise);
return fetchPromise;
}
// 消息内容解析
function parseMsgContent(contentStr) {
try {
const contentArr = JSON.parse(contentStr);
if (!Array.isArray(contentArr)) {
return "收到一条新消息";
}
let plainText = "";
contentArr.forEach(item => {
if (item.type === 'text') plainText += item.value || "";
else if (item.type === 'face') plainText += `[${item.face}]`;
else if (item.type === 'at') plainText += `@${item.tag} `;
});
// 多行文本转单行、压缩空格、截取前 80 字符
return plainText.replace(/[\r\n\s]+/g, ' ').trim().substring(0, 80);
} catch (e) { return "收到一条新消息"; }
}
// 通过 Service Worker 显示消息
function showNotification(title, body, tag, openUrl) {
if (!("Notification" in window) || Notification.permission !== "granted") return;
// 优先使用已缓存的 Service Worker Registration
if (swRegistration && swRegistration.showNotification) {
swRegistration.showNotification(title, {
body: body,
icon: '/favicon.ico',
tag: tag,
requireInteraction: false,
data: openUrl
}).catch(e => {
console.error("[Notify] SW 通知发送失败,尝试回退", e);
fallbackNotification(title, body, tag, openUrl);
});
} else {
fallbackNotification(title, body, tag, openUrl);
}
}
// 通过传统方式显示消息
function fallbackNotification(title, body, tag, openUrl) {
try {
const notification = new Notification(title, {
body: body,
icon: '/favicon.ico',
tag: tag,
requireInteraction: false
});
notification.onclick = function () {
window.focus();
if (openUrl) {
window.open(openUrl, '_blank');
}
this.close();
};
} catch (e) {
console.error(e);
}
}
// WebSocket 服务
function startWebSocketService() {
console.log("[Lock] 获得锁,当前标签页负责消息推送");
function connect() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
console.log("[WS] 正在尝试连接");
socket = new WebSocket(WS_URL);
socket.onopen = () => {
console.log("[WS] 连接建立,开始监听消息推送");
// 不订阅机器人上下线事件
socket.send(JSON.stringify({ "action": "unsub", "data": ["online", "offline"] }));
if (keepAliveTimer) {
clearInterval(keepAliveTimer);
}
keepAliveTimer = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send('{"action":"ping"}');
}
}, 30000); // 心跳包间隔 30 秒
};
socket.onmessage = async (event) => {
try {
const msg = JSON.parse(event.data);
// DEBUG: 打印消息对象
if (msg.event === "msg") {
console.log("[WS] 收到消息对象:", msg);
}
if (msg.event === "msg" && msg.data && (msg.data.type === 0 || msg.data.type === 1)) {
const data = msg.data;
const uid = data.byuid;
const senderName = await getUserName(uid);
let title;
let content;
let targetUrl = "";
const contentArr = JSON.parse(data.content);
switch (data.type) {
case 0: // 内信
title = `${senderName} 给您发了一条内信`;
content = parseMsgContent(data.content);
targetUrl = `/q.php/msg.index.chat.${uid}.html`;
break;
case 1: // @消息
const atNode = contentArr.find(item => item.type === 'atMsg');
title = `${senderName} 在${atNode.pos}@您`;
targetUrl = atNode.url;
content = parseMsgContent(JSON.stringify(atNode.msg));
break;
default:
title = `${senderName} 给您发了一条新消息`;
content = parseMsgContent(data.content);
targetUrl = `/q.php/index.index.html`;
break;
}
targetUrl = targetUrl.replace("{$BID}", "html");
// 确保 URL 是绝对路径,方便 SW 打开
if (targetUrl.startsWith('/')) {
targetUrl = location.origin + targetUrl;
}
showNotification(title, content, `hu60_msg_${data.id}`, targetUrl);
}
} catch (e) {
console.error("[WS] 处理新消息出错", e);
}
};
socket.onclose = (e) => {
console.log("[WS] 连接断开,5 秒后重连", e);
if (keepAliveTimer) clearInterval(keepAliveTimer);
setTimeout(connect, 5000);
};
socket.onerror = () => {
if (socket) {
socket.close();
}
}
}
connect();
// 监听标签页可见性变化
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === 'visible') {
console.log("[LifeCycle] 页面回到前台");
if (!socket || socket.readyState !== WebSocket.OPEN) {
console.log("[LifeCycle] 连接已失效,正在重连");
connect();
}
} else {
console.log("[LifeCycle] 页面进入后台,推送服务可能被系统暂停");
}
});
document.addEventListener("freeze", () => {
console.warn("[LifeCycle] 页面被浏览器冻结");
});
document.addEventListener("resume", () => {
console.log("[LifeCycle] 页面已解冻");
if (!socket || socket.readyState !== WebSocket.OPEN) {
connect();
} else {
socket.send('{"action":"ping"}');
}
});
window.addEventListener("online", () => {
console.log("[Net] 网络已恢复,尝试连接");
connect();
});
}
async function init() {
if ('serviceWorker' in navigator) {
try {
swRegistration = await navigator.serviceWorker.register(SERVICE_WORKER_PATH);
console.log("[SW] Service Worker 默认作用域注册成功,Scope:", swRegistration.scope);
} catch (err) {
console.error("[SW] Service Worker 注册失败", err);
}
}
const runService = () => {
if (navigator.locks) {
navigator.locks.request(LOCK_NAME, { mode: 'exclusive' }, async () => {
startWebSocketService();
return new Promise(() => {
});
});
} else {
startWebSocketService();
}
};
if (!("Notification" in window)) {
console.warn("[Notify] 浏览器不支持通知 API,不启动 WebSocket 推送服务");
return;
}
if (Notification.permission === "granted") {
runService();
} else if (Notification.permission === "default") {
console.log("[Notify] 权限为 default,显示手动授权提示");
const bar = document.createElement("div");
bar.style.cssText = "position:fixed;top:0;left:0;width:100%;z-index:2147483647;background:#fff3cd;color:#856404;text-align:center;padding:8px 0;border-bottom:1px solid #ffeeba;font-size:14px;line-height:1.5;box-shadow:0 2px 5px rgba(0,0,0,0.1);";
bar.innerHTML = '未获得通知权限,推送服务不会启动<a href="javascript:void(0);" style="color:#533f03;text-decoration:underline;font-weight:bold;cursor:pointer;">点击授予通知权限</a>';
const link = bar.querySelector('a');
link.onclick = async (e) => {
e.preventDefault();
try {
const permission = await Notification.requestPermission();
if (permission === "granted") {
bar.remove();
runService();
} else {
console.warn("[Notify] 用户拒绝了权限请求");
bar.innerHTML = '已拒绝通知权限,如需开启请在浏览器网站设置中授予';
setTimeout(() => bar.remove(), 10000);
}
} catch (err) {
console.error("[Notify] 请求权限发生错误", err);
}
};
document.body.prepend(bar);
} else {
console.warn("[Notify] 未获得通知权限(用户已拒绝),不启动 WebSocket 推送服务");
}
}
let isInitCalled = false;
const runInit = async () => {
if (isInitCalled) return;
isInitCalled = true;
await init();
};
if (document.readyState === 'complete' || document.readyState === 'interactive') {
runInit();
} else {
window.addEventListener('DOMContentLoaded', runInit);
window.addEventListener('load', runInit);
}
})();
---
service_worker.js 文件:
[text=height: 128px; padding: 4px; border: solid black 1px; overflow: scroll; white-space: pre; font-family: monospace; font-size: 0.8rem;]
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = event.notification.data;
if (targetUrl) {
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((windowClients) => {
for (let client of windowClients) {
if (client.url === targetUrl && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
})
);
}
});