广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何用Node.js搭建DeGuard风格广告拦截服务?

浏览器扩展能拦截大部分网页广告,但遇到智能电视、移动端 WebView 或者不想在每台设备上重复配置的情况,网络层的广告拦截服务反而更实用。DeGuard 这类服务的基本思路是在本地或服务器上启动一个 HTTP 代理,让所有设备流量先经过它,代理根据规则判断请求是否指向广告资源,如果是就直接返回空内容或断开连接,从而在不修改客户端代码的前提下实现全局去广告。下面用 Node.js 从零实现一个简化版的 DeGuard。

如何用Node.js搭建DeGuard风格广告拦截服务?

广告拦截代理的工作流程与关键模块

一个完整的广告拦截代理至少包含三个模块:代理服务器本身、规则引擎以及可选的响应过滤器。代理服务器负责接收客户端请求并转发到目标服务器,规则引擎判断哪些请求属于广告,响应过滤器则在转发返回的 HTML 或 JavaScript 中进一步剔除广告脚本。对于纯 HTTP 流量,代理可以直接读取请求头中的 Host 和路径,然后与广告域名列表比对。对于 HTTPS 流量,普通代理只能通过 CONNECT 方法建立隧道,无法看到加密内容,因此要么只处理明文 HTTP 广告请求,要么引入自签 CA 证书做中间人解密,让代理可以检查 HTTPS 请求的域名和响应体。

下面是一段最基础的 HTTP 代理代码,它监听 8080 端口,对命中的广告请求直接返回 204 No Content,其余请求原样转发。这里使用了 Node.js 内置的 http 模块,没有引入额外依赖,方便理解核心流程。

const http = require('http');
function isAdRequest(req) {
  // 实际项目会从规则引擎中加载广告域名
  const adHosts = new Set(['doubleclick.net', 'googlesyndication.com']);
  const host = req.headers.host || '';
  return adHosts.has(host);
}
const server = http.createServer((req, res) => {
  if (isAdRequest(req)) {
    res.writeHead(204, { 'Content-Type': 'text/plain' });
    res.end();
    return;
  }
  const options = {
    hostname: req.headers.host || 'localhost',
    port: 80,
    path: req.url,
    method: req.method,
    headers: req.headers
  };
  const proxyReq = http.request(options, (proxyRes) => {
    res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
    proxyRes.pipe(res);
  });
  proxyReq.on('error', (err) => {
    res.writeHead(502);
    res.end('Bad Gateway');
  });
  req.pipe(proxyReq);
});
server.listen(8080, () => {
  console.log('Ad blocking proxy listening on 8080');
});

这份代码只能处理 HTTP 请求,而且没有区分广告请求的方法和路径,实际运行中还需要加上错误处理和连接超时。另外 Host 头有时会包含端口号,比如 ipipp.com:8080,直接拿来做 Set 查找会漏判,因此需要先去掉端口部分。一个更健壮的做法是解析 URL 或使用正则提取纯域名。

对于 HTTPS 流量,可以在服务器上开启 HTTPS 代理模式,通过 CONNECT 建立隧道后不解析内容,这样广告请求会原样通过,无法拦截。如果需要拦截 HTTPS 广告,必须生成根证书并配置客户端信任,然后在代理层用 TLS 模块解密请求。这部分实现复杂度较高,但原理相同:解密后得到请求头,再套用同一套规则引擎即可。

加载与匹配 EasyList 风格的广告规则

