// ==UserScript==
// @name Ejchan: Post Collapser with Regex Rules
// @version 1.7
// @description Collapse/expand posts; saved regex rules with editor UI (bottom-left); shows which rule hid a post; option to remove matched posts from page (applies on load, gap-less); un-collapse popups on hover & strikeout replies
// @grant none
// @match https://ejchan.net/*/res/*.html*
// @match https://ejchan.site/*/res/*.html*
// ==/UserScript==
(function () {
'use strict';
const BUTTON_CLASS = 'gm-collapse-icon';
const COLLAPSED_CLASS = 'gm-collapsed';
const STORAGE_KEY = 'gm_hidden_post_ids_v1';
const RULES_KEY = 'gm_hidden_post_rules_v1';
const HIDE_COMPLETELY_KEY = 'gm_hide_matched_completely_v1';
const FIX_POPUPS_KEY = 'gm_fix_popups_and_strikeout_v1';
// Styles
const style = document.createElement('style');
style.textContent = `
.${BUTTON_CLASS} {
display:inline-flex;
align-items:center;
justify-content:center;
width:18px;
height:18px;
margin-left:6px;
font-size:12px;
line-height:1;
cursor:pointer;
border-radius:3px;
border:1px solid rgba(0,0,0,0.15);
background:transparent;
color:#444;
text-decoration:none;
user-select:none;
}
.${BUTTON_CLASS}:hover { background: rgba(0,0,0,0.06); }
.${COLLAPSED_CLASS} > :not(.intro):not(.post-right) { display: none !important; }
.${COLLAPSED_CLASS} .${BUTTON_CLASS} { color: #b00; border-color: rgba(176,0,0,0.25); background: rgba(176,0,0,0.04); }
.gm-rule-label { margin-left:6px; font-size:11px; color:#777; }
/* editor button bottom-left */
#gm-rule-editor-btn {
position: fixed;
left: 8px;
bottom: 8px;
z-index: 99999;
padding: 6px 10px;
background: #222;
color: #fff;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
opacity: 0.9;
}
#gm-rule-editor {
position: fixed;
left: 8px;
bottom: 48px;
z-index: 99999;
width: 360px;
max-width: calc(100% - 16px);
background: #fff;
border: 1px solid #ccc;
border-radius:6px;
box-shadow: 0 6px 30px rgba(0,0,0,0.2);
padding: 8px;
display: none;
}
#gm-rule-editor textarea { width:100%; height:200px; box-sizing:border-box; font-family: monospace; }
#gm-rule-editor .gm-row { display:flex; gap:6px; margin-top:6px; align-items:center; }
#gm-rule-editor button { flex:0 0 auto; }
/* completely hidden posts — gapless */
.gm-hide-complete { display: none !important; margin: 0 !important; padding: 0 !important; height: 0 !important; }
/* Default behavior: strikethrough for replies targeting hidden/collapsed posts */
a.gm-reply-crossed-out {
text-decoration: line-through !important;
opacity: 0.7;
}
/* Un-collapse posts & popups on hover when feature is enabled */
body.gm-fix-popups-enabled .gm-collapsed:hover > *,
body.gm-fix-popups-enabled .gm-collapsed[class*="hover"] > *,
body.gm-fix-popups-enabled [class*="hover"] .gm-collapsed > *,
body.gm-fix-popups-enabled .gm-collapsed[id*="hover"] > *,
body.gm-fix-popups-enabled [id*="hover"] .gm-collapsed > *,
body.gm-fix-popups-enabled .gm-collapsed[class*="popup"] > *,
body.gm-fix-popups-enabled [class*="popup"] .gm-collapsed > *,
body.gm-fix-popups-enabled .gm-collapsed[id*="popup"] > *,
body.gm-fix-popups-enabled [id*="popup"] .gm-collapsed > *,
body.gm-fix-popups-enabled .gm-collapsed[class*="preview"] > *,
body.gm-fix-popups-enabled [class*="preview"] .gm-collapsed > *,
body.gm-fix-popups-enabled .gm-collapsed[id*="preview"] > *,
body.gm-fix-popups-enabled [id*="preview"] .gm-collapsed > *,
body.gm-fix-popups-enabled .gm-collapsed[class*="inline"] > *,
body.gm-fix-popups-enabled [class*="inline"] .gm-collapsed > * {
display: revert !important;
}
body.gm-fix-popups-enabled .gm-hide-complete:hover,
body.gm-fix-popups-enabled .gm-hide-complete[class*="hover"],
body.gm-fix-popups-enabled [class*="hover"] .gm-hide-complete,
body.gm-fix-popups-enabled .gm-hide-complete[id*="hover"],
body.gm-fix-popups-enabled [id*="hover"] .gm-hide-complete,
body.gm-fix-popups-enabled .gm-hide-complete[class*="popup"],
body.gm-fix-popups-enabled [class*="popup"] .gm-hide-complete,
body.gm-fix-popups-enabled .gm-hide-complete[id*="popup"],
body.gm-fix-popups-enabled [id*="popup"] .gm-hide-complete,
body.gm-fix-popups-enabled .gm-hide-complete[class*="preview"],
body.gm-fix-popups-enabled [class*="preview"] .gm-hide-complete,
body.gm-fix-popups-enabled .gm-hide-complete[id*="preview"],
body.gm-fix-popups-enabled [id*="preview"] .gm-hide-complete,
body.gm-fix-popups-enabled .gm-hide-complete[class*="inline"],
body.gm-fix-popups-enabled [class*="inline"] .gm-hide-complete {
display: block !important;
height: auto !important;
margin: initial !important;
padding: initial !important;
}
`;
document.head.appendChild(style);
// Storage helpers
function loadHiddenSet() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch (e) { return new Set(); }
}
function saveHiddenSet(set) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(set))); } catch (e) {}
}
function loadRulesRaw() {
try {
const raw = localStorage.getItem(RULES_KEY);
return raw || '';
} catch (e) { return ''; }
}
function saveRulesRaw(txt) {
try { localStorage.setItem(RULES_KEY, txt); } catch (e) {}
}
function loadHideCompletely() {
try { return localStorage.getItem(HIDE_COMPLETELY_KEY) === '1'; } catch (e) { return false; }
}
function saveHideCompletely(v) {
try { localStorage.setItem(HIDE_COMPLETELY_KEY, v ? '1' : '0'); } catch (e) {}
}
function loadFixPopups() {
try {
const raw = localStorage.getItem(FIX_POPUPS_KEY);
return raw === null ? true : raw === '1';
} catch (e) { return true; }
}
function saveFixPopups(v) {
try { localStorage.setItem(FIX_POPUPS_KEY, v ? '1' : '0'); } catch (e) {}
}
const hiddenSet = loadHiddenSet();
let rulesRaw = loadRulesRaw();
let parsedRules = [];
let hideCompletely = loadHideCompletely();
let fixPopups = loadFixPopups();
function parseRules(txt) {
const out = [];
const lines = txt.split(/\r?\n/);
for (const ln of lines) {
const line = ln.trim();
if (!line) continue;
const parts = line.split('|').map(p => p.trim());
if (parts.length < 2) continue;
const label = parts[0] || 'rule';
const regexPart = parts.slice(1).join('|');
const m = regexPart.match(/^\/(.+)\/([gimsuy]*)$/);
if (!m) continue;
try {
const re = new RegExp(m[1], m[2]);
out.push({ label, regex: re });
} catch (e) {}
}
return out;
}
parsedRules = parseRules(rulesRaw);
function getPostAnchorId(post) {
const anchor = post.querySelector('a.post_anchor[id]');
return anchor ? anchor.id : null;
}
function getHiddenPostNumbers() {
const nums = new Set();
for (const id of hiddenSet) {
const num = String(id).replace(/\D/g, '');
if (num) nums.add(num);
}
document.querySelectorAll('div.post.reply.gm-collapsed, div.post.reply.gm-hide-complete').forEach(p => {
const id = getPostAnchorId(p);
if (id) {
const num = String(id).replace(/\D/g, '');
if (num) nums.add(num);
}
});
return nums;
}
function updateReplyLinksStrikeout() {
const hiddenNums = getHiddenPostNumbers();
const links = document.querySelectorAll('a[href*="#"]');
for (const a of links) {
const href = a.getAttribute('href') || '';
const m = href.match(/#(?:reply_|p)?(\d+)$/);
if (m && hiddenNums.has(m[1])) {
a.classList.add('gm-reply-crossed-out');
} else {
a.classList.remove('gm-reply-crossed-out');
}
}
}
function applyFixPopupsState() {
if (fixPopups) {
document.body.classList.add('gm-fix-popups-enabled');
} else {
document.body.classList.remove('gm-fix-popups-enabled');
}
}
function updateRuleLabelUI(post, ruleLabel) {
const existing = post.querySelector('.gm-rule-label');
if (!ruleLabel) {
if (existing) existing.remove();
return;
}
if (existing) existing.textContent = ruleLabel;
else {
const lbl = document.createElement('span');
lbl.className = 'gm-rule-label';
lbl.textContent = ruleLabel;
const btn = post.querySelector(`.${BUTTON_CLASS}`);
if (btn && btn.parentNode) btn.parentNode.insertBefore(lbl, btn.nextSibling);
}
}
function applyCollapsedState(post, collapse, ruleLabel) {
if (collapse) post.classList.add(COLLAPSED_CLASS);
else {
post.classList.remove(COLLAPSED_CLASS);
post.removeAttribute('data-gm-hidden-rule');
}
const btn = post.querySelector(`.${BUTTON_CLASS}`);
if (btn) btn.textContent = '×';
const intro = post.querySelector('.intro');
if (intro) intro.style.display = '';
if (ruleLabel) {
post.setAttribute('data-gm-hidden-rule', ruleLabel);
updateRuleLabelUI(post, ruleLabel);
} else {
if (!post.getAttribute('data-gm-hidden-rule')) updateRuleLabelUI(post, null);
}
applyGlobalHideFlag(post);
}
function applyGlobalHideFlag(post) {
if (hideCompletely && post.getAttribute('data-gm-hidden-rule')) {
post.classList.add('gm-hide-complete');
} else {
post.classList.remove('gm-hide-complete');
}
}
function togglePost(post) {
const id = getPostAnchorId(post);
if (!id) return;
const isNowCollapsed = post.classList.toggle(COLLAPSED_CLASS);
if (isNowCollapsed) {
hiddenSet.add(id);
post.removeAttribute('data-gm-hidden-rule');
updateRuleLabelUI(post, null);
} else {
hiddenSet.delete(id);
post.removeAttribute('data-gm-hidden-rule');
updateRuleLabelUI(post, null);
}
saveHiddenSet(hiddenSet);
applyGlobalHideFlag(post);
updateReplyLinksStrikeout();
}
function createButton() {
const btn = document.createElement('a');
btn.className = BUTTON_CLASS;
btn.href = 'javascript:void(0)';
btn.setAttribute('role', 'button');
btn.setAttribute('aria-label', 'Toggle collapse');
btn.textContent = '×';
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const post = btn.closest('div.post.reply');
if (post) togglePost(post);
});
return btn;
}
function addButtonToPost(post) {
if (!post || post.dataset.gmButtonAdded) return;
const postRight = post.querySelector('.post-right');
if (!postRight) return;
const actReply = postRight.querySelector('a.act-reply.open-modal');
const btn = createButton();
if (actReply && actReply.parentNode) {
actReply.parentNode.insertBefore(btn, actReply.nextSibling);
} else {
postRight.appendChild(btn);
}
post.dataset.gmButtonAdded = '1';
const id = getPostAnchorId(post);
if (id && hiddenSet.has(id)) {
if (post.getAttribute('data-gm-hidden-rule')) applyCollapsedState(post, true);
else applyCollapsedState(post, true, null);
} else {
const matched = applyRulesToPost(post);
if (matched) {
const id2 = getPostAnchorId(post);
if (id2) hiddenSet.add(id2);
saveHiddenSet(hiddenSet);
}
}
}
function matchRules(post) {
if (!parsedRules.length) return null;
const textParts = [];
textParts.push(post.textContent || '');
const subj = post.querySelector('.subject');
if (subj) textParts.push(subj.textContent || '');
const name = post.querySelector('.name');
if (name) textParts.push(name.textContent || '');
const anchor = post.querySelector('a.post_anchor[id]');
if (anchor) textParts.push(anchor.id || '');
const hay = textParts.join(' | ');
for (const r of parsedRules) {
try {
if (r.regex.test(hay)) return r.label;
} catch (e) {}
}
return null;
}
function applyRulesToPost(post) {
const label = matchRules(post);
if (!label) return null;
applyCollapsedState(post, true, label);
return label;
}
function recomputeAutoHiddenForAll() {
parsedRules = parseRules(rulesRaw);
document.querySelectorAll('div.post.reply').forEach(p => {
if (p.getAttribute('data-gm-hidden-rule')) {
p.removeAttribute('data-gm-hidden-rule');
updateRuleLabelUI(p, null);
}
});
document.querySelectorAll('div.post.reply').forEach(p => {
const matched = applyRulesToPost(p);
if (matched) {
const id = getPostAnchorId(p);
if (id) hiddenSet.add(id);
}
});
saveHiddenSet(hiddenSet);
document.querySelectorAll('div.post.reply').forEach(applyGlobalHideFlag);
updateReplyLinksStrikeout();
}
function initExisting() {
rulesRaw = loadRulesRaw();
parsedRules = parseRules(rulesRaw);
applyFixPopupsState();
document.querySelectorAll('div.post.reply').forEach(p => {
const matched = applyRulesToPost(p);
if (matched) {
const id = getPostAnchorId(p);
if (id) hiddenSet.add(id);
} else {
const id = getPostAnchorId(p);
if (id && hiddenSet.has(id)) applyCollapsedState(p, true);
}
});
saveHiddenSet(hiddenSet);
document.querySelectorAll('div.post.reply').forEach(applyGlobalHideFlag);
document.querySelectorAll('div.post.reply').forEach(addButtonToPost);
updateReplyLinksStrikeout();
}
const mo = new MutationObserver((mutations) => {
let newPostsAdded = false;
for (const m of mutations) {
for (const node of m.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.matches && node.matches('div.post.reply')) {
addButtonToPost(node);
newPostsAdded = true;
}
if (node.querySelectorAll) {
const replies = node.querySelectorAll('div.post.reply');
if (replies.length > 0) {
replies.forEach(addButtonToPost);
newPostsAdded = true;
}
}
}
}
if (newPostsAdded) updateReplyLinksStrikeout();
});
function createEditorUI() {
const btn = document.createElement('button');
btn.id = 'gm-rule-editor-btn';
btn.textContent = 'Rules';
btn.title = 'Edit hide rules';
btn.addEventListener('click', () => {
editor.style.display = editor.style.display === 'none' ? 'block' : 'none';
if (editor.style.display === 'block') {
textarea.value = loadRulesRaw();
hideCheckbox.checked = loadHideCompletely();
fixPopupsCheckbox.checked = loadFixPopups();
}
});
document.body.appendChild(btn);
const editor = document.createElement('div');
editor.id = 'gm-rule-editor';
editor.style.display = 'none';
editor.innerHTML = `
<div><strong>Rules (one per line): Label | /pattern/flags</strong></div>
`;
const textarea = document.createElement('textarea');
textarea.placeholder = 'Spam | /buy now/i';
textarea.value = rulesRaw || '';
editor.appendChild(textarea);
const row = document.createElement('div');
row.className = 'gm-row';
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save';
const clearBtn = document.createElement('button');
clearBtn.textContent = 'Clear';
const exportBtn = document.createElement('button');
exportBtn.textContent = 'Export';
const importBtn = document.createElement('button');
importBtn.textContent = 'Import';
row.appendChild(saveBtn);
row.appendChild(clearBtn);
row.appendChild(exportBtn);
row.appendChild(importBtn);
editor.appendChild(row);
const row2 = document.createElement('div');
row2.className = 'gm-row';
const hideCheckbox = document.createElement('input');
hideCheckbox.type = 'checkbox';
hideCheckbox.id = 'gm-hide-complete-checkbox';
const hideLabel = document.createElement('label');
hideLabel.htmlFor = hideCheckbox.id;
hideLabel.textContent = 'Hide matched posts completely';
row2.appendChild(hideCheckbox);
row2.appendChild(hideLabel);
editor.appendChild(row2);
const row3 = document.createElement('div');
row3.className = 'gm-row';
const fixPopupsCheckbox = document.createElement('input');
fixPopupsCheckbox.type = 'checkbox';
fixPopupsCheckbox.id = 'gm-fix-popups-checkbox';
const fixPopupsLabel = document.createElement('label');
fixPopupsLabel.htmlFor = fixPopupsCheckbox.id;
fixPopupsLabel.textContent = 'Un-collapse posts and popups on hover';
row3.appendChild(fixPopupsCheckbox);
row3.appendChild(fixPopupsLabel);
editor.appendChild(row3);
document.body.appendChild(editor);
hideCheckbox.checked = hideCompletely;
fixPopupsCheckbox.checked = fixPopups;
saveBtn.addEventListener('click', () => {
const txt = textarea.value;
saveRulesRaw(txt);
rulesRaw = txt;
parsedRules = parseRules(rulesRaw);
hideCompletely = !!hideCheckbox.checked;
saveHideCompletely(hideCompletely);
fixPopups = !!fixPopupsCheckbox.checked;
saveFixPopups(fixPopups);
applyFixPopupsState();
recomputeAutoHiddenForAll();
editor.style.display = 'none';
});
clearBtn.addEventListener('click', () => { textarea.value = ''; });
exportBtn.addEventListener('click', () => {
const data = { rules: textarea.value, hidden: Array.from(hiddenSet), hideCompletely, fixPopups };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'gm-post-collapser-export.json';
a.click();
URL.revokeObjectURL(url);
});
importBtn.addEventListener('click', () => {
const txt = prompt('Paste rules text or JSON exported earlier:');
if (!txt) return;
try {
const obj = JSON.parse(txt);
if (obj && typeof obj === 'object' && 'rules' in obj) {
textarea.value = obj.rules;
if (Array.isArray(obj.hidden)) {
hiddenSet.clear();
obj.hidden.forEach(x => hiddenSet.add(String(x)));
saveHiddenSet(hiddenSet);
}
if ('hideCompletely' in obj) {
hideCheckbox.checked = !!obj.hideCompletely;
hideCompletely = !!obj.hideCompletely;
saveHideCompletely(hideCompletely);
}
if ('fixPopups' in obj) {
fixPopupsCheckbox.checked = !!obj.fixPopups;
fixPopups = !!obj.fixPopups;
saveFixPopups(fixPopups);
}
} else {
textarea.value = txt;
}
alert('Imported. Click Save to apply.');
} catch (e) {
textarea.value = txt;
alert('Imported (raw). Click Save to apply.');
}
});
}
// Expose API
window.GMPostCollapser = {
getHiddenIds: () => Array.from(hiddenSet),
clearHiddenIds: () => { hiddenSet.clear(); saveHiddenSet(hiddenSet); document.querySelectorAll('div.post.reply').forEach(p => applyCollapsedState(p, false)); updateReplyLinksStrikeout(); },
importHiddenIds: (arr) => { if (Array.isArray(arr)) { hiddenSet.clear(); arr.forEach(x=>hiddenSet.add(String(x))); saveHiddenSet(hiddenSet); document.querySelectorAll('div.post.reply').forEach(p => { const id=getPostAnchorId(p); if(id&&hiddenSet.has(id)) applyCollapsedState(p, true); else applyCollapsedState(p, false); }); updateReplyLinksStrikeout(); } },
getRulesText: () => loadRulesRaw(),
setRulesText: (txt) => { saveRulesRaw(txt); rulesRaw = txt; parsedRules = parseRules(rulesRaw); recomputeAutoHiddenForAll(); },
getHideCompletely: () => hideCompletely,
setHideCompletely: (v) => { hideCompletely = !!v; saveHideCompletely(hideCompletely); document.querySelectorAll('div.post.reply').forEach(applyGlobalHideFlag); updateReplyLinksStrikeout(); },
getFixPopups: () => fixPopups,
setFixPopups: (v) => { fixPopups = !!v; saveFixPopups(fixPopups); applyFixPopupsState(); }
};
// Boot
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
createEditorUI();
initExisting();
mo.observe(document.body, { childList: true, subtree: true });
});
} else {
createEditorUI();
initExisting();
mo.observe(document.body, { childList: true, subtree: true });
}
})();