fix(themes): pages themes Twitch vides -> requetes Helix tokenisees + fallbacks game_id/top-streams
This commit is contained in:
+110
-14
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user