MyBlog

华医网课件页面自动播放故障技术报告

华医网课件页面自动播放故障技术报告

日期: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. 结论摘要

  1. 点击拦截的机制已完全查清,且不止三类。 页面对"扩展脚本用 element.click() 自动关弹窗、自动切课"(该注释由网站自己写在源码里)做了三层防护:捕获阶段的 isTrusted 检查映射为 (01)(02)(03);播放器初始化时检查 <video> 上的扩展标记(vsc-initialized 类、data-popup-wrap-id 属性);以及每秒一次的播放速率一致性检测。三者都汇聚到同一个 blockAbnormalPlugin()。
  2. 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() 的调用完全对应。
  3. isTrusted 无法在页面内伪造。 这是规范级约束,不是"脚本写得不够好"。因此"在用户脚本里绕过可信点击检测"这条路在原理上不存在。(规范的 [Unforgeable] 属性定义见 DOM Standard;click() 产生非可信事件见 HTML Standard。)
  4. 但"自动切下一节"根本不需要伪造点击。 实测发现列表项没有 <a href>,跳转地址内联在 onclick 里。读取该字符串后自行导航,等价于地址栏输入,不产生任何事件,因此不触碰任何拦截器。该路径已实测通过(见 §6)。
  5. 站点的"诚信约束"是产品设计的一部分,不是缺陷。 页面配置把倍速锁死为 1.0、把拖拽限制在"已观看区间"、禁用了续播记忆,并以 5 分钟/30 秒两种周期上报进度;服务端还通过 getMaxPlayTime() 注入每节的允许观看上限。这些机制与继续医学教育学分挂钩,因此本报告同时给出能力边界说明(§8),区分"技术上可绕"与"不做"。
  6. 最终产物是一个稳定可用的播放助手,而非无人值守刷课器。 它做三件事:休息提示/意外暂停/播放结束的三层提醒、一节播完后自动进入下一节、并在任何失败路径上降级为提示手动操作。它不伪造点击、不改进度、不动倍速、不写服务端数据。"整节播放 → 自动处理提示 → 自动进入下一节"的成功验收已经完成。

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_getCurrentTimefn/0当前播放秒数
j2s_getDurationfn/0总时长
j2s_resumeVideo / j2s_pauseVideo / j2s_stopVideofn/0播放控制
j2s_seekVideofn/1定位
j2s_setVolumefn/1音量(无 j2s_setMuted / j2s_getMuted)
changeVidfn/1换片(官方文档明示用于"上一个播完后自动播下一个")
switchBitrate / changeLevel / getLevels / changeRate / getCurrentRate / changeLinefn/1 或 fn/0清晰度/倍速/线路
on / once / off / trigger / triggerEventfn/2、fn/1事件契约
destroyfn/0销毁(拦截器使用)
j2s_realPlayVideoTime / j2s_stayInVideoTime / j2s_realPlayStatus / j2s_stayInVideoStatus / getMaxCurrentTimefn/0播放行为统计
toggleFullscreen / toggleFullPageScreenfn/0、fn/1全屏
changeStatistics / getScreenshotData / sendQuestion / changeQuestion / setSubtitleSize / getSubtitlesByTime / 弹幕系列—其他
play / pauseundefined不存在。所以直接调 video.play() 是绕开播放器,会与其内部状态冲突

3.2 事件契约

  • 官方文档确认的播放结束事件是 s2j_onPlayOver,且文档给出的连续播放范例正是在该事件里调用 changeVid()。
  • 本页源码中确认存在 window.s2j_onPlayerInitOver(页面自己实现了这个回调),证明这套事件契约是活的。
  • 实测订阅 on('s2j_onPlayOver') 可正常注册。该事件是否在真实播放结束时派发,尚未完成对照验证(见 §9)。

3.3 <video> 元素实测

属性实测值说明
src / currentSrcblob:https://cme28.91huayi.com/<uuid>MSE 承载,分片流经 MediaSource 注入,不是直接的 mp4
duration3804.97 / 2340 等正常
readyState0 → 1 → 4约 2 秒内就绪
volume0.75与站点配置 volume 默认一致
mutedfalse—
data-popup-wrap-idnull未被第三方扩展包装
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整个课程(项目)的 id10 项全部相同
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',有三种可能触发路径,尚未定案:

  1. 续播定位与拖拽限制冲突:列表地址不带 t= 参数,重定向后的播放位置由服务端决定;若服务端让播放器从 data-maxtime 处开始,而客户端 getMaxPlayTime() 的基准值低于该位置,就会构成"跳到未观看位置",触发提示。
  2. 页面设计如此:进入每一节都提示一次,与自动跳转无关。
  3. 助手脚本引入(可能性低):助手全文无任何 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: true1 == 1生效
