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 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 toJson() { return { 'video': video.toJson(), 'category': category, 'rating': rating, 'badQuality': badQuality, 'notInterested': notInterested, 'updatedAt': updatedAt, }; } factory InterestFeedbackEntry.fromJson(Map 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 categories = [ 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 activeCategory = ValueNotifier(allCategory); final ValueNotifier revision = ValueNotifier(0); final Map> _entries = >{}; // 18.7.25 : l'utilisateur peut corriger le classificateur. // Priorité : vidéo > chaîne > détection automatique. final Map _videoCategoryOverrides = {}; final Map _channelCategoryOverrides = {}; bool _loaded = false; Future? _loading; Future ensureLoaded() { if (_loaded) { return Future.value(); } final current = _loading; if (current != null) { return current; } final future = _load(); _loading = future; return future; } Future _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 = {}; 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 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 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 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 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 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 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 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 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 resetAll() async { await ensureLoaded(); _entries.clear(); await _preferences.remove(_entriesKey); revision.value++; } List 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 []; if (examples.isEmpty) { return const InterestLearningScore( points: 0, positivePoints: 0, negativePoints: 0, channelPoints: 0, reasons: [], ); } 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 = []; 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.unmodifiable(reasons), ); } int _migrateLegacyHorrorFeedback() { final legacy = _entries['Horreur']; if (legacy == null || legacy.isEmpty) { _entries.remove('Horreur'); return 0; } final target = _entries.putIfAbsent( 'Paranormal', () => {}, ); 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, () => {}, ); 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 _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 >[ _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 _decodeCategoryOverrideMap(String? raw) { if (raw == null || raw.trim().isEmpty) { return {}; } try { final decoded = jsonDecode(raw); if (decoded is! Map) { return {}; } final result = {}; 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 {}; } } Future _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 _channelLookupKeys(VideoItem video) { final keys = []; 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 _persistLoadedEntries() async { final payload = { for (final category in _entries.entries) category.key: { for (final entry in category.value.entries) entry.key: entry.value.toJson(), }, }; await _preferences.setString(_entriesKey, jsonEncode(payload)); } List _entriesForScoringCategory(String category) { if (category != allCategory) { return _entries[category]?.values.toList() ?? const []; } return _entries.values .expand((bucket) => bucket.values) .toList(growable: false); } void _put(InterestFeedbackEntry entry) { _entries.putIfAbsent( entry.category, () => {}, )[entry.video.id] = entry; } Future _persist() async { final payload = { for (final category in _entries.entries) category.key: { for (final entry in category.value.entries) entry.key: entry.value.toJson(), }, }; await _preferences.setString(_entriesKey, jsonEncode(payload)); revision.value++; } Set _fullTerms(VideoItem video) { return _terms('${video.title} ${video.description} ${video.author}'); } Set _specificTerms(VideoItem video, String category) { final result = _terms('${video.title} ${video.description}'); final generic = { ...?_categoryKeywords[category]?.expand(_terms), ..._genericQualityTerms, }; result.removeAll(generic); return result; } double _similarity(Set left, Set 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 _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 _genericQualityTerms = { 'video', 'youtube', 'officiel', 'official', 'francais', 'français', 'english', 'arabe', 'arabic', 'nouveau', 'new', '2025', '2026', }; static const Set _stopWords = { '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> _categoryKeywords = >{ 'Politique': [ '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': [ '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': [ 'documentaire', 'documentary', 'reportage', 'enquête', 'enquete', 'investigation', 'histoire', 'history', 'civilisation', 'civilization', 'archéologie', 'archeology', 'nature', 'wildlife', 'arte', 'national geographic', 'discovery channel', 'science documentaire', ], 'Paranormal': [ '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': [ 'islam', 'islamique', 'islamic', 'musulman', 'muslim', 'coran', 'quran', 'hadith', 'sunna', 'sunnah', 'ramadan', 'allah', 'mosquée', 'mosque', 'imam', 'prophète', 'prophet', 'muhammad', 'mohammed', 'الاسلام', 'الإسلام', 'القرآن', 'قرآن', 'حديث', 'مسلم', 'الله', ], 'Football': [ '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': [ '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', ], }; }