diff --git a/.github/ISSUE_TEMPLATE/new-source.yml b/.github/ISSUE_TEMPLATE/new-source.yml index e89574b7e..173621ca0 100644 --- a/.github/ISSUE_TEMPLATE/new-source.yml +++ b/.github/ISSUE_TEMPLATE/new-source.yml @@ -27,15 +27,15 @@ body: - type: dropdown id: category attributes: - label: Categorie / Category + label: Catégorie / Category options: - AI / IA - - Cybersecurity / Cybersecurite + - Cybersecurity / Cybersécurité - IoT - Windows - Mac / Apple - Linux - - Tech Generale + - Tech Générale - Entrepreneuriat / Entrepreneurship - Bourse & Finance - Crypto & Blockchain diff --git a/.github/ISSUE_TEMPLATE/subscribe.yml b/.github/ISSUE_TEMPLATE/subscribe.yml index 2177d7cd6..01ddd1db5 100644 --- a/.github/ISSUE_TEMPLATE/subscribe.yml +++ b/.github/ISSUE_TEMPLATE/subscribe.yml @@ -13,7 +13,7 @@ body: - type: checkboxes id: categories attributes: - label: Categories souhaitees / Desired categories + label: Catégories souhaitées / Desired categories options: - label: AI / IA - label: Cybersecurity @@ -30,9 +30,9 @@ body: - type: dropdown id: language attributes: - label: Langue preferee / Preferred language + label: Langue préférée / Preferred language options: - - Francais + - Français - English - Les deux / Both validations: @@ -40,7 +40,7 @@ body: - type: dropdown id: frequency attributes: - label: Frequence / Frequency + label: Fréquence / Frequency options: - Chaque mise a jour (toutes les 3h) / Every update - Quotidien / Daily digest diff --git a/.github/workflows/add-source.yml b/.github/workflows/add-source.yml index 01d08173b..1f9061bd0 100644 --- a/.github/workflows/add-source.yml +++ b/.github/workflows/add-source.yml @@ -23,6 +23,10 @@ jobs: const issue = context.payload.issue; const body = issue.body; + // Constants + const MAX_SOURCE_NAME_LENGTH = 100; + const MAX_TAG_LENGTH = 50; + // Parse form fields from issue body function getField(label) { const regex = new RegExp(`### ${label}\\s*\\n\\s*(.+)`, 'i'); @@ -33,24 +37,61 @@ jobs: const sourceName = getField('Nom de la source / Source Name'); const sourceUrl = getField('URL du site / Website URL'); const rssUrl = getField('URL du flux RSS / RSS Feed URL'); - const categoryRaw = getField('Categorie / Category'); + const categoryRaw = getField('Catégorie / Category'); const languageRaw = getField('Langue / Language'); const tags = getField('Tags / Mots-cles'); + // Validate required fields if (!sourceName || !rssUrl) { console.log('Missing required fields'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: 'Missing required fields (source name or RSS URL). Please provide all required information.' + }); + return; + } + + // Sanitize and validate source name (prevent injection) + const sanitizedSourceName = sourceName.replace(/[<>'"]/g, '').slice(0, MAX_SOURCE_NAME_LENGTH); + if (sanitizedSourceName !== sourceName) { + console.log('Source name contains invalid characters'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: 'Source name contains invalid characters. Please use only alphanumeric characters, spaces, and basic punctuation.' + }); + return; + } + + // Validate RSS URL format + try { + const urlObj = new URL(rssUrl); + if (!['http:', 'https:'].includes(urlObj.protocol)) { + throw new Error('Invalid protocol'); + } + } catch (e) { + console.log('Invalid RSS URL:', rssUrl); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `Invalid RSS URL format: "${rssUrl}". Please provide a valid HTTP or HTTPS URL.` + }); return; } // Map category const catMap = { 'AI / IA': 'ai', - 'Cybersecurity / Cybersecurite': 'cybersecurity', + 'Cybersecurity / Cybersécurité': 'cybersecurity', 'IoT': 'iot', 'Windows': 'windows', 'Mac / Apple': 'mac', 'Linux': 'linux', - 'Tech Generale': 'tech', + 'Tech Générale': 'tech', 'Entrepreneuriat / Entrepreneurship': 'entrepreneurship', 'Bourse & Finance': 'finance', 'Crypto & Blockchain': 'crypto', @@ -60,10 +101,10 @@ jobs: const category = catMap[categoryRaw] || 'tech'; // Map language - const lang = languageRaw.includes('Francais') ? 'fr' : 'en'; + const lang = languageRaw.includes('Français') ? 'fr' : 'en'; - // Parse tags - const tagList = tags ? tags.split(',').map(t => t.trim()).filter(t => t) : [category]; + // Parse and sanitize tags + const tagList = tags ? tags.split(',').map(t => t.trim().replace(/[<>'"]/g, '').slice(0, MAX_TAG_LENGTH)).filter(t => t) : [category]; // Read and update config.json const config = JSON.parse(fs.readFileSync('config.json', 'utf-8')); @@ -85,9 +126,9 @@ jobs: return; } - // Add new feed + // Add new feed with sanitized data config.categories[category].feeds.push({ - name: sourceName, + name: sanitizedSourceName, url: rssUrl, tags: tagList, lang: lang diff --git a/.github/workflows/manage-subscriber.yml b/.github/workflows/manage-subscriber.yml index 34ada499b..ab6a66812 100644 --- a/.github/workflows/manage-subscriber.yml +++ b/.github/workflows/manage-subscriber.yml @@ -23,6 +23,9 @@ jobs: const issue = context.payload.issue; const body = issue.body; + // Constants + const MAX_AT_SYMBOLS_THRESHOLD = 5; // Maximum @ symbols allowed to prevent email harvesting + function getField(label) { const regex = new RegExp(`### ${label}\\s*\\n\\s*(.+)`, 'i'); const match = body.match(regex); @@ -36,15 +39,48 @@ jobs: return [...match[1].matchAll(/- \[X\] (.+)/gi)].map(m => m[1].trim()); } - const email = getField('Votre email / Your email'); - if (!email || !email.includes('@')) { - console.log('Invalid email'); + // Validate email address format + function isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return ( + typeof email === 'string' && + email.length > 0 && + email.length <= 254 && + emailRegex.test(email) + ); + } + + // Basic abuse heuristic: ignore issues that appear to contain many email-like strings + const atCount = (body.match(/@/g) || []).length; + if (atCount > MAX_AT_SYMBOLS_THRESHOLD) { + console.log('Suspicious issue body (too many @ symbols); skipping subscription.'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: 'This subscription request appears suspicious and has been rejected. If this is an error, please contact the maintainers.' + }); + return; + } + + const rawEmail = getField('Votre email / Your email'); + // In case multiple emails or extra text are provided, only keep the first token + const email = rawEmail.split(/[,\s]+/).filter(Boolean)[0] || ''; + + if (!isValidEmail(email)) { + console.log('Invalid email:', rawEmail); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `Invalid email address format: "${rawEmail}". Please provide a valid email address.` + }); return; } - const selectedCats = getCheckboxes('Categories souhaitees / Desired categories'); - const langRaw = getField('Langue preferee / Preferred language'); - const freqRaw = getField('Frequence / Frequency'); + const selectedCats = getCheckboxes('Catégories souhaitées / Desired categories'); + const langRaw = getField('Langue préférée / Preferred language'); + const freqRaw = getField('Fréquence / Frequency'); const catMap = { 'AI / IA': 'ai', @@ -53,7 +89,7 @@ jobs: 'Windows': 'windows', 'Mac / Apple': 'mac', 'Linux': 'linux', - 'Tech Generale': 'tech', + 'Tech Générale': 'tech', 'Entrepreneuriat': 'entrepreneurship', 'Bourse & Finance': 'finance', 'Crypto & Blockchain': 'crypto', @@ -62,7 +98,7 @@ jobs: }; const categories = selectedCats.map(c => catMap[c]).filter(Boolean); - const lang = langRaw.includes('Francais') ? 'fr' : langRaw.includes('English') ? 'en' : 'all'; + const lang = langRaw.includes('Français') ? 'fr' : langRaw.includes('English') ? 'en' : 'all'; const frequency = freqRaw.includes('Quotidien') ? 'daily' : 'each_update'; // Read subscribers diff --git a/config.json b/config.json index 7193b2b68..9a7205e66 100644 --- a/config.json +++ b/config.json @@ -31,7 +31,7 @@ ] }, "cybersecurity": { - "labels": { "en": "Cybersecurity", "fr": "Cybersecurite" }, + "labels": { "en": "Cybersecurity", "fr": "Cybersécurité" }, "icon": "shield", "priority": 2, "feeds": [ @@ -117,7 +117,7 @@ ] }, "tech": { - "labels": { "en": "General Tech", "fr": "Tech Generale" }, + "labels": { "en": "General Tech", "fr": "Tech Générale" }, "icon": "zap", "priority": 7, "feeds": [ @@ -230,33 +230,33 @@ "artificial_intelligence": { "en": "Artificial Intelligence", "fr": "Intelligence Artificielle" }, "machine_learning": { "en": "Machine Learning", "fr": "Apprentissage Automatique" }, "deep_learning": { "en": "Deep Learning", "fr": "Apprentissage Profond" }, - "cybersecurity": { "en": "Cybersecurity", "fr": "Cybersecurite" }, - "data_breach": { "en": "Data Breach", "fr": "Fuite de Donnees" }, - "privacy": { "en": "Privacy", "fr": "Vie Privee" }, + "cybersecurity": { "en": "Cybersecurity", "fr": "Cybersécurité" }, + "data_breach": { "en": "Data Breach", "fr": "Fuite de Données" }, + "privacy": { "en": "Privacy", "fr": "Vie Privée" }, "home_automation": { "en": "Home Automation", "fr": "Domotique" }, - "connected_objects": { "en": "Connected Objects", "fr": "Objets Connectes" }, + "connected_objects": { "en": "Connected Objects", "fr": "Objets Connectés" }, "open_source": { "en": "Open Source", "fr": "Logiciel Libre" }, - "update": { "en": "Update", "fr": "Mise a jour" }, - "vulnerability": { "en": "Vulnerability", "fr": "Vulnerabilite" }, - "neural_network": { "en": "Neural Network", "fr": "Reseau de Neurones" }, + "update": { "en": "Update", "fr": "Mise à jour" }, + "vulnerability": { "en": "Vulnerability", "fr": "Vulnérabilité" }, + "neural_network": { "en": "Neural Network", "fr": "Réseau de Neurones" }, "computer_vision": { "en": "Computer Vision", "fr": "Vision par Ordinateur" }, "robotics": { "en": "Robotics", "fr": "Robotique" }, "quantum_computing": { "en": "Quantum Computing", "fr": "Informatique Quantique" }, "stock_market": { "en": "Stock Market", "fr": "Bourse" }, "cryptocurrency": { "en": "Cryptocurrency", "fr": "Cryptomonnaie" }, - "fundraising": { "en": "Fundraising", "fr": "Levee de Fonds" }, + "fundraising": { "en": "Fundraising", "fr": "Levée de Fonds" }, "entrepreneurship": { "en": "Entrepreneurship", "fr": "Entrepreneuriat" }, "startup": { "en": "Startup", "fr": "Startup" }, "investment": { "en": "Investment", "fr": "Investissement" }, "bug_bounty": { "en": "Bug Bounty", "fr": "Bug Bounty" }, "exploit": { "en": "Exploit", "fr": "Exploit" }, - "regulation": { "en": "Regulation", "fr": "Regulation" }, + "regulation": { "en": "Regulation", "fr": "Réglementation" }, "new_product": { "en": "New Product", "fr": "Nouveau Produit" }, "launch": { "en": "Launch", "fr": "Lancement" }, "crowdfunding": { "en": "Crowdfunding", "fr": "Financement Participatif" }, - "repository": { "en": "Repository", "fr": "Depot" }, - "self_hosted": { "en": "Self-hosted", "fr": "Auto-heberge" }, - "library": { "en": "Library", "fr": "Bibliotheque" }, + "repository": { "en": "Repository", "fr": "Dépôt" }, + "self_hosted": { "en": "Self-hosted", "fr": "Auto-hébergé" }, + "library": { "en": "Library", "fr": "Bibliothèque" }, "tool": { "en": "Tool", "fr": "Outil" } } } diff --git a/js/tracker.js b/js/tracker.js index a731be7c7..5adbceac8 100644 --- a/js/tracker.js +++ b/js/tracker.js @@ -53,16 +53,23 @@ * Le visitorId est généré localement et ne permet pas d'identifier la personne. */ const Tracker = { + // Check if localStorage is available + isLocalStorageAvailable: function() { + try { + const test = '__localStorage_test__'; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return true; + } catch (e) { + return false; + } + }, - /** - * ------------------------------------------------------------------------- - * MÉTHODE : init() - * ------------------------------------------------------------------------- - * Initialise le tracker au chargement de la page. - * Appelée automatiquement à la fin de ce fichier. - */ init: function () { - // Enregistrer cette visite + if (!this.isLocalStorageAvailable()) { + console.warn('localStorage is not available. Tracking features will be disabled.'); + return; + } this.trackVisit(); // Afficher des infos de débogage dans la console @@ -119,15 +126,17 @@ const Tracker = { * 5. Sauvegarde dans localStorage */ trackVisit: function () { - // Récupérer les stats existantes ou créer un nouvel objet - let stats = JSON.parse(localStorage.getItem('ai_pulse_stats')) || { - visitorId: this.generateUUID(), // ID unique pour ce navigateur - sessions: 0, // Nombre de sessions/visites - pageViews: 0, // Nombre de pages vues - lastVisit: 0, // Timestamp de la dernière visite - firstVisit: Date.now(), // Timestamp de la première visite - locations: [], // Liste des localisations - articleClicks: 0 // Nombre d'articles cliqués + if (!this.isLocalStorageAvailable()) return; + + try { + let stats = JSON.parse(localStorage.getItem('ai_pulse_stats')) || { + visitorId: this.generateUUID(), + sessions: 0, + pageViews: 0, + lastVisit: 0, + firstVisit: Date.now(), + locations: [], + articleClicks: 0 }; const now = Date.now(); @@ -161,6 +170,9 @@ const Tracker = { // Rendre les stats accessibles depuis d'autres scripts window.aiPulseStats = stats; window.aiPulseTracker = this; + } catch (e) { + console.error('Error tracking visit:', e); + } }, @@ -237,18 +249,26 @@ const Tracker = { * 2. Ajoute l'article à l'historique de lecture */ trackArticleClick: function (articleData) { - let stats = this.getStats(); + if (!this.isLocalStorageAvailable()) return; + + try { + let stats = this.getStats(); + stats.articleClicks = (stats.articleClicks || 0) + 1; + localStorage.setItem('ai_pulse_stats', JSON.stringify(stats)); - // Incrémenter le compteur de clics - stats.articleClicks = (stats.articleClicks || 0) + 1; - localStorage.setItem('ai_pulse_stats', JSON.stringify(stats)); + // Also add to read history + if ( + articleData.url && + typeof ReadHistory !== 'undefined' && + typeof ReadHistory.markRead === 'function' + ) { + ReadHistory.markRead(articleData.url, articleData.title || 'Unknown'); + } - // Ajouter à l'historique de lecture - if (articleData.url) { - ReadHistory.markRead(articleData.url, articleData.title || 'Unknown'); + console.log("Article tracked:", articleData.title); + } catch (e) { + console.error('Error tracking article click:', e); } - - console.log("Article tracked:", articleData.title); }, @@ -510,23 +530,29 @@ const ReadHistory = { * @param {string} title - Titre de l'article */ markRead: function (url, title) { - var articles = this.getAll(); - - // Vérifier si pas déjà lu - if (!this.isRead(url, title)) { - // Ajouter l'article - articles.push({ - url: url, - title: title, - readAt: Date.now() - }); - - // Garder maximum 500 entrées (supprimer les plus anciennes) - if (articles.length > 500) { - articles = articles.slice(-500); + try { + if (!Tracker.isLocalStorageAvailable()) return; + + var articles = this.getAll(); + if (!articles.some(function (a) { return a.url === url; })) { + articles.push({ url: url, title: title, readAt: Date.now() }); + // Keep max 500 entries to avoid localStorage bloat + // Average article entry ~200 bytes, 500 * 200 = 100KB (well within 5-10MB limit) + if (articles.length > 500) { + articles = articles.slice(-500); + } + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(articles)); + } + } catch (e) { + console.error('Error marking article as read:', e); + // If localStorage is full, remove oldest entries and try again + try { + var articles = this.getAll(); + articles = articles.slice(-250); // Keep only recent 250 + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(articles)); + } catch (e2) { + console.error('Failed to recover from localStorage error:', e2); } - - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(articles)); } }, diff --git a/readme-viewer.html b/readme-viewer.html index 531954c38..373a0166b 100644 --- a/readme-viewer.html +++ b/readme-viewer.html @@ -458,7 +458,7 @@

