- 移除 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
121 lines
4.3 KiB
TypeScript
121 lines
4.3 KiB
TypeScript
// 网易云登录 —— 扫码 / 手机号,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()
|
||
}
|