首先声明:本爬虫仅供学习交流使用,没有任何商业用途,如有侵犯行为,请联系作者删除!

由于毕业设计的内容与网易云音乐相关,需要得到一些音乐的评论数据,而网易云的评论可以说是这个平台最具价值的数据了。当然,里面的加密也有点复杂,爬取过程比较曲折,所以有了这次记录,本次爬取纯粹是练手,因为js逆向还没入门,等之后有所了解了再好好爬爬。

拿《Young And Beautiful》这首歌的评论来练手:

首先点击鼠标右键,查看源代码,看看这些评论是否是服务端渲染了发送给客户端的:

没有搜索到内容,说明是服务器发送json数据给客户端,由客户端渲染后呈现在页面上的,所以就需要我们对响应数据进行抓包查看:

从上到下逐一排查后发现在这个getxxx里面,现在就得到了请求的url,并且可以看到请求的类型是post,然后再看看请求参数:

再payload中找到form data,但是很不幸,两个参数:params和encSecKey都已经加密了,所以现在我们要对调用的js进行逆向分析:

在initiator中可以看到有一个Request call stack(请求调用栈),这个就是在发出请求时所执行的一系列js函数,顺序是从下到上(栈),也就是说最上面的js函数是最后一次调用的,现在点击这个函数,去看看里面的参数情况:

现在看到的js代码是经过压缩过的,所以我们可以点击左下角的那个括号,由chrome帮我们美化代码,要不然根本不可读

现在右边什么参数也没有,需要我们手动调试一下,首先在最后一行代码处打一个断点,然后刷新网页

现在有数据了,但是url并不是那个get啥的,所以需要点击②这个继续的按钮,直到显示的url是get

我们可以看到,现在的参数是经过加密过的,那么下一步动作便是从上到下点击观察调用栈中的每个函数,看看哪个函数是用来加密的:

从上图可以看到,此时params中的参数依旧是加密过的,继续往下:

此时终于看到了未加密的东西了,并且看格式,这个i7b大概率就是请求过去的数据,那说明什么呢? 这里可以看到未加密的东西,但是再调用一层就加密了,对的,那就说明t7m.be8w就是用于加密的js函数!现在我们就要来分析这个js函数了,我直接把它拷贝到下面的代码框:

t7m.be8W = function(Y8Q, e7d) {
        var i7b = {}
          , e7d = NEJ.X({}, e7d)
          , mx2x = Y8Q.indexOf("?");
        if (window.GEnc && /(^|\.com)\/api/.test(Y8Q) && !(e7d.headers && e7d.headers[ex9o.BL6F] == ex9o.FI7B) && !e7d.noEnc) {
            if (mx2x != -1) {
                i7b = j7c.hc0x(Y8Q.substring(mx2x + 1));
                Y8Q = Y8Q.substring(0, mx2x)
            }
            if (e7d.query) {
                i7b = NEJ.X(i7b, j7c.fQ9H(e7d.query) ? j7c.hc0x(e7d.query) : e7d.query)
            }
            if (e7d.data) {
                i7b = NEJ.X(i7b, j7c.fQ9H(e7d.data) ? j7c.hc0x(e7d.data) : e7d.data)
            }
            i7b["csrf_token"] = t7m.gW0x("__csrf");
            Y8Q = Y8Q.replace("api", "weapi");
            e7d.method = "post";
            delete e7d.query;
            var bVj7c = window.asrsea(JSON.stringify(i7b), bsR1x(["流泪", "强"]), bsR1x(Xp4t.md), bsR1x(["爱心", "女孩", "惊恐", "大笑"]));
            e7d.data = j7c.cq8i({
                params: bVj7c.encText,
                encSecKey: bVj7c.encSecKey
            })
        }
        var cdnHost = "y.music.163.com";
        var apiHost = "interface.music.163.com";
        if (location.host === cdnHost) {
            Y8Q = Y8Q.replace(cdnHost, apiHost);
            if (Y8Q.match(/^\/(we)?api/)) {
                Y8Q = "//" + apiHost + Y8Q
            }
            e7d.cookie = true
        }
        cxE2x(Y8Q, e7d)
    }
    ;
    t7m.be8W.redefine = true
}
)();

当然,这个js函数依旧不太能看懂,下一步动作思路如下:对每个语句进行断点调试,看看是哪个语句实现了加密操作:

根据上图可以知道发现了罪魁祸首,就是这个asrsea函数,就是它将data中的params和encSecKey进行了重新赋值,其中params就是encTextencSecKey就是encSecKey(加密操作),现在我们来对其进行分析:

