[网页插件]虎绿林消息浏览器推送(已支持Android Chrome)

@Ta 23小时前发布,2小时前修改 171点击

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

效果预览

通过浏览器推送,接收虎绿林的@消息和内信通知。
支持显示消息内容详情,支持点击通知跳转到消息对应页面。


使用方式

  • 导入网页插件
  • 页面顶部会请求通知权限,点击授权
    (如未显示,需在“网站设置”中手动授权)
  • 保持 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>

更新日志


源码

包含两个文件:插件主文件 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); } }) ); } });
回复列表(5|隐藏机器人聊天)
添加新回复
回复需要登录