Google Ads Script — Open Source

Daily Disapproved Landing Pages Monitor

Un Google Ads Script à installer dans votre MCC : chaque matin, il détecte les annonces actives dont la landing page est cassée (HTTP 4xx/5xx, URL non joignable, redirect problématique…) et vous envoie un mail HTML groupé par compte. Zéro dépendance, gratuit, prêt à l'emploi.

Ce que fait le script

Périmètre piloté par label

Scanne uniquement les sous-comptes MCC portant le label que vous définissez. (Dé)labellisez pour changer le scope.

Annonces actives uniquement

Filtre sur campagne + ad group + ad tous ENABLED. Pas de bruit sur du paused ou de l'archivé.

6 motifs LP surveillés

DESTINATION_NOT_WORKING, DESTINATION_MISMATCH, UNACCEPTABLE_URL, LANDING_PAGE, etc.

Mail HTML groupé

Un tableau par compte, une ligne par annonce (campagne, ad group, ad ID, URL, motif). Silencieux si RAS.

Ce que vous devez modifier

3 lignes dans le bloc CONFIG en haut du script. C'est tout.

LABEL_FILTER: 'YOUR_LABEL_NAME'

Le nom exact du label MCC que vous créerez pour marquer les comptes à scanner. Ex. 'ACTIF_CLIENT'.

TO: 'team@example.com'

Le destinataire principal du rapport. C'est requis.

CC: 'you@example.com'

Vos CCs éventuels (séparés par virgule). Laissez '' si vous ne voulez pas de CC.

Bonus optionnel — dans CONFIG vous pouvez aussi personnaliser : les motifs surveillés (DEST_TOPIC_KEYWORDS), les couleurs de charte (BRAND), le nom d'expéditeur (SENDER_NAME, doit être un alias Gmail que vous possédez) et la timezone (TIMEZONE, par défaut Europe/Paris).

