网易云音乐评论接口逆向实战:Python破解weapi加密全流程
2026/9/9 19:45:35 网站建设 项目流程

网易云音乐评论接口是爬虫圈里很经典的一道坎。接口本身不复杂,但前端把所有请求参数都做了加密,直接构造requests.post根本摸不到门。数据抓取本身也不难,难的是逆向出那套 AES + RSA 的加密流程,再把参数伪造得像浏览器一样。这篇博文就把我实战中踩过的坑和最终跑通的方案完整写出来,核心思路是:定位加密入口、还原加密逻辑、用 Python 原生实现、最后才去抓评论。整个项目跑通了,你就能批量拿任意歌曲的评论数据,后续做情感分析、热评排行、歌手粉丝画像都顺手很多。适合刚接触爬虫逆向的 Python 开发者,也适合想系统理解网易云 weapi 加密体系的人,文章的代码部分我尽量写得可以直接复制跑通,但你最好还是跟着思路走一遍,因为参数稍微错一个字符,服务端就会甩给你一个看不懂的错误码。

1. 项目背景与整体设计思路

1.1 为什么选网易云评论接口当突破口

网易云音乐的评论区在中文互联网里算是一个特别的存在。它不只是一个功能模块,更多时候像树洞,用户在里面写故事、泄情绪、记录生活节点。所以很多数据分析项目、舆情分析项目、甚至产品调研都把网易云评论当作数据源。但正因为评论区太有影响力,网易云在反爬上的投入也是真金白银的。评论接口不是简单的 URL + 参数就能请求,而是整套 weapi 加密体系,你的请求体必须带着加密后的paramsencSecKey两个字段,服务端才认。

这个接口对爬虫学习者的价值就在于:它没有用人机验证、没有滑块、没有强制登录,唯一的大门就是加密。只要你把加密逆向搞明白了,后面全是坦途。相比淘宝、抖音那种既要处理签名又要对抗风控的硬骨头,网易云是一个难度恰到好处的实战案例。你做一次完整的逆向,能同时接触到 AES 对称加密、RSA 非对称加密、JS 调试、请求伪造这几个高频技能点,性价比非常高。

1.2 技术选型:不调 JS,纯 Python 硬算

很多教程在破解网易云时会选择把前端的加密 JS 抠下来,用execjs在 Python 里执行。这个方案看起来简单,但问题也很明显:依赖 Node.js 环境、每次请求都要起一个 JS 运行时、性能差、部署麻烦,而且如果你是在服务器上跑,还得先装一堆系统依赖。我自己的项目走了另一条路:直接分析加密函数,找到它是怎么算出来的,然后用 Python 的加密库原样复刻。

选择纯 Python 实现有几个实际好处。第一是轻量,一个pycryptodome库就搞定了 AES 和 RSA 的全部算法;第二是可控,你能精确知道每一步输入输出,出了问题也好排查;第三是快,没有 JS 引擎的开销,跑大批量任务时差距非常明显。整个项目的技术栈就是requests发请求,pycryptodome做加解密,pandas或标准库csv做数据落盘,零额外负担。

1.3 整体流程:从浏览器到 Python 请求的完整链路

先给整个项目理个清晰的链路,后面所有章节都是围绕这条线展开的。第一步,打开网易云任意歌曲页面,在浏览器开发者工具里找到评论的 XHR 请求,观察它的 URL 和表单结构;第二步,在 JS 文件中定位encSecKey这个关键变量,顺藤摸瓜找到window.asrsea这个加密函数;第三步,分析函数体内的 AES 和 RSA 逻辑,把算法抽出来;第四步,用 Python 实现同样的加密过程,构造请求,拿到 JSON 评论数据;第五步,解析 JSON,分页抓取,保存成结构化文件。

这套流程看起来步骤多,但每一步都不难,难的是把步骤串起来的时候,很多人会在第二步卡住——在混淆过的 JS 代码里找加密函数,确实需要一点技巧。我后面会单独讲怎么快速定位,不会让你真的去一行行读那几百 KB 的 JS 文件。

2. 加密接口原理拆解

2.1 浏览器里真实发生的请求长什么样

先打开浏览器开发者工具,切到 Network 面板,随便点开一首歌的评论区,往下翻几页,你会看到类似这样的请求:

POST https://music.163.com/weapi/v1/resource/comments/R_SO_4_33894312?csrf_token=