var bVj7c = window.asrsea(JSON.stringify(i7b), bsR1x(["流泪", "强"]), bsR1x(Xp4t.md), bsR1x(["爱心", "女孩", "惊恐", "大笑"]));
            e7d.data = j7c.cq8i({
                params: bVj7c.encText,
                encSecKey: bVj7c.encSecKey
            })
        }

它传过去了四个参数:分别是JSON.stringify(i7b),这个函数就是将i7b进行了序列化,在右边的变量窗口中我们可以看到下图所示的东西,这个肯定就是请求url时发送过去的数据了

首先它传过去了四个参数:分别是JSON.stringify(i7b),这个函数就是将i7b进行了序列化,在右边的变量窗口中我们可以看到i7b是下图所示的东西,这个肯定就是请求url时发送过去的数据了,也就是服务端那边解密后的数据

后面三个参数是由bsR1x函数返回的一个什么东西,在窗口中搜索后找到这个函数,没怎么写过js,看不懂qaq,大概能猜到就是进行了个什么转换操作

把参数带着在console窗口中运行看看:

运行了好几次都是同一个值,说明大概率是定死了,然后看看后面的两个参数,同样运行多次:

也都是固定值,那么实参传过去的是什么东西,大概率就能弄清楚了,现在进入到asrsea这个函数内部观察其运行情况,按ctrl+f打开搜索框,直接搜asrsea,我们可以找到这样一段代码:

function a(a) {
        var d, e, b = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", c = "";
        for (d = 0; a > d; d += 1)
            e = Math.random() * b.length,
            e = Math.floor(e),
            c += b.charAt(e);
        return c
    }
    function b(a, b) {
        var c = CryptoJS.enc.Utf8.parse(b)
          , d = CryptoJS.enc.Utf8.parse("0102030405060708")
          , e = CryptoJS.enc.Utf8.parse(a)
          , f = CryptoJS.AES.encrypt(e, c, {
            iv: d,
            mode: CryptoJS.mode.CBC
        });
        return f.toString()
    }
    function c(a, b, c) {
        var d, e;
        return setMaxDigits(131),
        d = new RSAKeyPair(b,"",c),
        e = encryptedString(d, a)
    }
    function d(d, e, f, g) {
        var h = {}
          , i = a(16);
        return h.encText = b(d, g),
        h.encText = b(h.encText, i),
        h.encSecKey = c(i, e, f),
        h
    }
    function e(a, b, d, e) { //这个其实对加密过程没啥用
        var f = {};
        return f.encText = c(a + e, b, d),
        f
    }
    window.asrsea = d,
    window.ecnonasr = e  //这个也没用

不出意外,这段代码就是具体的加密过程了,首先分析asrsea,它在倒数第二行,由函数d赋值而来,也就是说d函数就是asrsea,d函数有四个参数,这个已经分析过了,第一个是data序列化后得结果,后面三个都是定值:

所以目前:

import requests
url = 'url = "https://music.163.com/weapi/comment/resource/comments/get?csrf_token="'
data = {
    "csrf_token": "",
    "cursor": "-1",
    "offset": "0",
    "orderType": "1",
    "pageNo": "1",
    "pageSize": "20",
    "rid": "R_SO_4_28299268",
    "threadId": "R_SO_4_28299268"
}
e = '010001' # 第一个参数
f = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
g = '0CoJUm6Qyw8W8jud' # 第三个参数

然后就要开始模拟这些加密函数了,首先看d:

function d(d, e, f, g) {
        var h = {}
          , i = a(16);
        return h.encText = b(d, g),
        h.encText = b(h.encText, i),
        h.encSecKey = c(i, e, f),
        h
    }

它接收到这四个参数后,先构造一个空对象,然后运行了a这个函数,16是参数,所以我们现在需要看看a函数:

function a(a) {
        var d, e, b = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", c = "";
        for (d = 0; a > d; d += 1)
            e = Math.random() * b.length,
            e = Math.floor(e),
            c += b.charAt(e);
        return c
    }

定义了四个变量 d e b c,然后一个循环,这个循环运行16次,每一次都要返回一个随机数乘以b这个字符串的长度赋值给e,e是一个随机值,然后后面对e进行取下整,取b中的第e位然后接再c的后面,所以c是一个16位的字符串。

再回到d,就能知道i是一个随机的十六位字符串,后面还需要执行这段代码

return h.encText = b(d, g),
        h.encText = b(h.encText, i),
        h.encSecKey = c(i, e, f),
        h

首先执行b函数通过传递 d, g生成一个encText,然后再次执行一个b函数,传递encText, i生成一个encText,一看就是二次加密,现在我们取看看b函数:

