Files
youtube_personal/lib/services/interest_profile_service.dart
T

1043 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/video_item.dart';
class InterestLearningScore {
const InterestLearningScore({
required this.points,
required this.positivePoints,
required this.negativePoints,
required this.channelPoints,
required this.reasons,
});
final int points;
final int positivePoints;
final int negativePoints;
final int channelPoints;
final List<String> reasons;
}
class InterestFeedbackEntry {
const InterestFeedbackEntry({
required this.video,
required this.category,
required this.rating,
required this.badQuality,
required this.notInterested,
required this.updatedAt,
});
final VideoItem video;
final String category;
final int rating;
final bool badQuality;
final bool notInterested;
final int updatedAt;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'video': video.toJson(),
'category': category,
'rating': rating,
'badQuality': badQuality,
'notInterested': notInterested,
'updatedAt': updatedAt,
};
}
factory InterestFeedbackEntry.fromJson(Map<String, dynamic> json) {
final rawVideo = json['video'];
if (rawVideo is! Map) {
throw const FormatException('video absent');
}
return InterestFeedbackEntry(
video: VideoItem.fromJson(
rawVideo.map(
(key, value) => MapEntry('$key', value),
),
),
category: json['category'] is String
? json['category'] as String
: InterestProfileService.allCategory,
rating: json['rating'] is num
? (json['rating'] as num).toInt().clamp(0, 5).toInt()
: 0,
badQuality: json['badQuality'] == true,
notInterested: json['notInterested'] == true,
updatedAt: json['updatedAt'] is num
? (json['updatedAt'] as num).toInt()
: 0,
);
}
}
class InterestProfileService {
InterestProfileService._();
static final InterestProfileService instance =
InterestProfileService._();
static const String allCategory = 'Tous';
static const List<String> categories = <String>[
allCategory,
'Politique',
'Jeux vidéo',
'Documentaires',
'Paranormal',
'Islam',
'Football',
'High-tech',
];
static const String _entriesKey =
'youtube_personal.interest_feedback.v1';
static const String _activeCategoryKey =
'youtube_personal.interest_active_category.v1';
static const String _videoCategoryOverridesKey =
'youtube_personal.interest_video_category_overrides.v1';
static const String _channelCategoryOverridesKey =
'youtube_personal.interest_channel_category_overrides.v1';
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
final ValueNotifier<String> activeCategory =
ValueNotifier<String>(allCategory);
final ValueNotifier<int> revision = ValueNotifier<int>(0);
final Map<String, Map<String, InterestFeedbackEntry>> _entries =
<String, Map<String, InterestFeedbackEntry>>{};
// 18.7.25 : l'utilisateur peut corriger le classificateur.
// Priorité : vidéo > chaîne > détection automatique.
final Map<String, String> _videoCategoryOverrides = <String, String>{};
final Map<String, String> _channelCategoryOverrides = <String, String>{};
bool _loaded = false;
Future<void>? _loading;
Future<void> ensureLoaded() {
if (_loaded) {
return Future<void>.value();
}
final current = _loading;
if (current != null) {
return current;
}
final future = _load();
_loading = future;
return future;
}
Future<void> _load() async {
try {
final savedCategory =
await _preferences.getString(_activeCategoryKey);
if (savedCategory == 'Horreur') {
activeCategory.value = 'Paranormal';
await _preferences.setString(_activeCategoryKey, 'Paranormal');
} else if (savedCategory != null && categories.contains(savedCategory)) {
activeCategory.value = savedCategory;
}
await _loadCategoryOverrides();
final raw = await _preferences.getString(_entriesKey);
if (raw != null && raw.trim().isNotEmpty) {
final decoded = jsonDecode(raw);
if (decoded is Map) {
for (final categoryEntry in decoded.entries) {
final category = '${categoryEntry.key}';
final rawItems = categoryEntry.value;
if (rawItems is! Map) {
continue;
}
final bucket = <String, InterestFeedbackEntry>{};
for (final item in rawItems.entries) {
final value = item.value;
if (value is! Map) {
continue;
}
try {
final parsed = InterestFeedbackEntry.fromJson(
value.map(
(key, value) => MapEntry('$key', value),
),
);
bucket[parsed.video.id] = parsed;
} catch (_) {
// Ignore a corrupted historical feedback entry.
}
}
if (bucket.isNotEmpty) {
_entries[category] = bucket;
}
}
}
}
// 18.7.23d : renommage de la catégorie Horreur -> Paranormal.
// Toutes les notes et feedbacks existants sont conservés.
final paranormalMigrated = _migrateLegacyHorrorFeedback();
// 18.7.23a : les anciens feedbacks saisis depuis le profil `Tous`
// pouvaient être rangés dans le bucket technique `Autres` lorsque le
// classificateur ne connaissait pas encore une marque ou un terme.
// On ne migre QUE `Autres` : les buckets sélectionnables représentent
// potentiellement un choix explicite de l'utilisateur et ne doivent pas
// être reclassés automatiquement.
final migrated = _migrateLegacyOtherFeedback();
if (paranormalMigrated > 0 || migrated > 0) {
await _persistLoadedEntries();
}
if (paranormalMigrated > 0) {
debugPrint(
'[INTEREST][18.7.23d] $paranormalMigrated feedback(s) Horreur migré(s) vers Paranormal',
);
}
if (migrated > 0) {
debugPrint(
'[INTEREST][18.7.23a] $migrated feedback(s) Autres reclassé(s)',
);
}
} catch (error) {
debugPrint('[INTEREST][18.7.23] chargement ignoré: $error');
} finally {
_loaded = true;
_loading = null;
}
}
Future<void> setActiveCategory(String category) async {
await ensureLoaded();
final safe = categories.contains(category) ? category : allCategory;
if (activeCategory.value == safe) {
return;
}
activeCategory.value = safe;
await _preferences.setString(_activeCategoryKey, safe);
}
List<String> get assignableCategories => categories
.where((category) => category != allCategory)
.toList(growable: false);
String? videoCategoryOverride(VideoItem video) {
final value = _videoCategoryOverrides[video.id];
return value != null && categories.contains(value) && value != allCategory
? value
: null;
}
String? channelCategoryOverride(VideoItem video) {
for (final key in _channelLookupKeys(video)) {
final value = _channelCategoryOverrides[key];
if (value != null && categories.contains(value) && value != allCategory) {
return value;
}
}
return null;
}
String? manualCategoryFor(VideoItem video) {
return videoCategoryOverride(video) ?? channelCategoryOverride(video);
}
Future<void> setVideoCategoryOverride(
VideoItem video,
String? category,
) async {
await ensureLoaded();
final safe = _safeAssignableCategory(category);
if (safe == null) {
_videoCategoryOverrides.remove(video.id);
} else {
_videoCategoryOverrides[video.id] = safe;
}
final target = manualCategoryFor(video) ?? classifyVideoAutomatically(video);
_moveFeedbackForVideoToCategory(video, target);
await _persistCategoryOverrides();
await _persistLoadedEntries();
revision.value++;
debugPrint(
'[INTEREST][18.7.25] vidéo ${video.id} -> ${safe ?? 'Auto ($target)'}',
);
}
Future<void> setChannelCategoryOverride(
VideoItem video,
String? category,
) async {
await ensureLoaded();
final safe = _safeAssignableCategory(category);
final keys = _channelLookupKeys(video);
for (final key in keys) {
if (safe == null) {
_channelCategoryOverrides.remove(key);
} else {
_channelCategoryOverrides[key] = safe;
}
}
_rehomeFeedbackForChannel(video);
await _persistCategoryOverrides();
await _persistLoadedEntries();
revision.value++;
debugPrint(
'[INTEREST][18.7.25] chaîne ${video.author} -> ${safe ?? 'Auto'}',
);
}
String effectiveCategoryFor(VideoItem video, {String? requested}) {
final selected = requested ?? activeCategory.value;
if (selected != allCategory && categories.contains(selected)) {
return selected;
}
return classifyVideo(video);
}
/// Catégorie réellement utilisée par le moteur. Une correction manuelle
/// est toujours prioritaire sur l'analyse automatique.
String classifyVideo(VideoItem video) {
return manualCategoryFor(video) ?? classifyVideoAutomatically(video);
}
/// Classification automatique pure, utile dans l'interface pour montrer
/// ce que le moteur aurait choisi sans correction de l'utilisateur.
String classifyVideoAutomatically(VideoItem video) {
final haystack = _normalize(
'${video.title} ${video.author} ${video.description}',
);
String best = 'Autres';
int bestScore = 0;
for (final entry in _categoryKeywords.entries) {
int score = 0;
for (final keyword in entry.value) {
final normalizedKeyword = _normalize(keyword).trim();
if (normalizedKeyword.isEmpty) {
continue;
}
if (_containsWholeKeyword(haystack, normalizedKeyword)) {
score += normalizedKeyword.contains(' ') ? 4 : 2;
}
}
if (score > bestScore) {
bestScore = score;
best = entry.key;
}
}
return best;
}
bool matchesCategory(VideoItem video, String category) {
if (category == allCategory) {
return true;
}
// Une note donnée explicitement dans un profil devient une association
// manuelle plus fiable que la classification automatique du titre.
if (_entries[category]?.containsKey(video.id) ?? false) {
return true;
}
return classifyVideo(video) == category;
}
String searchHint(String category) {
switch (category) {
case 'Politique':
return 'politique actualité géopolitique';
case 'Jeux vidéo':
return 'jeux vidéo gaming gameplay';
case 'Documentaires':
return 'documentaire reportage enquête';
case 'Paranormal':
return 'paranormal surnaturel horreur haunted ghost scary';
case 'Islam':
return 'islam musulman coran';
case 'Football':
return 'football soccer';
case 'High-tech':
return 'high tech technologie intelligence artificielle';
case allCategory:
default:
return '';
}
}
VideoItem categorySeed(String category) {
return VideoItem(
id: '_interest_seed_${category.hashCode}',
title: searchHint(category),
author: '',
thumbnailUrl: '',
description: '',
);
}
int ratingFor(VideoItem video, {String? category}) {
final effective = effectiveCategoryFor(video, requested: category);
return _entries[effective]?[video.id]?.rating ?? 0;
}
bool isBadQuality(VideoItem video, {String? category}) {
final effective = effectiveCategoryFor(video, requested: category);
return _entries[effective]?[video.id]?.badQuality ?? false;
}
bool isNotInterested(VideoItem video, {String? category}) {
final effective = effectiveCategoryFor(video, requested: category);
return _entries[effective]?[video.id]?.notInterested ?? false;
}
bool shouldSuppressExact(VideoItem video, {String? category}) {
final effective = effectiveCategoryFor(video, requested: category);
final entry = _entries[effective]?[video.id];
if (entry == null) {
return false;
}
return entry.notInterested || entry.badQuality ||
(entry.rating > 0 && entry.rating <= 2);
}
Future<void> rateVideo(
VideoItem video,
int rating, {
String? category,
}) async {
await ensureLoaded();
final safeRating = rating.clamp(1, 5).toInt();
final effective = effectiveCategoryFor(video, requested: category);
final previous = _entries[effective]?[video.id];
_put(
InterestFeedbackEntry(
video: video,
category: effective,
rating: safeRating,
badQuality: safeRating <= 2,
notInterested:
safeRating >= 4 ? false : (previous?.notInterested ?? false),
updatedAt: DateTime.now().millisecondsSinceEpoch,
),
);
await _persist();
}
Future<void> markBadQuality(
VideoItem video, {
String? category,
}) async {
await ensureLoaded();
final effective = effectiveCategoryFor(video, requested: category);
final previous = _entries[effective]?[video.id];
_put(
InterestFeedbackEntry(
video: video,
category: effective,
rating: previous == null || previous.rating == 0
? 1
: min(previous.rating, 2),
badQuality: true,
notInterested: previous?.notInterested ?? false,
updatedAt: DateTime.now().millisecondsSinceEpoch,
),
);
await _persist();
}
Future<void> markNotInterested(
VideoItem video, {
String? category,
}) async {
await ensureLoaded();
final effective = effectiveCategoryFor(video, requested: category);
final previous = _entries[effective]?[video.id];
_put(
InterestFeedbackEntry(
video: video,
category: effective,
rating: previous?.rating ?? 0,
badQuality: previous?.badQuality ?? false,
notInterested: true,
updatedAt: DateTime.now().millisecondsSinceEpoch,
),
);
await _persist();
}
Future<void> clearVideoFeedback(
VideoItem video, {
String? category,
}) async {
await ensureLoaded();
final effective = effectiveCategoryFor(video, requested: category);
_entries[effective]?.remove(video.id);
if (_entries[effective]?.isEmpty ?? false) {
_entries.remove(effective);
}
await _persist();
}
Future<void> resetAll() async {
await ensureLoaded();
_entries.clear();
await _preferences.remove(_entriesKey);
revision.value++;
}
List<VideoItem> positiveExamples(String category) {
final entries = _entriesForScoringCategory(category);
final sorted = entries
.where((entry) => entry.rating >= 4 && !entry.notInterested)
.toList()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return sorted
.take(20)
.map((entry) => entry.video)
.toList(growable: false);
}
int feedbackCount(String category) {
return _entriesForScoringCategory(category).length;
}
InterestLearningScore scoreVideo(VideoItem video, String category) {
final effective = category == allCategory ? classifyVideo(video) : category;
final examples = _entries[effective]?.values.toList() ??
const <InterestFeedbackEntry>[];
if (examples.isEmpty) {
return const InterestLearningScore(
points: 0,
positivePoints: 0,
negativePoints: 0,
channelPoints: 0,
reasons: <String>[],
);
}
final candidateSpecific = _specificTerms(video, effective);
final candidateFull = _fullTerms(video);
final author = video.author.trim().toLowerCase();
double positiveRaw = 0;
double negativeRaw = 0;
double topicNegativeRaw = 0;
int sameAuthorPositive = 0;
int sameAuthorNegative = 0;
for (final entry in examples) {
final exampleSpecific = _specificTerms(entry.video, effective);
final exampleFull = _fullTerms(entry.video);
final qualitySimilarity = _similarity(candidateSpecific, exampleSpecific);
final topicSimilarity = _similarity(candidateFull, exampleFull);
final sameAuthor = author.isNotEmpty &&
author == entry.video.author.trim().toLowerCase();
if (entry.rating >= 4 && !entry.notInterested) {
final weight = entry.rating == 5 ? 1.35 : 1.0;
positiveRaw += qualitySimilarity * weight;
if (sameAuthor) {
sameAuthorPositive += entry.rating == 5 ? 2 : 1;
}
}
if (entry.rating > 0 && entry.rating <= 2 || entry.badQuality) {
final weight = entry.badQuality || entry.rating == 1 ? 1.35 : 1.0;
negativeRaw += qualitySimilarity * weight;
if (sameAuthor) {
sameAuthorNegative += entry.rating == 1 || entry.badQuality ? 2 : 1;
}
}
if (entry.notInterested) {
topicNegativeRaw += topicSimilarity * 1.15;
}
}
final positivePoints = min(28, (positiveRaw * 24).round());
final negativePoints = min(
36,
(negativeRaw * 28 + topicNegativeRaw * 24).round(),
);
final channelPoints = (sameAuthorPositive * 5 - sameAuthorNegative * 7)
.clamp(-18, 14)
.toInt();
final points = (positivePoints - negativePoints + channelPoints)
.clamp(-45, 36)
.toInt();
final reasons = <String>[];
if (positivePoints >= 5) {
reasons.add('Proche de tes vidéos 4–5★ dans $effective');
}
if (negativePoints >= 5) {
reasons.add('Éloignée des vidéos mal notées dans $effective');
}
if (channelPoints >= 5) {
reasons.add('Chaîne bien notée dans $effective');
} else if (channelPoints <= -5) {
reasons.add('Chaîne déjà mal notée dans $effective');
}
return InterestLearningScore(
points: points,
positivePoints: positivePoints,
negativePoints: negativePoints,
channelPoints: channelPoints,
reasons: List<String>.unmodifiable(reasons),
);
}
int _migrateLegacyHorrorFeedback() {
final legacy = _entries['Horreur'];
if (legacy == null || legacy.isEmpty) {
_entries.remove('Horreur');
return 0;
}
final target = _entries.putIfAbsent(
'Paranormal',
() => <String, InterestFeedbackEntry>{},
);
int migrated = 0;
for (final item in legacy.entries) {
final entry = item.value;
final existing = target[item.key];
if (existing == null || entry.updatedAt > existing.updatedAt) {
target[item.key] = InterestFeedbackEntry(
video: entry.video,
category: 'Paranormal',
rating: entry.rating,
badQuality: entry.badQuality,
notInterested: entry.notInterested,
updatedAt: entry.updatedAt,
);
}
migrated++;
}
_entries.remove('Horreur');
return migrated;
}
int _migrateLegacyOtherFeedback() {
final legacy = _entries['Autres'];
if (legacy == null || legacy.isEmpty) {
return 0;
}
int migrated = 0;
final ids = legacy.keys.toList(growable: false);
for (final id in ids) {
final entry = legacy[id];
if (entry == null) {
continue;
}
final detected = classifyVideo(entry.video);
if (detected == 'Autres' || !categories.contains(detected)) {
continue;
}
final target = _entries.putIfAbsent(
detected,
() => <String, InterestFeedbackEntry>{},
);
final existing = target[id];
if (existing == null || entry.updatedAt > existing.updatedAt) {
target[id] = InterestFeedbackEntry(
video: entry.video,
category: detected,
rating: entry.rating,
badQuality: entry.badQuality,
notInterested: entry.notInterested,
updatedAt: entry.updatedAt,
);
}
legacy.remove(id);
migrated++;
}
if (legacy.isEmpty) {
_entries.remove('Autres');
}
return migrated;
}
Future<void> _loadCategoryOverrides() async {
final videoRaw = await _preferences.getString(_videoCategoryOverridesKey);
final channelRaw = await _preferences.getString(_channelCategoryOverridesKey);
_videoCategoryOverrides
..clear()
..addAll(_decodeCategoryOverrideMap(videoRaw));
_channelCategoryOverrides
..clear()
..addAll(_decodeCategoryOverrideMap(channelRaw));
bool migrated = false;
for (final map in <Map<String, String>>[
_videoCategoryOverrides,
_channelCategoryOverrides,
]) {
for (final key in map.keys.toList(growable: false)) {
if (map[key] == 'Horreur') {
map[key] = 'Paranormal';
migrated = true;
}
}
}
if (migrated) {
await _persistCategoryOverrides();
}
}
Map<String, String> _decodeCategoryOverrideMap(String? raw) {
if (raw == null || raw.trim().isEmpty) {
return <String, String>{};
}
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return <String, String>{};
}
final result = <String, String>{};
for (final entry in decoded.entries) {
final value = '${entry.value}';
final migrated = value == 'Horreur' ? 'Paranormal' : value;
if (categories.contains(migrated) && migrated != allCategory) {
result['${entry.key}'] = migrated;
}
}
return result;
} catch (_) {
return <String, String>{};
}
}
Future<void> _persistCategoryOverrides() async {
await _preferences.setString(
_videoCategoryOverridesKey,
jsonEncode(_videoCategoryOverrides),
);
await _preferences.setString(
_channelCategoryOverridesKey,
jsonEncode(_channelCategoryOverrides),
);
}
String? _safeAssignableCategory(String? category) {
if (category == null || category == allCategory) {
return null;
}
return categories.contains(category) ? category : null;
}
List<String> _channelLookupKeys(VideoItem video) {
final keys = <String>[];
final channelId = video.channelId?.trim();
if (channelId != null && channelId.isNotEmpty) {
keys.add('id:$channelId');
}
final author = _normalize(video.author).trim();
if (author.isNotEmpty) {
keys.add('name:$author');
}
return keys;
}
bool _sameChannel(VideoItem left, VideoItem right) {
final leftId = left.channelId?.trim();
final rightId = right.channelId?.trim();
if (leftId != null &&
leftId.isNotEmpty &&
rightId != null &&
rightId.isNotEmpty &&
leftId == rightId) {
return true;
}
final leftAuthor = _normalize(left.author).trim();
final rightAuthor = _normalize(right.author).trim();
return leftAuthor.isNotEmpty && leftAuthor == rightAuthor;
}
void _moveFeedbackForVideoToCategory(VideoItem video, String target) {
if (target == allCategory ||
(!categories.contains(target) && target != 'Autres')) {
return;
}
InterestFeedbackEntry? latest;
for (final bucket in _entries.values) {
final candidate = bucket.remove(video.id);
if (candidate != null &&
(latest == null || candidate.updatedAt > latest.updatedAt)) {
latest = candidate;
}
}
_entries.removeWhere((key, value) => value.isEmpty);
if (latest == null) {
return;
}
_put(
InterestFeedbackEntry(
video: latest.video,
category: target,
rating: latest.rating,
badQuality: latest.badQuality,
notInterested: latest.notInterested,
updatedAt: latest.updatedAt,
),
);
}
void _rehomeFeedbackForChannel(VideoItem channelVideo) {
final entries = _entries.values
.expand((bucket) => bucket.values)
.where((entry) => _sameChannel(entry.video, channelVideo))
.toList(growable: false);
for (final entry in entries) {
final target = manualCategoryFor(entry.video) ??
classifyVideoAutomatically(entry.video);
_moveFeedbackForVideoToCategory(entry.video, target);
}
}
Future<void> _persistLoadedEntries() async {
final payload = <String, dynamic>{
for (final category in _entries.entries)
category.key: <String, dynamic>{
for (final entry in category.value.entries)
entry.key: entry.value.toJson(),
},
};
await _preferences.setString(_entriesKey, jsonEncode(payload));
}
List<InterestFeedbackEntry> _entriesForScoringCategory(String category) {
if (category != allCategory) {
return _entries[category]?.values.toList() ??
const <InterestFeedbackEntry>[];
}
return _entries.values
.expand((bucket) => bucket.values)
.toList(growable: false);
}
void _put(InterestFeedbackEntry entry) {
_entries.putIfAbsent(
entry.category,
() => <String, InterestFeedbackEntry>{},
)[entry.video.id] = entry;
}
Future<void> _persist() async {
final payload = <String, dynamic>{
for (final category in _entries.entries)
category.key: <String, dynamic>{
for (final entry in category.value.entries)
entry.key: entry.value.toJson(),
},
};
await _preferences.setString(_entriesKey, jsonEncode(payload));
revision.value++;
}
Set<String> _fullTerms(VideoItem video) {
return _terms('${video.title} ${video.description} ${video.author}');
}
Set<String> _specificTerms(VideoItem video, String category) {
final result = _terms('${video.title} ${video.description}');
final generic = <String>{
...?_categoryKeywords[category]?.expand(_terms),
..._genericQualityTerms,
};
result.removeAll(generic);
return result;
}
double _similarity(Set<String> left, Set<String> right) {
if (left.isEmpty || right.isEmpty) {
return 0;
}
final intersection = left.intersection(right).length;
if (intersection == 0) {
return 0;
}
final denominator = max(3, min(left.length, right.length));
return (intersection / denominator).clamp(0.0, 1.0);
}
Set<String> _terms(String value) {
final normalized = _normalize(value);
return normalized
.split(RegExp(r'\s+'))
.map((item) => item.trim())
.where(
(item) => item.length >= 3 && !_stopWords.contains(item),
)
.toSet();
}
bool _containsWholeKeyword(String haystack, String keyword) {
final normalizedHaystack = _normalize(haystack).trim();
final normalizedKeyword = _normalize(keyword).trim();
if (normalizedHaystack.isEmpty || normalizedKeyword.isEmpty) {
return false;
}
// Les espaces jouent ici le rôle de frontières de mots après _normalize.
// Cela évite notamment que le mot-clé court `ai` corresponde à `trait`,
// `maison`, etc. Les expressions multi-mots restent supportées.
return ' $normalizedHaystack '.contains(' $normalizedKeyword ');
}
String _normalize(String value) {
return value
.toLowerCase()
.replaceAll(
RegExp(
r"[^a-z0-9à-ÿ\u0600-\u06ff]+",
caseSensitive: false,
),
' ',
);
}
static const Set<String> _genericQualityTerms = <String>{
'video',
'youtube',
'officiel',
'official',
'francais',
'français',
'english',
'arabe',
'arabic',
'nouveau',
'new',
'2025',
'2026',
};
static const Set<String> _stopWords = <String>{
'the', 'and', 'for', 'with', 'from', 'this', 'that', 'you', 'your',
'les', 'des', 'une', 'dans', 'pour', 'avec', 'sur', 'est', 'sont',
'qui', 'que', 'plus', 'tout', 'tous', 'aux', 'par', 'pas', 'mais',
'من', 'في', 'على', 'الى', 'إلى', 'عن', 'مع', 'هذا', 'هذه', 'التي',
'und', 'der', 'die', 'das', 'mit', 'von', 'für',
};
static const Map<String, List<String>> _categoryKeywords =
<String, List<String>>{
'Politique': <String>[
'politique', 'politics', 'président', 'president', 'gouvernement',
'government', 'parlement', 'élection', 'election', 'géopolitique',
'geopolitics', 'ministre', 'minister', 'diplomatie', 'diplomacy',
'macron', 'trump', 'biden', 'poutine', 'putin', 'ukraine', 'gaza',
'israel', 'iran', 'onu', 'nato', 'otan',
],
'Jeux vidéo': <String>[
'jeu vidéo', 'jeux vidéo', 'gaming', 'gameplay', 'gamer', 'game',
'playstation', 'ps5', 'xbox', 'nintendo', 'switch', 'steam',
'minecraft', 'roblox', 'fortnite', 'gta', 'call of duty', 'pokemon',
'elden ring', 'cyberpunk', 'console',
],
'Documentaires': <String>[
'documentaire', 'documentary', 'reportage', 'enquête', 'enquete',
'investigation', 'histoire', 'history', 'civilisation', 'civilization',
'archéologie', 'archeology', 'nature', 'wildlife', 'arte',
'national geographic', 'discovery channel', 'science documentaire',
],
'Paranormal': <String>[
'horreur', 'horror', 'épouvante', 'epouvante', 'terrifiant',
'terrifiante', 'effrayant', 'effrayante', 'scary', 'creepy',
'slasher', 'jumpscare', 'jump scare', 'creepypasta', 'analog horror',
'found footage', 'film d horreur', 'horror movie', 'horror film',
'paranormal', 'haunted', 'haunting', 'ghost', 'ghosts', 'spectre',
'fantôme', 'fantome', 'maison hantée', 'maison hantee',
'possession', 'démon', 'demon', 'demonic', 'exorcisme', 'exorcism',
'resident evil', 'silent hill', 'outlast', 'amnesia horror',
'jeu d horreur', 'horror game',
'رعب', 'مرعب', 'مرعبة', 'مخيف', 'مخيفة', 'قصة رعب', 'قصص رعب',
'اشباح', 'أشباح', 'شبح', 'مسكون', 'مسكونة',
'ملعون', 'ملعونة', 'لعنة', 'مسحور', 'مسحورة', 'عالم آخر',
'عالم الجن', 'الجن', 'دوار مهجور', 'قرية مهجورة', 'مكان مهجور',
'اختفى', 'اختفاء', 'مكان غريب', 'قصة مخيفة', 'قصة مرعبة',
],
'Islam': <String>[
'islam', 'islamique', 'islamic', 'musulman', 'muslim', 'coran',
'quran', 'hadith', 'sunna', 'sunnah', 'ramadan', 'allah', 'mosquée',
'mosque', 'imam', 'prophète', 'prophet', 'muhammad', 'mohammed',
'الاسلام', 'الإسلام', 'القرآن', 'قرآن', 'حديث', 'مسلم', 'الله',
],
'Football': <String>[
'football', 'soccer', 'fifa', 'champions league', 'premier league',
'la liga', 'bundesliga', 'serie a', 'ligue 1', 'real madrid',
'barcelona', 'barça', 'psg', 'manchester', 'liverpool', 'arsenal',
'bayern', 'juventus', 'inter milan', 'milan', 'mbappé', 'mbappe',
'messi', 'ronaldo', 'ballon d or',
],
'High-tech': <String>[
'high tech', 'high-tech', 'technologie', 'technology', 'tech',
'android', 'windows', 'linux', 'iphone', 'ios', 'apple', 'macbook',
'samsung', 'galaxy', 'xiaomi', 'redmi', 'poco', 'huawei', 'honor',
'oneplus', 'oppo', 'vivo', 'realme', 'google pixel', 'pixel',
'blackberry', 'black berry', 'بلاك بيري', 'بلاكبيري',
'سمارتفون', 'هاتف ذكي', 'هواتف ذكية', 'هاتف', 'هواتف', 'تقنية',
'تكنولوجيا', 'اندرويد', 'أندرويد', 'سامسونج', 'شاومي', 'ايفون',
'آيفون', 'هواوي', 'هونر', 'smartphone', 'phone', 'mobile', 'laptop',
'ordinateur', 'ordinateur gaming', 'pc', 'tablet', 'tablette',
'gpu', 'nvidia', 'geforce', 'radeon', 'amd', 'intel', 'snapdragon',
'qualcomm', 'mediatek', 'mediatek dimensity', 'usb', 'wifi', 'bluetooth',
'oled', 'amoled', 'review', 'test smartphone', 'comparatif smartphone',
'intelligence artificielle', 'artificial intelligence', 'ai', 'chatgpt',
'openai', 'robot', 'machine learning', 'deep learning',
],
};
}