131 lines
4.4 KiB
JavaScript
131 lines
4.4 KiB
JavaScript
// 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 [];
|
|
}
|
|
}
|