广告自动检测/静音/举报 + 消息过滤 + 界面优化
// ==UserScript==
// @name Stripchat Guard
// @name:zh-CN 骑士助手
// @version 2.2.0
// @description 广告自动检测/静音/举报 + 消息过滤 + 界面优化
// @namespace https://greasyfork.org/zh-CN/scripts/573083
// @match *://*.stripchat.com/*
// @match *://*.yelive.tv/*
// @match *://*.xhamsterlive.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=stripchat.com
// @run-at document-idle
// @author XHXIAIEIN
// @license CC-BY-NC-4.0
// ==/UserScript==
(() => {
'use strict';
// ===================== 配置 =====================
const CONFIG = {
reportText: 'NO SAY',
homophone: {
Q: '[QɊɋꝖꝗ]',
群: '[群裙郡]',
免: '[免冕勉]',
费: '[费废飞]',
网: '[网往忘旺]',
址: '[址扯]',
加: '[加架嫁]',
微: '[微薇威]',
信: '[信芯新]',
看: '[看坎砍]',
片: '[片偏骗篇]',
视: '[视市是式]',
频: '[频拼品]',
付: '[付副负富傅]',
录: '[录路鹿陆绿]',
播: '[播拨]',
破: '[破迫泼]',
解: '[解借姐]',
票: '[票漂飘]',
充: '[充冲虫]',
价: '[价驾架嫁]',
扣: '[扣抠口叩]',
分: '[分粉纷芬汾纷氛]',
},
/**
* 广告规则 DSL 语法:
* "关键词" → 谐音 + 间隔 "A...B" → A.*B "A...[B]" → A.*[B字符组]
* "QQ<5+>" → QQ + 5+位数字 "<5+>QQ" → 数字 + QQ
* "<cn3+>" → 3+连续中文数字 "<url>/<www>/<domain>" → URL 匹配
*/
adKeywords: [
'QQ<5+>',
'Q<5+>',
'<5+>QQ',
'Q群',
'扣群',
'<cn3+>',
'<url>',
'<www>',
'<domain>',
'主播往期开票合集',
'往期开票合集在线爽看',
'主播和榜一大哥趴趴流出',
'录播...合集',
'破解...[网址付费主播]',
'私聊...价',
'低价...代币',
'开票...回放',
'福利...群',
'付费...网',
],
adWhitelist: ['已关注', '已加入', '粉丝团'],
filters: {
autoGuard: { key: 'sg-auto-guard', label: '自动举报广告', default: true },
hideGifts: { key: 'sg-hide-gifts', label: '隐藏礼物信息', default: false },
hideInteraction: { key: 'sg-hide-interaction', label: '隐藏互动信息', default: false },
hideWelcomeBot: { key: 'sg-hide-welcome-bot', label: '隐藏欢迎机器人', default: false },
},
giftClasses: ['m-bg-tip-v2', 'm-bg-default-v2', 'm-bg-public-tip'],
interactionClasses: ['m-bg-goal', 'm-bg-action', 'm-bg-system'],
welcomeBotClasses: ['WelcomeBotMessage', 'ConsoleAnnouncementMessage'],
};
// ===================== 常量 =====================
const ICON_MUTE =
'<svg viewBox="0 0 24 24"><path d="M16.5 12A4.5 4.5 0 0 0 14 7.97v2.21l2.45 2.45c.03-.2.05-.41.05-.63zM19 12c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.8 8.8 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 0 0 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"/></svg>';
const ICON_REPORT = '<svg viewBox="0 0 24 24"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>';
const ICON_DONE = '<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>';
const ICON_LOADING = '<svg viewBox="0 0 24 24"><path d="M12 4V1L8 5l4 4V6a6 6 0 0 1 0 12 6 6 0 0 1-6-6H4a8 8 0 1 0 8-8z"/></svg>';
// ===================== 工具 =====================
const S = '[\\s\\u200b\\u200c\\u200d\\ufeff\\p{P}\\p{S}]*';
const CN_DIGITS = '零一二三四五六七八九〇壹贰叁肆伍陆柒捌玖';
const zh = w =>
w
.split('')
.map(c => CONFIG.homophone[c] || c)
.join(S);
const chars = c => (CONFIG.homophone[c] ? CONFIG.homophone[c].slice(1, -1) : c);
const uniq = () => Math.random().toString(36).slice(2);
const scrollToBottom = () => {
const el = document.querySelector('.model-chat-content');
if (el) el.scrollTop = el.scrollHeight;
};
// ===================== 样式 =====================
const CSS = `
/* 隐藏滚动条(原生 + PerfectScrollbar) */
.model-chat-content { scrollbar-width: none; }
.model-chat-content::-webkit-scrollbar { display: none; }
.ps__thumb-x, .ps__thumb-y { opacity: 0 !important; }
.ps__rail-x, .ps__rail-y { background: transparent !important; }
.model-chat-new-messages-btn { padding: 6px 35% !important; }
[class*="RegularMessage__contentWithControls"] > div:first-child {
display: flex !important; flex-wrap: wrap !important; align-items: center !important;
}
.mute-button {
order: -1 !important; flex-shrink: 0 !important;
margin: 0 !important; padding-right: 4px !important;
}
/* 快捷按钮是 contentWithControls 行内的 flex 子项,放在原生三点菜单左侧,不覆盖它 */
.knight-ad-actions {
display: flex !important; align-items: center !important; gap: 4px !important;
flex-shrink: 0 !important; margin-left: auto !important; padding-left: 6px !important;
opacity: 0 !important; transition: opacity .15s !important; pointer-events: none !important;
}
.message-base:hover .knight-ad-actions,
.fullscreen-message-wrapper:hover .knight-ad-actions { opacity: 1 !important; pointer-events: auto !important; }
/* 三点菜单原本靠 margin-left:auto 贴右;有快捷按钮时由按钮占住这段空隙 */
.knight-ad-flagged .message-more-menu { margin-left: 4px !important; }
/* 全屏模式:按钮在 wrapper 层级,和三点菜单同级 */
.fullscreen-message-wrapper > .knight-ad-actions { margin-left: 4px !important; padding-left: 0 !important; }
.knight-ad-actions button {
border: none !important; border-radius: 50% !important;
width: 22px !important; height: 22px !important; padding: 0 !important;
display: flex !important; align-items: center !important; justify-content: center !important;
cursor: pointer !important; transition: background .15s !important;
}
.knight-ad-actions button svg { width: 14px; height: 14px; fill: #f8f8f8; pointer-events: none; }
/* 原生三点菜单:与快捷按钮同尺寸的圆形热区 */
.model-chat-content [class*="RegularMessage"] .message-more-menu {
width: 22px !important; height: 22px !important; border-radius: 50% !important; transition: background .15s !important;
}
.model-chat-content [class*="RegularMessage"] .message-more-menu:hover { background: rgba(255,255,255,.12) !important; color: #f8f8f8 !important; }
.knight-quick-mute { background: rgba(180,60,60,.85) !important; }
.knight-quick-mute:hover { background: rgb(210,50,50) !important; }
.knight-quick-report { background: rgba(180,110,30,.85) !important; }
.knight-quick-report:hover { background: rgb(210,120,20) !important; }
.knight-ad-actions button.busy svg { animation: sg-spin .8s linear infinite; }
@keyframes sg-spin { to { transform: rotate(360deg); } }
.knight-ad-flagged {
background: rgba(255,60,60,.15) !important; border-left: 2px solid rgba(255,60,60,.6) !important;
border-radius: 2px !important;
}
.knight-ad-flagged .user-levels-username {
background: rgba(220,50,50,.85) !important; color: #fff !important;
border-radius: 3px !important; padding: 0 5px !important;
}
.knight-ad-flagged .user-levels-username-text { color: #fff !important; }
/* 已举报(独立生效,不依赖 knight-ad-flagged) */
.knight-ad-reported {
background: rgba(120,120,120,.15) !important; border-left-color: rgba(120,120,120,.4) !important;
opacity: .6 !important;
}
.knight-ad-reported .user-levels-username { background: rgba(100,100,100,.7) !important; }
/* 已静音(独立生效,不依赖 knight-ad-flagged) */
.knight-ad-muted {
background: rgba(120,100,30,.2) !important; border-left-color: rgba(180,150,40,.5) !important;
color: rgba(255,220,120,.75) !important;
}
.knight-ad-muted .user-levels-username {
background: rgba(140,120,30,.7) !important; color: rgba(255,220,120,.9) !important;
}
.knight-ad-muted .user-levels-username-text { color: rgba(255,220,120,.9) !important; }
.sg-filter-btn {
display: flex !important; align-items: center !important; justify-content: space-between !important;
width: 100% !important; padding: 10px 16px !important;
background: none !important; border: none !important;
color: rgba(255,255,255,.85) !important; font-size: 14px !important; cursor: pointer !important;
}
.sg-filter-btn:hover { background: rgba(255,255,255,.05) !important; }
.sg-filter-label { flex: 1 !important; text-align: left !important; }
.sg-switch {
position: relative !important; width: 40px !important; height: 22px !important;
background: rgba(255,255,255,.2) !important; border-radius: 11px !important;
transition: background .2s !important; flex-shrink: 0 !important;
}
.sg-switch.on { background: #4caf50 !important; }
.sg-switch-thumb {
position: absolute !important; top: 2px !important; left: 2px !important;
width: 18px !important; height: 18px !important; background: #fff !important;
border-radius: 50% !important; transition: transform .2s !important;
}
.sg-switch.on .sg-switch-thumb { transform: translateX(18px) !important; }
body.sg-hide-gifts .sg-msg-gift,
body.sg-hide-interaction .sg-msg-interaction,
body.sg-hide-welcome-bot .sg-msg-welcome-bot { display: none !important; }
`;
const oldStyle = document.getElementById('knight-yelive-tweaks');
if (oldStyle) oldStyle.remove();
const styleEl = document.createElement('style');
styleEl.id = 'knight-yelive-tweaks';
styleEl.textContent = CSS;
document.head.appendChild(styleEl);
// ===================== 广告检测 =====================
const NUM_GAP = '\\d[\\s.\\-]*';
const SAFE_DOMAINS = [
'youtube',
'youtu\\.be',
'google',
'gmail',
'goo\\.gl',
'instagram',
'facebook',
'fb\\.me',
'twitter',
'x\\.com',
'tiktok',
'reddit',
'wikipedia',
'github',
'discord',
'twitch',
'whatsapp',
'telegram',
't\\.me',
'line\\.me',
'imgur',
'spotify',
'netflix',
'amazon',
'amzn',
'stripchat',
'xhamsterlive',
'yelive',
];
const SAFE_DOMAIN_RE = `(?!(?:${SAFE_DOMAINS.join('|')})\\.\\w)`;
const compileRule = rule => {
if (rule === '<url>') return `h${S}t${S}t${S}p${S}s?${S}:${S}/${S}/`;
if (rule === '<www>') return `w${S}w${S}w${S}\\.`;
if (rule === '<domain>') return `(?!\\d+\\.\\d)(?!no\\.\\d)${SAFE_DOMAIN_RE}[a-zA-Z]\\w*\\.[a-zA-Z]{1,4}(?=\\s|$)`;
if (rule === '<cn3+>') return `[${CN_DIGITS}]${S}[${CN_DIGITS}]${S}[${CN_DIGITS}]`;
let m;
if ((m = rule.match(/^(.+)<(\d)\+>$/))) return zh(m[1]) + `[\\s::]?${NUM_GAP.repeat(+m[2] - 1)}\\d`;
if ((m = rule.match(/^<(\d)\+>(.+)$/))) return `\\d{${m[1]},}\\s*` + zh(m[2]);
if ((m = rule.match(/^(.+)\.\.\.\[(.+)\]$/))) return zh(m[1]) + '.*[' + m[2].split('').map(chars).join('') + ']';
if (rule.includes('...')) {
const [a, b] = rule.split('...');
return zh(a) + '.*' + zh(b);
}
return zh(rule);
};
let adPattern, whitelistPattern;
try {
adPattern = new RegExp(CONFIG.adKeywords.map(compileRule).join('|'), 'iu');
whitelistPattern = new RegExp(CONFIG.adWhitelist.map(w => w.replace(/\*/g, '.*')).join('|'));
} catch (e) {
console.error('[Stripchat Guard] pattern compile failed:', e);
adPattern = whitelistPattern = /(?!)/;
}
const isAdText = text => {
if (!text) return false;
const trimmed = text.trim();
if (trimmed.length < 6) return false;
if (trimmed.length < 12 && !/[\u4e00-\u9fff]/.test(trimmed)) return false;
if (whitelistPattern.test(text)) return false;
return adPattern.test(text);
};
const getMsgText = msg => {
const content = msg.querySelector('[class*="RegularMessage__contentWithControls"] > div:first-child');
if (!content) return msg.textContent;
let text = '';
for (const n of content.childNodes) {
if (n.nodeType === Node.TEXT_NODE) text += n.textContent;
else if (n.nodeType === Node.ELEMENT_NODE) {
const cls = n.className?.toString() || '';
if (!cls.includes('username') && !cls.includes('timestamp')) text += n.textContent;
}
}
return text || msg.textContent;
};
// ===================== 消息过滤开关 =====================
const getFilter = f => (localStorage.getItem(f.key) === null ? f.default : localStorage.getItem(f.key) === '1');
const setFilter = (f, v) => localStorage.setItem(f.key, v ? '1' : '0');
for (const f of Object.values(CONFIG.filters)) document.body.classList.toggle(f.key, getFilter(f));
const injectSettings = () => {
if (document.querySelector('[data-sg-filter]')) return;
const anchor = document.querySelector('[data-testid="timestamp-chat-settings-button"]');
const ul = anchor?.closest('li')?.parentElement;
if (!ul) return;
for (const f of Object.values(CONFIG.filters)) {
const li = document.createElement('li');
li.dataset.sgFilter = f.key;
li.style.listStyle = 'none';
const active = getFilter(f);
li.innerHTML = `<div class="sg-filter-btn"><span class="sg-filter-label">${f.label}</span><div class="sg-switch ${active ? 'on' : ''}"><div class="sg-switch-thumb"></div></div></div>`;
const sw = li.querySelector('.sg-switch');
li.querySelector('.sg-filter-btn').onclick = () => {
const next = !getFilter(f);
setFilter(f, next);
document.body.classList.toggle(f.key, next);
sw.classList.toggle('on', next);
};
ul.appendChild(li);
}
};
// ===================== 平台 API =====================
let csrfCache = null;
const fetchCsrf = async () => {
if (csrfCache && Date.now() - csrfCache._ts < 30 * 60 * 1000) return csrfCache;
try {
const res = await fetch(`/api/front/v3/config/initial-dynamic?requestPath=${encodeURIComponent(location.pathname)}`);
const { initialDynamic: d } = await res.json();
csrfCache = { csrfToken: d.csrfToken, csrfTimestamp: d.csrfTimestamp, csrfNotifyTimestamp: d.csrfNotifyTimestamp, _ts: Date.now() };
return csrfCache;
} catch {
return null;
}
};
// 房间 ID 只从 store 读取,且不缓存:切换房间不会整页刷新。
// 页面上的 [data-model-id] 属于相关主播列表链接,取到的是别的房间;
// 旧的 /api/front/models/username/ 接口已下线,返回 418。
const getModelId = () => {
const sc = window.StripChat;
if (!sc) return null;
try {
const id = sc.getCurrentViewCamModel?.()?.id;
if (id) return id;
const state = sc.getState?.();
return state?.viewCam?.model?.id || state?.viewCamBase?.model?.id || state?.publicChat?.messages?.server?.[0]?.modelId || null;
} catch {
return null;
}
};
const getUserId = msg => msg.querySelector('[id^="user-levels-name-"]')?.id?.match(/user-levels-name-(\d+)-/)?.[1];
const getUsername = msg => msg.querySelector('.user-levels-username-text')?.textContent?.trim();
// 返回 { ok, status, data };status 0 表示没拿到 CSRF 或网络错误
const apiPost = async (url, method, body) => {
const csrf = await fetchCsrf();
if (!csrf) return { ok: false, status: 0, data: null };
try {
const headers = { 'Content-Type': 'application/json' };
const version = window.DEPLOY_CONFIG?.releaseVersion;
if (version) headers['Front-Version'] = version;
const res = await fetch(url, { method, headers, body: JSON.stringify({ ...body, ...csrf, uniq: uniq() }) });
let data = null;
try {
data = await res.json();
} catch {}
return { ok: res.ok || res.status === 204, status: res.status, data };
} catch {
return { ok: false, status: 0, data: null };
}
};
const muteUser = async (targetId, hostId = getModelId()) => {
if (!targetId || !hostId) return { ok: false, status: 0, data: null };
return apiPost(`/api/front/users/${hostId}/bans/users/${targetId}`, 'PUT', { type: 'mute' });
};
// 与网站一致:先 checking,被限流时服务端在这一步拒绝并返回 details.limitType
const reportMsg = async (messageId, hostId = getModelId()) => {
if (!messageId || !hostId) return { ok: false, status: 0, data: null };
const base = { messageId, modelId: Number(hostId) };
const check = await apiPost('/api/front/message-reports/checking', 'POST', base);
if (!check.ok) return { ...check, limitType: check.data?.details?.limitType };
return apiPost('/api/front/message-reports', 'POST', { ...base, reasonText: CONFIG.reportText, type: 'spam' });
};
// ===================== UI 标记 =====================
const mutedUsers = new Set();
const reportedUsers = new Set();
const markBtn = btn => {
if (!btn) return;
btn.innerHTML = ICON_DONE;
btn.classList.remove('busy');
btn.style.pointerEvents = 'none';
btn.style.background = 'rgba(100,100,100,.5)';
};
const setBusy = btn => {
if (!btn) return;
if (btn.dataset.sgOrig === undefined) btn.dataset.sgOrig = btn.innerHTML;
btn.classList.add('busy');
btn.innerHTML = ICON_LOADING;
btn.style.pointerEvents = 'none';
};
const resetBtn = btn => {
if (!btn) return;
btn.classList.remove('busy');
if (btn.dataset.sgOrig !== undefined) btn.innerHTML = btn.dataset.sgOrig;
delete btn.dataset.sgOrig;
btn.style.pointerEvents = '';
};
const applyMark = (el, cls, btnSelector) => {
el.classList.add(cls);
markBtn(el.querySelector(btnSelector));
};
// 标记页面上某个用户的全部消息,含全屏副本
const markUser = (username, cls, btnSelector) => {
if (!username) return;
document.querySelectorAll('.user-levels-username-text').forEach(el => {
if (el.textContent.trim() !== username) return;
const other = el.closest('.message-base');
if (!other) return;
processMessage(other);
applyMark(other, cls, btnSelector);
const otherId = other.dataset.messageId;
if (otherId) document.querySelectorAll(`[data-message-id="${otherId}"]`).forEach(dup => applyMark(dup, cls, btnSelector));
});
};
const markSameUser = (msg, username, cls, btnSelector) => {
const msgId = msg.dataset.messageId;
if (msgId) document.querySelectorAll(`[data-message-id="${msgId}"]`).forEach(el => applyMark(el, cls, btnSelector));
markUser(username, cls, btnSelector);
};
const markMuted = (msg, username) => {
if (username) mutedUsers.add(username);
msg.classList.add('knight-ad-muted');
markBtn(msg.querySelector('.knight-quick-mute'));
markSameUser(msg, username, 'knight-ad-muted', '.knight-quick-mute');
scrollToBottom();
};
const markReported = (msg, username) => {
if (username) reportedUsers.add(username);
msg.classList.add('knight-ad-reported');
markBtn(msg.querySelector('.knight-quick-report'));
markSameUser(msg, username, 'knight-ad-reported', '.knight-quick-report');
scrollToBottom();
};
// ===================== 操作入口 =====================
// 原生按钮被拦截后 API 失败时,带标记重新点击,交回网站自己的确认框 / 举报弹窗
const clickNative = btn => {
if (!btn || !btn.isConnected) return;
btn.dataset.sgNative = '1';
btn.click();
};
const doMute = (msg, btn, nativeBtn) => {
setBusy(btn);
const targetId = getUserId(msg);
const fail = () => {
resetBtn(btn);
clickNative(nativeBtn);
};
if (!targetId) return fail();
muteUser(targetId).then(r => (r.ok ? markMuted(msg, getUsername(msg)) : fail()));
};
const doReport = (msg, btn, nativeBtn) => {
setBusy(btn);
const messageId = Number(msg.dataset.messageId);
const fail = () => {
resetBtn(btn);
clickNative(nativeBtn);
};
if (!messageId) return fail();
reportMsg(messageId).then(r => (r.ok ? markReported(msg, getUsername(msg)) : fail()));
};
const createBtn = (cls, icon, handler) => {
const btn = document.createElement('button');
btn.className = cls;
btn.innerHTML = icon;
btn.onmousedown = e => e.preventDefault();
btn.onclick = e => {
e.stopPropagation();
handler(btn);
};
return btn;
};
const injectAdActions = msg => {
if (msg.querySelector('.knight-ad-actions')) return;
const wrapper = msg.closest('.fullscreen-message-wrapper');
if (wrapper?.querySelector('.knight-ad-actions')) return;
const actions = document.createElement('div');
actions.className = 'knight-ad-actions';
actions.appendChild(createBtn('knight-quick-mute', ICON_MUTE, btn => doMute(msg, btn)));
actions.appendChild(createBtn('knight-quick-report', ICON_REPORT, btn => doReport(msg, btn)));
// 普通模式放进 contentWithControls 行内、三点菜单之前;全屏模式放在 wrapper 层级
const row = wrapper || msg.querySelector('[class*="RegularMessage__contentWithControls"]') || msg;
const moreMenu = row.querySelector('.message-more-menu');
if (moreMenu) row.insertBefore(actions, moreMenu);
else row.appendChild(actions);
};
// 原生按钮先走 API。找不到对应消息(如用户卡片里的静音按钮)时不拦截,交给网站处理。
const msgOf = el => el.closest('.message-base') || el.closest('.fullscreen-message-wrapper')?.querySelector('.message-base');
let menuMsg = null; // 最近一次点开三点菜单的消息;举报按钮渲染在 portal 里,无法从 DOM 反查
document.body.addEventListener(
'click',
e => {
const btn = e.target.closest('.message-more-menu, .mute-button, [class*="ReportButton"]');
if (!btn) return;
if (btn.dataset.sgNative) {
delete btn.dataset.sgNative;
return;
}
if (btn.classList.contains('message-more-menu')) {
menuMsg = msgOf(btn) || menuMsg;
return;
}
if (btn.classList.contains('mute-button')) {
const msg = msgOf(btn);
if (!msg || btn.classList.contains('muted')) return;
e.stopPropagation();
e.preventDefault();
doMute(msg, null, btn);
return;
}
const msg = msgOf(btn) || (menuMsg?.isConnected ? menuMsg : null);
if (!msg) return;
e.stopPropagation();
e.preventDefault();
doReport(msg, btn, btn);
},
true
);
// ===================== Store 预检 + 自动操作 =====================
{
const REPORT_PAUSE = 10 * 60 * 1000;
let lastId = 0;
let started = false;
let roomId = null;
let canMute = true; // 静音要求骑士或主播身份,收到 401/403 后本房间不再尝试
let reportPausedUntil = 0; // 举报被限流或未授权后暂停到这个时间
let loginWarned = false;
let pollErrorLogged = false;
const handled = new Set(); // 本房间已处理完毕、不再重试的用户
const pending = new Set(); // API 调用进行中的用户
const warn = (text, extra) => console.warn(`[Guard] ${text}`, extra ?? '');
const isTransient = status => status === 0 || status >= 500;
const isAuthError = status => status === 401 || status === 403;
const autoHandle = async m => {
const { username, id: userId } = m.userData;
const tasks = [];
let retry = false;
if (Date.now() >= reportPausedUntil) {
tasks.push(
reportMsg(m.id, m.modelId).then(r => {
if (r.ok) {
reportedUsers.add(username);
markUser(username, 'knight-ad-reported', '.knight-quick-report');
return;
}
if (r.limitType || r.status === 429 || isAuthError(r.status)) {
reportPausedUntil = Date.now() + REPORT_PAUSE;
warn(`举报被拒绝(${r.limitType || `HTTP ${r.status}`}),自动举报暂停 10 分钟`);
} else {
warn(`举报 ${username} 失败(HTTP ${r.status})`, r.data);
}
if (isTransient(r.status)) retry = true;
})
);
}
if (canMute) {
tasks.push(
muteUser(String(userId), m.modelId).then(r => {
if (r.ok) {
mutedUsers.add(username);
markUser(username, 'knight-ad-muted', '.knight-quick-mute');
return;
}
if (isAuthError(r.status)) {
canMute = false;
warn('当前账号在本房间没有静音权限(需要骑士或主播身份),自动静音已关闭');
} else {
warn(`静音 ${username} 失败(HTTP ${r.status})`, r.data);
}
if (isTransient(r.status)) retry = true;
})
);
}
if (!tasks.length) return; // 两条路都暂停时不算处理过,恢复后该用户的下一条消息会再进来
pending.add(username);
await Promise.allSettled(tasks);
pending.delete(username);
if (!retry) handled.add(username);
};
const poll = () => {
try {
const state = window.StripChat?.getState?.();
const msgs = state?.publicChat?.messages?.server;
if (!msgs?.length) return;
// 换房间不会整页刷新:重新对齐起点,清空本房间状态
const curRoom = getModelId();
if (curRoom !== roomId) {
roomId = curRoom;
started = false;
canMute = true;
reportPausedUntil = 0;
handled.clear();
pending.clear();
mutedUsers.clear();
reportedUsers.clear();
}
if (!started) {
lastId = msgs[msgs.length - 1]?.id || 0;
started = true;
return;
}
const curLastId = msgs[msgs.length - 1]?.id;
if (curLastId === lastId) return;
if (!getFilter(CONFIG.filters.autoGuard)) {
lastId = curLastId;
return;
}
// 未登录时举报要过验证码,静音也没有权限
if (!state.userSession?.isLoggedIn) {
if (!loginWarned) {
loginWarned = true;
warn('未登录,自动举报和静音不可用');
}
lastId = curLastId;
return;
}
const myId = state.userSession.currentUser?.id;
for (const m of msgs) {
if (m.id <= lastId) continue;
if (m.type !== 'text' || m.modelId !== roomId) continue;
const u = m.userData;
const body = m.details?.body;
if (!body || !u?.username || !u?.id) continue;
if (u.id === myId || u.id === m.modelId || u.isAdmin || u.isSupport) continue;
if (handled.has(u.username) || pending.has(u.username) || mutedUsers.has(u.username) || reportedUsers.has(u.username)) continue;
if (!isAdText(body)) continue;
console.info(`[Guard] 广告预检: %c${u.username}%c\n → ${body.substring(0, 60)}`, 'color:#f66;font-weight:bold', 'color:inherit;font-weight:normal');
autoHandle(m);
}
lastId = curLastId;
} catch (e) {
if (!pollErrorLogged) {
pollErrorLogged = true;
warn('轮询出错', e);
}
}
};
setInterval(poll, 500);
}
// ===================== 消息处理 + Observer =====================
const processMessage = msg => {
if (msg.dataset.processed) return;
msg.dataset.processed = '1';
const hasClass = cls => msg.matches(`[class*="${cls}"]`);
if (CONFIG.giftClasses.some(hasClass)) msg.classList.add('sg-msg-gift');
if (CONFIG.interactionClasses.some(hasClass) && !msg.classList.contains('user-muted-message')) msg.classList.add('sg-msg-interaction');
if (CONFIG.welcomeBotClasses.some(hasClass)) msg.classList.add('sg-msg-welcome-bot');
if (msg.matches('[class*="RegularMessage"]')) {
const isOwner = !!msg.querySelector('.user-levels-username-chat-owner, [class*="chat-owner"]');
if (!isOwner && isAdText(getMsgText(msg))) {
msg.classList.add('knight-ad-flagged');
injectAdActions(msg);
}
}
// 同步已操作状态(跳过系统消息,避免误标"已被静音"提示)
if (!msg.classList.contains('m-bg-system')) {
const name = getUsername(msg);
if (name) {
if (mutedUsers.has(name)) {
msg.classList.add('knight-ad-muted');
markBtn(msg.querySelector('.knight-quick-mute'));
}
if (reportedUsers.has(name)) {
msg.classList.add('knight-ad-reported');
markBtn(msg.querySelector('.knight-quick-report'));
}
}
}
};
document.querySelectorAll('.message-base').forEach(processMessage);
let settingsTimer = 0;
new MutationObserver(mutations => {
for (const { addedNodes } of mutations) {
for (const node of addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.classList?.contains('message-base')) processMessage(node);
else node.querySelectorAll?.('.message-base:not([data-processed])').forEach(processMessage);
}
}
if (!settingsTimer)
settingsTimer = setTimeout(() => {
settingsTimer = 0;
injectSettings();
}, 300);
}).observe(document.body, { childList: true, subtree: true });
})();