feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s

7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
This commit is contained in:
2026-09-30 07:57:11 -04:00
parent 37681c4f53
commit 665a0f0ebd
87 changed files with 8850 additions and 550 deletions
+185 -3
View File
@@ -132,6 +132,39 @@ function isChallenge(html) {
return /Just a moment|challenge-platform|cf-chl/i.test(String(html || '').slice(0, 4000));
}
/**
* Phase 3.3 — cache négatif court.
* Sans lui, chaque recherche Rumble déclenchait 2 fetch Node + 2 fetch Python
* (12-20 s) pour finir sur un challenge Cloudflare, à chaque frappe utilisateur.
* TTL volontairement court (5 min) : un challenge se lève vite, et on ne veut
* pas figer une panne de plus de quelques minutes.
* @type {Map<string, number>}
*/
const NEGATIVE_CACHE_TTL_MS = Number(process.env.RUMBLE_NEGATIVE_CACHE_TTL_MS || 5 * 60 * 1000);
const negativeCache = new Map();
function negativeCacheKey(q, page) { return `search:vide:${page}:${String(q || '').trim().toLowerCase()}`; }
function isNegativelyCached(key) {
const ts = negativeCache.get(key);
if (!ts) return false;
if ((Date.now() - ts) >= NEGATIVE_CACHE_TTL_MS) { negativeCache.delete(key); return false; }
return true;
}
function markNegative(key) {
// Garde-fou mémoire : le cache ne doit pas grossir sans borne.
if (negativeCache.size >= 200) {
for (const [k, ts] of negativeCache) {
if ((Date.now() - ts) >= NEGATIVE_CACHE_TTL_MS) negativeCache.delete(k);
if (negativeCache.size < 150) break;
}
}
negativeCache.set(key, Date.now());
}
export function resetRumbleNegativeCache() { negativeCache.clear(); }
/* --------------------------------- parsing -------------------------------- */
function parseDurationToSeconds(raw) {
@@ -180,8 +213,101 @@ function normalizeThumb(raw) {
return t.startsWith('//') ? `https:${t}` : t;
}
/**
* Compteur de vues localized : "1,2K views", "1.2M views", "3 456", "12,345".
* L'ancien code faisait `.replace(/[^\d]/g, '')` : le suffixe disparaissait et
* "1,2K" devenait 12 (au lieu de 1 200). Les vues étaient donc affichées
* fausses sur la quasi-totalité des cartes Rumble au-delà de 999.
*/
export function parseRumbleViews(raw) {
const s = String(raw ?? '').trim();
if (!s) return undefined;
const m = s.match(/([\d][\d\s\u00a0.,]*)\s*([KMBkmb])?/);
if (!m) return undefined;
// 1 234 -> "1234" ; 1,2 / 1.2 -> "1.2" (décimal) ; 1,234 -> "1234" (milliers)
let digits = m[1].replace(/[\s\u00a0]/g, '');
if (/^\d{1,3},\d{3}$/.test(digits) || /^\d{1,3}\.\d{3}$/.test(digits)) digits = digits.replace(/[.,]/g, '');
else digits = digits.replace(',', '.');
const num = Number(digits);
if (!Number.isFinite(num) || num < 0) return undefined;
const suffix = (m[2] || '').toLowerCase();
const mult = suffix === 'b' ? 1e9 : suffix === 'm' ? 1e6 : suffix === 'k' ? 1e3 : 1;
const total = Math.round(num * mult);
return total > 0 ? total : undefined;
}
/** Clé de rapprochement stable : dernier segment d'URL, sans extension ni tracking. */
function urlKey(u) {
const s = String(u || '').split('?')[0].replace(/\.html$/i, '').replace(/\/$/, '');
return (s.split('/').filter(Boolean).pop() || '').toLowerCase();
}
/**
* Phase 3.1 — extraction du JSON-LD (`application/ld+json`).
* Plus stable que le DOM : quand le balisage change, le JSON-LD reste. On
* accepte `VideoObject`, `ItemList` et `@graph`, et on n'indexe QUE ce qui est
* explicitement présent (aucune valeur déduite).
* @returns {Map<string, { publishedAt?: string, views?: number, thumbnail?: string, duration?: number, channelId?: string }>}
*/
export function parseJsonLd(html) {
/** @type {Map<string, any>} */
const out = new Map();
const re = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(String(html || ''))) !== null) {
let data;
try { data = JSON.parse(m[1].trim()); } catch { continue; }
const nodes = Array.isArray(data) ? data
: Array.isArray(data?.['@graph']) ? data['@graph']
: data ? [data] : [];
for (const node of nodes) visit(node, out, 0);
}
return out;
}
function visit(node, out, depth) {
if (!node || typeof node !== 'object' || depth > 4) return;
const list = Array.isArray(node['@graph'])
? node['@graph']
: (Array.isArray(node.itemListElement) ? node.itemListElement.map((e) => e?.item ?? e) : null);
if (list) { for (const c of list) visit(c, out, depth + 1); }
const type = String(node['@type'] || '').toLowerCase();
if (!type.includes('video')) return;
const key = urlKey(node.url || node.embedUrl || node.contentUrl || node['@id']);
if (!key) return;
const stats = node.interactionStatistic;
const statList = Array.isArray(stats) ? stats : (stats ? [stats] : []);
let views;
for (const st of statList) {
const t = String(st?.interactionType || '').toLowerCase();
if (!t.includes('watch') && !t.includes('view')) continue;
const n = Number(st?.userInteractionCount);
if (Number.isFinite(n) && n >= 0) { views = Math.round(n); break; }
}
const dateRaw = node.uploadDate || node.datePublished;
const parsed = dateRaw ? Date.parse(dateRaw) : NaN;
const publishedAt = Number.isFinite(parsed) && parsed > 0 ? new Date(parsed).toISOString() : undefined;
const duration = parseDurationToSeconds(node.duration);
const thumbRaw = Array.isArray(node.thumbnailUrl)
? node.thumbnailUrl[0]
: (typeof node.thumbnailUrl === 'string' ? node.thumbnailUrl : node.thumbnailUrl?.url);
const authorUrl = typeof node.author === 'string' ? node.author : node.author?.url;
const channelId = (String(authorUrl || '').match(/\/c\/([^/?#]+)/i)?.[1] || '').trim() || undefined;
out.set(key, {
...(publishedAt ? { publishedAt } : {}),
...(views !== undefined && views > 0 ? { views } : {}),
...(duration !== undefined ? { duration } : {}),
...(thumbRaw ? { thumbnail: normalizeThumb(thumbRaw) } : {}),
...(channelId ? { channelId } : {}),
});
}
/** Classic parser: li.video-listing-entry cards. */
function parseSearchHtml(html, { limit = 50 } = {}) {
export function parseSearchHtml(html, { limit = 50 } = {}) {
const $ = load(html);
const items = [];
$('li.video-listing-entry').each((_idx, el) => {
@@ -220,13 +346,35 @@ function parseSearchHtml(html, { limit = 50 } = {}) {
if (typeof parsed === 'number' && parsed > 0) { durationSeconds = parsed; break; }
}
const viewsText = $el.find('.video-item--views').first().text().trim();
const views = Number(String(viewsText).replace(/[^\d]/g, '')) || undefined;
const views = parseRumbleViews(viewsText);
// Phase 1.4 - date de publication. L'attribut `datetime` du <time> est la
// vraie date ISO ; son texte est un libelle relatif ("2 months ago") que
// Date.parse() ne sait pas lire, d'ou l'absence historique de publishedAt.
const timeEl = $el.find('.video-item--meta time, time.video-item--date, time').first();
const publishedRaw = (timeEl.attr('datetime') || timeEl.attr('data-datetime') || '').trim();
const publishedMs = publishedRaw ? Date.parse(publishedRaw) : NaN;
const publishedAt = Number.isFinite(publishedMs) && publishedMs > 0
? new Date(publishedMs).toISOString()
: undefined;
// Phase 1.5 - identifiant de chaine. L'URL porte soit /c/<username>/,
// soit (en fallback) le sous-domaine ; l'avatar est sur l'image du by-line.
const channelLink = $el.find('a.video-item--channel-link, a[href*="/c/"]').first();
const channelHref = (channelLink.attr('href') || '').trim();
const channelId = (channelHref.match(/\/c\/([^/?#]+)/i)?.[1] || '').trim() || undefined;
const avatarEl = $el.find('img.video-item--channel-thumb, .video-item--by-line img, a.video-item--channel-link img').first();
const uploaderAvatar = normalizeThumb(avatarEl.attr('src') || avatarEl.attr('data-src') || '');
items.push({
title: title || url,
id,
url,
thumbnail: normalizeThumb(rawThumbnail),
uploaderName: uploaderName || undefined,
// Phase 1.4 / 1.5 / 1.6 - les trois champs etaient presents dans le HTML
// mais jamais extraits, ce qui vidait la page chaine de sa date, de son
// lien "voir la chaine" et de son avatar.
publishedAt,
channelId,
uploaderAvatar,
views,
type: 'video',
duration: durationSeconds,
@@ -234,6 +382,23 @@ 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.
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;
}
}
} catch { /* la surcouche est un bonus, jamais un motif d'échec */ }
return items;
}
@@ -254,6 +419,16 @@ const handler = {
const query = String(q || '').trim();
if (!query) return [];
// Phase 3.3 — short-circuit si l'échec vient d'être constaté.
const cacheKey = negativeCacheKey(query, pageNum);
if (isNegativelyCached(cacheKey)) {
// Phase 3.4 — un échec est une ERREUR, pas un résultat vide : le front
// peut ainsi afficher un bandeau au lieu d'un « aucun résultat » trompeur.
throw Object.assign(new Error('rumble_cloudflare_challenge'), {
code: 'rumble_cloudflare_challenge', rumbleChallenged: true,
});
}
// --- Attempt 1: canonical search page (Node fetch, then python/curl_cffi) ---
try {
const params = new URLSearchParams({ q: query });
@@ -276,7 +451,14 @@ const handler = {
}
} catch { /* give up */ }
return [];
// Phase 3.4 — on distingue « échec » de « vide ». Une recherche Rumble qui
// renvoie 0 résultat à travers deux chemins est presque toujours un blocage
// Cloudflare, pas une absence de contenu : on le remonte, on met en cache
// négatif, et le groupe ne passe pas pour un recherche normale sans résultat.
markNegative(cacheKey);
throw Object.assign(new Error('rumble_unavailable'), {
code: 'rumble_unavailable', rumbleChallenged: true,
});
},
};