From b41246de3780355b21699675aa752514c22c533d Mon Sep 17 00:00:00 2001 From: Luke Williams Date: Sat, 28 Mar 2026 14:00:45 -0500 Subject: [PATCH 1/8] feat(route/rumble): add channel feed Add a Rumble channel route so channel videos can be subscribed to through RSSHub. Use stable server-rendered selectors and normalized item URLs to keep feed entries consistent. --- lib/routes/rumble/channel.ts | 128 +++++++++++++++++++++++++++++++++ lib/routes/rumble/namespace.ts | 7 ++ 2 files changed, 135 insertions(+) create mode 100644 lib/routes/rumble/channel.ts create mode 100644 lib/routes/rumble/namespace.ts diff --git a/lib/routes/rumble/channel.ts b/lib/routes/rumble/channel.ts new file mode 100644 index 000000000000..a351e22555a3 --- /dev/null +++ b/lib/routes/rumble/channel.ts @@ -0,0 +1,128 @@ +import { load } from 'cheerio'; +import type { Element } from 'domhandler'; + +import { config } from '@/config'; +import type { DataItem, Route } from '@/types'; +import { ViewType } from '@/types'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +const rootUrl = 'https://rumble.com'; + +export const route: Route = { + path: '/c/:channel', + categories: ['social-media'], + view: ViewType.Videos, + name: 'Channel', + maintainers: ['luckycold'], + example: '/rumble/c/Timcast', + parameters: { + channel: 'Channel slug from `https://rumble.com/c/`', + }, + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: true, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['rumble.com/c/:channel'], + target: '/c/:channel', + }, + ], + handler, +}; + +function parseChannelTitle($: ReturnType): string { + const h1 = $('h1').first().text().trim(); + if (h1) { + return h1; + } + + const title = $('title').first().text().trim(); + return title || 'Rumble'; +} + +function parseItemFromVideoElement($: ReturnType, videoElement: Element): DataItem | null { + const $video = $(videoElement); + const $link = $video.find('.title__link[href], .videostream__link[href]').first(); + const href = $link.attr('href')?.trim(); + if (!href) { + return null; + } + + const url = new URL(href, rootUrl); + url.search = ''; + url.hash = ''; + + const $title = $video.find('.thumbnail__title').first(); + const title = $title.attr('title')?.trim() || $title.text().trim() || url.pathname; + + const $img = $video.find('img.thumbnail__image, .thumbnail__thumb img').first(); + const imageRaw = $img.attr('src') || $img.attr('data-src'); + const image = imageRaw ? new URL(imageRaw, rootUrl).href : undefined; + const pubDateRaw = $video.find('time.videostream__time[datetime], time[datetime]').first().attr('datetime')?.trim(); + const pubDate = pubDateRaw ? parseDate(pubDateRaw) : undefined; + + const media = image + ? { + thumbnail: { + url: image, + }, + content: { + url: image, + medium: 'image', + }, + } + : undefined; + + const description = image ? `

` : undefined; + + return { + title, + link: url.href, + description, + itunes_item_image: image, + media, + pubDate, + }; +} + +async function handler(ctx) { + const channel = ctx.req.param('channel'); + const channelUrl = new URL(`/c/${encodeURIComponent(channel)}`, rootUrl).href; + + const response = await ofetch(channelUrl, { + headers: { + 'user-agent': config.trueUA, + }, + retryStatusCodes: [403], + }); + + const $ = load(response); + + const title = parseChannelTitle($); + + const uniqueIds = new Set(); + const items = $('.channel-listing__container .videostream[data-video-id], .videostream.thumbnail__grid--item[data-video-id]') + .toArray() + .map((element) => { + const videoId = $(element).attr('data-video-id')?.trim(); + if (!videoId || uniqueIds.has(videoId)) { + return null; + } + + uniqueIds.add(videoId); + return parseItemFromVideoElement($, element); + }) + .filter((item): item is DataItem => Boolean(item && item.link)); + + return { + title: `Rumble - ${title}`, + link: channelUrl, + item: items, + }; +} diff --git a/lib/routes/rumble/namespace.ts b/lib/routes/rumble/namespace.ts new file mode 100644 index 000000000000..56d04a2c3eb4 --- /dev/null +++ b/lib/routes/rumble/namespace.ts @@ -0,0 +1,7 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'Rumble', + url: 'rumble.com', + lang: 'en', +}; From 325f4cb29eb81062f1204d60cbdf0422a3572e75 Mon Sep 17 00:00:00 2001 From: Luke Williams Date: Sat, 28 Mar 2026 14:40:25 -0500 Subject: [PATCH 2/8] feat(route/rumble): add embed descriptions Add full Rumble video descriptions to channel feeds and expose an /embed route variant for readers that support iframe players. Keep the default route image-based while deriving stable embed URLs from each video page. --- lib/routes/rumble/channel.ts | 114 ++++++++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 16 deletions(-) diff --git a/lib/routes/rumble/channel.ts b/lib/routes/rumble/channel.ts index a351e22555a3..8ab62ad5e21c 100644 --- a/lib/routes/rumble/channel.ts +++ b/lib/routes/rumble/channel.ts @@ -4,13 +4,14 @@ import type { Element } from 'domhandler'; import { config } from '@/config'; import type { 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://rumble.com'; export const route: Route = { - path: '/c/:channel', + path: ['/c/:channel', '/c/:channel/embed'], categories: ['social-media'], view: ViewType.Videos, name: 'Channel', @@ -19,6 +20,7 @@ export const route: Route = { parameters: { channel: 'Channel slug from `https://rumble.com/c/`', }, + description: 'Append `/embed` to include the Rumble player iframe in each item description.', features: { requireConfig: false, requirePuppeteer: false, @@ -46,7 +48,82 @@ function parseChannelTitle($: ReturnType): string { return title || 'Rumble'; } -function parseItemFromVideoElement($: ReturnType, videoElement: Element): DataItem | null { +function parseDescription($: ReturnType): string | undefined { + const paragraphs = $('div[data-js="media_long_description_container"] > p.media-description') + .toArray() + .map((element) => $.html(element)) + .filter(Boolean) + .join(''); + + return paragraphs || $('meta[name="description"]').attr('content')?.trim() || undefined; +} + +function parseStructuredVideoObject($: ReturnType) { + for (const element of $('script[type="application/ld+json"]').toArray()) { + const content = $(element).text().trim(); + if (!content) { + continue; + } + + try { + const parsed = JSON.parse(content); + const entries = Array.isArray(parsed) ? parsed : [parsed]; + + for (const entry of entries) { + if (entry?.['@type'] === 'VideoObject') { + return entry as { + embedUrl?: string; + author?: { + name?: string; + }; + }; + } + } + } catch { + continue; + } + } +} + +function renderDescription(image: string | undefined, description: string | undefined, embedUrl: string | undefined, includeEmbed: boolean): string | undefined { + const parts: string[] = []; + + if (includeEmbed && embedUrl) { + parts.push(``); + } else if (image) { + parts.push(`

`); + } + + if (description) { + parts.push(description); + } + + return parts.join(''); +} + +function fetchVideoDetails(link: string) { + return cache.tryGet(link, async () => { + const response = await ofetch(link, { + headers: { + 'user-agent': config.trueUA, + }, + retryStatusCodes: [403], + }); + + const $ = load(response); + const videoObject = parseStructuredVideoObject($); + const category = $('.media-by--category a').first().text().trim(); + + return { + author: videoObject?.author?.name || $('.channel-header--title').first().text().trim() || undefined, + category: category || undefined, + description: parseDescription($), + embedUrl: videoObject?.embedUrl, + }; + }); +} + +async function parseItemFromVideoElement($: ReturnType, videoElement: Element, includeEmbed: boolean): Promise { const $video = $(videoElement); const $link = $video.find('.title__link[href], .videostream__link[href]').first(); const href = $link.attr('href')?.trim(); @@ -66,6 +143,7 @@ function parseItemFromVideoElement($: ReturnType, videoElement: Ele const image = imageRaw ? new URL(imageRaw, rootUrl).href : undefined; const pubDateRaw = $video.find('time.videostream__time[datetime], time[datetime]').first().attr('datetime')?.trim(); const pubDate = pubDateRaw ? parseDate(pubDateRaw) : undefined; + const details = await fetchVideoDetails(url.href); const media = image ? { @@ -79,10 +157,12 @@ function parseItemFromVideoElement($: ReturnType, videoElement: Ele } : undefined; - const description = image ? `

` : undefined; + const description = renderDescription(image, details.description, details.embedUrl, includeEmbed); return { title, + author: details.author, + category: details.category ? [details.category] : undefined, link: url.href, description, itunes_item_image: image, @@ -93,6 +173,7 @@ function parseItemFromVideoElement($: ReturnType, videoElement: Ele async function handler(ctx) { const channel = ctx.req.param('channel'); + const includeEmbed = ctx.req.path.endsWith('/embed'); const channelUrl = new URL(`/c/${encodeURIComponent(channel)}`, rootUrl).href; const response = await ofetch(channelUrl, { @@ -107,22 +188,23 @@ async function handler(ctx) { const title = parseChannelTitle($); const uniqueIds = new Set(); - const items = $('.channel-listing__container .videostream[data-video-id], .videostream.thumbnail__grid--item[data-video-id]') - .toArray() - .map((element) => { - const videoId = $(element).attr('data-video-id')?.trim(); - if (!videoId || uniqueIds.has(videoId)) { - return null; - } - - uniqueIds.add(videoId); - return parseItemFromVideoElement($, element); - }) - .filter((item): item is DataItem => Boolean(item && item.link)); + const items = await Promise.all( + $('.channel-listing__container .videostream[data-video-id], .videostream.thumbnail__grid--item[data-video-id]') + .toArray() + .map((element) => { + const videoId = $(element).attr('data-video-id')?.trim(); + if (!videoId || uniqueIds.has(videoId)) { + return null; + } + + uniqueIds.add(videoId); + return parseItemFromVideoElement($, element, includeEmbed); + }) + ); return { title: `Rumble - ${title}`, link: channelUrl, - item: items, + item: items.filter((item): item is DataItem => Boolean(item && item.link)), }; } From fd1d5163e7bb5e3b1d9b931ac3e58b7a15933e2e Mon Sep 17 00:00:00 2001 From: Luke Williams Date: Sat, 28 Mar 2026 14:43:48 -0500 Subject: [PATCH 3/8] fix(route/rumble): align embed option with youtube Match YouTube's optional embed parameter behavior so the default feed includes embedded players and any trailing path segment disables embedding. Keep full Rumble video descriptions in both output modes. --- lib/routes/rumble/channel.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/routes/rumble/channel.ts b/lib/routes/rumble/channel.ts index 8ab62ad5e21c..fdc75327e9b8 100644 --- a/lib/routes/rumble/channel.ts +++ b/lib/routes/rumble/channel.ts @@ -11,7 +11,7 @@ import { parseDate } from '@/utils/parse-date'; const rootUrl = 'https://rumble.com'; export const route: Route = { - path: ['/c/:channel', '/c/:channel/embed'], + path: '/c/:channel/:embed?', categories: ['social-media'], view: ViewType.Videos, name: 'Channel', @@ -19,8 +19,9 @@ export const route: Route = { example: '/rumble/c/Timcast', parameters: { channel: 'Channel slug from `https://rumble.com/c/`', + embed: 'Default to embed the video, set to any value to disable embedding', }, - description: 'Append `/embed` to include the Rumble player iframe in each item description.', + description: 'Fetches full Rumble video descriptions and embeds the player by default.', features: { requireConfig: false, requirePuppeteer: false, @@ -173,7 +174,7 @@ async function parseItemFromVideoElement($: ReturnType, videoElemen async function handler(ctx) { const channel = ctx.req.param('channel'); - const includeEmbed = ctx.req.path.endsWith('/embed'); + const includeEmbed = !ctx.req.param('embed'); const channelUrl = new URL(`/c/${encodeURIComponent(channel)}`, rootUrl).href; const response = await ofetch(channelUrl, { From bd9e634f924a55dd8fbf1f498fc5679ed6a92587 Mon Sep 17 00:00:00 2001 From: Luke Williams Date: Sat, 28 Mar 2026 23:38:32 -0500 Subject: [PATCH 4/8] fix(route/rumble): improve feed metadata Use structured detail-page data for Rumble descriptions and fallback thumbnails while keeping the channel listing on stable server-rendered markup. Limit detail fetch concurrency so the route is less likely to trip anti-bot protections. --- lib/routes/rumble/channel.ts | 70 ++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/lib/routes/rumble/channel.ts b/lib/routes/rumble/channel.ts index fdc75327e9b8..fc300051c8d0 100644 --- a/lib/routes/rumble/channel.ts +++ b/lib/routes/rumble/channel.ts @@ -49,14 +49,14 @@ function parseChannelTitle($: ReturnType): string { return title || 'Rumble'; } -function parseDescription($: ReturnType): string | undefined { +function parseDescription($: ReturnType, fallback: string | undefined): string | undefined { const paragraphs = $('div[data-js="media_long_description_container"] > p.media-description') .toArray() .map((element) => $.html(element)) .filter(Boolean) .join(''); - return paragraphs || $('meta[name="description"]').attr('content')?.trim() || undefined; + return paragraphs || $('meta[name="description"]').attr('content')?.trim() || fallback || undefined; } function parseStructuredVideoObject($: ReturnType) { @@ -73,10 +73,14 @@ function parseStructuredVideoObject($: ReturnType) { for (const entry of entries) { if (entry?.['@type'] === 'VideoObject') { return entry as { + description?: string; embedUrl?: string; + genre?: string | string[]; + keywords?: string | string[]; author?: { name?: string; }; + thumbnailUrl?: string | string[]; }; } } @@ -86,6 +90,34 @@ function parseStructuredVideoObject($: ReturnType) { } } +function parseImage($: ReturnType, videoObject: ReturnType) { + const thumbnailUrl = Array.isArray(videoObject?.thumbnailUrl) ? videoObject.thumbnailUrl[0] : videoObject?.thumbnailUrl; + const image = thumbnailUrl || $('meta[property="og:image"]').attr('content')?.trim(); + + return image ? new URL(image, rootUrl).href : undefined; +} + +async function mapLimit(values: T[], limit: number, mapper: (value: T, index: number) => Promise) { + const results = Array.from({ length: values.length }) as R[]; + let nextIndex = 0; + + const worker = async (): Promise => { + const currentIndex = nextIndex; + nextIndex += 1; + + if (currentIndex >= values.length) { + return; + } + + results[currentIndex] = await mapper(values[currentIndex], currentIndex); + await worker(); + }; + + await Promise.all(Array.from({ length: Math.min(limit, values.length) }, () => worker())); + + return results; +} + function renderDescription(image: string | undefined, description: string | undefined, embedUrl: string | undefined, includeEmbed: boolean): string | undefined { const parts: string[] = []; @@ -113,13 +145,13 @@ function fetchVideoDetails(link: string) { const $ = load(response); const videoObject = parseStructuredVideoObject($); - const category = $('.media-by--category a').first().text().trim(); + const image = parseImage($, videoObject); return { author: videoObject?.author?.name || $('.channel-header--title').first().text().trim() || undefined, - category: category || undefined, - description: parseDescription($), + description: parseDescription($, videoObject?.description?.trim()), embedUrl: videoObject?.embedUrl, + image, }; }); } @@ -141,10 +173,11 @@ async function parseItemFromVideoElement($: ReturnType, videoElemen const $img = $video.find('img.thumbnail__image, .thumbnail__thumb img').first(); const imageRaw = $img.attr('src') || $img.attr('data-src'); - const image = imageRaw ? new URL(imageRaw, rootUrl).href : undefined; + const listImage = imageRaw ? new URL(imageRaw, rootUrl).href : undefined; const pubDateRaw = $video.find('time.videostream__time[datetime], time[datetime]').first().attr('datetime')?.trim(); const pubDate = pubDateRaw ? parseDate(pubDateRaw) : undefined; const details = await fetchVideoDetails(url.href); + const image = listImage || details.image; const media = image ? { @@ -163,7 +196,7 @@ async function parseItemFromVideoElement($: ReturnType, videoElemen return { title, author: details.author, - category: details.category ? [details.category] : undefined, + image, link: url.href, description, itunes_item_image: image, @@ -189,19 +222,18 @@ async function handler(ctx) { const title = parseChannelTitle($); const uniqueIds = new Set(); - const items = await Promise.all( - $('.channel-listing__container .videostream[data-video-id], .videostream.thumbnail__grid--item[data-video-id]') - .toArray() - .map((element) => { - const videoId = $(element).attr('data-video-id')?.trim(); - if (!videoId || uniqueIds.has(videoId)) { - return null; - } + const videoElements = $('.channel-listing__container .videostream[data-video-id], .videostream.thumbnail__grid--item[data-video-id]') + .toArray() + .filter((element) => { + const videoId = $(element).attr('data-video-id')?.trim(); + if (!videoId || uniqueIds.has(videoId)) { + return false; + } - uniqueIds.add(videoId); - return parseItemFromVideoElement($, element, includeEmbed); - }) - ); + uniqueIds.add(videoId); + return true; + }); + const items = await mapLimit(videoElements, 5, (element) => parseItemFromVideoElement($, element, includeEmbed)); return { title: `Rumble - ${title}`, From de10808734e7dc6d236d57e19cd4e2ef72d0c18a Mon Sep 17 00:00:00 2001 From: Luke Williams Date: Sun, 29 Mar 2026 15:46:28 -0500 Subject: [PATCH 5/8] fix(route/rumble): rely on default request headers --- lib/routes/rumble/channel.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/routes/rumble/channel.ts b/lib/routes/rumble/channel.ts index fc300051c8d0..1ecd500e9f9a 100644 --- a/lib/routes/rumble/channel.ts +++ b/lib/routes/rumble/channel.ts @@ -1,7 +1,6 @@ import { load } from 'cheerio'; import type { Element } from 'domhandler'; -import { config } from '@/config'; import type { DataItem, Route } from '@/types'; import { ViewType } from '@/types'; import cache from '@/utils/cache'; @@ -137,9 +136,6 @@ function renderDescription(image: string | undefined, description: string | unde function fetchVideoDetails(link: string) { return cache.tryGet(link, async () => { const response = await ofetch(link, { - headers: { - 'user-agent': config.trueUA, - }, retryStatusCodes: [403], }); @@ -211,9 +207,6 @@ async function handler(ctx) { const channelUrl = new URL(`/c/${encodeURIComponent(channel)}`, rootUrl).href; const response = await ofetch(channelUrl, { - headers: { - 'user-agent': config.trueUA, - }, retryStatusCodes: [403], }); From 023c9fa1672a9821e41d904511d7320ec897a3d6 Mon Sep 17 00:00:00 2001 From: Luke Williams Date: Sun, 29 Mar 2026 15:47:21 -0500 Subject: [PATCH 6/8] fix(route/rumble): update example channel --- lib/routes/rumble/channel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/routes/rumble/channel.ts b/lib/routes/rumble/channel.ts index 1ecd500e9f9a..940a6074f4e5 100644 --- a/lib/routes/rumble/channel.ts +++ b/lib/routes/rumble/channel.ts @@ -15,7 +15,7 @@ export const route: Route = { view: ViewType.Videos, name: 'Channel', maintainers: ['luckycold'], - example: '/rumble/c/Timcast', + example: '/rumble/c/MikhailaPeterson', parameters: { channel: 'Channel slug from `https://rumble.com/c/`', embed: 'Default to embed the video, set to any value to disable embedding', From 78f074cb07a6bd5d2024f875cdc571acbc4e221a Mon Sep 17 00:00:00 2001 From: Luke Williams Date: Mon, 30 Mar 2026 20:49:11 -0500 Subject: [PATCH 7/8] fix(route/rumble): prefer videos tab for channel feed --- lib/routes/rumble/channel.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/routes/rumble/channel.ts b/lib/routes/rumble/channel.ts index 940a6074f4e5..99bce704b28b 100644 --- a/lib/routes/rumble/channel.ts +++ b/lib/routes/rumble/channel.ts @@ -31,7 +31,7 @@ export const route: Route = { }, radar: [ { - source: ['rumble.com/c/:channel'], + source: ['rumble.com/c/:channel', 'rumble.com/c/:channel/videos'], target: '/c/:channel', }, ], @@ -205,8 +205,9 @@ async function handler(ctx) { const channel = ctx.req.param('channel'); const includeEmbed = !ctx.req.param('embed'); const channelUrl = new URL(`/c/${encodeURIComponent(channel)}`, rootUrl).href; + const videosUrl = `${channelUrl}/videos`; - const response = await ofetch(channelUrl, { + const response = await ofetch(videosUrl, { retryStatusCodes: [403], }); @@ -230,7 +231,7 @@ async function handler(ctx) { return { title: `Rumble - ${title}`, - link: channelUrl, + link: videosUrl, item: items.filter((item): item is DataItem => Boolean(item && item.link)), }; } From 9f6c9f5c74b7f89ae2de83b3152a293dec43539c Mon Sep 17 00:00:00 2001 From: Luke Williams Date: Tue, 7 Apr 2026 23:19:21 -0500 Subject: [PATCH 8/8] fix(route/rumble): address review comments --- lib/routes/rumble/channel.ts | 194 +++++++++++++---------------------- 1 file changed, 71 insertions(+), 123 deletions(-) diff --git a/lib/routes/rumble/channel.ts b/lib/routes/rumble/channel.ts index 99bce704b28b..dc830a96fa7b 100644 --- a/lib/routes/rumble/channel.ts +++ b/lib/routes/rumble/channel.ts @@ -1,5 +1,6 @@ import { load } from 'cheerio'; import type { Element } from 'domhandler'; +import pMap from 'p-map'; import type { DataItem, Route } from '@/types'; import { ViewType } from '@/types'; @@ -8,10 +9,18 @@ import ofetch from '@/utils/ofetch'; import { parseDate } from '@/utils/parse-date'; const rootUrl = 'https://rumble.com'; +type RumbleVideoObject = { + author?: { + name?: string; + }; + description?: string; + embedUrl?: string; + thumbnailUrl?: string; +}; export const route: Route = { path: '/c/:channel/:embed?', - categories: ['social-media'], + categories: ['multimedia'], view: ViewType.Videos, name: 'Channel', maintainers: ['luckycold'], @@ -39,13 +48,7 @@ export const route: Route = { }; function parseChannelTitle($: ReturnType): string { - const h1 = $('h1').first().text().trim(); - if (h1) { - return h1; - } - - const title = $('title').first().text().trim(); - return title || 'Rumble'; + return $('title').first().text().trim() || 'Rumble'; } function parseDescription($: ReturnType, fallback: string | undefined): string | undefined { @@ -58,124 +61,45 @@ function parseDescription($: ReturnType, fallback: string | undefin return paragraphs || $('meta[name="description"]').attr('content')?.trim() || fallback || undefined; } -function parseStructuredVideoObject($: ReturnType) { - for (const element of $('script[type="application/ld+json"]').toArray()) { - const content = $(element).text().trim(); - if (!content) { - continue; - } - - try { - const parsed = JSON.parse(content); - const entries = Array.isArray(parsed) ? parsed : [parsed]; - - for (const entry of entries) { - if (entry?.['@type'] === 'VideoObject') { - return entry as { - description?: string; - embedUrl?: string; - genre?: string | string[]; - keywords?: string | string[]; - author?: { - name?: string; - }; - thumbnailUrl?: string | string[]; - }; - } - } - } catch { - continue; - } +function parseStructuredVideoObject($: ReturnType): RumbleVideoObject | undefined { + const content = $('script[type="application/ld+json"]').text().trim(); + if (!content) { + return; } -} - -function parseImage($: ReturnType, videoObject: ReturnType) { - const thumbnailUrl = Array.isArray(videoObject?.thumbnailUrl) ? videoObject.thumbnailUrl[0] : videoObject?.thumbnailUrl; - const image = thumbnailUrl || $('meta[property="og:image"]').attr('content')?.trim(); - return image ? new URL(image, rootUrl).href : undefined; + try { + const parsed = JSON.parse(content); + const type = parsed?.['@type']; + return type === 'VideoObject' || (Array.isArray(type) && type.includes('VideoObject')) ? (parsed as RumbleVideoObject) : undefined; + } catch { + return; + } } -async function mapLimit(values: T[], limit: number, mapper: (value: T, index: number) => Promise) { - const results = Array.from({ length: values.length }) as R[]; - let nextIndex = 0; +function parseImage($: ReturnType, videoObject: RumbleVideoObject | undefined) { + const image = videoObject?.thumbnailUrl || $('meta[property="og:image"]').attr('content')?.trim(); - const worker = async (): Promise => { - const currentIndex = nextIndex; - nextIndex += 1; - - if (currentIndex >= values.length) { - return; - } - - results[currentIndex] = await mapper(values[currentIndex], currentIndex); - await worker(); - }; - - await Promise.all(Array.from({ length: Math.min(limit, values.length) }, () => worker())); - - return results; + return image ? new URL(image, rootUrl).href : undefined; } function renderDescription(image: string | undefined, description: string | undefined, embedUrl: string | undefined, includeEmbed: boolean): string | undefined { - const parts: string[] = []; + let descriptionHtml = ''; if (includeEmbed && embedUrl) { - parts.push(``); + descriptionHtml += ``; } else if (image) { - parts.push(`

`); + descriptionHtml += `

`; } if (description) { - parts.push(description); + descriptionHtml += description; } - return parts.join(''); -} - -function fetchVideoDetails(link: string) { - return cache.tryGet(link, async () => { - const response = await ofetch(link, { - retryStatusCodes: [403], - }); - - const $ = load(response); - const videoObject = parseStructuredVideoObject($); - const image = parseImage($, videoObject); - - return { - author: videoObject?.author?.name || $('.channel-header--title').first().text().trim() || undefined, - description: parseDescription($, videoObject?.description?.trim()), - embedUrl: videoObject?.embedUrl, - image, - }; - }); + return descriptionHtml || undefined; } -async function parseItemFromVideoElement($: ReturnType, videoElement: Element, includeEmbed: boolean): Promise { - const $video = $(videoElement); - const $link = $video.find('.title__link[href], .videostream__link[href]').first(); - const href = $link.attr('href')?.trim(); - if (!href) { - return null; - } - - const url = new URL(href, rootUrl); - url.search = ''; - url.hash = ''; - - const $title = $video.find('.thumbnail__title').first(); - const title = $title.attr('title')?.trim() || $title.text().trim() || url.pathname; - - const $img = $video.find('img.thumbnail__image, .thumbnail__thumb img').first(); - const imageRaw = $img.attr('src') || $img.attr('data-src'); - const listImage = imageRaw ? new URL(imageRaw, rootUrl).href : undefined; - const pubDateRaw = $video.find('time.videostream__time[datetime], time[datetime]').first().attr('datetime')?.trim(); - const pubDate = pubDateRaw ? parseDate(pubDateRaw) : undefined; - const details = await fetchVideoDetails(url.href); - const image = listImage || details.image; - - const media = image +function getMedia(image: string | undefined): DataItem['media'] { + return image ? { thumbnail: { url: image, @@ -186,17 +110,27 @@ async function parseItemFromVideoElement($: ReturnType, videoElemen }, } : undefined; +} - const description = renderDescription(image, details.description, details.embedUrl, includeEmbed); +async function buildItem(link: string, title: string, listImage: string | undefined, pubDate: Date | undefined, includeEmbed: boolean): Promise { + const response = await ofetch(link, { + retryStatusCodes: [403], + }); + + const $ = load(response); + const videoObject = parseStructuredVideoObject($); + const image = listImage || parseImage($, videoObject); + const description = renderDescription(image, parseDescription($, videoObject?.description?.trim()), videoObject?.embedUrl, includeEmbed); + const author = videoObject?.author?.name; return { title, - author: details.author, + author, image, - link: url.href, + link, description, itunes_item_image: image, - media, + media: getMedia(image), pubDate, }; } @@ -215,19 +149,33 @@ async function handler(ctx) { const title = parseChannelTitle($); - const uniqueIds = new Set(); - const videoElements = $('.channel-listing__container .videostream[data-video-id], .videostream.thumbnail__grid--item[data-video-id]') - .toArray() - .filter((element) => { - const videoId = $(element).attr('data-video-id')?.trim(); - if (!videoId || uniqueIds.has(videoId)) { - return false; + const videoElements = $('.videostream.thumbnail__grid--item[data-video-id]').toArray(); + const items = await pMap( + videoElements, + (element: Element) => { + const $video = $(element); + const href = $video.find('.videostream__link[href]').attr('href')?.trim(); + if (!href) { + return null; + } + + const url = new URL(href, rootUrl); + url.search = ''; + + const title = $video.find('.thumbnail__title').text().trim(); + if (!title) { + return null; } - uniqueIds.add(videoId); - return true; - }); - const items = await mapLimit(videoElements, 5, (element) => parseItemFromVideoElement($, element, includeEmbed)); + const imageRaw = $video.find('img.thumbnail__image').attr('src'); + const listImage = imageRaw ? new URL(imageRaw, rootUrl).href : undefined; + const pubDateRaw = $video.find('time.videostream__time[datetime]').attr('datetime')?.trim(); + const pubDate = pubDateRaw ? parseDate(pubDateRaw) : undefined; + + return cache.tryGet(`${url.href}:${includeEmbed ? 'embed' : 'noembed'}`, () => buildItem(url.href, title, listImage, pubDate, includeEmbed)); + }, + { concurrency: 5 } + ); return { title: `Rumble - ${title}`,