正所谓条条道路通罗马,上次我们使用了Selenium自动化工具来爬取网易云的音乐评论,Selenium自动化工具可以驱动浏览器执行特定的动作,获得浏览器当前呈现的页面的源代码,做到可见即可爬,但需要等网页完全加载完,也就是JavaScript完全渲染出来才可以获取到当前的网页源代码,这样的爬取效率太低了、爬取速度太慢了。
追求完美、追求高效率的我们,怎么会容忍效率低下呢?所以我们今天利用Scrapy框架加js逆向来爬取网易云评论并做词云图,做效率最高的人!!!
在爬取前,我们首先要了解一下什么是js逆向。
js逆向
首先Javascript简称js,js是一种脚本语言,是不需要进行编译的,也是浏览器中的一部分,经常用在web客户端脚本语言,主要是用来给html增加动态功能,也可以进行数据加密。
加密在前端开发和爬虫中是很常见的,当我们掌握了加密算法且可以将加密的密文进行解密破解时,就可以从编程小白摇身变为编程大神,熟练掌握加密算法可以帮助我们实现高效的js逆向。由于加密算法的内容有很多,今天我们主要是简单了解一下加密算法有哪些,之前有写过加密算法,感兴趣可以看看往期文章!!!
常见的加密算法
js中常见的加密算法有以下几种:
js逆向作用
我们发送网络请求的时候,往往需要携带请求参数,如下图所示:
有爬虫基础的人都知道,上图发送的是POST网络请求,在发送请求时,我们还要携带一些参数,例如上图中的limit和current,其中limit是每次获取的数据个数,current是页码数。要想获取上面的URL链接所呈现中的数据时,必须要在发送网络请求时携带limit和current这两个参数。
有时候我们需要携带的请求参数是加密过的参数,如下图所示:
同样是发送POST网络请求,很明显这次的参数是已经加密过的参数,该参数是一大串不知道表达什么意思的字符串,这时就需要采用js逆向来破解该参数。有人可能说,直接复制粘贴那参数,也获取到数据呀。可是这样只能获取到一小部分数据或者一页的数据,不能获取到多页。
通过上面的例子,我们可以知道,js逆向可以帮助我们破解加密过的参数。
当然除了帮我们破解加密过的参数,还可以帮我们处理以下事情:
js逆向的实现
那么如何实现js逆向或者破解加密过的参数呢。
要破解加密过的参数,大致可以分为四步:
寻找加密参数的方法位置找出来;
设置断点找到未加密参数与方法;
把加密方法写入js文件;
调试js文件。
下面我们以待会要爬取的网易云音乐评论为例,所创建的js文件名为wangyi.js,来演示一下如何实现js逆向。
寻找加密函数位置
首先打开开发者模式,找到你要获取的数据的URL请求条目,再把加密参数的变量复制下来,点击右上角三个小点,选择Search。
在通过Search搜索把加密参数函数的存放位置找出来,如下图所示:
经过选择我们发现加密函数放在core_b15...中,点击4126这一行就会打开core_b15...,我们再在core_b15...中搜索有没有其他params,键盘同时按下Ctrl F,如下图所示:
由上图可知,core_b15...中有34个params,这34个params中都有可能是加密参数,这里我们来告诉大家一个小技巧,一般情况下,加密参数都是以下形式输出的,
参数:
参数 =
所以我们可以在搜索框中稍稍加点东西,例如把搜索框中的params改为params:,结果如下图所示:
这样params就被我们精确到只有两个,接下来我们开始设置断点。
设置断点找到未加密参数与函数
在上一步中,我们把params的范围缩短到只有两处,如下图所示:
第一种图的params只是一个类似字典的变量,而第二张图的params:bYm0x.encText,表示在bYm0x中选取encText的值赋给params,而在13367行代码中,表示encSecKey为bYm0x中encSecKey的值,所以我们可以通过变量bYm0x来获取,而在params:bYm0x.encText上两行代码中,bYm0x变量中window调用了asrsea()方法,13364行代码是我们加密参数的函数。我们把鼠标放在window.asrsea中间,如下图所示:
由上图可知,window.asrsea通过function d函数中调用的,其传入参数为d,e,f,g,点击f d(d,e,f,g),如下图所示:
当我们不知道从哪里设置断点时,我们可以尝试在它调用函数的一行设置断点或者你认为哪行代码可疑就在哪行代码设置断点,刷新页面,如下图所示:
点击上图的1,一步步放开断点,注意观察上图中的2,3处的变化,如下图如下图所示:
当左边出现了评论区,但没出现评论内容时,这时右边的方框刚好出现了d,e,f,g这三个数据,而且d中的数字刚好是歌曲的id。我们这四个参数复制下来,并去除\,观察一下:
d: "{"rid":"R_SO_4_1874158536","threadId":"R_SO_4_1874158536","pageNo":"1","pageSize":"20","cursor":"-1","offset":"0","orderType":"1","csrf_token":""}"
e: "010001"
f: "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
g: "0CoJUm6Qyw8W8jud"
通过上面的代码,我们推测rid和threadId是单曲id,pageNo是评论区的页数,pageSize是评论数据的行数,其他的不认识!!!
为了证实推测,我们换个歌单来测试获取d,e,f,g这四个参数:
d: "{"rid":"A_PL_0_6892176976","threadId":"A_PL_0_6892176976",\"pageNo":"1","pageSize":"20","cursor":"-1","offset":"0","orderType":"1","csrf_token":""}"
e: "010001"
f: "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
g: "0CoJUm6Qyw8W8jud"
通过观察可以发现,我们的推测是正确的,而且e,f,g是固定不变的,那么我们可以确定参数d中的参数就是未加密的参数,既然找到了未加密的参数,那么我们先把未加密的参数写入js文件中。
注意:rid中的A_PL_0代表的是歌单,而R_SO_4代表的是单曲。
把加密参数的方法写入js文件
未加密的参数我们在上一步已经获取到了,也就知道了加密参数的函数为接下来开始把加密参数的方法并写入js文件中。
该加密参数方法如下图所示:
加密参数方法为window.asrsea(),所以我们直接复制粘贴第13364行代码作为我们的加密参数方法,并写在入口函数中,并返回变量bYm0x,具体代码如下所示:
function start(){
var bYm0x = window.asrsea(JSON.stringify(i8a), bqf4j(["流泪", "强"]), bqf4j(Sr6l.md), bqf4j(["爱心", "女孩", "惊恐", "大笑"]));
return bYm0x
}
将鼠标放在window.asrsea中间,如下图所示:
在图中我们可以知道window.asrsea()调用了function d函数,而传入的参数对应着未加密的参数d、e、f、g,而d属于字典,e、f、g属于常量,所以我们可以把上面的代码改写为:
function start(){
var bYm0x=window.asrsea(JSON.stringify(d),e,f,g);
return bYm0x
}
写了入口函数后,我们开始观察function d函数,如下图所示:
通过function d()函数,我们发现function d()函数调用了a()函数、b()函数、c()函数,所以我们要把这些函数都复制在刚才的js文件中。当我们不知道要复制哪些代码时,就直接复制function d函数的外面一层花括号的所有代码,也就是第13217行代码为复制的开始点,第13257行代码为复制的结束点。
为了我们的js文件可以在控制台看到调试的结果,我们需要添加以下代码:
console.log(start())
调试js文件
好了,我们已经把代码复制在js文件中了,在调试js文件前,我们先安装node.js和node.js插件。
node.js
node.js安装方式很简单,进入node.js官网,如下图所示:
大家选择对应的系统来下载安装,由于安装实在太简单了,都是无脑下一步就可以了,当然最好参照网上的教程来安装,这里我们就不讲解如何安装node.js。
注意:一定要安装node.js,否则会在调试js文件中报以下错误:
execjs._exceptions.ProgramError: TypeError: ‘JSON‘ 未定义
node.js插件
我们写好js文件后,需要进行调试,而在pycharm中调试js文件需要安装node.js插件。
首先进入pycharm中的setting配置,如下图所示:
按照上图中的步骤,即可安装好插件。
好了,准备工作已经做好了,现在开始调试js文件,运行刚才的js文件,会发现报了以下错误:
window.asrsea = d,
^
ReferenceError: window is not defined
该错误是说window没定义,这时我们只需要在最前面添加以下代码即可:
window={}
进行运行我们的js文件,发现又报错了,错误如下所示:
var c = CryptoJS.enc.Utf8.parse(b)
^
ReferenceError: CryptoJS is not defined
错误提示又是参数没定义,但CryptoJS就不能简单的设置一个空字典,这需要我们继续在刚才的core_b15...中寻找CryptoJS了,如下图所示:
由图中可知,CryptoJS一共要13处那么多,那么我们该从何开始复制呢,又从何处结束复制呢,当我们不知道在哪里开始复制时,直接把所有的CrpytoJS都复制下来,请记住一个原则,宁愿复制多了也不复制少了,多了不会报错,少了会报错,而且还要找错,重新复制。
好了,我们复制完后,继续运行js文件。
运行结果如下:
好了,js文件已经运行准确无误了。接下来开始爬取数据
数据爬取
我们是通过Scrapy框架来爬取数据,所以我们首先来创建Scrapy项目和spider爬虫。
创建Scrapy项目、Spider爬虫
创建Scrapy项目和Spider爬虫很简单,依次执行以下代码即可:
scrapy startproject
cd
scrapy genspider <爬虫名字> <允许爬取的域名>
其中,我们的Scrapy项目名为NeteaseCould,爬虫名字为:NC,允许爬取的域名为:music.163.com。
好了创建Scrapy项目后,接下来我们创建一个名为JS的文件夹来存放刚才编写的js文件,项目目录如下所示:
这里我们还创建了一个名为Read_js.py文件,该文件用来读取js文件。
读取js文件——Read_js.py
我们编写好js文件后,当然要把它读取出来,具体代码如下所示:
def get_js():
path = dirname(realpath(__file__)) + '/js/' + 'wangyi' + '.js'
with open(path,'r',encoding='utf-8')as f:
r_js=f.read()
c_js=execjs.compile(r_js)
u_js=c_js.call('start')
data={
"params":u_js['encText'],
"encSecKey":u_js['encSecKey']
}
return data
我们把读取到的js文件内容存放在r_js变量中,然后通过execjs.compile()方法获取代码编译完成后的对象,再通过call()方法来调用js文件中的入口函数,也就是start()函数。然后将获取到的数据存放在字典data中,最后返回字典data。
对了,为了使我们的代码更灵活,我们可以把参数d放在Read_js.py文件中,具体代码如下所示:
url = 'https://music.163.com/#/song?id=17177324'
id = url.split('=')[-1]
d = {
"rid": f"R_SO_4_{id}",
"threadId": f"R_SO_4_{id}",
"pageNo": "1",
"pageSize": "5",
"cursor": "-1",
"offset": "0",
"orderType": "1",
"csrf_token": ""
}
u_js=c_js.call('start',d)
首先利用split()方法把歌曲的id获取下来,然后放在参数d中,当我们需要获取另一首歌的评论信息的时候,只需要修改上面的url即可。注意:参数d中R_SO_4代表的单曲,当我们要获取其他的评论信息时,则需要更改R_SO_4,例如获取歌单的时候则需要更改为A_PL_0。
items.py文件
在获取数据前,我们先在items.py文件中,定义爬取数据的字典,具体代码如下所示:
import scrapy
class NeteasecouldItem(scrapy.Item):
# define the fields for your item here like:
name = scrapy.Field()
content = scrapy.Field()
NC.py文件
在定义字段后,先看看评论数据的位置,如下图所示:
现在我们开始获取网易云音乐评论的数据,具体代码如下所示:
import scrapy
from NeteaseCould.Read_js import get_js
from NeteaseCould.items import NeteasecouldItem
class NcSpider(scrapy.Spider):
name = 'NC'
allowed_domains = ['music.163.com']
start_urls = ['https://music.163.com/weapi/comment/resource/comments/get?csrf_token=']
def start_requests(self):
js=get_js()
yield scrapy.FormRequest('https://music.163.com/weapi/comment/resource/comments/get?csrf_token=',formdata=js,callback=self.parse)
def parse(self, response):
json=response.json()
p=json.get('data').get('comments')
for i in p:
item = NeteasecouldItem()
item['content']=i.get('content')
yield item
首先我们导入get_js和NeteasecouldItem,再将start_urls中的链接修改为。
由于我们发送的是POST请求,所以我们需要重写start_requests()方法,在start_requests()方法中,我们先调用了get_js()方法,然后在通过ForMReuqest()方法发送网络请求。
其中,formdata=相当于我们普通爬虫的data=callback=self.parse()表示将响应返回给parse()方法。
最后通过parse()方法进行数据的获取并通过yield生成器返回给引擎。
pipelines.py文件
当我们需要把数据放在数据库或者存放在.txt文件中数,则需要在pipelines.py文件编写代码,这里我们把数据存放在txt文件中,具体代码如下所示:
from itemadapter import ItemAdapter
class NeteasecouldPipeline:
def process_item(self, item, spider):
with open('评论.txt','a',encoding='utf-8')as f:
f.write(item['content'])
f.write('\n')
冒泡到根,调用使用
,但是如果你点击任何嵌套的标签(例如 或 ),该处理程序也会运行:
If you click on EM
, the handler on DIV
runs.
这是不是有点奇怪?如果实际上点击的是 ,为什么在
上的处理程序会运行?
冒泡(bubbling)原理很简单。
当一个事件发生在一个元素上,它会首先运行在该元素上的处理程序,然后运行其父元素上的处理程序,然后一直向上到其他祖先上的处理程序。
假设我们有 3 层嵌套 FORM > DIV > P,它们各自拥有一个处理程序:
点击内部的
会首先运行 onclick:
在该
上的。然后是外部
上的。然后是外部 上的。以此类推,直到最后的 document 对象。
因此,如果我们点击
,那么我们将看到 3 个 alert:p → div → form。
这个过程被称为“冒泡(bubbling)”,因为事件从内部元素“冒泡”到所有父级,就像在水里的气泡一样。
几乎所有事件都会冒泡。
这句话中的关键词是“几乎”。
例如,focus 事件不会冒泡。同样,我们以后还会遇到其他例子。但这仍然是例外,而不是规则,大多数事件的确都是冒泡的。
父元素上的处理程序始终可以获取事件实际发生位置的详细信息。
引发事件的那个嵌套层级最深的元素被称为目标元素,可以通过 event.target 访问。
注意与 this(=event.currentTarget)之间的区别:
例如,如果我们有一个处理程序 form.onclick,那么它可以“捕获”表单内的所有点击。无论点击发生在哪里,它都会冒泡到 并运行处理程序。
在 form.onclick 处理程序中:
一探究竟:
结果
script.js
example.css
index.html
form.onclick = function(event) {
event.target.style.backgroundColor = 'yellow';
// chrome needs some time to paint yellow
setTimeout(() => {
alert("target = " + event.target.tagName + ", this=" + this.tagName);
event.target.style.backgroundColor = ''
}, 0);
};
form {
background-color: green;
position: relative;
width: 150px;
height: 150px;
text-align: center;
cursor: pointer;
}
div {
background-color: blue;
position: absolute;
top: 25px;
left: 25px;
width: 100px;
height: 100px;
}
p {
background-color: red;
position: absolute;
top: 25px;
left: 25px;
width: 50px;
height: 50px;
line-height: 50px;
margin: 0;
}
body {
line-height: 25px;
font-size: 16px;
}
A click shows both event.target
and this
to compare:
event.target 可能会等于 this —— 当点击事件发生在 元素上时,就会发生这种情况。
冒泡事件从目标元素开始向上冒泡。通常,它会一直上升到 ,然后再到 document 对象,有些事件甚至会到达 window,它们会调用路径上所有的处理程序。
但是任意处理程序都可以决定事件已经被完全处理,并停止冒泡。
用于停止冒泡的方法是 event.stopPropagation()。
例如,如果你点击 ,这里的 body.onclick 不会工作:
event.stopImmediatePropagation()
如果一个元素在一个事件上有多个处理程序,即使其中一个停止冒泡,其他处理程序仍会执行。
换句话说,event.stopPropagation() 停止向上移动,但是当前元素上的其他处理程序都会继续运行。
有一个 event.stopImmediatePropagation() 方法,可以用于停止冒泡,并阻止当前元素上的处理程序运行。使用该方法之后,其他处理程序就不会被执行。
不要在没有需要的情况下停止冒泡!
冒泡很方便。不要在没有真实需求时阻止它:除非是显而易见的,并且在架构上经过深思熟虑的。
有时 event.stopPropagation() 会产生隐藏的陷阱,以后可能会成为问题。
例如:
我们创建了一个嵌套菜单,每个子菜单各自处理对自己的元素的点击事件,并调用 stopPropagation,以便不会触发外部菜单。之后,我们决定捕获在整个窗口上的点击,以追踪用户的行为(用户点击的位置)。有些分析系统会这样做。通常,代码会使用 document.addEventListener('click'…) 来捕获所有的点击。我们的分析不适用于被 stopPropagation 所阻止点击的区域。太伤心了,我们有一个“死区”。
通常,没有真正的必要去阻止冒泡。一项看似需要阻止冒泡的任务,可以通过其他方法解决。其中之一就是使用自定义事件,稍后我们会介绍它们。此外,我们还可以将我们的数据写入一个处理程序中的 event 对象,并在另一个处理程序中读取该数据,这样我们就可以向父处理程序传递有关下层处理程序的信息。
事件处理的另一个阶段被称为“捕获(capturing)”。它很少被用在实际开发中,但有时是有用的。
DOM 事件标准描述了事件传播的 3 个阶段:
捕获阶段(Capturing phase)—— 事件(从 Window)向下走近元素。目标阶段(Target phase)—— 事件到达目标元素。冒泡阶段(Bubbling phase)—— 事件从元素上开始冒泡。
下面是在表格中点击 的图片,摘自规范:
也就是说:点击 ,事件首先通过祖先链向下到达元素(捕获阶段),然后到达目标(目标阶段),最后上升(冒泡阶段),在途中调用处理程序。
之前,我们只讨论了冒泡,因为捕获阶段很少被使用。通常我们看不到它。
使用 on 属性或使用 HTML 特性(attribute)或使用两个参数的 addEventListener(event, handler) 添加的处理程序,对捕获一无所知,它们仅在第二阶段和第三阶段运行。
为了在捕获阶段捕获事件,我们需要将处理程序的 capture 选项设置为 true:
elem.addEventListener(..., {capture: true})
// 或者,用 {capture: true} 的别名 "true"
elem.addEventListener(..., true)
capture 选项有两个可能的值:
请注意,虽然形式上有 3 个阶段,但第 2 阶段(“目标阶段”:事件到达元素)没有被单独处理:捕获阶段和冒泡阶段的处理程序都在该阶段被触发。
让我们来看看捕获和冒泡:
上面这段代码为文档中的 每个 元素都设置了点击处理程序,以查看哪些元素上的点击事件处理程序生效了。
如果你点击了
,那么顺序是:
HTML → BODY → FORM → DIV(捕获阶段第一个监听器):P(目标阶段,触发两次,因为我们设置了两个监听器:捕获和冒泡)DIV → FORM → BODY → HTML(冒泡阶段,第二个监听器)。
有一个属性 event.eventPhase,它告诉我们捕获事件的阶段数。但它很少被使用,因为我们通常是从处理程序中了解到它。
要移除处理程序,removeEventListener 需要同一阶段
如果我们 addEventListener(..., true),那么我们应该在 removeEventListener(..., true) 中提到同一阶段,以正确删除处理程序。
同一元素的同一阶段的监听器按其设置顺序运行
如果我们在同一阶段有多个事件处理程序,并通过 addEventListener 分配给了相同的元素,则它们的运行顺序与创建顺序相同:
elem.addEventListener("click", e => alert(1)); // 会先被触发
elem.addEventListener("click", e => alert(2));
当一个事件发生时 —— 发生该事件的嵌套最深的元素被标记为“目标元素”(event.target)。
每个处理程序都可以访问 event 对象的属性:
任何事件处理程序都可以通过调用 event.stopPropagation() 来停止事件,但不建议这样做,因为我们不确定是否确实不需要冒泡上来的事件,也许是用于完全不同的事情。
捕获阶段很少使用,通常我们会在冒泡时处理事件。这背后有一个逻辑。
在现实世界中,当事故发生时,当地警方会首先做出反应。他们最了解发生这件事的地方。然后,如果需要,上级主管部门再进行处理。
事件处理程序也是如此。在特定元素上设置处理程序的代码,了解有关该元素最详尽的信息。特定于 的处理程序可能恰好适合于该 ,这个处理程序知道关于该元素的所有信息。所以该处理程序应该首先获得机会。然后,它的直接父元素也了解相关上下文,但了解的内容会少一些,以此类推,直到处理一般性概念并运行最后一个处理程序的最顶部的元素为止。
冒泡和捕获为“事件委托”奠定了基础 —— 一种非常强大的事件处理模式,我们将在下一章中进行研究。