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:
+116
-13
@@ -10,7 +10,7 @@
|
||||
* @property {string=} type
|
||||
*/
|
||||
import {
|
||||
getYouTubeKeys, isKeyFailure, getSearchMode, getScrapeTtlMs,
|
||||
getYouTubeKeys, isKeyFailure, getEffectiveSearchMode, getScrapeTtlMs,
|
||||
hashSearchKey, ytMetrics,
|
||||
} from './youtube-common.mjs';
|
||||
import { searchViaScrape } from './youtube-scrape.mjs';
|
||||
@@ -109,6 +109,24 @@ export function ytScrapeCacheStats() {
|
||||
return { memEntries: memCache.size, memMax: MEM_MAX };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3.6 — cache des `pageToken` YouTube.
|
||||
* Avant : atteindre la page N exigeait de rejouer les pages 1..N à chaque visite
|
||||
* (`while (currentPage <= targetPage)`), soit jusqu'à 100 unités de quota pour un
|
||||
* simple « page 5 ». La Data API est paginée par jeton, pas par offset : on mémorise
|
||||
* donc la chaîne de jetons et chaque page thereafter coûte 1 appel.
|
||||
* TTL 15 min, borné à 200 entrées par instance.
|
||||
* @type {Map<string, { ts: number, tokens: string[] }>}
|
||||
*/
|
||||
const PAGE_TOKEN_TTL_MS = 15 * 60 * 1000;
|
||||
const pageTokenCache = new Map();
|
||||
|
||||
function pageTokenCacheKey(q, order, perPage, extra) {
|
||||
return `ytpage|${hashSearchKey(`${String(q).toLowerCase().trim()}|${order}|${perPage}|${JSON.stringify(extra || {})}`)}`;
|
||||
}
|
||||
|
||||
export function clearYouTubePageTokenCache() { pageTokenCache.clear(); }
|
||||
|
||||
async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filters = null } = {}) {
|
||||
const keys = getYouTubeKeys();
|
||||
if (!keys.length) {
|
||||
@@ -121,23 +139,42 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte
|
||||
const extra = apiSearchParams(filters);
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||
const targetPage = Math.max(1, Number(page || 1));
|
||||
let pageToken = '';
|
||||
let currentPage = 1;
|
||||
const cacheKey = pageTokenCacheKey(q, order, perPage, extra);
|
||||
|
||||
const hit = pageTokenCache.get(cacheKey);
|
||||
const entry = (hit && (Date.now() - hit.ts) < PAGE_TOKEN_TTL_MS) ? hit : null;
|
||||
if (!entry && hit) pageTokenCache.delete(cacheKey);
|
||||
if (pageTokenCache.size >= 200) {
|
||||
for (const [k, v] of pageTokenCache) {
|
||||
if ((Date.now() - v.ts) >= PAGE_TOKEN_TTL_MS) pageTokenCache.delete(k);
|
||||
if (pageTokenCache.size < 150) break;
|
||||
}
|
||||
}
|
||||
|
||||
/** `tokens[i]` = pageToken permettant de fetch la page i+2. Doit être contigu depuis 0. */
|
||||
const tokens = entry ? entry.tokens : [];
|
||||
const buildParams = (pageToken) => ({
|
||||
part: 'snippet', q, maxResults: String(perPage), order,
|
||||
videoEmbeddable: 'true', safeSearch: 'moderate',
|
||||
...extra,
|
||||
...(pageToken ? { pageToken } : {}),
|
||||
});
|
||||
|
||||
// Si le jeton de la page cible est déjà connu, on saute directement dessus :
|
||||
// 1 appel au lieu de N. Sinon on rejoue les pages manquantes à partir d'où
|
||||
// la chaîne s'arrête (jamais depuis le début si le cache est partiel).
|
||||
const startPage = Math.min(targetPage, tokens.length + 1);
|
||||
let pageToken = startPage >= 2 ? (tokens[startPage - 2] || '') : '';
|
||||
let lastItems = [];
|
||||
while (currentPage <= targetPage) {
|
||||
const params = {
|
||||
part: 'snippet', q, maxResults: String(perPage), order,
|
||||
videoEmbeddable: 'true', safeSearch: 'moderate',
|
||||
...extra,
|
||||
};
|
||||
if (pageToken) params.pageToken = pageToken;
|
||||
const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', params);
|
||||
for (let currentPage = startPage; currentPage <= targetPage; currentPage++) {
|
||||
const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', buildParams(pageToken));
|
||||
if (currentPage === targetPage) { lastItems = Array.isArray(data.items) ? data.items : []; break; }
|
||||
const next = data.nextPageToken;
|
||||
if (!next) { lastItems = []; break; }
|
||||
tokens[currentPage - 1] = String(next);
|
||||
pageToken = String(next);
|
||||
currentPage++;
|
||||
}
|
||||
pageTokenCache.set(cacheKey, { ts: Date.now(), tokens });
|
||||
const videoIds = (lastItems || []).map((item) => item?.id?.videoId).filter(Boolean);
|
||||
const detailsMap = new Map();
|
||||
if (videoIds.length > 0) {
|
||||
@@ -150,6 +187,31 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte
|
||||
console.warn('[YouTube] details fetch failed, continuing without durations:', e?.message || e);
|
||||
}
|
||||
}
|
||||
// Phase 1.7 - `search.list` ne renvoie QUE la vignette de la video, jamais
|
||||
// celle de la chaine. Un seul appel `channels.list` par page suffit ahydrater
|
||||
// `uploaderAvatar` pour tous les resultats. Degradation silencieuse : si aucune
|
||||
// cle n'est disponible ou si l'appel echoue, on rend la main sans avatar
|
||||
// (jamais d'image inventee).
|
||||
const channelAvatars = new Map();
|
||||
try {
|
||||
const channelIds = Array.from(new Set(
|
||||
(lastItems || [])
|
||||
.map((i) => i?.snippet?.channelId || i?.id?.channelId)
|
||||
.filter(Boolean),
|
||||
)).slice(0, 50);
|
||||
if (channelIds.length > 0) {
|
||||
const chData = await ytFetchJson('https://www.googleapis.com/youtube/v3/channels', {
|
||||
part: 'snippet', id: channelIds.join(','),
|
||||
});
|
||||
for (const ch of chData?.items || []) {
|
||||
const t = ch?.snippet?.thumbnails;
|
||||
const url = t?.high?.url || t?.medium?.url || t?.default?.url;
|
||||
if (ch?.id && url) channelAvatars.set(ch.id, url);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[YouTube] channel avatars fetch failed, continuing without:', e?.message || e);
|
||||
}
|
||||
return (lastItems || []).map((item) => {
|
||||
const videoId = item?.id?.videoId;
|
||||
// Une recherche de type `channel` renvoie des channelId, pas des videoId.
|
||||
@@ -169,8 +231,18 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte
|
||||
? `https://www.youtube.com/channel/${rawId}`
|
||||
: `https://www.youtube.com/watch?v=${rawId}`,
|
||||
thumbnail: thumb, uploaderName: snippet.channelTitle || undefined,
|
||||
// Phase 1.7 - avatar de la chaine (absent = pas d'avatar, pas de repli casse).
|
||||
...(channelId && channelAvatars.get(channelId)
|
||||
? { uploaderAvatar: channelAvatars.get(channelId), channelAvatarUrl: channelAvatars.get(channelId) }
|
||||
: {}),
|
||||
type: isChannel ? 'channel' : 'video',
|
||||
duration: duration > 0 ? duration : undefined, views,
|
||||
// Phase 1.7bis - `likeCount` est gratuit sur le meme appel
|
||||
// `videos?part=statistics` que la duree : autant le remonter pendant qu'on y est.
|
||||
...(details?.statistics?.likeCount != null ? { likes: Number(details.statistics.likeCount) } : {}),
|
||||
// `liveBroadcastContent` vient du snippet de la recherche (live/upcoming/none).
|
||||
...(snippet?.liveBroadcastContent === 'live' ? { isLive: true, type: 'live' } : {}),
|
||||
...(snippet?.liveBroadcastContent === 'upcoming' && !isChannel ? { type: 'upcoming' } : {}),
|
||||
publishedAt: snippet.publishedAt || undefined, channelId,
|
||||
channelHandle: snippet.channelTitle || undefined, channelExternalId: channelId,
|
||||
channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined, embeddable,
|
||||
@@ -191,7 +263,11 @@ const handler = {
|
||||
label: 'YouTube',
|
||||
async search(q, opts) {
|
||||
const { limit = 10, page = 1, sort = 'relevance', filters = null } = opts || {};
|
||||
const mode = getSearchMode();
|
||||
// Phase 4.4 : on branche sur le mode **effectif** (eventuellement force sur
|
||||
// scrape-first par la bascule automatique), pas sur l'intention. La cle de
|
||||
// cache inclut ce mode : sinon une bascule ferait servir des resultats
|
||||
// InnerTube comme s'ils venaient du scrape.
|
||||
const mode = getEffectiveSearchMode();
|
||||
const ttl = getScrapeTtlMs();
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||
// La signature des filtres entre dans la clé de cache : deux recherches
|
||||
@@ -224,6 +300,30 @@ const handler = {
|
||||
return items;
|
||||
};
|
||||
const t0 = Date.now();
|
||||
/**
|
||||
* Phase 4.4 — alimente la bascule automatique.
|
||||
*
|
||||
* Deux choses indispensables, et la premiere etait absente :
|
||||
* 1. l'appel InnerTube est un VRAI appel sortant, il doit etre mesure dans
|
||||
* `provider_metrics` sous le provider `yt` ;
|
||||
* 2. le snapshot est lu APRES cet increment, sinon la tentative courante
|
||||
* n'est pas comptee.
|
||||
* Sans (1), la ligne `yt` restait absente de `provider_metrics` (le registre
|
||||
* met en cache les autres fournisseurs, pas YouTube), donc
|
||||
* `recordInnerTubeOutcome` voyait toujours `calls === 0`, la condition
|
||||
* `row.calls < 3` sortait toujours, et le failover ne pouvait jamais
|
||||
* se declencher en production — uniquement dans les tests, qui injectaient
|
||||
* un snapshot synthetique.
|
||||
*/
|
||||
const noteInnerTube = async (ok, latencyMs, error) => {
|
||||
try {
|
||||
const [{ providerMetricsSnapshot, incProviderMetrics }, common] = await Promise.all([
|
||||
import('../db.mjs'), import('./youtube-common.mjs'),
|
||||
]);
|
||||
incProviderMetrics('yt', { ok, latencyMs, error: error || null });
|
||||
common.recordInnerTubeOutcome(ok, providerMetricsSnapshot({ hours: 1 }));
|
||||
} catch {}
|
||||
};
|
||||
const tryScrape = async () => {
|
||||
ytMetrics.scrapeCalls++;
|
||||
try { const { incYoutubeMetrics } = await import('../db.mjs'); incYoutubeMetrics({ scrapeCalls: 1 }); } catch {}
|
||||
@@ -292,14 +392,17 @@ const handler = {
|
||||
// Chaque couche ne fait jamais échouer la recherche à elle seule.
|
||||
const errors = [];
|
||||
try {
|
||||
const tIt = Date.now();
|
||||
const items = await tryInnerTube();
|
||||
log('innertube', items.length);
|
||||
await noteInnerTube(true, Date.now() - tIt);
|
||||
return persist(items, 'innertube');
|
||||
} catch (itErr) {
|
||||
ytMetrics.innertubeErrors = (ytMetrics.innertubeErrors || 0) + 1;
|
||||
ytMetrics.fallbacks++;
|
||||
errors.push(`innertube=${itErr?.message}`);
|
||||
console.warn(`[YT search] innertube failed (${itErr?.code || 'unknown'}), fallback to scrape`);
|
||||
await noteInnerTube(false, Date.now() - tIt, itErr?.code || itErr?.message);
|
||||
}
|
||||
try {
|
||||
const items = await tryScrape();
|
||||
|
||||
Reference in New Issue
Block a user