From 3e0880f24491de584e54af66221798adf82e2fcb Mon Sep 17 00:00:00 2001 From: Tony Date: Sat, 12 Jul 2025 01:11:39 +0800 Subject: [PATCH] fix(route/ximalaya): fix isPaid (#19586) * wip * refactor(utils): comment out unused getUrl function and optimize buffer handling --- lib/routes/ximalaya/album.ts | 102 +++++++++++------------------------ lib/routes/ximalaya/types.ts | 48 +++++++++++++++++ lib/routes/ximalaya/utils.ts | 16 +++--- 3 files changed, 89 insertions(+), 77 deletions(-) diff --git a/lib/routes/ximalaya/album.ts b/lib/routes/ximalaya/album.ts index 4784d1db3f..248567fa1a 100644 --- a/lib/routes/ximalaya/album.ts +++ b/lib/routes/ximalaya/album.ts @@ -1,12 +1,12 @@ import { Route } from '@/types'; import cache from '@/utils/cache'; import ofetch from '@/utils/ofetch'; -import { load } from 'cheerio'; import { getRandom16, decryptUrl } from './utils'; const baseUrl = 'https://www.ximalaya.com'; import { config } from '@/config'; import { parseDate } from '@/utils/parse-date'; -import { RichIntro, TrackInfoResponse } from './types'; +import { Album, RichIntro, TrackInfoResponse } from './types'; +import sanitizeHtml from 'sanitize-html'; // Find category from: https://help.apple.com/itc/podcasts_connect/?lang=en#/itc9267a2f12 const categoryDict = { @@ -18,51 +18,16 @@ const categoryDict = { IT科技: 'Technology', }; -// function parseState(state_string) { -// const data = {}; -// new Function('window', state_string)(data); -// return data.__INITIAL_STATE__ || {}; -// } -/** - * - * @param {string} stateString 网页中的有一处 script 标签内有语句 - * `window.__INITIAL_STATE__ = {"xx": "xx"};` - */ -function parseStateSafe(stateString) { - const prefix = 'window.__INITIAL_STATE__ = '; - const suffix = ';'; - stateString = stateString.slice(prefix.length, stateString.length - suffix.length); - try { - return JSON.parse(stateString); - } catch { - return {}; - } -} - -function getTextFromElement(element) { - if (element.children && element.children.length > 0) { - return element.children[0].data.trim(); - } - return ''; -} - -async function parseAlbumData(category, album_id) { - // 解析网页版的 HTML 获取该专辑的信息 - // 这里的 {category} 并不重要,系统会准确的返回该 id 对应专辑的信息 - // 现在 {category} 必须要精确到大的类别 - // https://www.ximalaya.com/{category}/{album_id} - const response = await ofetch(`${baseUrl}/${category}/${album_id}`); - const $ = load(response); - const stateElement = $('script') - .toArray() - .filter((element) => getTextFromElement(element).startsWith('window.__INITIAL_STATE__')); - if (stateElement.length > 0) { - const stateString = getTextFromElement(stateElement[0]); - if (stateString) { - return parseStateSafe(stateString); - } - } - return {}; +function getAlbumData(albumId) { + return cache.tryGet(`ximalaya:albumInfo:${albumId}`, async () => { + const response = await ofetch(`${baseUrl}/revision/album/v1/simple`, { + query: { + albumId, + }, + parseResponse: JSON.parse, + }); + return response.data.albumPageMainInfo as Album; + }); } function judgeTrue(str, ...validStrings) { @@ -85,7 +50,11 @@ export const route: Route = { path: ['/:type/:id/:all/:shownote?'], categories: ['multimedia'], example: '/ximalaya/album/299146', - parameters: { type: '专辑类型, 通常可以使用 `album`,可在对应专辑页面的 URL 中找到', id: '专辑 id, 可在对应专辑页面的 URL 中找到', all: '是否需要获取全部节目,填入 `1`、`true`、`all` 视为获取所有节目,填入其他则不获取。' }, + parameters: { + type: '专辑类型, 通常可以使用 `album`,可在对应专辑页面的 URL 中找到', + id: '专辑 id, 可在对应专辑页面的 URL 中找到', + all: '是否需要获取全部节目,填入 `1`、`true`、`all` 视为获取所有节目,填入其他则不获取。', + }, features: { requireConfig: [ { @@ -118,28 +87,22 @@ async function handler(ctx) { const shouldShowNote = judgeTrue(ctx.req.param('shownote'), 'shownote'); const pageSize = shouldAll ? 200 : 30; - const albumData = await parseAlbumData(type, id); + const albumData = await getAlbumData(id); - const { albumPageMainInfo, currentCategory, anchorInfo } = albumData?.store?.AlbumDetailPage || {}; + const isPaid = albumData.isPaid; - const isPaid = albumPageMainInfo.isPaid; + const author = albumData.anchorName; - const authorInfo = anchorInfo; // 作者数据 - const author = authorInfo.nickName; + const albumTitle = albumData.albumTitle; // 专辑标题 + const albumCover = 'https:' + albumData.cover; + const albumIntro = sanitizeHtml(albumData.detailRichIntro, { allowedTags: [], allowedAttributes: {} }); // 专辑介绍 - const albumInfo = albumPageMainInfo; // 专辑数据 - const albumTitle = albumInfo.albumTitle; // 专辑标题 - const albumCover = 'http:' + albumInfo.cover.split('!')[0]; - const albumIntro = albumInfo.detailRichIntro; // 专辑介绍 - - const categoryInfo = currentCategory; - const albumCategory = categoryInfo?.categoryTitle || albumPageMainInfo.categoryTitle; // 专辑分类名字 - const albumUrl = '/' + (categoryInfo?.categoryPinyin || type) + '/' + id + '/'; // 没有 categoryPinyin 这个字段了,用 type 兼容一下 + const albumCategory = albumData.categoryTitle; // 专辑分类名字 // sort 为 1 时是降序 // const isAsc = albumData.store.AlbumDetailTrackList.sort === 0; // 喜马拉雅的 API 的 query 参数 isAsc=0 时才是升序,不写就是降序。 - const trackInfoApi = `http://mobile.ximalaya.com/mobile/v1/album/track/?albumId=${id}&pageSize=${pageSize}&pageId=`; + const trackInfoApi = `https://mobile.ximalaya.com/mobile/v1/album/track/?albumId=${id}&pageSize=${pageSize}&pageId=`; const trackInfoResponse = await ofetch(trackInfoApi + '1', { parseResponse: JSON.parse, }); @@ -165,16 +128,15 @@ async function handler(ctx) { await Promise.all( playList.map(async (item) => { - const link = baseUrl + albumUrl + item.trackId; - item.desc = await cache.tryGet('ximalaya:' + shouldShowNote.toString() + 'trackRichInfo' + link, async () => { - let _desc; + item.desc = await cache.tryGet(`ximalaya:trackRichInfo:${item.trackId}:${shouldShowNote.toString()}`, async () => { + let _desc: string = ''; if (shouldShowNote) { const trackRichInfoApi = `https://mobile.ximalaya.com/mobile-track/richIntro?trackId=${item.trackId}`; const trackRichInfoResponse = await ofetch(trackRichInfoApi); _desc = trackRichInfoResponse.richIntro; } if (!_desc) { - _desc = `在网页中查看`; + _desc = item.intro; } return _desc; }); @@ -182,8 +144,8 @@ async function handler(ctx) { ); const token = config.ximalaya.token; - const randomToken = getRandom16(8) + '-' + getRandom16(4) + '-' + getRandom16(4) + '-' + getRandom16(4) + '-' + getRandom16(12); if (isPaid && token) { + const randomToken = getRandom16(8) + '-' + getRandom16(4) + '-' + getRandom16(4) + '-' + getRandom16(4) + '-' + getRandom16(12); await Promise.all( playList.map(async (item) => { const timestamp = Math.floor(Date.now()); @@ -217,8 +179,8 @@ async function handler(ctx) { const resultItems = playList.map((item) => { const title = item.title; const trackId = item.trackId; - const itunesItemImage = item.coverLarge ?? albumCover; - const link = baseUrl + albumUrl + trackId; + const itunesItemImage = item.coverLarge.split('!')[0] ?? albumCover; + const link = `${baseUrl}/sound/${trackId}`; const pubDate = parseDate(item.createdAt, 'x'); const duration = item.duration; // 时间长度:单位(秒) const enclosureUrl = item.playPathAacv224 || item.playPathAacv164; @@ -250,7 +212,7 @@ async function handler(ctx) { return { title: albumTitle, - link: baseUrl + albumUrl, + link: `${baseUrl}/${type}/${id}`, description: albumIntro, image: albumCover, itunes_author: author, diff --git a/lib/routes/ximalaya/types.ts b/lib/routes/ximalaya/types.ts index c6606d7b79..ad4b1a0d82 100644 --- a/lib/routes/ximalaya/types.ts +++ b/lib/routes/ximalaya/types.ts @@ -66,3 +66,51 @@ export interface RichIntro { ret: number; richIntro: string; } + +interface SubscriptInfo { + albumSubscriptValue: number; + url: string; +} + +export interface Album { + anchorUid: number; + albumStatus: number; + showApplyFinishBtn: boolean; + showEditBtn: boolean; + showTrackManagerBtn: boolean; + showInformBtn: boolean; + cover: string; + albumTitle: string; + updateDate: string; + createDate: string; + playCount: number; + isPaid: boolean; + isFinished: number; + isSubscribe: boolean; + richIntro: string; + shortIntro: string; + detailRichIntro: string; + isPublic: boolean; + hasBuy: boolean; + vipType: number; + canCopyText: boolean; + subscribeCount: number; + sellingPoint: object; + personalDescription: string; + bigshotRecommend: string; + outline: string; + customTitle: string; + produceTeam: string; + recommendReason: string; + subscriptInfo: SubscriptInfo; + albumSubscript: number; + tags: string[]; + categoryId: number; + ximiVipFreeType: number; + joinXimi: boolean; + freeExpiredTime: number; + categoryTitle: string; + anchorName: string; + albumSeoTitle: string; + visibleStatus: number; +} diff --git a/lib/routes/ximalaya/utils.ts b/lib/routes/ximalaya/utils.ts index 65c42df6be..ba03271e67 100644 --- a/lib/routes/ximalaya/utils.ts +++ b/lib/routes/ximalaya/utils.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +/* const getParams = (ep) => { const a1 = 'xkt3a41psizxrh9l'; const a = [ @@ -119,6 +120,7 @@ const getUrl = (r) => { const url = `https://audiopay.cos.xmcdn.com/download/${r.apiVersion}/${getPath(r.seed, r.fileId)}?${paramsArray.join('&')}`; return url; }; +*/ const getRandom16 = (len) => crypto @@ -139,28 +141,28 @@ const decryptUrl = (encryptedUrl) => { const a = [204, 53, 135, 197, 39, 73, 58, 160, 79, 24, 12, 83, 180, 250, 101, 60, 206, 30, 10, 227, 36, 95, 161, 16, 135, 150, 235, 116, 242, 116, 165, 171]; const padding = '='.repeat((4 - (encryptedUrl.length % 4)) % 4); - const encrypted_data = Buffer.from(encryptedUrl.replace('_', '/').replace('-', '+') + padding, 'base64'); - if (encrypted_data.length < 16) { + const encryptedData = Buffer.from(encryptedUrl.replace('_', '/').replace('-', '+') + padding, 'base64'); + if (encryptedData.length < 16) { return encryptedUrl; } - const data = encrypted_data.slice(0, -16); - const iv = encrypted_data.slice(-16); + const data = encryptedData.subarray(0, -16); + const iv = encryptedData.subarray(-16); const decryptedData = new Uint8Array(data); for (let i = 0; i < decryptedData.length; i++) { decryptedData[i] = o[decryptedData[i]]; } for (let i = 0; i < decryptedData.length; i += 16) { - const block = decryptedData.slice(i, i + 16); + const block = decryptedData.subarray(i, i + 16); for (const [j, element] of block.entries()) { decryptedData[i + j] = element ^ iv[j]; } } for (let i = 0; i < decryptedData.length; i += 32) { - const block = decryptedData.slice(i, i + 32); + const block = decryptedData.subarray(i, i + 32); for (const [j, element] of block.entries()) { decryptedData[i + j] = element ^ a[j]; } } return Buffer.from(decryptedData).toString('utf8'); }; -export { getUrl, getRandom16, decryptUrl }; +export { /* getUrl, */ getRandom16, decryptUrl };