// ==UserScript==
// @name Ejchan: Post Collapser with Regex Rules
// @version 1.5
// @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)
// @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';
// 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; }
`;
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) {}
}
const hiddenSet = loadHiddenSet();
let rulesRaw = loadRulesRaw();
let parsedRules = []; // [{label, regex}]
let hideCompletely = loadHideCompletely();
// parse rules from text: each non-empty line "Label | /pattern/flags"
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('|'); // allow | in pattern
const m = regexPart.match(/^\/(.+)\/([gimsuy]*)$/);
if (!m) continue;
try {
const re = new RegExp(m[1], m[2]);
out.push({ label, regex: re });
} catch (e) { /* skip invalid */ }
}
return out;
}
parsedRules = parseRules(rulesRaw);
// Get post anchor id
function getPostAnchorId(post) {
const anchor = post.querySelector('a.post_anchor[id]');
return anchor ? anchor.id : null;
}
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 = '';
// mark rule meta and UI
if (ruleLabel) {
post.setAttribute('data-gm-hidden-rule', ruleLabel);
updateRuleLabelUI(post, ruleLabel);
} else {
if (!post.getAttribute('data-gm-hidden-rule')) updateRuleLabelUI(post, null);
}
// apply global hide if enabled
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);
// clear any rule meta if toggled manually
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);
}
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';
// If this post's id is in hiddenSet, treat as collapsed (manual or previously auto)
const id = getPostAnchorId(post);
if (id && hiddenSet.has(id)) {
// if it's already marked by a rule (data attribute), keep that label; otherwise just collapse
if (post.getAttribute('data-gm-hidden-rule')) applyCollapsedState(post, true);
else applyCollapsedState(post, true, null);
} else {
// apply rule engine to auto-collapse if matches
const matched = applyRulesToPost(post);
if (matched) {
const id2 = getPostAnchorId(post);
if (id2) hiddenSet.add(id2); // remember auto-hidden to persist across reloads
saveHiddenSet(hiddenSet);
}
}
}
// Rule test: returns matched label or null. Tests post textContent + attributes/title
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) { /* ignore */ }
}
return null;
}
function applyRulesToPost(post) {
const label = matchRules(post);
if (!label) return null;
// collapse and mark
applyCollapsedState(post, true, label);
return label;
}
// Recompute auto-hidden posts for persistence: clears prior rule marks then reapplies rules
function recomputeAutoHiddenForAll() {
parsedRules = parseRules(rulesRaw);
document.querySelectorAll('div.post.reply').forEach(p => {
// remove previous rule metadata (but keep manual collapsed state if saved in hiddenSet without rule)
if (p.getAttribute('data-gm-hidden-rule')) {
p.removeAttribute('data-gm-hidden-rule');
// remove label UI
updateRuleLabelUI(p, null);
}
});
// apply rules and mark matching posts
document.querySelectorAll('div.post.reply').forEach(p => {
const matched = applyRulesToPost(p);
if (matched) {
const id = getPostAnchorId(p);
if (id) hiddenSet.add(id);
}
});
saveHiddenSet(hiddenSet);
// apply global hide flags to all posts
document.querySelectorAll('div.post.reply').forEach(applyGlobalHideFlag);
}
// Initialize existing posts
function initExisting() {
// parse stored rules and immediately apply to all posts so hideCompletely works on load
rulesRaw = loadRulesRaw();
parsedRules = parseRules(rulesRaw);
document.querySelectorAll('div.post.reply').forEach(p => {
// first, add button later — but ensure rule-based hiding runs now
const matched = applyRulesToPost(p);
if (matched) {
const id = getPostAnchorId(p);
if (id) hiddenSet.add(id);
} else {
// if post id in hiddenSet but no rule matches now, leave collapsed (manual)
const id = getPostAnchorId(p);
if (id && hiddenSet.has(id)) applyCollapsedState(p, true);
}
});
saveHiddenSet(hiddenSet);
// apply global hide flag immediately
document.querySelectorAll('div.post.reply').forEach(applyGlobalHideFlag);
// finally add buttons to posts
document.querySelectorAll('div.post.reply').forEach(addButtonToPost);
}
// Observe future posts
const mo = new MutationObserver((mutations) => {
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);
node.querySelectorAll && node.querySelectorAll('div.post.reply').forEach(addButtonToPost);
}
}
});
// Editor UI (bottom-left)
function createEditorUI() {
// button
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();
}
});
document.body.appendChild(btn);
// editor panel
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);
document.body.appendChild(editor);
// set initial checkbox
hideCheckbox.checked = hideCompletely;
saveBtn.addEventListener('click', () => {
const txt = textarea.value;
saveRulesRaw(txt);
rulesRaw = txt;
parsedRules = parseRules(rulesRaw);
// update hide completely setting
hideCompletely = !!hideCheckbox.checked;
saveHideCompletely(hideCompletely);
// recompute auto-hidden posts and apply global hide flag immediately
recomputeAutoHiddenForAll();
editor.style.display = 'none';
});
clearBtn.addEventListener('click', () => { textarea.value = ''; });
exportBtn.addEventListener('click', () => {
const data = { rules: textarea.value, hidden: Array.from(hiddenSet), hideCompletely: hideCompletely };
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);
}
} else {
// treat as raw rules text
textarea.value = txt;
}
alert('Imported. Click Save to apply.');
} catch (e) {
// not JSON, assume raw
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)); },
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); }); } },
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); }
};
// 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 });
}
})();