fix(route/ximalaya): fix isPaid (#19586)

* wip

* refactor(utils): comment out unused getUrl function and optimize buffer handling
This commit is contained in:
Tony
2025-07-12 01:11:39 +08:00
committed by GitHub
parent 66d1df4f90
commit 3e0880f244
3 changed files with 89 additions and 77 deletions
+32 -70
View File
@@ -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<TrackInfoResponse>(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<RichIntro>(trackRichInfoApi);
_desc = trackRichInfoResponse.richIntro;
}
if (!_desc) {
_desc = `<a href="${link}">在网页中查看</a>`;
_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,
+48
View File
@@ -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;
}
+9 -7
View File
@@ -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 };