// ==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 = `