├── 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 |
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