// ==UserScript==
// @name 百度搜索广告净化
// @namespace https://scriptcat.org/users/pwhscr
// @version 1.0.0
// @description 移除百度搜索结果页的推广/广告条目,恢复干净的结果列表。多重信号识别,支持动态加载结果,附带拦截计数与一键还原。
// @author pwhscr
// @license MIT
// @homepageURL https://userscripts-toolkit.app.workbuddy.host/
// @supportURL https://userscripts-toolkit.app.workbuddy.host/
// @match https://www.baidu.com/*
// @match https://baidu.com/*
// @match https://m.baidu.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
'use strict';
const STORE_KEY = 'baidu_adblock_stats';
// 不同脚本管理器对布尔值的存取方式不一致(有的原样返回,有的返回 'true'/'false' 字符串),统一收口
function readBool(key, def) {
const v = GM_getValue(key, def);
if (v === false || v === 'false' || v === 0 || v === '0') return false;
if (v === true || v === 'true' || v === 1 || v === '1') return true;
return !!def;
}
// 兼容「存储的是字符串」与「存储的是对象」两种 GM 实现
function readJSON(key) {
const raw = GM_getValue(key, '');
if (!raw) return {};
if (typeof raw === 'string') {
try {
return JSON.parse(raw);
} catch (e) {
return {};
}
}
return typeof raw === 'object' ? raw : {};
}
// ---------- 状态 ----------
const state = {
enabled: readBool('enabled', true),
// 用户点了「显示」后进入暂停态:不再自动收敛,否则他刚展开就又被收回去
paused: false,
hidden: 0,
total: 0,
hiddenSinceSave: 0,
// 记录被隐藏的节点,方便「临时显示」
records: [],
stash: [],
};
// ---------- 广告识别 ----------
// 思路:不用单一 class 判断(百度改版频繁),而是多信号加权。
// 实测(2026-09)新版百度在结果块内部标注:
// 广告
// 注意 data-tuiguang 挂在块内的 span 上,容器本身不带这个属性。
// 容器自身或内部出现这些 class 即视为广告位
const AD_CLASS_TOKENS = [
'ec-tuiguang', 'ecfc-tuiguang', 'ec_ads', 'ec_wise_ad',
'ec_ad_', 'wise_ad', 'result-ad', 'ad-result',
];
const AD_LABEL_RE = /^(广告|广告·|商业推广|推广|赞助|品牌推广|广告位)[\s·]*$/;
// 百度自然结果带的锚点属性,作为「不是广告」的保护条件
const NATURAL_ATTR = /mu=|data-log=/;
// 官方广告标注元素(新版挂在结果块内部,老版在容器自身)
const MARK_SEL = '[data-tuiguang], .ec-tuiguang, .ecfc-tuiguang';
/** 容器自身、或容器内的标注元素是否带广告 class */
function hasStrongClass(el) {
const own = ((el.getAttribute('class') || '') + ' ' + (el.getAttribute('id') || '')).toLowerCase();
if (own.trim()) {
if (AD_CLASS_TOKENS.some((t) => own.includes(t))) return true;
// ec_r_ 系列是前缀式命名,不能整词匹配
if (/(^|\s)ec_r_/.test(own)) return true;
}
const inner = el.querySelector(
'.ec-tuiguang, .ecfc-tuiguang, .ec_ads, .ec_wise_ad, [class*="ec_ad_"]');
return !!inner;
}
/** 块内是否出现「广告」文字标签(限制尺寸,避免误伤正文里提到"广告"的结果) */
function hasAdLabel(el) {
const nodes = el.querySelectorAll('span, div, a, em, i');
for (const n of nodes) {
const txt = (n.textContent || '').trim();
// 标签本身极短;长文本说明是摘要/标题,不是标注
if (!txt || txt.length > 6 || !AD_LABEL_RE.test(txt)) continue;
const r = n.getBoundingClientRect();
// 量得到尺寸且明显偏大 → 是内容块而非角标
if (r.width > 0 && r.height > 0 && (r.width > 90 || r.height > 34)) continue;
return true;
}
return false;
}
/** 块内是否有百度的推广跳转链接(自然结果用的是 /link?url=,不是 baidu.php?url=) */
function hasPromotionLink(el) {
for (const a of el.querySelectorAll('a[href]')) {
const href = a.getAttribute('href') || '';
if (/baidu\.php\?url=/i.test(href) || a.hasAttribute('data-landurl')) return true;
}
return false;
}
/** 判定某个结果块是不是广告 */
function isAdBlock(el) {
if (!el || el.nodeType !== 1) return false;
if (el.dataset.cleanerHidden) return false;
// 抓到的其实是上层容器时,一律不动——避免整个结果区被隐藏
if (looksLikeContainer(el)) return false;
// ① 广告标注 class(自身或内部)
if (hasStrongClass(el)) return true;
// ② 块内(或块自身)存在官方广告标记属性
// 实测新版挂在块内的标注 span 上,老版挂在结果容器自身,两种都要覆盖
if (el.hasAttribute('data-tuiguang') || el.querySelector('[data-tuiguang]')) return true;
// ③ 「广告」文字标签
if (hasAdLabel(el)) return true;
// ④ 推广跳转链接(带自然结果锚点的块不看这条,避免误伤)
if (!NATURAL_ATTR.test(el.outerHTML.slice(0, 400)) && hasPromotionLink(el)) return true;
return false;
}
// ---------- 收集候选块 ----------
// 结果块的形态随版本变化:
// 旧版:#content_left > div.result
// 新版:#content_left > section > div.result (中间多包了一层)
// 必须先下钻到真正的结果块,否则会把整个结果区当成"一条广告"整块隐藏。
const BLOCK_SEL = ':scope > .result, :scope > .result-op, :scope > .c-container, :scope > [class*="EC_result"]';
function collectRoots() {
const roots = [];
const left = document.querySelector('#content_left');
if (left) {
for (const child of left.children) {
const nested = child.querySelectorAll(BLOCK_SEL);
if (nested.length) {
for (const n of nested) roots.push(n);
} else {
roots.push(child);
}
}
}
// 顶部/底部/右侧的独立广告位
for (const id of ['#content_top', '#content_bottom', '#content_right', '#brand_ad', '#top-ad']) {
const box = document.querySelector(id);
if (box) roots.push(...box.children);
}
// 兜底
if (!roots.length) {
roots.push(...document.querySelectorAll('.result, .result-op, .c-container'));
}
// 过滤掉空壳占位(innerText 对不可见元素返回空,用 textContent 兜底)
return roots.filter((n) => n && n.nodeType === 1 &&
((n.innerText || n.textContent || '').trim().length > 0));
}
/** 安全阀:如果候选块里塞了很多条广告标记,说明抓到的是上层容器而不是单条广告。
* 宁可漏杀,也不能把整个结果区干掉。 */
function looksLikeContainer(el) {
try {
const marks = el.querySelectorAll(MARK_SEL);
if (marks.length >= 3) return true;
const txt = el.innerText || el.textContent || '';
if (txt.length > 6000) return true;
} catch (e) {
/* 忽略 */
}
return false;
}
/**
* 从官方广告标注向上找它所属的那一条结果块。
* 这是主力手段——新版百度给结果块用的是 CSS Modules 哈希 class(如 u2a6nht),
* 靠 class 名字根本认不出来,但标注元素本身是可靠的锚点。
*/
function blockOfMarker(marker, zone) {
let cur = marker;
let best = null;
for (let i = 0; i < 12; i++) {
if (!cur || cur.nodeType !== 1 || cur === zone) break;
// 注意:querySelectorAll 只查后代,标注挂在容器自身时要单独算上
const marks = (cur.matches(MARK_SEL) ? 1 : 0) + cur.querySelectorAll(MARK_SEL).length;
const len = (cur.textContent || '').trim().length;
// 标记变多或文本量暴涨 → 已经越过单条结果的边界
if (marks > 2 || len >= 2500) break;
if (marks <= 2 && len > 8) best = cur;
cur = cur.parentElement;
}
return best || marker;
}
// ---------- 执行净化 ----------
const ZONES = ['#content_left', '#content_right', '#content_top', '#content_bottom'];
function clean(reason) {
if (!state.enabled || state.paused) return 0;
const targets = new Set();
// ① 主力:官方广告标注 → 反推它所在的那条结果
for (const z of ZONES) {
const zone = document.querySelector(z);
if (!zone) continue;
for (const m of zone.querySelectorAll(MARK_SEL)) {
const block = blockOfMarker(m, zone);
if (block) targets.add(block);
}
}
// ② 兜底:class 特征、「广告」文字标签、推广跳转链接
for (const el of collectRoots()) {
if (isAdBlock(el)) targets.add(el);
}
let n = 0;
for (const el of targets) {
if (!el || !el.isConnected || el.dataset.cleanerHidden) continue;
hide(el);
n++;
}
if (n) {
state.hidden += n;
state.total += n;
state.hiddenSinceSave += n;
saveStats();
paintBadge();
}
return n;
}
function hide(el) {
if (el.dataset.cleanerHidden) return;
el.dataset.cleanerHidden = '1';
el.dataset.cleanerPrevDisplay = el.style.display || '';
el.style.display = 'none';
state.records.push(el);
}
/** 临时显示被拦截的结果 */
function restore() {
if (state.records.length) {
state.stash = state.records.filter((el) => el.isConnected);
}
for (const el of state.stash) {
el.style.display = el.dataset.cleanerPrevDisplay || '';
delete el.dataset.cleanerHidden;
}
state.records = [];
paintBadge();
}
/** 从「临时显示」切回净化状态 */
function rehide() {
state.paused = false;
state.records = state.stash.filter((el) => el.isConnected);
for (const el of state.records) {
el.style.display = 'none';
el.dataset.cleanerHidden = '1';
}
state.stash = [];
paintBadge();
}
// ---------- 统计 ----------
function saveStats() {
try {
const today = new Date().toISOString().slice(0, 10);
const data = readJSON(STORE_KEY);
data[today] = (data[today] || 0) + state.hiddenSinceSave;
state.hiddenSinceSave = 0;
GM_setValue(STORE_KEY, JSON.stringify(data));
} catch (e) {
/* 忽略 */
}
}
// ---------- 界面 ----------
let badge = null;
function paintBadge() {
if (!badge) return;
const btn = badge.querySelector('.bd-toggle');
const label = badge.querySelector('.bd-label');
const count = badge.querySelector('.bd-count');
if (state.paused) {
badge.style.display = 'flex';
badge.classList.add('paused');
label.textContent = '已还原';
count.textContent = String(state.stash.length);
btn.textContent = '隐藏';
return;
}
badge.classList.remove('paused');
label.textContent = '已净化';
btn.textContent = '显示';
if (!state.records.length) {
badge.style.display = 'none';
return;
}
badge.style.display = 'flex';
count.textContent = String(state.records.length);
}
function buildBadge() {
if (badge || !document.body) return;
badge = document.createElement('div');
badge.id = 'baidu-ad-cleaner-badge';
badge.innerHTML = [
'',
'已净化',
'0',
'条广告',
'',
].join('');
const css = document.createElement('style');
css.textContent = `
#baidu-ad-cleaner-badge{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:none;
align-items:center;gap:6px;padding:7px 11px;border-radius:999px;background:rgba(24,28,36,.92);
color:#e8eaed;font:12px/1.4 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
box-shadow:0 4px 16px rgba(0,0,0,.22);backdrop-filter:blur(8px);user-select:none}
#baidu-ad-cleaner-badge .bd-dot{width:7px;height:7px;border-radius:50%;background:#41d18a}
#baidu-ad-cleaner-badge .bd-count{font-weight:700;color:#41d18a}
#baidu-ad-cleaner-badge.paused .bd-dot{background:#f0a531}
#baidu-ad-cleaner-badge.paused .bd-count{color:#f0a531}
#baidu-ad-cleaner-badge .bd-toggle{margin-left:4px;padding:2px 8px;border:1px solid rgba(255,255,255,.28);
border-radius:999px;background:transparent;color:#e8eaed;font-size:11px;cursor:pointer}
#baidu-ad-cleaner-badge .bd-toggle:hover{background:rgba(255,255,255,.14)}`;
document.head.appendChild(css);
document.body.appendChild(badge);
badge.querySelector('.bd-toggle').addEventListener('click', () => {
if (!state.paused) {
state.paused = true;
restore();
} else {
rehide();
clean('resume');
}
// 每轮点击最多生效一次,避免用户连点导致状态错位
setTimeout(paintBadge, 0);
});
paintBadge();
}
// ---------- 观察动态加载 ----------
let timer = null;
function scheduleClean() {
if (timer) return;
timer = setTimeout(() => {
timer = null;
clean('mutation');
}, 260);
}
function watch() {
const obs = new MutationObserver(scheduleClean);
obs.observe(document.documentElement, { childList: true, subtree: true });
}
// ---------- 启动 ----------
function boot() {
buildBadge();
clean('init');
// 首屏后几秒是有广告注入的高峰期,多扫几轮
[400, 900, 1800, 3200].forEach((t) => setTimeout(() => clean('delayed'), t));
watch();
// 单页/翻页时重新激活
window.addEventListener('popstate', scheduleClean);
}
// ---------- 菜单 ----------
try {
GM_registerMenuCommand(state.enabled ? '停用净化(本页生效)' : '启用净化(本页生效)', () => {
state.enabled = !state.enabled;
GM_setValue('enabled', state.enabled);
state.paused = false;
if (!state.enabled) {
restore();
state.stash = [];
} else {
state.stash = [];
clean('menu');
}
paintBadge();
});
GM_registerMenuCommand('查看累计统计', () => {
const data = readJSON(STORE_KEY);
const today = new Date().toISOString().slice(0, 10);
const sum = Object.values(data).reduce((a, b) => a + (parseInt(b, 10) || 0), 0);
alert('广告净化统计\n\n今日:' + (data[today] || 0) + ' 条\n累计:' + sum + ' 条\n\n(按自然日统计)');
});
} catch (e) {
/* GM_registerMenuCommand 不可用时忽略 */
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();