├── .dockerignore ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .npmignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.js ├── bridge.js ├── ca.crt ├── docker-compose.yml ├── endpoint.worker.js ├── package.json ├── server.crt ├── server.key └── src ├── app.js ├── bridge.js ├── browser ├── README.md ├── background.html ├── background.js ├── convert.js ├── crypto.js ├── inject.js ├── manifest.json ├── request.js └── script.js ├── cache.js ├── cli.js ├── crypto.js ├── hook.js ├── kwDES.js ├── provider ├── baidu.js ├── find.js ├── insure.js ├── joox.js ├── kugou.js ├── kuwo.js ├── match.js ├── migu.js ├── netease.js ├── qq.js ├── select.js ├── xiami.js └── youtube.js ├── request.js ├── server.js └── sni.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 -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | env: 12 | REPOSITORY: unblockneteasemusic 13 | DOCKER_USERNAME: nondanee 14 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 15 | steps: 16 | - 17 | name: Prepare 18 | id: prepare 19 | run: | 20 | ARCH=(amd64 arm/v6 arm/v7 arm64 386 ppc64le s390x) 21 | PLATFORM=$(printf ",linux/%s" "${ARCH[@]}") 22 | echo ::set-output name=build_platform::${PLATFORM:1} 23 | echo ::set-output name=image_name::${DOCKER_USERNAME}/${REPOSITORY} 24 | - 25 | name: Checkout 26 | uses: actions/checkout@v2 27 | - 28 | name: Setup Buildx 29 | uses: crazy-max/ghaction-docker-buildx@v1 30 | - 31 | name: Login 32 | run: | 33 | echo "${DOCKER_PASSWORD}" | docker login --username ${DOCKER_USERNAME} --password-stdin 34 | - 35 | name: Build 36 | run: | 37 | docker buildx build \ 38 | --tag ${{ steps.prepare.outputs.image_name }} \ 39 | --platform ${{ steps.prepare.outputs.build_platform }} \ 40 | --output "type=image,push=true" \ 41 | --file Dockerfile . 42 | - 43 | name: Check Manifest 44 | run: | 45 | docker buildx imagetools inspect ${{ steps.prepare.outputs.image_name }} 46 | 47 | npm: 48 | runs-on: ubuntu-latest 49 | steps: 50 | - 51 | name: Checkout 52 | uses: actions/checkout@v2 53 | - 54 | name: Setup Node.js 55 | uses: actions/setup-node@v1 56 | with: 57 | registry-url: https://registry.npmjs.org/ 58 | - 59 | name: Publish 60 | run: npm publish 61 | env: 62 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .vscode 3 | .idea 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # next.js build output 65 | .next 66 | 67 | # pkg dist directory 68 | dist/ 69 | 70 | # es6 transformation 71 | src/browser/provider 72 | src/browser/cache.js -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | .gitignore 3 | .dockerignore 4 | 5 | Dockerfile* 6 | *.yml 7 | 8 | src/browser/ 9 | ca.* 10 | *.worker.js -------------------------------------------------------------------------------- /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"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Nzix 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | logo 2 | 3 | # UnblockNeteaseMusic 4 | 5 | 解锁网易云音乐客户端变灰歌曲 6 | 7 | ## 特性 8 | 9 | - 使用 QQ / 虾米 / 百度 / 酷狗 / 酷我 / 咪咕 / JOOX 音源替换变灰歌曲链接 (默认仅启用一、五、六) 10 | - 为请求增加 `X-Real-IP` 参数解锁海外限制,支持指定网易云服务器 IP,支持设置上游 HTTP / HTTPS 代理 11 | - 完整的流量代理功能 (HTTP / HTTPS),可直接作为系统代理 (同时支持 PAC) 12 | 13 | ## 运行 14 | 15 | 使用 npx 16 | 17 | ``` 18 | $ npx @nondanee/unblockneteasemusic 19 | ``` 20 | 21 | 或使用 Docker 22 | 23 | ``` 24 | $ docker run nondanee/unblockneteasemusic 25 | ``` 26 | 27 | ``` 28 | $ docker-compose up 29 | ``` 30 | 31 | ### 配置参数 32 | 33 | ``` 34 | $ unblockneteasemusic -h 35 | usage: unblockneteasemusic [-v] [-p port] [-a address] [-u url] [-f host] 36 | [-o source [source ...]] [-t token] [-e url] [-s] 37 | [-h] 38 | 39 | optional arguments: 40 | -v, --version output the version number 41 | -p port, --port port specify server port 42 | -a address, --address address specify server host 43 | -u url, --proxy-url url request through upstream proxy 44 | -f host, --force-host host force the netease server ip 45 | -o source [source ...], --match-order source [source ...] 46 | set priority of sources 47 | -t token, --token token set up proxy authentication 48 | -e url, --endpoint url replace virtual endpoint with public host 49 | -s, --strict enable proxy limitation 50 | -h, --help output usage information 51 | ``` 52 | 53 | ## 使用 54 | 55 | **警告:本项目不提供线上 demo,请不要轻易信任使用他人提供的公开代理服务,以免发生安全问题** 56 | 57 | **若将服务部署到公网,强烈建议使用严格模式 (此模式下仅放行网易云音乐所属域名的请求) `-s` 限制代理范围 (需使用 PAC 或 hosts),~~或启用 Proxy Authentication `-t :` 设置代理用户名密码~~ (目前密码认证在 Windows 客户端设置和 macOS 系统设置都无法生效,请不要使用),以防代理被他人滥用** 58 | 59 | 支持 Windows 客户端,UWP 客户端,Android 客户端,Linux 客户端 (1.2 版本以上需要自签证书 MITM,启动客户端需要增加 `--ignore-certificate-errors` 参数),macOS 客户端 (726 版本以上需要自签证书),iOS 客户端 (配置 https endpoint 或使用自签证书) 和网页版 (需要自签证书,需要脚本配合) 60 | 61 | 目前除 UWP 外其它客户端均优先请求 HTTPS 接口,默认配置下本代理对网易云所有 HTTPS API 连接返回空数据,促使客户端降级使用 HTTP 接口 (新版 Linux 客户端和 macOS 客户端已无法降级) 62 | 63 | 因 UWP 应用存在网络隔离,限制流量发送到本机,若使用的代理在 localhost,或修改的 hosts 指向 localhost,需为 "网易云音乐 UWP" 手动开启 loopback 才能使用,请以**管理员身份**执行命令 64 | 65 | ```powershell 66 | checknetisolation loopbackexempt -a -n="1F8B0F94.122165AE053F_j2p0p5q0044a6" 67 | ``` 68 | 69 | ### 方法 1. 修改 hosts 70 | 71 | 向 hosts 文件添加两条规则 72 | 73 | ``` 74 | music.163.com 75 | interface.music.163.com 76 | ``` 77 | 78 | > 使用此方法必须监听 80 端口 `-p 80` 79 | > 80 | > **若在本机运行程序**,请指定网易云服务器 IP `-f xxx.xxx.xxx.xxx` (可在修改 hosts 前通过 `ping music.163.com` 获得) **或** 使用代理 `-u http(s)://xxx.xxx.xxx.xxx:xxx`,以防请求死循环 81 | > 82 | > **Android 客户端下修改 hosts 无法直接使用**,原因和解决方法详见[云音乐安卓又搞事啦](https://jixun.moe/post/netease-android-hosts-bypass/),[安卓免 root 绕过网易云音乐 IP 限制](https://jixun.moe/post/android-block-netease-without-root/) 83 | 84 | ### 方法 2. 设置代理 85 | 86 | PAC 自动代理脚本地址 `http:///proxy.pac` 87 | 88 | 全局代理地址填写服务器地址和端口号即可 89 | 90 | | 平台 | 基础设置 | 91 | | :------ | :------------------------------- | 92 | | Windows | 设置 > 工具 > 自定义代理 (客户端内) | 93 | | UWP | Windows 设置 > 网络和 Internet > 代理 | 94 | | Linux | 系统设置 > 网络 > 网络代理 | 95 | | macOS | 系统偏好设置 > 网络 > 高级 > 代理 | 96 | | Android | WLAN > 修改网络 > 高级选项 > 代理 | 97 | | iOS | 无线局域网 > HTTP 代理 > 配置代理 | 98 | 99 | > 代理工具和方法有很多请自行探索,欢迎在 issues 讨论 100 | 101 | ### ✳方法 3. 调用接口 102 | 103 | 作为依赖库使用 104 | 105 | ``` 106 | $ npm install @nondanee/unblockneteasemusic 107 | ``` 108 | 109 | ```javascript 110 | const match = require('@nondanee/unblockneteasemusic') 111 | 112 | /** 113 | * Set proxy or hosts if needed 114 | */ 115 | global.proxy = require('url').parse('http://127.0.0.1:1080') 116 | global.hosts = {'i.y.qq.com': '59.37.96.220'} 117 | 118 | /** 119 | * Find matching song from other platforms 120 | * @param {Number} id netease song id 121 | * @param {Array||undefined} source support qq, xiami, baidu, kugou, kuwo, migu, joox 122 | * @return {Promise} 123 | */ 124 | match(418602084, ['qq', 'kuwo', 'migu']).then(console.log) 125 | ``` 126 | 127 | ## 效果 128 | 129 | #### Windows 客户端 130 | 131 | 132 | 133 | #### UWP 客户端 134 | 135 | 136 | 137 | #### Linux 客户端 138 | 139 | 140 | 141 | #### macOS 客户端 142 | 143 | 144 | 145 | #### Android 客户端 146 | 147 | 148 | 149 | #### iOS 客户端 150 | 151 | 152 | 153 | ## 致谢 154 | 155 | 感谢大佬们为逆向 eapi 所做的努力 156 | 157 | 使用的其它平台音源 API 出自 158 | 159 | [trazyn/ieaseMusic](https://github.com/trazyn/ieaseMusic) 160 | 161 | [listen1/listen1_chrome_extension](https://github.com/listen1/listen1_chrome_extension) 162 | 163 | 向所有同类项目致敬 164 | 165 | [EraserKing/CloudMusicGear](https://github.com/EraserKing/CloudMusicGear) 166 | 167 | [EraserKing/Unblock163MusicClient](https://github.com/EraserKing/Unblock163MusicClient) 168 | 169 | [ITJesse/UnblockNeteaseMusic](https://github.com/ITJesse/UnblockNeteaseMusic/) 170 | 171 | [bin456789/Unblock163MusicClient-Xposed](https://github.com/bin456789/Unblock163MusicClient-Xposed) 172 | 173 | [YiuChoi/Unlock163Music](https://github.com/YiuChoi/Unlock163Music) 174 | 175 | [yi-ji/NeteaseMusicAbroad](https://github.com/yi-ji/NeteaseMusicAbroad) 176 | 177 | [stomakun/NeteaseReverseLadder](https://github.com/stomakun/NeteaseReverseLadder/) 178 | 179 | [fengjueming/unblock-NetEaseMusic](https://github.com/fengjueming/unblock-NetEaseMusic) 180 | 181 | [acgotaku/NetEaseMusicWorld](https://github.com/acgotaku/NetEaseMusicWorld) 182 | 183 | [mengskysama/163-Cloud-Music-Unlock](https://github.com/mengskysama/163-Cloud-Music-Unlock) 184 | 185 | [azureplus/163-music-unlock](https://github.com/azureplus/163-music-unlock) 186 | 187 | [typcn/163music-mac-client-unlock](https://github.com/typcn/163music-mac-client-unlock) 188 | 189 | ## 许可 190 | 191 | The MIT License -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./src/app') -------------------------------------------------------------------------------- /bridge.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./src/bridge') -------------------------------------------------------------------------------- /ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDdzCCAl+gAwIBAgIJAKX8LdIETDklMA0GCSqGSIb3DQEBCwUAMFIxCzAJBgNV 3 | BAYTAkNOMSQwIgYDVQQDDBtVbmJsb2NrTmV0ZWFzZU11c2ljIFJvb3QgQ0ExHTAb 4 | BgNVBAoMFEdpdEh1Yi5jb20gQG5vbmRhbmVlMB4XDTE5MDUxODE2MDU0NVoXDTI0 5 | MDUxNjE2MDU0NVowUjELMAkGA1UEBhMCQ04xJDAiBgNVBAMMG1VuYmxvY2tOZXRl 6 | YXNlTXVzaWMgUm9vdCBDQTEdMBsGA1UECgwUR2l0SHViLmNvbSBAbm9uZGFuZWUw 7 | ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQD23K6Ti2TfLJToCmpCAVgE 8 | Xb8+qTMfrifCpnKlJ+hrL+4KI1j4vSqTOOatqmxGSXZdF/j2kJuI40YThaokcgYx 9 | GFcPcEftSCYGWy8o20u2hzTkkW3KW9wlsDRIXICFXVIsHeSDwz+aVSudkyJHjfaS 10 | aLNb5pPovE7MRj8tDbp55scaSqhEcOe3m1ZlwlCeeXvD7RLKr3xhBKbGEqlJAjFq 11 | RNGzuqylqyJVBLScNHC7Lcf4n6pKr1yPGOeLePOUrIwtj0ynHUcBfeMuCVCsIKL8 12 | vy/oNwlDrZaAMfu5QQslzEf87KY1QgtI6Ppii+tzbmVx1ZxnlaCKqiuwlgBoi/5r 13 | AgMBAAGjUDBOMB0GA1UdDgQWBBRDhbGjnXEUouE9wNFS2k9PtgYYjDAfBgNVHSME 14 | GDAWgBRDhbGjnXEUouE9wNFS2k9PtgYYjDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3 15 | DQEBCwUAA4IBAQDRUh5+JFLEALXQkhPfwrVf4sCXTwLMwVujTPo3NMbhpWiP4cnn 16 | XHGCD5V57bBwjeD6NSrczDIdnN9uTJyFmLNVFMZBguEIeZfLUJLJ6w1ZhfgciX1D 17 | 9djyyo6eclkHvi+aPZKfzgMmc5BvHcjyUyS5MzI23kUW6WXUDn3IDIUKrfaH9Mjc 18 | /d4DDZVKQCYrLoBL+XO7pEHUY0u9XZVYWEavQ5tSN8XY1SDrO0yGUpRWET0ltubE 19 | zV7W0LOhuoVCiemboc5H8+njBjCis8obAo1XMmDZzW189L9GPFxHNWlka+KlajZB 20 | tMo90PooZYEOw1rTUrzHb+VZY/tYIAAomGZ0 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nondanee/unblockneteasemusic", 3 | "version": "0.25.3", 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/nondanee/UnblockNeteaseMusic.git" 21 | }, 22 | "author": "nondanee", 23 | "license": "MIT", 24 | "dependencies": {}, 25 | "publishConfig": { 26 | "access": "public" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDkjCCAnqgAwIBAgIJAK/bIUIlE36LMA0GCSqGSIb3DQEBCwUAMFIxCzAJBgNV 3 | BAYTAkNOMSQwIgYDVQQDDBtVbmJsb2NrTmV0ZWFzZU11c2ljIFJvb3QgQ0ExHTAb 4 | BgNVBAoMFEdpdEh1Yi5jb20gQG5vbmRhbmVlMB4XDTIwMDUxNjE3MTIxM1oXDTIx 5 | MDUxNjE3MTIxM1owezELMAkGA1UEBhMCQ04xETAPBgNVBAcMCEhhbmd6aG91MSww 6 | KgYDVQQKDCNOZXRFYXNlIChIYW5nemhvdSkgTmV0d29yayBDby4sIEx0ZDERMA8G 7 | A1UECwwISVQgRGVwdC4xGDAWBgNVBAMMDyoubXVzaWMuMTYzLmNvbTCCASIwDQYJ 8 | KoZIhvcNAQEBBQADggEPADCCAQoCggEBALobECypwEoe8VqM/FJvBRR3p2T+ZWdi 9 | MSPrwfiRJr5p7OMtWBlLveCBV85+R3feidYbQTXlvVTdToY+GN6mFE1x6zG2dvLD 10 | s4UuRnipmvGcFYhIRTX8J4AJiN8VMtW0TNXscRMudpz/FAVtsRrggRaThYg4f/rI 11 | oAPMqKMsS4JoYhxs9ED6E6/tpj3XmSg1ekaXhgacYSYHeyxizZwoOFVCLH3TG5sF 12 | sD6CYNnukYol8bR+VRpvHftIYss5Yz+DyyhYEAMJm1CfQo+xoGR3D0ozbT3hUnzm 13 | fEoOhmSp3sALrFVE4iJSuajoh2/3xhmcyi3xZdWyq4F8hpb+URyaoW0CAwEAAaNC 14 | MEAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwKQYDVR0RBCIwIIINbXVzaWMuMTYzLmNv 15 | bYIPKi5tdXNpYy4xNjMuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQB+zW0o1169aGQI 16 | 7GA/8BQ769svpkpdy/lfvkokapFjzoxLTBQhjMo9rqzmGOwr9ksePwQqSDXn685W 17 | mKnEl0CzhBrKnL5x3gHus8bg591xpW+01xAFXSyLITOfMJqMEdY7ylymkm0XZ3aN 18 | vm+yFdP1fr/bZNw6Wrprg3i7eGhj7TdBXRA96usVgBcnCkC1SzEZfnDZsKl9o8Xx 19 | TSOpvzIMSaD7++Bp7BdzA5oCCydv2c++zV/sgCPIr26Jq8UQac+qQP5SMlYyGbAl 20 | vIQRRZyfQ4fPonYDnEPHWFCMyBkQIN39LMhDRsUgn8bT0rnP91xkNAd9S4VWbNDA 21 | 5TMiQy3F 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAuhsQLKnASh7xWoz8Um8FFHenZP5lZ2IxI+vB+JEmvmns4y1Y 3 | GUu94IFXzn5Hd96J1htBNeW9VN1Ohj4Y3qYUTXHrMbZ28sOzhS5GeKma8ZwViEhF 4 | NfwngAmI3xUy1bRM1exxEy52nP8UBW2xGuCBFpOFiDh/+sigA8yooyxLgmhiHGz0 5 | QPoTr+2mPdeZKDV6RpeGBpxhJgd7LGLNnCg4VUIsfdMbmwWwPoJg2e6RiiXxtH5V 6 | Gm8d+0hiyzljP4PLKFgQAwmbUJ9Cj7GgZHcPSjNtPeFSfOZ8Sg6GZKnewAusVUTi 7 | IlK5qOiHb/fGGZzKLfFl1bKrgXyGlv5RHJqhbQIDAQABAoIBAEmAvtalBMlBh1mY 8 | LV/xcTQwPfDpeOtoILhrOOUPjxnNhD4FfrIe9BNjgmaQAXIadp4VjZ/X6PtHnOfw 9 | RqpJNeOQhq/PvRMMsC59pF+rvQKH/wkgYhV8Ta2IFoLlQHqfB2nGRLKquzYumJ28 10 | QSK4YMOl6CtxBTrrWiemAUTRDdGm8tARiipJH1SEJrS6d/NoRoJx2vixFgD2eS6X 11 | bjnhGjIzvX/w5FWjctqj+SFITP1UI62b6DyWsPOkoosKNteK+Ulz+K6ZFvOx7day 12 | XgUoTcVpwCVr2dVGhJtOrbKPcl1jYCYHJAHwzUZND4x4yftm1mnnsi3bthYqbtHQ 13 | vxLE9YECgYEA9hiZxwiVvLjSe1xT/D75HbB8S1XSnwzpMmqgzStguxCQ0Qg5yiLI 14 | UKRDY8UZvEDV4i2bQGy7mk8lFvX1q2z7Q30+dtT9r2N9a6ujMk5RMfo2BZg/poI6 15 | yDWe2tKUg9cTwfgni4TutLOYkpz3VDPIQHs3k2mpNh7f+8X4RIybDqkCgYEAwZhp 16 | uWMV38Bb0WytswHXL1dRuwBskKqALUBY61dtXkyBuocj8AuRRxfxfZpgJRrHFxDX 17 | O9bQ2nxpVlwKsR6DJDUdxU3+kvwyPfseU5XUBey8WdkuAKD7cKZOHMhFVWccks0U 18 | YJzykNrxB+rGTiwVKa0MOhipuJ7boerwwaN2SyUCgYBP9Ow5o4tq9q3EUNoksZ0k 19 | zUuE+oxlCr/VlplKL9bM0HQMxlxoVWa59LTEfKyA4pvbUbAIfYtydlZ5oE5CdTUp 20 | 105tM4R88Jk2W1y5ooJ093OH29CKW/OXSvyi4hpIv592vRa0GOupoFRpBkDBhdWB 21 | RcdnyMOmht+FIOwp8XkLiQKBgAUK3j4Y6ZnxXbLfvMp70soF4TgYs7s05a/IDEjc 22 | 9xlMrthX6sS22GrcocqeucBdqS/dnW2Ok9QNB4VbUl/4pnvL8mGQPYBAl2Jr5wdQ 23 | ULxyxRkmAf+8MbBmdIRlZwDpdaIRO2Wk0OCbA0osgEvK9CYovrfIqqsHYDsgbnLs 24 | ugkNAoGBAJok06BN05caPXXLQ2pMwI/7mjcZFjcOMxSloYi7LFkxlyvoTqReAeSa 25 | yOb6W/7obS1X8ms/EAkqiyzJuPtNZJCW/nvV0iCoZ/NxLuyHnFaO344GBAweol+S 26 | Jx0MY8KuDCyeGErc2xdz/yr3ld2PSTq71dhBluGyba2YX+peJ2Yv 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /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: 'port', 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', 'xiami', 'baidu', 'kugou', 'kuwo', 'migu', 'joox', 'youtube']) 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'] 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 => console.log(error)) 87 | -------------------------------------------------------------------------------- /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 | xiami: require('./provider/xiami'), 9 | baidu: require('./provider/baidu'), 10 | kugou: require('./provider/kugou'), 11 | kuwo: require('./provider/kuwo'), 12 | migu: require('./provider/migu'), 13 | joox: require('./provider/joox') 14 | } 15 | 16 | const distribute = (url, router) => 17 | Promise.resolve() 18 | .then(() => { 19 | const route = url.pathname.slice(1).split('/').map(path => decodeURIComponent(path)) 20 | let pointer = router, argument = decodeURIComponent(url.query) 21 | try {argument = JSON.parse(argument)} catch(e) {} 22 | const miss = route.some(path => { 23 | if (path in pointer) pointer = pointer[path] 24 | else return true 25 | }) 26 | if (miss || typeof pointer != 'function') return Promise.reject() 27 | // return pointer.call(null, argument) 28 | return cache(pointer, argument, 15 * 60 * 1000) 29 | }) 30 | 31 | require('http').createServer() 32 | .listen(parseInt(process.argv[2]) || 9000) 33 | .on('request', (req, res) => 34 | distribute(parse(req.url), router) 35 | .then(data => res.write(data)) 36 | .catch(() => res.writeHead(404)) 37 | .then(() => res.end()) 38 | ) -------------------------------------------------------------------------------- /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) -------------------------------------------------------------------------------- /src/browser/background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /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: '118.88.88.88'}) 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/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/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/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/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 | } -------------------------------------------------------------------------------- /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 | })) -------------------------------------------------------------------------------- /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 | })() -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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/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/hook.js: -------------------------------------------------------------------------------- 1 | const cache = require('./cache') 2 | const parse = require('url').parse 3 | const crypto = require('./crypto') 4 | const request = require('./request') 5 | const match = require('./provider/match') 6 | const querystring = require('querystring') 7 | 8 | const hook = { 9 | request: { 10 | before: () => {}, 11 | after: () => {}, 12 | }, 13 | connect: { 14 | before: () => {} 15 | }, 16 | negotiate: { 17 | before: () => {} 18 | }, 19 | target: { 20 | host: new Set(), 21 | path: new Set() 22 | } 23 | } 24 | 25 | hook.target.host = new Set([ 26 | 'music.163.com', 27 | 'interface.music.163.com', 28 | 'interface3.music.163.com', 29 | 'apm.music.163.com', 30 | 'apm3.music.163.com', 31 | // 'mam.netease.com', 32 | // 'api.iplay.163.com', // look living 33 | // 'ac.dun.163yun.com', 34 | // 'crash.163.com', 35 | // 'clientlog.music.163.com', 36 | // 'clientlog3.music.163.com' 37 | ]) 38 | 39 | hook.target.path = new Set([ 40 | '/api/v3/playlist/detail', 41 | '/api/v3/song/detail', 42 | '/api/v6/playlist/detail', 43 | '/api/album/play', 44 | '/api/artist/privilege', 45 | '/api/album/privilege', 46 | '/api/v1/artist', 47 | '/api/v1/artist/songs', 48 | '/api/artist/top/song', 49 | '/api/v1/album', 50 | '/api/album/v3/detail', 51 | '/api/playlist/privilege', 52 | '/api/song/enhance/player/url', 53 | '/api/song/enhance/player/url/v1', 54 | '/api/song/enhance/download/url', 55 | '/api/song/enhance/privilege', 56 | '/batch', 57 | '/api/batch', 58 | '/api/v1/search/get', 59 | '/api/v1/search/song/get', 60 | '/api/search/complex/get', 61 | '/api/cloudsearch/pc', 62 | '/api/v1/playlist/manipulate/tracks', 63 | '/api/song/like', 64 | '/api/v1/play/record', 65 | '/api/playlist/v4/detail', 66 | '/api/v1/radio/get', 67 | '/api/v1/discovery/recommend/songs' 68 | ]) 69 | 70 | const domainList = [ 71 | 'music.163.com', 72 | 'music.126.net', 73 | 'iplay.163.com', 74 | 'look.163.com', 75 | 'y.163.com', 76 | ] 77 | 78 | hook.request.before = ctx => { 79 | const {req} = ctx 80 | req.url = (req.url.startsWith('http://') ? '' : (req.socket.encrypted ? 'https:' : 'http:') + '//' + (domainList.some(domain => (req.headers.host || '').endsWith(domain)) ? req.headers.host : null)) + req.url 81 | const url = parse(req.url) 82 | if ([url.hostname, req.headers.host].some(host => host.includes('music.163.com'))) ctx.decision = 'proxy' 83 | if ([url.hostname, req.headers.host].some(host => hook.target.host.has(host)) && req.method == 'POST' && (url.path == '/api/linux/forward' || url.path.startsWith('/eapi/'))) { 84 | return request.read(req) 85 | .then(body => req.body = body) 86 | .then(body => { 87 | if ('x-napm-retry' in req.headers) delete req.headers['x-napm-retry'] 88 | req.headers['X-Real-IP'] = '118.88.88.88' 89 | if (req.url.includes('stream')) return // look living eapi can not be decrypted 90 | if (body) { 91 | let data = null 92 | const netease = {} 93 | netease.pad = (body.match(/%0+$/) || [''])[0] 94 | netease.forward = (url.path == '/api/linux/forward') 95 | if (netease.forward) { 96 | data = JSON.parse(crypto.linuxapi.decrypt(Buffer.from(body.slice(8, body.length - netease.pad.length), 'hex')).toString()) 97 | netease.path = parse(data.url).path 98 | netease.param = data.params 99 | } 100 | else { 101 | data = crypto.eapi.decrypt(Buffer.from(body.slice(7, body.length - netease.pad.length), 'hex')).toString().split('-36cd479b6b5-') 102 | netease.path = data[0] 103 | netease.param = JSON.parse(data[1]) 104 | } 105 | netease.path = netease.path.replace(/\/\d*$/, '') 106 | ctx.netease = netease 107 | // console.log(netease.path, netease.param) 108 | 109 | if (netease.path == '/api/song/enhance/download/url') 110 | return pretendPlay(ctx) 111 | } 112 | }) 113 | .catch(error => console.log(error, req.url)) 114 | } 115 | else if ((hook.target.host.has(url.hostname)) && (url.path.startsWith('/weapi/') || url.path.startsWith('/api/'))) { 116 | req.headers['X-Real-IP'] = '118.88.88.88' 117 | ctx.netease = {web: true, path: url.path.replace(/^\/weapi\//, '/api/').replace(/\?.+$/, '').replace(/\/\d*$/, '')} 118 | } 119 | else if (req.url.includes('package')) { 120 | try { 121 | const data = req.url.split('package/').pop().split('/') 122 | const url = parse(crypto.base64.decode(data[0])) 123 | const id = data[1].replace(/\.\w+/, '') 124 | req.url = url.href 125 | req.headers['host'] = url.hostname 126 | req.headers['cookie'] = null 127 | ctx.package = {id} 128 | ctx.decision = 'proxy' 129 | // if (url.href.includes('google')) 130 | // return request('GET', req.url, req.headers, null, parse('http://127.0.0.1:1080')) 131 | // .then(response => (ctx.res.writeHead(response.statusCode, response.headers), response.pipe(ctx.res))) 132 | } 133 | catch(error) { 134 | ctx.error = error 135 | ctx.decision = 'close' 136 | } 137 | } 138 | } 139 | 140 | hook.request.after = ctx => { 141 | const {req, proxyRes, netease, package} = ctx 142 | if (req.headers.host === 'tyst.migu.cn' && proxyRes.headers['content-range'] && proxyRes.statusCode === 200) proxyRes.statusCode = 206 143 | if (netease && hook.target.path.has(netease.path) && proxyRes.statusCode == 200) { 144 | return request.read(proxyRes, true) 145 | .then(buffer => buffer.length ? proxyRes.body = buffer : Promise.reject()) 146 | .then(buffer => { 147 | const patch = string => string.replace(/([^\\]"\s*:\s*)(\d{16,})(\s*[}|,])/g, '$1"$2L"$3') // for js precision 148 | try { 149 | netease.encrypted = false 150 | netease.jsonBody = JSON.parse(patch(buffer.toString())) 151 | } 152 | catch(error) { 153 | netease.encrypted = true 154 | netease.jsonBody = JSON.parse(patch(crypto.eapi.decrypt(buffer).toString())) 155 | } 156 | 157 | if (new Set([401, 512]).has(netease.jsonBody.code) && !netease.web) { 158 | if (netease.path.includes('manipulate')) return tryCollect(ctx) 159 | else if (netease.path == '/api/song/like') return tryLike(ctx) 160 | } 161 | else if (netease.path.includes('url')) return tryMatch(ctx) 162 | }) 163 | .then(() => { 164 | ['transfer-encoding', 'content-encoding', 'content-length'].filter(key => key in proxyRes.headers).forEach(key => delete proxyRes.headers[key]) 165 | 166 | const inject = (key, value) => { 167 | if (typeof(value) === 'object' && value != null) { 168 | if ('fee' in value) value['fee'] = 0 169 | if ('st' in value && 'pl' in value && 'dl' in value && 'subp' in value) { // batch modify 170 | value['st'] = 0 171 | value['subp'] = 1 172 | value['pl'] = (value['pl'] == 0) ? 320000 : value['pl'] 173 | value['dl'] = (value['dl'] == 0) ? 320000 : value['dl'] 174 | } 175 | } 176 | return value 177 | } 178 | 179 | let body = JSON.stringify(netease.jsonBody, inject) 180 | body = body.replace(/([^\\]"\s*:\s*)"(\d{16,})L"(\s*[}|,])/g, '$1$2$3') // for js precision 181 | proxyRes.body = (netease.encrypted ? crypto.eapi.encrypt(Buffer.from(body)) : body) 182 | }) 183 | .catch(error => error ? console.log(error, req.url) : null) 184 | } 185 | else if (package) { 186 | if (new Set([201, 301, 302, 303, 307, 308]).has(proxyRes.statusCode)) { 187 | return request(req.method, parse(req.url).resolve(proxyRes.headers.location), req.headers) 188 | .then(response => ctx.proxyRes = response) 189 | } 190 | else if (/p\d+c*.music.126.net/.test(req.url)) { 191 | proxyRes.headers['content-type'] = 'audio/*' 192 | } 193 | } 194 | } 195 | 196 | hook.connect.before = ctx => { 197 | const {req} = ctx 198 | const url = parse('https://' + req.url) 199 | if ([url.hostname, req.headers.host].some(host => hook.target.host.has(host))) { 200 | if (url.port == 80) { 201 | req.url = `${global.address || 'localhost'}:${global.port[0]}` 202 | req.local = true 203 | } 204 | else if (global.port[1]) { 205 | req.url = `${global.address || 'localhost'}:${global.port[1]}` 206 | req.local = true 207 | } 208 | else { 209 | ctx.decision = 'blank' 210 | } 211 | } 212 | else if (url.href.includes(global.endpoint)) ctx.decision = 'proxy' 213 | } 214 | 215 | hook.negotiate.before = ctx => { 216 | const {req, socket, decision} = ctx 217 | const url = parse('https://' + req.url) 218 | const target = hook.target.host 219 | if (req.local || decision) return 220 | if (target.has(socket.sni) && !target.has(url.hostname)) { 221 | target.add(url.hostname) 222 | ctx.decision = 'blank' 223 | } 224 | } 225 | 226 | const pretendPlay = ctx => { 227 | const {req, netease} = ctx 228 | const turn = 'http://music.163.com/api/song/enhance/player/url' 229 | let query = null 230 | if (netease.forward) { 231 | const {id, br} = netease.param 232 | netease.param = {ids: `["${id}"]`, br} 233 | query = crypto.linuxapi.encryptRequest(turn, netease.param) 234 | } 235 | else { 236 | const {id, br, e_r, header} = netease.param 237 | netease.param = {ids: `["${id}"]`, br, e_r, header} 238 | query = crypto.eapi.encryptRequest(turn, netease.param) 239 | } 240 | req.url = query.url 241 | req.body = query.body + netease.pad 242 | } 243 | 244 | const tryCollect = ctx => { 245 | const {req, netease} = ctx 246 | const {trackIds, pid, op} = netease.param 247 | const trackId = (Array.isArray(trackIds) ? trackIds : JSON.parse(trackIds))[0] 248 | return request('POST', 'http://music.163.com/api/playlist/manipulate/tracks', req.headers, `trackIds=[${trackId},${trackId}]&pid=${pid}&op=${op}`).then(response => response.json()) 249 | .then(jsonBody => { 250 | netease.jsonBody = jsonBody 251 | }) 252 | .catch(() => {}) 253 | } 254 | 255 | const tryLike = ctx => { 256 | const {req, netease} = ctx 257 | const {trackId} = netease.param 258 | let pid = 0, userId = 0 259 | return request('GET', 'http://music.163.com/api/v1/user/info', req.headers).then(response => response.json()) 260 | .then(jsonBody => { 261 | userId = jsonBody.userPoint.userId 262 | return request('GET', `http://music.163.com/api/user/playlist?uid=${userId}&limit=1`, req.headers).then(response => response.json()) 263 | }) 264 | .then(jsonBody => { 265 | pid = jsonBody.playlist[0].id 266 | return request('POST', 'http://music.163.com/api/playlist/manipulate/tracks', req.headers, `trackIds=[${trackId},${trackId}]&pid=${pid}&op=add`).then(response => response.json()) 267 | }) 268 | .then(jsonBody => { 269 | if (new Set([200, 502]).has(jsonBody.code)) { 270 | netease.jsonBody = {code: 200, playlistId: pid} 271 | } 272 | }) 273 | .catch(() => {}) 274 | } 275 | 276 | const computeHash = task => request('GET', task.url).then(response => crypto.md5.pipe(response)) 277 | 278 | const tryMatch = ctx => { 279 | const {req, netease} = ctx 280 | const {jsonBody} = netease 281 | let tasks = [], target = 0 282 | 283 | const inject = item => { 284 | item.flag = 0 285 | if ((item.code != 200 || item.freeTrialInfo) && (target == 0 || item.id == target)) { 286 | return match(item.id) 287 | .then(song => { 288 | item.type = song.br === 999000 ? 'flac' : 'mp3' 289 | item.url = global.endpoint ? `${global.endpoint}/package/${crypto.base64.encode(song.url)}/${item.id}.${item.type}` : song.url 290 | item.md5 = song.md5 || crypto.md5.digest(song.url) 291 | item.br = song.br || 128000 292 | item.size = song.size 293 | item.code = 200 294 | item.freeTrialInfo = null 295 | return song 296 | }) 297 | .then(song => { 298 | if (!netease.path.includes('download') || song.md5) return 299 | const newer = (base, target) => { 300 | const difference = 301 | Array.from([base, target]) 302 | .map(version => version.split('.').slice(0, 3).map(number => parseInt(number) || 0)) 303 | .reduce((aggregation, current) => !aggregation.length ? current.map(element => [element]) : aggregation.map((element, index) => element.concat(current[index])), []) 304 | .filter(pair => pair[0] != pair[1])[0] 305 | return !difference || difference[0] <= difference[1] 306 | } 307 | const limit = {android: '0.0.0', osx: '0.0.0'} 308 | const task = {key: song.url.replace(/\?.*$/, '').replace(/(?<=kugou\.com\/)\w+\/\w+\//, '').replace(/(?<=kuwo\.cn\/)\w+\/\w+\/resource\//, ''), url: song.url} 309 | try { 310 | let {header} = netease.param 311 | header = typeof(header) === 'string' ? JSON.parse(header) : header 312 | const cookie = querystring.parse(req.headers.cookie.replace(/\s/g, ''), ';') 313 | const os = header.os || cookie.os, version = header.appver || cookie.appver 314 | if (os in limit && newer(limit[os], version)) 315 | return cache(computeHash, task, 7 * 24 * 60 * 60 * 1000).then(value => item.md5 = value) 316 | } 317 | catch(e) {} 318 | }) 319 | .catch(() => {}) 320 | } 321 | else if (item.code == 200 && netease.web) { 322 | item.url = item.url.replace(/(m\d+?)(?!c)\.music\.126\.net/, '$1c.music.126.net') 323 | } 324 | } 325 | 326 | if (!Array.isArray(jsonBody.data)) { 327 | tasks = [inject(jsonBody.data)] 328 | } 329 | else if (netease.path.includes('download')) { 330 | jsonBody.data = jsonBody.data[0] 331 | tasks = [inject(jsonBody.data)] 332 | } 333 | else { 334 | target = netease.web ? 0 : parseInt(((Array.isArray(netease.param.ids) ? netease.param.ids : JSON.parse(netease.param.ids))[0] || 0).toString().replace('_0', '')) // reduce time cost 335 | tasks = jsonBody.data.map(item => inject(item)) 336 | } 337 | return Promise.all(tasks).catch(() => {}) 338 | } 339 | 340 | module.exports = hook -------------------------------------------------------------------------------- /src/kwDES.js: -------------------------------------------------------------------------------- 1 | /* 2 | Thanks to 3 | https://github.com/XuShaohua/kwplayer/blob/master/kuwo/DES.py 4 | https://github.com/Levi233/MusicPlayer/blob/master/app/src/main/java/com/chenhao/musicplayer/utils/crypt/KuwoDES.java 5 | */ 6 | 7 | const Long = ( 8 | typeof(BigInt) === 'function' // BigInt support in Node 10+ 9 | ? n => (n = BigInt(n), ({ 10 | low: Number(n), 11 | valueOf: () => n.valueOf(), 12 | toString: () => n.toString(), 13 | not: () => Long(~n), 14 | isNegative: () => n < 0, 15 | or: x => Long(n | BigInt(x)), 16 | and: x => Long(n & BigInt(x)), 17 | xor: x => Long(n ^ BigInt(x)), 18 | equals: x => n === BigInt(x), 19 | multiply: x => Long(n * BigInt(x)), 20 | shiftLeft: x => Long(n << BigInt(x)), 21 | shiftRight: x => Long(n >> BigInt(x)), 22 | })) 23 | : (...args) => new (require('long'))(...args) 24 | ) 25 | 26 | const range = n => Array.from(new Array(n).keys()) 27 | const power = (base, index) => Array(index).fill().reduce((result) => result.multiply(base), Long(1)) 28 | const LongArray = (...array) => array.map(n => n === -1 ? Long(-1, -1) : Long(n)) 29 | 30 | // EXPANSION 31 | const arrayE = LongArray( 32 | 31, 0, 1, 2, 3, 4, -1, -1, 33 | 3, 4, 5, 6, 7, 8, -1, -1, 34 | 7, 8, 9, 10, 11, 12, -1, -1, 35 | 11, 12, 13, 14, 15, 16, -1, -1, 36 | 15, 16, 17, 18, 19, 20, -1, -1, 37 | 19, 20, 21, 22, 23, 24, -1, -1, 38 | 23, 24, 25, 26, 27, 28, -1, -1, 39 | 27, 28, 29, 30, 31, 30, -1, -1 40 | ) 41 | 42 | // INITIAL_PERMUTATION 43 | const arrayIP = LongArray( 44 | 57, 49, 41, 33, 25, 17, 9, 1, 45 | 59, 51, 43, 35, 27, 19, 11, 3, 46 | 61, 53, 45, 37, 29, 21, 13, 5, 47 | 63, 55, 47, 39, 31, 23, 15, 7, 48 | 56, 48, 40, 32, 24, 16, 8, 0, 49 | 58, 50, 42, 34, 26, 18, 10, 2, 50 | 60, 52, 44, 36, 28, 20, 12, 4, 51 | 62, 54, 46, 38, 30, 22, 14, 6 52 | ) 53 | 54 | // INVERSE_PERMUTATION 55 | const arrayIP_1 = LongArray( 56 | 39, 7, 47, 15, 55, 23, 63, 31, 57 | 38, 6, 46, 14, 54, 22, 62, 30, 58 | 37, 5, 45, 13, 53, 21, 61, 29, 59 | 36, 4, 44, 12, 52, 20, 60, 28, 60 | 35, 3, 43, 11, 51, 19, 59, 27, 61 | 34, 2, 42, 10, 50, 18, 58, 26, 62 | 33, 1, 41, 9, 49, 17, 57, 25, 63 | 32, 0, 40, 8, 48, 16, 56, 24 64 | ) 65 | 66 | // ROTATES 67 | const arrayLs = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1] 68 | const arrayLsMask = LongArray(0, 0x100001, 0x300003) 69 | const arrayMask = range(64).map(n => power(2, n)) 70 | arrayMask[arrayMask.length - 1] = arrayMask[arrayMask.length - 1].multiply(-1) 71 | 72 | // PERMUTATION 73 | const arrayP = LongArray( 74 | 15, 6, 19, 20, 28, 11, 27, 16, 75 | 0, 14, 22, 25, 4, 17, 30, 9, 76 | 1, 7, 23, 13, 31, 26, 2, 8, 77 | 18, 12, 29, 5, 21, 10, 3, 24 78 | ) 79 | 80 | // PERMUTED_CHOICE1 81 | const arrayPC_1 = LongArray( 82 | 56, 48, 40, 32, 24, 16, 8, 0, 83 | 57, 49, 41, 33, 25, 17, 9, 1, 84 | 58, 50, 42, 34, 26, 18, 10, 2, 85 | 59, 51, 43, 35, 62, 54, 46, 38, 86 | 30, 22, 14, 6, 61, 53, 45, 37, 87 | 29, 21, 13, 5, 60, 52, 44, 36, 88 | 28, 20, 12, 4, 27, 19, 11, 3 89 | ) 90 | 91 | // PERMUTED_CHOICE2 92 | const arrayPC_2 = LongArray( 93 | 13, 16, 10, 23, 0, 4, -1, -1, 94 | 2, 27, 14, 5, 20, 9, -1, -1, 95 | 22, 18, 11, 3, 25, 7, -1, -1, 96 | 15, 6, 26, 19, 12, 1, -1, -1, 97 | 40, 51, 30, 36, 46, 54, -1, -1, 98 | 29, 39, 50, 44, 32, 47, -1, -1, 99 | 43, 48, 38, 55, 33, 52, -1, -1, 100 | 45, 41, 49, 35, 28, 31, -1, -1 101 | ) 102 | 103 | const matrixNSBox = [[ 104 | 14, 4, 3, 15, 2, 13, 5, 3, 105 | 13, 14, 6, 9, 11, 2, 0, 5, 106 | 4, 1, 10, 12, 15, 6, 9, 10, 107 | 1, 8, 12, 7, 8, 11, 7, 0, 108 | 0, 15, 10, 5, 14, 4, 9, 10, 109 | 7, 8, 12, 3, 13, 1, 3, 6, 110 | 15, 12, 6, 11, 2, 9, 5, 0, 111 | 4, 2, 11, 14, 1, 7, 8, 13, ], [ 112 | 15, 0, 9, 5, 6, 10, 12, 9, 113 | 8, 7, 2, 12, 3, 13, 5, 2, 114 | 1, 14, 7, 8, 11, 4, 0, 3, 115 | 14, 11, 13, 6, 4, 1, 10, 15, 116 | 3, 13, 12, 11, 15, 3, 6, 0, 117 | 4, 10, 1, 7, 8, 4, 11, 14, 118 | 13, 8, 0, 6, 2, 15, 9, 5, 119 | 7, 1, 10, 12, 14, 2, 5, 9, ], [ 120 | 10, 13, 1, 11, 6, 8, 11, 5, 121 | 9, 4, 12, 2, 15, 3, 2, 14, 122 | 0, 6, 13, 1, 3, 15, 4, 10, 123 | 14, 9, 7, 12, 5, 0, 8, 7, 124 | 13, 1, 2, 4, 3, 6, 12, 11, 125 | 0, 13, 5, 14, 6, 8, 15, 2, 126 | 7, 10, 8, 15, 4, 9, 11, 5, 127 | 9, 0, 14, 3, 10, 7, 1, 12, ], [ 128 | 7, 10, 1, 15, 0, 12, 11, 5, 129 | 14, 9, 8, 3, 9, 7, 4, 8, 130 | 13, 6, 2, 1, 6, 11, 12, 2, 131 | 3, 0, 5, 14, 10, 13, 15, 4, 132 | 13, 3, 4, 9, 6, 10, 1, 12, 133 | 11, 0, 2, 5, 0, 13, 14, 2, 134 | 8, 15, 7, 4, 15, 1, 10, 7, 135 | 5, 6, 12, 11, 3, 8, 9, 14, ], [ 136 | 2, 4, 8, 15, 7, 10, 13, 6, 137 | 4, 1, 3, 12, 11, 7, 14, 0, 138 | 12, 2, 5, 9, 10, 13, 0, 3, 139 | 1, 11, 15, 5, 6, 8, 9, 14, 140 | 14, 11, 5, 6, 4, 1, 3, 10, 141 | 2, 12, 15, 0, 13, 2, 8, 5, 142 | 11, 8, 0, 15, 7, 14, 9, 4, 143 | 12, 7, 10, 9, 1, 13, 6, 3, ], [ 144 | 12, 9, 0, 7, 9, 2, 14, 1, 145 | 10, 15, 3, 4, 6, 12, 5, 11, 146 | 1, 14, 13, 0, 2, 8, 7, 13, 147 | 15, 5, 4, 10, 8, 3, 11, 6, 148 | 10, 4, 6, 11, 7, 9, 0, 6, 149 | 4, 2, 13, 1, 9, 15, 3, 8, 150 | 15, 3, 1, 14, 12, 5, 11, 0, 151 | 2, 12, 14, 7, 5, 10, 8, 13, ], [ 152 | 4, 1, 3, 10, 15, 12, 5, 0, 153 | 2, 11, 9, 6, 8, 7, 6, 9, 154 | 11, 4, 12, 15, 0, 3, 10, 5, 155 | 14, 13, 7, 8, 13, 14, 1, 2, 156 | 13, 6, 14, 9, 4, 1, 2, 14, 157 | 11, 13, 5, 0, 1, 10, 8, 3, 158 | 0, 11, 3, 5, 9, 4, 15, 2, 159 | 7, 8, 12, 15, 10, 7, 6, 12, ], [ 160 | 13, 7, 10, 0, 6, 9, 5, 15, 161 | 8, 4, 3, 10, 11, 14, 12, 5, 162 | 2, 11, 9, 6, 15, 12, 0, 3, 163 | 4, 1, 14, 13, 1, 2, 7, 8, 164 | 1, 2, 12, 15, 10, 4, 0, 3, 165 | 13, 14, 6, 9, 7, 8, 9, 6, 166 | 15, 1, 5, 12, 3, 10, 14, 5, 167 | 8, 7, 11, 0, 4, 13, 2, 11, ], 168 | ] 169 | 170 | const bitTransform = (arrInt, n, l) => { // int[], int, long : long 171 | let l2 = Long(0) 172 | range(n).forEach(i => { 173 | if (arrInt[i].isNegative() || (l.and(arrayMask[arrInt[i].low]).equals(0))) 174 | return 175 | l2 = l2.or(arrayMask[i]) 176 | }) 177 | return l2 178 | } 179 | 180 | const DES64 = (longs, l) => { // long[], long 181 | let out = Long(0) 182 | let SOut = Long(0) 183 | const pR = range(8).map(() => Long(0)) 184 | const pSource = [Long(0), Long(0)] 185 | let L = Long(0) 186 | let R = Long(0) 187 | out = bitTransform(arrayIP, 64, l) 188 | pSource[0] = out.and(0xFFFFFFFF) 189 | pSource[1] = out.and(-4294967296).shiftRight(32) 190 | 191 | range(16).forEach(i => { 192 | R = Long(pSource[1]) 193 | R = bitTransform(arrayE, 64, R) 194 | R = R.xor(longs[i]) 195 | range(8).forEach(j => { 196 | pR[j] = R.shiftRight(j * 8).and(255) 197 | }) 198 | SOut = Long(0) 199 | range(8).reverse().forEach(sbi => { 200 | SOut = SOut.shiftLeft(4).or(matrixNSBox[sbi][pR[sbi]]) 201 | }) 202 | R = bitTransform(arrayP, 32, SOut) 203 | L = Long(pSource[0]) 204 | pSource[0] = Long(pSource[1]) 205 | pSource[1] = L.xor(R) 206 | }) 207 | pSource.reverse() 208 | out = pSource[1].shiftLeft(32).and(-4294967296).or( 209 | pSource[0].and(0xFFFFFFFF) 210 | ) 211 | out = bitTransform(arrayIP_1, 64, out) 212 | return out 213 | } 214 | 215 | 216 | const subKeys = (l, longs, n) => { // long, long[], int 217 | let l2 = bitTransform(arrayPC_1, 56, l) 218 | range(16).forEach(i => { 219 | l2 = ( 220 | l2.and(arrayLsMask[arrayLs[i]]).shiftLeft(28 - arrayLs[i]).or( 221 | l2.and(arrayLsMask[arrayLs[i]].not()).shiftRight(arrayLs[i]) 222 | ) 223 | ) 224 | longs[i] = bitTransform(arrayPC_2, 64, l2) 225 | }) 226 | if (n === 1) { 227 | range(8).forEach(j => { 228 | [longs[j], longs[15 - j]] = [longs[15 - j], longs[j]] 229 | }) 230 | } 231 | } 232 | 233 | const crypt = (msg, key, mode) => { 234 | // 处理密钥块 235 | let l = Long(0) 236 | range(8).forEach(i => { 237 | l = Long(key[i]).shiftLeft(i * 8).or(l) 238 | }) 239 | 240 | const j = Math.floor(msg.length / 8) 241 | // arrLong1 存放的是转换后的密钥块, 在解密时只需要把这个密钥块反转就行了 242 | 243 | const arrLong1 = range(16).map(() => Long(0)) 244 | subKeys(l, arrLong1, mode) 245 | 246 | // arrLong2 存放的是前部分的明文 247 | const arrLong2 = range(j).map(() => Long(0)) 248 | 249 | range(j).forEach(m => { 250 | range(8).forEach(n => { 251 | arrLong2[m] = Long(msg[n + m * 8]).shiftLeft(n * 8).or(arrLong2[m]) 252 | }) 253 | }) 254 | 255 | // 用于存放密文 256 | const arrLong3 = range(Math.floor((1 + 8 * (j + 1)) / 8)).map(() => Long(0)) 257 | 258 | // 计算前部的数据块(除了最后一部分) 259 | range(j).forEach(i1 => { 260 | arrLong3[i1] = DES64(arrLong1, arrLong2[i1]) 261 | }) 262 | 263 | // 保存多出来的字节 264 | const arrByte1 = msg.slice(j * 8) 265 | let l2 = Long(0) 266 | 267 | range(msg.length % 8).forEach(i1 => { 268 | l2 = Long(arrByte1[i1]).shiftLeft(i1 * 8).or(l2) 269 | }) 270 | 271 | // 计算多出的那一位(最后一位) 272 | if (arrByte1.length || mode === 0) arrLong3[j] = DES64(arrLong1, l2) // 解密不需要 273 | 274 | // 将密文转为字节型 275 | const arrByte2 = range(8 * arrLong3.length).map(() => 0) 276 | let i4 = 0 277 | arrLong3.forEach(l3 => { 278 | range(8).forEach(i6 => { 279 | arrByte2[i4] = l3.shiftRight(i6 * 8).and(255).low 280 | i4 += 1 281 | }) 282 | }) 283 | return Buffer.from(arrByte2) 284 | } 285 | 286 | const SECRET_KEY = Buffer.from('ylzsxkwm') 287 | const encrypt = msg => crypt(msg, SECRET_KEY, 0) 288 | const decrypt = msg => crypt(msg, SECRET_KEY, 1) 289 | const encryptQuery = query => encrypt(Buffer.from(query)).toString('base64') 290 | 291 | module.exports = {encrypt, decrypt, encryptQuery} -------------------------------------------------------------------------------- /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: {id: song.album_id, name: song.album_title}, 12 | artists: song.author.split(',').map((name, index) => ({id: artistId[index], name})) 13 | } 14 | } 15 | 16 | const search = info => { 17 | const url = 18 | 'http://musicapi.taihe.com/v1/restserver/ting?' + 19 | 'from=qianqianmini&method=baidu.ting.search.merge&' + 20 | 'isNew=1&platform=darwin&page_no=1&page_size=30&' + 21 | `query=${encodeURIComponent(info.keyword)}&version=11.2.1` 22 | 23 | return request('GET', url) 24 | .then(response => response.json()) 25 | .then(jsonBody => { 26 | const list = jsonBody.result.song_info.song_list.map(format) 27 | const matched = select(list, info) 28 | return matched ? matched.id : Promise.reject() 29 | }) 30 | } 31 | 32 | const track = id => { 33 | const url = 34 | 'http://music.taihe.com/data/music/fmlink?' + 35 | 'songIds=' + id + '&type=mp3' 36 | 37 | return request('GET', url) 38 | .then(response => response.json()) 39 | .then(jsonBody => { 40 | if ('songList' in jsonBody.data) 41 | return jsonBody.data.songList[0].songLink || Promise.reject() 42 | else 43 | return Promise.reject() 44 | }) 45 | .catch(() => insure().baidu.track(id)) 46 | } 47 | 48 | const check = info => cache(search, info).then(track) 49 | 50 | module.exports = {check} -------------------------------------------------------------------------------- /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) && {[key]: object[key]}), {}) 5 | // Object.keys(object).filter(key => !keys.includes(key)).forEach(key => delete object[key]) 6 | 7 | const limit = text => { 8 | const output = [text[0]] 9 | const length = () => output.reduce((sum, token) => sum + token.length, 0) 10 | text.slice(1).some(token => length() > 15 ? true : (output.push(token), false)) 11 | return output 12 | } 13 | 14 | const find = id => { 15 | const url = 16 | 'https://music.163.com/api/song/detail?ids=[' + id + ']' 17 | 18 | return request('GET', url) 19 | .then(response => response.json()) 20 | .then(jsonBody => { 21 | const info = filter(jsonBody.songs[0], ['id', 'name', 'alias', 'duration']) 22 | info.name = (info.name || '') 23 | .replace(/(\s*cover[::\s][^)]+)/i, '') 24 | .replace(/\(\s*cover[::\s][^\)]+\)/i, '') 25 | .replace(/(\s*翻自[::\s][^)]+)/, '') 26 | .replace(/\(\s*翻自[::\s][^\)]+\)/, '') 27 | info.album = filter(jsonBody.songs[0].album, ['id', 'name']) 28 | info.artists = jsonBody.songs[0].artists.map(artist => filter(artist, ['id', 'name'])) 29 | info.keyword = info.name + ' - ' + limit(info.artists.map(artist => artist.name)).join(' / ') 30 | return info.name ? info : Promise.reject() 31 | }) 32 | } 33 | 34 | module.exports = id => cache(find, id) -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } 11 | 12 | const fit = info => { 13 | if (/[\u0800-\u4e00]/.test(info.name)) //is japanese 14 | return info.name 15 | else 16 | return info.keyword 17 | } 18 | 19 | const format = song => { 20 | const {decode} = crypto.base64 21 | return { 22 | id: song.songid, 23 | name: decode(song.info1 || ''), 24 | duration: song.playtime * 1000, 25 | album: {id: song.albummid, name: decode(song.info3 || '')}, 26 | artists: song.singer_list.map(({id, name}) => ({id, name: decode(name || '')})) 27 | } 28 | } 29 | 30 | const search = info => { 31 | const keyword = fit(info) 32 | const url = 33 | 'http://api-jooxtt.sanook.com/web-fcgi-bin/web_search?' + 34 | 'country=hk&lang=zh_TW&' + 35 | 'search_input=' + encodeURIComponent(keyword) + '&sin=0&ein=30' 36 | 37 | return request('GET', url, headers) 38 | .then(response => response.body()) 39 | .then(body => { 40 | const jsonBody = JSON.parse(body.replace(/'/g, '"')) 41 | const list = jsonBody.itemlist.map(format) 42 | const matched = select(list, info) 43 | return matched ? matched.id : Promise.reject() 44 | }) 45 | } 46 | 47 | const track = id => { 48 | const url = 49 | 'http://api.joox.com/web-fcgi-bin/web_get_songinfo?' + 50 | 'songid=' + id + '&country=hk&lang=zh_cn&from_type=-1&' + 51 | 'channel_id=-1&_=' + (new Date).getTime() 52 | 53 | return request('GET', url, headers) 54 | .then(response => response.jsonp()) 55 | .then(jsonBody => { 56 | const songUrl = (jsonBody.r320Url || jsonBody.r192Url || jsonBody.mp3Url || jsonBody.m4aUrl).replace(/M\d00([\w]+).mp3/, 'M800$1.mp3') 57 | if (songUrl) 58 | return songUrl 59 | else 60 | return Promise.reject() 61 | }) 62 | .catch(() => insure().joox.track(id)) 63 | } 64 | 65 | const check = info => cache(search, info).then(track) 66 | 67 | module.exports = {check, track} -------------------------------------------------------------------------------- /src/provider/kugou.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 format = song => { 8 | const SingerName = song.SingerName.split('、') 9 | return { 10 | id: song.FileHash, 11 | name: song.SongName, 12 | duration: song.Duration * 1000, 13 | album: {id: song.AlbumID, name: song.AlbumName}, 14 | artists: song.SingerId.map((id, index) => ({id, name: SingerName[index]})) 15 | } 16 | } 17 | 18 | const search = info => { 19 | const url = 20 | 'http://songsearch.kugou.com/song_search_v2?' + 21 | 'keyword=' + encodeURIComponent(info.keyword) + '&page=1' 22 | 23 | return request('GET', url) 24 | .then(response => response.json()) 25 | .then(jsonBody => { 26 | const list = jsonBody.data.lists.map(format) 27 | const matched = select(list, info) 28 | return matched ? matched.id : Promise.reject() 29 | }) 30 | .catch(() => insure().kugou.search(info)) 31 | } 32 | 33 | const track = id => { 34 | // const url = 35 | // 'http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=' + id 36 | 37 | // return request('GET', url) 38 | // .then(response => response.json()) 39 | // .then(jsonBody => jsonBody.url || Promise.reject()) 40 | 41 | const url = 42 | 'http://trackercdn.kugou.com/i/v2/?' + 43 | 'key=' + crypto.md5.digest(`${id}kgcloudv2`) + '&hash=' + id + '&' + 44 | 'br=hq&appid=1005&pid=2&cmd=25&behavior=play' 45 | 46 | return request('GET', url) 47 | .then(response => response.json()) 48 | .then(jsonBody => jsonBody.url[0] || Promise.reject()) 49 | } 50 | 51 | const check = info => cache(search, info).then(track) 52 | 53 | module.exports = {check, search} -------------------------------------------------------------------------------- /src/provider/kuwo.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 format = song => ({ 8 | id: song.musicrid.split('_').pop(), 9 | name: song.name, 10 | duration: song.songTimeMinutes.split(':').reduce((minute, second) => minute * 60 + parseFloat(second), 0) * 1000, 11 | album: {id: song.albumid, name: song.album}, 12 | artists: song.artist.split('&').map((name, index) => ({id: index ? null : song.artistid, name})) 13 | }) 14 | 15 | const search = info => { 16 | // const url = 17 | // // 'http://search.kuwo.cn/r.s?' + 18 | // // 'ft=music&itemset=web_2013&client=kt&' + 19 | // // 'rformat=json&encoding=utf8&' + 20 | // // 'all=' + encodeURIComponent(info.keyword) + '&pn=0&rn=20' 21 | // 'http://search.kuwo.cn/r.s?' + 22 | // 'ft=music&rformat=json&encoding=utf8&' + 23 | // 'rn=8&callback=song&vipver=MUSIC_8.0.3.1&' + 24 | // 'SONGNAME=' + encodeURIComponent(info.name) + '&' + 25 | // 'ARTIST=' + encodeURIComponent(info.artists[0].name) 26 | 27 | // return request('GET', url) 28 | // .then(response => response.body()) 29 | // .then(body => { 30 | // const jsonBody = eval( 31 | // '(' + body 32 | // .replace(/\n/g, '') 33 | // .match(/try\s*\{[^=]+=\s*(.+?)\s*\}\s*catch/)[1] 34 | // .replace(/;\s*song\s*\(.+\)\s*;\s*/, '') + ')' 35 | // ) 36 | // const matched = jsonBody.abslist[0] 37 | // if (matched) 38 | // return matched.MUSICRID.split('_').pop() 39 | // else 40 | // return Promise.reject() 41 | // }) 42 | 43 | const keyword = encodeURIComponent(info.keyword.replace(' - ', '')) 44 | const url = `http://www.kuwo.cn/api/www/search/searchMusicBykeyWord?key=${keyword}&pn=1&rn=30` 45 | 46 | return request('GET', `http://kuwo.cn/search/list?key=${keyword}`) 47 | .then(response => response.headers['set-cookie'].find(line => line.includes('kw_token')).replace(/;.*/, '').split('=').pop()) 48 | .then(token => request('GET', url, {referer: `http://www.kuwo.cn/search/list?key=${keyword}`, csrf: token, cookie: `kw_token=${token}`})) 49 | .then(response => response.json()) 50 | .then(jsonBody => { 51 | const list = jsonBody.data.list.map(format) 52 | const matched = select(list, info) 53 | return matched ? matched.id : Promise.reject() 54 | }) 55 | } 56 | 57 | const track = id => { 58 | const url = (crypto.kuwoapi 59 | ? 'http://mobi.kuwo.cn/mobi.s?f=kuwo&q=' + crypto.kuwoapi.encryptQuery( 60 | 'corp=kuwo&p2p=1&type=convert_url2&sig=0&format=' + ['flac', 'mp3'].slice(select.ENABLE_FLAC ? 0 : 1).join('|') + '&rid=' + id 61 | ) 62 | : 'http://antiserver.kuwo.cn/anti.s?type=convert_url&format=mp3&response=url&rid=MUSIC_' + id // flac refuse 63 | // : 'http://www.kuwo.cn/url?format=mp3&response=url&type=convert_url3&br=320kmp3&rid=' + id // flac refuse 64 | ) 65 | 66 | return request('GET', url, {'user-agent': 'okhttp/3.10.0'}) 67 | .then(response => response.body()) 68 | .then(body => { 69 | const url = (body.match(/http[^\s$"]+/) || [])[0] 70 | return url || Promise.reject() 71 | }) 72 | .catch(() => insure().kuwo.track(id)) 73 | } 74 | 75 | const check = info => cache(search, info).then(track) 76 | 77 | module.exports = {check, track} 78 | -------------------------------------------------------------------------------- /src/provider/match.js: -------------------------------------------------------------------------------- 1 | const find = require('./find') 2 | const request = require('../request') 3 | 4 | const provider = { 5 | netease: require('./netease'), 6 | qq: require('./qq'), 7 | xiami: require('./xiami'), 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 | } 15 | 16 | const match = (id, source) => { 17 | let meta = {} 18 | const candidate = (source || global.source || ['qq', 'kuwo', 'migu']).filter(name => name in provider) 19 | return find(id) 20 | .then(info => { 21 | meta = info 22 | return Promise.all(candidate.map(name => provider[name].check(info).catch(() => {}))) 23 | }) 24 | .then(urls => { 25 | urls = urls.filter(url => url) 26 | return Promise.all(urls.map(url => check(url))) 27 | }) 28 | .then(songs => { 29 | songs = songs.filter(song => song.url) 30 | if (!songs.length) return Promise.reject() 31 | console.log(`[${meta.id}] ${meta.name}\n${songs[0].url}`) 32 | return songs[0] 33 | }) 34 | } 35 | 36 | const check = url => { 37 | const song = {size: 0, br: null, url: null, md5: null} 38 | return Promise.race([request('GET', url, {'range': 'bytes=0-8191'}), new Promise((_, reject) => setTimeout(() => reject(504), 5 * 1000))]) 39 | .then(response => { 40 | if (!response.statusCode.toString().startsWith('2')) return Promise.reject() 41 | if (url.includes('qq.com')) 42 | song.md5 = response.headers['server-md5'] 43 | else if (url.includes('xiami.net') || url.includes('qianqian.com')) 44 | song.md5 = response.headers['etag'].replace(/"/g, '').toLowerCase() 45 | song.size = parseInt((response.headers['content-range'] || '').split('/').pop() || response.headers['content-length']) || 0 46 | song.url = response.url.href 47 | return response.headers['content-length'] === '8192' ? response.body(true) : Promise.reject() 48 | }) 49 | .then(data => { 50 | const bitrate = decode(data) 51 | song.br = (bitrate && !isNaN(bitrate)) ? bitrate * 1000 : null 52 | }) 53 | .catch(() => {}) 54 | .then(() => song) 55 | } 56 | 57 | const decode = buffer => { 58 | const map = { 59 | 3: { 60 | 3: ['free', 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 'bad'], 61 | 2: ['free', 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 'bad'], 62 | 1: ['free', 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 'bad'] 63 | }, 64 | 2: { 65 | 3: ['free', 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 'bad'], 66 | 2: ['free', 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 'bad'] 67 | } 68 | } 69 | map[2][1] = map[2][2] 70 | map[0] = map[2] 71 | 72 | let pointer = 0 73 | if (buffer.slice(0, 4).toString() === 'fLaC') return 999 74 | if (buffer.slice(0, 3).toString() === 'ID3') { 75 | pointer = 6 76 | const size = buffer.slice(pointer, pointer + 4).reduce((summation, value, index) => summation + (value & 0x7f) << (7 * (3 - index)), 0) 77 | pointer = 10 + size 78 | } 79 | const header = buffer.slice(pointer, pointer + 4) 80 | 81 | // https://www.allegro.cc/forums/thread/591512/674023 82 | if ( 83 | header.length === 4 && 84 | header[0] === 0xff && 85 | ((header[1] >> 5) & 0x7) === 0x7 && 86 | ((header[1] >> 1) & 0x3) !== 0 && 87 | ((header[2] >> 4) & 0xf) !== 0xf && 88 | ((header[2] >> 2) & 0x3) !== 0x3 89 | ) { 90 | const version = (header[1] >> 3) & 0x3 91 | const layer = (header[1] >> 1) & 0x3 92 | const bitrate = header[2] >> 4 93 | return map[version][layer][bitrate] 94 | } 95 | } 96 | 97 | module.exports = match -------------------------------------------------------------------------------- /src/provider/migu.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://music.migu.cn/', 9 | 'referer': 'http://music.migu.cn/' 10 | } 11 | 12 | const format = song => { 13 | const singerId = song.singerId.split(/\s*,\s*/) 14 | const singerName = song.singerName.split(/\s*,\s*/) 15 | return { 16 | id: song.copyrightId, 17 | name: song.title, 18 | album: {id: song.albumId, name: song.albumName}, 19 | artists: singerId.map((id, index) => ({id, name: singerName[index]})) 20 | } 21 | } 22 | 23 | const search = info => { 24 | const url = 25 | 'http://m.music.migu.cn/migu/remoting/scr_search_tag?' + 26 | 'keyword=' + encodeURIComponent(info.keyword) + '&type=2&rows=20&pgc=1' 27 | 28 | return request('GET', url) 29 | .then(response => response.json()) 30 | .then(jsonBody => { 31 | const list = ((jsonBody || {}).musics || []).map(format) 32 | const matched = select(list, info) 33 | return matched ? matched.id : Promise.reject() 34 | }) 35 | } 36 | 37 | const single = (id, format) => { 38 | const url = 39 | 'http://music.migu.cn/v3/api/music/audioPlayer/getPlayInfo?' + 40 | 'dataType=2&' + crypto.miguapi.encryptBody({copyrightId: id.toString(), type: format}) 41 | 42 | return request('GET', url, headers) 43 | .then(response => response.json()) 44 | .then(jsonBody => { 45 | const {playUrl} = jsonBody.data 46 | return playUrl ? encodeURI(playUrl) : Promise.reject() 47 | }) 48 | } 49 | 50 | const track = id => 51 | Promise.all( 52 | [3, 2, 1].slice(select.ENABLE_FLAC ? 0 : 1) 53 | .map(format => single(id, format).catch(() => null)) 54 | ) 55 | .then(result => result.find(url => url) || Promise.reject()) 56 | .catch(() => insure().migu.track(id)) 57 | 58 | const check = info => cache(search, info).then(track) 59 | 60 | module.exports = {check, track} -------------------------------------------------------------------------------- /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} -------------------------------------------------------------------------------- /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 | 6 | const headers = { 7 | 'origin': 'http://y.qq.com/', 8 | 'referer': 'http://y.qq.com/', 9 | 'cookie': process.env.QQ_COOKIE || null // 'uin=; qm_keyst=', 10 | } 11 | 12 | const playable = song => { 13 | const switchFlag = song['switch'].toString(2).split('') 14 | switchFlag.pop() 15 | switchFlag.reverse() 16 | const playFlag = switchFlag[0] 17 | const tryFlag = switchFlag[13] 18 | return ((playFlag == 1) || ((playFlag == 1) && (tryFlag == 1))) 19 | } 20 | 21 | const format = song => ({ 22 | id: {song: song.mid, file: song.file.media_mid}, 23 | name: song.name, 24 | duration: song.interval * 1000, 25 | album: {id: song.album.mid, name: song.album.name}, 26 | artists: song.singer.map(({mid, name}) => ({id: mid, name})) 27 | }) 28 | 29 | const search = info => { 30 | const url = 31 | 'https://c.y.qq.com/soso/fcgi-bin/client_search_cp?' + 32 | 'ct=24&qqmusic_ver=1298&new_json=1&remoteplace=txt.yqq.center&' + 33 | 'searchid=46804741196796149&t=0&aggr=1&cr=1&catZhida=1&lossless=0&' + 34 | 'flag_qc=0&p=1&n=20&w=' + encodeURIComponent(info.keyword) + '&' + 35 | 'g_tk=5381&jsonpCallback=MusicJsonCallback10005317669353331&loginUin=0&hostUin=0&' + 36 | 'format=jsonp&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq&needNewCode=0' 37 | 38 | return request('GET', url) 39 | .then(response => response.jsonp()) 40 | .then(jsonBody => { 41 | const list = jsonBody.data.song.list.map(format) 42 | const matched = select(list, info) 43 | return matched ? matched.id : Promise.reject() 44 | }) 45 | } 46 | 47 | const single = (id, format) => { 48 | // const classic = ['001yS0N33yPm1B', '000bog5B2DYgHN', '002bongo1BDtKz', '004RDW5Q2ol2jj', '001oEME64eXNbp', '001e9dH11YeXGp', '0021onBk2QNjBu', '001YoUs11jvsIK', '000SNxc91Mw3UQ', '002k94ea4379uy'] 49 | // id = id || classic[Math.floor(classic.length * Math.random())] 50 | const uin = ((headers.cookie || '').match(/uin=(\d+)/) || [])[1] || '0' 51 | 52 | const concatenate = vkey => { 53 | if (!vkey) return Promise.reject() 54 | const host = ['streamoc.music.tc.qq.com', 'mobileoc.music.tc.qq.com', 'isure.stream.qqmusic.qq.com', 'dl.stream.qqmusic.qq.com', 'aqqmusic.tc.qq.com/amobile.music.tc.qq.com'][3] 55 | return `http://${host}/${format.join(id.file)}?vkey=${vkey}&uin=0&fromtag=8&guid=7332953645` 56 | } 57 | 58 | // const url = 59 | // 'https://c.y.qq.com/base/fcgi-bin/fcg_music_express_mobile3.fcg' + 60 | // '?g_tk=0&loginUin=0&hostUin=0&format=json&inCharset=utf8' + 61 | // '&outCharset=utf-8¬ice=0&platform=yqq&needNewCode=0' + 62 | // '&cid=205361747&uin=0&guid=7332953645' + 63 | // '&songmid='+ id.song + '&filename='+ format.join(id.file) 64 | 65 | // return request('GET', url, headers) 66 | // .then(response => response.json()) 67 | // .then(jsonBody => { 68 | // const {vkey} = jsonBody.data.items[0] 69 | // return concatenate(vkey) 70 | // }) 71 | 72 | const url = 73 | 'https://u.y.qq.com/cgi-bin/musicu.fcg?data=' + 74 | encodeURIComponent(JSON.stringify({ 75 | // req: { 76 | // method: 'GetCdnDispatch', 77 | // module: 'CDN.SrfCdnDispatchServer', 78 | // param: { 79 | // calltype: 0, 80 | // guid: '7332953645', 81 | // userip: '' 82 | // } 83 | // }, 84 | req_0: { 85 | module: 'vkey.GetVkeyServer', 86 | method: 'CgiGetVkey', 87 | param: { 88 | guid: '7332953645', 89 | loginflag: 1, 90 | filename: [format.join(id.file)], 91 | songmid: [id.song], 92 | songtype: [0], 93 | uin, 94 | platform: '20' 95 | } 96 | } 97 | })) 98 | 99 | return request('GET', url, headers) 100 | .then(response => response.json()) 101 | .then(jsonBody => { 102 | const { sip, midurlinfo } = jsonBody.req_0.data 103 | // const vkey = 104 | // jsonBody.req_0.data.midurlinfo[0].vkey || 105 | // (jsonBody.req_0.data.testfile2g.match(/vkey=(\w+)/) || [])[1] 106 | // return concatenate(vkey) 107 | return midurlinfo[0].purl ? sip[0] + midurlinfo[0].purl : Promise.reject() 108 | }) 109 | } 110 | 111 | const track = id => { 112 | id.key = id.file 113 | return Promise.all( 114 | [['F000', '.flac'], ['M800', '.mp3'], ['M500', '.mp3']].slice((headers.cookie || typeof(window) !== 'undefined') ? (select.ENABLE_FLAC ? 0 : 1) : 2) 115 | .map(format => single(id, format).catch(() => null)) 116 | ) 117 | .then(result => result.find(url => url) || Promise.reject()) 118 | .catch(() => insure().qq.track(id)) 119 | 120 | // return request( 121 | // 'POST', 'http://acc.music.qq.com/base/fcgi-bin/fcg_music_express_mobile2.fcg', {}, 122 | // ` 123 | // 9070003190 124 | // 4600100100105 125 | // 02123456782 126 | // 352iosM800${id}.mp30 127 | // `.replace(/\s/, '') 128 | // ) 129 | // .then(response => response.body(true)) 130 | // .then(body => { 131 | // const xml = require('zlib').inflateSync(body.slice(5)).toString() 132 | // const focus = xml.match(/(.+)<\/item>/) 133 | // return `http://streamoc.music.tc.qq.com/${focus[1]}?vkey=${focus[2]}&guid=0&uin=12345678&fromtag=6` 134 | // }) 135 | 136 | // const url = 137 | // 'https://i.y.qq.com/v8/playsong.html?ADTAG=newyqq.song&songmid=' + id 138 | 139 | // const mobile = {'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1'} 140 | // return request('GET', url, mobile) 141 | // .then(response => response.body()) 142 | // .then(body => { 143 | // const audio = body.match(/]+src="([^"]+)"[^>]*>/) 144 | // if (audio) 145 | // return audio[1].replace(/C400(\w+)\.m4a/, 'M500$1.mp3') 146 | // else 147 | // return Promise.reject() 148 | // }) 149 | } 150 | 151 | const check = info => cache(search, info).then(track) 152 | 153 | module.exports = {check, track} -------------------------------------------------------------------------------- /src/provider/select.js: -------------------------------------------------------------------------------- 1 | module.exports = list => list[0] 2 | 3 | module.exports.ENABLE_FLAC = (process.env.ENABLE_FLAC || '').toLowerCase() === 'true' -------------------------------------------------------------------------------- /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: {id: song.album_id, name: song.album_name}, 17 | artists: [{id: song.artist_id, name: song.artist_name}] 18 | }) 19 | 20 | const caesar = pattern => { 21 | const height = parseInt(pattern[0]) 22 | pattern = pattern.slice(1) 23 | const width = Math.ceil(pattern.length / height) 24 | const unpad = height - (width * height - pattern.length) 25 | 26 | const matrix = Array.from(Array(height).keys()).map(i => 27 | pattern.slice(i < unpad ? i * width : unpad * width + (i - unpad) * (width - 1)).slice(0, i < unpad ? width : width - 1) 28 | ) 29 | 30 | const transpose = Array.from(Array(width).keys()).map(x => 31 | Array.from(Array(height).keys()).map(y => matrix[y][x]).join('') 32 | ) 33 | 34 | return unescape(transpose.join('')).replace(/\^/g, '0') 35 | } 36 | 37 | const token = () => { 38 | return request('GET', 'https://www.xiami.com') 39 | .then(response => 40 | response.headers['set-cookie'].map(line => line.replace(/;.+$/, '')).reduce( 41 | (cookie, line) => (line = line.split(/\s*=\s*/).map(decodeURIComponent), Object.assign(cookie, {[line[0]]: line[1]})), {} 42 | ) 43 | ) 44 | } 45 | 46 | // const search = info => { 47 | // return cache(token) 48 | // .then(cookie => { 49 | // const query = JSON.stringify({key: info.keyword, pagingVO: {page: 1, pageSize: 60}}) 50 | // const message = cookie['xm_sg_tk'].split('_')[0] + '_xmMain_/api/search/searchSongs_' + query 51 | // return request('GET', 'https://www.xiami.com/api/search/searchSongs?_q=' + encodeURIComponent(query) + '&_s=' + crypto.md5.digest(message), { 52 | // referer: 'https://www.xiami.com/search?key=' + encodeURIComponent(info.keyword), 53 | // cookie: Object.keys(cookie).map(key => encodeURIComponent(key) + '=' + encodeURIComponent(cookie[key])).join('; ') 54 | // }) 55 | // .then(response => response.json()) 56 | // .then(jsonBody => { 57 | // const matched = jsonBody.result.data.songs[0] 58 | // if (matched) 59 | // return matched.songId 60 | // else 61 | // return Promise.reject() 62 | // }) 63 | // }) 64 | // } 65 | 66 | const search = info => { 67 | const url = 68 | 'http://api.xiami.com/web?v=2.0&app_key=1' + 69 | '&key=' + encodeURIComponent(info.keyword) + '&page=1' + 70 | '&limit=20&callback=jsonp&r=search/songs' 71 | 72 | return request('GET', url, headers) 73 | .then(response => response.jsonp()) 74 | .then(jsonBody => { 75 | const list = jsonBody.data.songs.map(format) 76 | const matched = select(list, info) 77 | return matched ? matched.id : Promise.reject() 78 | }) 79 | } 80 | 81 | // const track = id => { 82 | // const url = 83 | // 'https://emumo.xiami.com/song/playlist/id/' + id + 84 | // '/object_name/default/object_id/0/cat/json' 85 | 86 | // return request('GET', url, headers) 87 | // .then(response => response.json()) 88 | // .then(jsonBody => { 89 | // if (jsonBody.data.trackList == null) { 90 | // return Promise.reject() 91 | // } 92 | // else { 93 | // const location = jsonBody.data.trackList[0].location 94 | // const songUrl = 'http:' + caesar(location) 95 | // return songUrl 96 | // } 97 | // }) 98 | // .then(origin => { 99 | // const updated = origin.replace('m128', 'm320') 100 | // return request('HEAD', updated) 101 | // .then(response => response.statusCode == 200 ? updated : origin) 102 | // .catch(() => origin) 103 | // }) 104 | // .catch(() => insure().xiami.track(id)) 105 | // } 106 | 107 | const track = id => { 108 | const url = 109 | 'https://api.xiami.com/web?v=2.0&app_key=1' + 110 | '&id=' + id + '&callback=jsonp&r=song/detail' 111 | 112 | return request('GET', url, headers) 113 | .then(response => response.jsonp()) 114 | .then(jsonBody => 115 | jsonBody.data.song.listen_file || Promise.reject() 116 | ) 117 | .catch(() => insure().xiami.track(id)) 118 | } 119 | 120 | const check = info => cache(search, info).then(track) 121 | 122 | module.exports = {check, track} -------------------------------------------------------------------------------- /src/provider/youtube.js: -------------------------------------------------------------------------------- 1 | const cache = require('../cache') 2 | const request = require('../request') 3 | const parse = query => (query || '').split('&').reduce((result, item) => (item = item.split('=').map(decodeURIComponent), Object.assign({}, result, {[item[0]]: item[1]})), {}) 4 | 5 | // const proxy = require('url').parse('http://127.0.0.1:1080') 6 | const proxy = undefined 7 | const key = process.env.YOUTUBE_KEY || null // YouTube Data API v3 8 | 9 | const signature = (id = '-tKVN2mAKRI') => { 10 | const url = 11 | `https://www.youtube.com/watch?v=${id}` 12 | 13 | return request('GET', url, {}, null, proxy) 14 | .then(response => response.body()) 15 | .then(body => { 16 | let assets = /"assets":{[^}]+}/.exec(body)[0] 17 | assets = JSON.parse(`{${assets}}`).assets 18 | return request('GET', 'https://youtube.com' + assets.js, {}, null, proxy).then(response => response.body()) 19 | }) 20 | .then(body => { 21 | const [_, funcArg, funcBody] = /function\((\w+)\)\s*{([^}]+split\(""\)[^}]+join\(""\))};/.exec(body) 22 | const helperName = /;(.+?)\..+?\(/.exec(funcBody)[1] 23 | const helperContent = new RegExp(`var ${helperName}={[\\s\\S]+?};`).exec(body)[0] 24 | return new Function([funcArg], helperContent + '\n' + funcBody) 25 | }) 26 | } 27 | 28 | const apiSearch = info => { 29 | const url = 30 | `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(info.keyword)}&type=video&key=${key}` 31 | 32 | return request('GET', url, {accept: 'application/json'}, null, proxy) 33 | .then(response => response.json()) 34 | .then(jsonBody => { 35 | const matched = jsonBody.items[0] 36 | if (matched) 37 | return matched.id.videoId 38 | else 39 | return Promise.reject() 40 | }) 41 | } 42 | 43 | const search = info => { 44 | const url = 45 | `https://www.youtube.com/results?search_query=${encodeURIComponent(info.keyword)}` 46 | 47 | return request('GET', url, {}, null, proxy) 48 | .then(response => response.body()) 49 | .then(body => { 50 | const initialData = JSON.parse(body.match(/window\["ytInitialData"\]\s*=\s*([^;]+);/)[1]) 51 | const matched = initialData.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents[0] 52 | if (matched) 53 | return matched.videoRenderer.videoId 54 | else 55 | return Promise.reject() 56 | }) 57 | } 58 | 59 | const track = id => { 60 | const url = 61 | `https://www.youtube.com/get_video_info?video_id=${id}&el=detailpage` 62 | 63 | return request('GET', url, {}, null, proxy) 64 | .then(response => response.body()) 65 | .then(body => JSON.parse(parse(body).player_response).streamingData) 66 | .then(streamingData => { 67 | const stream = streamingData.formats.concat(streamingData.adaptiveFormats) 68 | .find(format => format.itag === 140) 69 | // .filter(format => [249, 250, 140, 251].includes(format.itag)) // NetaseMusic PC client do not support webm format 70 | // .sort((a, b) => b.bitrate - a.bitrate)[0] 71 | const target = parse(stream.signatureCipher) 72 | return stream.url || (target.sp.includes('sig') ? cache(signature, undefined, 24 * 60 * 60 * 1000).then(sign => target.url + '&sig=' + sign(target.s)) : target.url) 73 | }) 74 | } 75 | 76 | const check = info => cache(key ? apiSearch : search, info).then(track) 77 | 78 | module.exports = {check, track} 79 | -------------------------------------------------------------------------------- /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/66.0.3359.181 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 | buffer = (buffer.length && ['gzip', 'deflate'].includes(connect.headers['content-encoding'])) ? zlib.unzipSync(buffer) : buffer 86 | return raw ? buffer : buffer.toString() 87 | }) 88 | 89 | const json = connect => read(connect, false).then(body => JSON.parse(body)) 90 | const jsonp = connect => read(connect, false).then(body => JSON.parse(body.slice(body.indexOf('(') + 1, -')'.length))) 91 | 92 | request.read = read 93 | request.create = create 94 | request.translate = translate 95 | request.configure = configure 96 | 97 | module.exports = request -------------------------------------------------------------------------------- /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, {'Content-Type': 'application/x-ns-proxy-autoconfig'}) 16 | res.end(` 17 | function FindProxyForURL(url, host) { 18 | if (${Array.from(hook.target.host).map(host => (`host == '${host}'`)).join(' || ')}) { 19 | return 'PROXY ${url.hostname}:${url.port || 80}' 20 | } 21 | return 'DIRECT' 22 | } 23 | `) 24 | } 25 | else { 26 | const ctx = {res, req} 27 | Promise.resolve() 28 | .then(() => proxy.protect(ctx)) 29 | .then(() => proxy.authenticate(ctx)) 30 | .then(() => hook.request.before(ctx)) 31 | .then(() => proxy.filter(ctx)) 32 | .then(() => proxy.log(ctx)) 33 | .then(() => proxy.mitm.request(ctx)) 34 | .then(() => hook.request.after(ctx)) 35 | .then(() => proxy.mitm.response(ctx)) 36 | .catch(() => proxy.mitm.close(ctx)) 37 | } 38 | }, 39 | tunnel: (req, socket, head) => { 40 | const ctx = {req, socket, head} 41 | Promise.resolve() 42 | .then(() => proxy.protect(ctx)) 43 | .then(() => proxy.authenticate(ctx)) 44 | .then(() => hook.connect.before(ctx)) 45 | .then(() => proxy.filter(ctx)) 46 | .then(() => proxy.log(ctx)) 47 | .then(() => proxy.tunnel.connect(ctx)) 48 | .then(() => proxy.tunnel.dock(ctx)) 49 | .then(() => hook.negotiate.before(ctx)) 50 | .then(() => proxy.tunnel.pipe(ctx)) 51 | .catch(() => proxy.tunnel.close(ctx)) 52 | } 53 | }, 54 | abort: (socket, from) => { 55 | // console.log('call abort', from) 56 | if (socket) socket.end() 57 | if (socket && !socket.destroyed) socket.destroy() 58 | }, 59 | protect: ctx => { 60 | const {req, res, socket} = ctx 61 | if (req) req.on('error', () => proxy.abort(req.socket, 'req')) 62 | if (res) res.on('error', () => proxy.abort(res.socket, 'res')) 63 | if (socket) socket.on('error', () => proxy.abort(socket, 'socket')) 64 | }, 65 | log: ctx => { 66 | const {req, socket, decision} = ctx 67 | const mark = {close: '|', blank: '-', proxy: '>'}[decision] || '>' 68 | if (socket) 69 | console.log('TUNNEL', mark, req.url) 70 | else 71 | console.log('MITM', mark, parse(req.url).host, req.socket.encrypted ? '(ssl)' : '') 72 | }, 73 | authenticate: ctx => { 74 | const {req, res, socket} = ctx 75 | const credential = Buffer.from((req.headers['proxy-authorization'] || '').split(/\s+/).pop() || '', 'base64').toString() 76 | if ('proxy-authorization' in req.headers) delete req.headers['proxy-authorization'] 77 | if (server.authentication && credential != server.authentication && (socket || req.url.startsWith('http://'))) { 78 | if (socket) 79 | socket.write('HTTP/1.1 407 Proxy Auth Required\r\nProxy-Authenticate: Basic realm="realm"\r\n\r\n') 80 | else 81 | res.writeHead(407, {'proxy-authenticate': 'Basic realm="realm"'}) 82 | return Promise.reject(ctx.error = 'authenticate') 83 | } 84 | }, 85 | filter: ctx => { 86 | if (ctx.decision || ctx.req.local) return 87 | const url = parse((ctx.socket ? 'https://' : '') + ctx.req.url) 88 | const match = pattern => url.href.search(new RegExp(pattern, 'g')) != -1 89 | try { 90 | const allow = server.whitelist.some(match) 91 | const deny = server.blacklist.some(match) 92 | // console.log('allow', allow, 'deny', deny) 93 | if (!allow && deny) { 94 | return Promise.reject(ctx.error = 'filter') 95 | } 96 | } 97 | catch(error) { 98 | ctx.error = error 99 | } 100 | }, 101 | mitm: { 102 | request: ctx => new Promise((resolve, reject) => { 103 | if (ctx.decision === 'close') return reject(ctx.error = ctx.decision) 104 | const {req} = ctx 105 | const url = parse(req.url) 106 | const options = request.configure(req.method, url, req.headers) 107 | ctx.proxyReq = request.create(url)(options) 108 | .on('response', proxyRes => resolve(ctx.proxyRes = proxyRes)) 109 | .on('error', error => reject(ctx.error = error)) 110 | req.readable ? req.pipe(ctx.proxyReq) : ctx.proxyReq.end(req.body) 111 | }), 112 | response: ctx => { 113 | const {res, proxyRes} = ctx 114 | proxyRes.on('error', () => proxy.abort(proxyRes.socket, 'proxyRes')) 115 | res.writeHead(proxyRes.statusCode, proxyRes.headers) 116 | proxyRes.readable ? proxyRes.pipe(res) : res.end(proxyRes.body) 117 | }, 118 | close: ctx => { 119 | proxy.abort(ctx.res.socket, 'mitm') 120 | } 121 | }, 122 | tunnel: { 123 | connect: ctx => new Promise((resolve, reject) => { 124 | if (ctx.decision === 'close') return reject(ctx.error = ctx.decision) 125 | const {req} = ctx 126 | const url = parse('https://' + req.url) 127 | if (global.proxy && !req.local) { 128 | const options = request.configure(req.method, url, req.headers) 129 | request.create(proxy)(options) 130 | .on('connect', (_, proxySocket) => resolve(ctx.proxySocket = proxySocket)) 131 | .on('error', error => reject(ctx.error = error)) 132 | .end() 133 | } 134 | else { 135 | const proxySocket = net.connect(url.port || 443, request.translate(url.hostname)) 136 | .on('connect', () => resolve(ctx.proxySocket = proxySocket)) 137 | .on('error', error => reject(ctx.error = error)) 138 | } 139 | }), 140 | dock: ctx => new Promise(resolve => { 141 | const {req, head, socket} = ctx 142 | socket 143 | .once('data', data => resolve(ctx.head = Buffer.concat([head, data]))) 144 | .write(`HTTP/${req.httpVersion} 200 Connection established\r\n\r\n`) 145 | }).then(data => ctx.socket.sni = sni(data)).catch(() => {}), 146 | pipe: ctx => { 147 | if (ctx.decision === 'blank') return Promise.reject(ctx.error = ctx.decision) 148 | const {head, socket, proxySocket} = ctx 149 | proxySocket.on('error', () => proxy.abort(ctx.proxySocket, 'proxySocket')) 150 | proxySocket.write(head) 151 | socket.pipe(proxySocket) 152 | proxySocket.pipe(socket) 153 | }, 154 | close: ctx => { 155 | proxy.abort(ctx.socket, 'tunnel') 156 | } 157 | } 158 | } 159 | 160 | const options = { 161 | key: fs.readFileSync(path.join(__dirname, '..', 'server.key')), 162 | cert: fs.readFileSync(path.join(__dirname, '..', 'server.crt')) 163 | } 164 | 165 | const server = { 166 | http: require('http').createServer().on('request', proxy.core.mitm).on('connect', proxy.core.tunnel), 167 | https: require('https').createServer(options).on('request', proxy.core.mitm).on('connect', proxy.core.tunnel) 168 | } 169 | 170 | server.whitelist = [] 171 | server.blacklist = ['://127\\.\\d+\\.\\d+\\.\\d+', '://localhost'] 172 | server.authentication = null 173 | 174 | module.exports = server -------------------------------------------------------------------------------- /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 | } --------------------------------------------------------------------------------