// ==UserScript== // @name CSDN 广告净化 // @namespace https://blog.csdn.net/ // @version 2.1.0 // @description 隐藏 CSDN 博客页的广告与 AI/会员推广悬浮组件;内置设置面板可逐条开关规则,改动即时生效 // @author CHuilin // @match *://*.csdn.net/* // @run-at document-start // @grant GM_getValue // @grant GM_setValue // ==/UserScript== (function () { 'use strict'; const DEBUG = false; // 改成 true 可在控制台看到命中日志 const log = (...a) => DEBUG && console.log('[CSDN净化]', ...a); let hiddenCount = 0; /* ========================================================= * 0. 页面类型判断 —— 部分规则只在博文页生效 * --------------------------------------------------------- * CSDN 的「右侧栏」在首页/列表页和博文页是两个完全不同的东西: * 博文页 /xxx/article/details/ → AI阅读助手 + 上下篇导航 + 滚动条(要清) * 首页/列表页 → 热榜 / 博主信息 / 推荐列表(要留) * 所以规则支持 scope 字段做限定,CS 侧和 JS 侧都要判断,缺一不可: * CSS 不判断 → 首页侧栏会被 CSS 抢先藏掉,JS 再还原会闪一下 * JS 不判断 → CSS 放过了,JS 又给藏回去,等于没限定 * 注意 location 在 document-start 时已可用,无需等 DOM。 * ========================================================= */ const PAGE = { // 博文详情页:blog.csdn.net/<作者>/article/details/<编号> // 也兼容 /article/details/ 后直接跟参数、以及带 category_ 前缀的变体 isArticle: /^\/[^/]+\/article\/details\/\d+/.test(location.pathname) || /\/article\/details\//.test(location.pathname), // 首页与各类列表页(栏目/博客列表/搜索)—— 目前仅用于日志排查 isHome: location.pathname === '/' || /^\/(nav|column|search)/.test(location.pathname), }; // 规则作用域判定:无 scope 字段 = 全站生效;scope:'article' = 仅博文页 const inScope = (rule) => !rule.scope || (rule.scope === 'article' && PAGE.isArticle); /* ========================================================= * 1. 隐藏规则 * --------------------------------------------------------- * match : JS 清理时用的选择器 * container : 需要整体移除的父容器;为空则只处理 match 元素本身 * remove : true 直接删除节点(不留空白),false 只 display:none * guard : 可选。返回 true 才算广告 —— 用于类名过于通用、 * 需要二次确认的场景,避免误伤同名元素 * css : 可选。自定义注入 CSS 的选择器(数组),或用 false 完全 * 不进 CSS。默认取 match + container。 * 凡是"类名通用"的规则都应在这里换成更高精度的选择器, * 例如 :has(...),做到精准命中、绝不误伤。 * scope : 可选。'article' = 只在博文详情页生效,用于避免 * 把首页/列表页的同名模块一起清掉。 * ========================================================= */ const RULES = [ { name: '显示会员推广悬浮卡片', // ele.html 里选中的那个元素;同时按 href 兜底,防止 class 改名 match: '.cp-flash__title-hit, a[href*="mall.csdn.net/vip"]', container: '.cp-flash', remove: true, }, { name: '显示文章广告位(左/右/上/下)', // .simple-ad-slot 是广告容器组件,-left / -right 等为其变体 match: '.simple-ad-slot', container: '.simple-ad-slot', remove: true, }, { name: '显示AI 写同款悬浮窗', // .cons 类名太通用,绝不能裸写进 CSS;CSS 侧用 :has() 锁定 // 「墨言 AI 图标」或「#rollTx 滚动文案」,JS 侧再用 guard 二次校验 match: 'span.cons', css: ['span.cons:has(.simple-moyan-mark)', 'span.cons:has(#rollTx)'], guard: (el) => !!el.querySelector('.simple-moyan-mark, #rollTx, .fix'), container: '', remove: false, // 该节点由页面 JS 动态增删,display:none 比删除更稳,不会让页面脚本报错 }, { name: '显示墨衍会员引流徽章', // 「墨衍会员 · AI 创作全网分发」徽章,跳转 mp.csdn.net/vip // 注意:其内部 .simple-moyan-mark 与上一条 AI 悬浮窗共用图标类名, // 因此绝不能把 .simple-moyan-mark 写进隐藏规则,只能锁外层容器。 match: '.simple-moyan-member-badge, a[href*="mp.csdn.net/vip"]', container: '.simple-moyan-member-badge', remove: true, }, { name: '显示底部吸底作者栏', // 滚动正文时从底部弹出的作者名片(头像/作者名/码龄粉丝原创/+关注)。 // 页面 JS 通过给它追加 is-show 类来触发展示,所以:见下面两个刻意的选择 // // ① remove: false —— 只 display:none,绝不删除节点。 // 该节点是页面 JS 常驻持有并通过 classList 切换的(wrap -> wrap is-show)。 // 一旦删除,它的滚动监听里 querySelector('#simple-sticky-userbar') // 就会拿到 null,classList.add 直接抛错,可能连带搞挂同批次的其它脚本。 // ② CSS 用 #id 选择器(权重 1,0,0)而非纯类选择器。 // 页面的 .simple-sticky-userbar-wrap.is-show 权重是 0,2,0;若它内部写了 // display !important,纯类选择器会因权重更低而失效。id 选择器权重更高, // 无论对方样式表加载顺序如何都能压住。JS 侧还会再补一层行内 !important。 match: '#simple-sticky-userbar, .simple-sticky-userbar-wrap', css: [ '#simple-sticky-userbar', '.simple-sticky-userbar-wrap', '.simple-sticky-userbar-wrap.is-show', ], container: '', remove: false, }, { name: '显示右侧栏整块(AI阅读助手 / 上下篇导航 / 滚动条)', // ele.html 明确标注:「全部需要清除,包括ai阅读助手、上下篇导航、滚动条」 // DOM 层级: #rightAside > .recommend-right_aside > #recommend-right // > (cknows 卡 / #groupfile / .article-previous 上下篇导航) // // 直接锁最外层 #rightAside 一次性全清,而不是逐个给内部子元素加规则 —— // 侧栏是 CSDN 最爱塞新推广模块的地方,锁外层后将来新增什么都自动覆盖, // 也不用为「滚动条」这类附属元素单独找选择器(随容器一起消失)。 // 注意 #rightAside 同时带 .recommend-right[data-type="recommend"], // 两个选择器指向同一节点,写成两个是防 id 改名的兜底。 // // remove: false —— 侧栏是页面滚动吸顶/定位脚本的作用对象, // 删除节点有可能让那些脚本查不到目标而抛错;display:none 同样能让 // flex 布局回收这块空间,视觉效果一致且无风险。 match: '#rightAside, .recommend-right[data-type="recommend"]', container: '#rightAside', remove: false, scope: 'article', // 只在博文页清侧栏;首页/列表页的侧栏是热榜等正常内容,保留 }, { name: '显示C知道「AI 阅读助手」卡片', // 右侧栏内由 Swiper 轮播驱动的推广卡(核心速览/代码判官/知识延伸)。 // 该卡片父级 #rightAside 已被上面那条整块清除,所以本条属于冗余兜底: // 万一 CSDN 把这张卡挪到侧栏之外(他们常做 A/B 投放),这里仍然拦得住。 // remove: false —— 该节点挂着 Swiper 实例(自动轮播 + 尺寸观测), // 删除后 Swiper 内部仍会对已分离节点做 transform/尺寸计算,风险高于收益; // 只隐藏即可达到完全相同的视觉效果。 match: '.cknows-side-card, .js-aside-cknows', container: '', remove: false, scope: 'article', // AI阅读助手只在博文页出现,与上一条保持一致 }, { name: '工具条「会员中心」按钮 + VIP 特权面板', // 顶部 csdn-toolbar 上的会员入口:按钮本体 + 悬浮展开的四宫格推广面板 // (VIP年卡/免费领周边/百万token/送3个月 + 立即开通),均跳 mall.csdn.net/vip。 // 只这两块推广结构,不含工具条上的搜索、消息、头像等正常功能。 // remove: true —— 该按钮及其面板由页面 JS 动态注入、模块卸载时会整块移除, // 直接删除不会留下残留,也不会让谁查不到节点。 match: '.toolbar-btn-vip, #csdn-plugin-vip', container: '.toolbar-btn-vip', remove: true, }, { name: '显示墨衍会员推广弹层', // 悬停在「墨衍会员」徽章上浮出的推广气泡:墨衍智能分发统计 + // 已同步平台列表 + 「墨衍会员 ¥399 起/年,写一次发全网」+ 我也要 CTA。 // 注意它与 .simple-moyan-member-badge 是同级兄弟,不是父子, // 所以不能靠 container 连带清除,必须单列规则。 // 类名足够具体,无需 guard/:has()。 // remove: false —— 该气泡由徽章 hover 事件按需显示/隐藏, // 页面 JS 可能持有引用并切换 class,删除有抛错风险,隐藏即可。 match: '.simple-moyan-member-popover', container: '', remove: false, }, { name: '显示文章头尾导流卡片', // 正文顶部/底部插入的「推荐阅读」长条卡(#simple-blue-card-top / -bottom)。 // 依据:其埋点 mod=popu_871、strategy 含 BlogCommendFromBaidu, // ab_strategy 含 commercial_es —— 属商业导流位而非博文正文内容。 // 用 [id^=] 前缀匹配而非精确 id,防止站点加后缀变体(-top2 等)。 match: 'a.simple-blue-card[id^="simple-blue-card"], .simple-blue-card', container: '', remove: true, scope: 'article', // 正文头尾导流卡只存在于博文页 }, { name: '显示正文底部广告背景层', // 广告位背景容器 .simple-ad-bg,内含程序化广告位 #kp_box_1107(data-pid)。 // 它负责给页面套「广告皮肤」(body.has-simple-ad-background-skin 配色), // 一并清掉可让页面恢复默认背景。 match: '.simple-ad-bg', container: '.simple-ad-bg', remove: true, }, { name: '显示推荐流广告条目', // 正文底部「推荐」流里通过百度联盟投放的条目(.recommend-item-box.baiduSearch) // 整块容器 .insert-baidu-box,与站内自然推荐(.type_blog)区分开,只清广告条目。 match: '.insert-baidu-box', container: '.insert-baidu-box', remove: true, }, { name: '显示右侧栏广告位', match: '.blog-right-advert', container: '.blog-right-advert', remove: true, }, { name: '显示顶部/工具条广告', match: '#dmp_ad, .toolbar-advert', container: '#dmp_ad, .toolbar-advert', remove: true, }, ]; /* ========================================================= * 1.5 配置层 —— 逐条规则的开关,存 GM 存储、可即时生效 * --------------------------------------------------------- * 设计要点: * · 每条规则用【固定 id】作为开关 key,而不是用数组下标或中文名。 * 用下标的话,将来在 RULES 中间插一条规则,存量配置就全部错位; * 用中文名则等于把展示文案当主键,改文案即丢配置。 * · 存量配置与默认配置做【合并】而非覆盖:新版本新增的规则默认开启, * 老用户不需要手动补齐(旧配置里没有的 key 就取默认值)。 * · 存储失败(GM 不可用 / 隐私模式)时全部回退为默认值,绝不抛错阻塞净化。 * ========================================================= */ const STORAGE_KEY = 'csdn-anti-ad:config'; const RULE_IDS = [ 'vipFloat', 'adSlot', 'aiWidget', 'moyanBadge', 'stickyUserbar', 'rightAside', 'cknowsCard', 'toolbarVip', 'moyanPopover', 'blueCard', 'adBg', 'baiduBox', 'rightAdvert', 'topAd', ]; function defaultConfig() { const cfg = {}; for (const id of RULE_IDS) cfg[id] = true; return cfg; } // GM_setValue / GM_getValue 在 @grant none 下不可用,做安全探测 const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; let config = defaultConfig(); let configWriteTimer = null; function loadConfig() { if (!hasGM) return; // 无 GM:直接用默认值(全部开启) try { const raw = GM_getValue(STORAGE_KEY, ''); if (!raw) return; const saved = typeof raw === 'string' ? JSON.parse(raw) : raw; if (saved && typeof saved === 'object') { // 合并:默认值打底,存量的显式设置覆盖之 for (const id of RULE_IDS) { if (typeof saved[id] === 'boolean') config[id] = saved[id]; } } } catch (e) { log('配置读取失败,已回退默认值', e); config = defaultConfig(); } } function saveConfigDebounced() { if (!hasGM) return; clearTimeout(configWriteTimer); configWriteTimer = setTimeout(() => { try { GM_setValue(STORAGE_KEY, JSON.stringify(config)); } catch (e) { log('配置写入失败', e); } }, 150); } const isRuleEnabled = (rule) => config[rule.id] !== false; loadConfig(); // 给每条规则绑定固定 id(顺序与 RULE_IDS 一一对应) RULES.forEach((rule, i) => { rule.id = RULE_IDS[i]; }); /* ========================================================= * 2. 页面加载前注入 CSS —— 抢在渲染之前隐藏,杜绝闪屏 * 注意:每条选择器单独成一条规则。若把它们拼成一个大逗号列表, * 只要其中一条不被浏览器支持,整条规则会被整体丢弃,导致全部失效。 * ========================================================= */ function buildCss() { const blocks = []; for (const rule of RULES) { if (rule.css === false) continue; if (!isRuleEnabled(rule)) continue; // 停用的规则不进 CSS if (!inScope(rule)) continue; // 不适用当前页面的规则不进 CSS(防首页闪一下) let list; if (Array.isArray(rule.css)) { list = rule.css; } else { list = [ ...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 = 'csdn-adblock-style'; style.textContent = buildCss(); (document.head || document.documentElement).appendChild(style); // 开关变化时重新生成并替换整个样式表内容(不换节点,避免页面引用失效) function refreshCss() { style.textContent = buildCss(); log('CSS 已刷新:', style.textContent.split('\n').length, '行'); } log('CSS 已注入:', style.textContent.split('\n').length, '行'); /* ========================================================= * 3. JS 清理 —— 处理动态注入 / 内联样式覆盖 / 通用类名精确判定 * ========================================================= */ function hideElement(el, rule) { if (!el) return; if (rule.remove) { // 删除型:节点一旦移除就结束,用标记避免重复操作。 // (若页面之后又插入一个同类新节点,那是新元素、没有标记,会被正常处理) if (el.dataset.csdnAdblockDone === '1') return; el.dataset.csdnAdblockDone = '1'; if (el.isConnected) el.remove(); } else { // 隐藏型:不删除、只压 display:none,且必须是幂等的。 // 为什么不能打标记跳过?因为这类节点常带有页面动态计算的行内样式 // (如吸底栏的 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 restoreElement(el, rule) { if (!el || !el.style) return; if (el.style.getPropertyValue('display') === 'none' && el.style.getPropertyPriority('display') === 'important') { el.style.removeProperty('display'); log(`已恢复【${rule.name}】`, el); } } // 按选择器批量撤销某条规则留下的痕迹(规则被停用 / 作用域不符时调用)。 // 同时清掉 deletion 标记,让规则重新启用时能立刻再次生效。 function restoreBySelector(rule) { let targets; try { targets = document.querySelectorAll(rule.match); } catch (e) { return; } for (const el of targets) { restoreElement(el, rule); if (el.dataset.csdnAdblockDone === '1') { delete el.dataset.csdnAdblockDone; } } } function cleanup() { for (const rule of RULES) { // 作用域不符(如首页遇到只作用于博文页的规则)时, // 不但不清,还要主动还原 —— 因为 SPA 站内跳转可能让页面类型变化。 if (!inScope(rule)) { restoreBySelector(rule); continue; } if (!isRuleEnabled(rule)) { // 停用的规则:撤销此前的隐藏,并清掉"已处理"标记, // 这样它重新启用时能立刻被再次清扫。 restoreBySelector(rule); continue; } let targets; try { targets = document.querySelectorAll(rule.match); } catch (e) { log(`选择器无效,已跳过【${rule.name}】`, rule.match); continue; } for (const el of targets) { if (el.dataset.csdnAdblockDone === '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 }); } } } /* ========================================================= * 5. 设置面板 —— 逐条规则开关,改动即时生效 * --------------------------------------------------------- * 为什么不学某些脚本"保存后 location.reload()"? * 因为我们已有 cleanup() 与 MutationObserver,切换开关后只要: * ① 重建 CSS(处理防闪屏那一层) * ② 立即跑一次 cleanup()(该清的清、该还原的还原) * 页面无需刷新,也不会丢失滚动位置。 * ========================================================= */ const PANEL_STYLE_ID = 'csdn-adblock-panel-style'; const PANEL_ID = 'csdn-adblock-panel'; const LAUNCHER_ID = 'csdn-adblock-launcher'; // 规则按"作用区域"分组,纯粹为了面板里好找 const RULE_GROUPS = [ { title: '正文区域', icon: '📄', ids: ['adSlot', 'blueCard', 'adBg', 'baiduBox', 'aiWidget', 'moyanBadge'], }, { title: '悬浮与吸底', icon: '🎈', ids: ['vipFloat', 'stickyUserbar', 'moyanPopover'], }, { title: '侧栏与工具条', icon: '📚', ids: ['rightAside', 'cknowsCard', 'toolbarVip'], }, { title: '其他广告位', icon: '🧹', ids: ['rightAdvert', 'topAd'], }, ]; function injectPanelStyle() { if (document.getElementById(PANEL_STYLE_ID)) return; const s = document.createElement('style'); s.id = PANEL_STYLE_ID; // 全部选择器都以 #csdn-adblock- 前缀限定,避免与页面样式互相污染 s.textContent = ` #${LAUNCHER_ID} { position: fixed; right: 20px; bottom: 88px; z-index: 2147483000; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: rgba(255, 255, 255, 0.92); border: 1px solid rgba(15, 23, 42, 0.10); box-shadow: 0 4px 16px rgba(15, 23, 42, 0.16); cursor: pointer; font-size: 19px; line-height: 1; user-select: none; transition: transform .18s ease, box-shadow .18s ease; backdrop-filter: blur(8px); } #${LAUNCHER_ID}:hover { transform: scale(1.08); box-shadow: 0 6px 20px rgba(15, 23, 42, 0.24); } #${PANEL_ID} { position: fixed; inset: 0; z-index: 2147483001; display: none; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.45); backdrop-filter: blur(3px); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; } #${PANEL_ID}.is-open { display: flex; } #${PANEL_ID} .cdab-box { width: 720px; max-width: 94vw; max-height: 86vh; display: flex; flex-direction: column; background: #ffffff; color: #0f172a; border-radius: 16px; overflow: hidden; box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35); animation: cdab-in .22s cubic-bezier(.34, 1.4, .64, 1); } @keyframes cdab-in { from { opacity: 0; transform: translateY(12px) scale(.98); } to { opacity: 1; transform: none; } } #${PANEL_ID} .cdab-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 22px; border-bottom: 1px solid #e2e8f0; } #${PANEL_ID} .cdab-title { font-size: 15px; font-weight: 700; } #${PANEL_ID} .cdab-sub { font-size: 12px; color: #64748b; margin-top: 3px; } #${PANEL_ID} .cdab-close { border: none; background: transparent; font-size: 20px; line-height: 1; color: #94a3b8; cursor: pointer; padding: 4px 8px; border-radius: 8px; } #${PANEL_ID} .cdab-close:hover { background: #f1f5f9; color: #0f172a; } #${PANEL_ID} .cdab-body { padding: 6px 22px 18px; overflow-y: auto; } #${PANEL_ID} .cdab-group-wrap { margin-top: 14px; } #${PANEL_ID} .cdab-group { font-size: 12px; font-weight: 600; color: #64748b; margin: 0 0 8px; } /* 两列网格:14 条规则排成 7 行,比单列短一半,基本不用滚动 */ #${PANEL_ID} .cdab-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 10px; } /* 窄屏(手机 / 分屏)回退单列,避免名字被压成一两个字 */ @media (max-width: 620px) { #${PANEL_ID} .cdab-grid { grid-template-columns: minmax(0, 1fr); } } #${PANEL_ID} .cdab-item { display: flex; align-items: center; gap: 9px; padding: 8px 10px; border-radius: 10px; cursor: pointer; min-width: 0; transition: background .15s ease; } #${PANEL_ID} .cdab-item:hover { background: #f8fafc; } /* 作用域外的规则(如首页里的博文页专属规则):置灰不可点 */ #${PANEL_ID} .cdab-item.is-outscope { cursor: not-allowed; opacity: .45; } #${PANEL_ID} .cdab-item.is-outscope:hover { background: transparent; } /* 真实 checkbox 视觉隐藏但保留交互(用 clip 而非 display:none, display:none 会让键盘/部分环境下的点击失效) */ #${PANEL_ID} .cdab-item input { position: absolute; width: 1px; height: 1px; opacity: 0; margin: 0; pointer-events: none; } #${PANEL_ID} .cdab-name { flex: 1 1 auto; min-width: 0; font-size: 12.5px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* 作用域提示(如「仅博文页」):在首页上说明这条规则为何此刻不生效 */ #${PANEL_ID} .cdab-note { flex: none; font-size: 10px; line-height: 1; padding: 3px 5px; border-radius: 5px; background: #f1f5f9; color: #64748b; white-space: nowrap; } /* 开关:紧跟在 input 之后,用通用兄弟选择器 ~ 而非相邻 + , 这样即使中间将来再插节点也不会失配 */ #${PANEL_ID} .cdab-switch { position: relative; flex: none; order: -1; /* 开关放到最左边,视觉上更像设置项列表 */ width: 36px; height: 20px; border-radius: 10px; background: #cbd5e1; transition: background .2s ease; } #${PANEL_ID} .cdab-switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15, 23, 42, .3); transition: transform .2s ease; } #${PANEL_ID} input:checked ~ .cdab-switch { background: #3b82f6; } #${PANEL_ID} input:checked ~ .cdab-switch::after { transform: translateX(16px); } #${PANEL_ID} .cdab-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 22px; border-top: 1px solid #e2e8f0; background: #f8fafc; font-size: 12px; color: #64748b; } #${PANEL_ID} .cdab-link { color: #3b82f6; cursor: pointer; text-decoration: none; } #${PANEL_ID} .cdab-link:hover { text-decoration: underline; } @media (prefers-color-scheme: dark) { #${PANEL_ID} .cdab-box { background: #1e293b; color: #e2e8f0; } #${PANEL_ID} .cdab-head { border-bottom-color: #334155; } #${PANEL_ID} .cdab-foot { background: #0f172a; border-top-color: #334155; color: #94a3b8; } #${PANEL_ID} .cdab-item:hover { background: #334155; } #${PANEL_ID} .cdab-item.is-outscope:hover { background: transparent; } #${PANEL_ID} .cdab-close:hover { background: #334155; color: #e2e8f0; } #${PANEL_ID} .cdab-switch { background: #475569; } #${LAUNCHER_ID} { background: rgba(30, 41, 59, .92); border-color: rgba(148, 163, 184, .25); } } `; (document.head || document.documentElement).appendChild(s); } function buildPanel() { if (document.getElementById(PANEL_ID)) return; const box = document.createElement('div'); box.id = PANEL_ID; box.innerHTML = ` `; // 逐组逐条渲染开关。用 fixed id 作 key,中文 name 仅用于展示。 const body = box.querySelector('.cdab-body'); for (const group of RULE_GROUPS) { const wrap = document.createElement('div'); wrap.className = 'cdab-group-wrap'; const title = document.createElement('div'); title.className = 'cdab-group'; title.textContent = `${group.icon} ${group.title}`; wrap.appendChild(title); // 两列网格容器:每条规则占一格,14 条规则视觉长度直接腰斩 const grid = document.createElement('div'); grid.className = 'cdab-grid'; for (const id of group.ids) { const rule = RULES.find((r) => r.id === id); if (!rule) continue; // 结构:[input][ .cdab-switch ][ .cdab-name ][ .cdab-note? ] // 关键:input 与 .cdab-switch 之间只能隔兄弟节点,用 ~ 通用兄弟 // 选择器驱动开关样式;不要再插一层包裹 span,否则 // input:checked + .cdab-switch 会失配(v2.0.0 开关不变色的 bug 根源)。 const label = document.createElement('label'); label.className = 'cdab-item'; label.title = rule.match; // 悬停可看到选择器,方便排查 label.dataset.ruleId = rule.id; const scoped = inScope(rule); if (!scoped) label.classList.add('is-outscope'); const input = document.createElement('input'); input.type = 'checkbox'; // 勾选状态始终忠实反映"配置里这条规则开没开",不掺入作用域。 // 否则首页会把博文页专属规则显示成未勾选,让人误以为配置丢了。 input.checked = isRuleEnabled(rule); input.disabled = !scoped; input.addEventListener('change', () => { config[rule.id] = input.checked; saveConfigDebounced(); applyConfigChange(); }); const switchEl = document.createElement('span'); switchEl.className = 'cdab-switch'; const nameEl = document.createElement('span'); nameEl.className = 'cdab-name'; nameEl.textContent = rule.name; label.appendChild(input); label.appendChild(switchEl); label.appendChild(nameEl); // 作用域外补一个提示,解释"开关是开的,但本页用不上" if (!scoped) { const note = document.createElement('span'); note.className = 'cdab-note'; note.textContent = '仅博文页'; label.appendChild(note); } grid.appendChild(label); } wrap.appendChild(grid); body.appendChild(wrap); } document.body.appendChild(box); // 事件绑定 box.querySelector('.cdab-close').addEventListener('click', closePanel); box.addEventListener('click', (e) => { // 点遮罩(而非卡片内部)关闭 if (e.target === box) closePanel(); }); const setAll = (value) => { // 只影响当前页面作用域内的规则;作用域外的规则保持原配置不动, // 否则在首页点一次"全部关闭"会把博文页的侧栏规则也一起关掉, // 等用户进博文页才发现侧栏还在,很难排查。 for (const rule of RULES) { if (!inScope(rule)) continue; config[rule.id] = value; } saveConfigDebounced(); syncPanelInputs(); applyConfigChange(); }; box.querySelector('.cdab-allon').addEventListener('click', () => setAll(true)); box.querySelector('.cdab-alloff').addEventListener('click', () => setAll(false)); syncPanelInputs(); } // 把当前 config 回填到面板控件(全部开启/关闭、或页面类型变化时用) function syncPanelInputs() { const box = document.getElementById(PANEL_ID); if (!box) return; const items = box.querySelectorAll('.cdab-item'); for (const item of items) { const input = item.querySelector('input'); const id = item.dataset.ruleId; if (!input || !id) continue; const rule = RULES.find((r) => r.id === id); if (!rule) continue; const scoped = inScope(rule); input.disabled = !scoped; input.checked = isRuleEnabled(rule); // 忠实反映配置,与作用域无关 item.classList.toggle('is-outscope', !scoped); } updateStatus(); } function updateStatus() { const box = document.getElementById(PANEL_ID); if (!box) return; const status = box.querySelector('.cdab-status'); if (!status) return; const scopedIds = RULE_IDS.filter((id) => { const rule = RULES.find((r) => r.id === id); return rule && inScope(rule); }); const on = scopedIds.filter((id) => config[id] !== false).length; const label = PAGE.isArticle ? '博文页' : '本页'; status.textContent = `${label}生效中 ${on} / ${scopedIds.length} 条` + `(全站共 ${RULE_IDS.length} 条)`; } // 开关变化的核心:重建 CSS + 立刻重扫一遍 function applyConfigChange() { refreshCss(); cleanup(); updateStatus(); } // 供外部(回归测试 / 控制台调试)驱动开关的通道。 // 仅在宿主环境显式提供 __csdnAdblockTest 时才挂载,正常浏览页面不产生任何影响。 try { if (typeof window !== 'undefined' && window.__csdnAdblockTest) { window.__csdnAdblockTest.api = { setRule(id, on) { config[id] = !!on; applyConfigChange(); }, getRule: (id) => config[id] !== false, isInScope: (id) => { const rule = RULES.find((r) => r.id === id); return !!rule && inScope(rule); }, ruleIds: RULE_IDS.slice(), page: () => ({ isArticle: PAGE.isArticle, isHome: PAGE.isHome }), apply: applyConfigChange, }; } } catch (e) { /* 忽略 */ } function openPanel() { buildPanel(); const box = document.getElementById(PANEL_ID); if (!box) return; syncPanelInputs(); // 每次都按当前页面作用域刷新一遍 box.classList.add('is-open'); } function closePanel() { const box = document.getElementById(PANEL_ID); if (box) box.classList.remove('is-open'); } function buildLauncher() { if (document.getElementById(LAUNCHER_ID)) return; injectPanelStyle(); const btn = document.createElement('div'); btn.id = LAUNCHER_ID; btn.title = 'CSDN 净化设置'; btn.textContent = '🧹'; btn.addEventListener('click', openPanel); document.body.appendChild(btn); // ESC 关闭 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePanel(); }); } /* ========================================================= * 6. 持续监听 —— CSDN 的广告是异步插入的,需要反复清理 * ========================================================= */ function startObserver() { cleanup(); // 6.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'], }); // 6.2 兜底轮询:部分广告由定时器插入,MutationObserver 可能漏掉 let ticks = 0; const timer = setInterval(() => { cleanup(); // 前 30 秒高频清理,之后降频常驻 if (++ticks === 120) { clearInterval(timer); setInterval(cleanup, 3000); } }, 250); buildLauncher(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startObserver, { once: true }); } else { startObserver(); } })();