refactor(rumble): un seul cœur de scraping, routeur fin, contrat 503 CF
CI / build-and-test (push) Successful in 14m57s
CI / build-and-test (push) Successful in 14m57s
- server/providers/rumble.mjs devient l'unique implémentation : fetch (cookie jar __cf_bm + curl_cffi + cooldown global 60 s après un échec total), parsing liste/vidéo avec surcouche JSON-LD partagée, vues via parseRumbleViews partout (« 1,2K » n'est plus lu en 12 sur /api/rumble). - server/rumble.mjs = couche HTTP mince : limiter 20/min, cache 60 s, 5 routes qui délèguent au cœur ; /search passe par le registre de providers (cache SQLite, negative cache et channelRef partagés avec /api/search). - Contrat d'erreur : un blocage Cloudflare est un 503 rumble_cloudflare_challenge, plus jamais un 404 (qui faisait retirer les vidéos du flux Shorts lors d'un challenge) ; page reçue sans identité = 404 rumble_video_not_found. - Code mort supprimé : scrapeRumbleVideo d'index.mjs (191 l., 3e implémentation jamais appelée), rumbleLimiter 10/min jamais monté, import cheerio devenu inutile. Net -238 lignes. - Tests : parseur générique (vues, durées, ids /shorts/), contrat 503 sans réseau via le cooldown, durées absentes -> undefined. - Docs : API_MCP_GUIDE §5.5 (routes, rate-limit réel, erreurs) + note README « Rumble peut être temporairement absent selon le réseau ».
This commit is contained in:
+355
-13
@@ -117,14 +117,32 @@ async function pythonFetch(url, { timeoutMs = 20_000 } = {}) {
|
||||
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);
|
||||
if (r.status === 200 && !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;
|
||||
}
|
||||
|
||||
@@ -382,26 +400,350 @@ export function parseSearchHtml(html, { limit = 50 } = {}) {
|
||||
// durée ≤75 s) tranche. Le scraper HTML n'expose pas de dimensions.
|
||||
});
|
||||
});
|
||||
// Phase 3.1 — surcouche JSON-LD : 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.
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
|
||||
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/).
|
||||
const m = /\/(v[0-9A-Za-z]+)(?:[-/.]|$)/.exec(href);
|
||||
const id = 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);
|
||||
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user