From 7fc1e1096d1632db9b0a0e464ae045607b682dfa Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 2 Oct 2026 08:49:44 -0400 Subject: [PATCH] fix(rumble): un HTTP 404 net n'est pas un blocage Cloudflare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probe live depuis le conteneur : rumble.com renvoie 404 (vraie page, non-challenge) pour un id inexistant. fetchHtml les classait en échec total, d'où deux régressions : 503 sur /video (les vidéos mortes ne sont plus retirées des Shorts) et armement du cooldown 60 s pour toute la maison au premier id bogus. - fetchHtml retourne toute réponse nette non-challenge ; le statut est jugé par les appelants (tous dans le même fichier). - rumbleFailureForStatus() exporté : 403 -> 503 rumble_cloudflare_challenge (bandeau identique à la recherche), 404/410 -> 404 rumble_not_found, 5xx -> 503 rumble_upstream_error. /video, /browse et /shorts passent par ce mapping. - handler.search ne parse plus que du HTTP 200 : les pages 404 de Rumble embarquent des cartes de recommandation qui seraient autrement prises pour des résultats de recherche. - Tests : 7 assertions sur le mapping (sans réseau), 64 au total. --- docs/API_MCP_GUIDE.md | 2 +- server/providers/rumble.mjs | 25 ++++++++++++++++++++++--- server/tests/rumble-ld.test.mjs | 12 +++++++++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/API_MCP_GUIDE.md b/docs/API_MCP_GUIDE.md index 241258d..7359489 100644 --- a/docs/API_MCP_GUIDE.md +++ b/docs/API_MCP_GUIDE.md @@ -126,7 +126,7 @@ curl "http://localhost:4000/api/details/youtube/dQw4w9WgXcQ" | jq '{title,upload Tendances YT sans clé (scrape). Phase 1 : `yt` uniquement (`400` sinon), fallback `{ items:[] }` jamais 500. ### 5.5 Rumble dédié (`/api/rumble/…`) -`GET /browse?page=&limit=&sort=`, `GET /shorts?page=&limit=`, `GET /search?q=…&page=|offset=`, `GET /video/:videoId`, `GET /video/:videoId/preplay`. Rate-limit 20/min. Le scraping vit dans `server/providers/rumble.mjs` (cœur partagé avec `/api/search` : cache SQLite, negative cache, cookie jar Cloudflare) ; `/search` passe par le registre de providers. Réponses : `{ items, total, page, limit, nextCursor }` (liste), `{ videoId, title, …, embedUrl }` (vidéo). Erreurs : `503 { error:"rumble_cloudflare_challenge" }` = blocage Cloudflare (cooldown global 60 s côté serveur), `404 { error:"rumble_video_not_found" }` = vidéo réellement absente, `400 { error:"Query parameter required" }` (search sans q). +`GET /browse?page=&limit=&sort=`, `GET /shorts?page=&limit=`, `GET /search?q=…&page=|offset=`, `GET /video/:videoId`, `GET /video/:videoId/preplay`. Rate-limit 20/min. Le scraping vit dans `server/providers/rumble.mjs` (cœur partagé avec `/api/search` : cache SQLite, negative cache, cookie jar Cloudflare) ; `/search` passe par le registre de providers. Réponses : `{ items, total, page, limit, nextCursor }` (liste), `{ videoId, title, …, embedUrl }` (vidéo). Erreurs : `503 { error:"rumble_cloudflare_challenge" }` = blocage Cloudflare (cooldown global 60 s côté serveur), `404 { error:"rumble_not_found"|"rumble_video_not_found" }` = ressource réellement absente (HTTP 404 net côté Rumble, ou page 200 sans identité vidéo), `400 { error:"Query parameter required" }` (search sans q). ## 6. Référence REST — transcript détaillé diff --git a/server/providers/rumble.mjs b/server/providers/rumble.mjs index a02f48e..f3c895b 100644 --- a/server/providers/rumble.mjs +++ b/server/providers/rumble.mjs @@ -138,7 +138,11 @@ 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; + // 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; @@ -416,6 +420,19 @@ 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 @@ -537,6 +554,7 @@ export async function scrapeRumbleVideo(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($); @@ -732,6 +750,7 @@ export async function scrapeRumbleList({ page = 1, limit = 24, sort = 'viral', m : `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); @@ -776,7 +795,7 @@ const handler = { 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 && !isChallenge(r.html)) { + if (r?.status === 200 && !isChallenge(r.html)) { const items = parseSearchHtml(r.html, { limit: perPage }); if (items.length > 0) return items; } @@ -787,7 +806,7 @@ const handler = { 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 && !isChallenge(r.html)) { + if (r?.status === 200 && !isChallenge(r.html)) { const items = parseSearchHtml(r.html, { limit: perPage }); if (items.length > 0) return items; } diff --git a/server/tests/rumble-ld.test.mjs b/server/tests/rumble-ld.test.mjs index 0314760..2d28005 100644 --- a/server/tests/rumble-ld.test.mjs +++ b/server/tests/rumble-ld.test.mjs @@ -1,4 +1,4 @@ -import { parseJsonLd, parseRumbleViews, parseSearchHtml, resetRumbleNegativeCache, parseCardsHtml, scrapeRumbleList, scrapeRumbleVideo, armRumbleFetchCooldown, resetRumbleFetchCooldown } from '../providers/rumble.mjs'; +import { parseJsonLd, parseRumbleViews, parseSearchHtml, resetRumbleNegativeCache, parseCardsHtml, scrapeRumbleList, scrapeRumbleVideo, armRumbleFetchCooldown, resetRumbleFetchCooldown, rumbleFailureForStatus } from '../providers/rumble.mjs'; import { parseRelativeDate } from '../providers/youtube-innertube.mjs'; import { itemDurationSec } from '../search-filters.mjs'; @@ -181,4 +181,14 @@ await expectFail(() => scrapeRumbleList({}), 503, 'rumble_cloudflare_challenge', resetRumbleFetchCooldown(); pass++; console.log(' V cooldown CF: /video et /browse échouent en 503 sans réseau'); +// --- Mapping statut HTTP -> erreur typée (rumble.com renvoie 404 NET pour un +// id inexistant : ce n'est PAS un blocage, ça ne doit pas armer le cooldown) --- +eq(rumbleFailureForStatus(404).status, 404, 'statut 404 rumble -> 404 (absente, pas un blocage)'); +eq(rumbleFailureForStatus(404).code, 'rumble_not_found', 'statut 404 rumble -> code rumble_not_found'); +eq(rumbleFailureForStatus(410).status, 404, 'statut 410 -> 404'); +eq(rumbleFailureForStatus(403).status, 503, 'statut 403 -> 503 bloqué'); +eq(rumbleFailureForStatus(403).code, 'rumble_cloudflare_challenge', '403 -> même code que le blocage de recherche (bandeau identique)'); +eq(rumbleFailureForStatus(500).status, 503, 'statut 5xx -> 503 amont'); +eq(rumbleFailureForStatus(500).code, 'rumble_upstream_error', '5xx -> code amont'); + console.log(`\n rumble-ld: ${pass} assertions OK`);