// ==UserScript== // @name Vichan Sage Filter // @namespace vichan-sage-filter // @version 2.2 // @description Hide sage/supersage spam posts (mass-quote, excessive blank-line spacing, or repetitive-line spam) on soyjak.st threads, with a movable on-page settings panel // @author Claude & Deepseek // @match *://soyjak.st/* // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; if (!/\/thread\//.test(window.location.pathname)) return; // ---- Persisted settings ------------------------------------------- const STORAGE_KEY = 'vichanSageFilterSettings'; const DEFAULT_SETTINGS = { enabled: true, // 'off' | 'massquote' | 'spam' | 'massquote_or_spam' | 'any' mode: 'massquote_or_spam', quoteThreshold: 5, minBlankLines: 3, minRepeats: 3, // how many full repeated cycles count as spam (abcabcabc = 3) maxRepeatPeriod: 10, // max repeating pattern length, in lines excludeIds: '', // comma-separated post numbers panelX: null, panelY: null, collapsed: false, }; function loadSettings() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { ...DEFAULT_SETTINGS }; const parsed = { ...DEFAULT_SETTINGS, ...JSON.parse(raw) }; // Migrate the old mode name so existing users don't lose their config. if (parsed.mode === 'massquote_or_blanklines') parsed.mode = 'massquote_or_spam'; return parsed; } catch (e) { return { ...DEFAULT_SETTINGS }; } } function saveSettings(s) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch (e) { /* ignore storage errors */ } } let settings = loadSettings(); // ---- Detection helpers ---------------------------------------------- const TRIGGER_PHRASES = ['sage', 'supersage']; function normalize(str) { return str.toLowerCase().replace(/[^a-z0-9]/g, ''); } function getPostId(postEl) { const idMatch = (postEl.id || '').match(/(\d+)$/); if (idMatch) return idMatch[1]; const noLink = postEl.querySelector('.intro a[href^="#"]'); if (noLink) { const hrefMatch = noLink.getAttribute('href').match(/(\d+)/); if (hrefMatch) return hrefMatch[1]; } return null; } function getEmailValue(postEl) { const emailLink = postEl.querySelector('a.email'); if (!emailLink) return null; const href = emailLink.getAttribute('href') || ''; if (!href.toLowerCase().startsWith('mailto:')) return null; try { return decodeURIComponent(href.slice(7)).trim(); } catch (e) { return href.slice(7).trim(); } } function isTriggerEmail(value) { if (!value) return false; const norm = normalize(value); return TRIGGER_PHRASES.some((p) => norm === normalize(p)); } function countQuotesInBody(postEl) { const body = postEl.querySelector('.body'); if (!body) return 0; return body.querySelectorAll('a[href*="#"]').length; } // Counts blank lines inside a post body, using the actual
line // breaks (not just whitespace in textContent, which collapses breaks). // Spam like: // stri // // // vkmdmv // produces several blank lines in a row; a normal paragraph reply // generally doesn't. function countBlankLines(postEl) { const body = postEl.querySelector('.body'); if (!body) return 0; const withBreaks = body.innerHTML.replace(//gi, '\n'); const scratch = document.createElement('div'); scratch.innerHTML = withBreaks; const text = scratch.textContent || ''; const lines = text.split('\n').map((l) => l.trim()); return lines.filter((l) => l.length === 0).length; } // Extracts the non-empty, trimmed lines of a post body. // Used for repetition detection. function getBodyLines(postEl) { const body = postEl.querySelector('.body'); if (!body) return []; const withBreaks = body.innerHTML.replace(//gi, '\n'); const scratch = document.createElement('div'); scratch.innerHTML = withBreaks; const text = scratch.textContent || ''; return text .split('\n') .map((l) => l.trim()) .filter((l) => l.length > 0); } // Returns the max number of times any contiguous block of lines is // repeated back-to-back, using periods up to `maxPeriod`. // Examples: // [a,b,c,a,b,c,a,b,c] -> 3 // [a,b,c,a,b,c,a,b,c,a,b,c] -> 4 // [x,x,x,x] -> 4 // [a,b,c,a,b,c,a,b] -> 2 (only 2 full cycles) // Returns 0 if the sequence has fewer than 2 lines. function maxRepeatCycles(lines, maxPeriod) { const n = lines.length; if (n < 2) return 0; let best = 0; const maxP = Math.min(maxPeriod, Math.floor(n / 2)); for (let p = 1; p <= maxP; p++) { // For period p, lines[i] should equal lines[i-p] while the // repetition holds. Track the length of the current matching run. let run = 0; for (let i = p; i < n; i++) { if (lines[i] === lines[i - p]) { run++; // The periodic run covers (run + p) lines, so the number of // complete cycles is floor((run + p) / p). const cycles = Math.floor((run + p) / p); if (cycles > best) best = cycles; } else { run = 0; } } } return best; } function isRepetitiveSpam(postEl) { const lines = getBodyLines(postEl); const minRepeats = Number(settings.minRepeats) || DEFAULT_SETTINGS.minRepeats; const maxPeriod = Number(settings.maxRepeatPeriod) || DEFAULT_SETTINGS.maxRepeatPeriod; if (lines.length < minRepeats) return false; return maxRepeatCycles(lines, maxPeriod) >= minRepeats; } function getExcludeIds() { return (settings.excludeIds || '') .split(',') .map((s) => s.trim()) .filter(Boolean); } function shouldHidePost(post) { if (!settings.enabled || settings.mode === 'off') return false; const id = getPostId(post); if (id && getExcludeIds().includes(id)) return false; const email = getEmailValue(post); if (!isTriggerEmail(email)) return false; if (settings.mode === 'any') return true; const massQuote = countQuotesInBody(post) >= (Number(settings.quoteThreshold) || DEFAULT_SETTINGS.quoteThreshold); if (settings.mode === 'massquote') return massQuote; const blankSpam = countBlankLines(post) >= (Number(settings.minBlankLines) || DEFAULT_SETTINGS.minBlankLines); const repeatSpam = isRepetitiveSpam(post); const spamPatterns = blankSpam || repeatSpam; if (settings.mode === 'spam') return spamPatterns; if (settings.mode === 'massquote_or_spam') return massQuote || spamPatterns; return false; } // ---- Scan / hide / link-strip logic ----------------------------------- const hiddenPostIds = new Set(); let processedPosts = new WeakSet(); let processedLinks = new WeakSet(); function handlePost(post) { if (processedPosts.has(post)) return; processedPosts.add(post); if (!shouldHidePost(post)) return; const id = getPostId(post); if (id) hiddenPostIds.add(id); post.style.display = 'none'; post.setAttribute('data-sagefilter-hidden', '1'); } function handleLink(link) { if (processedLinks.has(link)) return; processedLinks.add(link); const href = link.getAttribute('href') || ''; const match = href.match(/(\d+)\s*$/); if (!match) return; const refId = match[1]; if (hiddenPostIds.has(refId)) { link.style.display = 'none'; link.setAttribute('data-sagefilter-ref-hidden', '1'); } } function scanRoot(root) { root.querySelectorAll('.post').forEach(handlePost); root.querySelectorAll('a[href*="#"]').forEach(handleLink); } function rescanFromScratch() { // Everything we hide is display:none, never removed, so this is // fully reversible without a page reload. document.querySelectorAll('[data-sagefilter-hidden]').forEach((el) => { el.style.display = ''; el.removeAttribute('data-sagefilter-hidden'); }); document.querySelectorAll('[data-sagefilter-ref-hidden]').forEach((el) => { el.style.display = ''; el.removeAttribute('data-sagefilter-ref-hidden'); }); hiddenPostIds.clear(); processedPosts = new WeakSet(); processedLinks = new WeakSet(); scanRoot(document); } scanRoot(document); const pendingNodes = new Set(); let flushScheduled = false; function flush() { flushScheduled = false; const nodes = Array.from(pendingNodes); pendingNodes.clear(); nodes.forEach((node) => { if (node.nodeType !== 1) return; if (node.matches && node.matches('.post')) handlePost(node); if (node.querySelectorAll) node.querySelectorAll('.post').forEach(handlePost); if (node.matches && node.matches('a[href*="#"]')) handleLink(node); if (node.querySelectorAll) node.querySelectorAll('a[href*="#"]').forEach(handleLink); }); } const observer = new MutationObserver((mutations) => { for (const m of mutations) { if (!m.addedNodes || !m.addedNodes.length) continue; m.addedNodes.forEach((n) => pendingNodes.add(n)); } if (!flushScheduled) { flushScheduled = true; requestAnimationFrame(flush); } }); observer.observe(document.body, { childList: true, subtree: true }); // ---- Movable settings panel ------------------------------------------- const style = document.createElement('style'); style.textContent = ` #sagefilter-panel { position: fixed; top: ${settings.panelY != null ? settings.panelY + 'px' : '80px'}; left: ${settings.panelX != null ? settings.panelX + 'px' : 'auto'}; right: ${settings.panelX != null ? 'auto' : '16px'}; width: 260px; background: #1e1e1e; color: #eee; font: 12px/1.4 sans-serif; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 14px rgba(0,0,0,0.4); z-index: 999999; user-select: none; } #sagefilter-panel * { box-sizing: border-box; } #sagefilter-header { cursor: move; background: #2b2b2b; padding: 6px 8px; border-radius: 6px 6px 0 0; display: flex; justify-content: space-between; align-items: center; font-weight: bold; } #sagefilter-header span.title { pointer-events: none; } #sagefilter-toggle-collapse { cursor: pointer; background: none; border: none; color: #ccc; font-size: 12px; } #sagefilter-body { padding: 8px; display: flex; flex-direction: column; gap: 6px; } #sagefilter-body label { display: flex; flex-direction: column; gap: 2px; } #sagefilter-body select, #sagefilter-body input[type="number"], #sagefilter-body input[type="text"] { background: #111; color: #eee; border: 1px solid #444; border-radius: 4px; padding: 3px 4px; font: inherit; width: 100%; } #sagefilter-body .row-inline { flex-direction: row; align-items: center; gap: 6px; } #sagefilter-apply { margin-top: 4px; background: #3a6df0; color: white; border: none; border-radius: 4px; padding: 5px; cursor: pointer; font: inherit; } #sagefilter-apply:hover { background: #2f5ad1; } #sagefilter-status { opacity: 0.7; font-size: 11px; } `; document.head.appendChild(style); const panel = document.createElement('div'); panel.id = 'sagefilter-panel'; panel.innerHTML = `
Sage Filter
`; document.body.appendChild(panel); panel.querySelector('#sf-mode').value = settings.mode; function refreshStatus() { panel.querySelector('#sagefilter-status').textContent = `Hidden this load: ${hiddenPostIds.size}`; } refreshStatus(); panel.querySelector('#sagefilter-apply').addEventListener('click', () => { settings.enabled = panel.querySelector('#sf-enabled').checked; settings.mode = panel.querySelector('#sf-mode').value; settings.quoteThreshold = Number(panel.querySelector('#sf-quote-threshold').value) || DEFAULT_SETTINGS.quoteThreshold; settings.minBlankLines = Number(panel.querySelector('#sf-blank-lines').value) || DEFAULT_SETTINGS.minBlankLines; settings.minRepeats = Number(panel.querySelector('#sf-min-repeats').value) || DEFAULT_SETTINGS.minRepeats; settings.maxRepeatPeriod = Number(panel.querySelector('#sf-max-period').value) || DEFAULT_SETTINGS.maxRepeatPeriod; settings.excludeIds = panel.querySelector('#sf-exclude').value; saveSettings(settings); rescanFromScratch(); refreshStatus(); }); const collapseBtn = panel.querySelector('#sagefilter-toggle-collapse'); const bodyEl = panel.querySelector('#sagefilter-body'); collapseBtn.addEventListener('click', () => { settings.collapsed = !settings.collapsed; bodyEl.style.display = settings.collapsed ? 'none' : ''; collapseBtn.textContent = settings.collapsed ? '▸' : '▾'; saveSettings(settings); }); // Dragging const header = panel.querySelector('#sagefilter-header'); let dragging = false; let dragOffsetX = 0; let dragOffsetY = 0; header.addEventListener('mousedown', (e) => { dragging = true; const rect = panel.getBoundingClientRect(); dragOffsetX = e.clientX - rect.left; dragOffsetY = e.clientY - rect.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; panel.style.left = e.clientX - dragOffsetX + 'px'; panel.style.top = e.clientY - dragOffsetY + 'px'; panel.style.right = 'auto'; }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; const rect = panel.getBoundingClientRect(); settings.panelX = rect.left; settings.panelY = rect.top; saveSettings(settings); }); setInterval(refreshStatus, 2000); })();