移动端H5视频播放结束广告屏蔽与拦截实战
1. 问题背景与核心需求拆解移动端H5页面里用video标签播放视频播完突然弹出一段广告这个体验有多糟做过移动端开发的人应该都懂。尤其是嵌在QQ浏览器、微信内置浏览器这类WebView环境里视频结束后广告自动覆盖、自动跳转、甚至自动播放下一段推广内容用户骂的是产品背锅的却是开发者。这个项目的核心目标很明确在安卓端、QQ浏览器和微信H5环境下使用原生video标签播放视频时把视频播放结束后出现的广告去掉或者屏蔽掉。注意这里的关键词是“结束后”——不是贴片广告不是暂停广告而是视频播完那一刻触发的广告行为。先把这个问题的本质说清楚。移动端H5视频播放结束后的广告来源通常有三类第一类视频源本身拼接了广告片段。也就是说你拿到的视频文件末尾自带几秒到几十秒的推广内容播放器只是老老实实播完了整个文件。第二类播放器SDK或平台注入的广告逻辑。某些浏览器内核或WebView容器会在视频播放结束后通过自身注入的脚本触发广告层、跳转或者推荐流。第三类页面自身集成的第三方播放器组件带的广告模块。比如某些开源播放器默认开启了“播放结束推荐”功能。这三类的处理方式完全不同。第一类要从视频源下手第二类要从容器行为对抗第三类要从播放器配置入手。很多人一上来就想着“屏蔽”结果发现屏蔽不掉就是因为没搞清楚广告到底从哪来。提示在动手之前先用真机抓一次完整播放流程确认广告出现的时机和来源否则后面所有操作都是盲打。适合阅读这篇内容的人正在做移动端H5视频播放的开发者、需要交付微信内嵌页面的前端工程师、以及被“视频播完弹广告”这个问题折磨过的测试和产品同学。下面我会按照实际排查和处理的顺序把每一层的原理、操作和坑都讲透。2. 广告来源定位与排查思路2.1 用真机远程调试确认广告注入层安卓端QQ浏览器和微信内置浏览器的调试方式和普通Chrome不一样。微信内置浏览器需要用微信开发者工具或者通过特定调试页面开启远程调试QQ浏览器则可以在设置里找到调试开关。连上之后重点看两样东西DOM结构的变化和控制台的网络请求。具体操作逻辑是这样的视频播放到接近结束时观察DOM树里是否突然多出一个覆盖层元素比如div带着position: fixed和很高的z-index。如果有说明广告是页面层面注入的DOM。同时看Network面板视频结束后是否有新的请求发出请求的域名是不是你页面自身的域名。如果不是那基本可以确定是容器或平台注入的。这一步的核心目的是区分“广告来自我的页面”还是“广告来自外部容器”。两者的处理策略完全相反前者你能改代码解决后者你只能做对抗性处理。2.2 区分视频源广告与播放器广告把视频文件单独拿出来用系统自带播放器或者PotPlayer之类的工具播一遍看结尾有没有广告内容。如果有那就是视频源问题跟浏览器和播放器都没关系。这种情况在H5场景里其实很常见尤其是从某些视频托管平台拿到的转码文件平台会在转码时自动拼接尾帧广告。如果视频源干净但H5页面里播完还是有广告那就把video标签换成最简形式去掉所有第三方播放器库只保留原生标签和基本属性再测一遍。如果最简形式下广告消失说明是播放器库带的如果广告还在说明是浏览器容器注入的。这个排查过程我建议做成一个对照表方便快速定位测试条件广告是否出现推断来源本地播放器播原视频出现视频源自带广告本地播放器播原视频不出现排除视频源原生video标签最简页面出现容器或浏览器注入原生video标签最简页面不出现第三方播放器库导致换一个浏览器打开同页面不出现QQ浏览器/微信特有行为2.3 常见广告触发时机与行为特征视频结束后的广告触发时机通常集中在ended事件前后。有的广告是在ended事件触发后由监听器拉起有的则是在视频播放到某个时间点比如duration - 0.5秒就预加载好了只等结束就展示。行为特征上常见的有这几种视频画面被一个全屏层覆盖层里有倒计时和关闭按钮页面自动滚动到下方推荐内容区域自动跳转到新的页面或唤起App视频区域被替换成一张推广图片搞清楚行为特征之后你就能判断该拦截哪个环节。比如是覆盖层那就处理DOM是自动跳转那就拦截导航是替换内容那就锁定视频容器。3. 视频源层面的处理方案3.1 检测视频文件是否自带尾帧广告如果排查下来确认是视频源本身带了广告那最彻底的办法就是在服务端或构建阶段把广告段切掉。检测方法很简单用ffprobe查看视频时长和关键帧分布然后人工确认结尾部分的内容。ffprobe -v quiet -print_format json -show_format -show_streams input.mp4看duration字段然后从duration - 15秒开始截取一帧画面出来看ffmpeg -ss 00:01:50 -i input.mp4 -frames:v 1 check.png如果确认末尾有广告直接用ffmpeg裁掉ffmpeg -i input.mp4 -t 00:01:50 -c copy output.mp4这里的-t参数指定保留的时长-c copy表示不重新编码速度极快且画质无损。但要注意-c copy只能在关键帧处切割如果广告起始位置不在关键帧上切出来的视频开头可能会有短暂花屏。这种情况就需要重新编码ffmpeg -i input.mp4 -t 00:01:50 -c:v libx264 -c:a aac -movflags faststart output.mp4-movflags faststart这个参数对H5播放很重要它把视频的元数据移到文件头部让浏览器能更快开始播放而不是等整个文件加载完。3.2 服务端转码时统一去除广告段如果视频量大手工切不现实那就需要在转码流水线里加一步自动检测。常见做法是维护一个广告时间段配置转码时统一裁掉。比如所有视频都去掉最后10秒ffmpeg -i input.mp4 -t $(ffprobe -v error -show_entries formatduration -of csvp0 input.mp4 | awk {print $1-10}) -c copy output.mp4这条命令先取视频总时长减去10秒再作为保留时长传给ffmpeg。实际生产中我会把这个逻辑封装成一个脚本批量处理整个目录。注意裁掉末尾之前一定要确认广告的准确起始时间。有些视频的广告不是固定在最后10秒而是动态插入的这种情况只能靠人工抽检加配置表来维护。3.3 前端播放时限制播放进度如果视频源没法改比如是第三方提供的固定地址那可以在前端做一层限制。核心思路是监听timeupdate事件当播放时间接近广告起始点时主动暂停并触发ended逻辑。const video document.querySelector(video); const AD_START 110; // 广告起始秒数 video.addEventListener(timeupdate, () { if (video.currentTime AD_START) { video.pause(); video.currentTime AD_START; // 手动触发结束逻辑 handleVideoEnd(); } });这个方案的好处是不依赖视频源修改坏处是如果用户拖动进度条到广告区域仍然可能看到广告。所以还需要配合seeking事件做二次拦截。另外currentTime的精度在不同浏览器里有差异设置的时候最好留一点余量比如AD_START - 0.3。4. 播放器配置与事件拦截4.1 原生video标签的关键属性设置很多人不知道video标签本身有一些属性可以直接影响播放结束后的行为。虽然不能直接“去广告”但能减少广告触发的机会。video srcvideo.mp4 controls playsinline webkit-playsinline preloadmetadata disablepictureinpicture controlslistnodownload noplaybackrate /videoplaysinline和webkit-playsinline是移动端内联播放的关键不加的话视频会全屏播放全屏状态下广告更容易被容器接管。preloadmetadata只预加载元数据减少不必要的缓冲也能降低广告预加载的概率。disablepictureinpicture禁用画中画防止视频结束后被系统画中画窗口接管。controlslist这个属性在部分浏览器里可以限制下载和倍速按钮虽然跟广告没直接关系但能减少用户触发额外行为的入口。4.2 监听ended事件并阻断后续行为视频播放结束的ended事件是广告触发的高频入口。很多广告逻辑就是监听这个事件然后执行展示。我们可以在自己的监听器里做阻断。video.addEventListener(ended, (e) { e.stopImmediatePropagation(); e.preventDefault(); // 自己的结束逻辑 showReplayButton(); }, true);这里第三个参数true表示在捕获阶段监听比冒泡阶段更早执行。stopImmediatePropagation()会阻止同一事件上其他监听器的执行包括广告注入的监听器。但要注意如果广告逻辑是通过addEventListener在更早的时机注册的而且也在捕获阶段那顺序就取决于注册顺序了。实测下来在页面加载后尽早注册自己的捕获监听器能拦掉大部分基于ended事件的广告逻辑。但如果广告是容器层面用原生代码实现的JS层面的拦截就无效了。4.3 用时间点预判替代ended事件更稳妥的做法是不依赖ended事件而是自己用timeupdate预判结束。这样即使广告逻辑监听了ended我们也能在它触发之前就把视频停掉。let hasEnded false; video.addEventListener(timeupdate, () { if (hasEnded) return; if (video.duration video.currentTime video.duration - 0.2) { hasEnded true; video.pause(); handleVideoEnd(); } });这个方案的逻辑是在视频真正结束前0.2秒就主动暂停然后执行自己的结束逻辑。这样ended事件根本不会触发依赖它的广告逻辑自然也就不会执行。0.2秒这个值可以根据实际情况调整太小了可能来不及太大了会显得视频没播完就停了。提示用这个方案的时候记得把进度条也同步处理一下否则用户会看到进度条差一点点没满体验上会有点奇怪。可以在暂停后把currentTime设成duration视觉上就满了。5. 容器层面的对抗与规避5.1 微信内置浏览器的行为特点微信内置浏览器对视频播放有一套自己的接管逻辑。在安卓端微信会优先使用自己的播放器内核而不是系统WebView的默认播放器。这个内核在视频结束后可能会展示推荐内容或者广告。应对方式上有几个点可以尝试。第一强制使用内联播放避免全屏接管。第二在视频容器上覆盖一层透明元素拦截容器注入的点击事件。第三监听页面可见性变化如果视频结束后页面被切走尝试切回来。document.addEventListener(visibilitychange, () { if (document.hidden) { // 页面被切走记录状态 sessionStorage.setItem(video_interrupted, 1); } else { // 页面切回来检查是否需要恢复 if (sessionStorage.getItem(video_interrupted)) { sessionStorage.removeItem(video_interrupted); // 恢复自己的UI状态 } } });这个方案不能完全阻止广告但能减少广告展示后用户停留在广告页面的时间。5.2 QQ浏览器的视频接管机制QQ浏览器在安卓端对video标签的处理更加激进它有自己的视频播放组件会在视频结束后展示相关推荐。这个行为在部分版本里可以通过设置关闭但作为开发者你没法要求用户去改设置。能做的对抗手段主要是两个方向。一是用canvas绘制视频帧完全绕开原生video的渲染和事件体系。这个方案成本高但效果最彻底。二是用iframe嵌套视频页面利用iframe的沙箱特性隔离一部分容器行为。canvas方案的思路是用video加载视频但不插入DOM通过requestVideoFrameCallback或定时器把当前帧画到canvas上。这样用户看到的是canvas视频结束后的容器行为作用在一个隐藏的video元素上不会影响用户界面。const video document.createElement(video); video.src video.mp4; video.muted true; const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); function drawFrame() { if (!video.paused !video.ended) { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); requestAnimationFrame(drawFrame); } } video.play(); drawFrame();这个方案的代价是失去了原生控制条需要自己实现播放、暂停、进度条等UI。而且音频需要通过Web Audio API单独处理复杂度不低。但如果广告问题严重影响体验这个方案是值得考虑的。5.3 用iframe沙箱隔离视频播放环境把视频播放页面放到一个独立的iframe里iframe的sandbox属性可以限制很多行为。比如iframe srcvideo-player.html sandboxallow-scripts allow-same-origin allowautoplay /iframesandbox里不加allow-top-navigation就能阻止iframe内的页面跳转顶层页面。不加allow-popups就能阻止弹窗。这样即使视频结束后有跳转或弹窗逻辑也会被沙箱拦住。但要注意微信和QQ浏览器对iframe里的视频播放有自己的限制部分版本里iframe内的视频无法内联播放会强制全屏。这个需要实际测试不同版本行为不一样。6. 常见问题与排查技巧实录6.1 广告拦截失效的典型原因实际处理中拦截失效通常有这几个原因。第一广告逻辑注册在捕获阶段且比你的代码早stopImmediatePropagation拦不住。第二广告是原生容器实现的JS完全碰不到。第三广告通过postMessage从容器通信触发不在页面事件体系里。第四视频源本身带广告前端怎么拦都没用。排查的时候按这个顺序来先确认视频源再确认页面内是否有第三方播放器库然后确认容器行为最后才考虑对抗方案。顺序反了会浪费很多时间。6.2 不同安卓版本的差异处理安卓碎片化在视频播放这块体现得特别明显。低版本安卓7.0以下的WebView对video的支持很不完整playsinline可能无效ended事件可能不触发。高版本安卓10以上对自动播放限制更严没有用户交互的情况下play()会被拒绝。处理方式是做特性检测和降级。比如检测playsinline是否生效不生效就引导用户点击全屏按钮。检测play()的Promise是否被拒绝被拒绝就显示一个大的播放按钮让用户点。const playPromise video.play(); if (playPromise ! undefined) { playPromise.catch(() { // 自动播放被拒绝显示自定义播放按钮 showPlayButton(); }); }6.3 排查速查表现象可能原因排查方法处理方案视频播完弹全屏广告容器注入远程调试看DOM变化捕获阶段拦截ended视频播完跳转新页面容器导航监听beforeunloadiframe沙箱隔离视频末尾有推广内容视频源自带本地播放器验证ffmpeg裁剪视频播完出现推荐流播放器库功能移除播放器库测试关闭推荐配置部分机型有广告部分没有版本差异多机型对照测试特性检测降级6.4 几个实操中踩过的坑第一个坑stopImmediatePropagation在部分安卓WebView里对原生注入的监听器无效。原生代码注册的监听器不走JS事件体系拦不住。第二个坑用canvas方案的时候音频和视频不同步。因为canvas绘制有延迟而音频是直接播放的。解决办法是用requestVideoFrameCallback拿到精确的帧时间戳做音视频对齐。第三个坑iframe沙箱方案在微信里会导致视频无法内联播放用户体验反而更差。这个方案适合QQ浏览器不适合微信。第四个坑裁视频的时候用-c copy导致开头花屏。后来改成先定位关键帧再切或者直接重新编码问题解决。第五个坑timeupdate的触发频率在不同设备上差异很大有的设备一秒触发四次有的一秒一次。用currentTime duration - 0.2做判断的时候如果触发频率低可能直接跳过这个区间。解决办法是把阈值放大到0.5秒或者用requestAnimationFrame自己轮询。7. 完整实操流程与配置参考7.1 从零搭建一个抗广告的H5视频页先给一个完整的页面结构把前面讲的要点都串起来。!DOCTYPE html html head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1, maximum-scale1, user-scalableno style .video-wrap { position: relative; width: 100%; background: #000; } video { width: 100%; display: block; } .cover-layer { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 10; } /style /head body div classvideo-wrap video idmyVideo srcvideo.mp4 controls playsinline webkit-playsinline preloadmetadata disablepictureinpicture /video div classcover-layer idcoverLayer/div /div script srcplayer.js/script /body /htmlcover-layer这层透明元素的作用是拦截容器注入的点击事件。容器有时候会在视频区域上方加一层可点击的广告热区我们用自己的透明层盖住它点击就落不到广告层上。7.2 核心拦截脚本的完整实现(function() { const video document.getElementById(myVideo); const coverLayer document.getElementById(coverLayer); let hasEnded false; // 捕获阶段拦截ended事件 video.addEventListener(ended, function(e) { e.stopImmediatePropagation(); e.preventDefault(); if (!hasEnded) { hasEnded true; onVideoEnd(); } }, true); // 预判结束提前暂停 video.addEventListener(timeupdate, function() { if (hasEnded) return; if (video.duration video.currentTime video.duration - 0.5) { hasEnded true; video.pause(); video.currentTime video.duration; onVideoEnd(); } }); // 拦截seeking防止拖到广告区 video.addEventListener(seeking, function() { if (video.duration video.currentTime video.duration - 0.5) { video.currentTime video.duration - 1; } }); function onVideoEnd() { // 显示重播按钮不触发任何跳转 coverLayer.style.display none; showReplay(); } function showReplay() { const btn document.createElement(button); btn.textContent 重播; btn.style.cssText position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:20;padding:12px 24px;font-size:16px;; btn.onclick function() { hasEnded false; video.currentTime 0; video.play(); btn.remove(); }; document.querySelector(.video-wrap).appendChild(btn); } // 拦截页面跳转 window.addEventListener(beforeunload, function(e) { // 可以在这里做记录但无法真正阻止跳转 }); // 监听页面可见性 document.addEventListener(visibilitychange, function() { if (document.hidden) { sessionStorage.setItem(video_page_hidden, 1); } }); })();这个脚本的核心逻辑是三层防护捕获阶段拦截ended、timeupdate预判提前暂停、seeking拦截防止拖到广告区。三层叠加下来大部分基于页面事件的广告逻辑都能被拦住。7.3 参数选择与性能考量timeupdate里的duration - 0.5这个阈值实测下来在大多数安卓设备上都能可靠触发。如果设备性能特别差timeupdate触发间隔可能超过0.5秒那就需要把阈值放大到1秒。但阈值太大用户会感觉视频没播完就停了所以需要在可靠性和体验之间找平衡。coverLayer的z-index设成10比视频高但比自己的重播按钮低。这样容器注入的广告层如果z-index低于10就会被盖住如果高于10那也没办法只能靠事件拦截。preloadmetadata在弱网环境下能明显减少首屏加载时间因为不需要预加载整个视频文件。但代价是拖动进度条时可能需要重新缓冲。如果视频不长小于1分钟可以用preloadauto换取更流畅的拖动体验。7.4 测试验证清单上线前按这个清单过一遍安卓微信内置浏览器视频播完是否弹广告安卓QQ浏览器视频播完是否跳转安卓Chrome作为对照组测试视频拖动到末尾是否触发广告视频播放中切到后台再切回来状态是否正常弱网环境下视频加载和播放是否正常不同安卓版本8、10、12各测一遍每个环境至少测三遍因为广告触发有时候有随机性或者延迟。测的时候把远程调试开着观察控制台和网络请求。8. 个人实操体会与后续扩展这套方案我在几个项目里实际用过效果最好的是“timeupdate预判 捕获阶段拦截”的组合。纯靠ended拦截在部分机型上会漏加上预判之后就稳很多。canvas方案虽然彻底但改造成本太高除非广告问题已经严重到影响核心功能否则不太建议上。iframe沙箱方案在QQ浏览器上表现不错但在微信里会引入新的问题所以我的做法是根据容器做差异化处理微信里用事件拦截加透明层QQ浏览器里用iframe隔离。判断容器类型可以通过navigator.userAgent来做虽然UA可以伪造但在实际场景里够用了。后续如果还要继续优化有两个方向可以尝试。一是用MutationObserver监听DOM变化一旦发现广告层被注入就立即移除。这个方案对DOM注入类广告很有效但要注意别把自己的元素也误删了。二是用Service Worker拦截视频结束后的网络请求从请求层面阻断广告资源的加载。不过Service Worker在微信里的支持情况不太稳定需要实际测试。提示所有对抗性方案都有时效性浏览器和容器版本更新后可能失效。建议把关键逻辑做成可配置的方便后续调整。同时做好监控一旦发现广告拦截失效率上升及时排查是不是容器行为变了。最后说一个细节。视频结束后的重播按钮位置不要放在容器容易注入广告的区域。我一般放在视频区域的正中间偏下用绝对定位z-index设得比较高。这样即使容器想覆盖也得先过我这层。按钮的点击事件用stopPropagation阻止冒泡防止被容器捕获。这些细节看起来小但实际用起来差别很大。