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 ».
188 lines
6.6 KiB
JavaScript
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;
|