CI / build-and-test (push) Canceled after 0s
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.
833 lines
36 KiB
JavaScript
833 lines
36 KiB
JavaScript
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;
|