From de2dd311ee10d8d45088d285c07abd78e3aea291 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:27:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(qs):=20=E6=8E=A5=E5=85=A5=E5=AE=98?= =?UTF-8?q?=E6=96=B9=E7=AD=BE=E5=90=8D=E6=A8=A1=E5=9D=97=E3=80=81=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E3=80=81=E6=96=B0=E5=A2=9E=E6=9C=AC=E6=9C=BA=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 字节,把失败伪装 成空成功,没有签名就分不清「接口异常」与「风控拦截」。 --- scripts/qs-local-login.mjs | 157 +++++++++++++++++++++++++++ sdk/apis/qs.ts | 84 +++++++++++---- sdk/qs/device.ts | 19 +++- sdk/qs/mssdk.ts | 138 ++++++++++++++++++++++++ sdk/qs/musicSearch.ts | 54 +++++++--- sdk/qs/passport.ts | 214 +++++++++++++++++++++++++++---------- 6 files changed, 574 insertions(+), 92 deletions(-) create mode 100644 scripts/qs-local-login.mjs create mode 100644 sdk/qs/mssdk.ts diff --git a/scripts/qs-local-login.mjs b/scripts/qs-local-login.mjs new file mode 100644 index 0000000..98321c8 --- /dev/null +++ b/scripts/qs-local-login.mjs @@ -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))) diff --git a/sdk/apis/qs.ts b/sdk/apis/qs.ts index 4123df5..1a0177c 100644 --- a/sdk/apis/qs.ts +++ b/sdk/apis/qs.ts @@ -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 => { - 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, 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 = { + '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 => { + 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) diff --git a/sdk/qs/device.ts b/sdk/qs/device.ts index 0fd9355..aa4ee5e 100644 --- a/sdk/qs/device.ts +++ b/sdk/qs/device.ts @@ -151,4 +151,21 @@ export const getDevice = async (): Promise => { 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 } diff --git a/sdk/qs/mssdk.ts b/sdk/qs/mssdk.ts new file mode 100644 index 0000000..d8812b3 --- /dev/null +++ b/sdk/qs/mssdk.ts @@ -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): Record | 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 = {} + 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 => { + 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 } diff --git a/sdk/qs/musicSearch.ts b/sdk/qs/musicSearch.ts index 793ff79..5052e1d 100644 --- a/sdk/qs/musicSearch.ts +++ b/sdk/qs/musicSearch.ts @@ -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 = { '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) diff --git a/sdk/qs/passport.ts b/sdk/qs/passport.ts index d150456..793d597 100644 --- a/sdk/qs/passport.ts +++ b/sdk/qs/passport.ts @@ -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=&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 => { + 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[] => { const cookiePairsFrom = (headers: Record): string[] => cookieValues(headers).map(c => String(c).split(';')[0].trim()).filter(Boolean) -const sessionIdFrom = (headers: Record): 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): Record => { + const out: Record = {} + 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 => { + 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() -/** 创建扫码登录二维码 */ -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): 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> => { - 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> => { 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> => { 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 }