在前面的文章我们已经了解到了web端页面中存在的常见反爬手段,以及几个常见的加密算法,那么本篇文章主要就是以一个入门级别的网站作为案例带领大家去分析如何定位到我们所需的参数,以及将其改写为Python中的可执行代码。
一、需求定义
目标网站:aHR0cHM6Ly96aG9uZ2Nob3UubW9kaWFuLmNvbS9hbGwvdG9wX3RpbWUvYWxsLw==
目标数据:任意详情页中的评论信息。
二、页面结构分析
分析页面结构时不要管页面复杂还是简单,直接打开开发者工具然后刷新页面抓包进行分析。
2.1 主页分析
要进入到详情页获取目标数据,那么在主页就需要我们的代码能够提取出详情页的url再进行访问,所以,对于主页来说就要知道详情页的URL在主页是静态存在于页面中还是以异步加载的方式加载在主页。判断方式很多,但是最准确的我们直接抓包之后在开发者工具network中的DOC下找到当前访问页面的HTML文档,如果目标数据存在于该数据包中则证明目标数据在当前页面是静态存在于html文档之中的。如下:
从上图中明显可以看到详情页的url是静态存在于主页,那么我们直接对主页的URL发起请求则能够直接获取到详情页的url。
2.2 详情页分析
以页面中《古蜀华章》为例,点击进入其详情页,待页面加载结束之后再打开开发者工具,然后刷新页面进行抓包。
获取到数据包之后,以同样的方式先判断目标数据的加载方式。也可以通过预览的方式来进行判断。
如图,通过预览发现当前页面中并不存在评论,但是实际上是有8条评论的。这也就意味着评论数据是以异步加载的方式加载于当前页面。所以我们便可以直接定位到XHR进行分析,如下:
定位过来之后,可以发现数据包存在多个,所以要找到目标数据所在的包的话,就需要我们一个一个去打开查看,那如果数据包有几百个上千个呢?所以我们不用逐个查看,直接点击Name然后按CTRL+F从抓到的包中去进行搜索关键字。如下图所示:
点击打开数据包后进一步分析,如下:
然后再看目标输入所在的这个数据包的url
到这里其实就已经很明显了,此处的api便是评论信息的接口,而响应的内容则由携带的参数进行控制,那么在这个url中携带了这么多个参数哪一个才是控制不同的物件的评论呢?此时就需要我们去逐个尝试了,由于在前面的文章已经讲解过如何判断参数的必要性,所以本篇文章中不再详细分析,此处我们需要的正是pro_id,而pro_id的值便可以从详情页的url中进行提取。
如下所示:
到这里,结构分析结束,接下来实现代码。
三、代码实现
3.1 提取详情页url
要提取详情页url,那么我们就需要先对详情页url所在的地址发起访问才能够获取到响应内容从而提取出详情页的url,那么这部分的代码就可以如下进行编写。注意:刚刚已经对网站进行过分析,此处一些常规性的信息就不再截图展示,例如:主页的url获取。
...
def indexDeal(indexurl, headers):
response = requests.get(indexurl, headers=headers) # 访问主页
print(response.content.decode()) # 输出响应内容,是一个HTML
...
那么,要从HTML文档中提取出目标详情页的url的话,便可以使用到xpath来进行提取了,代码如下:
...
def indexDeal(indexurl, headers):
"""主页处理函数"""
response = requests.get(indexurl, headers=headers) # 访问主页
tree = etree.HTML(response.content.decode())
detail_url = tree.xpath('/html/body/div/div[2]/ul/li[2]/div/a/@href')[0] # 解析出详情页的url,此处为古蜀华章,请根据实际情况修改
pro_id = detail_url.split('/')[-1].split('.')[0] # 经过两次分割提取出pro_id,当然也可以直接以切片的形式提取
print(pro_id)
return pro_id
...
现在,详情页url有了,那么就可以直接获取参数pro_id了,当然,不要提取到参数之后要将其携带再去请求。
3.2 请求评论url
在获取到pro_id之后,将其余其他固定参数同时构造然后再去发起请求,代码如下所示。
...
comment_url = 'https://apim.modian.com/apis/mdcomment/get_reply_list' # 获取评论信息的接口
params = {
'mapi_query_time': '0',
'order_type': '2',
'page': '1',
'page_size': '20',
'post_id': '257718',
'pro_class': '101',
'pro_id': None,
'request_id': None
} # 构造请求评论所在url时需要传递的参数
def commentDeal(comment_url, headers, params):
"""获取评论"""
pro_id = indexDeal(indexurl, headers) # 获取pro_id
params['pro_id'] = pro_id # 将pro_id赋值到params中进行带参请求
response = requests.get(comment_url, params=params, headers=headers)
print(response.content.decode())
...
此处执行代码后代码输出结果如下所示:
很明显,返回的结果并不是我们所期望的,那么对于目前已经实现的代码来说从构造的参数也好,访问的url也好,必然都是正确的,但仍然没有返回准确数据,意味着我们已经被服务器的反爬手段给拦截了下来,所以无法获取到目标数据。
那么接下来又应该如何去入手将此反爬解决掉呢?
3.3 反反爬
首先遇到反爬不要惊慌,要根据服务器返回的提示来对应处理,如果服务器的提示无法判断的话那么我们可以从简到繁依次排除。
要知道,爬虫被识别出来,也就意味着访问者的身份出现了问题,所以身份出现问题的话咱们就要把爬虫的身份给伪装好,而伪装身份最基本的就是咱们的cookie、referer、user-agent了。所以,可以先将这些参数携带然后访问测试,当然,在本案例中与上述这些是无关的,所以不再进行演示。
那如果上述的参数都添加了仍然无法获取到相应数据怎么办呢?不要着急,我们来看看正常访问时的请求头先!
上方图片中就是评论接口的请求头信息了,很明显的可以发现除了常规的请求头参数之外,还多了mt和sign两个网站自定义的参数,当然除了这两个之外还有几个自定义的参数,只不过这些参数值为空,所以不必进行分析。
那既然这样的话,我将一个正常请求的参数携带着去访问,是否就能够获取到目标数据了呢?试一试咱就知道啦!上代码!
...
def commentDeal(comment_url, headers, params):
"""获取评论"""
# pro_id = indexDeal(indexurl, headers) # 获取pro_id
headers['mt'] = "1683185500" # 注意参数有时效所以测试的时候记得刷新页面重新获取
headers['sign'] = "796b2bba77430e618bfd527a7ee10f30"
params['pro_id'] = 126320 # 将pro_id赋值到params中进行带参请求
response = requests.get(comment_url, params=params, headers=headers)
print(response.content.decode())
...
要注意,每一个参数都要和浏览器中看到的保持一致。
输出结果如下所示。
很明显,数据出来了,那么也就意味着数据是否能够成功获取,与mt和sign两个参数直接相关,那么接下来我们就要去找到这两个参数在网站中的生成位置,然后改写为python中的代码进行实现。
3.4 参数生成算法分析
参数在请求头中,那么应该如何去找呢?全局搜索肯定是不行了,太慢,而且定位不准确,所以直接从该数据包的栈去找。
在跟的时候,jquery框架中的调用就不用去看了,框架底层的代码几乎不会改变,目标参数也不会在框架中去生成。所以此处我们直接从第三个栈(ajaxApimForM)开始跟,直接鼠标点击即可。
进来后直接就定位到了两个参数的位置,还是非常人性化的。那么接下来就是去打上断点,进一步跟栈看一下这两个参数的生成逻辑具体在什么地方,因为此处只是赋值而不是生成。将鼠标移动在对应代码的行号点击左键就能打上断点。如果断点自动跳到其他靠下的位置也不用理会,直接刷新页面即可。
刷新页面后,页面就在断点处被暂停了,接下来就可以去调试代码了,将鼠标放在我们需要的参数位置,会弹出该代码的相关信息。当然此处并不用,因为此处两个参数是直接通过对象属性来赋值,而不是调用了某一个方法,所以直接来看req对象的生成。
根据变量的作用域范围来看的话,很明显的req就是调用了getSign方法来生成的,那么我们就进一步来到getSign方法中去分析,将鼠标放在getSign方法上,弹出提示框,直接定位到该方法的逻辑。
然后在该方法result处打上断点,之后直接刷新页面。
很明显的,我们就可以看到,result.md就是mt的值,result.goodSign就是sign的值,同样的,其生成逻辑在此处也体现的非常明确,但是要注意,此处的逻辑可能是多个请求通用的,所以我们要弄清楚我们的请求是哪一个,否则可能一样无法成功获取到准确参数。先将其提取出来查看一下。
var hosts = hostAll.replace('http://', '').replace('https://', '');
var apimData = Date.parse(new Date()) / 1000;
...
var appkey = "MzgxOTg3ZDMZTgxO";
var goodSign = hex_md5(hosts + appkey + apimData + decodeURIComponent(query) + hex_md5(decodeURIComponent(props)));
然后回到浏览器,将鼠标放到 result.requestUrl之上,查看一下当前请求的url是否是评论相关。
如果是的话那么继续往下分析,不是的话就下一步直到代码再次断在此处;很明显,hosts就是评论接口删掉协议头,apimData就是当前时间戳除以1000取整,appkey是一个固定值,decodeURIComponent(query)可以在console中输出查看其值为mapi_query_time=0&order_type=2&page=1&page_size=20&post_id=257718&pro_class=101&pro_id=126320&request_id=
,而decodeURIComponent(props)则是一个空字符串,此时我们可以在console中查看。
最后,sign的值就是将hosts拼接上appkey,再拼接上apimData再拼接上decodeURIComponent(query)再拼接上decodeURIComponent(props)进行md5加密后的结果,最后再将拼接的长字符串进一步进行md5加密则获得最终的sign值。
那么将此逻辑写成python代码则如下:
from hashlib import md5
def getData():
hosts = 'apim.modian.com/apis/mdcomment/get_reply_list'
apimData = str(int(time.time())) # 在Python中时间戳精度为10的9次方,所以此处不用再除以1000,直接取整即可,不理解的话可以输出在终端查看
appkey = 'MzgxOTg3ZDMZTgxO'
decodeURIComponent_query = 'mapi_query_time=0&order_type=2&page=1&page_size=20&post_id=257718&pro_class=101&pro_id=126320&request_id='
decodeURIComponent_props = ''
sign = md5((hosts+appkey+apimData+decodeURIComponent_query+md5(decodeURIComponent_props.encode()).hexdigest()).encode()).hexdigest()
mt = apimData
return mt, sign
输出结果为
接下来直接代入headers进行请求
def commentDeal(comment_url, headers, params):
"""获取评论"""
pro_id = indexDeal(indexurl, headers) # 获取pro_id
mt, sign = getData()
headers['mt'] = mt # 注意参数有时效所以测试的时候记得刷新页面重新获取
headers['sign'] = sign
params['pro_id'] = 126320 # 将pro_id赋值到params中进行带参请求
response = requests.get(comment_url, params=params, headers=headers)
print(response.content.decode())
输出结果:
成功!但是有个问题,就是此处只能获取到古蜀华章相关的评论,其他的无法获取,其原因是因为除了pro_id之外还有post_id也是动态变化的,所以只需要将post_id也进行动态获取即可。
post_id的获取在详情页可以直接得到,就不再过多分析
到此,第一个逆向案例结束,完整代码在后面!
四、完整代码
import requests
import time
from lxml import etree
from hashlib import md5
indexurl = 'https://zhongchou.modian.com/all/top_time/all/' # 主页url
comment_url = 'https://apim.modian.com/apis/mdcomment/get_reply_list' # 获取评论信息的接口
params = {
'mapi_query_time': '0',
'order_type': '2',
'page': '1',
'page_size': '20',
'post_id': '257718',
'pro_class': '101',
'pro_id': None,
'request_id': ''
} # 构造请求评论所在url时需要传递的参数
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36'
} # 构造请求头
def indexDeal(indexurl, headers):
"""主页处理函数"""
response = requests.get(indexurl, headers=headers) # 访问主页
tree = etree.HTML(response.content.decode())
detail_url = tree.xpath('/html/body/div/div[2]/ul/li[2]/div/a/@href')[0] # 解析出详情页的url,此处为古蜀华章,请根据实际情况修改
pro_id = detail_url.split('/')[-1].split('.')[0] # 经过两次分割提取出pro_id,当然也可以直接以切片的形式提取
response_detail = requests.get(detail_url,headers=headers) # 访问详情页
tree_detail = etree.HTML(response_detail.content.decode())
post_id = tree_detail.xpath('/html/body/div[3]/div/div[2]/input[2]/@value')[0] # 从详情页提取post_id
return pro_id, post_id
def getData(post_id, pro_id):
hosts = 'apim.modian.com/apis/mdcomment/get_reply_list'
apimData = str(int(time.time())) # 在Python中时间戳精度为10的9次方,所以此处不用再除以1000,直接取整即可,不理解的话可以输出在终端查看
appkey = 'MzgxOTg3ZDMZTgxO'
decodeURIComponent_query = f'mapi_query_time=0&order_type=2&page=1&page_size=20&post_id={post_id}&pro_class=101&pro_id={pro_id}&request_id='
decodeURIComponent_props = ''
sign = md5((hosts+appkey+apimData+decodeURIComponent_query+md5(decodeURIComponent_props.encode()).hexdigest()).encode()).hexdigest()
mt = apimData
print(mt, sign)
return mt, sign
def commentDeal(comment_url, headers, params):
"""获取评论"""
pro_id, post_id = indexDeal(indexurl, headers) # 获取pro_id
mt, sign = getData(post_id, pro_id)
headers['mt'] = mt # 注意参数有时效所以测试的时候记得刷新页面重新获取
headers['sign'] = sign
params['pro_id'] = pro_id # 将pro_id赋值到params中进行带参请求
params['post_id'] = post_id # 将post_id赋值到params中
response = requests.get(comment_url, params=params, headers=headers)
print(response.content.decode())
if __name__ == '__main__':
commentDeal(comment_url, headers, params)