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

+37 -17
View File
@@ -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)