学车学到科目三,要我们在无忧乐行平台上看满 9 小时课程之后才能预约考试。这个平台非常恶心,不仅 UI 丑得没法看,而且在看视频的时候每隔一定时间会弹出验证码窗口,必须在指定时间之内完成验证;把网站标签页或窗口切到后台就会停止记录学时,必须保证前台观看。

魔高一尺,道高一丈,既然都是前端的小把戏,我们自然可以找到前端的解决方法。

TL;DR

最终解决方案非常简单,就三行代码……

window.inspect = function(){};
$(document).off('hide');
$(document).off('visibilitychange');

估计这是最简单的方案了。看到这里的你是不是有点智商被侮辱的感觉 😅……

已经上传 Greasy Fork 啦:无忧乐行刷学时。虽然这么简单可能没啥必要上传……

以下记录一下对这个网页的探索过程。

现存其他方案

最开始在 Greasy Fork 上找了一下,比较有用的是这个脚本:【刷学时】杭州科目驾驶培训网络课程。它的功能是「在任意切换窗口、浏览其他页面时会自动计时;如果有验证弹窗,发送桌面通知」。

然而在我使用的时候,时不时的弹窗验证通知还是让人非常不爽,而且这个脚本屏蔽「切换窗口停止计时」的手段好像是高频率地检测,这样会导致记录的学时少于实际的时间。总之不太理想。

于是晚上就开始研究了一下这个网站……

网站技术初探

这个网站后端是 JSP,估计是很 low 的外包公司,主要业务逻辑的 JS 代码都内联在 HTML 里。还有诸如 FuckInternetExplorer 这样的暴躁函数名(这搁 NOI 不得被禁赛几年)(虽然 IE 确实 👎),甚至锁进混用 Tab 和空格!

前端用了 jQuery,代码可以说非常杂乱(好在留了注释)。我以为这垃圾网站估计是十年前的,没想到代码里记录的最近更新日期是 2021-6-1……

探索「模拟请求」的方案

这种方法的探索失败了(或者说由于过于麻烦而放弃了)。可以跳过不看。

由于大量的业务逻辑放在前端,一开始我尝试研究了一下直接用前端发请求计学时的可能性。因为之前用过超星学习通小助手-视频专版这个脚本,这种直接发请求挂机的方式给我留下了深刻印象。

//签退 num为1时 强制  //随机验证
var todayEnd;
function signOut(type){
    //判断是否在自动上传,如果在,则延后0.5S,防止同时进行后台自动屏蔽学时
    if(!automaticUpload) {
        //失败则跳转到课件列表页面
        $(".tt_p").focus();
        isInspect =true;
        todayEnd=type;
        suspend();
        if(courseware.ruleDto.automatic == 1) {
            validate(1,3);
        }else {
            interfaceForStu(3);   //签退记录
        }
    }else {
        setTimeout("signOut("+type+")", 500);
    }
}

//视频验证接口 num: 1-签到 2-中间验证 3-签退
function interfaceForStu(num){
    //获取验证方式
        styleType = num;
        // 20200728:视频验证是通过配置来选择走的海景还是云从,故这边代码无需改造
        if(courseware.ruleDto.autoVerifyStyle==1){ //海景
            //...
        }else if(courseware.ruleDto.autoVerifyStyle==6){//2017.09.26 云从人脸识别
            //...
        }else if(courseware.ruleDto.autoVerifyStyle==3){ //回答问题
            window.scrollTo(0,0);//滚动到顶部
            page=$.layer({
                //...
                iframe : {
                    src : 'https://5u5u5u5u.com:443/learning/popup/inspect_ques2.jsp?type='+num+'&frequency='+courseware.ruleDto.verifyTimes+'&time='+courseware.ruleDto.verifyValidTime+'&verifyNotPassStyle='+courseware.ruleDto.verifyNotPassStyle+'&timeoutStyle='+courseware.ruleDto.timeoutStyle+'&resType='+courseware.resType
                }
            });
        }else if(courseware.ruleDto.autoVerifyStyle==5) { //不用了
            //...
        }else if(courseware.ruleDto.autoVerifyStyle == 4) {   //拍照
            //...
        }
}

interfaceForStu 这个函数就是弹出点击验证码的。可以看到点击签退时,弹出了一个浮层,尝试构造一个 src 去看看,比如 https://5u5u5u5u.com:443/learning/popup/inspect_ques2.jsp?type=2&frequency=3&time=3&verifyNotPassStyle=1&timeoutStyle=1&resType=1。可以看到这个 iframe 内部的验证码页面:

“无忧乐行”的验证码

形式是向后端请求一个图片,要求用户在指定时间内按照顺序用鼠标点击四个汉字的位置。

F12 审阅这个页面的代码。还是一样的风格,JS 内联在 HTML 里……(看第五行和第八行,一个 type 字段写了两遍,单双引号、空格风格还不统一…… 🤮)

//后台验证
function InpectvalidateCode(result){
    $.ajax({
        url:"/learning_json/inpectvalidateCode.action", //url需重定义
        type : "post",
        data : "result="+result,
        async:false,
        type:'post',
        success:function(json){
            if(json.msg !="success"){
                   nextInpect();
            }else{
                countFlag=false;
                parent.validate(1,validateType,null, json.code);
                try {
                    window.parent.closeCur();
                }catch(e) {
                }
            }
        }
    });
}

//获取点击位置
function getSelPic() {
    //...
}

$(function(){
    //...
    $(".queding_btn").click(function(){
        countFlag = false;
        var result = getSelPic();
        InpectvalidateCode(result);
    });
    //...
});

