feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s
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:
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
parsePeerTubeComposite,
|
||||
odyseeClaimToResolveArg,
|
||||
odyseeClaimToSlug,
|
||||
} from './channel-ref.mjs';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = Number(process.env.CHANNEL_FETCH_TIMEOUT_MS || 6000);
|
||||
|
||||
// Fournisseur de token Twitch branché par server/index.mjs (qui gère le cache
|
||||
@@ -32,6 +38,29 @@ async function fetchWithTimeout(url, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** N'accepte qu'une URL http(s) absolue — bloque `javascript:` et `data:`. */
|
||||
function sanitizeImageUrl(raw) {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const url = raw.trim();
|
||||
if (!/^https?:\/\//i.test(url)) return undefined;
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.7 — descriptions : texte brut, souvent multi-lignes et souvent
|
||||
* énorme (YouTube renvoie plusieurs kilo-octets). On normalise les espaces et on
|
||||
* plafonne : une description de 10 Ko dans une page chaîne saccade le rendu et
|
||||
* écrase le contenu. Tronquée = information partielle mais honnête ; l'absence
|
||||
* de plafond = page cassée.
|
||||
*/
|
||||
const DESCRIPTION_MAX = 600;
|
||||
function cleanDescription(raw) {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const text = raw.replace(/\s+/g, ' ').trim();
|
||||
if (!text) return undefined;
|
||||
return text.length > DESCRIPTION_MAX ? `${text.slice(0, DESCRIPTION_MAX - 1).trimEnd()}…` : text;
|
||||
}
|
||||
|
||||
function safeMeta(meta = {}, fallback = {}) {
|
||||
return {
|
||||
provider: fallback.provider,
|
||||
@@ -39,6 +68,10 @@ function safeMeta(meta = {}, fallback = {}) {
|
||||
title: meta.title ?? fallback.title,
|
||||
handle: meta.handle ?? fallback.handle,
|
||||
avatarUrl: meta.avatarUrl ?? fallback.avatarUrl,
|
||||
// Phase 7.7 : bannière. URL seulement — un `javascript:` glissé dans une
|
||||
// bannière deviendrait un vecteur XSS au moment de l'afficher.
|
||||
bannerUrl: sanitizeImageUrl(meta.bannerUrl) ?? sanitizeImageUrl(fallback.bannerUrl),
|
||||
description: cleanDescription(meta.description) ?? cleanDescription(fallback.description),
|
||||
url: meta.url ?? fallback.url,
|
||||
subsCount: typeof meta.subsCount === 'number' ? meta.subsCount : fallback.subsCount,
|
||||
verified: typeof meta.verified === 'boolean'
|
||||
@@ -69,16 +102,19 @@ async function fetchYoutubeChannel(externalId) {
|
||||
const url = branding.channel?.customUrl
|
||||
? `https://www.youtube.com/${branding.channel.customUrl}`
|
||||
: `https://www.youtube.com/channel/${externalId}`;
|
||||
return {
|
||||
provider: 'yt',
|
||||
externalId,
|
||||
return safeMeta({
|
||||
title: snippet.title || branding.channel?.title,
|
||||
handle: snippet.customUrl ? `@${snippet.customUrl.replace(/^@/, '')}` : undefined,
|
||||
avatarUrl: avatars,
|
||||
// Phase 7.7 — `brandingSettings` est DÉJÀ demandé (pour l'URL), il contient
|
||||
// aussi la bannière : aucun appel réseau supplémentaire.
|
||||
bannerUrl: branding.image?.bannerImageUrl
|
||||
|| (Array.isArray(branding.image?.thumbnails) && branding.image.thumbnails.at(-1)?.url),
|
||||
description: snippet.description,
|
||||
url,
|
||||
subsCount: stats.subscriberCount ? Number(stats.subscriberCount) : undefined,
|
||||
verified: Array.isArray(snippet.badges) ? snippet.badges.includes('verified') : undefined,
|
||||
};
|
||||
}, { provider: 'yt', externalId, url });
|
||||
} catch {
|
||||
return { provider: 'yt', externalId, url: `https://www.youtube.com/channel/${externalId}` };
|
||||
}
|
||||
@@ -89,7 +125,7 @@ async function fetchDailymotionChannel(externalId) {
|
||||
// NOTE : `avatar_url` n'est pas un champ valide de l'API user (400 sur
|
||||
// toute la requête) — seuls avatar_720_url / avatar_medium_url le sont.
|
||||
const params = new URLSearchParams({
|
||||
fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,url,followers_total,verified',
|
||||
fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,cover_url,description,url,followers_total,verified',
|
||||
});
|
||||
const data = await fetchWithTimeout(`https://api.dailymotion.com/user/${externalId}?${params.toString()}`);
|
||||
const username = data.username || externalId;
|
||||
@@ -97,6 +133,9 @@ async function fetchDailymotionChannel(externalId) {
|
||||
title: data.screenname || data.username,
|
||||
handle: data.username ? `@${data.username}` : undefined,
|
||||
avatarUrl: data.avatar_720_url || data.avatar_medium_url,
|
||||
// Phase 7.7 — `cover_url` est la bannière (l'API n'a pas de champ « banner »).
|
||||
bannerUrl: data.cover_url,
|
||||
description: data.description,
|
||||
url: `https://www.dailymotion.com/user/${username}`,
|
||||
subsCount: typeof data.followers_total === 'number' ? data.followers_total : undefined,
|
||||
verified: Boolean(data.verified),
|
||||
@@ -129,6 +168,11 @@ async function fetchTwitchChannel(externalId) {
|
||||
title: user.display_name,
|
||||
handle: `@${user.login}`,
|
||||
avatarUrl: user.profile_image_url,
|
||||
// Phase 7.7 — Helix les expose dans le même `/users` : rien à demander
|
||||
// de plus. `banner_image_url` est absent tant que l'utilisateur n'a pas
|
||||
// de bannière : on ne fabrique pas d'URL de substitution.
|
||||
bannerUrl: user.banner_image_url,
|
||||
description: user.description,
|
||||
url: `https://www.twitch.tv/${user.login}`,
|
||||
subsCount: typeof user.view_count === 'number' ? user.view_count : undefined,
|
||||
}, { provider: 'tw', externalId, url: `https://www.twitch.tv/${externalId}` });
|
||||
@@ -138,11 +182,11 @@ async function fetchTwitchChannel(externalId) {
|
||||
return { provider: 'tw', externalId, url: `https://www.twitch.tv/${externalId}` };
|
||||
}
|
||||
|
||||
function parsePeerTubeExternalId(externalId) {
|
||||
const [instance, channel] = String(externalId).split('|');
|
||||
if (!channel) return { instance: null, channel: externalId };
|
||||
return { instance, channel };
|
||||
}
|
||||
/**
|
||||
* Phase 6.4 — délègue à la source unique (`channel-ref.mjs`) au lieu de
|
||||
* redécouper `instance|channel` localement.
|
||||
*/
|
||||
const parsePeerTubeExternalId = parsePeerTubeComposite;
|
||||
|
||||
async function fetchPeerTubeChannel(externalId) {
|
||||
const { instance, channel } = parsePeerTubeExternalId(externalId);
|
||||
@@ -155,6 +199,11 @@ async function fetchPeerTubeChannel(externalId) {
|
||||
title: data.displayName || data.name,
|
||||
handle: data.host ? `@${data.name}@${data.host}` : undefined,
|
||||
avatarUrl: data?.avatar?.path ? `https://${instance}${data.avatar.path}` : undefined,
|
||||
// Phase 7.7 — l'API vidéo-channels expose déjà `banners` et `description`.
|
||||
bannerUrl: (Array.isArray(data?.banners) && data.banners.at(-1)?.path)
|
||||
? `https://${instance}${data.banners.at(-1).path}`
|
||||
: (data?.banner?.path ? `https://${instance}${data.banner.path}` : undefined),
|
||||
description: data.description,
|
||||
url: data?.url || `https://${instance}/video-channels/${channel}`,
|
||||
subsCount: typeof data.followersCount === 'number' ? data.followersCount : undefined,
|
||||
verified: Boolean(data.ownerAccount?.verified)
|
||||
@@ -164,12 +213,32 @@ async function fetchPeerTubeChannel(externalId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.7 — plus grande vignette LBRY disponible.
|
||||
* Le CDN Odysee sert la même image à plusieurs tailles via un paramètre `?size=` ;
|
||||
* on élargit donc la vignette de la chaîne, ce qui reste une VRAIE bannière.
|
||||
* À défaut de paramètre de taille, on rend l'URL telle quelle.
|
||||
*/
|
||||
function odyseeBannerUrl(value) {
|
||||
const base = value?.thumbnail?.url || value?.thumbnail;
|
||||
if (typeof base !== 'string' || !/^https?:\/\//i.test(base)) return undefined;
|
||||
if (/[?&]size=/i.test(base)) return base;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}size=1200x600`;
|
||||
}
|
||||
|
||||
async function fetchOdyseeChannel(externalId) {
|
||||
try {
|
||||
// Phase 6.4 : la normalisation du claim passe par la source unique
|
||||
// (`channel-ref.mjs`). Avant, deux règles cohabitaient dans CE fichier
|
||||
// (`startsWith('@') ? … : '@'+…` ici, `replace(/^@/,'')` pour l'URL 20 lignes
|
||||
// plus bas) et une troisième vivait côté front — la forme canonique pouvait
|
||||
// donc être `@x` ici et `x` là-bas.
|
||||
const claim = odyseeClaimToResolveArg(externalId);
|
||||
if (!claim) return { provider: 'od', externalId, url: 'https://odysee.com/' };
|
||||
const body = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'resolve',
|
||||
params: { urls: [externalId.startsWith('@') ? externalId : `@${externalId}`] },
|
||||
params: { urls: [claim] },
|
||||
id: 1
|
||||
};
|
||||
const resp = await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=resolve', {
|
||||
@@ -188,12 +257,19 @@ async function fetchOdyseeChannel(externalId) {
|
||||
title: value?.title,
|
||||
handle: meta.short_url ? meta.short_url.replace('https://odysee.com/', '') : undefined,
|
||||
avatarUrl: meta?.thumbnail?.url,
|
||||
// Phase 7.7 — LBRY n'a pas de « bannière » distincte : la vignette du
|
||||
// claim EST l'image de la chaîne. On demande la plus grande taille
|
||||
// servie par le CDN plutôt que d'inventer une URL qui n'existerait pas.
|
||||
bannerUrl: odyseeBannerUrl(value),
|
||||
description: value?.description || value?.tagged_description || meta?.description,
|
||||
url: meta.short_url,
|
||||
subsCount: typeof meta?.meta?.effective_amount === 'number' ? meta.meta.effective_amount : undefined,
|
||||
}, { provider: 'od', externalId, url: meta.short_url });
|
||||
}
|
||||
} catch {}
|
||||
return { provider: 'od', externalId, url: `https://odysee.com/${externalId.replace(/^@/, '')}` };
|
||||
// L'URL publique ne porte pas le `@` — d'où la conversion explicite plutôt
|
||||
// qu'un `replace` local.
|
||||
return { provider: 'od', externalId, url: `https://odysee.com/${odyseeClaimToSlug(externalId) || ''}` };
|
||||
}
|
||||
|
||||
async function fetchRumbleChannel(externalId) {
|
||||
@@ -203,10 +279,19 @@ async function fetchRumbleChannel(externalId) {
|
||||
});
|
||||
const titleMatch = /<title>([^<]+)<\/title>/i.exec(data);
|
||||
const avatarMatch = /property="og:image" content="([^"]+)"/i.exec(data);
|
||||
// Phase 7.7 — `og:description` est déjà dans la page : aucun appel en plus.
|
||||
// `content=` en PREMIER attribut, comme pour og:image : sur Rumble l'ordre
|
||||
// n'est pas garanti et une regex trop rigide ne renvoie jamais rien.
|
||||
const descMatch = /<meta[^>]+property="og:description"[^>]+content="([^"]*)"/i.exec(data)
|
||||
|| /<meta[^>]+content="([^"]*)"[^>]+property="og:description"/i.exec(data);
|
||||
const name = titleMatch ? titleMatch[1].replace(/ on Rumble.*$/i, '').trim() : undefined;
|
||||
return safeMeta({
|
||||
title: name,
|
||||
avatarUrl: avatarMatch ? avatarMatch[1] : undefined,
|
||||
// Pas de bannière dédiée côté Rumble : `og:image` est déjà la meilleure
|
||||
// image disponible, mais le doublonner avec l'avatar n'apporte rien —
|
||||
// on laisse donc `bannerUrl` vide et l'UI masquera la bandeau.
|
||||
description: descMatch ? descMatch[1] : undefined,
|
||||
url: `https://rumble.com/${externalId}`,
|
||||
}, { provider: 'ru', externalId, url: `https://rumble.com/${externalId}` });
|
||||
} catch {
|
||||
@@ -214,17 +299,32 @@ async function fetchRumbleChannel(externalId) {
|
||||
}
|
||||
}
|
||||
|
||||
export const channelRegistry = {
|
||||
yt: { fetchChannelById: fetchYoutubeChannel },
|
||||
dm: { fetchChannelById: fetchDailymotionChannel },
|
||||
tw: { fetchChannelById: fetchTwitchChannel },
|
||||
pt: { fetchChannelById: fetchPeerTubeChannel },
|
||||
od: { fetchChannelById: fetchOdyseeChannel },
|
||||
ru: { fetchChannelById: fetchRumbleChannel },
|
||||
/**
|
||||
* Phase 8.1 — la table par provider devient la SOURCE : `channelMetaByProvider`
|
||||
* indexe les collecteurs de métadonnées par id. `channelRegistry` et
|
||||
* `getChannelAdapter` restent exportés pour compatibilité (tests, historique),
|
||||
* mais ne font qu'aliasser cette table — l'adaptateur unifié (`providerAdapters`)
|
||||
* consomme `channelMetaByProvider` et n'a pas besoin de savoir qu'une autre
|
||||
* table a existé.
|
||||
*/
|
||||
export const channelMetaByProvider = {
|
||||
yt: fetchYoutubeChannel,
|
||||
dm: fetchDailymotionChannel,
|
||||
tw: fetchTwitchChannel,
|
||||
pt: fetchPeerTubeChannel,
|
||||
od: fetchOdyseeChannel,
|
||||
ru: fetchRumbleChannel,
|
||||
};
|
||||
|
||||
// Compatibilité phase 0/… : `channelRegistry.yt.fetchChannelById(...)` a été
|
||||
// utilisé par des tests et l'historique. On le DÉRIVE de la table source plutôt
|
||||
// que de le dupliquer.
|
||||
export const channelRegistry = Object.fromEntries(
|
||||
Object.entries(channelMetaByProvider).map(([provider, fetchMeta]) => [provider, { fetchChannelById: fetchMeta }]),
|
||||
);
|
||||
|
||||
export function getChannelAdapter(provider) {
|
||||
return channelRegistry[provider];
|
||||
return channelMetaByProvider[provider];
|
||||
}
|
||||
|
||||
export default channelRegistry;
|
||||
|
||||
Reference in New Issue
Block a user