From 83d3eb3b491c703775bbd63b3f444a8ae7a89974 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:43:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(qt):=20=E8=9C=BB=E8=9C=93FM=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=8E=A5=E5=85=A5=E2=80=94=E2=80=94audiostream=20HMAC?= =?UTF-8?q?-MD5=20=E7=AD=BE=E5=90=8D=E7=9B=B4=E9=93=BE=E3=80=81SSR=20?= =?UTF-8?q?=E6=90=9C=E7=B4=A2/=E9=A2=91=E9=81=93=E8=8A=82=E7=9B=AE?= =?UTF-8?q?=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/apis/qt.ts | 34 ++++++++++++++++++++++++++++++ sdk/qt/album.ts | 48 +++++++++++++++++++++++++++++++++++++++++++ sdk/qt/http.ts | 39 +++++++++++++++++++++++++++++++++++ sdk/qt/index.ts | 39 +++++++++++++++++++++++++++++++++++ sdk/qt/musicSearch.ts | 39 +++++++++++++++++++++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 sdk/apis/qt.ts create mode 100644 sdk/qt/album.ts create mode 100644 sdk/qt/http.ts create mode 100644 sdk/qt/index.ts create mode 100644 sdk/qt/musicSearch.ts diff --git a/sdk/apis/qt.ts b/sdk/apis/qt.ts new file mode 100644 index 0000000..fc2380a --- /dev/null +++ b/sdk/apis/qt.ts @@ -0,0 +1,34 @@ +// 蜻蜓FM URL 获取(点播 audiostream 签名直链) +// 链路(官网 main.js 逆向,2026-10-02 实测): +// GET https://audio.qtfm.cn/audiostream/redirect//?access_token=&device_id=MOBILESITE&qingting_id=&t=&sign= +// sign = HMAC-MD5("7l8CZ)SgZgM_bkrw", path+query) —— 签名内容含完整 path 与 query(sign 自身除外) +// 匿名空 token 即可取免费节目;响应 302 到华为云签名 CDN(vohwod-sign.qtfm.cn,m4a/mp3) +// 直播电台另有 /live//64k.mp3 + HMAC-MD5("Lwrpu$K5oP") 签名(lhttp-hw.qtfm.cn) +// songmid 为复合 id ":"(/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 需为 :(见 /album/qt 节目列表)') + } + return { type: type === '128k' ? '128k' : '64k', url: buildProgramUrl(channelId, programId) } + })() + return requestObj +} + +export default { getMusicUrl, buildProgramUrl } diff --git a/sdk/qt/album.ts b/sdk/qt/album.ts new file mode 100644 index 0000000..323cc24 --- /dev/null +++ b/sdk/qt/album.ts @@ -0,0 +1,48 @@ +// 蜻蜓FM 频道节目列表(官网 SSR 页解析) +// 接口: https://www.qingting.fm/channels/(302 → www.qtfm.cn,见 http.ts) +// 实测(2026-10-02)SSR 输出含节目表格:a.pTitle href=/channels//programs/ + p[title] + span.col2 时长 +// 节目条目的 songmid 采用复合 id ":"(取流签名需要两者) +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(/([^<]*)</)?.[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 } diff --git a/sdk/qt/http.ts b/sdk/qt/http.ts new file mode 100644 index 0000000..3601cba --- /dev/null +++ b/sdk/qt/http.ts @@ -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 } diff --git a/sdk/qt/index.ts b/sdk/qt/index.ts new file mode 100644 index 0000000..97c41e0 --- /dev/null +++ b/sdk/qt/index.ts @@ -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 diff --git a/sdk/qt/musicSearch.ts b/sdk/qt/musicSearch.ts new file mode 100644 index 0000000..8321a9a --- /dev/null +++ b/sdk/qt/musicSearch.ts @@ -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 }