Installation en 4 étapes

  1. Créez le label dans votre MCC. Outils & paramètres → Configuration → Libellés des comptes → créez un label (ex. ACTIF_CLIENT) et appliquez-le aux sous-comptes que vous voulez surveiller.
  2. Ouvrez l'éditeur de scripts du MCC. Outils & paramètres → Actions groupées → Scripts → cliquez sur +.
  3. Collez le script et personnalisez. Copiez le script ci-dessous, collez-le dans l'éditeur, modifiez les 3 lignes de CONFIG vues plus haut, autorisez les scopes demandés (Google Ads + Gmail).
  4. Testez puis planifiez. Cliquez Aperçu — le log vous indique le nombre de comptes scannés et d'annonces trouvées, sans qu'un mail ne parte. Si tout est OK, enregistrez, puis Fréquence → Daily → 08h00 (ou l'heure de votre choix).

Le script

250 lignes, aucune dépendance externe. Un clic sur le bouton et il est dans votre presse-papier.

daily-disapproved-lp-monitor.gs
/**
 * Daily Disapproved Landing Pages Monitor
 *
 * Google Ads Script (MCC-level) — scans all sub-accounts flagged
 * with a given MCC label, finds ads currently DISAPPROVED for a
 * destination / landing-page issue, and sends a daily HTML email
 * grouping findings by account.
 *
 * WHERE TO DEPLOY
 *   1. Log in to your Google Ads manager account (MCC)
 *   2. Tools & Settings > Bulk Actions > Scripts > +
 *   3. Paste this file, authorize scopes (Gmail + Google Ads)
 *   4. "Preview" to test, then "Save & Run"
 *   5. Set "Frequency" > "Daily" at your preferred hour
 *
 * SCOPE CONTROL
 *   The set of scanned accounts is driven by a MCC label
 *   (CONFIG.LABEL_FILTER below). Label/unlabel accounts in
 *   your MCC to add/remove them from the scan — no need to
 *   touch the script.
 *
 * NON-MCC ACCOUNTS
 *   Direct (unlinked) accounts cannot receive MCC labels.
 *   To include them: link them to the MCC, OR duplicate this
 *   script into each direct account (main() falls back to a
 *   single-account run when MccApp is undefined).
 *
 * LICENSE
 *   Free to use, modify, and share.
 */

// ============================================================
// CONFIG — customize the 3 marked lines below
// ============================================================
var CONFIG = {
  // >>> REQUIRED <<<
  // Name of the MCC label used to mark accounts you want scanned.
  // Create it once in your MCC (Tools > Setup > Account labels)
  // and apply it to the sub-accounts you want in scope.
  LABEL_FILTER: 'YOUR_LABEL_NAME',

  // >>> REQUIRED <<< email of the primary recipient
  TO: 'team@example.com',

  // >>> OPTIONAL <<< comma-separated CCs (leave '' for none)
  CC: 'you@example.com',

  // Which policy topics trigger an alert. Case-insensitive substring match.
  // See https://developers.google.com/google-ads/api/reference/rpc/latest/PolicyTopicEntryProto
  DEST_TOPIC_KEYWORDS: [
    'DESTINATION_NOT_WORKING',
    'DESTINATION_NOT_CRAWLABLE',
    'DESTINATION_MISMATCH',
    'DESTINATION_NOT_ACCESSIBLE',
    'UNACCEPTABLE_URL',
    'LANDING_PAGE'
  ],

  // Sender display name (must be a Gmail alias you own,
  // otherwise your default account name will be used).
  SENDER_NAME: 'Landing Page Monitor',

  // Cosmetic — tweak to your brand
  BRAND: {
    DARK:    '#323232',
    ACCENT:  '#E6BE6E',
    BG:      '#F5F5F0',
    MUTED:   '#6b6b6b'
  },

  // Timezone used to date-stamp the report
  TIMEZONE: 'Europe/Paris'
};

// GAQL query — currently disapproved ads on ENABLED entities only
var GAQL = [
  'SELECT customer.id, customer.descriptive_name, campaign.id, campaign.name,',
  '       ad_group.id, ad_group.name, ad_group_ad.ad.id,',
  '       ad_group_ad.ad.final_urls,',
  '       ad_group_ad.policy_summary.approval_status,',
  '       ad_group_ad.policy_summary.policy_topic_entries',
  '  FROM ad_group_ad',
  " WHERE ad_group_ad.policy_summary.approval_status = 'DISAPPROVED'",
  "   AND ad_group_ad.status = 'ENABLED'",
  "   AND ad_group.status = 'ENABLED'",
  "   AND campaign.status = 'ENABLED'"
].join(' ');

// ============================================================
// ENTRY POINT
// ============================================================
function main() {
  var rows = [];
  var scanned = 0;
  var skipped = [];

  if (typeof MccApp !== 'undefined') {
    // MCC context — filter accounts by label
    var labelCondition = "LabelNames CONTAINS_ANY ['" + CONFIG.LABEL_FILTER + "']";
    var iterator = MccApp.accounts().withCondition(labelCondition).get();
    while (iterator.hasNext()) {
      var account = iterator.next();
      MccApp.select(account);
      try {
        var found = collectForCurrentAccount();
        rows = rows.concat(found);
        scanned++;
      } catch (e) {
        skipped.push(account.getCustomerId() + ' (' + e.message + ')');
      }
    }
  } else {
    // Direct (non-MCC) account context
    try {
      rows = collectForCurrentAccount();
      scanned = 1;
    } catch (e) {
      skipped.push(AdsApp.currentAccount().getCustomerId() + ' (' + e.message + ')');
    }
  }

  var todayIso = Utilities.formatDate(new Date(), CONFIG.TIMEZONE, 'yyyy-MM-dd');

  if (rows.length === 0) {
    Logger.log('OK — no disapproved LP detected on ' + todayIso + '. ' +
               scanned + ' account(s) scanned.');
    if (skipped.length) Logger.log('Skipped: ' + skipped.join(', '));
    return;
  }

  rows.sort(function(a, b) {
    if (a.account !== b.account) return a.account < b.account ? -1 : 1;
    if (a.campaign !== b.campaign) return a.campaign < b.campaign ? -1 : 1;
    return a.adId < b.adId ? -1 : 1;
  });

  var grouped = groupByAccount(rows);
  var distinctAccounts = Object.keys(grouped).length;

  var subject = '[Google Ads] Disapproved landing pages — ' + todayIso +
                ' — ' + rows.length + ' ad(s) on ' + distinctAccounts + ' account(s)';
  var html = renderHtml(grouped, rows.length, distinctAccounts, todayIso);
  var text = renderText(grouped, rows.length, distinctAccounts, todayIso);

  var mailOpts = {
    to: CONFIG.TO,
    subject: subject,
    body: text,
    htmlBody: html,
    name: CONFIG.SENDER_NAME
  };
  if (CONFIG.CC) mailOpts.cc = CONFIG.CC;

  MailApp.sendEmail(mailOpts);

  Logger.log('Email sent: ' + rows.length + ' ad(s), ' + distinctAccounts + ' account(s).');
  if (skipped.length) Logger.log('Skipped: ' + skipped.join(', '));
}

// ============================================================
// COLLECTION
// ============================================================
function collectForCurrentAccount() {
  // We use AdsApp.search() (not AdsApp.report()) because
  // policy_topic_entries is a repeated MESSAGE field.
  // report() flattens it to an empty string; search() returns
  // full protobuf objects with nested access.
  var found = [];
  var iterator;
  try {
    iterator = AdsApp.search(GAQL);
  } catch (e) {
    return found;
  }

  var accountName = AdsApp.currentAccount().getName();
  var cidFallback = AdsApp.currentAccount().getCustomerId().replace(/-/g, '');

  while (iterator.hasNext()) {
    var r = iterator.next();
    var entries = (r.adGroupAd && r.adGroupAd.policySummary && r.adGroupAd.policySummary.policyTopicEntries) || [];

    var matchedTopics = [];
    for (var i = 0; i < entries.length; i++) {
      var topic = entries[i] && entries[i].topic;
      if (topic && isDestinationTopic(topic)) matchedTopics.push(topic);
    }
    if (matchedTopics.length === 0) continue;

    var ad = (r.adGroupAd && r.adGroupAd.ad) || {};
    found.push({
      account: (r.customer && r.customer.descriptiveName) || accountName,
      cid: (r.customer && r.customer.id) || cidFallback,
      campaign: (r.campaign && r.campaign.name) || '',
      campaignId: (r.campaign && r.campaign.id) || '',
      adGroup: (r.adGroup && r.adGroup.name) || '',
      adGroupId: (r.adGroup && r.adGroup.id) || '',
      adId: ad.id || '',
      finalUrls: ad.finalUrls || [],
      topics: matchedTopics
    });
  }
  return found;
}

function isDestinationTopic(topic) {
  var u = String(topic).toUpperCase();
  for (var i = 0; i < CONFIG.DEST_TOPIC_KEYWORDS.length; i++) {
    if (u.indexOf(CONFIG.DEST_TOPIC_KEYWORDS[i]) !== -1) return true;
  }
  return false;
}

function groupByAccount(rows) {
  var out = {};
  for (var i = 0; i < rows.length; i++) {
    var key = rows[i].account + '||' + rows[i].cid;
    if (!out[key]) out[key] = { account: rows[i].account, cid: rows[i].cid, rows: [] };
    out[key].rows.push(rows[i]);
  }
  return out;
}

// ============================================================
// RENDERING
// ============================================================
function renderHtml(grouped, total, accounts, today) {
  var B = CONFIG.BRAND;
  var parts = [];
  parts.push('<!doctype html><html lang="en"><head><meta charset="utf-8"></head>');
  parts.push('<body style="margin:0;padding:0;background:' + B.BG +
             ';font-family:Helvetica,Arial,sans-serif;color:' + B.DARK + ';">');
  parts.push('<div style="max-width:720px;margin:0 auto;padding:32px 24px;background:' + B.BG + ';">');
  parts.push('<div style="border-top:4px solid ' + B.ACCENT + ';padding-top:24px;">');
  parts.push('<h1 style="font-family:Georgia,serif;font-size:24px;margin:0 0 12px 0;color:' + B.DARK +
             ';">Google Ads — Disapproved landing pages</h1>');
  parts.push('<p style="font-size:14px;line-height:1.5;margin:0 0 8px 0;">Ads currently disapproved for a destination / landing-page issue, detected on <strong>' +
             escapeHtml(today) + '</strong>.</p>');
  parts.push('<p style="font-size:14px;line-height:1.5;margin:0 0 24px 0;"><strong>' +
             total + ' ad(s)</strong> on <strong>' + accounts +
             ' account(s)</strong> impacted — campaigns / ad groups / ads <strong>ENABLED</strong>.</p>');

  var keys = Object.keys(grouped).sort();
  for (var k = 0; k < keys.length; k++) {
    var g = grouped[keys[k]];
    parts.push('<h2 style="font-family:Georgia,serif;font-size:18px;margin:24px 0 8px 0;padding-bottom:6px;border-bottom:2px solid ' +
               B.ACCENT + ';">' + escapeHtml(g.account) +
               ' <span style="font-size:12px;color:' + B.MUTED +
               ';font-family:Helvetica,sans-serif;">— ID ' + escapeHtml(g.cid) +
               ' — ' + g.rows.length + ' ad(s)</span></h2>');
    parts.push('<table cellpadding="0" cellspacing="0" border="0" style="width:100%;border-collapse:collapse;font-size:12px;margin-bottom:16px;">');
    parts.push('<thead><tr style="background:' + B.DARK + ';color:' + B.BG + ';">' +
               '<th align="left" style="padding:8px;border-bottom:1px solid ' + B.ACCENT + ';">Campaign</th>' +
               '<th align="left" style="padding:8px;border-bottom:1px solid ' + B.ACCENT + ';">Ad group</th>' +
               '<th align="left" style="padding:8px;border-bottom:1px solid ' + B.ACCENT + ';">Ad ID</th>' +
               '<th align="left" style="padding:8px;border-bottom:1px solid ' + B.ACCENT + ';">Final URL</th>' +
               '<th align="left" style="padding:8px;border-bottom:1px solid ' + B.ACCENT + ';">Reason</th>' +
               '</tr></thead><tbody>');
    for (var i = 0; i < g.rows.length; i++) {
      var r = g.rows[i];
      var bg = (i % 2 === 0) ? '#ffffff' : B.BG;
      var url = (r.finalUrls && r.finalUrls.length) ? r.finalUrls[0] : '';
      var urlCell = url
        ? '<a href="' + escapeHtml(url) + '" style="color:' + B.DARK +
          ';word-break:break-all;">' + escapeHtml(url) + '</a>'
        : '<span style="color:' + B.MUTED + ';">(not available)</span>';
      parts.push('<tr style="background:' + bg + ';vertical-align:top;">' +
                 '<td style="padding:8px;border-bottom:1px solid #e5e5e5;">' + escapeHtml(r.campaign) + '</td>' +
                 '<td style="padding:8px;border-bottom:1px solid #e5e5e5;">' + escapeHtml(r.adGroup) + '</td>' +
                 '<td style="padding:8px;border-bottom:1px solid #e5e5e5;font-family:monospace;">' + escapeHtml(r.adId) + '</td>' +
                 '<td style="padding:8px;border-bottom:1px solid #e5e5e5;">' + urlCell + '</td>' +
                 '<td style="padding:8px;border-bottom:1px solid #e5e5e5;">' + escapeHtml(r.topics.join(', ')) + '</td>' +
                 '</tr>');
    }
    parts.push('</tbody></table>');
  }
  parts.push('<p style="font-size:11px;color:' + B.MUTED +
             ';margin-top:32px;border-top:1px solid #e5e5e5;padding-top:12px;">' +
             'Automated report — Google Ads Script — daily landing-page monitor.</p>');
  parts.push('</div></div></body></html>');
  return parts.join('');
}

function renderText(grouped, total, accounts, today) {
  var lines = [];
  lines.push('Google Ads — Disapproved landing pages — ' + today);
  lines.push('============================================================');
  lines.push(total + ' ad(s) on ' + accounts +
             ' account(s) impacted (campaigns / ad groups / ads ENABLED).');
  lines.push('');
  var keys = Object.keys(grouped).sort();
  for (var k = 0; k < keys.length; k++) {
    var g = grouped[keys[k]];
    lines.push('## ' + g.account + ' (ID ' + g.cid + ') — ' + g.rows.length + ' ad(s)');
    lines.push('------------------------------------------------------------');
    for (var i = 0; i < g.rows.length; i++) {
      var r = g.rows[i];
      var url = (r.finalUrls && r.finalUrls.length) ? r.finalUrls[0] : '(not available)';
      lines.push('  Campaign : ' + r.campaign);
      lines.push('  Ad group : ' + r.adGroup);
      lines.push('  Ad ID    : ' + r.adId);
      lines.push('  URL      : ' + url);
      lines.push('  Reason   : ' + r.topics.join(', '));
      lines.push('');
    }
    lines.push('');
  }
  lines.push('--');
  lines.push('Automated report — Google Ads Script — daily landing-page monitor.');
  return lines.join('\n');
}

// ============================================================
// UTIL
// ============================================================
function escapeHtml(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

Bon à savoir