逆向参数: 请求头部参数x-s


这里的x-s参数分为两个部分,第一部分是固定的XYW,第二部分是一个base64的字符串,我们可以在浏览器控制台通过atob对这个base64字符串进行解码,解码后的结果为:

1
'{"signSvn":"56","signType":"x2","appId":"xhs-pc-web","signVersion":"1","payload":"c72b7da9c62fd16a0dca20ecfe2e6487d037b22fc2aef47c8019048b76c9e9a96a33d6d8123094f01c69fdc732fbebd9a6aced375af518943b2363d86817644fed7fa17f77fa6580f911c4603d0ebe24f74a1bc47a39b4ee6fd7941d37614923e0c8fc14e4a908b43a0e410571a7316e8c7ce5ee0c25647b38e61501251b03a52f67f0449126b38768236d95078a3606354ff0e08dec829eac4716bdc41ee7d034e397d3d56759abf9ebb2d0a3eb73f8016c8d96c6219ce0f37e961d680f9c03bbf75904fc8b8b8cfa15ecf391a4daf6"}'

可以看到解码后的字符串是一个json格式的字符串,所以接下来主要解决payload的参数加密问题

定位x-s参数的加密位置:
  1. 可以通过hook json的方式进行定位
    1
    2
    3
    4
    5
    6
    7
    8
    hook代码: 
    var _stringify = JSON.stringify;
    JSON.stringify = function () {
    if(arguments[0] && arguments[0]['payload']){
    debugger;
    }
    return _stringify(this,arguments);
    };
  2. 可以通过搜索关键字的方式进行定位

    直接搜索关键字X-s,区分大小写后搜索出来的结果不多,可以很轻松的找到加密的入口

定位到加密入口后进入到加密函数,将代码整体拷贝到本地编辑器进行分析

这里可以看到类ob混淆的特征,L是一个解密函数,F是一个数组函数,然后有一个自执行函数将F函数传入对数组进行移位,最后的自执行函数中有一个b函数,我们可以先通过AST进行反混淆,然后再进行分析

AST反混淆

反混淆思路:

  1. 先处理进制数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const simplifyLiteral = {
    NumericLiteral({node}) {
    if (node.extra && /^0[obx]/i.test(node.extra.raw)) {
    node.extra = undefined;
    }
    },
    StringLiteral({node}) {
    if (node.extra && /\\[ux]/gi.test(node.extra.raw)) {
    node.extra = undefined;
    }
    },
    }
    traverse(ast, simplifyLiteral);
  2. 将?.?还原成字面量
    我们需要将所有依赖的对象复制到AST文件中,然后再编写插件进行字面量还原
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const visitor = {
    MemberExpression(path) {
    let {node} = path
    if (!(node.object.name && node.computed == false && node.property.name)) {
    return
    }

    path.replaceWith(types.valueToNode(eval(`${node.object.name}[node.property.name]`)))
    }
    }
    traverse(ast, visitor)
    3.方法调用还原成字面量
    这里有很多地方对解密函数L进行了重复赋值,我们需要将解密函数以及解密函数依赖的数组函数和数组移位自执行函数都复制到AST文件中,然后再编写插件进行字面量还原
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    const funcList = ['L']

    const callToString = {
    VariableDeclarator(path) {
    let {node, scope} = path

    let {id, init} = node

    if (!types.isIdentifier(id) || !types.isIdentifier(init)) {
    return
    }

    if (!funcList.includes(init.name)) return

    let binding = scope.getBinding(id.name)

    if (!binding) return

    let {constantViolations, referencePaths} = binding

    if (constantViolations.length !== 0) return

    let flag = false

    for (const referPath of referencePaths) {
    let parentPath = referPath.parentPath
    if (!parentPath.isCallExpression({"callee": referPath.node})) continue

    let {callee, arguments} = parentPath.node

    if (!types.isIdentifier(callee)) continue

    if (arguments.length !== 1 || !types.isNumericLiteral(arguments[0])) {
    continue
    }

    let value = L(arguments[0].value)
    console.log(parentPath.toString(), '-->', value)
    parentPath.replaceWith(types.valueToNode(value))

    flag = true
    }

    funcList.push(id.name)
    flag && path.remove()
    }
    }
    traverse(ast, callToString)

AST解混淆之后可以将解混淆之后的代码在浏览器上进行替换测试一下是否正常,正常说明解混淆没问题的

调试VMP代码

我们在浏览器中新建一个代码片段去调试我们解混淆之后的VMP代码,先将解混淆之后的代码粘贴到代码段中,我们从页面上拿一下请求参数下来进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let url = "/api/sns/web/v1/homefeed"
let data = {
"cursor_score": "",
"num": 39,
"refresh_type": 1,
"note_index": 35,
"unread_begin_note_id": "",
"unread_end_note_id": "",
"unread_note_count": 0,
"category": "homefeed_recommend",
"search_key": "",
"need_num": 14,
"image_formats": [
"jpg",
"webp",
"avif"
],
"need_filter_image": false
}

let xs = window._webmsxyw(url, data)
console.log(xs)


点击运行代码后我们可以从控制台看到X-s的值已经打印出来了

插桩分析

一般对于VMP代码的插桩我们可以在apply的位置进行插桩
搜索apply关键字,一共可以搜索到三个为位置:

  1. 第一个位置是在数组中
  2. 第二位置
    1
    i["SEoiV"](V, new (Function["prototype"]["bind"]["apply"](F4, F2))(), E, E, 0);
    这里有一个Function,应该也不是
  3. 第三个位置
    1
    V(F3["apply"](typeof F2['_sabo_c724'] == C["IgyrF"] ? R : F2["_sabo_c724"], F1), E, E, 0);
    这里应该就是我们需要插桩的位置了,可以在这个位置打上一个日志点
    1
    "函数名称: ", F3, "this: ", typeof F2['_sabo_c724'] == C["IgyrF"] ? R : F2["_sabo_c724"], "参数: ", F1
日志分析

观察第一个关键点:

1
url=/api/sns/web/v1/homefeed{"cursor_score":"","num":39,"refresh_type":1,"note_index":35,"unread_begin_note_id":"","unread_end_note_id":"","unread_note_count":0,"category":"homefeed_recommend","search_key":"","need_num":14,"image_formats":["jpg","webp","avif"],"need_filter_image":false}

