├── .gitignore ├── LICENSE ├── README.md ├── config.sample.yaml ├── index.py ├── redis_session.py ├── requirements.txt ├── static ├── app.css ├── app.js ├── app.min.css └── app.min.js └── templates ├── g-recaptcha.j2 └── index.j2 /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | config.yaml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Jixun.Moe 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 网易云音乐直链解析 API 2 | 通过模拟浏览器调用网易云网页版的 API,实现网易云音乐的永久解析链接。 3 | 4 | 详细博文请参考:[网易云音乐直链解析 API 服务器 / 梦姬博客](https://jixun.moe/netease-cloud-music-direct-api/) 5 | -------------------------------------------------------------------------------- /config.sample.yaml: -------------------------------------------------------------------------------- 1 | # Custom sign key 2 | sign_salt: salt 3 | 4 | debug: false 5 | 6 | redis: 7 | host: localhost 8 | port: 6379 9 | db: 0 10 | 11 | # The real ip header passed by reverse proxy (Apache, Nginx etc.) 12 | # None: null 13 | # Apache: X-Forwarded-For 14 | # Nginx: X-Real-IP 15 | # CloudFlare: CF-Connecting-IP 16 | ip_header: null 17 | 18 | # Netease Cloud Music API Key 19 | encrypt: 20 | e: >- 21 | e 22 | 23 | n: >- 24 | n 25 | 26 | nonce: >- 27 | nonce 28 | 29 | # Google reCAPTCHA key 30 | recaptcha: 31 | secret: secret 32 | sitekey: sitekey 33 | -------------------------------------------------------------------------------- /index.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python2.7 2 | # -*- coding: utf-8 -*- 3 | 4 | from flask import * 5 | 6 | from Crypto.Cipher import AES 7 | from Crypto.PublicKey import RSA 8 | from Crypto.Hash import SHA256 9 | from random import randint 10 | 11 | import binascii, os, json 12 | import yaml, requests 13 | 14 | from redis_session import RedisSessionInterface 15 | 16 | # Load and parse config file 17 | config = yaml.load(file('config.yaml', 'r')) 18 | encrypt = config['encrypt'] 19 | 20 | app = Flask(__name__, static_url_path='/static') 21 | app.config['recaptcha'] = config['recaptcha'] 22 | app.debug = config['debug'] 23 | app.session_interface = RedisSessionInterface(config['redis']) 24 | 25 | def aesEncrypt(text, secKey): 26 | pad = 16 - len(text) % 16 27 | text = text + pad * chr(pad) 28 | encryptor = AES.new(secKey, 1) 29 | cipherText = encryptor.encrypt(text) 30 | cipherText = binascii.b2a_hex(cipherText).upper() 31 | return cipherText 32 | 33 | def encrypted_request(jsonDict): 34 | jsonStr = json.dumps(jsonDict, separators = (",", ":")) 35 | encText = aesEncrypt(jsonStr, secretKey) 36 | data = { 37 | 'eparams': encText, 38 | } 39 | return data 40 | 41 | nonce = encrypt['nonce'] 42 | n, e = int(encrypt["n"], 16), int(encrypt["e"], 16) 43 | 44 | def req_netease(url, payload): 45 | data = encrypted_request(payload) 46 | r = requests.post(url, data = data, headers = headers) 47 | result = json.loads(r.text) 48 | if result['code'] != 200: 49 | return None 50 | return result 51 | 52 | def req_netease_detail(songId): 53 | payload = {"method": "POST", "params": {"c": "[{id:%d}]" % songId}, "url": "http://music.163.com/api/v3/song/detail"} 54 | data = req_netease('http://music.163.com/api/linux/forward', payload) 55 | if data is None or data['songs'] is None or len(data['songs']) != 1: 56 | return None 57 | song = data['songs'][0] 58 | return song 59 | 60 | def req_netease_url(songId, rate): 61 | payload = {"method": "POST", "params": {"ids": [songId],"br": rate}, "url": "http://music.163.com/api/song/enhance/player/url"} 62 | data = req_netease('http://music.163.com/api/linux/forward', payload) 63 | if data is None or data['data'] is None or len(data['data']) != 1: 64 | return None 65 | 66 | song = data['data'][0] 67 | if song['code'] != 200 or song['url'] is None: 68 | return None 69 | # song['url'] = song['url'].replace('http:', '') 70 | return song 71 | 72 | def req_recaptcha(response, remote_ip): 73 | r = requests.post('https://www.google.com/recaptcha/api/siteverify', data = { 74 | 'secret': config['recaptcha']['secret'], 75 | 'response': response, 76 | 'remoteip': remote_ip 77 | }); 78 | result = json.loads(r.text); 79 | print("req_recaptcha from %s, result: %s" % (remote_ip, r.text)) 80 | return result['success'] 81 | 82 | 83 | 84 | print("Generating secretKey for current session...") 85 | secretKey = binascii.a2b_hex(encrypt['secret']) 86 | 87 | headers = { 88 | 'Referer': 'http://music.163.com', 89 | 'X-Real-IP': '118.88.88.88', 90 | 'Cookie': 'os=linux; appver=1.0.0.1026; osver=Ubuntu%2016.10', 91 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36', 92 | } 93 | 94 | def sign_request(songId, rate): 95 | h = SHA256.new() 96 | h.update(str(songId)) 97 | h.update(str(rate)) 98 | h.update(config["sign_salt"]) 99 | return h.hexdigest() 100 | 101 | def is_verified(session): 102 | if not config['recaptcha']: 103 | return True 104 | return 'verified' in session and session['verified'] > 0 105 | 106 | def set_verified(session): 107 | if config['recaptcha']: 108 | session['verified'] = randint(10, 20) 109 | 110 | def decrease_verified(session): 111 | if config['recaptcha']: 112 | session['verified'] -= 1; 113 | 114 | @app.route("/") 115 | def index(): 116 | verified = is_verified(session) 117 | return render_template('index.j2', verified = verified) 118 | 119 | @app.route("/backdoor") 120 | def backdoor(): 121 | if app.debug: 122 | set_verified(session) 123 | return 'ok!' 124 | 125 | @app.route('/s/') 126 | def static_route(path): 127 | return app.send_static_file(path) 128 | 129 | @app.route("/sign//", methods=['POST']) 130 | def generate_sign(songId, rate): 131 | if not is_verified(session): 132 | # 首先检查谷歌验证 133 | if 'g-recaptcha-response' not in request.form \ 134 | or not req_recaptcha( 135 | request.form['g-recaptcha-response'], 136 | request.headers[config['ip_header']] if config['ip_header'] else request.remote_addr 137 | ): 138 | # 139 | return jsonify({"verified": is_verified(session), "errno": 2}) 140 | 141 | set_verified(session) 142 | 143 | # 请求歌曲信息, 然后签个名 144 | decrease_verified(session) 145 | song = req_netease_detail(songId) 146 | if song is None: 147 | return jsonify({"verified": is_verified(session), "errno": 1}) 148 | 149 | return jsonify({ 150 | "verified": True, 151 | "sign": sign_request(songId, rate), 152 | "song": { 153 | "id": song['id'], 154 | "name": song['name'], 155 | "artist": [{"id": a['id'], "name": a['name']} for a in song['ar']] 156 | } 157 | }) 158 | 159 | @app.route("///") 160 | def get_song_url(songId, rate, sign): 161 | if sign_request(songId, rate) != sign: 162 | return abort(403) 163 | 164 | song = req_netease_url(songId, rate) 165 | if song is None: 166 | return abort(404) 167 | 168 | response = redirect(song['url'], code=302) 169 | response.headers["max-age"] = song['expi'] 170 | return response 171 | 172 | if __name__ == "__main__": 173 | print("Running...") 174 | app.run() 175 | -------------------------------------------------------------------------------- /redis_session.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from datetime import timedelta 3 | from uuid import uuid4 4 | from redis import Redis 5 | from werkzeug.datastructures import CallbackDict 6 | from flask.sessions import SessionInterface, SessionMixin 7 | 8 | class RedisSession(CallbackDict, SessionMixin): 9 | 10 | def __init__(self, initial=None, sid=None, new=False): 11 | def on_update(self): 12 | self.modified = True 13 | CallbackDict.__init__(self, initial, on_update) 14 | self.sid = sid 15 | self.new = new 16 | self.modified = False 17 | 18 | def total_seconds(td): 19 | return td.days * 60 * 60 * 24 + td.seconds 20 | 21 | class RedisSessionInterface(SessionInterface): 22 | serializer = pickle 23 | session_class = RedisSession 24 | 25 | def __init__(self, redis=None, prefix='session:'): 26 | self.redis = Redis(host=redis['host'], port=redis['port'], db=redis['db']) 27 | self.prefix = prefix 28 | 29 | def generate_sid(self): 30 | return str(uuid4()) 31 | 32 | def get_redis_expiration_time(self, app, session): 33 | if session.permanent: 34 | return app.permanent_session_lifetime 35 | return timedelta(days=1) 36 | 37 | def open_session(self, app, request): 38 | sid = request.cookies.get(app.session_cookie_name) 39 | if not sid: 40 | sid = self.generate_sid() 41 | return self.session_class(sid=sid, new=True) 42 | val = self.redis.get(self.prefix + sid) 43 | if val is not None: 44 | data = self.serializer.loads(val) 45 | return self.session_class(data, sid=sid) 46 | return self.session_class(sid=sid, new=True) 47 | 48 | def save_session(self, app, session, response): 49 | domain = self.get_cookie_domain(app) 50 | if not session: 51 | self.redis.delete(self.prefix + session.sid) 52 | if session.modified: 53 | response.delete_cookie(app.session_cookie_name, 54 | domain=domain) 55 | return 56 | redis_exp = self.get_redis_expiration_time(app, session) 57 | cookie_exp = self.get_expiration_time(app, session) 58 | val = self.serializer.dumps(dict(session)) 59 | self.redis.setex(self.prefix + session.sid, val, 60 | int(total_seconds(redis_exp))) 61 | response.set_cookie(app.session_cookie_name, session.sid, 62 | expires=cookie_exp, httponly=True, 63 | domain=domain) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==0.12 2 | pycryptodome==3.4.5 3 | requests==2.13.0 4 | PyYAML==3.12 5 | redis==2.10.5 6 | -------------------------------------------------------------------------------- /static/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Helvetica Neue", Helvetica, 'Microsoft YaHei UI', Arial, sans-serif; 3 | } 4 | 5 | /* 隐藏 */ 6 | .hide, 7 | 8 | /* 无 js 时隐藏 */ 9 | .nojs .nojs-hide, 10 | 11 | /* 有 js 时隐藏 */ 12 | .js .js-hide { 13 | display: none; 14 | } 15 | 16 | /* 显示 */ 17 | .show-block, 18 | 19 | /* 有 js 时显示 */ 20 | .js .js-show-block, 21 | 22 | /* 无 js 时显示 */ 23 | .nojs .nojs-show-block, 24 | 25 | /* 有 vue 时显示 */ 26 | .vue .vue-show-block { 27 | display: block; 28 | } 29 | 30 | /* 音乐预览播放器 */ 31 | #preview { 32 | width: 100%; 33 | } 34 | 35 | /* 多艺术家时的分割线 */ 36 | ul.artists li:not(:first-child)::before { 37 | content: '/'; 38 | padding-right: 10px; 39 | } 40 | 41 | .no-margin-bottom { 42 | margin-bottom: 0; 43 | } -------------------------------------------------------------------------------- /static/app.js: -------------------------------------------------------------------------------- 1 | document.body.classList.remove('nojs'); 2 | document.body.classList.add('js'); 3 | $(function () { 4 | function hasTouch() { 5 | return (('ontouchstart' in window) || // html5 browsers 6 | (navigator.maxTouchPoints > 0) || // future IE 7 | (navigator.msMaxTouchPoints > 0)); // current IE10 8 | } 9 | 10 | var $body = $('body'); 11 | var base = location.protocol + '//' + location.host + '/'; 12 | var errors = ['', '音乐数据解析失败']; 13 | var preview = document.getElementById('preview'); 14 | 15 | var app = new Vue({ 16 | el: '#app', 17 | data: { 18 | verified: $body.data('verified'), 19 | error: '', 20 | 21 | src_url: '', 22 | rate: 128000, 23 | 24 | url: '', 25 | song: {} 26 | }, 27 | methods: { 28 | sign: function (e) { 29 | e.preventDefault(); 30 | app.url = ''; 31 | var data = $('#sign', app.$el).serialize(); 32 | var songId = app.src_url; 33 | if (!/^\d+$/.test(songId)) { 34 | // xxx/song/12345 35 | // xxx/song?id=12345 36 | var m = songId.match(/song(?:\?id=|\/)(\d+)/); 37 | if (m && m.length > 0) { 38 | songId = m[1]; 39 | } else { 40 | app.error = "无效的 ID 或无法识别的地址。"; 41 | return ; 42 | } 43 | } 44 | 45 | var param = songId + '/' + app.rate; 46 | 47 | $.post('/sign/' + param, data, function (response) { 48 | app.verified = response.verified; 49 | if (!response.verified) { 50 | app.error = '请先填写验证码!'; 51 | loadCaptcha(); 52 | return ; 53 | } 54 | 55 | if (response.errno) { 56 | app.error = errors[response.errno]; 57 | return ; 58 | } 59 | 60 | app.url = base + param + '/' + response.sign; 61 | app.song = response.song; 62 | app.error = ''; 63 | }).fail(function () { 64 | app.error = "服务器内部错误。"; 65 | }); 66 | }, 67 | 68 | sel: function (e) { 69 | var target = e.currentTarget; 70 | target.setSelectionRange(0, target.value.length); 71 | } 72 | }, 73 | created: function () { 74 | document.body.classList.add('vue'); 75 | if (!this.verified) { 76 | loadCaptcha(); 77 | } 78 | } 79 | }); 80 | 81 | var captchaId = null; 82 | function loadCaptcha () { 83 | if (window.grecaptcha) { 84 | if (captchaId !== null) { 85 | grecaptcha.reset(captchaId); 86 | } else { 87 | captchaId = grecaptcha.render('recaptcha', { 88 | 'sitekey': $body.data('sitekey') 89 | }); 90 | } 91 | } else { 92 | var script = document.createElement('script'); 93 | script.src = 'https://www.google.com/recaptcha/api.js?onload=loadCaptcha&render=explicit'; 94 | document.body.appendChild(script); 95 | } 96 | } 97 | 98 | window.loadCaptcha = loadCaptcha; 99 | var copyBtn = document.getElementById('copy'); 100 | var clipboard = new Clipboard(copyBtn); 101 | copyBtn.addEventListener('click', function (e) { 102 | e.preventDefault(); 103 | }); 104 | clipboard.on('success', function () { 105 | alert('复制成功!'); 106 | }).on('error', function () { 107 | var action = hasTouch() ? '长按' : '右键'; 108 | alert('复制失败,请' + action + '链接然后选择复制!'); 109 | }); 110 | 111 | 112 | // window.app = app; 113 | }); -------------------------------------------------------------------------------- /static/app.min.css: -------------------------------------------------------------------------------- 1 | body{font-family:"Helvetica Neue",Helvetica,'Microsoft YaHei UI',Arial,sans-serif}.hide,.js .js-hide,.nojs .nojs-hide{display:none}.js .js-show-block,.nojs .nojs-show-block,.show-block,.vue .vue-show-block{display:block}#preview{width:100%}ul.artists li:not(:first-child)::before{content:'/';padding-right:10px}.no-margin-bottom{margin-bottom:0} -------------------------------------------------------------------------------- /static/app.min.js: -------------------------------------------------------------------------------- 1 | document.body.classList.remove("nojs"),document.body.classList.add("js"),$(function(){function e(){return"ontouchstart"in window||navigator.maxTouchPoints>0||navigator.msMaxTouchPoints>0}function r(){if(window.grecaptcha)null!==i?grecaptcha.reset(i):i=grecaptcha.render("recaptcha",{sitekey:t.data("sitekey")});else{var e=document.createElement("script");e.src="https://www.google.com/recaptcha/api.js?onload=loadCaptcha&render=explicit",document.body.appendChild(e)}}var t=$("body"),n=location.protocol+"//"+location.host+"/",o=["","音乐数据解析失败"],a=(document.getElementById("preview"),new Vue({el:"#app",data:{verified:t.data("verified"),error:"",src_url:"",rate:128e3,url:"",song:{}},methods:{sign:function(e){e.preventDefault(),a.url="";var t=$("#sign",a.$el).serialize(),i=a.src_url;if(!/^\d+$/.test(i)){var c=i.match(/song(?:\?id=|\/)(\d+)/);if(!(c&&c.length>0))return void(a.error="无效的 ID 或无法识别的地址。");i=c[1]}var d=i+"/"+a.rate;$.post("/sign/"+d,t,function(e){return a.verified=e.verified,e.verified?e.errno?void(a.error=o[e.errno]):(a.url=n+d+"/"+e.sign,a.song=e.song,void(a.error="")):(a.error="请先填写验证码!",void r())}).fail(function(){a.error="服务器内部错误。"})},sel:function(e){var r=e.currentTarget;r.setSelectionRange(0,r.value.length)}},created:function(){document.body.classList.add("vue"),this.verified||r()}})),i=null;window.loadCaptcha=r;var c=document.getElementById("copy"),d=new Clipboard(c);c.addEventListener("click",function(e){e.preventDefault()}),d.on("success",function(){alert("复制成功!")}).on("error",function(){var r=e()?"长按":"右键";alert("复制失败,请"+r+"链接然后选择复制!")})}); -------------------------------------------------------------------------------- /templates/g-recaptcha.j2: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /templates/index.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 网易云音乐解析 7 | 8 | 9 | 10 | 11 |
12 |

云音乐直链生成器

13 |

制作: Jixun | GitHub repo

14 | 15 |
16 |
17 | 18 | 19 |
20 | 21 |
22 | 23 | 28 |
29 | 30 |
31 |
错误: {{ error }}
32 |
33 | 34 |
35 |
36 | 37 |
38 |
请开启 JavaScript 并允许 reCAPTCHA 加载。
39 |
40 |
41 | 42 |
43 |
44 | 45 | 46 |
47 | 48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 66 | 67 |
歌名
歌手 59 |
    60 |
  • 61 | 62 | 63 |
  • 64 |
65 |
68 |
69 | 70 |
71 | 72 | 73 |
74 |
75 | 76 |
77 | 78 | 79 |
80 |
81 |
82 | 83 | 84 | 85 | 86 | 87 | 88 | --------------------------------------------------------------------------------