function b(a, b) {
        var c = CryptoJS.enc.Utf8.parse(b)
          , d = CryptoJS.enc.Utf8.parse("0102030405060708")
          , e = CryptoJS.enc.Utf8.parse(a)
          , f = CryptoJS.AES.encrypt(e, c, {
            iv: d,
            mode: CryptoJS.mode.CBC
        });
        return f.toString()
    }

我们首先来看第一次加密:h.encText = b(d, g):首先将传递过去的形参b, "0102030405060708"和a进行utf-8字节化,然后再利用AES加密中的encrypt函数进行加密,加密的结果赋值给f,最后将f转化成字符串返回,现在棘手的就是这个encrypt,仔细分析下,第一个参数e是由a生成来的,a接受的是d,也就是那个数据,是一个确定的值。然后c是b生成而来,b接受的是g,也是一个确定的值。第三个参数是一个字典,有两项,第一个iv就是aes的偏移量,第二个mode是加密的模式。

我们再来看第二次加密:h.encText = b(h.encText, i):这个过程和上面一样,唯一不同的是这里的i是一个随机数

继续往下分析:h.encSecKey = c(i, e, f),这里的e、f都是固定的值,并且c这个函数里面没有什么东西能随机化,因此整个函数的不确定性就是由i来决定的,和上面的第二次加密过程一样,因此,现在的任务就是要将i给确定下来!

我们先来看encSecKey,因为它的值只与i相关,如果我们能把i给确定,那么encSecKey也就直接可以确定了,下一步动作就是直接取浏览器里面抓包,去看看i的值以及encSecKey的值:

第一次断点,我们在右边的变量框中得到i的值了,美滋滋,然后第二次断点,打在asrsea这个函数的后面,执行得到:

这样就拿到了该i对应的encSecKey,解释一下为什么i是一个随机值,但是我们可以将其固定,我的理解是,因为这个i是在本地产生的随机数,服务器那边没办法校验它是不是真的是随机值,因该只会验证格式之类的,所i有我们只要拿到一个合法的i即可,然后由于i和encSecKey满足一一映射的关系,因此我们又可以得到encSecKey。(以上是我自己的想象,不知道是否正确,我觉得应该是这样的)

现在的代码:

import requests
url = 'url = "https://music.163.com/weapi/comment/resource/comments/get?csrf_token="'
data = {
    "csrf_token": "",
    "cursor": "-1",
    "offset": "0",
    "orderType": "1",
    "pageNo": "1",
    "pageSize": "20",
    "rid": "R_SO_4_28299268",
    "threadId": "R_SO_4_28299268"
}
e = '010001' # 第一个参数
f = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
g = '0CoJUm6Qyw8W8jud' # 第三个参数
i = 'MHqoMhGU3pBUpuXt'
encseckey = '"b62ef0fa07c6efec7791697b4f42ec37b85a32ede253cb92020524b92d49aa1bcef86678d7bce08ec6cdc0b7633f89362dce641fcba65bccc1198ec6db745b0d36867a5612745b1d46076da57a514790747cc4a0b7ab05c28c1a10815abd0d9b17cd8504ca04ab2dcf38faf0ac6d275f5b365d1c91a8fb0c5c1eb5b3d3c9fa10"'

def getEncSecKey():
    return encseckey

现在我们需要得到二次加密后的encText,事实上就是参数中的params这个变量,需要在本地使用python模拟js中的b函数的加密过程

// js中的b函数
function b(a, b) {
        var c = CryptoJS.enc.Utf8.parse(b)
          , d = CryptoJS.enc.Utf8.parse("0102030405060708")
          , e = CryptoJS.enc.Utf8.parse(a)
          , f = CryptoJS.AES.encrypt(e, c, {
            iv: d,
            mode: CryptoJS.mode.CBC
        });
        return f.toString()
    }

流程前面已经说过了,直接开干:

# python实现加密的过程
def enc_params(data, key):
    iv = "0102030405060708"
    aes = AES.new(key=key.encode("utf-8"), IV=iv.encode('utf-8'), mode=AES.MODE_CBC)  # 创建一个加密器
    # new的第一个参数是密钥,要求传递字节,所以要encode,第二个参数是偏移量,也需要转换成字节,第三个参数是模式
    bs = aes.encrypt(data.encode("utf-8"))  # 加密
    # 由于aes加密后的结果不能直接被utf-8识别,因此需要用base64转
    return str(b64encode(bs), "utf-8")  # 转化成字符串返回,

现在params和encSecKey都有了,发请求试试:

