feat(groups): sujets émergents + analyse approfondie des chaînes

This commit is contained in:
2026-09-27 13:30:11 -04:00
parent dde0747ba3
commit 9b620aaab6
3 changed files with 204 additions and 23 deletions
@@ -41,17 +41,42 @@
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 class="font-semibold text-slate-100">✨ Groupes suggérés</h3>
<p class="text-xs text-slate-400">Analyse des noms de chaînes (mots-clés FR/EN). Vérifiez avant d’appliquer.</p>
<p class="text-xs text-slate-400">Noms de chaînes + sujets détectés. Vérifiez avant d’appliquer.</p>
</div>
<div class="flex flex-wrap gap-2">
<button
*ngIf="!deepAnalyzing()"
type="button"
(click)="runDeepAnalysis()"
class="rounded-md border border-violet-500/50 bg-transparent px-3 py-1.5 text-sm text-violet-200 transition hover:bg-violet-600/20"
title="Lit les titres récents des chaînes sans groupe pour affiner les suggestions"
>
🔍 Analyse approfondie
</button>
<button
*ngIf="deepAnalyzing()"
type="button"
(click)="stopDeepAnalysis()"
class="rounded-md border border-slate-600 px-3 py-1.5 text-sm text-slate-300 transition hover:border-red-400 hover:text-red-200"
>
Arrêter
</button>
<button
*ngIf="suggestions().length > 1"
type="button"
(click)="applyAllSuggestions()"
[disabled]="suggestBusy()"
class="rounded-md bg-violet-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-violet-500 disabled:opacity-40"
>
{{ suggestBusy() ? 'Application…' : 'Tout appliquer' }}
</button>
</div>
</div>
<div *ngIf="deepProgress() as dp" class="mt-2 text-xs text-slate-300">
Analyse des chaînes sans groupe : {{ dp.done }}/{{ dp.total }}
<div class="mt-1 h-1.5 w-full max-w-md overflow-hidden rounded bg-slate-700">
<div class="h-full bg-violet-500 transition-all" [style.width.%]="dp.total ? (dp.done / dp.total * 100) : 0"></div>
</div>
<button
*ngIf="suggestions().length > 1"
type="button"
(click)="applyAllSuggestions()"
[disabled]="suggestBusy()"
class="rounded-md bg-violet-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-violet-500 disabled:opacity-40"
>
{{ suggestBusy() ? 'Application…' : 'Tout appliquer' }}
</button>
</div>
<div *ngIf="suggestions().length === 0" class="mt-3 rounded-lg border border-dashed border-slate-700 p-4 text-center text-sm text-slate-400">
Aucune suggestion : chaque thème a moins de 2 chaînes, ou tout est déjà rangé.
@@ -65,6 +90,7 @@
<span class="font-semibold text-slate-100">{{ s.name }}</span>
<span *ngIf="s.kind === 'create'" class="rounded-full bg-green-900/40 border border-green-700 px-2 py-0.5 text-[11px] text-green-200">Nouveau</span>
<span *ngIf="s.kind === 'complete'" class="rounded-full bg-sky-900/40 border border-sky-700 px-2 py-0.5 text-[11px] text-sky-200">Compléter « {{ s.existingGroupName }} »</span>
<span *ngIf="s.origin === 'topics'" class="rounded-full bg-amber-900/40 border border-amber-700 px-2 py-0.5 text-[11px] text-amber-200">Détecté</span>
<span class="text-xs text-slate-400">{{ s.channelIds.length }} chaîne(s)</span>
</div>
<p class="truncate text-xs text-slate-500">{{ s.sample.join(' • ') }}</p>
@@ -2,11 +2,15 @@ import { ChangeDetectionStrategy, Component, computed, HostListener, inject, sig
import { CommonModule } from '@angular/common';
import { DatePipe, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
GROUP_COLORS,
GROUP_ICONS,
GROUP_ICON_KEYWORDS,
detectEmergentTopics,
normalizeChannelText,
suggestGroupsForChannels,
SubscriptionsService,
type GroupSuggestion,
@@ -93,17 +97,91 @@ export class SubscriptionsComponent {
showSuggestions = signal<boolean>(false);
suggestBusy = signal<boolean>(false);
readonly suggestions = computed<GroupSuggestion[]>(() =>
suggestGroupsForChannels(
this.items().map((item) => ({
subscriptionId: item.subscriptionId,
title: item.channel?.title || '',
handle: item.channel?.handle || null,
groupIds: this.itemGroupIds(item),
})),
this.groups().map((g) => ({ id: g.id, name: g.name })),
)
);
// --- Analyse approfondie : titres des dernières vidéos par chaîne ---
private http = inject(HttpClient);
private deepCancel = false;
deepAnalyzing = signal<boolean>(false);
deepProgress = signal<{ done: number; total: number } | null>(null);
private deepTexts = signal<Record<number, string>>({});
private suggestionBaseItems() {
const extra = this.deepTexts();
return this.items().map((item) => ({
subscriptionId: item.subscriptionId,
title: item.channel?.title || '',
handle: item.channel?.handle || null,
groupIds: this.itemGroupIds(item),
extra: extra[item.subscriptionId] || '',
}));
}
readonly suggestions = computed<GroupSuggestion[]>(() => {
const items = this.suggestionBaseItems();
const groups = this.groups().map((g) => ({ id: g.id, name: g.name }));
const rules = suggestGroupsForChannels(items, groups);
const taken = new Set(rules.map((r) => normalizeChannelText(r.name).trim()));
const topics = detectEmergentTopics(items, {
minSize: 2,
maxGroups: 10,
excludeNames: [...taken, ...groups.map((g) => g.name)],
});
return [...rules, ...topics].sort((a, b) => b.channelIds.length - a.channelIds.length);
});
/** Analyse approfondie : récupère les titres récents des chaînes non groupées. */
async runDeepAnalysis(): Promise<void> {
if (this.deepAnalyzing()) return;
const targets = this.items().filter((item) => {
if (this.deepTexts()[item.subscriptionId] !== undefined) return false;
if (!item.channel?.provider || !item.channel?.externalId) return false;
return (this.itemGroupIds(item).length === 0);
});
if (!targets.length) return;
this.deepCancel = false;
this.deepAnalyzing.set(true);
this.deepProgress.set({ done: 0, total: targets.length });
const texts: Record<number, string> = { ...this.deepTexts() };
const counter = { done: 0 };
const queue = [...targets];
const workers = Array.from({ length: 4 }, () => this.deepWorker(queue, texts, counter, targets.length));
await Promise.all(workers);
this.deepTexts.set(texts);
this.deepProgress.set(null);
this.deepAnalyzing.set(false);
}
stopDeepAnalysis(): void {
this.deepCancel = true;
}
private async deepWorker(
queue: SubscriptionItem[],
texts: Record<number, string>,
counter: { done: number },
total: number,
): Promise<void> {
while (queue.length && !this.deepCancel) {
const item = queue.shift();
if (!item?.channel) continue;
try {
const res: any = await firstValueFrom(
this.http.get(
`/api/channels/${encodeURIComponent(item.channel.provider)}/${encodeURIComponent(item.channel.externalId)}/content`,
{ params: { type: 'videos', limit: '5' } },
),
);
const titles = ((res?.items || []) as any[])
.map((v: any) => String(v?.title || ''))
.filter(Boolean)
.slice(0, 5);
texts[item.subscriptionId] = titles.join(' ');
} catch {
texts[item.subscriptionId] = '';
}
counter.done++;
this.deepProgress.set({ done: Math.min(counter.done, total), total });
}
}
/** Crée le groupe suggéré (ou complète l'existant) et y assigne les chaînes. */
applySuggestion(s: GroupSuggestion): Promise<void> {
+79 -2
View File
@@ -173,6 +173,8 @@ export const GROUP_ICON_KEYWORDS: Record<string, string> = {
/** Suggestion de rangement : créer un groupe ou compléter un groupe existant. */
export interface GroupSuggestion {
kind: 'create' | 'complete';
/** 'rules' = règles mot-clé, 'topics' = sujet émergent détecté. */
origin: 'rules' | 'topics';
name: string;
icon: string;
color: string;
@@ -221,12 +223,85 @@ export function normalizeChannelText(value: string | null | undefined): string {
.toLowerCase();
}
/** Mots vides FR/EN + termes YouTube génériques (évitent les méga-groupes inutiles). */
const TOPIC_STOPWORDS = new Set([
'avec', 'dans', 'pour', 'sans', 'sous', 'vers', 'chez', 'entre', 'contre', 'depuis',
'cette', 'celui', 'celle', 'ceux', 'celles', 'notre', 'votre', 'leur', 'leurs', 'tous',
'toute', 'toutes', 'aussi', 'encore', 'tres', 'bien', 'plus', 'moins', 'comme', 'mais',
'donc', 'alors', 'quand', 'comment', 'pourquoi', 'parce', 'sont', 'etes', 'font', 'vont',
'cest', 'nest', 'plus', 'tout', 'tous', 'aux', 'ces', 'ses', 'mon', 'mes', 'ton', 'tes',
'son', 'nous', 'vous', 'ils', 'elles', 'elle', 'celui', 'dont', 'ou', 'parce',
'the', 'and', 'for', 'with', 'from', 'your', 'you', 'your', 'this', 'that', 'these',
'those', 'are', 'was', 'were', 'been', 'have', 'has', 'will', 'would', 'there',
'their', 'what', 'when', 'where', 'which', 'while', 'about', 'into', 'over',
'after', 'before', 'your', 'videos', 'video', 'tube', 'youtube', 'channel', 'chaine',
'chanel', 'official', 'officiel', 'tv', 'media', 'network', 'studio', 'production',
'streaming', 'live', 'clips', 'shorts', 'france', 'francais', 'quebec', 'canada',
'montreal', 'paris', 'best', 'top', 'new', 'show', 'central', 'daily', 'hub', 'one',
]);
/** Devine une icône pour un mot (recherche inverse dans les mots-clés). */
export function guessIconForWord(word: string): string {
const w = normalizeChannelText(word).trim();
if (!w) return '📁';
for (const [emoji, kws] of Object.entries(GROUP_ICON_KEYWORDS)) {
if (String(kws).toLowerCase().split(/\s+/).includes(w)) return emoji;
}
return '📁';
}
/**
* Sujets émergents : mots significatifs partagés par ≥2 chaînes (titres,
* handles, + texte enrichi de l'analyse approfondie). Attrape ce que les
* règles ratent (noms de marques, communautés, sujets de niche).
* Pur et testable : ne touche pas au réseau.
*/
export function detectEmergentTopics(
items: Array<{ subscriptionId: number; title: string; handle?: string | null; extra?: string }>,
opts?: { minSize?: number; maxGroups?: number; excludeNames?: string[] },
): GroupSuggestion[] {
const minSize = Math.max(2, opts?.minSize || 2);
const maxGroups = Math.max(1, opts?.maxGroups || 10);
const excluded = new Set((opts?.excludeNames || []).map((n) => normalizeChannelText(n).trim()));
const byWord = new Map<string, typeof items>();
for (const item of items) {
const hay = normalizeChannelText(`${item.title || ''} ${item.handle || ''} ${item.extra || ''}`);
const words = new Set(
hay.split(/[^a-z0-9]+/).filter((w) => w.length >= 4 && !/^\d+$/.test(w) && !TOPIC_STOPWORDS.has(w)),
);
for (const w of words) {
const list = byWord.get(w) || [];
list.push(item);
byWord.set(w, list);
}
}
const out: GroupSuggestion[] = [];
const sorted = [...byWord.entries()]
.filter(([, list]) => list.length >= minSize)
.sort((a, b) => b[1].length - a[1].length)
.slice(0, maxGroups);
sorted.forEach(([word, list], idx) => {
const name = word.charAt(0).toUpperCase() + word.slice(1);
if (excluded.has(normalizeChannelText(name))) return;
out.push({
kind: 'create',
origin: 'topics',
name,
icon: guessIconForWord(word),
color: GROUP_COLORS[(idx * 5 + 3) % GROUP_COLORS.length],
channelIds: list.map((m) => m.subscriptionId),
sample: list.slice(0, 3).map((m) => m.title).filter(Boolean),
});
});
return out;
}
/**
* Analyse les chaînes et propose des groupes (création ou complétion).
* Pur et testable : ne touche pas au réseau.
*/
export function suggestGroupsForChannels(
items: Array<{ subscriptionId: number; title: string; handle?: string | null; groupIds: string[] }>,
items: Array<{ subscriptionId: number; title: string; handle?: string | null; groupIds: string[]; extra?: string }>,
existingGroups: Array<{ id: string; name: string }>,
): GroupSuggestion[] {
const byRule = new Map<number, typeof items>();
@@ -237,7 +312,7 @@ export function suggestGroupsForChannels(
return kw.length <= 4 ? new RegExp(`\\b${esc}(s|x)?\\b`) : new RegExp(`\\b${esc}\\w*`);
};
for (const item of items) {
const hay = normalizeChannelText(`${item.title || ''} ${item.handle || ''}`);
const hay = normalizeChannelText(`${item.title || ''} ${item.handle || ''} ${item.extra || ''}`);
if (!hay.trim()) continue;
let best = -1;
let bestScore = 0;
@@ -268,6 +343,7 @@ export function suggestGroupsForChannels(
if (!missing.length) continue;
suggestions.push({
kind: 'complete',
origin: 'rules',
name: existing.name,
icon: rule.icon,
color: GROUP_COLORS[rule.color % GROUP_COLORS.length],
@@ -279,6 +355,7 @@ export function suggestGroupsForChannels(
} else {
suggestions.push({
kind: 'create',
origin: 'rules',
name: rule.name,
icon: rule.icon,
color: GROUP_COLORS[rule.color % GROUP_COLORS.length],