feat(groups): sujets émergents + analyse approfondie des chaînes
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user