浏览器调试常用技巧
面板介绍
Elements/元素面板:
- 用于查看或修改当前网页HTML节点的属性、CSS属性、监听事件等。
- HTML和CSS都可以即时修改和即时显示。
Console/控制台面板:
- 用于查看调试日志或异常信息。
- 还可以在控制台输入JavaScript代码,方便调试。
Sources/源代码面板:
- 用于查看页面的HTML文件源代码、JavaScript源代码、CSS源代码。
- 还可以在此面板对JavaScript代码进行调试,比如添加和修改JavaScript断点,观察JavaScript变量变化等。
Network/网络面板:
- 用于查看页面的加载过程中的各个网络请求,包括请求、响应等。
Performance/性能面板:
- 用于记录和分析页面在运行时的所有活动,比如CPU占用情况、呈现页面的性能分析结果。
Memory/内存面板:
- 用于记录和分析网站加载的所有资源信息,如查看内存占用变化,查看JavaScript对象和HTML节点的内存分配。
Application/应用面板:
- 用于记录网站加载的所有资源信息,如存储、缓存、字体、图片等,同时也可以对一些资源进行修改和删除。
Lighthouse/审核面板:
- 用于分析网络应用和网页,收集现代性能指标并提供对开发人员最佳实践的意见。
查看节点事件
测试网站:https://spa2.scrape.center/
(右击检查翻页按钮)打开元素面板,可以查看当前页面的HTML源码,以及任意内容对应的页面源代码。
- 点开右侧的样式(styles)选项卡,可以看到对应节点的CSS样式。
- 点开右侧的计算样式(Computed)选项卡,可以看到当前节点的盒子模型以及当前节点最终计算出的CSS样式。
- 点开右侧的事件监听器(Event Listeners)选项卡,可以看到各个节点当前已经绑定的事件(都是JavaScript原生支持的):
- change:HTML元素改变时会触发的事件
- click:用户点击HTML元素时会触发的事件
- mouseover:用户在一个HTML元素上移动鼠标时会触发的事件
- mouseout:用户从一个HTML元素上移开鼠标时会触发的事件
- keydown:用户按下键盘按键时会触发的事件
- load:浏览器完成页面加载时会触发的事件
通常会给按钮绑定一个点击事件(处理逻辑由JavaScript定义)——点击按钮,对应的JavaScript代码便会执行。
例:选中切换到第二页的节点,右侧的事件监听器选项卡中就会看到它绑定的事件。
chunk-vendors.77daf991.js:7表示对应事件所在的文件及行数,点击就能跳转到Sources面板下,对应文件的对应位置(这时的代码往往被压缩过,可读性很差,不过Sources面板提供了代码美化的功能,点击左下角的“{}”就能用)。
断点调试
- 在需要的位置上打断点(点击行号,出现蓝色箭头),对应事件触发时,浏览器就会自动停在断点的位置等待调试;
- 然后选择单步调试,就能在面板中观察调用栈、变量值,更好地追踪对应位置的执行逻辑。
例:
- 做断点(行号处出现箭头,右侧的Breakpoints断点选项卡更新断点列表)
- 已知该断点是用来处理翻页按钮的点击事件的,在页面中点击第2页的按钮,就能触发断点机制了
- 此时页面中会显示一个“已在调试程序中暂停”(Paused in debugger)的提示——浏览器执行到断点处就不再执行
- 代码停在了第4446行,回调参数e对应的点击事件是PointerEvent(在右侧的Scope/作用域面板中,可以看到各个变量的值,比如在Local/本地域下有当前方法的局部变量,还可以看到变量的各个属性)
- 这里可以关注到一个方法o。在作用域下,除了本地域,还有一个Closure闭包域(Jr),在这里可以看到o的定义及其接收的参数
- 可以在Watch/监视面板中添加o.apply方法(展开后再点击FunctionLocation就能定位其源码的位置)
- 可以切换到Console/控制台面板,输入任意JavaScript代码,测试执行、输出的对应结果(如:查看变量arguments的第一个元素是什么,直接敲入arguments[0])只要是当前上下文能够访问到的变量都可以直接引用并输出
- 源代码面板中的四个重要的按钮(都可以做单步调试,但功能不同):
- 跳过下一个函数调用(逐语句执行,用得最多),快捷键:F10
- 进入下一个函数调用(进入方法内部执行),快捷键:F11
- 跳出当前函数(跳出当前方法),快捷键:shift+F11
- 单步调用,快捷键:F9
观察调用栈
在调试过程中,点击F10跳到一个新的位置时,可以通过查看右侧的调用堆栈/Call Stack面板查看全部的调用过程(当前在ct方法中,上一步是ot,再上一步是pt,点击就能跳转到对应的代码位置)
有时候非常有用,可以回溯某个逻辑的执行流程,从而快速找到突破口。
恢复JavaScript执行
点击蓝色按钮可以【继续执行脚本】(快捷键,F8)。浏览器会直接执行到下一个断点的位置,如果没有其他断点,浏览器就会恢复正常状态。
Ajax断点
除了通过DOM节点的监听器(Listener)手动设置断点来进行调试之外,还可以通过Ajax断点方法——可以在Ajax请求的时候触发断点。
例:
- 在【网络】面板中查看Ajax请求的逻辑(这里是点击翻页按钮2,看到内容是从这个URL请求回来的)
- 把之前的断点全部取消,切换到【Sources/源代码】面板中,展开XHR/fetch Breakpoints(XHR/提取断点),点击+按钮,我们这里填写如:/api/movie——截取自前面URL的部分路径
- 再次点击翻页按钮3,触发第三页的Ajax请求,会发现点击之后页面走到断点停下来了。
- 从【源代码】面板可以看到,代码停在了Ajax最后发送的时刻,即底层的XMLHttpRequest的send方法——d就是XMLHttpRequest,在页面源码中调用了它的send方法;
- 在【调用堆栈】中往回找,会找到包含limit、offset、token这三个参数的onFetchData方法;
- 取消断点:在【XHR/提取断点】选项卡中取消断点勾选即可
改写JavaScript文件
原理:JavaScript是从对应服务器上下载下来并在浏览器执行的。
无效修改:
直接在浏览器中的JavaScript文件内修改,加入的代码一刷新就没有了
有效修改:
- 借助浏览器插件ReRes,或者代理服务器Charles、Fiddler等
- 借助浏览器的原生开发者工具——Overrides/替换:
- 根据Ajax断点方法,找到对应的构造Ajax请求的位置,并判断出回调方法接收的参数a中包含了Ajax请求的结果
- 现在目标是要在Ajax请求成功获得响应的时候,在控制台输出响应的结果;
- 进入替换面板,点击+按钮,新建并选定文件夹ChromeOverrides,用于存储所有我们想要更改的JavaScript文件;
- 将2中定位到的JavaScript文件整页(美化后的)复制下来,在本地文本编辑器加入代码,然后再复制回该JavaScript文件中(美化前的原文件):
- 此时可以取消所有断点,然后刷新页面,在控制台中就能看到输出的响应结果了:成功将变量a输出,其中data字段就是Ajax的响应结果,而且刷新后也不会失效
- 后续可以再加上一些JavaScript逻辑,比如将变量a的结果通过API发送到远程服务器,并通过服务器将数据保存下来,也就完成了直接拦截Ajax请求并保存数据的过程了。
JavaScript Hook的使用
Hook技术(钩子技术),在程序运行的过程中,对其中的某个方法进行重写,在原先的方法前后加入我们自定义的代码。
Tampermonkey(“油猴”浏览器插件),可以在浏览器加载页面时自动执行某些JavaScript脚本。只要功能能够用JavaScript实现,Tampermonkey就能做到,比如自动爬虫、自动修改页面、自动响应事件,可以应用到JavaScript逆向分析中,来分析一些JavaScript加密和混淆代(开发文档:Tampermonkey • 文档)
例:
- 用账号+密码登录:https://login1.scrape.center/
- 利用网络面板,发现在点击“登录”按钮的时候,会向:https://login1.scrape.center/,发起了一个POST请求,内容是一串token(类似Base64编码),所以能够得知网站会将账号密码经过编码提交到服务器进行验证;
- 打开源代码面板查看页面代码,发现代码都是经过混淆的,此时要查找到token的生成位置,有两种方法:
Ajax断点
- 由于这个请求刚好是一个Ajax请求,所以可以通过设置XHR断点来监听(匹配内容填写域名即可:login1.scrape.center)
- 再次登录,断点生效,在堆栈信息中可以一步步找到编码的入口——其实在onSubmit方法那里
- 经过观察,这里断点的栈顶还包括一些Promise相关的内容,真正想找的是对用户名和密码进行处理,再进行Base64编码的地方,这些请求和调用实际上和找寻的入口没有很大的关系(??)
Hook
背景知识:在JavaScript中,Base64编码是通过btoa方法实现的,因此这里需要Hook btoa方法。
- 新建一个Tampermokey脚本
-
// ==UserScript== // @name HookBase64 // @namespace https://login1.scrape.center/ // @version 0.1 // @description Hook Base64 encode function // @author Hugo // @match https://login1.scrape.center/ // @grant none // ==/UserScript==(function() {'use strict'function hook(object, attr) {var func = object[attr]object[attr] = function () {console.log('hooked', object, attr)var ret = func.apply(object, arguments)debuggerreturn ret}}hook(window, 'btoa') })()// 首先定义了一些UserScript Header,其中比较重要的就是@name和@match,分别表示脚本名称和生效网址// 接着定义hook方法,其中包含两个位置参数:object和attr。这里的意思就是脚本Hook的目标是object对象的attr参数 // 如果想要的Hook的是alert方法,那就在object位置传入参数window,在attr位置传入参数'alert'(字符串格式) // 但实际上在这个例子里面,需要Hook的是btoa方法,所以调用的时候,需要传入的参数是window和'btoa'(因为在JavaScript中,Base64编码是用btoa方法实现的)// var func = object[attr] // 首先将object[attr]赋值到一个变量,这样后面调用func方法就能实现网站中这个JavaScript本来的功能// object[attr] = function () { // 接着将object[attr]改写成为一个新的方法,// var ret = func.apply(object, arguments) // 在新的方法中,通过func.apply重新调用了原来的方法,以此确保网站原有的功能不受影响,该干嘛干嘛// console.log('hooked', object, attr) // debugger // 现在就可以在func方法执行前后加入自己的代码实现一些自定义功能了。比如通过console.log将信息输出到控制台、通过debugger进入断点等。 // debugger是JavaScript中定义的一个专门用于断点调试的关键字// hook(window, 'btoa') // 最后调用hook方法,传入window对象和'btoa'字符串
- 点击文件,保存(快捷键:cmd-s),回到登录页面,刷新一下,就能看到脚本在当前页面生效了。
- 输入用户名和密码,再次登录,成功进入断点模式。代码卡在debugger这行代码的位置,成功Hook住了!说明JavaScript代码在执行过程中确实调用到了btoa方法。
- 这时再看一下控制台面板,这里也输出了window对象和btoa方法,验证正确。
- 再次查看堆栈信息,已经不会出现Promise相关信息了,能够清晰看到btoa方法逐层调用的过程。同样能够找到前面用Ajax断点已经找到过的onSubmit方法处理源码。
- 同时,在作用域里面也能看到arguments变量的信息——arguments就是指传给btoa方法的参数(用户名和密码经过JSON序列化之后的字符串),ret就是btoa方法返回的结果(也就是Ajax请求参数token的值)。
- 接下来进一步添加断点验证一下流程,比如在调用encode方法的那行添加断点,点击蓝色按钮回复JavaScript执行,跳过当前油猴定义的断点位置,再次点击登录按钮,代码会停在当前添加断点的位置。这时候就可以在Watch面板输入this.form来验证此处是否为表单中输入的用户名和密码了。
- 点击逐语句执行按钮(F10),就会跳到用油猴脚本Hook的地方。此时的返回值就是token
无限debugger的原理和绕过
基本原理:debugger是JavaScript中定义的一个专门用于断点调试的关键字,同时会被网站开发者所利用,来阻挠爬虫这方的调试
测试网站:https://antispider8.scrape.center/
网站特性:一打开开发者工具就会立即进入断点模式,就算点击恢复脚本执行按钮,也会无限循环进入断点模式。查看代码显示这是通过setInterval循环实现的,每秒执行一次debugger语句。(类似的还有无限for循环、无限while循环、无限递归调用等实现方法)
解决方法:禁用断点+替换文件
禁用断点
△这种方法会禁用全部断点,禁用后无法在其他位置设置断点进行调试,所以不是一个好方案。
对着行号右键,选择“一律不再此处暂停”,然后点击蓝色恢复按钮,现在就不会进入无限debugger模式了。
这个例子中还可以选择添加条件断点(期望某个变量的值超过某个具体值的时候停下来),由于这里是无限循环,也没有具体的变量可以作为判定依据,因此可以直接写一个简单的表达式来控制(这个例子里面用false就行了,意思就是永远不执行这里的断点,设置后效果跟前面的【一律不在此处暂停】相同)。
替换文件
在替换面板中【启动本地替换】,将格式化后的源代码复制到本地编辑器,直接删除或者注释掉debugger这个关键字,然后把修改后的代码复制到网站的JavaScript文件中,替换完成后,刷新一下网页,就会发现不会进入无限debugger模式了。
使用Python模拟执行JavaScript
因为Python中不一定有和JavaScript完全一样的类库,所以一般很难将代码完全重写一遍,此时就可以用Python直接模拟执行JavaScript得到结果。
测试网站:https://spa7.scrape.center/
特性:每张卡片上都有一个加密字符串,并且加密字符串与球星信息相关联,并且每个球星的加密字符串都是不一样的。
目标:找出这个加密字符串的加密算法,并用程序把加密字符串的生成过程模拟出来。
环境:
- 安装用于执行JavaScript的Python库:pyexecjs
- 安装JavaScript运行环境:Node.js(官网:Node.js (nodejs.org))
确认环境:
import execjs
print(execjs.get().name)# 返回:Node.js (V8)
网站分析:
- 在源代码面板找到加密字符串的生成逻辑(css文件夹里面的网站框架、img文件夹里面是图片资源、js文件夹里面除了main都是页面需要引用的JavaScript库)
- 在main.js文件中,首先声明了一个包含球员信息的列表,然后调用加密算法对其中的信息进行机密——getToken方法的参数就是单个球员的信息(就是上述列表中的元素对象),然后将this.key(一个固定的字符串)处理成一个key。接着提取球员名字,用Base64编码处理一下,接着,处理后的名字加上生日、身高、体重等信息,加上密钥key,进行DES加密,最后返回结果。
- 加密算法依赖于crypto-js库,为此网站直接引用了crypto-js库。执行crypto-js库对应的这个JavaScript文件之后,CryptoJS就被注入浏览器全局环境下,因此就可以在别的方法里直接使用CryptoJS对象里的方法了。
模拟调用:
需要模拟执行的内容包含两个部分:
- 模拟运行crypto-js.min.js里面的JavaScript,用于声明CryptoJS对象
- 模拟运行getToken方法的定义,用于声明getToken方法
首先来看,要模拟的就是这个getToken方法,可以复制下来,新建一个js文件,粘贴进去,改写一下。
原版:
改写后:
function getToken(player) {let key = CryptoJS.enc.Utf8.parse('fipFfVsZsTda94hJNKJfLoaqyqMZFFimwLt')const {name, birthday, height, weight} = playerlet base64Name = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(name))let encrypted = CryptoJS.DES.encrypt(`${base64Name}${birthday}${height}${weight}`, key, {mode: CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7})return encrypted.toString()}
主要改动点:
- 去掉methods,改为function
- 将作为key的字符串加进来
这个方法的模拟执行需要CryptoJS这个对象,如果直接调用这个方法,就会报CryptoJS未定义的错误,所以还需要模拟执行一下crypto-js.min.js。
这就需要将crypto-js.min.js里面的代码全都复制下来,跟改写后的getToken代码丢到同一个JS文件里面去。
接下来就可以调用PyExecJS库模拟执行一下了,代码如下:
import execjs
import jsonitem = {'name': '尼科拉-约基奇','image': 'jokic.png','birthday': '1995-02-19','height': '213cm','weight': '128.8KG'
}
# 单独定义一位球员的信息来测试,并赋值为item变量file = 'crypto.js'
# 导入前面创建JS文件的路径,赋值为file
node = execjs.get()
# 获取JavaScript执行环境,赋值为node
ctx = node.compile(open(file).read())
# compile方法会返回一个JavaScript的上下文对象,赋值给ctx
# 可以理解为ctx对象已经声明好了CryptoJS对象和getToken方法js = f"getToken({json.dumps(item, ensure_ascii=False)})"
# 定义一个js变量,内含标准的JavaScript方法调用以及字符串格式的球员信息
print(js)
result = ctx.eval(js)
# 调用ctx对象的eval方法并传入js变量,模拟执行JavaScript代码
print(result)
至此,直接运行的话会报错:CryptoJS未定义。
问题出自crypto-js.min.js中的头两行代码。这里声明了一个JavaScript的自执行方法——声明了一个方法然后紧接调用执行。
!function(t, e) {"object" == typeof exports ? module.exports = exports = e() : "function" == typeof define && define.amd ? define([], e) : t.CryptoJS = e()...
}//crypto-js.min.js中定义的方法接受t和e两个参数,其中t就是this(浏览器中的window对象),e就是一个function(用于定义CryptoJS的核心内容)// 在浏览器中运行的时候,环境中没有exports和define这两个对象,所以两个判断语句的结果都是False,最后执行的是t.CryptoJS = e()
// 这里就是把CryptoJS对象挂载到this对象上面,而this就是浏览器中的全局window对象,后面就可以直接用了// 在本地运行的时候,环境中(基于Node.js的JavaScript环境)包含exports对象(用来将一些对象的定义导出),所以第一个判断语句的结果为True,最后执行的是module.exports = exports = e()
// 这里相当于把e()作为整体导出,而这个e()其实就对应后面的整个function(里面定义了加密相关的各个实现,其实就指代整个加密算法库)// 关键是,这就导致没有声明CryptoJS对象!也没有把CryptoJS挂载到全局对象里面!所以后面就会出现未定义的错误。
划个重点!浏览器环境和本地环境的差异,导致crypto-js.min.js中实际上并没有声明CryptoJS对象!也没有把CryptoJS挂载到全局对象里面!所以后面就会出现未定义的错误。
最简单的解决方法就是:直接声明一个CryptoJS变量,然后直接给这个变量赋值e(),完成CryptoJS的初始化。
var CryptoJS;
!function(t, e) {
CryptoJS = e();"object" == typeof exports ? module.exports = exports = e() : "function" == typeof define && define.amd ? define([], e) : t.CryptoJS = e()
再次运行Python脚本,成功生成加密字符串!
使用Node.js模拟执行JavaScript
模拟执行
将网站上的crypto-js.min.js内的全部内容复制并保存下来(我这里是crypto2.js)——这是依赖库。
另外新建一个main.js文件——这是存放算法机制的脚本。
const CryptoJS = require("./crypto2");
//直接使用Node.js中的require方法导入crypto.js这个文件,然后赋值为CryptoJS对象,完成Crypto对象的初始化function getToken(player) {let key = CryptoJS.enc.Utf8.parse("fipFfVsZsTda94hJNKJfLoaqyqMZFFimwLt");// 调用enc的Utf8方法对字符串进行编码处理,生成一个keyconst { name, birthday, height, weight } = player;// 从player字典中提取各字段的内容let base64Name = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(name));// 调用enc的Base64和Utf8两个方法对name进行编码处理let encrypted = CryptoJS.DES.encrypt(`${base64Name}${birthday}${height}${weight}`,key,// 将处理后的key、name搭上其他球员信息,调用DES的方法进行加密{mode: CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7,});return encrypted.toString();// 返回最终的加密结果(字符串格式)
}const player = {name: "凯文-杜兰特",image: "durant.png",birthday: "1989-09-29",height: "208cm",weight: "108.9KG"
}
console.log(getToken(player))
// 插入player参数,调用getToken方法,打印返回信息
在命令行中敲入node main.js执行脚本,然后就能得到结果了。
为什么之前用Python模拟执行JavaScript的时候,要修改本地的crypto-js.min.js文件,这次用Node.js来模拟执行就不用呢?
!function(t, e) {"object" == typeof exports ? module.exports = exports = e() : "function" == typeof define && define.amd ? define([], e) : t.CryptoJS = e()...
}//回过头来再看看crypto-js.min.js文件中的头两行//在浏览器上,这段脚本实际执行的是:t.CryptoJS = e()——基于整个方法e()生成全局对象CryptoJS,因为前两个判断语句都是False(浏览器没有exports和define对象)
//在本地上,这段脚本实际执行的是:module.exports = exports = e() ——将整个方法e()导出,因为第一个判断语句就是True(基于Node.js的JavaScript环境是有exports对象的)//然而,在Python里面,没有对应的方法接收上述脚本的导出,因此导致脚本内实际上并没有初始化CryptoJS对象,所以后续在Python中调用这个脚本的时候,就会报错——对象没有初始化,为此必须先修改脚本,手动初始化一下CryptoJS对象
//而在Node.js中,有和exports配合的require方法,可以直接调用脚本并将结果赋值给CryptoJS变量,完成CryptoJS的初始化。后面就能调用CryptoJS里面的DES、enc等各个对象的方法来进行加密、编码操作了
搭建服务,连接Python和Node.js
思路:
使用Node.js将刚才的算法暴露成一个HTTP服务,这样Python就能直接调用HTTP服务,通过Request body传入对应的球员信息,然后加密字符串通过HTTP的Response返回即可。
HTTP的实现通过express来实现(Node.js中最流行的HTTP服务器框架),安装:在main.js同一文件夹内执行命令行:npm i express
改写main.js代码:
const CryptoJS = require("./crypto2");
// 直接使用Node.js中的require方法导入crypto.js这个文件,然后赋值为CryptoJS对象,完成Crypto对象的初始化
const express = require("express");
// 导入express模块(HTTP服务器框架),完成express对象的初始化
const app = express();
// 创建express实例,赋值app
const port = 3000;
// 设置服务器的端口号
app.use(express.json());function getToken(player) {...加密算法部分不用修改...
}app.post("/", (req, res) => {// 设定服务器机制:接收post请求,解析请求、返回响应const data = req.body;// 将请求内容的主体赋值datares.send(getToken(data));// 返回响应内容(调用getToken方法处理data后的结果)
});app.listen(port, () => {// 调用listen方法监听来自3000端口的请求console.log(`Example app listening on port ${port}!`)// 设置成功后打印日志信息
});
运行脚本:node main.js,让express服务在本地3000端口上运行起来
创建Python文件,直接使用requests调用该API,传入对应球员数据即可
import requestsdata = {"name": "凯文-杜兰特","image": "durant.png","birthday": "1989-09-29","height": "208cm","weight": "108.9KG"
}url = 'http://localhost:3000'
response = requests.post(url, json=data)
print(response.text)
浏览器环境模拟执行JavaScript
在浏览器中找到一个加密算法,想要获取最终的token是什么,此时用Python和Node.js来模拟执行JavaScript,关键在于以下两步:
- 把所有的依赖库下载到本地
- 使用PyExecJS或Node.js来加载依赖库并模拟调用加密方法
但往往会面临两个问题:
- 环境差异:Node.js中没有全局window对象,取而代之的是global对象。如果JavaScript文件中有任何引用window对象的方法,就没法在Node.js环境中直接运行(需要先改写成global对象);
- 依赖库查找:如果要完全剥离出加密方法所需要的JavaScript库,还是要花不少时间的,因为只要缺少一个,加密方法在本地就无法正常运行。
——想想看,加密方法依赖的全部逻辑、环境和依赖库其实都已经加载到浏览器了,如果能直接在浏览器环境模拟执行JavaScript脚本(也就是加密方法),岂不美哉?
环境:需要使用playwright来实现浏览器辅助逆向
安装playwright库:pip install playwright
安装内核浏览器:playwright install
测试网站:https://spa2.scrape.center/
网站分析:
- 通过网络面板,发现网站翻页的时候会请求包含:/api/movie路径的网址;
- 选中翻页按钮右键选检查;
- 进入事件监听器选click选ul.el-pager后面的文件地址;
- 进入源代码面板下的js文件夹下的(索引)文件,在右侧选项卡添加XHR断点:/api/movie;
- 在网页上点击下一页,激活断点机制,源代码停在了:d.send(u);
- 在右侧选项卡中调用堆栈面板,往回找到:onFetchData(这块代码中包含limit、offset、token这三个和翻页功能紧密相关的字段);
- 取消XHR断点,在此处设置一个断点,释放后再次翻页;
- 断点机制再次激活,源代码停在了刚才设置了断点的170行;
- 这时候,已经可以把鼠标放在不同变量上查看具体的值,比如this.page就是3,this.limit就是10。点击调用下一个函数(F10);
- this.$store.state.url.index是字符串“/api/movie”,a是20。再次点击调用下一个函数(F10);
- 至此,可以稍作分析了:
- this.limit也就是limit,是一个为10的常量,代表每一页包含10条数据
- a也就是offset,是一个变量,作为翻页用的偏移量:第一页的offset是0,第二页的offset是10,以此类推。计算公式:a = (this.paga – 1)* this.limit
- e也就是token,计算公式:e = Object(i["a"])(this.$store.state.url.index, a),因为两个参数都是已知的,所以可以断定Object(i["a"])里面就是核心的加密逻辑
- 接下来就可以追踪一下i["a"]方法了
- 代码如下:
"7d92": function(t, e, r) {"use strict";r("6b54");var n = r("3452");function i() {for (var t = Math.round((new Date).getTime() / 1e3).toString(), e = arguments.length, r = new Array(e), i = 0; i < e; i++)r[i] = arguments[i];r.push(t);var o = n.SHA1(r.join(",")).toString(n.enc.Hex), c = n.enc.Base64.stringify(n.enc.Utf8.parse([o, t].join(",")));return c}e["a"] = i},
- 至此,可以大致看到加密逻辑里面掺杂了时间、SHA1、Base64、列表等各种操作,比较复杂,如果要深入分析,是会比较花时间的。
解决思路:
- 既然浏览器已经把上下文环境和依赖库都加载成功了,完全可以模拟调用局部方法;
- 模拟调用局部方法只需要将局部方法挂载到全局window对象上;
- 这种情况下,最简单的挂载方法就是直接改源代码;
- 源代码已经在浏览器中运行了,这时候利用playwright的Request Interception机制将想要替换的任意文件进行替换即可。
实战:
将onFetchData所在的整个JavaScript文件复制下来(命名为chunk.js),修改代码:
...
var a = (this.page - 1) * this.limit, e = Object(i["a"])(this.$store.state.url.index, a);
window.encrypt = Object(i["a"]);...// 将Object(i["a"])挂载到全局window对象下的encrypt(名称自定义)属性
// 之后调用window.encrypt就相当于调用了Object["a"]方法了
编写Python脚本:
from playwright.sync_api import sync_playwright
import requestsBASE_URL = 'https://spa2.scrape.center'
INDEX_URL = BASE_URL + '/api/movie?limit={limit}&offset={offset}&token={token}'
# 设定请求地址模板(来自网络面板)
MAX_PAGE = 5
LIMIT = 10context = sync_playwright().start()
browser = context.webkit.launch()
# 使用playwright创建一个无头浏览器
page = browser.new_page()
# 创建一个新页面
page.route("/js/chunk-10192a00.243cb8b7.js",lambda route: route.fulfill(path="./3index.js")
)
# 定义一个关键路由:第一个参数是原本加载的文件路径,第二个参数是利用route的fufill方法指定本地的JS文件
page.goto(BASE_URL)def get_token(offset):result = page.evaluate('''() => {return window.encrypt("%s", "%s")}''' % ('/api/movie', offset))return result
# 在playwright环境中额外执行JavaScript代码
# 模拟执行方法需要传入两个参数,第一个是固定值/api/movie,第二个是变值
# 使用page对象的evaluate方法,传入JavaScript字符串
# 这个字符串是一个方法,代表返回window.encrypt方法的执行效果
# 最后赋值给result,然后返回即可for i in range(MAX_PAGE):offset = i * LIMITtoken = get_token(offset)# 指定遍历10页,构造offset变量,传给get_token方法获取tokenindex_url = INDEX_URL.format(limit=LIMIT, offset=offset, token=token)# 基于请求地址模板,补全参数,构建请求链接response = requests.get(index_url)print('response', response.json())
运行Python脚本即可获取网页数据。