识别直播聊天里的广告并自动静音、举报,可隐藏礼物、互动和欢迎消息
// ==UserScript==
// @name Stripchat Guard
// @name:zh-CN 骑士助手
// @version 2.3.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=yelive.tv
// @run-at document-idle
// @author XHXIAIEIN
// @license CC-BY-NC-4.0
// ==/UserScript==
const sgMain = () => {
'use strict';
// 只运行一份,以免主体注入页面后和当前环境各跑一次
const root = document.documentElement;
if (root.dataset.sgGuard) return;
root.dataset.sgGuard = '1';
// ===================== 配置 =====================
const CONFIG = {
// 举报时附带的详情,快捷举报和举报弹窗共用;网站要求至少 5 个字符
reportText: 'NO SAY',
// 广告里常见的谐音和变体字
homophone: {
Q: '[QɊɋꝖꝗ]',
群: '[群裙郡]',
免: '[免冕勉]',
费: '[费費废飞]',
网: '[网往忘旺]',
址: '[址扯止]',
加: '[加架嫁]',
微: '[微薇威]',
信: '[信芯新]',
看: '[看坎砍㸔]',
片: '[片偏骗篇]',
视: '[视市是式]',
频: '[频拼品]',
付: '[付副负富傅]',
录: '[录路鹿陆绿]',
播: '[播拨]',
破: '[破迫泼]',
解: '[解借姐]',
票: '[票漂飘]',
充: '[充冲虫]',
价: '[价驾架嫁]',
扣: '[扣抠口叩]',
分: '[分粉纷芬汾纷氛]',
搜: '[搜捜叟嗖]',
},
// 广告规则,任一条命中就算广告:
// 关键词 逐字匹配谐音,字间可夹空格、零宽字符和符号
// A...B A 之后出现 B
// A...[BCD] A 之后出现 B、C、D 或它们的谐音之一
// QQ<5+> QQ 后跟至少 5 位数字;<5+>QQ 是数字在前
// <cn3+> 连续 3 个中文数字
// <url> <www> 网址开头
// <domain> 裸域名,常见网站除外
// <spaced-domain> 字母间插空格、拿怪符号当点的域名,如 k g 7 8 9 . t o p
adKeywords: [
'QQ<5+>',
'Q<5+>',
'<5+>QQ',
'Q群',
'扣群',
'<cn3+>',
'<url>',
'<www>',
'<domain>',
'<spaced-domain>',
'主播往期开票合集',
'往期开票合集在线爽看',
'主播和榜一大哥趴趴流出',
'录播...合集',
'破解...[网址付费主播]',
'私聊...价',
'低价...代币',
'开票...回放',
'开票...回看',
'错过开票',
'逃票',
'规避...票',
'看我相册',
'相册...[票费片]',
'录播全集',
'浏览器...搜',
'浏览器...网址',
'福利...群',
'付费...网',
],
// 包含这些词的消息不算广告
adWhitelist: ['已关注', '已加入', '粉丝团'],
// 聊天设置里的开关;key 同时是存储键和 body 类名,改名会丢掉已存的设置
filters: {
hideWelcomeBot: { key: 'sg-hide-welcome-bot', label: '隐藏欢迎机器人', default: false },
hideInteraction: { key: 'sg-hide-interaction', label: '隐藏礼物选单', default: false },
hideGifts: { key: 'sg-hide-gifts', label: '隐藏互动信息', default: false },
mergeRepeats: { key: 'sg-merge-repeats', label: '合并重复消息', default: true },
dimAds: { key: 'sg-dim-ads', label: '弱化广告显示', default: true },
autoGuard: { key: 'sg-auto-guard', label: '自动举报广告', default: true },
},
// 各类消息在网站上的类名片段
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 STROKE = 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
// 静音图标
const ICON_MUTE = `<svg viewBox="0 0 24 24" ${STROKE}><path d="M3.5 9.5h3.2L11 6v12l-4.3-3.5H3.5z"/><path d="M15.5 9.5l5 5m0-5l-5 5"/></svg>`;
// 警告图标(举报)
const ICON_REPORT =
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.846 18.346V16.037H13.154V18.346H10.846ZM12 9.113C11.363 9.113 10.846 9.63 10.846 10.267V12.575C10.846 13.213 11.363 13.729 12 13.729 12.637 13.729 13.154 13.213 13.154 12.575V10.267C13.154 9.63 12.637 9.113 12 9.113Z"/><path fill-rule="evenodd" d="M1.31319 18.3458C0.424541 19.8808 1.53246 21.8081 3.30975 21.8081H20.6903C22.4675 21.8081 23.5755 19.8808 22.6868 18.3458L13.9966 3.33119C13.1079 1.79626 10.8921 1.79626 10.0034 3.33119L1.31319 18.3458ZM12 4.48527L20.6903 19.4999H3.30975L12 4.48527Z"/></svg>';
const ICON_LOADING = `<svg class="sg-spinner" viewBox="0 0 24 24" ${STROKE}><circle class="sg-track" cx="12" cy="12" r="8"/><circle class="sg-arc" cx="12" cy="12" r="8"/></svg>`;
const ICON_DONE = `<svg class="sg-ok" viewBox="0 0 24 24" ${STROKE}><circle cx="12" cy="12" r="9"/><path class="sg-check" pathLength="1" d="M8 12.3l2.7 2.7L16 9.7"/></svg>`;
// ===================== 工具 =====================
const warn = (...args) => console.warn('[Guard]', ...args);
// 字间可能夹的空白、零宽字符、标点和符号
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 = `
/* 隐藏聊天区滚动条 */
.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; }
/* 聊天区的滚动轨道不占位:轨道按 scrollTop 定位,切到消息少的房间时会撑住旧高度,消息全在可视区上方 */
.model-chat-content .ps__rail-x, .model-chat-content .ps__rail-y { display: none !important; }
/* 页面滚动条改成贴合深色页面的细条,去掉白色轨道和箭头 */
html::-webkit-scrollbar { width: 8px; height: 8px; background: transparent; }
html::-webkit-scrollbar-button { display: none; }
html::-webkit-scrollbar-track, html::-webkit-scrollbar-corner { background: transparent; }
html::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, .18); border-radius: 4px;
border: 2px solid transparent; background-clip: padding-box;
}
html::-webkit-scrollbar-thumb:hover { background-color: rgba(255, 255, 255, .35); }
/* 只给 Firefox 设 scrollbar-color,因为 Chromium 设了它会让上面的规则失效 */
@supports not selector(::-webkit-scrollbar) {
html { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, .25) transparent; }
}
/* 加宽「新消息」按钮的点击区域 */
.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;
}
/* 快捷按钮在三点菜单左侧,悬停消息时才出现 */
.sg-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 .sg-ad-actions,
.fullscreen-message-wrapper:hover .sg-ad-actions { opacity: 1 !important; pointer-events: auto !important; }
/* 广告消息里由快捷按钮顶到右侧,三点菜单紧挨着它 */
.sg-ad-flagged .message-more-menu { margin-left: 4px !important; }
/* 全屏模式下快捷按钮和三点菜单同级 */
.fullscreen-message-wrapper > .sg-ad-actions { margin-left: 4px !important; padding-left: 0 !important; }
/* 快捷按钮照三点菜单的样子:22px 圆形热区、透明底、16px 图标,悬停才出底色 */
.sg-ad-actions button {
border: none !important; border-radius: 50% !important; background: transparent !important;
width: 22px !important; height: 22px !important; padding: 0 !important;
display: flex !important; align-items: center !important; justify-content: center !important;
color: rgba(255,255,255,.7) !important;
cursor: pointer !important; transition: background .15s, color .15s !important;
}
.sg-ad-actions button svg { width: 16px; height: 16px; flex-shrink: 0; 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; }
/* 确认本房间能静音后才显示静音按钮 */
body:not(.sg-can-mute) .sg-quick-mute { display: none !important; }
/* 未登录或举报暂停期间隐藏举报按钮 */
body.sg-logged-out .sg-quick-report,
body.sg-report-paused .sg-quick-report { display: none !important; }
/* 静音悬停用网站主色红,举报悬停和三点菜单一样泛白 */
.sg-quick-mute:hover { background: rgba(250,83,101,.16) !important; color: #fa5365 !important; }
.sg-quick-report:hover { background: rgba(255,255,255,.12) !important; color: #f8f8f8 !important; }
/* 已完成的按钮只剩绿色对勾,不能再点,以免和待处理的按钮混淆 */
.sg-ad-actions button.sg-done { background: transparent !important; pointer-events: none !important; }
/* 转圈和对勾,快捷按钮和原生菜单共用,绿色取自网站的在线状态 */
.sg-spinner { animation: sg-spin .9s linear infinite; }
.sg-spinner .sg-track { opacity: .25; }
.sg-spinner .sg-arc { stroke-dasharray: 14 51; }
.sg-ok { color: #9fe20d; }
.sg-ok .sg-check { stroke-dasharray: 1; stroke-dashoffset: 1; animation: sg-draw .3s .08s ease-out forwards; }
@keyframes sg-spin { to { transform: rotate(360deg); } }
/* 原生菜单的举报按钮换成「举报中」「举报完成」,期间不能再点;图标和间距照原按钮 */
.sg-native-state { pointer-events: none !important; }
.sg-native-state > :not(.sg-status) { display: none !important; }
.sg-status { display: inline-flex !important; align-items: center !important; gap: 4px !important; animation: sg-fade-in .16s ease-out; }
.sg-status svg { width: 20px; height: 20px; flex-shrink: 0; }
@keyframes sg-fade-in { from { opacity: 0; } }
@keyframes sg-draw { to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) {
.sg-status { animation: none !important; }
.sg-ok .sg-check { animation: none !important; stroke-dashoffset: 0; }
}
/* 广告消息 */
.sg-ad-flagged {
background: rgba(255,60,60,.15) !important; border-left: 2px solid rgba(255,60,60,.6) !important;
border-radius: 2px !important;
}
.sg-ad-flagged .user-levels-username {
background: rgba(220,50,50,.85) !important; color: #fff !important;
border-radius: 3px !important; padding: 0 5px !important;
}
.sg-ad-flagged .user-levels-username-text { color: #fff !important; }
/* 已举报 */
.sg-ad-reported {
background: rgba(120,120,120,.15) !important; border-left-color: rgba(120,120,120,.4) !important;
opacity: .6 !important;
}
.sg-ad-reported .user-levels-username { background: rgba(100,100,100,.7) !important; }
/* 在用户名后加「[已举报]」,好让用户名那一列照旧左对齐;字号行高跟用户名一致 */
.sg-ad-reported .message-username::after {
content: '[已举报]'; margin-left: 6px;
font-size: 12px; font-weight: 400; line-height: 18px; color: rgba(255,255,255,.5);
}
/* 已静音 */
.sg-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;
}
.sg-ad-muted .user-levels-username {
background: rgba(140,120,30,.7) !important; color: rgba(255,220,120,.9) !important;
}
.sg-ad-muted .user-levels-username-text { color: rgba(255,220,120,.9) !important; }
/* 设置面板里的开关:原生过渡 .36s 偏慢,只在本脚本的分组里加快 */
[data-sg-section] [class*="Switcher"] { transition-duration: .12s !important; transition-timing-function: ease-out !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; }
/* 重复的礼物和互动消息只显示最新一条,次数跟在第一行后面 */
body.sg-merge-repeats [data-sg-dup] { display: none !important; }
.sg-count { display: none; }
body.sg-merge-repeats .sg-count {
display: inline; margin-left: 6px; font-weight: 700; color: rgba(255,255,255,.7);
white-space: nowrap; font-variant-numeric: tabular-nums;
}
/* 「×」的中心比数字和中文低约 0.1em,提上去和正文对齐 */
.sg-count-x { position: relative; top: -.1em; }
/* 次数变化时旧数字向上滚出,新数字从下面滚进来;两个数字叠在同一格,好让数字仍按基线和正文对齐 */
.sg-count-roll { display: inline-grid; clip-path: inset(0); text-align: right; }
.sg-count-roll > span { grid-area: 1 / 1; animation: .35s cubic-bezier(.2,.8,.2,1); }
.sg-count-roll > :first-child { transform: translateY(-100%); animation-name: sg-roll-out; }
.sg-count-roll > :last-child { animation-name: sg-roll-in; }
@keyframes sg-roll-out { from { transform: none; } }
@keyframes sg-roll-in { from { transform: translateY(100%); } }
@media (prefers-reduced-motion: reduce) {
.sg-count-roll > span { animation: none !important; }
}
/* 弱化广告,给没有静音和举报权限的用户用 */
body.sg-dim-ads .sg-ad-flagged { background: none !important; border-left: none !important; opacity: .3 !important; transition: opacity .15s !important; }
body.sg-dim-ads .sg-ad-flagged:hover { opacity: .8 !important; }
/* 去掉底色后也去掉给底色留的内边距,好让用户名和普通消息左对齐 */
body.sg-dim-ads .sg-ad-flagged .user-levels-username { background: none !important; padding: 0 !important; }
/* 正文单行铺到最右,超出部分悬停时用滚轮横向滚动 */
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] { position: relative !important; }
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > div:first-child {
flex: 1 1 auto !important; min-width: 0 !important;
flex-wrap: nowrap !important; white-space: nowrap !important;
overflow-x: auto !important; overflow-y: hidden !important; scrollbar-width: none !important;
}
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > div:first-child::-webkit-scrollbar { display: none !important; }
/* 用户名不收缩,以免被正文挤成 0 宽,连「[已举报]」一起看不见 */
body.sg-dim-ads .sg-ad-flagged .message-username { flex-shrink: 0 !important; }
/* 快捷按钮和三点菜单浮在右侧,不占正文宽度,悬停时才出现 */
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > .sg-ad-actions,
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > .message-more-menu {
position: absolute !important; top: 50% !important; transform: translateY(-50%) !important;
}
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > .message-more-menu { right: 0 !important; margin: 0 !important; z-index: 1 !important; }
/* 按钮区左侧渐变到聊天背景色,好让正文在图标下淡出,不叠在一起 */
body.sg-dim-ads .sg-ad-flagged [class*="RegularMessage__contentWithControls"] > .sg-ad-actions {
right: 0 !important; margin: 0 !important; padding: 0 26px 0 20px !important;
background: linear-gradient(to right, transparent, rgb(38,38,38) 20px) !important;
}
body.sg-dim-ads .sg-ad-flagged:not(:hover) .message-more-menu { opacity: 0 !important; pointer-events: none !important; }
`;
const styleEl = document.createElement('style');
styleEl.id = 'sg-style';
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',
'douyin',
'baidu',
'reddit',
'wikipedia',
'github',
'discord',
'twitch',
'whatsapp',
'telegram',
't\\.me',
'line\\.me',
'imgur',
'spotify',
'netflix',
'amazon',
'amzn',
'yfsp',
'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 === '<spaced-domain>') return `(?<![a-z0-9])${SAFE_DOMAIN_RE}(?:[a-z0-9]\\s+){2,}[a-z0-9]?\\s*[.‧·•∙・。\\u2800-\\u28ff]\\s*(?:t\\s*o\\s*p|c\\s*c|c\\s*o\\s*m|x\\s*y\\s*z|v\\s*i\\s*p|c\\s*n)(?![a-z])`;
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('[Guard] 广告规则编译失败,广告识别已停用', 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 SWITCH_ON_KEY = 'sg-switch-on-class';
const getSwitchOnClass = () => {
for (const el of document.querySelectorAll('[class*="Switcher_checked"]')) {
const cls = [...el.classList].find(c => c.startsWith('Switcher_checked'));
if (cls) {
localStorage.setItem(SWITCH_ON_KEY, cls);
return cls;
}
}
return localStorage.getItem(SWITCH_ON_KEY) || 'Switcher_checked#Da';
};
// 在「聊天外观」后面加一组开关,照抄原生分组的结构和类名,好让样式跟网站一致
const injectSettings = () => {
if (document.querySelector('[data-sg-section]')) return;
const tplBtn = document.querySelector('[data-testid="timestamp-chat-settings-button"]');
const tplSection = tplBtn?.closest('[data-testid="model-chat-appearance-section"]');
const tplHeader = tplSection?.querySelector('[class*="ChatSection__header"]');
if (!tplHeader) return;
const onCls = getSwitchOnClass();
const section = tplSection.cloneNode(false);
section.removeAttribute('data-testid');
section.dataset.sgSection = '1';
const header = tplHeader.cloneNode(false);
header.textContent = '骑士助手';
const ul = document.createElement('ul');
section.append(header, ul);
for (const f of Object.values(CONFIG.filters)) {
const li = tplBtn.closest('li').cloneNode(true);
li.dataset.sgFilter = f.key;
const btn = li.querySelector('button');
btn.removeAttribute('data-testid');
btn.querySelector('[class*="ChatSettingsItem__titleWrapper"] span').textContent = f.label;
const sw = btn.querySelector('[class*="Switcher_size"]');
const input = sw.querySelector('input');
const render = on => {
sw.classList.toggle(onCls, on);
input.checked = on;
input.setAttribute('aria-checked', String(on));
};
render(getFilter(f));
// 拦下点击,以免切换开关时设置面板收起
btn.onclick = e => {
e.stopPropagation();
const next = !getFilter(f);
setFilter(f, next);
document.body.classList.toggle(f.key, next);
render(next);
};
ul.appendChild(li);
}
tplSection.after(section);
};
// ===================== 平台 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;
}
};
// 每次从 store 读当前房间 ID,因为切换房间不会刷新页面;
// 页面上的 [data-model-id] 属于相关主播列表,不是当前房间
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();
// 发送带 CSRF 的请求,返回 { ok, status, data };status 为 0 表示没拿到响应,原因在 error 里
const apiPost = async (url, method, body) => {
const csrf = await fetchCsrf();
if (!csrf) return { ok: false, status: 0, data: null, error: '没拿到 CSRF' };
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 (e) {
return { ok: false, status: 0, data: null, error: String(e) };
}
};
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' });
};
// 查当前账号能否在本房间静音这些用户,网站的静音按钮也按它显示;请求失败时返回 null
const fetchCanMute = async (hostId, userIds) => {
try {
const qs = userIds.map(id => `userIds[]=${id}`).join('&');
const res = await fetch(`/api/front/models/${hostId}/relations?${qs}`);
if (!res.ok) return null;
const { data } = await res.json();
return data.some(r => r.canToggleMute || r.canMute);
} catch {
return null;
}
};
// 举报一条消息,和网站一样先过 checking,被限流时这一步就会拒绝并给出 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' });
};
// ===================== 操作状态 =====================
const mutedUsers = new Set();
const reportedUsers = new Set();
// 静音和举报是否可用,手动和自动操作共用,也决定快捷按钮显不显示
const REPORT_PAUSE = 10 * 60 * 1000; // 举报被限流或拒绝后暂停 10 分钟
const isAuthError = status => status === 401 || status === 403;
const isReportBlocked = r => !!r.limitType || r.status === 429 || isAuthError(r.status);
let canMute = false; // 本房间是否能静音,进房后查一次,被拒时关掉
const setCanMute = v => {
canMute = v;
document.body.classList.toggle('sg-can-mute', v);
};
let reportPausedUntil = 0; // 举报暂停到的时刻,期间不自动举报,也隐藏举报按钮
let reportPauseTimer = 0;
const pauseReport = ms => {
clearTimeout(reportPauseTimer);
reportPausedUntil = ms ? Date.now() + ms : 0;
document.body.classList.toggle('sg-report-paused', ms > 0);
if (ms) reportPauseTimer = setTimeout(() => pauseReport(0), ms);
};
// 把按钮换成绿色对勾
const markBtn = btn => {
if (!btn) return;
btn.innerHTML = ICON_DONE;
btn.classList.add('sg-done');
};
const setBusy = btn => {
if (!btn) return;
if (btn.dataset.sgOrig === undefined) btn.dataset.sgOrig = btn.innerHTML;
btn.innerHTML = ICON_LOADING;
btn.style.pointerEvents = 'none';
};
const resetBtn = btn => {
if (!btn) return;
if (btn.dataset.sgOrig !== undefined) btn.innerHTML = btn.dataset.sgOrig;
delete btn.dataset.sgOrig;
btn.style.pointerEvents = '';
};
// 隐藏失败的快捷按钮并记下原因,因为它没有网站弹窗可以兜底
const hideBtn = (btn, reason) => {
warn(reason);
if (!btn) return;
resetBtn(btn);
btn.style.setProperty('display', 'none', 'important');
};
const failReason = (action, r) => {
if (r.limitType || r.status === 429) return `${action}被限流(${r.limitType || 'HTTP 429'})`;
if (isAuthError(r.status)) return `${action}被拒绝:未登录或没有权限(HTTP ${r.status})`;
if (r.status === 0) return `${action}失败:${r.error || '网络错误'}`;
return `${action}失败(HTTP ${r.status})`;
};
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('sg-ad-muted');
markBtn(msg.querySelector('.sg-quick-mute'));
markSameUser(msg, username, 'sg-ad-muted', '.sg-quick-mute');
scrollToBottom();
};
const markReported = (msg, username) => {
if (username) reportedUsers.add(username);
msg.classList.add('sg-ad-reported');
markBtn(msg.querySelector('.sg-quick-report'));
markSameUser(msg, username, 'sg-ad-reported', '.sg-quick-report');
scrollToBottom();
};
// ===================== 手动静音和举报 =====================
const NATIVE_DONE_MS = 800; // 「举报完成」停留多久再收起菜单
// 在原生按钮里叠一层状态,不改 innerHTML,因为它的子节点归 React 管
const setNativeState = (btn, icon, text) => {
let el = btn.querySelector(':scope > .sg-status');
if (!el) {
// 锁住按钮宽度,以免换成较短的「举报中」时菜单跟着缩
btn.style.minWidth = `${btn.offsetWidth}px`;
el = document.createElement('span');
btn.appendChild(el);
}
el.className = 'sg-status';
el.innerHTML = `${icon}<span>${text}</span>`;
btn.classList.add('sg-native-state');
};
const clearNativeState = btn => {
btn.querySelector(':scope > .sg-status')?.remove();
btn.classList.remove('sg-native-state');
btn.style.minWidth = '';
};
// 带标记再点一次原生按钮,交回网站自己的确认框或举报弹窗
const clickNative = btn => {
if (!btn || !btn.isConnected) return;
btn.dataset.sgNative = '1';
btn.click();
};
// 操作失败时,原生按钮交回网站处理,快捷按钮就隐藏
const failAction = (btn, nativeBtn, reason) => {
if (!nativeBtn) return hideBtn(btn, reason);
warn(reason);
resetBtn(btn);
clearNativeState(nativeBtn);
clickNative(nativeBtn);
};
const doMute = (msg, btn, nativeBtn) => {
setBusy(btn);
const targetId = getUserId(msg);
if (!targetId) return failAction(btn, nativeBtn, '静音失败:消息里找不到用户 ID');
muteUser(targetId).then(r => {
if (r.ok) return markMuted(msg, getUsername(msg));
if (isAuthError(r.status)) setCanMute(false);
failAction(btn, nativeBtn, failReason('静音', r));
});
};
// 举报一条消息;从原生菜单发起时,成功后先显示「举报完成」,再用 closeMenu 收起菜单
const doReport = (msg, btn, nativeBtn, closeMenu) => {
setBusy(btn);
if (nativeBtn) setNativeState(nativeBtn, ICON_LOADING, '举报中');
const messageId = Number(msg.dataset.messageId);
if (!messageId) return failAction(btn, nativeBtn, '举报失败:消息上没有 data-message-id');
reportMsg(messageId).then(r => {
if (r.ok) {
if (nativeBtn) {
setNativeState(nativeBtn, ICON_DONE, '举报完成');
setTimeout(() => {
// 如果用户已经关掉菜单,就不再收起,以免反而把菜单重新打开
if (!nativeBtn.isConnected) return;
clearNativeState(nativeBtn);
closeMenu?.();
}, NATIVE_DONE_MS);
}
return markReported(msg, getUsername(msg));
}
if (isReportBlocked(r)) pauseReport(REPORT_PAUSE);
failAction(btn, nativeBtn, failReason('举报', r));
});
};
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('.sg-ad-actions')) return;
const wrapper = msg.closest('.fullscreen-message-wrapper');
if (wrapper?.querySelector('.sg-ad-actions')) return;
const actions = document.createElement('div');
actions.className = 'sg-ad-actions';
actions.appendChild(createBtn('sg-quick-mute', ICON_MUTE, btn => doMute(msg, btn)));
actions.appendChild(createBtn('sg-quick-report', ICON_REPORT, btn => doReport(msg, btn)));
// 放在三点菜单前面;全屏模式放到外层,和三点菜单同级
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);
};
// 找按钮所属的消息,全屏模式下从外层 wrapper 找
const msgOf = el => el.closest('.message-base') || el.closest('.fullscreen-message-wrapper')?.querySelector('.message-base');
// 最近点开的三点菜单和它的消息,因为菜单弹层渲染在消息外面,没法从 DOM 反查
let menuMsg = null;
let menuBtn = null;
// 消息菜单里的举报按钮,不含同样叫 ReportButton 的顶栏「举报模特」
const MENU_REPORT = '.message-more-menu__popover [class*="ReportButton"]';
// 原生静音和举报先走 API;找不到所属消息时(如用户卡片里的静音按钮)交给网站处理
document.body.addEventListener(
'click',
e => {
const btn = e.target.closest(`.message-more-menu, .mute-button, ${MENU_REPORT}`);
if (!btn) return;
// 放行 clickNative 交回网站的点击
if (btn.dataset.sgNative) {
delete btn.dataset.sgNative;
return;
}
// 点开三点菜单时记下它属于哪条消息
if (btn.classList.contains('message-more-menu')) {
menuMsg = msgOf(btn) || menuMsg;
menuBtn = btn;
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 = menuMsg?.isConnected ? menuMsg : null;
if (!msg) return;
e.stopPropagation();
e.preventDefault();
// 再点一次三点按钮就能收起菜单
const trigger = menuBtn;
doReport(msg, null, btn, () => trigger?.isConnected && trigger.click());
},
true
);
// ===================== 举报弹窗 =====================
const REPORT_FORM = 'form[class*="ReportModal__form"]';
const REPORT_REASONS = ['spam', 'spamming']; // 消息举报和用户举报里的「垃圾信息或发送广告」
const reactFiber = el => el[Object.keys(el).find(k => k.startsWith('__reactFiber$'))];
// 在表单的组件树里找原因下拉框,好直接选中选项,不必展开菜单
const findReasonSelect = form => {
const queue = [reactFiber(form)?.child];
for (let i = 0; i < queue.length && i < 500; i++) {
const fiber = queue[i];
if (!fiber) continue;
const p = fiber.memoizedProps;
if (typeof p?.onSelect === 'function' && Array.isArray(p.options)) return p;
queue.push(fiber.child, fiber.sibling);
}
return null;
};
// 填入预设举报详情,但只在用户还没写东西时
const fillReportDetails = form => {
const ta = form.querySelector('textarea[name="details"]') || form.querySelector('textarea');
if (!ta || ta.value.trim()) return;
// 用原生 setter 赋值,React 才能收到这次输入
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(ta, CONFIG.reportText);
ta.dispatchEvent(new Event('input', { bubbles: true }));
};
// 网站举报弹窗一出现就选好广告原因、填好详情,等用户自己点「上报」
const autofillReportForm = form => {
if (form.dataset.sgFilled) return;
form.dataset.sgFilled = '1';
try {
const select = findReasonSelect(form);
if (!select) return warn('举报弹窗没能自动选原因:找不到下拉框');
const spam = select.options.find(o => REPORT_REASONS.includes(o.value));
// 举报主播、举报内容的弹窗没有广告选项,保持原样
if (!spam) return;
if (!select.value) select.onSelect(spam);
fillReportDetails(form);
} catch (e) {
warn('举报弹窗自动填写失败', e);
}
};
// ===================== 自动举报和静音 =====================
{
let lastId = 0;
let started = false;
let roomId = null;
let permChecked = false;
let permPending = false;
let permRetryAt = 0;
let loginWarned = false;
let pollErrorLogged = false;
const handled = new Set(); // 本房间处理完、不再重试的用户
const pending = new Set(); // 正在处理的用户
const isTransient = status => status === 0 || status >= 500;
// 进房后确认能否静音:主播本人和本房骑士直接放行,其他身份拿最近几位发言者问服务端
const checkMutePermission = (state, msgs) => {
if (permChecked || permPending || Date.now() < permRetryAt) return;
const myId = state.userSession?.currentUser?.id;
if (!state.userSession?.isLoggedIn || !myId) return;
if (myId === roomId || state.knights?.knightsCollection?.[roomId]?.includes(myId)) {
setCanMute(true);
permChecked = true;
return;
}
const targets = [...new Set(msgs.map(m => m.userData?.id).filter(id => id && id !== myId && id !== roomId))].slice(-5);
if (!targets.length) return;
permPending = true;
const room = roomId;
fetchCanMute(room, targets).then(ok => {
if (room !== roomId) return;
permPending = false;
// 如果请求失败,就 30 秒后再查,期间按没有权限处理
if (ok === null) {
permRetryAt = Date.now() + 30 * 1000;
return;
}
permChecked = true;
setCanMute(ok);
console.info(`[Guard] 本房间静音权限:${ok ? '有' : '无,已隐藏静音按钮'}`);
});
};
// 自动举报并静音一名广告用户,暂停或没有权限的那项跳过
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, 'sg-ad-reported', '.sg-quick-report');
return;
}
if (isReportBlocked(r)) {
pauseReport(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, 'sg-ad-muted', '.sg-quick-mute');
return;
}
if (isAuthError(r.status)) {
setCanMute(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);
};
// 每 500 毫秒从 store 取新消息,好在渲染前识别广告
const poll = () => {
try {
const state = window.StripChat?.getState?.();
if (state?.userSession) document.body.classList.toggle('sg-logged-out', !state.userSession.isLoggedIn);
const msgs = state?.publicChat?.messages?.server;
if (!msgs?.length) return;
// 换了房间就重新对齐起点、清空上个房间的状态,因为换房间不会刷新页面
const curRoom = getModelId();
if (curRoom !== roomId) {
roomId = curRoom;
started = false;
setCanMute(false);
permChecked = permPending = false;
permRetryAt = 0;
pauseReport(0);
handled.clear();
pending.clear();
mutedUsers.clear();
reportedUsers.clear();
}
checkMutePermission(state, msgs);
// 进房时已有的消息不处理
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);
}
// ===================== 合并重复消息 =====================
const MERGE_WINDOW = 20; // 往回找多少条消息,大约一屏
const MERGEABLE = '.sg-msg-gift, .sg-msg-interaction';
// 消息所在的一行,全屏模式下是外层 wrapper
const rowOf = msg => msg.closest('.fullscreen-message-wrapper') || msg;
// 比对用的文字,在标上次数前记下,并去掉时间,以免开着时间显示时认不出重复
const repeatKeys = new WeakMap();
const repeatKey = msg => {
if (!repeatKeys.has(msg)) {
const ts = msg.querySelector('[class*="timestamp"]');
repeatKeys.set(msg, (ts ? msg.textContent.replace(ts.textContent, '') : msg.textContent).trim());
}
return repeatKeys.get(msg);
};
// 次数跟在消息第一行后面;图标、正文和按钮排成网格的消息,放进正文那一格,以免次数另占一格
const countTarget = msg => {
const first = msg.querySelector('.message-body')?.firstElementChild;
if (!first) return msg;
if (getComputedStyle(first).display !== 'grid') return first;
return [...first.children].find(el => el.tagName !== 'BUTTON' && el.textContent.trim()) || first;
};
// 标上次数,数字从上一个值滚到这次的值
const showCount = (msg, count) => {
const badge = document.createElement('span');
badge.className = 'sg-count';
badge.innerHTML = `<span class="sg-count-x">×</span><span class="sg-count-roll"><span aria-hidden="true">${count - 1}</span><span>${count}</span></span>`;
countTarget(msg).appendChild(badge);
};
// 一屏内出现相同的礼物或互动消息时,收起较早那条,次数累加到新消息上;
// 收起和次数的显示交给 CSS,好让关掉开关就恢复原样
const mergeRepeat = msg => {
if (!msg.matches(MERGEABLE)) return;
const key = repeatKey(msg);
if (!key) return;
let row = rowOf(msg).previousElementSibling;
for (let seen = 0; row && seen < MERGE_WINDOW; row = row.previousElementSibling) {
if (row.dataset.sgDup) continue;
seen++;
const prev = row.matches('.message-base') ? row : row.querySelector('.message-base');
if (!prev?.matches(MERGEABLE) || repeatKey(prev) !== key) continue;
const count = Number(prev.dataset.sgCount || 1) + 1;
prev.querySelector('.sg-count')?.remove();
row.dataset.sgDup = '1';
msg.dataset.sgCount = count;
showCount(msg, count);
return;
}
};
// ===================== 聊天消息 =====================
// 给消息归类、标出广告,并同步已静音和已举报的状态
const processMessage = msg => {
if (msg.dataset.sgProcessed) return;
msg.dataset.sgProcessed = '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');
mergeRepeat(msg);
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('sg-ad-flagged');
injectAdActions(msg);
}
}
// 同步已静音、已举报的状态,但跳过系统消息,以免标到「已被静音」提示上
if (!msg.classList.contains('m-bg-system')) {
const name = getUsername(msg);
if (name) {
if (mutedUsers.has(name)) {
msg.classList.add('sg-ad-muted');
markBtn(msg.querySelector('.sg-quick-mute'));
}
if (reportedUsers.has(name)) {
msg.classList.add('sg-ad-reported');
markBtn(msg.querySelector('.sg-quick-report'));
}
}
}
};
document.querySelectorAll('.message-base').forEach(processMessage);
// 弱化广告时,在单行正文上把竖向滚轮转成横向滚动
document.addEventListener(
'wheel',
e => {
if (!document.body.classList.contains(CONFIG.filters.dimAds.key) || e.shiftKey) return;
const el = e.target.closest?.('.sg-ad-flagged [class*="RegularMessage__contentWithControls"] > div:first-child');
if (!el || el.scrollWidth <= el.clientWidth) return;
const delta = e.deltaY || e.deltaX;
const max = el.scrollWidth - el.clientWidth;
// 已经滚到头就交还给页面
if ((delta < 0 && el.scrollLeft <= 0) || (delta > 0 && el.scrollLeft >= max - 1)) return;
e.preventDefault();
el.scrollLeft += delta;
},
{ passive: false }
);
// 处理新进来的消息,举报弹窗出现时自动填写,设置面板打开时补上开关
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-sg-processed])').forEach(processMessage);
// 举报弹窗随整棵子树一起挂载,先用 form 计数筛掉聊天消息
if (node.tagName === 'FORM' || node.getElementsByTagName('form').length) {
const form = node.matches(REPORT_FORM) ? node : node.querySelector(REPORT_FORM);
if (form) autofillReportForm(form);
}
}
}
// 设置面板 300 毫秒内只查一次
if (!settingsTimer)
settingsTimer = setTimeout(() => {
settingsTimer = 0;
injectSettings();
}, 300);
}).observe(document.body, { childList: true, subtree: true });
};
// 把主体注入页面执行,因为脚本管理器可能把脚本放进读不到 StripChat 的隔离环境;
// 如果页面禁止内联脚本,注入不生效,就在当前环境运行
(() => {
const script = document.createElement('script');
script.textContent = `(${sgMain})();`;
(document.head || document.documentElement).appendChild(script);
script.remove();
sgMain();
})();