├── README.md ├── main.py └── requestments.txt /README.md: -------------------------------------------------------------------------------- 1 | ## 名字(Name) 2 | 3 | netease_download -- 一款简单且极少依赖的网易云音乐下载器,支持根据网易歌曲详情修改mp3的id3 tag信息 4 | 5 | ## 声明(Statement) 6 | 7 | 代码为大多是本人原创,部分参考其他开源项目(代码内部和下方有注明),且仅限于学习交流,请勿用于任何商业用途!本人不承担任何法律责任,如果涉及到侵权问题,请留言告知。 8 | 9 | ## 使用(Useage) 10 | 11 | 需要python3 12 | 13 | **注意:** 14 | 1. 如果是windows的cmd/powershell,默认字符集是GBK,部分歌名会报错,所以需要运行 `CHCP 65001`。 15 | 2. 部分歌曲下架后,下载时会提示,暂无版权。 16 | 17 | 18 | ```bash 19 | git clone https://github.com/anjia0532/netease_download.git 20 | cd netease_download 21 | pip install -r requestments.txt 22 | 23 | python main.py http://music.163.com/#/song?id=27836179 # 单曲 24 | python main.py http://music.163.com/#/album?id=37253721 # 专辑 25 | python main.py http://music.163.com/#/playlist?id=2225407480 # 歌单 26 | python main.py http://music.163.com/#/discover/toplist?id=1978921795 # 流行榜 27 | python main.py http://music.163.com/#/artist?id=905705 # 艺术家 28 | python main.py http://music.163.com/#/djradio?id=526696677 # 电台节目 29 | python main.py http://music.163.com/#/program?id=1369232209 # dj 30 | ``` 31 | 32 | ## 鸣谢(Thanks) 33 | 34 | 本工具借鉴了下面三个优秀开源项目的代码,特此注明 35 | 36 | - https://github.com/Jack-Cherish/python-spider.git (Encrypyed 部分) 37 | 38 | - https://github.com/PeterDing/iScript.git (50%+的代码,包括但不限于根据url解析不同分属,修改id3 tag,解析歌曲信息,但是下载部分改用py原生,大部分接口改用网易api) 39 | 40 | - https://github.com/Binaryify/NeteaseCloudMusicApi.git (api的uri和参数调用部分) 41 | 42 | ## 反馈(Feedback) 43 | 44 | 如果有问题,欢迎提 [issues][] 45 | 46 | Copyright and License 47 | ===================== 48 | 49 | This module is licensed under the BSD license. 50 | 51 | Copyright (C) 2017-, by AnJia . 52 | 53 | All rights reserved. 54 | 55 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 56 | 57 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 58 | 59 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 60 | 61 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 62 | 63 | 64 | [issues]: https://github.com/anjia0532/netease_download/issues/new 65 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import re 3 | import sys 4 | import os 5 | import random 6 | import time 7 | import json 8 | import argparse 9 | import requests 10 | from hashlib import md5 11 | from mutagen.id3 import ID3,TRCK,TIT2,TALB,TPE1,APIC,TDRC,COMM,TPOS,USLT 12 | import html 13 | import tqdm,base64, binascii 14 | from Crypto.Cipher import AES 15 | 16 | 17 | # 解析歌曲信息header头 18 | headers = { 19 | 'Accept': '*/*', 20 | 'Accept-Encoding': 'gzip,deflate,sdch', 21 | 'Accept-Language': 'zh-CN,zh;q=0.8,gl;q=0.6,zh-TW;q=0.4', 22 | 'Connection': 'keep-alive', 23 | 'Content-Type': 'application/x-www-form-urlencoded', 24 | 'Host': 'music.163.com', 25 | 'Referer': 'http://music.163.com/search/', 26 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' \ 27 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36' 28 | } 29 | # 下载时header 30 | download_headers={ 31 | 'Accept-Encoding': 'identity;q=1, *;q=0', 32 | 'DNT': '1', 33 | 'Range': 'bytes=0-', 34 | 'Referer': 'http://music.163.com/', 35 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' \ 36 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36', 37 | } 38 | # 网易云音乐api 的uri 39 | apis={ 40 | 'song_detail':'/weapi/v3/song/detail', 41 | 'song_url':'/weapi/song/enhance/player/url', 42 | 'play_detail':'/api/playlist/detail?id=%s', 43 | 'album':'/weapi/v1/album/%s', 44 | 'artist':'/weapi/v1/artist/%s', 45 | 'artist_album':'/weapi/artist/albums/%s', 46 | 'djradio':'/weapi/dj/program/byradio', 47 | 'dj':'/weapi/dj/program/detail', 48 | } 49 | 50 | ss = requests.session() 51 | ss.headers.update(headers) 52 | 53 | s = u'\x1b[%d;%dm%s\x1b[0m' # terminual color template 54 | 55 | # 去除非法字符 56 | def modificate_text(text): 57 | text = html.unescape(text or '') 58 | text = re.sub(r'//*', '-', text) 59 | text = text.replace('/', '-') 60 | text = text.replace('\\', '-') 61 | text = re.sub(r'\s\s+', ' ', text) 62 | return text 63 | 64 | # 去除非法字符 65 | def modificate_file_name_for_wget(file_name): 66 | file_name = re.sub(r'\s*:\s*', u' - ', file_name) 67 | file_name = file_name.replace('?', '') 68 | file_name = file_name.replace('"', '\'') 69 | file_name = file_name.replace('*', '_') 70 | return file_name 71 | 72 | # 复制自 https://github.com/Jack-Cherish/python-spider/blob/01c82a70cdc1783a09d537023ef14947f8588533/Netease/Netease.py#L12 73 | class encrypyed(): 74 | """ 75 | 解密算法 76 | """ 77 | def __init__(self): 78 | self.modulus = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b' \ 79 | '725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda' \ 80 | '92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3' \ 81 | 'e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7' 82 | self.nonce = '0CoJUm6Qyw8W8jud' 83 | self.pub_key = '010001' 84 | 85 | # 登录加密算法, 基于https://github.com/stkevintan/nw_musicbox脚本实现 86 | def encrypted_request(self, text): 87 | text = json.dumps(text) 88 | sec_key = self.create_secret_key(16) 89 | enc_text = self.aes_encrypt(self.aes_encrypt(text, self.nonce), sec_key.decode('utf-8')) 90 | enc_sec_key = self.rsa_encrpt(sec_key, self.pub_key, self.modulus) 91 | data = {'params': enc_text, 'encSecKey': enc_sec_key} 92 | return data 93 | 94 | def aes_encrypt(self, text, secKey): 95 | pad = 16 - len(text) % 16 96 | text = text + chr(pad) * pad 97 | encryptor = AES.new(secKey.encode('utf-8'), AES.MODE_CBC, b'0102030405060708') 98 | ciphertext = encryptor.encrypt(text.encode('utf-8')) 99 | ciphertext = base64.b64encode(ciphertext).decode('utf-8') 100 | return ciphertext 101 | 102 | def rsa_encrpt(self, text, pubKey, modulus): 103 | text = text[::-1] 104 | rs = pow(int(binascii.hexlify(text), 16), int(pubKey, 16), int(modulus, 16)) 105 | return format(rs, 'x').zfill(256) 106 | 107 | def create_secret_key(self, size): 108 | return binascii.hexlify(os.urandom(size))[:16] 109 | 110 | class neteaseMusic(object): 111 | def __init__(self): 112 | self.song_infos = [] 113 | # 默认当前目录,可以通过参数指定 114 | self.dir_ = args.dir or os.getcwd() 115 | self.ep = encrypyed() 116 | self.timeout = 60 117 | 118 | 119 | def id_parser(self,key): 120 | ''' 121 | 获取所需id 122 | :key key 123 | :url url 124 | :return 所需id 125 | ''' 126 | return re.search(r'%s.+?(\d+)'%key, args.url).group(1) 127 | 128 | def post_request(self, uri, params): 129 | ''' 130 | 调用网易api 131 | :uri api的uri,不含host段 132 | :params 待加密请求参数 133 | :return 如果code=200,返回执行结果,否则返回None 134 | ''' 135 | data = self.ep.encrypted_request(params) 136 | resp = ss.post('http://music.163.com/%s'%uri, data=data, timeout=self.timeout) 137 | result = resp.json() 138 | if result['code'] != 200: 139 | print('post_request error') 140 | return None 141 | else: 142 | return result 143 | 144 | def get_durls(self,songs): 145 | ''' 146 | :songs 待解析歌曲列表 147 | return {key 歌曲id,value 歌曲url} 148 | ''' 149 | params = {'ids': list(s["id"] for s in songs), 'br': '320000', 'csrf_token': ''} 150 | result = self.post_request(apis['song_url'], params) 151 | return dict([str(s["id"]),s["url"]] for s in result['data'] if result['code'] == 200) 152 | 153 | 154 | def modified_id3(self, file_name, info): 155 | ''' 156 | 给歌曲增加id3 tag信息 157 | :file_name 待修改的歌曲完整地址 158 | :info 歌曲信息 159 | :return None 160 | ''' 161 | 162 | if not os.path.exists(file_name): 163 | return None 164 | 165 | id3 = ID3() 166 | id3.add(TRCK(encoding=3, text=info['track'])) 167 | id3.add(TDRC(encoding=3, text=info['year'])) 168 | id3.add(TIT2(encoding=3, text=info['song_name'])) 169 | id3.add(TALB(encoding=3, text=info['album_name'])) 170 | id3.add(TPE1(encoding=3, text=info['artist_name'])) 171 | id3.add(TPOS(encoding=3, text=info['cd_serial'])) 172 | id3.add(COMM(encoding=3, desc=u'Comment', text=info['song_url'])) 173 | id3.save(file_name) 174 | 175 | def get_song_infos(self, songs): 176 | ''' 177 | 获取歌曲列表中歌曲信息 178 | :songs 待解析歌曲列表 179 | :return 歌曲信息列表 180 | ''' 181 | urls=self.get_durls(songs) 182 | for i in songs: 183 | song_info = self.get_song_info(i) 184 | song_info['durl']=urls.get(str(i['id'])) #歌曲地址 185 | self.song_infos.append(song_info) 186 | 187 | def get_song_info(self, i): 188 | ''' 189 | 解析歌曲信息 190 | :i song json 191 | :return 解析后的歌曲信息 192 | ''' 193 | song_info = {} 194 | song_info['song_id'] = i['id'] 195 | song_info['song_url'] = u'http://music.163.com/song/%s'% i['id'] 196 | song_info['track'] = str(i['no']) #歌曲在专辑里的序号 197 | 198 | al,ar,h,m,publishTime='al','ar','h','m',i.get('publishTime') 199 | if not 'publishTime' in i: 200 | al,ar,h,m,publishTime='album','artists','hMusic','mMusic',i['album']['publishTime'] 201 | song_info['mp3_quality'] =h in i and 'h' or m in i and 'm' or 'l' 202 | t = time.gmtime(int(publishTime<0 and 946656000000 or publishTime)*0.001) 203 | song_info['year'] = '-'.join([str(t.tm_year), str(t.tm_mon), str(t.tm_mday)]) 204 | song_info['song_name'] = modificate_text(i['name']).strip() 205 | song_info['artist_name'] = modificate_text(' & '.join(str(ar['name']) for ar in i[ar])) 206 | song_info['album_pic_url'] = i[al]['picUrl'] 207 | song_info['cd_serial'] = '1' 208 | song_info['album_name'] = modificate_text(i[al]['name']) 209 | 210 | file_name = song_info['song_name'] \ 211 | + ' - ' + song_info['artist_name'] \ 212 | + '.mp3' 213 | song_info['file_name'] = file_name 214 | return song_info 215 | 216 | def download_song(self,song_id,name=""): 217 | ''' 218 | 解析单曲 219 | :song_id 歌曲id 220 | :return 单曲信息 221 | ''' 222 | params={'c':str([{'id':song_id}]),'ids':[song_id],'csrf_token':''} 223 | result = self.post_request(apis['song_detail'],params) 224 | song=result['songs'] and result['songs'][0] 225 | song["name"]=song["name"] or name 226 | self.get_song_infos([song]) 227 | print(s % (2, 97, u'\n >> 1首歌曲将要下载.')) 228 | 229 | def download_playlist(self,playlist_id): 230 | ''' 231 | 解析歌单 232 | :playlist_id 歌单id 233 | :return 歌单信息 234 | ''' 235 | params={'id':playlist_id,'n':100000,'csrf_token':''} 236 | result = self.post_request(apis['play_detail']%playlist_id,params) 237 | songs = result['result']['tracks'] 238 | d = modificate_text(result['result']['name'] + ' - ' \ 239 | + result['result']['creator']['nickname']) 240 | self.dir_ = os.path.join(os.getcwd(), modificate_file_name_for_wget(d)) 241 | self.amount_songs = str(len(songs)) 242 | print(s % (2, 97, u'\n >> ' + self.amount_songs + u' 首歌曲将要下载.')) 243 | self.get_song_infos(songs) 244 | 245 | 246 | def download_album(self,album_id): 247 | ''' 248 | 解析专辑 249 | :album_id 专辑id 250 | :return 专辑信息 251 | ''' 252 | params={'csrf_token':''} 253 | result = self.post_request(apis['album']%album_id,params) 254 | songs=result['songs'] 255 | d = modificate_text(result['album']['name'] \ 256 | + ' - ' + result['album']['artist']['name']) 257 | self.dir_ = os.path.join(os.getcwd(), modificate_file_name_for_wget(d)) 258 | self.amount_songs = str(len(songs)) 259 | 260 | print(s % (2, 97, u'\n >> ' + self.amount_songs + u' 首歌曲将要下载.')) 261 | 262 | for i in songs: 263 | i['publishTime']=result['album']['publishTime'] 264 | self.get_song_infos(songs) 265 | 266 | 267 | def download_artist_albums(self,artist_id): 268 | ''' 269 | 解析艺术家所有专辑 270 | :artist_id 艺术家id 271 | :return 专辑id 272 | ''' 273 | album_ids,offset,total=[],0,30 274 | while len(album_ids)> ' + amount_songs + u' 首歌曲将要下载.')) 305 | for i in songs: 306 | i['publishTime']=result['artist']['publishTime'] 307 | self.get_song_infos(songs) 308 | 309 | def download_djradio(self,djradio_id): 310 | ''' 311 | 电台节目 312 | :djradio_id 电台节目id 313 | :return 电台节目信息 314 | ''' 315 | radio_ids,offset,total=[],0,30 316 | 317 | while len(radio_ids)> 输入 a 下载该艺术家所有专辑.\n' \ 408 | ' >> 输入 t 下载该艺术家 Top 50 歌曲.\n >> ') 409 | if code == 'a': 410 | print(s % (2, 92, u'\n -- 正在分析艺术家专辑信息 ...')) 411 | self.download_artist_albums(artist_id) 412 | elif code == 't': 413 | print(s % (2, 92, u'\n -- 正在分析艺术家 Top 50 信息 ...')) 414 | self.download_artist_songs(artist_id) 415 | else: 416 | print(s % (1, 92, u' --> Over')) 417 | elif 'song' in url: 418 | print(s % (2, 92, u'\n -- 正在分析歌曲信息 ...')) 419 | self.download_song(self.id_parser('song')) 420 | elif 'djradio' in url: 421 | print(s % (2, 92, u'\n -- 正在分析DJ节目信息 ...')) 422 | self.download_djradio(self.id_parser('id')) 423 | elif 'program' in url: 424 | print(s % (2, 92, u'\n -- 正在分析DJ节目信息 ...')) 425 | self.download_dj(self.id_parser('id')) 426 | else: 427 | print(s % (2, 91, u' 请正确输入music.163.com网址.')) 428 | sys.exit(0) 429 | self.download() 430 | print('结束') 431 | 432 | def main(url): 433 | x = neteaseMusic() 434 | x.url_parser(url) 435 | 436 | if __name__ == '__main__': 437 | p = argparse.ArgumentParser( 438 | description='downloading any music.163.com') 439 | 440 | p.add_argument('url', help='any url of music.163.com') 441 | p.add_argument('-d','--dir', help='save files to this dir',default="mp3") 442 | p.add_argument('-c', '--undownload', action='store_true', \ 443 | help='no download, using to renew id3 tags') 444 | # args = p.parse_args(args=["http://music.163.com/#/song?id=27836179"]) 445 | # args = p.parse_args(args=["http://music.163.com/#/album?id=37253721"]) 446 | # args = p.parse_args(args=["http://music.163.com/#/playlist?id=2225407480"]) 447 | # args = p.parse_args(args=["http://music.163.com/#/discover/toplist?id=1978921795"]) 448 | # args = p.parse_args(args=["http://music.163.com/#/artist?id=905705"]) 449 | # args = p.parse_args(args=["http://music.163.com/#/djradio?id=526696677"]) 450 | # args = p.parse_args(args=["http://music.163.com/#/program?id=1369232209"]) 451 | args = p.parse_args() 452 | main(args.url) 453 | -------------------------------------------------------------------------------- /requestments.txt: -------------------------------------------------------------------------------- 1 | mutagen==1.40.0 2 | pycryptodome==3.6.1 3 | requests==2.18.4 4 | tqdm==4.23.3 5 | --------------------------------------------------------------------------------