Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/routes/ivoox/namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Namespace } from '@/types';

export const namespace: Namespace = {
name: 'iVoox',
url: 'www.ivoox.com',
categories: ['multimedia'],
lang: 'es',
};
205 changes: 205 additions & 0 deletions lib/routes/ivoox/podcast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { load } from 'cheerio';
import { decodeHTML } from 'entities';

import InvalidParameterError from '@/errors/types/invalid-parameter';
import type { Data, DataItem, Route } from '@/types';
import { ViewType } from '@/types';
import cache from '@/utils/cache';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';

const rootUrl = 'https://www.ivoox.com';
const itunesAuthorSelector = String.raw`itunes\:author`;
const itunesCategorySelector = String.raw`itunes\:category`;
const itunesDurationSelector = String.raw`itunes\:duration`;
const itunesExplicitSelector = String.raw`itunes\:explicit`;
Comment thread
guillevc marked this conversation as resolved.
Outdated
const itunesImageSelector = String.raw`itunes\:image`;

export const route: Route = {
path: '/podcast/:id',
categories: ['multimedia'],
example: '/ivoox/podcast/11178419',
parameters: {
id: 'Podcast ID, can be found in the iVoox podcast URL after `_sq_f`, for example `11178419` or `f11178419`',
},
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportRadar: true,
supportBT: false,
supportPodcast: true,
supportScihub: false,
},
radar: [
{
source: ['www.ivoox.com/podcast-*_sq_f:id_1.html', 'www.ivoox.com/en/podcast-*_sq_f:id_1.html', 'www.ivoox.com/*_sq_f:id_1.html', 'www.ivoox.com/en/*_sq_f:id_1.html'],
target: (params) => `/podcast/${params.id}`,
},
],
name: 'Podcast',
url: 'www.ivoox.com',
maintainers: ['guillevc'],
handler,
description: 'Transforms an iVoox podcast page into an RSS feed that exposes the full episode audio enclosures instead of the short clip feed.',
view: ViewType.Audios,
};

