1. 从抓包到断点:逆向分析的起点

大家好,我是老张,一个在爬虫和逆向领域摸爬滚打了十来年的老码农。今天咱们不聊那些虚的,直接上硬货,手把手带你破解网易云音乐评论的加密机制。很多朋友一看到加密参数 paramsencSecKey 那一长串字符就头疼,觉得这是天书。别怕,我刚开始也这样,但逆向这玩意儿,说白了就是一层窗户纸,捅破了就豁然开朗。

首先,你得明白一个基本事实:网易云音乐的评论数据,不是直接写在网页HTML里的。你打开浏览器开发者工具(F12),在“元素”面板里翻个底朝天,也找不到评论内容。这说明什么?说明评论是通过JavaScript动态加载的,也就是我们常说的“异步加载”或者“Ajax请求”。数据是通过网络请求“偷偷”传过来的,我们的目标就是找到这个请求,并搞清楚它发送的数据是怎么被“加工”的。

第一步,打开网易云音乐网页版,随便找一首歌,比如周杰伦的《晴天》,进入它的播放页面。接着,按下 F12 打开开发者工具,切换到 “网络” 面板。这时候页面可能是空的,别急,刷新一下页面,或者直接往下滚动评论列表。你会看到网络请求列表里刷刷刷地出现一堆条目。我们的目标就在这里面。

怎么找呢?有个小技巧:在请求列表上方的筛选器里,选择 “XHR”“Fetch”。这能过滤掉图片、CSS这些无关请求,只留下传输数据的请求。然后,你就在列表里找,看哪个请求的URL里包含 comment 或者 weapi 这样的关键字。我实测下来,评论数据通常是通过一个 POST 请求发送到 https://music.163.com/weapi/comment/resource/comments/get?csrf_token= 这个地址的。点开这个请求,在“载荷”或者“请求负载”标签页里,你会看到两个让人望而生畏的家伙:paramsencSecKey。它们就是两串长得毫无规律的密文,我们的核心任务就是搞清楚这两串东西是怎么从我们看得懂的歌单信息(比如歌曲ID、页码)变出来的。

找到目标请求只是万里长征第一步。接下来,我们要进入最关键的环节:逆向分析JavaScript。在刚才找到的那个评论请求上,右键点击,选择“以节点方式查看”。在打开的“发起程序”标签页里,你会看到这个请求是由哪一段JavaScript代码发起的。通常,你会看到一个名为 core.js 或者带有一串哈希值的 .js 文件,旁边标明了行号。点击这个行号,就能直接跳转到 “源代码” 面板对应的位置。

这里就是我们的主战场了。你会在那行代码附近看到一个非常可疑的函数调用,很可能叫 window.asrsea 或者类似的名字,它的参数就是我们的明文数据。在这行代码的左侧行号上点击一下,打上一个断点。这个操作的意思是:告诉浏览器,下次再执行到这一行时,先暂停一下,让我看看现场。打上断点后,回到网页,再次触发评论加载(比如翻到下一页评论)。浏览器的执行流就会乖乖地停在你设下的断点处。

此时,整个世界的运行仿佛静止了。在开发者工具的右侧,你会看到“作用域”面板,里面展示了当前函数执行时,各个变量的值。这里就是宝藏所在!你需要仔细查看,找到那个即将被加密的原始数据对象。它可能叫 i6c,也可能叫 d,或者别的什么。它的结构通常一目了然,包含了 rid(歌曲ID)、offset(偏移量)、pageSize(每页数量)等字段。这就是加密前的“明文”。我们逆向的目的,就是找到把这段明文,变成 paramsencSecKey 这两个密文的完整过程。

2. 抽丝剥茧:定位核心加密函数

断点打好了,数据也看到了,下一步就是顺着调用栈,往回找加密发生的地方。在开发者工具的“调用堆栈”面板里,你可以看到当前函数是被谁调用的,它的上一层又是谁。这就像破案时追溯线索。我们需要点击调用栈中更早的、位于当前函数上方的条目,一层层往回退,观察数据在每一步的变化。

