Last active
January 27, 2026 18:59
-
-
Save vhsu/a8ea450c171f698d20aff5654af881f2 to your computer and use it in GitHub Desktop.
Recevoir une notification quand un formulaire de lead google ads genère un lead et stocker dans Google Sheets
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * SCRIPT : GOOGLE ADS FORMULAIRE DE LEADS VERS SHEETS + E-MAIL | |
| * Auteur : Gemini & [Vincent Hsu/Suisseo] | |
| * Version : 3.5 FR (Final : Dynamic Columns + Matrix Scan + Strict Fingerprint) | |
| * Guide d'installation : https://www.suisseo.ch/blog/recevoir-un-e-mail-a-chaque-lead-issue-dun-formulaire-de-lead-google-ads-google-sheets/ | |
| */ | |
| function doPost(e) { | |
| // --- 1. CONFIGURATION --- | |
| var EMAIL_NOTIFICATION = "[email protected]"; // E-mail pour recevoir les leads | |
| var SHEET_NAME = "LEADS"; | |
| // ------------------------ | |
| // CONFIGURATION SÉCURITÉ | |
| // Laisser vide ("") pour désactiver. Si défini, la clé Google Ads doit être : Nom Formulaire|MonSecret123 | |
| var EXPECTED_SECRET = ""; | |
| // ------------------------ | |
| // --- 2. VERROUILLAGE & SYNCHRONISATION (CRITIQUE) --- | |
| var lock = LockService.getScriptLock(); | |
| // Attend jusqu'à 30 secondes. | |
| // Si le script est sollicité par plusieurs leads en même temps, il les met en file d'attente. | |
| if (!lock.tryLock(30000)) { | |
| return ContentService.createTextOutput("Serveur Occupé").setMimeType(ContentService.MimeType.TEXT); | |
| } | |
| try { | |
| // FORCE SYNC : On s'assure de voir exactement ce que le lead précédent vient d'écrire | |
| SpreadsheetApp.flush(); | |
| if (!e || !e.postData) return ContentService.createTextOutput("Pas de Données"); | |
| var data = JSON.parse(e.postData.contents); | |
| // --- 3. SÉCURITÉ & NOM DU FORMULAIRE --- | |
| var rawKey = data.google_key || ""; | |
| var formName = "Formulaire Inconnu"; | |
| var providedSecret = ""; | |
| if (rawKey.indexOf("|") > -1) { | |
| var parts = rawKey.split("|"); | |
| formName = parts[0].trim(); | |
| providedSecret = parts[1].trim(); | |
| } else { | |
| formName = rawKey.trim() || "Formulaire Inconnu"; | |
| } | |
| if (EXPECTED_SECRET !== "" && providedSecret !== EXPECTED_SECRET) { | |
| console.warn("Alerte Sécurité : Secret Invalide. Formulaire : " + formName); | |
| return ContentService.createTextOutput("Accès Refusé").setMimeType(ContentService.MimeType.TEXT); | |
| } | |
| // --- 4. PRÉPARATION DU FICHIER --- | |
| var ss = SpreadsheetApp.getActiveSpreadsheet(); | |
| var sheet = ss.getSheetByName(SHEET_NAME); | |
| if (!sheet) sheet = ss.insertSheet(SHEET_NAME); | |
| // Initialisation des en-têtes si l'onglet est vide | |
| if (sheet.getLastColumn() === 0) { | |
| // NOTE : "GCLID" est le point de repère pour les colonnes dynamiques. Ne pas renommer. | |
| var initialHeaders = ["Date", "Nom Formulaire", "GCLID", "ID Campagne", "Données Brutes"]; | |
| sheet.appendRow(initialHeaders); | |
| sheet.getRange(1, 1, 1, initialHeaders.length).setFontWeight("bold"); | |
| sheet.setFrozenRows(1); | |
| } | |
| // --- 5. VÉRIFICATION ANTI-DOUBLON RENFORCÉE --- | |
| var incomingGCLID = (data.gcl_id || "").toString().trim(); | |
| // DÉFINITION DE L'EMPREINTE UNIQUE | |
| // Cas 1 (Standard) : On utilise le GCLID. | |
| // Cas 2 (Test/Bug) : Si pas de GCLID, on utilise la première réponse utilisateur (Email ou Nom) comme identifiant. | |
| var fingerprint = incomingGCLID; | |
| if ((!fingerprint || fingerprint === "N/A") && data.user_column_data && data.user_column_data.length > 0) { | |
| // On prend la valeur de la première question (ex: l'email) pour éviter les doublons de test | |
| fingerprint = (data.user_column_data[0].string_value || "").trim(); | |
| } | |
| // On lance la vérification seulement si on a une empreinte valide | |
| if (fingerprint && fingerprint !== "N/A" && sheet.getLastRow() > 1) { | |
| var lastRow = sheet.getLastRow(); | |
| var checkLimit = 1000; // On vérifie les 1000 dernières lignes | |
| var startRow = Math.max(2, lastRow - checkLimit + 1); | |
| var numRows = lastRow - startRow + 1; | |
| // On récupère le bloc de données complet (Matrice) des lignes récentes | |
| var dataMatrix = sheet.getRange(startRow, 1, numRows, sheet.getLastColumn()).getValues(); | |
| // Scan profond : Vérifie CHAQUE cellule des lignes récentes pour trouver l'empreinte exacte | |
| var isDuplicate = dataMatrix.some(function(row) { | |
| return row.some(function(cell) { | |
| return cell.toString().trim() === fingerprint; | |
| }); | |
| }); | |
| if (isDuplicate) { | |
| console.info("Doublon Bloqué (Matrice Match) : " + fingerprint); | |
| // On renvoie "Succès" pour dire à Google d'arrêter de réessayer | |
| return ContentService.createTextOutput("Doublon Ignoré").setMimeType(ContentService.MimeType.TEXT); | |
| } | |
| } | |
| // --- 6. TRAITEMENT DES DONNÉES (COLONNES DYNAMIQUES) --- | |
| var incomingData = {}; | |
| var rawDetails = []; | |
| var htmlList = "<ul>"; | |
| var phoneNumber = ""; | |
| var p_user = "", p_work = "", p_other = ""; | |
| if (data.user_column_data) { | |
| data.user_column_data.forEach(function(item) { | |
| // Google envoie "column_name" (Le texte de la question) | |
| var headerName = item.column_name || item.column_id; | |
| var value = item.string_value; | |
| var colId = item.column_id; | |
| // Stockage pour mappage colonne | |
| incomingData[headerName] = value; | |
| // Préparation E-mail | |
| htmlList += "<li style='margin-bottom: 5px;'><strong>" + headerName + " :</strong> " + value + "</li>"; | |
| rawDetails.push(headerName + ": " + value); | |
| // Détection téléphone | |
| if (colId === "PHONE_NUMBER") p_user = value; | |
| else if (colId === "WORK_PHONE") p_work = value; | |
| else if (/phone|tel|mobile|cell/i.test(headerName)) p_other = value; | |
| }); | |
| } | |
| htmlList += "</ul>"; | |
| phoneNumber = p_user || p_work || p_other; | |
| // Ajout des données techniques | |
| incomingData["Date"] = new Date(); | |
| incomingData["Nom Formulaire"] = formName; | |
| incomingData["GCLID"] = incomingGCLID || "N/A"; | |
| incomingData["ID Campagne"] = data.campaign_id || "N/A"; | |
| incomingData["Données Brutes"] = rawDetails.join(" | "); | |
| // --- 7. MISE À JOUR DES EN-TÊTES & ÉCRITURE --- | |
| // Cette fonction vérifie si de nouvelles questions sont arrivées et crée les colonnes | |
| updateHeadersIfNeeded(sheet, Object.keys(incomingData)); | |
| // Relecture des en-têtes (qui viennent peut-être de changer) | |
| var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; | |
| // Mappage des données dans le bon ordre | |
| var newRow = headers.map(function(header) { | |
| return incomingData[header] || ""; | |
| }); | |
| newRow = newRow.map(sanitizeForSheet); | |
| sheet.appendRow(newRow); | |
| // Commit immédiat pour que le prochain lead voie ces données | |
| SpreadsheetApp.flush(); | |
| // --- 8. ENVOI E-MAIL --- | |
| var subject = (data.is_test ? "⚠️ [TEST] " : "🚀 ") + "Nouveau Lead : " + formName; | |
| var callAction = ""; | |
| if (phoneNumber) { | |
| callAction = "<p style='text-align: center; margin: 20px 0;'>" + | |
| "<a href='tel:" + phoneNumber + "' style='background-color:#2196F3; color:white; padding:12px 20px; text-decoration:none; border-radius:25px; font-weight:bold; font-size:16px;'>📞 Appeler le " + phoneNumber + "</a></p>"; | |
| } | |
| var htmlBody = "<div style='font-family: Helvetica, Arial, sans-serif; max-width: 600px; margin: auto; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>" + | |
| "<div style='background-color: #4CAF50; color: white; padding: 15px; text-align: center;'>" + | |
| "<h2 style='margin:0;'>👋 Nouveau Lead Reçu !</h2>" + | |
| "<p style='margin:5px 0 0 0; opacity: 0.9;'>" + formName + "</p></div>" + | |
| "<div style='padding: 20px;'><p>Voici les détails du prospect :</p>" + | |
| "<div style='background-color: #f9f9f9; padding: 15px; border-radius: 5px; border-left: 4px solid #4CAF50;'>" + htmlList + "</div>" + | |
| callAction + | |
| "<hr style='border: 0; border-top: 1px solid #eee; margin: 20px 0;'>" + | |
| "<p style='font-size: 12px; color: #888; text-align: center;'>ID Campagne : " + (data.campaign_id || "N/A") + "<br>" + | |
| "<a href='" + ss.getUrl() + "' style='color: #4CAF50; text-decoration: none;'>Ouvrir le fichier Google Sheet</a></p></div></div>"; | |
| MailApp.sendEmail({ to: EMAIL_NOTIFICATION, subject: subject, htmlBody: htmlBody }); | |
| return ContentService.createTextOutput("Succès").setMimeType(ContentService.MimeType.TEXT); | |
| } catch (error) { | |
| console.error(error); | |
| // Tentative d'alerte par email en cas de crash | |
| try { | |
| MailApp.sendEmail(EMAIL_NOTIFICATION, "❌ ERREUR SCRIPT", "Une erreur webhook est survenue. Vérifiez les logs."); | |
| } catch(e) {} | |
| // IMPORTANT : On renvoie "Erreur" ici, mais Google considérera cela comme un 200 OK. | |
| // Si vous voulez forcer un retry, utilisez 'throw error' (mais attention aux boucles infinies). | |
| return ContentService.createTextOutput("Erreur").setMimeType(ContentService.MimeType.TEXT); | |
| } finally { | |
| lock.releaseLock(); | |
| } | |
| } | |
| // --- FONCTIONS UTILITAIRES --- | |
| function sanitizeForSheet(value) { | |
| if (typeof value !== "string") return value; | |
| var normalized = value.replace(/[\u200B-\u200D\uFEFF\u00A0]/g, "").trimStart(); | |
| if (/^[=+\-@]/.test(normalized)) return "'" + value; | |
| return value; | |
| } | |
| function updateHeadersIfNeeded(sheet, incomingKeys) { | |
| var lastCol = sheet.getLastColumn(); | |
| if (lastCol === 0) return; | |
| var currentHeaders = sheet.getRange(1, 1, 1, lastCol).getValues()[0]; | |
| var missing = []; | |
| // Identifie les nouvelles questions | |
| incomingKeys.forEach(function(k) { | |
| if (currentHeaders.indexOf(k) === -1) missing.push(k); | |
| }); | |
| if (missing.length > 0) { | |
| // Cherche la colonne ancre "GCLID" | |
| var insertIdx = currentHeaders.indexOf("GCLID"); | |
| if (insertIdx === -1) { | |
| // Si pas de GCLID (structure anormale), on ajoute à la fin | |
| insertIdx = lastCol + 1; | |
| sheet.getRange(1, insertIdx, 1, missing.length).setValues([missing]).setFontWeight("bold"); | |
| } else { | |
| // Si GCLID existe, on insère AVANT pour garder GCLID et les données techniques à droite | |
| // insertIdx est base-0, mais insertColumnsBefore est base-1. | |
| // Si GCLID est à l'index 2 (3ème colonne), on veut insérer à la colonne 3. | |
| var colNum = insertIdx + 1; | |
| sheet.insertColumnsBefore(colNum, missing.length); | |
| sheet.getRange(1, colNum, 1, missing.length).setValues([missing]).setFontWeight("bold").setBackground("#EFEFEF"); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment