feat(search): suggestions sans doublons + suggestions locales a la frappe

This commit is contained in:
2026-09-26 13:09:37 -04:00
parent 55c02ac52f
commit 4578f5b887
7 changed files with 206 additions and 25 deletions
+3 -1
View File
@@ -16,6 +16,7 @@ import * as cheerio from 'cheerio';
import axios from 'axios';
import rumbleRouter from './rumble.mjs';
import { providerRegistry, validateProviders } from './providers/registry.mjs';
import { dedupeSuggestGroups } from './suggest.mjs';
import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError } from './transcript.mjs';
import {
getUserByUsername,
@@ -2614,7 +2615,8 @@ app.get('/api/search/suggest', suggestLimiter, async (req, res) => {
groups[providerId] = [];
}
});
const data = { q, groups };
// La même occurrence n'est renvoyée qu'une fois (premier provider gagne).
const data = { q, groups: dedupeSuggestGroups(groups, validProviders) };
suggestCacheSet(cacheKey, data);
return res.json(data);
} catch (e) {
+41
View File
@@ -0,0 +1,41 @@
// Suggest helpers (pure, no network, no DB) — shared dedup policy:
// the same occurrence is only ever returned once (first provider wins).
/** Normalize a suggestion string for dedup comparisons. */
export function normSuggestKey(s) {
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
}
/**
* Deduplicate suggestion groups across providers: the same occurrence
* (case/whitespace-insensitive) is kept only once, in the first provider
* group (registry order). Within a group, order is preserved (first wins).
* @param {Record<string, string[]>} groups
* @param {string[]} order provider ids in priority order
* @returns {Record<string, string[]>} same keys, deduped lists
*/
export function dedupeSuggestGroups(groups, order = ['yt', 'dm', 'tw', 'pt', 'od', 'ru']) {
const src = groups && typeof groups === 'object' ? groups : {};
const seen = new Set();
const out = {};
const pushList = (pid) => {
const list = Array.isArray(src[pid]) ? src[pid] : [];
const clean = [];
for (const raw of list) {
const t = String(raw ?? '').trim();
if (!t) continue;
const k = normSuggestKey(t);
if (seen.has(k)) continue;
seen.add(k);
clean.push(t);
}
out[pid] = clean;
};
for (const pid of order) {
if (Object.prototype.hasOwnProperty.call(src, pid)) pushList(pid);
}
for (const pid of Object.keys(src)) {
if (!order.includes(pid)) pushList(pid);
}
return out;
}
+12
View File
@@ -20,6 +20,7 @@ function logOk(msg) { console.log(`✓ ${msg}`); }
const yt = (await import('../providers/youtube.mjs')).default;
const { parseYoutubeSuggestResponse } = await import('../providers/youtube.mjs');
const dm = (await import('../providers/dailymotion.mjs')).default;
const { dedupeSuggestGroups } = await import('../suggest.mjs');
// Bare JSON payload
expect(
@@ -56,6 +57,17 @@ expect(JSON.stringify(await yt.suggest('a')) === '[]', 'yt.suggest short query r
expect(JSON.stringify(await dm.suggest('x')) === '[]', 'dm.suggest short query returns [] without network');
logOk('suggest min-length guard (no network)');
// Cross-provider dedup: the same occurrence is only returned once (first provider wins)
{
const groups = dedupeSuggestGroups(
{ yt: ['Tutoriel Angular', 'tutoriel android'], dm: ['TUTORIEL angular', 'autre', ''] },
['yt', 'dm'],
);
expect(JSON.stringify(groups.yt) === JSON.stringify(['Tutoriel Angular', 'tutoriel android']), 'first provider keeps its occurrences');
expect(JSON.stringify(groups.dm) === JSON.stringify(['autre']), 'duplicates removed from later providers');
logOk('dedupeSuggestGroups cross-provider (no duplicates)');
}
// ---------- 2) API contract against a real isolated server ----------
const PORT = await new Promise((resolve) => {
const srv = net.createServer();