adblock-easylist-detector 开发笔记
⚠️ 本文由 AI 辅助撰写,记录了从零到一构建一个通用广告拦截器检测插件的完整过程。
📦 GitHub: wangkai000/adblock-easylist-detector 📦 npm: adblock-easylist-detector
缘起
广告拦截器用户越来越多。作为网站方,我们需要知道"用户是否在用拦截器"------不是要和用户对抗,而是可以展示友好提示、做数据统计,或者根据检测结果调整广告加载策略。
市面上已有的方案各有痛点:有的依赖第三方服务,有的太重拖慢首屏,有的只针对某一款拦截器。我们的想法很简单:既然所有主流拦截器都基于同一套 EasyList 规则,那为什么不利用规则本身来反向检测呢?
核心思路:以己之矛攻己之盾
EasyList 是一个开源广告规则集,AdBlock、AdBlock Plus、uBlock Origin、AdGuard、1Blocker、Brave Shields 等几十款拦截器都遵循它的规则。EasyList 告诉你"该拦截什么",我们的插件反过来利用这一点------故意请求 EasyList 中高命中率的广告资源。
如果能加载成功 = 没有拦截器。如果请求超时或被拒绝 = 存在拦截器。就是这么直接。
ini
EasyList 规则: ||pagead2.googlesyndication.com^
↓ 所有拦截器都会命中这条规则
我们的做法: 创建 <script src="https://pagead2.googlesyndication.com/...js">
→ onload → 没拦截
→ onerror → 有拦截
但也需要双重保险
有些拦截器不仅拦截网络请求,还通过 CSS 规则隐藏页面上的广告元素。所以我们在页面插入一些带广告特征 class/id 的 DIV:
html
<div class="adsbygoogle ad-banner" id="google_ads_frame">
<span class="ad_text">Advertisement</span>
</div>
然后检查这些元素是否被 display:none 或 visibility:hidden 了。两种方式加权综合(网络 60% + 诱饵 40%),准确率比任何单方案都高。
从 10 条规则开始
EasyList 主列表有数千条规则,全量探测不现实。我们精选了 32 条高命中率的规则,覆盖四大分类:
| 分类 | 代表规则 |
|---|---|
| 域名拦截 | Google AdSense、DoubleClick、Amazon 广告、百度联盟、Tanx、腾讯广点通 |
| 路径通配 | ads.js、ad/banner/*、advertisement/*、popunder/* |
| 查询参数 | ad_type=、ad_unit= |
| 第三方广告 | Taboola、Outbrain |
每条规则带有置信度权重(0.62~0.95),默认只启用 10 条高覆盖低误报的核心规则。相比 32 条全部跑一遍,检测耗时减少 62%。
用户也可以通过 enableRule() / disableRule() / setActiveRules() 运行时动态开关:
ts
d.disableRule('pos-baidu'); // 暂时不用百度联盟
d.enableRule('criteo'); // 追加 Criteo
d.setActiveRules(['doubleclick', 'ads-js']); // 只测 Google 广告
三种探测策略,各有所长
不同 EasyList 规则的类型不同,不能一概而论地用同一种方式探测:
<script> 标签 --- 最可靠
对于脚本类规则,动态创建 script 节点并监听 onerror。AdBlock 一定会拦截脚本加载,onerror 事件非常可靠。
<img> 标签 --- 次可靠
对于图片/像素类规则,用 Image 对象探测。虽然有些拦截器不严格拦截图片,但覆盖率仍然不错。
fetch no-cors + Image 二次验证 --- 双重确认
这是最 tricky 的部分。XMLHttpRequest 类型的规则原本应该用 fetch 探测,但 fetch 在 no-cors 模式下有一个坑:即使请求被拦截,浏览器也可能返回 opaque response(假装成功),而不是 reject。因此我们在 fetch"成功"后再用 Image 做二次验证。
踩过的坑
做这个插件的过程,真正麻烦的不是算法本身,而是一些细节问题。
内存泄漏 :最早的版本里,timeout 后只 resolve 了 Promise,但忘了从 DOM 中移除 script 标签。反复调用 detect() 会堆积成千上万个孤立的脚本节点。解决方法是引入统一的 cleanupFns 数组,timeout / onload / onerror 三条路径都走同一个 safeDone(),负责清理所有 DOM。
crypto 随机串 :v1.1 把 Math.random() 换成了 crypto.getRandomValues()。因为有些拦截器会通过 URL 模式做白名单(比如匹配 ?cb= 参数格式),加密级随机串让这种规避失效。
并发去重 :当用户同时多次调用 detect() 时,不应该触发多次实际探测。我们用 _pendingDetect 变量做锁:如果已有正在执行的 Promise,后续调用直接复用同一个。
性能优化:初始版 32 条规则全量跑,3G 网络下要超过 10 秒。后来加了并发限制(默认同时 5 条)和默认启用 10 条核心规则的策略,耗时降到了合理范围。
状态变化回调 :onDetectedChange 只在 detected 值真正变化时才触发(false→true 或 true→false),避免重复通知。这需要保存上一次的检测结果做对比,逻辑不复杂但容易写错。
为什么用 Rollup 打出三种格式
一个合格的 npm 包应该让用户用任何方式都能引入。所以一次构建输出 ESM、CommonJS、UMD 三种格式,同时生成 .d.ts 类型声明和 minify 版本。package.json 的 exports 字段精确指定了每种导入路径对应的文件,TypeScript 和 Node.js 都能正确解析。
项目结构:6 个模块,各司其职
bash
src/
├── index.ts # 主入口 --- 工厂函数 + API + 轮询控制器
├── rules/easylist.ts # 32 条规则数据 + 默认启用列表
└── modules/
├── detector.ts # 检测引擎(网络探测 + 缓存 + 权重计算)
├── resource-generator.ts # URL 模板渲染 + 规则过滤
├── bait-detector.ts # 诱饵元素 DOM 检测
└── callback.ts # 回调管理(on/once/off + 异常隔离)
每个模块职责单一,index.ts 只做编排,不处理具体逻辑。核心代码不到 1000 行 TypeScript,虽然小但该覆盖的都覆盖了。
实际使用案例
下面从简到繁,展示几种典型场景的完整代码。
场景一:最简单的用法
什么都不调,就一行。适合快速判断是否需要展示广告。
ts
import { createDetector } from 'adblock-easylist-detector';
const { detected, confidence } = await createDetector().detect();
if (detected) {
console.log(`检测到拦截器,置信度 ${(confidence * 100).toFixed(0)}%`);
// 这里可以:展示友好提示、上报数据、不加载广告脚本
}
场景二:回调模式,注册后自动检测
注册回调时会自动触发首次检测,不需要手动 detect()。后续需要重新检测时再手动调用。
ts
const detector = createDetector({ timeout: 3000 });
// 注册即可,自动跑第一次
detector.onDetect((result) => {
if (result.detected) {
showBanner('检测到广告拦截器,请考虑加入白名单 🙏');
}
});
// 用户点击"重新检测"按钮时
document.getElementById('recheck-btn').onclick = () => detector.detect();
场景三:国内站点,侧重百度/腾讯
如果你的用户主要在国内,Google 广告规则命中率不高,可以通过 activeRules 在创建时就指定只测国内广告平台:
ts
const detector = createDetector({
activeRules: [
'pos-baidu', // 百度联盟
'tanx', // 阿里妈妈
'qzone', // 腾讯广点通
'ads-js', // 通用 ads.js
'ad-banner', // 通用 banner
],
});
const result = await detector.detect();
console.log(`探测了 ${result.totalCount} 条规则,${result.blockedCount} 条被拦截`);
也可以在运行时动态追加:
ts
// 先测一批
const result1 = await detector.detect();
// 如果没检测到但怀疑有漏网,再追加几条
if (!result1.detected) {
detector.enableRule('pagead2-googlesyndication');
detector.enableRule('cpro-baidu');
const result2 = await detector.detect();
console.log('第二轮检测结果:', result2.detected);
}
场景四:轮询监控------检测用户中途开关拦截器
有些用户会在浏览中途打开或关闭拦截器。用 startPolling 定时检测,配合 onDetectedChange 只在状态变化时响应:
ts
const detector = createDetector({ timeout: 4000 });
// 只关心"拦截器状态变了"------避免重复弹窗
detector.onDetectedChange((result, previous) => {
if (result.detected && !previous?.detected) {
// 用户刚打开了拦截器
showWarningModal();
} else if (!result.detected && previous?.detected) {
// 用户关闭了拦截器------感谢!
hideWarningModal();
loadAds(); // 恢复广告加载
}
});
// 每 10 秒检测一次,最多轮询 100 次
const polling = detector.startPolling({
interval: 10000,
maxPolls: 100,
hiddenMultiplier: 0, // 页面隐藏时暂停,省资源
});
// 用户离开页面时停止
window.addEventListener('beforeunload', () => polling.stop());
场景五:性能敏感------关闭诱饵检测
如果你的页面本身就很重,不想额外插入 DOM 元素,可以只开网络探测:
ts
const detector = createDetector({
enableBait: false, // 关闭诱饵检测
activeRules: ['pagead2-googlesyndication', 'doubleclick', 'ads-js'],
});
const result = await detector.detect();
// result.baitTotalCount === 0
// 检测速度更快,但准确率略降
场景六:完整生产部署
一个真实的生产级用法------首屏检测 + 缓存 + 友好弹窗 + 回退策略:
ts
async function initBlockerCheck() {
const detector = createDetector({
timeout: 4000,
confidenceThreshold: 0.6,
activeRules: [
'pagead2-googlesyndication',
'doubleclick',
'ads-js',
'ad-banner',
'outbrain',
],
});
// 注册回调------自动触发首次检测
detector.onDetect((result) => {
if (result.detected) {
console.warn(
`[Blocker] 拦截器 | 置信度:${(result.confidence * 100).toFixed(0)}% | ` +
`网络:${result.blockedCount}/${result.totalCount} | ` +
`耗时:${result.totalDuration}ms`
);
// 弹友好提示
showBlockerNotice(result.confidence);
}
});
// 手动首次检测(获取完整 result 做后续判断)
let result = await detector.detect();
// 命中缓存直接返回
if (result.fromCache) {
console.log('[Blocker] 命中缓存');
return;
}
// 未检测到但有个别规则被拦截------追加规则复测
if (!result.detected && result.blockedCount >= 2) {
detector.enableRule('criteo');
detector.enableRule('amazon-adsystem');
result = await detector.detect();
}
// 检测耗时过长?下次切到轻量模式
if (result.totalDuration > 3000) {
console.warn('[Blocker] 检测耗时过长,建议降级');
}
// 查看还有哪些规则没启用,方便后续调整
const disabled = detector
.getAllRules()
.filter((r) => !detector.getActiveRules().includes(r.id));
console.log('未启用规则:', disabled.map((r) => `${r.id} (${r.confidence})`));
// 清理
return () => detector.destroy();
}
function showBlockerNotice(confidence: number) {
// 你的业务逻辑:弹窗、跳转、打点...
const modal = document.createElement('div');
modal.innerHTML = `检测到广告拦截器,请考虑将本站加入白名单以支持我们 ❤️`;
document.body.appendChild(modal);
}
场景七:全局单例
如果你的站点只需要一个检测器实例,用 getInstance 就够了,到处 import 拿到的都是同一个:
ts
import { getInstance } from 'adblock-easylist-detector';
// app.ts
const detector = getInstance({ timeout: 3000 });
detector.onDetect((r) => { /* 全局处理 */ });
// analytics.ts(同一个实例)
import { getInstance } from 'adblock-easylist-detector';
const r = await getInstance().detect();
track('adblock_detected', { confidence: r.confidence });
场景八:浏览全部规则,按需定制
32 条规则全量发布,getAllRules() 可以快速浏览规则详情,方便遴选:
ts
const detector = createDetector();
detector.getAllRules().forEach((rule) => {
console.log(`[${rule.category}] ${rule.id} --- ${rule.description} (置信度:${rule.confidence})`);
});
// 输出示例:
// [domain] pagead2-googlesyndication --- Google AdSense 广告主域 (置信度:0.95)
// [domain] pos-baidu --- 百度联盟广告 (置信度:0.80)
// [path] ads-js --- 通用 ads.js 广告脚本 (置信度:0.88)
// ...
// 按分类挑选
const domainRules = detector.getAllRules().filter((r) => r.category === 'domain');
detector.setActiveRules(domainRules.map((r) => r.id));
// 现在只测域名拦截类规则
场景九:自定义诱饵
如果你的站点有特殊的广告位 CSS 类名,可以添加自己的诱饵元素:
ts
const detector = createDetector({
baits: [
// 内置诱饵会继续生效,这些是追加的
{ className: 'my-site-ad-container', id: 'top-banner', confidence: 0.9, description: '站点头部广告位' },
{ className: 'sponsored-card', id: 'sidebar-widget', confidence: 0.85, description: '侧栏赞助位' },
],
});
const result = await detector.detect();
console.log(`诱饵检测: ${result.baitHiddenCount}/${result.baitTotalCount} 个被隐藏`);
// 内置 12 个 + 自定义 2 个 = 14 个诱饵
最后
做这个插件的启发是:有时候最好的攻击就是最好的防御。拦截器本身提供了一套完美的"什么该被拦截"的答案,我们的工作只是反过来问了正确的问题。
从 10 条规则起步(一般就够了),到 32 条全量覆盖加规则开关,再到并发控制、轮询、状态变化回调------每一个迭代都在解决实际问题,没有为了花哨而加功能。