// Web + Odysee suggestion enrichment (free APIs, no key required). // Pure parsers are exported for offline unit tests; fetchers never throw. const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'; function webTimeoutMs() { const n = Number(process.env.SUGGEST_WEB_TIMEOUT_MS || 4000); return Number.isFinite(n) && n > 0 ? Math.min(n, 15000) : 4000; } /** * Parse an OSJSON response (`["q", ["s1", "s2", ...], ...]`) shared by * Bing (`osjson.aspx`) and Google (`client=chrome`). Garbage yields []. */ export function parseOsjsonList(text) { try { const data = JSON.parse(String(text || '')); const list = Array.isArray(data) ? data[1] : null; if (!Array.isArray(list)) return []; return list.map((s) => String(s ?? '').trim()).filter(Boolean); } catch { return []; } } /** Bing autocomplete (no key): fr suggestions, up to `limit`. */ export async function fetchBingSuggest(q, { limit = 10, lang = 'fr' } = {}) { const query = String(q || '').trim(); if (query.length < 2) return []; try { const params = new URLSearchParams({ query, language: String(lang || 'fr') }); const resp = await fetch(`https://api.bing.com/osjson.aspx?${params.toString()}`, { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: AbortSignal.timeout(webTimeoutMs()), }); if (!resp.ok) return []; return parseOsjsonList(await resp.text()).slice(0, limit); } catch { return []; } } /** Google autocomplete, `client=chrome` flavour (no key). Same infra family * as the YouTube suggest already used, but generic web suggestions. */ export async function fetchGoogleChromeSuggest(q, { limit = 10, lang = 'fr' } = {}) { const query = String(q || '').trim(); if (query.length < 2) return []; try { const params = new URLSearchParams({ client: 'chrome', q: query, hl: String(lang || 'fr') }); const resp = await fetch(`https://suggestqueries.google.com/complete/search?${params.toString()}`, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(webTimeoutMs()), }); if (!resp.ok) return []; return parseOsjsonList(await resp.text()).slice(0, limit); } catch { return []; } } /** Turn an Odysee/LBRY claim `name` slug into a readable suggestion. */ export function humanizeClaimName(name) { return String(name || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim(); } /** * Parse a Lighthouse (Odysee search API, no key) response into readable * video-title suggestions. */ export function parseLighthouseList(data, limit = 10) { const arr = Array.isArray(data) ? data : []; const out = []; for (const item of arr) { const t = humanizeClaimName(item?.name); if (t && t.length >= 3) out.push(t); if (out.length >= limit) break; } return out; } /** Odysee Lighthouse full-text search (no key): real Odysee video titles. */ export async function fetchOdyseeLighthouseSuggest(q, { limit = 10 } = {}) { const query = String(q || '').trim(); if (query.length < 2) return []; try { const params = new URLSearchParams({ s: query, size: String(Math.min(20, Math.max(1, limit))), from: '0' }); const resp = await fetch(`https://lighthouse.odysee.com/search?${params.toString()}`, { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: AbortSignal.timeout(webTimeoutMs()), }); if (!resp.ok) return []; return parseLighthouseList(await resp.json(), limit); } catch { return []; } } function normKey(s) { return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' '); } /** * Combined generic web suggestions (Bing + Google chrome, in parallel), * deduplicated (Bing first). Never throws. */ export async function fetchWebSuggest(q, { limit = 10, lang = 'fr' } = {}) { const query = String(q || '').trim(); if (query.length < 2) return []; const lim = Math.min(20, Math.max(1, Number(limit) || 10)); try { const [bing, chrome] = await Promise.all([ fetchBingSuggest(query, { limit: lim, lang }), fetchGoogleChromeSuggest(query, { limit: lim, lang }), ]); const seen = new Set(); const out = []; for (const t of [...(bing || []), ...(chrome || [])]) { const clean = String(t ?? '').trim(); if (!clean) continue; const k = normKey(clean); if (seen.has(k)) continue; seen.add(k); out.push(clean); if (out.length >= lim) break; } return out; } catch { return []; } }