广告自动检测/静音/举报 + 消息过滤 + 界面优化
// ==UserScript==
// @name Stripchat Guard
// @name:zh-CN 骑士助手
// @version 2.1.0
// @description 广告自动检测/静音/举报 + 消息过滤 + 界面优化
// @namespace https://greasyfork.org/zh-CN/scripts/573083-stripchat-guard
// @match *://*.stripchat.com/*
// @match *://*.xhamsterlive.com/*
// @match *://*.yelive.tv/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=yelive.tv
// @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;
}
.knight-ad-actions {
position: absolute !important; right: 4px !important;
top: 50% !important; transform: translateY(-50%) !important;
display: flex !important; gap: 4px !important; z-index: 10 !important;
opacity: 0 !important; transition: opacity .15s !important; pointer-events: none !important;
}
.message-base:hover .knight-ad-actions { opacity: 1 !important; pointer-events: auto !important; }
/* 全屏模式:按钮在 wrapper 层级,和三点菜单同级,常显 */
.fullscreen-message-wrapper > .knight-ad-actions {
position: static !important; transform: none !important;
opacity: 1 !important; pointer-events: auto !important;
}
.knight-ad-actions button {
border: none !important; border-radius: 50% !important;
width: 28px !important; height: 28px !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: 16px; height: 16px; fill: #f8f8f8; pointer-events: none; }
.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; position: relative !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;
let modelIdCache = 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;
}
};
const getModelId = async () => {
if (modelIdCache) return modelIdCache;
const el = document.querySelector('[data-model-id]');
if (el) return (modelIdCache = el.dataset.modelId);
const name = location.pathname.match(/^\/([^/]+)/)?.[1];
if (!name) return null;
try {
const res = await fetch(`/api/front/models/username/${name}`);
const json = await res.json();
return (modelIdCache = json.user?.id || json.id);
} 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();
const apiPost = async (url, method, body) => {
const csrf = await fetchCsrf();
if (!csrf) return false;
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, ...csrf, uniq: uniq() }),
});
return res.ok || res.status === 204;
} catch {
return false;
}
};
const muteUser = async targetId => {
const modelId = await getModelId();
if (!targetId || !modelId) return false;
return apiPost(`/api/front/users/${modelId}/bans/users/${targetId}`, 'PUT', { type: 'mute' });
};
const reportMsg = async messageId => {
const modelId = await getModelId();
if (!messageId || !modelId) return false;
const base = { messageId, modelId: Number(modelId) };
await apiPost('/api/front/message-reports/checking', 'POST', base);
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;
btn.classList.add('busy');
btn.innerHTML = ICON_LOADING;
btn.style.pointerEvents = 'none';
};
const resetBtn = (btn, icon) => {
if (!btn) return;
btn.classList.remove('busy');
btn.innerHTML = icon;
btn.style.pointerEvents = '';
};
const applyMark = (el, cls, btnSelector) => {
el.classList.add(cls);
markBtn(el.querySelector(btnSelector));
};
const markSameUser = (msg, username, cls, btnSelector) => {
const msgId = msg.dataset.messageId;
if (msgId) {
document.querySelectorAll(`[data-message-id="${msgId}"]`).forEach(el => {
if (el !== msg) applyMark(el, 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 || other === msg || other.dataset.messageId === msgId) return;
processMessage(other);
applyMark(other, cls, btnSelector);
const otherId = other.dataset.messageId;
if (otherId) {
document.querySelectorAll(`[data-message-id="${otherId}"]`).forEach(dup => {
if (dup !== other) applyMark(dup, 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();
};
// ===================== 操作入口 =====================
const doMute = (msg, btn) => {
setBusy(btn);
const targetId = getUserId(msg);
if (!targetId) {
resetBtn(btn, ICON_MUTE);
return;
}
muteUser(targetId).then(ok => {
if (ok) markMuted(msg, getUsername(msg));
else resetBtn(btn, ICON_MUTE);
});
};
const doReport = (msg, btn) => {
setBusy(btn);
const messageId = Number(msg.dataset.messageId);
if (!messageId) {
resetBtn(btn, ICON_REPORT);
return;
}
reportMsg(messageId).then(ok => {
if (ok) markReported(msg, getUsername(msg));
else resetBtn(btn, ICON_REPORT);
});
};
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)));
if (wrapper) {
const moreMenu = wrapper.querySelector('.message-more-menu');
if (moreMenu) wrapper.insertBefore(actions, moreMenu);
else wrapper.appendChild(actions);
} else {
msg.appendChild(actions);
}
};
document.body.addEventListener(
'click',
e => {
const muteBtn = e.target.closest('.mute-button');
if (muteBtn && !muteBtn.classList.contains('muted')) {
e.stopPropagation();
e.preventDefault();
const msg = muteBtn.closest('.message-base') || muteBtn.closest('.fullscreen-message-wrapper')?.querySelector('.message-base');
if (msg) doMute(msg);
return;
}
const reportBtn = e.target.closest('[class*="ReportButton"]');
if (reportBtn) {
e.stopPropagation();
e.preventDefault();
const msg = document.querySelector('.message-base:hover') || document.querySelector('.fullscreen-message-wrapper:hover .message-base');
if (msg) doReport(msg, reportBtn);
return;
}
},
true
);
// ===================== Store 预检 + 自动操作 =====================
{
let lastId = 0;
let started = false;
let canMute = true;
const poll = () => {
try {
const msgs = window.StripChat?.getState()?.publicChat?.messages?.server;
if (!msgs?.length) return;
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;
}
for (const m of msgs) {
if (m.id <= lastId) continue;
if (m.type !== 'text') continue;
const body = m.details?.body;
const username = m.userData?.username;
const userId = m.userData?.id;
if (!body || !username || !userId) continue;
if (mutedUsers.has(username) || reportedUsers.has(username)) continue;
if (isAdText(body)) {
console.info(`[Guard] 广告预检: %c${username}%c\n → ${body.substring(0, 60)}`, 'color:#f66;font-weight:bold', 'color:inherit;font-weight:normal');
reportedUsers.add(username);
reportMsg(m.id);
if (canMute) {
muteUser(String(userId)).then(ok => {
if (ok) mutedUsers.add(username);
else canMute = false;
});
}
}
}
lastId = curLastId;
} catch {}
};
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 });
})();