
开发日常里我们经常会被网页里的弹窗、浮动广告、视频前贴片打扰想在本地验证广告拦截规则又不想直接改浏览器插件配置这时候用日志离线跑一遍规则是最快、最安全的方式。本文会写一个完整的本地广告拦截统计脚本用 Python 加载广告规则对访问日志逐条匹配最终在一份包含 100 万条广告请求的模拟日志里成功拦截并统计出 1000000 个广告请求。整个流程适合想理解广告拦截原理、想学习正则与域名匹配、或者想把日志分析和规则过滤结合起来使用的开发者。1. 1000000 个广告是怎么被拦下来的核心思路在动手写代码前先想清楚一个问题我们平时说的“广告拦截”到底拦的是什么1.1 广告到底在哪里被拦截广告拦截并不是一个魔法功能它本质上发生在网络请求链路的某一个环节。常见的有四个层面拦截层常见实现拦截对象浏览器插件层AdBlock、uBlock Origin页面加载时拦截请求、隐藏元素DNS 层AdGuard Home、Pi-hole按域名返回空 IP 或 NXDOMAIN代理层HTTP 代理、反向代理在请求转发前拦截 URLHosts 文件层修改本地 hosts把广告域名指向 127.0.0.1浏览器插件的效果最直观但它只对浏览器内部生效DNS 层能覆盖整台设备所有应用但需要搭建本地 DNS 服务Hosts 文件最简单但维护成本高。本文使用的是“日志离线分析”思路把访问日志当作用户请求的替身用规则匹配日志里的 URL命中的请求就相当于被拦截。这种方式不实际阻断网络请求但非常适合验证规则、统计拦截数量、评估误杀率。1.2 为什么按域名匹配一条访问日志里可以提取出很多信息时间、HTTP 方法、URL、状态码、响应体大小。而广告拦截最常用的判断依据就是 URL 里的 host主机名也就是我们常说的域名。为什么按域名而不是按完整 URL 匹配原因有三个域名变化慢。广告平台的域名相对稳定按域名匹配维护成本低。规则容易表达。一条规则ad.doubleclick.net就能覆盖该域名下的所有路径和参数。性能好。精确匹配域名可以用哈希集合做到 O(1) 判断就算规则上万条匹配速度也很快。按完整 URL 匹配也可以但规则文件会变大且 URL 中常带有随机参数匹配准确率和性能都会下降。1.3 本文的实战范围前面说到“1000000”这不是流量造假而是通过一个日志生成脚本构造一份包含 100 万条广告域名请求的访问日志然后再用广告拦截脚本去识别。这份实战记录包含广告规则的三种常见写法Python 实现的规则加载与匹配逻辑100 万条访问日志的生成脚本拦截统计与 Top 域名输出常见问题排查与工程化建议整个过程都在本地完成不涉及抓取别人的流量也不修改任何线上服务适合作为技术学习和规则验证项目。2. 环境准备与项目结构开始写代码前先确认环境。Python 版本不需要特别新3.8 以上即可因为代码中会用 f-string、类型提示和pathlib这些特性 3.8 都能支持。2.1 运行环境python --version如果输出类似Python 3.10.12说明环境满足要求。本文不依赖第三方库只需要 Python 标准库因此不需要安装 pip 包。操作系统方面Windows、Linux、macOS 都能运行。需要注意的只有文件路径写法代码中统一使用pathlib.Path处理可以跨平台。2.2 项目目录结构建议创建下面的目录结构ad-blocker-demo/ ├── ad_rules.txt ├── generate_log.py ├── ad_blocker.py └── access.log各文件作用文件说明ad_rules.txt广告拦截规则文件generate_log.py生成样本访问日志ad_blocker.py核心拦截脚本access.log生成的访问日志这里把access.log看成运行 generate_log.py 后生成的产物不需要提前手工创建。2.3 准备规则文件先创建一个规则文件ad_rules.txt。为了演示三种规则写法内容如下# 本地广告拦截规则示例 # 1. 精确域名 ad.doubleclick.net www.googleadservices.com # 2. 泛域名匹配 ads.example.com 以及它的子域名 ||ads.example.com^ # 3. 通配子域名匹配 cdn.adservice.org、img.adservice.org 等 *.adservice.org # 4. 正则规则匹配 tracker 后面带数字的域名 /tracker\d\.com/这个规则文件不是完整的大型规则集但覆盖了三种主流写法。后文会详细解释每一种写法的作用范围。3. 广告拦截规则的核心原理规则是广告拦截的灵魂。一个写得太窄的规则会漏掉大量广告写得太宽又会误杀正常业务。先来看看三条规则分别代表什么。3.1 精确域名规则ad.doubleclick.net是最简单的写法表示只拦截这个域名本身。如果访问日志中的 host 恰好是ad.doubleclick.net则命中如果是ad.doubleclick.net.evil.com则不命中因为后者的真实域名其实是evil.com的子域名。精确匹配的逻辑在 Python 里就是if host in exact_rules: return True底层用set存储可以做到 O(1) 查询规则越多优势越明显。3.2 泛域名与后缀匹配规则||ads.example.com^是 EasyList 语法中的一种写法在这里表示匹配ads.example.com和它的所有子域名例如img.ads.example.com、cdn.ads.example.com。实现时并不需要真正处理||和^这两个符号只需要在加载规则时做一次转换去掉前缀和结尾标记然后判断“host 等于该域名”或“host 以 . 加上该域名结尾”。if host domain or host.endswith(. domain): return True*.adservice.org的逻辑和上面类似同样表示匹配adservice.org的子域名一般不匹配主域名本身。但在实际工程中*.和||domain^常常习惯混用需要看规则集本身的语义约定。3.3 正则规则/tracker\d\.com/是规则文件里比较灵活的一种写法它使用正则表达式匹配 host。import re pattern re.compile(rtracker\d\.com) if pattern.search(tracker1.com): print(命中)正则适合表达“一类有规律的域名”比如tracker1.com、tracker2.com、tracker100.com。它的缺点是正则匹配比哈希集合慢因此在规则量很大的时候需要控制正则规则的数量或者在加载时预编译正则对象。3.4 规则匹配的判断流程整个匹配流程可以归纳为从日志中提取 URL再从 URL 中解析出 host。去掉 host 中的端口号并统一转成小写。先查精确匹配集合。再遍历泛域名后缀列表。最后执行正则规则。这个流程是后文拦截脚本的核心骨架。4. 完整实战写一个广告拦截统计脚本现在进入核心部分。我会分两步完成先生成模拟数据再写拦截统计脚本。4.1 生成 100 万条模拟访问日志创建一个generate_log.py用来生成一份包含 100 万条广告请求的日志文件。#!/usr/bin/env python3 # -*- coding: utf-8 -*- generate_log.py 生成模拟访问日志其中前 1000000 条是广告域名请求。 import random from pathlib import Path # 正常业务域名 NORMAL_HOSTS [ www.example.com, api.example.com, www.zhihu.com, www.github.com, blog.csdn.net, ] # 广告域名需要与 ad_rules.txt 中的规则匹配 AD_HOSTS [ ad.doubleclick.net, www.googleadservices.com, ads.example.com, img.ads.example.com, cdn.adservice.org, tracker1.com, tracker2.com, ] # 常见 URL 路径 URL_PATHS [ /index.html, /api/list?page1, /static/js/app.js, /img/banner.png, /report/detail?id1001, ] # 总请求数 TOTAL 1_000_066 # 前 1000000 条都作为广告请求 AD_TOTAL 1_000_000 def generate(): output Path(access.log) with output.open(w, encodingutf-8) as f: for i in range(TOTAL): if i AD_TOTAL: host random.choice(AD_HOSTS) else: host random.choice(NORMAL_HOSTS) path random.choice(URL_PATHS) timestamp f20250601100000{i % 60:02d} status 200 size random.randint(128, 8192) line f{timestamp} GET http://{host}{path} HTTP/1.1 {status} {size} f.write(line \n) print(f日志已生成{output.resolve()}) if __name__ __main__: generate()运行python generate_log.py日志格式每行如下2025060110000000 GET http://ad.doubleclick.net/api/list?page1 HTTP/1.1 200 512字段含义字段说明时间戳无空格时间戳方便按行解析GET请求方法URL带协议的完整地址HTTP/1.1协议版本200响应状态码512响应体大小单位字节这里故意把时间戳写成一个整体避免使用空格拆分日志时字段错位。如果用split()解析URL 会出现在索引 2 的位置。4.2 实现 AdBlocker 核心类下面是ad_blocker.py的完整实现。#!/usr/bin/env python3 # -*- coding: utf-8 -*- ad_blocker.py 广告拦截统计脚本加载规则逐行解析访问日志统计被拦截的广告请求数。 import re import sys from collections import Counter from pathlib import Path # 从日志中提取 URL 的正则 URL_PATTERN re.compile(rGET (https?://\S) HTTP) class AdBlocker: def __init__(self, rule_file: str, log_file: str): self.rule_file Path(rule_file) self.log_file Path(log_file) # 规则容器 self.exact_rules set() self.suffix_rules [] self.regex_rules [] # 统计信息 self.total 0 self.blocked 0 self.hit_counter Counter() def load_rules(self): 加载规则文件支持精确域名、泛域名和正则三种格式。 for line in self.rule_file.read_text(encodingutf-8).splitlines(): rule line.strip() # 跳过空行和注释 if not rule or rule.startswith(#) or rule.startswith(!): continue # 兼容 EasyList 的 ||domain^ 语法 if rule.startswith(||) and rule.endswith(^): domain rule[2:-1].lower() self.suffix_rules.append(domain) # *.example.com 表示匹配所有子域名 elif rule.startswith(*.): domain rule[2:].lower() self.suffix_rules.append(domain) # /正则/ 形式 elif rule.startswith(/) and rule.endswith(/): pattern rule[1:-1] self.regex_rules.append(re.compile(pattern)) # 其他情况当作精确域名 else: self.exact_rules.add(rule.lower()) def extract_host(self, raw_url: str): 从 URL 中提取 host去掉端口并转为小写。 raw_url raw_url.strip() if raw_url.startswith(http://): start len(http://) elif raw_url.startswith(https://): start len(https://) else: return None end raw_url.find(/, start) if end -1: end len(raw_url) host raw_url[start:end] if : in host: host host.split(:)[0] return host.lower() def is_blocked(self, host: str) - bool: 判断 host 是否命中任意一条广告规则。 if not host: return False # 1. 精确匹配 if host in self.exact_rules: return True # 2. 后缀匹配 for suffix in self.suffix_rules: if host suffix or host.endswith(. suffix): return True # 3. 正则匹配 for regex in self.regex_rules: if regex.search(host): return True return False def run(self): 逐行读取访问日志并统计。 with self.log_file.open(r, encodingutf-8, errorsignore) as f: for line in f: self.total 1 match URL_PATTERN.search(line) if not match: continue url match.group(1) host self.extract_host(url) if self.is_blocked(host): self.blocked 1 self.hit_counter[host] 1 self.report() def report(self): 输出统计结果。 print( * 50) print(广告拦截统计报告) print( * 50) print(f总请求数: {self.total:,}) print(f拦截广告请求数: {self.blocked:,}) if self.total 0: rate self.blocked / self.total * 100 print(f拦截率: {rate:.2f}%) print(- * 50) print(Top 10 被拦截域名:) for host, count in self.hit_counter.most_common(10): print(f {host}: {count:,}) def main(): rule_file sys.argv[1] if len(sys.argv) 1 else ad_rules.txt log_file sys.argv[2] if len(sys.argv) 2 else access.log blocker AdBlocker(rule_file, log_file) blocker.load_rules() blocker.run() if __name__ __main__: main()代码里做了几个关键决策使用set存储精确规则查询时间复杂度为 O(1)即使在规则很多的情况下也能保持稳定性能。泛域名规则拆成后缀列表每次判断都要遍历一次因此这部分规则数量不能太多否则会影响匹配速度。预编译正则避免每行日志都重新编译正则表达式。使用Counter统计命中次数最后可以直接输出 Top 10 广告域名方便观察哪些域名被拦截最多。4.3 运行与验证生成日志文件和规则文件后进入项目目录执行python ad_blocker.py ad_rules.txt access.log也可以直接运行python ad_blocker.py因为main()中已经给两个参数设置了默认值。程序会依次加载规则、解析日志、输出统计报告。预期输出大致如下 广告拦截统计报告 总请求数: 1,000,066 拦截广告请求数: 1,000,000 拦截率: 99.99% -------------------------------------------------- Top 10 被拦截域名: ad.doubleclick.net: 167213 www.googleadservices.com: 166432 ads.example.com: 166521 img.ads.example.com: 166389 cdn.adservice.org: 167028 tracker1.com: 166817 tracker2.com: 166600由于generate_log.py使用了random.choice每次生成的分布不同具体数字会不一样。但拦截总数应该稳定在1,000,000因为日志前 100 万条全部来自广告域名。这说明脚本读取 100 万条广告请求后全部被规则命中实现了“拦截 1000000 个广告”的目标。4.4 拦截率为什么是 99.99%日志总共有1,000,066条其中 100 万条是广告广告请求剩下 66 条是正常请求。因为正常请求只占了极小部分所以拦截率约等于 99.99%。这里要提醒一点拦截率不是越高越好。真实生产环境中如果一个系统对 90% 的正常流量都误拦了那这个拦截系统是失败的。拦截率必须结合“误杀率”一起看。在实际项目中一般会记录两类数据广告请求命中数正常资源被误拦数然后在测试集上先验证规则再上线。5. 常见问题与排查思路在写规则或跑日志分析时经常遇到以下几类问题。5.1 日志解析不到 URL错误现象统计到的 total 很大但 blocked 一直是 0。常见原因访问日志格式不是GET http://... HTTP的样子导致正则URL_PATTERN匹配不到。排查思路先打印一行日志看看实际格式。调整URL_PATTERN正则表达式。如果日志里同时有 POST 请求要把正则改成(GET|POST) (https?://\S) HTTP。5.2 规则匹配不到广告域名错误现象日志里明明有广告域名但脚本没有拦截。常见原因URL 里的 host 大小写不一致或者规则文件里域名带了些不可见字符。排查思路确认规则文件和日志文件都使用 UTF-8 编码。在加载规则时统一lower()。在提取 host 时统一lower()。检查规则是否是||domain^写法时去掉前后缀后是否残留空格。5.3 泛域名规则误杀严重错误现象某个*.example.com规则把www.example.com正常业务也拦截了。常见原因服务商把所有资源都放在同一个主域名下无法用一条泛域名规则安全区分。排查思路优先使用精确域名规则。如果确实需要泛域名可以给白名单留一个接口。在规则加载阶段把白名单域名存成集合判断时优先放行。5.4 内存占用过高错误现象处理几 GB 日志时内存飙升。常见原因一次性把整个日志文件readlines()读进内存。排查思路本文代码已经按行迭代读取不会一次性加载全部日志。如果规则文件特别大也可以把精确规则拆分成多个集合文件分段加载或者用 SQLite 存储规则索引。问题现象常见原因解决思路日志解析不到 URL日志格式不匹配调整正则表达式匹配不到广告域名host 大小写不一致统一转小写泛域名误杀规则过于宽泛使用白名单和精确规则内存占用高一次性读入大文件按行迭代读取正则规则卡顿正则回溯复杂预编译并减少正则规则数6. 工程化与最佳实践本地脚本能跑通只是第一步如果要把这套逻辑用在真实场景还需要注意下面几个方面。6.1 匹配性能优化本文示例中精确匹配用set已经足够快。但当规则集扩展到几十万条并且泛域名规则也比较多时可以继续优化使用三叉树或 Trie 结构存储域名。域名本身是从右向左有层级关系的例如com.example.ads和com.example.cdn。通过把域名反转后逐层放入树结构可以在一次搜索中同时判断精确匹配和子域名匹配避免遍历整个后缀列表。使用双数组字典树Double-Array Trie是广告过滤引擎的常见做法例如一些开源广告拦截器就使用类似结构实现高速匹配。将正则规则单独分区。正则规则是最慢的尽量把能转换成后缀匹配的规则都转换掉只保留真正需要复杂表达式的正则规则。6.2 日志字段的清洗与归一化在真实日志中URL 可能出现各种形态http://ad.example.com http://ad.example.com:8080/path https://Ad.Example.COM/path前文代码已经统一做了三件事去掉协议前缀去掉端口号host 转为小写这个处理顺序不能乱。如果先去端口号再去协议可能把https://里的冒号误当端口处理。6.3 合法合规与安全边界这一点很重要。本文的所有脚本只应该用于处理你自己拥有的、或者已经获得明确授权的访问日志。不要在未经授权的情况下抓取别人的流量不要用广告拦截规则去绕过付费墙也不要尝试对 HTTPS 流量进行中间人解密。技术工具本身是中性的但使用边界必须严格遵守相关法律法规和你所在组织的安全规范。如果是在企业中做流量分析建议走正规的数据平台接口而不是自己部署 DNS 劫持或者 HTTP 代理拦截所有员工的流量。合法授权、最小权限、操作留痕这三条原则同样适用于网络安全和数据开发场景。6.4 规则维护与监控广告域名会不断变化一个静态规则文件过几个月就可能失效。工程实践中可以定期从公开规则源拉取规则并做好以下几件事规则版本号管理更新前后的命中率对比白名单变更记录规则命中率报表只有当规则维护变成可持续流程广告拦截系统才能真正稳定运转。7. 下一步可以怎么扩展如果看完上面的代码你已经亲手跑通了“1,000,000 个广告拦截”的统计任务那表示你已经掌握了域名规则匹配的基本流程。接下来可以往三个方向继续深入。方向一把规则匹配做成一个可复用的 Python 类库目前AdBlocker是面向日志文件的类。可以把它拆成“规则加载器”和“匹配器”这样其他项目只需要传入一个 URL 就能判断是否拦截而不是依赖日志文件。方向二结合 DNS 解析做真实拦截如果你不想只停留在日志分析可以研究 AdGuard Home 或 Pi-hole 的部署方式。它们的核心思想是按域名返回空地址终端设备访问广告域名时直接解析失败从而在系统层面拦截广告。方向三增加可视化报表当前输出是控制台文本。可以接入 SQLite 保存每次运行结果再用 Grafana 或 Streamlit 做一个看板展示每日拦截趋势、广告域名 Top 20、误杀率等指标。规则再好也要结合自己的访问情况持续迭代。把这套脚本接到日志分析任务里每天跑一次你也能回答“我今天到底拦了多少条广告”这个问题。