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:
snowy-yang committed 2026-10-03 00:27:19 +08:00
1 parent 68e071b166
commit de2dd311ee
6 files changed
+574 -92

No files matched your search

+65 -19
View File
@@ -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)