再往下翻日志可以看到一个数组[1732584193, -271733879, -1732584194, 271733878, 1030517365, 7, -680876936]
直接搜索一下发现数组的前四位1732584193, -271733879, -1732584194, 271733878是 MD5 算法的初始幻数(Initial Magic Numbers),对应 4 个 32 位寄存器(A、B、C、D)的初始值
后续数值可能的作用是:

  1. 输入分组的长度信息(如消息填充后的长度)
  2. 算法轮次中的中间变量(如循环左移位数或非线性函数的输出)
  3. 具体需结合上下文代码判断,但 MD5 的每一轮计算会涉及类似的位操作和常量

我们直接使用在线工具对以上字符串进行MD5加密,看看加密后的结果是什么,然后在打印的日志记录中搜索,发现搜索不到对应的字符串,我们接着往下翻日志,发现一下一段日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
函数名称:  ƒ charAt() { [native code] } this:  0123456789abcdef 参数:  [7]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [11]0: 11length: 1[[Prototype]]: Array(0)
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: 3 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [3]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [3]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: Æ 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [12]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [6]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: p 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [7]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [0]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: ß 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [13]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [15]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: Ú 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [13]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [10]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: ¥ 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [10]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [5]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: ê 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [14]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [10]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: W 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [5]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [7]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this:  参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [1]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [10]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: Ú 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [13]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [10]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: T 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [5]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [4]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: › 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [9]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [11]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this:  参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [1]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [12]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: Ž 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [8]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [14]
xs:2118 函数名称: ƒ charCodeAt() { [native code] } this: Ý 参数: [0]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [13]
xs:2118 函数名称: ƒ charAt() { [native code] } this: 0123456789abcdef 参数: [13]

这里对字符串0123456789abcdef调用了32次的charAt方法,我们将这32个字符串拼接起来就是得到的MD5加密后的结果

观察第二个关键点:
看到了下一个明文字符串4uzjr7mbsibcaldp,但是暂时不知道这个字符串对后续的加密有什么用处,先记录一下继续往下翻日志

观察第三个关键点:
继续往下翻日志可以看到往数组中push一个base64的字符串

接下来的日志就是对数组中的每一个字母进行charCodeAt操作,将charCodeAt后的结果再次push到一个新的数组中,我们直接往下翻找到最后的数组

这里可以看到是一个208位的数组,我们对数组fromCharCode操作一下,得到的结果就是一个base64的字符串

1
2
3
4
5
6
7
8
9
10
11
12
String.fromCharCode(...[101, 68, 69, 57, 78, 50, 73, 122, 77, 50, 77, 50, 78, 122, 66, 107, 90, 109, 82, 104, 89, 84, 86, 108, 89, 84, 85, 51, 77, 87, 70, 107, 89, 84, 85, 48, 79, 87, 73, 120, 89, 122, 104, 108, 90, 71, 81, 55, 101, 68, 73, 57, 77, 72, 119, 119, 102, 68, 66, 56, 77, 88, 119, 119, 102, 68, 66, 56, 77, 88, 119, 119, 102, 68, 66, 56, 77, 72, 119, 120, 102, 68, 66, 56, 77, 72, 119, 119, 102, 68, 66, 56, 77, 88, 119, 119, 102, 68, 66, 56, …])

输出的结果为:
'eDE9N2IzM2M2NzBkZmRhYTVlYTU3MWFkYTU0OWIxYzhlZGQ7eDI9MHwwfDB8MXwwfDB8MXwwfDB8MHwxfDB8MHwwfDB8MXwwfDB8MDt4Mz0xOTZjY2UyYTQ2MzdleXRhdzltem16aWY3cXVvNWZ6NGswM3U0OXh6MjUwMDAwMjA5MDE0O3g0PTE3NDA3MTA5MDc5NTg7\b\b\b\b\b\b\b\b'

将以上base64的字符串复制下来进行atob操作,得到的结果为:
'x1=7b33c670dfdaa5ea571ada549b1c8edd;x2=0|0|0|1|0|0|1|0|0|0|1|0|0|0|0|1|0|0|0;x3=196cce2a4637eytaw9mzmzif7quo5fz4k03u49xz250000209014;x4=1740710907958;'

x1=7b33c670dfdaa5ea571ada549b1c8edd; 加密后的md5结果
x2=0|0|0|1|0|0|1|0|0|0|1|0|0|0|0|1|0|0|0; 这里应该是对浏览器的环境检测
x3=196cce2a4637eytaw9mzmzif7quo5fz4k03u49xz250000209014; cookie中a1的值
x4=1740710907958; 时间戳

在上面解码的base64字符串中后面出现了很多的\b\b\b\b\b\b\b\b,查看日志发现是因为最后往数组的末尾push了8个8

这里push完8个8之后数组的长度变成了208位,208位的数组长度在密码学中可能是魔改的AES加密算法

观察第四个关键点:

往下查看日志后可以看到一个已经加密好的数组,将数组进行拼接其实就是我们需要的payload参数,那么现在最主要的就是解决如何进行加密得到最后的payload参数

核心加密逻辑分析

我们根据现有的日志没有办法再获取到更多的信息,我们继续观察插桩的位置,发现都是调用F3,而F3 = C[‘ckdoI’](j, F2),我们进入到ckdoI方法中,进入到ckdoI方法中发现调用了cAlyE方法,我们继续跟进去cAlyE方法,对cAlyE方法进行插桩

1
2
3
4
5
'cAlyE': function(C, f) {
const result = C(f)
window.log_result += result + '\r\n'
return result;
}

我们最后使用copy(window.log_result)的方式将日志拷贝下来到本地进行分析,我们先搜索一下字符串4uzjr7mbsibcaldp,然后往下查看日志,发现进行了多次的charCodeAt和push操作,最后出现了一个数组52,117,122,106,114,55,109,98,115,105,98,99,97,108,100,112,其实这个数组就是将字符串4uzjr7mbsibcaldp的每一个字符进行了charCodeAt操作然后得到的,长度是16位很有可能是做了异或操作

