feat(sdk): kg/kw/tx/wy 补齐专辑、歌单与 parse 能力

四个主流平台从「搜索 + 取流」补到能力对齐,新增 playlist/parse
(tx、wy 另加 userPlaylist),顺带修掉酷我直链的一个真实缺陷。

- kw:酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3…`),
  `$` 非 URL 标准分隔符,播放器与解析器普遍不认,导致拿到直链也放不出声;
  统一还原成 `=` 再对外返回
- wy:补齐 album/playlist/parse/userPlaylist,与 kg/tx 能力对齐
- kg/kw/tx:接入 parse(跨平台精确匹配的输入源)与歌单

为什么单独一次提交:这批改动不动架构,只做能力补齐与 URL 规范化,
与新增平台的注册表改动分开,出问题时便于单独回滚。
This commit is contained in:
snowy-yang committed 2026-10-03 00:26:51 +08:00
1 parent 33b96f92c4
commit 68e071b166
20 files changed
+2218 -33

No files matched your search

+63
View File
@@ -1,5 +1,8 @@
import { getMusicInfosByList } from './musicInfo.ts'
import { createHttpFetch } from './util.ts'
import { httpFetch } from '../request.ts'
const albumCover = (url: any): string => String(url ?? '').replace('{size}', '240')
export default {
/**
@@ -60,4 +63,64 @@ export default {
},
}
},
/**
* 专辑搜索(上游 kugou SearchAlbum:mobilecdn v3 search/album)
* 返回摘要统一形状(对齐契约:id/name/cover/trackCount/creator/description/link/source)
*/
async searchAlbum(keyword, page = 1, limit = 10) {
const word = String(keyword ?? '').trim()
if (!word) return { source: 'kg', list: [], page, limit, total: 0 }
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 10)
const apiURL = `http://mobilecdn.kugou.com/api/v3/search/album?keyword=${encodeURIComponent(word)}&format=json&page=${pageNo}&pagesize=${pageSize}`
const { statusCode, body } = await httpFetch(apiURL, {
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
Referer: 'http://m.kugou.com',
},
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
throw new Error(body?.error || '酷狗专辑搜索失败')
}
const list = (body?.data?.info ?? []).map((item: any) => {
const albumID = String(item?.albumid ?? '').trim()
if (!albumID || albumID === '0') return null
return {
id: albumID,
name: String(item?.albumname ?? '').trim(),
cover: albumCover(item?.imgurl),
trackCount: Number(item?.songcount ?? 0) || 0,
creator: String(item?.singername ?? '').trim(),
description: String(item?.intro ?? '').trim(),
publishTime: String(item?.publishtime ?? '').trim(),
link: `https://www.kugou.com/album/${albumID}.html`,
source: 'kg',
type: 'album',
}
}).filter(Boolean)
return { source: 'kg', list, page: pageNo, limit: pageSize, total: Number(body?.data?.total ?? 0) || list.length }
},
/** 上游 GetAlbumSongs:复用 getAlbumDetail 拉全量曲目 */
async getAlbumSongs(id) {
const albumID = String(id ?? '').trim()
if (!albumID) throw new Error('酷狗专辑 id 为空')
const { list } = await this.getAlbumDetail(albumID, 1, 10000)
return list
},
/** 上游 ParseAlbum:album/single/<id>.html、album/<id>.html、albumid=<id> */
async parseAlbumLink(link) {
const raw = String(link ?? '')
const patterns = [
/album\/single\/(\d+)\.html/,
/yy\/album\/single\/(\d+)\.html/,
/album\/(\d+)\.html/,
/albumid=(\d+)/,
]
for (const pattern of patterns) {
const match = raw.match(pattern)
if (match) return this.getAlbumDetail(match[1], 1)
}
throw new Error('无效的酷狗专辑链接')
},
}
+4
View File
@@ -8,6 +8,8 @@ import hotSearch from './hotSearch.ts'
import comment from './comment.ts'
import singer from './singer.ts'
import album from './album.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import { registerDfid } from '../auth/kg.ts'
const kg = {
@@ -22,6 +24,8 @@ const kg = {
comment,
singer,
album,
playlist,
parse,
getMusicUrl(songInfo, type) {
return kgApi.getMusicUrl(songInfo, type)
},
+111
View File
@@ -0,0 +1,111 @@
// 酷狗链接解析
// 歌曲:https://www.kugou.com/song/#hash=<32位hash>(含 &album_id=... 等尾串)、裸 hash
// 上游对应:music-lib/kugou/song.go Parse / kugou.go fetchSongInfo
// 元数据 m.kugou.com/app/i/getSongInfo.php(songName/author_name/timeLength/album_img/bitRate)
// 另尽力合并 musicInfo.getMusicInfo 的专辑/多档 hash,直链取流失败不影响返回(上游为必得 URL)。
import { httpFetch } from '../request.ts'
import { formatPlayTime, decodeName } from '../index.ts'
import kgApi from '../apis/kg.ts'
import { getMusicInfo } from './musicInfo.ts'
import album from './album.ts'
import playlist from './playlist.ts'
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
const HASH_RE = /hash=([a-fA-F0-9]{32})/i
const RAW_HASH_RE = /^[a-fA-F0-9]{32}$/
/** 链接/裸 hash → 32 位小写 hash(上游 Parse 的 `(?i)hash=([a-f0-9]{32})`,另接受裸 hash) */
export const extractHash = (link: any): string => {
const raw = String(link ?? '').trim()
const match = raw.match(HASH_RE)
if (match) return match[1]!.toLowerCase()
return RAW_HASH_RE.test(raw) ? raw.toLowerCase() : ''
}
/** 上游 fetchSongInfo:m.kugou.com playInfo(返回元数据 + 直链,付费曲无 url) */
const fetchSongInfoPhp = async(hash: string) => {
const { statusCode, body } = await httpFetch(`http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=${hash}`, {
method: 'get',
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || typeof body !== 'object' || body == null) return null
if (Number(body.errcode ?? 0) !== 0 || !body.hash) return null
return body
}
/** 上游 Parse:酷狗歌曲链接(或裸 hash)→ Song,尽力附带直链 */
export const parseSong = async(link: string) => {
const hash = extractHash(link)
if (!hash) throw new Error('无效的酷狗歌曲链接(未找到 32 位 hash)')
let [raw, info] = await Promise.all([
fetchSongInfoPhp(hash).catch(() => null),
getMusicInfo(hash).catch(() => undefined),
])
if (!raw && !info) throw new Error('酷狗歌曲不存在或已下架')
const fileName = String(raw?.fileName ?? '')
const name = decodeName(String(raw?.songName ?? '').trim() || fileName.split(' - ').slice(1).join(' - ') || info?.name || '')
const singer = decodeName(String(raw?.author_name ?? '').trim() || info?.singer || '')
const duration = Number(raw?.timeLength ?? 0) || 0
const song: any = {
songmid: String(info?.songmid ?? raw?.audio_id ?? '').trim(),
hash: String(raw?.hash ?? hash).toLowerCase(),
name,
singer,
albumName: decodeName(String(info?.albumName ?? '').trim()),
albumId: String(info?.albumId ?? raw?.albumid ?? raw?.req_albumid ?? '').trim(),
interval: duration > 0 ? formatPlayTime(duration) : (info?.interval ?? '--/--'),
img: String(raw?.album_img ?? '').replace('{size}', '240') || info?.img || null,
lrc: null,
otherSource: null,
types: info?.types ?? [],
_types: info?._types ?? {},
typeUrl: {},
source: 'kg' as const,
extra: {
hash: String(raw?.hash ?? hash).toLowerCase(),
url: String(raw?.url ?? ''),
backup_url: raw?.backup_url ?? [],
bitrate: Number(raw?.bitRate ?? 0) || 0,
link: `https://www.kugou.com/song/#hash=${hash}`,
},
}
// 上游 fetchSongInfo 必得 URL:playInfo 的 url/backup_url 优先(官方直链,付费曲为空)
if (!song.url) {
const rawUrl = String(raw?.url ?? '') || String(raw?.backup_url?.[0] ?? '')
if (rawUrl) {
song.url = rawUrl
song.type = Number(raw?.bitRate ?? 0) >= 320000 ? '320k' : '128k'
}
}
// 仍无直链时(风控/付费)用 apis/kg 的签名通道尽力补一次;失败仅返回元数据
if (!song.url) {
try {
const hit = await kgApi.getMusicUrl({
hash: song.hash,
albumId: song.albumId,
songmid: song.songmid,
_types: song._types,
} as any, '128k').promise
if (hit?.url) {
song.url = hit.url
song.type = hit.type
}
} catch {
// 忽略:与上游付费曲无直链的失败语义一致(元数据仍返回)
}
}
return song
}
export const parseAlbum = async(link: string) => album.parseAlbumLink(link)
export const parsePlaylist = async(link: string) => playlist.parsePlaylistLink(link)
export default { parseSong, parseAlbum, parsePlaylist }
+237
View File
@@ -0,0 +1,237 @@
// 酷狗歌单 —— 搜索 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析
// 上游对应:music-lib/kugou/playlist.go + kugou.go 的 fetchPlaylistDetail
// 链接解析优先复用既有 sdk/kg/songList.ts 的 getUserListDetail / getListDetailBySpecialId /
// getListInfoByChain / decodeGcid 等能力(覆盖 special/single、songlist/gcid、share chain、
// zlist、global_collection_id 等形态),本模块只做统一形状归一。
import { httpFetch } from '../request.ts'
import songList from './songList.ts'
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
const firstNonEmpty = (...values: any[]): string => {
for (const value of values) {
if (value == null) continue
const str = String(value).trim()
if (str) return str
}
return ''
}
const playlistCover = (url: any): string => String(url ?? '').replace('{size}', '240')
/** 歌单摘要(对齐契约形状;id 用平台 specialid,link 指向网页版) */
const toSummary = (item: any) => {
const rawId = firstNonEmpty(item?.specialid, item?.global_specialid, item?.id)
const id = rawId.replace(/^id_/, '')
if (!id) return null
const playCount = Number(item?.playcount)
return {
id,
name: firstNonEmpty(item?.specialname, item?.name),
cover: playlistCover(firstNonEmpty(item?.imgurl, item?.img)),
trackCount: Number(item?.songcount ?? item?.total ?? 0) || 0,
playCount: Number.isFinite(playCount) ? playCount : (item?.play_count ?? 0),
creator: firstNonEmpty(item?.nickname, item?.username, item?.author),
description: firstNonEmpty(item?.intro, item?.desc),
publishTime: String(item?.publishtime ?? item?.time ?? '').trim(),
link: `https://www.kugou.com/yy/special/single/${id}.html`,
source: 'kg' as const,
}
}
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search 的 msearchretry v3 接口) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
if (!word) return { source: 'kg' as const, list: [] as any[], page, limit, total: 0 }
const res = await songList.search(word, page, limit)
const list = (res?.list ?? []).map(toSummary).filter(Boolean)
return {
source: 'kg' as const,
list,
page,
limit,
total: Number(res?.total ?? 0) || list.length,
}
}
/** 数字 id / id_ 前缀 / 链接 → songList.getListDetail 可识别的入参 */
const normalizePlaylistId = (id: any): string => {
const raw = String(id ?? '').trim()
if (!raw) return ''
if (/^\d+$/.test(raw)) return `id_${raw}`
return raw
}
/**
* 歌单详情(含元数据与曲目)
* 委托 songList.getListDetail(special 页 / gcid songlist / share chain / 裸酷狗码等),
* 平台自身返回全量时按 page/limit 本地开窗。
*/
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const requestId = normalizePlaylistId(id)
if (!requestId) throw new Error('酷狗歌单 id 为空')
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 50)
const res = await songList.getListDetail(requestId, pageNo)
if (!res) throw new Error('酷狗歌单不存在或解析失败')
let list: any[] = res.list ?? []
const total = Number(res.total ?? 0) || list.length
if (list.length > pageSize) {
const start = (pageNo - 1) * pageSize
list = list.slice(start, start + pageSize)
}
return {
source: 'kg' as const,
info: {
name: firstNonEmpty(res.info?.name, String(id)),
img: playlistCover(res.info?.img),
desc: firstNonEmpty(res.info?.desc),
author: firstNonEmpty(res.info?.author),
play_count: res.info?.play_count ?? '',
},
list,
page: pageNo,
limit: pageSize,
total,
}
}
/** 上游 GetPlaylistSongs:只取曲目(复用详情全量) */
export const getPlaylistSongs = async(id: string) => {
const res = await songList.getListDetail(normalizePlaylistId(id), 1)
return res?.list ?? []
}
/** 歌单分类(上游 GetPlaylistCategories:tag/list;categoryId 形如 `<id>:<tagId>`) */
export const getCategories = async() => {
const { statusCode, body } = await httpFetch('http://mobilecdnbj.kugou.com/api/v3/tag/list?pid=0&apiver=2&plat=0', {
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
throw new Error(body?.error || '酷狗歌单分类获取失败')
}
const list: Array<{ id: string, name: string, group: string, hot?: boolean }> = [
{ id: '', name: '全部', group: '全部' },
]
for (const group of body?.data?.info ?? []) {
const groupName = String(group?.name ?? '').trim()
const groupID = Number(group?.id ?? 0) || 0
if (groupID > 0 && groupName) {
list.push({ id: `${groupID}:0`, name: groupName, group: groupName })
}
for (const child of group?.children ?? []) {
const id = Number(child?.id ?? 0) || 0
const name = String(child?.name ?? '').trim()
if (!id || !name) continue
const tagId = Number(child?.special_tag_id ?? 0) || 0
list.push({ id: `${id}:${tagId}`, name, group: groupName, hot: Number(child?.is_hot ?? 0) === 1 })
}
}
return { source: 'kg' as const, list }
}
/** categoryId → [id, tagId];空/dirty 时取分类表首个非空分类 */
const parseCategoryID = (categoryId: any): { id: string, tagId: string } => {
const parts = String(categoryId ?? '').trim().split(':')
const id = String(parts[0] ?? '').trim()
const tagId = String(parts[1] ?? '').trim() || '0'
return { id, tagId }
}
/** 分类歌单(上游 GetCategoryPlaylists:tag/specialList) */
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 20)
let { id, tagId } = parseCategoryID(categoryId)
if (!id) {
const { list } = await getCategories()
const first = list.find(item => item.id)
if (!first) throw new Error('酷狗歌单分类为空')
;({ id, tagId } = parseCategoryID(first.id))
}
const apiURL = 'http://mobilecdnbj.kugou.com/api/v3/tag/specialList?' + [
'plat=0',
`page=${pageNo}`,
`tagid=${tagId}`,
`pagesize=${pageSize}`,
'ugc=1',
`id=${id}`,
'sort=2',
].join('&')
const { statusCode, body } = await httpFetch(apiURL, {
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
throw new Error(body?.error || '酷狗分类歌单获取失败')
}
const list = (body?.data?.info ?? []).map((item: any) => {
const summary: any = toSummary(item) ?? toSummary({ ...item, specialid: item?.global_specialid })
if (!summary) return null
summary.categoryId = `${id}:${tagId}`
return summary
}).filter(Boolean)
return {
source: 'kg' as const,
list,
page: pageNo,
limit: pageSize,
total: Number(body?.data?.total ?? 0) || list.length,
}
}
/** 推荐歌单(上游 GetRecommendedPlaylists:m.kugou.com/plist/index&json=true) */
export const getRecommended = async(page = 1, limit = 30) => {
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 30)
const { statusCode, body } = await httpFetch(`http://m.kugou.com/plist/index&json=true&page=${pageNo}`, {
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || typeof body !== 'object' || body == null) {
throw new Error('酷狗推荐歌单获取失败')
}
let list = (body?.plist?.list?.info ?? []).map(toSummary).filter(Boolean)
if (list.length > pageSize) list = list.slice(0, pageSize)
return {
source: 'kg' as const,
list,
page: pageNo,
limit: pageSize,
total: Number(body?.plist?.list?.total ?? 0) || list.length,
}
}
/**
* 上游 ParsePlaylist:
* /yy/special/single/<id>.html → special 详情
* /songlist/<gcid_xxx>/ → gcid 详情
* 其余链接形态(share chain、zlist、global_collection_id、裸 id/酷狗码)交给 songList 通用解析。
*/
export const parsePlaylistLink = async(link: string) => {
const raw = String(link ?? '').trim()
if (!raw) throw new Error('无效的酷狗歌单链接')
const special = raw.match(/special\/single\/(\d+)\.html/)
const gcid = raw.match(/songlist\/(gcid_[A-Za-z0-9]+)/)
if (special) return getPlaylistDetail(special[1]!, 1)
if (gcid) return getPlaylistDetail(gcid[1]!, 1)
return getPlaylistDetail(raw, 1)
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
getRecommended,
parsePlaylistLink,
}