feat(me): 猫耳FM平台接入——App API HMAC 签名取明文直链、匿名搜索/剧集、token 绑定
This commit is contained in:
1 parent
5a9915b74c
commit
020e9bbb2e
6 files changed
+248
No files matched your search
@@ -0,0 +1,39 @@
|
|||||||
|
// 猫耳FM URL 获取(App API 通道)
|
||||||
|
// 链路:HMAC-SHA256(appsecret) 签名 → app.missevan.com/sound/sound → 免签名明文直链
|
||||||
|
// soundurl/soundurl_64 = 原文件(码率视源,常见 192k AAC)
|
||||||
|
// soundurl_128 = 128k m4a;soundurl_32 = 192k 变体(字段命名与实际码率错位,以实测为准)
|
||||||
|
// 实测(2026-10-02)匿名可取免费声音,直链首字节 ftyp M4A 明文可播;
|
||||||
|
// 付费声音需 /login/me 绑定含 token 的 cookie 后由服务端携带
|
||||||
|
// 统一接口约定:apis('me').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||||
|
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
|
||||||
|
|
||||||
|
import { getSoundInfo } from '../me/musicInfo.ts'
|
||||||
|
|
||||||
|
// 音质 → 字段优先级(原文件可能高于 128k,128k 档固定 m4a)
|
||||||
|
const QUALITY_FIELDS: Record<string, string[]> = {
|
||||||
|
'128k': ['soundurl_128', 'soundurl', 'soundurl_64', 'soundurl_32'],
|
||||||
|
'64k': ['soundurl_64', 'soundurl', 'soundurl_128', 'soundurl_32'],
|
||||||
|
}
|
||||||
|
export const supportQualitys = ['64k', '128k']
|
||||||
|
|
||||||
|
export const getMusicUrl = (songInfo, type) => {
|
||||||
|
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||||
|
const soundId = 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 }
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
})()
|
||||||
|
return requestObj
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { getMusicUrl }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// 猫耳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 只含各季首集,列表用本接口
|
||||||
|
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) => {
|
||||||
|
const { statusCode, body } = await httpFetch(
|
||||||
|
`${EPISODES_URL}?drama_id=${dramaId}&season=1&page=${page}&order=0&pageSize=${Math.max(30, limit)}`,
|
||||||
|
{
|
||||||
|
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/mdrama/drama/${dramaId}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
},
|
||||||
|
).promise as any
|
||||||
|
if (statusCode != 200 || body?.success === false) {
|
||||||
|
throw new Error(body?.info || `获取剧集失败(HTTP ${statusCode})`)
|
||||||
|
}
|
||||||
|
const info = body?.info ?? {}
|
||||||
|
const drama = info.drama ?? {}
|
||||||
|
const episodes = info.episodes?.episode ?? []
|
||||||
|
return {
|
||||||
|
info: { name: drama.name || String(dramaId), total: episodes.length },
|
||||||
|
list: episodes.slice(0, limit).map((item: any) => ({
|
||||||
|
songmid: String(item.sound_id),
|
||||||
|
name: item.name || '',
|
||||||
|
singer: drama.name || '',
|
||||||
|
albumId: String(dramaId),
|
||||||
|
albumName: drama.name || '',
|
||||||
|
interval: item.duration ? formatPlayTime(Math.round(Number(item.duration) / 1000)) : 0,
|
||||||
|
payPlay: Number(item.pay_type) > 0,
|
||||||
|
img: drama.cover || null,
|
||||||
|
lrc: '',
|
||||||
|
lrcx: '',
|
||||||
|
types: [],
|
||||||
|
_types: {},
|
||||||
|
disabled: false,
|
||||||
|
})),
|
||||||
|
total: episodes.length,
|
||||||
|
limit,
|
||||||
|
allPage: 1,
|
||||||
|
source: 'me' as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { getAlbumDetail }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// 猫耳FM(MissEvan,ACG 广播剧/音声平台)平台模块
|
||||||
|
// 匿名可用面(2026-10-02 实测):搜索 / 剧集列表 / 免费声音明文直链 / 封面。
|
||||||
|
// 取流走 App API 签名通道(app.missevan.com,HMAC-SHA256(appsecret)),
|
||||||
|
// 返回 maoercdn 免签名直链——网页版 hls.m3u8 是 Widevine 加密流,勿用 web soundurl。
|
||||||
|
// 付费声音需 POST /login/me 绑定含 token 的 cookie。
|
||||||
|
// 有声内容不参与聚合搜索与跨平台匹配(noAggregate),仅按 source=me 直查。
|
||||||
|
import meApi from '../apis/me.ts'
|
||||||
|
import musicSearch from './musicSearch.ts'
|
||||||
|
import { getSoundInfo } from './musicInfo.ts'
|
||||||
|
import { getAlbumDetail } from './album.ts'
|
||||||
|
import { getCredentials } from '../auth/store.ts'
|
||||||
|
|
||||||
|
const me = {
|
||||||
|
musicSearch,
|
||||||
|
getMusicUrl(songInfo, type) {
|
||||||
|
return meApi.getMusicUrl(songInfo, type)
|
||||||
|
},
|
||||||
|
getPic(songInfo) {
|
||||||
|
return getSoundInfo(songInfo.songmid).then((info: any) => info?.front_cover || null)
|
||||||
|
},
|
||||||
|
album: { getAlbumDetail },
|
||||||
|
getMusicDetailPageUrl(songInfo) {
|
||||||
|
return `https://www.missevan.com/sound/${songInfo.songmid}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录状态(凭据存在性判断) */
|
||||||
|
export const status = () => {
|
||||||
|
const cred = getCredentials('me') as { cookie?: string, updatedAt?: string } | null
|
||||||
|
return cred?.cookie
|
||||||
|
? { logged: true, updatedAt: cred.updatedAt }
|
||||||
|
: { logged: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default me
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// 猫耳FM 曲目信息(App API 通道)
|
||||||
|
// 接口: GET https://app.missevan.com/sound/sound?sound_id=
|
||||||
|
// 返回 info 直接是 sound 对象(非 web 版的 info.sound 包装):
|
||||||
|
// soundstr/duration(毫秒)/front_cover/soundurl(原文件)/soundurl_64(=原文件)/
|
||||||
|
// soundurl_128/-192k 变体,均为 maoercdn.com 免签名明文直链
|
||||||
|
import { appApiGet } from './sign.ts'
|
||||||
|
|
||||||
|
export const getSoundInfo = async(soundId: string): Promise<any> => appApiGet('/sound/sound', `sound_id=${soundId}`)
|
||||||
|
|
||||||
|
export default { getSoundInfo }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// 猫耳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 毫秒)
|
||||||
|
import { httpFetch } from '../request.ts'
|
||||||
|
import { formatPlayTime } from '../index.ts'
|
||||||
|
|
||||||
|
const SEARCH_URL = 'https://www.missevan.com/sound/getsearch'
|
||||||
|
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default { search }
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// 猫耳FM(MissEvan)App API 签名 —— app.missevan.com 域鉴权
|
||||||
|
// 算法(知乎 p/359692208 + zunley/gofm pkg/missevan/utils.go,2026-10-02 实测可用):
|
||||||
|
// authorization = "MissEvan " + base64(HMAC-SHA256(appsecret, DATA))
|
||||||
|
// DATA = METHOD\n + url(冒号转义%3A)\n + querystring\n + equip_id:<id>\n [+ token:<t>\n]
|
||||||
|
// + x-m-date:<UTC毫秒ISO>\n + x-m-nonce:<uuid>\n [+ base64(sha256(body))\n]
|
||||||
|
// 头:User-Agent MissEvanApp、x-m-date、x-m-nonce、Cookie(equip_id[+token])
|
||||||
|
// 该通道返回的是免签名 CDN 明文直链(sound-ali*-cdn-cn.maoercdn.com,m4a 原文件/-128k 变体);
|
||||||
|
// 网页版 hls.m3u8 为 Widevine 加密,与此无关。
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { httpFetch } from '../request.ts'
|
||||||
|
import { getCredentials } from '../auth/store.ts'
|
||||||
|
|
||||||
|
const APP_SECRET = 'f8Yw1M3u0e5MV7z34PcbY9wgP56YwJ'
|
||||||
|
const APP_UA = 'MissEvanApp/4.7.4 (iOS;15.1;iPhone11,6)'
|
||||||
|
const EQUIP_ID = '52e76e9a-7592-43af-ae72-8ec755094e41'
|
||||||
|
|
||||||
|
const utcMillis = () => new Date().toISOString().replace(/\.\d+Z$/, '.000Z')
|
||||||
|
|
||||||
|
/** 构造 authorization 签名(GET 便捷封装;POST 需补 body 摘要行) */
|
||||||
|
const buildAuth = (method: string, url: string, query: string, { equipId, token, date, nonce }: {
|
||||||
|
equipId: string, token?: string, date: string, nonce: string
|
||||||
|
}) => {
|
||||||
|
const lines = [
|
||||||
|
method,
|
||||||
|
url.replace(/:/g, '%3A'),
|
||||||
|
query,
|
||||||
|
`equip_id:${equipId}`,
|
||||||
|
...(token ? [`token:${token}`] : []),
|
||||||
|
`x-m-date:${date}`,
|
||||||
|
`x-m-nonce:${nonce}`,
|
||||||
|
]
|
||||||
|
return 'MissEvan ' + crypto.createHmac('sha256', APP_SECRET).update(lines.join('\n') + '\n').digest('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录 token(/login/me 绑定的 cookie 中提取;未登录为空) */
|
||||||
|
export const getMeToken = (): string => {
|
||||||
|
const cookie = (getCredentials('me') as any)?.cookie || ''
|
||||||
|
return cookie.match(/(?:^|;\s*)token=([^;\s]+)/)?.[1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App API GET 请求(自动携带登录态 token) */
|
||||||
|
export const appApiGet = async(path: string, query = ''): Promise<any> => {
|
||||||
|
const full = `https://app.missevan.com${path}`
|
||||||
|
const date = utcMillis()
|
||||||
|
const nonce = crypto.randomUUID()
|
||||||
|
const token = getMeToken()
|
||||||
|
const { statusCode, body } = await httpFetch(query ? `${full}?${query}` : full, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': APP_UA,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Accept-Language': 'zh-Hans-CN;q=1',
|
||||||
|
'x-m-date': date,
|
||||||
|
'x-m-nonce': nonce,
|
||||||
|
Cookie: `equip_id=${EQUIP_ID}${token ? `; token=${token}` : ''}`,
|
||||||
|
Authorization: buildAuth('GET', full, query, { equipId: EQUIP_ID, token, date, nonce }),
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
}).promise as any
|
||||||
|
if (statusCode != 200) throw new Error(`猫耳 App API HTTP ${statusCode}`)
|
||||||
|
const b = body ?? {}
|
||||||
|
if (b.success === false || (b.code != null && b.code !== 0)) {
|
||||||
|
throw new Error(b.info || `猫耳 App API code ${b.code}`)
|
||||||
|
}
|
||||||
|
return b.info ?? b
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ME_UA = APP_UA
|
||||||
|
export default { appApiGet, getMeToken, ME_UA }
|
||||||
Reference in new issue
Block a user