Compare commits
2
Commits
1fec828547
...
bc1d23cbba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc1d23cbba | ||
|
|
cd5d9b1c91 |
No files matched your search
+36
-7
@@ -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,12 +18,7 @@ const QUALITY_FIELDS: Record<string, string[]> = {
|
|||||||
}
|
}
|
||||||
export const supportQualitys = ['64k', '128k']
|
export const supportQualitys = ['64k', '128k']
|
||||||
|
|
||||||
export const getMusicUrl = (songInfo, type) => {
|
const pickUrl = (info: any, type: string) => {
|
||||||
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']
|
const fields = QUALITY_FIELDS[type] ?? QUALITY_FIELDS['128k']
|
||||||
let url = ''
|
let url = ''
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
@@ -29,9 +26,41 @@ export const getMusicUrl = (songInfo, type) => {
|
|||||||
if (v && String(v).startsWith('http')) { url = String(v); break }
|
if (v && String(v).startsWith('http')) { url = String(v); break }
|
||||||
}
|
}
|
||||||
if (!url) throw new Error('无可用音频资源(付费内容需 POST /login/me 绑定 token)')
|
if (!url) throw new Error('无可用音频资源(付费内容需 POST /login/me 绑定 token)')
|
||||||
// -128k 变体归 128k 桶,原文件按 64k 桶展示(原始码率不固定,多为 192k)
|
|
||||||
const hit128 = url.includes('-128k') || type === '128k'
|
const hit128 = url.includes('-128k') || type === '128k'
|
||||||
return { type: hit128 ? '128k' : '64k', url }
|
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) => {
|
||||||
|
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||||
|
const songmid = String(songInfo.songmid ?? '')
|
||||||
|
|
||||||
|
requestObj.promise = (async() => {
|
||||||
|
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)')
|
||||||
|
}
|
||||||
|
const info = await getSoundInfo(soundId)
|
||||||
|
return pickUrl(info, type)
|
||||||
})()
|
})()
|
||||||
return requestObj
|
return requestObj
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -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)}`,
|
||||||
{
|
{
|
||||||
|
|||||||
+51
-24
@@ -1,29 +1,41 @@
|
|||||||
// 猫耳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) => ({
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
songmid: String(item.id),
|
||||||
name: item.soundstr || '',
|
name: item.soundstr || '',
|
||||||
singer: item.username || '',
|
singer: item.username || '',
|
||||||
@@ -37,8 +49,23 @@ export const search = (keyword: string, page = 1, limit = 20) =>
|
|||||||
types: [],
|
types: [],
|
||||||
_types: {},
|
_types: {},
|
||||||
disabled: false,
|
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 }
|
||||||
+39
-28
@@ -6,11 +6,14 @@
|
|||||||
// 文档页、旧 query 路由移除回归
|
// 文档页、旧 query 路由移除回归
|
||||||
|
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
|
import net from 'node:net'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
// 本地默认 16688(避开 1024~15000 的动态端口冲突区),可用 PORT/BASE_URL 覆盖
|
// 端口策略:默认在随机空闲端口自起独立实例(避免连到 16688 等常驻实例——
|
||||||
const PORT = process.env.PORT || 16688
|
// 旧代码会让新用例误报失败);显式设置 BASE_URL 时测试该外部实例、不再自动启动
|
||||||
const BASE = process.env.BASE_URL || `http://localhost:${PORT}`
|
const PORT = Number(process.env.PORT || 0)
|
||||||
|
let BASE = process.env.BASE_URL || ''
|
||||||
|
const externalServer = Boolean(BASE)
|
||||||
const results: { name: string; ok: boolean; detail?: any }[] = []
|
const results: { name: string; ok: boolean; detail?: any }[] = []
|
||||||
|
|
||||||
const get = (path: string): Promise<any> => new Promise((resolve, reject) => {
|
const get = (path: string): Promise<any> => new Promise((resolve, reject) => {
|
||||||
@@ -73,25 +76,12 @@ const test = async(name: string, fn: () => Promise<any>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查本地服务器是否已在运行
|
* 启动本地服务器(指定端口)并返回进程句柄
|
||||||
*/
|
*/
|
||||||
const isServerRunning = () => new Promise(resolve => {
|
const startLocalServer = (port: number) => new Promise((resolve, reject) => {
|
||||||
if (!BASE.startsWith('http://localhost:') && !BASE.startsWith('http://127.0.0.1:')) return resolve(true)
|
|
||||||
const req = http.get(`${BASE}/health`, res => {
|
|
||||||
res.resume()
|
|
||||||
resolve(res.statusCode === 200)
|
|
||||||
})
|
|
||||||
req.on('error', () => resolve(false))
|
|
||||||
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动本地服务器并返回关闭句柄
|
|
||||||
*/
|
|
||||||
const startLocalServer = () => new Promise((resolve, reject) => {
|
|
||||||
const proc = spawn(process.execPath, ['src/server.ts'], {
|
const proc = spawn(process.execPath, ['src/server.ts'], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: { ...process.env, PORT: String(PORT) },
|
env: { ...process.env, PORT: String(port) },
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
@@ -118,13 +108,21 @@ const startLocalServer = () => new Promise((resolve, reject) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let localServer: any = null
|
let localServer: any = null
|
||||||
const running = await isServerRunning()
|
if (externalServer) {
|
||||||
if (!running) {
|
console.log(`测试外部实例 ${BASE}\n`)
|
||||||
console.log('本地服务器未运行,正在自动启动...')
|
|
||||||
localServer = await startLocalServer()
|
|
||||||
console.log('本地服务器已启动\n')
|
|
||||||
} else {
|
} else {
|
||||||
console.log('使用已运行的服务器\n')
|
// 未显式指定 BASE_URL/PORT 时取随机空闲端口
|
||||||
|
const freePort = await new Promise<number>(resolve => {
|
||||||
|
const srv = net.createServer()
|
||||||
|
srv.listen(0, '127.0.0.1', () => {
|
||||||
|
const p = (srv.address() as any).port
|
||||||
|
srv.close(() => resolve(p))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const port = PORT || freePort
|
||||||
|
BASE = `http://localhost:${port}`
|
||||||
|
localServer = await startLocalServer(port)
|
||||||
|
console.log(`本地服务器已启动(:${port})\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索
|
// 搜索
|
||||||
@@ -243,10 +241,23 @@ await test('URL lr 未登录 → 明确错误', async() => {
|
|||||||
|
|
||||||
// me 有声内容:直查可用、聚合排除
|
// me 有声内容:直查可用、聚合排除
|
||||||
await test('搜索 me 直查', async() => {
|
await test('搜索 me 直查', async() => {
|
||||||
const r = await get(`/search?source=me&name=${encodeURIComponent('广播剧')}&limit=3`)
|
const r = await get(`/search?source=me&name=${encodeURIComponent('伪装学渣')}&limit=10`)
|
||||||
if (r.code != 0) throw new Error(r.msg)
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
if (!r.data?.list?.length) throw new Error('响应不完整')
|
const list = r.data?.list ?? []
|
||||||
return `首条 ${r.data.list[0].name}`
|
if (!list.length) throw new Error('响应不完整')
|
||||||
|
// 搜索结构对齐网页版:正剧(dramaapi/search 的 d: 前缀条目)应置顶
|
||||||
|
if (!String(list[0].songmid).startsWith('d:') || !String(list[0].name).includes('伪装学渣')) {
|
||||||
|
throw new Error(`首条应为正剧条目,实际 ${JSON.stringify(list[0]?.songmid)} ${list[0]?.name}`)
|
||||||
|
}
|
||||||
|
return `首条 ${list[0].name}(d: 剧条目置顶)`
|
||||||
|
})
|
||||||
|
|
||||||
|
// me 剧条目 d:<id> 取流:自动选首个免费集(58262 = 伪装学渣第一季,2026-10-02 实测可用)
|
||||||
|
await test('URL me 剧条目 d: 前缀', async() => {
|
||||||
|
const r = await get('/url/me/d:58262?quality=128k')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.url || !/maoercdn\.com/.test(r.data.url)) throw new Error('非 maoercdn 直链')
|
||||||
|
return `${r.data.type} ${r.data.url.slice(0, 60)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// xm 有声内容:直查可用、聚合排除
|
// xm 有声内容:直查可用、聚合排除
|
||||||
|
|||||||
Reference in new issue
Block a user