如何开发一个用户脚本系列(5)——脚本三:网易云课堂下载助手

在这篇文章中,我们将一起学习脚本 网易云课堂下载助手 的开发。在正式开始之前,先说一下我认为开发脚本应该遵循的两个准则:

  • 功能实现。当你决定要开发一个脚本的时候,你肯定清楚你的脚本要实现什么功能,只有你的脚本实现了你所描述的功能,才会有更多的人安装使用,才会有更多的人给你好评;
  • 样式实现。什么叫样式实现?就是你在目标网站中添加的元素,要尽量与原网站的配色,样式相一致。这一项是非必须的,但我认为是非常重要的。你想想,如果原网站整体是蓝色,而你添加的按钮是红色,那该有多突兀,有多丑,虽然你的按钮确实突出了,但别人一看就是山寨,看着会很不舒服。而如果你的按钮也用它网站的颜色,这样就会跟原网站已有的元素契合,整体特别自然,做到以假乱真的效果。你的脚本让别人用的舒服,别人才更愿意给你好评。

需求分析

网易云课堂 是一个非常不错的在线学习网站,上面有很多视频课程提供给我们学习。但是有点遗憾的是,官方在 PC 端并没有提供视频的下载功能,而在移动 APP 端可以下载视频,但是下载的视频也只能在软件内部观看。所以为了更加方便在某些网络不允许的情况下学习,我们可以将视频资源下载到本地。通过对课程结构的观察,我们发现一门课程有可能有很多章,每一章有可能有好几节,那么我们最好既提供单个视频下载功能,也提供批量下载功能,这样能满足更多人的需求。官方原版和我们要实现的最终效果分别如下图:


功能实现

在开始编写代码之前,需要说明的是,要写这种资源下载类的脚本,必须确保提前在网页上查看了各个网络请求,能够通过接口请求的方式拿到资源的 URL,并且下载下来的资源是有效的,否则只会白忙活一场。就像在这个脚本中,不支持收费视频的下载,因为收费视频进行了加密,下载下来也是不能播放的。我们要将按钮添加到课程主页,通过观察,课程主页的 URL 形式为: https://study.163.com/course/courseMain.htm?courseId=xxx,我们用 @match 匹配。在脚本编写过程中会用到 jQuery,所以我们使用 @require 引入 jQuery 库。我们需要保存用户设置的一些数据,需要进行网络请求,需要在新 tab 页中打开链接,还需要使用当前网页中的变量,所以需要脚本管理器的 GM_getValue()GM_setValue()GM_xmlhttpRequest()GM_openInTab()unsafeWindow 函数,我们用 @grant 声明。

// @require           https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js
// @match             *://study.163.com/course/courseMain.htm?courseId=*
// @grant             unsafeWindow
// @grant             GM_getValue
// @grant             GM_setValue
// @grant             GM_xmlhttpRequest
// @grant             GM_openInTab