Personnaliser votre journal

Langue / Language

@@ -481,11 +481,7 @@

Articles par section

30 -
- -
- - + @@ -511,6 +507,17 @@

Articles par section

const PrefsManager = { STORAGE_KEY: 'ai_pulse_preferences', + isLocalStorageAvailable() { + try { + const test = '__localStorage_test__'; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return true; + } catch (e) { + return false; + } + }, + getDefaults() { return { lang: 'all', @@ -522,19 +529,38 @@

Articles par section

}, load() { + if (!this.isLocalStorageAvailable()) { + console.warn('localStorage not available. Using default preferences.'); + return this.getDefaults(); + } try { const stored = localStorage.getItem(this.STORAGE_KEY); if (stored) return { ...this.getDefaults(), ...JSON.parse(stored) }; - } catch (e) { /* ignore */ } + } catch (e) { + console.error('Error loading preferences:', e); + } return this.getDefaults(); }, save(prefs) { - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(prefs)); + if (!this.isLocalStorageAvailable()) { + console.warn('localStorage not available. Preferences will not be saved.'); + return; + } + try { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(prefs)); + } catch (e) { + console.error('Error saving preferences:', e); + } }, reset() { - localStorage.removeItem(this.STORAGE_KEY); + if (!this.isLocalStorageAvailable()) return; + try { + localStorage.removeItem(this.STORAGE_KEY); + } catch (e) { + console.error('Error resetting preferences:', e); + } } }; @@ -542,10 +568,18 @@

