您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Pulls auto-silence usernames + trigger words from Supabase periodically. CSP-safe via GM_xmlhttpRequest. Adds Silence button (if mod), auto-silences or highlights. SPA-safe.
// ==UserScript== // @name CB Quick Silence // @namespace aravvn.tools // @version 2.0.5 // @description Pulls auto-silence usernames + trigger words from Supabase periodically. CSP-safe via GM_xmlhttpRequest. Adds Silence button (if mod), auto-silences or highlights. SPA-safe. // @author aravvn // @license CC-BY-NC-SA-4.0 // @match https://*.chaturbate.com/* // @run-at document-idle // @noframes // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect *.supabase.co // @connect supabase.co // ==/UserScript== (() => { 'use strict'; // ----------- CONFIG ----------- const SUPABASE_URL = 'https://gbscowfidfdiaywktqal.supabase.co'; const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imdic2Nvd2ZpZGZkaWF5d2t0cWFsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYxNDU0MzUsImV4cCI6MjA3MTcyMTQzNX0.BITW_rG0-goeo8VCgl-ZTSWfa8BDYsmT2xhIg-9055g'; const SUPABASE_TABLE_USERS = 'auto_silence_users'; const SUPABASE_TABLE_WORDS = 'trigger_words'; const DEFAULT_POLL_MS = 300_000; const FALLBACK_USERS = []; const FALLBACK_WORDS = []; const CONFIG_DEFAULTS = { enableAutoSilence: true, enableHighlighting: true, showSilenceButtons: true, pollMs: DEFAULT_POLL_MS, }; // ----------- LOG/UTIL ----------- const DEBUG = true; const log = (...a) => DEBUG && console.log('[QS]', ...a); const getCfg = (k) => GM_getValue(k, CONFIG_DEFAULTS[k]); const setCfg = (k, v) => GM_setValue(k, v); function toast(msg) { const t = document.createElement('div'); t.className = 'qs_toast'; t.textContent = msg; document.body.appendChild(t); requestAnimationFrame(() => t.classList.add('show')); setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 180); }, 1600); } function stripTs(s) { return String(s || '').replace(/^[\[]\d{2}:\d{2}[]]\s*/, '').trim(); } function getCookie(name) { return document.cookie.split('; ').reduce((r, v) => { const parts = v.split('='); return parts[0] === name ? decodeURIComponent(parts[1]) : r; }, ''); } function getRoomFromURL() { const first = location.pathname.replace(/^\/+|\/+$/g, '').split('/')[0] || ''; return (!first || ['b','p','tags','api','auth','proxy'].includes(first)) ? '' : first; } function waitForSelector(selector, timeout = 5000) { return new Promise((resolve) => { const found = document.querySelector(selector); if (found) return resolve(found); const obs = new MutationObserver(() => { const el = document.querySelector(selector); if (el) { obs.disconnect(); resolve(el); } }); obs.observe(document.documentElement, { childList: true, subtree: true }); setTimeout(() => { obs.disconnect(); resolve(null); }, timeout); }); } // ----------- DOM/CSS ----------- const SEL = { rootMessage: 'div[data-testid="chat-message"]', usernameContainer: 'div[data-testid="chat-message-username"]', username: 'span[data-testid="username"]', viewerUsername: '.user_information_header_username, [data-testid="user-information-username"]', }; const style = document.createElement('style'); style.textContent = ` .qs_sil_btn{display:inline-block;margin-left:6px;padding:0 6px;font:11px/18px system-ui,sans-serif;border-radius:4px;cursor:pointer;user-select:none;background:#d13b3b;color:#fff;border:0} .qs_sil_btn:hover{filter:brightness(1.06)} .qs_toast{position:fixed;right:12px;bottom:12px;z-index:2147483647;background:#151515;color:#e9e9ea;border:1px solid #3a3a3d;border-radius:8px;padding:8px 10px;font:12px system-ui,sans-serif;box-shadow:0 6px 18px rgba(0,0,0,.45);opacity:0;transform:translateY(8px);transition:all .2s ease} .qs_toast.show{opacity:1;transform:translateY(0)} .qs_flagged_user { border-left: 4px solid #f40 !important; background: rgba(255,80,80,0.08) !important; } .qs_flagged_word { border-left: 4px solid #f93 !important; background: rgba(255,160,60,0.08) !important; } `; document.head.appendChild(style); // ----------- GM_xmlhttpRequest wrapper (CSP-safe) ----------- function gmRequestJSON(method, url, headers = {}, data = null) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, headers, data, responseType: 'json', onload: (res) => { const status = res.status; const text = res.responseText; if (status >= 200 && status < 300) { try { resolve(JSON.parse(text || 'null')); } catch { resolve(res.response ?? null); } } else { reject(new Error(`HTTP ${status}: ${text?.slice(0, 200)}`)); } }, onerror: (e) => reject(new Error('Network error')), ontimeout: () => reject(new Error('Timeout')), }); }); } const hasSupabase = () => SUPABASE_URL.startsWith('https://') && SUPABASE_ANON_KEY && SUPABASE_ANON_KEY.length > 20; function buildUrl(path, params = {}) { const url = new URL(`${SUPABASE_URL}/rest/v1/${path}`); const defaultParams = { select: '*', order: 'updated_at.desc', limit: '10000' }; const q = new URLSearchParams({ ...defaultParams, ...params }); url.search = q.toString(); return url.toString(); } async function sbGet(path, params = {}) { const url = buildUrl(path, params); return gmRequestJSON('GET', url, { apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}`, Accept: 'application/json', }); } async function fetchUsers() { const rows = await sbGet(SUPABASE_TABLE_USERS, { select: 'username,is_active,updated_at', 'is_active': 'eq.true' }); return rows .filter(r => r && r.username) .map(r => String(r.username).toLowerCase().trim()) .filter(Boolean); } async function fetchWords() { const rows = await sbGet(SUPABASE_TABLE_WORDS, { select: 'word,is_active,updated_at', 'is_active': 'eq.true' }); return rows .filter(r => r && r.word) .map(r => String(r.word).toLowerCase().trim()) .filter(Boolean); } // ----------- State ----------- let AUTO_SILENCE_USERS = [...FALLBACK_USERS.map(s => s.toLowerCase())]; let TRIGGER_WORDS = [...FALLBACK_WORDS.map(s => s.toLowerCase())]; let lastUsersSig = JSON.stringify(AUTO_SILENCE_USERS); let lastWordsSig = JSON.stringify(TRIGGER_WORDS); let pollTimer = null; function applyNewLists(users, words) { let changed = false; if (users) { const mergedUsers = Array.from(new Set([...FALLBACK_USERS.map(s => s.toLowerCase()), ...users])); AUTO_SILENCE_USERS = mergedUsers; changed = true; } if (words) { const mergedWords = Array.from(new Set([...FALLBACK_WORDS.map(s => s.toLowerCase()), ...words])); TRIGGER_WORDS = mergedWords; changed = true; } if (changed) { rescanExistingForHighlights(); } } async function syncFromSupabaseOnce() { if (!hasSupabase()) return; try { const [users, words] = await Promise.all([fetchUsers(), fetchWords()]); applyNewLists(users, words); setCfg('sb_last_ok', Date.now()); } catch (e) { log('Supabase sync failed:', e.message || e); const lastOk = GM_getValue('sb_last_ok', 0); if (!lastOk) toast('⚠️ Supabase unreachable, using fallback lists'); } } function startPolling() { if (pollTimer) clearInterval(pollTimer); const ms = Math.max(10_000, Number(getCfg('pollMs')) || DEFAULT_POLL_MS); pollTimer = setInterval(syncFromSupabaseOnce, ms); } // ----------- CB actions / logic ----------- async function fetchIsPrivileged(viewer, room) { const url = `${location.origin}/api/ts/chatmessages/user_info/${encodeURIComponent(viewer)}/?room=${encodeURIComponent(room)}`; try { const res = await fetch(url, { credentials: 'include', headers: { 'Accept': 'application/json' } }); const data = await res.json(); return !!(data?.user?.is_mod || data?.user?.is_broadcaster); } catch { return false; } } async function postSilence(username) { const url = `${location.origin}/roomsilence/${encodeURIComponent(username)}${location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'}`; const csrf = getCookie('csrftoken') || getCookie('CSRF-TOKEN') || getCookie('XSRF-TOKEN'); const headers = { 'X-Requested-With': 'XMLHttpRequest', 'Accept': '*/*', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', ...(csrf ? { 'X-CSRFToken': csrf, 'X-CSRF-Token': csrf } : {}), 'Referer': location.href }; const body = 'source=chat'; const res = await fetch(url, { method: 'POST', credentials: 'include', headers, body, redirect: 'manual' }); return res.ok; } function getUsernameFromRoot(root) { const span = root.querySelector(SEL.username) || root.querySelector(`${SEL.usernameContainer} ${SEL.username}`); if (span && span.textContent) return stripTs(span.textContent); const cont = root.querySelector(SEL.usernameContainer); const raw = cont?.textContent || ''; const idx = raw.lastIndexOf(']'); return stripTs(idx >= 0 ? raw.slice(idx + 1) : raw); } let running = false; let currentRoom = ''; let isPrivileged = false; function mountButtonForMessage(root) { if (!(root instanceof HTMLElement) || root.dataset.qsBtn === '1') return; const nameWrap = root.querySelector(SEL.usernameContainer); if (!nameWrap) return; const username = getUsernameFromRoot(root); if (!username) return; const usernameLc = username.toLowerCase(); const msgText = root.textContent.toLowerCase(); const triggeredByWord = TRIGGER_WORDS.find(word => word && msgText.includes(word)); const triggeredByUser = AUTO_SILENCE_USERS.includes(usernameLc); if (isPrivileged && getCfg('showSilenceButtons')) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'qs_sil_btn'; btn.textContent = 'Silence'; btn.addEventListener('click', async (ev) => { ev.preventDefault(); ev.stopPropagation(); const ok = await postSilence(username); toast(ok ? `🛑 Silenced @${username}` : `⚠️ Forbidden @${username}`); root.setAttribute('data-qs-silenced', ok ? '1' : '0'); }); nameWrap.appendChild(btn); root.dataset.qsBtn = '1'; } if ((triggeredByUser || triggeredByWord) && root.getAttribute('data-qs-silenced') !== '1') { if (isPrivileged && getCfg('enableAutoSilence')) { postSilence(username).then(ok => { toast(ok ? `🤫 Auto-silenced @${username}${triggeredByWord ? ` for "${triggeredByWord}"` : ''}` : `⚠️ Auto-silence failed @${username}`); root.setAttribute('data-qs-silenced', ok ? '1' : '0'); }); } else if (!isPrivileged && getCfg('enableHighlighting')) { if (triggeredByUser) root.classList.add('qs_flagged_user'); if (triggeredByWord) root.classList.add('qs_flagged_word'); root.setAttribute('data-qs-silenced', '0'); } } } function scanExisting() { document.querySelectorAll(SEL.rootMessage).forEach(mountButtonForMessage); } function rescanExistingForHighlights() { if (!getCfg('enableHighlighting')) return; document.querySelectorAll(SEL.rootMessage).forEach(root => { root.classList.remove('qs_flagged_user','qs_flagged_word'); root.removeAttribute('data-qs-silenced'); mountButtonForMessage(root); }); } const msgObserver = new MutationObserver((muts) => { for (const m of muts) for (const node of m.addedNodes) { if (!(node instanceof HTMLElement)) continue; if (node.matches?.(SEL.rootMessage)) { mountButtonForMessage(node); } else { node.querySelectorAll?.(SEL.rootMessage).forEach(mountButtonForMessage); } } }); function stopAll() { if (!running) return; msgObserver.disconnect(); document.querySelectorAll('.qs_sil_btn').forEach(btn => btn.remove()); running = false; isPrivileged = false; } async function startForRoom(room) { stopAll(); const viewerEl = await waitForSelector(SEL.viewerUsername, 5000); const viewer = viewerEl?.textContent?.trim(); if (!viewer) return; isPrivileged = await fetchIsPrivileged(viewer, room); log('Viewer:', viewer, 'isPrivileged:', isPrivileged); running = true; if (hasSupabase()) { await syncFromSupabaseOnce(); startPolling(); } else { toast('ℹ️ Using local fallback lists (no Supabase config)'); } scanExisting(); msgObserver.observe(document.body, { childList: true, subtree: true }); } function handleRoomEnter() { const room = getRoomFromURL(); if (!room || room === currentRoom) return; currentRoom = room; startForRoom(room); } // SPA locationchange hook (() => { const _ps = history.pushState, _rs = history.replaceState; history.pushState = function (...a) { const r = _ps.apply(this, a); window.dispatchEvent(new Event('locationchange')); return r; }; history.replaceState = function (...a) { const r = _rs.apply(this, a); window.dispatchEvent(new Event('locationchange')); return r; }; window.addEventListener('popstate', () => window.dispatchEvent(new Event('locationchange'))); window.addEventListener('locationchange', handleRoomEnter); })(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', handleRoomEnter, { once: true }); } else { handleRoomEnter(); } // Menüs GM_registerMenuCommand(`[Toggle] Auto Silence: ${getCfg('enableAutoSilence') ? '✅ ON' : '❌ OFF'}`, () => { setCfg('enableAutoSilence', !getCfg('enableAutoSilence')); toast(`Auto Silence is now ${getCfg('enableAutoSilence') ? '✅ ON' : '❌ OFF'}`); }); GM_registerMenuCommand(`[Toggle] Highlight Matches: ${getCfg('enableHighlighting') ? '✅ ON' : '❌ OFF'}`, () => { setCfg('enableHighlighting', !getCfg('enableHighlighting')); toast(`Highlighting is now ${getCfg('enableHighlighting') ? '✅ ON' : '❌ OFF'}`); rescanExistingForHighlights(); }); GM_registerMenuCommand(`[Toggle] Show Buttons (mods only): ${getCfg('showSilenceButtons') ? '✅ ON' : '❌ OFF'}`, () => { setCfg('showSilenceButtons', !getCfg('showSilenceButtons')); toast(`Silence buttons are now ${getCfg('showSilenceButtons') ? '✅ ON' : '❌ OFF'}`); }); GM_registerMenuCommand(`Sync now (Supabase)`, async () => { await syncFromSupabaseOnce(); }); GM_registerMenuCommand(`Polling interval: ${(getCfg('pollMs')/1000)|0}s (click to change)`, () => { const cur = Number(getCfg('pollMs')) || DEFAULT_POLL_MS; const s = prompt('Polling interval in seconds (min 10s):', String((cur/1000)|0)); if (s === null) return; const sec = Math.max(10, Number(s) || 60); setCfg('pollMs', sec * 1000); startPolling(); toast(`⏱️ Polling every ${sec}s`); }); })();