Files
MusicServer/sdk/joox/util.ts
T
snowy-yang 3381e9b906 refactor(api)!: 平台 id 改两字母短码——qian→bd、joox→jx、5sing→5s、bili→bl
对外 id 统一为两字母短码,与既有的 kw/kg/tx/wy/qs/xm/me/qt/lr 对齐;
migu 与 apple 保持原样(Apple Music 没有自然的短码)。

**破坏性变更**:id 出现在所有 URL、/sources 返回、OpenAPI 示例以及
data/auth.json 的凭据键里,这四个平台已存的登录态会失效,需重新登录。

- sdk/index.ts:id 与 supportQuality 映射键
- sdk/types.ts:Source 联合类型
- src/routes.ts 与 routes-login.ts:路由路径与 schema 描述
  (/login/bili/qr → /login/bl/qr 等)、/login/status 的响应键
- test/smoke-test.ts、README.md、docs/API.md 同步

有意未改:sdk 目录与文件名仍用平台名(sdk/qian/、sdk/apis/joox.ts …),
规则定为「目录=平台名,id=对外短码」,映射只在 SUPPORTED_SOURCES 一处声明。
域名与正则(joox.com、5sing.kugou.com、/joox\.com\//)、jooxSign 等标识符、
错误文案里的中文平台名、以及 kg 榜单名(含「joox本地热歌榜」)全部保持原样——
它们只是恰好含平台名,并不是 id。

验证:typecheck ✓、pnpm test 112/112 ✓;另起临时实例实证 /sources 与
/login/status 的新 id 与展示名,新旧 id 均无残留。
2026-10-03 11:03:06 +08:00

182 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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('jx') 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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#0*39;/g, "'")
.replace(/&nbsp;/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 页面重定向次数过多')
}