Contents

go实战一:爱课程网视频以及课件爬虫

最近在上《离散数学》课程,听说屈婉玲老师的讲的三学期的《离散数学》更为系统、全面,在网上搜索了一下,爱课程网站保留了该课程的课堂视频以及课件,想下载下来以供参考。

最近正好接触go语言,因此我想尝试一下最近刚刚学习的go 语言来进行爱课程的爬虫,达到我期望的带目录结构的下载效果同时能够实现并发下载达到较好的下载速度。

1.网站分析

目标网址:http://www.icourses.cn/sCourse/course_6447.html

1.1 登录部分

课程内容是需要登录才能看到的(体现在用chrome network看到的Request headers有cookie参数),但是后面研究网站url发现不需要cookie也可以获得课程信息,因此不需要提供cookie,直接构造请求的header就可以。

{'Server': 'nginx/1.12.0',
'Content-Type': 'text/html;charset=UTF-8',
'Transfer-Encoding': 'chunked',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS'
}

1.2 下载文件部分

需要下载的文件有四部分:

  • pdf格式的课件,mp4格式的课程视频在相同的页面(http://www.icourses.cn/web/sword/portal/shareDetails?cId=6447#/course/chapter)
  • pdf格式的习题作业(http://www.icourses.cn/web/sword/portal/shareDetails?cId=6447#/course/testPaper)。
  • pdf格式的测试试卷(http://www.icourses.cn/web/sword/portal/shareDetails?cId=6447#/course/testPaper)
  • 其他资源,这个我觉得可以不下载(http://www.icourses.cn/web/sword/portal/shareDetails?cId=6447#/course/sharerSource)

因此最终请求的网址为

https://www.icourses.cn/web/sword/portal/shareChapter?cid=6447

http://www.icourses.cn/web/sword/portal/assignments?cid=6447

http://www.icourses.cn/web/sword/portal/testPaper?cid=6447

http://www.icourses.cn/web/sword/portal/sharerSource?cid=6447

1.2.1 pdf文件

pdf格式的课件以及课堂作业在网页的源代码部分就已经给出:

1
<a data-class="media" data-title="模块1习题" data-type="pdf" data-url="https://res1.icourses.cn/share/process20/pdf/2018/4/21/0/2734102a-1e47-4d3d-a882-1df8e1329c4c.pdf" data-id="979886" class="chapter-body-content-text-in pdf" href="javascript:void(0);">模块1习题</a>

因此写一个正则表达式或者使用html元素选择的框架选择这个<a>元素的data-url属性就能得到下载链接,根据data-title属性进行重命名。下载时可以根据该元素的两个父元素的属性定义下载路径进行下载。

1.2.2 视频文件

视频文件的下载链接也已经在源代码中给出了,同样进行重命名即可。

1
<a data-class="media" data-title="集合论与图论课程引言" data-type="mp4" data-url="https://res1.icourses.cn/share/process21/mp4/2018/12/10/2f14fe9f1eee4e83851e4495f122d1d1_SD.mp4" data-id="635463" class="chapter-body-content-text-in video" href="javascript:void(0);">集合论与图论课程引言</a>

2. 下载过程分析

出发页面为爱课程的课程页面,http://www.icourses.cn/sCourse/course_6447.html

2.1 得到页面内容

2.1.1 构造http请求

根据https://golang.org/pkg/net/http/
尝试构造简单的http请求,成功返回网页内容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
)
func httpGet(s string) string {
	resp, err := http.Get(s)
	if err != nil {
		// handle error
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		// handle error
	}
	return string(body)

}
func main() {
	url:="http://www.icourses.cn/web/sword/portal/testPaper?cid=6447"
	fmt.Println(httpGet(url))
}

2.1.2 从网页内容中提取需要的信息

需要的信息有:文件的url、文件的名字、文件的类型/后缀以及文件的路径,可以直接写正则表达式匹配查找,也可以使用已有的HTML解析库goquery,其官方文档位于https://godoc.org/github.com/PuerkitoBio/goquery

首先安装git,再使用go get github.com/PuerkitoBio/goquery安装goquery即可(没办法直接下载golang.org的包可以先在github上下载再复制到$GOPATH/src/golang.org/x文件夹里)

测试试卷信息

首先尝试提取测试试卷的信息,大部分为二级目录,三级目录的我也将其忽视了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func extractURLs(s string) {
	// 构造请求.
	res, err := http.Get(s)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()
	if res.StatusCode != 200 {
		log.Fatalf("status code error: %d %s", res.StatusCode, res.Status)
	}

	// 加载html
	doc, err := goquery.NewDocumentFromReader(res.Body)
	if err != nil {
		log.Fatal(err)
	}

	// 定位每一章节
	doc.Find("#chapters >.panel").Each(func(i int, s *goquery.Selection) {
		//提取章节的名称
		parentPATH:=s.Find(".chapter-title-text").Text()
		//去除换行符(不知道为什么trimSpace函数不管用)
		parentPATH=strings.Replace(parentPATH, "\n", "", -1)
		//去除空格
		parentPATH = strings.Replace(parentPATH, "\t", "", -1)

		s.Find("a[data-class=media]").Each(func(j int, a *goquery.Selection) {
			// 分别记录文件名称、下载URL、文件类型以及构造的文件路径
			fileTitle,_ := a.Attr("data-title")
			fileURL, _ := a.Attr("data-url")
			fileType, _ := a.Attr("data-type")
			filePATH := parentPATH+"/"+fileTitle+"."+fileType
			fmt.Printf("Review %d: %s - %s\n", j,fileURL,filePATH)
		})
	})
}

结果如下

file 0: https://res1.icourses.cn/share/process20/pdf/2018/4/20/16/adbb9697-3f3f-45ce-8229-b9e8e5b9a18d.pdf - 第14章离散数学(1)期末考试/离散数学(1)期末考试.pdf
file 1: https://res1.icourses.cn/share/process20/pdf/2018/4/21/0/7f9ba9cf-cac8-4b6c-8cd4-96f1c72b87ea.pdf - 第14章离散数学(1)期末考试/离散数学(1)期末考试参考答案.pdf
file 0: https://res1.icourses.cn/share/process20/pdf/2018/4/20/16/c7c581ae-0bc7-44c4-936f-cdbbde4786f0.pdf - 第22章组合计数方法(教材第二十二章)/小测验.pdf
file 0: https://res1.icourses.cn/share/process20/pdf/2018/4/20/16/5eb9a97a-a986-49e5-9407-fbaf9d7553c4.pdf - 第25章命题逻辑(教材第二十六章)/期中考试.pdf
file 0: https://res1.icourses.cn/share/process20/pdf/2018/4/20/16/dc6e926c-bc2e-4280-ba37-174a53f38bce.pdf - 第27章离散数学(3)期末考试/离散数学(3)期末考试.pdf
file 1: https://res1.icourses.cn/share/process20/pdf/2018/4/20/16/77662c83-93ac-4c2d-992d-885bdd6abc30.pdf - 第27章离散数学(3)期末考试/离散数学(3)期末考试参考答案.pdf
课件视频、习题作业信息

这一部分的网页是动态加载的,http网页只能得到它的目录结构,具体内容需要JavaScript渲染点击后才加载

研究一下鼠标点击的javascript代码发现构造了一个post请求,唯一的参数是sectionId,而这个参数在静态网页中存在,因此只要构造这个请求就能在返回的json数据中找到需要的文件(以下为相关的javascript代码)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
		$(".chapter-body-i-click").on("click",function() {
			var $this = $(this);
			var datas = $this.find(".section-event-t.no-load").data();
			if (!isShow(datas.secid)) {
				loadingShow();
				$.ajax({
					type: "POST",
					url: getServer()+"/sword/portal/getRess",
					dataType: "json",
					data: {
						sectionId : datas.secid
					},
					success: function(data){
						loadingHide();
						for(var it=0;it<data.model.listRes.length;it++){
							var dd = data.model.listRes[it];
							var html=getResHtml(dd);
							$this.find("ul").append(html);
						}
						showArr.push(datas.secid);
					}
				});

因此,这部分的信息提取代码需要重新写,提取出的sectionID需要构造post请求得到文件信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
//根据sectionID,构造post请求
func getVideo(id string) string {
	res, err := http.PostForm("https://www.icourses.cn/web//sword/portal/getRess",url.Values{"sectionId": {id}})
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()
	if res.StatusCode != 200 {
		log.Fatalf("status code error: %d %s", res.StatusCode, res.Status)
	}
	body, err := ioutil.ReadAll(res.Body)
	return string(body)
}

请求成功,返回json格式的数据

{
    "model":{
        "listRes":[
            {
                "characterId":"87823",
                "courseId":"6447",
                "fullResUrl":"https://res1.icourses.cn/share/process20/pdf/2018/4/20/16/38b360b7-70e1-4b2e-b22a-f9845146edaa.pdf",
                "id":"635462",
                "mediaType":"ppt",
                "operName":"刘田",
                "pptResUrl":"https://office.icourses.cn/op/view.aspx?src=%5C%5C%5C%5C10.3.200.123%5C%5Cppt1%5C%5Ca40993e221836703af349d4314e9a342%2F201311%2F667998_01c8ef355163f20fe188b1176645b93a_20131109094605.ppt",
                "resBelong":"1",
                "resExtName":"ppt",
                "resMediaType":"ppt",
                "resSize":"1184256",
                "resSort":"2",
                "resSortDesc":"演示文稿",
                "schoolId":"2178",
                "title":"集合论与图论课程引言",
                "video":false
            },
            {
                "characterId":"87823",
                "courseId":"6447",
                "fullResUrl":"https://res1.icourses.cn/share/process21/mp4/2018/12/10/2f14fe9f1eee4e83851e4495f122d1d1_SD.mp4",
                "fullResUrl2":"https://res2.icourses.cn/share/process21/mp4/2018/12/10/2f14fe9f1eee4e83851e4495f122d1d1_HD.mp4",
                "id":"635463",
                "mediaType":"mp4",
                "operName":"刘田",
                "resBelong":"1",
                "resExtName":"mp4",
                "resMediaType":"mp4",
                "resSize":"155195040",
                "resSort":"1",
                "resSortDesc":"教学录像",
                "schoolId":"2178",
                "title":"集合论与图论课程引言",
                "video":true
            }
        ]
    },
    "msg":"",
    "status":"200",
    "wrapModel":true
}
json文件信息提取

go语言中没有特别适合json的结构,比如python的dict,因此使用正则表达式直接提取需要的信息,然而go语言正则表达式的部分匹配还是会返回全匹配的内容,因此需要对返回的二维切片进行处理,使之变成一维的字符串切片,代表多个文件的某一特性。定义一个结构体file,由两个数据组成,一个是文件的下载链接,另一个是文件保存的包含路径的文件名。

具体实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func getVideo(id string,parentPath string) []file {
	var files []file
	res, err := http.PostForm("https://www.icourses.cn/web//sword/portal/getRess",url.Values{"sectionId": {id}})
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()
	if res.StatusCode != 200 {
		log.Fatalf("status code error: %d %s", res.StatusCode, res.Status)
	}
	body, err := ioutil.ReadAll(res.Body)
	//匹配模式,json在go中真的不太好处理呀
	//匹配url
	r := regexp.MustCompile(`"fullResUrl":"(.*?)"`)
	//匹配文件名
	t := regexp.MustCompile(`"title":"(.*?)"`)
	//匹配文件类型
	m := regexp.MustCompile(`"resMediaType":"(.*?)"`)

	url:=r.FindAllStringSubmatch(string(body), -1)
	title:=t.FindAllStringSubmatch(string(body), -1)
	fileType:=m.FindAllStringSubmatch(string(body), -1)
	for i:=0;i<len(url);i++{
		//为了生成正确的文件名真的不容易,json中ppt类型的文件实际为pdf类型
		files=append(files,file{url[i][1],parentPath+title[i][1]+"."+strings.Replace(fileType[i][1],"ppt","pdf",-1)})
	}
	return files
}

输入参数调用该函数返回的结果如下:

[{https://res2.icourses.cn/share/process20/pdf/2018/4/20/16/38b360b7-70e1-4b2e-b22a-f9845146edaa.pdf 第1章 集合(教材第一章)/1. 1引言/集合论与图论课程引言.pdf} {https://res1.icourses.cn/share/process21/mp4/2018/12/10/2f14fe9f1eee4e83851e4495f122d1d1_SD.mp4 第1章 集合(教材第一章)/1. 1引言/集合论与图论课程引言.mp4}]

符合之前的要求。

总体来说,得到文件信息的途径分为两类:一是直接从网页得到文件信息,二是从网页得到目录名以及一个独特的ID,再根据ID构造请求得到文件信息。可以将这两种写在一个函数体内,根据条件选择不同的分支。

其它资源信息

其它资源部份http://www.icourses.cn/web/sword/portal/sharerSource?cid=6447
并没有目录结构,直接从html中抓取元素下载即可,可以专门建一个文件夹来保存这些文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func extractOthers(url string) []file{
	data:=httpGet(url)
	doc, err := goquery.NewDocumentFromReader(strings.NewReader(data))
	check(err)
	//需要下载的文件集合
	var files []file
	doc.Find("#other-sources > ul > li > a").Each(func(i int, s *goquery.Selection) {
		fileTitle, _ :=s.Attr("data-title")
		fileType, _ :=s.Attr("data-type")
		fileURL, _ :=s.Attr("data-url")
		files=append(files,file{fileURL:fileURL,filePATH:filepath.Join("其它文件",fileTitle+"."+fileType)})
	})
	return files
}

2.2 下载步骤的实现

目前得到了包含多个文件的数组,每个数组包含文件的下载地址以及文件的保存路径,下载部分需要实现功能的包括:建立下载路径中不存在的文件夹、保存文件到下载路径中、防止重复下载、显示下载进度以及下载速度。

然而高级用法我都还不会实现,就使用一个简单的get请求来下载文件, filepath.Dir获得文件的路径,os.MkdirAll来建立多个目录。关于显示下载进度条的部分,使用https://github.com/schollz/progressbar
包来辅助实现(为了达到理想的显示效果,我把源码部分函数修改了一下)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func downloadFile(url string,filePath string){
	//获取需要下载的文件大小
	dataSize:=getFileSize(url)
	//获取需要写入的信息
	res, err := http.Get(url)
	check(err)
	if res.StatusCode != 200 {
		log.Fatalf("status code error: %d %s", res.StatusCode, res.Status)
	}
	//body, err := ioutil.ReadAll(res.Body)
	defer res.Body.Close()
	//检查文件是否存在

	//检查目录是否存在
	if _, err := os.Stat(filepath.Dir(filePath)); os.IsNotExist(err) {
		//建立目录
		_ = os.MkdirAll(filepath.Dir(filePath), os.ModePerm)
	}

	bar := progressbar.NewOptions(
		int(dataSize),
		progressbar.OptionSetBytes(int(dataSize)),
	)

	// 创建文件
	dest, err := os.Create(filePath)
	if err != nil {
		fmt.Printf("Can't create %s: %v\n", filePath, err)
		return
	}
	defer dest.Close()
	// 将数据存入文件
	out := io.MultiWriter(dest, bar)
	_, _ = io.Copy(out, res.Body)
}

2.3 增加参数选项

这一部分使用flag标准库进行完成,需要设置的命令行参数有

  • 下载的内容(DownloadContent -c)
  • 下载存放的文件夹(outputPath -o)

设置的已定义命令行参数除了上述两个之外还有

  • 显示版本(version -v)
  • 显示使用帮助(help -h)

创建config.go定义以上四个变量

1
2
3
4
5
6
7
8
var (
	//下载文件夹的路径
	OutputPath string
	//下载的内容
	ContentOptions string
	// 显示版本号
    Version bool
)

在version.go中定义版本号const VERSION = "0.0.1"

在main.go中设置参数的初始值

1
2
3
4
5
6
7
8
//初始化相关参数
func init() {
	flag.BoolVar(&config.Version, "v", false, "Show version")
	//all为全部下载,most为视频课件以及试卷,也为下载默认选项,videoPPT仅下载视频和课件,exams为仅下载试卷,resources仅下载其它资源
	flag.StringVar(&config.ContentOptions, "c", "most", "Specify the download content {all,most,videoPPT,assignments,testPaper,shareResource}")
	flag.StringVar(&config.OutputPath, "o", "", "Specify the output path")
	//flag.StringVar(&config.StartUrl, "F", "", "course URL")
}

2.4 下载模块

根据不同的下载选项进行不同的下载方案,具体函数的实现在download.go模块中,此处为了节省篇幅略掉

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func download(url string,options string) bool{
    id:=utils.MatchAll(url,`course_([0-9]*)`)
	if id != nil{
		//得到课程的id地址
		idNum:=id[0][1]
		//fmt.Println(idNum)
        //根据下载选项下载不同的内容
		switch options{
		case "all":
			parser.DownloadAll(idNum)
		case "most":
			parser.DownloadMost(idNum)
		case "videoPPT":
			parser.DownloadVideoPPT(idNum)
		case "assignments":
			parser.DownloadAssignments(idNum)
		case "testPaper":
			parser.DownloadTestPaper(idNum)
		case "shareResource":
			parser.DownloadShareResource(idNum)
			}
	} else{
		//网址不符合格式
		fmt.Printf("this website %s is not supported now",url)
		return true
	}
 return true
}

2.5 项目结构

│  main.go
│
├─config
│      config.go
│      version.go
│
├─download
│      download.go
│
├─parser
│      parser.go
│
└─utils
        utils.go

3. 项目地址

https://github.com/webscrapingproject/icourse-downloader

参考网站

  1. https://github.com/Foair/course-crawler
  2. https://github.com/LiuDianshi/Icourses-Videos-and-PPTs-Download
  3. https://github.com/PyJun/Mooc_Downloader
  4. 解决连不上dl.google.com的问题 https://www.jianshu.com/p/8fb367a51b9f
  5. Go 语言环境安装 https://www.runoob.com/go/go-environment.html
  6. https://golang.org/pkg/net/http/
  7. 示例: 并发的Web爬虫 https://books.studygolang.com/gopl-zh/ch8/ch8-06.html
  8. https://github.com/PuerkitoBio/goquery
  9. goquery-文档 https://www.itlipeng.cn/2017/04/25/goquery-%e6%96%87%e6%a1%a3/
  10. https://github.com/aria2/aria2
  11. https://gobyexample.com/writing-files
  12. https://github.com/schollz/progressbar
  13. flag - 命令行参数解析https://books.studygolang.com/The-Golang-Standard-Library-by-Example/chapter13/13.1.html