这个过程需要耐心。你可能会看到数据从一个函数传到另一个函数,格式在不断变化。我们的目标是找到那个接收明文、输出密文的关键函数。在网易云音乐的例子中,这个函数通常被命名为 d,或者被一个叫 window.asrsea 的函数包裹。当你回溯到某个函数,发现它内部调用了 AES 加密或者 RSA 加密的相关方法时,恭喜你,离真相不远了。

我以2024年最新的网易云音乐网页端为例,带大家走一遍。当你回溯到核心加密函数时,很可能会看到类似下面的代码结构:

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
}

别看它短,信息量巨大。这个 d 函数就是整个加密流程的控制器。它接收四个参数,返回一个包含 encText(对应请求中的 params)和 encSecKey 的对象。我们来逐一拆解:

  1. var i = a(16): 首先,它调用一个叫 a 的函数,生成了一个16位的随机字符串 i。这个 i 是整个加密过程中的一个核心“临时密钥”。
  2. h.encText = b(d, g): 然后,它用函数 b,对第一个参数 d(我们的明文数据)和第四个参数 g 进行加密,得到第一层密文。
  3. h.encText = b(h.encText, i): 接着,它再次用函数 b,对上一步得到的第一层密文和刚才生成的随机字符串 i 进行加密,得到最终的 encText这里进行了两次AES加密
  4. h.encSecKey = c(i, e, f): 最后,它用函数 c,对随机字符串 i、第二个参数 e 和第三个参数 f 进行处理,生成 encSecKey这里通常是一次RSA加密

所以,整个流程可以概括为:明文 -(AES, 使用固定密钥g加密)-> 中间密文 -(AES, 使用随机密钥i加密)-> 最终params (encText)。同时,随机密钥i -(RSA, 使用公钥e和f加密)-> encSecKey。服务器拿到请求后,先用它的私钥解密 encSecKey 得到随机密钥 i,再用 i 解密 params 得到中间密文,最后用固定的密钥 g 解密中间密文,就拿到了我们的原始请求数据。这个设计非常经典,结合了AES的快速和RSA的安全传输密钥。

那么,剩下的问题就是:参数 e, f, g 是什么?函数 a, b, c 具体干了什么?我们继续深挖。

3. 参数与函数解析:固定值与非对称加密

在刚才的 d 函数调用处,我们可以看到传入的四个参数。第一个 d 是我们的动态数据(歌曲ID、页码等),那么后三个 e, f, g 是什么呢?在调用栈的上一层,你一定能找到它们的来源。它们大概率是通过一个叫 bsu6o 的函数生成的,而传入这个函数的参数是一些固定的中文词语数组,比如 ["流泪", "强"]Xo0x.md["爱心", "女孩", "惊恐", "大笑"]

这意味着什么?意味着 e, f, g 是固定值!因为无论你怎么刷新页面,这些中文词语数组不会变,所以 bsu6o 函数输出的结果也不会变。我们可以直接在浏览器的控制台里运行 bsu6o(["流泪", "强"]) 来验证。你会发现,它返回的永远是 "010001"。同理:

  • bsu6o(Xo0x.md) 返回一个非常长的16进制字符串,这就是RSA加密的公钥模数 n
  • bsu6o(["爱心", "女孩", "惊恐", "大笑"]) 返回 "0CoJUm6Qyw8W8jud",这就是第一次AES加密所用的固定密钥。

至此,我们确定了三个固定参数:

  • e = "010001" (RSA公钥指数,通常就是65537的16进制表示)
  • f = "00e0b509f6259df8...(很长一串)" (RSA公钥模数n)
  • g = "0CoJUm6Qyw8W8jud" (AES固定密钥)

接下来看三个核心函数 a, b, c

函数 a:生成随机密钥。它的代码通常很简单,就是生成一个16位的随机字符串。在Python中,我们可以用 randomstring 模块来模拟。

函数 b:AES加密函数。这是重点。我们点进去看它的实现,会发现它使用了 AES-CBC 加密模式,填充方式为 PKCS7,初始化向量 IV 是固定的 "0102030405060708"。加密后,结果还会进行 Base64 编码。这就是标准的AES-CBC加密流程。我们在Python里用 pycryptodome 库可以完美复现。

