Hide low-post-count IDs inside /pol/ and /biz/ threads once the thread passes a post threshold; auto-unhide a poster once they exceed the per-ID limit; mask the filtered ID in the badge, quote previews, inline expansions and quotelinks (Requires 4chanX)
// ==UserScript==
// @name Filter 1PBTID (in-thread)
// @namespace Violentmonkey Scripts
// @match https://boards.4chan.org/*
// @match https://boards.4channel.org/*
// @grant none
// @version 2.3
// @run-at document-start
// @author Adapted from Some Anon on /g/ (https://desuarchive.org/g/thread/85972536/#q85996940)
// @description Hide low-post-count IDs inside /pol/ and /biz/ threads once the thread passes a post threshold; auto-unhide a poster once they exceed the per-ID limit; mask the filtered ID in the badge, quote previews, inline expansions and quotelinks (Requires 4chanX)
// @license MIT
// ==/UserScript==
const POST_THRESHOLD = 100; // thread must have at least this many posts before filtering activates
const MAX_POSTS_PER_ID = 1; // filter posters with this many posts or fewer (1 = 1pbtID, 2 = up to 2pbtID, etc.)
const BOARDS = ['pol', 'biz']; // boards with poster IDs that this runs on
const HIDE_OP = false; // set true to also hide the OP when OP is a filtered ID
const VERBOSE = false; // set false to silence console logging
const HIDDEN_CLASS = 'onepbtid-hidden';
const REVEAL_CLASS = 'onepbtid-reveal';
const COUNTER_LABEL = '1pbtID Niggers filtered'; // label shown on the on-screen counter
const MASK_TEXT = 'user filtered'; // text shown in place of a filtered poster's ID and quotelinks to it
const MASK_COLOR = '#d00'; // color of the mask text
const RECHECK_DELAY = 250; // ms debounce for re-evaluating after thread updates
const log = (...args) => { if (VERBOSE) console.log('[1pbtID]', ...args); };
const getBoard = () => {
const m = location.href.match(/https:\/\/boards\.4chan(?:nel)?\.org\/([^/]+)\/thread\//);
return m ? m[1] : null;
};
const getUid = container => {
const el = container.querySelector('.posteruid');
if (!el) return null;
const m = el.className.match(/\bid_(\S+)/);
return m ? m[1] : null;
};
const injectStyle = () => {
if (document.getElementById('onepbtid-style')) return;
const style = document.createElement('style');
style.id = 'onepbtid-style';
style.textContent = `
.${HIDDEN_CLASS}{display:none !important;}
body.${REVEAL_CLASS} .${HIDDEN_CLASS}{display:block !important;outline:2px solid #d00;}
#onepbtid-counter{position:fixed;bottom:8px;left:8px;z-index:2147483647;
font:12px/1.4 sans-serif;background:#1d1f21;color:#eee;padding:5px 8px;
border:1px solid #444;border-radius:4px;cursor:pointer;opacity:.85;user-select:none;}
#onepbtid-counter:hover{opacity:1;}
#onepbtid-counter b{color:#ff6b6b;}
`;
(document.head || document.documentElement).appendChild(style);
};
// Replace the visible identity of filtered posters with MASK_TEXT wherever it renders:
// - the poster ID badge (.posteruid .hand) on the post, quote previews and inline expansions
// - any quotelink/backlink (>>postno) that points to a filtered post
// Driven purely by the id_<uid> class and the post number via href, so dynamically
// created previews/inlines/backlinks are covered without extra observers. Real
// identities are shown again while the thread is in reveal mode.
let maskStyleEl = null;
let maskSig = '';
const updateMaskStyle = (uids, nums) => {
const uidList = [...uids].sort();
const numList = [...nums].sort();
const sig = uidList.join('|') + '#' + numList.join('|');
if (sig === maskSig) return;
maskSig = sig;
if (!maskStyleEl) {
maskStyleEl = document.createElement('style');
maskStyleEl.id = 'onepbtid-mask-style';
(document.head || document.documentElement).appendChild(maskStyleEl);
}
const rules = [];
if (uidList.length) {
const base = uidList.map(u => `body:not(.${REVEAL_CLASS}) .posteruid.${CSS.escape('id_' + u)} .hand`);
rules.push(base.join(',') + '{font-size:0 !important;letter-spacing:0;}');
rules.push(base.map(s => s + '::after').join(',') + `{content:'${MASK_TEXT}';font-size:12px;letter-spacing:normal;color:${MASK_COLOR} !important;}`);
}
if (numList.length) {
const qbase = numList.map(n => `body:not(.${REVEAL_CLASS}) a.quotelink[href$="#p${n}"]`);
rules.push(qbase.join(',') + '{font-size:0;letter-spacing:0;}');
rules.push(qbase.map(s => s + '::after').join(',') + `{content:'>>${MASK_TEXT}';font-size:10pt;letter-spacing:normal;color:${MASK_COLOR} !important;}`);
}
maskStyleEl.textContent = rules.join('\n');
};
const buildCounter = () => {
let el = document.getElementById('onepbtid-counter');
if (el) return el;
el = document.createElement('div');
el.id = 'onepbtid-counter';
el.title = 'Click to reveal/re-hide filtered posts';
el.addEventListener('click', () => {
const revealed = document.body.classList.toggle(REVEAL_CLASS);
log(revealed ? 'Revealing filtered posts' : 'Re-hiding filtered posts');
});
document.body.appendChild(el);
return el;
};
const updateCounter = (hidden, total, active) => {
const el = buildCounter();
const status = active ? `${total}/${POST_THRESHOLD}` : `${total}/${POST_THRESHOLD} (inactive)`;
el.innerHTML = `${COUNTER_LABEL}: <b>${hidden}</b> · posts ${status}`;
};
// Recompute counts every pass so a poster who exceeds MAX_POSTS_PER_ID is automatically unhidden.
const evaluate = thread => {
const containers = [...thread.querySelectorAll(':scope > .postContainer')];
const counts = new Map();
const uids = new Map();
for (const c of containers) {
const uid = getUid(c);
uids.set(c, uid);
if (uid) counts.set(uid, (counts.get(uid) || 0) + 1);
}
const active = containers.length >= POST_THRESHOLD;
const maskedUids = new Set();
const maskedNums = new Set();
let hidden = 0;
let newlyHidden = 0;
let newlyShown = 0;
for (const c of containers) {
const uid = uids.get(c);
const isOp = c.classList.contains('opContainer');
const hide = active && uid && counts.get(uid) <= MAX_POSTS_PER_ID && (HIDE_OP || !isOp);
const was = c.classList.contains(HIDDEN_CLASS);
if (hide && !was) { newlyHidden++; log(`Hiding post >>${c.id.replace(/^pc/, '')} (ID ${uid})`); }
if (!hide && was) { newlyShown++; log(`Unhiding post >>${c.id.replace(/^pc/, '')} (ID ${uid})`); }
c.classList.toggle(HIDDEN_CLASS, hide);
if (hide) { hidden++; maskedUids.add(uid); maskedNums.add(c.id.replace(/^pc/, '')); }
}
updateMaskStyle(maskedUids, maskedNums);
log(`Pass: ${containers.length} posts, active=${active}, filtered=${hidden} (+${newlyHidden}/-${newlyShown})`);
updateCounter(hidden, containers.length, active);
};
const start = () => {
const board = getBoard();
if (!board || !BOARDS.includes(board)) return;
const thread = document.querySelector('.thread');
if (!thread) return;
injectStyle();
evaluate(thread);
let timer = null;
const recheck = () => {
clearTimeout(timer);
timer = setTimeout(() => evaluate(thread), RECHECK_DELAY);
};
new MutationObserver(recheck).observe(thread, { childList: true });
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}