Files
NewTube/server/providers/rumble.mjs
T
bruno 4e44d85717
CI / build-and-test (push) Canceled after 0s
fix(rumble): le lien de nav /videos n'est plus pris pour une vidéo
Le scan générique acceptait tout href /^\/v/ dont le regex d'id
matchait : `/videos` donnait `videoId: "videos"` — une fausse carte en
tête du flux /shorts (pré-validée puis retirée, mais visible le temps
du résolve). Probe live : 32/32 vrais ids rumble portent au moins un
chiffre, seul /videos n'en a pas. Garde « >=1 chiffre » dans l'id,
plafond documenté (un id sans chiffre ferait sauter sa carte).

Effet de bord heureux : la branche /video/123 (isLegacy) était morte
depuis toujours — le regex capturait « video » avant elle — elle
redevient accessible.

Tests : lien /videos ajouté au fixture du flux, 64 assertions vertes.
2026-10-02 08:57:41 -04:00

833 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { load } from 'cheerio';
import { spawn } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
/**
* Rumble provider.
*
* Context (2026-09): rumble.com sits behind Cloudflare. Server-side fetches from
* Node/OpenSSL receive 403 "Just a moment..." challenges on /search/* paths —
* the TLS (JA3) fingerprint is rejected regardless of headers. Verified working
* bypasses:
* - curl built against Windows Schannel (dev machine only, not portable);
* - python curl_cffi with browser impersonation (works on Linux/Docker).
*
* Fetch strategy, best-effort in order:
* 1. Node fetch with a full browser header set + shared __cf_bm cookie jar
* (works on networks where CF does not challenge this fingerprint);
* 2. python3 + curl_cffi helper (rumble_fetch.py) impersonating Chrome —
* the reliable path inside the Docker image (curl_cffi installed there);
* 3. if everything is challenged, return [] — the unified search already
* degrades gracefully per-provider.
*/
const BROWSER_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'sec-ch-ua': '"Chromium";v="131", "Not_A Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'document',
'sec-fetch-mode': 'navigate',
'sec-fetch-site': 'none',
'sec-fetch-user': '?1',
'upgrade-insecure-requests': '1',
};
// One shared cookie jar: Cloudflare issues __cf_bm on the first 200 and expects
// it on subsequent requests. Refreshed lazily every ~25 minutes.
let cookieJar = null;
let cookieJarAt = 0;
const COOKIE_JAR_TTL_MS = 25 * 60 * 1000;
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
const PYTHON_HELPER = path.join(MODULE_DIR, 'rumble_fetch.py');
const PYTHON_HELPER_EXISTS = fs.existsSync(PYTHON_HELPER);
function mergeSetCookies(existing, setCookieHeaders) {
if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return existing;
const jar = new Map();
for (const pair of String(existing || '').split(';').map(s => s.trim()).filter(Boolean)) {
const idx = pair.indexOf('=');
if (idx > 0) jar.set(pair.slice(0, idx), pair.slice(idx + 1));
}
for (const raw of setCookieHeaders) {
const first = String(raw || '').split(';')[0].trim();
const idx = first.indexOf('=');
if (idx > 0) jar.set(first.slice(0, idx), first.slice(idx + 1));
}
return Array.from(jar.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
}
function headersWithCookies() {
const headers = { ...BROWSER_HEADERS };
if (cookieJar && (Date.now() - cookieJarAt) < COOKIE_JAR_TTL_MS) {
headers['Cookie'] = cookieJar;
}
return headers;
}
/** Node fetch with cookie-jar bookkeeping. Returns {status, html}. */
async function nodeFetch(url, { timeoutMs = 12_000 } = {}) {
const controller = new AbortController();
const tid = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { headers: headersWithCookies(), redirect: 'follow', signal: controller.signal });
try {
const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
if (setCookies.length > 0) {
cookieJar = mergeSetCookies(cookieJar, setCookies);
cookieJarAt = Date.now();
}
} catch {}
const html = await res.text();
return { status: res.status, html };
} finally {
clearTimeout(tid);
}
}
/** python3 + curl_cffi helper. Returns {status, html} or null when unavailable. */
async function pythonFetch(url, { timeoutMs = 20_000 } = {}) {
if (!PYTHON_HELPER_EXISTS) return null;
const pythons = ['python3', 'python'];
for (const py of pythons) {
const result = await new Promise((resolve) => {
let settled = false;
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
const child = spawn(py, [PYTHON_HELPER, url, String(Math.ceil(timeoutMs / 1000))], {
stdio: ['ignore', 'pipe', 'ignore'],
});
let stdout = '';
let failed = false;
const timer = setTimeout(() => { failed = true; try { child.kill(); } catch {} done(null); }, timeoutMs + 5_000);
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.on('error', () => { clearTimeout(timer); done(null); });
child.on('close', (code) => {
clearTimeout(timer);
if (failed) return;
done(code === 0 && stdout.length > 1000 ? { status: 200, html: stdout } : null);
});
});
if (result) return result;
}
return null;
}
/**
* Cooldown global après un échec total de fetch (phase « ménage Rumble »).
* Sans lui, chaque route (/browse, /shorts, /video, /search) repaie le cycle
* complet Node (12 s max) + spawns python (~20 s) pendant TOUT un blocage
* Cloudflare — c'était 12-20 s × N à chaque page chargée, alors que le
* negative cache ne couvrait que les clés de recherche de l'unifiée.
* ponytail: cooldown fixe 60 s, pas de backoff exponentiel — si le trafic
* monte : daemon python persistant + backoff.
*/
const FETCH_COOLDOWN_MS = Number(process.env.RUMBLE_FETCH_COOLDOWN_MS || 60_000);
let fetchCooldownUntil = 0;
export function armRumbleFetchCooldown(ms = FETCH_COOLDOWN_MS) {
fetchCooldownUntil = Date.now() + (Number(ms) > 0 ? Number(ms) : FETCH_COOLDOWN_MS);
}
export function resetRumbleFetchCooldown() { fetchCooldownUntil = 0; }
/** Node fetch first, python/curl_cffi fallback. */
async function fetchHtml(url) {
if (Date.now() < fetchCooldownUntil) return null;
try {
const r = await nodeFetch(url);
// Toute réponse NETTE non-challenge est retournée telle quelle (200, mais
// aussi 404 « ressource absente » : rumble.com répond 404 avec une vraie
// page, cf. probe container). Le statut est jugé par les appelants — armer
// le cooldown sur un 404 empoisonnerait le circuit breaker 60 s pour tous.
if (r.status && !isChallenge(r.html)) return r;
} catch { /* fallback */ }
const py = await pythonFetch(url);
if (py) return py;
armRumbleFetchCooldown(); // échec total : quasi certainement un blocage CF
return null;
}
function isChallenge(html) {
return /Just a moment|challenge-platform|cf-chl/i.test(String(html || '').slice(0, 4000));
}
/**
* Phase 3.3 — cache négatif court.
* Sans lui, chaque recherche Rumble déclenchait 2 fetch Node + 2 fetch Python
* (12-20 s) pour finir sur un challenge Cloudflare, à chaque frappe utilisateur.
* TTL volontairement court (5 min) : un challenge se lève vite, et on ne veut
* pas figer une panne de plus de quelques minutes.
* @type {Map<string, number>}
*/
const NEGATIVE_CACHE_TTL_MS = Number(process.env.RUMBLE_NEGATIVE_CACHE_TTL_MS || 5 * 60 * 1000);
const negativeCache = new Map();
function negativeCacheKey(q, page) { return `search:vide:${page}:${String(q || '').trim().toLowerCase()}`; }
function isNegativelyCached(key) {
const ts = negativeCache.get(key);
if (!ts) return false;
if ((Date.now() - ts) >= NEGATIVE_CACHE_TTL_MS) { negativeCache.delete(key); return false; }
return true;
}
function markNegative(key) {
// Garde-fou mémoire : le cache ne doit pas grossir sans borne.
if (negativeCache.size >= 200) {
for (const [k, ts] of negativeCache) {
if ((Date.now() - ts) >= NEGATIVE_CACHE_TTL_MS) negativeCache.delete(k);
if (negativeCache.size < 150) break;
}
}
negativeCache.set(key, Date.now());
}
export function resetRumbleNegativeCache() { negativeCache.clear(); }
/* --------------------------------- parsing -------------------------------- */
function parseDurationToSeconds(raw) {
if (raw == null) return undefined;
const value = String(raw).trim();
if (!value) return undefined;
// Garde-fou : les attributs datetime (dates de publication, ex.
// "2026-09-23T18:11:52-04:00") ne sont JAMAIS des durées — sans ce test,
// l'extraction des chiffres+':' produit des durées absurdes (~351840 s).
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return undefined;
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
// Rumble expose parfois des durées en millisecondes sur les cartes de
// recherche (ex. 351840 ≈ 5:52). Au-delà de ~27 h en "secondes", c'est
// quasi certainement des ms : on convertit (sans impact sur le filtre
// Shorts, car >100 s reste une vidéo dans tous les cas).
if (/^\d+(\.\d+)?$/.test(value) && numeric > 100000) return Math.floor(numeric / 1000);
return Math.floor(numeric);
}
const isoMatch = value.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i);
if (isoMatch) {
const total = (Number(isoMatch[1] || 0) * 3600) + (Number(isoMatch[2] || 0) * 60) + Number(isoMatch[3] || 0);
if (total > 0) return total;
}
const textMatch = value.match(/^(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:utes)?)?)?\s*(?:(\d+)\s*s(?:ec(?:onds)?)?)?$/i);
if (textMatch && (textMatch[1] || textMatch[2] || textMatch[3])) {
const total = (Number(textMatch[1] || 0) * 3600) + (Number(textMatch[2] || 0) * 60) + Number(textMatch[3] || 0);
if (total > 0) return total;
}
const colonCandidate = value.replace(/[^0-9:]/g, '');
if (colonCandidate.includes(':')) {
const segments = colonCandidate.split(':').filter(Boolean).map(s => Number(s));
if (segments.length >= 2 && segments.every(n => Number.isFinite(n))) {
while (segments.length < 3) segments.unshift(0);
const [h, m, s] = segments.slice(-3);
const total = (h * 3600) + (m * 60) + s;
if (total > 0) return total;
}
}
return undefined;
}
function normalizeThumb(raw) {
const t = String(raw || '').replace(/\s+/g, '').trim();
if (!t) return undefined;
return t.startsWith('//') ? `https:${t}` : t;
}
/**
* Compteur de vues localized : "1,2K views", "1.2M views", "3 456", "12,345".
* L'ancien code faisait `.replace(/[^\d]/g, '')` : le suffixe disparaissait et
* "1,2K" devenait 12 (au lieu de 1 200). Les vues étaient donc affichées
* fausses sur la quasi-totalité des cartes Rumble au-delà de 999.
*/
export function parseRumbleViews(raw) {
const s = String(raw ?? '').trim();
if (!s) return undefined;
const m = s.match(/([\d][\d\s\u00a0.,]*)\s*([KMBkmb])?/);
if (!m) return undefined;
// 1 234 -> "1234" ; 1,2 / 1.2 -> "1.2" (décimal) ; 1,234 -> "1234" (milliers)
let digits = m[1].replace(/[\s\u00a0]/g, '');
if (/^\d{1,3},\d{3}$/.test(digits) || /^\d{1,3}\.\d{3}$/.test(digits)) digits = digits.replace(/[.,]/g, '');
else digits = digits.replace(',', '.');
const num = Number(digits);
if (!Number.isFinite(num) || num < 0) return undefined;
const suffix = (m[2] || '').toLowerCase();
const mult = suffix === 'b' ? 1e9 : suffix === 'm' ? 1e6 : suffix === 'k' ? 1e3 : 1;
const total = Math.round(num * mult);
return total > 0 ? total : undefined;
}
/** Clé de rapprochement stable : dernier segment d'URL, sans extension ni tracking. */
function urlKey(u) {
const s = String(u || '').split('?')[0].replace(/\.html$/i, '').replace(/\/$/, '');
return (s.split('/').filter(Boolean).pop() || '').toLowerCase();
}
/**
* Phase 3.1 — extraction du JSON-LD (`application/ld+json`).
* Plus stable que le DOM : quand le balisage change, le JSON-LD reste. On
* accepte `VideoObject`, `ItemList` et `@graph`, et on n'indexe QUE ce qui est
* explicitement présent (aucune valeur déduite).
* @returns {Map<string, { publishedAt?: string, views?: number, thumbnail?: string, duration?: number, channelId?: string }>}
*/
export function parseJsonLd(html) {
/** @type {Map<string, any>} */
const out = new Map();
const re = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(String(html || ''))) !== null) {
let data;
try { data = JSON.parse(m[1].trim()); } catch { continue; }
const nodes = Array.isArray(data) ? data
: Array.isArray(data?.['@graph']) ? data['@graph']
: data ? [data] : [];
for (const node of nodes) visit(node, out, 0);
}
return out;
}
function visit(node, out, depth) {
if (!node || typeof node !== 'object' || depth > 4) return;
const list = Array.isArray(node['@graph'])
? node['@graph']
: (Array.isArray(node.itemListElement) ? node.itemListElement.map((e) => e?.item ?? e) : null);
if (list) { for (const c of list) visit(c, out, depth + 1); }
const type = String(node['@type'] || '').toLowerCase();
if (!type.includes('video')) return;
const key = urlKey(node.url || node.embedUrl || node.contentUrl || node['@id']);
if (!key) return;
const stats = node.interactionStatistic;
const statList = Array.isArray(stats) ? stats : (stats ? [stats] : []);
let views;
for (const st of statList) {
const t = String(st?.interactionType || '').toLowerCase();
if (!t.includes('watch') && !t.includes('view')) continue;
const n = Number(st?.userInteractionCount);
if (Number.isFinite(n) && n >= 0) { views = Math.round(n); break; }
}
const dateRaw = node.uploadDate || node.datePublished;
const parsed = dateRaw ? Date.parse(dateRaw) : NaN;
const publishedAt = Number.isFinite(parsed) && parsed > 0 ? new Date(parsed).toISOString() : undefined;
const duration = parseDurationToSeconds(node.duration);
const thumbRaw = Array.isArray(node.thumbnailUrl)
? node.thumbnailUrl[0]
: (typeof node.thumbnailUrl === 'string' ? node.thumbnailUrl : node.thumbnailUrl?.url);
const authorUrl = typeof node.author === 'string' ? node.author : node.author?.url;
const channelId = (String(authorUrl || '').match(/\/c\/([^/?#]+)/i)?.[1] || '').trim() || undefined;
out.set(key, {
...(publishedAt ? { publishedAt } : {}),
...(views !== undefined && views > 0 ? { views } : {}),
...(duration !== undefined ? { duration } : {}),
...(thumbRaw ? { thumbnail: normalizeThumb(thumbRaw) } : {}),
...(channelId ? { channelId } : {}),
});
}
/** Classic parser: li.video-listing-entry cards. */
export function parseSearchHtml(html, { limit = 50 } = {}) {
const $ = load(html);
const items = [];
$('li.video-listing-entry').each((_idx, el) => {
if (items.length >= limit) return false;
const $el = $(el);
const anchor = ($el.find('a.video-item--a').attr('href') || '').trim();
if (!anchor) return;
const img = $el.find('img.video-item--img');
const rawThumbnail = img.attr('data-src') || img.attr('data-original') || img.attr('src') || '';
const title = $el.find('h3.video-item--title').text().replace(/\s+/g, ' ').trim();
const uploaderName = $el.find('.ellipsis-1').text().replace(/\s+/g, ' ').trim();
// Strip tracking params (e9s, sci, …) from the anchor for a canonical URL
const cleanAnchor = anchor.startsWith('http')
? anchor
: `https://rumble.com${anchor.split('?')[0]}`;
const url = cleanAnchor;
const id = $el.attr('data-id')
|| (url.split('/').filter(Boolean).pop() || '').replace(/\.html$/, '')
|| String(Math.random());
// Rumble expose la durée dans <span class="video-item--duration" data-value="3:06:42">
// (le span n'a pas de contenu texte). L'attribut datetime des <time> est
// une DATE de publication — jamais une durée (cf. garde-fou ci-dessus).
const durEl = $el.find('.video-item--duration, .video-item--meta time, .video-item--meta .duration').first();
const durationCandidates = [
$el.attr('data-duration'),
$el.attr('data-video-duration'),
$el.find('[data-duration]').attr('data-duration'),
durEl.attr('data-value'),
durEl.attr('data-duration'),
durEl.attr('title'),
durEl.text(),
];
let durationSeconds;
for (const candidate of durationCandidates) {
const parsed = parseDurationToSeconds(candidate);
if (typeof parsed === 'number' && parsed > 0) { durationSeconds = parsed; break; }
}
const viewsText = $el.find('.video-item--views').first().text().trim();
const views = parseRumbleViews(viewsText);
// Phase 1.4 - date de publication. L'attribut `datetime` du <time> est la
// vraie date ISO ; son texte est un libelle relatif ("2 months ago") que
// Date.parse() ne sait pas lire, d'ou l'absence historique de publishedAt.
const timeEl = $el.find('.video-item--meta time, time.video-item--date, time').first();
const publishedRaw = (timeEl.attr('datetime') || timeEl.attr('data-datetime') || '').trim();
const publishedMs = publishedRaw ? Date.parse(publishedRaw) : NaN;
const publishedAt = Number.isFinite(publishedMs) && publishedMs > 0
? new Date(publishedMs).toISOString()
: undefined;
// Phase 1.5 - identifiant de chaine. L'URL porte soit /c/<username>/,
// soit (en fallback) le sous-domaine ; l'avatar est sur l'image du by-line.
const channelLink = $el.find('a.video-item--channel-link, a[href*="/c/"]').first();
const channelHref = (channelLink.attr('href') || '').trim();
const channelId = (channelHref.match(/\/c\/([^/?#]+)/i)?.[1] || '').trim() || undefined;
const avatarEl = $el.find('img.video-item--channel-thumb, .video-item--by-line img, a.video-item--channel-link img').first();
const uploaderAvatar = normalizeThumb(avatarEl.attr('src') || avatarEl.attr('data-src') || '');
items.push({
title: title || url,
id,
url,
thumbnail: normalizeThumb(rawThumbnail),
uploaderName: uploaderName || undefined,
// Phase 1.4 / 1.5 / 1.6 - les trois champs etaient presents dans le HTML
// mais jamais extraits, ce qui vidait la page chaine de sa date, de son
// lien "voir la chaine" et de son avatar.
publishedAt,
channelId,
uploaderAvatar,
views,
type: 'video',
duration: durationSeconds,
// Pas de flag maison ici : le filtre central (isShortItem,
// durée ≤75 s) tranche. Le scraper HTML n'expose pas de dimensions.
});
});
patchItemsFromJsonLd(items, html);
return items;
}
/* ================= Cœur partagé (routeur /api/rumble + recherche) ========= */
/**
* Erreur typée : un échec Cloudflare n'est JAMAIS un résultat vide (phase 3.4).
* `status` pilote le code HTTP côté routeur : 503 = « bloqué, à réessayer »,
* 404 = « réellement introuvable » (le seul cas où le flux Shorts retire une
* vidéo, cf. watch-short.component.ts).
*/
function rumbleFail(code, status, { challenged = true } = {}) {
return Object.assign(new Error(code), { code, status, rumbleChallenged: challenged });
}
/**
* Statut HTTP rumble.com -> erreur typée, partagé par /video, /browse et
* /shorts : 403 = blocage (mêmes mots-clés que les erreurs de recherche =>
* même bandeau « bloqué temporairement »), 404/410 = ressource réellement
* absente (404 côté Shorts = retrait du flux, c'est le contrat), 5xx = amont
* cassé. Exporté pour que ce contrat soit testable sans réseau.
*/
export function rumbleFailureForStatus(status) {
if (status === 403) return rumbleFail('rumble_cloudflare_challenge', 503);
if (status === 404 || status === 410) return rumbleFail('rumble_not_found', 404, { challenged: false });
return rumbleFail('rumble_upstream_error', 503, { challenged: false });
}
/**
* Surcouche JSON-LD partagée (phase 3.1) : complète ce que le DOM n'expose pas
* (date de publication, vues, auteur) SANS jamais écraser une valeur réellement
* présente dans la liste. Le DOM reste la source primaire, le JSON-LD le filet.
* @param {Array<any>} items items au format Suggestion (mutés en place)
* @param {string} html page d'origine (pour y relire les blocs ld+json)
*/
function patchItemsFromJsonLd(items, html) {
try {
const ld = parseJsonLd(html);
if (ld.size === 0) return items;
for (const it of items) {
const patch = ld.get(urlKey(it.url)) || ld.get(urlKey(it.id));
if (!patch) continue;
if (it.publishedAt === undefined && patch.publishedAt) it.publishedAt = patch.publishedAt;
if (it.views === undefined && patch.views) it.views = patch.views;
if (it.duration === undefined && patch.duration) it.duration = patch.duration;
if (it.thumbnail === undefined && patch.thumbnail) it.thumbnail = patch.thumbnail;
if (it.channelId === undefined && patch.channelId) it.channelId = patch.channelId;
}
} catch { /* la surcouche est un bonus, jamais un motif d'échec */ }
return items;
}
/* ------------------------- Utils: normalisation ID ------------------------ */
/**
* Rumble expose plusieurs formes:
* - Page canoniques: https://rumble.com/v6siqxf-some-title.html
* - Ancienne forme: https://rumble.com/video/12345
* - URL d’embed officielle: https://rumble.com/embed/v6siqxf/
* - ID brut attendu: v6siqxf (toujours commence par 'v' + base62)
*
* Accepte ID ou URL et renvoie { id: 'vXXXX', urlCanonique, embedUrl }
* (id null + urlCanonique pour l'ancienne forme /video/123 : la page porte
* le vrai identifiant, c'est `extractVideoIdentity` qui le lira.)
*/
export function normalizeRumbleId(input) {
if (!input) return null;
// 1) Si on nous donne déjà un ID "vXXXX"
const clean = String(input).trim();
if (/^v[0-9A-Za-z]+$/.test(clean)) {
return { id: clean, urlCanonique: `https://rumble.com/${clean}`, embedUrl: `https://rumble.com/embed/${clean}/` };
}
// 2) Si on nous donne une URL
try {
const u = new URL(clean, 'https://rumble.com');
// /embed/vXXXX/
let m = /\/embed\/(v[0-9A-Za-z]+)/.exec(u.pathname);
if (!m) m = /\/(v[0-9A-Za-z]+)(?:[-/.]|$)/.exec(u.pathname);
if (!m) {
// ancienne forme /video/123 → on ne peut pas convertir de manière fiable
if (/\/video\/([0-9A-Za-z]+)/.test(u.pathname)) return { id: null, urlCanonique: u.href, embedUrl: null };
return null;
}
const id = m[1];
return { id, urlCanonique: `https://rumble.com/${id}`, embedUrl: `https://rumble.com/embed/${id}/` };
} catch {
return null;
}
}
/* ---------------------- Parsing robuste d’une PAGE vidéo ------------------ */
/**
* Source d’autorité pour le vrai ID: le JS inline:
* Rumble("play", {..., "video":"vXXXX", ...})
* Puis fallback: <meta property="og:video"> (souvent /embed/vXXXX/),
* puis <link rel="canonical"> / <meta property="og:url"> (contenant /vXXXX-...).
*/
function extractVideoIdentity($) {
// 1) Script "Rumble('play', {... "video":"vXXXX" ...})" — regex, jamais d'exécution.
const html = $.html() || '';
const m = /Rumble\(\s*["']play["']\s*,\s*{[^}]*["']video["']\s*:\s*["'](v[0-9A-Za-z]+)["']/s.exec(html);
if (m && m[1]) {
const id = m[1];
return { id, embedUrl: `https://rumble.com/embed/${id}/`, urlCanonique: `https://rumble.com/${id}` };
}
// 2) og:video → .../embed/vXXXX/...
let embed = $('meta[property="og:video"]').attr('content')
|| $('meta[name="twitter:player"]').attr('content');
if (embed) {
if (embed.startsWith('//')) embed = 'https:' + embed;
const mm = /\/embed\/(v[0-9A-Za-z]+)/.exec(embed);
if (mm) {
const id = mm[1];
return { id, embedUrl: `https://rumble.com/embed/${id}/`, urlCanonique: `https://rumble.com/${id}` };
}
}
// 3) Canonical / og:url → .../vXXXX-...
const canon = $('link[rel="canonical"]').attr('href')
|| $('meta[property="og:url"]').attr('content');
if (canon) {
const mm = /\/(v[0-9A-Za-z]+)(?:[-/.]|$)/.exec(canon);
if (mm) {
const id = mm[1];
return { id, embedUrl: `https://rumble.com/embed/${id}/`, urlCanonique: `https://rumble.com/${id}` };
}
}
return null;
}
/* -------------------------- Scraper d’une vidéo -------------------------- */
/**
* Accepte un id (vXXXX), une URL canonique ou l'ancienne forme /video/123.
*
* Contrat d'erreur (ménage Rumble) :
* - fetch total échoué / cooldown CF → throw 503 `rumble_cloudflare_challenge`.
* Un blocage ne doit JAMAIS se transformer en 404 : le 404 fait retirer la
* vidéo du flux Shorts (« introuvable de façon certaine »), alors qu'un
* challenge Cloudflare ne dit rien sur la vidéo.
* - page reçue mais identité introuvable → throw 404 `rumble_video_not_found`.
*/
export async function scrapeRumbleVideo(videoIdOrUrl) {
const norm = normalizeRumbleId(videoIdOrUrl);
const fetchUrl = norm?.urlCanonique || `https://rumble.com/${videoIdOrUrl}`;
const r = await fetchHtml(fetchUrl);
if (!r || isChallenge(r.html)) throw rumbleFail('rumble_cloudflare_challenge', 503);
if (r.status !== 200) throw rumbleFailureForStatus(r.status);
const $ = load(r.html);
const ident = extractVideoIdentity($);
if (!ident?.id) throw rumbleFail('rumble_video_not_found', 404, { challenged: false });
const title =
$('h1.video-title, .video-title h1').first().text().trim()
|| $('meta[property="og:title"]').attr('content') || 'Untitled Video';
let thumbnail = $('meta[property="og:image"]').attr('content') || '';
if (thumbnail && thumbnail.startsWith('//')) thumbnail = 'https:' + thumbnail;
const uploaderName =
$('.media-by--a, .channel-name, a[href*="/c/"]').first().text().trim() || '';
// Vues : parseRumbleViews (K/M/B, espaces insécables). L'ancien parseur
// `.replace(/[^\d]/g,'')` lisait « 1,2K » en 12 ; undefined = absent, et le
// front conserve alors le compteur venu de la liste.
const viewsText =
$('.rumbles-views, .video-views, .media-view-count, [data-view-count]').first().text().trim() || '';
const views = parseRumbleViews(viewsText);
const duration = parseInt($('meta[property="video:duration"]').attr('content') || '', 10) || 0;
const uploadedDate =
$('meta[property="article:published_time"]').attr('content')
|| $('time[datetime]').attr('datetime') || '';
const description =
$('meta[property="og:description"]').attr('content')
|| $('meta[name="description"]').attr('content') || '';
return {
videoId: ident.id,
title,
thumbnail,
uploaderName,
views,
duration,
uploadedDate,
description,
url: ident.urlCanonique,
embedUrl: ident.embedUrl, // toujours la forme officielle /embed/vXXXX/
type: 'video',
};
}
/* ------------------- Scraper de liste (browse / shorts) ------------------ */
/**
* Scan générique de cartes, pour les pages qui ne sont PAS une recherche
* (/videos?sort=… et le flux natif /shorts, dont les liens sont /shorts/vXXXX).
* Aucune structure de balises n'est supposée : on part des liens /vXXXX et on
* remonte au conteneur le plus proche — c'est ce qui rend ces pages lisibles
* quand le markup diffère de `li.video-listing-entry`. La recherche passe par
* `parseSearchHtml` (structurel + JSON-LD), via le registre.
*
* @param {string} html
* @param {{ mode?: 'browse'|'shorts' }} [opts]
* @returns {Array<any>} items non bornés (le dédoublonnage et la limite sont
* appliqués par `scrapeRumbleList`, pour ne jamais couper avant dédup).
*/
export function parseCardsHtml(html, { mode = 'browse' } = {}) {
const $ = load(html);
const isShortsMode = mode === 'shorts';
const found = [];
const linkSelector = isShortsMode
? 'a[href*="/shorts/v"], a[href^="/v"], a[href^="/video/"]'
: 'a[href^="/v"], a[href^="/video/"]';
$(linkSelector).each((_idx, el) => {
const href = ($(el).attr('href') || '').split('?')[0];
// On préfère STRICTEMENT l’ID /vXXXX (aussi après /shorts/).
// ≥1 chiffre exigé : les vrais ids en portent toujours (probe 2026-10 :
// 32/32 sur /shorts + /videos), alors que les liens de nav (`/videos`)
// passeraient le regex et sortiraient une fausse carte « videoId: videos ».
// La branche /video/123 (isLegacy) ne devient accessible que grâce à ça :
// sans ce garde-fou, le regex capturait 'video' et l'ID s'échappait jamais.
// ponytail: si Rumble émettait un id sans chiffre, sa carte est sautée.
const m = /\/(v[0-9A-Za-z]+)(?:[-/.]|$)/.exec(href);
const id = m && /[0-9]/.test(m[1]) ? m[1] : null;
// Fallback /video/123 : on ne convertit pas ici, /video/:id normalise par parse de page.
const isLegacy = !id && /^\/video\//.test(href);
if (!id && !isLegacy) return;
const card = $(el).closest('li, article, .video-listing-entry, .video-item, .video-card, div');
// Titre : préférer le vrai titre de la carte (h3/h2) au texte brut du lien
// (le lien contient souvent des badges "N watching" / durées qui polluaient le titre).
const cardTitle = card.find('h3, h2, .video-item--title').first().text().replace(/\s+/g, ' ').trim();
const linkTitle = ($(el).attr('title') || '').replace(/\s+/g, ' ').trim();
const linkText = $(el).text().replace(/\s+/g, ' ').trim();
let title = cardTitle || linkTitle || linkText;
// Filtrer les faux titres (badges viewers, durées seules, chaînes vides)
if (!title || /^\d+\s+watching$/i.test(title) || /^[0-9:.,\s]+$/.test(title) || title.length < 3) {
// Dernier recours : attribut alt de l'image de la carte
const alt = (card.find('img').first().attr('alt') || '').replace(/\s+/g, ' ').trim();
if (alt && alt.length >= 3 && !/^\d+\s+watching$/i.test(alt)) title = alt;
else return; // ignorer cette carte (badge live, doublon de lien, …)
}
const thumb = normalizeThumb(card.find('img').first().attr('data-src') || card.find('img').first().attr('src') || '');
// Durée : data-value (motif Rumble <span class="video-item--duration" data-value="3:06:42">),
// data-duration/time/aria-label/title/texte, puis motif HH:MM(:SS) dans le HTML de la carte.
const durationElement = card.find(
'.video-item--duration, .video-duration, .duration, .video-item__duration, ' +
'[data-duration], .videoDuration, .video-time, .time, ' +
'.video-card__duration, .media__duration, .thumb-time, ' +
'.video-listing-entry__duration, .video-item__duration, time'
).first();
const durationCandidates = [];
if (durationElement.length) {
durationCandidates.push(
durationElement.attr('data-value'),
durationElement.attr('data-duration'),
durationElement.attr('data-time'),
durationElement.attr('aria-label'),
durationElement.attr('title'),
durationElement.text()?.trim()
);
}
try {
const htmlSnippet = card.html() || '';
const match = />\s*([0-9]+:[0-9]{2}(?::[0-9]{2})?)\s*</.exec(htmlSnippet);
if (match && match[1]) durationCandidates.push(match[1]);
} catch { /* snippet illisible → on garde les candidats déjà collectés */ }
let durationSeconds;
for (const candidate of durationCandidates) {
const parsed = parseDurationToSeconds(candidate);
if (typeof parsed === 'number' && parsed > 0) { durationSeconds = parsed; break; }
}
// Vues : parseRumbleViews gère « 1,2K », « 3 456 », les espaces insécables.
// L'ancien parseur du routeur faisait .replace(/[^\d]/g,'') → « 1,2K » = 12.
const viewsText =
card.find('.video-item--views, .rumbles-views, .views, .video-item__views, [data-views]').first()
.attr('data-views') ||
card.find('.video-item--views, .rumbles-views, .views, .video-item__views, .video-views').first().text().trim();
const views = parseRumbleViews(viewsText);
const uploaderName = card.find('.video-item--channel, .channel-name, a[href^="/c/"], a[href^="/user/"]').first().text().replace(/\s+/g, ' ').trim() || undefined;
// On renvoie TOUJOURS une URL canonique cohérente.
let videoId = null;
let url = null;
if (id) {
videoId = id;
url = `https://rumble.com/${id}`;
} else if (isLegacy) {
videoId = href.replace(/^\//, ''); // "video/123..."
url = `https://rumble.com/${videoId}`;
}
found.push({
videoId,
title,
thumbnail: thumb,
uploaderName,
views,
duration: durationSeconds,
// Indicateur natif pour le front. En mode "shorts", tout ce qui sort du
// flux rumble.com/shorts (vertical ≤ 90 s) est un Short, même si le badge
// de durée est absent du HTML (la durée reste la source de vérité).
// `== null` : une durée absente vaut undefined (contrat « jamais 0 ») et
// doit rester acceptée par le flux Shorts, comme l'ancien 0 le faisait.
isShort: isShortsMode ? (durationSeconds == null || durationSeconds <= 90) : (durationSeconds > 0 && durationSeconds <= 75),
url,
type: 'video',
});
});
// Dédoublonnage (un lien peut apparaître plusieurs fois dans une carte).
const seen = new Set();
const unique = [];
for (const it of found) {
const key = it.videoId;
if (!key || seen.has(key)) continue;
seen.add(key);
unique.push(it);
}
// Le front /api/rumble/* lit `id` en priorité (mapRumbleItemToVideo) ; on
// garde `videoId` en clé secondaire pour l'ancien merge des Shorts.
for (const it of unique) it.id = it.videoId;
patchItemsFromJsonLd(unique, html);
return unique;
}
/**
* Pages liste hors recherche : /videos?sort=… (browse) et /shorts.
* Envelope { items, total, page, limit, nextCursor } identique à l'ancien
* routeur. Un blocage CF → throw 503 (le front dégrade en `items: []`,
* le bandeau « bloqué temporairement » vient de /api/search).
*/
export async function scrapeRumbleList({ page = 1, limit = 24, sort = 'viral', mode = 'browse' } = {}) {
const pageNum = Math.max(1, Number(page) || 1);
const limitNum = Math.min(50, Math.max(1, Number(limit) || 24));
const url = mode === 'shorts'
? `https://rumble.com/shorts?page=${pageNum}`
: `https://rumble.com/videos?sort=${encodeURIComponent(String(sort))}&page=${pageNum}`;
const r = await fetchHtml(url);
if (!r || isChallenge(r.html)) throw rumbleFail('rumble_cloudflare_challenge', 503);
if (r.status !== 200) throw rumbleFailureForStatus(r.status);
const items = parseCardsHtml(r.html, { mode });
const list = items.slice(0, limitNum);
return {
items: list,
total: items.length,
page: pageNum,
limit: limitNum,
nextCursor: list.length === limitNum ? String(pageNum + 1) : null,
};
}
/* --------------------------------- handler -------------------------------- */
const handler = {
id: 'ru',
label: 'Rumble',
/**
* @param {string} q
* @param {{ limit: number, page?: number, sort?: string }} opts
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10, page = 1 } = opts || {};
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
const pageNum = Math.max(1, Number(page || 1));
const query = String(q || '').trim();
if (!query) return [];
// Phase 3.3 — short-circuit si l'échec vient d'être constaté.
const cacheKey = negativeCacheKey(query, pageNum);
if (isNegativelyCached(cacheKey)) {
// Phase 3.4 — un échec est une ERREUR, pas un résultat vide : le front
// peut ainsi afficher un bandeau au lieu d'un « aucun résultat » trompeur.
throw Object.assign(new Error('rumble_cloudflare_challenge'), {
code: 'rumble_cloudflare_challenge', rumbleChallenged: true,
});
}
// --- Attempt 1: canonical search page (Node fetch, then python/curl_cffi) ---
try {
const params = new URLSearchParams({ q: query });
if (pageNum > 1) params.set('page', String(pageNum));
const r = await fetchHtml(`https://rumble.com/search/video?${params.toString()}`);
if (r?.status === 200 && !isChallenge(r.html)) {
const items = parseSearchHtml(r.html, { limit: perPage });
if (items.length > 0) return items;
}
} catch { /* next */ }
// --- Attempt 2: search/all (all-types page, video facet included) ---
try {
const params = new URLSearchParams({ 'search-videos': '1', q: query });
if (pageNum > 1) params.set('page', String(pageNum));
const r = await fetchHtml(`https://rumble.com/search/all?${params.toString()}`);
if (r?.status === 200 && !isChallenge(r.html)) {
const items = parseSearchHtml(r.html, { limit: perPage });
if (items.length > 0) return items;
}
} catch { /* give up */ }
// Phase 3.4 — on distingue « échec » de « vide ». Une recherche Rumble qui
// renvoie 0 résultat à travers deux chemins est presque toujours un blocage
// Cloudflare, pas une absence de contenu : on le remonte, on met en cache
// négatif, et le groupe ne passe pas pour un recherche normale sans résultat.
markNegative(cacheKey);
throw Object.assign(new Error('rumble_unavailable'), {
code: 'rumble_unavailable', rumbleChallenged: true,
});
},
};
export default handler;