大多数广告拦截工具使用 EasyList 这类规则列表,每一行是一条规则,常见格式包括 ||doubleclick.net^、||ipipp.com/banner/* 以及 /ad-\d+\.js/ 这样的正则表达式。规则文件中以 ! 开头的行是注释,[ 开头的是配置分组,需要过滤掉。把域名规则提取出来存进 Set,查找时时间复杂度为 O(1),比逐个正则匹配快得多。对于确实需要正则的规则,可以单独存到一个数组,只在域名规则未命中时再遍历。

下面的代码实现了规则文件的加载与匹配,支持 ||domain.com^ 形式的域名规则和以斜杠包裹的正则规则。为了简化,这里没有处理通配符和选项标志,但足以说明核心思路。

const fs = require('fs');
function loadRules(filePath) {
  const content = fs.readFileSync(filePath, 'utf8');
  const domains = new Set();
  const patterns = [];
  const lines = content.split('\n');
  for (let line of lines) {
    line = line.trim();
    if (!line || line.startsWith('!') || line.startsWith('[')) continue;
    if (line.startsWith('||')) {
      let domain = line.slice(2);
      const separator = domain.search(/[\/^]/);
      if (separator !== -1) domain = domain.slice(0, separator);
      // 去掉可能的通配符前缀,这里按纯域名处理
      if (domain.startsWith('*.')) domain = domain.slice(2);
      domains.add(domain);
    } else if (line.startsWith('/') && line.endsWith('/')) {
      patterns.push(new RegExp(line.slice(1, -1)));
    }
  }
  return { domains, patterns };
}
function isAdRequest(url, host, rules) {
  if (rules.domains.has(host)) return true;
  for (const pattern of rules.patterns) {
    if (pattern.test(url)) return true;
  }
  return false;
}
module.exports = { loadRules, isAdRequest };

规则匹配的性能直接决定代理的延迟。如果正则数量很多,每次请求都遍历会带来明显开销。常见的优化手段包括:把域名规则组织成前缀树或哈希表,对正则进行编译缓存,甚至把规则编译成有限状态机。Node.js 的单线程模型下,大量同步正则匹配会阻塞事件循环,因此可以把耗时匹配放到 Worker 线程中,或者使用像 adblock-rs 这类 Rust 编写的原生模块来加速。

另外一个容易忽略的问题是规则更新。广告域名经常变化,服务端需要定期从远程拉取最新的 EasyList 并重新加载,同时保证正在处理的请求不会因为规则对象被替换而出现不一致。简单做法是使用双缓冲,先构建新的规则对象,再原子地替换全局引用。

响应体过滤与 HTML 内容修改

有些广告脚本不是独立的请求,而是内嵌在 HTML 中的 <script> 标签或动态注入的节点。仅仅拦截广告域名的请求无法移除这类元素,因此代理还需要具备修改响应体的能力。实现方式是创建一个 Transform 流,在数据经过时缓冲并替换掉匹配的 HTML 片段。

下面是一个最简单的响应过滤流,它把响应中的 <script> 标签整体移除。这个例子假设脚本标签都在同一个 chunk 内,实际网络数据可能被切成多个 chunk,跨 chunk 的标签会漏掉,所以更可靠的做法是先收集完整响应体,再做字符串替换或 DOM 操作。

const { Transform } = require('stream');
class AdRemoveTransform extends Transform {
  _transform(chunk, encoding, callback) {
    this.buffer += chunk.toString();
    // 简单移除页面中的 script 标签,实际需要处理跨 chunk 的情况
    this.buffer = this.buffer.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
    this.push(this.buffer);
    this.buffer = '';
    callback();
  }
}
// 在代理响应管道中使用
// proxyRes.pipe(new AdRemoveTransform()).pipe(res);

上面的正则替换非常脆弱,遇到 <script type="text/javascript"> 这种带属性的标签还能处理,但遇到跨行、大小写混合或者反斜杠转义的情况就可能失效。生产环境建议使用 cheerio 或 parse5 这样的 HTML 解析库,它可以把 HTML 解析成 DOM 树,然后精确删除广告脚本节点。缺点是会重新序列化整个文档,可能改变空格和属性顺序,对性能也有一定影响。

实际部署时通常只对 text/html 类型的响应启用过滤,对图片、CSS、字体等二进制资源直接放行,避免不必要的处理。同时要检查响应头中的 Content-Encoding,如果内容是 gzip 或 br 压缩过的,必须先解压再过滤,过滤后再压缩返回,否则客户端无法解析。

性能优化与部署建议

Node.js 代理的性能瓶颈主要在网络 IO 和规则匹配。对于代理服务器本身,可以使用 cluster 模块启动多个进程,利用多核 CPU。每个子进程独立监听同一个端口,由操作系统负载均衡。同时设置合理的 timeout 和 keepAlive,复用与上游服务器的连接,减少握手开销。

下面是一个简单的 cluster 启动模板,主进程按 CPU 核心数 fork 子进程,子进程加载代理逻辑。代码中的小于号需要转义,展示时才是正常的比较表达式。

const cluster = require('cluster');
const os = require('os');
if (cluster.isMaster) {
  const numCPUs = os.cpus().length;
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
  cluster.on('exit', (worker) => {
    console.log('worker ' + worker.process.pid + ' died, restarting...');
    cluster.fork();
  });
} else {
  require('./proxy-server');
}

如果代理部署在局域网内,可以把客户端的代理设置指向运行 Node.js 的机器 IP 和端口,也可以配合系统 PAC 文件实现自动分流。对于 HTTPS 广告过滤,需要生成自签根证书并安装到所有客户端设备,这一步会带来安全警告,只适合个人或家庭使用。对外提供公共服务时,不要在未获得证书授权的情况下解密用户流量,否则可能违反法律和隐私政策。

缓存方面,经常访问的广告域名可以缓存在内存中,并设置 TTL 定期刷新。DNS 查询也可以缓存,Node.js 默认使用系统 DNS 并带有一定缓存,但代理场景下请求量大,可以考虑使用 dns.lookup 的缓存选项或第三方 DNS 客户端。另外,日志记录要做异步化,避免同步写日志阻塞事件循环。

通过上述实现,一个基础的 DeGuard 风格广告拦截服务就搭建完成了。虽然距离商业级产品还有不少差距,但核心的代理转发、规则匹配、响应过滤和性能优化思路已经具备,可以在这个基础上继续扩展自定义规则、Web 管理界面和统计报表。

广告位 · 文章底部(后台-模块管理替换此区域)