feat(qs): 接入官方签名模块、修复扫码登录链路、新增本机登录脚本
- passport:响应自带的 qrcode 图指向已 404 的旧扫码页 (ucenter_web/app/sdk-next),抖音扫码会提示「无法访问页面」;改为用 qrcode_index_url 的 token 重建官方 bff-pc.qishui.com/light/invoke/scan_login 地址,与客户端 renderer 的 getHandleScanCodeH5Img 算法一致。同时状态 映射按官方 renderer 语义对齐(1/new 待扫码、2/scanned 已扫码待确认、 3/confirmed 已确认),并采集轮询下发的 Set-Cookie、响应带 redirect_url 时补跳一次,否则拿不到 sessionid - qs/mssdk:进程内加载官方 bdms.node 生成 X-Helios / X-Medusa 签名, 不另起 HTTP 签名服务;缺原生模块时整体降级,不影响匿名可用 - scripts/qs-local-login.mjs:直接读本机官方客户端的 Cookies 与 DeviceV1 写入 data/auth.json,免扫码(独立脚本,不参与服务运行) 为什么需要 mssdk 签名:汽水对未签名请求回 HTTP 200 + 0 字节,把失败伪装 成空成功,没有签名就分不清「接口异常」与「风控拦截」。
This commit is contained in:
1 parent
68e071b166
commit
de2dd311ee
6 files changed
+574
-92
No files matched your search
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
// 汽水音乐「本机客户端登录」独立脚本 —— 不参与服务运行,需要时手动跑一次
|
||||
//
|
||||
// 做什么:官方 SodaMusic PC 客户端是 Chromium,登录态就存在本机
|
||||
// %APPDATA%\SodaMusic\Network\Cookies(SQLite)里,设备身份在 %APPDATA%\SodaMusic\DeviceV1
|
||||
// (gzip JSON,含 did/iid)。本脚本直接读出来写进 data/auth.json 的 qs 字段,
|
||||
// 免去扫码登录。参考实现:YYF1337/PopDownloader(SaKongA/PopDownloader 的 fork)。
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/qs-local-login.mjs # 读取 + 校验 + 写入 data/auth.json
|
||||
// node scripts/qs-local-login.mjs --print # 只打印 sessionid,不落盘
|
||||
// node scripts/qs-local-login.mjs --no-verify # 跳过 /luna/pc/me 校验
|
||||
// node scripts/qs-local-login.mjs --dir <目录> # 指定客户端数据目录(默认 %APPDATA%\SodaMusic)
|
||||
// node scripts/qs-local-login.mjs --auth <文件> # 指定 auth.json(默认 <仓库>/data/auth.json)
|
||||
//
|
||||
// 校验需要官方签名模块(soda-mssdk/bdms.node 或 QS_MSSDK_DIR),没有就跳过校验只写入。
|
||||
// 注意:服务在跑时 auth.json 已缓存,写完需重启服务才生效(脚本会提示)。
|
||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const flag = (name) => argv.includes(name)
|
||||
const value = (name) => {
|
||||
const i = argv.indexOf(name)
|
||||
return i >= 0 ? argv[i + 1] : ''
|
||||
}
|
||||
|
||||
const dataDir = resolve(value('--dir') || process.env.QS_SODA_DATA_DIR || join(process.env.APPDATA || '', 'SodaMusic'))
|
||||
const authPath = resolve(value('--auth') || join(root, 'data', 'auth.json'))
|
||||
const printOnly = flag('--print')
|
||||
const skipVerify = flag('--no-verify')
|
||||
const DEVICE_FILE = join(dataDir, 'DeviceV1')
|
||||
const COOKIE_DB = join(dataDir, 'Network', 'Cookies')
|
||||
|
||||
const die = (msg) => {
|
||||
console.error(`✗ ${msg}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// —— 1. 客户端设备身份(DeviceV1:gzip JSON 的 {did,iid})——
|
||||
const readDevice = () => {
|
||||
if (!existsSync(DEVICE_FILE)) return null
|
||||
const raw = readFileSync(DEVICE_FILE)
|
||||
let text
|
||||
try {
|
||||
text = gunzipSync(raw).toString('utf8')
|
||||
} catch {
|
||||
text = raw.toString('utf8')
|
||||
}
|
||||
const data = JSON.parse(text)
|
||||
return data?.did && data?.iid ? { did: String(data.did), iid: String(data.iid) } : null
|
||||
}
|
||||
|
||||
// —— 2. 客户端登录态(Chromium cookie 库,复制副本后只读查询)——
|
||||
const readSessionId = () => {
|
||||
if (!existsSync(COOKIE_DB)) die(`未找到客户端登录数据:${COOKIE_DB}\n 请先安装并登录官方汽水音乐 PC 客户端,或用 --dir 指定数据目录`)
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'qs-cookies-'))
|
||||
try {
|
||||
const copy = join(tempDir, 'Cookies')
|
||||
copyFileSync(COOKIE_DB, copy)
|
||||
for (const side of ['-wal', '-shm']) {
|
||||
if (existsSync(COOKIE_DB + side)) copyFileSync(COOKIE_DB + side, copy + side)
|
||||
}
|
||||
const { DatabaseSync } = require('node:sqlite')
|
||||
const db = new DatabaseSync(copy, { readOnly: true })
|
||||
try {
|
||||
const row = db.prepare(
|
||||
"SELECT value FROM cookies WHERE host_key IN ('.qishui.com', 'qishui.com') AND name = 'sessionid' LIMIT 1",
|
||||
).get()
|
||||
return String(row?.value ?? '').trim()
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
} catch (err) {
|
||||
if (/EBUSY|locked|busy|unable to open/i.test(String(err?.message))) {
|
||||
die('客户端正在运行且 cookie 库被占用,请退出汽水音乐客户端后重试')
|
||||
}
|
||||
die(`读取 cookie 库失败: ${err?.message ?? err}`)
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// —— 3. 可选:签名打 /luna/pc/me 校验(status_code === 0 视为有效)——
|
||||
const findSigner = () => {
|
||||
const dirs = [process.env.QS_MSSDK_DIR, join(root, 'soda-mssdk')].filter(Boolean)
|
||||
for (const dir of dirs) {
|
||||
if (existsSync(join(dir, 'bdms.node'))) return join(dir, 'bdms.node')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const verify = async (sessionid, device) => {
|
||||
const modulePath = findSigner()
|
||||
if (!modulePath) return null
|
||||
try {
|
||||
const bdms = require(modulePath)
|
||||
bdms.init({ deviceId: device.did })
|
||||
const query = new URLSearchParams({
|
||||
aid: '386088', app_name: 'luna_pc', region: 'cn', geo_region: 'cn', os_region: 'cn', sim_region: '',
|
||||
device_id: device.did, cdid: '', iid: device.iid, version_name: '3.7.0', version_code: '30070000',
|
||||
channel: 'official', build_mode: 'master', network_carrier: '', ac: 'wifi', tz_name: 'Asia/Shanghai',
|
||||
resolution: '', device_platform: 'windows', device_type: 'Windows', os_version: 'Windows 11', fp: device.did,
|
||||
})
|
||||
const url = `https://api.qishui.com/luna/pc/me?${query}`
|
||||
const headers = { 'user-agent': 'LunaPC/3.7.0(452316191)', cookie: `sessionid=${sessionid}` }
|
||||
const lines = Object.entries(headers).map(([k, v]) => `${k}\r\n${v}`).join('\r\n')
|
||||
const parts = String(bdms.generateHttpSignatureHeaders(url, lines)).split('\r\n').filter(t => t.trim())
|
||||
for (let i = 0; i + 1 < parts.length; i += 2) headers[parts[i]] = parts[i + 1]
|
||||
const res = await fetch(url, { headers })
|
||||
if (res.status !== 200) return null
|
||||
const body = await res.json().catch(() => null)
|
||||
const code = body?.status_code
|
||||
if (code === 0) return true
|
||||
return code == null ? null : false
|
||||
} catch (err) {
|
||||
console.log(`! 校验失败(按未校验处理): ${err?.message ?? err}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const device = readDevice()
|
||||
if (!device) console.log(`! 未读到客户端设备身份(${DEVICE_FILE}),校验时将跳过`)
|
||||
else console.log(`· 客户端设备: did=${device.did} iid=${device.iid}`)
|
||||
|
||||
const sessionid = readSessionId()
|
||||
if (!sessionid) die('客户端未登录(cookie 库中没有 .qishui.com 的 sessionid),请先在客户端登录')
|
||||
|
||||
let verified = null
|
||||
if (!skipVerify && device) {
|
||||
verified = await verify(sessionid, device)
|
||||
if (verified === false) die('客户端登录态已失效,请在汽水音乐客户端重新登录后再跑本脚本')
|
||||
}
|
||||
console.log(`· sessionid: ${sessionid.slice(0, 6)}…${sessionid.slice(-4)}(${sessionid.length} 字符)`)
|
||||
console.log(`· 校验: ${verified === true ? '有效' : verified === false ? '已失效' : skipVerify ? '已跳过(--no-verify)' : '无法判定(缺签名模块或网络异常)'}`)
|
||||
|
||||
if (printOnly) {
|
||||
console.log(sessionid)
|
||||
return
|
||||
}
|
||||
|
||||
const auth = existsSync(authPath) ? JSON.parse(readFileSync(authPath, 'utf8')) : {}
|
||||
auth.qs = { ...(auth.qs || {}), sessionid, cookie: `sessionid=${sessionid}`, source: 'local-client', updatedAt: new Date().toISOString() }
|
||||
writeFileSync(authPath, `${JSON.stringify(auth, null, 2)}\n`)
|
||||
console.log(`✓ 已写入 ${authPath}(qs.sessionid)`)
|
||||
console.log(' 服务在运行的话需要重启才生效(凭据已缓存);之后 /login/status 的 qs.logged 应为 true')
|
||||
}
|
||||
|
||||
main().catch(err => die(err?.message ?? String(err)))
|
||||
+65
-19
@@ -3,20 +3,23 @@
|
||||
// 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k)
|
||||
// 登录态: 设备注册 + X-Argus/X-Ladon 自签 → /luna/pc/track_v2 → 加密音频(cenc-aes-ctr)
|
||||
// → spade key 解密 → /stream/qs/:id 代理端点回明文流(相对地址,路由层负责补全 origin)
|
||||
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(抖音扫码 /login/qs/qr)。
|
||||
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。
|
||||
// 登录请求失败自动降级匿名试听。
|
||||
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import musicInfo from '../qs/musicInfo.ts'
|
||||
import { getDevice } from '../qs/device.ts'
|
||||
import { getSessionId } from '../qs/passport.ts'
|
||||
import { getCookie, getSessionId } from '../qs/passport.ts'
|
||||
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from '../qs/mssdk.ts'
|
||||
import { signRequest } from '../qs/crypto/sign.ts'
|
||||
import { decryptAudioBuffer, resolveAudioKey } from '../qs/crypto/audiodecrypt.ts'
|
||||
import type { HttpResponse } from '../types.ts'
|
||||
|
||||
// 官方直连接口支持的音质:匿名只有 30 秒试听 128k;登录态取码率最高条目(324k/hi_res 视账号权益)
|
||||
// 官方直连接口支持的音质:授权曲匿名只有 30 秒试听 128k(登录态取码率最高条目,324k/hi_res 视权益);
|
||||
// 用户上传曲匿名即可整曲(实测 2026-10-02:DJ 版整曲 204.84s / 三档 68k・132k・260k)
|
||||
export const supportQualitys = ['128k', '320k']
|
||||
|
||||
const TRACK_V2_URL = 'https://api.qishui.com/luna/pc/track_v2'
|
||||
@@ -117,32 +120,75 @@ const pickBestEntry = (playInfoList: any) => {
|
||||
return entries[0]
|
||||
}
|
||||
|
||||
const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
|
||||
const device = await getDevice()
|
||||
const sessionId = getSessionId()
|
||||
const url = buildSignedUrl(device)
|
||||
const requestBody = JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
|
||||
const headers = await signRequest(url, requestBody)
|
||||
const fetchTrackPlayer = async (url: string, headers: Record<string, string>, requestBody: string) => {
|
||||
const { statusCode, body } = (await httpFetch(url, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
...headers,
|
||||
'User-Agent': 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'X-Luna-Background-Type': 'foreground',
|
||||
'X-Luna-Is-Background-Req': '0',
|
||||
'X-Luna-Is-Local-User': '1',
|
||||
...(sessionId ? { Cookie: `sessionid=${sessionId}` } : {}),
|
||||
},
|
||||
headers,
|
||||
body: requestBody,
|
||||
timeout: 15000,
|
||||
}).promise) as HttpResponse
|
||||
|
||||
const trackPlayer = (body as any)?.track_player
|
||||
if (statusCode != 200 || !trackPlayer?.url_player_info) {
|
||||
const errMsg = (body as any)?.status_info?.status_msg || (body as any)?.message || ''
|
||||
throw new Error(`track_v2 获取失败: HTTP ${statusCode} ${errMsg}`.trim())
|
||||
}
|
||||
return trackPlayer as { url_player_info: string }
|
||||
}
|
||||
|
||||
const TRACK_V2_BODY = (trackId: string) =>
|
||||
JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
|
||||
|
||||
// 通道 A(默认):安卓 App 参数 + X-Argus/X-Ladon 自签
|
||||
const fetchTrackPlayerByApp = async (trackId: string) => {
|
||||
const device = await getDevice()
|
||||
const sessionId = getSessionId()
|
||||
const url = buildSignedUrl(device)
|
||||
const requestBody = TRACK_V2_BODY(trackId)
|
||||
const headers = await signRequest(url, requestBody)
|
||||
return fetchTrackPlayer(url, {
|
||||
...headers,
|
||||
'User-Agent': 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'X-Luna-Background-Type': 'foreground',
|
||||
'X-Luna-Is-Background-Req': '0',
|
||||
'X-Luna-Is-Local-User': '1',
|
||||
...(sessionId ? { Cookie: getCookie() || `sessionid=${sessionId}` } : {}),
|
||||
}, requestBody)
|
||||
}
|
||||
|
||||
// 通道 B(降级):官方 PC 客户端签名 X-Helios/X-Medusa(需 sdk/qs/mssdk.ts 的签名模块)
|
||||
const fetchTrackPlayerByPc = async (trackId: string) => {
|
||||
const sessionId = getSessionId()
|
||||
if (!mssdkAvailable()) throw new Error('官方签名模块不可用')
|
||||
const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}`
|
||||
const requestBody = TRACK_V2_BODY(trackId)
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'user-agent': PC_UA,
|
||||
'x-luna-background-type': 'foreground',
|
||||
'x-luna-is-background-req': '0',
|
||||
'x-luna-is-local-user': '0',
|
||||
'x-tt-trace-id': `00-${randomBytes(8).toString('hex')}-${randomBytes(8).toString('hex')}-01`,
|
||||
'x-ss-stub': createHash('md5').update(requestBody).digest('hex').toUpperCase(),
|
||||
...(sessionId ? { cookie: getCookie() || `sessionid=${sessionId}` } : {}),
|
||||
}
|
||||
const signed = signQishuiRequest(url, headers)
|
||||
if (!signed) throw new Error('官方签名失败')
|
||||
return fetchTrackPlayer(url, { ...headers, ...signed }, requestBody)
|
||||
}
|
||||
|
||||
const resolveTrackPlayer = async (trackId: string) => {
|
||||
try {
|
||||
return await fetchTrackPlayerByApp(trackId)
|
||||
} catch (err: any) {
|
||||
if (!mssdkAvailable()) throw err
|
||||
console.log(`[qs/track_v2] 安卓自签通道失败(${err?.message ?? err}),降级官方 PC 签名`)
|
||||
return fetchTrackPlayerByPc(trackId)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
|
||||
const trackPlayer = await resolveTrackPlayer(trackId)
|
||||
|
||||
const playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise
|
||||
const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList)
|
||||
|
||||
+18
-1
@@ -151,4 +151,21 @@ export const getDevice = async (): Promise<QsDevice> => {
|
||||
export const hasDevice = (): boolean =>
|
||||
Boolean(cachedDevice || (getCredentials('_qs_device') as QsDevice | null)?.device_id)
|
||||
|
||||
export default { registerDevice, getDevice, hasDevice }
|
||||
/**
|
||||
* 设备身份(同步读,不触发注册):已注册设备优先;尚未注册时用独立生成的
|
||||
* `_qs_passport_device`,避免覆盖注册记录。护照/签名模块共用,保证「同一设备」。
|
||||
*/
|
||||
export const getDeviceIds = (): { deviceId: string; installId: string } => {
|
||||
const registered = getCredentials('_qs_device') as QsDevice | null
|
||||
if (registered?.device_id && registered?.install_id) {
|
||||
return { deviceId: String(registered.device_id), installId: String(registered.install_id) }
|
||||
}
|
||||
const existing = getCredentials('_qs_passport_device') as { deviceId?: string; installId?: string } | null
|
||||
if (existing?.deviceId && existing.installId) return { deviceId: String(existing.deviceId), installId: String(existing.installId) }
|
||||
const now = Date.now()
|
||||
const ids = { deviceId: String(now), installId: String(now + 1) }
|
||||
setCredentials('_qs_passport_device', ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
export default { registerDevice, getDevice, hasDevice, getDeviceIds }
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// 汽水官方 PC 客户端签名(X-Helios / X-Medusa)—— 可选增强,缺原生模块时整体降级
|
||||
//
|
||||
// 原理:直接复用官方客户端自带的原生模块 bdms.node(官方 main 进程就是
|
||||
// bdms.init({ deviceId }) → generateHttpSignatureHeaders(url, "名字\r\n值\r\n…"),
|
||||
// 把返回的 name/value 成对写回请求头)。与 sodahub-org/libmssdk 同一套做法,
|
||||
// 区别是我们进程内加载,不另起 HTTP 签名服务。
|
||||
// 官方只对 qishui.com 且 **非** /passport/、非 /ttwid/ 的请求签名 —— 登录链路用不到本模块。
|
||||
// 签名覆盖 URL + 全部请求头;POST 的 x-ss-stub(body 的 MD5 大写)既是签名输入也要随请求发出。
|
||||
//
|
||||
// 原生文件(官方客户端二进制,专有、不入库、不再分发)放置位置任选其一:
|
||||
// 1) 环境变量 QS_MSSDK_DIR 指向同时含 bdms.node 与 metasecml.dll 的目录
|
||||
// 2) 仓库根 soda-mssdk/(已 gitignore)
|
||||
// 3) 本机已安装的官方客户端:%LOCALAPPDATA%\Programs\Soda Music\<版本>\resources\app.asar.unpacked
|
||||
// 从安装包提取:官方 SodaMusic-v*-official-win32_x64.exe → Bandizip 解 NSIS →
|
||||
// $Var31/app.7z → resources/app.asar.unpacked/{bdms.node, metasecml.dll}
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { getDeviceIds } from './device.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const MODULE_NAME = 'bdms.node'
|
||||
const CLIENT_DIR_NAMES = ['Soda Music', 'SodaMusic', '汽水音乐']
|
||||
// 仓库根(本文件在 sdk/qs/ 下)——不依赖 process.cwd(),从任何目录启动都能找到
|
||||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
|
||||
const findModuleDir = (): string | null => {
|
||||
const candidates: string[] = []
|
||||
if (process.env.QS_MSSDK_DIR) candidates.push(process.env.QS_MSSDK_DIR)
|
||||
candidates.push(join(REPO_ROOT, 'soda-mssdk'))
|
||||
const localAppData = process.env.LOCALAPPDATA
|
||||
if (localAppData) {
|
||||
for (const name of CLIENT_DIR_NAMES) {
|
||||
const root = join(localAppData, 'Programs', name)
|
||||
if (!existsSync(root)) continue
|
||||
try {
|
||||
for (const ver of readdirSync(root)) candidates.push(join(root, ver, 'resources', 'app.asar.unpacked'))
|
||||
} catch {
|
||||
// 读不到目录就跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates.find(dir => dir && existsSync(join(dir, MODULE_NAME))) ?? null
|
||||
}
|
||||
|
||||
interface BdmsModule {
|
||||
init: (options: { deviceId: string }) => unknown
|
||||
generateHttpSignatureHeaders: (url: string, headersLines: string) => string
|
||||
}
|
||||
|
||||
let signer: { module: BdmsModule; deviceId: string; dir: string } | null | undefined
|
||||
|
||||
/** 懒加载官方签名模块(失败只提示一次,之后一律按不可用处理) */
|
||||
const loadSigner = () => {
|
||||
if (signer !== undefined) return signer
|
||||
signer = null
|
||||
try {
|
||||
const dir = findModuleDir()
|
||||
if (!dir) return signer
|
||||
const mod = require(join(dir, MODULE_NAME)) as BdmsModule
|
||||
if (typeof mod?.init !== 'function' || typeof mod?.generateHttpSignatureHeaders !== 'function') {
|
||||
console.log(`[qs/mssdk] ${MODULE_NAME} 导出不符合预期,按不可用处理`)
|
||||
return signer
|
||||
}
|
||||
const { deviceId } = getDeviceIds()
|
||||
mod.init({ deviceId })
|
||||
signer = { module: mod, deviceId, dir }
|
||||
console.log(`[qs/mssdk] 官方签名模块已加载: ${dir} (device_id=${deviceId})`)
|
||||
} catch (err: any) {
|
||||
console.log(`[qs/mssdk] 官方签名模块不可用,降级: ${err?.message ?? err}`)
|
||||
}
|
||||
return signer
|
||||
}
|
||||
|
||||
/** 官方签名是否可用(不影响主流程,仅决定走 PC 通道还是安卓通道) */
|
||||
export const mssdkAvailable = (): boolean => Boolean(loadSigner())
|
||||
|
||||
/** 签名时绑定的设备(与请求 URL 里的 device_id/iid 必须一致) */
|
||||
export const mssdkDeviceId = (): string => loadSigner()?.deviceId ?? ''
|
||||
|
||||
/**
|
||||
* 给一个 qishui.com 请求签名
|
||||
* @param headers 必须是「真实要发出去的那一份」(含 cookie / x-ss-stub)
|
||||
* @returns 需追加的头;不可用时返回 null(调用方走原通道)
|
||||
*/
|
||||
export const signQishuiRequest = (url: string, headers: Record<string, string>): Record<string, string> | null => {
|
||||
const current = loadSigner()
|
||||
if (!current) return null
|
||||
try {
|
||||
const lines = Object.entries(headers)
|
||||
.filter(([name, value]) => name && value != null)
|
||||
.map(([name, value]) => `${name}\r\n${value}`)
|
||||
.join('\r\n')
|
||||
const fields = String(current.module.generateHttpSignatureHeaders(url, lines)).split('\r\n').filter(Boolean)
|
||||
const signed: Record<string, string> = {}
|
||||
for (let i = 0; i + 1 < fields.length; i += 2) signed[fields[i]] = fields[i + 1]
|
||||
if (!signed['X-Helios'] && !signed['x-helios']) return null
|
||||
return signed
|
||||
} catch (err: any) {
|
||||
console.log(`[qs/mssdk] 签名失败,本次降级: ${err?.message ?? err}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 官方 PC 端 UA(签名与 UA 绑定同一客户端版本) */
|
||||
export const PC_UA = 'LunaPC/3.7.0(452316191)'
|
||||
|
||||
/** 官方 PC 端公共查询参数(与签名设备成套;track_v2 / search 等 luna_pc 接口共用) */
|
||||
export const pcCommonParams = (): Record<string, string> => {
|
||||
const { deviceId, installId } = getDeviceIds()
|
||||
return {
|
||||
aid: '386088',
|
||||
app_name: 'luna_pc',
|
||||
region: 'cn',
|
||||
geo_region: 'cn',
|
||||
os_region: 'cn',
|
||||
sim_region: '',
|
||||
device_id: deviceId,
|
||||
cdid: '',
|
||||
iid: installId,
|
||||
version_name: '3.7.0',
|
||||
version_code: '30070000',
|
||||
channel: 'official',
|
||||
build_mode: 'master',
|
||||
network_carrier: '',
|
||||
ac: 'wifi',
|
||||
tz_name: 'Asia/Shanghai',
|
||||
resolution: '',
|
||||
device_platform: 'windows',
|
||||
device_type: 'Windows',
|
||||
os_version: 'Windows 11',
|
||||
fp: deviceId,
|
||||
}
|
||||
}
|
||||
|
||||
export default { mssdkAvailable, mssdkDeviceId, signQishuiRequest, pcCommonParams, PC_UA }
|
||||
+37
-17
@@ -1,10 +1,13 @@
|
||||
// 汽水音乐搜索(App 搜索接口直连)
|
||||
// 接口: https://api.qishui.com/luna/search/track?<安卓设备参数>&q=<关键词>&cursor=&count=
|
||||
// 实测(2026-10-01)匿名可搜索;关键在安卓 App 的设备参数指纹——
|
||||
// 缺参数或用 PC 端参数(device_platform=windows)上游会静默风控,返回 200 + 空 body
|
||||
// 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor=
|
||||
// 两条通道(同一接口,差在公共参数与签名,返回结构一致):
|
||||
// 官方 PC 通道: 装好官方签名模块(sdk/qs/mssdk.ts)时优先——luna_pc 参数 + X-Helios/X-Medusa;
|
||||
// 安卓指纹通道: 未装签名模块时回退——静态安卓设备参数,缺参数或用 PC 参数不签名会被静默风控
|
||||
// (200 + 空 body);被风控时需换新指纹。
|
||||
import { sizeFormate, formatPlayTime } from '../index.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { coverUrl } from './musicInfo.ts'
|
||||
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from './mssdk.ts'
|
||||
|
||||
// 安卓 App 设备参数指纹(社区验证可用的静态设备 id,与 musicdl 一致;被风控时需换新)
|
||||
const SEARCH_DEVICE_PARAMS = {
|
||||
@@ -54,15 +57,29 @@ const SEARCH_DEVICE_PARAMS = {
|
||||
}
|
||||
|
||||
const SEARCH_UA = 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80'
|
||||
const PC_PAGE_SIZE = 20
|
||||
|
||||
// 有官方签名模块时走官方 PC 通道(PC 参数必须配 X-Helios/X-Medusa,否则上游 200 + 空 body)
|
||||
const buildSearchUrl = (str: string, cursor: number, count: number) => {
|
||||
const params = new URLSearchParams({
|
||||
...SEARCH_DEVICE_PARAMS,
|
||||
_rticket: String(Date.now()),
|
||||
q: str,
|
||||
cursor: String(cursor),
|
||||
count: String(count),
|
||||
})
|
||||
const params = mssdkAvailable()
|
||||
? new URLSearchParams({
|
||||
...pcCommonParams(),
|
||||
q: str,
|
||||
cursor: String(cursor),
|
||||
search_id: '',
|
||||
search_method: 'input',
|
||||
debug_params: '',
|
||||
from_search_id: '',
|
||||
search_scene: 'normal',
|
||||
scene_name: 'search',
|
||||
})
|
||||
: new URLSearchParams({
|
||||
...SEARCH_DEVICE_PARAMS,
|
||||
_rticket: String(Date.now()),
|
||||
q: str,
|
||||
cursor: String(cursor),
|
||||
count: String(count),
|
||||
})
|
||||
return `https://api.qishui.com/luna/search/track?${params.toString()}`
|
||||
}
|
||||
|
||||
@@ -103,13 +120,16 @@ export default {
|
||||
},
|
||||
search(str, page = 1, limit, retryNum = 0) {
|
||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
if (limit == null) limit = this.limit
|
||||
const cursor = limit * (page - 1)
|
||||
const searchRequest = httpFetch(buildSearchUrl(str, cursor, limit), {
|
||||
method: 'get',
|
||||
headers: { 'User-Agent': SEARCH_UA },
|
||||
timeout: 15000,
|
||||
})
|
||||
const pcChannel = mssdkAvailable()
|
||||
// PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠
|
||||
const pageSize = pcChannel ? PC_PAGE_SIZE : (limit ?? this.limit)
|
||||
const cursor = pageSize * (page - 1)
|
||||
const url = buildSearchUrl(str, cursor, pageSize)
|
||||
// 签名覆盖真实发出的头:先定头、再签、再发
|
||||
const headers: Record<string, string> = { 'User-Agent': pcChannel ? PC_UA : SEARCH_UA }
|
||||
const signed = signQishuiRequest(url, headers)
|
||||
if (signed) Object.assign(headers, signed)
|
||||
const searchRequest = httpFetch(url, { method: 'get', headers, timeout: 15000 })
|
||||
return searchRequest.promise.then(({ body }) => {
|
||||
// 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}}),重试
|
||||
if (!body?.result_groups) return this.search(str, page, limit, retryNum)
|
||||
|
||||
+159
-55
@@ -1,12 +1,21 @@
|
||||
// 汽水音乐扫码登录 —— 抖音 passport web 二维码(须用「抖音 APP」扫码,汽水 App 扫可能不完成确认)
|
||||
// 汽水音乐扫码登录 —— 复刻官方 PC 客户端(SodaMusic)的扫码链路
|
||||
// 接口:
|
||||
// GET https://api.qishui.com/passport/web/get_qrcode/ → token + qrcode 图片 + 初始化 Cookie
|
||||
// GET https://api.qishui.com/passport/web/get_qrcode/ → token + 初始化 Cookie
|
||||
// GET 二维码内容不是响应里的 qrcode 图,而是用 qrcode_index_url 的 token 重建的官方扫码页:
|
||||
// https://bff-pc.qishui.com/light/invoke/scan_login?token=<token>&os=Windows&computer_name=<主机名>
|
||||
// (官方 renderer getHandleScanCodeH5Img 即此算法;响应自带的 qrcode 图指向旧页
|
||||
// ucenter_web/app/sdk-next,该页已 404,抖音扫码会提示「无法访问页面」)
|
||||
// POST https://api.qishui.com/passport/web/check_qrconnect/ → 轮询状态,确认后 Set-Cookie 带 sessionid
|
||||
// 状态映射沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存)
|
||||
// 状态映射沿用本仓库约定:800 过期/拒绝 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存)
|
||||
import os from 'node:os'
|
||||
import QRCode from 'qrcode'
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
import { getCredentials, setCredentials } from '../auth/store.ts'
|
||||
import { getDeviceIds } from './device.ts'
|
||||
|
||||
const PASSPORT_HOST = 'https://api.qishui.com'
|
||||
const SCAN_LOGIN_URL = 'https://bff-pc.qishui.com/light/invoke/scan_login'
|
||||
const WEB_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36'
|
||||
|
||||
const PASSPORT_FIXED = {
|
||||
aid: '386088',
|
||||
@@ -18,12 +27,30 @@ const PASSPORT_FIXED = {
|
||||
need_short_url: 'false',
|
||||
is_frontier: 'true',
|
||||
is_new_login: '1',
|
||||
iid: '27960026095955',
|
||||
version_code: '30020100',
|
||||
}
|
||||
|
||||
// 各 passport 请求共用的公共参数(设备身份 + 平台/版本)
|
||||
const commonParams = (): Record<string, string> => {
|
||||
const { deviceId, installId } = getDeviceIds()
|
||||
return {
|
||||
passport_jssdk_version: PASSPORT_FIXED.passport_jssdk_version,
|
||||
passport_jssdk_type: PASSPORT_FIXED.passport_jssdk_type,
|
||||
is_from_ttaccountsdk: PASSPORT_FIXED.is_from_ttaccountsdk,
|
||||
aid: PASSPORT_FIXED.aid,
|
||||
language: 'zh',
|
||||
is_new_login: PASSPORT_FIXED.is_new_login,
|
||||
device_id: deviceId,
|
||||
install_id: installId,
|
||||
did: deviceId,
|
||||
iid: installId,
|
||||
device_platform: 'PC',
|
||||
version_code: PASSPORT_FIXED.version_code,
|
||||
}
|
||||
}
|
||||
|
||||
const webHeaders = (cookie = '') => ({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36',
|
||||
'User-Agent': WEB_UA,
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
})
|
||||
|
||||
@@ -35,28 +62,59 @@ const cookieValues = (headers: Record<string, any>): string[] => {
|
||||
const cookiePairsFrom = (headers: Record<string, any>): string[] =>
|
||||
cookieValues(headers).map(c => String(c).split(';')[0].trim()).filter(Boolean)
|
||||
|
||||
const sessionIdFrom = (headers: Record<string, any>): string => {
|
||||
for (const value of cookieValues(headers)) {
|
||||
const match = String(value).match(/(?:^|[;,]\s*)sessionid=([^;,\s]+)/i)
|
||||
if (match) return match[1]
|
||||
const cookieMapFrom = (headers: Record<string, any>): Record<string, string> => {
|
||||
const out: Record<string, string> = {}
|
||||
for (const pair of cookiePairsFrom(headers)) {
|
||||
const idx = pair.indexOf('=')
|
||||
if (idx > 0) out[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
|
||||
}
|
||||
return ''
|
||||
return out
|
||||
}
|
||||
|
||||
const mergeCookies = (current: string, fresh: Record<string, string>): string => {
|
||||
for (const pair of current.split(';')) {
|
||||
const idx = pair.indexOf('=')
|
||||
if (idx > 0) {
|
||||
const name = pair.slice(0, idx).trim()
|
||||
if (name) fresh[name] = fresh[name] || pair.slice(idx + 1).trim()
|
||||
}
|
||||
}
|
||||
return Object.entries(fresh).map(([k, v]) => `${k}=${v}`).join('; ')
|
||||
}
|
||||
|
||||
// 登录态 cookie 名(passport 确认后下发;sessionid 为必需项)
|
||||
const SESSION_COOKIE_NAMES = ['sessionid', 'sessionid_ss', 'sid_guard', 'sid_tt']
|
||||
|
||||
const sessionIdOf = (cookie: string): string =>
|
||||
cookie.match(/(?:^|;\s*)sessionid=([^;]+)/)?.[1] ?? ''
|
||||
|
||||
// get_qrcode 的 Set-Cookie 需在轮询时原样带上(内存暂存,随二维码生命周期)
|
||||
const qrCookies = new Map<string, string>()
|
||||
|
||||
/** 创建扫码登录二维码 */
|
||||
export const createQr = async (): Promise<{ token: string; qrimg: string; expireTime: number; scanApp: string }> => {
|
||||
/** 官方扫码页地址:响应 qrcode_index_url 里的 token + PC 标识(官方 renderer 同款算法) */
|
||||
export const buildScanUrl = (indexUrl: string, computerName = os.hostname() || 'Windows-PC'): string => {
|
||||
let token = ''
|
||||
try {
|
||||
token = new URL(String(indexUrl)).searchParams.get('token') || ''
|
||||
} catch {
|
||||
token = ''
|
||||
}
|
||||
if (!token) throw new Error(`二维码地址缺少 token: ${String(indexUrl).slice(0, 80)}`)
|
||||
const url = new URL(SCAN_LOGIN_URL)
|
||||
url.searchParams.set('token', token)
|
||||
url.searchParams.set('os', 'Windows')
|
||||
url.searchParams.set('computer_name', String(computerName).trim() || 'Windows-PC')
|
||||
// 抖音/客户端把 + 解析成空格,官方实现同样显式替换
|
||||
return url.toString().replace(/\+/g, '%20')
|
||||
}
|
||||
|
||||
/** 创建扫码登录二维码(本地渲染官方扫码页,响应自带的 qrcode 图已失效不可用) */
|
||||
export const createQr = async (): Promise<{ token: string; qrimg: string; expireTime: number; scanApp: string; scanUrl: string }> => {
|
||||
const query = new URLSearchParams({
|
||||
passport_jssdk_version: PASSPORT_FIXED.passport_jssdk_version,
|
||||
passport_jssdk_type: PASSPORT_FIXED.passport_jssdk_type,
|
||||
is_from_ttaccountsdk: PASSPORT_FIXED.is_from_ttaccountsdk,
|
||||
aid: PASSPORT_FIXED.aid,
|
||||
...commonParams(),
|
||||
next: PASSPORT_FIXED.next,
|
||||
need_logo: PASSPORT_FIXED.need_logo,
|
||||
need_short_url: PASSPORT_FIXED.need_short_url,
|
||||
is_new_login: PASSPORT_FIXED.is_new_login,
|
||||
})
|
||||
const request = httpFetch(`${PASSPORT_HOST}/passport/web/get_qrcode/?${query}`, {
|
||||
method: 'get',
|
||||
@@ -68,38 +126,64 @@ export const createQr = async (): Promise<{ token: string; qrimg: string; expire
|
||||
if (statusCode != 200 || !data.token) {
|
||||
throw new Error(`获取二维码失败: HTTP ${statusCode} ${JSON.stringify(body).slice(0, 120)}`)
|
||||
}
|
||||
qrCookies.set(data.token, cookiePairsFrom(headers).join('; '))
|
||||
|
||||
// qrcode 可能是 data URL,也可能是图片链接——后者取回转 data URL 方便直接展示
|
||||
let qrimg = String(data.qrcode || '')
|
||||
if (qrimg && !qrimg.startsWith('data:')) {
|
||||
try {
|
||||
const imgRes = await httpFetch(qrimg, { method: 'get', timeout: 15000 }).promise
|
||||
qrimg = `data:image/png;base64,${imgRes.raw.toString('base64')}`
|
||||
} catch {
|
||||
// 取图失败时退回原始链接,由调用方处理
|
||||
}
|
||||
}
|
||||
const scanUrl = buildScanUrl(data.qrcode_index_url ?? '', undefined)
|
||||
const qrimg = await QRCode.toDataURL(scanUrl, { errorCorrectionLevel: 'M', margin: 2, width: 360 })
|
||||
qrCookies.set(data.token, mergeCookies('', cookieMapFrom(headers)))
|
||||
return {
|
||||
token: String(data.token),
|
||||
qrimg,
|
||||
expireTime: Number(data.expire_time) || 0,
|
||||
scanApp: String(data.copywriting || '').includes('抖音') ? 'douyin' : '',
|
||||
// 官方 PC 客户端扫码文案要求「打开汽水音乐 APP → 搜索框右侧 扫一扫」(passport 的
|
||||
// copywriting 写的是抖音 APP,但扫描目标是 bff-pc 的 scan_login 页,实际须汽水 App)
|
||||
scanApp: 'qishui',
|
||||
scanUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export interface QrDecision {
|
||||
state: 'waiting' | 'scanned' | 'confirmed' | 'expired' | 'failed' | 'mfa'
|
||||
message: string
|
||||
status: string
|
||||
errorCode: number
|
||||
rateLimited?: boolean
|
||||
}
|
||||
|
||||
// 官方状态语义(SodaMusic renderer use-scan-code-login):
|
||||
// 1/new 等待扫码;2/scanned 已扫码待确认;3/confirmed 已确认;4/5/refused/expired 过期或被拒
|
||||
export const decideQrStatus = (data: Record<string, any>): QrDecision => {
|
||||
const status = String(data?.status ?? '').toLowerCase()
|
||||
const errorCode = Number(data?.error_code) || 0
|
||||
const accountFlow = String(data?.account_flow ?? '').toLowerCase()
|
||||
const description = String(data?.description ?? '')
|
||||
|
||||
if (errorCode === 2046 || accountFlow === 'verify') {
|
||||
return { state: 'mfa', message: '需要手机端二次验证(本服务无法自动完成,请在汽水 App 内完成验证后重试)', status, errorCode }
|
||||
}
|
||||
if (errorCode === 7) {
|
||||
return { state: 'waiting', message: '接口限流,请降低轮询频率', status, errorCode, rateLimited: true }
|
||||
}
|
||||
if (['3', 'confirmed'].includes(status)) {
|
||||
return { state: 'confirmed', message: '已确认,等待登录态下发', status, errorCode }
|
||||
}
|
||||
if (['2', 'scanned'].includes(status)) {
|
||||
return { state: 'scanned', message: '已扫码,待确认', status, errorCode }
|
||||
}
|
||||
if (['4', '5', 'refused', 'expired'].includes(status) || errorCode === 4 || /expire|过期|失效/i.test(description)) {
|
||||
const refused = status === 'refused' || /refus|拒绝/i.test(description)
|
||||
return { state: 'expired', message: refused ? '已在手机端拒绝登录' : '二维码已失效', status, errorCode }
|
||||
}
|
||||
if (['error', 'failed'].includes(status) || (errorCode !== 0 && status !== 'new' && status !== '1' && status !== '')) {
|
||||
return { state: 'failed', message: description || `扫码失败 (code=${errorCode})`, status, errorCode }
|
||||
}
|
||||
return { state: 'waiting', message: '等待扫码', status, errorCode }
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询扫码状态
|
||||
* 轮询扫码状态(确认后可能还需再轮询一次才下发登录态)
|
||||
* @returns {{ code: 800|801|802|803, message: string, status?: string, errorCode?: number, nickname? }}
|
||||
*/
|
||||
export const checkQr = async (token: string): Promise<Record<string, any>> => {
|
||||
const query = new URLSearchParams({
|
||||
passport_jssdk_version: PASSPORT_FIXED.passport_jssdk_version,
|
||||
passport_jssdk_type: PASSPORT_FIXED.passport_jssdk_type,
|
||||
is_from_ttaccountsdk: PASSPORT_FIXED.is_from_ttaccountsdk,
|
||||
aid: PASSPORT_FIXED.aid,
|
||||
iid: PASSPORT_FIXED.iid,
|
||||
})
|
||||
const query = new URLSearchParams(commonParams())
|
||||
const form = new URLSearchParams({
|
||||
need_logo: PASSPORT_FIXED.need_logo,
|
||||
need_short_url: PASSPORT_FIXED.need_short_url,
|
||||
@@ -108,12 +192,11 @@ export const checkQr = async (token: string): Promise<Record<string, any>> => {
|
||||
is_new_login: PASSPORT_FIXED.is_new_login,
|
||||
next: PASSPORT_FIXED.next,
|
||||
})
|
||||
const cookie = qrCookies.get(token) || ''
|
||||
const request = httpFetch(`${PASSPORT_HOST}/passport/web/check_qrconnect/?${query}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...webHeaders(cookie),
|
||||
...webHeaders(qrCookies.get(token) || ''),
|
||||
},
|
||||
body: form.toString(),
|
||||
timeout: 15000,
|
||||
@@ -121,33 +204,51 @@ export const checkQr = async (token: string): Promise<Record<string, any>> => {
|
||||
const { statusCode, headers, body } = await request.promise
|
||||
if (statusCode != 200) throw Object.assign(new Error(`轮询扫码状态失败: HTTP ${statusCode}`), { status: 502 })
|
||||
const data = (body as any)?.data ?? {}
|
||||
const sessionid = sessionIdFrom(headers)
|
||||
const status = String(data.status ?? '')
|
||||
const errorCode = Number(data.error_code) || 0
|
||||
const decision = decideQrStatus(data)
|
||||
|
||||
if (sessionid) {
|
||||
setCredentials('qs', { sessionid })
|
||||
// 采集本次轮询下发的 Set-Cookie(确认后登录态就在这里)
|
||||
let cookie = mergeCookies(qrCookies.get(token) || '', cookieMapFrom(headers))
|
||||
|
||||
// 确认后若响应体带 redirect_url(浏览器里跳这一跳才落 sessionid),服务端补跳一次收 cookie
|
||||
if (decision.state === 'confirmed' && !SESSION_COOKIE_NAMES.some(name => new RegExp(`(?:^|;\\s*)${name}=`).test(cookie))) {
|
||||
const redirectUrl = String(data.redirect_url || '')
|
||||
if (redirectUrl) {
|
||||
try {
|
||||
const hop = await httpFetch(redirectUrl, { method: 'get', headers: webHeaders(cookie), timeout: 15000 }).promise
|
||||
cookie = mergeCookies(cookie, cookieMapFrom(hop.headers))
|
||||
} catch {
|
||||
// 补跳失败不致命:下一轮轮询仍可能下发登录态
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sessionid = sessionIdOf(cookie)
|
||||
if (sessionid && SESSION_COOKIE_NAMES.some(name => new RegExp(`(?:^|;\\s*)${name}=`).test(cookie))) {
|
||||
setCredentials('qs', { sessionid, cookie })
|
||||
qrCookies.delete(token)
|
||||
const profile = (body as any)?.data?.user_info ?? {}
|
||||
return {
|
||||
code: 803,
|
||||
message: '登录成功',
|
||||
nickname: profile.nickname ?? profile.name ?? undefined,
|
||||
status,
|
||||
errorCode,
|
||||
status: decision.status,
|
||||
errorCode: decision.errorCode,
|
||||
}
|
||||
}
|
||||
|
||||
// 未确认:区分等待/已扫码/过期。上游状态语义未完全公开,按观察到的值映射,未知值一律视为等待
|
||||
const expired = errorCode === 4 || /expire|过期|失效/i.test(String(data.description ?? ''))
|
||||
if (expired) {
|
||||
qrCookies.set(token, cookie)
|
||||
|
||||
if (decision.state === 'confirmed') {
|
||||
return { code: 802, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
||||
}
|
||||
if (decision.state === 'expired' || decision.state === 'failed') {
|
||||
qrCookies.delete(token)
|
||||
return { code: 800, message: '二维码已失效', status, errorCode }
|
||||
return { code: 800, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
||||
}
|
||||
if (/scan/i.test(status) || errorCode === 205) {
|
||||
return { code: 802, message: '已扫码,待确认', status, errorCode }
|
||||
if (decision.state === 'scanned' || decision.state === 'mfa') {
|
||||
return { code: 802, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
||||
}
|
||||
return { code: 801, message: '等待扫码', status, errorCode }
|
||||
return { code: 801, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
||||
}
|
||||
|
||||
/** 当前登录态 */
|
||||
@@ -159,4 +260,7 @@ export const status = (): { logged: boolean; updatedAt?: string } => {
|
||||
/** 登录态 sessionid(未登录返回空串) */
|
||||
export const getSessionId = (): string => (getCredentials('qs') as any)?.sessionid || ''
|
||||
|
||||
export default { createQr, checkQr, status, getSessionId }
|
||||
/** 登录态完整 Cookie(未登录返回空串;取流优先用它,sessionid 只是最小集) */
|
||||
export const getCookie = (): string => (getCredentials('qs') as any)?.cookie || ''
|
||||
|
||||
export default { createQr, checkQr, status, getSessionId, getCookie, buildScanUrl, decideQrStatus }
|
||||
Reference in new issue
Block a user