记一次漫画批量的爬取/下载

背景

想找回一部很久之前看的漫画,网上的资源较少,大多都已被封禁,少数能看的网页加载还超级慢,有些图片显示还非常奇怪,影响正常阅读,遂萌生了下载下来在本地看的想法。后来找到一个网页,显示正常,能看,但是速度有点不稳定,于是就选择爬取该网页,将漫画全部下载到电脑上。

实现思路

漫画下载无非就是图片的抓取下载,也算是一种比较简单的爬虫。分析选为抓取来源的网页的 URL,发现其 URL 规律为host/type/漫画的 ID+漫画章节号+第几页,可以按照该规律生成所有漫画每一页的 URL,则可以根据每一个 URL 获取里面的漫画图片下载。进一步分析这些页面上漫画图片的 URL,发现是跳转到一个 ASP 页面进行提供,链接为三级 ID 组成,包括漫画 ID,章节号,页码组成。在浏览器直接访问图片的 URL,发现会跳转到 404 页面。根据开发者调试工具的 Network 栏中拦截的请求,发现图片实则来自另一个 URL,并且图片 URL 的规律十分明显。于是问题就转变成根据规律批量生成图片的 URL 并下载。

具体实现:Python

为了简单,就没有使用任何的轮子。直接使用 requests 库访问这些 URL 获取图片资源,并写入到本地文件中保存。

v1

具体的实现思路大概如下。对于漫画的每一话,先创建目录,然后根据该话的序号和页面生成目标资源 URL,发送 GET 请求获取资源,通过文件写入保存到本地,直到访问的 URL 不存在漫画图片,跳转到 404 页面,此处我们通过判断响应首部的内容长度是否等于 404 图片的大小来判断该话是否结束爬取。根据漫画的总数,对每一话进行下载。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
baseUrl = 'https://www.xxxxxx.com/1234/1234/{0:0>3d}/{1:0>3d}.jpg'

def getChapter(no):
    '''获取并保存第 no 话的漫画'''
    cnt = 1
    os.mkdir(saveDir + str(no))
    req = requests.get(baseUrl.format(no,cnt))
    print(req.status_code, req.headers)
    while req.status_code == 200 and req.headers['Content-Length'] != size404:
        with open(saveDir + str(no) + '/' + str(cnt)+'.jpg','wb') as f:
            f.write(req.content)           
        cnt += 1
        req = requests.get(baseUrl.format(no,cnt))
    return cnt - 1

效果

实在是太慢了!

尝试着下了一话大概花费半到一分多钟左右,这里总共有一百多话,两个多小时肯定是走不掉了,而且还没考虑网络不稳定的因素。考虑了一下其中效率的制约因素,最主要为:

  • 网络请求。发请求获取资源需要传输时间。
  • IO。图片保存到本地需要写入时间。

v2

考虑使用多线程进行并行下载,进而提高速度。虽说 Python 提供的多线程只是伪多线程,实际上还是只能有一个线程被核心处理,但应该还是可以减少其中的等待时间。采用 threading.Thread 对象,将下载任务分成若干个 patch 交由不同的线程完成,每个线程完成 20 话的下载任务。

1
2
3
4
5
6
7
8
def getPatch(start,size,max_chapter):
    for i in range(start,min(size,max_chapter)+1):
        print('save chapter {:0>3d} page {:0>3d}'.format(i,getChapter(i)))

patch = 20
for i in range(7):
    t = threading.Thread(target=getPatch,kwargs={'start':i*patch+1,'size':patch,'max_chapter':136})
    t.start()

效果

并没有提升多少速度。感觉这个多线程并没提高多少并行程度,我开了 7 个线程,但是最开始只创建了 4 文件夹进行下载,在这 4 话中进行调度交替下载。也不知道花了多少时间,下完这 4 话之后,我就强制关掉了,弃掉该方案。

具体实现:Golang

说到多线程,最方便的肯定就是 Go 语言了,直接的关键字支持多线程。于是拾起很久没碰过的 Goland,甚至新电脑上还没安装环境,还需要重新安装 Go 语言,配置环境和开发工具,就下 vscode 的插件都花了点功夫。

实现思路还是同 Python 版本一样,为了简单不使用任何额外的轮子,直接使用 net/http 包进行 http 访问,获取图片,并写入到本地文件。

v3

