feat(sdk): 新增咪咕/千千/JOOX/哔哩哔哩/5sing/Apple Music 六平台
平台注册表由 9 个扩到 15 个,同时补齐 sdk/types.ts 的类型与 OpenAPI 文档里的能力位声明。 - migu:命中 Z3D/I3D 加密候选时不返回直链,改由服务端解密代理出流, SDK 侧只负责登记票据,避免客户端拿到无法播放的密文 - apple:只提供 preview 试听片段(完整曲为 DRM),标 noAggregate 排除在跨平台精确匹配之外,防止用它顶掉其它平台的可播结果 - 其余平台按既有 PlatformModule 约定实现搜索/专辑/歌单/歌词 为什么合并成一次提交:六平台共用同一份注册表与能力位声明, 拆开会产生互相引用、单独无法通过类型检查的中间态。
This commit is contained in:
1 parent
47ce651f46
commit
33b96f92c4
54 files changed
+6161
-5
No files matched your search
@@ -0,0 +1,169 @@
|
||||
// JOOX 专辑(上游 joox/album.go 移植 + 新版 openjoox2 通道)
|
||||
// 搜索:GET https://cache.api.joox.com/openjoox/v3/search(section_type=1 专辑区,item.type=2 专辑)
|
||||
// 曲目:GET https://cache.api.joox.com/openjoox2/v1/album/<id>/tracks?country=hk&lang=zh_TW&index=&num=&secret=
|
||||
// (web 播放器通道,需 md5 签名;实测 2026-10-02 HTTP 200)
|
||||
// 兜底:www.joox.com/<region>/album/<id> SSR __NEXT_DATA__.props.pageProps.albumData(上游做法,需 X-Forwarded-For)
|
||||
// 链接解析:ParseAlbum 的正则与裸 ID 规则同上游。
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import {
|
||||
jooxHeaders,
|
||||
jooxSign,
|
||||
jooxAlbumLink,
|
||||
normalizeJooxID,
|
||||
normalizeJooxLang,
|
||||
pickJooxImage,
|
||||
jooxArtists,
|
||||
firstNonEmpty,
|
||||
firstNonZero,
|
||||
extractJooxNextData,
|
||||
fetchJooxPage,
|
||||
} from './util.ts'
|
||||
import { JOOX_OPENJOOX2 } from './musicInfo.ts'
|
||||
import { jooxSongFromTrack } from './song.ts'
|
||||
|
||||
const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search'
|
||||
|
||||
/** openjoox2 专辑曲目(含专辑元信息) */
|
||||
const fetchAlbumTracks = async(albumId: string, page: number, limit: number) => {
|
||||
const country = 'hk'
|
||||
const lang = normalizeJooxLang('zh_hk')
|
||||
const index = Math.max(0, (page - 1) * limit)
|
||||
const params = { country, lang, index, num: limit }
|
||||
const url = `${JOOX_OPENJOOX2}/album/${albumId}/tracks?country=${country}&lang=${lang}&index=${index}&num=${limit}&secret=${jooxSign(params)}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body || body.code) return null
|
||||
|
||||
const tracks: any[] = body?.tracks?.items ?? []
|
||||
const list = tracks.map(item => jooxSongFromTrack(item, body?.name, pickJooxImage(body?.images), albumId)).filter(Boolean)
|
||||
if (!list.length) return null
|
||||
|
||||
const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, list.length)
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
info: {
|
||||
name: String(firstNonEmpty(body?.name, albumId)),
|
||||
img: pickJooxImage(body?.images) || null,
|
||||
desc: String(firstNonEmpty(body?.description, body?.publish_date)),
|
||||
author: jooxArtists(body?.artist_list),
|
||||
},
|
||||
list,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
/** SSR 页面专辑数据(上游 fetchAlbumPageData + fetchAlbumDetail 的 data 部分) */
|
||||
const fetchAlbumPageData = async(albumId: string) => {
|
||||
const { body: html } = await fetchJooxPage(jooxAlbumLink(albumId))
|
||||
const nextData = extractJooxNextData(html)
|
||||
const pageProps = nextData?.props?.pageProps
|
||||
const albumData = pageProps?.albumData
|
||||
if (normalizeJooxID(albumData?.id)) return albumData
|
||||
const nested = pageProps?.content?.page?.albumData
|
||||
if (normalizeJooxID(nested?.id)) return nested
|
||||
throw new Error('joox 专辑页数据不存在')
|
||||
}
|
||||
|
||||
/** 专辑详情(openjoox2 优先,SSR 页兜底) */
|
||||
export const getAlbumDetail = async(rawId: string, page = 1, limit = 50) => {
|
||||
const albumId = normalizeJooxID(rawId)
|
||||
if (!albumId) throw new Error('joox 专辑 id 无效')
|
||||
try {
|
||||
const detail = await fetchAlbumTracks(albumId, page, limit)
|
||||
if (detail) return detail
|
||||
} catch {
|
||||
// 通道不可用时走页面兜底
|
||||
}
|
||||
return getAlbumDetailFromPage(albumId, page, limit)
|
||||
}
|
||||
|
||||
/** 专辑详情(SSR 网页兜底通道,上游 fetchAlbumDetail 的页面路径) */
|
||||
export const getAlbumDetailFromPage = async(rawId: string, page = 1, limit = 50) => {
|
||||
const albumId = normalizeJooxID(rawId)
|
||||
if (!albumId) throw new Error('joox 专辑 id 无效')
|
||||
const albumData = await fetchAlbumPageData(albumId)
|
||||
const items: any[] = albumData?.trackList?.items ?? []
|
||||
const pageStart = Math.max(0, (page - 1) * limit)
|
||||
const pageItems = items.slice(pageStart, pageStart + limit)
|
||||
const cover = String(firstNonEmpty(pickJooxImage(albumData?.images), albumData?.imgSrc))
|
||||
const list = pageItems
|
||||
.map(item => jooxSongFromTrack(item, albumData?.title, cover, albumId))
|
||||
.filter(Boolean)
|
||||
if (!list.length) throw new Error('joox 专辑无歌曲')
|
||||
|
||||
const total = firstNonZero(albumData?.trackList?.total_count, albumData?.trackList?.list_count, items.length)
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
info: {
|
||||
name: String(firstNonEmpty(albumData?.title, albumId)),
|
||||
img: cover || null,
|
||||
desc: String(firstNonEmpty(albumData?.description, albumData?.publishDate)),
|
||||
author: jooxArtists(albumData?.artistList),
|
||||
},
|
||||
list,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
/** 专辑曲目(上游 GetAlbumSongs:仅曲目列表) */
|
||||
export const getAlbumSongs = async(id: string, page = 1, limit = 50) => {
|
||||
const { list } = await getAlbumDetail(id, page, limit)
|
||||
return list
|
||||
}
|
||||
|
||||
/** 专辑搜索(上游 SearchAlbum) */
|
||||
export const searchAlbum = async(keyword: string, page = 1, limit = 20) => {
|
||||
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body?.section_list) {
|
||||
return { list: [], total: 0, page, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
const list: any[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const section of body.section_list) {
|
||||
if (Number(section?.section_type) !== 1) continue
|
||||
for (const item of section?.item_list ?? []) {
|
||||
if (Number(item?.type) !== 2) continue
|
||||
const album = item?.album ?? {}
|
||||
const id = normalizeJooxID(album.id)
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
list.push({
|
||||
id,
|
||||
name: String(album.name ?? ''),
|
||||
cover: pickJooxImage(album.images) || null,
|
||||
creator: jooxArtists(album.artist_list),
|
||||
description: String(album.publish_date ?? '').trim(),
|
||||
publishDate: String(album.publish_date ?? '').trim(),
|
||||
trackCount: 0,
|
||||
link: jooxAlbumLink(id),
|
||||
source: 'joox' as const,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
/** 专辑链接解析(上游 ParseAlbum) */
|
||||
export const parseAlbum = async(link: string) => {
|
||||
const patterns = [
|
||||
/joox\.com\/.*\/album\/([^/?#]+)/,
|
||||
/h_activity_id=([^&]+)/,
|
||||
/albumid=([^&]+)/,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const m = pattern.exec(String(link ?? ''))
|
||||
if (m) return getAlbumDetail(m[1])
|
||||
}
|
||||
const raw = String(link ?? '').trim()
|
||||
if (raw.length > 10 && !raw.includes('/')) return getAlbumDetail(raw)
|
||||
throw new Error('invalid joox album link')
|
||||
}
|
||||
|
||||
export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbum }
|
||||
@@ -0,0 +1,62 @@
|
||||
// JOOX(腾讯海外音乐,东南亚)平台模块
|
||||
// 匿名可用面(2026-10-02 实测,需 X-Forwarded-For 东南亚 IP 绕过 www 地区重定向):
|
||||
// 搜索 / 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+链接解析)/ 歌词 / 链接解析(歌曲)/
|
||||
// 取流(游客 128k MP3,VIP 曲目为 preview/refrain 片段)。
|
||||
// 通道:
|
||||
// - 搜索/专辑搜索/歌单搜索:cache.api.joox.com/openjoox/v3/search(无签名)
|
||||
// - 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(需 md5 签名,见 util.jooxSign)
|
||||
// - 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content)
|
||||
// - 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__
|
||||
// 上游 New(cookie):默认游客 Cookie 来自 joox.go 常量;/login/joox 绑定后从 sdk/auth/store.ts 读取,
|
||||
// 登录 cookie 的 wmid/session_key 会作为 uid/usk 参与 openjoox2 签名(VIP 完整曲目依赖它)。
|
||||
// userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。
|
||||
import jooxApi from '../apis/joox.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import lyric from './lyric.ts'
|
||||
import { getTrackDetail } from './musicInfo.ts'
|
||||
import { pickJooxImage, normalizeJooxID, jooxSongLink } from './util.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
|
||||
const joox = {
|
||||
musicSearch,
|
||||
getMusicUrl(songInfo, type) {
|
||||
return jooxApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
getLyric(songInfo, ...rest) {
|
||||
return lyric.getLyric(songInfo, ...rest)
|
||||
},
|
||||
getPic(songInfo) {
|
||||
const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? '')
|
||||
return getTrackDetail(songId).then(detail => pickJooxImage(detail?.images) || null)
|
||||
},
|
||||
album,
|
||||
playlist,
|
||||
// 契约别名:JOOX 的「分类/列表」能力即 playlist.go 的歌单分类,没有独立的 songList 接口,
|
||||
// 这里把同一套分类/分类歌单挂到 songList 键,便于 /song-list/joox 路由复用。
|
||||
songList: {
|
||||
sortList: [{ name: '全部', id: '' }],
|
||||
getTags() {
|
||||
return playlist.getCategories()
|
||||
},
|
||||
getList(sortId: string, tagId: string, page = 1, limit = 30) {
|
||||
return playlist.getCategoryPlaylists(tagId ?? sortId ?? '', page, limit)
|
||||
},
|
||||
},
|
||||
parse,
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
return jooxSongLink(songInfo?.songmid ?? songInfo?.id ?? '')
|
||||
},
|
||||
}
|
||||
|
||||
/** 登录状态(凭据存在性判断;无在线校验接口) */
|
||||
export const status = () => {
|
||||
const cred = getCredentials('joox') as { cookie?: string, updatedAt?: string } | null
|
||||
return cred?.cookie
|
||||
? { logged: true, updatedAt: cred.updatedAt }
|
||||
: { logged: false }
|
||||
}
|
||||
|
||||
export default joox
|
||||
@@ -0,0 +1,84 @@
|
||||
// JOOX 歌词(上游 joox/lyric.go 移植)
|
||||
// 端点:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=<id>&country=sg&lang=zh_cn
|
||||
// 响应形如 MusicJsonCallback({"code":0,"lyric":"<base64>"}),lyric 为 base64 UTF-8 歌词。
|
||||
// 实测(2026-10-02)该旧接口仍可用(web_get_songinfo 已 404,但 web_lyric 未下线)。
|
||||
// 兜底:openjoox2 /track 详情的 lrc_content(同为 base64),旧接口失败时使用。
|
||||
// 解码:base64 → UTF-8 → HTML 实体(& 等)→ { lyric, tlyric: '' }
|
||||
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric }
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { jooxHeaders, decodeJooxLyric, normalizeJooxID, firstNonEmpty } from './util.ts'
|
||||
import { buildTrackUrl } from './musicInfo.ts'
|
||||
|
||||
export const LEGACY_LYRIC_URL = 'https://api.joox.com/web-fcgi-bin/web_lyric'
|
||||
|
||||
/** 解析 MusicJsonCallback(...) / 裸 JSON 包裹 */
|
||||
export const parseLyricPayload = (body: any): any => {
|
||||
if (body && typeof body === 'object') return body
|
||||
let text = String(body ?? '')
|
||||
const idx = text.indexOf('MusicJsonCallback(')
|
||||
if (idx >= 0) {
|
||||
text = text.slice(idx + 'MusicJsonCallback('.length)
|
||||
text = text.replace(/\)\s*;?\s*$/, '')
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本 */
|
||||
export const parseLegacyLyric = (statusCode: number, body: any): string => {
|
||||
if (statusCode != 200) throw new Error(`joox 歌词获取失败(HTTP ${statusCode})`)
|
||||
const payload = parseLyricPayload(body)
|
||||
if (!payload) throw new Error('joox 歌词解析失败')
|
||||
const raw = String(firstNonEmpty(payload.lyric, ''))
|
||||
if (Number(payload.code ?? 0) !== 0 || !raw) throw new Error('joox 歌词不存在或为空')
|
||||
return decodeJooxLyric(raw)
|
||||
}
|
||||
|
||||
/** 上游 GetLyrics 通道(非取消句柄版,测试/内部用) */
|
||||
export const getLegacyLyric = async(songId: string): Promise<string> => {
|
||||
const url = `${LEGACY_LYRIC_URL}?musicid=${encodeURIComponent(songId)}&country=sg&lang=zh_cn`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
return parseLegacyLyric(statusCode, body)
|
||||
}
|
||||
|
||||
export const getLyric = (songInfo: any, ..._rest: any[]) => {
|
||||
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
|
||||
let current: any = null
|
||||
requestObj.cancelHttp = () => {
|
||||
if (current?.cancelHttp) current.cancelHttp()
|
||||
}
|
||||
|
||||
const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid ?? '')
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
if (!songId) throw new Error('joox 歌曲 id 无效')
|
||||
|
||||
// 1) 上游 web_lyric 通道
|
||||
let legacyErr: any = null
|
||||
try {
|
||||
current = httpFetch(
|
||||
`${LEGACY_LYRIC_URL}?musicid=${encodeURIComponent(songId)}&country=sg&lang=zh_cn`,
|
||||
{ headers: jooxHeaders(), timeout: 20000 },
|
||||
)
|
||||
const { statusCode, body } = await current.promise
|
||||
return { lyric: parseLegacyLyric(statusCode, body), tlyric: '' }
|
||||
} catch (err) {
|
||||
legacyErr = err
|
||||
}
|
||||
|
||||
// 2) 兜底:openjoox2 详情 lrc_content(base64)
|
||||
current = httpFetch(buildTrackUrl(songId), { headers: jooxHeaders(), timeout: 20000 })
|
||||
const { statusCode, body } = await current.promise as any
|
||||
const raw = String(firstNonEmpty(body?.lrc_content, ''))
|
||||
if (statusCode != 200 || !raw) throw legacyErr ?? new Error('joox 歌词不存在或为空')
|
||||
return { lyric: decodeJooxLyric(raw), tlyric: '' }
|
||||
})()
|
||||
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getLyric, getLegacyLyric, parseLyricPayload, parseLegacyLyric }
|
||||
@@ -0,0 +1,83 @@
|
||||
// JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口)
|
||||
// 端点:GET https://cache.api.joox.com/openjoox2/v1/track/<id>
|
||||
// ?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=<wmid>][&usk=<session_key>]&secret=<md5>
|
||||
// 签名:secret = md5('Jo0x@t3Nc3nT' + 'country=..&lang=..&lyric=1&fs=..&im=..[&uid=..][&usk=..]&id=..')
|
||||
// 参数按插入序参与签名;id 用 '/' → '_' 替换后的值,且发布 URL 前会从 query 中删除 id。
|
||||
// 该签名算法由官网 player-d JS bundle 逆向 + 实测比对(见 tmp/joox-test.mjs 断言)。
|
||||
// 上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已于 2026-10 前 404,此文件为其替代。
|
||||
// fs=1 请求完整曲目(游客仍可能拿到 preview/refrain),im=mobile 标记。
|
||||
// 返回体含 play_url_list / refrain_url / lrc_content(base64) / qrc_content(base64) /
|
||||
// has_hq / has_hifi / vip_flag / node_is_preview 等。
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import {
|
||||
jooxHeaders,
|
||||
jooxSign,
|
||||
jooxLoginCookie,
|
||||
cookieValue,
|
||||
normalizeJooxID,
|
||||
normalizeJooxLang,
|
||||
} from './util.ts'
|
||||
|
||||
export const JOOX_OPENJOOX2 = 'https://cache.api.joox.com/openjoox2/v1'
|
||||
|
||||
interface TrackOptions {
|
||||
country?: string
|
||||
lang?: string
|
||||
/** true 请求完整曲目(fs=1),false 预览(fs=0) */
|
||||
fullSong?: boolean
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
/** 构造 openjoox2 歌曲详情 URL(含签名;登录态自动带 uid/usk) */
|
||||
export const buildTrackUrl = (rawId: unknown, options: TrackOptions = {}): string => {
|
||||
const id = String(normalizeJooxID(rawId)).replace(/\//g, '_')
|
||||
const country = options.country || 'hk'
|
||||
const lang = normalizeJooxLang(options.lang || 'zh_TW')
|
||||
const params: Record<string, any> = {
|
||||
country,
|
||||
lang,
|
||||
lyric: 1,
|
||||
fs: options.fullSong ? 1 : 0,
|
||||
im: options.isMobile ? 1 : 0,
|
||||
}
|
||||
// 仅绑定登录凭据后才带 uid/usk(游客态与官网播放器一致,不带这两项)
|
||||
const loginCookie = jooxLoginCookie()
|
||||
const uid = loginCookie ? cookieValue(loginCookie, 'wmid') : ''
|
||||
const usk = loginCookie ? cookieValue(loginCookie, 'session_key') : ''
|
||||
if (uid) params.uid = uid
|
||||
if (usk) params.usk = usk
|
||||
// 签名时包含 id(与 web 播放器一致),发布 query 时再删除
|
||||
const secret = jooxSign({ ...params, id })
|
||||
const query = Object.entries({ ...params, secret })
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`)
|
||||
.join('&')
|
||||
return `${JOOX_OPENJOOX2}/track/${id}?${query}`
|
||||
}
|
||||
|
||||
/** 歌曲详情(openjoox2;上游 fetchSongInfo 的替代实现) */
|
||||
export const getTrackDetail = async (rawId: unknown, options: TrackOptions = {}): Promise<any> => {
|
||||
const id = normalizeJooxID(rawId)
|
||||
if (!id) throw new Error('joox 歌曲 id 无效')
|
||||
const url = buildTrackUrl(id.replace(/\//g, '_'), options)
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body || typeof body !== 'object' || body.code) {
|
||||
const msg = body?.msg || body?.message || `HTTP ${statusCode}`
|
||||
throw new Error(`joox 歌曲详情获取失败:${msg}`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/** 从详情里挑选可播放地址:play_url_list 优先,refrain_url 兜底;并还原 & 实体 */
|
||||
export const pickTrackUrl = (detail: any): string => {
|
||||
const clean = (value: any) => String(value ?? '').replace(/&/g, '&')
|
||||
const urls: any[] = Array.isArray(detail?.play_url_list) ? detail.play_url_list : []
|
||||
for (const url of urls) {
|
||||
const text = clean(url)
|
||||
if (text.startsWith('http')) return text
|
||||
}
|
||||
const refrain = clean(detail?.refrain_url)
|
||||
return refrain.startsWith('http') ? refrain : ''
|
||||
}
|
||||
|
||||
export default { buildTrackUrl, getTrackDetail, pickTrackUrl }
|
||||
@@ -0,0 +1,42 @@
|
||||
// JOOX 搜索(上游 joox/song.go Search 移植)
|
||||
// 端点:GET https://cache.api.joox.com/openjoox/v3/search?country=sg&lang=zh_cn&keyword=<kw>
|
||||
// 返回 section_list(0 歌曲 / 2 歌手 / 1 专辑 / 3 歌单 / 7 用户),歌曲在 item_list[].song[].song_info。
|
||||
// 该接口无分页参数(上游也未分页),page 参数仅为统一契约保留;allPage 恒为 1。
|
||||
// 实测(2026-10-02):英文/中文关键词均 HTTP 200。
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { jooxHeaders } from './util.ts'
|
||||
import { jooxSongFromSearch } from './song.ts'
|
||||
|
||||
const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search'
|
||||
|
||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body?.section_list) {
|
||||
return { list: [], allPage: 0, total: 0, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
const list: any[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const section of body.section_list) {
|
||||
for (const item of section?.item_list ?? []) {
|
||||
for (const entry of item?.song ?? []) {
|
||||
const song = jooxSongFromSearch(entry?.song_info)
|
||||
if (!song || seen.has(song.songmid)) continue
|
||||
seen.add(song.songmid)
|
||||
list.push(song)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
list: list.slice(0, limit),
|
||||
allPage: 1,
|
||||
total: list.length,
|
||||
limit,
|
||||
source: 'joox' as const,
|
||||
}
|
||||
}
|
||||
|
||||
export default { search }
|
||||
@@ -0,0 +1,28 @@
|
||||
// JOOX 链接解析(上游 joox/song.go Parse + album.go ParseAlbum + playlist.go ParsePlaylist)
|
||||
// 歌曲:joox.com/<region>/single/<id> 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链)
|
||||
// 专辑:joox.com/.../album/<id>、h_activity_id=、albumid= → album.getAlbumDetail
|
||||
// 歌单:joox.com/.../playlist/<id>、playlistid|playlist_id|id= → playlist.getPlaylistDetail
|
||||
|
||||
import { normalizeJooxID } from './util.ts'
|
||||
import { getTrackDetail, pickTrackUrl } from './musicInfo.ts'
|
||||
import { jooxSongFromDetail } from './song.ts'
|
||||
import { getAlbumDetail, parseAlbum } from './album.ts'
|
||||
import { getPlaylistDetail, parsePlaylist } from './playlist.ts'
|
||||
|
||||
/** 歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则) */
|
||||
export const parseSong = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
const m = /joox\.com\/.*\/single\/([^/?#]+)/.exec(raw)
|
||||
let songId = ''
|
||||
if (m) {
|
||||
songId = normalizeJooxID(m[1])
|
||||
} else if (raw.length > 10 && !raw.includes('/')) {
|
||||
songId = normalizeJooxID(raw)
|
||||
} else {
|
||||
throw new Error('invalid joox link')
|
||||
}
|
||||
const detail = await getTrackDetail(songId)
|
||||
return jooxSongFromDetail(detail, pickTrackUrl(detail))
|
||||
}
|
||||
|
||||
export default { parseSong, parseAlbum, parsePlaylist }
|
||||
@@ -0,0 +1,316 @@
|
||||
// JOOX 歌单(上游 joox/playlist.go 移植:OpenJOOX 接口 + 网页数据兜底两条路)
|
||||
// 搜索:GET https://cache.api.joox.com/openjoox/v3/search(item.type=1 editor_playlist)
|
||||
// 曲目:GET https://cache.api.joox.com/openjoox2/v1/playlist/<id>/tracks?...&secret= ← 新版 web 播放器通道
|
||||
// (上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留)
|
||||
// 兜底:www.joox.com/{id,sg,hk}/playlist/<id> SSR __NEXT_DATA__
|
||||
// props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体)
|
||||
// 分类:www.joox.com/sg/playlist(302 → /<region> 首页)__NEXT_DATA__.props.pageProps.mlList
|
||||
// 链接解析:joox.com/{region}/playlist/<id>、playlistid/id 查询参数、裸 ID
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import {
|
||||
jooxHeaders,
|
||||
jooxSign,
|
||||
jooxPlaylistLink,
|
||||
jooxPlaylistLinks,
|
||||
normalizeJooxID,
|
||||
normalizeJooxLang,
|
||||
pickJooxImage,
|
||||
firstNonEmpty,
|
||||
firstNonZero,
|
||||
decodeJooxBase64Text,
|
||||
extractJooxNextData,
|
||||
fetchJooxPage,
|
||||
} from './util.ts'
|
||||
import { JOOX_OPENJOOX2 } from './musicInfo.ts'
|
||||
import { jooxSongFromTrack } from './song.ts'
|
||||
|
||||
const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search'
|
||||
const CATEGORY_PAGE = 'https://www.joox.com/sg/playlist'
|
||||
|
||||
const mapPlaylistSummary = (item: any) => {
|
||||
const id = normalizeJooxID(item?.id)
|
||||
if (!id) return null
|
||||
return {
|
||||
id,
|
||||
name: String(item?.name ?? ''),
|
||||
cover: pickJooxImage(item?.images) || null,
|
||||
trackCount: 0,
|
||||
creator: '',
|
||||
description: '',
|
||||
link: jooxPlaylistLink(id),
|
||||
source: 'joox' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** openjoox2 歌单曲目(含歌单元信息) */
|
||||
const fetchPlaylistTracks = async(playlistId: string, page: number, limit: number) => {
|
||||
const country = 'hk'
|
||||
const lang = normalizeJooxLang('zh_hk')
|
||||
const index = Math.max(0, (page - 1) * limit)
|
||||
const params = { country, lang, index, num: limit }
|
||||
const url = `${JOOX_OPENJOOX2}/playlist/${playlistId}/tracks?country=${country}&lang=${lang}&index=${index}&num=${limit}&secret=${jooxSign(params)}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body || body.code) return null
|
||||
|
||||
const list = (body?.tracks?.items ?? [])
|
||||
.map(item => jooxSongFromTrack(item, String(body?.name ?? ''), pickJooxImage(body?.images)))
|
||||
.filter(Boolean)
|
||||
if (!list.length) return null
|
||||
|
||||
const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, body?.track_count, list.length)
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
info: {
|
||||
name: String(firstNonEmpty(body?.name, playlistId)),
|
||||
img: pickJooxImage(body?.images) || null,
|
||||
desc: String(firstNonEmpty(body?.description, body?.publish_date)),
|
||||
author: '',
|
||||
},
|
||||
list,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
/** 网页兜底:解析歌单 SSR 数据(上游 fetchPlaylistPageDataFromURL 的移植) */
|
||||
const parsePlaylistPageData = (nextData: any, pageUrl: string) => {
|
||||
const pageProps = nextData?.props?.pageProps ?? {}
|
||||
let detail: any = pageProps.playlistDetailList ?? {}
|
||||
let tracks: any[] = pageProps.allPlaylistTracks?.tracks?.items ?? []
|
||||
let trackCount = firstNonZero(
|
||||
pageProps.allPlaylistTracks?.tracks?.total_count,
|
||||
pageProps.allPlaylistTracks?.tracks?.totalCount,
|
||||
pageProps.allPlaylistTracks?.tracks?.list_count,
|
||||
)
|
||||
if (!tracks.length) {
|
||||
tracks = detail?.trackList ?? []
|
||||
trackCount = firstNonZero(trackCount, detail?.trackCount, detail?.totalCount, detail?.total_count, detail?.list_count)
|
||||
}
|
||||
if (!tracks.length) {
|
||||
detail = pageProps.content?.page?.playlistDetailList ?? {}
|
||||
tracks = pageProps.content?.page?.allPlaylistTracks?.tracks?.items ?? []
|
||||
trackCount = firstNonZero(
|
||||
pageProps.content?.page?.allPlaylistTracks?.tracks?.total_count,
|
||||
pageProps.content?.page?.allPlaylistTracks?.tracks?.totalCount,
|
||||
pageProps.content?.page?.allPlaylistTracks?.tracks?.list_count,
|
||||
trackCount,
|
||||
)
|
||||
if (!tracks.length) {
|
||||
tracks = detail?.trackList ?? []
|
||||
trackCount = firstNonZero(trackCount, detail?.trackCount, detail?.totalCount, detail?.total_count, detail?.list_count)
|
||||
}
|
||||
}
|
||||
if (!tracks.length) return null
|
||||
|
||||
const id = normalizeJooxID(firstNonEmpty(detail?.id, detail?.listId, detail?.playlistId))
|
||||
const name = String(firstNonEmpty(detail?.name, detail?.title, detail?.playlistName, id))
|
||||
const cover = String(firstNonEmpty(detail?.imgSrc, detail?.cover, detail?.image, detail?.pic))
|
||||
const creator = firstNonEmpty(
|
||||
detail?.creator, detail?.creatorName, detail?.userName, detail?.nickName, detail?.ownerName,
|
||||
detail?.author, detail?.authorName,
|
||||
detail?.user?.name, detail?.user?.userName, detail?.user?.nickName, detail?.user?.nick,
|
||||
detail?.owner?.name, detail?.owner?.userName, detail?.owner?.nickName, detail?.owner?.nick,
|
||||
detail?.creatorInfo?.name, detail?.creatorInfo?.userName, detail?.creatorInfo?.nickName, detail?.creatorInfo?.nick,
|
||||
)
|
||||
const description = firstNonEmpty(detail?.description, detail?.intro, detail?.desc)
|
||||
return { id, name, cover, creator, description, trackCount, tracks, pageUrl }
|
||||
}
|
||||
|
||||
/** 网页兜底:逐个区域尝试歌单页(上游 fetchPlaylistPageData / ParsePlaylist 的页面路径) */
|
||||
export const getPlaylistDetailFromPage = async(rawId: string) => {
|
||||
const playlistId = normalizeJooxID(rawId)
|
||||
if (!playlistId) throw new Error('joox 歌单 id 无效')
|
||||
let lastErr: any = null
|
||||
for (const pageUrl of jooxPlaylistLinks(playlistId)) {
|
||||
try {
|
||||
const { body: html } = await fetchJooxPage(pageUrl)
|
||||
const nextData = extractJooxNextData(html)
|
||||
const parsed = parsePlaylistPageData(nextData, pageUrl)
|
||||
if (!parsed) throw new Error('joox 歌单页数据不存在')
|
||||
const pageId = normalizeJooxID(parsed.id) || playlistId
|
||||
const cover = String(parsed.cover || '')
|
||||
const list = parsed.tracks
|
||||
.map(item => jooxSongFromTrack(item, parsed.name, cover))
|
||||
.filter(Boolean)
|
||||
if (!list.length) throw new Error('joox 歌单无歌曲')
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
info: {
|
||||
name: String(parsed.name || pageId),
|
||||
img: cover || null,
|
||||
desc: String(parsed.description || ''),
|
||||
author: String(parsed.creator || ''),
|
||||
},
|
||||
list,
|
||||
page: 1,
|
||||
limit: list.length,
|
||||
total: firstNonZero(parsed.trackCount, list.length),
|
||||
}
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new Error('joox 歌单页数据不存在')
|
||||
}
|
||||
|
||||
/** 歌单详情:openjoox2 通道优先,网页兜底(上游两条路都保留) */
|
||||
export const getPlaylistDetail = async(rawId: string, page = 1, limit = 50) => {
|
||||
const playlistId = normalizeJooxID(rawId)
|
||||
if (!playlistId) throw new Error('joox 歌单 id 无效')
|
||||
try {
|
||||
const detail = await fetchPlaylistTracks(playlistId, page, limit)
|
||||
if (detail) return detail
|
||||
} catch {
|
||||
// 通道不可用时走网页兜底
|
||||
}
|
||||
return getPlaylistDetailFromPage(playlistId)
|
||||
}
|
||||
|
||||
/** 歌单曲目(上游 GetPlaylistSongs:仅曲目列表;v3 接口 404 时用网页兜底) */
|
||||
export const getPlaylistSongs = async(id: string, page = 1, limit = 50) => {
|
||||
try {
|
||||
const detail = await fetchPlaylistTracks(normalizeJooxID(id), page, limit)
|
||||
if (detail) return detail.list
|
||||
} catch {
|
||||
// 走网页兜底
|
||||
}
|
||||
const { list } = await getPlaylistDetailFromPage(normalizeJooxID(id))
|
||||
return list
|
||||
}
|
||||
|
||||
/** 歌单搜索(上游 SearchPlaylist) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body?.section_list) {
|
||||
return { list: [], total: 0, page, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
const list: any[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const section of body.section_list) {
|
||||
for (const item of section?.item_list ?? []) {
|
||||
if (Number(item?.type) !== 1) continue
|
||||
const summary = mapPlaylistSummary(item?.editor_playlist)
|
||||
if (!summary || seen.has(summary.id)) continue
|
||||
seen.add(summary.id)
|
||||
list.push(summary)
|
||||
}
|
||||
}
|
||||
|
||||
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
/** 歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage) */
|
||||
const fetchPlaylistCategoriesPage = async() => {
|
||||
const { body: html } = await fetchJooxPage(CATEGORY_PAGE)
|
||||
const nextData = extractJooxNextData(html)
|
||||
const mlList = nextData?.props?.pageProps?.mlList
|
||||
if (!mlList?.category?.length) throw new Error('joox 歌单分类不存在')
|
||||
return mlList
|
||||
}
|
||||
|
||||
/** 分类 id:上游 jooxCategoryPlaylistID(数字/字符串都归一) */
|
||||
const categoryPlaylistId = (value: unknown): string => {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'number') return String(Math.trunc(value))
|
||||
return normalizeJooxID(value)
|
||||
}
|
||||
|
||||
/** 分类列表(上游 GetPlaylistCategories;首个为「全部」) */
|
||||
export const getCategories = async() => {
|
||||
const data = await fetchPlaylistCategoriesPage()
|
||||
const list: any[] = [{ id: '', name: '全部', group: '全部', hot: false }]
|
||||
data.category.forEach((category: any, index: number) => {
|
||||
const name = decodeJooxBase64Text(category?.title)
|
||||
const items: any[] = category?.itemlist ?? []
|
||||
if (!name || !items.length) return
|
||||
list.push({ id: String(index), name, group: 'JOOX', hot: false, count: items.length })
|
||||
})
|
||||
if (list.length === 1) throw new Error('joox 歌单分类不存在')
|
||||
return { source: 'joox' as const, list }
|
||||
}
|
||||
|
||||
/** 分类下歌单(上游 GetCategoryPlaylists) */
|
||||
export const getCategoryPlaylists = async(categoryId = '', page = 1, limit = 20) => {
|
||||
const data = await fetchPlaylistCategoriesPage()
|
||||
const cid = String(categoryId ?? '').trim()
|
||||
let items: any[] = []
|
||||
let categoryName = '全部'
|
||||
|
||||
if (!cid) {
|
||||
const seen = new Set<string>()
|
||||
for (const category of data.category) {
|
||||
for (const item of category?.itemlist ?? []) {
|
||||
const id = categoryPlaylistId(item?.id)
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let index = 0; index < data.category.length; index++) {
|
||||
const category = data.category[index]
|
||||
const name = decodeJooxBase64Text(category?.title)
|
||||
if (String(index) === cid || (name && name.toLowerCase() === cid.toLowerCase())) {
|
||||
items = category?.itemlist ?? []
|
||||
categoryName = name
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!items.length) throw new Error('joox 歌单分类不存在')
|
||||
}
|
||||
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
if (start >= items.length) throw new Error('joox 分类下无歌单')
|
||||
const pageItems = items.slice(start, start + limit)
|
||||
|
||||
const list = pageItems
|
||||
.map(item => {
|
||||
const id = categoryPlaylistId(item?.id)
|
||||
const name = decodeJooxBase64Text(item?.title)
|
||||
if (!id || !name) return null
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
cover: String(item?.picurl ?? '').replace('%d', '300') || null,
|
||||
trackCount: 0,
|
||||
creator: '',
|
||||
description: '',
|
||||
link: jooxPlaylistLink(id),
|
||||
categoryId: cid,
|
||||
categoryName,
|
||||
source: 'joox' as const,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (!list.length) throw new Error('joox 分类下无歌单')
|
||||
|
||||
return { list, total: items.length, page, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
/** 歌单链接解析(上游 ParsePlaylist) */
|
||||
export const parsePlaylist = async(link: string) => {
|
||||
const patterns = [
|
||||
/joox\.com\/.*\/playlist\/([^/?#]+)/,
|
||||
/(?:playlistid|playlist_id|id)=([^&]+)/,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const m = pattern.exec(String(link ?? ''))
|
||||
if (m) return getPlaylistDetail(m[1])
|
||||
}
|
||||
const raw = String(link ?? '').trim()
|
||||
if (raw.length > 8 && !raw.includes('/')) return getPlaylistDetail(raw)
|
||||
throw new Error('invalid joox playlist link')
|
||||
}
|
||||
|
||||
export default {
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
searchPlaylist,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
parsePlaylist,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// JOOX 歌曲形状映射(统一 Song 形状,见 tmp/port-spec.md)
|
||||
// 兼容来源:openjoox2 track/album/playlist 条目、openjoox v3 search 的 song_info、
|
||||
// 以及 SSR 页面上的 track 条目(字段有 snake/camel 两套命名,上游 Go 也两套都兜底)。
|
||||
|
||||
import { formatPlayTime } from '../common/utils.ts'
|
||||
import {
|
||||
normalizeJooxID,
|
||||
pickJooxImage,
|
||||
jooxArtists,
|
||||
firstNonEmpty,
|
||||
firstNonZero,
|
||||
} from './util.ts'
|
||||
|
||||
/** 详情/列表 track 条目 → 统一 Song 形状 */
|
||||
export const jooxSongFromTrack = (
|
||||
item: any,
|
||||
fallbackAlbum = '',
|
||||
fallbackCover = '',
|
||||
fallbackAlbumId = '',
|
||||
): any | null => {
|
||||
const songmid = normalizeJooxID(item?.id)
|
||||
if (!songmid) return null
|
||||
|
||||
const artists = Array.isArray(item?.artist_list) && item.artist_list.length
|
||||
? item.artist_list
|
||||
: item?.artistList
|
||||
const albumId = normalizeJooxID(firstNonEmpty(item?.album_id, item?.albumId, fallbackAlbumId))
|
||||
|
||||
return {
|
||||
songmid,
|
||||
name: String(firstNonEmpty(item?.name, item?.title, 'Unknown')),
|
||||
singer: String(firstNonEmpty(jooxArtists(artists), 'Unknown')),
|
||||
albumId: String(albumId || ''),
|
||||
albumName: String(firstNonEmpty(item?.album_name, item?.albumName, fallbackAlbum)),
|
||||
interval: formatPlayTime(firstNonZero(item?.play_duration, item?.playDuration, item?.duration)),
|
||||
img: String(firstNonEmpty(
|
||||
pickJooxImage(item?.images),
|
||||
item?.imgSrc,
|
||||
item?.cover,
|
||||
item?.image,
|
||||
item?.pic,
|
||||
fallbackCover,
|
||||
)) || null,
|
||||
lrc: null,
|
||||
vip: Number(item?.vip_flag ?? 0) === 1,
|
||||
types: [],
|
||||
_types: {},
|
||||
source: 'joox' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** openjoox v3 搜索歌曲条目(song_info)→ 统一 Song 形状 */
|
||||
export const jooxSongFromSearch = (info: any): any | null => jooxSongFromTrack(info)
|
||||
|
||||
/** 详情接口返回的整曲信息(parse 用,含可播放地址)→ 统一 Song 形状 */
|
||||
export const jooxSongFromDetail = (detail: any, playUrl = ''): any => {
|
||||
const song = jooxSongFromTrack(detail) ?? {
|
||||
songmid: normalizeJooxID(detail?.id),
|
||||
name: String(detail?.name ?? ''),
|
||||
singer: String(jooxArtists(detail?.artist_list)),
|
||||
albumId: String(normalizeJooxID(detail?.album_id)),
|
||||
albumName: String(detail?.album_name ?? ''),
|
||||
interval: formatPlayTime(firstNonZero(detail?.play_duration)),
|
||||
img: String(pickJooxImage(detail?.images)) || null,
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
source: 'joox' as const,
|
||||
}
|
||||
if (playUrl) song.url = playUrl
|
||||
song.vip = Number(detail?.vip_flag ?? 0) === 1
|
||||
return song
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// JOOX 平台公共工具:请求头/签名/ID 归一化/链接构造/页面数据提取
|
||||
// 背景(2026-10-02 实测):
|
||||
// - JOOX 是东南亚服务,www.joox.com 会按来源 IP 重定向到 /intl;上游 Go 通过伪造
|
||||
// X-Forwarded-For 为东南亚 IP(36.73.34.109)绕过,本移植保持一致。
|
||||
// - 新版 web 播放器走 cache.api.joox.com/openjoox2/v1/*,必须带 secret 签名:
|
||||
// secret = md5('Jo0x@t3Nc3nT' + 参数按插入序 k=v&... )(值经 encodeURIComponent),
|
||||
// 密钥来自官网 JS bundle 常量 secretAPIKey(web 公开常量,非账号密钥)。
|
||||
// - 旧接口 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(取流/歌曲详情改用 openjoox2),
|
||||
// 但 web_lyric 仍可用(歌词仍走上游接口)。
|
||||
// - 上游 New(cookie):cookie 用于游客态/登录态请求;登录 cookie 里的 wmid/session_key
|
||||
// 在签名参数里作为 uid/usk 参与(可拿到完整曲目/更高档位)。
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
|
||||
export const JOOX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36'
|
||||
/** 上游 Go 的默认游客 Cookie(jaox.go 常量 Cookie) */
|
||||
export const JOOX_DEFAULT_COOKIE = 'wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;'
|
||||
/** 上游 Go 的 X-Forwarded-For(东南亚 IP,绕过地区重定向) */
|
||||
export const JOOX_XFF = '36.73.34.109'
|
||||
/** openjoox2 签名密钥(官网 web bundle 常量 secretAPIKey) */
|
||||
export const JOOX_SECRET_KEY = 'Jo0x@t3Nc3nT'
|
||||
|
||||
export const md5Hex = (str: string): string => crypto.createHash('md5').update(str, 'utf8').digest('hex')
|
||||
|
||||
/**
|
||||
* openjoox2 签名:md5(secretAPIKey + `k=encodeURIComponent(v)` 按插入序以 & 连接)
|
||||
* 注意键序即对象插入序(服务端按同序校验),不要重排参数。
|
||||
*/
|
||||
export const jooxSign = (params: Record<string, any>): string =>
|
||||
md5Hex(JOOX_SECRET_KEY + Object.keys(params).map(key => `${key}=${encodeURIComponent(String(params[key]))}`).join('&'))
|
||||
|
||||
/** 绑定到 sdk/auth/store.ts 的登录 cookie(未登录返回空串,不回退游客 cookie) */
|
||||
export const jooxLoginCookie = (): string => {
|
||||
const cred = getCredentials('joox') as { cookie?: string } | null
|
||||
return cred?.cookie || ''
|
||||
}
|
||||
|
||||
/** 请求用 cookie(登录态优先;未登录回退上游游客 cookie) */
|
||||
export const jooxCookie = (): string => jooxLoginCookie() || JOOX_DEFAULT_COOKIE
|
||||
|
||||
/** 从 cookie 串里取一个字段 */
|
||||
export const cookieValue = (cookie: string, name: string): string => {
|
||||
const m = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(String(cookie ?? ''))
|
||||
return m ? m[1].trim() : ''
|
||||
}
|
||||
|
||||
/** 平台公共请求头(含地区伪装与 Cookie) */
|
||||
export const jooxHeaders = (extra: Record<string, any> = {}): Record<string, any> => ({
|
||||
'User-Agent': JOOX_UA,
|
||||
Cookie: jooxCookie(),
|
||||
'X-Forwarded-For': JOOX_XFF,
|
||||
Referer: 'https://www.joox.com/',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
...extra,
|
||||
})
|
||||
|
||||
/** 语言归一(对齐官网 51bm.c:zh_hk/zh_mo → zh_TW,zh_cn → zh_CN) */
|
||||
export const normalizeJooxLang = (lang?: string): string => {
|
||||
if (lang === 'zh_hk' || lang === 'zh_mo') return 'zh_TW'
|
||||
if (lang === 'zh_cn') return 'zh_CN'
|
||||
return lang || 'zh_TW'
|
||||
}
|
||||
|
||||
/** ID 归一化(上游 normalizeJooxID):去空白 + 百分号解码(空格还原为 +) */
|
||||
export const normalizeJooxID = (raw: unknown): string => {
|
||||
let id = String(raw ?? '').trim()
|
||||
if (!id) return ''
|
||||
if (id.includes('%')) {
|
||||
try {
|
||||
id = decodeURIComponent(id)
|
||||
} catch {
|
||||
// 上游 PathUnescape/QueryUnescape 失败时保留原值
|
||||
}
|
||||
id = id.replace(/ /g, '+')
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export const jooxSongLink = (id: unknown): string => `https://www.joox.com/hk/single/${normalizeJooxID(id)}`
|
||||
export const jooxAlbumLink = (id: unknown): string => `https://www.joox.com/hk/album/${normalizeJooxID(id)}`
|
||||
export const jooxRegionalPlaylistLink = (region: string, id: unknown): string =>
|
||||
`https://www.joox.com/${region}/playlist/${normalizeJooxID(id)}`
|
||||
export const jooxPlaylistLink = (id: unknown): string => jooxRegionalPlaylistLink('id', id)
|
||||
/** 歌单页候选(上游顺序:id → sg → hk,逐个尝试直到页面数据可用) */
|
||||
export const jooxPlaylistLinks = (id: unknown): string[] =>
|
||||
['id', 'sg', 'hk'].map(region => jooxRegionalPlaylistLink(region, id))
|
||||
|
||||
/** 封面挑选(上游 pickJooxImage:优先 300 → 1000 → 100,再回退首个非空) */
|
||||
export const pickJooxImage = (images: any[]): string => {
|
||||
const list = Array.isArray(images) ? images : []
|
||||
for (const width of [300, 1000, 100]) {
|
||||
for (const image of list) {
|
||||
const url = String(image?.url ?? '').trim()
|
||||
if (Number(image?.width) === width && url) return url
|
||||
}
|
||||
}
|
||||
for (const image of list) {
|
||||
const url = String(image?.url ?? '').trim()
|
||||
if (url) return url
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 歌手拼接(上游 joinJooxArtists,分隔符 ' / ') */
|
||||
export const jooxArtists = (list: any[]): string =>
|
||||
(Array.isArray(list) ? list : []).map(a => String(a?.name ?? '').trim()).filter(Boolean).join(' / ')
|
||||
|
||||
export const firstNonEmpty = (...values: any[]): any => {
|
||||
for (const value of values) {
|
||||
if (value != null && String(value).trim() !== '') return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const firstNonZero = (...values: any[]): number => {
|
||||
for (const value of values) {
|
||||
const n = Number(value)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** base64 文本解码(上游 decodeJooxBase64Text:容忍 URL-safe 与缺失填充) */
|
||||
export const decodeJooxBase64Text = (value: unknown): string => {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
const normalized = raw.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
try {
|
||||
const text = Buffer.from(padded, 'base64').toString('utf8')
|
||||
// 解码结果是二进制噪声时回退原值(对齐上游 stdout base64 校验语义)
|
||||
return text.includes('\uFFFD') ? raw : text.trim()
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
/** HTML 实体解码(歌词文本兜底) */
|
||||
export const decodeHtmlEntities = (text: unknown): string =>
|
||||
String(text ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/�*39;/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
|
||||
/** 歌词解码:base64 → utf8 → HTML 实体(上游 base64 + 常见实体兜底) */
|
||||
export const decodeJooxLyric = (value: unknown): string => decodeHtmlEntities(decodeJooxBase64Text(value))
|
||||
|
||||
/** 从 SSR 页面提取 __NEXT_DATA__ JSON */
|
||||
export const extractJooxNextData = (html: unknown): any => {
|
||||
const m = /<script[^>]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(String(html ?? ''))
|
||||
if (!m) return null
|
||||
try {
|
||||
return JSON.parse(m[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取页面并手动跟随 3xx(request.ts 不自动跟跳;Go 客户端会自动跟)
|
||||
* 返回最终页 HTML(非字符串 body 视为空串)
|
||||
*/
|
||||
export const fetchJooxPage = async (url: string, maxRedirect = 3): Promise<{ statusCode: number, body: string, finalUrl: string }> => {
|
||||
let current = url
|
||||
for (let i = 0; i <= maxRedirect; i++) {
|
||||
const res: any = await httpFetch(current, { headers: jooxHeaders(), timeout: 20000 }).promise
|
||||
const location = res?.headers?.location
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && location) {
|
||||
current = new URL(String(location), current).href
|
||||
continue
|
||||
}
|
||||
return { statusCode: res.statusCode, body: typeof res.body === 'string' ? res.body : '', finalUrl: current }
|
||||
}
|
||||
throw new Error('joox 页面重定向次数过多')
|
||||
}
|
||||
Reference in new issue
Block a user