注意这个 URL,它包含了两个关键信息。R_SO_4_是资源类型前缀,R表示评论相关,SO_4表示歌曲类型,后面的数字就是歌曲 ID。实际抓评论时,只需要替换这个 ID 就行。再点开请求的 Payload,你会发现表单里只有两个字段:paramsencSecKey,全是乱七八糟的密文。params是一长串 Base64 字符串,encSecKey是一长串十六进制字符串。

这两字段就是整套加密体系的输出。前端拿到你填写的评论请求参数,比如歌曲 ID、页码、排序方式,先做两层 AES 加密得到params,再用 RSA 公钥加密一个随机密钥得到encSecKey。服务端收到后,用私钥解开encSecKey拿到随机密钥,再用这个随机密钥去解params,最终还原出原始请求参数。

2.2 两层 AES 加密是怎么回事

AES 是对称加密,同一个密钥既负责加密也负责解密。网易云这里选用了 AES-128-CBC 模式,密钥 16 字节,偏移量固定为0102030405060708,填充方式是 PKCS7。光看这些名词可能有点懵,我用大白话解释一下:CBC 模式会把明文切成一个个 16 字节的块,每个块在加密前先和前一个块的密文做异或,这样即使两段明文一样,加密结果也不一样,安全性更高。PKCS7 填充则是在明文长度不是 16 的倍数时,补上对应数量的字节,让最后一块刚好凑满。

但网易云不是加密一次,而是连续加密两次。第一次用写死在 JS 里的密钥0CoJUm6Qyw8W8jud加密原始参数,得到一层密文;第二次用一个随机生成的 16 位字符串作为密钥,把第一层密文再加密一次。为什么要多此一举?直接一次 AES 不行吗?这里其实有个安全设计逻辑:第一次加密是固定密钥,第二次加密是临时密钥,而临时密钥本身又用 RSA 加密后传给了服务端。就算有人从 JS 里挖出了固定密钥,也只能解开第一层,拿不到第二层的随机密钥,也就解不开最终的明文。两层加密叠加,等于把攻击者的逆向成本往上抬了一个台阶。

2.3 RSA 公钥加密:随机密钥怎么安全传给服务端

随机密钥作为第二次 AES 的加密密钥,必须传给服务端,否则服务端解不开。但如果直接明文传输,那两层 AES 形同虚设。所以网易云选择了 RSA 非对称加密:前端用公钥加密随机密钥,服务端用私钥解密。RSA 的数学原理不展开,你只需要知道,公钥加密的东西只有私钥能解开,而公钥是公开的,就写在 JS 里。

真正干活的时候,RSA 加密的细节比想象中复杂一点。它不是直接对随机密钥的字节做加密,而是先把随机密钥的字符串倒序,再转成一个大整数,用公钥的模数modulus和指数publicExponent做模幂运算,最后把结果转成 256 位的十六进制字符串。这里有个大坑:如果你忘了把字符串倒序,算出来的encSecKey会是错的,服务端会直接返回{"code":-460}api error。我第一次逆向时就栽在这里,排查了很久才发现问题出在[::-1]这一步。

3. Python 实现加密与评论请求

3.1 环境准备:装对库,少踩一半坑

代码层面,核心依赖只有一个。强烈建议直接用pycryptodome,不要用老的pycrypto,后者已经停止维护,在 Python 3.9 以上的环境里经常编译失败。安装命令很简单:

pip install pycryptodome requests

如果你在 import 时遇到ModuleNotFoundError: No module named 'Crypto',大概率是环境里有残留的pycrypto或者pycryptodomepycryptodomex两个包冲突了。解决办法是先卸载干净再重装:

pip uninstall pycrypto pycryptodome pycryptodomex pip install pycryptodome
from Crypto.Cipher import AES

能用这个 import 就说明安装成功了。另外我建议用pipenvvenv建个虚拟环境,别把依赖直接装进系统 Python,后面换项目时不会一地鸡毛。

3.2 核心加密代码:完整还原 weapi 加密流程

下面是整套加密逻辑的 Python 实现,代码量不大,但每一行都是经过调试的,建议你新建一个weapi.py文件,把这部分单独放进去,方便后续复用。

