Google Ads Script — Open Source
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.
Scanne uniquement les sous-comptes MCC portant le label que vous définissez. (Dé)labellisez pour changer le scope.
Filtre sur campagne + ad group + ad tous ENABLED. Pas de bruit sur du paused ou de l'archivé.
DESTINATION_NOT_WORKING, DESTINATION_MISMATCH, UNACCEPTABLE_URL, LANDING_PAGE, etc.
Un tableau par compte, une ligne par annonce (campagne, ad group, ad ID, URL, motif). Silencieux si RAS.
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.
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).
ACTIF_CLIENT) et appliquez-le aux sous-comptes que vous voulez surveiller.
CONFIG
vues plus haut, autorisez les scopes demandés (Google Ads + Gmail).
250 lignes, aucune dépendance externe. Un clic sur le bouton et il est dans votre presse-papier.
/**
* 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
main() bascule automatiquement en mode compte seul si MccApp n'est pas dispo).PAUSED et REMOVED. Modifiez le WHERE de la constante GAQL si vous voulez le comportement inverse.SENDER_NAME ne fonctionne que si c'est un alias Gmail que vous possédez. Sinon, votre nom Google par défaut sera utilisé.