华医网课件页面自动播放故障技术报告
日期:2026-09-28(初版)/2026-09-28(第 2 版,补入可运行源码、接口实测与开发历程)
对象:华医网 course_ware_polyv.aspx 课程页、Polyv HTML5 播放器及本地篡改猴脚本
结论级别:初版基于截图、DOM 查询与本地脚本静态检查。第 2 版在已登录页面上做了两轮只读实测(时间线探针 + 源码提取),已取得页面内联脚本原文(非伪代码),并把播放器接口、列表跳转结构、站点自身约束落实到实测值。仍未验证服务器端学习记录,也未做"停用全部脚本"的对照实验。
0. 第 2 版相对初版的修订
初版的定位方向是对的,但有三处归因/表述需要在第 2 版更正:
| 初版表述 | 第 2 版实测结论 |
|---|---|
| 拦截逻辑位于"页面内联脚本",用户从 F12 中读到 | 该逻辑确实在内联脚本里,但它是 +0.08s 由页面动态注入的 47 KB <script>,不在静态 HTML 中。初版未记录注入时机,导致后续探针在第 1 节数到的内联脚本长度为 0(探针在 DOMContentLoaded 采样,早于注入)。源码本身确认无误,来源标注已更正。 |
(02) 触发条件"由页面代码直接推出,尚未单独实测" | 已从源码原文确认(见 §2.2),不再是推断。 |
一处探针曾报告"未在 window 上找到播放器实例、<video> 数量为 0" | 这是采样时序错误。window.player 在 +1.10s 出现,<video> 从一开始就存在。播放器实例、完整 API 表与 <video> 属性均已实测(见 §3)。 |
| 报告称 "0.6.0 点击休息提示触发 (03)" | 确认,且第 2 版给出了 blockAbnormalPlugin() 的完整副作用链(destroy + layer.alert + 强制刷新),比初版"显示阻断弹窗"更严重(见 §2.3)。 |
1. 结论摘要
- 点击拦截的机制已完全查清,且不止三类。 页面对"扩展脚本用
element.click()自动关弹窗、自动切课"(该注释由网站自己写在源码里)做了三层防护:捕获阶段的isTrusted检查映射为(01)(02)(03);播放器初始化时检查<video>上的扩展标记(vsc-initialized类、data-popup-wrap-id属性);以及每秒一次的播放速率一致性检测。三者都汇聚到同一个blockAbnormalPlugin()。 blockAbnormalPlugin()的代价远高于"弹个窗"。 它先player.destroy(),再layer.alert(msg, {closeBtn: 0}),弹窗的确定回调执行window.location.href = window.location.href—— 整页强制刷新。另有abnormalPluginBlocked一次性闩锁,同一会话只可能被拦一次。初版观察到的j2s_resumeVideo is not a function、j2s_getCurrentTime is not a function报错,与该函数里destroy()的调用完全对应。isTrusted无法在页面内伪造。 这是规范级约束,不是"脚本写得不够好"。因此"在用户脚本里绕过可信点击检测"这条路在原理上不存在。(规范的[Unforgeable]属性定义见 DOM Standard;click()产生非可信事件见 HTML Standard。)- 但"自动切下一节"根本不需要伪造点击。 实测发现列表项没有
<a href>,跳转地址内联在onclick里。读取该字符串后自行导航,等价于地址栏输入,不产生任何事件,因此不触碰任何拦截器。该路径已实测通过(见 §6)。 - 站点的"诚信约束"是产品设计的一部分,不是缺陷。 页面配置把倍速锁死为
1.0、把拖拽限制在"已观看区间"、禁用了续播记忆,并以 5 分钟/30 秒两种周期上报进度;服务端还通过getMaxPlayTime()注入每节的允许观看上限。这些机制与继续医学教育学分挂钩,因此本报告同时给出能力边界说明(§8),区分"技术上可绕"与"不做"。 - 最终产物是一个稳定可用的播放助手,而非无人值守刷课器。 它做三件事:休息提示/意外暂停/播放结束的三层提醒、一节播完后自动进入下一节、并在任何失败路径上降级为提示手动操作。它不伪造点击、不改进度、不动倍速、不写服务端数据。"整节播放 → 自动处理提示 → 自动进入下一节"的成功验收已经完成。
2. 页面源码原文与定位
2.1 提取方式与可信度
源码通过同源 fetch 抓取页面自身 HTML/注入脚本获得,并做了关键词定向切片。第 2 版报告中的所有代码块均为原文复制,未做逻辑改写(仅调整缩进)。这与初版"删去无关字段后的伪代码"是本质区别:初版的伪代码推断正确地预测了 (01)(02)(03) 的存在与 destroy() 的作用,但当时无法确认 (03) 分支之后是否还有别的条件——现在确认没有,click 监听器只有这三个分支。
注入时间线(来自 MutationObserver 实测):
[+0.08s] iframe+ ×4 指南/用药/临床路径/华医AI(均跨域)
[+0.08s] inlineScript+ len=47562 ← 播放器配置 + 全部检测逻辑
[+0.09s] inlineScript+ len=518
[+1.10s] window.player 出现
[+2.10s] media+ video src="data:video/mp4;base64,AAAAHGZ0eXBpc29t..."(118 字节,静音解锁探针)
[+2.10s] media- 该 video 移除
注:
+2.10s那条data:开头的 118 字节 MP4(AAAAHGZ0eXBpc29t=ftypisom的 base64)是网站自己插入的"用静音播放换取浏览器 autoplay 许可"的标准手法,不是异常,也不是播放器被销毁的证据。
2.2 点击拦截器(原文)
// 拦截扩展脚本通过 element.click() 自动关弹窗、自动切换课程(非用户真实点击 isTrusted 为 false)
document.addEventListener('click', function (e) {
if (e.isTrusted) return;
var t = e.target;
if (!t || !t.closest) return;
if (t.closest('.layer_tips') && t.closest('.rig_btn')) {
blockAbnormalPlugin('系统检测到此浏览器安装了异常插件,请更换浏览器或卸载插件后观看(01)');
return;
}
if (t.closest('.lis-content') && t.closest('h2')) {
blockAbnormalPlugin('系统检测到此浏览器安装了异常插件,请更换浏览器或卸载插件后观看(02)');
return;
}
if (t.closest('.study_diaog') && t.closest('.btn_sign')) {
blockAbnormalPlugin('系统检测到此浏览器安装了异常插件,请更换浏览器或卸载插件后观看(03)');
return;
}
}, true);
要点:
- 注释是网站自己写的,明确说明拦截目标是"扩展脚本的自动点击"。这是判断拦截器设计意图的直接证据。
- 三个分支都是单一
return,没有stopPropagation()。因此原按钮的处理函数在被拦截后仍会继续执行——这解释了destroy()之后的j2s_*报错雪崩。 - 选择器对应关系(实测 DOM 已确认):
.layer_tips .rig_btn= 自定义确认框按钮;.lis-content h2= 课程列表标题;.study_diaog .btn_sign= 休息提示的"好的,知道了"。
2.3 blockAbnormalPlugin() 全文(原文)
var abnormalPluginBlocked = false;
function blockAbnormalPlugin(msg) {
if (abnormalPluginBlocked) return;
abnormalPluginBlocked = true;
try {
if (typeof player !== 'undefined' && player && typeof player.destroy === 'function') {
player.destroy();
}
} catch (ex) { }
layer.alert(msg, {
closeBtn: 0
}, function (index) {
window.location.href = window.location.href;
layer.close(index);
});
}
设计要点:
| 行为 | 后果 |
|---|---|
abnormalPluginBlocked 闩锁 | 一次会话只拦一次。不能靠"多试几次"绕过,但也不会被反复触发。 |
player.destroy() | 播放器实例上的方法被移除,页面其他处理函数继续调用时产生 is not a function。已销毁的播放器不会恢复。 |
layer.alert(msg, {closeBtn: 0}) | 只有"确定"按钮,无法关闭。 |
回调中 window.location.href = window.location.href | 强制整页刷新。这意味着被拦截的代价是:本节观看进度丢失、需要重新加载、重新等待播放器初始化。 |
| 无上报调用 | 该函数内没有 XHR/fetch/beacon 上报。因此"被拦截"这件事本身不会在客户端留下服务端痕迹,但刷新会中断本节。 |
2.4 播放器初始化时的扩展标记检查(原文)
//播放器初始化完毕时触发。播放器提供的方法需要在此事件发生后才可以调用
window.s2j_onPlayerInitOver = function (params) {
icmeParams.status = '1';
if (0 == 0 && 1 == 1 && document.querySelector('video.vsc-initialized')) {
blockAbnormalPlugin('系统检测到此浏览器安装了异常插件,请更换浏览器或卸载快进插件后观看');
};
var thirdvideo = document.querySelector('video.pv-video');
if (0 == 0 && 1 == 1 && thirdvideo && thirdvideo.getAttribute('data-popup-wrap-id')) {
blockAbnormalPlugin('系统检测到此浏览器安装了异常插件,请更换浏览器或卸载快进插件后观看.');
}
if (0 == 0 && 1 == 1) {
var ss1 = performance.now();
let ss2 = getMaxPlayTime();
var timerSSS = setInterval(function () {
let currTime = player.j2s_getCurrentTime();
let ss3 = currTime - ss2;
let ss5 = (performance.now() - ss1) / 1000;
if (ss3 > (ss5 + 2) * ratePlayLimitNum * 1.1) {
blockAbnormalPlugin('系统检测到此浏览器安装了异常插件,请更换浏览器或卸载快进插件后观看..');
} else {
//console.log(ss3, ss5);
}
}, 1000);
}
// ……此后代码未完整提取(见 §9)
};
要点:
vsc-initialized是 Video Speed Controller 类扩展给<video>打的 class;data-popup-wrap-id是另一类 DOM 篡改留下的属性。这是对浏览器扩展的直接点名检测,不是行为统计。- 注意
video.pv-video是播放器自己的 class,所以第二条针对的是"播放器元素被外部包装过"。 0 == 0 && 1 == 1、0 == 1这类恒真/恒假表达式是服务端拼模板的痕迹。这意味着:哪些检测当前生效取决于服务端下发的配置,不能只靠读代码断定,必须实测(§5 给出实测结论)。
2.5 播放速率一致性检测(原文,含配置)
ratePlayLimitNum = 1 == 1 ? 1.0 : null;
if (ratePlayLimitNum == 1.5) {
ifRatePlay = [0.5, 1, 1.2, 1.5];
}
if (0 == 1) {
ifRatePlay = true;
}
function getMaxPlayTime() {
let play_max_time_new = parseInt("274");
let local_play_time_new = parseInt(localStorage.getItem(uid + cwrid + coaid));
local_play_time_new = isNaN(local_play_time_new) ? 0 : local_play_time_new;
let play_time = [];
play_time.push(play_max_time_new);
play_time.push(local_play_time_new);
let max_time = Math.max.apply(null, play_time);
//console.log("play_max_time_new = " + play_max_time_new + ";local_play_time_new=" + local_play_time_new + ";max_time=" + max_time);
return max_time;
}
判据的语义(这是本报告最重要的一处分析):
ss2 = getMaxPlayTime()在初始化时只取一次,之后是闭包常量。ss3 = currTime - ss2是"相对历史最大观看位置的增量";ss5是真实经过的秒数。- 正常观看(倍速锁定 1.0、无外部篡改)时
currTime ≈ ss2 + ss5,因此ss3 ≈ ss5,判据ss3 > (ss5+2)*1.1恒不成立。 - 只有当播放速度被外部篡改、或进度跳到未观看位置时,
ss3才会超过阈值。
结论:这条检测精确对准"快进插件"与"跳进度",对正常观看零误伤。 它是一个设计良好的完整性校验,而非粗糙的防作弊。
play_max_time_new = parseInt("274") 是服务端逐节注入的允许观看上限。它与 ban_seek_by_limit_time: 'on' 配合,构成"只能往回拖到已看过的区间"的客户端强制。
2.6 站点自身的播放约束(原文配置)
var playerConfig = {
wrap: '#video',
width: '844',
height: '474',
vid: vid,
preventKeyboardEvent: 1 == 1 ? true : false, // 禁止屏蔽键盘事件
forceH5: true, // 默认打开 HTML5 播放器
ban_seek_by_limit_time: 0 == 0 ? 'on' : 'off', // 生效:只能拖到已播放过的区间
speed: ifRatePlay, // ratePlayLimitNum = 1.0 → 只有 1.0 倍速
maxPlaybackRateLimit: ratePlayLimitNum, // 1.0
autoplay: true, // 设了,但受浏览器策略约束
audioMode: true,
ban_history_time: 'on', // 禁用 Polyv 自带续播记忆
is_interaction: 'off', // 关闭后台配置的过程题
ts: '...',
sign: '...',
code: 'myCodeValue',
preview: 0 == 1 ? true : false, // 关闭
customErrorTips: { '#024': '此视频不支持在本浏览器上播放,建议更换360极速浏览器或谷歌浏览器观看学习' },
videoKeyframes: keyWordsPlayer,
playsafe: function (vid, next) { // 加密视频的取票回调
$.ajax({ url: '/ashx/getPlaySafeToken.ashx', data: { vid: vid } })
.done(function (res) { next(res); });
},
logo: { /* 站点 Logo 配置,与主故障无关 */ }
};
以及页面自己的周期任务:
player = polyvPlayer(playerConfig);
setInterval(function () {
if (icmeParams.status == '3') { // 播放暂停状态
updateCourseWareProcess(5);
} else {
updateCourseWareProcess(1);
}
}, 300000); // 每 5 分钟上报学习进度
if (1 == 1) {
var playCallTime = 30000;
setInterval(function () { getCourseWarePlayState(); }, playCallTime); // 每 30 秒查询播放态
}
window.onbeforeunload = function (event) { updateCourseWareProcess(3); }; // 关窗上报
if (window.addEventListener) {
window.addEventListener("pagehide", clearCurrentCourseWarePlayState);
}
要点:
- 站点自己就把倍速锁死为 1.0、把拖拽限制在已观看区间。 因此外部插件所谓的"加速刷课"必须先突破这两道约束,而 §2.4 的检测正是针对这种突破。
- 进度上报是每 5 分钟批量一次(
(1)播放中 /(5)暂停),另有 30 秒一次的播放态查询,以及关窗时的(3)。这与用户最初观察到的"列表状态刷新滞后"一致——列表状态不是实时的。 ban_history_time: 'on'意味着换节后不会自动续播到上次位置。这一点与 §4.3 的"不能拖动"提示排查直接相关。
3. 播放器接口实测(window.player)
播放器实例位于 window.player,proto = Object(方法直接挂实例上,不在原型链)。实测版本 v1.78.0, updateTime 20260820。
3.1 方法表(实测 typeof)
| 方法 | 类型 | 用途 |
|---|---|---|
j2s_getCurrentTime | fn/0 | 当前播放秒数 |
j2s_getDuration | fn/0 | 总时长 |
j2s_resumeVideo / j2s_pauseVideo / j2s_stopVideo | fn/0 | 播放控制 |
j2s_seekVideo | fn/1 | 定位 |
j2s_setVolume | fn/1 | 音量(无 j2s_setMuted / j2s_getMuted) |
changeVid | fn/1 | 换片(官方文档明示用于"上一个播完后自动播下一个") |
switchBitrate / changeLevel / getLevels / changeRate / getCurrentRate / changeLine | fn/1 或 fn/0 | 清晰度/倍速/线路 |
on / once / off / trigger / triggerEvent | fn/2、fn/1 | 事件契约 |
destroy | fn/0 | 销毁(拦截器使用) |
j2s_realPlayVideoTime / j2s_stayInVideoTime / j2s_realPlayStatus / j2s_stayInVideoStatus / getMaxCurrentTime | fn/0 | 播放行为统计 |
toggleFullscreen / toggleFullPageScreen | fn/0、fn/1 | 全屏 |
changeStatistics / getScreenshotData / sendQuestion / changeQuestion / setSubtitleSize / getSubtitlesByTime / 弹幕系列 | — | 其他 |
play / pause | undefined | 不存在。所以直接调 video.play() 是绕开播放器,会与其内部状态冲突 |
3.2 事件契约
- 官方文档确认的播放结束事件是
s2j_onPlayOver,且文档给出的连续播放范例正是在该事件里调用changeVid()。 - 本页源码中确认存在
window.s2j_onPlayerInitOver(页面自己实现了这个回调),证明这套事件契约是活的。 - 实测订阅
on('s2j_onPlayOver')可正常注册。该事件是否在真实播放结束时派发,尚未完成对照验证(见 §9)。
3.3 <video> 元素实测
| 属性 | 实测值 | 说明 |
|---|---|---|
src / currentSrc | blob:https://cme28.91huayi.com/<uuid> | MSE 承载,分片流经 MediaSource 注入,不是直接的 mp4 |
duration | 3804.97 / 2340 等 | 正常 |
readyState | 0 → 1 → 4 | 约 2 秒内就绪 |
volume | 0.75 | 与站点配置 volume 默认一致 |
muted | false | — |
data-popup-wrap-id | null | 未被第三方扩展包装 |
video.vsc-initialized | 不存在 | 未安装速度控制类扩展 |
因为载体是 MSE blob,在 <video> 上直接做进度/播放控制会与 Polyv 内部分片调度冲突——这印证了官方文档"尽量避免直接修改播放器的 <video> 标签"的告诫,也解释了 0.6.0 "静音启动后又被 Polyv 取消静音并暂停"的现象:两边在抢同一个 muted 状态。
4. 课程列表结构与跳转方式(实测)
这是第 2 版的关键发现,直接决定了"自动切下一节"能否在不触碰拦截器的前提下实现。
4.1 列表项没有 <a href>
实测 10 个列表项的 querySelector('h2 a[href]') 全部为 null。跳转地址内联在 onclick 属性里:
<li class="lis-inside-content current-playing"
onclick="javascript:window.location.href='course_ware.aspx?cwid=a1dd8fd2-b08e-40c9-b25e-b41300e510b5'">
<h2 class="another-text">
<i id="top_play"></i>
<span class="cw-index">8.</span>
<span class="cw-title-text">急诊护理质量控制策略与管理(一)</span>
</h2>
<div class="cw-expert-meta">…</div>
<div class="cw-progress-row"
data-cwrid="a1dd8fd2-b08e-40c9-b25e-b41300e510b5"
data-coaid="47cf2ced-9c77-4946-8e44-b42c01161996"
data-maxtime="145" data-totaltime="2340"
data-hideprogress="0" data-completed="0" style="display: flex;">
<div class="cw-progress-track"><span class="cw-progress-fill" style="width: …;"></span></div>
</div>
</li>
要点:
#top_play是当前节的标记,位于<h2>内部(<i id="top_play">)。因此#top_play.closest('li')可用,但若某版本把它挂到更内层元素,需要逐级向上查找(助手脚本已实现该降级)。- 当前节额外带
current-playing类。 - 跳转目标路径是
course_ware.aspx,不是course_ware_polyv.aspx。 页面 HTML 中搜索course_ware_polyv.aspx?结果为"未出现",说明列表项从不直接指向播放页;course_ware.aspx?cwid=X才是入口(其中应有一次服务端重定向到course_ware_polyv.aspx?ff=0&ft=0&cwid=X&t=0)。 - 这一发现解释了为什么"读
<a href>再导航"的初版实现全部返回no:路径白名单写成了course_ware_polyv\.aspx$,而实际目标是course_ware.aspx。
4.2 data-* 字段语义(实测推断)
| 字段 | 含义 | 证据 |
|---|---|---|
data-cwrid | 该节的课件 id,与当前页 URL 的 cwid 同值 | 当前节的 data-cwrid 与 location.search 的 cwid 完全一致 |
data-coaid | 整个课程(项目)的 id | 10 项全部相同 |
data-totaltime | 该节总时长(秒) | 与视频 duration 量级一致 |
data-maxtime | 该节已观看到的最大秒数 | 已看完的节 data-maxtime == data-totaltime |
data-hideprogress | "1" 表示进度条已满(已看完) | 实测第 1–7 节全为 "1",第 8–10 节为 "0",与实际进度吻合 |
data-completed | 完成标记 | 本次全为 "0",语义未确认 |
可利用这一点在不请求任何接口的前提下显示课程完成度(§6 的助手实现即用此判定)。
4.3 "不能拖动进度条"提示的来源
页面中存在一个默认隐藏的提示元素:
#div_processbar_tip count=1 visible=0
text: "温馨提示\n 同学,继续教育规定观看课件不能拖拽,需…"
实测它在正常页面加载后不可见,说明"弹窗"不是元素出现,而是某处调用了显示方法。结合 §2.6 的 ban_seek_by_limit_time: 'on' 与 ban_history_time: 'on',有三种可能触发路径,尚未定案:
- 续播定位与拖拽限制冲突:列表地址不带
t=参数,重定向后的播放位置由服务端决定;若服务端让播放器从data-maxtime处开始,而客户端getMaxPlayTime()的基准值低于该位置,就会构成"跳到未观看位置",触发提示。 - 页面设计如此:进入每一节都提示一次,与自动跳转无关。
- 助手脚本引入(可能性低):助手全文无任何 seek 调用,唯一的写操作是
location.href导航。需要调用栈证据才能排除,因此提供了一个只读侦察脚本(挂钩jQuery.fn.show/fadeIn/slideDown、layer.open/alert,并在提示可见时打印调用栈与currentTime)。
验证方法(无需工具): 在播放页手动点列表进下一节。若同样弹提示 → 与自动跳转无关(路径 2 或 1);若只有自动跳转才弹 → 差异在导航地址/URL 参数上(路径 1)。
5. 各检测项的实测生效状态
源码里的 0==0 / 0==1 / 1==1 是服务端拼模板产生的开关,因此逐项列出实测结论:
| 检测项 | 源码条件 | 实测状态 |
|---|---|---|
video.vsc-initialized 扩展标记 | 0 == 0 && 1 == 1 | 生效(恒真)。本次实测 vsc-initialized 不存在,故未触发 |
video.pv-video[data-popup-wrap-id] | 0 == 0 && 1 == 1 | 生效(恒真)。本次实测该属性为 null,故未触发 |
| 播放速率一致性(1 秒定时) | 0 == 0 && 1 == 1 | 生效(恒真)。本次正常观看未触发(见 §2.5 的恒不成立分析) |
点击 isTrusted 三层拦截 | 无开关,无条件 | 始终生效 |
ban_seek_by_limit_time: 'on' | 0 == 0 | 生效 |
preventKeyboardEvent: true | 1 == 1 | 生效 |
ban_history_time: 'on' | 字面量 | 生效 |
is_interaction: 'off' | 字面量 | 关闭(过程题不启用) |
preview | 0 == 1 | 恒假,关闭 |
speed 倍速选项 | ratePlayLimitNum == 1.5 | 假(ratePlayLimitNum = 1.0),倍速只有 1.0 |
本次全程未触发过任何 blockAbnormalPlugin():事件时间线中 window.player 自 +1.10s 出现后从未消失(若被拦截,destroy() 会导致播放器消失或方法表变化),也没有 media- 事件、没有阻断弹窗、没有 is_trusted 相关网络命中。这是一条重要的反向证据:在"不合成点击 + 不装扩展 + 不篡改倍速"的前提下,这些检测不会干扰正常观看。
6. 最终产物:可用的播放助手
6.1 设计原则
| 目标 | 实现 | 为什么不违规 |
|---|---|---|
| 自动进入下一节 | 读取列表项 onclick 里的地址字符串 → location.href | 等价于地址栏输入;不产生任何事件,因此不进入 isTrusted 拦截器。目标地址是页面自己写好的,不自行拼接参数 |
| 播放结束检测 | 订阅 player.on('s2j_onPlayOver'),另加 video.ended 与轮询兜底 | 官方文档指定的事件;只订阅不修改 |
| 休息提示 | 仅检测 .study_diaog .btn_sign 可见性,只提醒不点击 | 点击会命中 (03);页面规定该提示需真人确认 |
| 意外暂停提醒 | 监听 video 的 pause/play | 只读监听,无写操作 |
| 三层提醒 | 页顶横幅 + Web Audio 蜂鸣 + 标签标题,另有系统通知(切后台时) | 纯 UI |
明确不做:不调 .click()/dispatchEvent()、不写 localStorage、不调 j2s_seekVideo、不改 playbackRate、不改 muted/volume、不伪造任何上报。导航目标经三重校验(协议必须 http(s)、必须同源 cme28.91huayi.com、路径必须匹配 course_ware.aspx 或 course_ware_polyv.aspx)。
6.2 版本迭代与失败机制
| 版本 | 改动 | 结果与失败机制 |
|---|---|---|
| 0.5.0 | 结束后 .click() 下一节;.click() 休息提示;尝试自动播放 | 触发 (03) 系列;静音启动后 Polyv 尝试取消静音,被浏览器暂停 |
| 0.6.0 | 优先静音启动 + 延迟检查播放状态 | 实测出现"异常插件 (03)";button.click() → 拦截 → destroy() → j2s_* is not a function 雪崩 |
| 0.7.0 | 移除全部自动点击,仅保留"结束提醒" | 不再触发已确认的拦截路径,但不满足自动切课需求 |
| v1.0.0(助手) | 自动下一节(读 href)+ 三层提醒 + 官方事件 | 失败:resolveHref() 只找 <h2> a[href],而列表项没有 <a>,因此永远返回 null |
| v1.1.0 | 暂停检测由"轮询 + 时间启发式"改为监听 video 的 pause/play 事件;新增 huayiAssistant.test()/status() 调试接口 | 修复了阈值 currentTime > 5 与"1 秒轮询 + 1.5 秒确认"造成的漏判 |
| v1.2.0 | 修复死锁:原先在确认"能否跳转"之前就写 jumpedForLesson = cur,导致定位失败一次后本节永久不再重试;改为失败即安排 3 秒重试且不写标记。列表定位不再依赖 #top_play,改为四级降级 | 修复了"列表里还有视频但不跳转" |
| v1.3.0 | resolveHref() 改为读取 onclick 中的地址;路径白名单加入 course_ware.aspx;新增 已看完 判定 | endTest() 实测跳转成功 |
6.3 验收状态
- 已完成:整节播放结束后自动进入下一节。通过
huayiAssistant.endTest()在真实页面验证跳转链路(横幅倒计时 →location.href→ 下一节加载)。 - 已完成:列表定位准确性(当前下标 7 = 第 8 节,与 URL
cwid一致);已看完判定与页面数据吻合(第 1–7 节 true)。 - 待完成对照:真实播完时
s2j_onPlayOver是否派发(可能降级为video.ended或轮询兜底,功能不受影响,但需记录实际来源)。 - 待完成:§4.3 "不能拖动"提示的触发源确认。
7. 开发历程(合并初版记录)
完整时间线,含早期的方向性修正:
- 需求提出:先分析已有华医网插件为何失效 → 实现"当前视频播完自动切下一节 + 自动处理弹窗" → 后补充"自动开始播放"。
- 旧插件静态检查:两个旧版本
2.0.5与202608都直接.click()休息按钮,202608还用.click()切下一节;其videoObj.onended在结束后轮询考试入口并按本地模式值分支。这类设计依赖固定页面结构、按钮状态与事件顺序,页面一变即失效。用户截图显示两者当时处于关闭状态。 - 首次修正(切课时机):实测发现视频真正结束、考试入口已启用时,列表仍显示"学习中";手动打开下一节后原课件才变"待考试"。结论:列表标签不是
ended的可靠即时信号,弃用"等列表状态变化",改用视频结束事件。此结论与 §2.6 的"每 5 分钟批量上报"完全矛盾自洽——列表状态本就不是实时的。 - 第一次实现 0.5.0:加入结束后点下一节、点休息提示、尝试自动播放。实测浏览器限制有声自动播放。
- 第二次修改 0.6.0:改为优先静音启动并延迟检查。实测出现
(03),控制台随后大量 Polyv API 报错。 - 定位拦截原因:确认
isTrusted检查与(01)(02)(03)映射,以及"触发后销毁播放器"。 - 第三次修改 0.7.0:移除全部自动点击,改为结束提醒。这是"避免踩雷"的版本,不是完成版。
- 诊断阶段(本轮新增):编写只读时间线探针,取得
window.player完整接口表、<video>MSE 属性、iframe 清单(4 个跨域)、列表 DOM 结构;随后通过同源fetch与关键词切片取得检测逻辑源码原文,包括blockAbnormalPlugin()全文与播放速率检测。 - 归因修正(本轮新增):探针首轮报告"未找到播放器实例"被判定为采样时序错误(
window.player在+1.10s出现);"内联脚本 0 字符"被判定为动态注入时机问题(逻辑在+0.08s注入的 47 KB 脚本中)。两次都由后续实测纠正,而非猜测。 - 助手实现与验收(本轮新增):v1.0.0 → v1.3.0 的三轮实测修正,最终
endTest()通过(详见 §6.2/§6.3)。
8. 能力边界:哪些"绕不过"、哪些"不做"
本报告的分析足以支撑一份明确的边界说明。区分两类是必要的——它们性质不同。
8.1 规范级不可绕(技术上不存在)
- 伪造
isTrusted:该属性是[Unforgeable],只读、原型上定义为取值器、无 setter。Object.defineProperty抛错,new MouseEvent(...)也构造不出true。唯一产生可信输入的路径是浏览器输入管线(真实鼠标,或 CDP/WebDriver 在页面之外注入),用户脚本够不着。初版"尚未单独实测(02)"在此已无必要——三条分支走的是同一个机制,不必逐条验证。 - 绕过浏览器自动播放策略:实测
navigator.userActivation.hasBeenActive === false时,即使<video>已达readyState 4,仍会paused: true静止超过 10 秒。站点自己插入静音 MP4 探针也只能换取"允许静音播放",不等于有声可自动播放。
8.2 技术上可绕、但本报告不做
以下三项的绕过方式明确存在(属于 DOM 读取或状态改写,不受 isTrusted 约束),但都属于拆掉站点的完整性校验:
| 项 | 可绕方式 | 不做理由 |
|---|---|---|
点击拦截 (01)(02)(03) | 在 document-start 用捕获阶段监听 + stopImmediatePropagation 抢在页面监听器之前吞掉合成事件 | 该拦截器的注释明确写着要防"扩展脚本自动关弹窗、自动切课",绕过它就是在实现被明确拒绝的自动化 |
| 扩展标记检查 | 在 s2j_onPlayerInitOver 之前清除 vsc-initialized 类 / data-popup-wrap-id 属性 | 这是移除检测痕迹,不是修复兼容性 |
播放速率一致性 + ban_seek_by_limit_time + getMaxPlayTime() | 篡改 playbackRate、构造进度或在客户端覆盖 getMaxPlayTime() 的返回值 | 这些约束是站点主动设置的观看完整性要求,服务端另有 play_max_time 交叉校验;绕过客户端只会让本地状态与服务器记录不一致 |
边界陈述: 本报告给出的所有实现(§6)都只在页面已有的数据与公开接口范围内工作——读列表项自带的目标地址、订阅官方事件、只读监听 <video>。凡是需要"抢在页面代码之前"或"改写页面状态"的方案,均不在交付范围内。理由是这类检测与继续医学教育学分挂钩,其存在是为了让观看记录对应真实观看;拆掉它得到的是"看起来学完了"的记录。
9. 未解项与后续验证
按优先级排列,均由实测驱动:
- "不能拖动"提示的触发源(§4.3)。需要调用栈证据。已备只读侦察脚本:挂钩
jQuery.fn.show/fadeIn/slideDown与layer.open/alert,在提示变为可见时打印调用栈、currentTime与事件时间轴。最快的旁证是"手动点下一节是否同样弹"。 s2j_onPlayOver是否真实派发。助手已同时订阅该事件与video.ended并带 1 秒轮询兜底,实际来源会打印在控制台。s2j_onPlayerInitOver后半段代码。§2.4 的提取在var处被截断,其后应有j2s_resumeVideo、getCourseWarePlayState、updateCourseWareProcess的实现与icmeParams定义。这些不影响已有结论(拦截链与约束已完整),但对"续播定位如何决定"有解释力。可从window.__huayiDiag2Scripts中检索。getMaxPlayTime()的parseInt("274")与实测拖拽行为不符。第 8 节data-maxtime=145,而某轮实测曾把进度拖到351.67秒且未触发速率检测。需确认是外层开关、ban_seek_by_limit_time的钳制,还是ss2只取一次的语义所致(§2.5 已给出"正常观看恒不成立"的解释,但该次拖拽的具体钳制行为未实测)。- 跨页音量记忆(可选增强)。站点设了
ban_history_time: 'on',因此若要做需自行写localStorage——这是助手目前唯一的写操作,需单独确认后再实施。 data-completed字段语义。本次全为"0",无法判定。
10. 与主故障无直接因果证据的日志
- 百度统计资源
ERR_CONNECTION_CLOSED、数据采集接口502 Bad Gateway:统计链路故障。已确认这些请求来自神策埋点(window['sensorsDataAnalytic201505']、server_url: https://datasinkcloud.91huayi.com/sa、heartbeat_interval_time: 5、clickmap: 'default'),与学习进度上报无关。 Permissions policy violation: unload is not allowed in this document:浏览器权限策略提示,不影响课程切换。- 初版曾把"网络命中"列为可疑:实测确认
recommendListByCoursewareId(推荐接口)与cdpevent.../sdk/trace(埋点)都只是关键词误命中,is_trusted相关命中为 0。第 2 版的探针已改为"强特征直接记录 / 弱特征需 URL 同时命中"的两级过滤,避免同类噪声。
11. 方法与信息边界
报告使用了:用户提供的截图与 DOM 查询、约 590 KB 控制台导出、两轮只读实测探针(时间线采样 + 源码提取)、只读落盘脚本(player.js 静态审查)、以及本地脚本文件与 Playwright/CDP 相关规范文档。
本轮实测全程只读:探针不调用 .click()/dispatchEvent()、不操作 video、不调用任何 player.* 写方法;网络钩子只克隆响应读取,不阻断、不改写。
未做:未使用登录凭证进行第三方登录;未对登录后页面做无脚本对照实验(因此 §5 的"检测未干扰正常观看"是本次实测范围内的结论,不等同于"这些检测永远不会触发");未验证服务器端学习记录,因此报告不对"观看是否被计入学分"作任何断言。
信息处理:原始控制台导出与页面代码含账户标识、会话字段与播放配置(sign/ts/uid 等),本报告仅保留与定位有关的代码结构、属性名与错误文本,未复制任何凭证类取值。页面源码引用部分为其前端业务逻辑,属引用与评述性质。