- 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 字节,把失败伪装 成空成功,没有签名就分不清「接口异常」与「风控拦截」。
139 lines
5.8 KiB
TypeScript
139 lines
5.8 KiB
TypeScript
// 汽水官方 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 }
|