Skip to content

Instantly share code, notes, and snippets.

@vhsu
Last active January 27, 2026 18:59
Show Gist options
  • Select an option

  • Save vhsu/a8ea450c171f698d20aff5654af881f2 to your computer and use it in GitHub Desktop.

Select an option

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
/**
* 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