目录
背景介绍
网站分析
第1步:找到网页源代码
第2步:分析网页源代码
Python 实现
成果展示
后续 Todo
今天这篇文章,3个目的,1个是自己记录,1个是给大家分享,还有1个是向这个被爬网站的前端程序员致敬 —— 就是最近花了不少时间和精力研究,然后终于有所突破的1个Python 爬虫案例。
背景呢是我在B站上找了1个 讲 Scrapy 爬虫框架的视频,就跟着敲代码,跟着他在学。学的过程中就遇到他讲到1个案例 - 爬 aqistudy 这个天气网站的历史数据,他这个视频上传时间是2022年3月,但实际是2019年录制的,反正从他录的视频看,2019年时候这个网站还是很“正常”的。这里的“正常” 是带引号的正常哈,当时虽然也是异步请求页面数据,但是他可以右键,可以F12啥的,然后就是在 Scrapy 的中间件里调用 Selenium 模拟了下页面渲染,就能拿到数据了。
但是但是,各位,现在是2013年了,我打开这个网站一看,简直是天翻地覆,改得程序员他妈都不认识了,2019年那套东西,完全用不了了。可是这又激发了我的兴趣,我们一向是逢山开路遇水架桥,有问题就要解决的,而且正好最近失业赋闲,有大把的时间,那怎么办?搞他!
正式开始前还想交代下,我研究这个就是因为好奇和好玩,面向问题遇到问题解决问题。我不是前端程序员,对于我来说更重要的是搞清楚逻辑,不是具体实现。所以有些地方的解读可能会存在偏差,请大家见谅。
闲话不多说,先直接来看下这个网站。他的数据实际是按照城市,然后每个城市的月份,再然后每个月份的每一天这样来组织的。我们的目的,是想拿到这个网站上提供的所有城市的天维度的历史数据 —— 野心勃勃,动机不良,入门还是入狱,请谨慎操作!!!!
爬虫么,正常思路就是先看网页源代码有没有提供数据,没有的话就找Ajax请求,我们就照着这个思路往下走。
城市列表页面,相对简单,右键查看源代码,可以根据xpath 匹配得到城市名称,以及城市对应的url。这里推荐1个Chrome 插件:xpath helper ,很快很好用。
月数据页面我们不关心,来看日维度的数据页面。天啊,这是什么,非法调试,右键被禁用?F12被禁用? —— 最开始搞到这儿,我是又惊又喜,惊的是 WTF 这怎么搞,喜的是不错哦,有点儿意思 。
然后接下来我就开始各种搜索,怎么破这个限制。有人说Ctrl + shift + i的,有人说 更多工具 -> 开发者工具的,我一试:牛逼,如图 —— 来到了传说中的“无限Debugger” —— 总之就是你按照这样的形式(注意这个定语)样打开开发者工具,他就让你一直在这里循环,页面不会加载,更不会发出什么ajax 请求。
但是这里我有个发现 —— 其实也是反反复复费尽周折无心插柳:网页源代码出来了,就是这个 daydata.php,如果你在开发者工具Page栏里双击这个文件,你看到的是这样样子。我第一次看到这个时,反应时,WTF 不行,看样子对方把我识别为Machine 了,给我这么1个网页。
但是 —— 后面会有无数个但是,忽然有一次我发现这个这个文件右侧有滚动条,可以下拉,于是我就下拉,拉了很久发现全都是空白就要放弃的时候,新大陆出现了 —— 从第639行开始,有html 代码了!!!
然后再往下,有了正常的 css 样式,和月份以及城市列表了。到这儿我知道,我的第一个难关应该已经跨过去了,有这个东西就好说,可以继续。
后来还找到1个查看网页源代码的方法:在浏览器地址栏里输入 view-source:那些被禁用了右键的网页的url 。对,映入眼帘的还是一大片一大片的空白,让你以为你是不是被封印了。
接下来可以看看网页源代码了,刨去那大片留白,其实他的代码很少,但是无论你怎么找,你都不会找到网页上展示的这些数据,你能找到几个包含了空气指标的Table 标签,但是等等等等:页面上展示的只有1个表格,为什么源代码里会有3个 Table 标签,而且各自的表头都不一致???
如果说前面遇到的障碍还不算什么,那这里真的就是我想给网站的程序员点赞的第1个地方。事实上,在后续的代码逻辑中,他确实是根据一些逻辑来在3个里面选择1个加以使用的。代码长下面这个样子 —— 有人会注意到为什么和上面那个图的 Table ID 不一样,因为不是1个时间点也不是同1个城市同1个月份,Table ID 不重要,重要的是逻辑。这段逻辑我命名为 数据获取和展示.代码。—— 因为这这个时间点,我只是存疑,并不知道具体是什么样的逻辑或代码。后面我们会讲到这个 数据获取和展示.代码 是怎么来的
有些跑题,我们最终关心的是怎么拿到原始数据,而不是这些数据在他网站是怎么展示的。继续,网页源代码里没有直接提供原始数据,也看不到发送ajax 请求的地方,那怎么办?调试吧,看他代码到底是怎么执行的。但是前面可以看到已经是无限Debugger了,怎么调试???
于是乎我又开始了艰难的搜索之旅,有人说在Debugger断点设置 条件断点为False,或者Never pause,我甚至尝试了安装油猴插件做js脚本替换。前面2个比较直观,不行就是不行。后面这个可能是我姿势不对,最终也是不行。
但是皇天不负有心人,终于的终于,在1个网站上看到说:网站可能会监控网页窗口的大小,判断是否打开了开发者工具,从而进入无限Debugger。Bingo!牛逼!原来还有这么一招!于是我把开发者工具窗口 undock into separate window,拆分成1个新的窗口。
然后直接在地址栏里输入日历史数据页面的地址。很好,现在页面正常展示了,不再是“检测到非法调试,请关闭调试终端后刷新本页面重试!”,虽然开发者工具看起来还是进入到了无限Debugger。
实际上在这里,如果切换到network 栏,已经可以看到新发出的请求,对就是这个 historyapi.php。headers 里可以看到请求地址,请求方法,请求头。然后最关键的,也是这个案例中最迷人的, payload 和 response 里可以看到的那串长长的看不懂的字符。
有经验的同学应该一看就知道,这一定是有对参数进行加密和对返回解密的过程。但是的但是,我是前端菜鸟啊,我当时就想,WTF 这怎么来的?怎么办,找吧。怎么找?我搜索FormData 里的这个key,No matches found.
怎么办?还是回到起点,调试,一步步执行,看是在哪里生成的吧。于是新的问题来了,开发者工具即使能打开,也是无限 Debugger,没有办法调试。怎么能让代码停下来,在监测窗口大小代码被执行前停下来,甚至在最开始就停下来,受控执行,而不是放任浏览器自己一鼓作气势如破竹一路向西?我灵光一现,想起2021年底研究 js 逆向时设置过页面的鼠标点击事件断点 —— 用于监测点击登录按钮后停下来,那回到这里是不是有些其他条件可以用?—— 咦,怎么有个Script,下面还有个 Script First Statement —— Statement 无论哪种编程语言都会有这东西,脚本第1次被申明的时间点 —— 这会不会就是我梦寐以求(其实是真爱和自由)的东西?
@此时是2023年1月10号凌晨1:45,暂停休息,早起继续。
@现在是2023年1月10号早晨8:32,继续。
接下来的步骤,一定要注意顺序不能错,否则就还是无限Debugger模式。
哇喔,发生了什么?他停下来了,在 daydata.php 这个网页源代码中的第1段 script 的地方,浏览器停了下来,没有进入无限Debugger!!! hhhhhh 请让我仰天大笑五十分钟 —— 这就是调试的作用。
再下来要做的事情,就是按部就班,找到所有的Script 部分,然后打上断点 —— 这个操作事后被证明是多余,因为前面设置了 Script 事件断点,浏览器在每段 Script 的开头一定会停下来,等着你操作。
来逐一审视下这一共5个 Script 代码片段。
第1段这里是去执行 baidu 的这个js 文件,这个文件,他是百度的1个标准的东西,用来生成cookie 啥的,感兴趣的同学可以自行搜索:百度统计。或者进入传送门:百度统计的JS脚本原理分析 另外我们要爬的这个网站,他没有要求我们必须注册才能访问,所以很明显,我们没有必要去关心cookie 是怎么设置的,事实上我们期望每次都是不使用cookie,直接发送请求获取最新数据。所以第1段代码,我们按F8直接运行跳过。
第2段代码,可以看到他是去服务器上请求1个新的js 文件,路径是 “resource/js/jquery.min.js?v=1.11”,这个文件很重要,我们将这段代码逻辑命名为:前置数据编码和解码.代码. 前置是因为后面还有进一步的编码解码逻辑会调用到他。他是在标准的jquery.min.js (路径参数是v1.11,实际是1.10.2)基础上加了一些代码,一共7个部分。
# !/usr/bin/env python3
# _*_ coding:utf-8 _*_
"""
@File : test.py
@Project : Scrapy
@CreateTime : 2023/1/3 14:17
@Author : biaobro
@Software : PyCharm
@Last Modify Time : 2023/1/3 14:17
@Version : 1.0
@Description : None
"""
# 这些是在 能够通过html 页面得到的 jquery.min.js 文件中定义的,目前看来是固定的
encrypt_param = [太多了自己复制粘贴到这里吧]
response_param = [太多了自己复制粘贴到这里吧]
debug_param1 = [太多了自己复制粘贴到这里吧]
debug_param2 = [太多了自己复制粘贴到这里吧]
decode_param = [太多了自己复制粘贴到这里吧]
param_list = encrypt_param
# 列表中的元素依次赋值给6个变量
p, a, c, k, e, d = param_list
# 对应JS代码里的 W() 函数
# 将10进制数 转成 36进制
# 36进制 = 26个字母 + 10个数字
def toString36(number):
num_str = '0123456789abcdefghijklmnopqrstuvwxyz'
if number == 0:
return '0'
base36 = []
while number != 0:
number, i = divmod(number, 36) # 返回 number// 36 , number%36
base36.append(num_str[i])
result = ''.join(reversed(base36))
# print("return from base36_encode() : " + result)
return result
def e_func(c):
if c < a:
x1 = ''
else:
x1 = e_func(c // a)
c = c % a
if c > 35:
# 将Unicode 编码转为一个字符:
x2 = chr((c + 29) & 0xffff)
else:
x2 = toString36(c) # c.toString(36)
return x1 + x2
def update_p(src_p):
regex = r'\b\w+\b'
import re
return re.sub(regex, repl, src_p)
# 作为 re.sub 的第二个参数, repl 只能有1个默认参数 match object
def repl(match):
x = d.get(match.group())
return x
if __name__ == '__main__':
while c:
c = c - 1
d[e_func(c)] = k[c] or e_func(c)
# d type is dict, will be str if after json.dump
# print(type(d), d)
new_p = update_p(p)
print(new_p)
// 第1个参数 mOOJ9grcY: 具体的接口名称,值固定为'GETDAYDATA'
// 第2个参数 oJQns9QgnY: 请求参数,字典形式,包含城市和月份 {city:"蚌埠",month:"201411"}
// 第3个参数 用于处理数据图形化的回调函数,可以忽略
// 第4个参数 值固定为6,用来判断是从本地缓存取数据,还是发送实时请求,可以忽略
function sqYiXA5UVXgiuVRV(mOOJ9grcY, oJQns9QgnY, c6Zyoz4Xr, pgA9wHO) {
// 调用 hex_md5 函数,对接口和参数进行 md5 加密
const kxhi = hex_md5(mOOJ9grcY + JSON.stringify(oJQns9QgnY));
// 判断本地缓存中是否存在数据,以及数据是否过期,返回缓存中的数据或者 null
const dpoaV = gCJUyxOQRicGo55H(kxhi, pgA9wHO);
// 如果本地数据为 null,则发起请求
if (!dpoaV) {
// 对请求方法、参数、以及appid、时间戳等参数组合,然后做 Base64, DES 加密
var pVCQ1id = pkbD4Ulf3(mOOJ9grcY, oJQns9QgnY);
// 发起请求
$.ajax({
url: 'api/historyapi.php',
// data 字典中的key hl5u9DqdT 是解析出来的,pVCQ1id 是前面加密结果
// key 也是每隔10分钟变化,不能一直沿用
data: {
hl5u9DqdT: pVCQ1id
},
type: "post",
success: function(dpoaV) {
// 对数据进行Base64、AES、DES 多轮解密
dpoaV = d8l2LIyh8mFRH8ZZF(dpoaV);
op22Ya = JSON.parse(dpoaV);
// 如果成功拿到数据,则保存到本地1份
if (op22Ya.success) {
if (pgA9wHO > 0) {
op22Ya.result.time = new Date().getTime();
localStorageUtil.save(kxhi, op22Ya.result)
}
c6Zyoz4Xr(op22Ya.result)
} else {
console.log(op22Ya.errcode, op22Ya.errmsg)
}
}
})
// 如果本地数据有效,则直接发给数据图形化回调函数
} else {
c6Zyoz4Xr(dpoaV)
}
}
你说都已经把网站逻辑研究到如此透彻了,实现起来还不是手拿把纂,轻而易举?但实际上在Python 实现上花的时间并没有比研究网站逻辑更少,而且后期还有很多边实现,边回头重新研究的过程,我只能说:行百里者半九十吧,永远不要想当然,细节是魔鬼。Scrapy 用法不是我这篇文章的重点,我只讲爬取逻辑实现好了
这里创建的 Scrapy 爬虫名字叫 aqi_1,cd 进入项目目录,scrapy crawl aqi_1 --nolog 运行爬虫,--nolog 是让Scrapy 不输出中间的调试日志。