我们搜索52,117,122,106,114,55,109,98,115,105,98,99,97,108,100,112,定位到最后一次出现的位置继续往下翻可以看到一串base64的字符串eDE9N2IzM2M2NzBkZmRhYTVlYTU3MWFkYTU0OWIxYzhlZGQ7eDI9MHwwfDB8MXwwfDB8MXwwfDB8MHwxfDB8MHwwfDB8MXwwfDB8MDt4Mz0xOTZjY2UyYTQ2MzdleXRhdzltem16aWY3cXVvNWZ6NGswM3U0OXh6MjUwMDAwMjA5MDE0O3g0PTE3NDA3MTA5MDc5NTg7,这个就是需要加密的base64字符串,我们继续往下翻就看到了熟悉的208位数组,那么接下来就要开始进行加密了

接下来取出了数组的前16位101,68,69,57,78,50,73,122,77,50,77,50,78,122,66,107,紧接着出现了另外一个16位的数组81,49,63,83,60,5,36,24,62,91,47,81,47,22,38,27,结合刚才我们对字符串4uzjr7mbsibcaldp字符串charCodeAt操作得到的数组52,117,122,106,114,55,109,98,115,105,98,99,97,108,100,112可以发现如下特点:

1
2
3
4
101 ^ 52 = 81
68 ^ 117 = 49
69 ^ 122 = 63
57 ^ 106 = 83

所以可以确定数组52,117,122,106,114,55,109,98,115,105,98,99,97,108,100,112是一个iv,iv找到之后接下来就差key了