具体的单话下载代码如下。方法跟 Python 版本的几乎一样,不过 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
func downChapter(no int) {
	page := 1
	for ;; page++ {
		_, err := os.Stat(fmt.Sprintf(saveDir, no))
		if err != nil {
			fmt.Println(fmt.Sprintf(saveDir, no))
			os.Mkdir(fmt.Sprintf(saveDir, no), os.ModePerm)
		}
		res, err := http.Get(fmt.Sprintf(baseUrl, no, page))
		if err != nil {
			fmt.Println(err.Error())
			return
		}
		if res.Header.Get("Content-Length") == size404{
			break
		}
		file, err := os.Create(fmt.Sprintf(saveDir, no) + fmt.Sprintf(postfix, page))
		if err != nil {
			fmt.Println(err.Error())
			return
		}
		io.Copy(file, res.Body)
		defer res.Body.Close()
	}
	fmt.Printf("down chapter %3d with %3d pages\n",no,page)
}

使用 Go 语言最主要就是要用它的多线程特性。在 Go 中只需要在调用函数前加上关键字 Go 就可以开启新的多线程调用函数。将下载任务分成 20 个为一批的多个 patch,开启了 7 个线程进行下载。此处使用 WaitGroup 进行多线程的等待,避免主线程提前结束。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func downPatch(start, patch, maxChapter int) {
	defer wg.Done()
	cnt:=0
	for no:=start;no<=start+patch && no<=maxChapter;no++{
		downChapter(no)
		cnt++
	}
	fmt.Printf("down patch from %3d to %3d total %d",start,start+cnt-1,cnt)
}

func main() {
	for i:=0;i<maxChapter/patch;i++{
		wg.Add(1)
		go downPatch(i * patch,patch,maxChapter)
	}
	wg.Wait()
	fmt.Println("finish!")
}

效果

速度确实提升了,但是感觉没有到很快的程度。

直到写文章的此刻,跑了两个半小时,下了大概 86 话。打开任务管理器看了看情况,CPU 占用率一直很低,磁盘读写占用也很低,感觉瓶颈就在网络传输上面。另外,觉得 7 个线程也开得有点少了,应该多开一点,榨干电脑的性能,而且开多了也不会有很大的浪费。不过似乎制约速率的瓶颈就这网络传输上面,确实没办法。

思考

  • 对于网络爬虫/下载,最为制约效率的因素始终是网络因素,这个也是我们最不能把握的。可能是服务器端的接入速率因素,可能是服务器端的处理计算速度因素,可能是链路的传输速度因素,还可能是墙的因素,有很多的可能性。
  • 在程序刚运行的时候,我想到过多的 http 请求会不会把那个站点搞崩。一百多话,每画平均 25 页,接近三千多张图片,需要发三千多个 http 请求,会不会 over 了,不过在当前这个速度下显然是想多了。不过这在以后的爬虫获取数据或者资源的时候确实需要考虑,为他人想想,可以考虑加点间隔时间。
  • 东西不用了就会忘记,技能确实需要是不是拾起来使使。
  • 有时候问题并不是在选择的方法或工具上面,可能只是简单的自己做错了/做得不够,或者当时的环境不行。

后记

最终因为电脑在休眠的时候自动更新,强行重启了,最终还是没下完,大概下了 120 话,花了 4 个半小时,远远超出我的预期。大概原因为

  • 线程设置有误。原本想设置 7 个线程,但没考虑到 Go 里面除法取整,少了一个线程,最后一个 patch 的任务没有执行,实际是六线程运行。
  • 代码存在一些不合理的地方,造成了操作上的重复。如判断在每一话当中判断文件夹是否存在,我把他放到了循环当中,每下载一页的图片前都会判断;还有设置 patch 的时候没有考虑开闭区间,且下载的时候没有判断文件是否已经下载,导致首尾的漫画重复下载。
  • 后期漫画的图片质量上去了,由前面的一百几十 kb 提升到后面的四五百 kb,所以负责后面漫画下载的线程速度较慢,速度没有达到预期。
  • 网络问题。可能昨天晚上的网确实不行,也可能是频繁访问被制约了网速。

今天早上用 Python 把剩下的十几话下载下来,发现其实昨天的多线程代码有问题,修改了一下,顺利下载,而且速度还不错,我开了 6 个线程,下 18 话用了不到 20 分钟。看来还是网络的问题,难顶。