函数 c:RSA加密函数。这个函数的作用是用公钥 (e, n) 加密随机密钥 i。网易云音乐使用的RSA实现是自定义的,但算法是标准的 RSA,并且使用了 PKCS1_v1_5 填充。在Python中,我们可以利用 rsa 库或者用 Crypto 库的 PKCS1_v1_5 模式来模拟。不过,由于它的公钥指数 e"010001"(即十进制的65537),这是一个非常常见的值,所以实现起来并不复杂。

理解了这个框架,我们心里就有底了。整个加密过程,只有第一个参数(请求数据)和随机密钥 i 是每次请求都会变化的,其他都是固定的。我们的Python脚本,就是要完美模拟这个 d 函数的行为。

4. Python实战:复现加密流程

理论分析完毕,是时候动手写代码了。我们将用Python一步步还原整个加密过程。首先,确保你的环境安装了必要的库:requests 用于网络请求,pycryptodome 用于AES和RSA加密。安装命令很简单:

pip install requests pycryptodome

接下来,我们开始构建加密函数。首先,实现AES加密部分,对应JS中的 b 函数:

import base64
import json
import random
import string
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5

# 固定参数,直接从JS分析得来
AES_FIXED_KEY = "0CoJUm6Qyw8W8jud"  # 对应参数 g
AES_IV = "0102030405060708"        # CBC模式需要的初始化向量,固定
RSA_PUBLIC_KEY_EXPONENT = "010001" # RSA公钥指数 e
RSA_PUBLIC_KEY_MODULUS = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"  # RSA公钥模数 n,对应参数 f

def aes_encrypt(text, key):
    """
    模拟JS中的 b 函数,进行AES-CBC加密,PKCS7填充,结果Base64编码。
    text: 要加密的文本
    key: 加密密钥
    """
    # 确保密钥和IV是16字节(AES-128)
    key = key.encode('utf-8')
    iv = AES_IV.encode('utf-8')
    # 创建AES加密器,CBC模式
    cipher = AES.new(key, AES.MODE_CBC, iv)
    # 对文本进行PKCS7填充并加密
    padded_text = pad(text.encode('utf-8'), AES.block_size)
    ciphertext = cipher.encrypt(padded_text)
    # 将加密后的字节进行Base64编码并返回字符串
    return base64.b64encode(ciphertext).decode('utf-8')

然后,我们需要一个函数来生成16位的随机字符串,模拟JS中的 a 函数:

def generate_random_key(length=16):
    """生成指定长度的随机字符串,模拟 a 函数"""
    chars = string.ascii_letters + string.digits
    return ''.join(random.choice(chars) for _ in range(length))

接下来是最复杂的部分:模拟RSA加密,即JS中的 c 函数。网易云音乐的RSA实现有个特点,它加密的不是原始文本,而是将文本反转后再进行加密。我们需要特别注意:

def rsa_encrypt(text, pub_key_e, pub_key_n):
    """
    模拟JS中的 c 函数,进行RSA加密。
    注意:JS中的实现是先对文本进行反转,然后用公钥(e, n)进行RSA加密,结果转为16进制字符串。
    text: 要加密的文本(随机密钥 i)
    pub_key_e: 公钥指数 (e)
    pub_key_n: 公钥模数 (n)
    """
    # 1. 将文本反转(这是关键步骤!)
    text = text[::-1]
    # 2. 将文本转换为字节,并填充为公钥模数长度
    text_bytes = text.encode('utf-8')
    # 3. 将16进制的模数n和指数e转换为整数
    n = int(pub_key_n, 16)
    e = int(pub_key_e, 16)
    # 4. 使用RSA公钥加密
    # 这里我们使用Python的pow函数模拟 RSA 加密: ciphertext = plaintext^e mod n
    # 首先将文本字节转换为大整数
    plaintext_int = int.from_bytes(text_bytes, byteorder='big')
    # 进行模幂运算
    ciphertext_int = pow(plaintext_int, e, n)
    # 5. 将结果整数转换为16进制字符串,并确保长度为256字符(因为模数n是256字节)
    ciphertext_hex = format(ciphertext_int, 'x')
    # 补零到指定长度(512个十六进制字符,即256字节)
    # JS中的RSA加密输出长度是固定的,与模数n的位数有关
    # 这里的模数n是256字节(512位十六进制字符),所以输出要补零到512位
    return ciphertext_hex.zfill(256 * 2)  # 256字节 * 2 (十六进制)

