Files
MusicServer/sdk/bili/login.ts
T
snowy-yang 33b96f92c4 feat(sdk): 新增咪咕/千千/JOOX/哔哩哔哩/5sing/Apple Music 六平台
平台注册表由 9 个扩到 15 个,同时补齐 sdk/types.ts 的类型与
OpenAPI 文档里的能力位声明。

- migu:命中 Z3D/I3D 加密候选时不返回直链,改由服务端解密代理出流,
  SDK 侧只负责登记票据,避免客户端拿到无法播放的密文
- apple:只提供 preview 试听片段(完整曲为 DRM),标 noAggregate
  排除在跨平台精确匹配之外,防止用它顶掉其它平台的可播结果
- 其余平台按既有 PlatformModule 约定实现搜索/专辑/歌单/歌词

为什么合并成一次提交:六平台共用同一份注册表与能力位声明,
拆开会产生互相引用、单独无法通过类型检查的中间态。
2026-10-03 00:26:32 +08:00

164 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Bilibili 扫码登录(上游 bilibili/login.go 的 CreateQRLogin/CheckQRLogin + account.go 的 IsVipAccount)
// 接口:
// GET https://passport.bilibili.com/x/passport-login/web/qrcode/generate → data.{url, qrcode_key}
// GET https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key= → data.code
// 0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
// 成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
// 凭据落盘到 sdk/auth/store.ts 的 `bili` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
// 上游 New(cookie) 的实例化语义由 getCredentials('bili') 承担(见 util.ts getCookie)。
// 状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
import QRCode from 'qrcode'
import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from '../auth/store.ts'
import { REFERER, UA } from './util.ts'
import { resetVipCache } from '../apis/bili.ts'
const QR_GENERATE_API = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate'
const QR_POLL_API = 'https://passport.bilibili.com/x/passport-login/web/qrcode/poll'
/** 上游 utils.WithRandomIPHeader:随机国内 IP,规避部分区域限制 */
const randomChinaIp = (): string => {
const first = [36, 42, 58, 60, 101, 106, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 171, 175, 180, 182, 183, 210, 211, 218, 219, 220, 221, 222, 223][Math.floor(Math.random() * 35)]
return `${first}.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}`
}
const qrHeaders = (): Record<string, string> => {
const ip = randomChinaIp()
return {
'User-Agent': UA,
Referer: REFERER,
'X-Forwarded-For': ip,
'X-Real-IP': ip,
}
}
const cookieMapFromHeaders = (headers: any): Record<string, string> => {
const raw = headers?.['set-cookie']
const values: string[] = Array.isArray(raw) ? raw : raw ? [String(raw)] : []
const jar: Record<string, string> = {}
for (const value of values) {
const pair = String(value).split(';')[0].trim()
const idx = pair.indexOf('=')
if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
}
return jar
}
/** crossDomain 回跳地址的查询串里也带 SESSDATA 等(无 cookie 包时后备) */
const cookieMapFromCrossDomain = (crossUrl: string): Record<string, string> => {
const jar: Record<string, string> = {}
try {
const url = new URL(String(crossUrl || ''))
for (const name of ['DedeUserID', 'DedeUserID__ckMd5', 'SESSDATA', 'bili_jct', 'sid']) {
const value = url.searchParams.get(name)
if (value) jar[name] = value
}
} catch {
// 非 URL 时忽略
}
return jar
}
const cookieString = (jar: Record<string, string>): string =>
Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
/** 创建扫码登录会话:返回 { key, qrurl, qrimg, expiresAt }(qrimg 可直接展示) */
export const createQRLogin = async() => {
const { statusCode, body } = await httpFetch(QR_GENERATE_API, { headers: qrHeaders(), timeout: 15000 }).promise as any
if (statusCode !== 200 || !body || typeof body !== 'object') {
throw new Error(`获取B站登录二维码失败(HTTP ${statusCode})`)
}
if (body.code !== 0 || !String(body.data?.qrcode_key || '').trim()) {
throw new Error(`获取B站登录二维码失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
}
const key = String(body.data.qrcode_key)
const url = String(body.data.url || '')
const qrimg = await QRCode.toDataURL(url, { errorCorrectionLevel: 'M', margin: 2, width: 360 }).catch(() => '')
return {
key,
qrurl: url,
qrimg,
// 上游 QRLoginSession.ExpiresAt:3 分钟有效期
expiresAt: Math.floor(Date.now() / 1000) + 180,
}
}
/** 拉取账号资料(nav;失败不阻断登录) */
const fetchProfile = async(cookie: string) => {
try {
const { body } = await httpFetch('https://api.bilibili.com/x/web-interface/nav', {
headers: { 'User-Agent': UA, Referer: REFERER, Cookie: cookie },
timeout: 15000,
}).promise as any
const data = body?.data ?? {}
return {
mid: data.mid ?? null,
nickname: data.uname ?? null,
vipType: Number(data.vipType) || 0,
vipStatus: Number(data.vipStatus) || 0,
}
} catch {
return { mid: null, nickname: null, vipType: 0, vipStatus: 0 }
}
}
/**
* 轮询扫码状态
* @returns {{ code: 800|801|802|803, message: string, status: string, profile? }}
* 800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(凭据已落盘)
*/
export const checkQRLogin = async(key: string) => {
const qrcodeKey = String(key || '').trim()
if (!qrcodeKey) throw new Error('B站扫码 key 为空')
const { statusCode, headers, body } = await httpFetch(`${QR_POLL_API}?qrcode_key=${encodeURIComponent(qrcodeKey)}`, {
headers: { 'User-Agent': UA, Referer: REFERER },
timeout: 15000,
}).promise as any
if (statusCode !== 200) {
throw Object.assign(new Error(`轮询B站扫码状态失败(HTTP ${statusCode})`), { status: 502 })
}
const data = body?.data ?? {}
const code = Number(data.code)
if (code === 0) {
const jar = { ...cookieMapFromCrossDomain(data.url), ...cookieMapFromHeaders(headers) }
const cookie = cookieString(jar)
if (!cookie) throw new Error('B站登录成功但未获取到 Cookie,请重试')
const profile = await fetchProfile(cookie)
setCredentials('bili', {
cookie,
refreshToken: String(data.refresh_token || ''),
mid: profile.mid,
nickname: profile.nickname,
vipType: profile.vipType,
vipStatus: profile.vipStatus,
})
resetVipCache()
return { code: 803, message: '登录成功', status: 'success', profile }
}
const statusMap: Record<number, [number, string, string]> = {
86038: [800, '二维码已过期', 'expired'],
86090: [802, '已扫码,待确认', 'scanned'],
86101: [801, '等待扫码', 'waiting'],
}
const [mappedCode, message, status] = statusMap[code] ?? [800, String(data.message || `未知状态 ${code}`), 'failed']
return { code: mappedCode, message, status }
}
/** 当前登录态(凭据存在性 + 落盘时保存的账号资料) */
export const status = () => {
const cred = getCredentials('bili') as any
return cred?.cookie
? {
logged: true,
nickname: cred.nickname ?? null,
mid: cred.mid ?? null,
vipType: cred.vipType ?? 0,
updatedAt: cred.updatedAt,
}
: { logged: false }
}
export default { createQRLogin, checkQRLogin, status }