async function handler(ctx): Promise<Data> {
const id = normalizePodcastId(ctx.req.param('id'));
Comment thread
guillevc marked this conversation as resolved.
Outdated
const limit = Number.parseInt(ctx.req.query('limit') ?? '10', 10);
const feedUrl = `${rootUrl}/feed_fg_f${id}_filtro_1.xml`;
const response = await ofetch(feedUrl, {
Comment thread
TonyRL marked this conversation as resolved.
Outdated
parseResponse: (txt) => txt,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why specify the default option https://github.com/unjs/ofetch/blob/dfbe3ca4ef8a22fc023fca5a5ef530e525f5e523/src/fetch.ts#L230 again? Does the request fail if you don't specify the default option again?

Copy link
Copy Markdown
Author

@guillevc guillevc Jun 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried removing it... ofetch returns a blob for application/xml content types, not raw text, so cheerio can't parse it. There's actually a comment in uber/blog.ts:49 documenting the same behavior: "Without this, ofetch will parse the response as a blob instead of text, which cannot be loaded by cheerio". Keeping it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image Does this mean that cheerio on my PC is better than yours since it can parse it without any issues?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. The feed returns content-type: text/html, so ofetch already handles it as plain text. My previous reply was wrong. Removed parseResponse in the latest commit.

});

const $ = load(response, { xmlMode: true });
const channel = $('channel').first();
Comment thread
guillevc marked this conversation as resolved.
Outdated
if (!channel.length) {
throw new Error(`Invalid iVoox podcast feed for ID ${id}`);
}

const items = (
await Promise.all(
channel
.children('item')
.toArray()
.slice(0, Number.isNaN(limit) ? 10 : limit)
.map(async (element): Promise<DataItem | undefined> => {
const itemElement = $(element);
const enclosure = itemElement.children('enclosure').first();
Comment thread
guillevc marked this conversation as resolved.
Outdated
const enclosureUrl = enclosure.attr('url');
const itemId = normalizeEpisodeId(childText(itemElement, 'guid') || childText(itemElement, 'link'));
Comment thread
guillevc marked this conversation as resolved.
Outdated
Comment thread
guillevc marked this conversation as resolved.
Outdated
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since https://github.com/DIYgod/RSSHub/pull/22072/changes#r3342448784 is marked as resolved by you, could you explain how is it resolved?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for not explaining. The intermediate variable was inlined in commit c8f7c2c5e, normalizeEpisodeId(...) is now called directly in the const itemId assignment.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you show me where is commit c8f7c2c5e in https://github.com/DIYgod/RSSHub/pull/22072/commits?

Comment thread
TonyRL marked this conversation as resolved.
Outdated

if (!enclosureUrl) {
return;
}

const title = childText(itemElement, 'title');
const image = childAttr(itemElement, itunesImageSelector, 'href');
const length = parseOptionalInteger(enclosure.attr('length'));
const resolvedEnclosureUrl = itemId ? await resolveEpisodeAudioUrl(itemId, enclosureUrl, childText(itemElement, 'link')) : enclosureUrl;
Comment thread
TonyRL marked this conversation as resolved.

return {
title,
description: childText(itemElement, 'description') || undefined,
link: childText(itemElement, 'link') || undefined,
Comment thread
TonyRL marked this conversation as resolved.
pubDate: parseOptionalDate(childText(itemElement, 'pubDate')),
Comment thread
guillevc marked this conversation as resolved.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since https://github.com/DIYgod/RSSHub/pull/22072/changes#r3343163538 is marked as resolved by you, could you explain how is it resolved?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inlined in the same commit c8f7c2c5e. Sorry for resolving without an explanation.

guid: childText(itemElement, 'guid') || undefined,
Comment thread
TonyRL marked this conversation as resolved.
enclosure_url: resolvedEnclosureUrl,
enclosure_type: enclosure.attr('type') || mediaTypeFromUrl(resolvedEnclosureUrl),
enclosure_title: title,
...(length === undefined ? {} : { enclosure_length: length }),
itunes_duration: childText(itemElement, itunesDurationSelector) || undefined,
Comment thread
guillevc marked this conversation as resolved.
Outdated
itunes_item_image: image,
};
})
)
).filter((current): current is DataItem => current !== undefined);

return {
title: childText(channel, 'title'),
description: childText(channel, 'description') || undefined,
link: childText(channel, 'link') || rootUrl,
Comment thread
guillevc marked this conversation as resolved.
Comment thread
TonyRL marked this conversation as resolved.
item: items,
image: childText(channel.children('image').first(), 'url') || childAttr(channel, itunesImageSelector, 'href'),
itunes_image: childAttr(channel, itunesImageSelector, 'href') || childText(channel.children('image').first(), 'url') || undefined,
language: normalizeLanguage(childText(channel, 'language')),
Comment thread
guillevc marked this conversation as resolved.
Outdated
feedLink: feedUrl,
itunes_author: childText(channel, itunesAuthorSelector) || undefined,
Comment thread
guillevc marked this conversation as resolved.
Outdated
itunes_category: childAttr(channel, itunesCategorySelector, 'text'),
itunes_explicit: childText(channel, itunesExplicitSelector) || undefined,
Comment thread
guillevc marked this conversation as resolved.
Outdated
};
}

function normalizePodcastId(id: string): string {
const match = /^f?(\d+)$/i.exec(id);
if (!match) {
throw new InvalidParameterError(`Invalid iVoox podcast ID: ${id}`);
}

return match[1];
}

function normalizeEpisodeId(value: string): string | undefined {
const match = /(?:_rf_|\/)(\d+)(?:_\d+)?(?:\.html)?/i.exec(value) ?? /(\d+)/.exec(value);
return match?.[1];
}

function resolveEpisodeAudioUrl(audioId: string, fallbackUrl: string, referer: string): Promise<string> {
return cache.tryGet(`ivoox:audio-url:${audioId}`, async () => {
try {
const response = await ofetch(`https://vcore-web.ivoox.com/v1/public/audios/${audioId}/download-url`);
const downloadUrl = response?.data?.downloadUrl;
if (typeof downloadUrl === 'string' && downloadUrl) {
const audioResponse = await ofetch.raw(new URL(downloadUrl, rootUrl).href, {
headers: {
Referer: referer,
},
redirect: 'manual',
method: 'GET',
});

if (audioResponse.status >= 300 && audioResponse.status < 400) {
const location = audioResponse.headers.get('location');
if (location) {
return new URL(location, rootUrl).href;
}
}

if (audioResponse.url) {
return audioResponse.url;
}

return new URL(downloadUrl, rootUrl).href;
}
} catch {
// Fall back to the original feed enclosure when iVoox does not return a direct URL.
}

return fallbackUrl;
});
}

