fix(me): 搜索对齐网页版——dramaapi/search 正剧置顶,d:<剧id> 条目自动取首个免费集

This commit is contained in:
snowy-yang committed 2026-10-02 20:08:35 +08:00
1 parent 1fec828547
commit cd5d9b1c91
3 files changed
+110 -51

No files matched your search

+40 -11
View File
@@ -4,6 +4,8 @@
// soundurl_128 = 128k m4a;soundurl_32 = 192k 变体(字段命名与实际码率错位,以实测为准) // soundurl_128 = 128k m4a;soundurl_32 = 192k 变体(字段命名与实际码率错位,以实测为准)
// 实测(2026-10-02)匿名可取免费声音,直链首字节 ftyp M4A 明文可播; // 实测(2026-10-02)匿名可取免费声音,直链首字节 ftyp M4A 明文可播;
// 付费声音需 /login/me 绑定含 token 的 cookie 后由服务端携带 // 付费声音需 /login/me 绑定含 token 的 cookie 后由服务端携带
// songmid 约定:裸 id = 声音 id(声音搜索/剧集列表产出);
// "d:<drama_id>" = 广播剧(剧搜索产出)——取流时自动选剧的首个免费集(预告/第一集)
// 统一接口约定:apis('me').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('me').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值) // promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
@@ -16,22 +18,49 @@ const QUALITY_FIELDS: Record<string, string[]> = {
} }
export const supportQualitys = ['64k', '128k'] 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:<drama_id>" → 首个免费集的 sound_id(getdrama episodes.episode[]) */
const resolveDramaFirstFree = async(dramaId: string): Promise<string> => {
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) => { export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any } const requestObj = { cancelHttp() {}, promise: null as any }
const soundId = String(songInfo.songmid ?? '') const songmid = String(songInfo.songmid ?? '')
requestObj.promise = (async() => { requestObj.promise = (async() => {
const info = await getSoundInfo(soundId) let soundId = songmid
const fields = QUALITY_FIELDS[type] ?? QUALITY_FIELDS['128k'] if (songmid.startsWith('d:')) {
let url = '' if (!/^\d+$/.test(songmid.slice(2))) throw new Error('剧 id 无效')
for (const field of fields) { soundId = await resolveDramaFirstFree(songmid.slice(2))
const v = info?.[field] } else if (!/^\d+$/.test(soundId)) {
if (v && String(v).startsWith('http')) { url = String(v); break } throw new Error('me id 需为声音 id 或 d:<剧id>(见 /search?source=me 与 /album/me)')
} }
if (!url) throw new Error('无可用音频资源(付费内容需 POST /login/me 绑定 token)') const info = await getSoundInfo(soundId)
// -128k 变体归 128k 桶,原文件按 64k 桶展示(原始码率不固定,多为 192k) return pickUrl(info, type)
const hit128 = url.includes('-128k') || type === '128k'
return { type: hit128 ? '128k' : '64k', url }
})() })()
return requestObj return requestObj
} }
+5 -2
View File
@@ -1,13 +1,16 @@
// 猫耳FM 广播剧集列表(web 通道,匿名免签) // 猫耳FM 广播剧集列表(web 通道,匿名免签)
// 接口: https://www.missevan.com/dramaapi/getdramaepisodes?drama_id=&season=&page=&order=0&pageSize= // 接口: 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 // 结果在 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 { httpFetch } from '../request.ts'
import { formatPlayTime } from '../index.ts' import { formatPlayTime } from '../index.ts'
const EPISODES_URL = 'https://www.missevan.com/dramaapi/getdramaepisodes' 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:<id>"),这里剥掉
const dramaId = dramaIdRaw.startsWith('d:') ? dramaIdRaw.slice(2) : dramaIdRaw
if (!/^\d+$/.test(dramaId)) throw new Error('剧 id 无效')
const { statusCode, body } = await httpFetch( const { statusCode, body } = await httpFetch(
`${EPISODES_URL}?drama_id=${dramaId}&season=1&page=${page}&order=0&pageSize=${Math.max(30, limit)}`, `${EPISODES_URL}?drama_id=${dramaId}&season=1&page=${page}&order=0&pageSize=${Math.max(30, limit)}`,
{ {
+65 -38
View File
@@ -1,44 +1,71 @@
// 猫耳FM 搜索(web 通道,匿名免签) // 猫耳FM 搜索(双通道聚合,与网页版对齐)
// 接口: https://www.missevan.com/sound/getsearch?s=&p=&type=3&page_size=30&sort=2 // 广播剧(置顶): https://www.missevan.com/dramaapi/search?s=&page= → info.Datas[](id/name/author/cover/abstract)
// 结果在 data.info.Datas[](soundstr/username/id/pay_type/front_cover/duration 毫秒) // ——网页版搜索排前面的正剧来自这里;getsearch 的 type 参数只有 1UP主/2音单/3声音/4声优,没有剧集类型
// 声音(补足): https://www.missevan.com/sound/getsearch?s=&p=&type=3&page_size=&sort=2 → info.Datas[]
// 剧条目 songmid 用 "d:<drama_id>" 前缀(取流时自动取首个免费集,见 apis/me.ts);声音条目为裸 sound_id
import { httpFetch } from '../request.ts' import { httpFetch } from '../request.ts'
import { formatPlayTime } from '../index.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) => const searchDramas = async(keyword: string, page: number, limit: number) => {
httpFetch(`${SEARCH_URL}?s=${encodeURIComponent(keyword)}&p=${page}&type=3&page_size=${limit}&sort=2`, { const { statusCode, body } = await httpFetch(`https://www.missevan.com/dramaapi/search?s=${encodeURIComponent(keyword)}&page=${page}`, { headers: H, timeout: 15000 }).promise as any
headers: { if (statusCode != 200 || body?.success === false) return []
'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', return (body?.info?.Datas ?? []).slice(0, limit).map((item: any) => ({
Referer: 'https://www.missevan.com/', songmid: `d:${item.id}`,
Accept: 'application/json', name: item.name || '',
}, singer: item.author || '',
timeout: 15000, albumId: String(item.id),
}).promise.then(({ statusCode, body }: any) => { albumName: item.name || '',
if (statusCode != 200 || body?.success === false) throw new Error(body?.info || '搜索失败') interval: 0,
const docs = body?.info?.Datas ?? [] payPlay: true,
const total = Number(body?.info?.pagination?.count ?? docs.length) img: item.cover || null,
return { lrc: '',
allPage: Math.ceil(total / limit) || page, lrcx: '',
total, types: [],
limit, _types: {},
source: 'me' as const, disabled: false,
list: docs.map((item: any) => ({ }))
songmid: String(item.id), }
name: item.soundstr || '',
singer: item.username || '', const searchSounds = async(keyword: string, page: number, limit: number) => {
albumId: '', const { statusCode, body } = await httpFetch(
albumName: '', `https://www.missevan.com/sound/getsearch?s=${encodeURIComponent(keyword)}&p=${page}&type=3&page_size=${limit}&sort=2`,
interval: item.duration ? formatPlayTime(Math.round(Number(item.duration) / 1000)) : 0, { headers: H, timeout: 15000 },
payPlay: Number(item.pay_type) > 0, ).promise as any
img: item.front_cover || null, if (statusCode != 200 || body?.success === false) return []
lrc: '', return (body?.info?.Datas ?? []).map((item: any) => ({
lrcx: '', songmid: String(item.id),
types: [], name: item.soundstr || '',
_types: {}, singer: item.username || '',
disabled: false, 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 } export default { search }