Files
NewTube/server/rumble.mjs
T
bruno e48d76be57
CI / build-and-test (push) Successful in 14m57s
refactor(rumble): un seul cœur de scraping, routeur fin, contrat 503 CF
- 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 ».
2026-10-02 08:36:48 -04:00

188 lines
6.6 KiB
JavaScript

import express from 'express';
import rateLimit from 'express-rate-limit';
import { getProviderAdapter } from './providers/registry.mjs';
import {
normalizeRumbleId,
scrapeRumbleList,
scrapeRumbleVideo,
} from './providers/rumble.mjs';
/**
* Routes HTTP Rumble — COUCHE MINCE uniquement.
*
* Tout le scraping (fetch Cloudflare à 3 niveaux, cookie jar __cf_bm, cooldown
* global, negative cache, parsing DOM/JSON-LD, vues K/M/B) vit dans
* `server/providers/rumble.mjs`, le cœur partagé avec la recherche unifiée :
* aucune logique de fetch ni de parse dans ce fichier (l'implémentation
* dupliquée — axios + spawn python + parseur de cartes, vues « 1,2K » lues en
* 12 — a été supprimée ici). `/search` passe par le registre de providers :
* cache SQLite, negative cache et channelRef sont partagés avec `/api/search`.
*/
const router = express.Router();
/* ----------------------------- Rate limiting ----------------------------- */
// 20/min : la valeur vivante historique (l'ancien rumbleLimiter 10/min
// d'index.mjs n'était jamais monté — supprimé avec le code mort).
const rumbleLimiter = rateLimit({
windowMs: 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests to Rumble. Please try again later.' }
});
router.use(rumbleLimiter);
/* --------------------------------- Cache -------------------------------- */
// Cache positif court : les pages Rumble changent peu et chaque miss coûte un
// fetch (+ éventuellement un spawn python). Les ÉCHECS ne sont pas cachés ici :
// c'est le negative cache + le cooldown du cœur qui les portent.
const cache = new Map();
const TTL_MS = 60 * 1000; // 60s
function cacheKey(path, params) {
return `${path}?${new URLSearchParams(params).toString()}`;
}
function setCache(key, data) {
cache.set(key, { data, expires: Date.now() + TTL_MS });
}
function getCache(key) {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() > hit.expires) { cache.delete(key); return null; }
return hit.data;
}
/**
* Contrat d'erreur : un blocage Cloudflare est un 503 typé, JAMAIS un 404
* (le 404 ferait retirer la vidéo du flux Shorts — « introuvable de façon
* certaine ») et jamais un 200 vide sans diagnostic. Les erreurs sans statut
* (bug inattendu) restent en 500 typé.
*/
function sendError(res, e) {
const status = Number(e?.status) || 500;
return res.status(status).json({ error: e?.code || 'rumble_failed' });
}
/* --------------------------------- Routes -------------------------------- */
// Tendances / classements : https://rumble.com/videos?sort=…
router.get('/browse', async (req, res) => {
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
const limit = Math.min(50, Math.max(1, parseInt(String(req.query.limit || '24'), 10) || 24));
const sort = String(req.query.sort || 'viral');
const key = cacheKey('/browse', { page, limit, sort });
const cached = getCache(key);
if (cached) return res.json(cached);
try {
const data = await scrapeRumbleList({ page, limit, sort });
setCache(key, data);
return res.json(data);
} catch (e) {
return sendError(res, e);
}
});
/**
* GET /api/rumble/shorts?page=&limit=
* Flux natif des Shorts Rumble (rumble.com/shorts, vertical ≤ 90 s) :
* IDs /vXXXX propres, compatibles avec l'embed officiel /embed/vXXXX/.
*/
router.get('/shorts', async (req, res) => {
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
const limit = Math.min(50, Math.max(1, parseInt(String(req.query.limit || '24'), 10) || 24));
const key = cacheKey('/shorts', { page, limit });
const cached = getCache(key);
if (cached) return res.json(cached);
try {
const data = await scrapeRumbleList({ page, limit, mode: 'shorts' });
setCache(key, data);
return res.json(data);
} catch (e) {
return sendError(res, e);
}
});
// Recherche : même adaptateur (et donc même cache SQLite, negative cache et
// channelRef) que le groupe `ru` de la recherche unifiée.
router.get('/search', async (req, res) => {
const q = String(req.query.q || '').trim();
if (!q) return res.status(400).json({ error: 'Query parameter required' });
const limit = Math.min(50, Math.max(1, parseInt(String(req.query.limit || '24'), 10) || 24));
const page = (() => {
if (req.query.offset != null) {
const offset = parseInt(String(req.query.offset), 10) || 0;
return Math.floor(offset / limit) + 1;
}
return Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
})();
const key = cacheKey('/search', { q, page, limit });
const cached = getCache(key);
if (cached) return res.json(cached);
try {
const items = await getProviderAdapter('ru').search(q, { limit, page });
const data = {
items,
total: items.length,
page,
limit,
nextCursor: items.length === limit ? String(page + 1) : null,
};
setCache(key, data);
return res.json(data);
} catch (e) {
return sendError(res, e);
}
});
// Détails d'une vidéo (résolution d'embed fiable pour /watch et /shorts).
// Accepte :videoId pouvant être "vXXXX" OU "video/123..." — l'ancien pattern
// :videoId(*) est conservé tel quel (comportement de route inchangé).
router.get('/video/:videoId(*)', async (req, res) => {
try {
const raw = String(req.params.videoId);
const key = cacheKey('/video', { videoId: raw });
const cached = getCache(key);
if (cached) return res.json(cached);
const norm = normalizeRumbleId(raw) || { urlCanonique: `https://rumble.com/${raw}` };
const data = await scrapeRumbleVideo(norm.id || norm.urlCanonique);
setCache(key, data);
return res.json(data);
} catch (e) {
return sendError(res, e);
}
});
/**
* Option : « prélecteur » sans pub (non-embed).
* On NE désactive PAS les pubs côté Rumble (pas de paramètre officiel fiable) :
* cette route renvoie juste les métadonnées du composant prélecteur
* (miniature/titre + lien d'ouverture fournisseur, iframe en dernier recours).
*/
router.get('/video/:videoId/preplay', async (req, res) => {
try {
const raw = String(req.params.videoId);
const norm = normalizeRumbleId(raw) || { urlCanonique: `https://rumble.com/${raw}` };
const data = await scrapeRumbleVideo(norm.id || norm.urlCanonique);
return res.json({
videoId: data.videoId,
title: data.title,
thumbnail: data.thumbnail,
rumbleUrl: data.url, // bouton "Ouvrir sur le site du fournisseur"
embedUrl: data.embedUrl // injection différée si l'utilisateur insiste
});
} catch (e) {
return sendError(res, e);
}
});
export default router;