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; } /** Node fetch first, python/curl_cffi fallback. */ async function fetchHtml(url) { try { const r = await nodeFetch(url); if (r.status === 200 && !isChallenge(r.html)) return r; } catch { /* fallback */ } const py = await pythonFetch(url); if (py) return py; 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} */ 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} */ export function parseJsonLd(html) { /** @type {Map} */ const out = new Map(); const re = /]+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 // (le span n'a pas de contenu texte). L'attribut datetime des