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); } }); /** * GET /api/rumble/live?page=&limit= * Directs en cours (rumble.com/browse/live, SSR) : IDs /vXXXX propres, * items marqués `type:'live'` par `parseCardsHtml` (mode live). */ router.get('/live', 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('/live', { page, limit }); const cached = getCache(key); if (cached) return res.json(cached); try { const data = await scrapeRumbleList({ page, limit, mode: 'live' }); 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;