现在,万事俱备,我们可以组装出最终的加密函数,也就是JS中 d 函数的Python版本:

def encrypt_params(data_dict):
    """
    主加密函数,模拟 window.asrsea 或 d 函数。
    输入:原始的请求参数字典 (如包含rid, offset等)
    输出:包含 params 和 encSecKey 的字典
    """
    # 1. 将数据字典转换为JSON字符串
    text = json.dumps(data_dict)
    # 2. 生成16位随机密钥 i
    i = generate_random_key(16)
    # 3. 第一次AES加密,使用固定密钥 g
    enc_text = aes_encrypt(text, AES_FIXED_KEY)
    # 4. 第二次AES加密,使用随机密钥 i
    enc_text = aes_encrypt(enc_text, i)
    # 5. 对随机密钥 i 进行RSA加密,得到 encSecKey
    enc_sec_key = rsa_encrypt(i, RSA_PUBLIC_KEY_EXPONENT, RSA_PUBLIC_KEY_MODULUS)
    
    return {
        'params': enc_text,
        'encSecKey': enc_sec_key
    }

这个 encrypt_params 函数,就是我们逆向工程的最终成果。它接收一个包含歌曲ID、页码等信息的Python字典,输出可以直接用于 requests.post 请求的 paramsencSecKey

5. 组装爬虫与结果解析

加密问题解决了,爬虫的骨架就搭起来了。现在,我们来编写一个完整的、可复用的爬虫类。这个类会处理请求的构建、加密、发送以及响应的解析。

首先,我们定义一个类,并初始化一些基本参数:

import requests

class NetEaseMusicCommentCrawler:
    def __init__(self, song_id, page_size=20):
        """
        初始化爬虫
        :param song_id: 歌曲ID,例如 `167827` 对应《晴天》
        :param page_size: 每页获取的评论数,默认为20
        """
        self.song_id = song_id
        self.page_size = page_size
        self.base_url = 'https://music.163.com/weapi/comment/resource/comments/get?csrf_token='
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
            'Referer': f'https://music.163.com/song?id={song_id}',
            'Origin': 'https://music.163.com',
            'Content-Type': 'application/x-www-form-urlencoded'
        }
        # 这里可以初始化一个requests Session,方便管理cookies和连接
        self.session = requests.Session()
        self.session.headers.update(self.headers)

接下来,我们添加一个方法来构建每次请求的原始数据。注意,网易云音乐的评论接口使用 cursoroffset 机制来分页,而不是简单的 pageNo。第一页的 cursor 通常是 "-1",后续页的 cursor 从上一页的响应中获取。

    def _build_raw_data(self, cursor='-1', page_no=1):
        """
        构建待加密的原始数据字典
        :param cursor: 游标,第一页为'-1'
        :param page_no: 页码,从1开始
        :return: 原始数据字典
        """
        # offset 的计算方式:(page_no - 1) * page_size
        offset = (page_no - 1) * self.page_size
        raw_data = {
            'csrf_token': '', # 未登录时为空
            'cursor': cursor,
            'offset': str(offset),
            'orderType': '1', # 1: 按推荐排序,2: 按时间排序
            'pageNo': str(page_no),
            'pageSize': str(self.page_size),
            'rid': f'R_SO_4_{self.song_id}', # 资源ID,R_SO_4_ 表示歌曲
            'threadId': f'R_SO_4_{self.song_id}'
        }
        return raw_data