点击「确定」按钮后,向后端发送了用户点击的坐标,然后拿到了后端返回的一个 code,传给 parent(也就是 iframe 外层的 document)的 validate 函数,这个函数应该就是用来增加有效学时的。

回到视频播放页面:

//验证回调方法  validateType:1-签到  2-随机 3-中间验证
function validate(result,validateType,image, code){
    //...
    if(validateType==1){
        //...
    }else if(validateType==3){
        layer.closeAll();
        var valid=getValidTime();
        var newVideoTime=getVideoTime();
        //...
        $.ajax({
            url:"/learning_json/signOut.action?ttm=" + (new Date()).getTime(),
            async:false,
            data:"id="+courseware.id+"&result="+result+"&videoTime="+newVideoTime+"&valid="+valid+"&type="+todayEnd+"&lecturePage="+pageNo+"&verifyType="+courseware.ruleDto.autoVerifyStyle+"&stageId="+stageId+"&photoUrl="+image+"&stageEnd="+stageEnd+"&tag="+tag+"&code="+code+"&videoid="+videoid,
            type:"post",
            success:function(json){
                if(result==-1){
                    faultMessage();
                    return ;
                }
                if(json=="success"){
                    endSucAlert();
                }else if(json ){
                    //入库失败
                    //...
                }else {
                    toLessonList();
                }

            }
        });
    }else if(validateType==2){ //随机验证
        var _flag = true;
        var valid=getValidTime();
        var newVideoTime=getVideoTime();
        //同步方法 async:false,
        $.ajax({
            url:"/learning_json/inspect.action?ttm=" + (new Date()).getTime(),
            async:false,
            data:"id="+courseware.id+"&result="+result+"&videoTime="+newVideoTime+"&valid="+valid+"&lecturePage="+pageNo+"&verifyType="+courseware.ruleDto.autoVerifyStyle+"&stageId="+stageId+"&photoUrl="+image+"&stageEnd="+stageEnd+"&tag="+tag+"&code="+code+"&videoid="+videoid,
            type:"post",
            success:function(json){
                if(sucRedirect) {
                    endSucAlert();
                    return;
                }
                if(result==-1){
                    faultMessage();
                    return ;
                }
                if(json && json !="success"){
                    //...
                }
            }
        });
        if(result==1 && !sucRedirect && _flag){//验证结果返回成功
            curState = cast;
            validTime=validTime+valid;//随机验证已经有效的学习时间
            setRandomTime();
            goon();
        }

    }
    //...
}

尝试修改 cast 变量,会发现右上角的计时器时间也会更改;假设改为 2 小时,点击签退,验证之后会发现弹窗显示「有效学时 2 小时」,然而进入课程列表刷新会发现实际更新的时间并不是 2 小时,而是实际进入页面的时间。

尝试了多种方式,用 BS 修改请求啥的,结果记录的时间永远是我实际打开视频页面的时间……看来后端有某种不太好搞的验证方式。暂时懒得研究了。

屏蔽「随机验证码」

接下来还是关注一下比较简单的前端 tricks 了。

首先是这个不定时弹出的「随机验证码」,可以找到这段代码:

//随机验证
function inspect(){
    if(!automaticUpload) {
        $(".tt_p").focus();
        inspectUpload = true;
        isInspect =true;
        suspend();
        interfaceForStu(2);   //视频验证
    }else {
        setTimeout("inspect()", 500);
    }
}


//显示计时时间
function startCount(){
    //只有讲义
    if(courseware.type==2){
        //...
    }
    //非讲义
    //alert(flag);
    if(flag){
        if(courseware.type!=2){
        //按消耗时间计时
        if(courseware.recordType==3){
            operation(cast);
            setStuTime(cast);
            if(courseware.ruleDto.autoVerify==1)
                if(randomTime==cast&&flag&&!endFlag){
                    inspect();
                }else if(randomTime&&randomTime<cast){
                    setRandomTime();
                }
            if(courseware.ruleDto.autoVerify==3){
                if(randomTime==cast&&flag&&!endFlag&&!validone){
                    inspect();
                    validone=true;
                }else if(randomTime&&randomTime<cast){
                    setRandomTime();
                }
            }
            if(courseware.ruleDto.autoVerify==2){
                if(validonetime>73 && !validone){
                    inspect();
                    validone=true;
                }else {
                    validonetime++;
                }
            }
        }else if(courseware.recordType==2){
            //...
        }else if(courseware.recordType==1){
            //...
            }
        }
    }
    //...
     ds = window.setTimeout(function(){startCount();},1000);
}

看来这是个周期函数。如果 cast(学时)达到 randomTime 就调用 inspect() 进行验证。经过实测 flag 变量如果为 false 则不会再计时……

那么,我们直接把 inspect() 函数弄成空,让它什么都不做不就好了。也就是:

window.inspect = function(){};

(足够简单粗暴……)

屏蔽「切后台检测」

继续看代码,发现这一段:

//笔记  评价  窗口最小化暂停视频播放
$(function(){
     $('.xubox_close').css('display','none');
    //最小化窗口暂停视频播放
    $(document).on('hide', function() {
        //...
        layer.alert('您已离开当前页面,计时停止.', 8,function(index){
            //...
        });
    });
});

很简单,只要把绑定在 document 上 hide 事件的监听器取消掉就好了。

然而其实还有另一个监听器(虽然没找到代码在哪里):visibilitychange,通过 Chrome 的 F12 可以找到:

Chrome 的 F12 工具可以看到监听器

用 jQuery 的 off() 可以解绑这俩监听器:

$(document).off('hide');
$(document).off('visibilitychange');

大功告成!

遗留问题

好像如果把页面放在后台,这个学习时间的计时会慢一点,过了 15min 它好像只记了 9min。猜测可能和 Chrome 的后台节省内存的机制有关?