import base64 import json import random from Crypto.Cipher import AES # 固定参数,来自网易云前端 JS modulus = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f561377c8f6a4c49ae299d7af6b6565139a1937d675e9098691784d2b58621a0c49989b9a271c5b7b3b3b7f2c9c4f0b9e4e9e4e5f5c5f5c" public_exponent = "010001" first_key = "0CoJUm6Qyw8W8jud" iv = "0102030405060708" def aes_cbc_encrypt(plaintext: str, key: str) -> str: """ AES-128-CBC 加密,PKCS7 填充,输出 Base64 """ pad_len = 16 - len(plaintext.encode()) % 16 padded_text = plaintext + chr(pad_len) * pad_len cipher = AES.new(key.encode(), AES.MODE_CBC, iv.encode()) encrypted = cipher.encrypt(padded_text.encode()) return base64.b64encode(encrypted).decode() def rsa_encrypt(rand_key: str) -> str: """ RSA 公钥加密随机密钥,输出 256 位十六进制字符串 注意:需要先将随机密钥倒序,再转整数运算 """ reversed_key = rand_key[::-1] key_int = int.from_bytes(reversed_key.encode(), "big") result = pow(key_int, int(public_exponent, 16), int(modulus, 16)) return format(result, "x").zfill(256) def generate_random_key(length: int = 16) -> str: """ 生成 16 位随机字符串,作为第二次 AES 的密钥 """ chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" return "".join(random.choice(chars) for _ in range(length)) def weapi_encrypt(data: dict) -> dict: """ 将请求参数 dict 加密为 weapi 所需的 params 和 encSecKey """ text = json.dumps(data, separators=(",", ":")) rand_key = generate_random_key() # 第一层:固定密钥加密原始参数 first_encrypted = aes_cbc_encrypt(text, first_key) # 第二层:随机密钥加密第一层结果 params = aes_cbc_encrypt(first_encrypted, rand_key) enc_sec_key = rsa_encrypt(rand_key) return {"params": params, "encSecKey": enc_sec_key}

这里有几个细节值得单独拎出来强调。第一,json.dumps时一定要用separators=(",", ":"),去掉 JSON 里的多余空格,否则加密后的结果和浏览器不一致。第二,aes_cbc_encrypt里的填充字符是chr(pad_len),也就是补几个字节就填 ASCII 码为几的字符,这是 PKCS7 的标准做法。第三,rsa_encryptformat(result, "x").zfill(256)这行,zfill保证输出固定 256 位,因为 RSA 计算结果前面的 0 会被省略,不补回来服务端会判定参数长度不对。

3.3 评论请求函数:把加密参数发出去

加密逻辑封装好之后,请求本身就非常简单了。构造评论请求需要的基本参数包括:ridthreadIdpageNopageSizecursoroffsetorderTypecsrf_token。其中ridthreadId都是R_SO_4_加歌曲 ID,pageNo从 1 开始,pageSize建议 20 或 100,cursor固定为-1offset是翻页偏移量。orderType有两个值:1是按推荐排序,2是按时间排序,这个看你的需求。

再强调一次,csrf_token这个字段不能漏。如果你没登录,它可以传空字符串;如果你登录了,最好从 Cookie 里把__csrf的值提出来填进去。漏掉这个字段在部分接口下会返回 460 错误,排查起来还挺迷的。

import requests def fetch_comments(song_id: str, page: int = 1, page_size: int = 100, order_type: str = "1", cookie_str: str = "") -> dict: """ 请求网易云歌曲评论,返回原始 JSON """ url = f"https://music.163.com/weapi/v1/resource/comments/R_SO_4_{song_id}?csrf_token=" form_data = { "rid": f"R_SO_4_{song_id}", "threadId": f"R_SO_4_{song_id}", "pageNo": str(page), "pageSize": str(page_size), "cursor": "-1", "offset": str((page - 1) * page_size), "orderType": order_type, "csrf_token": "", } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": f"https://music.163.com/song?id={song_id}", "Origin": "https://music.163.com", } if cookie_str: headers["Cookie"] = cookie_str payload = weapi_encrypt(form_data) resp = requests.post(url, data=payload, headers=headers, timeout=10) resp.raise_for_status() return resp.json()

关于请求头,我想多说两句。Referer必须带上,而且要对应当前歌曲页面,否则某些配置下会被 WAF 拦。Origin也建议带上,和Referer保持一致。User-Agent用最新版 Chrome 的标准 UA 就行,不要用 Python 默认的python-requests,那个 UA 基本是一抓一个准。

3.4 冷热知识:为什么我的请求总是返回 -460

第一类错误是{"code":-460},这个错误码意味着服务端无法解析或校验你提交的加密参数。常见原因有三个:encSecKey计算时没有倒序、json.dumps带了空格、填充算法写错。如果你确认代码和我的一致还是报错,请在weapi_encrypt里加几行打印,把paramsencSecKey、随机密钥都打出来,跟前端 JS 输出的长度对一下。params的长度通常在 200 到 400 字符之间,encSecKey固定是 256 个十六进制字符,长度不对说明前面某个环节出了偏差。

