feat(search): suggestions sans doublons + suggestions locales a la frappe
This commit is contained in:
+3
-1
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { firstValueFrom, of } from 'rxjs';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { SuggestService } from './suggest.service';
|
||||
import { dedupeSort, mergeGroups, highlightParts } from './suggest.util';
|
||||
import { dedupeSort, mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions } from './suggest.util';
|
||||
|
||||
/**
|
||||
* Step 15 — Unit tests for query typeahead (parsing / dedup / min-length guard).
|
||||
@@ -57,6 +57,23 @@ function logOk(msg: string): void {
|
||||
);
|
||||
logOk('mergeGroups order + dedup');
|
||||
|
||||
// --- Pure helpers: dedupeStrings (whitespace + case variants) ---
|
||||
assertEqual(
|
||||
dedupeStrings([' Tutoriel Angular ', 'tutoriel angular', 'TUTORIEL ANGULAR', 'autre', '', ' ']),
|
||||
['Tutoriel Angular', 'autre'],
|
||||
'dedupeStrings shows each occurrence only once (first wins)',
|
||||
);
|
||||
logOk('dedupeStrings dedup');
|
||||
|
||||
// --- Pure helpers: local fallback suggestions while typing ---
|
||||
assertEqual(buildLocalQuerySuggestions('a'), [], 'no local suggestions below 2 chars');
|
||||
const local = buildLocalQuerySuggestions('angular', 8);
|
||||
assert(local.length > 0 && local.length <= 8, 'local suggestions fill the panel while typing');
|
||||
assertEqual(dedupeStrings(local), local, 'local suggestions contain no duplicates');
|
||||
assert(!local.some((s) => s.trim().toLowerCase() === 'angular'), 'local suggestions never repeat the raw query');
|
||||
assert(local.some((s) => s.toLowerCase().includes('angular')), 'local suggestions derive from the typed text');
|
||||
logOk('buildLocalQuerySuggestions fallback');
|
||||
|
||||
// --- Pure helpers: highlight ---
|
||||
assertEqual(
|
||||
highlightParts('Tutoriel Angular', 'toriel an'),
|
||||
|
||||
@@ -34,6 +34,53 @@ export function dedupeSort(items: string[], q = ''): string[] {
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate a flat string list (case/whitespace-insensitive, first
|
||||
* occurrence wins). Order is preserved.
|
||||
*/
|
||||
export function dedupeStrings(items: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of items || []) {
|
||||
const t = String(raw ?? '').trim();
|
||||
if (!t) continue;
|
||||
const k = normSuggest(t);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local query suggestions built while typing, without any network call.
|
||||
* Used as a fallback tier when provider suggestions are missing or sparse
|
||||
* (providers without a suggest API, offline, rate-limited): typing always
|
||||
* yields suggestions. Output is deduplicated; the raw query itself is never
|
||||
* included.
|
||||
*/
|
||||
export function buildLocalQuerySuggestions(q: string, max = 10): string[] {
|
||||
const base = String(q ?? '').trim().replace(/\s+/g, ' ');
|
||||
if (base.length < 2) return [];
|
||||
const year = new Date().getFullYear();
|
||||
const candidates = [
|
||||
`${base} official`,
|
||||
`${base} live`,
|
||||
`${base} lyrics`,
|
||||
`${base} remix`,
|
||||
`${base} cover`,
|
||||
`${base} tutorial`,
|
||||
`${base} review`,
|
||||
`${base} playlist`,
|
||||
`${base} best of`,
|
||||
`${base} full album`,
|
||||
`${base} ${year}`,
|
||||
`"${base}"`,
|
||||
];
|
||||
const n = Math.max(1, Number(max || 10));
|
||||
return dedupeStrings(candidates.filter((c) => normSuggest(c) !== normSuggest(base))).slice(0, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten provider groups into a single ordered list (registry order),
|
||||
* deduped case-insensitively. Unknown provider keys go last (insertion order).
|
||||
|
||||
@@ -49,19 +49,28 @@ export class HeaderComponent {
|
||||
// Suggestion item shape: history vs generated
|
||||
readonly suggestionItems = computed<{ text: string; source: 'history' | 'generated' }[]>(() => {
|
||||
type Item = { text: string; source: 'history' | 'generated' };
|
||||
const norm = (s: string) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
const q = (this.searchQuery || '').trim();
|
||||
const provider = this.selectedProvider();
|
||||
const fromHistory = (this.recentSearches() || [])
|
||||
.map(it => (it.query || '').trim())
|
||||
.filter(Boolean);
|
||||
// Historique dédupliqué (la même recherche ne s'affiche qu'une fois).
|
||||
const seenHistory = new Set<string>();
|
||||
const historyBase: string[] = [];
|
||||
for (const it of this.recentSearches() || []) {
|
||||
const txt = (it.query || '').trim();
|
||||
if (!txt) continue;
|
||||
const k = norm(txt);
|
||||
if (seenHistory.has(k)) continue;
|
||||
seenHistory.add(k);
|
||||
historyBase.push(txt);
|
||||
}
|
||||
|
||||
// 1) Base list from history
|
||||
let historyList: string[];
|
||||
if (!q) {
|
||||
historyList = fromHistory.slice(0, 15);
|
||||
historyList = historyBase.slice(0, 15);
|
||||
} else {
|
||||
const lower = q.toLowerCase();
|
||||
historyList = fromHistory.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15);
|
||||
historyList = historyBase.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15);
|
||||
}
|
||||
|
||||
const items: Item[] = historyList.map(text => ({ text, source: 'history' }));
|
||||
@@ -70,15 +79,18 @@ export class HeaderComponent {
|
||||
if (items.length < 15) {
|
||||
const need = 15 - items.length;
|
||||
const generated = this.generateQuerySuggestions(q, provider);
|
||||
// Avoid duplicates with history
|
||||
const existing = new Set(items.map(i => i.text.toLowerCase()));
|
||||
// Avoid duplicates with history (normalized comparison)
|
||||
const existing = new Set(items.map(i => norm(i.text)));
|
||||
for (const g of generated) {
|
||||
const t = (g || '').trim();
|
||||
if (!t) continue;
|
||||
if (existing.has(t.toLowerCase())) continue;
|
||||
const k = norm(t);
|
||||
if (existing.has(k)) continue;
|
||||
existing.add(k);
|
||||
items.push({ text: t, source: 'generated' });
|
||||
if (items.length >= 15) break;
|
||||
}
|
||||
void need;
|
||||
}
|
||||
|
||||
// Reset highlight if list size changes or becomes empty
|
||||
@@ -152,10 +164,12 @@ export class HeaderComponent {
|
||||
tokens.push(`${base} highlights`, `${base} podcast`);
|
||||
}
|
||||
|
||||
// Keep unique and meaningful
|
||||
// Keep unique and meaningful (normalized: case + whitespace)
|
||||
const normTok = (s: string) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
const seen = new Set<string>();
|
||||
for (const t of tokens) {
|
||||
const key = t.toLowerCase();
|
||||
const key = normTok(t);
|
||||
if (!key || key === normTok(base)) continue;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
suggestions.push(t);
|
||||
@@ -352,7 +366,15 @@ export class HeaderComponent {
|
||||
this.history.getSearchHistory(15).subscribe({
|
||||
next: (items) => {
|
||||
// API likely returns newest first; ensure we keep order and dedupe by query
|
||||
this.recentSearches.set(items || []);
|
||||
const seen = new Set<string>();
|
||||
const clean: SearchHistoryItem[] = [];
|
||||
for (const it of items || []) {
|
||||
const k = String(it?.query || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
clean.push(it);
|
||||
}
|
||||
this.recentSearches.set(clean);
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Subject, of } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, switchMap, catchError, finalize } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { SuggestService } from '../../app/search/suggest.service';
|
||||
import { mergeGroups, highlightParts } from '../../app/search/suggest.util';
|
||||
import { mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions, normSuggest } from '../../app/search/suggest.util';
|
||||
// No overlays; we render fixed-position modals with high z-index
|
||||
|
||||
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
|
||||
@@ -87,16 +87,32 @@ export class SearchBoxComponent {
|
||||
private suggestShownFor: string | null = null;
|
||||
readonly suggestFlat = computed((): SuggestFlatItem[] => {
|
||||
const q = this.query();
|
||||
// Tier 1 — recherches récentes (dédupliquées, une seule fois chacune).
|
||||
const recents = (this.recentSuggest() || []).slice(0, 5).map((t) => ({ text: t, provider: null as string | null }));
|
||||
const seen = new Set(recents.map((r) => r.text.toLowerCase()));
|
||||
const seen = new Set(recents.map((r) => normSuggest(r.text)));
|
||||
// Tier 2 — suggestions providers, dédupliquées entre providers (mergeGroups)
|
||||
// et contre l'historique.
|
||||
const merged = mergeGroups(this.suggestGroups() || {}).filter((m) => {
|
||||
const k = m.text.toLowerCase();
|
||||
const k = normSuggest(m.text);
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
const out: SuggestFlatItem[] = [...recents, ...merged];
|
||||
// Tier 3 — suggestions locales générées à la frappe (sans réseau) : la
|
||||
// frappe propose toujours quelque chose, même sans provider suggest
|
||||
// (tw/pt/od/ru), hors-ligne ou en cas de réponse vide.
|
||||
if (out.length < 10) {
|
||||
for (const t of buildLocalQuerySuggestions(q, 10 - out.length)) {
|
||||
const k = normSuggest(t);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push({ text: t, provider: null });
|
||||
if (out.length >= 15) break;
|
||||
}
|
||||
}
|
||||
void q;
|
||||
return [...recents, ...merged].slice(0, 15);
|
||||
return out.slice(0, 15);
|
||||
});
|
||||
|
||||
// Element to restore focus to when a modal closes (a11y)
|
||||
@@ -247,21 +263,28 @@ export class SearchBoxComponent {
|
||||
/** Filter cached history synchronously; refresh the cache at most every 10s. */
|
||||
private refreshRecentSuggest(value: string) {
|
||||
const q = String(value || '').trim().toLowerCase();
|
||||
if (q.length >= 2 && this.allRecentCache.length > 0) {
|
||||
this.recentSuggest.set(this.allRecentCache.filter((t) => t.toLowerCase().includes(q)).slice(0, 5));
|
||||
if (this.allRecentCache.length > 0) {
|
||||
// Historique dédupliqué : la même recherche ne s'affiche qu'une fois.
|
||||
this.recentSuggest.set(
|
||||
q.length >= 2
|
||||
? this.allRecentCache.filter((t) => t.toLowerCase().includes(q)).slice(0, 5)
|
||||
: this.allRecentCache.slice(0, 5),
|
||||
);
|
||||
} else if (q.length < 2) {
|
||||
this.recentSuggest.set([]);
|
||||
}
|
||||
const now = Date.now();
|
||||
if (q.length < 2 || now - this.lastRecentFetch < 10_000) return;
|
||||
if (now - this.lastRecentFetch < 10_000) return;
|
||||
this.lastRecentFetch = now;
|
||||
try {
|
||||
this.history.getSearchHistory(15).subscribe({
|
||||
next: (items) => {
|
||||
this.allRecentCache = (items || []).map((i) => String(i?.query || '').trim()).filter(Boolean);
|
||||
this.allRecentCache = dedupeStrings((items || []).map((i) => String(i?.query || '').trim()).filter(Boolean));
|
||||
const cur = String(this.query() || '').trim().toLowerCase();
|
||||
if (cur.length >= 2) {
|
||||
this.recentSuggest.set(this.allRecentCache.filter((t) => t.toLowerCase().includes(cur)).slice(0, 5));
|
||||
} else {
|
||||
this.recentSuggest.set(this.allRecentCache.slice(0, 5));
|
||||
}
|
||||
},
|
||||
error: () => {},
|
||||
@@ -300,7 +323,9 @@ export class SearchBoxComponent {
|
||||
}
|
||||
|
||||
onSuggestInputFocus() {
|
||||
if (!this.atOpen() && String(this.query() || '').trim().length >= 2 && this.suggestFlat().length > 0) {
|
||||
// Même avec moins de 2 caractères, on propose l'historique récent.
|
||||
this.refreshRecentSuggest(this.query());
|
||||
if (!this.atOpen() && this.suggestFlat().length > 0) {
|
||||
this.suggestionsOpen.set(true);
|
||||
}
|
||||
}
|
||||
@@ -475,8 +500,23 @@ export class SearchBoxComponent {
|
||||
this.saveFocus();
|
||||
this.quickOpen.set(true);
|
||||
this.telemetry.track('quick_menu_open');
|
||||
// Load last 15 searches
|
||||
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
|
||||
// Load last 15 searches (deduped: same query shown once)
|
||||
try {
|
||||
this.history.getSearchHistory(15).subscribe({
|
||||
next: (items) => {
|
||||
const seen = new Set<string>();
|
||||
const clean: SearchHistoryItem[] = [];
|
||||
for (const it of items || []) {
|
||||
const k = normSuggest(it?.query || '');
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
clean.push(it);
|
||||
}
|
||||
this.recentSearches.set(clean);
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
closeQuickMenu() {
|
||||
if (!this.quickOpen()) return;
|
||||
|
||||
Reference in New Issue
Block a user