通过查看网络请求得知,要获取视频的下载地址,需要知道视频的 id,所以我们要先拿到课程中所有视频的基本信息。这些基本信息有时候需要通过接口获取,有时候可以通过页面中的变量得到,需要你耐心的去寻找。这里我们可以通过页面中的变量 courseVo 拿到课程的信息。为了后边更方便的对每一节课程操作,我们把所有的课程信息保存在一个 json 类型的变量里面。最终我们这个变量保存的课程信息有课程 id,课程名称,课程价格,课程每一章节的信息。每一章节的信息有章节 id,章节名称,每一课时的信息。每一课时的信息有课时 id,课时名称,课时类型。为了方便后边下载时命名,我们还给每一课时加了一个编号。在JavaScript 中,我们可以用 forEach() 方法对 Array 数组进行遍历,可以用 push() 方法向数组末尾添加一个元素。

    var course_info = {'course_id': {},'course_name': {},'chapter_info': [],'course_price': {}}; //保存课程信息的变量
    function getCourseInfo(){ //获取课程信息
        var courseVo = unsafeWindow.courseVo;
        course_info.course_id = courseVo.id; //课程 id
        course_info.course_name = courseVo.name.replace(/:|\?|\*|"|<|>|\|/g," "); //课程名称
        course_info.course_price = courseVo.price; //课程价格
        var chapter = courseVo.chapterDtos; //课程章节
        chapter.forEach(function(val,index){
            var chapter = {'chapter_id': val.id,'chapter_name': val.name.replace(/:|\?|\*|"|<|>|\|/g," "),'lesson_info': []}; //保存章节信息的变量
            var lessonDtos = val.lessonDtos;
            lessonDtos.forEach(function(val,index){
                var lesson = {'keshi':val.ksstr,'lesson_id':val.id,'lesson_name':val.lessonName.replace(/:|\?|\*|"|<|>|\|/g," "),'lesson_type':val.lessonType}; //保存课时信息的变量
                chapter.lesson_info.push(lesson);
            });
            course_info.chapter_info.push(chapter);
        });
        if(course_info.course_price > 0){
            return false;
        }else{
            return true;
        }
    }

拿到课程信息之后,我们先在页面中每一节课时上面添加一个下载按钮,用来下载当前选中的课时。我们希望我们添加的 下载 按钮和当前已有的 开始学习 按钮的字体大小,字体颜色,背景色都保持一致,所以我们先通过 getStyle() 方法拿到开始学习按钮的样式,然后在创建下载按钮时赋值给下载按钮。因为我们要为每一课时都添加一个下载按钮,所以创建元素的代码应该写在 for 循环里面。

        var ksbtn = document.getElementsByClassName('ksbtn')[0];
        var ksbtn_style = 'display:' + getStyle(ksbtn,'display') + ';width:' + getStyle(ksbtn,'width') + ';background-position:' + getStyle(ksbtn,'background-position') + ';margin-top:' + getStyle(ksbtn,'margin-top') + ';';
        var ksbtn_span = ksbtn.firstChild;
        var ksbtn_span_style = 'display:' + getStyle(ksbtn_span,'display') + ';text-align:' + getStyle(ksbtn_span,'text-align') + ';background:' + getStyle(ksbtn_span,'background') +
                         ';width:' + getStyle(ksbtn_span,'width') + ';font-size:' + getStyle(ksbtn_span,'font-size') + ';height:' + getStyle(ksbtn_span,'height') + ';line-height:' +
                         getStyle(ksbtn_span,'line-height') + ';color:' + getStyle(ksbtn_span,'color') + ';background-position:' + getStyle(ksbtn_span,'background-position') + ';';
        var allNodes = document.getElementsByClassName("section");
        for (var i = 0;i < allNodes.length;i ++) {
            var download_button = document.createElement("a");
            var style = 'display:block;text-align:center;padding-left:10px;width:58px;font-size:12px;height:34px;line-height:33px;color:#fff;background-position:-40px 0px;';
            download_button.innerHTML = "<span>下载</span>";
            download_button.className = "f-fr j-hovershow download-button";
            download_button.style = ksbtn_style;
            download_button.lastChild.style = ksbtn_span_style;
            allNodes[i].appendChild(download_button);
        }
    function getStyle(element,cssPropertyName){ //获取元素样式
        if(window.getComputedStyle){ //如果支持getComputedStyle属性(IE9及以上,ie9以下不兼容)
            return window.getComputedStyle(element)[cssPropertyName];
        } else { //如果支持currentStyle(IE9以下使用),返回
            return element.currentStyle[cssPropertyName];
        }
    }

下载按钮添加完成后,我们需要对每一个按钮进行点击事件的处理。在 jQuery 中,我们使用 each() 方法遍历选择的多个元素。我们在后边进行网络请求时,需要视频 id,所以我们在点击事件里面需要拿到被点击的课时信息。我们在后面下载视频时,需要文件保存路径和文件名,所以我们在点击事件里面将这两个值拼接好,并传递给后面的函数。在进行点击操作时,要注意事件冒泡和事件捕获。

    $('.download-button').each(function(){ //下载按钮点击事件
        $(this).click(function(event){
            loadSetting();
            if(course_save_path==""){
                alert("请到下载助手的设置里面填写文件保存位置");
            }else if(aria2_url==""){
                alert("请到下载助手的设置里面填写 Aria2 地址");
            }else{
                var data_chapter = event.target.parentNode.parentNode.getAttribute("data-chapter");
                var data_lesson = event.target.parentNode.parentNode.getAttribute("data-lesson");
                var index = Number(data_lesson);
                for(var i = 0;i < Number(data_chapter); i ++){
                    index = index - course_info.chapter_info[i].lesson_info.length;
                }
                var lesson = course_info.chapter_info[data_chapter].lesson_info[index];
                mylog("选择的课为【lesson_name: " + lesson.lesson_name + ",lesson_id: " + lesson.lesson_id + ",lesson_type: " + lesson.lesson_type + '】');
                var file_name = lesson.keshi + '_' + lesson.lesson_name;
                var save_path = course_save_path.replace(/\\/g,'\/') + '/' + course_info.course_name + '/章节' + (Number(data_chapter) + 1) + '_' + course_info.chapter_info[data_chapter].chapter_name;
                if(lesson.lesson_type=="3"){
                    getTextLearnInfo(lesson,file_name,save_path);
                }else{
                    getVideoLearnInfo(lesson,file_name,save_path);
                }
            }
            event.stopPropagation();
        });
    });

我们拿到当前点击的课时信息后,需要请求接口拿到视频地址。并且还注意到,课程中除了视频,还有 PDF 文件,所以我们根据课时类型分别请求不同的接口。在 jQuery 中,我们可以使用 $.ajax() 来进行网络请求。每个接口需要的参数都是从网页中观察得到的。由于视频可能提供不止一种格式,不止一种清晰度,所以我们在后面会添加一个设置按钮让用户可以选择下载哪种格式,哪种清晰度的视频。

    function getTextLearnInfo(lesson,file_name,save_path){ // 获取文档下载地址
        var timestamp = new Date().getTime();
        var params = {
            "callCount":"1",
            "scriptSessionId":"${scriptSessionId}190",
            "httpSessionId":match_cookie,
            "c0-scriptName":"LessonLearnBean",
            "c0-methodName":"getTextLearnInfo",
            "c0-id":"0",
            "c0-param0":"string:" + lesson.lesson_id,
            "c0-param1":"string:" + course_info.course_id,
            "batchId":timestamp
        }; //接口需要的数据
        var url = "https://study.163.com/dwr/call/plaincall/LessonLearnBean.getTextLearnInfo.dwr?" + timestamp;
        $.ajax({
            url:url,
            method:'POST',
            async: true,
            data: params,
            success: function (response){
                var pdfUrl = response.match(/pdfUrl:"(.*?)"/)[1];
                sendDownloadTaskToAria2(pdfUrl,file_name + ".pdf",save_path);
            }
        });
    }
    function getVideoUrl(videoId,signature,file_name,save_path){ // 获取视频下载地址
        var params = {
            'videoId':videoId,
            'signature':signature,
            'clientType':'1'
        };
        $.ajax({
            url:"https://vod.study.163.com/eds/api/v1/vod/video",
            method:'POST',
            async:true,
            data:params,
            success:function(response){
                var videoUrls = response.result.videos;
                var video_url_list = [];
                videoUrls.forEach(function(video){
                    if(video.format == video_format) {
                        video_url_list.push({'video_format': video.format,'video_quality': video.quality,'video_url': video.videoUrl});
                    }
                });
                if(video_url_list.length != 0){
                    if(video_quality=="2"){
                        video_download_url = video_url_list[video_url_list.length-1].video_url;
                    }else{
                        video_download_url = video_url_list[0].video_url;
                    }
                }
                if(video_download_url != ""){
                    //mylog(video_download_url);
                    sendDownloadTaskToAria2(video_download_url,file_name + '.' + video_format,save_path);
                }
            }
        });
    }

我们获取到文档和视频的下载地址后,就可以进行下载了。脚本管理器提供一个叫做 GM_download() 的方法可以下载文件,但经过尝试,体验不是太好,尤其是我们后边还要进行批量下载,所以就没有采用。这里我们借助的工具是 Aria2,如何通过 Aria2下载文件可以看这篇文章: 如何配置 Aria2 来进行文件下载。我们将获取到的下载地址和文件名,文件保存路径都传给 Aria2,就可以开始下载了。然后我们可以在网站 http://aria2c.com/ 上看到下载进度。

    function sendDownloadTaskToAria2(download_url,file_name,save_path){
        var json_rpc = {
            id:'',
            jsonrpc:'2.0',
            method:'aria2.addUri',
            params:[
                [download_url],
                {
                    dir:save_path,
                    out:file_name
                }
            ]
        };
        GM_xmlhttpRequest({
            url:aria2_url,
            method:'POST',
            data:JSON.stringify(json_rpc),
            onerror:function(response){
                mylog(response);
            },
            onload:function(response){
                mylog(response);
                if (!hasOpenAriac2Tab){
                    GM_openInTab('http://aria2c.com/',{active:true});
                    hasOpenAriac2Tab = true;
                }
            }
        });
    }

这样我们单个视频下载的功能就实现了,下面我们要实现批量下载功能,同时还要提供给用户一个设置按钮,让用户可以选择视频的格式,清晰度,以及填写文件保存路径。我们在页面顶部创建一个下载助手按钮,当鼠标移入下载助手时,显示一个下拉框,下拉框里面有批量下载和设置,点击批量下载,我们调用批量下载的方法,遍历所有课时,对每一个课时都调用前面获取视频地址的方法,然后下载。点击设置,我们弹出一个设置页面,让用户可以进行相应的设置。我们要使用 GM_setValue() 将设置的内容进行保存,然后在脚本加载的时候使用 GM_getValue() 取出数据,这样用户只需要设置一次,以后一直有效,并且脚本更新之后也有效。

    function addDownloadAssistant(){ // 添加下载助手按钮
        $(".u-navsearchUI").css("width","224px");
        var download_assistant_div = $("<div class='m-nav_item'></div>");
        var download_assistant = $("<span>下载助手</span>");
        var assistant_div = $("<div class='f-pa' style='line-height:40px;display:none;left:0px;top:60px;width:auto;height:auto;background-color:#fff;color:#666;border:1px solid #ddd;padding:5px 10px;text-align:center;'><div class='arrr f-pa' style='background:url(//s.stu.126.net/res/images/ui/ui_new_yktnav_sprite.png) 9999px 9999px no-repeat;top:-9px;left:40px;width:14px;height:9px;background-position:-187px 0;'></div></div>");
        var batch_download = $("<a>批量下载</a>");
        var assistant_setting = $("<a>设置</a>");
        assistant_div.append(batch_download).append(assistant_setting);
        download_assistant_div.append(download_assistant).append(assistant_div);
        $('.m-nav').append(download_assistant_div);
        download_assistant_div.mouseover(function(){
            assistant_div.show();
        });
        download_assistant_div.mouseout(function(){
            assistant_div.hide();
        });
        batch_download.click(function(){
            assistant_div.hide();
            loadSetting();
            if(course_save_path==""){
                alert("请到下载助手的设置里面填写文件保存位置");
            }else if(aria2_url==""){
                alert("请到下载助手的设置里面填写 Aria2 地址");
            }else{
                batchDownload();
            }
        });
        assistant_setting.click(function(){
            assistant_div.hide();
            showSetting();
        });
    }
    function batchDownload(){ // 批量下载
        course_info.chapter_info.forEach(function(chapter,index){
            chapter.lesson_info.forEach(function(lesson){
                var file_name = lesson.keshi + '_' + lesson.lesson_name;
                var save_path = course_save_path.replace(/\\/g,'\/') + '/' + course_info.course_name + '/章节' + (index + 1) + '_' + chapter.chapter_name;
                if(lesson.lesson_type=="3"){
                    getTextLearnInfo(lesson,file_name,save_path);
                }else{
                    getVideoLearnInfo(lesson,file_name,save_path);
                }
            });
        });
    }

至此,我们就完成了这个脚本的开发,用户可以用它来下载单个视频,也可以批量下载视频,并且可以进行设置,选择视频清晰度,视频格式。至于发布脚本的流程可以参考文章 如何开发一个用户脚本系列(3)——脚本一:百度首页和搜索页面添加 Google 搜索框

总结

本文对脚本 网易云课堂下载助手 的开发过程进行了介绍,如果还有疑问,可以留言,下一篇文章将对脚本 视频跳过广告和 VIP 视频解析 的开发过程进行介绍。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,265评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,078评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,852评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,408评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,445评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,772评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,921评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,688评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,130评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,467评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,617评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,276评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,882评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,740评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,967评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,315评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,486评论 2 348

推荐阅读更多精彩内容