fix(themes): pages themes Twitch vides -> requetes Helix tokenisees + fallbacks game_id/top-streams

This commit is contained in:
2026-09-26 18:46:55 -04:00
parent db03b70854
commit 21f2296c41
4 changed files with 389 additions and 87 deletions
+110 -14
View File
@@ -224,18 +224,79 @@ function mapClip(c) {
};
}
/**
* Mots-clés unitaires exploitables par Helix : /search/channels et
* /search/categories ne matchent que des noms proches, jamais de longues
* phrases multi-mots (ex. "cooking food Food Cooking Recipe").
*/
function twitchKeywords(q, max = 3) {
const words = String(q || '')
.toLowerCase()
.split(/[^a-z0-9à-ÿ]+/i)
.map((w) => w.trim())
.filter((w) => w.length >= 3);
const seen = new Set();
const out = [];
for (const w of words) {
if (seen.has(w)) continue;
seen.add(w);
out.push(w);
if (out.length >= max) break;
}
return out;
}
/** Catégorie Twitch la plus pertinente pour une requête (ex. "sports" -> Sports). */
async function findCategoryId(q, auth) {
try {
const data = await helixGet('/search/categories', new URLSearchParams({ query: q, first: '5' }), auth, { retries: 1 });
const list = Array.isArray(data?.data) ? data.data : [];
if (!list.length) return null;
const needle = String(q || '').trim().toLowerCase();
const exact = list.find((c) => String(c?.name || '').toLowerCase() === needle);
return (exact || list[0])?.id || null;
} catch {
return null;
const attempts = [String(q || '').trim(), ...twitchKeywords(q, 3)].filter(Boolean).slice(0, 4);
// Déduplique en conservant l'ordre (requête complète d'abord).
const seen = new Set();
const ordered = attempts.filter((a) => {
const k = String(a).toLowerCase();
if (seen.has(k)) return false;
seen.add(k);
return true;
});
for (const attempt of ordered) {
try {
const data = await helixGet('/search/categories', new URLSearchParams({ query: attempt, first: '5' }), auth, { retries: 1 });
const list = Array.isArray(data?.data) ? data.data : [];
if (!list.length) continue;
const needle = String(attempt || '').trim().toLowerCase();
const exact = list.find((c) => String(c?.name || '').toLowerCase() === needle);
const best = exact || list[0];
if (best?.id) return best.id;
} catch {
// Essaie le mot-clé suivant.
}
}
return null;
}
/** Recherche de chaînes sur plusieurs mots-clés (fusion dédupliquée). */
async function searchChannelsMulti(queries, first, auth, after = '') {
const seen = new Set();
const merged = [];
let cursor = null;
for (let i = 0; i < queries.length; i++) {
try {
const params = new URLSearchParams({ query: queries[i], first: String(first) });
if (i === 0 && after) params.set('after', after);
const data = await helixGet('/search/channels', params, auth, { retries: 1 });
const list = Array.isArray(data?.data) ? data.data : [];
if (i === 0) cursor = data?.pagination?.cursor || null;
else if (!cursor && data?.pagination?.cursor) cursor = data.pagination.cursor;
for (const c of list) {
const key = String(c?.id || '');
if (!key || seen.has(key)) continue;
seen.add(key);
merged.push(c);
}
// Assez de candidats : inutile de spammer Helix.
if (merged.length >= 10) break;
} catch {}
}
return { channels: merged, cursor };
}
const handler = {
@@ -258,16 +319,42 @@ const handler = {
const targetPage = Math.max(1, Number(page || 1));
// 1) Chaînes (paginées : la pagination repose sur ce curseur).
// Helix ne matche pas les longues phrases : on cherche la requête
// complète puis chaque mot-clé (ex. "cooking" pour "cooking food...").
const channelQueries = [query, ...twitchKeywords(query, 3).filter((k) => k !== query.toLowerCase())].slice(0, 4);
let after = '';
let currentPage = 1;
let channelsData = [];
let channelsCursor = null;
while (currentPage <= targetPage) {
const params = new URLSearchParams({ query, first: String(Math.min(20, perPage)) });
if (after) params.set('after', after);
const data = await helixGet('/search/channels', params, auth);
const list = Array.isArray(data?.data) ? data.data : [];
channelsCursor = data?.pagination?.cursor || null;
const { channels, cursor } = await searchChannelsMulti(
channelQueries,
Math.min(20, perPage),
auth,
after,
);
// Alternative brute si Helix a tout filtré : top streams comme base de chaînes.
let list = channels;
let cur = cursor;
if (!list.length && currentPage === 1 && targetPage === 1) {
try {
const top = await helixGet('/streams', new URLSearchParams({ first: '20' }), auth, { retries: 1 });
const streams = Array.isArray(top?.data) ? top.data : [];
list = streams.map((s) => ({
id: s.user_id,
broadcaster_login: s.user_login,
user_login: s.user_login,
display_name: s.user_name,
is_live: true,
title: s.title,
thumbnail_url: s.thumbnail_url,
started_at: s.started_at,
game_name: s.game_name,
}));
cur = top?.pagination?.cursor || null;
} catch {}
}
channelsCursor = cur;
if (currentPage === targetPage) {
channelsData = list;
break;
@@ -445,6 +532,15 @@ const handler = {
seen.add(key);
out.push(item);
}
// Filet de sécurité : ne jamais renvoyer une page thème vide quand
// Twitch est configuré (thèmes sans chaîne ni catégorie correspondante).
if (!out.length && targetPage === 1) {
try {
const top = await helixGet('/streams', new URLSearchParams({ first: String(Math.min(20, perPage)) }), auth, { retries: 1 });
const fallback = (Array.isArray(top?.data) ? top.data : []).map(mapStream);
return fallback;
} catch {}
}
return out;
} catch (error) {
console.error('Twitch search error:', error?.message || error);