CI / build-and-test (push) Canceled after 0s
Le scan générique acceptait tout href /^\/v/ dont le regex d'id matchait : `/videos` donnait `videoId: "videos"` — une fausse carte en tête du flux /shorts (pré-validée puis retirée, mais visible le temps du résolve). Probe live : 32/32 vrais ids rumble portent au moins un chiffre, seul /videos n'en a pas. Garde « >=1 chiffre » dans l'id, plafond documenté (un id sans chiffre ferait sauter sa carte). Effet de bord heureux : la branche /video/123 (isLegacy) était morte depuis toujours — le regex capturait « video » avant elle — elle redevient accessible. Tests : lien /videos ajouté au fixture du flux, 64 assertions vertes.
198 lines
11 KiB
JavaScript
198 lines
11 KiB
JavaScript
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';
|
|
|
|
let pass = 0;
|
|
const eq = (a, b, m) => {
|
|
const x = JSON.stringify(a), y = JSON.stringify(b);
|
|
if (x !== y) throw new Error(`${m}\n expected ${y}\n got ${x}`);
|
|
pass++; console.log(` V ${m}`);
|
|
};
|
|
/**
|
|
* Comparaison d'horodatage calculé sur l'horloge VIVANTE : le test et le code
|
|
* appellent `Date.now()` à des instants distincts, donc une égalité à la
|
|
* milliseconde est une loterie (elle échouait 1 fois sur ~2, selon le tick).
|
|
* On vérifie l'ordre de grandeur : c'est ce que la fonction promet.
|
|
*/
|
|
const eqNear = (iso, expectedMs, toleranceMs, m) => {
|
|
const got = Date.parse(iso);
|
|
if (!Number.isFinite(got)) throw new Error(`${m}\n expected ~${new Date(expectedMs).toISOString()}\n got ${iso}`);
|
|
const delta = Math.abs(got - expectedMs);
|
|
if (delta > toleranceMs) throw new Error(`${m}\n expected ~${new Date(expectedMs).toISOString()} (±${toleranceMs}ms)\n got ${iso} (écart ${delta}ms)`);
|
|
pass++; console.log(` V ${m}`);
|
|
};
|
|
|
|
// --- parseRumbleViews (bug historique : "1,2K" -> 12) ---
|
|
eq(parseRumbleViews('1,2K views'), 1200, 'FR compact "1,2K" -> 1200 (was 12)');
|
|
eq(parseRumbleViews('1.2M views'), 1200000, 'EN compact "1.2M" -> 1 200 000');
|
|
eq(parseRumbleViews('3.4B views'), 3400000000, 'EN compact billions');
|
|
eq(parseRumbleViews('12,345 views'), 12345, '"12,345" -> thousands, not 12.345');
|
|
eq(parseRumbleViews('3 456'), 3456, 'space-separated thousands');
|
|
eq(parseRumbleViews('1 234 567'), 1234567, 'multi-group thousands');
|
|
eq(parseRumbleViews('1.5k'), 1500, 'lowercase k');
|
|
eq(parseRumbleViews('999'), 999, 'plain number');
|
|
eq(parseRumbleViews('vues indisponibles'), undefined, 'non-numeric -> undefined (never 0)');
|
|
eq(parseRumbleViews(''), undefined, 'empty -> undefined');
|
|
eq(parseRumbleViews(null), undefined, 'null -> undefined');
|
|
eq(parseRumbleViews('0 views'), undefined, 'zero -> undefined (phase 2.2: no zero counters)');
|
|
|
|
// --- DOM parsing (Phase 1.4/1.5/1.6) ---
|
|
const domHtml = `<html><body><ul>
|
|
<li class="video-listing-entry" data-id="v1-a">
|
|
<a class="video-item--a" href="/v1-a.html?e9s=track"></a>
|
|
<h3 class="video-item--title">Titre A</h3>
|
|
<div class="video-item--by-line">
|
|
<a class="video-item--channel-link" href="/c/myc/">
|
|
<img class="video-item--channel-thumb" src="//i.rumble.com/av.png">
|
|
<span class="ellipsis-1">Ma Chaîne</span></a></div>
|
|
<span class="video-item--views">1,2K views</span>
|
|
<time datetime="2026-02-01T10:00:00-05:00">il y a 1 mois</time>
|
|
<span class="video-item--duration" data-value="3:02"></span>
|
|
</li>
|
|
<li class="video-listing-entry" data-id="v2-b">
|
|
<a class="video-item--a" href="/v2-b.html"></a>
|
|
<h3 class="video-item--title">Titre B</h3>
|
|
<span class="ellipsis-1">Auteur B</span>
|
|
<span class="video-item--views">vues indisponibles</span>
|
|
</li>
|
|
</ul></body></html>`;
|
|
|
|
const dom = parseSearchHtml(domHtml, { limit: 10 });
|
|
eq(dom.length, 2, 'DOM: both cards parsed');
|
|
eq(dom[0].publishedAt, '2026-02-01T15:00:00.000Z', 'DOM: publishedAt from <time datetime> (not the "il y a 1 mois" label)');
|
|
eq(dom[0].channelId, 'myc', 'DOM: channelId extracted from /c/ (phase 1.5)');
|
|
eq(dom[0].uploaderAvatar, 'https://i.rumble.com/av.png', 'DOM: uploaderAvatar protocol-relative -> https (phase 1.6)');
|
|
eq(dom[0].duration, 182, 'DOM: duration from data-value');
|
|
eq(dom[0].views, 1200, 'DOM: views "1,2K" -> 1200');
|
|
eq(dom[0].url, 'https://rumble.com/v1-a.html', 'DOM: tracking params stripped from URL');
|
|
|
|
eq(dom[1].publishedAt, undefined, 'DOM: absent <time> -> publishedAt absent, not null/empty');
|
|
eq(dom[1].channelId, undefined, 'DOM: absent channel link -> channelId absent');
|
|
eq(dom[1].uploaderAvatar, undefined, 'DOM: absent avatar -> absent');
|
|
eq(dom[1].duration, undefined, 'DOM: absent duration -> absent (never 0)');
|
|
eq(dom[1].views, undefined, 'DOM: unparseable views -> absent (never 0)');
|
|
|
|
eq(parseSearchHtml('<ul></ul>').length, 0, 'DOM: empty listing -> empty array');
|
|
eq(parseSearchHtml('').length, 0, 'DOM: empty html -> empty array');
|
|
|
|
// --- JSON-LD (Phase 3.1) ---
|
|
const ldHtml = `<html><head>
|
|
<script type="application/ld+json">${JSON.stringify({
|
|
'@context': 'https://schema.org', '@type': 'ItemList',
|
|
itemListElement: [{
|
|
'@type': 'VideoObject', name: 'A', url: 'https://rumble.com/v1-a.html',
|
|
uploadDate: '2026-02-01T10:00:00Z', duration: 'PT3M2S',
|
|
thumbnailUrl: ['//i.rumble.com/a.jpg'],
|
|
interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: 1234 },
|
|
author: { '@type': 'Person', name: 'Chan', url: 'https://rumble.com/c/myc/' },
|
|
}],
|
|
})}</script></head><body>
|
|
<ul><li class="video-listing-entry" data-id="v1-a">
|
|
<a class="video-item--a" href="/v1-a.html?e9s=track"></a>
|
|
<h3 class="video-item--title">A</h3>
|
|
<div class="video-item--by-line">
|
|
<a class="video-item--channel-link" href="/c/myc/">
|
|
<img class="video-item--channel-thumb" src="//i.rumble.com/av.png">
|
|
<span class="ellipsis-1">Chan</span></a></div>
|
|
<span class="video-item--views">1,2K views</span>
|
|
<time datetime="2026-02-01T10:00:00-05:00">1 month ago</time>
|
|
<span class="video-item--duration" data-value="3:02"></span>
|
|
</li></ul></body></html>`;
|
|
|
|
const ld = parseJsonLd(ldHtml);
|
|
eq(ld.size, 1, 'JSON-LD: one VideoObject indexed out of the ItemList');
|
|
eq(ld.get('v1-a'), {
|
|
publishedAt: '2026-02-01T10:00:00.000Z',
|
|
views: 1234,
|
|
duration: 182,
|
|
thumbnail: 'https://i.rumble.com/a.jpg',
|
|
channelId: 'myc',
|
|
}, 'JSON-LD: date, views, duration, thumbnail, channelId extracted (phase 1.4/1.5/1.6)');
|
|
|
|
eq(parseJsonLd(`<script type="application/ld+json">{oops</script>`).size, 0, 'JSON-LD: malformed block is skipped, not thrown');
|
|
eq(parseJsonLd('').size, 0, 'JSON-LD: empty html -> empty map');
|
|
eq(parseJsonLd(`<script type="application/ld+json">${JSON.stringify({ '@type': 'WebPage', name: 'x' })}</script>`).size, 0,
|
|
'JSON-LD: non-video node ignored (no invented data)');
|
|
eq(parseJsonLd(`<script type="application/ld+json">${JSON.stringify({ '@graph': [{ '@type': 'VideoObject', url: 'https://rumble.com/g1.html' }] })}</script>`).get('g1'),
|
|
{}, 'JSON-LD: @graph supported, absent fields stay absent');
|
|
|
|
// --- parseRelativeDate (Phase 1.9) ---
|
|
eqNear(parseRelativeDate('il y a 2 semaines'), Date.now() - 2 * 6048e5, 2000, 'FR relative weeks (live clock)');
|
|
eq(parseRelativeDate('Streamed live 3 days ago') !== undefined, true, 'EN relative days');
|
|
eqNear(parseRelativeDate('il y a 8 mois'), Date.now() - 8 * 26298e6, 2000, 'FR relative months');
|
|
eq(parseRelativeDate('abonné'), undefined, 'unrelated label -> undefined (no invented date)');
|
|
|
|
// --- itemDurationSec (Phase 2.2) ---
|
|
eq(itemDurationSec({ duration: 0 }), undefined, 'duration 0 -> undefined');
|
|
eq(itemDurationSec({}), undefined, 'missing duration -> undefined');
|
|
eq(itemDurationSec({ duration: '3:02' }), 182, 'mm:ss still parsed');
|
|
eq(itemDurationSec({ duration: 0, width: 1080, height: 1920 }), undefined,
|
|
'zero duration does not leak into classification input');
|
|
|
|
// --- Rumble negative cache API is importable/clearable ---
|
|
resetRumbleNegativeCache();
|
|
pass++; console.log(' V negative cache is resettable (tests stay hermetic)');
|
|
|
|
// --- Cœur partagé (ménage Rumble) : cartes génériques + contrat 503/404 ---
|
|
// Formes d'URL réelles : /vXXXX-titre-slug.html (l'id s'arrête au slug) et
|
|
// /shorts/vXXXX pour le flux natif.
|
|
const cardsHtml = `<html><body>
|
|
<div class="video-item">
|
|
<a href="/vAAA11-mon-titre.html?e9s=x"><img data-src="//i.rumble.com/a.jpg"></a>
|
|
<h3>Titre A</h3><span class="video-item--views">1,2K views</span>
|
|
<span class="video-item--duration" data-value="1:30"></span>
|
|
</div>
|
|
<div class="video-item">
|
|
<a href="/vBBB22-autre-titre.html"><img src="https://i.rumble.com/b.jpg"></a>
|
|
<h3>Titre B</h3><span class="video-item--views" data-views="3456"></span>
|
|
</div>
|
|
<div class="video-item">
|
|
<a href="/videos"><h3>Featured Channels</h3></a>
|
|
</div>
|
|
<div class="video-item">
|
|
<a href="/shorts/vCCC33"><h3>Titre C</h3></a>
|
|
</div>
|
|
</body></html>`;
|
|
|
|
const browseCards = parseCardsHtml(cardsHtml, { mode: 'browse' });
|
|
eq(browseCards.length, 2, 'cards browse: /vXXXX lu, /shorts/ et /videos (nav) ignorés');
|
|
eq(browseCards[0].id, 'vAAA11', 'cards: id = vXXXX avant le slug -titre');
|
|
eq(browseCards[0].views, 1200, 'cards: vues « 1,2K » -> 1200 (bug parseur legacy corrigé)');
|
|
eq(browseCards[0].duration, 90, 'cards: durée depuis data-value');
|
|
eq(browseCards[0].thumbnail, 'https://i.rumble.com/a.jpg', 'cards: vignette protocol-relative -> https');
|
|
eq(browseCards[0].isShort, false, 'cards browse: 90 s > 75 -> pas un Short');
|
|
eq(browseCards[1].views, 3456, 'cards: data-views numérique brut lu tel quel');
|
|
eq(browseCards[1].duration, undefined, 'cards: durée absente -> undefined (jamais 0)');
|
|
|
|
const shortCards = parseCardsHtml(cardsHtml, { mode: 'shorts' });
|
|
eq(shortCards.length, 3, 'cards shorts: lien /shorts/vXXXX aussi lu');
|
|
eq(shortCards[2].id, 'vCCC33', 'cards shorts: id extrait après /shorts/');
|
|
eq(shortCards[2].isShort, true, 'cards shorts: durée absente -> Short natif (ancien comportement 0)');
|
|
|
|
// Cooldown CF : fetch court-circuité => 503 typé, JAMAIS 404 (le 404 ferait
|
|
// retirer la vidéo du flux Shorts), et ce pour les deux chemins de scrape.
|
|
armRumbleFetchCooldown(60_000);
|
|
const expectFail = async (fn, status, code, msg) => {
|
|
let err = null;
|
|
try { await fn(); } catch (e) { err = e; }
|
|
if (!err) throw new Error(`${msg}: aucun throw — le cooldown n'a pas court-circuité le fetch`);
|
|
eq(err.status, status, `${msg}: status ${status}`);
|
|
eq(err.code, code, `${msg}: code ${code}`);
|
|
};
|
|
await expectFail(() => scrapeRumbleVideo('v-cooltest'), 503, 'rumble_cloudflare_challenge', '/video en cooldown CF');
|
|
await expectFail(() => scrapeRumbleList({}), 503, 'rumble_cloudflare_challenge', '/browse en cooldown CF');
|
|
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`);
|