前言
月明星稀,凄神寒骨,独守窗台,
孤灯的残影烙印在远处,
遮住了我的双目,也挡住了去的路,
望着天顶的失落,原来最后的等待,叫未来可期,
正如我许多次半夜里突然转醒,久不能眠,
于是我吃饱没事干,心血来潮,拉下窗帘,
打开网抑云,写个爬虫,去感受最真实的人间~ T_T
相关知识
网页一般有两种渲染方式:
- 服务器渲染
- 客户端渲染
服务器渲染:在服务器那边直接把数据和html整合在一起,统一返回给浏览器
- 在页面源代码中能看到数据
客户端渲染:第一次请求只要一个HTML框架,第二次请求拿到数据,进行数据展示。
- 在页面源代码中,看不到数据
思路
大部分爬虫的思路基本都是一致的:
- 确认内容是否在源代码内
- 若在,则直接通过re,xpath,beautifulsoup等方式提取内容
- 若不在,则定位内容对应的URL,在其中提取内容
在页面源代码看不到数据的情况下,可以使用浏览器F12的开发者工具,利用其中给的网络模块查看整个网页请求数据的过程,并分析出数据所在的URL请求,最后写爬虫访问整个URL即可。
爬虫所用工具
-
Microsoft Edge 94.0.992.31
-
Pycharm 2019.3
-
Python 3.8
Python使用的相关库:
- 内置库
- beautifulsoup4 4.10.0
- lxml 4.6.3
- pycryptodome 3.10.4
- requests 2.25.1
开始实战
本次爬虫以该歌单[戏腔]戏子多秋 可怜一处情深旧 - 歌单 - 网易云音乐 (163.com)为例,进行一步一步的分析。
图1 初始界面
查看热评。
图2 热评
在网页源代码中搜索其中一条热评,发现搜索不到。
图3 搜索热评
因此,启用方案B,打开F12开发者工具,在其中的网络模块找点线索。
图4 开发者工具
刷新网页,并在筛选器中,勾选XHR,发现有多条URL请求。
图5 刷新网页
从第一条请求开始,一条一条往下分析,查看有无相关的评论内容,如第一条URL请求就没有相关评论内容。
图6 未找到评论内容
在get?csrf_token=
这条URL中,发现了评论内容
图7 发现评论内容
这时候可能就会有小伙伴好奇,csrf_token=是啥,咋每条URL都有,这个不用管它,它只有在登录之后才会有值。
这个时候我们已经定位到了评论对应的URL,此时只需要像换个URL发起请求,就能得到我们想要的评论内容。
此时URL为https://music.163.com/weapi/comment/resource/comments/get?csrf_token=
,请求的方式为POST。
图8 定位URL和确定请求方式
往下翻,会发现POST请求时所带的表单数据,参数分别为params和encSecKey。这时就有小伙伴砂岩了,哎呀我的妈,这是啥玩意,全是一堆人类无法理解的玩意。
图9 找到请求参数
不过不要慌,现在我们已经确定了,评论内容就是在该URL返回的结果里,结果是往服务器里发送一个叫params的参数和一个叫encSecKey的参数的POST数据得到的,而这两个玩意的真实内容,是被加密了的。
网易服务器收到请求后,就会将这两个参数解密,得到真实的内容,再像网页返回评论数据。
因此,我们要做的就是想办法找到未加密前的数据,以及找到加密过程,最后在我们的程序里模拟出加密过程,向服务器发送请求,得到结果。
- 找到未加密的参数
- 想办法把参数进行加密(必须参考网易的逻辑),params, encSecKey
- 请求到网易,拿到评论信息
JS逆向
JS逆向主要就是通过调试,分析出JS代码的作用,从而推导出加密过程,最后找出参数的真实含义。
目前加密的方式总结有下面几点:
- 对称加密(加密解密密钥相同):DES、DES3、AES
- 非对称加密(分公钥私钥):RSA
- 信息摘要算法/签名算法:MD5、HMAC、SHA
- 前端实际使用中MD5、AES、RSA,自定义加密函数使用频率是最高的
- 几种加密方式配合次序:采用非对称加密算法管理对称算法的密钥,然后用对称加密算法加密数据,用签名算法生成非对称加密的摘要
- DES、DES3、AES、RSA、MD5、SHA、HMAC传入的消息或者密钥都是bytes数据类型,不是bytes数据类型的需要先转换;密钥一般是8的倍数
- Python实现RSA中,在rsa库中带有生成签名和校对签名的方法
总之,加密工作主要是在前端进行,因此我们可以在浏览器中分析出加密过程。
点击菜单栏里的“发起程序”,有的浏览器叫“启动器”,就会看到发送请求的时候,一共经历的JS脚本过程,也就是请求调用的堆栈,从下往上排列,最开始执行的在下面。
图10 请求调用堆栈
我们从最上面的开始调试,点击之后,页面会跳转到其中的一行代码,也就是说程序执行完这行代码后,请求就被发出。
图11 开始调试
在这一行设置断点,然后重新刷新页面,然后查看当前变量的数据,和当前请求的URL。
图12 设置断点
发现URL不是我们目标的URL,于是点击“恢复执行脚本”,放过本次的拦截,直至URL为目标URL为止。
图13 放过拦截
图14 到达目标
在这里发现了被加密的数据,在进入该函数之后,从箭头位置往下走,参数被加密了,所以我们要去看在进入该函数之前,参数是否被加密。
图15 发现加密数据
于是从调用堆栈这里,一步一步点击往下找。
图16 往下寻找
观察参数,直至发现加密前的参数,从而找出的相应的加密过程。
图17 加密前
图18 加密后
因此,判断在这段函数期间,参数被加密了。
图19 找到加密过程
范围已经被确定,因此可以从该函数的第一行开始设置断点,并点击“单步跳过下一个函数调用”,去观察参数究竟在哪一行代码中被加密了。
图20 断点调试
经过调试,发现加密过程是在这一行代码中,加密函数为window.asrsea
。
图21 发现加密函数
此时,我们将该函数名拿去搜索,会发现只有两个地方出现该函数,一个是刚才的地方,另一个如图所示,这一行window.asrsea = d
代码的意思就是,将一个叫d的函数赋给了window.asrsea,也就是说d函数和window.asrsea函数是同一个函数。
图22 搜索加密函数
接下来的加密过程就与下面出现的函数有关。
图23 相关的加密函数
JS解密
到目前为止,我们已经进一步缩小了加密过程的范围,并确定了加密与上四个函数有关,我们采用逆推的思路,因此,我们先从d
函数来开始分析:
d
函数其实就是window.asrsea
函数,其中window.asrsea
所需的参数如下:
window.asrsea = d
//。。。
var bKf6Z = window.asrsea(JSON.stringify(i8a), bva3x(["流泪", "强"]), bva3x(Tu8m.md), bva3x(["爱心", "女孩", "惊恐", "大笑"]));
分别是:
- i8a
- bva3x([“流泪”, “强”])
- bva3x(Tu8m.md)
- bva3x([“爱心”, “女孩”, “惊恐”, “大笑”])
i8a其实就是加密前的参数data,也就是:
{
cursor: -1
offset: 0
orderType: 1
pageNo: 1
pageSize: 20
rid: "A_PL_0_2217610700"
threadId: "A_PL_0_2217610700"
}
bva3x是一个新出现的函数,看着参数的值也很奇怪,但是不用管它,直接丢进控制台里面运行得到结果,可以多运行几遍,确认结果是否是随机的。如果运行显示函数未定义,需要将调用堆栈切换到加密后的那个堆栈中。
图24 新函数的运行结果
因此,现在能够确定d
函数其中的三个参数是固定的,会变的只有data数据,我们继续分析:
var h = {}
创建一个 空对象,暂时没什么用,
i = a(16)
调用a函数,并将返回值赋给i
那么a
函数是干嘛的呢?我们继续分析:
function a(a) { // 参数传为16
var d, e, b = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", c = "";
for (d = 0; a > d; d += 1) // 循环16次
e = Math.random() * b.length, // 随机数
e = Math.floor(e), // 取整
c += b.charAt(e); // 取在字符串b中的XXX位置
return c // 产生16位随机的字母或数字
}
a
函数的过程也没什么好说的,就是产生一个16位的随机数,此时我们将a
函数丢进控制台里面运行或者设置断点查看变量值,就能得到结果,虽然是个随机数,但是我们可以将这个结果固定住,不管这个值怎么变化,我们固定住的这个结果永远都是正确的。
图25 i的值
回到d
函数,我们接着往下分析:
function d(d, e, f, g) { // d:数据,e:010001,f:很长的定值,e:定值
var h = {} // 空对象
, i = a(16); // i就是一个16位的随机值,我们可以把i设置成定值
return h.encText = b(d, g),
h.encText = b(h.encText, i),
h.encSecKey = c(i, e, f),
h
}
看着上面的返回值长得那么奇怪,其实就是:
function d(d, e, f, g) { // d:数据,e:010001,f:很长的定值,e:定值
var h = {} // 空对象
, i = a(16); // i就是一个16位的随机值,我们可以把i设置成定值
h.encText = b(d, g),
h.encText = b(h.encText, i),
h.encSecKey = c(i, e, f),
return h
}
我们先从h.encSecKey = c(i, e, f)
这里开始分析,因为这部分比较简单,
i
我们已经固定了,现在是个定值,e
也是定值,f
也是定值,同时,c
函数内没有产生随机数的函数,所以说c
函数的返回值也是个定值,c
函数是怎么运行的我们就不用管它了。将c
函数丢进控制台里面运行或者设置断点,即可查看变量值
接下来分析h.encText = b(d, g)
,其中d
就是我们的data数据,g
是个定值,因此返回的结果与d
有关,因为我们请求的数据是不一样的例如获取不同歌单的评论
b
函数是个加密数据的函数,其中涉及到了AES加密:
高级加密标准(AES,Advanced Encryption Standard)为最常见的对称加密算法(微信小程序加密传输就是用这个加密算法的)。
对称加密算法也就是加密和解密用相同的密钥。
对称加密算法:
加密和解密用到的密钥是相同的,这种加密方式加密速度非常快,适合经常发送数据的场合。缺点是密钥的传输比较麻烦。
非对称加密算法:
加密和解密用的密钥是不同的,这种加密方式是用数学上的难解问题构造的,通常加密解密的速度比较慢,适合偶尔发送数据的场合。优点是密钥传输方便。常见的非对称加密算法为RSA、ECC和EIGamal。
实际中,一般是通过RSA加密AES的密钥,传输到接收方,接收方解密得到AES密钥,然后发送方和接收方用AES密钥来通信。
我们只有分析出该函数的执行才能,才能使用python对爬虫所请求的参数进行加密
其中出现最多的CryptoJS.enc.Utf8.parse()
的作用是从UTF8编码解析出原始字符串
从这一行代码能看出,f = CryptoJS.AES.encrypt(e, c, { iv: d, mode: CryptoJS.mode.CBC })
,这是执行AES加密的函数,e
也就是a
是加密的数据,c
也就是b
是加密的密钥,iv
也就是d
是加密所需的偏移量,加密的模式为CBC
function b(a, b) { // a是要加密的内容
var c = CryptoJS.enc.Utf8.parse(b) // b是密钥
, d = CryptoJS.enc.Utf8.parse("0102030405060708")
, e = CryptoJS.enc.Utf8.parse(a) // e是数据
, f = CryptoJS.AES.encrypt(e, c, { // c是加密的密钥
iv: d, // 偏移量
mode: CryptoJS.mode.CBC // 加密模式:CBC
});
return f.toString()
}
d
函数最后是返回了两个值,分别是encText
和encSecKey
,其实分别对应了data参数里的params
和encSecKey
图26 data参数
至此,我们基本就分析得出了参数加密的整个过程,现在梳理一下:
- 通过网络模块,定位到URL,发现参数加密
- 通过调试JS代码,定位到加密过程,锁定加密函数
- 逐步分析代码作用,得到加密原理
PS.可能不同时候进行分析,看到的变量名不一样,但是过程和原理是一样的
Python实现
还原加密过程
我们使用python来还原参数的加密过程:
先将所固定的值列出来:
# 服务于d函数的
e = '010001'
f = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
g = '0CoJUm6Qyw8W8jud'
i = 'nYXLafpydDFlqRNh' # 手动固定,人家的是随机的
首先,还原d
函数中的encSecKey
变量,该变量实际也是个定值
# 由于i,e,f固定,那么c函数结果固定
def get_encSecKey():
return "8f2960e5fa10ec2f643aa6a9f76f6b40f85dc4e0f7cfadc70370991ffa3234b08987d5f684619660448a8f0880dbc34436011b1f5b1091d1de4b448acc8ae259d71f84573229ade8ed9894ea55ebbfb6cd1a92e827c93ae14f5af34bdd994c004286dfa3fee40c12cf1d9da5cc3a33313a9f6b19cb10f1eb28d45d9cb8933590"
其次,还原d
函数中的encText
变量,该变量实际就是进行两次加密得到的
# 把参数进行加密
# 其中g, i是定值
def get_params(data): # data为json字符串
first = enc_params(data, g)
second = enc_params(first, i)
return second
接下来,我们还原b
函数的加密过程,由于AES加密的规则,加密的明文的长度必须为16的倍数,且不满足则需补充至16位,补充的内容为char(所差的长度)
如:1234567890
则补充6位的char(6)
,
即1234567890char(6)char(6)char(6)char(6)char(6)char(6)
加密过程如下,注意字节和字符串的转换:
# 加密需要用到两个库
from Crypto.Cipher import AES
from base64 import b64encode
# 转化成16的倍数,为下方加密算法服务
def to_16(data):
pad = 16 - len(data) % 16
data += chr(pad) * pad
return data
# 加密过程
def enc_params(data, key):
iv = '0102030405060708'
data = to_16(data)
# 创建加密器
aes = AES.new(key=key.encode('utf-8'), iv=iv.encode('utf-8'),mode=AES.MODE_CBC)
# 加密,加密的内容的长度必须是16的倍数,AES加密的逻辑
bs = aes.encrypt(data.encode('utf-8'))
# bs的结果不能直接转换成字节,需要先转换成base64
return str(b64encode(bs), 'utf-8') # 转换成字符串返回
PyCryptodome是python一个强大的加密算法库,可以实现常见的单向加密、对称加密、非对称加密和流加密算法。直接pip安装即可:
pip install pycryptodome
爬虫实现
搞了半天,终于到了真正爬虫的环节!淦
经过了上述的解密过程,接下来的爬虫其实就很容易了,只需提交参数,找到评论内容输出即可
data
中的参数有很多,其中
-
pageNo:当前评论的页数
-
pageSize:一页评论的数量
-
rid和threadId:歌单的ID或一首歌的ID
歌单的ID格式一般为
A_PL_0_XXXXXXXXXX
一首歌的ID格式一般为
R_SO_4_XXXXXXXXXX
不仅是歌单的评论,一首歌的评论也能爬取哦
data = {
"csrf_token": "",
"cursor": "-1",
"offset": "0",
"orderType": "1",
"pageNo": "1",
"pageSize": "20",
"rid": "A_PL_0_2022186054",
"threadId": "A_PL_0_2022186054"
}
接下来就以某一歌单为例,爬取评论
import requests
import json
# 发送请求得到评论
resp = requests.post(url,data={
'params':get_params(json.dumps(data)),
'encSecKey':get_encSecKey()
})
resp_data = json.loads(resp.text)['data']
# 获取热评
hotComments = resp_data['hotComments']
if hotComments == None:
print('数据为空')
exit()
lengh = len(hotComments)
# 保存用户名和评论
user_name = []
comments = []
# 获取用户名
for i in range(lengh):
user_name.append(hotComments[i]['user']['nickname'])
# 获取评论内容
for i in range(lengh):
comments.append(hotComments[i]['content'])
# print(user_name)
# print(comments)
# 输出评论
for i in range(lengh):
print(user_name[i], '说:', comments[i])
print('-'*20)
脚本的爬取的评论和在浏览器看到的评论,是一致的:
图27 爬取评论
图28 评论
爬取网抑云的评论,成功实现!史前巨感动
扩展
网抑云的评论爬取到了,也可以爬爬歌单名和简介这些
from bs4 import BeautifulSoup
# 获取歌单名和介绍
url_music = 'https://music.163.com/playlist?id=2022186054'
resp = requests.get(url_music)
html = resp.text
soup = BeautifulSoup(html,'lxml')
# 获取歌单标题
title = soup.title.string
# 获取歌单介绍
introduce = soup.find(name='p',attrs={'id':'album-desc-more'})
print(title)
print(introduce.text)
结果如下:
图29 歌单简介
可扩展的方面还有很多,例如:
- 爬取歌词
- 爬取歌单里的歌名
- 爬取首页的歌单或单曲的链接,使用多线程技术对不同的歌单评论进行爬取
- 。。。
由于写文章 + 贴图史前巨尼玛麻烦,这里就不继续展示了,有兴趣的小伙伴可以自己动手试试哦,网易的网站分析过程基本都是这样。
当然,去爬爬B站啥的也可以
总结
本次的爬虫之旅到此结束,爬虫本身不难,由于网站的反爬虫机制,难就难在要分析各种参数的加密过程,不管怎么说,本次爬虫的收获还是挺大的(光是在这吹水就吹了很久)
完整代码
分析过程 + 爬虫完整代码,如下:
# 1. 找到未加密的参数
# 2. 想办法把参数进行加密(必须参考网易的逻辑) ,params => encText, encSecKey => encSecKey
# 3. 请求到网易,拿到评论信息
# pip install pycryptodome
from Crypto.Cipher import AES
from base64 import b64encode
from bs4 import BeautifulSoup
import requests
import json
url = "https://music.163.com/weapi/comment/resource/comments/get"
# 请求方式是POST
data = {
"csrf_token": "",
"cursor": "-1",
"offset": "0",
"orderType": "1",
"pageNo": "1",
"pageSize": "20",
"rid": "A_PL_0_2217610700",
"threadId": "A_PL_0_2217610700"
}
# 单曲歌 R_SO_4_1313118277
# 歌单 "A_PL_0_2022186054"
# 服务于d函数的
e = '010001'
f = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
g = '0CoJUm6Qyw8W8jud'
i = 'nYXLafpydDFlqRNh' # 手动固定,人家的是随机的
def get_encSecKey(): # 由于i,e,f固定,那么c函数结果固定
return "8f2960e5fa10ec2f643aa6a9f76f6b40f85dc4e0f7cfadc70370991ffa3234b08987d5f684619660448a8f0880dbc34436011b1f5b1091d1de4b448acc8ae259d71f84573229ade8ed9894ea55ebbfb6cd1a92e827c93ae14f5af34bdd994c004286dfa3fee40c12cf1d9da5cc3a33313a9f6b19cb10f1eb28d45d9cb8933590"
# 转化成16的倍数,为下方加密算法服务
def to_16(data):
pad = 16 - len(data) % 16
data += chr(pad) * pad
return data
def enc_params(data, key): # 加密过程
iv = '0102030405060708'
data = to_16(data)
aes = AES.new(key=key.encode('utf-8'), iv=iv.encode('utf-8'),mode=AES.MODE_CBC) # 创建加密器
bs = aes.encrypt(data.encode('utf-8')) # 加密,加密的内容的长度必须是16的倍数,AES加密的逻辑
# bs的结果不能直接转换成字节,需要先转换成base64
return str(b64encode(bs), 'utf-8') # 转换成字符串返回
# 把参数进行加密
def get_params(data): # data为json字符串
first = enc_params(data, g)
second = enc_params(first, i)
return second
# 处理加密过程
'''
function a(a) { # 参数传为16
var d, e, b = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", c = "";
for (d = 0; a > d; d += 1) # 循环16次
e = Math.random() * b.length, # 随机数
e = Math.floor(e), # 取整
c += b.charAt(e); # 取在字符串b中的XXX位置
return c # 产生16位随机的字母或数字
}
function b(a, b) { # a是要加密的内容
var c = CryptoJS.enc.Utf8.parse(b) # b是密钥
, d = CryptoJS.enc.Utf8.parse("0102030405060708")
, e = CryptoJS.enc.Utf8.parse(a) # e是数据
, f = CryptoJS.AES.encrypt(e, c, { # c是加密的密钥
iv: d, # 偏移量
mode: CryptoJS.mode.CBC # 加密模式:CBC
});
return f.toString()
}
function c(a, b, c) {
var d, e;
return setMaxDigits(131),
d = new RSAKeyPair(b,"",c),
e = encryptedString(d, a)
}
function d(d, e, f, g) { d:数据,e:010001,f:很长的定值,e:定值
var h = {} # 空对象
, i = a(16); # i就是一个16位的随机值,我们可以把i设置成定值
return h.encText = b(d, g),
h.encText = b(h.encText, i),
h.encSecKey = c(i, e, f),
h
}
上面那部分相当于
h.encText = b(d, g), # g是密钥
h.encText = b(h.encText, i), # 得到的就是params i也是密钥
h.encSecKey = c(i, e, f), # 得到的就是encSecKey,e和f是定死的,如果此时把i固定,c函数返回的值也是固定的
return h
window.asrsea = d
.....
var bKf6Z = window.asrsea(JSON.stringify(i8a), bva3x(["流泪", "强"]), bva3x(Tu8m.md), bva3x(["爱心", "女孩", "惊恐", "大笑"]));
bva3x(["流泪", "强"])运算结果为:010001
bva3x(Tu8m.md)运算结果为:00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7
bva3x(["爱心", "女孩", "惊恐", "大笑"])运算结果为:0CoJUm6Qyw8W8jud
'''
# 发送请求得到评论
resp = requests.post(url,data={
'params':get_params(json.dumps(data)),
'encSecKey':get_encSecKey()
})
resp_data = json.loads(resp.text)['data']
# 获取热评
hotComments = resp_data['hotComments']
if hotComments == None:
print('数据为空')
exit()
lengh = len(hotComments)
# 保存用户名和评论
user_name = []
comments = []
# 获取用户名
for i in range(lengh):
user_name.append(hotComments[i]['user']['nickname'])
# 获取评论内容
for i in range(lengh):
comments.append(hotComments[i]['content'])
for i in range(lengh):
print(user_name[i], '说:', comments[i])
print('-'*20)
# 获取歌单名和介绍
url_music = 'https://music.163.com/playlist?id=2022186054'
url_music = 'https://music.163.com/playlist?id=2217610700'
resp = requests.get(url_music)
html = resp.text
soup = BeautifulSoup(html,'lxml')
# 获取歌单标题
title = soup.title.string
# 获取歌单介绍
introduce = soup.find(name='p',attrs={'id':'album-desc-more'})
print(title)
print(introduce.text)
参考文献
https://blog.csdn.net/weixin_41173374/article/details/103474801
https://blog.csdn.net/qq_28205153/article/details/55798628
https://blog.csdn.net/weixin_30347335/article/details/99123821
。。。。。。