ban_history_time: 'on'字面量生效
is_interaction: 'off'字面量关闭(过程题不启用)
preview0 == 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.0resolveHref() 改为读取 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. 开发历程(合并初版记录)

完整时间线,含早期的方向性修正:

  1. 需求提出:先分析已有华医网插件为何失效 → 实现"当前视频播完自动切下一节 + 自动处理弹窗" → 后补充"自动开始播放"。
  2. 旧插件静态检查:两个旧版本 2.0.5 与 202608 都直接 .click() 休息按钮,202608 还用 .click() 切下一节;其 videoObj.onended 在结束后轮询考试入口并按本地模式值分支。这类设计依赖固定页面结构、按钮状态与事件顺序,页面一变即失效。用户截图显示两者当时处于关闭状态。
  3. 首次修正(切课时机):实测发现视频真正结束、考试入口已启用时,列表仍显示"学习中";手动打开下一节后原课件才变"待考试"。结论:列表标签不是 ended 的可靠即时信号,弃用"等列表状态变化",改用视频结束事件。此结论与 §2.6 的"每 5 分钟批量上报"完全矛盾自洽——列表状态本就不是实时的。
  4. 第一次实现 0.5.0:加入结束后点下一节、点休息提示、尝试自动播放。实测浏览器限制有声自动播放。
  5. 第二次修改 0.6.0:改为优先静音启动并延迟检查。实测出现 (03),控制台随后大量 Polyv API 报错。
  6. 定位拦截原因:确认 isTrusted 检查与 (01)(02)(03) 映射,以及"触发后销毁播放器"。
  7. 第三次修改 0.7.0:移除全部自动点击,改为结束提醒。这是"避免踩雷"的版本,不是完成版。
  8. 诊断阶段(本轮新增):编写只读时间线探针,取得 window.player 完整接口表、<video> MSE 属性、iframe 清单(4 个跨域)、列表 DOM 结构;随后通过同源 fetch 与关键词切片取得检测逻辑源码原文,包括 blockAbnormalPlugin() 全文与播放速率检测。
  9. 归因修正(本轮新增):探针首轮报告"未找到播放器实例"被判定为采样时序错误(window.player 在 +1.10s 出现);"内联脚本 0 字符"被判定为动态注入时机问题(逻辑在 +0.08s 注入的 47 KB 脚本中)。两次都由后续实测纠正,而非猜测。
  10. 助手实现与验收(本轮新增):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. 未解项与后续验证

按优先级排列,均由实测驱动:

  1. "不能拖动"提示的触发源(§4.3)。需要调用栈证据。已备只读侦察脚本:挂钩 jQuery.fn.show/fadeIn/slideDown 与 layer.open/alert,在提示变为可见时打印调用栈、currentTime 与事件时间轴。最快的旁证是"手动点下一节是否同样弹"。
  2. s2j_onPlayOver 是否真实派发。助手已同时订阅该事件与 video.ended 并带 1 秒轮询兜底,实际来源会打印在控制台。
  3. s2j_onPlayerInitOver 后半段代码。§2.4 的提取在 var 处被截断,其后应有 j2s_resumeVideo、getCourseWarePlayState、updateCourseWareProcess 的实现与 icmeParams 定义。这些不影响已有结论(拦截链与约束已完整),但对"续播定位如何决定"有解释力。可从 window.__huayiDiag2Scripts 中检索。
  4. getMaxPlayTime() 的 parseInt("274") 与实测拖拽行为不符。第 8 节 data-maxtime=145,而某轮实测曾把进度拖到 351.67 秒且未触发速率检测。需确认是外层开关、ban_seek_by_limit_time 的钳制,还是 ss2 只取一次的语义所致(§2.5 已给出"正常观看恒不成立"的解释,但该次拖拽的具体钳制行为未实测)。
  5. 跨页音量记忆(可选增强)。站点设了 ban_history_time: 'on',因此若要做需自行写 localStorage——这是助手目前唯一的写操作,需单独确认后再实施。
  6. 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 等),本报告仅保留与定位有关的代码结构、属性名与错误文本,未复制任何凭证类取值。页面源码引用部分为其前端业务逻辑,属引用与评述性质。