记一次爬虫遇到的坑,初探分段下载
发布于 7 年前 作者 blackmatch 8867 次浏览 来自 分享

遇到的问题

我去年写了一个爬虫传送门,这个爬虫主要是爬某国外网站,获取下载链接,然后根据下载链接下载对应的视频文件到本地。爬的内容有点不可描述,主要还是为了练手吧(学技术学技术。嗯嗯)。放在github一段时间后,竟然被一个朋友关注到这个爬虫,然后他悄悄地爬视频,某一天他找到我说我的爬虫问题:下载的视频很多是不完整的,比如某个文件下载45%就停止了,不管重新下载多少次都是差不多到45%就停止了。这样每次看视频的时候,只能看到前面一点,把进度条拖动到后面视频就停止播放了,这很影响用户体验啊!!!(咳咳)

排查原因

下载的时候我使用了request模块,我一开始以为是请求参数的问题,然后去github、google查了好久,尝试了在请求头中加Connection: keep-alivegzip: true都不行,折腾了两天未果,暂时扔一边。原来答应朋友要更新版本也没更新。。。

过完年回来,每天会打开github,然后看看当天的trending,偶尔看到有人给我这个爬虫star。感觉还是挺欣慰,于是乎想把这个严重影响用户体验的问题修一修,没有啦,只是想弄清楚是怎么回事(学技术学技术)。

然后开始鼓捣,先爬取某个视频的下载链接,然后把这个链接扔到chrome,打开开发者模式看网络请求,请求头大致如下:

headers.jpg

根据请求头响应头,我没看出有什么端倪,感觉会不会是请求超时了,但是我监听了error方法,没有触发。然后又是一顿google,然后怀疑服务器端是不是allowHalfOpen问题,经过打印socket实例,排除了这个可能,再次陷入僵局。折腾了一番后我有点想放弃使用request了,而是拥抱puppeteer,因为puppeteer毕竟是基于Chromium的,因为我已经确认爬到的下载链接通过浏览器是可以下载的。但是puppeteer目前还没有下载功能,所以问题又回到了原点。

中午吃完饭,番剧都看完了。又想起了这个严重影响用户体验的问题,于是爬一个下载链接,扔chrome开发者模式,不断刷新观察网络情况。刷新了几次,我貌似发现了一些猫腻,先上一张图:

debug.jpg

老铁们看出猫腻了没?可能刚看完番剧心情比较好,我发现在浏览器打开下载链接的时候,同一个下载链接请求了两次。(注:在浏览器直接打开下载链接,会直接在浏览器播放视频)直觉告诉我问题肯定出在这两次请求上,于是分析这两次请求,第一个请求如下:

request1.jpg

第二个请求如下:

request2.jpg

对比两次请求:

  • 第一次请求的Status Code200,第二次请求的Status Code206
  • 第二次请求的响应头里多了一个Content-Range属性。

又是直觉告诉我这个Content-Range有古怪,于是去查找相关资料。官方的解释是:

The Content-Range response HTTP header indicates where in a full body message a partial message belongs.

大致的意思是:Content-Range在响应头中表明本次请求的内容只是一个完整的body的一个部分。也就是说,一个完整的文件被分成了多个部分返回给客户端,所以客户端需要通过多次请求才能完整的接收整个文件。这下问题就清晰了。

解决问题

经过上面的分析,我知道了:我的爬虫程序只进行了上述的第一次请求,得到的Status Code200,而且响应头中并没有Content-Range属性。我一开始想的是,我的爬虫干脆也先后发起两次请求,在第二次请求中获取Content-Range属性进行下一步操作。后来想想这样太麻烦了,应该还有更简单的方法。于是就琢磨了这个方法:发起一次请求,拿到文件的总大小(响应头中的Content-Length),然后根据文件的大小判断要不要分段下载。经过简单的测试,当文件大小超过50M的时候,服务器返回的时候就会分段返回,因为我的爬虫默认下载最高质量的文件,一般是720P(心中无码,自然高清),所以下载的文件很多都是不完整的。

最终敲定方法:如果文件大于20M,就分段下载,否则就直接下载。分段下载是多个文件,等所有文件下载完后,再把这些文件拼接成一个完整的文件,最后把分段下载的文件删掉即可。

于是乎开始改造爬虫,核心代码如下:

