diff --git a/.gitignore b/.gitignore index 2ff65d2..d0ce6b0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ lx-music-source-v5.js *.log logs/ tmp/ +data/ .env .env.local .env.*.local diff --git a/README.md b/README.md index 17a7133..b1dba48 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ pnpm test # 运行冒烟测试(自动启动/关闭本地服务器) | `GET /singer/:source/:id/songs?page=1&limit=30` | 歌手歌曲列表(kg/tx/wy) | | `GET /singer/:source/:id/albums?page=1&limit=10` | 歌手专辑列表(kg/tx/wy) | | `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/mg/tx) | +| `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) | +| `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) | +| `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) | +| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) | | `GET /sources` | 平台列表(音质与能力) | | `GET /health` | 健康检查 | | `GET /docs` `/redoc` `/openapi.json` | 接口文档(Swagger UI / ReDoc / OpenAPI 3.1) | diff --git a/docs/API.md b/docs/API.md index 867771e..3482a73 100644 --- a/docs/API.md +++ b/docs/API.md @@ -75,6 +75,21 @@ 歌手/专辑 id 来源:搜索结果条目包含 `albumId`;歌手 id 需从平台方获取(不在搜索结果中)。 +### 登录 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/login/wy/qr` | 获取网易云扫码登录二维码(返回 `key`/`qrurl`/`qrimg`,`qrimg` 为 data URL 可直接展示) | +| POST | `/login/wy/qr/check` | 轮询扫码状态,body 传 `{ "key": "..." }`。返回 `code`:800 过期 / 801 等待 / 802 已扫码 / 803 登录成功(自动保存凭据) | +| POST | `/login/wy/cellphone` | 网易云手机号登录;body 传 `phone` + `password`(明文,服务端 MD5 后提交)或 `captcha`,可带 `countrycode`(默认 86) | +| DELETE | `/login/wy` | 退出网易云登录 | +| POST | `/login/kg` | 绑定酷狗 token;body 传 `{ "token": "...", "userid": ... }`(App 抓包获取),服务端向用户中心验证后存储 | +| DELETE | `/login/kg` | 退出酷狗登录 | +| GET | `/login/status` | 登录状态(wy 实时校验 / kg 已存凭据) | + +- 登录态持久化于 `data/auth.json`(不入库),登录后取流与平台请求自动携带凭据。 +- VIP 歌曲完整播放地址暂不在支持范围;登录态目前用于提升请求稳定性与为后续能力预留。 + ## 响应示例 ```jsonc diff --git a/musicSdk/wy/utils/index.ts b/musicSdk/wy/utils/index.ts index c0ad033..35ebc87 100644 --- a/musicSdk/wy/utils/index.ts +++ b/musicSdk/wy/utils/index.ts @@ -1,14 +1,17 @@ import { httpFetch } from '../../request.ts' import { eapi } from './crypto.ts' +import { getWyCookie } from '../../src/auth/store.js' import type { HttpRequestObject } from '../../types.ts' export const eapiRequest = (url: string, data: object): HttpRequestObject => { + // 登录后携带 MUSIC_U(见 /login/wy),未登录时不附加 + const cookie = getWyCookie() return httpFetch('http://interface.music.163.com/eapi/batch', { method: 'post', headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36', origin: 'https://music.163.com', - // cookie: 'os=pc; deviceId=A9C064BB4584D038B1565B58CB05F95290998EE8B025AA2D07AE; osver=Microsoft-Windows-10-Home-China-build-19043-64bit; appver=2.5.2.197409; channel=netease; MUSIC_A=37a11f2eb9de9930cad479b2ad495b0e4c982367fb6f909d9a3f18f876c6b49faddb3081250c4980dd7e19d4bd9bf384e004602712cf2b2b8efaafaab164268a00b47359f85f22705cc95cb6180f3aee40f5be1ebf3148d888aa2d90636647d0c3061cd18d77b7a0; __csrf=05b50d54082694f945d7de75c210ef94; mode=Z7M-KP5(7)GZ; NMTID=00OZLp2VVgq9QdwokUgq3XNfOddQyIAAAF_6i8eJg; ntes_kaola_ad=1', + ...(cookie ? { cookie } : {}), }, form: eapi(url, data), }) diff --git a/package.json b/package.json index 4473117..b22026e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "fastify": "^5.12.1", "iconv-lite": "^0.6.3", "isolated-vm": "^7.0.1", - "pako": "^2.1.0" + "pako": "^2.1.0", + "qrcode": "^1.5.4" }, "devDependencies": { "@types/node": "^26.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7791e97..63b1ebb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: pako: specifier: ^2.1.0 version: 2.2.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 devDependencies: '@types/node': specifier: ^26.4.0 @@ -383,6 +386,14 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -398,6 +409,20 @@ packages: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + content-disposition@2.0.1: resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} engines: {node: '>=18'} @@ -419,6 +444,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -427,6 +456,12 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -461,6 +496,14 @@ packages: resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} engines: {node: '>=20'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -480,6 +523,10 @@ packages: resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} engines: {node: '>= 10'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + isolated-vm@7.0.1: resolution: {integrity: sha512-nBCQ1hXEaYKYJ79wwfhaF44ITOJC30Hw0wPEsFeNsdD3hdeKK7FW1SGMQYBUA+qasvkqdOazI+iBuq0lcbN/aw==} engines: {node: '>=24.0.0'} @@ -497,6 +544,10 @@ packages: light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -528,9 +579,25 @@ packages: openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + pako@2.2.0: resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -555,12 +622,21 @@ packages: engines: {node: '>=20'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} process-warning@5.1.0: resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -571,10 +647,17 @@ packages: real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -605,6 +688,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -622,6 +708,14 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -642,11 +736,29 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yaml@2.9.1: resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + snapshots: '@fastify/accept-negotiator@2.1.0': {} @@ -796,6 +908,12 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + atomic-sleep@1.0.0: {} avvio@9.3.0: @@ -809,6 +927,20 @@ snapshots: dependencies: balanced-match: 4.0.4 + camelcase@5.3.1: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + content-disposition@2.0.1: {} cookie@1.1.1: {} @@ -819,10 +951,16 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + depd@2.0.0: {} dequal@2.0.3: {} + dijkstrajs@1.0.3: {} + + emoji-regex@8.0.0: {} + escape-html@1.0.3: {} fast-decode-uri-component@1.0.1: {} @@ -876,6 +1014,13 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.1.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + get-caller-file@2.0.5: {} + glob@13.0.6: dependencies: minimatch: 10.2.6 @@ -898,6 +1043,8 @@ snapshots: ipaddr.js@2.5.0: {} + is-fullwidth-code-point@3.0.0: {} + isolated-vm@7.0.1: dependencies: node-gyp-build: 4.8.4 @@ -922,6 +1069,10 @@ snapshots: process-warning: 4.0.1 set-cookie-parser: 2.7.2 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lru-cache@11.5.2: {} mime@3.0.0: {} @@ -940,8 +1091,20 @@ snapshots: openapi-types@12.1.3: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + pako@2.2.0: {} + path-exists@4.0.0: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.5.2 @@ -973,18 +1136,30 @@ snapshots: dependencies: playwright-core: 1.63.0 + pngjs@5.0.0: {} + process-warning@4.0.1: {} process-warning@5.1.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + quick-format-unescaped@4.0.4: {} real-require@0.2.0: {} real-require@1.0.0: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + ret@0.5.0: {} reusify@1.1.0: {} @@ -1003,6 +1178,8 @@ snapshots: semver@7.8.5: {} + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} setprototypeof@1.2.0: {} @@ -1015,6 +1192,16 @@ snapshots: statuses@2.0.2: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -1048,4 +1235,33 @@ snapshots: undici-types@8.9.0: {} + which-module@2.0.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@4.0.3: {} + yaml@2.9.1: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 diff --git a/src/apis/kg.js b/src/apis/kg.js index f1a5049..5f741af 100644 --- a/src/apis/kg.js +++ b/src/apis/kg.js @@ -4,6 +4,7 @@ // promise resolve → { type, url } import { httpFetch } from '../common/request.js' +import { getKgAuth } from '../auth/store.js' let mid = '' @@ -16,7 +17,8 @@ const getMid = () => { } const getPlayUrl = (hash, albumId) => getMid().then(m => { - const url = `https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash=${hash}${albumId ? `&album_id=${albumId}` : ''}&mid=${m}` + const auth = getKgAuth() + const url = `https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash=${hash}${albumId ? `&album_id=${albumId}` : ''}&mid=${m}${auth ? `&token=${encodeURIComponent(auth.token)}&userid=${auth.userid}` : ''}` return httpFetch(url, { method: 'get', headers: { diff --git a/src/apis/wy.js b/src/apis/wy.js index a2ca0e5..3e6f1e1 100644 --- a/src/apis/wy.js +++ b/src/apis/wy.js @@ -6,6 +6,7 @@ import { httpFetch } from '../common/request.js' import { eapi } from '../../musicSdk/wy/utils/crypto.ts' +import { getWyCookie } from '../auth/store.js' const levelMap = { '128k': 'standard', @@ -21,11 +22,13 @@ export const getMusicUrl = (songInfo, type) => { level: levelMap[type] || 'standard', encodeType: 'flac', } + const cookie = getWyCookie() const request = httpFetch('https://interface3.music.163.com/eapi/song/enhance/player/url/v1', { method: 'post', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', origin: 'https://music.163.com', + ...(cookie ? { cookie } : {}), }, form: eapi('/api/song/enhance/player/url/v1', data), timeout: 15000, diff --git a/src/auth/kg.js b/src/auth/kg.js new file mode 100644 index 0000000..74f8c74 --- /dev/null +++ b/src/auth/kg.js @@ -0,0 +1,105 @@ +// 酷狗登录 —— token 绑定与验证(网关 android 签名,与 KuGouMusicApi 对齐) +// 完整的手机号/扫码登录需移植其 RSA/AES 全套协议,后续按需扩展; +// token 可从酷狗 App 抓包获取(login.token) + +import crypto from 'node:crypto' +import { httpFetch } from '../common/request.js' +import { getCredentials, setCredentials } from './store.js' + +const md5 = s => crypto.createHash('md5').update(s).digest('hex') +const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d' +const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp' +const APPID = 1005 +const CLIENTVER = 11430 +const PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB +-----END PUBLIC KEY-----` + +const calculateMid = (guid) => BigInt(`0x${md5(guid)}`).toString() +const randomString = (n) => { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789' + return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('') +} + +/** 设备标识持久化在凭据里,保证同一登录态的请求指纹稳定 */ +const device = () => { + const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) } + if (!dev.mid) dev.mid = calculateMid(dev.guid) + setCredentials('_kg_device', dev) + return dev +} + +const androidSign = (params, data = '') => { + const s = Object.keys(params).sort() + .map(k => `${k}=${typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k]}`) + .join('') + return md5(SALT_SIGN + s + data + SALT_SIGN) +} + +/** 网关移动端签名请求(gateway.kugou.com + x-router) */ +const gatewayRequest = async(url, { params: extraParams, data, router }) => { + const dev = device() + const mid = dev.mid || (dev.mid = calculateMid(dev.guid)) + setCredentials('_kg_device', dev) + + const params = { + dfid: dev.dfid, mid, uuid: '-', appid: APPID, clientver: CLIENTVER, + clienttime: Math.floor(Date.now() / 1000), + ...extraParams, + } + const signature = androidSign(params, data ? JSON.stringify(data) : '') + const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&') + const r = await httpFetch(`https://gateway.kugou.com${url}?${qs}&signature=${signature}`, { + method: 'post', + headers: { + dfid: dev.dfid, mid, clienttime: String(params.clienttime), + 'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F', + 'x-router': router, + 'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi', + }, + body: data ?? undefined, + timeout: 15000, + }).promise + return r.body ?? {} +} + +/** + * 验证 token 并返回账号资料(usercenter /v3/get_my_info) + * p = RSA 原始加密(零填充){ token, clienttime } + */ +export const verifyToken = async({ token, userid }) => { + const unixSec = Math.floor(Date.now() / 1000) + const payload = Buffer.from(JSON.stringify({ token, clienttime: unixSec })) + // 原始 RSA(无填充):左侧零填充到密钥长度(128 字节) + const padded = Buffer.concat([Buffer.alloc(128 - payload.length), payload]) + const p = crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_NO_PADDING }, padded).toString('hex').toUpperCase() + + const body = await gatewayRequest('/v3/get_my_info', { + router: 'usercenter.kugou.com', + params: { plat: 1 }, + data: { visit_time: unixSec, usertype: 1, p, userid: Number(userid) || 0 }, + }) + if (body.status !== 1 || !body.data) { + const err = new Error(body.error ?? 'token 无效或已过期') + err.status = 401 + throw err + } + return { + userid: body.data.userid ?? Number(userid), + nickname: body.data.nickname ?? body.data.username ?? null, + vip: body.data.vip ? Boolean(body.data.vip) : null, + } +} + +/** 绑定并验证 kg token */ +export const loginByToken = async({ token, userid }) => { + const profile = await verifyToken({ token, userid }) + setCredentials('kg', { token, userid: profile.userid, nickname: profile.nickname }) + return profile +} + +/** 当前 kg 登录资料(未登录返回 null) */ +export const status = () => { + const cred = getCredentials('kg') + return cred?.token ? { userid: cred.userid, nickname: cred.nickname ?? null } : null +} diff --git a/src/auth/store.d.ts b/src/auth/store.d.ts new file mode 100644 index 0000000..f5fc9bf --- /dev/null +++ b/src/auth/store.d.ts @@ -0,0 +1,29 @@ +// src/auth/store.js 的类型声明(实现为同目录 store.js) +export interface WyCredentials { + cookie: string + musicU: string + userid?: number + nickname?: string | null + avatar?: string | null + vipType?: number | null + updatedAt?: string +} + +export interface KgCredentials { + token: string + userid: number | string + nickname?: string | null + updatedAt?: string +} + +interface KgDevice { + guid: string + mid: string + dfid: string +} + +export declare function getCredentials(platform: 'wy' | 'kg' | '_kg_device'): WyCredentials | KgCredentials | KgDevice | null +export declare function setCredentials(platform: 'wy' | 'kg' | '_kg_device', credentials: object): void +export declare function clearCredentials(platform: 'wy' | 'kg' | '_kg_device'): void +export declare function getWyCookie(): string +export declare function getKgAuth(): { token: string; userid: number | string } | null diff --git a/src/auth/store.js b/src/auth/store.js new file mode 100644 index 0000000..805ef90 --- /dev/null +++ b/src/auth/store.js @@ -0,0 +1,50 @@ +// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库) +// 结构:{ wy: { cookie, musicU, userid?, nickname?, updatedAt }, kg: { token, userid, nickname?, updatedAt } } + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json') + +let cache = null + +const load = () => { + if (!cache) { + try { + cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {} + } catch { + cache = {} + } + } + return cache +} + +const save = (data) => { + cache = data + mkdirSync(dirname(DATA_FILE), { recursive: true }) + writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)) +} + +export const getCredentials = (platform) => load()[platform] ?? null + +export const setCredentials = (platform, credentials) => { + const data = load() + data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() } + save(data) +} + +export const clearCredentials = (platform) => { + const data = load() + delete data[platform] + save(data) +} + +/** wy 登录 cookie 字符串(未登录返回空串) */ +export const getWyCookie = () => getCredentials('wy')?.cookie || '' + +/** kg 登录态(未登录返回 null) */ +export const getKgAuth = () => { + const cred = getCredentials('kg') + return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null +} diff --git a/src/auth/wy.js b/src/auth/wy.js new file mode 100644 index 0000000..bfd418d --- /dev/null +++ b/src/auth/wy.js @@ -0,0 +1,120 @@ +// 网易云登录 —— 扫码 / 手机号,weapi 加密请求 +// 参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status) + +import crypto from 'node:crypto' +import { httpFetch } from '../common/request.js' +import { weapi } from '../../musicSdk/wy/utils/crypto.ts' +import { getWyCookie, setCredentials } from './store.js' + +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' + +const requestWeapi = async(path, data, cookie = getWyCookie()) => { + const r = await httpFetch(`https://music.163.com/weapi${path}`, { + method: 'post', + headers: { + 'User-Agent': UA, + origin: 'https://music.163.com', + Referer: 'https://music.163.com/', + ...(cookie ? { cookie } : {}), + }, + form: weapi(data), + timeout: 15000, + }).promise + if (r.statusCode !== 200) throw new Error('网易请求失败') + return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] } +} + +/** 明文 api 请求(二维码接口只提供该形态,weapi 形态已 404) */ +const requestApi = async(path, data) => { + const r = await httpFetch(`https://music.163.com${path}`, { + method: 'post', + headers: { + 'User-Agent': UA, + origin: 'https://music.163.com', + Referer: 'https://music.163.com/', + }, + form: data, + timeout: 15000, + }).promise + if (r.statusCode !== 200) throw new Error('网易请求失败') + return { body: r.body, setCookie: r.headers['set-cookie'] ?? [] } +} + +/** 从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U */ +const extractLoginCookie = (setCookie) => { + const joined = setCookie.map(c => c.split(';')[0]).filter(Boolean).join('; ') + const musicU = /MUSIC_U=([^;]+)/.exec(joined)?.[1] + return { cookie: joined, musicU } +} + +/** 登录成功后拉取账号资料 */ +const fetchProfile = async() => { + const { body } = await requestWeapi('/api/w/nuser/account/get', {}) + return { + userid: body?.account?.id ?? null, + nickname: body?.profile?.nickname ?? null, + avatar: body?.profile?.avatarUrl ?? null, + vipType: body?.profile?.vipType ?? null, + } +} + +/** 应用登录 cookie(成功后调用) */ +const applyLogin = async(setCookie) => { + const { cookie, musicU } = extractLoginCookie(setCookie) + if (!musicU) throw new Error('登录响应中缺少 MUSIC_U') + const profile = await fetchProfile() + setCredentials('wy', { cookie, musicU, ...profile }) + return profile +} + +/** 创建扫码登录 key 与二维码内容 */ +export const qrKey = async() => { + const { body } = await requestApi('/api/login/qrcode/unikey', { type: 3 }) + if (body?.code !== 200 || !body.unikey) throw new Error('获取二维码 key 失败') + const key = body.unikey + const qrurl = `https://music.163.com/login?codekey=${key}` + const qrimg = await import('qrcode').then(m => m.default.toDataURL(qrurl)).catch(() => '') + return { key, qrurl, qrimg } +} + +/** + * 轮询扫码状态 + * @returns {{ code: 800|801|802|803, message?: string, profile? }} 800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录) + */ +export const qrCheck = async(key) => { + const { body, setCookie } = await requestApi('/api/login/qrcode/client/login', { key, type: 3 }) + if (body.code === 803) { + const profile = await applyLogin(setCookie) + return { code: 803, message: '登录成功', profile } + } + const messages = { 800: '二维码已过期', 801: '等待扫码', 802: '已扫码,待确认' } + return { code: body.code, message: messages[body.code] ?? body.message ?? '未知状态' } +} + +/** + * 手机号登录(密码为明文,传输前本地 MD5;或直接传验证码 captcha) + */ +export const cellphoneLogin = async({ phone, password, captcha, countrycode = '86' }) => { + const data = { + type: '1', + https: 'true', + phone, + countrycode, + remember: 'true', + [captcha ? 'captcha' : 'password']: captcha ?? crypto.createHash('md5').update(password ?? '').digest('hex'), + } + const { body, setCookie } = await requestWeapi('/api/w/login/cellphone', data) + if (body.code !== 200) { + const err = new Error(body.message ?? body.msg ?? '登录失败') + err.status = 401 + throw err + } + const profile = await applyLogin(setCookie) + return profile +} + +/** 当前账号资料(未登录返回 null) */ +export const status = async() => { + if (!getWyCookie()) return null + return fetchProfile() +} diff --git a/src/routes-login.js b/src/routes-login.js new file mode 100644 index 0000000..6095633 --- /dev/null +++ b/src/routes-login.js @@ -0,0 +1,120 @@ +// 登录路由 —— wy 扫码/手机号登录,kg token 绑定,凭据持久化于 data/auth.json +// 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data } + +import * as wyAuth from './auth/wy.js' +import * as kgAuth from './auth/kg.js' +import { clearCredentials, getCredentials } from './auth/store.js' + +const fail = (reply, err) => { + const status = err.status ?? 502 + return reply.code(status).send({ code: status, msg: err.message }) +} + +/** + * 注册登录相关路由 + * @param {import('fastify').FastifyInstance} fastify + */ +export const loginRoutes = async(fastify) => { + // ---------- 网易云 ---------- + + fastify.post('/login/wy/qr', { + schema: { + summary: '获取网易云扫码登录二维码', + description: '用网易云 App 扫码后轮询 /login/wy/qr/check 完成登录。返回 qrimg(data URL)可直接展示。', + tags: ['登录'], + }, + }, async() => wyAuth.qrKey()) + + fastify.post('/login/wy/qr/check', { + schema: { + summary: '轮询网易云扫码状态', + description: 'code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据并返回账号资料)。', + tags: ['登录'], + body: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] }, + }, + }, async(request, reply) => { + try { + return await wyAuth.qrCheck(request.body.key) + } catch (err) { + return fail(reply, err) + } + }) + + fastify.post('/login/wy/cellphone', { + schema: { + summary: '网易云手机号登录', + description: 'password(明文,服务端 MD5 后提交)与 captcha(短信验证码)二选一。', + tags: ['登录'], + body: { + type: 'object', + properties: { + phone: { type: 'string' }, + password: { type: 'string' }, + captcha: { type: 'string' }, + countrycode: { type: 'string', default: '86' }, + }, + required: ['phone'], + }, + }, + }, async(request, reply) => { + try { + return await wyAuth.cellphoneLogin(request.body) + } catch (err) { + return fail(reply, err) + } + }) + + fastify.delete('/login/wy', { + schema: { summary: '退出网易云登录', tags: ['登录'] }, + }, async() => { + clearCredentials('wy') + return { logged: false } + }) + + // ---------- 酷狗 ---------- + + fastify.post('/login/kg', { + schema: { + summary: '绑定酷狗 token', + description: 'token 从酷狗 App 抓包获取(login.token),服务端向用户中心验证后存储。手机号/扫码登录暂未实现。', + tags: ['登录'], + body: { + type: 'object', + properties: { + token: { type: 'string' }, + userid: { type: ['integer', 'string'] }, + }, + required: ['token', 'userid'], + }, + }, + }, async(request, reply) => { + try { + return await kgAuth.loginByToken(request.body) + } catch (err) { + return fail(reply, err) + } + }) + + fastify.delete('/login/kg', { + schema: { summary: '退出酷狗登录', tags: ['登录'] }, + }, async() => { + clearCredentials('kg') + return { logged: false } + }) + + // ---------- 通用 ---------- + + fastify.get('/login/status', { + schema: { + summary: '查看登录状态', + description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg 为已存凭据。', + tags: ['登录'], + }, + }, async() => { + const [wy, kg] = await Promise.all([wyAuth.status().catch(() => null), Promise.resolve(kgAuth.status())]) + return { + wy: wy ? { logged: true, ...wy } : { logged: false }, + kg: kg ? { logged: true, ...kg } : { logged: false }, + } + }) +} diff --git a/src/server.js b/src/server.js index 146dd63..0530993 100644 --- a/src/server.js +++ b/src/server.js @@ -29,6 +29,7 @@ import { dirname, join } from 'node:path' import musicSdk from '../musicSdk/index.js' import { ApiError } from './http/api-error.js' import { musicRoutes } from './routes.js' +import { loginRoutes } from './routes-login.js' import { COMPONENT_SCHEMAS } from './openapi/components.js' import { redocHtml } from './openapi/ui.js' @@ -144,6 +145,7 @@ fastify.register(swagger, { { name: '榜单', description: '排行榜' }, { name: '歌单', description: '歌单与热搜' }, { name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' }, + { name: '登录', description: '账号登录(wy 扫码/手机号、kg token)与状态' }, { name: '系统', description: '健康检查、平台信息、文档' }, ], }, @@ -180,6 +182,7 @@ fastify.get('/', { // ---- 业务路由 ---- fastify.register(musicRoutes) +fastify.register(loginRoutes) export const startServer = async(port = PORT) => { await fastify.listen({ port, host: '0.0.0.0' }) diff --git a/src/smoke-test.js b/src/smoke-test.js index 9e92f70..09770e8 100644 --- a/src/smoke-test.js +++ b/src/smoke-test.js @@ -42,6 +42,25 @@ const postRaw = (path) => new Promise((resolve, reject) => { req.end() }) +// JSON POST(登录等写入接口) +const postJson = (path, body) => new Promise((resolve, reject) => { + const payload = JSON.stringify(body) + const req = http.request(`${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, + }, res => { + let data = '' + res.on('data', c => { data += c }) + res.on('end', () => resolve({ + status: res.statusCode, + body: (() => { try { return JSON.parse(data) } catch { return null } })(), + })) + }) + req.on('error', reject) + req.write(payload) + req.end() +}) + const test = async(name, fn) => { try { const result = await fn() @@ -360,6 +379,28 @@ await test('Swagger UI 规范端点 /docs/json(不可包信封)', async() => return spec.openapi }) +// ---- 登录 ---- + +await test('登录状态 /login/status', async() => { + const r = await get('/login/status') + if (r.code != 0) throw new Error(r.msg) + if (typeof r.data.wy?.logged !== 'boolean' || typeof r.data.kg?.logged !== 'boolean') throw new Error('响应不完整') + return `wy:${r.data.wy.logged} kg:${r.data.kg.logged}` +}) + +await test('扫码二维码创建 POST /login/wy/qr', async() => { + const r = await postJson('/login/wy/qr', {}) + if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`) + if (!r.body.data?.key || !r.body.data?.qrurl || !r.body.data?.qrimg) throw new Error('响应不完整') + return r.body.data.key.slice(0, 18) + '…' +}) + +await test('kg 假 token 拒绝 POST /login/kg', async() => { + const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 }) + if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`) + return r.body?.msg +}) + // 汇总 const passed = results.filter(r => r.ok).length const failed = results.filter(r => !r.ok)