玩转 Python 网络爬虫:QQ 音乐全站爬虫开发

--write by zhuwx 2019-06-24 20:58:17 +0800 CST

点击量:34

作者/分享人:Hyx

一、分析说明

现在的音乐类网站仅提供歌曲在线免费试听,如果下载歌曲,往往要收取版权费用,但通过爬虫可绕开这类收费问题,可以直接下载我们所需要的歌曲。

以 QQ 音乐为爬取对象,爬取范围是全站的歌曲信息,爬取方式是在歌手列表下获取每一位歌手的全部歌曲。由于爬取的数量较大,还会使用异步编程实现分布式爬虫开发,提高爬虫效率。

整个爬虫项目按功能分为爬虫规则和数据入库,分别对应文件 music.py 和 music_db.py。

爬虫规则大致如下:

在歌手列表(https://y.qq.com/portal/singer_list.html)中按照字母类别对歌手进行分类,遍历每个分类下的每位歌手页面,然后获取每位歌手页面下的全部歌曲信息。根据该设计方案列出遍历次数:

  1. 遍历每个歌手的歌曲页数。
  2. 遍历每个字母分类的每页歌手信息。
  3. 遍历每个字母分类的歌手总页数。
  4. 遍历 26 个字母分类的歌手列表。

在功能上至少需要实现 4 次遍历,但实际开发中往往比这个次数要多。统计遍历次数,主要能让开发者对项目开发有整体的设计逻辑。项目开发使用模块化设计思想,对整个项目模块的划分如下:

  1. 歌曲下载。
  2. 歌手信息和歌曲信息。
  3. 字母分类下的歌手列表。
  4. 全站歌手列表。

二、歌曲下载

下载歌曲前,先要找到歌曲的相关信息,才能够确定歌曲的下载链接。以 QQ 音乐中的某一首歌曲为例进行介绍(歌曲链接),如图所示。

enter image description here

我们在网页里点击歌曲播放,在新的页面打开谷歌开发者工具,点击 Netword 的 Media 选项卡即可找到歌曲播放文件,如图所示:

enter image description here

歌曲播放文件有很多,但是真正能播放就只有一个,分析 URL 发现,只有一个文件名与其他的不一样的,如图上的 C400003OUlho2HcRHC.m4a,我们将其 URL 复制到浏览器的地址栏访问,发现歌曲是可以播放,如图所示:

enter image description here

从整个歌曲文件的 URL 分析可知,这是一个 GET 请求并设有各种请求参数,如下所示:

http://dl.stream.qqmusic.qq.com/C400003OUlho2HcRHC.m4a?guid=7475275330&vkey=6438F3731278CD2AD8C1A5C3C25723DFD7386607DD790C429B2BF8A2AC75FE4652E348DA94EAC480DFA393AB66FAC6ECE244E48B928D6B10&uin=6153&fromtag=66

那么,要实现歌曲下载,首先需要找到歌曲文件的 URL 请求参数。我们复制某个请求参数,并在其他请求信息里查找这个请求参数,以参数 vkey 的值为例,在每个请求信息的响应内容里查找,最终在 JS 选项卡下找到该参数,如图所示:

enter image description here

从图上发现,purl 的值是歌曲文件 URL 的构成部分,我们只需再加上一个域名即可得到完整的歌曲文件 URL。对于域名的选择,QQ 音乐提供了五个域名,每个域名都可以获取歌曲文件,这是一种集群的管理方式。在图上可以找到具体的域名,如图所示:

enter image description here

我们再分析这个请求的地址,发现 URL 很长,并且有复杂的请求参数。请求参数分为三大类型:

  1. 整个参数可以直接去掉。
  2. 参数值固定不变。
  3. 参数值从其他请求信息获取。

根据参数类型进行分析,将 URL 放到浏览器的地址栏访问,逐一删除某些参数查看响应内容是否发生改变,若没改变,则参数是可以直接去除。此外也发现有两个参数的来源尚不明确,如图所示:

enter image description here

对于尚不明确的参数 guid 和 songmid,参数 songmid 从命名角度看来,这是歌曲的唯一标识值,每首歌曲的 songmid 都是固定的。而参数 guid 是来自 Cookies,这是一种常见的反爬虫机制,并且每个请求都不是有 Cookies。我们将歌曲下载定为函数 download,并设置参数 guid、songmid 和 cookie_dict,分别代表请求参数 guid 和 songmid 和用户的 Cookies 信息,具体代码如下所示:

enter image description here

对于 Cookies 信息的获取,我们需要使用 selenium 来实现,并且要分别访问两次 QQ 音乐才能生成 Cookies。第一次访问 QQ 音乐的网站首页,第二次访问一个 ajax 请求,该请求会生成 Cookies 信息,如图所示:

enter image description here

生成的 Cookies 信息还需要将其转换成字典格式,因此,Cookies 的获取过程如下所示:

enter image description here

现在将函数 getCookies 和 download 结合使用就能实现单首歌曲下载。在 music.py 编写以下代码并运行即可下载歌曲。注意:函数参数 songmid 的获取会在下一章节讲述。

enter image description here

三、歌手和歌曲信息

现在已经实现单首歌曲下载,只要调用函数 download 并传入不同的参数 songmid 即可下载不同的歌曲,在本章节,我们通过歌手页面来获取不同歌曲的 songmid。以周杰伦为例,打开歌手页面 并在开发者工具查找歌曲信息,最终在 JS 选项卡里找到歌曲信息,如图所示:

enter image description here

分析图上请求的 URL,发现某些参数存在一定的规律,比如 singermid 是歌手的唯一标识值,每个歌手都有一个 singermid;begin 是页数,每一页有 30 首歌曲,每次翻页的时候,begin 以 30 进行递增,如第一页为 0,第二页为 30,第三页为 60……以此类推;其余的参数都是固定不变,如图所示:

enter image description here

本章所实现的代码主要针对图上的请求 URL 而进行的。首先获取歌手的总歌曲数量,然后根据总歌曲数来计算页数,最后遍历每一页来获取每首歌曲信息以及歌曲的 songmid 进行歌曲下载。实现代码如下:

enter image description here

函数 get_singer_songs() 用于爬取歌手的全部歌曲,代码说明如下:

  1. 参数 singermid 代表歌手的唯一值,只需要传入不同歌手的 singermid,就能爬取不同歌手的全部歌曲。
  2. 代码有两个相同变量 url,第一个用于动态设置歌手的 singermid,获取歌曲总数和歌手姓名;第二个用于动态设置页数,获取当前歌手每一页的歌曲信息。
  3. 下载歌曲调用已实现的 download() 函数;入库处理是调用入库函数 insert_data(),该函数会在后续章节讲解。

四、分类歌手列表

现在已实现获取单个歌手的全部歌曲信息,只要在此功能的基础上遍历输入不同歌手的 singermid,就能获取不同歌手的歌曲信息。从 Chrome 开发者工具对歌手列表的分析得知,歌手页数有 297 页,每页 80 位歌手,全站的歌手共有 23760 位。

根据项目设计,将循环次数按字母分类划分。在歌手列表页上使用字母 A~Z 对歌手进行分类筛选,利用这个分类功能可以将全部歌手分为两层循环:

  1. 第一层是循环每一个字母分类
  2. 第二层是循环每个分类下的总页数

拆分两层循环主要为异步编程提供切入点,具体实现方式会在后面的章节讲解。首先在网页上单击分类“A”,可在开发者工具的JS标签看到相应的请求信息,如图所示:

enter image description here

分别点击不同的字母分类以及页数,分析每个请求参数,发现请求参数 index、sin 和 cur_page 是有变化规律:

  1. index 代表字母分类 A,从 1 开始,2 代表字母 B,以此类推。
  2. sin 是根据页数计算歌手数量,如第一页为 0,每页 80 位歌手,第二页为 80,第三页为 160,以此类推。
  3. cur_page 代表当前页数,从 1 开始,每页以 1 递增,如第二页为 2。

根据上述分析,本章的功能代码如下所示:

enter image description here

函数 get_genre_singer 是获取单个字母分类的歌手列表,函数参数的说明如下:

  1. index 代表字母的数字,如 1 代表 A,2 代表 B,以此类推。
  2. page_list 代表当前字母分类的总页数。
  3. cookie_dict 代表函数 getCookies 的返回值,即用户的 Cookies 信息。

五、全站歌手列表

现在得到函数 get_genre_singer,我们只需将传入不同的函数参数 index 和 page_list 即可实现 26 个英文字母分类的歌手列表。在此基础上遍历 26 个字母即可实现,将这个遍历定义在函数 get_all_singer,具体的代码如下:

enter image description here

上述代码是整个项目程序的运行入口,程序运行执行函数的顺序如下:

  1. get_all_singer():循环 26 个字母,构建参数并调用函数get_genre_singer()
  2. get_genre_singer(index, page_list, cookie_dict):遍历当前分类总页数,获取每页每位歌手的歌曲信息。
  3. get_singer_songs(singermid, cookie_dict):实现歌手的歌曲入库和下载。
  4. download(guid, songmid, cookie_dict):下载歌曲。
  5. getCookies():使用 Selenium 获取用户的 Cookies
  6. insert_data(song_dict):入库处理。

每个函数之间通过层层的调用来实现整个网站的歌曲下载和信息入库,每次函数调用都会传入不同的函数参数,使得函数之间存在一定的关联。

六、数据存储

在爬虫逻辑功能实现过程中发现数据入库的函数 insert_data(),该函数主要存放在 music_db.py 中,本节使用 SQLAlchemy 实现数据入库。

从爬虫规则分析,入库的数据有歌名、所属专辑、时长、歌曲 mid(下载歌曲文件以歌曲 mid 命名)和歌手姓名。针对所爬取的数据及性质,数据库命名如下:

enter image description here

根据数据库的命名,SQLAlchemy 映射数据库代码如下:

enter image description here

函数 insert_data() 主要对传递的参数 song_dict 进行入库处理,参数 song_dict 为字典格式。函数运行会创建新的数据库连接,创建新数据库连接主要是为异步编程做准备。

上述代码存放在 music_db.py 文件中,在 music.py 中只需导入 music_db.py 的 insert_data() 函数即可实现数据入库。

七、并发库 concurrent.futures

Python 标准库为我们提供了 threading 和 multiprocessing 模块编写相应的多线程 / 多进程代码。从 Python 3.2 开始,标准库为我们提供了 concurrent.futures 模块,它提供了 ThreadPoolExecutor 和 ProcessPoolExecutor 两个类,实现了对 threading 和 multiprocessing 更高级的抽象,对编写线程池 / 进程池提供了直接的支持。

下面通过简单的例子讲解如何使用 concurrent.futures,代码如下:

enter image description here

在上述代码中,创建了进程 ProcessPoolExecutor 和线程 ThreadPoolExecutor,其中在每个进程中又创建了两个线程。

下面简单讲述一下 concurrent.futures 属性和方法。

Executor:Executor 是一个抽象类,它不能被直接使用。为具体的异步执行定义了基本的方法:ThreadPoolExecutor 和 ProcessPoolExecutor 继承了 Executor,分别被用来创建线程池和进程池的代码。

创建进程和线程之后,Executor 提供了 submit() 和 map() 方法对其操作。submit() 和 map() 最大的区别是参数类型,map() 的参数必须是列表、元组和迭代器的数据类型。

Future:可以理解为一个在未来完成的操作,这是异步编程的基础。通常情况下,我们执行 IO 操作和访问 URL 时,在等待结果返回之前会产生阻塞,CPU 不能做其他事情,而 Future 的引入帮助我们在等待的这段时间可以完成其他的操作。

八、分布式爬虫

我们已经知道,爬取全站歌曲信息是按照字母 A~Z 依次循环爬取的,这是在单进程单线程的情况下运行的。如果将这 26 次循环分为 26 个进程同时执行,每个进程只需执行对应的字母分类,假设执行一个分类的时间相同,那么多进程并发的效率是单进程的 26 倍。

除了运用多进程之外,项目代码大部分是 IO 密集型的,那么在每个进程下使用多线程也可以提高每个进程的运行效率。我们知道歌手列表页是通过两层循环实现的,第一层是循环每个分类字母,现将每个分类字母作为一个单独进程处理;第二层是循环每个分类的歌手总页数,可将这个循环使用多线程处理。假设每个进程使用 10 条线程(线程数可自行设定,具体看实际需求),那么每个进程的效率也相对提高 10 倍。

分布式策略考虑的因素有网站服务器负载量、网速快慢、硬件配置和数据库最大连接量。举个例子,爬取某个网站 1000 万数据,从数据量分析,当然进程和线程越多,爬取的速度越快。但往往忽略了网站服务器的并发量,假设设定 10 个进程,每个进程 200 条线程,每秒并发量为 200×10=2000,若网站服务器并发量远远低于该并发量,在请求网站的时候,就会出现卡死的情况,导致请求超时(即使对超时做了相应处理),无形之中增加等待时间。除此之外,进程和线程越多,对运行程序的系统的压力越大,若涉及数据入库,还要考虑并发数是否超出数据库连接数。

根据上述分布式策略,在 music_db.py 中分别添加函数 myThread 和 myProcess,分别代码多线程和多进程:

enter image description here
enter image description here

代码中定义了 myProcess() 和 myThread() 方法函数,分别实现多进程和多线程。

  • 多进程 myProcess() 函数:主要是循环字母 A~Z 以及 #,将每个字母独立创建一个进程,每个进程执行的方法函数是 myThread()。
  • 多线程 myThread() 函数:首先根据传入函数参数获取当前分类的歌手总页数,然后根据得到的总页数和设定的线程数计算每条线程应执行的页数,最后遍历设定线程数,让每条线程执行相应的页数。例如总页数 100 页、10 条线程,每条线程应执行 10 页,第一条线程执行 0~10 页,第二条线程执行 10~20 页,以此类推。线程调用的方法函数是 get_genre_singer()

在实现分布式爬虫的时候,必须注意的是:

  1. 全局变量不能放在 if __name__ == '__main__' 中,因为使用多进程的时候,新开的进程不会在此获取数据。
  2. 使用 SQLalchemy 入库最好重新创建一个数据库连接,如果多个线程和进程共同使用一个连接,就会出现异常。
  3. 分布式策略最好在程序代码的最外层实现。例如在项目中,get_singer_songs() 方法函数里有两个循环,不建议在此使用分布式处理,在代码底层实现分布式不是不可行,只是代码变动太大,而且考虑的因素较多,代码维护相对较难。

总结

本 Chat 以 QQ 音乐为爬取对象,爬取范围是全站的歌曲信息,爬取方式在歌手列表获取每一位歌手的全部歌曲。如果爬取的数量较大,就使用异步编程实现分布式爬虫开发,可提高爬虫效率。读者应重点掌握以下内容:

1. 项目实现的功能

  1. 歌曲下载 download(guid, songmid, cookie_dict):爬虫最底层的功能,也是爬虫最核心的功能。
  2. 歌手和歌曲信息 get_singer_songs(singermid, cookie_dict):将歌手的歌曲信息入库和歌曲下载。
  3. 分类歌手列表 get_genre_singer(index, page_list, cookie_dict):获取单一字母分类的全部歌手和歌曲信息。
  4. 全站歌手列表 get_all_singer():获取全站歌手和歌曲信息。
  5. 用户 Cookies 信息 getCookies():使用 Selenium 获取用户的Cookies。
  6. 数据存储 insert_data(song_dict):将爬取的歌手和歌曲信息入库处理。
  7. 多进程 myProcess():每个字母分类创建一个单独进程运行。
  8. 多线程 myThread(genre):每个进程使用多线程爬取数据。

2. 分布式策略考虑的因素

分布式策略考虑的因素有网站服务器负载量、网速快慢、硬件配置和数据库最大连接量。举个例子,比如爬取某个网站 1000 万数据,从数据量分析,当然进程和线程越多,爬取的速度越快。但往往忽略了网站服务器的并发量,假设设定 10 个进程,每个进程 200 条线程,每秒并发量为 200×10=2000,若网站服务器并发量远远低于该并发量,在请求网站的时候,就会出现卡死的情况,导致请求超时(即使对超时做了相应处理),无形之中增加等待时间。除此之外,进程和线程越多,对程序运行的系统的压力越大,若涉及数据入库,还要考虑并发数是否超出数据库连接数。

3. 实现分布式爬虫的注意事项

  1. 全局变量不能放在 if __name__ == '__main__' 中,因为使用多进程的时候,新开的进程不会在此获取数据。
  2. 使用 SQLalchemy 入库最好重新创建一个数据库连接,如果多个线程和进程共同使用一个连接,就会抛出异常。
  3. 分布式策略最好在程序代码的最外层实现。例如在项目中,get_singer_songs() 方法函数里有两个循环,不建议在此使用分布式处理,在代码底层实现分布式不是不可行,只是代码变动太大,而且考虑的因素较多,代码维护相对较难。

最后,本 Chat 教程是来自本人著作的《玩转 Python 网络爬虫》里面的实战教程。如有问题以及建议,请留下您宝贵的意见,谢谢!!