往下翻,81,49,63,83,60,5,36,24,62,91,47,81,47,22,38,27之后出现了很多的4位数,怀疑这里是将81,49,63,83,60,5,36,24,62,91,47,81,47,22,38,27进行了转换,我们对这个数组进行fromCharCode一下试试看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
String.fromCharCode(...[81, 49, 63, 83, 60, 5, 36, 24, 62, 91, 47, 81, 47, 22, 38, 27])
输出结果为:
Q1?S<$>[/Q/&

通过js代码还原一下
const CryptoJS = require('crypto-js')
const key = CryptoJS.enc.Utf8.parse('Q1?S<$>[/Q/&')
console.log(key)

输出结果为:
{
words: [1362181971, 1006969880, 1046163281, 789980699],
sigBytes: 16
}

我们直接搜索1362181971,1006969880,1046163281,789980699,发现可以搜索到内容

搜索到之后往下查看日志发现有对数组中的4个元素进行异或操作

1
2
1362181971 ^ 929260340 = 1362181971
1006969880 ^ 1633971297 = 1566656633

那么key的值为 929260340,1633971297,895580464,925905270

总结
  1. 将url和请求载荷进行拼接成字符串,将字符串进行MD5加密得到的结果
  2. 将字符串’x1=<md5值>;x2=0|0|0|1|0|0|1|0|0|0|1|0|0|0|0|1|0|0|0;x3=<cookie中a1的值>;x4=<时间戳>;’进行base64加密
  3. 将加密后的base64字符串进行AES加密
  4. 将字符串’{“signSvn”: “56”, “signType”: “x2”, “appId”: “ugc”, “signVersion”: “1”, “payload”: <aes加密后的字符串>}’进行base64加密
  5. 最终的base64值与字符串XYW_进行拼接

网址: aHR0cHM6Ly93d3cubWFzaGFuZ3BhLmNvbS9wcm9ibGVtLWRldGFpbC8xNC8=

1、抓包找到对应接口,点击查看启动器,点击启动器进入分析


这里可以看到是对params参数进行了加密

直接找到启动器中的loadPage进入分析

2、点击$.ajax()函数

在$.ajax的位置发现发现加密参数,那么应该是对ajax进行了重写,我们直接打上断点步入函数进行调试

3、断点到send发包的位置


这里一直跟到了send发包的位置还是没有发现加密参数

我们将鼠标悬停在send函数上发现这个send函数是可以点击进入调试的,我们可以随便找一个用ajax发送请求的网站来对比一下就可以发现如果是原生的ajax请求send是一个bultin函数,而这里的send是一个function,说明这里对ajax进行了重写,我们直接点击send函数进入调试

4、分析send函数


这里可以看到open函数并非一个bultin函数,而是一个function,我们直接跟进open函数进入调试

进入open函数之后很快就找到了加密的位置,接下来就是着重分析这个x函数

5、分析x函数

观察整体代码可以发现是一个类似于ob的混淆,我们可以通过AST进行反混淆

6、反混淆思路
  1. 先处理十六进制数据
  2. 处理完十六进制数据之后可以发现大量的计算操作,直接进行常量折叠
  3. 字面量还原。其实这里一个类ob混淆,解密函数和自执行函数,我们可以先把解密函数__sk_Q及解密函数依赖的函数__sk_z和自执行函数先扣取到本地进行字面量还原。混淆代码对解密函数进行了多次的重复赋值,我们可以先将解密函数名__sk_Q存进数组中,通过绑定的方式找到赋值语句,判断赋值语句右节点的变量名称是否在数组中,如果在的话通过左边节点的变量名称的引用位置的父节点找到需要还原的代码(如a(398)),我们已经将解密的代码拿到了本地,我们获取到参数后直接调用__sk_Q(398)得到实际字面量之后替换节点即可,最后再将左节点的变量名称添加到数组中,这样就可以完美解决解密函数重复赋值的问题。

网址: aHR0cHM6Ly93d3cubWFzaGFuZ3BhLmNvbS9wcm9ibGVtLWRldGFpbC8xMi8=

1、抓包找到对应接口,点击查看启动器,点击启动器进入分析

2、打上断点进行翻页


在$.ajax()打上断点,做过图灵题目的都知道这里的$.ajax是关键的加密逻辑,这里的$.ajax并非是来自于jquery,而是对ajax进行了重写

3、点击$.ajax()函数

进入到$.ajax()函数中发现里面的变量名以及函数名经过了混淆,我们观察整体代码,可以看到有明显的jsvmp混淆特征

可以参考网站:https://www.jsjiami.com/

4、全扣补环境

对于jsvmp混淆的代码,这里直接采用全扣补环境的方式,将代码全部拿下来之后在本地先运行,出现如下报错:

1
2
3
OOO00OO['push'](Q0OQO0O[OQOQQ0Q]);

TypeError: Cannot read properties of undefined (reading 'ajax')

定位到报错的行号打印对应的Q0OQO0O[OQOQQ0Q]

1
2
console.log(Q0OQO0O[OQOQQ0Q])
console.log(OQOQQ0Q)

这里发现Q0OQO0O[OQOQQ0Q]是一个undefined,OQOQQ0Q的值为$,这里我们就需要在页面上对应的位置打上条件断点,去观察看看浏览器中当OQOQQ0Q==$时Q0OQO0O[OQOQQ0Q]的值是什么

发现当OQOQQ0Q==$时Q0OQO0O[OQOQQ0Q]是一个function,那我们在这里改造一下代码,当OQOQQ0Q==$时给Q0OQO0O[OQOQQ0Q]赋值为一个function

1
2
3
4
5
6
7
$e = function() {

}
if (OQOQQ0Q === '$') {
Q0OQO0O[OQOQQ0Q] = $e
}
OOO00OO['push'](Q0OQO0O[OQOQQ0Q]);

继续运行会发现接下来就是补一些浏览器的环境对象了,我们直接上代理器进行补环境,补完环境之后发现不报错了,但是也不出结果,那是因为我们没有调用对应的启动函数,我们直接调用loadPage函数传入参数(参数为对应的页码),在调用loadPage函数的时候还是会出现一些报错,这里直接修改一下loadPage函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function loadPage(page) {
$.ajax({
url: `/api/problem-detail/12/data/?page=${page}`,
method: 'GET',
dataType: 'json',
success: function (data) {
if (!data) {
return
}
updatePageContent(data);
},
error: function (jqXHR, textStatus, errorThrown) {
console.error('Error fetching problem details:', textStatus, errorThrown);
}
});
}

改造完loadPage函数之后还会出现报错$不存在,根据刚才在浏览器条件断点的时候,window.$是一个function,我们将window.$复制给了$e,那么我们需要把$e的值复制给window.$

1
2
3
4
$e = function() {

}
window.$ = $e

再次运行还是出现报错,我们将pycharm调试模式下的出现异常断点模式打开,右击进行调试模式,等到出现异常时断住,我们可以看到其实完整的请求url已经来了,在QQO0OQ0参数中

出来之后我们往上看代码,发现QQO0OQ0最开始赋值的是一个空数组然后经过for循环进行元素的push,那么我们改造一下,当数组的长度为4时将数组中的url进行全局导出然后将代码进行return,这样就可以得到最终的结果了

1
2
3
4
5
6
7
QQO0OQ0 = [];
for (Q0OQO0Q = 0x0; QQ0OQOQ[OQQQ00Q(0x258, 'TUGE')](Q0OQO0Q, OQOQQ0Q); Q0OQO0Q++)
QQO0OQ0[OQQQ00Q(0x31e, 'ck!W')](OOO00OO[OQQQ00Q(0x295, 'SEv2')]());
if (QQO0OQ0.length === 4) {
window.url = QQO0OQ0[2]['url']
return
}

我们在调用完loadPage函数之后,将window.url的值打印出来,发现已经得到了最终的结果了,将调用过程封装成一个函数传参调用

1
2
3
4
5
6
function get_url(page) {
loadPage(page)
console.log(window.url)
}

get_url(process.argv[2])
5、python调用
1
2
3
4
5
6
7
8
base_url = "https://www.mashangpa.com"

for page in range(1, 21):
url = subprocess.run(['node', 'encrypt.js', str(page)], capture_output=True, text=True)
new_url = url.stdout.strip()
finally_url = base_url + new_url

response = requests.get(finally_url, headers=headers, cookies=cookies)

random-user-agent介绍

random-user-agent 是一个用于随机生成用户代理字符串的 Python 库。它可以帮助你在进行网络爬虫或发送 HTTP 请求时随机化用户代理,从而降低被网站检测的风险。

1
pip install random-user-agent

random-user-agent的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
from random_user_agent.user_agent import UserAgent
from random_user_agent.params import SoftwareName, OperatingSystem

def random_user_agent():
# 生成随机的user-agent
software_names = [SoftwareName.CHROME.value, SoftwareName.FIREFOX.value, SoftwareName.EDGE.value,
SoftwareName.SAFARI] # 指定浏览器
operating_systems = [OperatingSystem.WINDOWS.value] # 指定操作系统

user_agent_rotator = UserAgent(software_names=software_names, operating_systems=operating_systems, limit=100)

random_user_agent = user_agent_rotator.get_random_user_agent()
return random_user_agent

在上述代码是使用random_user_agent的示例,random_user_agent可以生成多种浏览器的及操作系统的user_agent,使用起来非常的方便,定制性非常强,用户可以根据自己的需要去定制ua生成的范围,比如某些网站只支持web浏览器的请求头,那么operating_systems参数就可以指定对应的ua生成范围为web浏览器。

software_names参数用来指定浏览器,接收的是一个列表参数,下面是software_names参数支持的浏览器类型,支持的浏览器非常的多,除了主流的浏览器之外支持其他的浏览器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ALERTSITE_MONITORING_BOT = 'alertsite-monitoring-bot'
ANDROID = 'android-browser'
AWESOMIUM = 'awesomium'
BLACKBERRY = 'blackberry-browser'
CATCHPOINT_ANALYSER = 'catchpoint-analyser'
CHROME = 'chrome'
CHROMIUM = 'chromium'
COSMOS_CRAWLER = 'cosmos-crawler'
DOTCOM_MONITOR_BOT = 'dotcom-monitor-bot'
EDGE = 'edge'
FIREFOX = 'firefox'
FIREFOX_FOCUS = 'firefox-focus'
GOOGLE_APP_ENGINE = 'google-app-engine-software'
......

operating_systems参数用来指定操作系统,接收的也是一个列表参数,下面是operating_systems参数支持的操作系统类型,支持的操作系统也是非常的多

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
UNIX = 'a-unix-based-os'
ANDROID = 'android'
BADA = 'bada'
BEOS = 'beos'
BLACKBERRY = 'blackberry-os'
CHROMEOS = 'chromeos'
FIRE_OS = 'fire-os'
FREEBSD = 'freebsd'
HAIKU = 'haiku'
IOS = 'ios'
LINUX = 'linux'
MAC = 'mac'
MAC_OS_X = 'mac-os-x'
MACOS = 'macos'
OPENBSD = 'openbsd'
SUNOS = 'sunos'
SYMBIAN = 'symbian'
WEBOS = 'webos'
WINDOWS = 'windows'
WINDOWS_MOBILE = 'windows-mobile'
WINDOWS_PHONE = 'windows-phone'
HP_WEBOS = 'hp-webos'
DARWIN = 'darwin'
IRIX = 'irix'
RIM_TABLET_OS = 'rim-tablet-os'
LIVEAREA = 'livearea'
PALMOS = 'palmos'

limit参数用来指定生成ua的个数,最终从生成的ua中随机选择一个ua返回。

random-user-agent库的优势

1.随机化用户代理:通过随机生成用户代理字符串,可以有效降低被网站检测到的风险,避免被封锁或限制访问。
2.易于使用:库的 API 设计简单,方便快速集成到现有项目中,无需复杂配置。
3.多样化用户代理:提供了多种常见的用户代理字符串,可以模拟不同浏览器和设备,增加请求的多样性。
4.缓存支持:支持缓存功能,可以提升获取用户代理的速度,减少请求延迟。
5.可定制:用户可以传入自定义的用户代理列表,满足特定需求,提供灵活性。
6.跨平台:作为一个 Python 库,支持在多种操作系统上使用,适合各种环境。

深入理解JavaScript原型链

JavaScript 是一种基于原型的语言。理解原型和原型链对于掌握 JavaScript 的继承机制和对象模型至关重要。在本文中,我们将深入探讨 JavaScript 原型链的概念及其在实际编程中的应用。

1.什么是原型

在 JavaScript 中,每个对象都有一个与之关联的原型对象。这个原型对象也是一个对象,它可以拥有自己的原型,以此类推,形成一个链条,这就是所谓的原型链。

2.构造函数和原型
1
2
3
4
5
6
7
8
9
10
11
12
13
function Person(name) {
this.name = name;
}

Person.prototype.sayHello = function() {
console.log('Hello, my name is ' + this.name);
};

let person1 = new Person('Alice');
let person2 = new Person('Bob');

person1.sayHello(); // Hello, my name is Alice
person2.sayHello(); // Hello, my name is Bob

在上述的实例中Person是一个构造函数,Person.prototype是Person构造函数的原型对象,sayHello方法被添加到Person.prototype上,这意味着所有 Person 的实例都可以访问这个方法。

3.property和__proto__属性

在JavaScript中,对象属性分为两类:自身属性和原型链属性。
自身属性是直接定义在对象自身上的属性,原型属性是通过原型链继承的属性。

1
2
3
4
5
6
7
8
const person = {
name: "Alice",
age: 30
};

console.log(person.hasOwnProperty('name')); // true
console.log(person.hasOwnProperty('age')); // true
console.log(person.hasOwnProperty('toString')); // false,因为 toString 是从 Object.prototype 继承来的

__proto__属性是每一个对象都有的一个内部属性,它指向对象的原型(即构造函数的 prototype 属性)。

4.原型链

当我们访问对象的属性或方法时,JavaScript 引擎会首先在对象自身的属性中查找。如果找不到,则会沿着原型链向上查找,直到找到属性或到达原型链的顶端(Object.prototype),其 proto 为 null。

1
2
3
console.log(person1.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__ === null); // true

原型链检测

在我们扣代码时通常会遇到一些环境检测,环境检测通过才会返回正确的结果,但我们在补环境时通常不会按照实际浏览器的原型链去补,下面是常用的补环境方式:

1
2
navightor = {}
navightor.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0'

当js代码中存在原型链检测,这种补环境的方式是无法通过的,比如:

1
2
3
4
5
6
7
8
navightor = {}
navightor.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0'
if (Object.hasOwnProperty(navigator, 'userAgent')) {
return '错误的结果'
} else {
return '正确的结果'
}
// hasOwnProperty方法是JavaScript中所有对象从 Object.prototype 继承而来的一个方法。它用于检查一个对象是否拥有指定的属性(该属性必须是对象自身的属性,而不是从原型链继承来的属性)。

上面这种常规补环境的方式如果遇到原型链的检测那无法得到正确的结果,我们必须符合浏览器的环境,所以我们要将对应的属性补在原型链上,如下是补环境的方式符合浏览器的真实环境:

1
2
3
4
5
6
7
Navigator = function () {
}
Navigator.prototype = {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0'
}
navigator = new Navigator()
// 以上将userAgent属性补在了原型对象上面,符合真实的浏览器环境

描述符检测

Object.getOwnPropertyDescriptor() 方法返回指定对象上一个自有属性对应的属性描述符。(自有属性指的是直接赋予该对象的属性,不需要从原型链上进行查找的属性)

1
2
3
4
5
6
7
8
9
// 浏览器里面执行
Object.getOwnPropertyDescriptor(navigator,'userAgent') // ---> undefined

// node环境执行
var navigator = {
userAgent:"aaaaa"
}
Object.getOwnPropertyDescriptor(navigator,'userAgent')
// 输出{value: 'aaaaa',writable: true,enumerable: true,configurable: true}

value:与属性关联的值(仅限数据描述符)。
writable:当且仅当与属性关联的值可以更改时,为 true(仅限数据描述符)
configurable:当且仅当此属性描述符的类型可以更改且该属性可以从相应对象中删除时,为 true
enumerable:当且仅当此属性在相应对象的属性枚举中出现时,为 true

以下是检测描述符检测的正确补法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 第1种方式,直接通过navigator来补他的隐式原型
navigator = {}
navigator.__proto__.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'

console.log(navigator.userAgent);
console.log(Object.getOwnPropertyDescriptor(navigator, 'userAgent'));

// 第2种方式, 通过navigator创建的类来补显式原型
var Navigator = function() {};
Navigator.prototype = {"userAgent": "123123123"};
navigator = new Navigator();
console.log(navigator.userAgent)
console.log(Object.getOwnPropertyDescriptor(navigator, 'userAgent'));

// 补方法
Document = function Document(){}
// Object.defineProperty 直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。
Object.defineProperty(Document.prototype,'createElement',{
configurable: true,
enumerable: true,
value: function createElement(){},
writable: true,
})
HTMLDocument = function HTMLDocument(){}
//可以将一个指定对象的原型(即内部的隐式原型属性)设置为另一个对象
Object.setPrototypeOf(HTMLDocument.prototype,Document.prototype)
document = new HTMLDocument()

console.log(Object.getOwnPropertyDescriptor(document.__proto__.__proto__, 'createElement'));

toString检测

在 JavaScript 中,toString() 是一个内置函数,用于将一个值转换为字符串。
toString() 方法可以应用于大部分 JavaScript 值类型,包括数字、布尔值、对象、数组等。它的返回值是表示该值的字符串。
如果js代码中检测toSting方法,那么即使我们按照原型链的方式去补环境也无法通过检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Document = function Document(){}
Object.defineProperty(Document.prototype,'createElement',{
configurable: true,
enumerable: true,
value: function createElement(){},
writable: true,
})
HTMLDocument = function HTMLDocument(){}
Object.setPrototypeOf(HTMLDocument.prototype,Document.prototype)
document = new HTMLDocument()

console.log(document.toString()); --> 输出function createElement(){}

// 浏览器控制台输出 --> 'function createElement() { [native code] }'
// 这个时候如果检测了toString即使补原型链也无法通过
// 我们可以重写toString方法来达到目的
Document = function Document(){}
Object.defineProperty(Document.prototype,'createElement',{
configurable: true,
enumerable: true,
value: function createElement(){},
writable: true,
})

HTMLDocument = function HTMLDocument(){}
Object.setPrototypeOf(HTMLDocument.prototype,Document.prototype)
document = new HTMLDocument()
document.createElement.toString = function(){return "function createElement() { [native code] }"}
console.log(document.createElement.toString());

什么是jsdom

jsdom是一个纯粹由 javascript 实现的一系列 web标准,特别是 WHATWG 组织制定的DOMHTML 标准,用于在nodejs中使用。大体上来说,该项目的目标是模拟足够的Web浏览器子集,以便用于测试和挖掘真实世界的Web应用程序

jsdom的基本使用

1
2
3
4
5
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const dom = new JSDOM(`<div>Hello Wrold!</div>`);
title = dom.window.document.querySelector("div").textContent
console.log(title)

很多人都喜欢使用jsdom来补环境,但是jsdom设置userAgent时会有jsdom的一些标识,如果生成的数据中检测了userAgent就会导致数据错误从而请求失败

以下是常见的userAgent的设置方式

1
2
3
4
5
6
7
8
9
10
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
const dom = new JSDOM(``, {
url: "https://example.org/",
referrer: "https://example.com/",
userAgent: userAgent,
});
window = dom.window
console.log(window.navigator.userAgent)

以上代码输出的结果为: Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/24.0.0
在userAgent中有很明显的jsdom特征

再次尝试使用如下代码:

1
2
3
4
5
6
7
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const dom = new JSDOM();
window = dom.window
navigator = window.navigator
navigator.userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
console.log(window.navigator.userAgent)

以上代码输出的结果仍然为: Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/24.0.0

jsdom正确设置userAgent

1
2
3
4
5
6
7
8
9
10
11
12
13
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
const resourceLoader = new jsdom.ResourceLoader({
userAgent: userAgent
});
const dom = new JSDOM(``, {
url: "https://example.org/",
referrer: "https://example.com/",
resources: resourceLoader,
});
window = dom.window
console.log(window.navigator.userAgent)

以上代码输出的结果为: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
通过以上方式则可以正常设置userAgent

有些网站一打开控制台页面就会发生跳转会关闭当前页面,防止我们对网页进行抓包分析

常见的检测用户打开开发者工具的js代码

1.通过profiles记录里面有没有数据来判断用户是否打开了控制台

1
2
3
4
5
6
7
8
9
10
function ck() { 
console.profile();
console.profileEnd(); //我们判断一下profiles里面有没有东西,如果有,肯定有人按F12了,没错!!
if(console.clear) {
console.clear()
};
if (typeof console.profiles =="object"){
return console.profiles.length > 0;
}
}

2.通过判断窗口的高度来检测用户是否打开了控制台

1
2
3
4
5
6
7
8
9
function fuckyou(){ 
window.close(); //关闭当前窗口(防抽)
window.location="about:blank"; //将当前窗口跳转置空白页
}

window.onresize = function(){
if((window.outerHeight-window.innerHeight)>200) //判断当前窗口内页高度和窗口高度,如果差值大于200,则关闭当前窗口跳转至空白页
fuckyou();
}

如何进行调试

1.分析打开网站后网站的表现(如:跳转至指定页面、关闭当前窗口、控制输出”已清除控制台”等)

2.提前打开控制台,在源代码页面中找到事件侦听器断点,勾选脚本选项,再次进入指定网站

3.页面处于断点时搜索文件中相关的js代码进行定位,找到对应的位置

如搜索window.close/console.clear/window.location.href/window.history.back

4.将对应的js代码进行注释,并替换文件

1.在要扣取的webpack模块内打上断点,刷新页面在断点处停留

2.进入到加载器,在加载器第一行的位置打上断点并点击下一步进入到加载器,将加载器置空,并重新命名一个对象

3.在加载器第一行的断点取消并右键添加条件断点,输入aaa[t] = e[t],0

4.回到最开始打上断点的webpack模块,在下方位置再次添加一处断点,防止模块加载过多

5.点击跳到下一个断点位置

6.控制台输入aaa就可以看见webpack所加载的模块了

7.使用以下js代码将加载的webpack模块导出

1
2
3
4
5
result = '{';
for (let x of Object.keys(aaa)) {
result = result + '"' + x + '"' + ":" + aaa[x] + ','
}
result = result + '}'

1、爬虫子模块的使用

1.1.通过parse函数返回字典数据

通过(scrapy genspider 爬虫名称 目标网址)命令可以创建我们的爬虫子模块,爬虫子模块的作用主要是负责定义白名单、起始请求的url地址、以及对response对象的数据清洗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def parse(self, response: HtmlResponse, **kwargs):
a_list = response.xpath("//div[@class='rank-list']/a")
for a_temp in a_list:
rank_number = a_temp.xpath('./div[@class="badge"]/text()').extract_first() # 排名
img_url = a_temp.xpath('./img/@src').extract_first() # 图片地址
title = a_temp.xpath('./div[@class="content"]/div[@class="title"]/text()').extract_first() # 标题
desc = a_temp.xpath('./div[@class="content"]/div[@class="desc"]/text()').extract_first() # 描述
play_number = a_temp.xpath('.//div[@class="info-item"][1]/span/text()').extract_first()

# 需要使用yield关键字将解析好的数据交给管道模块
yield {
'type': 'info',
'rank_number': rank_number,
'img_url': img_url,
'title': title,
'desc': desc,
'play_number': play_number
}

注意: yield能够传递的对象只有: BaseItem、Request、dict、None

1.2.通过parse函数返回request对象

在实际爬虫过程中我们可能需要先提取到一个url,再通过url再次发起请求去获取对应的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def parse(self, response: HtmlResponse, **kwargs):
a_list = response.xpath("//div[@class='rank-list']/a")
for a_temp in a_list:
rank_number = a_temp.xpath('./div[@class="badge"]/text()').extract_first() # 排名
img_url = a_temp.xpath('./img/@src').extract_first() # 图片地址
title = a_temp.xpath('./div[@class="content"]/div[@class="title"]/text()').extract_first() # 标题
desc = a_temp.xpath('./div[@class="content"]/div[@class="desc"]/text()').extract_first() # 描述
play_number = a_temp.xpath('.//div[@class="info-item"][1]/span/text()').extract_first()

# 需要使用yield关键字将解析好的数据交给管道模块
yield {
'rank_number': rank_number,
'img_url': img_url,
'title': title,
'desc': desc,
'play_number': play_number
}

# 自行构造新的request请求并交给下载器下载
"""
callback: 指定解析函数
cb_kwargs: 如果解析方法中存在形参,则可以通过cb_kwargs传递,传递参数的类型必须是字典,字典中的key必须与解析方法中的形参名称保持一致
"""
yield scrapy.Request(img_url, callback=self.parse_imgage, cb_kwargs={'image_name': title})

def parse_imgage(self, response, image_name):
yield {
'image_name': image_name.replace('|', '') + '.png',
'image_content': response.body
}

在上一个例子中我们通过yield返回了一个request对象,通过callback定义了一个回调函数,这个回调函数主要负责去对该request请求的响应结果进行解析,通过cb_kwargs将回调函数需要的参数值进行传递。
注意: cb_kwargs接收的是一个字典,并且字典中的key名称必须与回调函数的形参名称保持一致。

1.3.start_request方法的使用

假设我们需要访问多页的数据或者访问的是一个api接口,如果将所有的请求都放在start_urls列表中就会显得很臃肿,并且可扩展性不高,那么我们可以在爬虫子类中重写start_requests方法来定义我们请求的规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Top250Spider(scrapy.Spider):
"""
如果使用spider类默认的请求方式,则不会对重复请求进行过滤
会重复请求相同的url
"""
name = "top250"
allowed_domains = ["movie.douban.com"]
start_urls = ["http://movie.douban.com/top250", "http://movie.douban.com/top250"]

# 需要实现对同一个url去重操作,必须重写start_requests方法
# 如果请求的地址是api接口,则需要通过start_request方法构造链接
def start_requests(self):
url = 'https://movie.douban.com/top250?start={}&filter='
for page in range(10):
yield scrapy.Request(url.format(page * 25), dont_filter=False)

当我们重写了start_requests方法时,爬虫启动后则不会去遍历start_urls列表构造request请求,而是直接进入start_requests方法中构造我们自己定义的请求规则。scrapy.Request方法中有一个参数dont_filter,这个参数主要的作用是在请求url时不会重复请求相同的url

1.4.发送post请求

1.4.1.发送表单数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class JcInfoSpider(scrapy.Spider):
name = "jc_info"

def start_requests(self):
url = 'http://www.xxx.com.cn/new/disclosure'
for page in range(1, 11):
data = {
"column": "szse_latest",
"pageNum": str(page),
"pageSize": "30",
"sortName": "",
"sortType": "",
"clusterFlag": "true"
}
yield scrapy.FormRequest(url=url, formdata=data)
1.4.2.发送json数据
1
2
3
4
5
6
7
8
9
10
11
12
13
from scrapy.http import JsonRequest

class NetEaseInfoSpider(scrapy.Spider):
name = "netease_info"

def start_requests(self):
url = 'https://xxx.com/api/hr163/position/queryPage'
for page in range(1, 10):
payload = {
"currentPage": page,
"pageSize": 10
}
yield JsonRequest(url, data=payload)

发送json数据的时候需要导入JsonRequest方法

2、下载中间件的使用

下载中间件位于middlewares.py文件中,当引擎将request对象交给下载器时会先经过下载中间件,下载中间件可以给request添加请求头或者代理等,自定义的下载中间件需要在setting文件中激活。

2.1.下载中间件给request对象添加请求头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class UserAgentDownloaderMiddleware:
USER_AGENTS_LIST = [
"Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET CLR 2.0.50727; Media Center PC 6.0)",
"Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0; WOW64; Trident/4.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET CLR 1.0.3705; .NET CLR 1.1.4322)",
"Mozilla/4.0 (compatible; MSIE 7.0b; Windows NT 5.2; .NET CLR 1.1.4322; .NET CLR 2.0.50727; InfoPath.2; .NET CLR 3.0.04506.30)",
"Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN) AppleWebKit/523.15 (KHTML, like Gecko, Safari/419.3) Arora/0.3 (Change: 287 c9dfb30)",
"Mozilla/5.0 (X11; U; Linux; en-US) AppleWebKit/527+ (KHTML, like Gecko, Safari/419.3) Arora/0.6",
"Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.2pre) Gecko/20070215 K-Ninja/2.1.1",
"Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9) Gecko/20080705 Firefox/3.0 Kapiko/3.0",
"Mozilla/5.0 (X11; Linux i686; U;) Gecko/20070322 Kazehakase/0.4.5"
]

def process_request(self, request, spider):
print('下载中间件的process_request方法被调用...')
user_agent = random.choice(self.USER_AGENTS_LIST)
request.headers['User-Agent'] = user_agent

"""
如果返回None, 表示当前的request提交下一个权重低的process_request。
如果传递到最后一个process_request,则传递给下载器进行下载。

如果返回的是response对象则不再请求,把response对象直接交给引擎
"""

2.2.下载中间件给request对象添加代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ProxyDownloaderMiddleware:
def process_request(self, request, spider):
print('免费代理中间件 - 代理设置...')

# 元数据
request.meta['proxy'] = 'http://117.0.0.1:7890'
print(request.meta['proxy'])

def process_response(self, request, response, spider):
print('免费中间件 - 代理检测...')
if response.status != 200:
request.dont_filter = True # 关闭过滤
return request # 把请求失败的请求对象重新提交给调度器
return response

3、管道的使用

管道用于接收spider解析好的数据,通过管道可以将解析好的数据进行保存,使用管道前需要将管在setting文件中进行激活
管道中的方法:
process_item(self, item, spider): 管道中必须要有的方法,实现对item数据的处理,一般情况下都会return item,如果没有return item,那么会将None传递给权重较低的process item
open_spider(self, spider): 在爬虫开启的时候仅执行一次,可以在该方法中链接数据库、打开文件等
close_spider(self, spider): 在爬虫关闭的时候仅执行一次,可以在该方法中关闭数据库连接、关闭文件对象等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class TxWorkFilePipeline:
# 在爬虫启动时创建一个文件对象
def open_spider(self, spider): # spider: 接收的是spider类的一个实例
if spider.name == 'tx_work_info':
self.file_obj = open('tx_work_info.txt', 'a', encoding='utf-8')

def process_item(self, item, spider):
"""
在当前方法中可以对item进行数据判断,如果不符合数据要求,一般有两种方式来处理:
1. 扔掉
抛出一个异常中断当前管道,阻止item通过return传递给下一个管道
2. 修复
在当前方法中编辑修复代码逻辑并使用return将修复的数据传递给下一个item

注意:
当前方法如果存在return item则将item数据传递给下一个item
如果return不存在则将None传递给下一个item
"""
# raise DropItem # 丢掉不符合要求的数据
if spider.name == 'tx_work_info':
self.file_obj.write(json.dumps(item, ensure_ascii=False, indent=4) + '\n')
print('数据写入成功:', item)
return item

def close_spider(self, spider):
if spider.name == 'tx_work_info':
self.file_obj.close()


class TxWorkMongoPipeline:
# 在爬虫启动时创建一个数据库链接对象对象
def open_spider(self, spider): # spider: 接收的是spider类的一个实例
if spider.name == 'tx_work_info':
self.mongo_client = pymongo.MongoClient(host='192.168.198.130')
self.collection = self.mongo_client['py_spider']['tx_work_info']

def process_item(self, item, spider):
if spider.name == 'tx_work_info':
self.collection.insert_one(item)
print('数据写入mongodb成功:', item)
return item

def close_spider(self, spider):
if spider.name == 'tx_work_info':
self.mongo_client.close()

上述例子中自定义了2个管道类,这两个管道类分别将数据存储在不同的地方,需要注意的是如果某一个管道类需要先执行完再去执行下一个管道,则需要在settings文件中将需要先执行的管道类权重低于后执行管道类的权重

4.settings文件的参数详解

1
ROBOTSTXT_OBEY = True

是否开启robots.txt验证

1
DOWNLOAD_DELAY = 3

请求延迟

1
2
3
4
DEFAULT_REQUEST_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en",
}

