From cd5d9b1c916ef19298d32ca92decce2f5323f171 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:08:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(me):=20=E6=90=9C=E7=B4=A2=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=E7=89=88=E2=80=94=E2=80=94dramaapi/search=20?= =?UTF-8?q?=E6=AD=A3=E5=89=A7=E7=BD=AE=E9=A1=B6=EF=BC=8Cd:<=E5=89=A7id>=20?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E8=87=AA=E5=8A=A8=E5=8F=96=E9=A6=96=E4=B8=AA?= =?UTF-8?q?=E5=85=8D=E8=B4=B9=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/apis/me.ts | 51 ++++++++++++++++----- sdk/me/album.ts | 7 ++- sdk/me/musicSearch.ts | 103 ++++++++++++++++++++++++++---------------- 3 files changed, 110 insertions(+), 51 deletions(-) diff --git a/sdk/apis/me.ts b/sdk/apis/me.ts index ba71140..af0c09c 100644 --- a/sdk/apis/me.ts +++ b/sdk/apis/me.ts @@ -4,6 +4,8 @@ // soundurl_128 = 128k m4a;soundurl_32 = 192k 变体(字段命名与实际码率错位,以实测为准) // 实测(2026-10-02)匿名可取免费声音,直链首字节 ftyp M4A 明文可播; // 付费声音需 /login/me 绑定含 token 的 cookie 后由服务端携带 +// songmid 约定:裸 id = 声音 id(声音搜索/剧集列表产出); +// "d:" = 广播剧(剧搜索产出)——取流时自动选剧的首个免费集(预告/第一集) // 统一接口约定:apis('me').getMusicUrl(songInfo, type) → { promise, cancelHttp } // promise resolve → { type, url }(type 为实际命中档位,可能低于请求值) @@ -16,22 +18,49 @@ const QUALITY_FIELDS: Record = { } export const supportQualitys = ['64k', '128k'] +const pickUrl = (info: any, type: string) => { + const fields = QUALITY_FIELDS[type] ?? QUALITY_FIELDS['128k'] + let url = '' + for (const field of fields) { + const v = info?.[field] + if (v && String(v).startsWith('http')) { url = String(v); break } + } + if (!url) throw new Error('无可用音频资源(付费内容需 POST /login/me 绑定 token)') + const hit128 = url.includes('-128k') || type === '128k' + return { type: hit128 ? '128k' : '64k', url } +} + +/** 剧条目 "d:" → 首个免费集的 sound_id(getdrama episodes.episode[]) */ +const resolveDramaFirstFree = async(dramaId: string): Promise => { + const { httpFetch } = await import('../request.ts') + const { statusCode, body } = await httpFetch(`https://www.missevan.com/dramaapi/getdrama?drama_id=${dramaId}`, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + Accept: 'application/json', + Referer: `https://www.missevan.com/mdrama/${dramaId}`, + }, + timeout: 15000, + }).promise as any + const episodes = (statusCode == 200 ? body?.info?.episodes?.episode : null) ?? [] + if (!episodes.length) throw new Error('剧集列表为空或已下线') + const first = episodes.find((e: any) => Number(e.pay_type) === 0) ?? episodes[0] + return String(first.sound_id) +} + export const getMusicUrl = (songInfo, type) => { const requestObj = { cancelHttp() {}, promise: null as any } - const soundId = String(songInfo.songmid ?? '') + const songmid = String(songInfo.songmid ?? '') requestObj.promise = (async() => { - const info = await getSoundInfo(soundId) - const fields = QUALITY_FIELDS[type] ?? QUALITY_FIELDS['128k'] - let url = '' - for (const field of fields) { - const v = info?.[field] - if (v && String(v).startsWith('http')) { url = String(v); break } + let soundId = songmid + if (songmid.startsWith('d:')) { + if (!/^\d+$/.test(songmid.slice(2))) throw new Error('剧 id 无效') + soundId = await resolveDramaFirstFree(songmid.slice(2)) + } else if (!/^\d+$/.test(soundId)) { + throw new Error('me id 需为声音 id 或 d:<剧id>(见 /search?source=me 与 /album/me)') } - if (!url) throw new Error('无可用音频资源(付费内容需 POST /login/me 绑定 token)') - // -128k 变体归 128k 桶,原文件按 64k 桶展示(原始码率不固定,多为 192k) - const hit128 = url.includes('-128k') || type === '128k' - return { type: hit128 ? '128k' : '64k', url } + const info = await getSoundInfo(soundId) + return pickUrl(info, type) })() return requestObj } diff --git a/sdk/me/album.ts b/sdk/me/album.ts index ee7213f..585c6bb 100644 --- a/sdk/me/album.ts +++ b/sdk/me/album.ts @@ -1,13 +1,16 @@ // 猫耳FM 广播剧集列表(web 通道,匿名免签) // 接口: https://www.missevan.com/dramaapi/getdramaepisodes?drama_id=&season=&page=&order=0&pageSize= // 结果在 info.episodes.episode[](name/sound_id/pay_type/vip);剧名在 info.drama.name -// 注意:getdrama(不带 episodes)必须带 type=2 且 seasons 只含各季首集,列表用本接口 +// 注意:getdrama(不带 type=2 时)的 info.episodes.episode 同样可用(MusicFree 插件用法),本接口支持翻页故用之 import { httpFetch } from '../request.ts' import { formatPlayTime } from '../index.ts' const EPISODES_URL = 'https://www.missevan.com/dramaapi/getdramaepisodes' -export const getAlbumDetail = async(dramaId: string, page = 1, limit = 30) => { +export const getAlbumDetail = async(dramaIdRaw: string, page = 1, limit = 30) => { + // 搜索结果的剧条目带 d: 前缀(songmid="d:"),这里剥掉 + const dramaId = dramaIdRaw.startsWith('d:') ? dramaIdRaw.slice(2) : dramaIdRaw + if (!/^\d+$/.test(dramaId)) throw new Error('剧 id 无效') const { statusCode, body } = await httpFetch( `${EPISODES_URL}?drama_id=${dramaId}&season=1&page=${page}&order=0&pageSize=${Math.max(30, limit)}`, { diff --git a/sdk/me/musicSearch.ts b/sdk/me/musicSearch.ts index 070cd55..024b265 100644 --- a/sdk/me/musicSearch.ts +++ b/sdk/me/musicSearch.ts @@ -1,44 +1,71 @@ -// 猫耳FM 搜索(web 通道,匿名免签) -// 接口: https://www.missevan.com/sound/getsearch?s=&p=&type=3&page_size=30&sort=2 -// 结果在 data.info.Datas[](soundstr/username/id/pay_type/front_cover/duration 毫秒) +// 猫耳FM 搜索(双通道聚合,与网页版对齐) +// 广播剧(置顶): https://www.missevan.com/dramaapi/search?s=&page= → info.Datas[](id/name/author/cover/abstract) +// ——网页版搜索排前面的正剧来自这里;getsearch 的 type 参数只有 1UP主/2音单/3声音/4声优,没有剧集类型 +// 声音(补足): https://www.missevan.com/sound/getsearch?s=&p=&type=3&page_size=&sort=2 → info.Datas[] +// 剧条目 songmid 用 "d:" 前缀(取流时自动取首个免费集,见 apis/me.ts);声音条目为裸 sound_id import { httpFetch } from '../request.ts' import { formatPlayTime } from '../index.ts' -const SEARCH_URL = 'https://www.missevan.com/sound/getsearch' +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' +const H = { 'User-Agent': UA, Accept: 'application/json', Referer: 'https://www.missevan.com/sound/search' } -export const search = (keyword: string, page = 1, limit = 20) => - httpFetch(`${SEARCH_URL}?s=${encodeURIComponent(keyword)}&p=${page}&type=3&page_size=${limit}&sort=2`, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', - Referer: 'https://www.missevan.com/', - Accept: 'application/json', - }, - timeout: 15000, - }).promise.then(({ statusCode, body }: any) => { - if (statusCode != 200 || body?.success === false) throw new Error(body?.info || '搜索失败') - const docs = body?.info?.Datas ?? [] - const total = Number(body?.info?.pagination?.count ?? docs.length) - return { - allPage: Math.ceil(total / limit) || page, - total, - limit, - source: 'me' as const, - list: docs.map((item: any) => ({ - songmid: String(item.id), - name: item.soundstr || '', - singer: item.username || '', - albumId: '', - albumName: '', - interval: item.duration ? formatPlayTime(Math.round(Number(item.duration) / 1000)) : 0, - payPlay: Number(item.pay_type) > 0, - img: item.front_cover || null, - lrc: '', - lrcx: '', - types: [], - _types: {}, - disabled: false, - })), - } - }) +const searchDramas = async(keyword: string, page: number, limit: number) => { + const { statusCode, body } = await httpFetch(`https://www.missevan.com/dramaapi/search?s=${encodeURIComponent(keyword)}&page=${page}`, { headers: H, timeout: 15000 }).promise as any + if (statusCode != 200 || body?.success === false) return [] + return (body?.info?.Datas ?? []).slice(0, limit).map((item: any) => ({ + songmid: `d:${item.id}`, + name: item.name || '', + singer: item.author || '', + albumId: String(item.id), + albumName: item.name || '', + interval: 0, + payPlay: true, + img: item.cover || null, + lrc: '', + lrcx: '', + types: [], + _types: {}, + disabled: false, + })) +} + +const searchSounds = async(keyword: string, page: number, limit: number) => { + const { statusCode, body } = await httpFetch( + `https://www.missevan.com/sound/getsearch?s=${encodeURIComponent(keyword)}&p=${page}&type=3&page_size=${limit}&sort=2`, + { headers: H, timeout: 15000 }, + ).promise as any + if (statusCode != 200 || body?.success === false) return [] + return (body?.info?.Datas ?? []).map((item: any) => ({ + songmid: String(item.id), + name: item.soundstr || '', + singer: item.username || '', + albumId: '', + albumName: '', + interval: item.duration ? formatPlayTime(Math.round(Number(item.duration) / 1000)) : 0, + payPlay: Number(item.pay_type) > 0, + img: item.front_cover || null, + lrc: '', + lrcx: '', + types: [], + _types: {}, + disabled: false, + })) +} + +export const search = async(keyword: string, page = 1, limit = 20) => { + // 剧优先置顶(约六成配额),零散声音补足;剧集翻页直接传 page + const [dramas, sounds] = await Promise.all([ + searchDramas(keyword, page, Math.max(4, Math.floor(limit * 0.6))).catch(() => []), + searchSounds(keyword, page, limit).catch(() => []), + ]) + const list = [...dramas, ...sounds].slice(0, limit) + return { + allPage: page + (list.length >= limit ? 1 : 0), + total: dramas.length + sounds.length, + limit, + source: 'me' as const, + list, + } +} export default { search }