diff --git a/README.md b/README.md index e670cc3..fd1cfc8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ pnpm test # 类型检查(tsc)+ 冒烟测试(自动启动/关闭本 pnpm typecheck # 仅类型检查,不运行 ``` +环境变量(`PORT`、`SOURCE_*` 等)可写入项目根目录的 `.env` 文件(已 gitignore),启动时自动加载;已存在的真实环境变量优先于 `.env` 文件值。 + ## HTTP 接口 接口统一为路径参数风格(`/search`、`/find` 搜索类使用 query 参数)。 @@ -39,7 +41,11 @@ pnpm typecheck # 仅类型检查,不运行 | `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/tx) | | `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) | | `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) | +| `POST /login/kg/qr` → `POST /login/kg/qr/check` | 酷狗扫码登录(扫码后轮询完成登录) | +| `POST /login/kg/captcha` → `POST /login/kg/cellphone` | 酷狗手机验证码登录 | | `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) | +| `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) | +| `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) | | `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) | | `GET /sources` | 平台列表(音质与能力) | | `GET /health` | 健康检查 | @@ -54,34 +60,56 @@ pnpm typecheck # 仅类型检查,不运行 - **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`) - **kg**:`hash`(搜索结果的 `hash` 字段;当前环境 kg 取流接口被服务端限制,URL 获取可能失败) +## 外部音源 + +设置 `SOURCE_ENABLED=1` 后取流切换到外部音源(默认未设置时走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。 + +**社区音源脚本(`.js`)** —— 通用格式,isolated-vm 硬隔离沙箱执行(详见 `src/source-ivm.ts` 头注释),文件名排序即优先级;`SOURCE_SCRIPT_PATH` 可改为指定目录外单脚本,`SOURCE_LOG=0` 关闭请求日志,`SOURCE_ALLOW_ALL_HTTP=1` 放开 HTTP 白名单(调试用)。 + +**简单 API 脚本(`.mjs`)** —— 进程内直接加载、无沙箱的第二套极简契约,只需导出一个 `getMusicUrl`,适合自己写的取流脚本(运行于服务器进程内,请只放自己审计过的代码): + +```js +// sources/99-my-api.mjs —— dev 热重载模式下保存即自动重启生效 +export const sources = ['kw', 'kg', 'tx', 'wy'] // 可选,默认四平台 +export const timeout = 15000 // 可选,单次取流超时 ms(默认 15000) + +export async function getMusicUrl({ source, id, songInfo, quality, signal }) { + // source: 平台 id(kw/kg/tx/wy);id: 歌曲 id(songmid/hash) + // quality: 128k/320k/flac/flac24bit + // signal: 取消/超时信号(AbortSignal),fetch(url, { signal }) 透传即可支持取消 + const res = await fetch(`https://your-api/get?source=${source}&id=${id}&quality=${quality}`, { signal }) + const { data } = await res.json() + return data.url // 播放地址字符串,或 { type, url } +} +``` + ## 项目结构 ``` src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码) server.ts HTTP 服务器入口(pnpm start 实际启动文件) routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据) - routes-login.ts 登录路由(wy 扫码/手机号、kg token、tx cookie) + routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/token、tx 扫码/cookie) + source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流重定向到 sources/ 脚本) + source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱) + source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载) http/ api-error.ts 业务错误类型(404/502 等 → 统一 JSON 响应) openapi/ components.ts OpenAPI 共享 schema(与路由校验同源) ui.ts ReDoc 页面模板 -sdk/ 音乐聚合 SDK(自包含:平台模块 + 取流 + 凭据 + 基础设施;kw/kg/tx/wy 四平台) +sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;kw/kg/tx/wy 四平台) index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts) - api-source.ts 接口选择层(internal 内置直连 / sources/ 外部音源脚本,SOURCE_ENABLED 切换) - apis/ 各平台 URL 直连取流实现(kw/kg/tx/wy)+ 外部音源加载器(source.ts / source-ivm.ts) - auth/ 登录态与登录协议(store.ts 持久化凭据到 data/auth.json;wy.ts 扫码/手机号、kg.ts token) + apis/ 各平台官方 URL 直连取流实现(kw/kg/tx/wy) + auth/ 登录态与登录协议(store.ts 持久化凭据到 data/auth.json;wy.ts 扫码/手机号、kg.ts 扫码/短信/token) common/ SDK 基础设施 request.ts HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压) utils.ts 工具函数(sizeFormate/formatPlayTime/dateFormat 等) message.ts 请求错误消息 - ipcNames.ts IPC 事件名(兼容 lx-music 结构) - rendererIpc.ts IPC 本地实现(kw/tx 歌词解码) - lyricDecode.ts 酷我/QQ 歌词解码 - qrcDecode.ts QQ 歌词 3DES 解密(纯 JS 移植) - lyricUtils/kg.ts 酷狗 krc 歌词解码 + kg/ kw/ tx/ wy/ 平台模块(搜索/歌词/榜单/歌单等 + 各自的歌词解码器) test/ - smoke-test.ts 冒烟测试(pnpm test 运行,自动启动/关闭本地服务器) + smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口) + source-simple-test.ts 简单 API 音源脚本加载器测试(离线) ``` > 分层依赖严格单向:`src` → `sdk`,`sdk` 内部自包含(不反向依赖 src)。源码统一为 TypeScript(import 一律写 `.ts` 后缀),由 Node ≥22.18 的原生类型剥离直接运行——**无编译产物**,改完即跑;tsc 仅用于类型检查(`pnpm typecheck`)。代码只使用可剥离的 TS 语法(无 enum/namespace/参数属性),新增代码也须遵守。 @@ -90,4 +118,4 @@ test/ - **kg 取流**:酷狗当前环境下 `wwwapi`/`trackercdn`/`m.kugou.com` 等取流接口均被服务端风控(err_code 30020/20010),播放地址获取可能失败;歌词、搜索、榜单正常。 - **kw flac**:`antiserver` 接口对 flac 返回 `refuse`,高音质会回退官方接口(需要 csrf token,token 失效时自动重试)。 -- 平台接口随时可能变更,遇失效时可对照 `src/apis/` 内的接口文档调整。 +- 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。 diff --git a/sdk/auth/kg.ts b/sdk/auth/kg.ts index b0763a7..60a3ddb 100644 --- a/sdk/auth/kg.ts +++ b/sdk/auth/kg.ts @@ -1,6 +1,6 @@ -// 酷狗登录 —— token 绑定与验证(网关 android 签名,与 KuGouMusicApi 对齐) -// 完整的手机号/扫码登录需移植其 RSA/AES 全套协议,后续按需扩展; -// token 可从酷狗 App 抓包获取(login.token) +// 酷狗登录 —— token 绑定 / 扫码 / 短信验证码(与 MakcRe/KuGouMusicApi 协议对齐) +// 扫码走 login-user.kugou.com 的 web 签名通道;短信走 android 签名 + AES/RSA; +// token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定 import crypto from 'node:crypto' import { httpFetch } from '../common/request.ts' @@ -12,6 +12,11 @@ const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d' const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp' const APPID = 1005 const CLIENTVER = 11430 +// 登录专用域按参考实现固定 clientver 20489(qr/短信接口实测通过的版本) +const LOGIN_CLIENTVER = 20489 +const WEB_SALT = 'NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt' +const SRC_APPID = 2919 +const QR_URL_PREFIX = 'https://h5.kugou.com/apps/loginQRCode/html/index.html' const PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB -----END PUBLIC KEY-----` @@ -39,8 +44,14 @@ export const getDevice = () => { return dev } -/** 网关移动端签名请求(gateway.kugou.com + x-router) */ -const gatewayRequest = async(url, { params: extraParams, data, router, headers: extraHeaders = {} }) => { +/** 网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域) */ +const gatewayRequest = async(url, { params: extraParams, data, router, host = 'https://gateway.kugou.com', headers: extraHeaders = {} }: { + params?: Record + data?: any + router?: string + host?: string + headers?: Record +}) => { const dev = getDevice() const params = { dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER, @@ -49,13 +60,13 @@ const gatewayRequest = async(url, { params: extraParams, data, router, headers: } 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}`, { + const r = await httpFetch(`${host}${url}?${qs}&signature=${signature}`, { method: 'post', headers: { dfid: dev.dfid, mid: dev.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', + ...(router ? { 'x-router': router } : {}), ...extraHeaders, }, body: data ?? undefined, @@ -98,8 +109,145 @@ export const loginByToken = async({ token, userid }) => { return profile } +// ---- 扫码登录(login-user.kugou.com,web 签名,盐与 H5 网页端一致)---- + +const webSign = (params) => { + const s = Object.keys(params).map(k => `${k}=${params[k]}`).sort().join('') + return md5(`${WEB_SALT}${s}${WEB_SALT}`) +} + +/** login-user.kugou.com GET 请求(web 签名,与取流共用同一设备指纹) */ +const loginUserGet = async(url, extraParams = {}) => { + const dev = getDevice() + const params = { + dfid: dev.dfid, mid: dev.mid, uuid: '-', + appid: APPID, clientver: LOGIN_CLIENTVER, clienttime: Math.floor(Date.now() / 1000), + ...extraParams, + } + const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&') + const r = await httpFetch(`https://login-user.kugou.com${url}?${qs}&signature=${webSign(params)}`, { + headers: { + dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime), + 'kg-rc': '1', 'kg-thash': '5d816a0', 'kg-rec': '1', 'kg-rf': 'B9EDA08A64250DEFFBCADDEE00F8F25F', + 'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi', + }, + timeout: 15000, + }).promise + const body = r.body ?? {} + if (body.status !== 1) { + const err = new Error(body.error ?? '酷狗登录服务请求失败') + ;(err as any).status = 502 + throw err + } + return body +} + +/** 保存登录凭据并拉取账号资料(扫码/短信登录共用;资料校验失败不阻断登录) */ +const applyKgLogin = async({ token, userid }) => { + try { + const profile = await verifyToken({ token, userid }) + setCredentials('kg', { token, userid: profile.userid, nickname: profile.nickname }) + return profile + } catch { + const uid = Number(userid) || 0 + setCredentials('kg', { token, userid: uid }) + return { userid: uid, nickname: null, vip: null } + } +} + +/** 创建扫码登录 key 与二维码(qrimg 为可直接展示的 data URL) */ +export const qrKey = async() => { + const body = await loginUserGet('/v2/qrcode', { + appid: 1001, type: 1, plat: 4, + qrcode_txt: `${QR_URL_PREFIX}?appid=${APPID}&`, + srcappid: SRC_APPID, + }) + const key = body.data?.qrcode + if (!key) throw new Error('获取二维码 key 失败') + const qrurl = `${QR_URL_PREFIX}?qrcode=${key}` + const qrimg = body.data.qrcode_img + || await import('qrcode').then(m => m.default.toDataURL(qrurl)).catch(() => '') + return { key, qrurl, qrimg } +} + +/** + * 轮询扫码状态(沿用登录路由的 800/801/802/803 语义,酷狗原生为 0/1/2/4) + * @returns {{ code: 800|801|802|803, message?: string, profile? }} 800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录) + */ +export const qrCheck = async(key) => { + const body = await loginUserGet('/v2/get_userinfo_qrcode', { + plat: 4, appid: APPID, srcappid: SRC_APPID, qrcode: key, + }) + const qrStatus = body.data?.status + if (qrStatus === 4) { + const { token, userid } = body.data + if (!token) throw new Error('登录响应中缺少 token') + return { code: 803, message: '登录成功', profile: await applyKgLogin({ token, userid }) } + } + const map = { 0: [800, '二维码已过期'], 1: [801, '等待扫码'], 2: [802, '已扫码,待确认'] } + const [code, message] = map[qrStatus] ?? [qrStatus, `未知状态 ${qrStatus}`] + return { code, message } +} + +// ---- 手机验证码登录 ---- +// 参考 KuGouMusicApi captcha_sent / login_cellphone:签名数据中 mobile 打码 +// (前 2 位 + ***** + 末位),完整号码与验证码走 AES 随机密钥加密; +// pk = RSA 裸加密 {clienttime_ms, key},服务端解出密钥后还原 params。 + +const signLoginKey = (clienttimeMs) => md5(`${APPID}${SALT_SIGN}${LOGIN_CLIENTVER}${clienttimeMs}`) + +/** 发送短信验证码(businessid 5 = 登录场景) */ +export const sendCaptcha = async(mobile) => { + const body = await gatewayRequest('/v7/send_mobile_code', { + router: 'login.user.kugou.com', + params: { clientver: LOGIN_CLIENTVER }, + data: { businessid: 5, mobile: String(mobile), plat: 3 }, + }) + if (body.status !== 1) { + const err = new Error(body.error ?? body.msg ?? '验证码发送失败') + ;(err as any).status = 502 + throw err + } + return { sent: true } +} + +/** 手机验证码登录(mobile + code,成功后自动保存凭据并返回账号资料) */ +export const cellphoneLogin = async({ mobile, code }) => { + const clienttimeMs = Date.now() + const enc = aesEncryptRandom({ mobile: String(mobile), code: String(code) }) + const body = await gatewayRequest('/v7/login_by_verifycode', { + host: 'https://loginserviceretry.kugou.com', + params: { clientver: LOGIN_CLIENTVER }, + data: { + plat: 1, + support_multi: 1, + t1: 0, + t2: 0, + clienttime_ms: clienttimeMs, + mobile: `${String(mobile).slice(0, 2)}*****${String(mobile).slice(10, 11)}`, + key: signLoginKey(clienttimeMs), + pk: rsaEncryptHex({ clienttime_ms: clienttimeMs, key: enc.key }).toUpperCase(), + params: enc.str, + t3: 'MCwwLDAsMCwwLDAsMCwwLDA=', + }, + headers: { + 'User-Agent': 'Android16-1070-11440-130-0-LOGIN-wifi', + 'support-calm': '1', + }, + }) + if (body.status !== 1) { + const err = new Error(body.error ?? `登录失败(code ${body.error_code ?? '未知'})`) + ;(err as any).status = 401 + throw err + } + const decrypted = body.data?.secu_params ? aesDecryptHex(body.data.secu_params, enc.key) : null + const token = typeof decrypted === 'object' ? decrypted?.token : decrypted + if (!token) throw new Error('登录响应解密失败') + return applyKgLogin({ token, userid: decrypted?.userid ?? body.data?.userid ?? 0 }) +} + // ---- token 自动续期 ---- -// login_by_token 协议(与 lx-music-api-server 对齐): +// login_by_token 协议: // p3 = AES({clienttime, token}),密钥由固定串派生;params = AES({})(随机密钥); // pk = RSA 裸加密 {clienttime_ms, key: 随机密钥}(右侧零填充 128 字节); // 成功响应 data.secu_params 用随机密钥 AES 解密得到新 token。 diff --git a/sdk/auth/tx.ts b/sdk/auth/tx.ts index 40cf8c3..4cb3139 100644 --- a/sdk/auth/tx.ts +++ b/sdk/auth/tx.ts @@ -1,9 +1,12 @@ -// QQ音乐凭据 —— cookie 解析、VIP 检测与登录态续期(music.login.LoginServer) -// 与 lx-music-api-server 的 refresh/tx.py 对齐,但走本项目既有的 musicu.fcg 未签名通道 -// (与 apis/tx.ts 一致);Login/VipLogin 模块若拒绝未签名请求则以 code != 0 失败, +// QQ音乐凭据 —— cookie 解析、扫码登录、VIP 检测与登录态续期(music.login.LoginServer) +// 登录态续期走本项目既有的 musicu.fcg 未签名通道(与 apis/tx.ts 一致); +// Login/VipLogin 模块若拒绝未签名请求则以 code != 0 失败, // 此时保留原凭据并抛错,由调用方记录日志。 // 续期条件:仅浏览器 cookie(无 openId 等抓包字段)时只带 musicid/musickey 尝试, // 失败率较高;完整凭据(/login/tx 可选传入 openId/accessToken/refreshToken/refreshKey)最可靠。 +// 扫码登录(参考 sansenjian/qq-music-api):ptlogin 扫码 → check_sig → +// graph.qq.com OAuth authorize 换 code → QQConnectLogin.LoginServer/QQLogin 换凭据, +// 成功响应体一般携带 refresh_key 等完整字段,可走自动续期。 import { httpFetch } from '../common/request.ts' import { getCredentials, setCredentials } from './store.ts' @@ -99,3 +102,193 @@ export const refreshLogin = async() => { const vipType = await checkVip().catch(() => null) return { refreshed: true, ...(vipType ? { vipType } : {}) } } + +// ---------------- 扫码登录 ---------------- + +/** ptqrtoken:hash33(qrsig) */ +const hash33 = (str: string): number => { + let hash = 0 + for (let i = 0; i < str.length; ++i) hash += (hash << 5) + str.charCodeAt(i) + return 2147483647 & hash +} + +/** g_tk:p_skey 的 5381 哈希 */ +const getGtk = (skey: string): number => { + let hash = 5381 + for (let i = 0; i < skey.length; ++i) hash += (hash << 5) + skey.charCodeAt(i) + return 2147483647 & hash +} + +const randomGuid = (): string => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0 + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16).toUpperCase() +}) + +/** 登录链路共享的 cookie 罐:同名后到优先,存「name=value」对 */ +class CookieJar { + private jar = new Map() + + merge(setCookie: string[] | undefined) { + for (const item of setCookie ?? []) { + const pair = item.split(';')[0] + const name = pair.split('=')[0]?.trim() + if (name && pair.includes('=')) this.jar.set(name, pair) + } + } + + get(name: string) { return this.jar.get(name)?.split('=').slice(1).join('=') ?? '' } + + entries() { return this.jar.entries() } + + header() { return [...this.jar.values()].join('; ') } +} + +const ptloginRequest = async(path: string, query: Record, cookie: string) => { + const r = await httpFetch(`https://ssl.ptlogin2.qq.com/${path}?${new URLSearchParams(query)}`, { + method: 'get', + ...(cookie ? { headers: { cookie } } : {}), + timeout: 15000, + }).promise + if (r.statusCode !== 200) { + const err = new Error(`ptlogin ${path} 失败(HTTP ${r.statusCode})`) as any + err.statusCode = r.statusCode + throw err + } + return r +} + +/** 创建扫码登录二维码(客户端扫码后轮询 qrCheck),qrsig 需原样回传 */ +export const qrShow = async() => { + const r = await httpFetch( + 'https://ssl.ptlogin2.qq.com/ptqrshow?appid=716027609&e=2&l=M&s=3&d=72&v=4' + + `&t=${Math.random()}&daid=383&pt_3rd_aid=100497308&u1=${encodeURIComponent('https://graph.qq.com/oauth2.0/login_jump')}`, + { method: 'get', timeout: 15000 }, + ).promise + const qrsig = (r.headers['set-cookie'] ?? []).map(c => c.match(/qrsig=([^;]+)/)?.[1]).find(Boolean) + if (!qrsig) throw new Error('获取二维码失败(响应缺少 qrsig)') + return { qrimg: `data:image/png;base64,${r.raw.toString('base64')}`, qrsig } +} + +/** + * 轮询扫码状态(与 wy 扫码一致的状态码语义) + * @returns {{ code: 800|801|802|803, message?: string, uin?, vipType? }} 800 过期 / 801 等待 / 802 已扫码 / 803 成功(凭据已保存) + */ +export const qrCheck = async(qrsig: string) => { + if (!qrsig) { + const err = new Error('缺少 qrsig') as any + err.status = 422 + throw err + } + const jar = new CookieJar() + jar.merge([`qrsig=${qrsig}`]) + + // 1. 轮询扫码状态:0 成功 / 65 已失效 / 66 未扫描 / 67 已扫描 / 68 授权中 + // qrsig 无效/已被 ptlogin 下线时返回 403,按二维码过期处理 + let poll + try { + poll = await ptloginRequest('ptqrlogin', { + u1: 'https://graph.qq.com/oauth2.0/login_jump', + ptqrtoken: String(hash33(qrsig)), + ptredirect: '0', h: '1', t: '1', g: '1', + from_ui: '1', ptlang: '2052', + action: `0-0-${Date.now()}`, + js_ver: '23111510', js_type: '1', + pt_uistyle: '40', aid: '716027609', daid: '383', + pt_3rd_aid: '100497308', + o1vId: randomGuid(), + }, jar.header()) + } catch (err: any) { + if (err.statusCode === 403) return { code: 800, message: '二维码已失效' } + throw err + } + jar.merge(poll.headers['set-cookie']) + + const text = typeof poll.body === 'string' ? poll.body : '' + const cb = text.match(/ptuiCB\('(\d+)','[^']*','([^']*)'/) + const state = cb?.[1] ?? '' + if (state !== '0') { + const states: Record = { + 65: [800, '二维码已失效'], + 66: [801, '等待扫码'], + 67: [802, '已扫码,待确认'], + 68: [802, '授权中'], + } + const [code, message] = states[state] ?? [801, '等待扫码'] + return { code, message } + } + + // 2. check_sig 换 p_skey(g_tk 需要) + const checkSigUrl = cb?.[2] + if (!checkSigUrl?.startsWith('http')) throw new Error('登录检查失败(未返回 check_sig 地址)') + const checkSig = await httpFetch(checkSigUrl, { + method: 'get', + headers: { cookie: jar.header() }, + timeout: 15000, + }).promise + jar.merge(checkSig.headers['set-cookie']) + const pSkey = jar.get('p_skey') + if (!pSkey) throw new Error('登录检查失败(响应缺少 p_skey)') + + // 3. OAuth authorize 换 code(appid 100497308 为 QQ 音乐开放平台) + const authorize = await httpFetch('https://graph.qq.com/oauth2.0/authorize', { + method: 'post', + headers: { Referer: 'https://graph.qq.com/oauth2.0/authorize', cookie: jar.header() }, + form: { + response_type: 'code', + client_id: '100497308', + redirect_uri: 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/', + scope: 'get_user_info,get_app_friends', + state: 'state', + switch: '', + from_ptlogin: '1', + src: '1', + update_auth: '1', + openapi: '1010_1030', + g_tk: String(getGtk(pSkey)), + auth_time: String(Date.now()), + ui: randomGuid(), + }, + timeout: 15000, + }).promise + jar.merge(authorize.headers['set-cookie']) + const code = String(authorize.headers.location ?? '').match(/[?&]code=([^&]+)/)?.[1] + if (!code) throw new Error('OAuth 授权失败(未取得 code)') + + // 4. QQLogin 换凭据:响应体 req.data 携带 musickey/musicid(及 openid 等续期字段), + // 同时响应 Set-Cookie 也会下发 y.qq.com 登录 cookie + const login = await httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { + method: 'post', + headers: { ...HEADERS, cookie: jar.header() }, + body: { + comm: { g_tk: getGtk(pSkey), platform: 'yqq', ct: 24, cv: 0 }, + req: { module: 'QQConnectLogin.LoginServer', method: 'QQLogin', param: { code } }, + }, + timeout: 15000, + }).promise + jar.merge(login.headers['set-cookie']) + + const data = login.body?.req?.data ?? {} + const musickey = data.musickey || jar.get('qm_keyst') + if (!musickey) throw new Error('登录失败(未取得 musickey)') + // ptlogin 域的 uin 可能带 o 前缀(如 o0123456),统一剥掉 + const uin = String(data.musicid || jar.get('uin')).replace(/^o0*/, '') || '0' + + // 最终 cookie:uin/qm_keyst 用凭据值放最前,其余链路 cookie 原样保留(排除 ptlogin 域噪声) + const pairs = [`uin=${uin}`, `qm_keyst=${musickey}`] + if (data.encryptUin) pairs.push(`euin=${data.encryptUin}`) + for (const [name, pair] of jar.entries()) { + if (!['qrsig', 'ptui_loginuin', 'uin', 'qm_keyst', 'euin'].includes(name)) pairs.push(pair) + } + + setCredentials('tx', { + cookie: pairs.join('; '), + uin, + ...(data.openid ? { openId: String(data.openid) } : {}), + ...(data.access_token ? { accessToken: String(data.access_token) } : {}), + ...(data.refresh_token ? { refreshToken: String(data.refresh_token) } : {}), + ...(data.refresh_key ? { refreshKey: String(data.refresh_key) } : {}), + }) + + const vipType = await checkVip().catch(() => null) + return { code: 803, message: '登录成功', uin, ...(vipType ? { vipType } : {}) } +} diff --git a/src/routes-login.ts b/src/routes-login.ts index f64ae9c..c466d46 100644 --- a/src/routes-login.ts +++ b/src/routes-login.ts @@ -1,8 +1,9 @@ -// 登录路由 —— wy 扫码/手机号登录,kg token 绑定,凭据持久化于 data/auth.json +// 登录路由 —— wy 扫码/手机号,kg 扫码/短信/token,tx 扫码/cookie 绑定,凭据持久化于 data/auth.json // 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data } import * as wyAuth from '../sdk/auth/wy.ts' import * as kgAuth from '../sdk/auth/kg.ts' +import * as txAuth from '../sdk/auth/tx.ts' import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts' import { refreshAll } from './refresh.ts' @@ -77,7 +78,8 @@ export const loginRoutes = async(fastify) => { fastify.post('/login/kg', { schema: { summary: '绑定酷狗 token', - description: 'token 从酷狗 App 抓包获取(login.token),服务端向用户中心验证后存储。手机号/扫码登录暂未实现。', + description: 'token 从酷狗 App 抓包获取(login.token),服务端向用户中心验证后存储。' + + '推荐改用 /login/kg/qr 扫码或 /login/kg/cellphone 短信验证码登录,无需抓包。', tags: ['登录'], body: { type: 'object', @@ -96,6 +98,66 @@ export const loginRoutes = async(fastify) => { } }) + fastify.post('/login/kg/qr', { + schema: { + summary: '获取酷狗扫码登录二维码', + description: '用酷狗 App 扫码后轮询 /login/kg/qr/check 完成登录。返回 qrimg(data URL)可直接展示。', + tags: ['登录'], + }, + }, async() => kgAuth.qrKey()) + + fastify.post('/login/kg/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 kgAuth.qrCheck(request.body.key) + } catch (err) { + return fail(reply, err) + } + }) + + fastify.post('/login/kg/captcha', { + schema: { + summary: '发送酷狗短信验证码', + description: '向手机号发送登录验证码(有频控,勿频繁调用),随后用 /login/kg/cellphone 提交。', + tags: ['登录'], + body: { type: 'object', properties: { mobile: { type: 'string' } }, required: ['mobile'] }, + }, + }, async(request, reply) => { + try { + return await kgAuth.sendCaptcha(request.body.mobile) + } catch (err) { + return fail(reply, err) + } + }) + + fastify.post('/login/kg/cellphone', { + schema: { + summary: '酷狗手机验证码登录', + description: 'mobile + code(/login/kg/captcha 发送的短信验证码),成功后自动保存凭据并返回账号资料。', + tags: ['登录'], + body: { + type: 'object', + properties: { + mobile: { type: 'string' }, + code: { type: 'string' }, + }, + required: ['mobile', 'code'], + }, + }, + }, async(request, reply) => { + try { + return await kgAuth.cellphoneLogin(request.body) + } catch (err) { + return fail(reply, err) + } + }) + fastify.delete('/login/kg', { schema: { summary: '退出酷狗登录', tags: ['登录'] }, }, async() => { @@ -105,6 +167,29 @@ export const loginRoutes = async(fastify) => { // ---------- QQ音乐 ---------- + fastify.post('/login/tx/qr', { + schema: { + summary: '获取 QQ 音乐扫码登录二维码', + description: '用 QQ 音乐 App(或 QQ/微信)扫码后轮询 /login/tx/qr/check 完成登录。返回 qrimg(data URL)可直接展示,qrsig 需原样回传。', + tags: ['登录'], + }, + }, async() => txAuth.qrShow()) + + fastify.post('/login/tx/qr/check', { + schema: { + summary: '轮询 QQ 音乐扫码状态', + description: 'code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据并返回账号信息)。', + tags: ['登录'], + body: { type: 'object', properties: { qrsig: { type: 'string' } }, required: ['qrsig'] }, + }, + }, async(request, reply) => { + try { + return await txAuth.qrCheck(request.body.qrsig) + } catch (err) { + return fail(reply, err) + } + }) + fastify.post('/login/tx', { schema: { summary: '绑定 QQ 音乐 cookie',