feat(qt): 蜻蜓FM平台接入——audiostream HMAC-MD5 签名直链、SSR 搜索/频道节目列表

This commit is contained in:
snowy-yang committed 2026-10-02 19:43:20 +08:00
1 parent 020e9bbb2e
commit 83d3eb3b49
5 files changed
+199

No files matched your search

+48
View File
@@ -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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/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 }
+39
View File
@@ -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 }
+39
View File
@@ -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
+39
View File
@@ -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 }