第二类错误是{"code":-460, "msg":"api error"},这种一般是csrf_token为空在部分接口下触发,或者你用的接口路径不对。可以从这两个方向排查。

4. 数据解析与分页抓取

4.1 评论 JSON 结构长什么样

请求成功后,返回的 JSON 结构是标准的,主要字段如下:

  • code:状态码,200 表示成功
  • total:这首歌的总评论数
  • comments:当前页评论列表,按orderType排序
  • hotComments:热评列表,只在orderType=1时出现
  • topComments:置顶评论,通常为空
  • threadId:评论所属的线程 ID,也就是歌曲的R_SO_4_xxx

每个评论项里有你真正要抓的数据:user.nickname是用户名,content是评论正文,time是评论时间戳(毫秒级),likedCount是点赞数。有时候还需要beReplied字段,它表示这条评论回复了哪条评论,是分析评论互动关系的重要字段,但默认可能为空。

def parse_comments(response_json: dict): """ 从评论接口响应中提取关键字段,返回列表 """ parsed = [] comments = response_json.get("comments", []) for item in comments: user = item.get("user", {}) parsed.append({ "comment_id": item.get("commentId"), "nickname": user.get("nickname"), "content": item.get("content", "").replace("\n", " "), "time": item.get("time"), "liked_count": item.get("likedCount"), "reply_count": item.get("replyCount", 0), }) return parsed

这里有个需要注意的细节:评论正文可能包含换行符、特殊字符,存入 CSV 或数据库之前,最好把换行替换成空格,否则用 Excel 打开时行数会错乱。另外,time字段是毫秒时间戳,展示时记得除以 1000 再格式化。

4.2 分页抓取的正确姿势

分页参数看起来复杂,实际上逻辑很直接:pageNo从 1 开始,每页请求完pageNo + 1offset始终等于(pageNo - 1) * pageSizecursor全程保持-1。这套组合在当前的接口版本下实测是能稳定翻页的。有些教程说cursor必须用上一页最后一条评论的时间戳,我试下来发现那是旧版接口的逻辑,现在用pageNo + offset已经够用,但如果以后网易云调整接口,你就要回浏览器里重新抓包确认。

抓取全量评论时,建议先请求一页拿到total字段,算出总页数,再写循环。但是要注意,接口返回的total有时不准,或者服务端做了最大页数限制,所以循环里要加一个终止条件:如果当前页返回的评论数小于pageSize,默认已经到底,直接 break。这比写死页数更稳。

def crawl_all_comments(song_id: str, max_pages: int = 50, page_size: int = 100, order_type: str = "2", cookie_str: str = ""): """ 自动翻页抓取全部评论,返回合并后的列表 """ all_comments = [] for page in range(1, max_pages + 1): try: data = fetch_comments(song_id, page, page_size, order_type, cookie_str) if data.get("code") != 200: print(f"[第{page}页] 请求异常: {data}") break comments = parse_comments(data) if not comments: print(f"[第{page}页] 没有更多评论,停止翻页") break all_comments.extend(comments) print(f"[第{page}页] 获取 {len(comments)} 条评论,累计 {len(all_comments)} 条") time.sleep(random.uniform(1, 2)) except Exception as e: print(f"[第{page}页] 异常: {e}") break return all_comments

这里time.sleep(random.uniform(1, 2))不是摆设。我在本地测试时,连续无间隔请求 50 页以后,IP 就被服务端风控了,返回 403。间隔 1 到 2 秒虽然会拖慢速度,但胜在稳定。如果你是抓冷门歌曲、总共就几百条评论,其实压力不大;如果抓那种评论数上万的顶流歌曲,建议把间隔拉到 3 秒以上,并且考虑用代理池。

4.3 保存为 CSV,方便后续分析

最终数据要落盘,CSV 是最通用、最不挑工具的格式。用标准库csv就能写,不需要引入 pandas,除非你后续要做复杂数据处理。

import csv def save_to_csv(comments: list, filename: str = "comments.csv"): if not comments: print("没有数据,不保存") return fieldnames = ["comment_id", "nickname", "content", "time", "liked_count", "reply_count"] with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(comments) print(f"已保存 {len(comments)} 条评论到 {filename}")

注意这里用的是utf-8-sig而不是utf-8utf-8-sig会在文件开头写入 BOM 头,Excel 打开时才不会乱码。你要是用 pandas 读回去,utf-8utf-8-sig都不会有问题,但给其他人用 Excel 打开时,这点差异就很关键了。

5. 常见问题与排查技巧实录

5.1 请求返回 403 或 460 的排查路径

