实现一个adblock/adblock plus等浏览器广告拦截器检测插件

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:nonevisibility:hidden 了。两种方式加权综合(网络 60% + 诱饵 40%),准确率比任何单方案都高。

从 10 条规则开始

EasyList 主列表有数千条规则,全量探测不现实。我们精选了 32 条高命中率的规则,覆盖四大分类:

分类 代表规则
域名拦截 Google AdSense、DoubleClick、Amazon 广告、百度联盟、Tanx、腾讯广点通
路径通配 ads.jsad/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.jsonexports 字段精确指定了每种导入路径对应的文件,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 条全量覆盖加规则开关,再到并发控制、轮询、状态变化回调------每一个迭代都在解决实际问题,没有为了花哨而加功能。


相关推荐
阳光是sunny2 小时前
Vue 项目怎么做用户行为全链路监控?轻量插件方案详解
前端·面试·架构
ZhengEnCi2 小时前
Q04-Vite禁用CSS代码分割-解决生产环境样式加载顺序混乱问题
前端·vue.js·vite
九酒2 小时前
AI Agent 开发踩坑记:口播功能非得用 APP 原生实现吗?
前端·人工智能·agent
Jackson__3 小时前
做了一段时间的AI coding后,我终于搞清了 CLI 和 MCP 的区别
前端·agent·ai编程
IT_陈寒5 小时前
JavaScript项目实战经验分享
前端·人工智能·后端
用户47949283569156 小时前
6w star,GitHub 趋势第一的 Ponytail,这个agent插件到底在火什么
前端·后端
薛定喵的谔7 小时前
我开源了一个精致的 Next.js 博客模板:Skyplume
前端·前端框架·next.js
张龙6878 小时前
构建生产级 AI Agent:工具调用与记忆架构实战指南
前端
kyriewen9 小时前
2026 年了,还在用 Node.js?Bun 迁移实战:20 分钟搞定,附踩坑记录
前端·javascript·node.js