- 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 字节,把失败伪装 成空成功,没有签名就分不清「接口异常」与「风控拦截」。
152 lines
5.5 KiB
TypeScript
152 lines
5.5 KiB
TypeScript
// 汽水音乐搜索(App 搜索接口直连)
|
||
// 接口: 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 = {
|
||
device_platform: 'android',
|
||
os: 'android',
|
||
ssmix: 'a',
|
||
cdid: '46556f98-1720-4248-83da-62b74b60b46a',
|
||
channel: 'xiaomi_8478_64',
|
||
aid: '386088',
|
||
app_name: 'luna',
|
||
version_code: '100198030',
|
||
version_name: '19.8.0',
|
||
manifest_version_code: '100198030',
|
||
update_version_code: '100198030',
|
||
resolution: '1080*1920',
|
||
dpi: '480',
|
||
device_type: 'ABR-AL80',
|
||
device_brand: 'HUAWEI',
|
||
language: 'zh',
|
||
os_api: '35',
|
||
os_version: '15',
|
||
ac: 'wifi',
|
||
device_model: 'ABR-AL80',
|
||
save_power: '0',
|
||
font_size: '1.00',
|
||
luna_first_launch_apk_type: 'normal_apk',
|
||
diversion_channel_name: 'xiaomi_8478_64',
|
||
is_car_play: '0',
|
||
battery: '0.99',
|
||
network_speed: '10156',
|
||
hybrid_version_code: '100198030',
|
||
tz_name: 'Asia/Shanghai',
|
||
tz_offset: '28800',
|
||
luna_register_time: '1784311292',
|
||
package: 'com.luna.music',
|
||
charge: '0',
|
||
luna_apk_type: 'normal_apk',
|
||
output_device_type: 'Phone',
|
||
volume: '1.00',
|
||
brightness: '0.08',
|
||
need_personal_recommend: '1',
|
||
is_teen_mode: '0',
|
||
sim_region: 'cn',
|
||
android_device_type: 'default',
|
||
iid: '2204957404569386',
|
||
device_id: '2204957404565290',
|
||
}
|
||
|
||
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 = 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()}`
|
||
}
|
||
|
||
export default {
|
||
limit: 30,
|
||
total: 0,
|
||
page: 0,
|
||
allPage: 1,
|
||
getSinger(artists) {
|
||
return (artists || []).map(singer => singer.name).filter(Boolean).join('、')
|
||
},
|
||
handleResult(rawList) {
|
||
if (!rawList) return []
|
||
return rawList.map(item => {
|
||
const track = item?.entity?.track
|
||
if (!track?.id) return null
|
||
// types 反映匿名实际可获取的资源:30 秒试听(preview.bit_rates 为试听码率)
|
||
// track.bit_rates 是完整曲目码率元数据(66k~hi_res),但匿名无权取流,不对外声明
|
||
const previewSize = track.preview?.bit_rates?.[0]?.size
|
||
const size = previewSize ? sizeFormate(previewSize) : null
|
||
const types = [{ type: '128k', size }]
|
||
const _types = { '128k': { size } }
|
||
return {
|
||
singer: this.getSinger(track.artists),
|
||
name: track.name,
|
||
albumName: track.album?.name ?? null,
|
||
albumId: track.album?.id ?? null,
|
||
source: 'qs',
|
||
interval: formatPlayTime(track.duration / 1000),
|
||
songmid: String(track.id),
|
||
img: coverUrl(track.album),
|
||
lrc: null,
|
||
types,
|
||
_types,
|
||
typeUrl: {},
|
||
}
|
||
}).filter(Boolean)
|
||
},
|
||
search(str, page = 1, limit, retryNum = 0) {
|
||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||
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)
|
||
const group = body.result_groups.find(g => g?.data?.length) || { data: [], has_more: false }
|
||
const list = this.handleResult(group.data)
|
||
const total = group.has_more ? cursor + list.length + 1 : cursor + list.length
|
||
this.total = total
|
||
this.page = page
|
||
this.allPage = group.has_more ? page + 1 : page
|
||
return {
|
||
list,
|
||
allPage: this.allPage,
|
||
limit: this.limit,
|
||
total,
|
||
source: 'qs',
|
||
}
|
||
})
|
||
},
|
||
}
|