Articles par section

const ReadHistory = { STORAGE_KEY: 'ai_pulse_read_articles', + isLocalStorageAvailable() { + return PrefsManager.isLocalStorageAvailable(); + }, + getAll() { + if (!this.isLocalStorageAvailable()) return []; try { return JSON.parse(localStorage.getItem(this.STORAGE_KEY)) || []; - } catch (e) { return []; } + } catch (e) { + console.error('Error loading read history:', e); + return []; + } }, isRead(url) { @@ -553,10 +587,17 @@

Articles par section

}, markRead(url, title) { - const articles = this.getAll(); - if (!articles.some(a => a.url === url)) { - articles.push({ url, title, readAt: Date.now() }); - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(articles)); + if (!this.isLocalStorageAvailable()) return; + try { + const articles = this.getAll(); + if (!articles.some(a => a.url === url)) { + articles.push({ url, title, readAt: Date.now() }); + // Keep max 500 entries to avoid localStorage bloat + const limited = articles.length > 500 ? articles.slice(-500) : articles; + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(limited)); + } + } catch (e) { + console.error('Error marking article as read:', e); } } }; @@ -741,8 +782,27 @@

Articles par section

} }); - // Rebuild section dropdown when filters change - buildSectionNav(); + // ─── Intersection Observer for active section ─────── + function setupScrollSpy() { + // Check if IntersectionObserver is supported + if (!('IntersectionObserver' in window)) { + console.warn('IntersectionObserver not supported. Scroll spy disabled.'); + return; + } + + const sections = document.querySelectorAll('#readme-content section[data-category]'); + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const cat = entry.target.getAttribute('data-category'); + document.querySelectorAll('.section-nav a').forEach(a => a.classList.remove('active')); + const activeLink = document.querySelector(`.section-nav a[data-nav-cat="${cat}"]`); + if (activeLink) activeLink.classList.add('active'); + } + }); + }, { rootMargin: '-20% 0px -60% 0px' }); + + sections.forEach(section => observer.observe(section)); } // ─── Preferences Panel Events ─────────────────────── diff --git a/src/aggregator.js b/src/aggregator.js index ae695cc79..91d46b04c 100644 --- a/src/aggregator.js +++ b/src/aggregator.js @@ -123,20 +123,27 @@ const linkedinHelper = require('./linkedin-helper'); * Résultat : /chemin/vers/AI-Pulse/config.json */ const CONFIG_PATH = path.join(__dirname, '..', 'config.json'); - -/** - * Lecture et parsing du fichier JSON - * fs.readFileSync lit le fichier de manière synchrone (bloquante) - * JSON.parse transforme le texte JSON en objet JavaScript - */ -const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); - -/** - * SETTINGS : Paramètres généraux (articlesPerFeed, deduplication, etc.) - * CATEGORIES : Liste des catégories avec leurs sources RSS - */ -const SETTINGS = config.settings; -const CATEGORIES = config.categories; +let config, SETTINGS, CATEGORIES; +try { + if (!fs.existsSync(CONFIG_PATH)) { + console.error(`ERROR: Configuration file not found at ${CONFIG_PATH}`); + console.error('Please ensure config.json exists in the repository root.'); + process.exit(1); + } + const configContent = fs.readFileSync(CONFIG_PATH, 'utf-8'); + config = JSON.parse(configContent); + SETTINGS = config.settings; + CATEGORIES = config.categories; + + if (!SETTINGS || !CATEGORIES) { + console.error('ERROR: Invalid config.json structure. Missing required "settings" or "categories" fields.'); + process.exit(1); + } +} catch (e) { + console.error(`ERROR: Failed to load or parse config.json: ${e.message}`); + console.error('Please check that config.json is valid JSON and properly formatted.'); + process.exit(1); +} // ───────────────────────────────────────────────────────────────────────────── @@ -199,8 +206,9 @@ try { return langMap[code] || code; }; } catch (e) { - console.error('WARNING: franc-min package not found. Language detection will be limited to feed-declared languages only.'); - console.error('To enable automatic language detection, please run: npm install'); + console.error('franc-min not available for language detection.'); + console.error('To enable automatic language detection, run: npm install franc-min'); + console.error('Continuing with feed-declared language only...'); detectLang = () => null; } @@ -221,28 +229,13 @@ const parser = new Parser({ headers: { 'User-Agent': 'AI-Pulse/3.0' } // Identification de l'application }); +// Valid ISO 639-1 language codes for HTML lang attribute +const VALID_LANG_CODES = ['en', 'fr', 'es', 'de', 'pt', 'it', 'nl', 'ru', 'zh', 'ja', 'ko', 'ar']; -// ───────────────────────────────────────────────────────────────────────────── -// FONCTION: AJOUT DES PARAMÈTRES UTM -// ───────────────────────────────────────────────────────────────────────────── +// Default sender email for notifications +const DEFAULT_SENDER_EMAIL = 'AI-Pulse '; -/** - * Ajoute des paramètres UTM aux URLs pour le suivi du trafic - * - * UTM = Urchin Tracking Module (paramètres de tracking Google Analytics) - * Permet de savoir d'où viennent les visiteurs d'un site - * - * @param {string} url - L'URL originale de l'article - * @param {string} category - La catégorie de l'article (ai, cybersecurity, etc.) - * @returns {string} - L'URL avec les paramètres UTM ajoutés - * - * EXEMPLE: - * Entrée: https://example.com/article - * Sortie: https://example.com/article?utm_source=ai-pulse&utm_medium=reader&... - * - * CAS SPÉCIAL MEDIUM: - * Les articles Medium sont redirigés vers Freedium pour éviter le paywall - */ +// UTM parameters for AI-Pulse traffic tracking function addUTMParams(url, category = 'general') { try { // Analyse l'URL pour vérifier si c'est un article Medium @@ -456,13 +449,13 @@ function textSimilarity(wordsA, wordsB) { function deduplicateArticles(articles) { // Vérifier si la déduplication est activée dans la config if (!SETTINGS.deduplication || !SETTINGS.deduplication.enabled) return articles; - - // Récupérer les seuils depuis la config - const titleThreshold = SETTINGS.deduplication.similarityThreshold || 0.7; // 70% pour les titres - const contentThreshold = SETTINGS.deduplication.contentThreshold || 0.5; // 50% pour le contenu - - const kept = []; // Articles gardés - const normalizedData = []; // Données normalisées pour comparaison rapide + const threshold = (typeof SETTINGS.deduplication.similarityThreshold === 'number' && + SETTINGS.deduplication.similarityThreshold > 0 && + SETTINGS.deduplication.similarityThreshold <= 1) + ? SETTINGS.deduplication.similarityThreshold + : 0.7; + const kept = []; + const normalizedTitles = []; for (const article of articles) { // Normaliser le titre et le résumé de l'article courant @@ -539,6 +532,10 @@ async function processArticle(article, sourceName, tags, category, feedLang) { // Détecter la langue du contenu let detectedLang = detectLang(rawSummary || article.title); + // Validate detected language is a valid 2-letter ISO 639-1 code + if (detectedLang && !VALID_LANG_CODES.includes(detectedLang)) { + detectedLang = null; // Fallback to feedLang if invalid + } const lang = detectedLang || feedLang || 'en'; // Essayer de récupérer et sauvegarder le contenu complet @@ -613,16 +610,16 @@ async function processArticle(article, sourceName, tags, category, feedLang) { // Retourner l'objet article traité return { - title: (sanitizeText(article.title) || 'Untitled').slice(0, 200), // Titre limité à 200 caractères - link: finalReaderLink, // Lien vers la version locale ou originale - originalLink: articleUrl, // Lien original (toujours gardé) - pubDate: new Date(article.pubDate || Date.now()), // Date de publication - source: sourceName, // Nom de la source - tags: tags, // Tags de la source - category: category, // Catégorie - lang: detectedLang || feedLang || 'en', // Langue détectée ou déclarée - summary: smartTruncate(rawSummary), // Résumé tronqué intelligemment - hasLocalContent: hasLocalContent // Indique si on a une copie locale + title: (sanitizeText(article.title) || 'Untitled').slice(0, 200), + link: finalReaderLink, + originalLink: articleUrl, // Always set to ensure RSS feeds have valid external URLs + pubDate: new Date(article.pubDate || Date.now()), + source: sourceName, + tags: tags, + category: category, + lang: detectedLang || feedLang || 'en', + summary: smartTruncate(rawSummary), + hasLocalContent: hasLocalContent }; } @@ -865,27 +862,21 @@ function writeRSSFeeds(categorizedArticles) { console.error('RSS feeds generated successfully'); } +// ─── Email Digest ─────────────────────────────────────────── -// ───────────────────────────────────────────────────────────────────────────── -// ENVOI DES EMAILS -// ───────────────────────────────────────────────────────────────────────────── +// Validate email address format +function isValidEmail(email) { + if (!email || typeof email !== 'string') return false; + // Basic email validation regex + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email) && email.length <= 254; +} + +// Sleep helper for rate limiting +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} -/** - * Envoie les emails de digest aux abonnés - * - * @param {Object} categorizedArticles - Articles groupés par catégorie - * - * PRÉREQUIS: - * - Variable d'environnement API_RESEND contenant la clé API Resend - * - Fichier data/subscribers.json avec la liste des abonnés - * - Fichier templates/email-digest.html avec le template d'email - * - * FONCTIONNEMENT: - * 1. Vérifier que la clé API existe - * 2. Lire la liste des abonnés - * 3. Pour chaque abonné, générer un email personnalisé - * 4. Envoyer via l'API Resend - */ async function sendEmailDigests(categorizedArticles) { // Vérifier que la clé API existe const apiKey = process.env.API_RESEND; @@ -894,7 +885,11 @@ async function sendEmailDigests(categorizedArticles) { return; } - // Vérifier que le fichier d'abonnés existe + // Validate API key format (should start with re_ for Resend) + if (!apiKey.startsWith('re_')) { + console.error('WARNING: API_RESEND key does not appear to be a valid Resend API key'); + } + const subscribersPath = path.join(__dirname, '..', 'data', 'subscribers.json'); if (!fs.existsSync(subscribersPath)) { console.error('No subscribers.json found, skipping email digests'); @@ -924,10 +919,28 @@ async function sendEmailDigests(categorizedArticles) { const template = fs.readFileSync(templatePath, 'utf-8'); const today = new Date().toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' }); - // Envoyer un email à chaque abonné - for (const subscriber of subscribers) { + // Get sender email from environment or use default + const senderEmail = process.env.EMAIL_FROM || DEFAULT_SENDER_EMAIL; + if (senderEmail === DEFAULT_SENDER_EMAIL) { + console.error('WARNING: Using default Resend development domain for sender email.'); + console.error('For production, set EMAIL_FROM environment variable with a verified custom domain.'); + } + + console.error(`Sending email digests to ${subscribers.length} subscriber(s)...`); + let sentCount = 0; + let failedCount = 0; + + for (let i = 0; i < subscribers.length; i++) { + const subscriber = subscribers[i]; try { - // Filtrer les articles selon les préférences de l'abonné + // Validate email address + if (!isValidEmail(subscriber.email)) { + console.error(` Skipping invalid email address: ${subscriber.email}`); + failedCount++; + continue; + } + + // Filter articles based on subscriber preferences let filteredSections = []; const subCategories = subscriber.categories || Object.keys(CATEGORIES); const subLang = subscriber.lang || 'all'; @@ -972,7 +985,7 @@ async function sendEmailDigests(categorizedArticles) { // Envoyer l'email via l'API Resend const response = await axios.post('https://api.resend.com/emails', { - from: 'AI-Pulse ', + from: senderEmail, to: [subscriber.email], subject: `AI-Pulse Digest - ${today}`, html: emailHtml @@ -984,10 +997,22 @@ async function sendEmailDigests(categorizedArticles) { }); console.error(` Email sent to ${subscriber.email}: ${response.data.id}`); + sentCount++; + + // Rate limiting: wait 100ms between emails to respect API limits + // Resend free tier allows 100 emails/day, with burst protection + if (i < subscribers.length - 1) { + await sleep(100); + } } catch (error) { - console.error(` Failed to send email to ${subscriber.email}: ${error.message}`); + failedCount++; + // Sanitize error message to avoid exposing sensitive information + const errorMsg = error.response?.data?.message || error.message || 'Unknown error'; + console.error(` Failed to send email to ${subscriber.email}: ${errorMsg}`); } } + + console.error(`\nEmail digest summary: ${sentCount} sent, ${failedCount} failed`); } diff --git a/templates/email-digest.html b/templates/email-digest.html index 8cb715dd0..ac6a2cea0 100644 --- a/templates/email-digest.html +++ b/templates/email-digest.html @@ -12,7 +12,7 @@

AI-Pulse

-

Votre digest personnalise - {{DATE}}

+

Votre digest personnalisé - {{DATE}}

@@ -25,10 +25,10 @@

AI-Pulse

- Vous recevez ce mail car vous etes abonne a AI-Pulse ({{EMAIL}}). + Vous recevez ce mail car vous êtes abonné à AI-Pulse ({{EMAIL}}).

- Se desabonner + Se désabonner | Ouvrir AI-Pulse