// ==UserScript== // @name 全站关键词遮挡助手 // @namespace https://bbs.tampermonkey.net.cn/ // @version 1.0.0 // @description 默认在所有网页运行,可设置多个关键词、替换文字和屏蔽站点。 // @author 水友 // @match *://*/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // ==/UserScript== (() => { 'use strict'; const STORAGE_KEY = 'keyword-scrubber-config-v1'; const HIDDEN_MARKER = 'data-keyword-scrubber-hidden'; const RESULT_MARKER = 'data-keyword-scrubber-result'; const UI_MARKER = 'data-keyword-scrubber-ui'; const INTERNAL_SELECTOR = `[${HIDDEN_MARKER}], [${RESULT_MARKER}], [${UI_MARKER}]`; const SKIPPED_TAGS = new Set([ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT', 'OPTION', 'BUTTON', ]); const DEFAULT_CONFIG = { rules: [{ find: '面试', replacement: '' }], excludedSites: [], hideMode: 'transparent', blurAmount: 6, showSettingsButton: true, }; let config = loadConfig(); let wordPattern = null; let rulesByWord = new Map(); let observer = null; let queuedRoots = new Set(); let flushPending = false; function sanitizeRules(rules) { if (!Array.isArray(rules)) return DEFAULT_CONFIG.rules; const uniqueRules = new Map(); for (const rule of rules) { const find = typeof rule?.find === 'string' ? rule.find.trim() : ''; if (!find) continue; uniqueRules.set(find, { find, replacement: typeof rule.replacement === 'string' ? rule.replacement : '', }); } return [...uniqueRules.values()]; } function normalizeHost(value) { if (typeof value !== 'string') return ''; return value .trim() .toLowerCase() .replace(/^https?:\/\//, '') .replace(/^\*\./, '*.') .replace(/[/?#].*$/, '') .replace(/:\d+$/, ''); } function sanitizeSites(sites) { if (!Array.isArray(sites)) return []; return [...new Set(sites.map(normalizeHost).filter(Boolean))]; } function sanitizeBlurAmount(value) { const amount = Number(value); if (!Number.isFinite(amount)) return DEFAULT_CONFIG.blurAmount; return Math.min(20, Math.max(1, Math.round(amount))); } function loadConfig() { const saved = GM_getValue(STORAGE_KEY, {}); return { ...DEFAULT_CONFIG, ...(saved && typeof saved === 'object' ? saved : {}), rules: sanitizeRules(saved?.rules ?? DEFAULT_CONFIG.rules), excludedSites: sanitizeSites(saved?.excludedSites), hideMode: saved?.hideMode === 'blur' ? 'blur' : 'transparent', blurAmount: sanitizeBlurAmount(saved?.blurAmount), showSettingsButton: saved?.showSettingsButton !== false, }; } function saveConfig(nextConfig) { config = { ...nextConfig, rules: sanitizeRules(nextConfig.rules), excludedSites: sanitizeSites(nextConfig.excludedSites), hideMode: nextConfig.hideMode === 'blur' ? 'blur' : 'transparent', blurAmount: sanitizeBlurAmount(nextConfig.blurAmount), showSettingsButton: nextConfig.showSettingsButton !== false, }; GM_setValue(STORAGE_KEY, config); } function isExcluded(hostname) { return config.excludedSites.some((site) => { if (site.startsWith('*.')) { const domain = site.slice(2); return hostname.endsWith(`.${domain}`); } return hostname === site || hostname.endsWith(`.${site}`); }); } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function compileRules() { rulesByWord = new Map(config.rules.map((rule) => [rule.find, rule])); const words = [...rulesByWord.keys()].sort((a, b) => b.length - a.length); wordPattern = words.length ? new RegExp(words.map(escapeRegExp).join('|'), 'g') : null; } function isInternalNode(node) { const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; return Boolean(element?.closest(INTERNAL_SELECTOR)); } function textNodeCanBeProcessed(textNode) { const parent = textNode.parentElement; if (!parent || !textNode.nodeValue || !wordPattern) return false; if (isInternalNode(textNode)) return false; if (parent.isContentEditable) return false; if (SKIPPED_TAGS.has(parent.tagName)) { return false; } return true; } function textNodeMatches(textNode) { if (!textNodeCanBeProcessed(textNode)) return false; wordPattern.lastIndex = 0; return wordPattern.test(textNode.nodeValue); } function makeHiddenWord(word) { const hidden = document.createElement('span'); hidden.setAttribute(HIDDEN_MARKER, ''); hidden.textContent = word; hidden.style.setProperty('user-select', 'none', 'important'); if (config.hideMode === 'blur') { hidden.style.setProperty('filter', `blur(${config.blurAmount}px)`, 'important'); } else { hidden.style.setProperty('color', 'transparent', 'important'); hidden.style.setProperty('-webkit-text-fill-color', 'transparent', 'important'); hidden.style.setProperty('text-shadow', 'none', 'important'); } return hidden; } function makeReplacement(text) { const replacement = document.createElement('span'); replacement.setAttribute(RESULT_MARKER, ''); replacement.textContent = text; return replacement; } function replaceInTextNode(textNode, alreadyMatched = false) { if (!alreadyMatched && !textNodeCanBeProcessed(textNode)) return; const source = textNode.nodeValue; if (!alreadyMatched) { wordPattern.lastIndex = 0; if (!wordPattern.test(source)) return; } const fragment = document.createDocumentFragment(); let lastIndex = 0; wordPattern.lastIndex = 0; for (const match of source.matchAll(wordPattern)) { const matchedWord = match[0]; const index = match.index; const rule = rulesByWord.get(matchedWord); if (index > lastIndex) { fragment.append(document.createTextNode(source.slice(lastIndex, index))); } if (rule.replacement === '') { fragment.append(makeHiddenWord(matchedWord)); } else { // Prevent a replacement which matches another rule from being processed again. fragment.append(makeReplacement(rule.replacement)); } lastIndex = index + matchedWord.length; } if (lastIndex < source.length) { fragment.append(document.createTextNode(source.slice(lastIndex))); } textNode.replaceWith(fragment); } function processRoot(root) { if (!wordPattern || !root || isInternalNode(root)) return; if (root.nodeType === Node.TEXT_NODE) { replaceInTextNode(root); return; } const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { return textNodeMatches(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; }, }); const textNodes = []; while (walker.nextNode()) textNodes.push(walker.currentNode); textNodes.forEach((textNode) => replaceInTextNode(textNode, true)); } function scheduleProcess(root) { if (!root.isConnected || isInternalNode(root)) return; queuedRoots.add(root); if (flushPending) return; flushPending = true; requestAnimationFrame(() => { flushPending = false; const roots = [...queuedRoots]; queuedRoots.clear(); getMinimalRoots(roots).forEach(processRoot); }); } function getMinimalRoots(roots) { const rootSet = new Set(roots); return roots.filter((root) => { if (!root.isConnected || isInternalNode(root)) return false; for (let parent = root.parentNode; parent; parent = parent.parentNode) { if (rootSet.has(parent)) return false; } return true; }); } function observeDynamicContent() { observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if ( (node.nodeType === Node.TEXT_NODE || node.nodeType === Node.ELEMENT_NODE) && !isInternalNode(node) ) { scheduleProcess(node); } } } }); observer.observe(document.body, { childList: true, subtree: true }); } function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function createRuleRow(rule = { find: '', replacement: '' }) { const row = document.createElement('div'); row.className = 'rule-row'; row.innerHTML = ` `; row.querySelector('.remove-rule').addEventListener('click', () => row.remove()); return row; } function mountSettingsLauncher() { const host = document.createElement('div'); host.setAttribute(UI_MARKER, ''); const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` `; shadow.querySelector('.launcher').addEventListener('click', openSettings); document.documentElement.append(host); return host; } function mountSettingsUI(existingHost = null) { const host = existingHost || document.createElement('div'); if (!existingHost) { host.setAttribute(UI_MARKER, ''); host.attachShadow({ mode: 'open' }); } const shadow = host.shadowRoot; shadow.innerHTML = ` `; if (!host.isConnected) document.documentElement.append(host); const launcher = shadow.querySelector('.launcher'); const overlay = shadow.querySelector('.overlay'); const ruleList = shadow.querySelector('.rule-list'); const excludedSites = shadow.querySelector('.excluded-sites'); const hideMode = shadow.querySelector('.hide-mode'); const blurControl = shadow.querySelector('.blur-control'); const blurAmount = shadow.querySelector('.blur-amount'); const blurValue = shadow.querySelector('.blur-value'); const showSettingsButton = shadow.querySelector('.show-settings-button'); const currentSite = location.hostname; shadow.querySelector('.site-name').textContent = `当前:${currentSite}`; function updateBlurControl() { const isBlurMode = hideMode.value === 'blur'; blurAmount.disabled = !isBlurMode; blurControl.classList.toggle('disabled', !isBlurMode); blurValue.textContent = `${blurAmount.value}px`; } function resetSettingsForm() { ruleList.replaceChildren(); config.rules.forEach((rule) => ruleList.append(createRuleRow(rule))); if (!config.rules.length) ruleList.append(createRuleRow()); excludedSites.value = config.excludedSites.join('\n'); hideMode.value = config.hideMode; blurAmount.value = String(config.blurAmount); showSettingsButton.checked = config.showSettingsButton; updateBlurControl(); } function closeSettings() { overlay.hidden = true; resetSettingsForm(); launcher.focus(); } function showSettings() { resetSettingsForm(); overlay.hidden = false; ruleList.querySelector('.find-word')?.focus(); } launcher.addEventListener('click', showSettings); shadow.querySelector('.close').addEventListener('click', closeSettings); shadow.querySelector('.cancel').addEventListener('click', closeSettings); hideMode.addEventListener('change', updateBlurControl); blurAmount.addEventListener('input', updateBlurControl); shadow.querySelector('.add-rule').addEventListener('click', () => { const row = createRuleRow(); ruleList.append(row); row.querySelector('.find-word').focus(); }); shadow.querySelector('.add-current-site').addEventListener('click', () => { const existing = excludedSites.value.trim(); const sites = sanitizeSites(existing ? existing.split(/\r?\n/) : []); if (!sites.includes(currentSite)) sites.push(currentSite); excludedSites.value = sites.join('\n'); }); overlay.addEventListener('click', (event) => { if (event.target === overlay) closeSettings(); }); shadow.querySelector('.save').addEventListener('click', () => { const rules = [...ruleList.querySelectorAll('.rule-row')].map((row) => ({ find: row.querySelector('.find-word').value, replacement: row.querySelector('.replacement').value, })); const sites = excludedSites.value.split(/\r?\n/); saveConfig({ rules, excludedSites: sites, hideMode: hideMode.value, blurAmount: blurAmount.value, showSettingsButton: showSettingsButton.checked, }); closeSettings(); location.reload(); }); if (config.showSettingsButton) { launcher.hidden = false; } else { launcher.hidden = true; } resetSettingsForm(); return host; } function openSettings() { let host = document.querySelector(`[${UI_MARKER}]`); if (!host || !host.shadowRoot.querySelector('.overlay')) { host = mountSettingsUI(host); } const shadow = host.shadowRoot; shadow.querySelector('.launcher').click(); } function registerMenuCommands() { GM_registerMenuCommand('关键词遮挡设置', openSettings); GM_registerMenuCommand('屏蔽当前网站', () => { const nextSites = [...config.excludedSites, location.hostname]; saveConfig({ ...config, excludedSites: nextSites }); location.reload(); }); } function start() { registerMenuCommands(); // 屏蔽站点仍可通过 Tampermonkey 的“关键词遮挡设置”菜单恢复。 if (isExcluded(location.hostname)) return; compileRules(); if (wordPattern) { processRoot(document.body); observeDynamicContent(); } if (config.showSettingsButton) mountSettingsLauncher(); } if (document.body) { start(); } else { window.addEventListener('DOMContentLoaded', start, { once: true }); } })();