function childText(element, selector: string): string {
return decodeHTML(element.children(selector).first().text());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use decodeHTML with xmlMode

const $ = load(response, { xmlMode: true });

Copy link
Copy Markdown
Author

@guillevc guillevc Jun 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

xmlMode only decodes the 5 standard XML entities (&amp;, &lt;, &gt;, &quot;, &apos;). RSS feeds commonly include HTML named entities like &nbsp; or &mdash; in text fields( technically invalid XML but very common in practice. decodeHTML handles those cases)

}

function childAttr(element, selector: string, attribute: string): string | undefined {
return element.children(selector).first().attr(attribute);
}

function parseOptionalDate(value: string): Date | undefined {
return value ? parseDate(value) : undefined;
}

function parseOptionalInteger(value: string | undefined): number | undefined {
if (!value) {
return;
}

const number = Number.parseInt(value, 10);
return Number.isNaN(number) ? undefined : number;
}

function normalizeLanguage(value: string): Data['language'] | undefined {
const language = value.toLowerCase();
return language ? ((language === 'es-es' ? 'es' : language) as Data['language']) : undefined;
}

function mediaTypeFromUrl(url: string): string {
const pathname = new URL(url).pathname.toLowerCase();
if (pathname.endsWith('.m4a')) {
return 'audio/mp4';
}

if (pathname.endsWith('.ogg') || pathname.endsWith('.opus')) {
return 'audio/ogg';
}

if (pathname.endsWith('.aac')) {
return 'audio/aac';
}

return 'audio/mpeg';
}
1 change: 1 addition & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export type Data = {
itunes_author?: string;
itunes_category?: string;
itunes_explicit?: string | boolean;
itunes_image?: string;
id?: string;
icon?: string;
logo?: string;
Expand Down
126 changes: 76 additions & 50 deletions lib/views/rss.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,61 +3,87 @@ import type { FC } from 'hono/jsx';
import type { Data } from '@/types';

const RSS: FC<{ data: Data }> = ({ data }) => {
const hasItunes = data.itunes_author || data.itunes_category || (data.item && data.item.some((i) => i.itunes_item_image || i.itunes_duration));
const hasMedia = data.item?.some((i) => i.media);
const isTelegramLink = data.link?.startsWith('https://t.me/s/');
const hasItunes = hasItunesMetadata(data);
const hasMedia = hasMediaMetadata(data);

return (
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:itunes={hasItunes ? 'http://www.itunes.com/dtds/podcast-1.0.dtd' : undefined} xmlns:media={hasMedia ? 'http://search.yahoo.com/mrss/' : undefined} version="2.0">
<channel>
<title>{data.title || 'RSSHub'}</title>
<link>{data.link || 'https://docs.rsshub.app'}</link>
<atom:link href={data.atomlink} rel="self" type="application/rss+xml" />
<description>{data.description || data.title} - Powered by RSSHub</description>
<generator>RSSHub</generator>
<webMaster>contact@rsshub.app (RSSHub)</webMaster>
{data.itunes_author && <itunes:author>{data.itunes_author}</itunes:author>}
{data.itunes_category && <itunes:category text={data.itunes_category} />}
{data.itunes_author && <itunes:explicit>{data.itunes_explicit || 'false'}</itunes:explicit>}
<language>{data.language || 'en'}</language>
{data.image && (
<image>
<url>{data.image}</url>
<title>{data.title || 'RSSHub'}</title>
<link>{data.link}</link>
{isTelegramLink && (
<>
<height>31</height>
<width>88</width>
</>
)}
</image>
)}
<lastBuildDate>{data.lastBuildDate}</lastBuildDate>
<ttl>{data.ttl}</ttl>
{data.item?.map((item) => (
<item>
<title>{item.title}</title>
<description>{item.description}</description>
<link>{item.link}</link>
<guid isPermaLink="false">{item.guid || item.link || item.title}</guid>
{item.pubDate && <pubDate>{item.pubDate}</pubDate>}
{item.author && <author>{item.author}</author>}
{item.image && <enclosure url={item.image} type="image/jpeg" />}
{item.itunes_item_image && <itunes:image href={item.itunes_item_image} />}
{item.enclosure_url && <enclosure url={item.enclosure_url} length={item.enclosure_length} type={item.enclosure_type} />}
{item.itunes_duration && <itunes:duration>{item.itunes_duration}</itunes:duration>}
{typeof item.category === 'string' ? <category>{item.category}</category> : item.category?.map((c) => <category>{c}</category>)}
{item.media &&
Object.entries(item.media).map(([key, value]) => {
const Tag = `media:${key}`;
return <Tag {...value} />;
})}
</item>
))}
</channel>
<channel>{renderChannel(data)}</channel>
</rss>
);
};

function renderChannel(data: Data) {
const isTelegramLink = data.link?.startsWith('https://t.me/s/');

return (
<>
<title>{data.title || 'RSSHub'}</title>
<link>{data.link || 'https://docs.rsshub.app'}</link>
<atom:link href={data.atomlink} rel="self" type="application/rss+xml" />
<description>{data.description || data.title} - Powered by RSSHub</description>
<generator>RSSHub</generator>
<webMaster>contact@rsshub.app (RSSHub)</webMaster>
{data.itunes_author && <itunes:author>{data.itunes_author}</itunes:author>}
{data.itunes_category && <itunes:category text={data.itunes_category} />}
{data.itunes_author && <itunes:explicit>{data.itunes_explicit || 'false'}</itunes:explicit>}
{data.itunes_image && <itunes:image href={data.itunes_image} />}
<language>{data.language || 'en'}</language>
{data.image && <ChannelImage data={data} isTelegramLink={isTelegramLink} />}
<lastBuildDate>{data.lastBuildDate}</lastBuildDate>
<ttl>{data.ttl}</ttl>
{data.item?.map((item) => (
<RSSItem key={item.guid || item.link || item.title} item={item} />
))}
</>
);
}

function ChannelImage({ data, isTelegramLink }: { data: Data; isTelegramLink?: boolean }) {
return (
<image>
<url>{data.image}</url>
<title>{data.title || 'RSSHub'}</title>
<link>{data.link}</link>
{isTelegramLink && (
<>
<height>31</height>
<width>88</width>
</>
)}
</image>
);
}

function RSSItem({ item }) {
return (
<item>
<title>{item.title}</title>
<description>{item.description}</description>
<link>{item.link}</link>
<guid isPermaLink="false">{item.guid || item.link || item.title}</guid>
{item.pubDate && <pubDate>{item.pubDate}</pubDate>}
{item.author && <author>{item.author}</author>}
{item.image && <enclosure url={item.image} type="image/jpeg" />}
{item.itunes_item_image && <itunes:image href={item.itunes_item_image} />}
{item.enclosure_url && <enclosure url={item.enclosure_url} length={item.enclosure_length} type={item.enclosure_type} />}
{item.itunes_duration && <itunes:duration>{item.itunes_duration}</itunes:duration>}
{typeof item.category === 'string' ? <category>{item.category}</category> : item.category?.map((c) => <category>{c}</category>)}
{item.media &&
Object.entries(item.media).map(([key, value]) => {
const Tag = `media:${key}`;
return <Tag {...value} />;
})}
</item>
);
}

function hasItunesMetadata(data: Data): boolean {
return Boolean(data.itunes_author || data.itunes_category || data.itunes_image || (data.item && data.item.some((i) => i.itunes_item_image || i.itunes_duration)));
}

function hasMediaMetadata(data: Data): boolean {
return Boolean(data.item?.some((i) => i.media));
}

Comment thread
guillevc marked this conversation as resolved.
Outdated
export default RSS;
2 changes: 2 additions & 0 deletions lib/views/views.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('RSS view', () => {
itunes_author: 'Podcast Author',
itunes_category: 'Tech',
itunes_explicit: 'true',
itunes_image: 'https://example.com/podcast-itunes.jpg',
image: 'https://example.com/image.jpg',
item: [
{
Expand Down Expand Up @@ -129,6 +130,7 @@ describe('RSS view', () => {
expect(html).toContain('<itunes:author>Podcast Author</itunes:author>');
expect(html).toContain('itunes:category text="Tech"');
expect(html).toContain('<itunes:explicit>true</itunes:explicit>');
expect(html).toContain('<itunes:image href="https://example.com/podcast-itunes.jpg"');
expect(html).toContain('<height>31</height>');
expect(html).toContain('<width>88</width>');
expect(html).toContain('media:content');
Expand Down
Loading