网易云音乐是通过用ajax和json来实现从服务器加载评论信息,所以想直接用requests.get是无法调用到搜索API和获取评论的API,只能通过慢慢分析和解密JS文件来慢慢摸清加密API的参数,因为我不太懂前端的知识,是根据知乎大佬https://www.zhihu.com/question/36081767/answer/140287795的思路来分析JS的,我就是在此文章的帮助下写出了网易云音乐搜索功能的代码,在此由衷感谢这位大牛。
知乎大佬链接1
但是因为年代久远,网易云音乐现在的API和json和这位答主的有少许出入(当然不影响原代码的使用),而且当时题主是用Python2写的代码,我就用Python3和Java重新写了一遍,那我就用自己的理解来解释一下这位大佬的思路,以便让每个像我一样的初学者都能看懂。
按照这位知乎大佬的思路,我们还是先用fiddler抓包 (前三步不是必须的,如果你能直接通过浏览器的F12开发者功能找到请求API的js文件,可以之间跳到第四步):
按下Ctrl+X把所有乱七八糟的请求都清空了
,然后立刻加载网易云音乐的搜索页面:https://music.163.com/#/search/m/?s=%E7%A8%BB%E9%A6%99&type=1,我在这里的搜索内容以"稻香"为例子
经过一步一步的调试网页的请求,在运行了这个链接后https://music.163.com/weapi/cloudsearch/get/web?csrf_token=
浏览器已经能看到了歌曲搜索内容的页面了
说明这个链接https://music.163.com/weapi/cloudsearch/get/web?csrf_token= 很可能是歌曲搜索功能的API接口,为了验证这个想法,先把fiddler的断点功能关了
接着我们先打开浏览器的F12键,然后刷新页面看看网页的加载信息
可以很明显的发现这个链接就是搜歌的API
返回值response里面就是我们需要的json文件,里面包含了搜索到的歌曲信息
抓到了歌曲搜索的API后,可以看出这是个POST请求,而关键参数就是params和encSecKey这两个参数,这很明显是经过加密后的参数,所以任务就是破解加密的参数。
那么我们先分析发出这个请求的JS文件,在initiator中找到发起请求的JS文件分析代码
这个JS代码缩进被压缩的太厉害了,就复制下来在sublime中格式化一下
在sublime text中用插件JSFormat的快捷键Ctrl alt + f美化代码(不过按这个快捷键我的fiddler老是跳出了只能先关闭fiddler再美化了)
这样看可读性好多了,不过这代码太长了差点把我电脑搞死机了
接着安装那位知乎大神的思路,查询参数params和encSecKey
在js代码中跳到了下面位置
在此引用知乎大佬的分析(某些变量不一样但代码结构都是一样的):
当然,这位大佬说的有点儿抽象,对于我这样的JS真小白来说是跟不上大佬的思路的,自己慢慢分析才知道意思大概,根据下面代码
分析可知,参数params和encSecKey的值由bVX2x决定,而bVX2x又是由函数window.asrsea得到的,那现在研究重心是在函数window.asrsea,由代码可知给这个函数的第一个参数就是i3x(知乎大佬文章的参数是bl,因为年代久远这些变量改名了,但总体结构没改变),而这个i3x的值是多少呢我们不知道,只能通过函数,所以我们想在浏览器控制台显示两个变量的值,给JS加个window.console.warn(i3x);和window.console.warn(bVX2x.encText);
然后打开我们fiddler,添加规则,用本地JS文件代替请求的JS文件
第三个参数 bqK4O(Ya8S.md):00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7
第四个参数 bqK4O([“爱心”, “女孩”, “惊恐”, “大笑”]):
0CoJUm6Qyw8W8jud
然后我们可以得到这个window.asrsea函数实际是函数d(d, e, f, g)
可以看出变量i是个长度为16的随机字符串,既然是随机的,就直接让他等于16个F:
因为我没学过JS,而且这个return返回值的格式是我在任何代码中都没见过的(怪我才疏学浅),我开始以为是像Python那样返回的是四个值,但经过分析后还是觉得函数返回的就是h,因为后面的params是等于bVX6R.encText的,而很明显返回值h之前的那三个赋值代码就是获取params和encSecKey的;那么既然知道了这三个是赋值代码,那就能明显看出h.encText是经过b函数两次处理的结果,而b函数的返回值就是经过AES128 cbc pkcs7加密后的结果
现在整体思路就理清了,我们主要任务就是写出实现 AES128 cbc pkcs7加密的函数,而输入的参数经过两次加密 的返回的结果就是我们需要的params。
而获取歌曲评论的分析是和抓取搜索差不多的,而且那位知乎答主的代码(Python2版本)就是抓取评论的,这里就不再赘述了,就直接在最后放上代码就行了
Python3版搜索歌曲代码
# -*- coding: utf-8 -*-
import requests
import json
import pprint
from urllib import parse
import base64
from Crypto.Cipher import AES
Search_api='https://music.163.com/weapi/cloudsearch/get/web?csrf_token='
search_headers = {
'User-Agent':'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36',
'Referer':'https://music.163.com/search/',
'Origin':'http://music.163.com',
'Host':'music.163.com'
}
second_param = "010001"
third_param = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
forth_param = "0CoJUm6Qyw8W8jud"
def pkcs7padding(text):
"""
明文使用PKCS7填充
最终调用AES加密方法时,传入的是一个byte数组,要求是16的整数倍,因此需要对明文进行处理
:param text: 待加密内容(明文)
:return:
"""
bs = AES.block_size # 16
length = len(text)
bytes_length = len(bytes(text, encoding='utf-8'))
# tips:utf-8编码时,英文占1个byte,而中文占3个byte
padding_size = length if(bytes_length == length) else bytes_length
padding = bs - padding_size % bs
# tips:chr(padding)看与其它语言的约定,有的会使用'\0'
padding_text = chr(padding) * padding
return text + padding_text
def aes_en(text,key,iv):
#print('pkcs7padding处理之前:',text)
text =pkcs7padding(text)
#print('pkcs7padding处理之后:',text)
#entext = text + ('\0' * add)
# 初始化加密器
aes = AES.new(key.encode(encoding='utf-8'), AES.MODE_CBC, iv)
enaes_text = str(base64.b64encode(aes.encrypt(str.encode(text))),encoding='utf-8')
return enaes_text
def get_params(first_param):
iv = b"0102030405060708"
first_key = forth_param
second_key = 16 * 'F'
h_encText = aes_en(first_param, first_key, iv)
h_encText = aes_en(h_encText, second_key, iv)
return h_encText
if __name__ == "__main__":
search_name = '周杰伦'
page = "0" #必须是30的整数倍,因为下面first_param参数中的limit值为30,意思是每页最多显示30个评论
if page != 0:
if_firstPage = "true" #如果是第一页(即page=0)则if_firstPage为false,否则都为true
else:
if_firstPage = "false" #page为0,这是评论第一页则if_firstPage为false
first_param = "{\"hlpretag\":\"\",\"hlposttag\":\"\",\"s\":\"%s\",\"type\":\"1\",\"offset\":\"%s\",\"total\":\"%s\",\"limit\":\"30\",\"csrf_token\":\"\"}" %(search_name,page,if_firstPage)
user_data = {
'params': get_params(first_param),
'encSecKey': "257348aecb5e556c066de214e531faadd1c55d814f9be95fd06d6bff9f4c7a41f831f6394d5a3fd2e3881736d94a02ca919d952872e7d0a50ebfa1769a7a62d512f5f1ca21aec60bc3819a9c3ffca5eca9a0dba6d6f7249b06f5965ecfff3695b54e1c28f3f624750ed39e7de08fc8493242e26dbc4484a01c76f739e135637c"
}
response = requests.post(Search_api,headers=search_headers,data=user_data)
#pprint.pprint(response.text)
json_dict = json.loads(response.text)
print (json_dict)
print('----------------------------------')
for directory_temp in json_dict['result']['songs']:
song_name = directory_temp['name'];
song_id = directory_temp['id']
singer_name = directory_temp['ar'][0]['name']
singer_id = directory_temp['ar'][0]['id']
album_name = directory_temp['al']['name']
album_id = directory_temp['al']['id']
print('歌名:'+song_name)
print('歌名ID',song_id)
print('歌手:'+singer_name)
print('歌手ID:',singer_id)
print('专辑:'+album_name)
print('专辑ID:',album_id)
print('++++++++++++++++++++++++++++++++')
JAVA版搜索歌曲代码
package com.example.api;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Hex;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
public class EncryptTools {
public static void main(String[] args) throws Exception {
searchAPI("稻香","0"); //第二个参数是页数,0就是第一页
}
//AES加密
public static String encrypt(String text, String secKey) throws Exception {
byte[] raw = secKey.getBytes("utf-8");
int padding_size = text.getBytes(StandardCharsets.UTF_8).length;
int padding = 16 - padding_size % 16;
String padding_text = String.join("", Collections.nCopies(padding, Character.toString ((char) padding)));
text = text+ padding_text;
SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES");
// "算法/模式/补码方式"
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
// 使用CBC模式,需要一个向量iv,可增加加密算法的强度
IvParameterSpec iv = new IvParameterSpec("0102030405060708".getBytes("utf-8"));
cipher.init(Cipher.ENCRYPT_MODE, skeySpec, iv);
byte[] encrypted = cipher.doFinal(text.getBytes("utf-8"));
return Base64.getEncoder().encodeToString(encrypted);
}
//字符填充
public static String zfill(String result, int n) {
if (result.length() >= n) {
result = result.substring(result.length() - n, result.length());
} else {
StringBuilder stringBuilder = new StringBuilder();
for (int i = n; i > result.length(); i--) {
stringBuilder.append("0");
}
stringBuilder.append(result);
result = stringBuilder.toString();
}
return result;
}
public static void searchAPI(String input_content,String page) throws Exception {
//私钥,随机16位字符串(自己可改)
String secKey = "FFFFFFFFFFFFFFFF";
String text = String.format("{\"hlpretag\":\"\",\"hlposttag\":\"\",\"s\":\"%s\",\"type\":\"1\",\"offset\":\"%s\",\"total\":\"%s\",\"limit\":\"30\",\"csrf_token\":\"\"}", input_content, page, page == "0" ? "true":"false");
String modulus = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7";
String nonce = "0CoJUm6Qyw8W8jud";
String pubKey = "010001";
//2次AES加密,得到params
String params = EncryptTools.encrypt(EncryptTools.encrypt(text, nonce), secKey);
StringBuffer stringBuffer = new StringBuffer(secKey);
//逆置私钥
secKey = stringBuffer.reverse().toString();
String hex = Hex.encodeHexString(secKey.getBytes());
BigInteger bigInteger1 = new BigInteger(hex, 16);
BigInteger bigInteger2 = new BigInteger(pubKey, 16);
BigInteger bigInteger3 = new BigInteger(modulus, 16);
//RSA加密计算
BigInteger bigInteger4 = bigInteger1.pow(bigInteger2.intValue()).remainder(bigInteger3);
String encSecKey= Hex.encodeHexString(bigInteger4.toByteArray());
//字符填充
encSecKey= EncryptTools.zfill(encSecKey, 256);
//评论获取
Document document = Jsoup.connect("https://music.163.com/weapi/cloudsearch/get/web?csrf_token=").cookie("appver", "1.5.0.75771")
.header("Referer", "https://music.163.com/search/").data("params", params).data("encSecKey", encSecKey)
.ignoreContentType(true).post();
JSONObject json = JSONObject.parseObject(document.text());
String temp_string = json.getString("result");
System.out.println(temp_string);
List<HashMap> list = JSONObject.parseArray(JSONObject.parseObject(temp_string).getString("songs"), HashMap.class);
System.out.println(JSONObject.parseObject(temp_string).getString("songs"));
System.out.println();
for (HashMap<String,Object> directory_temp : list) {
String song_name = directory_temp.get("name").toString();
String song_id = directory_temp.get("id").toString();
String singer_info = directory_temp.get("ar").toString();
JSONArray test_json = JSONObject.parseArray(singer_info);
JSONObject jsonTemp = JSONObject.parseObject(test_json.get(0).toString());
System.out.println(jsonTemp);
String singer_name = jsonTemp.getString("name");
String singer_id = jsonTemp.getString("id").toString();
System.out.println("歌手姓名是:"+singer_name);
System.out.println("歌手ID是:"+singer_id);
String album_info = directory_temp.get("al").toString();
jsonTemp = JSONObject.parseObject(album_info.toString());
System.out.println(jsonTemp);
String album_name = jsonTemp.getString("name");
String album_id = jsonTemp.getString("id");
String album_pic = jsonTemp.getString("picUrl");
System.out.println("专辑名字是:"+album_name);
System.out.println("专辑ID是:"+album_id);
System.out.println("专辑图片链接是:"+album_pic);
System.out.println("-----------------------------------");
}
//System.out.println("这搜到的歌曲:" + document.text());
}
}
因为我最开始的需求只是抓取歌曲的评论,所以我没有一步一步按照知乎大佬的步骤去抓包和破解,只是直接用他写的代码也实现了功能,但我最近又需要新加一个搜索功能,并且通过网易云的搜索获取歌曲并抓取评论,所以我才从头到尾跟着那位答主的回答走了一遍,然后发现网易云早就更换了获取评论的API,新版本的评论API要比旧版本难用的多,因为多了一个参数course,这个course参数很奇葩,我会在后面详细的说,我先在这里给出抓取评论的Python3/Java代码
Python3版抓取歌曲评论代码
# -*- coding: utf-8 -*-
from Crypto.Cipher import AES
import base64
import requests
import json
headers = {
'Cookie': 'appver=1.5.0.75771;',
'Referer': 'http://music.163.com/'
}
first_param = '{rid:"", offset:"0", total:"true", limit:"20", csrf_token:""}'
#offset就是(评论页数-1) * 20,total在第一页是true,其余是false
second_param = "010001"
third_param = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
forth_param = "0CoJUm6Qyw8W8jud"
def get_params():
iv = b"0102030405060708"
first_key = forth_param
second_key = 16 * 'F'
h_encText = AES_encrypt(first_param, first_key, iv)
h_encText = AES_encrypt(h_encText, second_key, iv)
return h_encText
def get_encSecKey():
encSecKey = "257348aecb5e556c066de214e531faadd1c55d814f9be95fd06d6bff9f4c7a41f831f6394d5a3fd2e3881736d94a02ca919d952872e7d0a50ebfa1769a7a62d512f5f1ca21aec60bc3819a9c3ffca5eca9a0dba6d6f7249b06f5965ecfff3695b54e1c28f3f624750ed39e7de08fc8493242e26dbc4484a01c76f739e135637c"
return encSecKey
def AES_encrypt(text, key, iv):
pad = 16 - len(text) % 16
text = text + pad * chr(pad)
text=text.encode("utf-8")
encryptor = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv)
encrypt_text = encryptor.encrypt(text)
encrypt_text = base64.b64encode(encrypt_text)
return encrypt_text.decode('utf-8')
def get_json(url, params, encSecKey):
data = {
"params": params,
"encSecKey": encSecKey
}
response = requests.post(url, headers=headers, data=data)
return response.content
if __name__ == "__main__":
url = "http://music.163.com/weapi/v1/resource/comments/R_SO_4_452601948/?csrf_token=" # R_SO_4_加上歌曲的id就是抓取评论的API
params = get_params(); #获取 first_param 经过AES两次加密后的结果
encSecKey = get_encSecKey();
json_text = get_json(url, params, encSecKey)
json_dict = json.loads(json_text)
try:
print (json_dict['total'])
for item in json_dict['comments']:
print (item['content'])
except:
print (json_dict)
package com.example.api;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Hex;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
public class EncryptTools {
public static void main(String[] args) throws Exception {
EncryptTools.commentAPI();
}
//AES加密
public static String encrypt(String text, String secKey) throws Exception {
byte[] raw = secKey.getBytes("utf-8");
int padding_size = text.getBytes(StandardCharsets.UTF_8).length;
int padding = 16 - padding_size % 16;
String padding_text = String.join("", Collections.nCopies(padding, Character.toString ((char) padding)));
text = text+ padding_text;
SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES");
// "算法/模式/补码方式"
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
// 使用CBC模式,需要一个向量iv,可增加加密算法的强度
IvParameterSpec iv = new IvParameterSpec("0102030405060708".getBytes("utf-8"));
cipher.init(Cipher.ENCRYPT_MODE, skeySpec, iv);
byte[] encrypted = cipher.doFinal(text.getBytes("utf-8"));
return Base64.getEncoder().encodeToString(encrypted);
}
//字符填充
public static String zfill(String result, int n) {
if (result.length() >= n) {
result = result.substring(result.length() - n, result.length());
} else {
StringBuilder stringBuilder = new StringBuilder();
for (int i = n; i > result.length(); i--) {
stringBuilder.append("0");
}
stringBuilder.append(result);
result = stringBuilder.toString();
}
return result;
}
public static void commentAPI() throws Exception {
//#私钥,随机16位字符串(自己可改)
String secKey = "cd859f54539b24b7";
String text = "{rid:\"\", offset:\"0\", total:\"true\", limit:\"20\", csrf_token:\"\"}";
//offset就是(评论页数-1) * 20,total在第一页是true,其余是false
String modulus = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7";
String nonce = "0CoJUm6Qyw8W8jud";
String pubKey = "010001";
//2次AES加密,得到params
String params = EncryptTools.encrypt(EncryptTools.encrypt(text, nonce), secKey);
StringBuffer stringBuffer = new StringBuffer(secKey);
//逆置私钥
secKey = stringBuffer.reverse().toString();
String hex = Hex.encodeHexString(secKey.getBytes());
BigInteger bigInteger1 = new BigInteger(hex, 16);
BigInteger bigInteger2 = new BigInteger(pubKey, 16);
BigInteger bigInteger3 = new BigInteger(modulus, 16);
//RSA加密计算
BigInteger bigInteger4 = bigInteger1.pow(bigInteger2.intValue()).remainder(bigInteger3);
String encSecKey= Hex.encodeHexString(bigInteger4.toByteArray());
//字符填充
encSecKey= EncryptTools.zfill(encSecKey, 256);
//评论获取
Document document = Jsoup.connect("http://music.163.com/weapi/v1/resource/comments/R_SO_4_452601948/").cookie("appver", "1.5.0.75771")
.header("Referer", "http://music.163.com/").data("params", params).data("encSecKey", encSecKey)
.ignoreContentType(true).post();
System.out.println("评论:" + document.text());
}
}
其实返回的数据里面还有被回复的评论,但当时没注意,现在也懒得写了,需要的可以自己写一下
是的,你没有看错,网易云就是有个bug式未加密API,如果不是看了知乎我也不敢相信,话说知乎大佬还是牛人多啊,可看
这个使用方法就很简单了,直接进去这个链接:http://music.163.com/api/v1/resource/comments/R_SO_4_516997458
剩下的你们都懂了把,不过这个只是第一页的评论,如果想改变页数就直接写成下面格式:http://music.163.com/api/v1/resource/comments/R_SO_4_516997458?limit=20&offset=40
limit和offset就是改变页数的参数,我在代码注释里面写的有,limit是每页获取的(最大)评论数,offset就是(评论页数-1) * 20
我前面说了,因为年代久远,网易云已经更改web端的歌曲评论获取API,虽然老版本的API依然能用,甚至还有未加密的bug级的API获取,但还是很有可能会被官方封掉(虽然好几年过去了依然能用),所以我就写下最新获取评论的API:
https://music.163.com/weapi/comment/resource/comments/get?csrf_token=的参数破解思路。
这个加密格式和要传入的参数格式和前面分析的都是一样的,唯一不一样的就是最关键的参数first_param的内容,因为我们能发现这个最新的API是没有歌曲ID参数的,所以歌曲ID参数是要放到里面的,具体格式就是:
{“rid”:“R_SO_4_33789165”,“threadId”:“R_SO_4_33789165”,“pageNo”:“1”,“pageSize”:“20”,“cursor”:"-1",“offset”:“40”,“orderType”:“1”,“csrf_token”:""}
但这个只是访问第一页的评论的参数格式,第一页以后的里面的值就变化多了,虽然第一页对于抓取热评来说也够了,但出于刨根问底的精神,我还是想要抓取第一页以后的评论,经过分析,除了要把pageNo改成想要的页数外,还要修改cursor的值,而这个cursor的值可以固定也可以不固定,不过我可以明确的告诉你每首歌的cursor肯定不一样,因为这个cursor是根据每一页最后一个用户的评论时间确定的,而且会对offset有影响的。
代码和具体的分析以后有机会再说吧,今天写博客太累了,而且这些天我还要写个JS逆向分析抓取Oh漫画里面漫画的博客,有点儿累了,睡了睡了。
https://www.zhihu.com/question/36081767/answer/140287795 ↩︎