/** * ═══════════════════════════════════════════════════════════════════ * VEILLE AUTOMATIQUE — Digest matinal des articles à lire * ─────────────────────────────────────────────────────────────────── * Toutes les nuits à 3h, ce script : * 1. Lit toutes les URL collées dans le Sheet la veille * 2. Extrait le contenu de chaque article * 3. Demande à Gemini d'en faire un résumé propre * 4. Envoie un email digest à votre adresse * 5. Coche la case "Envoyé" dans le Sheet * * 🆕 AUTO-DÉTECTION 100% AUTOMATIQUE DU MODÈLE GEMINI * Le script va chercher tout seul le modèle Gemini le plus récent * disponible pour votre clé, et s'adapte tout seul quand Google * en sort de nouveaux ou en déprécie d'anciens. Vous ne touchez * plus jamais au code, même dans 5 ans. * * À coller dans : Google Sheets → Extensions → Apps Script * ═══════════════════════════════════════════════════════════════════ */ // ─── 1. CONFIGURATION ─────────────────────────────────────────────── // Toute la configuration utilisateur (email, clé API Gemini, longueur // du résumé) se trouve désormais dans l'onglet ⚙️ Configuration de votre // Google Sheet. Vous n'avez plus à toucher au code après l'installation. // // Cet objet CONFIG sert uniquement de fallback technique et est rempli // dynamiquement au démarrage par chargerConfigurationDepuisSheet(). // // 💡 Migration depuis une version précédente : si vous aviez déjà rempli // les valeurs ci-dessous, elles restent utilisées tant que l'onglet // ⚙️ Configuration est vide. Vous pouvez basculer en douceur. const CONFIG = { EMAIL_DESTINATAIRE: '', GEMINI_API_KEY: '', LONGUEUR_RESUME: 'moyenne', // Nom de l'onglet où Google Forms dépose ses réponses. // Laissez vide ('') pour activer l'auto-détection : le script trouvera // automatiquement l'onglet qui a "Horodateur" en colonne A et "URL" en // colonne B, peu importe la langue ou le nom exact. // Ne mettez un nom ici QUE si vous avez plusieurs onglets Forms et que // vous voulez forcer un onglet précis. NOM_ONGLET: '' }; // ─── Nom de l'onglet de configuration (avec emoji pour être reconnaissable) const NOM_ONGLET_CONFIGURATION = '⚙️ Configuration'; // ─── 2. POINT D'ENTRÉE PRINCIPAL ──────────────────────────────────── // Cette fonction sera déclenchée automatiquement chaque nuit. /** * Déclenchée automatiquement par Google chaque fois que vous ouvrez ce * Sheet. Crée silencieusement l'onglet ⚙️ Configuration s'il n'existe * pas encore, ce qui évite à l'utilisateur de devoir lancer une fonction * manuelle pour la première installation. * * Résultat : à la première ouverture du Sheet après installation du * script, l'onglet ⚙️ Configuration apparaît tout seul. L'utilisateur * n'a plus qu'à le remplir. */ function onOpen() { const classeur = SpreadsheetApp.getActiveSpreadsheet(); const ongletConfig = classeur.getSheetByName(NOM_ONGLET_CONFIGURATION); // Si l'onglet existe déjà, on ne fait rien (cas normal) if (ongletConfig) return; // Sinon, on le crée silencieusement try { creerOngletConfiguration(classeur); Logger.log('🎉 Premier lancement : onglet ⚙️ Configuration créé. ' + 'Remplissez-le pour activer votre veille.'); } catch (e) { // En cas d'erreur (permissions insuffisantes au tout début), on // ignore silencieusement. L'utilisateur lancera manuellement // preparerLaConfiguration() au pire. Logger.log('⚠️ Création différée de l\'onglet Configuration : ' + e.message); } } /** * Charge la configuration depuis l'onglet ⚙️ Configuration du Sheet. * Si l'onglet n'existe pas, il est créé automatiquement avec un en-tête, * 3 lignes de paramètres et une zone de saisie en évidence. * * Comportement : * - Lit les 3 valeurs : email, clé API Gemini, longueur du résumé * - Remplit le CONFIG dynamiquement (les valeurs du Sheet écrasent * celles du code) * - Lève une erreur claire si email ou clé manquent (pour guider * l'utilisateur lors du premier lancement) * * À appeler au tout début de envoyerDigestMatinal() pour que la * configuration soit fraîche à chaque exécution. */ function chargerConfigurationDepuisSheet() { const classeur = SpreadsheetApp.getActiveSpreadsheet(); // PHASE 1 : on crée l'onglet ⚙️ Configuration s'il n'existe pas let ongletConfig = classeur.getSheetByName(NOM_ONGLET_CONFIGURATION); if (!ongletConfig) { Logger.log('🔧 Création de l\'onglet ' + NOM_ONGLET_CONFIGURATION); ongletConfig = creerOngletConfiguration(classeur); } // PHASE 2 : lecture des 3 valeurs en colonne B (lignes 3, 4, 5) // Format de chaque ligne : [paramètre, valeur, explication] const valeursConfig = ongletConfig.getRange(3, 1, 3, 2).getValues(); // valeursConfig[0] = [📧 Adresse e-mail, valeur] // valeursConfig[1] = [🔑 Clé API Gemini, valeur] // valeursConfig[2] = [📏 Longueur du résumé, valeur] const emailSheet = (valeursConfig[0][1] || '').toString().trim(); const cleSheet = (valeursConfig[1][1] || '').toString().trim(); const longueurSheet = (valeursConfig[2][1] || '').toString().trim().toLowerCase(); // PHASE 3 : on remplit le CONFIG dynamiquement. // Les valeurs du Sheet écrasent celles du code, mais si le Sheet est // vide ET que le CONFIG du code a des valeurs, on garde celles du code // (migration douce depuis une version précédente). if (emailSheet) CONFIG.EMAIL_DESTINATAIRE = emailSheet; if (cleSheet) CONFIG.GEMINI_API_KEY = cleSheet; if (['courte', 'moyenne', 'longue'].indexOf(longueurSheet) !== -1) { CONFIG.LONGUEUR_RESUME = longueurSheet; } // PHASE 4 : validation. Si email ou clé manquent, on ne lève PAS // d'exception sèche : on retourne false avec un message guidé chaleureux // qui explique exactement quoi faire. L'appelant gère la suite. const emailManquant = !CONFIG.EMAIL_DESTINATAIRE || CONFIG.EMAIL_DESTINATAIRE === 'votre.adresse@gmail.com'; const cleManquante = !CONFIG.GEMINI_API_KEY || CONFIG.GEMINI_API_KEY === 'COLLEZ_VOTRE_CLE_ICI'; if (emailManquant || cleManquante) { Logger.log(''); Logger.log('═══════════════════════════════════════════════════════════'); Logger.log('🎉 Bienvenue ! Plus qu\'une étape avant que tout fonctionne.'); Logger.log('═══════════════════════════════════════════════════════════'); Logger.log(''); Logger.log('👉 Ouvrez votre Google Sheet, allez dans l\'onglet ' + '⚙️ Configuration (en bas), et remplissez :'); if (emailManquant) { Logger.log(' • Cellule B3 : votre adresse e-mail Gmail'); } if (cleManquante) { Logger.log(' • Cellule B4 : votre clé API Gemini ' + '(à obtenir sur https://aistudio.google.com/apikey)'); } Logger.log(''); Logger.log('💡 Une fois rempli, relancez cette fonction. ' + 'Vous n\'aurez plus jamais à toucher au code.'); Logger.log('═══════════════════════════════════════════════════════════'); return false; // Indique à l'appelant que la config n'est pas prête } Logger.log('✅ Configuration chargée depuis le Sheet. ' + 'Longueur résumé : ' + CONFIG.LONGUEUR_RESUME); return true; } /** * Crée l'onglet ⚙️ Configuration avec les 3 paramètres principaux : * adresse e-mail, clé API Gemini, longueur du résumé. * * L'utilisateur remplit cet onglet UNE FOIS, et n'a plus jamais à toucher * au code Apps Script après ça. */ function creerOngletConfiguration(classeur) { const onglet = classeur.insertSheet(NOM_ONGLET_CONFIGURATION); // Largeur des colonnes onglet.setColumnWidth(1, 280); // Paramètre onglet.setColumnWidth(2, 350); // Valeur onglet.setColumnWidth(3, 500); // Explication // ─── Ligne 1 : Titre principal ─── onglet.getRange(1, 1, 1, 3).merge(); onglet.getRange(1, 1) .setValue('⚙️ Configuration de votre veille') .setFontSize(16) .setFontWeight('bold') .setBackground('#1a73e8') .setFontColor('#ffffff') .setHorizontalAlignment('center') .setVerticalAlignment('middle'); onglet.setRowHeight(1, 40); // ─── Ligne 2 : En-têtes ─── onglet.getRange(2, 1, 1, 3) .setValues([['Paramètre', 'Valeur', 'Explication']]) .setFontWeight('bold') .setBackground('#e8eaed') .setHorizontalAlignment('center'); onglet.setRowHeight(2, 30); // ─── Lignes 3-5 : Les 3 paramètres ─── const donnees = [ [ '📧 Adresse e-mail de réception', '', 'L\'adresse Gmail qui recevra votre digest matinal. ' + 'Doit être votre propre adresse Google (pour pouvoir envoyer ' + 'depuis votre compte).' ], [ '🔑 Clé API Gemini', '', '⚠️ Obtenez-en une gratuitement sur ' + 'https://aistudio.google.com/apikey ' + '— NE PARTAGEZ JAMAIS ce Sheet publiquement avec la clé visible. ' + 'Ne la diffusez pas non plus en capture d\'écran sur les réseaux sociaux.' ], [ '📏 Longueur du résumé', 'moyenne', 'Choisissez : courte (3 phrases), moyenne (3 paragraphes courts) ' + 'ou longue (5 paragraphes détaillés avec les chiffres clés).' ] ]; const plage = onglet.getRange(3, 1, donnees.length, 3); plage.setValues(donnees); plage.setVerticalAlignment('middle'); plage.setWrap(true); // Hauteur confortable for (let i = 3; i < 3 + donnees.length; i++) { onglet.setRowHeight(i, 70); } // ─── Mise en forme de la colonne B (Valeur) ─── // Colonne entière en fond jaune léger pour signaler "à remplir" onglet.getRange(3, 2, 3, 1) .setBackground('#fff9e6') .setHorizontalAlignment('center') .setFontWeight('bold'); // La clé API en rouge clair pour signaler la sensibilité onglet.getRange(4, 2) .setBackground('#fce8e6') .setFontColor('#990000'); // ─── Validation de données pour la longueur (menu déroulant) ─── const validation = SpreadsheetApp.newDataValidation() .requireValueInList(['courte', 'moyenne', 'longue'], true) .setAllowInvalid(false) .setHelpText('Choisissez courte, moyenne, ou longue.') .build(); onglet.getRange(5, 2).setDataValidation(validation); // ─── Ligne de bas de page : aide ─── const ligneAide = 3 + donnees.length + 1; onglet.getRange(ligneAide, 1, 1, 3).merge(); onglet.getRange(ligneAide, 1) .setValue('💡 Ces 3 paramètres sont obligatoires pour que la veille ' + 'fonctionne. Remplissez-les une seule fois et n\'y touchez ' + 'plus jamais (sauf si vous changez d\'adresse ou de clé API). ' + 'Les changements prennent effet au prochain envoi de digest.') .setFontStyle('italic') .setFontColor('#666666') .setBackground('#fff9e6') .setWrap(true) .setHorizontalAlignment('left') .setVerticalAlignment('middle'); onglet.setRowHeight(ligneAide, 50); // Gel des 2 premières lignes (titre + en-tête) onglet.setFrozenRows(2); return onglet; } /** * À lancer manuellement après l'installation pour créer l'onglet * ⚙️ Configuration sans déclencher un envoi complet de digest. * Pratique pour le premier setup (mais normalement, l'onglet est créé * automatiquement à l'ouverture du Sheet via onOpen()). */ function preparerLaConfiguration() { const ok = chargerConfigurationDepuisSheet(); if (ok) { Logger.log('✅ Configuration prête. Vous pouvez lancer ' + 'envoyerDigestMatinal() ou attendre le déclenchement automatique.'); } // Si pas OK, le message guidé chaleureux a déjà été loggé. } // Trouve automatiquement l'onglet créé par Google Forms. // Stratégie en cascade : // 1. Si NOM_ONGLET est défini dans CONFIG, on l'utilise directement. // 2. Sinon, on scanne tous les onglets et on retient celui qui a // "Horodateur" / "Timestamp" en A1 ET "URL" en B1. C'est la signature // unique d'un onglet Forms, peu importe la langue ou le nom exact. // 3. En dernier recours, on prend simplement le premier onglet avec // "URL" en B1. function trouverOngletForms() { const ss = SpreadsheetApp.getActiveSpreadsheet(); // 1. Si l'utilisateur a forcé un nom dans CONFIG, on l'utilise if (CONFIG.NOM_ONGLET && CONFIG.NOM_ONGLET.trim() !== '') { const sheetForce = ss.getSheetByName(CONFIG.NOM_ONGLET); if (sheetForce) return sheetForce; Logger.log('⚠️ NOM_ONGLET="' + CONFIG.NOM_ONGLET + '" introuvable. Bascule sur auto-détection...'); } // 2. Auto-détection : on cherche l'onglet avec la signature Forms // (Horodateur/Timestamp en A1 + URL en B1) const motsHorodateur = [ 'horodateur', 'timestamp', 'marca temporal', 'zeitstempel', 'horodatage', 'data e ora', 'tijdstempel', 'znacznik czasu' ]; const onglets = ss.getSheets(); for (let i = 0; i < onglets.length; i++) { const o = onglets[i]; const a1 = String(o.getRange('A1').getValue() || '').toLowerCase().trim(); const b1 = String(o.getRange('B1').getValue() || '').toLowerCase().trim(); const aMatch = motsHorodateur.indexOf(a1) !== -1; const bMatch = b1 === 'url' || b1.indexOf('url') !== -1; if (aMatch && bMatch) { return o; } } // 3. Plan B : on prend le premier onglet qui a "url" en B1 for (let i = 0; i < onglets.length; i++) { const b1 = String(onglets[i].getRange('B1').getValue() || '') .toLowerCase().trim(); if (b1 === 'url' || b1.indexOf('url') !== -1) { Logger.log('ℹ️ Fallback : onglet "' + onglets[i].getName() + '" retenu car colonne B = URL.'); return onglets[i]; } } return null; } function envoyerDigestMatinal() { // ─── 0. Chargement de la configuration depuis l'onglet ⚙️ Configuration // Si la config n'est pas prête (email ou clé manquants), on s'arrête // proprement avec un message guidé déjà loggé. const configOk = chargerConfigurationDepuisSheet(); if (!configOk) return; const sheet = trouverOngletForms(); if (!sheet) { Logger.log('❌ Impossible de trouver un onglet Google Forms valide ' + '(Horodateur en A, URL en B).'); return; } Logger.log('📋 Onglet utilisé : "' + sheet.getName() + '"'); // On résout le modèle Gemini à utiliser (cache + auto-détection) const modele = obtenirModeleGemini(); Logger.log('Modèle Gemini utilisé : ' + modele); // On lit toutes les lignes du Sheet const donnees = sheet.getDataRange().getValues(); const articles = []; // On parcourt à partir de la ligne 2 (la ligne 1 = en-têtes) for (let i = 1; i < donnees.length; i++) { const ligne = donnees[i]; const url = ligne[1]; // Colonne B : URL (gérée par Google Forms) const dejaTraite = ligne[2]; // Colonne C : case "Envoyé" (manuelle) // On ignore les URL déjà traitées ou les lignes vides if (!url || dejaTraite === true) continue; Logger.log('Traitement : ' + url); try { const article = traiterArticle(url, modele); articles.push({ ligne: i + 1, url: url, titre: article.titre, resume: article.resume, langueOrigine: article.langueOrigine }); } catch (e) { Logger.log('Erreur sur ' + url + ' : ' + e.message); articles.push({ ligne: i + 1, url: url, titre: url, resume: '
⚠️ Erreur : ' + e.message + '
', langueOrigine: 'français' }); } } // S'il n'y a rien à envoyer, on s'arrête là (pas d'email vide) if (articles.length === 0) { Logger.log('Aucun nouvel article à traiter cette nuit.'); return; } // Envoi du digest envoyerEmail(articles, modele); // On coche la case "Envoyé" pour chaque article traité articles.forEach(a => { sheet.getRange(a.ligne, 3).setValue(true); sheet.getRange(a.ligne, 4).setValue(new Date()); }); Logger.log('Digest envoyé : ' + articles.length + ' article(s).'); } // ─── 3. AUTO-DÉTECTION DU MEILLEUR MODÈLE GEMINI ──────────────────── // Stratégie : on liste les modèles dispo, on filtre les variantes // inadaptées (image, audio, embedding...), on garde les "flash" qui // supportent generateContent, et on prend le numéro de version le // plus élevé. Aucune liste codée en dur, aucune maintenance. function obtenirModeleGemini() { const cache = PropertiesService.getScriptProperties(); const enCache = cache.getProperty('MODELE_GEMINI'); const dateCache = cache.getProperty('MODELE_DATE'); // Le cache est valable 7 jours. Au-delà, on revérifie automatiquement // (au cas où un nouveau modèle plus récent serait sorti). const maintenant = new Date().getTime(); const septJours = 7 * 24 * 60 * 60 * 1000; if (enCache && dateCache && (maintenant - parseInt(dateCache)) < septJours) { return enCache; } // Sinon, on appelle l'API Models de Gemini pour détecter le meilleur const modele = detecterMeilleurModele(); cache.setProperty('MODELE_GEMINI', modele); cache.setProperty('MODELE_DATE', maintenant.toString()); return modele; } function detecterMeilleurModele() { const apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models?key=' + CONFIG.GEMINI_API_KEY; const reponse = UrlFetchApp.fetch(apiUrl, { muteHttpExceptions: true }); if (reponse.getResponseCode() !== 200) { throw new Error( 'Impossible de lister les modèles Gemini. Vérifiez votre clé API. ' + 'Code : ' + reponse.getResponseCode() ); } const data = JSON.parse(reponse.getContentText()); const tousModeles = data.models || []; // ─── Étape 1 : on garde uniquement les modèles utilisables ─── // Critères : // - supporte 'generateContent' (sinon on ne peut pas l'appeler) // - nom commence par 'gemini-' (on évite gemma, embedding, etc.) // - ne contient AUCUN suffixe spécialisé qui ne nous concerne pas // // Les suffixes interdits couvrent les variantes image, audio, // vidéo, vision, embedding, live, TTS, "thinking", "preview", // "experimental"... Tout ce qui n'est pas un modèle texte // standard, polyvalent et stable. const SUFFIXES_INTERDITS = [ 'image', 'audio', 'video', 'vision', 'embedding', 'embed', 'live', 'tts', 'thinking', 'experimental', 'exp', 'learnlm', 'aqa', 'native', 'dialog' ]; const candidats = tousModeles.filter(m => { const nom = m.name.replace('models/', '').toLowerCase(); if (!nom.startsWith('gemini-')) return false; const supporte = m.supportedGenerationMethods || []; if (supporte.indexOf('generateContent') === -1) return false; for (const interdit of SUFFIXES_INTERDITS) { if (nom.indexOf(interdit) !== -1) return false; } return true; }); if (candidats.length === 0) { throw new Error('Aucun modèle Gemini compatible trouvé.'); } // ─── Étape 2 : on score chaque modèle ─── // Score basé sur : // - Numéro de version (gemini-3.x > gemini-2.x > gemini-1.x) // - Préférence pour 'flash' (rapide + quota gratuit généreux) // - Stabilité (on évite 'preview' si possible) const candidatsScores = candidats.map(m => { const nom = m.name.replace('models/', ''); let score = 0; // Extraction du numéro de version (ex: "gemini-2.5-flash" -> 2.5) const matchVersion = nom.match(/gemini-(\d+(?:\.\d+)?)/); if (matchVersion) { score += parseFloat(matchVersion[1]) * 1000; } // Bonus pour 'flash' : rapide et bonnes limites gratuites if (nom.indexOf('flash') !== -1) score += 100; // Léger bonus pour 'pro' (puissant mais quota plus strict) else if (nom.indexOf('pro') !== -1) score += 50; // Malus pour 'lite' (moins puissant) if (nom.indexOf('lite') !== -1) score -= 20; // Malus pour les versions preview/beta non stables if (nom.indexOf('preview') !== -1) score -= 200; if (nom.indexOf('beta') !== -1) score -= 200; // Malus léger pour les versions très spécifiques (ex: -001, -002) // au profit des alias auto-mis-à-jour (ex: gemini-2.5-flash) if (nom.match(/-\d{3}$/)) score -= 5; return { nom: nom, score: score }; }); // ─── Étape 3 : on prend le mieux noté ─── candidatsScores.sort((a, b) => b.score - a.score); Logger.log('Top 5 candidats :'); candidatsScores.slice(0, 5).forEach(c => Logger.log(' • ' + c.nom + ' (score ' + c.score + ')') ); const choisi = candidatsScores[0].nom; Logger.log('✅ Modèle sélectionné : ' + choisi); return choisi; } // ─── 4. EXTRACTION + RÉSUMÉ D'UN ARTICLE ──────────────────────────── function traiterArticle(url, modele) { // Téléchargement du HTML const reponse = UrlFetchApp.fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ChromebookVeille)' }, muteHttpExceptions: true, followRedirects: true }); const html = reponse.getContentText(); // Extraction du titre brut depuis le HTML (servira de fallback) let titreOrigine = ''; const matchH1 = html.match(/]*>([\s\S]*?)<\/p>/gi) || []; const texteParagraphes = paragraphes .map(p => p.replace(/<[^>]+>/g, '').trim()) .filter(p => p.length > 50) .join('\n\n'); // On préfixe par le titre pour que Gemini ait le contexte complet const texte = (titreOrigine ? 'TITRE : ' + titreOrigine + '\n\n' : '') + texteParagraphes; if (texteParagraphes.length < 200) { throw new Error('Article trop court ou inaccessible (paywall ?)'); } // Appel à Gemini : renvoie { langueOrigine, titre, resume } const reponseGemini = appelerGemini(texte, modele); // On utilise le titre traduit par Gemini si disponible, sinon fallback const titreFinal = reponseGemini.titre || titreOrigine || url; return { titre: titreFinal, resume: reponseGemini.resume, langueOrigine: reponseGemini.langueOrigine }; } // ─── 5. APPEL À L'API GEMINI ──────────────────────────────────────── // Si le modèle en cache est devenu indisponible (404), on invalide // le cache et on relance avec un modèle frais. Auto-récupération // totalement transparente pour l'utilisateur. function appelerGemini(texte, modele, tentative) { tentative = tentative || 1; const consigneLongueur = { 'courte': '3 phrases', 'moyenne': '3 paragraphes courts', 'longue': '5 paragraphes détaillés avec les chiffres clés' }; const prompt = 'Tu es un journaliste expert en synthèse multilingue. Voici un article :\n\n' + '---\n' + texte.substring(0, 30000) + '\n---\n\n' + 'Ta mission est de produire UNIQUEMENT un objet JSON valide avec ' + 'cette structure exacte :\n' + '{\n' + ' "langueOrigine": "français" | "anglais" | "espagnol" | "allemand" | "italien" | "néerlandais" | "portugais" | "autre",\n' + ' "titre": "le titre de l\'article TRADUIT en français (si déjà en français, garde-le tel quel)",\n' + ' "resume": "le résumé en français au format HTML, voir règles ci-dessous"\n' + '}\n\n' + 'RÈGLES POUR "titre" :\n' + '- Détecte le titre principal de l\'article.\n' + '- Si l\'article est en français, garde le titre tel quel.\n' + '- Sinon, traduis le titre en français de manière fidèle mais ' + 'naturelle (pas du mot-à-mot maladroit). Garde les noms propres, ' + 'marques et termes techniques dans leur forme originale.\n' + '- Pas de guillemets autour du titre, pas de "Titre :", juste le ' + 'texte du titre.\n\n' + 'RÈGLES POUR "resume" :\n' + '- Toujours en français, même si l\'article est en langue étrangère.\n' + '- Longueur : ' + consigneLongueur[CONFIG.LONGUEUR_RESUME] + '.\n' + '- Structure obligatoire : 2 à 4 paragraphes courts (2 à 4 phrases ' + 'chacun), JAMAIS un seul bloc. Chaque paragraphe traite d\'une idée ' + 'différente : contexte, détails, conséquences, ou ce qu\'il faut ' + 'retenir.\n' + '- Style synthèse, pas dissertation : phrases directes, zéro ' + 'fioriture littéraire. Pense "note de briefing" plutôt qu\'article ' + 'de blog.\n' + '- Pas de phrase d\'introduction du genre "Voici le résumé". Va ' + 'direct au contenu.\n' + '- Format HTML :
...
pour chaque paragraphe, ...' + ' pour les chiffres clés et noms propres importants.\n' + '- Conserve les chiffres importants, noms propres, conclusions clés.\n\n' + 'IMPORTANT : ta réponse doit être UNIQUEMENT le JSON, sans ' + 'préambule, sans balise markdown ```json, sans commentaire. Juste ' + 'l\'objet JSON pur, qui doit être parsable directement.'; const apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/' + modele + ':generateContent?key=' + CONFIG.GEMINI_API_KEY; const reponse = UrlFetchApp.fetch(apiUrl, { method: 'post', contentType: 'application/json', payload: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }), muteHttpExceptions: true }); const codeRetour = reponse.getResponseCode(); // Si le modèle a été déprécié entre-temps (404 ou 400 modèle inconnu) // on invalide le cache et on relance avec un modèle fraichement détecté. // Limite à 2 tentatives pour éviter une boucle infinie. if ((codeRetour === 404 || codeRetour === 400) && tentative === 1) { Logger.log('⚠️ Modèle ' + modele + ' indisponible (code ' + codeRetour + '). Re-détection automatique...'); PropertiesService.getScriptProperties().deleteProperty('MODELE_GEMINI'); PropertiesService.getScriptProperties().deleteProperty('MODELE_DATE'); const nouveauModele = detecterMeilleurModele(); PropertiesService.getScriptProperties().setProperty( 'MODELE_GEMINI', nouveauModele ); PropertiesService.getScriptProperties().setProperty( 'MODELE_DATE', new Date().getTime().toString() ); return appelerGemini(texte, nouveauModele, 2); } // Si Gemini est saturé (503) ou en rate limit (429), on attend et on retente. // Backoff progressif : 5s, puis 15s, puis 30s. // Au-delà de 3 tentatives, on abandonne sur cet article (pas sur tout le digest). if ((codeRetour === 503 || codeRetour === 429) && tentative <= 3) { const attentes = [5000, 15000, 30000]; const attente = attentes[tentative - 1]; Logger.log('⏳ Gemini saturé (code ' + codeRetour + '), tentative ' + tentative + '/3. Attente de ' + (attente / 1000) + 's...'); Utilities.sleep(attente); return appelerGemini(texte, modele, tentative + 1); } if (codeRetour !== 200) { throw new Error( 'Erreur API Gemini (code ' + codeRetour + ') : ' + reponse.getContentText().substring(0, 200) ); } const data = JSON.parse(reponse.getContentText()); if (!data.candidates || !data.candidates[0]) { throw new Error('Réponse Gemini invalide'); } let texteRecu = data.candidates[0].content.parts[0].text; // Nettoyage : Gemini ajoute parfois ```json ... ``` autour du JSON, // ou un préambule, malgré nos consignes. On extrait juste le JSON. texteRecu = texteRecu.trim(); texteRecu = texteRecu.replace(/^```json\s*/i, '').replace(/```\s*$/, ''); texteRecu = texteRecu.replace(/^```\s*/, '').replace(/```\s*$/, ''); // On cherche le premier { et le dernier } pour isoler l'objet JSON const debutJson = texteRecu.indexOf('{'); const finJson = texteRecu.lastIndexOf('}'); if (debutJson !== -1 && finJson !== -1) { texteRecu = texteRecu.substring(debutJson, finJson + 1); } // Parsing JSON avec fallback robuste si Gemini renvoie du texte brut let resultat; try { resultat = JSON.parse(texteRecu); } catch (e) { Logger.log('⚠️ JSON Gemini invalide, fallback en mode texte brut'); return { langueOrigine: 'français', titre: '', resume: '' + texteRecu.substring(0, 2000) + '
' }; } return { langueOrigine: resultat.langueOrigine || 'français', titre: resultat.titre || '', resume: resultat.resume || '' }; } // ─── 6. ENVOI DU DIGEST PAR EMAIL ─────────────────────────────────── // Formate une date en français pur (jour, mois, etc. en français) // indépendamment de la locale du fuseau horaire d'Apps Script. function formaterDateFr(date) { const jours = [ 'dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi' ]; const mois = [ 'janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre' ]; return jours[date.getDay()] + ' ' + date.getDate() + ' ' + mois[date.getMonth()] + ' ' + date.getFullYear(); } // Formate l'heure d'une date en français : ex. "03h17", "18h42". // Pratique quand on a plusieurs digests dans la journée pour les // distinguer dans la boîte mail. function formaterHeureFr(date) { const heures = ('0' + date.getHours()).slice(-2); const minutes = ('0' + date.getMinutes()).slice(-2); return heures + 'h' + minutes; } // Choisit la palette de bleus à utiliser dans le mail selon l'heure // d'envoi. Cela permet d'avoir un rendu doux le matin (yeux pas encore // bien réveillés), plus énergique l'après-midi, apaisé le soir, et // désaturé en pleine nuit pour ne pas agresser les noctambules. function choisirPaletteSelonHeure(date) { const heure = date.getHours(); // Matin (5h-11h) : bleu pastel doux, comme un lever de soleil if (heure >= 5 && heure < 12) { return { principal: '#7da7e0', bordure: '#7da7e0', lien: '#7da7e0' }; } // Après-midi (12h-18h) : bleu profond et énergique, plein soleil if (heure >= 12 && heure < 19) { return { principal: '#3b5bb5', bordure: '#3b5bb5', lien: '#3b5bb5' }; } // Soir (19h-22h) : bleu velouté apaisé, lumière du crépuscule if (heure >= 19 && heure < 23) { return { principal: '#5a7ab8', bordure: '#5a7ab8', lien: '#5a7ab8' }; } // Nuit profonde (23h-4h) : bleu désaturé minéral, doux pour les // noctambules, insomniaques, ou lectures en vol long-courrier return { principal: '#4a6090', bordure: '#4a6090', lien: '#4a6090' }; } // Extrait proprement le nom de domaine d'une URL pour l'afficher dans le // bouton CTA. Enlève le préfixe "www." si présent. // Exemples : // https://mychromebook.fr/article/... → mychromebook.fr // https://www.lemonde.fr/economie/... → lemonde.fr // https://www.theverge.com/2026/... → theverge.com function extraireDomaine(url) { try { const match = url.match(/^https?:\/\/(?:www\.)?([^\/]+)/i); if (match && match[1]) { return match[1]; } return 'le site'; } catch (e) { return 'le site'; } } function envoyerEmail(articles, modele) { const maintenant = new Date(); const dateJour = formaterDateFr(maintenant); const heureJour = formaterHeureFr(maintenant); const palette = choisirPaletteSelonHeure(maintenant); let html = `${articles.length} article${articles.length === 1 ? '' : 's'} collecté${articles.length === 1 ? '' : 's'} et résumé${articles.length === 1 ? '' : 's'} automatiquement.
`; articles.forEach((a, i) => { // Mention discrète "Article original en [langue]" si non français const mentionLangue = (a.langueOrigine && a.langueOrigine !== 'français') ? `Article original en ${a.langueOrigine}
` : ''; html += `
Digest généré automatiquement par votre Apps Script personnel
via Google Sheets et le modèle ${modele}
(sélectionné automatiquement).
Aucune donnée n'a quitté votre écosystème Google.
|