设置默认的请求头信息

1
2
3
SPIDER_MIDDLEWARES = {
"TxWork.middlewares.SeleniumDownloaderMiddleware": 543,
}

爬虫中间件的配置信息

1
2
3
DOWNLOADER_MIDDLEWARES = {
"TxWork.middlewares.SeleniumDownloaderMiddleware": 543,
}

下载中间件的配置信息,若要使用下载中间件就需要在settings文件中将下载中间件的注释放开

1
2
3
4
ITEM_PIPELINES = {
"TxWork.pipelines.TxWorkFilePipeline": 300,
"TxWork.pipelines.TxWorkMongoPipeline": 301
}

管道配置信息,若要使用管道则需要在settings文件中手动开启

1
2
3
EXTENSIONS = {
"scrapy.extensions.telnet.TelnetConsole": None,
}

拓展类的配置信息,如果自定义了一些工具类想要使用,则需要在settings文件中启用

5、items的使用

items类主要用于进行字段的校验,如果将数据保存进数据库,数据库表字段名称和我们最终解析返回字典数据中的key不匹配时会导致数据插入失败,我们可以通过在items中定义字段名用于校验解析数据字典的key是否准确。
先将期望的数据库表字段在items中进行声明

1
2
3
4
5
6
7
8
9
10
class BookItem(scrapy.Item):
"""
items文件主要用于字段校验
"""
title = scrapy.Field()
price = scrapy.Field()
author = scrapy.Field()
date_data = scrapy.Field()
detail = scrapy.Field()
producer = scrapy.Field()

