feat(qt): 蜻蜓FM平台接入——audiostream HMAC-MD5 签名直链、SSR 搜索/频道节目列表
This commit is contained in:
1 parent
020e9bbb2e
commit
83d3eb3b49
5 files changed
+199
No files matched your search
@@ -0,0 +1,34 @@
|
||||
// 蜻蜓FM URL 获取(点播 audiostream 签名直链)
|
||||
// 链路(官网 main.js 逆向,2026-10-02 实测):
|
||||
// GET https://audio.qtfm.cn/audiostream/redirect/<channel>/<program>?access_token=&device_id=MOBILESITE&qingting_id=&t=<ms>&sign=<hex>
|
||||
// sign = HMAC-MD5("7l8CZ)SgZgM_bkrw", path+query) —— 签名内容含完整 path 与 query(sign 自身除外)
|
||||
// 匿名空 token 即可取免费节目;响应 302 到华为云签名 CDN(vohwod-sign.qtfm.cn,m4a/mp3)
|
||||
// 直播电台另有 /live/<cid>/64k.mp3 + HMAC-MD5("Lwrpu$K5oP") 签名(lhttp-hw.qtfm.cn)
|
||||
// songmid 为复合 id "<channelId>:<programId>"(/album/qt 列表产出)
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const SIGN_KEY = '7l8CZ)SgZgM_bkrw'
|
||||
const AUDIO_HOST = 'https://audio.qtfm.cn'
|
||||
|
||||
export const supportQualitys = ['64k', '128k']
|
||||
|
||||
export const buildProgramUrl = (channelId: string, programId: string) => {
|
||||
const s = `/audiostream/redirect/${channelId}/${programId}?access_token=&device_id=MOBILESITE&qingting_id=&t=${Date.now()}`
|
||||
const sign = crypto.createHmac('md5', SIGN_KEY).update(s).digest('hex')
|
||||
return `${AUDIO_HOST}${s}&sign=${sign}`
|
||||
}
|
||||
|
||||
export const getMusicUrl = (songInfo, type) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||
const songmid = String(songInfo.songmid ?? '')
|
||||
const [channelId, programId] = songmid.split(':')
|
||||
requestObj.promise = (async() => {
|
||||
if (!channelId || !programId) {
|
||||
throw new Error('蜻蜓 id 需为 <channelId>:<programId>(见 /album/qt 节目列表)')
|
||||
}
|
||||
return { type: type === '128k' ? '128k' : '64k', url: buildProgramUrl(channelId, programId) }
|
||||
})()
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl, buildProgramUrl }
|
||||
@@ -0,0 +1,48 @@
|
||||
// 蜻蜓FM 频道节目列表(官网 SSR 页解析)
|
||||
// 接口: https://www.qingting.fm/channels/<id>(302 → www.qtfm.cn,见 http.ts)
|
||||
// 实测(2026-10-02)SSR 输出含节目表格:a.pTitle href=/channels/<cid>/programs/<pid> + p[title] + span.col2 时长
|
||||
// 节目条目的 songmid 采用复合 id "<channelId>:<programId>"(取流签名需要两者)
|
||||
import { ssrGet } from './http.ts'
|
||||
|
||||
const decodeEntities = (s: string) => s
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, "'")
|
||||
|
||||
const fetchChannelHtml = async(channelId: string) => {
|
||||
const { statusCode, html } = await ssrGet(`https://www.qingting.fm/channels/${channelId}`)
|
||||
if (statusCode != 200) throw new Error(`获取频道失败(HTTP ${statusCode})`)
|
||||
return html
|
||||
}
|
||||
|
||||
export const getChannelPrograms = async(channelId: string) => {
|
||||
const html = await fetchChannelHtml(channelId)
|
||||
|
||||
const name = decodeEntities(html.match(/<title>([^<]*)</)?.[1]?.split('-')[0]?.trim() || channelId)
|
||||
const rows = [...html.matchAll(/href="\/channels\/(\d+)\/programs\/(\d+)"[^>]*>\s*<p[^>]*title="([^"]*)"/g)]
|
||||
// 标题可能为空(p[title] 空串),用行内文本兜底;时长在后续 col2
|
||||
const list = rows.map(m => ({
|
||||
songmid: `${m[1]}:${m[2]}`,
|
||||
name: decodeEntities(m[3]) || `节目 ${m[2]}`,
|
||||
singer: name,
|
||||
albumId: String(channelId),
|
||||
albumName: name,
|
||||
interval: 0,
|
||||
payPlay: false,
|
||||
img: null,
|
||||
lrc: '',
|
||||
lrcx: '',
|
||||
types: [],
|
||||
_types: {},
|
||||
disabled: false,
|
||||
}))
|
||||
return { name, list }
|
||||
}
|
||||
|
||||
/** 频道封面(SSR 页 og:image) */
|
||||
export const getChannelOgImage = async(channelId: string): Promise<string | null> => {
|
||||
const html = await fetchChannelHtml(channelId)
|
||||
const og = html.match(/property="og:image"\s+content="([^"]+)"/)?.[1] || html.match(/content="([^"]+)"\s+property="og:image"/)?.[1]
|
||||
return og ? og.replace('http://', 'https://') : null
|
||||
}
|
||||
|
||||
export default { getChannelPrograms }
|
||||
@@ -0,0 +1,39 @@
|
||||
// 蜻蜓FM SSR 请求助手 —— 域名迁移(www.qingting.fm → www.qtfm.cn)伴随华为 WAF 会话,
|
||||
// 302 时需带上 Set-Cookie 的 HWWAFSESID/HWWAFSESTIME 再请求 Location,否则可能被 WAF 拦
|
||||
import { httpFetch } from '../request.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
/** GET 并跟随最多 3 跳重定向(累计收集 Cookie 回带),返回最终响应 */
|
||||
export const ssrGet = async(url: string): Promise<{ statusCode: number, html: string }> => {
|
||||
let current = url
|
||||
let cookie = ''
|
||||
for (let hop = 0; hop < 4; hop++) {
|
||||
const { statusCode, headers, raw, body } = await httpFetch(current, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://www.qtfm.cn/',
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
timeout: 15000,
|
||||
}).promise as any
|
||||
const setCookies = (headers as any)?.['set-cookie'] ?? []
|
||||
const pairs = (Array.isArray(setCookies) ? setCookies : [setCookies])
|
||||
.map((c: string) => String(c).split(';')[0].trim())
|
||||
.filter(Boolean)
|
||||
if (pairs.length) cookie = [...cookie.split('; ').filter(Boolean), ...pairs].join('; ')
|
||||
|
||||
if (statusCode >= 300 && statusCode < 400) {
|
||||
const loc = String((headers as any)?.location || '')
|
||||
if (loc) {
|
||||
current = loc.startsWith('http') ? loc : new URL(loc, current).href
|
||||
continue
|
||||
}
|
||||
}
|
||||
return { statusCode, html: raw ? raw.toString('utf8') : String(body ?? '') }
|
||||
}
|
||||
throw new Error('重定向次数过多')
|
||||
}
|
||||
|
||||
export default { ssrGet }
|
||||
@@ -0,0 +1,39 @@
|
||||
// 蜻蜓FM 平台模块
|
||||
// 匿名可用面(2026-10-02 实测):搜索(官网 SSR 解析)/ 频道节目列表(SSR)/
|
||||
// 免费节目点播直链(audiostream 签名 302 到华为云 CDN)。
|
||||
// 搜索结果为频道(专辑)级条目,songmid=channelId;节目条目 songmid="<channelId>:<programId>"。
|
||||
// 付费节目与直播电台签名(Lwrpu$K5oP)暂未接登录。有声内容不参与聚合(noAggregate)。
|
||||
import qtApi from '../apis/qt.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import { getChannelPrograms } from './album.ts'
|
||||
|
||||
const qt = {
|
||||
musicSearch,
|
||||
getMusicUrl(songInfo, type) {
|
||||
return qtApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
async getPic(songInfo) {
|
||||
// 搜索结果已带封面;频道详情页无独立封面接口,降级用 SSR 页 og:image
|
||||
const { getChannelOgImage } = await import('./album.ts')
|
||||
return getChannelOgImage(songInfo.songmid.split(':')[0])
|
||||
},
|
||||
album: {
|
||||
async getAlbumDetail(channelId: string) {
|
||||
const { name, list } = await getChannelPrograms(channelId)
|
||||
return {
|
||||
info: { name, total: list.length },
|
||||
list,
|
||||
total: list.length,
|
||||
limit: list.length,
|
||||
allPage: 1,
|
||||
source: 'qt' as const,
|
||||
}
|
||||
},
|
||||
},
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
const [cid, pid] = String(songInfo.songmid).split(':')
|
||||
return pid ? `https://www.qingting.fm/channels/${cid}/programs/${pid}` : `https://www.qingting.fm/channels/${cid}`
|
||||
},
|
||||
}
|
||||
|
||||
export default qt
|
||||
@@ -0,0 +1,39 @@
|
||||
// 蜻蜓FM 搜索(官网 SSR 频道结果页解析)
|
||||
// 接口: https://www.qingting.fm/search/channel_ondemand/<keyword>(302 → www.qtfm.cn,见 http.ts)
|
||||
// 实测(2026-10-02)SSR 渲染且与关键词强相关;?keyword= 混合页的结果区为客户端渲染
|
||||
// (服务端只吐热门推荐),JSON 搜索端点(webapi/webbff/searchapi)均 404——本页是唯一匿名搜索通道
|
||||
import { ssrGet } from './http.ts'
|
||||
|
||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const { statusCode, html } = await ssrGet(`https://www.qingting.fm/search/channel_ondemand/${encodeURIComponent(keyword)}?page=${page}`)
|
||||
if (statusCode != 200) throw new Error(`搜索失败(HTTP ${statusCode})`)
|
||||
// 结果项:href=/channels/<id> … <span title="<名称>">,封面在随后的 img src
|
||||
const items = [...html.matchAll(/href="\/channels\/(\d+)"[\s\S]{0,400}?<span title="([^"]*)">/g)]
|
||||
const list = items.map(m => {
|
||||
const after = html.slice(m.index ?? 0, (m.index ?? 0) + 700)
|
||||
const img = after.match(/<img src="(http[^"]+)"/)?.[1]
|
||||
return {
|
||||
songmid: m[1],
|
||||
name: m[2],
|
||||
singer: '',
|
||||
albumId: '',
|
||||
albumName: '',
|
||||
interval: 0,
|
||||
img: img ? img.replace('http://', 'https://') : null,
|
||||
lrc: '',
|
||||
lrcx: '',
|
||||
types: [],
|
||||
_types: {},
|
||||
disabled: false,
|
||||
}
|
||||
}).filter((item): item is NonNullable<typeof item> => Boolean(item.name)).slice(0, limit)
|
||||
return {
|
||||
allPage: page + (list.length >= 10 ? 1 : 0),
|
||||
total: list.length,
|
||||
limit,
|
||||
source: 'qt' as const,
|
||||
list,
|
||||
}
|
||||
}
|
||||
|
||||
export default { search }
|
||||
Reference in new issue
Block a user