然后,核心的获取评论的方法就水到渠成了。它会调用我们之前写好的加密函数,发送请求,并解析返回的JSON数据。

    def get_comments(self, page=1, cursor='-1'):
        """
        获取指定页的评论
        :param page: 页码
        :param cursor: 游标,首次调用使用默认值 '-1'
        :return: 包含评论列表和下一页游标的元组 (comments, next_cursor)
        """
        # 1. 构建原始数据
        raw_data = self._build_raw_data(cursor=cursor, page_no=page)
        # 2. 加密数据
        encrypted_data = encrypt_params(raw_data)
        # 3. 发送POST请求
        try:
            response = self.session.post(self.base_url, data=encrypted_data, timeout=10)
            response.raise_for_status() # 检查请求是否成功
            result_json = response.json()
        except requests.exceptions.RequestException as e:
            print(f"网络请求失败: {e}")
            return [], cursor
        except json.JSONDecodeError as e:
            print(f"响应解析失败: {e}")
            return [], cursor
        
        # 4. 解析响应
        if result_json.get('code') != 200:
            print(f"接口返回错误: {result_json.get('code')}, 消息: {result_json.get('message')}")
            return [], cursor
        
        data = result_json.get('data', {})
        # 评论列表
        comments = data.get('comments', [])
        # 下一页的游标
        next_cursor = data.get('cursor', cursor)
        
        return comments, next_cursor

为了更方便地获取大量评论,我们可以再写一个方法,循环获取直到没有更多数据或达到指定页数。

    def get_all_comments(self, max_pages=50):
        """
        获取多页评论(谨慎使用,避免请求过快)
        :param max_pages: 最大获取页数
        :return: 所有评论的列表
        """
        all_comments = []
        current_cursor = '-1'
        current_page = 1
        
        while current_page <= max_pages:
            print(f"正在获取第 {current_page} 页评论...")
            comments, next_cursor = self.get_comments(page=current_page, cursor=current_cursor)
            
            if not comments:
                print("没有更多评论或获取失败。")
                break
                
            all_comments.extend(comments)
            print(f"  本页获取到 {len(comments)} 条评论。")
            
            # 判断是否还有下一页
            if next_cursor == current_cursor or not next_cursor:
                print("已到达最后一页。")
                break
                
            current_cursor = next_cursor
            current_page += 1
            
            # 建议添加延迟,避免请求过快被封IP
            import time
            time.sleep(1) # 延迟1秒
            
        print(f"总计获取了 {len(all_comments)} 条评论。")
        return all_comments

最后,我们写一个简单的 main 函数来测试一下。这里以歌曲ID 167827(周杰伦《晴天》)为例:

def main():
    # 替换成你想爬取的歌曲ID
    song_id = '167827'
    crawler = NetEaseMusicCommentCrawler(song_id, page_size=20)
    
    # 测试获取第一页
    comments, next_cursor = crawler.get_comments(page=1)
    if comments:
        print(f"成功获取到 {len(comments)} 条评论。")
        for idx, comment in enumerate(comments[:3]): # 打印前3条看看
            user = comment.get('user', {}).get('nickname', 'N/A')
            content = comment.get('content', 'N/A')
            print(f"  {idx+1}. 用户 [{user}] 说:{content[:50]}...")
        print(f"下一页游标: {next_cursor}")
    else:
        print("获取评论失败。")
        
    # 如果需要获取多页,可以调用下面的方法(请谨慎控制页数)
    # all_comments = crawler.get_all_comments(max_pages=5)
    # 后续可以将 all_comments 保存为JSON或CSV文件

if __name__ == '__main__':
    main()

运行这个脚本,如果一切顺利,你会在控制台看到打印出来的评论内容。这证明我们的加密算法完全正确,成功骗过了服务器的验证。

6. 避坑指南与高级技巧

代码跑通了,但实战中你肯定会遇到各种问题。这里我分享几个我踩过的坑和对应的解决方案,希望能帮你节省大量时间。

第一个大坑:encSecKey 长度不对或加密结果不一致。 这是最常见的问题。99%的原因出在RSA加密的文本反转结果填充上。务必确认你的Python rsa_encrypt 函数严格按照以下顺序操作:

  1. 将16位随机密钥 i 反转 (i[::-1])。
  2. 将反转后的字符串转换为字节。
  3. 使用公钥 (e, n) 进行RSA加密。注意,网易云使用的是 “无填充” 模式的RSA加密吗?不,它实际上使用了 PKCS1_v1_5 填充,但它的JS实现是自定义的,我们模拟的 pow(plaintext_int, e, n) 并手动补零到512位十六进制字符的方法,是经过验证能匹配其输出的。如果你使用 Crypto 库的 PKCS1_v1_5,可能需要调整。
  4. 将加密后的整数转换为16进制字符串,并左侧补零到512位(256字节)。长度一定要对,服务器会严格校验这个长度。

