import crypto from 'node:crypto' export const txHeaders = { 'User-Agent': 'QQMusic 14090508(android 12)' } export const TX_MUSICU_URL = 'https://u.y.qq.com/cgi-bin/musicu.fcg' const QQ_MID_PREFIX_RE = /^qqmid:/i const QQ_LEGACY_ID_RE = /^\d+$/ const TX_DETAIL_CACHE_TTL = 6 * 60 * 60 * 1000 // 签名算法常量 const PART_1_INDEXES = [23, 14, 6, 36, 16, 40, 7, 19] const PART_2_INDEXES = [16, 1, 32, 12, 19, 27, 8, 5] const SCRAMBLE_VALUES = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179] function pickHashByIdx(hash: string, indexes: number[]) { return indexes.map((idx) => hash[idx]).join('') } function base64Encode(data: Buffer | string) { return Buffer.from(data) .toString('base64') .replace(/[\\/+=]/g, '') } /** * zzcSign 签名算法 * 模拟官方客户端签名,抗封锁能力更强 */ export async function zzcSign(text: string) { const hash = crypto.createHash('sha1').update(text).digest('hex') const part1 = pickHashByIdx(hash, PART_1_INDEXES) const part2 = pickHashByIdx(hash, PART_2_INDEXES) const part3 = SCRAMBLE_VALUES.map((value, i) => value ^ parseInt(hash.slice(i * 2, i * 2 + 2), 16)) const b64Part = base64Encode(Buffer.from(part3)).replace(/[\\/+=]/g, '') return `zzc${part1}${b64Part}${part2}`.toLowerCase() } /** * 使用签名请求 QQ 音乐 API * 相比普通 musicu.fcg,musics.fcg?sign=... 的签名请求更稳定 */ export const txSignedRequest = async (body: any, options: { signal?: AbortSignal } = {}) => { const sign = await zzcSign(JSON.stringify(body)) try { const response = await $fetch(`https://u.y.qq.com/cgi-bin/musics.fcg?sign=${sign}`, { method: 'POST', headers: txHeaders, body, responseType: 'json', signal: options.signal }) return response } catch (error) { console.error('TX Signed Request Error:', error) throw error } } type TxIdType = 'legacy-id' | 'mid' interface TxNormalizedMusicId { rawMusicId: string normalizedMusicId: string idType: TxIdType } interface TxSongPlayableInfo extends TxNormalizedMusicId { songmid: string songId?: string strMediaMid?: string } const txSongDetailCache = new Map< string, { expiresAt: number value: TxSongPlayableInfo } >() /** * PC 客户端版 searchid:32 位大写十六进制 GUID + 5 位补零随机数(37 字符) */ const getTxSearchId = () => { let guid = '' for (let i = 0; i < 32; i++) guid += Math.floor(Math.random() * 16).toString(16) return guid.toUpperCase() + String(Math.floor(Math.random() * 100000)).padStart(5, '0') } export const createTxSearchBody = (str: string, page: number, limit: number) => { return { comm: { _channelid: '0', _os_version: '6.2.9200-2', ct: '19', cv: '2151', guid: '1F70E520B2EAA7D25E11760783C53CA9', patch: '118', psrf_access_token_expiresAt: 0, psrf_qqaccess_token: '', psrf_qqopenid: '', psrf_qqunionid: '', tmeAppID: 'qqmusic', tmeLoginType: 0, uin: '0', wid: '7223299733393904640' }, 'music.search.SearchCgiService': { module: 'music.search.SearchCgiService', method: 'DoSearchForQQMusicDesktop', param: { grp: 1, num_per_page: limit, page_num: page, query: str, remoteplace: 'txt.newclient.top', search_type: 0, searchid: getTxSearchId() } } } } export const normalizeTxMusicId = (musicId: string | number): TxNormalizedMusicId => { const rawMusicId = String(musicId ?? '').trim() const normalizedMusicId = rawMusicId.replace(QQ_MID_PREFIX_RE, '').trim() if (!normalizedMusicId) { throw createError({ statusCode: 400, message: '缺少 QQ 音乐ID' }) } return { rawMusicId, normalizedMusicId, idType: QQ_LEGACY_ID_RE.test(normalizedMusicId) ? 'legacy-id' : 'mid' } } export const upgradeTxAudioUrl = (url: string) => { return url.startsWith('http://') ? url.replace('http://', 'https://') : url } export const createTxSongDetailBody = (musicId: TxNormalizedMusicId) => { const param = musicId.idType === 'legacy-id' ? { song_type: 0, song_id: Number(musicId.normalizedMusicId) } : { song_type: 0, song_mid: musicId.normalizedMusicId } return { comm: { ct: '19', cv: '1859', uin: '0' }, req: { module: 'music.pf_song_detail_svr', method: 'get_song_detail_yqq', param } } } const getCachedTxSongDetail = (key: string) => { const cached = txSongDetailCache.get(key) if (!cached) return null if (cached.expiresAt <= Date.now()) { txSongDetailCache.delete(key) return null } return cached.value } const setCachedTxSongDetail = (key: string, value: TxSongPlayableInfo) => { if (txSongDetailCache.size >= 1000) { const firstKey = txSongDetailCache.keys().next().value if (firstKey !== undefined) { txSongDetailCache.delete(firstKey) } } txSongDetailCache.set(key, { expiresAt: Date.now() + TX_DETAIL_CACHE_TTL, value }) } export const getTxSongPlayableInfo = async ( musicId: string | number ): Promise => { const normalized = normalizeTxMusicId(musicId) const cacheKey = `${normalized.idType}:${normalized.normalizedMusicId}` const cached = getCachedTxSongDetail(cacheKey) if (cached) return cached // 旧 QQ 数字 ID 只在播放时转 MID,避免批量改历史数据。 let result: any try { result = await txRequest(TX_MUSICU_URL, createTxSongDetailBody(normalized)) } catch (error) { if (normalized.idType === 'mid') { const value = { ...normalized, songmid: normalized.normalizedMusicId } setCachedTxSongDetail(cacheKey, value) return value } throw error } if (!result || result.code !== 0 || result.req?.code !== 0) { if (normalized.idType === 'mid') { const value = { ...normalized, songmid: normalized.normalizedMusicId } setCachedTxSongDetail(cacheKey, value) return value } throw createError({ statusCode: 502, message: 'QQ 音乐详情接口异常' }) } const trackInfo = result.req?.data?.track_info const songmid = trackInfo?.mid || normalized.normalizedMusicId if (!songmid) { throw createError({ statusCode: 502, message: 'QQ 音乐详情缺少 MID' }) } const value = { ...normalized, songmid, songId: String(trackInfo?.id || normalized.normalizedMusicId), strMediaMid: trackInfo?.file?.media_mid } setCachedTxSongDetail(cacheKey, value) setCachedTxSongDetail(`mid:${songmid}`, { ...value, normalizedMusicId: songmid, idType: 'mid' }) return value } export const txRequest = async ( url: string, body: any, options: { signal?: AbortSignal } = {} ) => { try { const response = await $fetch(url, { method: 'POST', headers: txHeaders, body, responseType: 'json', signal: options.signal }) return response } catch (error) { console.error('TX Request Error:', error) throw error } }