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:
1 parent
33b96f92c4
commit
68e071b166
20 files changed
+2218
-33
No files matched your search
@@ -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('无效的酷狗专辑链接')
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user