refactor: 架构整固——分层单向化、全量 TypeScript、直接运行
- 移除 bd/mg 两平台及磁盘残留产物;清理 api-test 死脚本、musicSdk/message+options 死模块、infSign vendor - musicSdk/ 改名 sdk/;src/common、src/auth、src/apis 整体移入 sdk/,依赖方向定为 src → sdk 严格单向 - src/ 手写 JS 全量转 .ts,删除 JS 类型垫片 modules.d.ts;源码 import 统一 .ts 后缀 - Node 原生类型剥离直接运行 .ts(engines >=22.18),取消 tsc 发射产物,tsc 仅类型检查(pnpm typecheck) - 删除 kwdes 备用实现与 src/index.ts 转发垫片;测试移至 test/smoke-test.ts - common/request.ts 补显式导出类型;修复 set-cookie 单值形态、ApiError 类字段等隐患 验证:tsc 零错误,smoke-test 41/41
This commit is contained in:
1 parent
c8c939aa32
commit
354df2e739
126 files changed
+413
-2942
No files matched your search
+103
@@ -0,0 +1,103 @@
|
||||
// 酷狗登录 —— token 绑定与验证(网关 android 签名,与 KuGouMusicApi 对齐)
|
||||
// 完整的手机号/扫码登录需移植其 RSA/AES 全套协议,后续按需扩展;
|
||||
// token 可从酷狗 App 抓包获取(login.token)
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
import { getCredentials, setCredentials } from './store.ts'
|
||||
|
||||
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('')
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
/** 设备标识持久化(guid/mid/dfid 稳定复用,登录验证与取流共用同一指纹) */
|
||||
export const getDevice = () => {
|
||||
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
|
||||
}
|
||||
|
||||
/** 网关移动端签名请求(gateway.kugou.com + x-router) */
|
||||
const gatewayRequest = async(url, { params: extraParams, data, router }) => {
|
||||
const dev = getDevice()
|
||||
const params = {
|
||||
dfid: dev.dfid, mid: dev.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: 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',
|
||||
},
|
||||
body: data ?? undefined,
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
return r.body ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 token 并返回账号资料(usercenter /v3/get_my_info)
|
||||
* p = RSA 原始加密(零填充 128 字节){ token, clienttime }
|
||||
*/
|
||||
export const verifyToken = async({ token, userid }) => {
|
||||
const unixSec = Math.floor(Date.now() / 1000)
|
||||
const payload = Buffer.from(JSON.stringify({ token, clienttime: unixSec }))
|
||||
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 as any).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
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...} }
|
||||
// 注意:store.js 为 tsc 就地发射产物(不入库);改逻辑只改本文件,然后 pnpm build
|
||||
|
||||
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')
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface KgDevice {
|
||||
guid: string
|
||||
mid: string
|
||||
dfid: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface TxCredentials {
|
||||
cookie: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
let cache: Record<string, any> = {}
|
||||
let loaded = false
|
||||
|
||||
const load = (): Record<string, any> => {
|
||||
if (loaded) return cache
|
||||
try {
|
||||
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {}
|
||||
} catch {
|
||||
cache = {}
|
||||
}
|
||||
loaded = true
|
||||
return cache
|
||||
}
|
||||
|
||||
const save = (data: Record<string, any>): void => {
|
||||
cache = data
|
||||
loaded = true
|
||||
mkdirSync(dirname(DATA_FILE), { recursive: true })
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
export const getCredentials = (platform: string): any => load()[platform] ?? null
|
||||
|
||||
export const setCredentials = (platform: string, credentials: Record<string, any>): void => {
|
||||
const data = load()
|
||||
data[platform] = { ...data[platform], ...credentials, updatedAt: new Date().toISOString() }
|
||||
save(data)
|
||||
}
|
||||
|
||||
export const clearCredentials = (platform: string): void => {
|
||||
const data = load()
|
||||
delete data[platform]
|
||||
save(data)
|
||||
}
|
||||
|
||||
/** wy 登录 cookie 字符串(未登录返回空串) */
|
||||
export const getWyCookie = (): string => (getCredentials('wy') as WyCredentials | null)?.cookie || ''
|
||||
|
||||
/** kg 登录态(未登录返回 null) */
|
||||
export const getKgAuth = (): { token: string; userid: number | string } | null => {
|
||||
const cred = getCredentials('kg') as KgCredentials | null
|
||||
return cred?.token ? { token: cred.token, userid: cred.userid || 0 } : null
|
||||
}
|
||||
|
||||
/** tx(QQ) 登录态:cookie 与其中的 uin(未登录返回 null) */
|
||||
export const getTxAuth = (): { cookie: string; uin: string } | null => {
|
||||
const cred = getCredentials('tx') as TxCredentials | null
|
||||
if (!cred?.cookie) return null
|
||||
const uin = cred.cookie.match(/uin=(\d+)/)?.[1] ?? '0'
|
||||
return { cookie: cred.cookie, uin }
|
||||
}
|
||||
+120
@@ -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.ts'
|
||||
import { weapi } from '../wy/utils/crypto.ts'
|
||||
import { getWyCookie, setCredentials } from './store.ts'
|
||||
|
||||
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 as any).status = 401
|
||||
throw err
|
||||
}
|
||||
const profile = await applyLogin(setCookie)
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 当前账号资料(未登录返回 null) */
|
||||
export const status = async() => {
|
||||
if (!getWyCookie()) return null
|
||||
return fetchProfile()
|
||||
}
|
||||
Reference in new issue
Block a user