fuo-qqmusic 1.0.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fuo-qqmusic might be problematic. Click here for more details.
- fuo_qqmusic/__init__.py +25 -0
- fuo_qqmusic/api.py +694 -0
- fuo_qqmusic/assets/icon.svg +10 -0
- fuo_qqmusic/consts.py +6 -0
- fuo_qqmusic/excs.py +6 -0
- fuo_qqmusic/provider.py +447 -0
- fuo_qqmusic/provider_ui.py +102 -0
- fuo_qqmusic/schemas.py +359 -0
- fuo_qqmusic-1.0.5.dist-info/METADATA +19 -0
- fuo_qqmusic-1.0.5.dist-info/RECORD +13 -0
- fuo_qqmusic-1.0.5.dist-info/WHEEL +5 -0
- fuo_qqmusic-1.0.5.dist-info/entry_points.txt +2 -0
- fuo_qqmusic-1.0.5.dist-info/top_level.txt +1 -0
fuo_qqmusic/api.py
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
import json
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from .excs import QQIOError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
api_base_url = 'http://c.y.qq.com'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def djb2(string):
|
|
21
|
+
''' Hash a word using the djb2 algorithm with the specified base. '''
|
|
22
|
+
h = 5381
|
|
23
|
+
for c in string:
|
|
24
|
+
h = ((h << 5) + h + ord(c)) & 0xffffffff
|
|
25
|
+
return str(2147483647 & h)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# reference from: https://blog.csdn.net/zq1391345114/article/details/113815906
|
|
29
|
+
def _get_sign(data):
|
|
30
|
+
# zza+一段随机的小写字符串,由小写字母和数字组成,长度为10-16位+CJBPACrRuNy7和data取md5。
|
|
31
|
+
st = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
32
|
+
count = (math.floor(random.randint(10, 16)))
|
|
33
|
+
sign = 'zza'
|
|
34
|
+
for i in range(count):
|
|
35
|
+
sign += st[math.floor(random.randint(0, 35))]
|
|
36
|
+
s = 'CJBPACrRuNy7' + data
|
|
37
|
+
s_md5 = hashlib.md5(s.encode('utf-8')).hexdigest()
|
|
38
|
+
sign += s_md5
|
|
39
|
+
return sign
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CodeShouldBe0(QQIOError):
|
|
43
|
+
def __init__(self, data):
|
|
44
|
+
self._code = data['code']
|
|
45
|
+
|
|
46
|
+
def __str__(self):
|
|
47
|
+
return f'json code field should be 0, got {self._code}'
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def check(cls, data):
|
|
51
|
+
if data['code'] != 0:
|
|
52
|
+
raise cls(data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class API(object):
|
|
56
|
+
"""qq music api
|
|
57
|
+
|
|
58
|
+
Please http capture request from (mobile) qqmusic mobile web page
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, timeout=2):
|
|
62
|
+
# TODO: 暂时无脑统一一个 timeout
|
|
63
|
+
# 正确的应该是允许不同接口有不同的超时时间
|
|
64
|
+
self._timeout = timeout
|
|
65
|
+
self._headers = {
|
|
66
|
+
'Accept': '*/*',
|
|
67
|
+
'Accept-Encoding': 'gzip,deflate,sdch',
|
|
68
|
+
'Accept-Language': 'zh-CN,zh;q=0.8,gl;q=0.6,zh-TW;q=0.4',
|
|
69
|
+
'Referer': 'http://y.qq.com/',
|
|
70
|
+
'User-Agent': 'Mozilla/5.0 (Linux; Android 6.0;'
|
|
71
|
+
' Nexus 5 Build/MRA58N)'
|
|
72
|
+
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
|
73
|
+
'Chrome/66.0.3359.181 Mobile Safari/537.36',
|
|
74
|
+
}
|
|
75
|
+
self.set_cookies(None)
|
|
76
|
+
|
|
77
|
+
def set_cookies(self, cookies):
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
:type cookies: dict
|
|
81
|
+
"""
|
|
82
|
+
if cookies:
|
|
83
|
+
self._cookies = cookies
|
|
84
|
+
self._uin = self.get_uin_from_cookies(cookies)
|
|
85
|
+
self._guid = cookies.get('guid', str(int(random.random() * 1000000000)))
|
|
86
|
+
else:
|
|
87
|
+
self._cookies = None
|
|
88
|
+
self._uin = '0'
|
|
89
|
+
self._guid = str(int(random.random() * 1000000000)) # 暂时不知道 guid 有什么用
|
|
90
|
+
|
|
91
|
+
def get_uin_from_cookies(self, cookies):
|
|
92
|
+
if 'wxuin' in cookies:
|
|
93
|
+
# a sample wxuin: o1152921504803324670
|
|
94
|
+
# remove the 'o' prefix
|
|
95
|
+
wxuin = cookies['wxuin']
|
|
96
|
+
if wxuin.startswith('o'):
|
|
97
|
+
uin = wxuin[1:]
|
|
98
|
+
else:
|
|
99
|
+
uin = wxuin
|
|
100
|
+
else:
|
|
101
|
+
uin = cookies.get('uin')
|
|
102
|
+
return uin
|
|
103
|
+
|
|
104
|
+
def get_token_from_cookies(self):
|
|
105
|
+
cookies = self._cookies
|
|
106
|
+
if not cookies:
|
|
107
|
+
return 5381 # 不知道这个数字有木有特殊含义
|
|
108
|
+
|
|
109
|
+
# 不同客户端cookies返回的字段类型各有不同, 这里做一个折衷
|
|
110
|
+
string = cookies.get('qqmusic_key') or cookies.get('p_skey') or \
|
|
111
|
+
cookies.get('skey') or cookies.get('p_lskey') or \
|
|
112
|
+
cookies.get('lskey') or ''
|
|
113
|
+
return djb2(string)
|
|
114
|
+
|
|
115
|
+
def get_cover(self, mid, type_):
|
|
116
|
+
"""获取专辑、歌手封面
|
|
117
|
+
|
|
118
|
+
:param type_: 专辑: 2,歌手:1
|
|
119
|
+
"""
|
|
120
|
+
return 'http://y.gtimg.cn/music/photo_new/T00{}R800x800M000{}.jpg' \
|
|
121
|
+
.format(type_, mid)
|
|
122
|
+
|
|
123
|
+
def search(self, keyword, type_=0, limit=20, page=1):
|
|
124
|
+
# Other supported types: songlist, user, mv, qc, gedantip, zhida.
|
|
125
|
+
if type_ == 0:
|
|
126
|
+
key_ = 'song'
|
|
127
|
+
elif type_ == 1:
|
|
128
|
+
key_ = 'singer'
|
|
129
|
+
elif type_ == 2:
|
|
130
|
+
key_ = 'album'
|
|
131
|
+
elif type_ == 3:
|
|
132
|
+
key_ = 'songlist' # playlist
|
|
133
|
+
elif type_ == 4:
|
|
134
|
+
key_ = 'mv' # video
|
|
135
|
+
else:
|
|
136
|
+
raise QQIOError('invalid search type_:%d', type_)
|
|
137
|
+
payload = {
|
|
138
|
+
"search": {
|
|
139
|
+
"method": "DoSearchForQQMusicDesktop",
|
|
140
|
+
"module": "music.search.SearchCgiService",
|
|
141
|
+
"param": {
|
|
142
|
+
# People said that the max num_per_page is 30.
|
|
143
|
+
"num_per_page": max(limit, 30),
|
|
144
|
+
"page_num": page,
|
|
145
|
+
"search_type": type_,
|
|
146
|
+
"query": keyword,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
js = self.rpc(payload)
|
|
151
|
+
result = js['search']['data']['body'][key_]['list']
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
def search_playlists(self, query, limit=20, page=1):
|
|
155
|
+
raise QQIOError('search api is not available')
|
|
156
|
+
|
|
157
|
+
def song_detail(self, song_id):
|
|
158
|
+
uin = self._uin
|
|
159
|
+
song_id = int(song_id)
|
|
160
|
+
# 往 payload 添加字段,有可能还可以获取相似歌曲、歌单等
|
|
161
|
+
payload = {
|
|
162
|
+
'comm': {
|
|
163
|
+
'g_tk': 5381,
|
|
164
|
+
'uin': uin,
|
|
165
|
+
'format': 'json',
|
|
166
|
+
'inCharset': 'utf-8',
|
|
167
|
+
'outCharset': 'utf-8',
|
|
168
|
+
'notice': 0,
|
|
169
|
+
'platform': 'h5',
|
|
170
|
+
'needNewCode': 1
|
|
171
|
+
},
|
|
172
|
+
'detail': {
|
|
173
|
+
'module': 'music.pf_song_detail_svr',
|
|
174
|
+
'method': 'get_song_detail',
|
|
175
|
+
'param': {'song_id': song_id}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
js = self.rpc(payload)
|
|
179
|
+
data_song = js['detail']['data']['track_info']
|
|
180
|
+
if data_song['id'] <= 0:
|
|
181
|
+
return None
|
|
182
|
+
return data_song
|
|
183
|
+
|
|
184
|
+
def batch_song_details(self, song_ids):
|
|
185
|
+
"""
|
|
186
|
+
song_ids should be a list of int
|
|
187
|
+
"""
|
|
188
|
+
payload = {
|
|
189
|
+
'comm': self.get_wkv17_common_params(),
|
|
190
|
+
'req_0': {
|
|
191
|
+
'module': 'music.trackInfo.UniformRuleCtrl',
|
|
192
|
+
'method': 'CgiGetTrackInfo',
|
|
193
|
+
'param': {
|
|
194
|
+
'ids': song_ids,
|
|
195
|
+
'types': [200]*len(song_ids),
|
|
196
|
+
'source': 'AiNoFree',
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
js = self.rpc(payload)
|
|
201
|
+
return js['req_0']['data']['tracks']
|
|
202
|
+
|
|
203
|
+
def song_similar(self, song_id):
|
|
204
|
+
payload = {
|
|
205
|
+
"simsongs": {
|
|
206
|
+
"module": "rcmusic.similarSongRadioServer",
|
|
207
|
+
"method": "get_simsongs",
|
|
208
|
+
"param": {
|
|
209
|
+
"songid": song_id,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
js = self.rpc(payload)
|
|
214
|
+
data_songs = js['simsongs']['data']['songInfoList']
|
|
215
|
+
return data_songs
|
|
216
|
+
|
|
217
|
+
def artist_detail(self, artist_mid):
|
|
218
|
+
payload = {
|
|
219
|
+
'req_0': {
|
|
220
|
+
'module': 'music.musichallSinger.SingerInfoInter',
|
|
221
|
+
'method': 'GetSingerDetail',
|
|
222
|
+
'param': {
|
|
223
|
+
'singer_mids': [artist_mid],
|
|
224
|
+
'pic': 1,
|
|
225
|
+
'group_singer': 1,
|
|
226
|
+
'wiki_singer': 1,
|
|
227
|
+
'ex_singer': 1
|
|
228
|
+
}},
|
|
229
|
+
'comm': {
|
|
230
|
+
'g_tk': self.get_token_from_cookies(),
|
|
231
|
+
'uin': self._uin,
|
|
232
|
+
'format': 'json',
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
js = self.rpc(payload)
|
|
236
|
+
data = js['req_0']['data']['singer_list'][0]
|
|
237
|
+
data = {
|
|
238
|
+
'singer_id': data['basic_info']['singer_id'],
|
|
239
|
+
'singer_mid': data['basic_info']['singer_mid'],
|
|
240
|
+
'singer_name': data['basic_info']['name'],
|
|
241
|
+
'SingerDesc': data['ex_info']['desc'],
|
|
242
|
+
}
|
|
243
|
+
return data
|
|
244
|
+
|
|
245
|
+
def artist_songs(self, artist_id, page=1, page_size=50):
|
|
246
|
+
payload = {
|
|
247
|
+
'req_0': {
|
|
248
|
+
'module': 'music.musichallSong.SongListInter',
|
|
249
|
+
'method': 'GetSingerSongList',
|
|
250
|
+
'param': {
|
|
251
|
+
'singerid': artist_id,
|
|
252
|
+
'begin': (page - 1) * page_size,
|
|
253
|
+
'num': page_size,
|
|
254
|
+
'order': 1, # 热门/新,不带这个字段就是按歌曲新旧排序
|
|
255
|
+
# 有 newsong 字段时,服务端会返回含有 file 字段的字典
|
|
256
|
+
'newsong': 1
|
|
257
|
+
}},
|
|
258
|
+
'comm': {
|
|
259
|
+
'g_tk': self.get_token_from_cookies(),
|
|
260
|
+
'uin': self._uin,
|
|
261
|
+
'format': 'json',
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
js = self.rpc(payload)
|
|
265
|
+
return js['req_0']['data']
|
|
266
|
+
|
|
267
|
+
def artist_albums(self, artist_id, page=1, page_size=20):
|
|
268
|
+
url = api_base_url + '/v8/fcg-bin/fcg_v8_singer_album.fcg'
|
|
269
|
+
params = {
|
|
270
|
+
'singerid': artist_id,
|
|
271
|
+
'order': 'time',
|
|
272
|
+
'begin': (page - 1) * page_size, # TODO: 这里应该代表偏移量
|
|
273
|
+
'num': page_size
|
|
274
|
+
}
|
|
275
|
+
response = requests.get(url, params=params)
|
|
276
|
+
js = response.json()
|
|
277
|
+
return js['data']
|
|
278
|
+
|
|
279
|
+
def album_detail(self, album_id):
|
|
280
|
+
url = api_base_url + '/v8/fcg-bin/fcg_v8_album_detail_cp.fcg'
|
|
281
|
+
params = {
|
|
282
|
+
'albumid': album_id,
|
|
283
|
+
'format': 'json',
|
|
284
|
+
'newsong': 1
|
|
285
|
+
}
|
|
286
|
+
resp = requests.get(url, params=params)
|
|
287
|
+
return resp.json()['data']
|
|
288
|
+
|
|
289
|
+
def playlist_detail(self, pid, offset=0, limit=50):
|
|
290
|
+
url = api_base_url + '/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg'
|
|
291
|
+
params = {
|
|
292
|
+
'type': '1',
|
|
293
|
+
'utf8': '1',
|
|
294
|
+
'disstid': pid,
|
|
295
|
+
'format': 'json',
|
|
296
|
+
'new_format': '1', # 需要这个字段来获取file等信息
|
|
297
|
+
'song_begin': offset,
|
|
298
|
+
'song_num': limit,
|
|
299
|
+
}
|
|
300
|
+
resp = requests.get(url, params=params, headers=self._headers,
|
|
301
|
+
cookies=self._cookies, timeout=self._timeout)
|
|
302
|
+
js = resp.json()
|
|
303
|
+
if js['code'] != 0:
|
|
304
|
+
raise CodeShouldBe0(js)
|
|
305
|
+
return js['cdlist'][0]
|
|
306
|
+
|
|
307
|
+
def user_detail(self, uid):
|
|
308
|
+
"""
|
|
309
|
+
this API can be called only when user has logged in
|
|
310
|
+
"""
|
|
311
|
+
url = api_base_url + '/rsc/fcgi-bin/fcg_get_profile_homepage.fcg'
|
|
312
|
+
params = {
|
|
313
|
+
# 这两个字段意义不明,不过至少固定为此值时可正常使用
|
|
314
|
+
'cid': 205360838,
|
|
315
|
+
'reqfrom': 1,
|
|
316
|
+
'userid': uid
|
|
317
|
+
}
|
|
318
|
+
resp = requests.get(url, params=params, headers=self._headers,
|
|
319
|
+
cookies=self._cookies, timeout=self._timeout)
|
|
320
|
+
js = resp.json()
|
|
321
|
+
if js['code'] != 0:
|
|
322
|
+
raise CodeShouldBe0(js)
|
|
323
|
+
return js['data']
|
|
324
|
+
|
|
325
|
+
def user_favorite_artists(self, uid, mid, page=1, page_size=30):
|
|
326
|
+
# FIXME: page/page_size is just a guess
|
|
327
|
+
payload = {
|
|
328
|
+
'req_0': {
|
|
329
|
+
'module': 'music.concern.RelationList',
|
|
330
|
+
'method': 'GetFollowSingerList',
|
|
331
|
+
'param': {
|
|
332
|
+
'From': page - 1,
|
|
333
|
+
'Size': page_size,
|
|
334
|
+
'HostUin': mid
|
|
335
|
+
}},
|
|
336
|
+
'comm': {
|
|
337
|
+
"cv": 4747474,
|
|
338
|
+
"ct": 24,
|
|
339
|
+
'g_tk': int(self.get_token_from_cookies()),
|
|
340
|
+
'g_tk_new_20200303': int(self.get_token_from_cookies()),
|
|
341
|
+
'uin': uid,
|
|
342
|
+
'format': 'json',
|
|
343
|
+
'notice': 0,
|
|
344
|
+
'platform': 'yqq.json',
|
|
345
|
+
'needNewCode': 1,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
js = self.rpc(payload)
|
|
349
|
+
return js['req_1']['data']['List']
|
|
350
|
+
|
|
351
|
+
def user_favorite_albums(self, uid, start=0, end=100):
|
|
352
|
+
url = api_base_url + '/fav/fcgi-bin/fcg_get_profile_order_asset.fcg'
|
|
353
|
+
params = {
|
|
354
|
+
'ct': 20, # 不知道此字段什么含义
|
|
355
|
+
'reqtype': 2,
|
|
356
|
+
'sin': start, # 每一页的开始
|
|
357
|
+
'ein': end, # 每一页的结尾,目前假设最多收藏 30 个专辑
|
|
358
|
+
'cid': 205360956,
|
|
359
|
+
'reqfrom': 1,
|
|
360
|
+
'userid': uid
|
|
361
|
+
}
|
|
362
|
+
resp = requests.get(url, params=params, headers=self._headers,
|
|
363
|
+
cookies=self._cookies, timeout=self._timeout)
|
|
364
|
+
js = resp.json()
|
|
365
|
+
if js['code'] != 0:
|
|
366
|
+
raise CodeShouldBe0(js)
|
|
367
|
+
return js['data']['albumlist']
|
|
368
|
+
|
|
369
|
+
def user_favorite_playlists(self, uid, mid, start=0, end=100):
|
|
370
|
+
url = api_base_url + '/fav/fcgi-bin/fcg_get_profile_order_asset.fcg'
|
|
371
|
+
|
|
372
|
+
params = {
|
|
373
|
+
'loginUin': uid,
|
|
374
|
+
'userid': mid,
|
|
375
|
+
'cid': 205360956,
|
|
376
|
+
'sin': start,
|
|
377
|
+
'ein': end,
|
|
378
|
+
'reqtype': 3,
|
|
379
|
+
'ct': 20, # 没有该字段 返回中文字符是乱码
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
resp = requests.get(url, params=params, headers=self._headers,
|
|
383
|
+
cookies=self._cookies, timeout=self._timeout)
|
|
384
|
+
js = resp.json()
|
|
385
|
+
if js['code'] != 0:
|
|
386
|
+
raise CodeShouldBe0(js)
|
|
387
|
+
return js['data']['cdlist']
|
|
388
|
+
|
|
389
|
+
def recommend_playlists(self):
|
|
390
|
+
data = {
|
|
391
|
+
'recomPlaylist': {
|
|
392
|
+
'module': "playlist.HotRecommendServer",
|
|
393
|
+
'method': "get_hot_recommend",
|
|
394
|
+
'param': {
|
|
395
|
+
'cmd': 2,
|
|
396
|
+
'async': 1
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
js = self.rpc(data)
|
|
401
|
+
playlist = js['recomPlaylist']
|
|
402
|
+
return playlist['data']['v_hot']
|
|
403
|
+
|
|
404
|
+
def get_recommend_feed(self, page=1):
|
|
405
|
+
# APIs are found in https://y.qq.com/wk_v17/#/recommend
|
|
406
|
+
data = {
|
|
407
|
+
'req_0': {
|
|
408
|
+
'module': 'recommend.RecommendFeedServer',
|
|
409
|
+
'method': 'get_recommend_feed',
|
|
410
|
+
'param': {
|
|
411
|
+
'direction': 0,
|
|
412
|
+
'page': page,
|
|
413
|
+
'v_cache': [],
|
|
414
|
+
'v_uniq': [],
|
|
415
|
+
's_num': 0
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
'comm': self.get_wkv17_common_params(),
|
|
419
|
+
}
|
|
420
|
+
js = self.rpc(data)
|
|
421
|
+
return js['req_0']['data']
|
|
422
|
+
|
|
423
|
+
def get_lyric_by_songmid(self, songmid):
|
|
424
|
+
url = api_base_url + '/lyric/fcgi-bin/fcg_query_lyric_new.fcg'
|
|
425
|
+
params = {
|
|
426
|
+
'songmid': songmid,
|
|
427
|
+
'pcachetime': int(round(time.time() * 1000)),
|
|
428
|
+
'format': 'json',
|
|
429
|
+
}
|
|
430
|
+
response = requests.get(url, params=params, headers=self._headers,
|
|
431
|
+
timeout=self._timeout)
|
|
432
|
+
js = response.json()
|
|
433
|
+
CodeShouldBe0.check(js)
|
|
434
|
+
lyric = js['lyric'] or ''
|
|
435
|
+
return base64.b64decode(lyric).decode()
|
|
436
|
+
|
|
437
|
+
def rpc(self, payload):
|
|
438
|
+
if 'comm' not in payload:
|
|
439
|
+
payload['comm'] = self.get_common_params()
|
|
440
|
+
data_str = json.dumps(payload, ensure_ascii=False)
|
|
441
|
+
params = {
|
|
442
|
+
'_': int(round(time.time() * 1000)),
|
|
443
|
+
'sign': _get_sign(data_str),
|
|
444
|
+
'data': data_str,
|
|
445
|
+
}
|
|
446
|
+
url = 'https://u.y.qq.com/cgi-bin/musicu.fcg'
|
|
447
|
+
resp = requests.get(url, params=params, headers=self._headers,
|
|
448
|
+
cookies=self._cookies, timeout=self._timeout)
|
|
449
|
+
js = resp.json()
|
|
450
|
+
CodeShouldBe0.check(js)
|
|
451
|
+
return js
|
|
452
|
+
|
|
453
|
+
def get_wkv17_common_params(self):
|
|
454
|
+
return {
|
|
455
|
+
# ct field is important, without this field,
|
|
456
|
+
# the req_0 result is completely different.
|
|
457
|
+
"ct": 20,
|
|
458
|
+
"cv": 1770,
|
|
459
|
+
'g_tk': 5381,
|
|
460
|
+
'uin': self._uin,
|
|
461
|
+
'format': 'json',
|
|
462
|
+
'inCharset': 'utf-8',
|
|
463
|
+
'outCharset': 'utf-8',
|
|
464
|
+
'platform': 'wk_v17',
|
|
465
|
+
'uid': '',
|
|
466
|
+
'guid': '',
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
def get_common_params(self):
|
|
470
|
+
return {
|
|
471
|
+
'loginUin': self._uin,
|
|
472
|
+
'hostUin': 0,
|
|
473
|
+
'g_tk': self.get_token_from_cookies(),
|
|
474
|
+
'inCharset': 'utf8',
|
|
475
|
+
'outCharset': 'utf-8',
|
|
476
|
+
'notice': 0,
|
|
477
|
+
'platform': 'yqq',
|
|
478
|
+
'needNewCode': 0,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
def get_mv(self, vid):
|
|
482
|
+
payload = {
|
|
483
|
+
'getMvUrl': {
|
|
484
|
+
'module': "gosrf.Stream.MvUrlProxy",
|
|
485
|
+
'method': "GetMvUrls",
|
|
486
|
+
'param': {
|
|
487
|
+
"vids": [vid],
|
|
488
|
+
'request_typet': 10001
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
js = self.rpc(payload)
|
|
493
|
+
return js['getMvUrl']['data'][vid]
|
|
494
|
+
|
|
495
|
+
def get_radio_music(self):
|
|
496
|
+
payload = {
|
|
497
|
+
'songlist': {
|
|
498
|
+
'module': "mb_track_radio_svr",
|
|
499
|
+
'method': "get_radio_track",
|
|
500
|
+
'param': {
|
|
501
|
+
'id': 99,
|
|
502
|
+
'firstplay': 1,
|
|
503
|
+
'num': 15
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
'radiolist': {
|
|
507
|
+
'module': "pf.radiosvr",
|
|
508
|
+
'method': "GetRadiolist",
|
|
509
|
+
'param': {
|
|
510
|
+
'ct': "24"
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
'comm': {
|
|
514
|
+
'ct': 24,
|
|
515
|
+
'cv': 0
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
js = self.rpc(payload)
|
|
519
|
+
return js['songlist']['data']['tracks']
|
|
520
|
+
|
|
521
|
+
def get_song_url(self, song_mid):
|
|
522
|
+
uin = self._uin
|
|
523
|
+
songvkey = str(random.random()).replace("0.", "")
|
|
524
|
+
guid = self._guid
|
|
525
|
+
# filename = f'C400{song_mid}.m4a'
|
|
526
|
+
data = {
|
|
527
|
+
"req": {
|
|
528
|
+
"module": "CDN.SrfCdnDispatchServer",
|
|
529
|
+
"method": "GetCdnDispatch",
|
|
530
|
+
"param": {
|
|
531
|
+
"guid": guid,
|
|
532
|
+
"calltype": 0,
|
|
533
|
+
"userip": ""
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
"req_0": {
|
|
537
|
+
"module": "vkey.GetVkeyServer",
|
|
538
|
+
"method": "CgiGetVkey",
|
|
539
|
+
"param": {
|
|
540
|
+
"cid": 205361747,
|
|
541
|
+
"guid": guid,
|
|
542
|
+
"songmid": [song_mid],
|
|
543
|
+
# "filename": [filename],
|
|
544
|
+
"songtype": [1],
|
|
545
|
+
"uin": str(uin), # NOTE: must be a string
|
|
546
|
+
# "loginflag": 1,
|
|
547
|
+
# "platform": "20"
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
"comm": {
|
|
551
|
+
"uin": uin,
|
|
552
|
+
"format": "json",
|
|
553
|
+
"ct": 24,
|
|
554
|
+
"cv": 0
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
data_str = json.dumps(data, ensure_ascii=False)
|
|
558
|
+
params = {
|
|
559
|
+
'-': 'getplaysongvkey' + str(songvkey),
|
|
560
|
+
'g_tk': 5381,
|
|
561
|
+
'loginUin': uin,
|
|
562
|
+
'hostUin': 0,
|
|
563
|
+
'format': 'json',
|
|
564
|
+
'inCharset': 'utf8',
|
|
565
|
+
'outCharset': 'utf8',
|
|
566
|
+
'notice': 0,
|
|
567
|
+
'platform': 'yqq.json',
|
|
568
|
+
'needNewCode': 0,
|
|
569
|
+
}
|
|
570
|
+
# 这里没有把 data=data_str 放在 params 中,因为 QQ 服务端不识别这种写法
|
|
571
|
+
# 另外测试发现:python(flask) 是可以识别这两种写法的
|
|
572
|
+
url = 'http://u.y.qq.com/cgi-bin/musicu.fcg?data=' + data_str
|
|
573
|
+
# 如果是绿钻会员,这里带上 cookies,就能请求到收费歌曲的 url
|
|
574
|
+
resp = requests.get(url, params=params,
|
|
575
|
+
headers=self._headers, cookies=self._cookies)
|
|
576
|
+
js = resp.json()
|
|
577
|
+
midurlinfo = js['req_0'].get('data', {}).get('midurlinfo')
|
|
578
|
+
if midurlinfo:
|
|
579
|
+
purl = midurlinfo[0]['purl']
|
|
580
|
+
prefix = 'http://dl.stream.qqmusic.qq.com/'
|
|
581
|
+
prefix = 'http://mobileoc.music.tc.qq.com/'
|
|
582
|
+
|
|
583
|
+
# 经过个人(cosven)测试,无论是普通还是绿钻用户,下面几个都会失败
|
|
584
|
+
quality_suffix = [
|
|
585
|
+
# ('sq', 'M500', 'mp3'),
|
|
586
|
+
# ('shq', 'F000', 'flac'),
|
|
587
|
+
# ('hq', 'M800', 'mp3'),
|
|
588
|
+
# ('shq', 'A000', 'ape'),
|
|
589
|
+
]
|
|
590
|
+
C400_filename = midurlinfo[0]['filename']
|
|
591
|
+
pure_filename = C400_filename[4:-3]
|
|
592
|
+
|
|
593
|
+
req_data = js['req']['data']
|
|
594
|
+
testfilewifi = req_data.get('testfilewifi', '')
|
|
595
|
+
vkey = req_data['vkey']
|
|
596
|
+
|
|
597
|
+
# 抓客户端的包,发现 guid/uin/vkey 三个参数配上对非常重要。
|
|
598
|
+
# 尝试了客户端的 cookie 拷贝过来,还是请求不到无损音乐。
|
|
599
|
+
if testfilewifi:
|
|
600
|
+
params_str = testfilewifi.split('?')[1]
|
|
601
|
+
else:
|
|
602
|
+
params_str = f'vkey={vkey}&guid=MS&uin=0&fromtag=8'
|
|
603
|
+
|
|
604
|
+
# 如果前两个音质都不行,我们认为后面的音乐也都不可以,
|
|
605
|
+
# 目前通过这样简单的策略来节省请求次数
|
|
606
|
+
max_try_count = 2
|
|
607
|
+
failed_try_count = 0
|
|
608
|
+
valid_urls = {}
|
|
609
|
+
for quality, q, s in quality_suffix:
|
|
610
|
+
if quality in valid_urls:
|
|
611
|
+
continue
|
|
612
|
+
q_filename = q + pure_filename + s
|
|
613
|
+
# 通过抓客户端接口可以发现,这个 uin 和用户 uin 不是一个东西
|
|
614
|
+
# 这个 uin 似乎只有三位数
|
|
615
|
+
url = f'{prefix}{q_filename}?{params_str}'
|
|
616
|
+
print(url)
|
|
617
|
+
_resp = requests.head(url, headers=self._headers, cookies=self._cookies)
|
|
618
|
+
if _resp.status_code == 200:
|
|
619
|
+
valid_urls[quality] = url
|
|
620
|
+
logger.info(f'song:{song_mid} quality:{q} url is valid')
|
|
621
|
+
continue
|
|
622
|
+
logger.info(f'song:{song_mid} quality:{q} url is invalid')
|
|
623
|
+
failed_try_count += 1
|
|
624
|
+
if failed_try_count >= max_try_count:
|
|
625
|
+
break
|
|
626
|
+
# 尝试拿到网页版接口的 url
|
|
627
|
+
if not valid_urls and purl:
|
|
628
|
+
song_path = purl
|
|
629
|
+
url = prefix + song_path
|
|
630
|
+
valid_urls['lq'] = url
|
|
631
|
+
logger.info(f'song:{song_mid} quality:web url is valid')
|
|
632
|
+
return valid_urls
|
|
633
|
+
return {}
|
|
634
|
+
|
|
635
|
+
def get_song_url_v2(self, song_mid, media_id, quality):
|
|
636
|
+
switcher = {
|
|
637
|
+
'F000': 'flac',
|
|
638
|
+
'A000': 'ape',
|
|
639
|
+
'M800': 'mp3',
|
|
640
|
+
'C600': 'm4a',
|
|
641
|
+
'M500': 'mp3'
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
uin = self._uin
|
|
645
|
+
guid = self._guid
|
|
646
|
+
filename = '{}{}.{}'.format(quality, media_id, switcher.get(quality))
|
|
647
|
+
data = {
|
|
648
|
+
"req_0": {
|
|
649
|
+
"module": "vkey.GetVkeyServer",
|
|
650
|
+
"method": "CgiGetVkey",
|
|
651
|
+
"param": {
|
|
652
|
+
"filename": [filename],
|
|
653
|
+
"guid": guid,
|
|
654
|
+
"songmid": [song_mid],
|
|
655
|
+
"songtype": [0],
|
|
656
|
+
"uin": str(uin), # NOTE: must be a string
|
|
657
|
+
"loginflag": 1,
|
|
658
|
+
"platform": "20"
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
"comm": {
|
|
662
|
+
"uin": str(uin),
|
|
663
|
+
"format": "json",
|
|
664
|
+
"ct": 19,
|
|
665
|
+
"cv": 0
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
data_str = json.dumps(data, ensure_ascii=False)
|
|
669
|
+
|
|
670
|
+
sign = _get_sign(data_str)
|
|
671
|
+
params = {
|
|
672
|
+
'sign': sign,
|
|
673
|
+
'g_tk': 5381,
|
|
674
|
+
'loginUin': '',
|
|
675
|
+
'hostUin': 0,
|
|
676
|
+
'format': 'json',
|
|
677
|
+
'inCharset': 'utf8',
|
|
678
|
+
'outCharset': 'utf-8¬ice=0',
|
|
679
|
+
'platform': 'yqq.json',
|
|
680
|
+
'needNewCode': 0,
|
|
681
|
+
'data': data_str
|
|
682
|
+
}
|
|
683
|
+
url = 'https://u.y.qq.com/cgi-bin/musicu.fcg'
|
|
684
|
+
# TODO: 似乎存在一种有效时间更长的cookies, https://github.com/PeterDing/chord
|
|
685
|
+
resp = requests.get(url, params=params,
|
|
686
|
+
headers=self._headers, cookies=self._cookies)
|
|
687
|
+
js = resp.json()
|
|
688
|
+
midurlinfo = js['req_0'].get('data', {}).get('midurlinfo')
|
|
689
|
+
if midurlinfo and midurlinfo[0]['purl']:
|
|
690
|
+
return 'http://isure.stream.qqmusic.qq.com/{}'.format(midurlinfo[0]['purl'])
|
|
691
|
+
return ''
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
api = API()
|