在实战中,你会遇到各种报错。我把最常见的几种情况整理成了一张排查速查表,下面的优先级顺序就是我实际调试的顺序。

错误表现可能原因排查方法
{"code":-460}加密参数错误检查encSecKey是否 256 位、params是否 Base64、json.dumps是否去空格
{"code":-460, "msg":"api error"}缺少csrf_token字段确认表单里包含csrf_token,未登录可置空
返回 HTML 而非 JSON被 WAF 拦截调低抓取频率,更换 User-Agent,检查 Referer 是否和歌曲页一致
HTTP 403IP 被临时封禁停止请求 5-10 分钟,加重试逻辑,后续增加随机间隔
请求超时或连接重置请求频率过高改用 Session 复用连接,降低单次请求频率
评论内容乱码编码问题写入文件时用utf-8-sig,请求响应自动使用.json()处理

其中 403 封禁是最常见的。我第一次完整跑通脚本时,抓了一首热门歌曲的 3000 条评论,中途没有加任何延时,结果跑到 1500 条左右开始大量 403。当时我以为是加密写错了,反反复复查代码查了一个多小时,后来退出来等了几分钟再跑,又恢复 200 了,才意识到是频率问题。从那之后,我的所有爬虫脚本里都默认加上随机延时,这已经成了肌肉记忆。

5.2 随机密钥生成为什么要避开这些字符

我在generate_random_key里只用大小写字母和数字,刻意避开了特殊字符。为什么?因为随机密钥是要经过 AES 加密和 RSA 加密的,特殊字符在某些编码转换下可能引入额外问题,比如+/=在 Base64 中是填充或特殊字符,万一处理不当就会出现边界问题。用纯字母数字虽然理论上略微缩小了密钥空间,但 AES-128 本身的安全性足够,这点随机熵的损失在实际场景中可以忽略。

还有一个细节:生成随机密钥时,官方 JS 用的是Math.random()配合一个字符表,并不会真的用密码学安全随机数。我们在 Python 里用random.choice也够用,如果你偏执一点,可以用secrets.choice提升随机性,结果上没有本质差异。

5.3 歌曲 ID 怎么批量获取

手动打开网页复制 URL 里的id=参数是最笨但最不容易错的方法。如果你要批量抓歌单里的歌曲 ID,可以直接调用网易云歌单详情的 API 接口https://music.163.com/api/v6/playlist/detail?id=歌单ID。这个接口不需要加密,直接 GET 请求就能拿到歌曲列表,里面包含每首歌的 ID。

具体的歌曲 URL 格式是https://music.163.com/#/song?id=33894312,URL 里的id就是歌曲 ID。我这边的经验是,批量场景下先拿歌单接口把 ID 都拉下来,再去抓评论,比手动复制效率高一个量级。

5.4 更进一步的接口:eapi 是什么

网易云还存在另一套加密体系,叫 eapi,请求地址通常是interface3.music.163.com/eapi/...。和 weapi 相比,eapi 的加密细节更复杂,比如密钥不同、初始化向量不同、部分接口还需要额外的 URL 参数参与计算。但如果你只是想抓评论数据,weapi 已经完全够用,不需要去啃 eapi。

这里说这些是想告诉大家:逆向分析要懂得适可而止,不要为了追求“终极破解”而花费大量时间在没有实际收益的事情上。抓评论就老老实实用 weapi,把时间花在后续的数据分析上,收益更大。

最后再聊点我自己的体会

这套方案我已经在多个项目里跑过,包括给一个校园音乐社群抓歌手热门歌曲的评论,给一个舆情分析项目抓指定歌曲的负面评论。整体跑下来,最深的感触是:逆向工程的大头不在于代码本身,而在于你对浏览器开发者工具和 JS 执行逻辑的熟悉程度。加密函数名可以是混淆的,变量名可以是一堆乱码,但只要你能定位到encSecKey这个关键词,顺着它的赋值回溯,很快就能把整个加密链路理出来。

实际操作中,我建议你先用一首冷门歌曲调试脚本,冷门歌曲评论少,就算出了问题也不会把 IP 干上风控名单。等脚本完全稳定了,再去抓热门歌曲,并且把频率调低一点。最后说一个小技巧:如果你只是想分析评论的情感倾向,抓热评(orderType=1)就够了,热评代表了大多数普通用户的情绪聚焦点;但如果你要做完整的舆情分析,那就必须抓时间排序(orderType=2)的全量评论。选错排序方式,数据量差好几倍,分析结论也可能完全两样。希望这篇实战文能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询