const maxChunkLen = 20 * 1024 * 1024; // 20M

if (ctLength > maxChunkLen) {
          log.info('the file is big, need to split to pieces...');
          const rgs = [];
          const num = parseInt(ctLength / maxChunkLen);
          const mod = parseInt(ctLength % maxChunkLen);
          for (let i = 0; i < num; i++) {
            const rg = {
              start: i === 0 ? i : i * maxChunkLen + 1,
              end: (i + 1) * maxChunkLen
            };
            rgs.push(rg);
          }

          if (mod > 0) {
            const rg = {
              start: num * maxChunkLen + 1,
              end: ctLength
            };
            rgs.push(rg);
          }

          const pms = [];
          const files = [];
          rgs.forEach((item, idx) => {
            const copyOpts = _.cloneDeep(opts);
            copyOpts.headers['Range'] = `bytes=${item.start}-${item.end}`;
            copyOpts.headers['Connection'] = 'keep-alive';

            const file = path.join(dir, `${ditem.key}${idx}`);
            files.push(file);
            const pm = new Promise((resolve, reject) => {
              request.get(copyOpts)
                .on('error', err => {
                  reject(err);
                })
                .pipe(fse.createWriteStream(file, { encoding: 'binary' }))
                .on('close', () => {
                  resolve(`file${idx} has been downloaded!`);
                });
            });
            pms.push(pm);
          });

          log.info('now, download pieces...');
          return Promise.all(pms).then(arr => {
            // concat files
            log.info('now, concat pieces...');
            const ws = fse.createWriteStream(dst, { flag: 'a' });
            files.forEach(file => {
              const bf = fse.readFileSync(file);
              ws.write(bf);
            });
            ws.end();

            // delete temp files
            log.info('now, delete pieces...');
            files.forEach(file => {
              fse.unlinkSync(file);
            });

            return resolve(`${dst} has been downloaded!`);
          }).catch(err => {
            return reject(err);
          });
        }

经过改造后,文件都能下载完整了。看视频的时候就可随意拖动进度条了(学技术学技术,嗯嗯):

files.jpg

后记

这个问题断断续续困扰了我好几天,最后还是把这个严重影响用户体验的问题解决了。走了不少弯路,查了一些关于http的东西,也算是有点收获。最重要的是了解了分段下载这个场景,以及Content-Range的用法。需要注意以下几点:

  • 请求时,在请求头中添加Range属性来设置分段下载的文件大小范围,值的格式为:bytes=start-end
  • 文件大小范围start要从0开始,不能从1开始,否则拼接后的文件无法打开。
  • 分段下载时,每一个下载的子进程获取的文件大小范围不要有交叉也不能忽略某部分,必须是连续的。

参考资料

https://emacsist.github.io/2015/12/29/Http-%E5%8D%8F%E8%AE%AE%E4%B8%AD%E7%9A%84Range%E8%AF%B7%E6%B1%82%E5%A4%B4%E4%BE%8B%E5%AD%90/

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range

21 回复

谢谢分享网址

@wangchaoduo 一起学技术学技术。。。哈哈哈

@JacksonTian 感谢朴大点评,在排查这个问题的时候还参考了您的《深入浅出》中关于http那部分的内容。不断努力学习中。

@Sunshine168 谢谢,我对网络这块学得少,不断努力中。

真的很6啊 感恩大大

好人一生平安

给你个6,好像Python的scrapy里,比较好的一个入门项目也是爬的 不可描述hub~ 楼主可以试试xvideos

@Niien 一起学习。

@leizongmin 大佬这画风不太对啊!是我想太多了么? 一起学技术学技术。哈哈哈

@CarlosRen 谢谢!目前做后端比较关注网络和数据库这两块,爬虫只是用来练手,不深究,怕一条道走到黑。哈哈哈

还好看清楚了, 差点就在公司把网址打开了

@liujavamail 你要抱着学习技术的心态打开网址。哈哈

好人一生平安,哈哈哈哈哈哈哈哈哈

@18820227745 哈哈哈。老铁你这回帖的风格好熟悉~~~

碉堡了,最近做断点下载的客户端,帮了大忙

@shuaishenk 666,有空分享一下。

@buffge

全部放到迅雷里面下

这个我有想过,但是程序怎么做到呢?把URL塞给迅雷然后自动下载。求指导

回到顶部