在爬虫文件中导入定义的items类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BookInfoSpider(scrapy.Spider):
name = "book_info"
start_urls = ["http://xxx.com/"]

def parse(self, response: HtmlResponse, **kwargs):
li_list = response.xpath('//ul[@class="bigimg"]/li')
for li in li_list:
item = BookItem()
item['title'] = li.xpath('./a/@title').extract_first()
item['price'] = li.xpath('./p[@class="price"]/span[1]/text()').extract_first()
item['author'] = li.xpath('./p[@class="search_book_author"]/span[1]/a[1]/@title').extract_first()
item['date_data'] = li.xpath('./p[@class="search_book_author"]/span[last()-1]/text()').extract_first()
item['detail'] = li.xpath('./p[@class="detail"]/text()').extract_first() if li.xpath(
'./p[@class="detail"]/text()') else '空'
item['producer'] = li.xpath(
'./p[@class="search_book_author"]/span[last()]/a/text()').extract_first() if li.xpath(
'./p[@class="search_book_author"]/span[last()]/a/text()') else '空'

yield item

当我们定义字典的key时,没有按照items声明的key进行定义则会抛出异常

scrapy框架内置模块


注意: 爬虫中间件和下载中间件只是运行逻辑的位置不同,作用是重复的: 如替换user-Agent、设置代理ip等。

scrapy框架的执行流程


scrapy框架执行流程说明:
1.scrapy从spider子类中提取start_urls,然后构造为request请求对象
2.将request对象传递给爬虫中间件
3.再将request对象传递给scrap引擎
4.将request对象传递给调度器(调度器负责多个request调度,好比交通管理负责交通的指挥员)
5.将request对象传递给scrapy引擎
6.scrapy引擎将request请求对象传递给下载中间件(下载中间件可以更换代理IP、更换cookie、更换user-agent以及自动重试等)
7.request对象传递给下载中间件经过处理后会给到下载器(下载器通过异步的方式发送http(s)请求),得到响应后封装为response对象
8.将response对象传递给下载中间件
9.下载中间件将response对象传递给scrapy引擎
10.scrapy引擎将response对象传递给爬虫中间件(这里可以处理异常情况等)
11.爬虫对象中的parse函数被调用(这里主要负责对接收到的response对象进行处理。如通过xpath提取数据等)
12.将提取的数据传递给scrapy引擎,它将数据再传递给管道(在管道中可以定义数据存储的方式,如MySQL、Mongodb、csv文件等)

0%