Skip to content

Commit 4e9940d

Browse files
committed
fix(route/rumble): address review comments
1 parent 78f074c commit 4e9940d

File tree

1 file changed

+73
-123
lines changed

1 file changed

+73
-123
lines changed

lib/routes/rumble/channel.ts

Lines changed: 73 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { load } from 'cheerio';
22
import type { Element } from 'domhandler';
3+
import pMap from 'p-map';
34

45
import type { DataItem, Route } from '@/types';
56
import { ViewType } from '@/types';
@@ -8,10 +9,18 @@ import ofetch from '@/utils/ofetch';
89
import { parseDate } from '@/utils/parse-date';
910

1011
const rootUrl = 'https://rumble.com';
12+
type RumbleVideoObject = {
13+
author?: {
14+
name?: string;
15+
};
16+
description?: string;
17+
embedUrl?: string;
18+
thumbnailUrl?: string;
19+
};
1120

1221
export const route: Route = {
1322
path: '/c/:channel/:embed?',
14-
categories: ['social-media'],
23+
categories: ['multimedia'],
1524
view: ViewType.Videos,
1625
name: 'Channel',
1726
maintainers: ['luckycold'],
@@ -39,13 +48,7 @@ export const route: Route = {
3948
};
4049

4150
function parseChannelTitle($: ReturnType<typeof load>): string {
42-
const h1 = $('h1').first().text().trim();
43-
if (h1) {
44-
return h1;
45-
}
46-
47-
const title = $('title').first().text().trim();
48-
return title || 'Rumble';
51+
return $('title').first().text().trim() || 'Rumble';
4952
}
5053

5154
function parseDescription($: ReturnType<typeof load>, fallback: string | undefined): string | undefined {
@@ -58,124 +61,45 @@ function parseDescription($: ReturnType<typeof load>, fallback: string | undefin
5861
return paragraphs || $('meta[name="description"]').attr('content')?.trim() || fallback || undefined;
5962
}
6063

61-
function parseStructuredVideoObject($: ReturnType<typeof load>) {
62-
for (const element of $('script[type="application/ld+json"]').toArray()) {
63-
const content = $(element).text().trim();
64-
if (!content) {
65-
continue;
66-
}
67-
68-
try {
69-
const parsed = JSON.parse(content);
70-
const entries = Array.isArray(parsed) ? parsed : [parsed];
71-
72-
for (const entry of entries) {
73-
if (entry?.['@type'] === 'VideoObject') {
74-
return entry as {
75-
description?: string;
76-
embedUrl?: string;
77-
genre?: string | string[];
78-
keywords?: string | string[];
79-
author?: {
80-
name?: string;
81-
};
82-
thumbnailUrl?: string | string[];
83-
};
84-
}
85-
}
86-
} catch {
87-
continue;
88-
}
64+
function parseStructuredVideoObject($: ReturnType<typeof load>): RumbleVideoObject | undefined {
65+
const content = $('script[type="application/ld+json"]').first().text().trim();
66+
if (!content) {
67+
return;
8968
}
90-
}
91-
92-
function parseImage($: ReturnType<typeof load>, videoObject: ReturnType<typeof parseStructuredVideoObject>) {
93-
const thumbnailUrl = Array.isArray(videoObject?.thumbnailUrl) ? videoObject.thumbnailUrl[0] : videoObject?.thumbnailUrl;
94-
const image = thumbnailUrl || $('meta[property="og:image"]').attr('content')?.trim();
9569

96-
return image ? new URL(image, rootUrl).href : undefined;
70+
try {
71+
const parsed = JSON.parse(content);
72+
const entries = Array.isArray(parsed) ? parsed : [parsed];
73+
return entries.find((entry) => entry?.['@type'] === 'VideoObject') as RumbleVideoObject | undefined;
74+
} catch {
75+
return;
76+
}
9777
}
9878

99-
async function mapLimit<T, R>(values: T[], limit: number, mapper: (value: T, index: number) => Promise<R>) {
100-
const results = Array.from({ length: values.length }) as R[];
101-
let nextIndex = 0;
79+
function parseImage($: ReturnType<typeof load>, videoObject: RumbleVideoObject | undefined) {
80+
const image = videoObject?.thumbnailUrl || $('meta[property="og:image"]').attr('content')?.trim();
10281

103-
const worker = async (): Promise<void> => {
104-
const currentIndex = nextIndex;
105-
nextIndex += 1;
106-
107-
if (currentIndex >= values.length) {
108-
return;
109-
}
110-
111-
results[currentIndex] = await mapper(values[currentIndex], currentIndex);
112-
await worker();
113-
};
114-
115-
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, () => worker()));
116-
117-
return results;
82+
return image ? new URL(image, rootUrl).href : undefined;
11883
}
11984

12085
function renderDescription(image: string | undefined, description: string | undefined, embedUrl: string | undefined, includeEmbed: boolean): string | undefined {
121-
const parts: string[] = [];
86+
let descriptionHtml = '';
12287

12388
if (includeEmbed && embedUrl) {
124-
parts.push(`<iframe src="${embedUrl}" width="640" height="360" frameborder="0" allowfullscreen></iframe>`);
89+
descriptionHtml += `<iframe src="${embedUrl}" width="640" height="360" frameborder="0" allowfullscreen></iframe>`;
12590
} else if (image) {
126-
parts.push(`<p><img src="${image}"></p>`);
91+
descriptionHtml += `<p><img src="${image}"></p>`;
12792
}
12893

12994
if (description) {
130-
parts.push(description);
95+
descriptionHtml += description;
13196
}
13297

133-
return parts.join('');
134-
}
135-
136-
function fetchVideoDetails(link: string) {
137-
return cache.tryGet(link, async () => {
138-
const response = await ofetch(link, {
139-
retryStatusCodes: [403],
140-
});
141-
142-
const $ = load(response);
143-
const videoObject = parseStructuredVideoObject($);
144-
const image = parseImage($, videoObject);
145-
146-
return {
147-
author: videoObject?.author?.name || $('.channel-header--title').first().text().trim() || undefined,
148-
description: parseDescription($, videoObject?.description?.trim()),
149-
embedUrl: videoObject?.embedUrl,
150-
image,
151-
};
152-
});
98+
return descriptionHtml || undefined;
15399
}
154100

155-
async function parseItemFromVideoElement($: ReturnType<typeof load>, videoElement: Element, includeEmbed: boolean): Promise<DataItem | null> {
156-
const $video = $(videoElement);
157-
const $link = $video.find('.title__link[href], .videostream__link[href]').first();
158-
const href = $link.attr('href')?.trim();
159-
if (!href) {
160-
return null;
161-
}
162-
163-
const url = new URL(href, rootUrl);
164-
url.search = '';
165-
url.hash = '';
166-
167-
const $title = $video.find('.thumbnail__title').first();
168-
const title = $title.attr('title')?.trim() || $title.text().trim() || url.pathname;
169-
170-
const $img = $video.find('img.thumbnail__image, .thumbnail__thumb img').first();
171-
const imageRaw = $img.attr('src') || $img.attr('data-src');
172-
const listImage = imageRaw ? new URL(imageRaw, rootUrl).href : undefined;
173-
const pubDateRaw = $video.find('time.videostream__time[datetime], time[datetime]').first().attr('datetime')?.trim();
174-
const pubDate = pubDateRaw ? parseDate(pubDateRaw) : undefined;
175-
const details = await fetchVideoDetails(url.href);
176-
const image = listImage || details.image;
177-
178-
const media = image
101+
function getMedia(image: string | undefined): DataItem['media'] {
102+
return image
179103
? {
180104
thumbnail: {
181105
url: image,
@@ -186,17 +110,27 @@ async function parseItemFromVideoElement($: ReturnType<typeof load>, videoElemen
186110
},
187111
}
188112
: undefined;
113+
}
189114

190-
const description = renderDescription(image, details.description, details.embedUrl, includeEmbed);
115+
async function buildItem(link: string, title: string, listImage: string | undefined, pubDate: Date | undefined, includeEmbed: boolean): Promise<DataItem> {
116+
const response = await ofetch(link, {
117+
retryStatusCodes: [403],
118+
});
119+
120+
const $ = load(response);
121+
const videoObject = parseStructuredVideoObject($);
122+
const image = listImage || parseImage($, videoObject);
123+
const description = renderDescription(image, parseDescription($, videoObject?.description?.trim()), videoObject?.embedUrl, includeEmbed);
124+
const author = videoObject?.author?.name || $('.channel-header--title').first().text().trim() || undefined;
191125

192126
return {
193127
title,
194-
author: details.author,
128+
author,
195129
image,
196-
link: url.href,
130+
link,
197131
description,
198132
itunes_item_image: image,
199-
media,
133+
media: getMedia(image),
200134
pubDate,
201135
};
202136
}
@@ -215,19 +149,35 @@ async function handler(ctx) {
215149

216150
const title = parseChannelTitle($);
217151

218-
const uniqueIds = new Set<string>();
219-
const videoElements = $('.channel-listing__container .videostream[data-video-id], .videostream.thumbnail__grid--item[data-video-id]')
220-
.toArray()
221-
.filter((element) => {
222-
const videoId = $(element).attr('data-video-id')?.trim();
223-
if (!videoId || uniqueIds.has(videoId)) {
224-
return false;
152+
const videoElements = $('.videostream.thumbnail__grid--item[data-video-id]').toArray();
153+
const items = await pMap(
154+
videoElements,
155+
(element: Element) => {
156+
const $video = $(element);
157+
const $link = $video.find('.videostream__link[href]').first();
158+
const href = $link.attr('href')?.trim();
159+
if (!href) {
160+
return null;
161+
}
162+
163+
const url = new URL(href, rootUrl);
164+
url.search = '';
165+
166+
const $title = $video.find('.thumbnail__title').first();
167+
const title = $title.attr('title')?.trim() || $title.text().trim();
168+
if (!title) {
169+
return null;
225170
}
226171

227-
uniqueIds.add(videoId);
228-
return true;
229-
});
230-
const items = await mapLimit(videoElements, 5, (element) => parseItemFromVideoElement($, element, includeEmbed));
172+
const imageRaw = $video.find('img.thumbnail__image, .thumbnail__thumb img').first().attr('src');
173+
const listImage = imageRaw ? new URL(imageRaw, rootUrl).href : undefined;
174+
const pubDateRaw = $video.find('time.videostream__time[datetime]').first().attr('datetime')?.trim();
175+
const pubDate = pubDateRaw ? parseDate(pubDateRaw) : undefined;
176+
177+
return cache.tryGet(`${url.href}:${includeEmbed ? 'embed' : 'noembed'}`, () => buildItem(url.href, title, listImage, pubDate, includeEmbed));
178+
},
179+
{ concurrency: 5 }
180+
);
231181

232182
return {
233183
title: `Rumble - ${title}`,

0 commit comments

Comments
 (0)