├── app.js ├── bridge.js ├── .gitattributes ├── .npmignore ├── .dockerignore ├── docker-compose.yml ├── src ├── browser │ ├── background.html │ ├── script.js │ ├── manifest.json │ ├── README.md │ ├── request.js │ ├── crypto.js │ ├── background.js │ ├── convert.js │ └── inject.js ├── provider │ ├── insure.js │ ├── netease.js │ ├── pyncmd.js │ ├── bilibili.js │ ├── baidu.js │ ├── find.js │ ├── joox.js │ ├── kugou.js │ ├── select.js │ ├── migu.js │ ├── kuwo.js │ ├── youtube.js │ ├── match.js │ ├── xiami.js │ └── qq.js ├── cache.js ├── bridge.js ├── sni.js ├── similarity.js ├── request.js ├── crypto.js ├── app.js ├── cli.js ├── server.js ├── kwDES.js └── hook.js ├── Dockerfile ├── package.json ├── endpoint.worker.js ├── renew-cert.sh ├── server.csr ├── LICENSE ├── ca.crt ├── generate-cert.sh ├── server.crt ├── .gitignore ├── server.key └── README.md /app.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('./src/app') -------------------------------------------------------------------------------- /bridge.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./src/bridge') -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | .gitignore 3 | .dockerignore 4 | 5 | Dockerfile* 6 | *.yml 7 | 8 | src/browser/ 9 | ca.* 10 | *.worker.js -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .npmignore 3 | .gitignore 4 | .dockerignore 5 | 6 | LICENSE 7 | *.md 8 | 9 | node_modules 10 | npm-debug.log 11 | 12 | Dockerfile* 13 | *.yml 14 | 15 | src/browser/ 16 | ca.* 17 | *.worker.js -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | unblockneteasemusic: 5 | image: nondanee/unblockneteasemusic 6 | environment: 7 | NODE_ENV: production 8 | ports: 9 | - 8080:8080 10 | -------------------------------------------------------------------------------- /src/browser/background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/browser/script.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | let script = (document.head || document.documentElement).appendChild(document.createElement('script')) 3 | script.src = chrome.extension.getURL('inject.js') 4 | script.onload = script.parentNode.removeChild(script) 5 | })() -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | RUN apk add --update nodejs npm --repository=http://dl-cdn.alpinelinux.org/alpine/latest-stable/main/ 3 | 4 | ENV NODE_ENV production 5 | 6 | WORKDIR /usr/src/app 7 | COPY package*.json ./ 8 | RUN npm install --production 9 | COPY . . 10 | 11 | EXPOSE 8080 8081 12 | 13 | ENTRYPOINT ["node", "app.js"] -------------------------------------------------------------------------------- /src/browser/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "UnblockNeteaseMusic", 3 | "description": "For test (es6 only)", 4 | "version": "0.1", 5 | "background": { 6 | "page": "background.html" 7 | }, 8 | "content_scripts": [{ 9 | "js": ["script.js"], 10 | "matches": ["*://music.163.com/*"], 11 | "all_frames": true 12 | }], 13 | "web_accessible_resources": ["inject.js"], 14 | "externally_connectable": { 15 | "matches": ["*://music.163.com/*"] 16 | }, 17 | "manifest_version": 2, 18 | "permissions": ["*://*/*", "webRequest", "webRequestBlocking"], 19 | "content_security_policy": "script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net; object-src 'self'" 20 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nondanee/unblockneteasemusic", 3 | "version": "0.27.1", 4 | "description": "Revive unavailable songs for Netease Cloud Music", 5 | "main": "src/provider/match.js", 6 | "bin": { 7 | "unblockneteasemusic": "app.js" 8 | }, 9 | "scripts": { 10 | "pkg": "pkg . --out-path=dist/" 11 | }, 12 | "pkg": { 13 | "assets": [ 14 | "server.key", 15 | "server.crt" 16 | ] 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/1582421598/UnblockNeteaseMusic-Renewed.git" 21 | }, 22 | "author": "nondanee", 23 | "license": "MIT", 24 | "dependencies": {}, 25 | "publishConfig": { 26 | "access": "public" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/provider/insure.js: -------------------------------------------------------------------------------- 1 | const request = require('../request') 2 | const host = null // 'http://localhost:9000' 3 | 4 | module.exports = () => { 5 | const proxy = new Proxy(() => {}, { 6 | get: (target, property) => { 7 | target.route = (target.route || []).concat(property) 8 | return proxy 9 | }, 10 | apply: (target, _, payload) => { 11 | if (module.exports.disable || !host) return Promise.reject() 12 | const path = target.route.join('/') 13 | const query = typeof (payload[0]) === 'object' ? JSON.stringify(payload[0]) : payload[0] 14 | // if (path != 'qq/ticket') return Promise.reject() 15 | return request('GET', `${host}/${path}?${encodeURIComponent(query)}`) 16 | .then(response => response.body()) 17 | } 18 | }) 19 | return proxy 20 | } -------------------------------------------------------------------------------- /endpoint.worker.js: -------------------------------------------------------------------------------- 1 | addEventListener('fetch', event => { 2 | event.respondWith(handleRequest(event.request)) 3 | }) 4 | 5 | const pattern = /^\/package\/([0-9a-zA-Z_\-=]+)\/(\w+\.\w+)$/ 6 | 7 | const handleRequest = async request => { 8 | const notFound = new Response(null, { status: 404 }) 9 | const path = new URL(request.url).pathname 10 | const [matched, base64Url, fileName] = pattern.exec(path || '') || [] 11 | if (!matched) return notFound 12 | let url = base64Url.replace(/-/g, '+').replace(/_/g, '/') 13 | try { url = new URL(atob(url)) } catch(_) { url = null } 14 | if (!url) return notFound 15 | const headers = new Headers(request.headers) 16 | headers.set('host', url.host) 17 | headers.delete('cookie') 18 | const { method, body } = request 19 | return fetch(url, { method, headers, body }) 20 | } -------------------------------------------------------------------------------- /src/provider/netease.js: -------------------------------------------------------------------------------- 1 | const cache = require('../cache') 2 | const crypto = require('../crypto') 3 | const request = require('../request') 4 | 5 | const search = info => { 6 | const url = 7 | 'http://music.163.com/api/album/' + info.album.id 8 | 9 | return request('GET', url) 10 | .then(response => response.body()) 11 | .then(body => { 12 | const jsonBody = JSON.parse(body.replace(/"dfsId":(\d+)/g, '"dfsId":"$1"')) // for js precision 13 | const matched = jsonBody.album.songs.find(song => song.id === info.id) 14 | if (matched) 15 | return matched.hMusic.dfsId || matched.mMusic.dfsId || matched.lMusic.dfsId 16 | else 17 | return Promise.reject() 18 | }) 19 | } 20 | 21 | const track = id => { 22 | if (!id || id === '0') return Promise.reject() 23 | return crypto.uri.retrieve(id) 24 | } 25 | 26 | const check = info => cache(search, info).then(track) 27 | 28 | module.exports = {check} -------------------------------------------------------------------------------- /renew-cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TYPE="${TYPE:-RSA}" 4 | 5 | if [ "$TYPE" == "RSA" ]; then 6 | openssl genrsa -out server.key 2048 7 | openssl req -new -sha256 -key server.key -out server.csr -subj "/C=CN/L=Hangzhou/O=NetEase (Hangzhou) Network Co., Ltd/OU=IT Dept./CN=*.music.163.com" 8 | openssl x509 -req -extfile <(printf "extendedKeyUsage=serverAuth\nsubjectAltName=DNS:music.163.com,DNS:*.music.163.com") -sha256 -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt 9 | elif [ "$TYPE" == "ECC" ]; then 10 | openssl ecparam -genkey -name secp384r1 -out server.key 11 | openssl req -new -sha384 -key server.key -out server.csr -subj "/C=CN/L=Hangzhou/O=NetEase (Hangzhou) Network Co., Ltd/OU=IT Dept./CN=*.music.163.com" 12 | openssl x509 -req -extfile <(printf "extendedKeyUsage=serverAuth\nsubjectAltName=DNS:music.163.com,DNS:*.music.163.com") -sha384 -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt 13 | fi 14 | -------------------------------------------------------------------------------- /src/browser/README.md: -------------------------------------------------------------------------------- 1 | # Web Extension Port 2 | 3 | For test 4 | 5 | ## Implementation 6 | 7 | - Convert node module to ES6 module which can be directly executed in Chrome 61+ without Babel 8 | - Rewrite crypto module (using CryptoJS) and request (using XMLHttpRequest) module for browser environment 9 | - Do matching in background and transfer result with chrome runtime communication 10 | - Inject content script for hijacking Netease Music Web Ajax response 11 | 12 | ## Build 13 | 14 | ``` 15 | $ node convert.js 16 | ``` 17 | 18 | ## Install 19 | 20 | Load unpacked extension in Developer mode 21 | 22 | ## Known Issue 23 | 24 | Audio resources from `kuwo`, `kugou` and `migu` are limited in http protocol only and hence can't load 25 | Most audio resources from `qq` don't support preflight request (OPTIONS) and make playbar buggy 26 | 27 | ## Reference 28 | 29 | - [brix/crypto-js](https://github.com/brix/crypto-js) 30 | - [travist/jsencrypt](https://github.com/travist/jsencrypt) 31 | - [JixunMoe/cuwcl4c](https://github.com/JixunMoe/cuwcl4c) -------------------------------------------------------------------------------- /server.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICwDCCAagCAQAwezELMAkGA1UEBhMCQ04xETAPBgNVBAcMCEhhbmd6aG91MSww 3 | KgYDVQQKDCNOZXRFYXNlIChIYW5nemhvdSkgTmV0d29yayBDby4sIEx0ZDERMA8G 4 | A1UECwwISVQgRGVwdC4xGDAWBgNVBAMMDyoubXVzaWMuMTYzLmNvbTCCASIwDQYJ 5 | KoZIhvcNAQEBBQADggEPADCCAQoCggEBALAdhBfxfl8pd+JnFty9SbxScfnJjDf0 6 | Cqb+Q2YyoM6NDUxcUnHFRVdcH67DZPOCIB/8OdGm5efl3TSfApAcEX7e/7RB4dTw 7 | r4hbeZeuZ+DDW5lSuNQbQb4Q0u7uoZxZNbC+FPbqnUP3ch95wQPLcE3wWEyUUCg9 8 | Pwc2jSOlVEshvwHOKPD7KN62qogLqXTNTqnyHnADSEJHIoVrXsNkKN2scUbowt1D 9 | giFqApJ+Zhx+Lw81XNZMCEVxy0fI9aTlvy0JGS3iLGGP7v6d38oDQr95a7UcbzDf 10 | ws02OfJPrnIGk0ydNQi+bswZAPHqHC2rSqnNnO9dWpHdTXNKdCzt7wcCAwEAAaAA 11 | MA0GCSqGSIb3DQEBCwUAA4IBAQABcq6yPbKj65n/waV0/jZsXM17Sbmd390qS8mY 12 | eoYYPudQlgdd10mHPKUOSQbv796Zf1x97im1iG90z47WbDaqVoEIGWc1tBH8DIoP 13 | NLi/BQkS+uyv8q+nN6bSocCv9IVnt+0NGjJT2rc0B8COHZ2K8KXwT93xrsxrXCle 14 | t93amXFy1nUWlH0ECJQcEYz5snDylPVOjjDSrQZM06lGRoNWcjwslxatNMnbd92g 15 | 1ez/6jOHrkfCD7S9hrWMUAQ+Ht6R7Ontcx5wcapdig+Yh1fZdiT9DfNy9IOCAeXG 16 | XDKCP+yyyx/f5iPY9v0ZM/EprVB2f28Wt+5uBjBEJX7n6xrO 17 | -----END CERTIFICATE REQUEST----- 18 | -------------------------------------------------------------------------------- /src/cache.js: -------------------------------------------------------------------------------- 1 | const collector = (job, cycle) => 2 | setTimeout(() => { 3 | let keep = false 4 | Object.keys(job.cache || {}) 5 | .forEach(key => { 6 | if (!job.cache[key]) return 7 | job.cache[key].expiration < Date.now() 8 | ? job.cache[key] = null 9 | : keep = keep || true 10 | }) 11 | keep ? collector(job, cycle) : job.collector = null 12 | }, cycle) 13 | 14 | module.exports = (job, parameter, live = 30 * 60 * 1000) => { 15 | const cache = job.cache ? job.cache : job.cache = {} 16 | if (!job.collector) job.collector = collector(job, live / 2) 17 | const key = parameter == null ? 'default' : (typeof(parameter) === 'object' ? (parameter.id || parameter.key || JSON.stringify(parameter)) : parameter) 18 | const done = (status, result) => cache[key].execution = Promise[status](result) 19 | if (!cache[key] || cache[key].expiration < Date.now()) 20 | cache[key] = { 21 | expiration: Date.now() + live, 22 | execution: job(parameter) 23 | .then(result => done('resolve', result)) 24 | .catch(result => done('reject', result)) 25 | } 26 | return cache[key].execution 27 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 1582421598 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/browser/request.js: -------------------------------------------------------------------------------- 1 | export default (method, url, headers, body) => new Promise((resolve, reject) => { 2 | headers = headers || {} 3 | const xhr = new XMLHttpRequest() 4 | xhr.onreadystatechange = () => {if (xhr.readyState == 4) resolve(xhr)} 5 | xhr.onerror = error => reject(error) 6 | xhr.open(method, url, true) 7 | const safe = {}, unsafe = {} 8 | Object.keys(headers).filter(key => (['origin', 'referer'].includes(key.toLowerCase()) ? unsafe : safe)[key] = headers[key]) 9 | Object.entries(safe).forEach(entry => xhr.setRequestHeader.apply(xhr, entry)) 10 | if (Object.keys(unsafe)) xhr.setRequestHeader('Additional-Headers', btoa(JSON.stringify(unsafe))) 11 | xhr.send(body) 12 | }).then(xhr => Object.assign(xhr, { 13 | statusCode: xhr.status, 14 | headers: 15 | xhr.getAllResponseHeaders().split('\r\n').filter(line => line).map(line => line.split(/\s*:\s*/)) 16 | .reduce((result, pair) => Object.assign(result, {[pair[0].toLowerCase()]: pair[1]}), {}), 17 | url: {href: xhr.responseURL}, 18 | body: () => xhr.responseText, 19 | json: () => JSON.parse(xhr.responseText), 20 | jsonp: () => JSON.parse(xhr.responseText.slice(xhr.responseText.indexOf('(') + 1, -')'.length)) 21 | })) -------------------------------------------------------------------------------- /ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDaTCCAlGgAwIBAgIUGIZXAaB7qSKVOrEgUXT+Pe/GhpYwDQYJKoZIhvcNAQEL 3 | BQAwRDELMAkGA1UEBhMCQ04xJDAiBgNVBAMMG1VuYmxvY2tOZXRlYXNlTXVzaWMg 4 | Um9vdCBDQTEPMA0GA1UECgwGbm9ib2R5MB4XDTIyMDQxMDEwMzcxNFoXDTI3MDQw 5 | OTEwMzcxNFowRDELMAkGA1UEBhMCQ04xJDAiBgNVBAMMG1VuYmxvY2tOZXRlYXNl 6 | TXVzaWMgUm9vdCBDQTEPMA0GA1UECgwGbm9ib2R5MIIBIjANBgkqhkiG9w0BAQEF 7 | AAOCAQ8AMIIBCgKCAQEAnUMcFXIjq5LMVMeu/Bik/N+21S1B/zGOyJ9BttHTvdoh 8 | HKHXj/KT6WQOpDnvgygoyJx4tpZTQSh1QlznBclNfEzN0r4W4cwtVzHneIRQaT6g 9 | QiTWm+yyaAiCi5deD9YkXguWkY/alvvoxRzzSS+TGthtnestA3YmvceboIbc5251 10 | c9catgoyem9hC90wWIBOujd4Z8A5siFvQRh5DtUuxa6U3JHWMU8Apb5RZFIaU9XK 11 | xHIYMmGvZcS5wixrE+dmNBqoID6HYHGI6pR3K6R/fFhYIWLVcN24Ltw6wDcCzN6n 12 | K3YdwcK6UZXtNXKUClPD5nEkuO59eT/oXzc7CLbc8wIDAQABo1MwUTAdBgNVHQ4E 13 | FgQUGg2nEGT0p/9vGNbu0pr5ndPvI7kwHwYDVR0jBBgwFoAUGg2nEGT0p/9vGNbu 14 | 0pr5ndPvI7kwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAZeeJ 15 | s1Nh9RwZk7QABXbDW5cSKXgL3XbDC4kmU8APEeazF/+zsersuW/xG8NLzx7DHBJm 16 | jFEHRRdlY1/ixjB6vzegzBXS9r8twLYyKBp2rHkPl5OuC9fSJ8IAJgThyd+stkwf 17 | ISG9lM+K/b9LNuThDmcQEjAS1VTU2yvZDiPsHagkOCWhm53wpwtv/1TaGnsyhJUA 18 | JUJeO6gP2x8VSdScQ4w9uLZ6D1dcE9YMbJuDtvJDxrpvaViyHijB3isU461MmQNj 19 | pKZNO7UVSTCpnOEOg858Vh92Rs/8MiG9W/mUoZ/XADRobWMhOrKVUs347zDt1hXx 20 | aHNAWg5P0diP3YCS0g== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /generate-cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TYPE="${TYPE:-RSA}" 4 | ISSUENAME="${ISSUENAME:-nobody}" 5 | 6 | if [ "$TYPE" == "RSA" ]; then 7 | openssl genrsa -out ca.key 2048 8 | openssl req -x509 -new -nodes -key ca.key -sha256 -days 1825 -out ca.crt -subj "/C=CN/CN=UnblockNeteaseMusic Root CA/O=$ISSUENAME" 9 | openssl genrsa -out server.key 2048 10 | openssl req -new -sha256 -key server.key -out server.csr -subj "/C=CN/L=Hangzhou/O=NetEase (Hangzhou) Network Co., Ltd/OU=IT Dept./CN=*.music.163.com" 11 | openssl x509 -req -extfile <(printf "extendedKeyUsage=serverAuth\nsubjectAltName=DNS:music.163.com,DNS:*.music.163.com") -sha256 -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt 12 | elif [ "$TYPE" == "ECC" ]; then 13 | openssl ecparam -genkey -name secp384r1 -out ca.key 14 | openssl req -x509 -new -nodes -key ca.key -sha384 -days 1825 -out ca.crt -subj "/C=CN/CN=UnblockNeteaseMusic Root CA/O=$ISSUENAME" 15 | openssl ecparam -genkey -name secp384r1 -out server.key 16 | openssl req -new -sha384 -key server.key -out server.csr -subj "/C=CN/L=Hangzhou/O=NetEase (Hangzhou) Network Co., Ltd/OU=IT Dept./CN=*.music.163.com" 17 | openssl x509 -req -extfile <(printf "extendedKeyUsage=serverAuth\nsubjectAltName=DNS:music.163.com,DNS:*.music.163.com") -sha384 -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt 18 | fi 19 | -------------------------------------------------------------------------------- /src/bridge.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const cache = require('./cache') 3 | const parse = require('url').parse 4 | require('./provider/insure').disable = true 5 | 6 | const router = { 7 | qq: require('./provider/qq'), 8 | baidu: require('./provider/baidu'), 9 | kugou: require('./provider/kugou'), 10 | kuwo: require('./provider/kuwo'), 11 | migu: require('./provider/migu'), 12 | joox: require('./provider/joox'), 13 | bilibili: require('./provider/bilibili'), 14 | pyncmd: require('./provider/pyncmd') 15 | } 16 | 17 | const distribute = (url, router) => 18 | Promise.resolve() 19 | .then(() => { 20 | const route = url.pathname.slice(1).split('/').map(path => decodeURIComponent(path)) 21 | let pointer = router, argument = decodeURIComponent(url.query) 22 | try {argument = JSON.parse(argument)} catch(e) {} 23 | const miss = route.some(path => { 24 | if (path in pointer) pointer = pointer[path] 25 | else return true 26 | }) 27 | if (miss || typeof pointer != 'function') return Promise.reject() 28 | // return pointer.call(null, argument) 29 | return cache(pointer, argument, 15 * 60 * 1000) 30 | }) 31 | 32 | require('http').createServer() 33 | .listen(parseInt(process.argv[2]) || 9000) 34 | .on('request', (req, res) => 35 | distribute(parse(req.url), router) 36 | .then(data => res.write(data)) 37 | .catch(() => res.writeHead(404)) 38 | .then(() => res.end()) 39 | ) 40 | -------------------------------------------------------------------------------- /src/provider/pyncmd.js: -------------------------------------------------------------------------------- 1 | const select = require("./select"); 2 | const request = require("../request"); 3 | const cache = require("../cache"); 4 | 5 | const track = (info) => { 6 | const url = 7 | ["https://pyncmd.vercel.app", "http://76.76.21.114"].slice( 8 | select.CAN_ACCESS_GOOGLE ? 0 : 1, 9 | select.CAN_ACCESS_GOOGLE ? 1 : 2 10 | ) + 11 | "/api/pyncm?module=track&method=GetTrackAudio&song_ids=" + 12 | info.id + 13 | "&bitrate=" + 14 | ["999000", "320000"].slice( 15 | select.ENABLE_FLAC ? 0 : 1, 16 | select.ENABLE_FLAC ? 1 : 2 17 | ); //https://pyncmd.apis.imouto.in/api/pyncm?module=track&method=GetTrackAudio&song_ids= 18 | let headers = null; 19 | if (!select.CAN_ACCESS_GOOGLE) headers = { host: "pyncmd.gov.cn" }; 20 | return request("GET", url, headers) 21 | .then((response) => response.json()) 22 | .then((jsonBody) => { 23 | if ( 24 | jsonBody && 25 | typeof jsonBody === "object" && 26 | "code" in jsonBody && 27 | jsonBody.code !== 200 28 | ) 29 | return Promise.reject(); 30 | 31 | const matched = jsonBody.data.find((song) => song.id === info.id); 32 | if (matched && matched.url) 33 | return { 34 | url: matched.url, 35 | weight: Number.MAX_VALUE, 36 | }; 37 | 38 | return Promise.reject(); 39 | }); 40 | }; 41 | 42 | const check = (info) => cache(track, info); 43 | 44 | module.exports = { 45 | check, 46 | }; 47 | -------------------------------------------------------------------------------- /server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIID0TCCArmgAwIBAgIUY8X7tT0YKKvfaOTNEue1LFk5BYUwDQYJKoZIhvcNAQEL 3 | BQAwRDELMAkGA1UEBhMCQ04xJDAiBgNVBAMMG1VuYmxvY2tOZXRlYXNlTXVzaWMg 4 | Um9vdCBDQTEPMA0GA1UECgwGbm9ib2R5MB4XDTIyMDQxMDEwMzcxNVoXDTIzMDQx 5 | MDEwMzcxNVowezELMAkGA1UEBhMCQ04xETAPBgNVBAcMCEhhbmd6aG91MSwwKgYD 6 | VQQKDCNOZXRFYXNlIChIYW5nemhvdSkgTmV0d29yayBDby4sIEx0ZDERMA8GA1UE 7 | CwwISVQgRGVwdC4xGDAWBgNVBAMMDyoubXVzaWMuMTYzLmNvbTCCASIwDQYJKoZI 8 | hvcNAQEBBQADggEPADCCAQoCggEBALAdhBfxfl8pd+JnFty9SbxScfnJjDf0Cqb+ 9 | Q2YyoM6NDUxcUnHFRVdcH67DZPOCIB/8OdGm5efl3TSfApAcEX7e/7RB4dTwr4hb 10 | eZeuZ+DDW5lSuNQbQb4Q0u7uoZxZNbC+FPbqnUP3ch95wQPLcE3wWEyUUCg9Pwc2 11 | jSOlVEshvwHOKPD7KN62qogLqXTNTqnyHnADSEJHIoVrXsNkKN2scUbowt1DgiFq 12 | ApJ+Zhx+Lw81XNZMCEVxy0fI9aTlvy0JGS3iLGGP7v6d38oDQr95a7UcbzDfws02 13 | OfJPrnIGk0ydNQi+bswZAPHqHC2rSqnNnO9dWpHdTXNKdCzt7wcCAwEAAaOBgzCB 14 | gDATBgNVHSUEDDAKBggrBgEFBQcDATApBgNVHREEIjAggg1tdXNpYy4xNjMuY29t 15 | gg8qLm11c2ljLjE2My5jb20wHQYDVR0OBBYEFN2WvC9sL7ba9C9GjbXqSuhrZt2m 16 | MB8GA1UdIwQYMBaAFBoNpxBk9Kf/bxjW7tKa+Z3T7yO5MA0GCSqGSIb3DQEBCwUA 17 | A4IBAQAAG7oh/VW+ZZ8ZdJf2x7Ul2tx5VVTNuNT9eDV7sbcct7Eu3Y5D6zzd3Dah 18 | Q8rXBQtALquD3IZVyY22UQs6QDq6grfua9zCf2jZHfUDj12JFjHR7315gZLMB3LN 19 | rP20oRf3utF1UoeP1MSIZLaHqWait/CsgS87bdExvMi4Mhe7wYoO5j/6aY/zaPS/ 20 | qeCVl4GKO9tDugGkxH3Psa3GKeXljagvf7bZj4+vZs/K7eR1cRBTFrYmd+oZQ+5P 21 | 6AEa/uFfNMvA1v3vGWC+2yVzrJpexWNGBOvbrMV7+gwn6hB3pNsJoi65ptLJuQvJ 22 | 23i21digC0Ub2mgmMvEzNcbc/Oc2 23 | -----END CERTIFICATE----- 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #cert private key 2 | ca.key 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # parcel-bundler cache (https://parceljs.org/) 64 | .cache 65 | 66 | # next.js build output 67 | .next 68 | 69 | # nuxt.js build output 70 | .nuxt 71 | 72 | # vuepress build output 73 | .vuepress/dist 74 | 75 | # Serverless directories 76 | .serverless 77 | 78 | # FuseBox cache 79 | .fusebox/ 80 | # VSCode Configuration 81 | .vscode/ 82 | -------------------------------------------------------------------------------- /src/browser/crypto.js: -------------------------------------------------------------------------------- 1 | 2 | const bodyify = object => Object.entries(object).map(entry => entry.map(encodeURIComponent).join('=')).join('&') 3 | 4 | const toBuffer = string => (new TextEncoder()).encode(string) 5 | const toHex = arrayBuffer => Array.from(arrayBuffer).map(n => n.toString(16).padStart(2, '0')).join('') 6 | const toBase64 = arrayBuffer => btoa(arrayBuffer) 7 | 8 | export default { 9 | uri: { 10 | retrieve: id => { 11 | id = id.toString().trim() 12 | const key = '3go8&$8*3*3h0k(2)2' 13 | let string = Array.from(Array(id.length).keys()).map(index => String.fromCharCode(id.charCodeAt(index) ^ key.charCodeAt(index % key.length))).join('') 14 | let result = CryptoJS.MD5(string).toString(CryptoJS.enc.Base64).replace(/\//g, '_').replace(/\+/g, '-') 15 | return `http://p1.music.126.net/${result}/${id}` 16 | } 17 | }, 18 | md5: { 19 | digest: value => CryptoJS.MD5(value).toString() 20 | }, 21 | miguapi: { 22 | encrypt: object => { 23 | let text = JSON.stringify(object), signer = new JSEncrypt() 24 | let password = Array.from(window.crypto.getRandomValues(new Uint8Array(32))).map(n => n.toString(16).padStart(2, '0')).join('') 25 | signer.setPublicKey('-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8asrfSaoOb4je+DSmKdriQJKWVJ2oDZrs3wi5W67m3LwTB9QVR+cE3XWU21Nx+YBxS0yun8wDcjgQvYt625ZCcgin2ro/eOkNyUOTBIbuj9CvMnhUYiR61lC1f1IGbrSYYimqBVSjpifVufxtx/I3exReZosTByYp4Xwpb1+WAQIDAQAB\n-----END PUBLIC KEY-----') 26 | return bodyify({ 27 | data: CryptoJS.AES.encrypt(text, password).toString(), 28 | secKey: signer.encrypt(password) 29 | }) 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/browser/background.js: -------------------------------------------------------------------------------- 1 | import match from './provider/match.js' 2 | const self = chrome.runtime.id 3 | 4 | chrome.runtime.onMessageExternal.addListener((request, sender, sendResponse) => { 5 | match(request.match, ['qq']) 6 | .then(song => sendResponse(song)) 7 | .catch(console.log) 8 | return true 9 | }) 10 | 11 | chrome.webRequest.onBeforeSendHeaders.addListener(details => { 12 | let headers = details.requestHeaders 13 | if(details.url.includes('//music.163.com/')){ 14 | headers.push({name: 'X-Real-IP', value: '119.233.233.233'}) 15 | } 16 | if(details.initiator == `chrome-extension://${self}`){ 17 | let index = headers.findIndex(item => item.name.toLowerCase() === 'additional-headers') 18 | if(index === -1) return 19 | Object.entries(JSON.parse(atob(headers[index].value))).forEach(entry => headers.push({name: entry[0], value: entry[1]})) 20 | headers.splice(index, 1) 21 | } 22 | if(details.initiator == 'https://music.163.com' && (details.type == 'media' || details.url.includes('.mp3'))){ 23 | headers = headers.filter(item => !['referer', 'origin'].includes(item.name.toLowerCase())) 24 | } 25 | return {requestHeaders: headers} 26 | }, {urls: ['*://*/*']}, ['blocking', 'requestHeaders', 'extraHeaders']) 27 | 28 | chrome.webRequest.onHeadersReceived.addListener(details => { 29 | let headers = details.responseHeaders 30 | if(details.initiator == 'https://music.163.com' && (details.type == 'media' || details.url.includes('.mp3'))){ 31 | headers.push({name: 'Access-Control-Allow-Origin', value: '*'}) 32 | } 33 | return {responseHeaders: headers} 34 | }, {urls: ['*://*/*']}, ['blocking', 'responseHeaders']) -------------------------------------------------------------------------------- /src/sni.js: -------------------------------------------------------------------------------- 1 | // Thanks to https://github.com/buschtoens/sni 2 | 3 | module.exports = data => { 4 | let end = data.length 5 | let pointer = 5 + 1 + 3 + 2 + 32 6 | const nan = (number = pointer) => isNaN(number) 7 | 8 | if (pointer + 1 > end || nan()) return null 9 | pointer += 1 + data[pointer] 10 | 11 | if (pointer + 2 > end || nan()) return null 12 | pointer += 2 + data.readInt16BE(pointer) 13 | 14 | if (pointer + 1 > end || nan()) return null 15 | pointer += 1 + data[pointer] 16 | 17 | if (pointer + 2 > end || nan()) return null 18 | const extensionsLength = data.readInt16BE(pointer) 19 | pointer += 2 20 | const extensionsEnd = pointer + extensionsLength 21 | 22 | if (extensionsEnd > end || nan(extensionsEnd)) return null 23 | end = extensionsEnd 24 | 25 | while (pointer + 4 <= end || nan()) { 26 | const extensionType = data.readInt16BE(pointer) 27 | const extensionSize = data.readInt16BE(pointer + 2) 28 | pointer += 4 29 | if (extensionType !== 0) { 30 | pointer += extensionSize 31 | continue 32 | } 33 | if (pointer + 2 > end || nan()) return null 34 | const nameListLength = data.readInt16BE(pointer) 35 | pointer += 2 36 | if (pointer + nameListLength > end) return null 37 | 38 | while (pointer + 3 <= end || nan()) { 39 | const nameType = data[pointer] 40 | const nameLength = data.readInt16BE(pointer + 1) 41 | pointer += 3 42 | if (nameType !== 0) { 43 | pointer += nameLength 44 | continue 45 | } 46 | if (pointer + nameLength > end || nan()) return null 47 | return data.toString('ascii', pointer, pointer + nameLength) 48 | } 49 | } 50 | 51 | return null 52 | } -------------------------------------------------------------------------------- /src/provider/bilibili.js: -------------------------------------------------------------------------------- 1 | const cache = require('../cache') 2 | const insure = require('./insure') 3 | const select = require('./select') 4 | const request = require('../request') 5 | 6 | const format = song => { 7 | return { 8 | id: song.id, 9 | name: song.title, 10 | playcount: song.play_count, 11 | // album: {id: song.album_id, name: song.album_title}, 12 | artists: { 13 | id: song.mid, 14 | name: song.author 15 | }, 16 | weight: 0 17 | } 18 | } 19 | 20 | var weight = 0 21 | 22 | const search = info => { 23 | const url = 24 | 'https://api.bilibili.com/audio/music-service-c/s?' + 25 | 'search_type=music&page=1&pagesize=5&' + 26 | `keyword=${encodeURIComponent(info.keyword)}` 27 | return request('GET', url) 28 | .then(response => response.json()) 29 | .then(jsonBody => { 30 | const list = jsonBody.data.result.map(format) 31 | const matched = select.selectList(list, info) 32 | weight = matched.weight 33 | return matched ? matched.id : Promise.reject() 34 | }) 35 | } 36 | 37 | const track = id => { 38 | const url = 39 | 'https://www.bilibili.com/audio/music-service-c/web/url?rivilege=2&quality=2&' + 40 | 'sid=' + id 41 | 42 | return request('GET', url) 43 | .then(response => response.json()) 44 | .then(jsonBody => { 45 | if (jsonBody.code === 0) { 46 | // bilibili music requires referer, connect do not support referer, so change to http 47 | return { 48 | url: jsonBody.data.cdns[0].replace("https", "http"), 49 | weight: weight 50 | } 51 | } else { 52 | return Promise.reject() 53 | } 54 | }) 55 | .catch(() => insure().bilibili.track(id)) 56 | } 57 | 58 | const check = info => cache(search, info).then(track) 59 | 60 | module.exports = { 61 | check, 62 | track 63 | } -------------------------------------------------------------------------------- /server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCwHYQX8X5fKXfi 3 | ZxbcvUm8UnH5yYw39Aqm/kNmMqDOjQ1MXFJxxUVXXB+uw2TzgiAf/DnRpuXn5d00 4 | nwKQHBF+3v+0QeHU8K+IW3mXrmfgw1uZUrjUG0G+ENLu7qGcWTWwvhT26p1D93If 5 | ecEDy3BN8FhMlFAoPT8HNo0jpVRLIb8Bzijw+yjetqqIC6l0zU6p8h5wA0hCRyKF 6 | a17DZCjdrHFG6MLdQ4IhagKSfmYcfi8PNVzWTAhFcctHyPWk5b8tCRkt4ixhj+7+ 7 | nd/KA0K/eWu1HG8w38LNNjnyT65yBpNMnTUIvm7MGQDx6hwtq0qpzZzvXVqR3U1z 8 | SnQs7e8HAgMBAAECggEAAxcXt4udEf0sHmZ8kAsadID7ZKkIaIwLEc6A5FzmR40Y 9 | 0+qSH/1DM35+mdK1U7S6M+kHlLEAu2TkF9A5WALFLrVUcLrhp7cIU61aVjnYJOI1 10 | GjJXxubG3l6fiVPZ0cQ3LueSfkQ0K3NNA2RDW11pMqsfkJ/7l9vTs67BMZJtI0Xg 11 | 5XYVlPr9CA7ajMIjC2rGegmyOr2+IxTNOeboiwOegg4gMX+cA13GPCzq1xHgEjHk 12 | JJuxTgWWLZIsRe4BXIsK7pVuQ4MzRD/rvBxqQgCiwa3lQiXAwne6KOPNcic397xG 13 | k8XKFw3LGEQsiiwt5jayx5h23aJwAOWPSg6/d0591QKBgQDE3uJAYk8rmfrcwaO+ 14 | thtVp6rd1u4v4WmP+zyauCEC2/9FT+Yo4JRu01jcMtneudxYgDVNnDejUTjBAl+1 15 | OJ2sQLwuS4A9drPu10pjz3JEosli6yvpjQwwjmB2SibiWy94511C95dFRdZmgT37 16 | 4q2wW78JhMC9LSVtwU4FC0hPvQKBgQDlAsg0msOiQZr7BBqplGAyMIHwiwnxIA9P 17 | E+3vFrDrbmJHd8Pk+mTI1mmGvvZiY91v8YvCnERDNFVSn9TimlLAuT1nvKs/vUq+ 18 | rx2HPUoRWRUkZXx1g0Pd5cuvHI1+ROryEN6eA3m4jnlvgjw7RB3+0GInQDjJ6shu 19 | DxtfeV5UEwKBgDEiw+t33fSu8MrKVbkSsI3XVDEcJMS0iOlTtlOTY0HYcMT25SYM 20 | r19dxo7m7jPFxbYdAbDGLajIa9bYZdTQNaI5Yf5X/8DXcJ9LApkYvJde3c6fjY00 21 | E/fGgVLkvQG/6oBNlpxROWMjxBg/Z54HfHxI2cxhYs2UiAP1vChMIZctAoGAfb3Z 22 | T4jqgdyjy8+lFBdz+hrIPdsZyltgDHtU2UIMQjiJndQUq1UQoXWY23NetQdAPobR 23 | xjknAf9qGcPIj+NMLKWJIbxmCslUkP51qBvu3zeadDGE9MDuMphKDgwPZJVSqza4 24 | BOrDmqIf6yoHCEOOdKrWOdb5V2SiSEvjK7joIiECgYBYMNAytNVk/iLq8iLWkie4 25 | K3fLrb04QVVO9ISyriZC+2urtHxGd5Jr0KrS7KcXgXs1xvNTlZWOW+iTx8O2XpYT 26 | UQc7+1q5mzb+eAD0ST9IcfofRpUvXJm7ruT3QJFm8r7xTZuPq/JTYtuf0GUeqIhG 27 | hd54azL4ZNFT1PKHi/HlkA== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /src/browser/convert.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const importReplacer = (match, state, alias, file) => { 5 | file = file + (file.endsWith('.js') ? '' : '.js') 6 | return `import ${alias} from '${file}'` 7 | } 8 | 9 | const converter = (input, output, processor) => { 10 | let data = fs.readFileSync(input).toString() 11 | if(processor){ 12 | data = processor(data) 13 | } 14 | else{ 15 | data = data.replace(/global\./g, 'window.') 16 | data = data.replace(/(const|let|var)\s+(\w+)\s*=\s*require\(\s*['|"](.+)['|"]\s*\)/g, importReplacer) 17 | data = data.replace(/module\.exports\s*=\s*/g, 'export default ') 18 | } 19 | fs.writeFileSync(output, data) 20 | } 21 | 22 | converter(path.resolve(__dirname, '..', 'cache.js'), path.resolve(__dirname, '.', 'cache.js')) 23 | 24 | if(!fs.existsSync(path.resolve(__dirname, 'provider'))) fs.mkdirSync(path.resolve(__dirname, 'provider')) 25 | 26 | fs.readdirSync(path.resolve(__dirname, '..', 'provider')).filter(file => !file.includes('test')).forEach(file => { 27 | converter(path.resolve(__dirname, '..', 'provider', file), path.resolve(__dirname, 'provider', file)) 28 | }) 29 | 30 | const providerReplacer = (match, state, data) => { 31 | let provider = [] 32 | let imports = data.match(/\w+\s*:\s*require\(['|"].+['|"]\)/g).map(line => { 33 | line = line.match(/(\w+)\s*:\s*require\(['|"](.+)['|"]\)/) 34 | provider.push(line[1]) 35 | return importReplacer(null, null, line[1], line[2]) 36 | }) 37 | return imports.join('\n') + '\n\n' + `${state} provider = {${provider.join(', ')}}` 38 | } 39 | 40 | converter(path.resolve(__dirname, 'provider', 'match.js'), path.resolve(__dirname, 'provider', 'match.js'), data => { 41 | data = data.replace(/(const|let|var)\s+provider\s*=\s*{([^}]+)}/g, providerReplacer) 42 | return data 43 | }) -------------------------------------------------------------------------------- /src/provider/baidu.js: -------------------------------------------------------------------------------- 1 | const cache = require('../cache') 2 | const insure = require('./insure') 3 | const select = require('./select') 4 | const request = require('../request') 5 | 6 | const format = song => { 7 | const artistId = song.all_artist_id.split(',') 8 | return { 9 | id: song.song_id, 10 | name: song.title, 11 | album: { 12 | id: song.album_id, 13 | name: song.album_title 14 | }, 15 | artists: song.author.split(',').map((name, index) => ({ 16 | id: artistId[index], 17 | name 18 | })), 19 | weight: 0 20 | } 21 | } 22 | 23 | var weight = 0 24 | 25 | const search = info => { 26 | const url = 27 | 'http://musicapi.taihe.com/v1/restserver/ting?' + 28 | 'from=qianqianmini&method=baidu.ting.search.merge&' + 29 | 'isNew=1&platform=darwin&page_no=1&page_size=5&' + 30 | `query=${encodeURIComponent(info.keyword)}&version=11.2.1` 31 | 32 | return request('GET', url) 33 | .then(response => response.json()) 34 | .then(jsonBody => { 35 | const list = jsonBody.result.song_info.song_list.map(format) 36 | const matched = select.selectList(list, info) 37 | weight = matched.weight 38 | return matched ? matched.id : Promise.reject() 39 | }) 40 | } 41 | 42 | const track = id => { 43 | const url = 44 | 'http://music.taihe.com/data/music/fmlink?' + 45 | 'songIds=' + id + '&type=mp3' 46 | 47 | return request('GET', url) 48 | .then(response => response.json()) 49 | .then(jsonBody => { 50 | if ('songList' in jsonBody.data) { 51 | let result = jsonBody.data.songList[0].songLink 52 | if (result) { 53 | return { 54 | url: result, 55 | weight: weight 56 | } 57 | } else Promise.reject() 58 | } else 59 | return Promise.reject() 60 | }) 61 | .catch(() => insure().baidu.track(id)) 62 | } 63 | 64 | const check = info => cache(search, info).then(track) 65 | 66 | module.exports = { 67 | check 68 | } -------------------------------------------------------------------------------- /src/provider/find.js: -------------------------------------------------------------------------------- 1 | const cache = require('../cache') 2 | const request = require('../request') 3 | 4 | const filter = (object, keys) => Object.keys(object).reduce((result, key) => Object.assign(result, keys.includes(key) && { 5 | [key]: object[key] 6 | }), {}) 7 | // Object.keys(object).filter(key => !keys.includes(key)).forEach(key => delete object[key]) 8 | 9 | const limit = text => { 10 | const output = [text[0]] 11 | const length = () => output.reduce((sum, token) => sum + token.length, 0) 12 | text.slice(1).some(token => length() > 15 ? true : (output.push(token), false)) 13 | return output 14 | } 15 | 16 | const getFormatData = (data) => { 17 | try { 18 | const info = filter(data, ["id", "name", "alias", "duration"]); 19 | info.name = (info.name || "") 20 | .replace(/(\s*cover[::\s][^)]+)/i, "") 21 | .replace(/\(\s*cover[::\s][^\)]+\)/i, "") 22 | .replace(/(\s*翻自[::\s][^)]+)/, "") 23 | .replace(/\(\s*翻自[::\s][^\)]+\)/, ""); 24 | info.album = filter(data.album, ["id", "name"]); 25 | info.artists = data.artists.map((artist) => filter(artist, ["id", "name"])); 26 | info.keyword = info.name + " - " + limit(info.artists.map((artist) => artist.name)).join(" / "); 27 | return info; 28 | } catch (err) { 29 | console.log("getFormatData err: ", err); 30 | return {}; 31 | } 32 | }; 33 | 34 | const find = (id, data) => { 35 | if (data) { 36 | const info = getFormatData(data); 37 | return info.name ? Promise.resolve(info) : Promise.reject(); 38 | } else { 39 | const url = "https://music.163.com/api/song/detail?ids=[" + id + "]"; 40 | return request("GET", url) 41 | .then((response) => response.json()) 42 | .then((jsonBody) => { 43 | const info = getFormatData(jsonBody.songs[0]); 44 | return info.name ? info : Promise.reject(); 45 | }); 46 | } 47 | }; 48 | 49 | module.exports = (id, data) => { 50 | if (data) { 51 | return find(id, data); 52 | } else { 53 | return cache(find, id); 54 | } 55 | }; -------------------------------------------------------------------------------- /src/similarity.js: -------------------------------------------------------------------------------- 1 | const compareTwoStrings = (first, second) => { 2 | first = first.replace(/\s+/g, '').toLowerCase() 3 | second = second.replace(/\s+/g, '').toLowerCase() 4 | 5 | if (first === second) return 1; // identical or empty 6 | if (first.length < 2 || second.length < 2) return 0; // if either is a 0-letter or 1-letter string 7 | 8 | let firstBigrams = new Map(); 9 | for (let i = 0; i < first.length - 1; i++) { 10 | const bigram = first.substring(i, i + 2); 11 | const count = firstBigrams.has(bigram) ? 12 | firstBigrams.get(bigram) + 1 : 13 | 1; 14 | 15 | firstBigrams.set(bigram, count); 16 | }; 17 | 18 | let intersectionSize = 0; 19 | for (let i = 0; i < second.length - 1; i++) { 20 | const bigram = second.substring(i, i + 2); 21 | const count = firstBigrams.has(bigram) ? 22 | firstBigrams.get(bigram) : 23 | 0; 24 | 25 | if (count > 0) { 26 | firstBigrams.set(bigram, count - 1); 27 | intersectionSize++; 28 | } 29 | } 30 | 31 | return (2.0 * intersectionSize) / (first.length + second.length - 2); 32 | } 33 | 34 | const findBestMatch = (mainString, targetStrings) => { 35 | if (!areArgsValid(mainString, targetStrings)) throw new Error('Bad arguments: First argument should be a string, second should be an array of strings'); 36 | 37 | const ratings = []; 38 | let bestMatchIndex = 0; 39 | 40 | for (let i = 0; i < targetStrings.length; i++) { 41 | const currentTargetString = targetStrings[i]; 42 | const currentRating = compareTwoStrings(mainString, currentTargetString) 43 | ratings.push({ 44 | target: currentTargetString, 45 | rating: currentRating 46 | }) 47 | if (currentRating > ratings[bestMatchIndex].rating) { 48 | bestMatchIndex = i 49 | } 50 | } 51 | 52 | 53 | const bestMatch = ratings[bestMatchIndex] 54 | 55 | return { 56 | ratings: ratings, 57 | bestMatch: bestMatch, 58 | bestMatchIndex: bestMatchIndex 59 | }; 60 | } 61 | 62 | const areArgsValid = (mainString, targetStrings) => { 63 | if (typeof mainString !== 'string') return false; 64 | if (!Array.isArray(targetStrings)) return false; 65 | if (!targetStrings.length) return false; 66 | if (targetStrings.find(function (s) { 67 | return typeof s !== 'string' 68 | })) return false; 69 | return true; 70 | } 71 | 72 | module.exports = { 73 | compareTwoStrings, 74 | findBestMatch 75 | } -------------------------------------------------------------------------------- /src/provider/joox.js: -------------------------------------------------------------------------------- 1 | const cache = require('../cache') 2 | const insure = require('./insure') 3 | const select = require('./select') 4 | const crypto = require('../crypto') 5 | const request = require('../request') 6 | 7 | const headers = { 8 | 'origin': 'http://www.joox.com', 9 | 'referer': 'http://www.joox.com', 10 | 'cookie': process.env.JOOX_COOKIE || null, // 'wmid=; session_key=;' 11 | } 12 | 13 | const fit = info => { 14 | if (/[\u0800-\u4e00]/.test(info.name)) //is japanese 15 | return info.name 16 | else 17 | return info.keyword 18 | } 19 | 20 | const format = song => { 21 | const { 22 | decode 23 | } = crypto.base64 24 | return { 25 | id: song.songid, 26 | name: decode(song.info1 || ''), 27 | duration: song.playtime * 1000, 28 | album: { 29 | id: song.albummid, 30 | name: decode(song.info3 || '') 31 | }, 32 | artists: song.singer_list.map(({ 33 | id, 34 | name 35 | }) => ({ 36 | id, 37 | name: decode(name || '') 38 | })), 39 | weight: 0 40 | } 41 | } 42 | 43 | var weight = 0 44 | 45 | const search = info => { 46 | const keyword = fit(info) 47 | const url = 48 | 'http://api-jooxtt.sanook.com/web-fcgi-bin/web_search?' + 49 | 'country=hk&lang=zh_TW&' + 50 | 'search_input=' + encodeURIComponent(keyword) + '&sin=0&ein=5' 51 | 52 | return request('GET', url, headers) 53 | .then(response => response.body()) 54 | .then(body => { 55 | const jsonBody = JSON.parse(body.replace(/'/g, '"')) 56 | const list = jsonBody.itemlist.map(format) 57 | const matched = select.selectList(list, info) 58 | weight = matched.weight 59 | return matched ? matched.id : Promise.reject() 60 | }) 61 | } 62 | 63 | const track = id => { 64 | const url = 65 | 'http://api.joox.com/web-fcgi-bin/web_get_songinfo?' + 66 | 'songid=' + id + '&country=hk&lang=zh_cn&from_type=-1&' + 67 | 'channel_id=-1&_=' + (new Date).getTime() 68 | 69 | return request('GET', url, headers) 70 | .then(response => response.jsonp()) 71 | .then(jsonBody => { 72 | const songUrl = (jsonBody.r320Url || jsonBody.r192Url || jsonBody.mp3Url || jsonBody.m4aUrl).replace(/M\d00([\w]+).mp3/, 'M800$1.mp3') 73 | if (songUrl) 74 | return { 75 | url: songUrl, 76 | weight: weight 77 | } 78 | else 79 | return Promise.reject() 80 | }) 81 | .catch(() => insure().joox.track(id)) 82 | } 83 | 84 | const check = info => cache(search, info).then(track) 85 | 86 | module.exports = { 87 | check, 88 | track 89 | } -------------------------------------------------------------------------------- /src/browser/inject.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const remote = 'oleomikdicccalekkpcbfgdmpjehnpkp' 3 | const remoteMatch = id => new Promise(resolve => { 4 | chrome.runtime.sendMessage(remote, {match: id}, response => { 5 | resolve(response) 6 | }) 7 | }) 8 | 9 | const waitTimeout = wait => new Promise(resolve => { 10 | setTimeout(() => { 11 | resolve() 12 | }, wait) 13 | }) 14 | 15 | const searchFunction = (object, keyword) => 16 | Object.keys(object) 17 | .filter(key => object[key] && typeof object[key] == 'function') 18 | .find(key => String(object[key]).match(keyword)) 19 | 20 | if(self.frameElement && self.frameElement.tagName == 'IFRAME'){ //in iframe 21 | const keyOne = searchFunction(window.nej.e, '\\.dataset;if') 22 | const keyTwo = searchFunction(window.nm.x, '\\.copyrightId==') 23 | const keyThree = searchFunction(window.nm.x, '\\.privilege;if') 24 | const functionOne = window.nej.e[keyOne] 25 | 26 | window.nej.e[keyOne] = (z, name) => { 27 | if (name == 'copyright' || name == 'resCopyright') return 1 28 | return functionOne(z, name) 29 | } 30 | window.nm.x[keyTwo] = () => false 31 | window.nm.x[keyThree] = song => { 32 | song.status = 0 33 | if (song.privilege) song.privilege.pl = 320000 34 | return 0 35 | } 36 | const table = document.querySelector('table tbody') 37 | if(table) Array.from(table.childNodes) 38 | .filter(element => element.classList.contains('js-dis')) 39 | .forEach(element => element.classList.remove('js-dis')) 40 | } 41 | else{ 42 | const keyAjax = searchFunction(window.nej.j, '\\.replace\\("api","weapi') 43 | const functionAjax = window.nej.j[keyAjax] 44 | window.nej.j[keyAjax] = (url, param) => { 45 | const onload = param.onload 46 | param.onload = data => { 47 | Promise.resolve() 48 | .then(() => { 49 | if(url.includes('enhance/player/url')){ 50 | if(data.data[0].url){ 51 | data.data[0].url = data.data[0].url.replace(/(m\d+?)(?!c)\.music\.126\.net/, '$1c.music.126.net') 52 | } 53 | else{ 54 | return Promise.race([remoteMatch(data.data[0].id), waitTimeout(4000)]) 55 | .then(result => { 56 | if(result){ 57 | data.data[0].code = 200 58 | data.data[0].br = 320000 59 | data.data[0].type = 'mp3' 60 | data.data[0].size = result.size 61 | data.data[0].md5 = result.md5 62 | data.data[0].url = result.url.replace(/http:\/\//, 'https://') 63 | } 64 | }) 65 | } 66 | } 67 | }) 68 | .then(() => onload(data)) 69 | } 70 | functionAjax(url, param) 71 | } 72 | } 73 | })() -------------------------------------------------------------------------------- /src/provider/kugou.js: -------------------------------------------------------------------------------- 1 | const insure = require('./insure'); 2 | const select = require('./select'); 3 | const crypto = require('../crypto'); 4 | const request = require('../request'); 5 | const cache = require('../cache'); 6 | 7 | const format = (song) => { 8 | return { 9 | // id: song.FileHash, 10 | // name: song.SongName, 11 | // duration: song.Duration * 1000, 12 | // album: {id: song.AlbumID, name: song.AlbumName}, 13 | // artists: song.SingerId.map((id, index) => ({id, name: SingerName[index]})) 14 | id: song['hash'], 15 | id_hq: song['320hash'], 16 | id_sq: song['sqhash'], 17 | name: song['songname'], 18 | duration: song['duration'] * 1000, 19 | album: { 20 | id: song['album_id'], 21 | name: song['album_name'] 22 | }, 23 | weight: 0 24 | }; 25 | }; 26 | 27 | var weight = 0 28 | 29 | const search = (info) => { 30 | const url = 31 | // 'http://songsearch.kugou.com/song_search_v2?' + 32 | 'http://mobilecdn.kugou.com/api/v3/search/song?' + 33 | 'keyword=' + 34 | encodeURIComponent(info.keyword) + 35 | '&page=1&pagesize=5'; 36 | 37 | return request('GET', url) 38 | .then((response) => response.json()) 39 | .then((jsonBody) => { 40 | // const list = jsonBody.data.lists.map(format) 41 | const list = jsonBody.data.info.map(format); 42 | const matched = select.selectList(list, info) 43 | weight = matched.weight 44 | return matched ? matched : Promise.reject(); 45 | }) 46 | .catch(() => insure().kugou.search(info)); 47 | }; 48 | 49 | const single = (song, format) => { 50 | const getHashId = () => { 51 | switch (format) { 52 | case 'hash': 53 | return song.id; 54 | case 'hqhash': 55 | return song.id_hq; 56 | case 'sqhash': 57 | return song.id_sq; 58 | default: 59 | break; 60 | } 61 | return ''; 62 | }; 63 | 64 | const url = 65 | 'http://trackercdn.kugou.com/i/v2/?' + 66 | 'key=' + 67 | crypto.md5.digest(`${getHashId()}kgcloudv2`) + 68 | '&hash=' + 69 | getHashId() + 70 | '&' + 71 | 'appid=1005&pid=2&cmd=25&behavior=play&album_id=' + 72 | song.album.id; 73 | return request('GET', url) 74 | .then((response) => response.json()) 75 | .then((result) => { 76 | let url = result.find((url) => url); 77 | if (url) { 78 | return { 79 | url: url, 80 | weight: weight, 81 | }; 82 | } else Promise.reject(); 83 | }) 84 | .catch(() => insure().kugou.track(song)); 85 | }; 86 | 87 | const track = (song) => 88 | Promise.all( 89 | ['sqhash', 'hqhash', 'hash'] 90 | .slice(select.ENABLE_FLAC ? 0 : 1) 91 | .map((format) => single(song, format).catch(() => Promise.reject())) 92 | ) 93 | .then((result) => { 94 | let url = result.find((url) => url) 95 | if (url) { 96 | return { 97 | url: url, 98 | weight: weight 99 | } 100 | } else Promise.reject() 101 | }) 102 | .catch(() => insure().kugou.track(song)); 103 | 104 | const check = info => cache(search, info).then(track) 105 | 106 | module.exports = { 107 | check, 108 | search 109 | }; -------------------------------------------------------------------------------- /src/provider/select.js: -------------------------------------------------------------------------------- 1 | const similarity = require("../similarity"); 2 | 3 | const replaceSpace = (string) => 4 | string.replace(/ /g, " ").replace(/nbsp;/g, " "); 5 | 6 | const calcWeight = (song, info) => { 7 | var weight = 0; 8 | const songName = replaceSpace( 9 | song.name 10 | .replace(/(\s*cover[::\s][^)]+)/i, "") 11 | .replace(/\(\s*cover[::\s][^\)]+\)/i, "") 12 | .replace(/(\s*翻自[::\s][^)]+)/, "") 13 | .replace(/\(\s*翻自[::\s][^\)]+\)/, "") 14 | ).toLowerCase(); 15 | const similarityVaule = similarity.compareTwoStrings(songName, info.name); 16 | if (similarityVaule === 0) return 0; //歌曲名不相似绝对不一样 17 | if (similarityVaule === 1) weight = 0.15; 18 | else weight = similarityVaule / 4; 19 | 20 | if (song.artists) { 21 | var authorName = ""; 22 | if (Array.isArray(song.artists)) { 23 | song.artists.forEach((artists) => { 24 | authorName = authorName + artists.name.replace(/ /g, " "); 25 | }); 26 | } else { 27 | authorName = song.artists.name; 28 | } 29 | authorName = replaceSpace(authorName).toLowerCase(); 30 | const songName = song.name ? song.name : ""; 31 | info.artists.forEach((artists) => { 32 | const originalName = artists.name.toLowerCase(); 33 | if (authorName.includes(originalName)) weight = weight + 0.1; 34 | else if (songName.includes(originalName)) weight = weight + 0.1; 35 | else weight = weight - 0.1; 36 | }); 37 | } 38 | if (song.duration) { 39 | const songLength = Math.abs(song.duration - info.duration); 40 | if (songLength < 3 * 1e3) weight = weight + 0.1; 41 | else if (songLength < 6 * 1e3) weight = weight + 0.06; 42 | else if (songLength < 9 * 1e3) weight = weight + 0.03; 43 | } 44 | if (song.playcount) { 45 | let addweight = song.playcount * 0.00001; 46 | if (addweight > 0.1) addweight = 0.1; 47 | weight += addweight; 48 | } 49 | return weight.toFixed(2) * 100; 50 | }; 51 | 52 | const selectList = (list, info) => { 53 | for (let index = 0; index < list.length; index++) { 54 | list[index].weight = calcWeight(list[index], info); 55 | } 56 | return selectArray(list); 57 | }; 58 | 59 | const selectArray = (array) => { 60 | var song = array[0]; 61 | for (let index = 1; index < array.length; index++) { 62 | const nowSong = array[index]; 63 | if (song.weight < nowSong.weight) song = nowSong; 64 | } 65 | return song; 66 | }; 67 | 68 | module.exports = { 69 | selectList, 70 | selectArray, 71 | }; 72 | 73 | module.exports.ENABLE_FLAC = 74 | (process.env.ENABLE_FLAC || "").toLowerCase() === "true"; 75 | 76 | module.exports.CAN_ACCESS_GOOGLE = false; 77 | const request = require("../request"); 78 | request("GET", "https://www.google.com") 79 | .then((response) => { 80 | if (response.statusCode == 200) module.exports.CAN_ACCESS_GOOGLE = true; 81 | }) 82 | .catch((error) => {}); 83 | -------------------------------------------------------------------------------- /src/provider/migu.js: -------------------------------------------------------------------------------- 1 | const insure = require('./insure'); 2 | const select = require('./select'); 3 | const request = require('../request'); 4 | const cache = require('../cache'); 5 | 6 | const headers = { 7 | origin: 'http://music.migu.cn/', 8 | referer: 'http://m.music.migu.cn/v3/', 9 | // cookie: 'migu_music_sid=' + (process.env.MIGU_COOKIE || null), 10 | aversionid: process.env.MIGU_COOKIE || null, 11 | channel: '0146921', 12 | }; 13 | 14 | const format = (song) => { 15 | const singerId = song.singerId.split(/\s*,\s*/); 16 | const singerName = song.singerName.split(/\s*,\s*/); 17 | return { 18 | // id: song.copyrightId, 19 | id: song.id, 20 | name: song.title, 21 | album: { 22 | id: song.albumId, 23 | name: song.albumName 24 | }, 25 | artists: singerId.map((id, index) => ({ 26 | id, 27 | name: singerName[index] 28 | })), 29 | weight: 0 30 | }; 31 | }; 32 | 33 | var weight = 0 34 | 35 | const search = (info) => { 36 | const url = 37 | 'https://m.music.migu.cn/migu/remoting/scr_search_tag?' + 38 | 'keyword=' + 39 | encodeURIComponent(info.keyword) + 40 | '&type=2&rows=5&pgc=1'; 41 | 42 | return request('GET', url, headers) 43 | .then((response) => response.json()) 44 | .then((jsonBody) => { 45 | const list = ((jsonBody || {}).musics || []).map(format); 46 | const matched = select.selectList(list, info) 47 | weight = matched.weight 48 | return matched ? matched.id : Promise.reject(); 49 | }); 50 | }; 51 | 52 | const single = (id, format) => { 53 | // const url = 54 | // 'https://music.migu.cn/v3/api/music/audioPlayer/getPlayInfo?' + 55 | // 'dataType=2&' + crypto.miguapi.encryptBody({copyrightId: id.toString(), type: format}) 56 | 57 | const url = 58 | 'https://app.c.nf.migu.cn/MIGUM2.0/strategy/listen-url/v2.4?' + 59 | 'netType=01&resourceType=2&songId=' + 60 | id.toString() + 61 | '&toneFlag=' + 62 | format; 63 | 64 | return request('GET', url, headers) 65 | .then((response) => response.json()) 66 | .then((jsonBody) => { 67 | // const {playUrl} = jsonBody.data 68 | // return playUrl ? encodeURI('http:' + playUrl) : Promise.reject() 69 | const { 70 | audioFormatType 71 | } = jsonBody.data; 72 | if (audioFormatType !== format) return Promise.reject(); 73 | else if (url) { 74 | return { 75 | url: jsonBody.data.url, 76 | weight: weight 77 | } 78 | } else Promise.reject(); 79 | }); 80 | }; 81 | 82 | const track = (id) => 83 | Promise.all( 84 | // [3, 2, 1].slice(select.ENABLE_FLAC ? 0 : 1) 85 | ['ZQ24', 'SQ', 'HQ', 'PQ'] 86 | .slice(select.ENABLE_FLAC ? 0 : 2) 87 | .map((format) => single(id, format).catch(() => null)) 88 | ) 89 | .then((result) => { 90 | let url = result.find((url) => url) 91 | if (url) { 92 | return { 93 | url: url, 94 | weight: weight 95 | } 96 | } else Promise.reject() 97 | }) 98 | .catch(() => insure().migu.track(id)); 99 | 100 | const check = info => cache(search, info).then(track) 101 | 102 | module.exports = { 103 | check, 104 | track 105 | }; -------------------------------------------------------------------------------- /src/request.js: -------------------------------------------------------------------------------- 1 | const zlib = require('zlib') 2 | const http = require('http') 3 | const https = require('https') 4 | const parse = require('url').parse 5 | 6 | const translate = host => (global.hosts || {})[host] || host 7 | 8 | const create = (url, proxy) => (((typeof(proxy) === 'undefined' ? global.proxy : proxy) || url).protocol === 'https:' ? https : http).request 9 | 10 | const configure = (method, url, headers, proxy) => { 11 | headers = headers || {} 12 | proxy = typeof(proxy) === 'undefined' ? global.proxy : proxy 13 | if ('content-length' in headers) delete headers['content-length'] 14 | 15 | const options = {} 16 | options._headers = headers 17 | if (proxy && url.protocol === 'https:') { 18 | options.method = 'CONNECT' 19 | options.headers = Object.keys(headers).reduce((result, key) => Object.assign(result, ['host', 'user-agent'].includes(key) && {[key]: headers[key]}), {}) 20 | } 21 | else { 22 | options.method = method 23 | options.headers = headers 24 | } 25 | 26 | if (proxy) { 27 | options.hostname = translate(proxy.hostname) 28 | options.port = proxy.port || ((proxy.protocol === 'https:') ? 443 : 80) 29 | options.path = (url.protocol === 'https:') ? (translate(url.hostname) + ':' + (url.port || 443)) : ('http://' + translate(url.hostname) + url.path) 30 | } 31 | else { 32 | options.hostname = translate(url.hostname) 33 | options.port = url.port || ((url.protocol === 'https:') ? 443 : 80) 34 | options.path = url.path 35 | } 36 | return options 37 | } 38 | 39 | const request = (method, url, headers, body, proxy) => { 40 | url = parse(url) 41 | headers = headers || {} 42 | const options = configure(method, url, Object.assign({ 43 | 'host': url.hostname, 44 | 'accept': 'application/json, text/plain, */*', 45 | 'accept-encoding': 'gzip, deflate', 46 | 'accept-language': 'zh-CN,zh;q=0.9', 47 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36' 48 | }, headers), proxy) 49 | 50 | return new Promise((resolve, reject) => { 51 | create(url, proxy)(options) 52 | .on('response', response => resolve(response)) 53 | .on('connect', (_, socket) => 54 | https.request({ 55 | method: method, 56 | path: url.path, 57 | headers: options._headers, 58 | socket: socket, 59 | agent: false 60 | }) 61 | .on('response', response => resolve(response)) 62 | .on('error', error => reject(error)) 63 | .end(body) 64 | ) 65 | .on('error', error => reject(error)) 66 | .end(options.method.toUpperCase() === 'CONNECT' ? undefined : body) 67 | }) 68 | .then(response => { 69 | if (new Set([201, 301, 302, 303, 307, 308]).has(response.statusCode)) 70 | return request(method, url.resolve(response.headers.location || url.href), (delete headers.host, headers), body, proxy) 71 | else 72 | return Object.assign(response, {url: url, body: raw => read(response, raw), json: () => json(response), jsonp: () => jsonp(response)}) 73 | }) 74 | } 75 | 76 | const read = (connect, raw) => 77 | new Promise((resolve, reject) => { 78 | const chunks = [] 79 | connect 80 | .on('data', chunk => chunks.push(chunk)) 81 | .on('end', () => resolve(Buffer.concat(chunks))) 82 | .on('error', error => reject(error)) 83 | }) 84 | .then(buffer => { 85 | if (buffer.length) { 86 | switch (connect.headers['content-encoding']) { 87 | case 'deflate': 88 | case 'gzip': 89 | buffer = zlib.unzipSync(buffer); 90 | break; 91 | case 'br': 92 | buffer = zlib.brotliDecompressSync(buffer); 93 | break; 94 | } 95 | } 96 | return raw ? buffer : buffer.toString() 97 | }) 98 | 99 | const json = connect => read(connect, false).then(body => JSON.parse(body)) 100 | const jsonp = connect => read(connect, false).then(body => JSON.parse(body.slice(body.indexOf('(') + 1, -')'.length))) 101 | 102 | request.read = read 103 | request.create = create 104 | request.translate = translate 105 | request.configure = configure 106 | 107 | module.exports = request -------------------------------------------------------------------------------- /src/provider/kuwo.js: -------------------------------------------------------------------------------- 1 | const insure = require("./insure"); 2 | const select = require("./select"); 3 | const crypto = require("../crypto"); 4 | const request = require("../request"); 5 | const cache = require("../cache"); 6 | const { encrypt } = require("../kwDES"); 7 | 8 | const format = (song) => ({ 9 | id: song.musicrid.split("_").pop(), 10 | name: song.name, 11 | // duration: song.songTimeMinutes.split(':').reduce((minute, second) => minute * 60 + parseFloat(second), 0) * 1000, 12 | duration: song.duration * 1000, 13 | album: { 14 | id: song.albumid, 15 | name: song.album, 16 | }, 17 | artists: song.artist.split("&").map((name, index) => ({ 18 | id: index ? null : song.artistid, 19 | name, 20 | })), 21 | weight: 0, 22 | }); 23 | 24 | const encryptKey = "Hm_Iuvt_cdb524f42f0cer9b268e4v7y734w5esq24"; 25 | var weight = 0; 26 | 27 | const search = (info) => { 28 | // const url = 29 | // // 'http://search.kuwo.cn/r.s?' + 30 | // // 'ft=music&itemset=web_2013&client=kt&' + 31 | // // 'rformat=json&encoding=utf8&' + 32 | // // 'all=' + encodeURIComponent(info.keyword) + '&pn=0&rn=20' 33 | // 'http://search.kuwo.cn/r.s?' + 34 | // 'ft=music&rformat=json&encoding=utf8&' + 35 | // 'rn=8&callback=song&vipver=MUSIC_8.0.3.1&' + 36 | // 'SONGNAME=' + encodeURIComponent(info.name) + '&' + 37 | // 'ARTIST=' + encodeURIComponent(info.artists[0].name) 38 | 39 | // return request('GET', url) 40 | // .then(response => response.body()) 41 | // .then(body => { 42 | // const jsonBody = eval( 43 | // '(' + body 44 | // .replace(/\n/g, '') 45 | // .match(/try\s*\{[^=]+=\s*(.+?)\s*\}\s*catch/)[1] 46 | // .replace(/;\s*song\s*\(.+\)\s*;\s*/, '') + ')' 47 | // ) 48 | // const matched = jsonBody.abslist[0] 49 | // if (matched) 50 | // return matched.MUSICRID.split('_').pop() 51 | // else 52 | // return Promise.reject() 53 | // }) 54 | 55 | const keyword = encodeURIComponent(info.keyword.replace(" - ", " ")); 56 | const url = `http://www.kuwo.cn/api/www/search/searchMusicBykeyWord?key=${keyword}&pn=1&rn=5`; 57 | 58 | return request("GET", `http://kuwo.cn/search/list?key=${keyword}`) 59 | .then((response) => 60 | response.headers["set-cookie"] 61 | .find((line) => line.includes(encryptKey)) 62 | .replace(/;.*/, "") 63 | .split("=") 64 | .pop() 65 | ) 66 | .then((data) => 67 | request("GET", url, { 68 | Referer: `http://www.kuwo.cn/search/list?key=${keyword}`, 69 | Cookie: `${encryptKey}=${data}`, 70 | Secret: crypto.kuwoapi.SecretCalc(data, encryptKey), 71 | }) 72 | ) 73 | .then((response) => response.json()) 74 | .then((jsonBody) => { 75 | if (!jsonBody || jsonBody.code !== 200 || jsonBody.data.total < 1) 76 | return Promise.reject(); 77 | const list = jsonBody.data.list.map(format); 78 | const matched = select.selectList(list, info); 79 | weight = matched.weight; 80 | return matched ? matched.id : Promise.reject(); 81 | }); 82 | }; 83 | 84 | const track = (id) => { 85 | const url = crypto.kuwoapi 86 | ? "http://mobi.kuwo.cn/mobi.s?f=kuwo&q=" + 87 | crypto.kuwoapi.encryptQuery( 88 | "corp=kuwo&p2p=1&type=convert_url2&sig=0&format=" + 89 | ["flac", "mp3"] 90 | .slice(select.ENABLE_FLAC ? 0 : 1) 91 | .join("|") + 92 | "&rid=" + 93 | id 94 | ) 95 | : "http://antiserver.kuwo.cn/anti.s?type=convert_url&format=mp3&response=url&rid=MUSIC_" + 96 | id; // flac refuse 97 | // : 'http://www.kuwo.cn/url?format=mp3&response=url&type=convert_url3&br=320kmp3&rid=' + id // flac refuse 98 | 99 | return request("GET", url, { 100 | "user-agent": "okhttp/3.10.0", 101 | }) 102 | .then((response) => response.body()) 103 | .then((body) => { 104 | const url = (body.match(/http[^\s$"]+/) || [])[0]; 105 | if (url) { 106 | return { 107 | url: url, 108 | weight: weight, 109 | }; 110 | } else Promise.reject(); 111 | }) 112 | .catch(() => insure().kuwo.track(id)); 113 | }; 114 | 115 | const check = (info) => cache(search, info).then(track); 116 | 117 | module.exports = { 118 | check, 119 | track, 120 | }; 121 | -------------------------------------------------------------------------------- /src/provider/youtube.js: -------------------------------------------------------------------------------- 1 | const cache = require('../cache') 2 | const request = require('../request') 3 | const select = require('./select') 4 | const parse = query => (query || '').split('&').reduce((result, item) => (item = item.split('=').map(decodeURIComponent), Object.assign({}, result, { 5 | [item[0]]: item[1] 6 | })), {}) 7 | 8 | //const proxy = require('url').parse('http://127.0.0.1:8888') 9 | const proxy = undefined 10 | const key = process.env.YOUTUBE_KEY || null // YouTube Data API v3 11 | 12 | const format = song => { 13 | song = song.videoRenderer 14 | return { 15 | id: song.videoId, 16 | name: song.title.runs[0].text, 17 | duration: song.lengthText.simpleText.split(':').reduce((minute, second) => minute * 60 + parseFloat(second), 0) * 1000, 18 | artists: song.ownerText.runs.map(data => ({ 19 | name: data.text 20 | })), 21 | weight: 0 22 | } 23 | } 24 | 25 | var weight = 0 26 | 27 | const signature = (id = '-tKVN2mAKRI') => { 28 | const url = 29 | `https://www.youtube.com/watch?v=${id}` 30 | 31 | return request('GET', url, {}, null, proxy) 32 | .then(response => response.body()) 33 | .then(body => { 34 | let assets = /"WEB_PLAYER_CONTEXT_CONFIG_ID_KEVLAR_VERTICAL_LANDING_PAGE_PROMO":{[^}]+}/.exec(body)[0] 35 | assets = JSON.parse(`{${assets}}}`).WEB_PLAYER_CONTEXT_CONFIG_ID_KEVLAR_VERTICAL_LANDING_PAGE_PROMO 36 | return request('GET', 'https://youtube.com' + assets.jsUrl, {}, null, proxy).then(response => response.body()) 37 | }) 38 | .then(body => { 39 | const [_, funcArg, funcBody] = /function\((\w+)\)\s*{([^}]+split\(""\)[^}]+join\(""\))};/.exec(body) 40 | const helperName = /;(.+?)\..+?\(/.exec(funcBody)[1] 41 | const helperContent = new RegExp(`var ${helperName}={[\\s\\S]+?};`).exec(body)[0] 42 | return new Function([funcArg], helperContent + '\n' + funcBody) 43 | }) 44 | } 45 | 46 | const apiSearch = info => { 47 | const url = 48 | `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(info.keyword)}&type=video&key=${key}` 49 | 50 | return request('GET', url, { 51 | accept: 'application/json' 52 | }, null, proxy) 53 | .then(response => response.json()) 54 | .then(jsonBody => { 55 | const matched = jsonBody.items[0] 56 | if (matched) 57 | return matched.id.videoId 58 | else 59 | return Promise.reject() 60 | }) 61 | } 62 | 63 | const search = info => { 64 | const url = 65 | `https://www.youtube.com/results?search_query=${encodeURIComponent(info.keyword)}` 66 | 67 | return request('GET', url, {}, null, proxy) 68 | .then(response => response.body()) 69 | .then(body => { 70 | const initialData = JSON.parse(body.match(/ytInitialData\s*=\s*([^;]+);/)[1]).contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents 71 | const list = initialData.slice(0, 5).filter(data => data.videoRenderer).map(format) // 取前五个视频 72 | const matched = select.selectList(list, info) 73 | weight = matched.weight 74 | return matched ? matched.id : Promise.reject() 75 | }) 76 | } 77 | 78 | const track = id => { 79 | const url = `https://www.youtube.com/get_video_info?video_id=${id}&el=detailpage&html5=1` 80 | return request('GET', url, {}, null, proxy) 81 | .then(response => response.body()) 82 | .then(body => JSON.parse(parse(body).player_response).streamingData) 83 | .then(streamingData => { 84 | const stream = streamingData.formats.concat(streamingData.adaptiveFormats) 85 | .find(format => format.itag === 140) 86 | // .filter(format => [249, 250, 140, 251].includes(format.itag)) // NetaseMusic PC client do not support webm format 87 | // .sort((a, b) => b.bitrate - a.bitrate)[0] 88 | const target = parse(stream.signatureCipher) 89 | return stream.url || (target.sp.includes('sig') ? cache(signature, undefined, 24 * 60 * 60 * 1000).then(sign => ({ 90 | url: target.url + '&sig=' + sign(target.s), 91 | weight: weight 92 | })) : { 93 | url: target.url, 94 | weight: weight 95 | }) 96 | }) 97 | .catch(() => insure().youtube.track(id)) 98 | } 99 | 100 | const check = info => cache(key ? apiSearch : search, info).then(track) 101 | 102 | module.exports = { 103 | check, 104 | track 105 | } -------------------------------------------------------------------------------- /src/provider/match.js: -------------------------------------------------------------------------------- 1 | const find = require('./find') 2 | const select = require('./select') 3 | const request = require('../request') 4 | 5 | const provider = { 6 | netease: require('./netease'), 7 | qq: require('./qq'), 8 | baidu: require('./baidu'), 9 | kugou: require('./kugou'), 10 | kuwo: require('./kuwo'), 11 | migu: require('./migu'), 12 | joox: require('./joox'), 13 | youtube: require('./youtube'), 14 | bilibili: require('./bilibili'), 15 | pyncmd: require('./pyncmd') 16 | } 17 | 18 | const match = (id, source, data) => { 19 | let meta = {} 20 | const candidate = (source || global.source || ['pyncmd', 'qq', 'kuwo', 'bilibili']).filter(name => name in provider) 21 | return find(id, data) 22 | .then(info => { 23 | meta = info 24 | return Promise.all(candidate.map(name => provider[name].check(info).catch(() => {}))) 25 | }) 26 | .then(datas => { 27 | datas = datas.filter(data => data && data.url) 28 | return Promise.all(datas.map(data => check(data.url, data.weight))) 29 | }) 30 | .then(songs => { 31 | if (!songs.length) return Promise.reject() 32 | const song = select.selectArray(songs) 33 | console.log(`[${meta.id}] ${meta.name}\n${song.url}\nWeight: ${song.weight}`) 34 | return song 35 | }) 36 | } 37 | 38 | const check = (url, weight) => { 39 | const song = { 40 | size: 0, 41 | br: null, 42 | url: null, 43 | md5: null, 44 | weight: weight 45 | } 46 | let header = { 47 | 'range': 'bytes=0-8191' 48 | } 49 | if (url.includes("bilivideo.com")) { 50 | header = { 51 | 'range': 'bytes=0-8191', 52 | 'referer': "https://www.bilibili.com/" 53 | } 54 | } 55 | return Promise.race([request('GET', url, header), new Promise((_, reject) => setTimeout(() => reject(504), 5 * 1000))]) 56 | .then(response => { 57 | if (!response.statusCode.toString().startsWith('2')) return Promise.reject() 58 | if (url.includes('126.net')) 59 | // song.md5 = response.headers['x-nos-meta-origin-md5'] || response.headers['etag'].replace(/"/g, '') 60 | song.md5 = url.split('/').slice(-1)[0].replace(/\..*/g, '') 61 | else if (url.includes('qq.com')) 62 | song.md5 = response.headers['server-md5'] 63 | else if (url.includes('qianqian.com')) 64 | song.md5 = response.headers['etag'].replace(/"/g, '').toLowerCase() 65 | song.size = parseInt((response.headers['content-range'] || '').split('/').pop() || response.headers['content-length']) || 0 66 | song.url = response.url.href 67 | return response.headers['content-length'] === '8192' ? response.body(true) : Promise.reject() 68 | }) 69 | .then(data => { 70 | const bitrate = decode(data) 71 | song.br = (bitrate && !isNaN(bitrate)) ? bitrate * 1000 : null 72 | }) 73 | .catch(() => {}) 74 | .then(() => song) 75 | } 76 | 77 | const decode = buffer => { 78 | const map = { 79 | 3: { 80 | 3: ['free', 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 'bad'], 81 | 2: ['free', 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 'bad'], 82 | 1: ['free', 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 'bad'] 83 | }, 84 | 2: { 85 | 3: ['free', 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 'bad'], 86 | 2: ['free', 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 'bad'] 87 | } 88 | } 89 | map[2][1] = map[2][2] 90 | map[0] = map[2] 91 | 92 | let pointer = 0 93 | if (buffer.slice(0, 4).toString() === 'fLaC') return 999 94 | if (buffer.slice(0, 3).toString() === 'ID3') { 95 | pointer = 6 96 | const size = buffer.slice(pointer, pointer + 4).reduce((summation, value, index) => summation + (value & 0x7f) << (7 * (3 - index)), 0) 97 | pointer = 10 + size 98 | } 99 | const header = buffer.slice(pointer, pointer + 4) 100 | 101 | // https://www.allegro.cc/forums/thread/591512/674023 102 | if ( 103 | header.length === 4 && 104 | header[0] === 0xff && 105 | ((header[1] >> 5) & 0x7) === 0x7 && 106 | ((header[1] >> 1) & 0x3) !== 0 && 107 | ((header[2] >> 4) & 0xf) !== 0xf && 108 | ((header[2] >> 2) & 0x3) !== 0x3 109 | ) { 110 | const version = (header[1] >> 3) & 0x3 111 | const layer = (header[1] >> 1) & 0x3 112 | const bitrate = header[2] >> 4 113 | return map[version][layer][bitrate] 114 | } 115 | } 116 | 117 | module.exports = match -------------------------------------------------------------------------------- /src/crypto.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const crypto = require('crypto') 4 | const parse = require('url').parse 5 | const bodyify = require('querystring').stringify 6 | 7 | const eapiKey = 'e82ckenh8dichen8' 8 | const linuxapiKey = 'rFgB&h#%2?^eDg:Q' 9 | 10 | const decrypt = (buffer, key) => { 11 | const decipher = crypto.createDecipheriv('aes-128-ecb', key, '') 12 | return Buffer.concat([decipher.update(buffer), decipher.final()]) 13 | } 14 | 15 | const encrypt = (buffer, key) => { 16 | const cipher = crypto.createCipheriv('aes-128-ecb', key, '') 17 | return Buffer.concat([cipher.update(buffer), cipher.final()]) 18 | } 19 | 20 | module.exports = { 21 | eapi: { 22 | encrypt: buffer => encrypt(buffer, eapiKey), 23 | decrypt: buffer => decrypt(buffer, eapiKey), 24 | encryptRequest: (url, object) => { 25 | url = parse(url) 26 | const text = JSON.stringify(object) 27 | const message = `nobody${url.path}use${text}md5forencrypt` 28 | const digest = crypto.createHash('md5').update(message).digest('hex') 29 | const data = `${url.path}-36cd479b6b5-${text}-36cd479b6b5-${digest}` 30 | return { 31 | url: url.href.replace(/\w*api/, 'eapi'), 32 | body: bodyify({ 33 | params: module.exports.eapi.encrypt(Buffer.from(data)).toString('hex').toUpperCase() 34 | }) 35 | } 36 | } 37 | }, 38 | linuxapi: { 39 | encrypt: buffer => encrypt(buffer, linuxapiKey), 40 | decrypt: buffer => decrypt(buffer, linuxapiKey), 41 | encryptRequest: (url, object) => { 42 | url = parse(url) 43 | const text = JSON.stringify({method: 'POST', url: url.href, params: object}) 44 | return { 45 | url: url.resolve('/api/linux/forward'), 46 | body: bodyify({ 47 | eparams: module.exports.linuxapi.encrypt(Buffer.from(text)).toString('hex').toUpperCase() 48 | }) 49 | } 50 | } 51 | }, 52 | miguapi: { 53 | encryptBody: object => { 54 | const text = JSON.stringify(object) 55 | const derive = (password, salt, keyLength, ivSize) => { // EVP_BytesToKey 56 | salt = salt || Buffer.alloc(0) 57 | const keySize = keyLength / 8 58 | const repeat = Math.ceil((keySize + ivSize * 8) / 32) 59 | const buffer = Buffer.concat(Array(repeat).fill(null).reduce( 60 | result => result.concat(crypto.createHash('md5').update(Buffer.concat([result.slice(-1)[0], password, salt])).digest()), 61 | [Buffer.alloc(0)] 62 | )) 63 | return { 64 | key: buffer.slice(0, keySize), 65 | iv: buffer.slice(keySize, keySize + ivSize) 66 | } 67 | } 68 | const password = Buffer.from(crypto.randomBytes(32).toString('hex')), salt = crypto.randomBytes(8) 69 | const key = '-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8asrfSaoOb4je+DSmKdriQJKWVJ2oDZrs3wi5W67m3LwTB9QVR+cE3XWU21Nx+YBxS0yun8wDcjgQvYt625ZCcgin2ro/eOkNyUOTBIbuj9CvMnhUYiR61lC1f1IGbrSYYimqBVSjpifVufxtx/I3exReZosTByYp4Xwpb1+WAQIDAQAB\n-----END PUBLIC KEY-----' 70 | const secret = derive(password, salt, 256, 16) 71 | const cipher = crypto.createCipheriv('aes-256-cbc', secret.key, secret.iv) 72 | return bodyify({ 73 | data: Buffer.concat([Buffer.from('Salted__'), salt, cipher.update(Buffer.from(text)), cipher.final()]).toString('base64'), 74 | secKey: crypto.publicEncrypt({key, padding: crypto.constants.RSA_PKCS1_PADDING}, password).toString('base64') 75 | }) 76 | } 77 | }, 78 | base64: { 79 | encode: (text, charset) => Buffer.from(text, charset).toString('base64').replace(/\+/g, '-').replace(/\//g, '_'), 80 | decode: (text, charset) => Buffer.from(text.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString(charset) 81 | }, 82 | uri: { 83 | retrieve: id => { 84 | id = id.toString().trim() 85 | const key = '3go8&$8*3*3h0k(2)2' 86 | const string = Array.from(Array(id.length).keys()).map(index => String.fromCharCode(id.charCodeAt(index) ^ key.charCodeAt(index % key.length))).join('') 87 | const result = crypto.createHash('md5').update(string).digest('base64').replace(/\//g, '_').replace(/\+/g, '-') 88 | return `http://p1.music.126.net/${result}/${id}` 89 | } 90 | }, 91 | md5: { 92 | digest: value => crypto.createHash('md5').update(value).digest('hex'), 93 | pipe: source => new Promise((resolve, reject) => { 94 | const digest = crypto.createHash('md5').setEncoding('hex') 95 | source.pipe(digest) 96 | .on('error', error => reject(error)) 97 | .once('finish', () => resolve(digest.read())) 98 | }) 99 | } 100 | } 101 | 102 | try {module.exports.kuwoapi = require('./kwDES')} catch(e) {} -------------------------------------------------------------------------------- /src/provider/xiami.js: -------------------------------------------------------------------------------- 1 | const cache = require('../cache') 2 | const insure = require('./insure') 3 | const select = require('./select') 4 | const crypto = require('../crypto') 5 | const request = require('../request') 6 | 7 | const headers = { 8 | // 'origin': 'http://www.xiami.com/', 9 | // 'referer': 'http://www.xiami.com/' 10 | 'referer': 'https://h.xiami.com/' 11 | } 12 | 13 | const format = song => ({ 14 | id: song.song_id, 15 | name: song.song_name, 16 | album: { 17 | id: song.album_id, 18 | name: song.album_name 19 | }, 20 | artists: [{ 21 | id: song.artist_id, 22 | name: song.artist_name 23 | }], 24 | weight: 0 25 | }) 26 | 27 | var weight = 0 28 | 29 | const caesar = pattern => { 30 | const height = parseInt(pattern[0]) 31 | pattern = pattern.slice(1) 32 | const width = Math.ceil(pattern.length / height) 33 | const unpad = height - (width * height - pattern.length) 34 | 35 | const matrix = Array.from(Array(height).keys()).map(i => 36 | pattern.slice(i < unpad ? i * width : unpad * width + (i - unpad) * (width - 1)).slice(0, i < unpad ? width : width - 1) 37 | ) 38 | 39 | const transpose = Array.from(Array(width).keys()).map(x => 40 | Array.from(Array(height).keys()).map(y => matrix[y][x]).join('') 41 | ) 42 | 43 | return unescape(transpose.join('')).replace(/\^/g, '0') 44 | } 45 | 46 | const token = () => { 47 | return request('GET', 'https://www.xiami.com') 48 | .then(response => 49 | response.headers['set-cookie'].map(line => line.replace(/;.+$/, '')).reduce( 50 | (cookie, line) => (line = line.split(/\s*=\s*/).map(decodeURIComponent), Object.assign(cookie, { 51 | [line[0]]: line[1] 52 | })), {} 53 | ) 54 | ) 55 | } 56 | 57 | // const search = info => { 58 | // return cache(token) 59 | // .then(cookie => { 60 | // const query = JSON.stringify({key: info.keyword, pagingVO: {page: 1, pageSize: 60}}) 61 | // const message = cookie['xm_sg_tk'].split('_')[0] + '_xmMain_/api/search/searchSongs_' + query 62 | // return request('GET', 'https://www.xiami.com/api/search/searchSongs?_q=' + encodeURIComponent(query) + '&_s=' + crypto.md5.digest(message), { 63 | // referer: 'https://www.xiami.com/search?key=' + encodeURIComponent(info.keyword), 64 | // cookie: Object.keys(cookie).map(key => encodeURIComponent(key) + '=' + encodeURIComponent(cookie[key])).join('; ') 65 | // }) 66 | // .then(response => response.json()) 67 | // .then(jsonBody => { 68 | // const matched = jsonBody.result.data.songs[0] 69 | // if (matched) 70 | // return matched.songId 71 | // else 72 | // return Promise.reject() 73 | // }) 74 | // }) 75 | // } 76 | 77 | const search = info => { 78 | const url = 79 | 'http://api.xiami.com/web?v=2.0&app_key=1' + 80 | '&key=' + encodeURIComponent(info.keyword) + '&page=1' + 81 | '&limit=20&callback=jsonp&r=search/songs' 82 | 83 | return request('GET', url, headers) 84 | .then(response => response.jsonp()) 85 | .then(jsonBody => { 86 | const list = jsonBody.data.songs.map(format) 87 | const matched = select.selectList(list, info) 88 | weight = matched.weight 89 | return matched ? matched.id : Promise.reject() 90 | }) 91 | } 92 | 93 | // const track = id => { 94 | // const url = 95 | // 'https://emumo.xiami.com/song/playlist/id/' + id + 96 | // '/object_name/default/object_id/0/cat/json' 97 | 98 | // return request('GET', url, headers) 99 | // .then(response => response.json()) 100 | // .then(jsonBody => { 101 | // if (jsonBody.data.trackList == null) { 102 | // return Promise.reject() 103 | // } 104 | // else { 105 | // const location = jsonBody.data.trackList[0].location 106 | // const songUrl = 'http:' + caesar(location) 107 | // return songUrl 108 | // } 109 | // }) 110 | // .then(origin => { 111 | // const updated = origin.replace('m128', 'm320') 112 | // return request('HEAD', updated) 113 | // .then(response => response.statusCode == 200 ? updated : origin) 114 | // .catch(() => origin) 115 | // }) 116 | // .catch(() => insure().xiami.track(id)) 117 | // } 118 | 119 | const track = id => { 120 | const url = 121 | 'https://api.xiami.com/web?v=2.0&app_key=1' + 122 | '&id=' + id + '&callback=jsonp&r=song/detail' 123 | 124 | return request('GET', url, headers) 125 | .then(response => response.jsonp()) 126 | .then(jsonBody => { 127 | let result = jsonBody.data.song.listen_file 128 | if (result) { 129 | return { 130 | url: result, 131 | weight: weight 132 | } 133 | } else Promise.reject() 134 | }) 135 | .catch(() => insure().xiami.track(id)) 136 | } 137 | 138 | const check = info => cache(search, info).then(track) 139 | 140 | module.exports = { 141 | check, 142 | track 143 | } -------------------------------------------------------------------------------- /src/provider/qq.js: -------------------------------------------------------------------------------- 1 | const cache = require("../cache"); 2 | const insure = require("./insure"); 3 | const select = require("./select"); 4 | const request = require("../request"); 5 | const crypto = require("../crypto"); 6 | 7 | const headers = { 8 | origin: "https://y.qq.com", 9 | referer: "https://y.qq.com/", 10 | cookie: process.env.QQ_COOKIE || null, // 'uin=; qm_keyst=', 11 | }; 12 | 13 | var myguid = String( 14 | (Math.round(2147483647 * Math.random()) * new Date().getUTCMilliseconds()) % 15 | 1e10 16 | ); 17 | var weight = 0; 18 | 19 | const format = (song) => ({ 20 | id: { 21 | song: song.mid, 22 | file: song.file.media_mid, 23 | }, 24 | name: song.title, 25 | duration: song.interval * 1000, 26 | album: { 27 | id: song.album.mid, 28 | name: song.album.title, 29 | }, 30 | artists: song.singer.map(({ mid, title }) => ({ 31 | id: mid, 32 | title, 33 | })), 34 | weight: 0, 35 | }); 36 | 37 | const search = (info) => { 38 | const url = 39 | "https://u.y.qq.com/cgi-bin/musicu.fcg?data=" + 40 | encodeURIComponent( 41 | JSON.stringify({ 42 | search: { 43 | method: "DoSearchForQQMusicDesktop", 44 | module: "music.search.SearchCgiService", 45 | param: { 46 | num_per_page: 5, 47 | page_num: 1, 48 | query: info.keyword, 49 | search_type: 0, 50 | }, 51 | }, 52 | }) 53 | ); 54 | 55 | return request("GET", url, headers) 56 | .then((response) => response.json()) 57 | .then((jsonBody) => { 58 | const list = jsonBody.search.data.body.song.list.map(format); 59 | const matched = select.selectList(list, info); 60 | weight = matched.weight; 61 | return matched ? matched.id : Promise.reject(); 62 | }); 63 | }; 64 | 65 | const single = (id, format) => { 66 | const uin = ((headers.cookie || "").match(/uin=(\d+)/) || [])[1] || "0"; 67 | data = JSON.stringify({ 68 | req_0: { 69 | module: "vkey.GetVkeyServer", 70 | method: "CgiGetVkey", 71 | param: { 72 | guid: myguid, 73 | loginflag: 1, 74 | filename: format[0] ? [format.join(id.file)] : null, 75 | songmid: [id.song], 76 | songtype: [0], 77 | uin, 78 | platform: "20", 79 | }, 80 | }, 81 | }); 82 | 83 | const url = 84 | "https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq.json&needNewCode=0&data=" + 85 | encodeURIComponent(data); 86 | 87 | return request("GET", url, headers) 88 | .then((response) => response.json()) 89 | .then((jsonBody) => { 90 | const { sip, midurlinfo } = jsonBody.req_0.data; 91 | 92 | if (!midurlinfo[0].purl) return Promise.reject(); 93 | 94 | const playurl = sip[0] + midurlinfo[0].purl; 95 | const header = { 96 | range: "bytes=0-8191", 97 | "accept-encoding": "identity", 98 | }; 99 | return request("GET", playurl, header).then((response) => { 100 | if (response.statusCode < 200 || response.statusCode > 299) 101 | return Promise.reject(); 102 | else return { url: playurl, weight: weight }; 103 | }); 104 | }); 105 | }; 106 | 107 | const track = (id) => { 108 | id.key = id.file; 109 | return Promise.all( 110 | [ 111 | ["F000", ".flac"], 112 | ["M800", ".mp3"], 113 | ['M500', '.mp3'], 114 | ["C400", ".m4a"], 115 | [null, null], 116 | ] 117 | .slice( 118 | headers.cookie || typeof window !== "undefined" 119 | ? select.ENABLE_FLAC 120 | ? 0 121 | : 1 122 | : 2 123 | ) 124 | .map((format) => single(id, format).catch(() => null)) 125 | ) 126 | .then((result) => { 127 | let url = result.find((url) => url); 128 | if (url) { 129 | return { 130 | url: url, 131 | weight: weight, 132 | }; 133 | } else Promise.reject(); 134 | }) 135 | .catch(() => insure().qq.track(id)); 136 | }; 137 | 138 | const check = (info) => cache(search, info).then(track); 139 | 140 | module.exports = { 141 | check, 142 | track, 143 | }; 144 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const package = require('../package.json') 4 | const config = require('./cli.js') 5 | .program({name: package.name.replace(/@.+\//, ''), version: package.version}) 6 | .option(['-v', '--version'], {action: 'version'}) 7 | .option(['-p', '--port'], { metavar: 'http[:https]', help: 'specify server port' }) 8 | .option(['-a', '--address'], {metavar: 'address', help: 'specify server host'}) 9 | .option(['-u', '--proxy-url'], {metavar: 'url', help: 'request through upstream proxy'}) 10 | .option(['-f', '--force-host'], {metavar: 'host', help: 'force the netease server ip'}) 11 | .option(['-o', '--match-order'], {metavar: 'source', nargs: '+', help: 'set priority of sources'}) 12 | .option(['-t', '--token'], {metavar: 'token', help: 'set up proxy authentication'}) 13 | .option(['-e', '--endpoint'], {metavar: 'url', help: 'replace virtual endpoint with public host'}) 14 | .option(['-s', '--strict'], {action: 'store_true', help: 'enable proxy limitation'}) 15 | .option(['-h', '--help'], {action: 'help'}) 16 | .parse(process.argv) 17 | 18 | global.address = config.address 19 | config.port = (config.port || '8080').split(':').map(string => parseInt(string)) 20 | const invalid = value => (isNaN(value) || value < 1 || value > 65535) 21 | if (config.port.some(invalid)) { 22 | console.log('Port must be a number higher than 0 and lower than 65535.') 23 | process.exit(1) 24 | } 25 | if (config.proxyUrl && !/http(s?):\/\/.+:\d+/.test(config.proxyUrl)) { 26 | console.log('Please check the proxy url.') 27 | process.exit(1) 28 | } 29 | if (config.endpoint && !/http(s?):\/\/.+/.test(config.endpoint)) { 30 | console.log('Please check the endpoint host.') 31 | process.exit(1) 32 | } 33 | if (config.forceHost && require('net').isIP(config.forceHost) === 0) { 34 | console.log('Please check the server host.') 35 | process.exit(1) 36 | } 37 | if (config.matchOrder) { 38 | const provider = new Set(['netease', 'qq', 'baidu', 'kugou', 'kuwo', 'migu', 'joox', 'youtube', 'bilibili', 'pyncmd']) 39 | const candidate = config.matchOrder 40 | if (candidate.some((key, index) => index != candidate.indexOf(key))) { 41 | console.log('Please check the duplication in match order.') 42 | process.exit(1) 43 | } 44 | else if (candidate.some(key => !provider.has(key))) { 45 | console.log('Please check the availability of match sources.') 46 | process.exit(1) 47 | } 48 | global.source = candidate 49 | } 50 | if (config.token && !/\S+:\S+/.test(config.token)) { 51 | console.log('Please check the authentication token.') 52 | process.exit(1) 53 | } 54 | 55 | const parse = require('url').parse 56 | const hook = require('./hook') 57 | const server = require('./server') 58 | const random = array => array[Math.floor(Math.random() * array.length)] 59 | const target = Array.from(hook.target.host) 60 | 61 | global.port = config.port 62 | global.proxy = config.proxyUrl ? parse(config.proxyUrl) : null 63 | global.hosts = target.reduce((result, host) => Object.assign(result, {[host]: config.forceHost}), {}) 64 | server.whitelist = ['://[\\w.]*music\\.126\\.net', '://[\\w.]*vod\\.126\\.net', '://acstatic-dun.126.net', '://[\\w.]*\\.netease.com', '://[\\w.]*\\.163yun.com']; 65 | if (config.strict) server.blacklist.push('.*') 66 | server.authentication = config.token || null 67 | global.endpoint = config.endpoint 68 | if (config.endpoint) server.whitelist.push(escape(config.endpoint)) 69 | 70 | // hosts['music.httpdns.c.163.com'] = random(['59.111.181.35', '59.111.181.38']) 71 | // hosts['httpdns.n.netease.com'] = random(['59.111.179.213', '59.111.179.214']) 72 | 73 | const dns = host => new Promise((resolve, reject) => require('dns').lookup(host, {all: true}, (error, records) => error ? reject(error) : resolve(records.map(record => record.address)))) 74 | const httpdns = host => require('./request')('POST', 'http://music.httpdns.c.163.com/d', {}, host).then(response => response.json()).then(jsonBody => jsonBody.dns.reduce((result, domain) => result.concat(domain.ips), [])) 75 | const httpdns2 = host => require('./request')('GET', 'http://httpdns.n.netease.com/httpdns/v2/d?domain=' + host).then(response => response.json()).then(jsonBody => Object.keys(jsonBody.data).map(key => jsonBody.data[key]).reduce((result, value) => result.concat(value.ip || []), [])) 76 | 77 | Promise.all([httpdns, httpdns2].map(query => query(target.join(','))).concat(target.map(dns))) 78 | .then(result => { 79 | const {host} = hook.target 80 | result.forEach(array => array.forEach(host.add, host)) 81 | server.whitelist = server.whitelist.concat(Array.from(host).map(escape)) 82 | const log = type => console.log(`${['HTTP', 'HTTPS'][type]} Server running @ http://${address || '0.0.0.0'}:${port[type]}`) 83 | if (port[0]) server.http.listen(port[0], address).once('listening', () => log(0)) 84 | if (port[1]) server.https.listen(port[1], address).once('listening', () => log(1)) 85 | }) 86 | .catch(error => { 87 | console.log(error) 88 | process.exit(1) 89 | }) 90 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | const cli = { 2 | width: 80, 3 | _program: {}, 4 | _options: [], 5 | program: (information = {}) => { 6 | cli._program = information 7 | return cli 8 | }, 9 | option: (flags, addition = {}) => { 10 | // name or flags - Either a name or a list of option strings, e.g. foo or -f, --foo. 11 | // dest - The name of the attribute to be added to the object returned by parse_options(). 12 | 13 | // nargs - The number of command-line arguments that should be consumed. // N, ?, *, +, REMAINDER 14 | // action - The basic type of action to be taken when this argument is encountered at the command line. // store, store_true, store_false, append, append_const, count, help, version 15 | 16 | // const - A constant value required by some action and nargs selections. (supporting store_const and append_const action) 17 | 18 | // metavar - A name for the argument in usage messages. 19 | // help - A brief description of what the argument does. 20 | 21 | // required - Whether or not the command-line option may be omitted (optionals only). 22 | // default - The value produced if the argument is absent from the command line. 23 | // type - The type to which the command-line argument should be converted. 24 | // choices - A container of the allowable values for the argument. 25 | 26 | flags = Array.isArray(flags) ? flags : [flags] 27 | addition.dest = addition.dest || flags.slice(-1)[0].toLowerCase().replace(/^-+/, '').replace(/-[a-z]/g, character => character.slice(1).toUpperCase()) 28 | addition.help = addition.help || {'help': 'output usage information', 'version': 'output the version number'}[addition.action] 29 | cli._options.push(Object.assign(addition, {flags: flags, positional: !flags[0].startsWith('-')})) 30 | return cli 31 | }, 32 | parse: argv => { 33 | const positionals = cli._options.map((option, index) => option.positional ? index : null).filter(index => index !== null), optionals = {} 34 | cli._options.forEach((option, index) => option.positional ? null : option.flags.forEach(flag => optionals[flag] = index)) 35 | 36 | cli._program.name = cli._program.name || require('path').parse(argv[1]).base 37 | const args = argv.slice(2).reduce((result, part) => /^-[^-]/.test(part) ? result.concat(part.slice(1).split('').map(string => '-' + string)) : result.concat(part), []) 38 | 39 | let pointer = 0 40 | while (pointer < args.length) { 41 | let value = null 42 | const part = args[pointer] 43 | const index = part.startsWith('-') ? optionals[part] : positionals.shift() 44 | if (index == undefined) part.startsWith('-') ? error(`no such option: ${part}`) : error(`extra arguments found: ${part}`) 45 | if (part.startsWith('-')) pointer += 1 46 | const {action} = cli._options[index] 47 | 48 | if (['help', 'version'].includes(action)) { 49 | if (action === 'help') help() 50 | else if (action === 'version') version() 51 | } 52 | else if (['store_true', 'store_false'].includes(action)) { 53 | value = action === 'store_true' 54 | } 55 | else { 56 | const gap = args.slice(pointer).findIndex(part => part in optionals) 57 | const next = gap === -1 ? args.length : pointer + gap 58 | value = args.slice(pointer, next) 59 | if (value.length === 0) { 60 | if (cli._options[index].positional) 61 | error(`the following arguments are required: ${part}`) 62 | else if (cli._options[index].nargs === '+') 63 | error(`argument ${part}: expected at least one argument`) 64 | else 65 | error(`argument ${part}: expected one argument`) 66 | } 67 | if (cli._options[index].nargs !== '+') { 68 | value = value[0] 69 | pointer += 1 70 | } 71 | else { 72 | pointer = next 73 | } 74 | } 75 | cli[cli._options[index].dest] = value 76 | } 77 | if (positionals.length) error(`the following arguments are required: ${positionals.map(index => cli._options[index].flags[0]).join(', ')}`) 78 | // cli._options.forEach(option => console.log(option.dest, cli[option.dest])) 79 | return cli 80 | } 81 | } 82 | 83 | const pad = length => (new Array(length + 1)).join(' ') 84 | 85 | const usage = () => { 86 | const options = cli._options.map(option => { 87 | const flag = option.flags.sort((a, b) => a.length - b.length)[0] 88 | const name = option.metavar || option.dest 89 | if (option.positional) { 90 | if (option.nargs === '+') 91 | return `${name} [${name} ...]` 92 | else 93 | return `${name}` 94 | } 95 | else { 96 | if (['store_true', 'store_false', 'help', 'version'].includes(option.action)) 97 | return `[${flag}]` 98 | else if (option.nargs === '+') 99 | return `[${flag} ${name} [${name} ...]]` 100 | else 101 | return `[${flag} ${name}]` 102 | } 103 | }) 104 | const maximum = cli.width 105 | const title = `usage: ${cli._program.name}` 106 | const lines = [title] 107 | 108 | options.map(name => ' ' + name).forEach(option => { 109 | lines[lines.length - 1].length + option.length < maximum 110 | ? lines[lines.length - 1] += option 111 | : lines.push(pad(title.length) + option) 112 | }) 113 | console.log(lines.join('\n')) 114 | } 115 | 116 | const help = () => { 117 | usage() 118 | const positionals = cli._options.filter(option => option.positional) 119 | .map(option => [option.metavar || option.dest, option.help]) 120 | const optionals = cli._options.filter(option => !option.positional) 121 | .map(option => { 122 | const {flags} = option 123 | const name = option.metavar || option.dest 124 | let use = '' 125 | if (['store_true', 'store_false', 'help', 'version'].includes(option.action)) 126 | use = flags.map(flag => `${flag}`).join(', ') 127 | else if (option.nargs === '+') 128 | use = flags.map(flag => `${flag} ${name} [${name} ...]`).join(', ') 129 | else 130 | use = flags.map(flag => `${flag} ${name}`).join(', ') 131 | return [use, option.help] 132 | }) 133 | let align = Math.max.apply(null, positionals.concat(optionals).map(option => option[0].length)) 134 | align = align > 30 ? 30 : align 135 | const rest = cli.width - align - 4 136 | const publish = option => { 137 | const slice = string => 138 | Array.from(Array(Math.ceil(string.length / rest)).keys()) 139 | .map(index => string.slice(index * rest, (index + 1) * rest)) 140 | .join('\n' + pad(align + 4)) 141 | option[0].length < align 142 | ? console.log(` ${option[0]}${pad(align - option[0].length)} ${slice(option[1])}`) 143 | : console.log(` ${option[0]}\n${pad(align + 4)}${slice(option[1])}`) 144 | } 145 | if (positionals.length) console.log('\npositional arguments:') 146 | positionals.forEach(publish) 147 | if (optionals.length) console.log('\noptional arguments:') 148 | optionals.forEach(publish) 149 | process.exit() 150 | } 151 | 152 | const version = () => { 153 | console.log(cli._program.version) 154 | process.exit() 155 | } 156 | 157 | const error = message => { 158 | usage() 159 | console.log(cli._program.name + ':', 'error:', message) 160 | process.exit(1) 161 | } 162 | 163 | module.exports = cli -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const net = require('net') 3 | const path = require('path') 4 | const parse = require('url').parse 5 | 6 | const sni = require('./sni') 7 | const hook = require('./hook') 8 | const request = require('./request') 9 | 10 | const proxy = { 11 | core: { 12 | mitm: (req, res) => { 13 | if (req.url == '/proxy.pac') { 14 | const url = parse('http://' + req.headers.host) 15 | res.writeHead(200, { 16 | 'Content-Type': 'application/x-ns-proxy-autoconfig' 17 | }) 18 | res.end(` 19 | function FindProxyForURL(url, host) { 20 | if (${Array.from(hook.target.host).map(host => (`host == '${host}'`)).join(' || ')}) { 21 | return 'PROXY ${url.hostname}:${url.port || 80}' 22 | } 23 | return 'DIRECT' 24 | } 25 | `) 26 | } else { 27 | const ctx = { 28 | res, 29 | req 30 | } 31 | Promise.resolve() 32 | .then(() => proxy.protect(ctx)) 33 | .then(() => proxy.authenticate(ctx)) 34 | .then(() => hook.request.before(ctx)) 35 | .then(() => proxy.filter(ctx)) 36 | .then(() => proxy.log(ctx)) 37 | .then(() => proxy.mitm.request(ctx)) 38 | .then(() => hook.request.after(ctx)) 39 | .then(() => proxy.mitm.response(ctx)) 40 | .catch(() => proxy.mitm.close(ctx)) 41 | } 42 | }, 43 | tunnel: (req, socket, head) => { 44 | const ctx = { 45 | req, 46 | socket, 47 | head 48 | } 49 | Promise.resolve() 50 | .then(() => proxy.protect(ctx)) 51 | .then(() => proxy.authenticate(ctx)) 52 | .then(() => hook.connect.before(ctx)) 53 | .then(() => proxy.filter(ctx)) 54 | .then(() => proxy.log(ctx)) 55 | .then(() => proxy.tunnel.connect(ctx)) 56 | .then(() => proxy.tunnel.dock(ctx)) 57 | .then(() => hook.negotiate.before(ctx)) 58 | .then(() => proxy.tunnel.pipe(ctx)) 59 | .catch(() => proxy.tunnel.close(ctx)) 60 | } 61 | }, 62 | abort: (socket, from) => { 63 | // console.log('call abort', from) 64 | if (socket) socket.end() 65 | if (socket && !socket.destroyed) socket.destroy() 66 | }, 67 | protect: ctx => { 68 | const { 69 | req, 70 | res, 71 | socket 72 | } = ctx 73 | if (req) req.on('error', () => proxy.abort(req.socket, 'req')) 74 | if (res) res.on('error', () => proxy.abort(res.socket, 'res')) 75 | if (socket) socket.on('error', () => proxy.abort(socket, 'socket')) 76 | }, 77 | log: ctx => { 78 | const { 79 | req, 80 | socket, 81 | decision 82 | } = ctx 83 | const mark = { 84 | close: '|', 85 | blank: '-', 86 | proxy: '>' 87 | } [decision] || '>' 88 | if (socket) 89 | console.log('TUNNEL', mark, req.url) 90 | else 91 | console.log('MITM', mark, parse(req.url).host, req.socket.encrypted ? '(ssl)' : '') 92 | }, 93 | authenticate: ctx => { 94 | const { 95 | req, 96 | res, 97 | socket 98 | } = ctx 99 | const credential = Buffer.from((req.headers['proxy-authorization'] || '').split(/\s+/).pop() || '', 'base64').toString() 100 | if ('proxy-authorization' in req.headers) delete req.headers['proxy-authorization'] 101 | if (server.authentication && credential != server.authentication && (socket || req.url.startsWith('http://'))) { 102 | if (socket) 103 | socket.write('HTTP/1.1 407 Proxy Auth Required\r\nProxy-Authenticate: Basic realm="realm"\r\n\r\n') 104 | else 105 | res.writeHead(407, { 106 | 'proxy-authenticate': 'Basic realm="realm"' 107 | }) 108 | return Promise.reject(ctx.error = 'authenticate') 109 | } 110 | }, 111 | filter: ctx => { 112 | if (ctx.decision || ctx.req.local) return 113 | const url = parse((ctx.socket ? 'https://' : '') + ctx.req.url) 114 | const match = pattern => url.href.search(new RegExp(pattern, 'g')) != -1 115 | try { 116 | const allow = server.whitelist.some(match) 117 | const deny = server.blacklist.some(match) 118 | // console.log('allow', allow, 'deny', deny) 119 | if (!allow && deny) { 120 | return Promise.reject(ctx.error = 'filter') 121 | } 122 | } catch (error) { 123 | ctx.error = error 124 | } 125 | }, 126 | mitm: { 127 | request: ctx => new Promise((resolve, reject) => { 128 | if (ctx.decision === 'close') return reject(ctx.error = ctx.decision) 129 | const { 130 | req 131 | } = ctx 132 | if (req.url.includes('bilivideo.com')) { 133 | req.headers['referer'] = 'https://www.bilibili.com/' 134 | req.headers['user-agent'] = 'okhttp/3.4.1' 135 | } 136 | const url = parse(req.url) 137 | const options = request.configure(req.method, url, req.headers) 138 | ctx.proxyReq = request.create(url)(options) 139 | .on('response', proxyRes => resolve(ctx.proxyRes = proxyRes)) 140 | .on('error', error => reject(ctx.error = error)) 141 | req.readable ? req.pipe(ctx.proxyReq) : ctx.proxyReq.end(req.body) 142 | }), 143 | response: ctx => { 144 | const { 145 | res, 146 | proxyRes 147 | } = ctx 148 | proxyRes.on('error', () => proxy.abort(proxyRes.socket, 'proxyRes')) 149 | res.writeHead(proxyRes.statusCode, proxyRes.headers) 150 | proxyRes.readable ? proxyRes.pipe(res) : res.end(proxyRes.body) 151 | }, 152 | close: ctx => { 153 | proxy.abort(ctx.res.socket, 'mitm') 154 | } 155 | }, 156 | tunnel: { 157 | connect: ctx => new Promise((resolve, reject) => { 158 | if (ctx.decision === 'close') return reject(ctx.error = ctx.decision) 159 | const { 160 | req 161 | } = ctx 162 | const url = parse('https://' + req.url) 163 | if (global.proxy && !req.local) { 164 | const options = request.configure(req.method, url, req.headers) 165 | request.create(proxy)(options) 166 | .on('connect', (_, proxySocket) => resolve(ctx.proxySocket = proxySocket)) 167 | .on('error', error => reject(ctx.error = error)) 168 | .end() 169 | } else { 170 | const proxySocket = net.connect(url.port || 443, request.translate(url.hostname)) 171 | .on('connect', () => resolve(ctx.proxySocket = proxySocket)) 172 | .on('error', error => reject(ctx.error = error)) 173 | } 174 | }), 175 | dock: ctx => new Promise(resolve => { 176 | const { 177 | req, 178 | head, 179 | socket 180 | } = ctx 181 | socket 182 | .once('data', data => resolve(ctx.head = Buffer.concat([head, data]))) 183 | .write(`HTTP/${req.httpVersion} 200 Connection established\r\n\r\n`) 184 | }).then(data => ctx.socket.sni = sni(data)).catch(() => {}), 185 | pipe: ctx => { 186 | if (ctx.decision === 'blank') return Promise.reject(ctx.error = ctx.decision) 187 | const { 188 | head, 189 | socket, 190 | proxySocket 191 | } = ctx 192 | proxySocket.on('error', () => proxy.abort(ctx.proxySocket, 'proxySocket')) 193 | proxySocket.write(head) 194 | socket.pipe(proxySocket) 195 | proxySocket.pipe(socket) 196 | }, 197 | close: ctx => { 198 | proxy.abort(ctx.socket, 'tunnel') 199 | } 200 | } 201 | } 202 | 203 | const cert = process.env.SIGN_CERT || path.join(__dirname, '..', 'server.crt') 204 | const key = process.env.SIGN_KEY || path.join(__dirname, '..', 'server.key') 205 | const options = { 206 | key: fs.readFileSync(key), 207 | cert: fs.readFileSync(cert) 208 | } 209 | 210 | const server = { 211 | http: require('http').createServer().on('request', proxy.core.mitm).on('connect', proxy.core.tunnel), 212 | https: require('https').createServer(options).on('request', proxy.core.mitm).on('connect', proxy.core.tunnel) 213 | } 214 | 215 | server.whitelist = [] 216 | server.blacklist = ['://127\\.\\d+\\.\\d+\\.\\d+', '://localhost'] 217 | server.authentication = null 218 | 219 | module.exports = server -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | logo 2 | 3 | # UnblockNeteaseMusic 4 | 5 | 解锁网易云音乐客户端变灰歌曲 6 | 本版本为原项目的个人修改版本,不定期更新一些自己觉得OK的功能。 7 | 8 | ## 特性 9 | 10 | - 使用 QQ / 虾米 / 百度 / 酷狗 / 酷我 / 咪咕 / JOOX 音源替换变灰歌曲链接 (默认仅启用一、五、六) 11 | - 为请求增加 `X-Real-IP` 参数解锁海外限制,支持指定网易云服务器 IP,支持设置上游 HTTP / HTTPS 代理 12 | - 完整的流量代理功能 (HTTP / HTTPS),可直接作为系统代理 (同时支持 PAC) 13 | 14 | ## 运行 15 | 16 | 目前所有的可用环境变量:ENABLE_LOCAL_VIP,ENABLE_FLAC,ENABLE_HIRES,YOUTUBE_KEY,QQ_COOKIE,SIGN_KEY,SIGN_CERT 17 | 18 | PC版网易云需要同时启用https,示例如下,如果按例子运行,http代理端口应填23331 19 | 20 | ```bash 21 | node app.js -p 23331:23332 -o kugou bilibili 22 | ``` 23 | 24 | 使用 npx 25 | 26 | ``` 27 | $ npx @nondanee/unblockneteasemusic 28 | ``` 29 | 30 | 或使用 Docker 31 | 32 | ``` 33 | $ docker run nondanee/unblockneteasemusic 34 | ``` 35 | 36 | ``` 37 | $ docker-compose up 38 | ``` 39 | 40 | ### 配置参数 41 | 42 | ``` 43 | $ unblockneteasemusic -h 44 | usage: unblockneteasemusic [-v] [-p http[:https]] [-a address] [-u url] [-f host] 45 | [-o source [source ...]] [-t token] [-e url] [-s] 46 | [-h] 47 | 48 | optional arguments: 49 | -v, --version output the version number 50 | -p port, --port http[:https] specify server port 51 | -a address, --address address specify server host 52 | -u url, --proxy-url url request through upstream proxy 53 | -f host, --force-host host force the netease server ip 54 | -o source [source ...], --match-order source [source ...] 55 | set priority of sources 56 | -t token, --token token set up proxy authentication 57 | -e url, --endpoint url replace virtual endpoint with public host 58 | -s, --strict enable proxy limitation 59 | -h, --help output usage information 60 | ``` 61 | 62 | ### 音源清单 63 | 64 | 将有兴趣的音源代号用 `-o` 传入 UNM 即可使用,像这样: 65 | 66 | ```bash 67 | node app.js -o kugou bilibili 68 | ``` 69 | 70 | | 名称 | 代号 | 默认启用 | 注意事项 | 71 | | --------------------------- | ------------ | -------- | ------------------------------------------------------------------------------ | 72 | | QQ 音乐 | `qq` | ✅ | 需要准备自己的 `QQ_COOKIE`(请参阅下方〈环境变量〉处)。必须使用 QQ 登录。 | 73 | | 酷狗音乐 | `kugou` | | | 74 | | 酷我音乐 | `kuwo` | ✅ | | 75 | | 咪咕音乐 | `migu` | | 需要准备自己的 `MIGU_COOKIE`(请参阅下方〈环境变量〉处)。 | 76 | | JOOX | `joox` | | 需要准备自己的 `JOOX_COOKIE`(请参阅下方〈环境变量〉处)。似乎有严格地区限制。 | 77 | | YouTube(纯 JS 解析方式) | `youtube` | | 需要 Google 认定的**非中国大陆区域** IP 地址。 | 78 | | B 站音乐 | `bilibili` | ✅ | | 79 | | 第三方网易云 API | `pyncmd` | ✅ | | 80 | 81 | ### 环境变量 82 | 83 | | 变量名称 | 类型 | 描述 | 示例 | 84 | | ---------------------------- | ---- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | 85 | | ENABLE_FLAC | bool | 激活无损音质获取 | `ENABLE_FLAC=true` | 86 | | ENABLE_HIRES | bool | 激活Hi-Res音质获取 | `ENABLE_HIRES=true` | 87 | | ENABLE_LOCAL_VIP | str | 激活本地黑胶 VIP,可选值:`true`(等同于 CVIP)、`cvip` 和 `svip` | `ENABLE_LOCAL_VIP=svip` | 88 | | LOCAL_VIP_UID | str | 仅对这些 UID 激活本地黑胶 VIP,默认为对全部用户生效 | `LOCAL_VIP_UID=123456789,1234,123456` | 89 | | BLOCK_ADS | bool | 屏蔽应用内部分广告 | `BLOCK_ADS=true` | 90 | | DISABLE_UPGRADE_CHECK | bool | 禁用更新检测。 | `DISABLE_UPGRADE_CHECK=true` | 91 | | JOOX_COOKIE | str | JOOX 音源的 wmid 和 session_key cookie | `JOOX_COOKIE="wmid=; session_key="` | 92 | | MIGU_COOKIE | str | 咪咕音源的 aversionid cookie | `MIGU_COOKIE=""` | 93 | | QQ_COOKIE | str | QQ 音源的 uin 和 qm_keyst cookie | `QQ_COOKIE="uin=; qm_keyst="` | 94 | | YOUTUBE_KEY | str | Youtube 音源的 Data API v3 Key | `YOUTUBE_KEY=""` | 95 | | SIGN_CERT | path | 自定义证书文件 | `SIGN_CERT="./server.crt"` | 96 | | SIGN_KEY | path | 自定义密钥文件 | `SIGN_KEY="./server.key"` | 97 | 98 | ## 使用 99 | 100 | **警告:本项目不提供线上 demo,请不要轻易信任使用他人提供的公开代理服务,以免发生安全问题** 101 | 102 | **若将服务部署到公网,强烈建议使用严格模式 (此模式下仅放行网易云音乐所属域名的请求) `-s` 限制代理范围 (需使用 PAC 或 hosts),~~或启用 Proxy Authentication `-t :` 设置代理用户名密码~~ (目前密码认证在 Windows 客户端设置和 macOS 系统设置都无法生效,请不要使用),以防代理被他人滥用** 103 | 104 | 支持 Windows 客户端,UWP 客户端,Android 客户端,Linux 客户端 (1.2 版本以上需要自签证书 MITM,启动客户端需要增加 `--ignore-certificate-errors` 参数),macOS 客户端 (726 版本以上需要自签证书),iOS 客户端 (配置 https endpoint 或使用自签证书) 和网页版 (需要自签证书,需要脚本配合) 105 | 106 | 目前除 UWP 外其它客户端均优先请求 HTTPS 接口,~~默认配置下本代理对网易云所有 HTTPS API 连接返回空数据,促使客户端降级使用 HTTP 接口~~ (新版 Linux 客户端和 macOS 客户端已无法降级) 107 | 108 | 因 UWP 应用存在网络隔离,限制流量发送到本机,若使用的代理在 localhost,或修改的 hosts 指向 localhost,需为 "网易云音乐 UWP" 手动开启 loopback 才能使用,请以**管理员身份**执行命令 109 | 110 | ```powershell 111 | checknetisolation loopbackexempt -a -n="1F8B0F94.122165AE053F_j2p0p5q0044a6" 112 | ``` 113 | 114 | ### 方法 1. 修改 hosts 115 | 116 | 向 hosts 文件添加两条规则 117 | 118 | ``` 119 | music.163.com 120 | interface.music.163.com 121 | ``` 122 | 123 | > 使用此方法必须监听 80 端口 `-p 80` 124 | > 125 | > **若在本机运行程序**,请指定网易云服务器 IP `-f xxx.xxx.xxx.xxx` (可在修改 hosts 前通过 `ping music.163.com` 获得) **或** 使用代理 `-u http(s)://xxx.xxx.xxx.xxx:xxx`,以防请求死循环 126 | > 127 | > **Android 客户端下修改 hosts 无法直接使用**,原因和解决方法详见[云音乐安卓又搞事啦](https://jixun.moe/post/netease-android-hosts-bypass/),[安卓免 root 绕过网易云音乐 IP 限制](https://jixun.moe/post/android-block-netease-without-root/) 128 | 129 | ### 方法 2. 设置代理 130 | 131 | PAC 自动代理脚本地址 `http:///proxy.pac` 132 | 133 | 全局代理地址填写服务器地址和端口号即可 134 | 135 | | 平台 | 基础设置 | 136 | | :------ | :------------------------------- | 137 | | Windows | 设置 > 工具 > 自定义代理 (客户端内) | 138 | | UWP | Windows 设置 > 网络和 Internet > 代理 | 139 | | Linux | 系统设置 > 网络 > 网络代理 | 140 | | macOS | 系统偏好设置 > 网络 > 高级 > 代理 | 141 | | Android | WLAN > 修改网络 > 高级选项 > 代理 | 142 | | iOS | 无线局域网 > HTTP 代理 > 配置代理 | 143 | 144 | > 代理工具和方法有很多请自行探索,欢迎在 issues 讨论 145 | 146 | ### ✳方法 3. 调用接口 147 | 148 | 作为依赖库使用 149 | 150 | ``` 151 | $ npm install @nondanee/unblockneteasemusic 152 | ``` 153 | 154 | ```javascript 155 | const match = require('@nondanee/unblockneteasemusic') 156 | 157 | /** 158 | * Set proxy or hosts if needed 159 | */ 160 | global.proxy = require('url').parse('http://127.0.0.1:1080') 161 | global.hosts = {'i.y.qq.com': '59.37.96.220'} 162 | 163 | /** 164 | * Find matching song from other platforms 165 | * @param {Number} id netease song id 166 | * @param {Array||undefined} source support qq, xiami, baidu, kugou, kuwo, migu, joox 167 | * @return {Promise} 168 | */ 169 | match(418602084, ['qq', 'kuwo', 'migu']).then(console.log) 170 | ``` 171 | 172 | ## 效果 173 | 174 | #### Windows 客户端 175 | 176 | 177 | 178 | #### UWP 客户端 179 | 180 | 181 | 182 | #### Linux 客户端 183 | 184 | 185 | 186 | #### macOS 客户端 187 | 188 | 189 | 190 | #### Android 客户端 191 | 192 | 193 | 194 | #### iOS 客户端 195 | 196 | 197 | 198 | ## 致谢 199 | 200 | 感谢大佬们为逆向 eapi 所做的努力 201 | 202 | 使用的其它平台音源 API 出自 203 | 204 | [trazyn/ieaseMusic](https://github.com/trazyn/ieaseMusic) 205 | 206 | [listen1/listen1_chrome_extension](https://github.com/listen1/listen1_chrome_extension) 207 | 208 | 向所有同类项目致敬 209 | 210 | [EraserKing/CloudMusicGear](https://github.com/EraserKing/CloudMusicGear) 211 | 212 | [EraserKing/Unblock163MusicClient](https://github.com/EraserKing/Unblock163MusicClient) 213 | 214 | [ITJesse/UnblockNeteaseMusic](https://github.com/ITJesse/UnblockNeteaseMusic/) 215 | 216 | [bin456789/Unblock163MusicClient-Xposed](https://github.com/bin456789/Unblock163MusicClient-Xposed) 217 | 218 | [YiuChoi/Unlock163Music](https://github.com/YiuChoi/Unlock163Music) 219 | 220 | [yi-ji/NeteaseMusicAbroad](https://github.com/yi-ji/NeteaseMusicAbroad) 221 | 222 | [stomakun/NeteaseReverseLadder](https://github.com/stomakun/NeteaseReverseLadder/) 223 | 224 | [fengjueming/unblock-NetEaseMusic](https://github.com/fengjueming/unblock-NetEaseMusic) 225 | 226 | [acgotaku/NetEaseMusicWorld](https://github.com/acgotaku/NetEaseMusicWorld) 227 | 228 | [mengskysama/163-Cloud-Music-Unlock](https://github.com/mengskysama/163-Cloud-Music-Unlock) 229 | 230 | [azureplus/163-music-unlock](https://github.com/azureplus/163-music-unlock) 231 | 232 | [typcn/163music-mac-client-unlock](https://github.com/typcn/163music-mac-client-unlock) 233 | 234 | ## 许可 235 | 236 | The MIT License -------------------------------------------------------------------------------- /src/kwDES.js: -------------------------------------------------------------------------------- 1 | /* 2 | Thanks to 3 | https://github.com/XuShaohua/kwplayer/blob/master/kuwo/DES.py 4 | https://github.com/Levi233/MusicPlayer/blob/master/app/src/main/java/com/chenhao/musicplayer/utils/crypt/KuwoDES.java 5 | */ 6 | 7 | const Long = 8 | typeof BigInt === "function" // BigInt support in Node 10+ 9 | ? (n) => ( 10 | (n = BigInt(n)), 11 | { 12 | low: Number(n), 13 | valueOf: () => n.valueOf(), 14 | toString: () => n.toString(), 15 | not: () => Long(~n), 16 | isNegative: () => n < 0, 17 | or: (x) => Long(n | BigInt(x)), 18 | and: (x) => Long(n & BigInt(x)), 19 | xor: (x) => Long(n ^ BigInt(x)), 20 | equals: (x) => n === BigInt(x), 21 | multiply: (x) => Long(n * BigInt(x)), 22 | shiftLeft: (x) => Long(n << BigInt(x)), 23 | shiftRight: (x) => Long(n >> BigInt(x)), 24 | } 25 | ) 26 | : (...args) => new (require("long"))(...args); 27 | 28 | const range = (n) => Array.from(new Array(n).keys()); 29 | const power = (base, index) => 30 | Array(index) 31 | .fill() 32 | .reduce((result) => result.multiply(base), Long(1)); 33 | const LongArray = (...array) => 34 | array.map((n) => (n === -1 ? Long(-1, -1) : Long(n))); 35 | 36 | // EXPANSION 37 | const arrayE = LongArray( 38 | 31, 39 | 0, 40 | 1, 41 | 2, 42 | 3, 43 | 4, 44 | -1, 45 | -1, 46 | 3, 47 | 4, 48 | 5, 49 | 6, 50 | 7, 51 | 8, 52 | -1, 53 | -1, 54 | 7, 55 | 8, 56 | 9, 57 | 10, 58 | 11, 59 | 12, 60 | -1, 61 | -1, 62 | 11, 63 | 12, 64 | 13, 65 | 14, 66 | 15, 67 | 16, 68 | -1, 69 | -1, 70 | 15, 71 | 16, 72 | 17, 73 | 18, 74 | 19, 75 | 20, 76 | -1, 77 | -1, 78 | 19, 79 | 20, 80 | 21, 81 | 22, 82 | 23, 83 | 24, 84 | -1, 85 | -1, 86 | 23, 87 | 24, 88 | 25, 89 | 26, 90 | 27, 91 | 28, 92 | -1, 93 | -1, 94 | 27, 95 | 28, 96 | 29, 97 | 30, 98 | 31, 99 | 30, 100 | -1, 101 | -1 102 | ); 103 | 104 | // INITIAL_PERMUTATION 105 | const arrayIP = LongArray( 106 | 57, 107 | 49, 108 | 41, 109 | 33, 110 | 25, 111 | 17, 112 | 9, 113 | 1, 114 | 59, 115 | 51, 116 | 43, 117 | 35, 118 | 27, 119 | 19, 120 | 11, 121 | 3, 122 | 61, 123 | 53, 124 | 45, 125 | 37, 126 | 29, 127 | 21, 128 | 13, 129 | 5, 130 | 63, 131 | 55, 132 | 47, 133 | 39, 134 | 31, 135 | 23, 136 | 15, 137 | 7, 138 | 56, 139 | 48, 140 | 40, 141 | 32, 142 | 24, 143 | 16, 144 | 8, 145 | 0, 146 | 58, 147 | 50, 148 | 42, 149 | 34, 150 | 26, 151 | 18, 152 | 10, 153 | 2, 154 | 60, 155 | 52, 156 | 44, 157 | 36, 158 | 28, 159 | 20, 160 | 12, 161 | 4, 162 | 62, 163 | 54, 164 | 46, 165 | 38, 166 | 30, 167 | 22, 168 | 14, 169 | 6 170 | ); 171 | 172 | // INVERSE_PERMUTATION 173 | const arrayIP_1 = LongArray( 174 | 39, 175 | 7, 176 | 47, 177 | 15, 178 | 55, 179 | 23, 180 | 63, 181 | 31, 182 | 38, 183 | 6, 184 | 46, 185 | 14, 186 | 54, 187 | 22, 188 | 62, 189 | 30, 190 | 37, 191 | 5, 192 | 45, 193 | 13, 194 | 53, 195 | 21, 196 | 61, 197 | 29, 198 | 36, 199 | 4, 200 | 44, 201 | 12, 202 | 52, 203 | 20, 204 | 60, 205 | 28, 206 | 35, 207 | 3, 208 | 43, 209 | 11, 210 | 51, 211 | 19, 212 | 59, 213 | 27, 214 | 34, 215 | 2, 216 | 42, 217 | 10, 218 | 50, 219 | 18, 220 | 58, 221 | 26, 222 | 33, 223 | 1, 224 | 41, 225 | 9, 226 | 49, 227 | 17, 228 | 57, 229 | 25, 230 | 32, 231 | 0, 232 | 40, 233 | 8, 234 | 48, 235 | 16, 236 | 56, 237 | 24 238 | ); 239 | 240 | // ROTATES 241 | const arrayLs = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]; 242 | const arrayLsMask = LongArray(0, 0x100001, 0x300003); 243 | const arrayMask = range(64).map((n) => power(2, n)); 244 | arrayMask[arrayMask.length - 1] = arrayMask[arrayMask.length - 1].multiply(-1); 245 | 246 | // PERMUTATION 247 | const arrayP = LongArray( 248 | 15, 249 | 6, 250 | 19, 251 | 20, 252 | 28, 253 | 11, 254 | 27, 255 | 16, 256 | 0, 257 | 14, 258 | 22, 259 | 25, 260 | 4, 261 | 17, 262 | 30, 263 | 9, 264 | 1, 265 | 7, 266 | 23, 267 | 13, 268 | 31, 269 | 26, 270 | 2, 271 | 8, 272 | 18, 273 | 12, 274 | 29, 275 | 5, 276 | 21, 277 | 10, 278 | 3, 279 | 24 280 | ); 281 | 282 | // PERMUTED_CHOICE1 283 | const arrayPC_1 = LongArray( 284 | 56, 285 | 48, 286 | 40, 287 | 32, 288 | 24, 289 | 16, 290 | 8, 291 | 0, 292 | 57, 293 | 49, 294 | 41, 295 | 33, 296 | 25, 297 | 17, 298 | 9, 299 | 1, 300 | 58, 301 | 50, 302 | 42, 303 | 34, 304 | 26, 305 | 18, 306 | 10, 307 | 2, 308 | 59, 309 | 51, 310 | 43, 311 | 35, 312 | 62, 313 | 54, 314 | 46, 315 | 38, 316 | 30, 317 | 22, 318 | 14, 319 | 6, 320 | 61, 321 | 53, 322 | 45, 323 | 37, 324 | 29, 325 | 21, 326 | 13, 327 | 5, 328 | 60, 329 | 52, 330 | 44, 331 | 36, 332 | 28, 333 | 20, 334 | 12, 335 | 4, 336 | 27, 337 | 19, 338 | 11, 339 | 3 340 | ); 341 | 342 | // PERMUTED_CHOICE2 343 | const arrayPC_2 = LongArray( 344 | 13, 345 | 16, 346 | 10, 347 | 23, 348 | 0, 349 | 4, 350 | -1, 351 | -1, 352 | 2, 353 | 27, 354 | 14, 355 | 5, 356 | 20, 357 | 9, 358 | -1, 359 | -1, 360 | 22, 361 | 18, 362 | 11, 363 | 3, 364 | 25, 365 | 7, 366 | -1, 367 | -1, 368 | 15, 369 | 6, 370 | 26, 371 | 19, 372 | 12, 373 | 1, 374 | -1, 375 | -1, 376 | 40, 377 | 51, 378 | 30, 379 | 36, 380 | 46, 381 | 54, 382 | -1, 383 | -1, 384 | 29, 385 | 39, 386 | 50, 387 | 44, 388 | 32, 389 | 47, 390 | -1, 391 | -1, 392 | 43, 393 | 48, 394 | 38, 395 | 55, 396 | 33, 397 | 52, 398 | -1, 399 | -1, 400 | 45, 401 | 41, 402 | 49, 403 | 35, 404 | 28, 405 | 31, 406 | -1, 407 | -1 408 | ); 409 | 410 | const matrixNSBox = [ 411 | [ 412 | 14, 4, 3, 15, 2, 13, 5, 3, 13, 14, 6, 9, 11, 2, 0, 5, 4, 1, 10, 12, 15, 413 | 6, 9, 10, 1, 8, 12, 7, 8, 11, 7, 0, 0, 15, 10, 5, 14, 4, 9, 10, 7, 8, 414 | 12, 3, 13, 1, 3, 6, 15, 12, 6, 11, 2, 9, 5, 0, 4, 2, 11, 14, 1, 7, 8, 415 | 13, 416 | ], 417 | [ 418 | 15, 0, 9, 5, 6, 10, 12, 9, 8, 7, 2, 12, 3, 13, 5, 2, 1, 14, 7, 8, 11, 4, 419 | 0, 3, 14, 11, 13, 6, 4, 1, 10, 15, 3, 13, 12, 11, 15, 3, 6, 0, 4, 10, 1, 420 | 7, 8, 4, 11, 14, 13, 8, 0, 6, 2, 15, 9, 5, 7, 1, 10, 12, 14, 2, 5, 9, 421 | ], 422 | [ 423 | 10, 13, 1, 11, 6, 8, 11, 5, 9, 4, 12, 2, 15, 3, 2, 14, 0, 6, 13, 1, 3, 424 | 15, 4, 10, 14, 9, 7, 12, 5, 0, 8, 7, 13, 1, 2, 4, 3, 6, 12, 11, 0, 13, 425 | 5, 14, 6, 8, 15, 2, 7, 10, 8, 15, 4, 9, 11, 5, 9, 0, 14, 3, 10, 7, 1, 426 | 12, 427 | ], 428 | [ 429 | 7, 10, 1, 15, 0, 12, 11, 5, 14, 9, 8, 3, 9, 7, 4, 8, 13, 6, 2, 1, 6, 11, 430 | 12, 2, 3, 0, 5, 14, 10, 13, 15, 4, 13, 3, 4, 9, 6, 10, 1, 12, 11, 0, 2, 431 | 5, 0, 13, 14, 2, 8, 15, 7, 4, 15, 1, 10, 7, 5, 6, 12, 11, 3, 8, 9, 14, 432 | ], 433 | [ 434 | 2, 4, 8, 15, 7, 10, 13, 6, 4, 1, 3, 12, 11, 7, 14, 0, 12, 2, 5, 9, 10, 435 | 13, 0, 3, 1, 11, 15, 5, 6, 8, 9, 14, 14, 11, 5, 6, 4, 1, 3, 10, 2, 12, 436 | 15, 0, 13, 2, 8, 5, 11, 8, 0, 15, 7, 14, 9, 4, 12, 7, 10, 9, 1, 13, 6, 437 | 3, 438 | ], 439 | [ 440 | 12, 9, 0, 7, 9, 2, 14, 1, 10, 15, 3, 4, 6, 12, 5, 11, 1, 14, 13, 0, 2, 441 | 8, 7, 13, 15, 5, 4, 10, 8, 3, 11, 6, 10, 4, 6, 11, 7, 9, 0, 6, 4, 2, 13, 442 | 1, 9, 15, 3, 8, 15, 3, 1, 14, 12, 5, 11, 0, 2, 12, 14, 7, 5, 10, 8, 13, 443 | ], 444 | [ 445 | 4, 1, 3, 10, 15, 12, 5, 0, 2, 11, 9, 6, 8, 7, 6, 9, 11, 4, 12, 15, 0, 3, 446 | 10, 5, 14, 13, 7, 8, 13, 14, 1, 2, 13, 6, 14, 9, 4, 1, 2, 14, 11, 13, 5, 447 | 0, 1, 10, 8, 3, 0, 11, 3, 5, 9, 4, 15, 2, 7, 8, 12, 15, 10, 7, 6, 12, 448 | ], 449 | [ 450 | 13, 7, 10, 0, 6, 9, 5, 15, 8, 4, 3, 10, 11, 14, 12, 5, 2, 11, 9, 6, 15, 451 | 12, 0, 3, 4, 1, 14, 13, 1, 2, 7, 8, 1, 2, 12, 15, 10, 4, 0, 3, 13, 14, 452 | 6, 9, 7, 8, 9, 6, 15, 1, 5, 12, 3, 10, 14, 5, 8, 7, 11, 0, 4, 13, 2, 11, 453 | ], 454 | ]; 455 | 456 | const bitTransform = (arrInt, n, l) => { 457 | // int[], int, long : long 458 | let l2 = Long(0); 459 | range(n).forEach((i) => { 460 | if (arrInt[i].isNegative() || l.and(arrayMask[arrInt[i].low]).equals(0)) 461 | return; 462 | l2 = l2.or(arrayMask[i]); 463 | }); 464 | return l2; 465 | }; 466 | 467 | const DES64 = (longs, l) => { 468 | // long[], long 469 | let out = Long(0); 470 | let SOut = Long(0); 471 | const pR = range(8).map(() => Long(0)); 472 | const pSource = [Long(0), Long(0)]; 473 | let L = Long(0); 474 | let R = Long(0); 475 | out = bitTransform(arrayIP, 64, l); 476 | pSource[0] = out.and(0xffffffff); 477 | pSource[1] = out.and(-4294967296).shiftRight(32); 478 | 479 | range(16).forEach((i) => { 480 | R = Long(pSource[1]); 481 | R = bitTransform(arrayE, 64, R); 482 | R = R.xor(longs[i]); 483 | range(8).forEach((j) => { 484 | pR[j] = R.shiftRight(j * 8).and(255); 485 | }); 486 | SOut = Long(0); 487 | range(8) 488 | .reverse() 489 | .forEach((sbi) => { 490 | SOut = SOut.shiftLeft(4).or(matrixNSBox[sbi][pR[sbi]]); 491 | }); 492 | R = bitTransform(arrayP, 32, SOut); 493 | L = Long(pSource[0]); 494 | pSource[0] = Long(pSource[1]); 495 | pSource[1] = L.xor(R); 496 | }); 497 | pSource.reverse(); 498 | out = pSource[1] 499 | .shiftLeft(32) 500 | .and(-4294967296) 501 | .or(pSource[0].and(0xffffffff)); 502 | out = bitTransform(arrayIP_1, 64, out); 503 | return out; 504 | }; 505 | 506 | const subKeys = (l, longs, n) => { 507 | // long, long[], int 508 | let l2 = bitTransform(arrayPC_1, 56, l); 509 | range(16).forEach((i) => { 510 | l2 = l2 511 | .and(arrayLsMask[arrayLs[i]]) 512 | .shiftLeft(28 - arrayLs[i]) 513 | .or(l2.and(arrayLsMask[arrayLs[i]].not()).shiftRight(arrayLs[i])); 514 | longs[i] = bitTransform(arrayPC_2, 64, l2); 515 | }); 516 | if (n === 1) { 517 | range(8).forEach((j) => { 518 | [longs[j], longs[15 - j]] = [longs[15 - j], longs[j]]; 519 | }); 520 | } 521 | }; 522 | 523 | const crypt = (msg, key, mode) => { 524 | // 处理密钥块 525 | let l = Long(0); 526 | range(8).forEach((i) => { 527 | l = Long(key[i]) 528 | .shiftLeft(i * 8) 529 | .or(l); 530 | }); 531 | 532 | const j = Math.floor(msg.length / 8); 533 | // arrLong1 存放的是转换后的密钥块, 在解密时只需要把这个密钥块反转就行了 534 | 535 | const arrLong1 = range(16).map(() => Long(0)); 536 | subKeys(l, arrLong1, mode); 537 | 538 | // arrLong2 存放的是前部分的明文 539 | const arrLong2 = range(j).map(() => Long(0)); 540 | 541 | range(j).forEach((m) => { 542 | range(8).forEach((n) => { 543 | arrLong2[m] = Long(msg[n + m * 8]) 544 | .shiftLeft(n * 8) 545 | .or(arrLong2[m]); 546 | }); 547 | }); 548 | 549 | // 用于存放密文 550 | const arrLong3 = range(Math.floor((1 + 8 * (j + 1)) / 8)).map(() => 551 | Long(0) 552 | ); 553 | 554 | // 计算前部的数据块(除了最后一部分) 555 | range(j).forEach((i1) => { 556 | arrLong3[i1] = DES64(arrLong1, arrLong2[i1]); 557 | }); 558 | 559 | // 保存多出来的字节 560 | const arrByte1 = msg.slice(j * 8); 561 | let l2 = Long(0); 562 | 563 | range(msg.length % 8).forEach((i1) => { 564 | l2 = Long(arrByte1[i1]) 565 | .shiftLeft(i1 * 8) 566 | .or(l2); 567 | }); 568 | 569 | // 计算多出的那一位(最后一位) 570 | if (arrByte1.length || mode === 0) arrLong3[j] = DES64(arrLong1, l2); // 解密不需要 571 | 572 | // 将密文转为字节型 573 | const arrByte2 = range(8 * arrLong3.length).map(() => 0); 574 | let i4 = 0; 575 | arrLong3.forEach((l3) => { 576 | range(8).forEach((i6) => { 577 | arrByte2[i4] = l3.shiftRight(i6 * 8).and(255).low; 578 | i4 += 1; 579 | }); 580 | }); 581 | return Buffer.from(arrByte2); 582 | }; 583 | 584 | const SecretCalc = (buffer, key) => { 585 | if (null == key || key.length <= 0) { 586 | console.log( 587 | "Key is null or empty. Please choose a password with 1 or more characters." 588 | ); 589 | return null; 590 | } 591 | var n = ""; 592 | for (var i = 0; i < key.length; i++) { 593 | n += key.charCodeAt(i).toString(); 594 | } 595 | var r = Math.floor(n.length / 5), 596 | o = parseInt( 597 | n.charAt(r) + 598 | n.charAt(2 * r) + 599 | n.charAt(3 * r) + 600 | n.charAt(4 * r) + 601 | n.charAt(5 * r) 602 | ), 603 | l = Math.ceil(key.length / 2), 604 | c = Math.pow(2, 31) - 1; 605 | if (o < 2) { 606 | console.log( 607 | "Algorithm cannot find a suitable hash. Please choose a password with 8 or more characters." 608 | ); 609 | return null; 610 | } 611 | var d = Math.round(1e9 * Math.random()) % 1e8; 612 | n += d; 613 | while (n.length > 10) { 614 | n = ( 615 | parseInt(n.substring(0, 10)) + parseInt(n.substring(10, n.length)) 616 | ).toString(); 617 | } 618 | n = (o * n + l) % c; 619 | var h = "", 620 | f = ""; 621 | for (i = 0; i < buffer.length; i++) { 622 | f += 623 | (h = parseInt(buffer.charCodeAt(i) ^ Math.floor((n / c) * 255))) < 624 | 16 625 | ? "0" + h.toString(16) 626 | : h.toString(16); 627 | n = (o * n + l) % c; 628 | } 629 | d = d.toString(16); 630 | while (d.length < 8) { 631 | d = "0" + d; 632 | } 633 | return f + d; 634 | }; 635 | 636 | const SECRET_KEY = Buffer.from("ylzsxkwm"); 637 | const encrypt = (msg) => crypt(msg, SECRET_KEY, 0); 638 | const decrypt = (msg) => crypt(msg, SECRET_KEY, 1); 639 | const encryptQuery = (query) => encrypt(Buffer.from(query)).toString("base64"); 640 | 641 | module.exports = { encrypt, decrypt, encryptQuery, SecretCalc }; 642 | -------------------------------------------------------------------------------- /src/hook.js: -------------------------------------------------------------------------------- 1 | const cache = require("./cache"); 2 | const parse = require("url").parse; 3 | const crypto = require("./crypto"); 4 | const request = require("./request"); 5 | const match = require("./provider/match"); 6 | const querystring = require("querystring"); 7 | 8 | const ENABLE_LOCAL_VIP = ["true", "cvip", "svip"].includes( 9 | (process.env.ENABLE_LOCAL_VIP || "").toLowerCase() 10 | ); 11 | const ENABLE_LOCAL_SVIP = 12 | (process.env.ENABLE_LOCAL_VIP || "").toLowerCase() === "svip"; 13 | const LOCAL_VIP_UID = (process.env.LOCAL_VIP_UID || "") 14 | .split(",") 15 | .map((str) => parseInt(str)) 16 | .filter((num) => !Number.isNaN(num)); 17 | const HIRES = (process.env.ENABLE_HIRES || "").toLowerCase() === "true"; 18 | const BLOCK_ADS = (process.env.BLOCK_ADS || "").toLowerCase() === "true"; 19 | const DISABLE_UPGRADE_CHECK = 20 | (process.env.DISABLE_UPGRADE_CHECK || "").toLowerCase() === "true"; 21 | 22 | const hook = { 23 | request: { 24 | before: () => {}, 25 | after: () => {}, 26 | }, 27 | connect: { 28 | before: () => {}, 29 | }, 30 | negotiate: { 31 | before: () => {}, 32 | }, 33 | target: { 34 | host: new Set(), 35 | path: new Set(), 36 | }, 37 | }; 38 | 39 | hook.target.host = new Set([ 40 | "music.163.com", 41 | "interface.music.163.com", 42 | "interface3.music.163.com", 43 | "apm.music.163.com", 44 | "apm3.music.163.com", 45 | // 'mp.music.163.com', 46 | // 'api2.music.163.com', 47 | // 'st.music.163.com', 48 | // 'mam.netease.com', 49 | // 'api.iplay.163.com', // look living 50 | // 'ac.dun.163yun.com', 51 | // 'crash.163.com', 52 | // 'clientlog.music.163.com', 53 | // 'clientlog3.music.163.com' 54 | ]); 55 | 56 | hook.target.path = new Set([ 57 | "/api/v3/playlist/detail", 58 | "/api/v3/song/detail", 59 | "/api/v6/playlist/detail", 60 | "/api/album/play", 61 | "/api/artist/privilege", 62 | "/api/album/privilege", 63 | "/api/v1/artist", 64 | "/api/v1/artist/songs", 65 | "/api/artist/top/song", 66 | "/api/v1/album", 67 | "/api/album/v3/detail", 68 | "/api/playlist/privilege", 69 | "/api/song/enhance/player/url", 70 | "/api/song/enhance/player/url/v1", 71 | "/api/song/enhance/download/url", 72 | "/api/song/enhance/download/url/v1", 73 | "/api/song/enhance/privilege", 74 | "/api/ad", 75 | "/batch", 76 | "/api/batch", 77 | "/api/listen/together/privilege/get", 78 | "/api/v1/search/get", 79 | "/api/v1/search/song/get", 80 | "/api/search/complex/get", 81 | "/api/search/complex/page", 82 | "/api/search/song/page", 83 | "/api/cloudsearch/pc", 84 | "/api/v1/playlist/manipulate/tracks", 85 | "/api/song/like", 86 | "/api/v1/play/record", 87 | "/api/playlist/v4/detail", 88 | "/api/v1/radio/get", 89 | "/api/v1/discovery/recommend/songs", 90 | "/api/vipauth/app/auth/query", 91 | "/api/music-vip-membership/client/vip/info", 92 | ]); 93 | 94 | const domainList = [ 95 | "music.163.com", 96 | "music.126.net", 97 | "iplay.163.com", 98 | "look.163.com", 99 | "y.163.com", 100 | ]; 101 | 102 | hook.request.before = (ctx) => { 103 | const { req } = ctx; 104 | req.url = 105 | (req.url.startsWith("http://") 106 | ? "" 107 | : (req.socket.encrypted ? "https:" : "http:") + 108 | "//" + 109 | (domainList.some((domain) => 110 | (req.headers.host || "").endsWith(domain) 111 | ) 112 | ? req.headers.host 113 | : null)) + req.url; 114 | const url = parse(req.url); 115 | if ( 116 | [url.hostname, req.headers.host].some((host) => 117 | host.includes("music.163.com") 118 | ) 119 | ) 120 | ctx.decision = "proxy"; 121 | if ( 122 | [url.hostname, req.headers.host].some((host) => 123 | hook.target.host.has(host) 124 | ) && 125 | req.method == "POST" && 126 | (url.path == "/api/linux/forward" || url.path.startsWith("/eapi/")) 127 | ) { 128 | return request 129 | .read(req) 130 | .then((body) => (req.body = body)) 131 | .then((body) => { 132 | if ("x-napm-retry" in req.headers) 133 | delete req.headers["x-napm-retry"]; 134 | req.headers["X-Real-IP"] = "119.233.233.233"; 135 | req.headers["Accept-Encoding"] = "gzip, deflate"; // https://blog.csdn.net/u013022222/article/details/51707352 136 | if (req.url.includes("stream")) return; // look living eapi can not be decrypted 137 | if (body) { 138 | let data = null; 139 | const netease = {}; 140 | netease.pad = (body.match(/%0+$/) || [""])[0]; 141 | netease.forward = url.path == "/api/linux/forward"; 142 | if (netease.forward) { 143 | data = JSON.parse( 144 | crypto.linuxapi 145 | .decrypt( 146 | Buffer.from( 147 | body.slice( 148 | 8, 149 | body.length - netease.pad.length 150 | ), 151 | "hex" 152 | ) 153 | ) 154 | .toString() 155 | ); 156 | netease.path = parse(data.url).path; 157 | netease.param = data.params; 158 | } else { 159 | data = crypto.eapi 160 | .decrypt( 161 | Buffer.from( 162 | body.slice( 163 | 7, 164 | body.length - netease.pad.length 165 | ), 166 | "hex" 167 | ) 168 | ) 169 | .toString() 170 | .split("-36cd479b6b5-"); 171 | netease.path = data[0]; 172 | netease.param = JSON.parse(data[1]); 173 | } 174 | netease.path = netease.path.replace(/\/\d*$/, ""); 175 | ctx.netease = netease; 176 | // console.log(netease.path, netease.param) 177 | 178 | if (netease.path === "/api/song/enhance/download/url") 179 | return pretendPlay(ctx); 180 | else if ( 181 | netease.path === "/api/song/enhance/download/url/v1" 182 | ) 183 | return pretendPlayV1(ctx); 184 | else if ( 185 | HIRES && 186 | (netease.path === "/api/song/enhance/player/url" || 187 | netease.path === "/api/song/enhance/player/url/v1") 188 | ) 189 | return pretendDownload(ctx); 190 | 191 | if (BLOCK_ADS) { 192 | if (netease.path.startsWith("/api/ad")) { 193 | ctx.error = new Error("ADs blocked."); 194 | ctx.decision = "close"; 195 | } 196 | } 197 | 198 | if (DISABLE_UPGRADE_CHECK) { 199 | if ( 200 | netease.path.match( 201 | /^\/api(\/v1)?\/(android|ios|osx|pc)\/(upgrade|version)/ 202 | ) 203 | ) { 204 | ctx.error = new Error("Upgrade check blocked."); 205 | ctx.decision = "close"; 206 | } 207 | } 208 | } 209 | }) 210 | .catch((error) => console.log(error, req.url)); 211 | } else if ( 212 | hook.target.host.has(url.hostname) && 213 | (url.path.startsWith("/weapi/") || url.path.startsWith("/api/")) 214 | ) { 215 | req.headers["X-Real-IP"] = "119.233.233.233"; 216 | req.headers["Accept-Encoding"] = "deflate"; // https://blog.csdn.net/u013022222/article/details/51707352 217 | ctx.netease = { 218 | web: true, 219 | path: url.path 220 | .replace(/^\/weapi\//, "/api/") 221 | .replace(/\?.+$/, "") 222 | .replace(/\/\d*$/, ""), 223 | }; 224 | } else if (req.url.includes("package")) { 225 | try { 226 | const data = req.url.split("package/").pop().split("/"); 227 | const url = parse(crypto.base64.decode(data[0])); 228 | const id = data[1].replace(/\.\w+/, ""); 229 | req.url = url.href; 230 | req.headers["host"] = url.hostname; 231 | req.headers["cookie"] = null; 232 | ctx.package = { 233 | id, 234 | }; 235 | ctx.decision = "proxy"; 236 | // if (url.href.includes('google')) 237 | // return request('GET', req.url, req.headers, null, parse('http://127.0.0.1:1080')) 238 | // .then(response => (ctx.res.writeHead(response.statusCode, response.headers), response.pipe(ctx.res))) 239 | } catch (error) { 240 | ctx.error = error; 241 | ctx.decision = "close"; 242 | } 243 | } 244 | }; 245 | 246 | hook.request.after = (ctx) => { 247 | const { req, proxyRes, netease, package } = ctx; 248 | if ( 249 | req.headers.host === "tyst.migu.cn" && 250 | proxyRes.headers["content-range"] && 251 | proxyRes.statusCode === 200 252 | ) 253 | proxyRes.statusCode = 206; 254 | if ( 255 | netease && 256 | hook.target.path.has(netease.path) && 257 | proxyRes.statusCode == 200 258 | ) { 259 | return request 260 | .read(proxyRes, true) 261 | .then((buffer) => 262 | buffer.length ? (proxyRes.body = buffer) : Promise.reject() 263 | ) 264 | .then((buffer) => { 265 | const patch = (string) => 266 | string.replace( 267 | /([^\\]"\s*:\s*)(\d{16,})(\s*[}|,])/g, 268 | '$1"$2L"$3' 269 | ); // for js precision 270 | try { 271 | netease.encrypted = false; 272 | netease.jsonBody = JSON.parse(patch(buffer.toString())); 273 | } catch (error) { 274 | netease.encrypted = true; 275 | netease.jsonBody = JSON.parse( 276 | patch(crypto.eapi.decrypt(buffer).toString()) 277 | ); 278 | } 279 | 280 | if (ENABLE_LOCAL_VIP) { 281 | const vipPath = "/api/music-vip-membership/client/vip/info"; 282 | if ( 283 | netease.path === "/batch" || 284 | netease.path === "/api/batch" || 285 | netease.path === vipPath 286 | ) { 287 | const info = 288 | netease.path === vipPath 289 | ? netease.jsonBody 290 | : netease.jsonBody[vipPath]; 291 | const defaultPackage = { 292 | iconUrl: null, 293 | dynamicIconUrl: null, 294 | isSign: false, 295 | isSignIap: false, 296 | isSignDeduct: false, 297 | isSignIapDeduct: false, 298 | }; 299 | const vipLevel = 7; // ? months 300 | if ( 301 | info && 302 | (LOCAL_VIP_UID.length === 0 || 303 | LOCAL_VIP_UID.includes(info.data.userId)) 304 | ) { 305 | try { 306 | const expireTime = info.data.now + 31622400000; 307 | info.data.redVipLevel = vipLevel; 308 | info.data.redVipAnnualCount = 1; 309 | 310 | info.data.musicPackage = { 311 | ...defaultPackage, 312 | ...info.data.musicPackage, 313 | vipCode: 230, 314 | vipLevel: vipLevel, 315 | expireTime, 316 | }; 317 | 318 | info.data.associator = { 319 | ...defaultPackage, 320 | ...info.data.associator, 321 | vipCode: 100, 322 | vipLevel: vipLevel, 323 | expireTime, 324 | }; 325 | 326 | if (ENABLE_LOCAL_SVIP) { 327 | info.data.redplus = { 328 | ...defaultPackage, 329 | ...info.data.redplus, 330 | vipCode: 300, 331 | vipLevel: vipLevel, 332 | expireTime, 333 | }; 334 | 335 | info.data.albumVip = { 336 | ...defaultPackage, 337 | ...info.data.albumVip, 338 | vipCode: 400, 339 | vipLevel: 0, 340 | expireTime, 341 | }; 342 | } 343 | 344 | if (netease.path === vipPath) 345 | netease.jsonBody = info; 346 | else netease.jsonBody[vipPath] = info; 347 | } catch (error) {} 348 | } 349 | } 350 | } 351 | if ( 352 | new Set([401, 512]).has(netease.jsonBody.code) && 353 | !netease.web 354 | ) { 355 | if (netease.path.includes("manipulate")) 356 | return tryCollect(ctx); 357 | else if (netease.path == "/api/song/like") 358 | return tryLike(ctx); 359 | } else if (netease.path.includes("url")) return tryMatch(ctx); 360 | else if (netease.path.includes("/usertool/sound/")) 361 | return unblockSoundEffects(netease.jsonBody); 362 | else if (netease.path.includes("batch")) { 363 | for (const key in netease.jsonBody) { 364 | if (key.includes("/usertool/sound/")) 365 | unblockSoundEffects(netease.jsonBody[key]); 366 | } 367 | } else if (netease.path.includes("/vipauth/app/auth/query")) 368 | return unblockLyricsEffects(netease.jsonBody); 369 | }) 370 | .then(() => { 371 | ["transfer-encoding", "content-encoding", "content-length"] 372 | .filter((key) => key in proxyRes.headers) 373 | .forEach((key) => delete proxyRes.headers[key]); 374 | 375 | const inject = (key, value) => { 376 | if (typeof value === "object" && value != null) { 377 | if ("cp" in value) value["cp"] = 1; 378 | if ("fee" in value) value["fee"] = 0; 379 | if ( 380 | "dl" in value && 381 | "downloadMaxbr" in value && 382 | value["dl"] < value["downloadMaxbr"] 383 | ) 384 | value["dl"] = value["downloadMaxbr"]; 385 | if ( 386 | "pl" in value && 387 | "playMaxbr" in value && 388 | value["pl"] < value["playMaxbr"] 389 | ) 390 | value["pl"] = value["playMaxbr"]; 391 | if ( 392 | "fl" in value && 393 | "maxbr" in value && 394 | value["fl"] < value["maxbr"] 395 | ) 396 | value["fl"] = value["maxbr"]; 397 | if (HIRES) { 398 | if ( 399 | "dlLevel" in value && 400 | "downloadMaxBrLevel" in value && 401 | value["downloadMaxBrLevel"] !== "none" 402 | ) 403 | value["dlLevel"] = value["downloadMaxBrLevel"]; 404 | if ( 405 | "plLevel" in value && 406 | "playMaxBrLevel" in value && 407 | value["playMaxBrLevel"] !== "none" 408 | ) 409 | value["plLevel"] = value["playMaxBrLevel"]; 410 | if ( 411 | "flLevel" in value && 412 | "maxBrLevel" in value && 413 | value["maxBrLevel"] !== "none" 414 | ) 415 | value["flLevel"] = value["maxBrLevel"]; 416 | } 417 | if ("sp" in value && "st" in value && "subp" in value) { 418 | // batch modify 419 | value["sp"] = 7; 420 | value["st"] = 0; 421 | value["subp"] = 1; 422 | } 423 | if ( 424 | "start" in value && 425 | "end" in value && 426 | "playable" in value && 427 | "unplayableType" in value && 428 | "unplayableUserIds" in value 429 | ) { 430 | value["start"] = 0; 431 | value["end"] = 0; 432 | value["playable"] = true; 433 | value["unplayableType"] = "unknown"; 434 | value["unplayableUserIds"] = []; 435 | } 436 | if ("noCopyrightRcmd" in value) 437 | value["noCopyrightRcmd"] = null; 438 | if ("payed" in value && value["payed"] == 0) 439 | value["payed"] = 1; 440 | if ("flLevel" in value && value["flLevel"] === "none") 441 | value["flLevel"] = "exhigh"; 442 | if ("plLevel" in value && value["plLevel"] === "none") 443 | value["plLevel"] = "exhigh"; 444 | if ("dlLevel" in value && value["dlLevel"] === "none") 445 | value["dlLevel"] = "exhigh"; 446 | } 447 | return value; 448 | }; 449 | 450 | let body = JSON.stringify(netease.jsonBody, inject); 451 | body = body.replace( 452 | /([^\\]"\s*:\s*)"(\d{16,})L"(\s*[}|,])/g, 453 | "$1$2$3" 454 | ); // for js precision 455 | proxyRes.body = netease.encrypted 456 | ? crypto.eapi.encrypt(Buffer.from(body)) 457 | : body; 458 | }) 459 | .catch((error) => (error ? console.log(error, req.url) : null)); 460 | } else if (package) { 461 | if (new Set([201, 301, 302, 303, 307, 308]).has(proxyRes.statusCode)) { 462 | return request( 463 | req.method, 464 | parse(req.url).resolve(proxyRes.headers.location), 465 | req.headers 466 | ).then((response) => (ctx.proxyRes = response)); 467 | } else if (/p\d+c*.music.126.net/.test(req.url)) { 468 | proxyRes.headers["content-type"] = "audio/*"; 469 | } 470 | } 471 | }; 472 | 473 | hook.connect.before = (ctx) => { 474 | const { req } = ctx; 475 | const url = parse("https://" + req.url); 476 | if ( 477 | [url.hostname, req.headers.host].some((host) => 478 | hook.target.host.has(host) 479 | ) 480 | ) { 481 | if (url.port == 80) { 482 | req.url = `${global.address || "localhost"}:${global.port[0]}`; 483 | req.local = true; 484 | } else if (global.port[1]) { 485 | req.url = `${global.address || "localhost"}:${global.port[1]}`; 486 | req.local = true; 487 | } else { 488 | ctx.decision = "blank"; 489 | } 490 | } else if (url.href.includes(global.endpoint)) ctx.decision = "proxy"; 491 | }; 492 | 493 | hook.negotiate.before = (ctx) => { 494 | const { req, socket, decision } = ctx; 495 | const url = parse("https://" + req.url); 496 | const target = hook.target.host; 497 | if (req.local || decision) return; 498 | if (target.has(socket.sni) && !target.has(url.hostname)) { 499 | target.add(url.hostname); 500 | ctx.decision = "blank"; 501 | } 502 | }; 503 | 504 | const pretendPlay = (ctx) => { 505 | //Deprecated 506 | const { req, netease } = ctx; 507 | const turn = "http://music.163.com/api/song/enhance/player/url"; 508 | let query = null; 509 | if (netease.forward) { 510 | const { id, br } = netease.param; 511 | netease.param = { 512 | ids: `["${id}"]`, 513 | br, 514 | }; 515 | query = crypto.linuxapi.encryptRequest(turn, netease.param); 516 | } else { 517 | const { id, br, e_r, header } = netease.param; 518 | netease.param = { 519 | ids: `["${id}"]`, 520 | br, 521 | e_r, 522 | header, 523 | }; 524 | query = crypto.eapi.encryptRequest(turn, netease.param); 525 | } 526 | req.url = query.url; 527 | req.body = query.body + netease.pad; 528 | }; 529 | 530 | const pretendPlayV1 = (ctx) => { 531 | const { req, netease } = ctx; 532 | const turn = "http://music.163.com/api/song/enhance/player/url/v1"; 533 | let query; 534 | if (netease.forward) { 535 | const { id, level, immerseType } = netease.param; 536 | netease.param = { 537 | ids: `["${id}"]`, 538 | level, 539 | encodeType: "flac", 540 | immerseType, 541 | }; 542 | query = crypto.linuxapi.encryptRequest(turn, netease.param); 543 | } else { 544 | const { id, level, immerseType, e_r, header } = netease.param; 545 | netease.param = { 546 | ids: `["${id}"]`, 547 | level, 548 | encodeType: "flac", 549 | immerseType, 550 | e_r, 551 | header, 552 | }; 553 | query = crypto.eapi.encryptRequest(turn, netease.param); 554 | } 555 | req.url = query.url; 556 | req.body = query.body + netease.pad; 557 | }; 558 | 559 | const pretendDownload = (ctx) => { 560 | const { req, netease } = ctx; 561 | const turn = "https://music.163.com/api/song/enhance/download/url/v1"; 562 | let query = null; 563 | if (netease.forward) { 564 | const { ids, br } = netease.param; 565 | netease.param = { 566 | id: JSON.parse(ids)[0], 567 | br, 568 | }; 569 | query = crypto.linuxapi.encryptRequest(turn, netease.param); 570 | } else { 571 | const { ids, level, e_r, header } = netease.param; 572 | netease.param = { 573 | id: JSON.parse(ids)[0], 574 | level, 575 | e_r, 576 | header, 577 | }; 578 | query = crypto.eapi.encryptRequest(turn, netease.param); 579 | } 580 | req.url = query.url; 581 | req.body = query.body + netease.pad; 582 | }; 583 | 584 | const tryCollect = (ctx) => { 585 | const { req, netease } = ctx; 586 | const { trackIds, pid, op } = netease.param; 587 | const trackId = ( 588 | Array.isArray(trackIds) ? trackIds : JSON.parse(trackIds) 589 | )[0]; 590 | return request( 591 | "POST", 592 | "http://music.163.com/api/playlist/manipulate/tracks", 593 | req.headers, 594 | `trackIds=[${trackId},${trackId}]&pid=${pid}&op=${op}` 595 | ) 596 | .then((response) => response.json()) 597 | .then((jsonBody) => { 598 | netease.jsonBody = jsonBody; 599 | }) 600 | .catch(() => {}); 601 | }; 602 | 603 | const tryLike = (ctx) => { 604 | const { req, netease } = ctx; 605 | const { trackId } = netease.param; 606 | let pid = 0, 607 | userId = 0; 608 | return request("GET", "http://music.163.com/api/v1/user/info", req.headers) 609 | .then((response) => response.json()) 610 | .then((jsonBody) => { 611 | userId = jsonBody.userPoint.userId; 612 | return request( 613 | "GET", 614 | `http://music.163.com/api/user/playlist?uid=${userId}&limit=1`, 615 | req.headers 616 | ).then((response) => response.json()); 617 | }) 618 | .then((jsonBody) => { 619 | pid = jsonBody.playlist[0].id; 620 | return request( 621 | "POST", 622 | "http://music.163.com/api/playlist/manipulate/tracks", 623 | req.headers, 624 | `trackIds=[${trackId},${trackId}]&pid=${pid}&op=add` 625 | ).then((response) => response.json()); 626 | }) 627 | .then((jsonBody) => { 628 | if (new Set([200, 502]).has(jsonBody.code)) { 629 | netease.jsonBody = { 630 | code: 200, 631 | playlistId: pid, 632 | }; 633 | } 634 | }) 635 | .catch(() => {}); 636 | }; 637 | 638 | const computeHash = (task) => 639 | request("GET", task.url).then((response) => crypto.md5.pipe(response)); 640 | 641 | const tryMatch = (ctx) => { 642 | const { req, netease } = ctx; 643 | const { jsonBody } = netease; 644 | let tasks = [], 645 | target = 0; 646 | 647 | const inject = (item) => { 648 | item.flag = 0; 649 | if ( 650 | (item.code != 200 || item.freeTrialInfo) && 651 | (target == 0 || item.id == target) 652 | ) { 653 | return match(item.id) 654 | .then((song) => { 655 | item.type = song.br === 999000 ? "flac" : "mp3"; 656 | item.url = global.endpoint 657 | ? `${global.endpoint}/package/${crypto.base64.encode( 658 | song.url 659 | )}/${item.id}.${item.type}` 660 | : song.url; 661 | item.md5 = song.md5 || crypto.md5.digest(song.url); 662 | item.br = song.br || 128000; 663 | item.size = song.size; 664 | item.code = 200; 665 | item.freeTrialInfo = null; 666 | return song; 667 | }) 668 | .then((song) => { 669 | if (!netease.path.includes("download") || song.md5) return; 670 | const newer = (base, target) => { 671 | const difference = Array.from([base, target]) 672 | .map((version) => 673 | version 674 | .split(".") 675 | .slice(0, 3) 676 | .map((number) => parseInt(number) || 0) 677 | ) 678 | .reduce( 679 | (aggregation, current) => 680 | !aggregation.length 681 | ? current.map((element) => [element]) 682 | : aggregation.map((element, index) => 683 | element.concat(current[index]) 684 | ), 685 | [] 686 | ) 687 | .filter((pair) => pair[0] != pair[1])[0]; 688 | return !difference || difference[0] <= difference[1]; 689 | }; 690 | const limit = { 691 | android: "0.0.0", 692 | osx: "0.0.0", 693 | }; 694 | const task = { 695 | key: song.url 696 | .replace(/\?.*$/, "") 697 | .replace(/(?<=kugou\.com\/)\w+\/\w+\//, "") 698 | .replace(/(?<=kuwo\.cn\/)\w+\/\w+\/resource\//, ""), 699 | url: song.url, 700 | }; 701 | try { 702 | let { header } = netease.param; 703 | header = 704 | typeof header === "string" 705 | ? JSON.parse(header) 706 | : header; 707 | const cookie = querystring.parse( 708 | req.headers.cookie.replace(/\s/g, ""), 709 | ";" 710 | ); 711 | const os = header.os || cookie.os, 712 | version = header.appver || cookie.appver; 713 | if (os in limit && newer(limit[os], version)) 714 | return cache( 715 | computeHash, 716 | task, 717 | 7 * 24 * 60 * 60 * 1000 718 | ).then((value) => (item.md5 = value)); 719 | } catch (e) {} 720 | }) 721 | .catch(() => {}); 722 | } else if (item.code == 200 && netease.web) { 723 | item.url = item.url.replace( 724 | /(m\d+?)(?!c)\.music\.126\.net/, 725 | "$1c.music.126.net" 726 | ); 727 | } 728 | }; 729 | 730 | if (!Array.isArray(jsonBody.data)) { 731 | if (HIRES && netease.path.includes("/api/song/enhance/player/url")) { 732 | jsonBody.data = [jsonBody.data]; 733 | tasks = jsonBody.data.map((item) => inject(item)); 734 | } else { 735 | tasks = [inject(jsonBody.data)]; 736 | } 737 | } else if (netease.path.includes("download")) { 738 | jsonBody.data = jsonBody.data[0]; 739 | tasks = [inject(jsonBody.data)]; 740 | } else { 741 | target = netease.web 742 | ? 0 743 | : parseInt( 744 | ( 745 | (Array.isArray(netease.param.ids) 746 | ? netease.param.ids 747 | : JSON.parse(netease.param.ids))[0] || 0 748 | ) 749 | .toString() 750 | .replace("_0", "") 751 | ); // reduce time cost 752 | tasks = jsonBody.data.map((item) => inject(item)); 753 | } 754 | return Promise.all(tasks).catch(() => {}); 755 | }; 756 | 757 | const unblockSoundEffects = (obj) => { 758 | const { data, code } = obj; 759 | if (code === 200) { 760 | if (Array.isArray(data)) 761 | data.map((item) => { 762 | if (item.type) item.type = 1; 763 | }); 764 | else if (data.type) data.type = 1; 765 | } 766 | }; 767 | 768 | const unblockLyricsEffects = (obj) => { 769 | const { data, code } = obj; 770 | if (code === 200 && Array.isArray(data)) { 771 | data.forEach((item) => { 772 | if ("canUse" in item) item.canUse = true; 773 | if ("canNotUseReasonCode" in item) item.canNotUseReasonCode = 200; 774 | }); 775 | } 776 | }; 777 | 778 | module.exports = hook; 779 | --------------------------------------------------------------------------------