import requests
import json
from base64 import b64encode
from Crypto.Cipher import AES
url ="https://music.163.com/weapi/comment/resource/comments/get?csrf_token="
data = {
    "csrf_token": "",
    "cursor": "-1",
    "offset": "0",
    "orderType": "1",
    "pageNo": "1",
    "pageSize": "20",
    "rid": "R_SO_4_28299268",
    "threadId": "R_SO_4_28299268"
}
e = '010001' # 第一个参数
f = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
g = '0CoJUm6Qyw8W8jud' # 第三个参数
i = 'MHqoMhGU3pBUpuXt'
encseckey = '"b62ef0fa07c6efec7791697b4f42ec37b85a32ede253cb92020524b92d49aa1bcef86678d7bce08ec6cdc0b7633f89362dce641fcba65bccc1198ec6db745b0d36867a5612745b1d46076da57a514790747cc4a0b7ab05c28c1a10815abd0d9b17cd8504ca04ab2dcf38faf0ac6d275f5b365d1c91a8fb0c5c1eb5b3d3c9fa10"'

def getEncSecKey():
    return encseckey

def enc_params(data, key):
    iv = "0102030405060708"
    aes = AES.new(key=key.encode("utf-8"), IV=iv.encode('utf-8'), mode=AES.MODE_CBC)  # 创建一个加密器
    # new的第一个参数是密钥,要求传递字节,所以要encode,第二个参数是偏移量,也需要转换成字节,第三个参数是模式
    bs = aes.encrypt(data.encode("utf-8"))  # 加密
    return str(b64encode(bs), "utf-8")  # 转化成字符串返回,

def getParams(data): # 默认收到的data是字符串数据,然后使用enc_params进行两次加密后返回,得到encText
    first = enc_params(data, g)
    second = enc_params(first, i)
    return second

resp = requests.post(url, data = {
    "params": getParams(json.dumps(data)),
    "encSecKey":getEncSecKey()
})

print(resp.text)

报错,原因是aes加密的内容必须得是16位的倍数,如果不满足16位的倍数,比如说只有12位,那么剩余四位会由4个chr(4)进行填充,如果正好16个,后面需要放16个chr(16)

import requests
import json
from base64 import b64encode
from Crypto.Cipher import AES
url = "https://music.163.com/weapi/comment/resource/comments/get?csrf_token="
data = {
    "csrf_token": "",
    "cursor": "-1",
    "offset": "0",
    "orderType": "1",
    "pageNo": "1",
    "pageSize": "20",
    "rid": "R_SO_4_28299268",
    "threadId": "R_SO_4_28299268"
}
e = '010001' # 第一个参数
f = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
g = '0CoJUm6Qyw8W8jud' # 第三个参数
i = 'MHqoMhGU3pBUpuXt'
encseckey = 'b62ef0fa07c6efec7791697b4f42ec37b85a32ede253cb92020524b92d49aa1bcef86678d7bce08ec6cdc0b7633f89362dce641fcba65bccc1198ec6db745b0d36867a5612745b1d46076da57a514790747cc4a0b7ab05c28c1a10815abd0d9b17cd8504ca04ab2dcf38faf0ac6d275f5b365d1c91a8fb0c5c1eb5b3d3c9fa10'

def to_16(data):
    pad = 16 - len(data) % 16
    data += chr(pad) * pad
    return data

def getEncSecKey():
    return encseckey

def enc_params(data, key):
    iv = "0102030405060708"
    data = to_16(data) # 16位化
    aes = AES.new(key=key.encode("utf-8"), IV=iv.encode('utf-8'), mode=AES.MODE_CBC)  # 创建一个加密器
    # new的第一个参数是密钥,要求传递字节,所以要encode,第二个参数是偏移量,也需要转换成字节,第三个参数是模式
    bs = aes.encrypt(data.encode("utf-8"))  # 加密
    return str(b64encode(bs), "utf-8")  # 转化成字符串返回,

def getParams(data): # 默认收到的data是字符串数据,然后使用enc_params进行两次加密后返回,得到encText
    first = enc_params(data, g)
    second = enc_params(first, i)
    return second

resp = requests.post(url, data={
    "params": getParams(json.dumps(data)),
    "encSecKey": getEncSecKey()
})

print(resp.text)

可以提取到一些评论了,因为第一次爬这种特别复杂的网站,爬取的过程真的是非常曲折......至于清洗数据之类的,下次再水一篇博客吧。总的来说写这个小程序真的收获满满,这是我第一次接触js逆向和模拟加密,脑子都大了...不过时间算是没白花hhh


立志成为一名攻城狮