// ==UserScript== // @name ScriptCat 列表页广告净化 // @namespace https://scriptcat.org/ // @version 1.0.0 // @description 隐藏 scriptcat.org 列表页上的左右侧栏广告位与推广组件 // @author CHuilin // @icon https://scriptcat.org/_next/image?url=%2Fassets%2Flogo.png&w=64&q=75 // @match *://*.scriptcat.org/* // @match *://scriptcat.org/* // @run-at document-start // @grant none // ==/UserScript== (function () { 'use strict'; const DEBUG = false; // true 时在控制台打印命中日志 const log = (...a) => DEBUG && console.log('[净化]', ...a); let hiddenCount = 0; /* ========================================================= * 1. 隐藏规则 * --------------------------------------------------------- * match : JS 清理时用的选择器 * container : 需要整体处理的父容器;留空则只处理 match 元素本身 * remove : true 直接删除节点(不留空白),false 只 display:none * —— 页面 JS 会 classList 切换的常驻节点必须用 false, * 否则页面脚本会因查不到节点而抛错 * guard : 可选,返回 true 才算广告。类名通用时必须加, * 用于二次确认、防止误伤 * css : 可选,自定义注入 CSS 的选择器数组,或 false 完全不进 CSS。 * 类名通用的规则必须在这里换成 attr() 等高精度选择器 * ========================================================= */ // 侧栏广告位的固定尺寸:160×616 的外层帧,内部是 160×600 的广告位 // 这是 ele.html 中两个广告容器唯一稳定的共同特征——它们没有 id、没有 class, // 只有「fixed 定位 + 固定宽高」这一组内联样式。用它做 CSS 侧的选择器, // 既能在广告异步填充之前就命中(避免闪屏),又不会误伤普通内容区块。 const AD_FRAME = 'div[style*="width: 160px"][style*="height: 616px"]'; const RULES = [ { name: '侧栏广告位(左/右通用)', // 两个广告容器都没有 id / class,唯一稳定特征是「fixed 定位 + // 外层 160×616 的内联尺寸」。广告本体(内层 160×600)有时会 // 晚于外层帧出现,所以把帧的直接子节点也纳入匹配, // 保证广告异步填充前后都能命中。 match: `${AD_FRAME}, ${AD_FRAME} > div, a[href*="/advertise/"][href*="/click"]`, css: [`${AD_FRAME}`, `${AD_FRAME} > div`], container: AD_FRAME, // 这两个容器是纯展示节点,页面没有持有引用做 classList 切换, // 删除后不会留下空白占位,也不会让页面脚本拿到 null。 remove: true, }, // 若站点后续给容器补上了 id/class,或出现新的广告形态,在此追加规则。 // 示例:带 id 的广告位(权重 1,0,0,能压住页面 .foo.is-show 展开态) // { // name: 'XXX 广告位', // match: '#site-ad-slot', // css: ['#site-ad-slot', '#site-ad-slot.is-show'], // container: '#site-ad-slot', // remove: true, // }, ]; /* ========================================================= * 2. 页面加载前注入 CSS —— 抢在渲染之前隐藏,杜绝闪屏 * 每条选择器单独成一条规则:CSS 中逗号列表里只要有一个选择器 * 不被浏览器支持,整条规则会被丢弃,导致全部规则一起失效。 * (:has() 因此绝不写进逗号列表,见 references/pitfalls.md §7) * ========================================================= */ function buildCss() { const blocks = []; for (const rule of RULES) { if (rule.css === false) continue; const list = Array.isArray(rule.css) ? rule.css : [ ...rule.match.split(','), ...(rule.container ? rule.container.split(',') : []), ]; for (const sel of new Set(list.map((s) => s.trim()).filter(Boolean))) { blocks.push( `${sel} {\n` + ` display: none !important;\n` + ` visibility: hidden !important;\n` + ` pointer-events: none !important;\n` + `}` ); } } return blocks.join('\n'); } const style = document.createElement('style'); style.id = 'adblock-style'; style.textContent = buildCss(); (document.head || document.documentElement).appendChild(style); /* ========================================================= * 3. JS 清理 * ========================================================= */ function hideElement(el, rule) { if (!el) return; if (rule.remove) { // 删除型:移除后打标记避免重复操作。页面若重新插入同类节点, // 那是新元素、没有标记,会被正常处理。 if (el.dataset.adblockDone === '1') return; el.dataset.adblockDone = '1'; if (el.isConnected) el.remove(); } else { // 隐藏型:必须幂等,不能打标记跳过。这类节点常带页面动态计算的 // 行内样式(left/width),页面重写 style 属性时会把我们的 // display:none 一起冲掉,靠每轮重新校验才能自动补回。 if (el.style && el.style.getPropertyValue('display') === 'none' && el.style.getPropertyPriority('display') === 'important') { return; } el.style.setProperty('display', 'none', 'important'); } hiddenCount++; log(`已清理【${rule.name}】`, el); } function cleanup() { for (const rule of RULES) { let targets; try { targets = document.querySelectorAll(rule.match); } catch (e) { log(`选择器无效,已跳过【${rule.name}】`, rule.match); continue; } for (const el of targets) { if (el.dataset.adblockDone === '1') continue; // 通用特征兜底校验。内部结构可能晚于外层节点出现,所以每轮 // 都重新判定,不做"判定失败就永久跳过"的缓存。 if (typeof rule.guard === 'function' && !rule.guard(el)) continue; // 有 container 时向上找广告本体整体处理,避免留下空白占位 let victim = el; if (rule.container) { let node = el; while (node && node !== document.body) { if (node.matches && node.matches(rule.container)) { victim = node; break; } node = node.parentElement; } } hideElement(victim, rule); if (victim !== el) hideElement(el, { ...rule, remove: false }); } } } /* ========================================================= * 4. 持续监听 —— 广告是异步插入 / 异步切换的 * ========================================================= */ function startObserver() { cleanup(); // 4.1 MutationObserver:DOM 一变就清理,合并到微任务避免频繁触发 let scheduled = false; const observer = new MutationObserver(() => { if (scheduled) return; scheduled = true; queueMicrotask(() => { scheduled = false; cleanup(); }); }); observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style'], }); // 4.2 兜底轮询:部分广告由定时器插入,observer 可能漏掉 let ticks = 0; const timer = setInterval(() => { cleanup(); if (++ticks === 120) { clearInterval(timer); setInterval(cleanup, 3000); } }, 250); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startObserver, { once: true }); } else { startObserver(); } })();