第二个坑:请求被拒绝,返回 -460 错误码。 这通常是风控机制在起作用。网易云音乐可能会检查请求头、Cookie,甚至请求频率。解决方案:

  1. 完善请求头:确保 User-AgentRefererOrigin 等头部信息与浏览器一致。我上面代码里给出的 headers 是一个基础版本,有时可能需要更完整的头部。
  2. 携带Cookie:如果你需要爬取大量数据,最好先用浏览器登录网易云音乐,然后从开发者工具中复制 Cookie 字符串,添加到 headers 里。但请注意,Cookie有有效期,且关联你的账号,请勿滥用
  3. 控制请求频率:在循环获取多页评论时,务必使用 time.sleep() 添加随机延迟(比如1-3秒),模拟人类操作。过于频繁的请求会导致IP被暂时封禁。
  4. 使用Session:像上面代码一样,使用 requests.Session() 可以自动管理Cookie,保持会话状态,有时比单独设置Header更有效。

第三个坑:params 加密结果偶尔不对。 确保你的AES加密模式是 CBC,填充是 PKCS7,IV是 0102030405060708,并且进行了 Base64 编码。密钥 g 和随机密钥 i 都是字符串,需要编码为字节再使用。pycryptodome 库的 pad 函数默认就是PKCS7填充,直接用就行。

关于分页的进阶理解:网易云音乐的评论分页机制在近几年从简单的 pageNooffset 演变为更复杂的 cursor 机制。cursor 是一个表示“当前位置”的字符串,从上一页的响应中获取,用于获取下一页。第一页的 cursor"-1"。在我们的 _build_raw_data 方法中,offset 字段似乎没用了?其实不然,根据我的测试,offset 仍然需要正确计算 ((page_no-1)*page_size),但决定下一页数据的是 cursor。所以,在 get_all_comments 循环中,我们使用返回的 next_cursor 来获取下一页。

数据存储建议:获取到的评论数据是JSON格式,包含丰富的信息,如用户ID、昵称、头像、评论内容、点赞数、时间戳等。建议使用 json 模块直接保存为文件,或者用 pandas 库转为 DataFrame 后再保存为CSV或Excel,方便后续分析。

import pandas as pd
def save_comments_to_csv(comments_list, filename='netease_comments.csv'):
    """将评论列表保存为CSV文件"""
    if not comments_list:
        print("评论列表为空,无需保存。")
        return
    # 提取我们感兴趣的字段
    data = []
    for cmt in comments_list:
        data.append({
            'comment_id': cmt.get('commentId'),
            'user_id': cmt.get('user', {}).get('userId'),
            'user_name': cmt.get('user', {}).get('nickname'),
            'content': cmt.get('content'),
            'liked_count': cmt.get('likedCount', 0),
            'time': pd.to_datetime(cmt.get('time'), unit='ms') if cmt.get('time') else None, # 时间戳转日期
        })
    df = pd.DataFrame(data)
    df.to_csv(filename, index=False, encoding='utf-8-sig')
    print(f"评论已保存至 {filename}")

最后,务必遵守法律法规和网站规则。本文技术分享仅用于学习和研究目的。爬取数据时,请尊重网站的服务条款,不要对服务器造成过大压力,不要将数据用于商业或非法用途。合理控制爬取速度和数量,做一个有道德的开发者。

逆向工程就像解谜,每一次成功破解都带来巨大的成就感。希望这篇超详细的指南,能帮你彻底打通网易云音乐评论爬虫的任督二脉。如果过程中遇到任何问题,欢迎多调试、多思考,或者参考完整的代码示例。记住,开发者工具是你的最佳伙伴,控制台是你的试验场。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