AdsPlatform
Monitoring · beginner · Dagelijks

Broken URL Checker

Controleert alle final URL's van advertenties, zoekwoorden en sitelinks op HTTP-fouten en 'niet op voorraad'-teksten en mailt een rapport.

url404monitoringurlfetche-mail

Wat dit script doet

  • Verzamelt unieke final URL's uit actieve advertenties, zoekwoorden en sitelink-assets (campagne- en accountniveau).
  • Verwijdert ValueTrack-parameters zodat de URL's echt opgevraagd kunnen worden.
  • Roept de URL's in batches aan met UrlFetchApp.fetchAll, met pauzes en een runtime-limiet.
  • Herkent 4xx/5xx-statuscodes, time-outs en configureerbare 'uitverkocht'-teksten in de pagina.
  • Schrijft alle resultaten naar een Sheet en mailt alleen als er problemen zijn.

Broncode

broken-url-checker.js· 304 regels
1/**
2 * Broken URL Checker
3 *
4 * Verzamelt alle unieke final URL's van actieve advertenties, zoekwoorden en
5 * sitelink-assets, roept ze aan met UrlFetchApp (met rate limiting en batches) en
6 * rapporteert URL's die een 4xx/5xx-status geven, een time-out veroorzaken of een
7 * "niet op voorraad"-tekst bevatten. Het rapport gaat per e-mail en optioneel naar
8 * een Google Sheet. Het script wijzigt niets in het account.
9 *
10 * Versie: 1.5.0
11 * Frequentie: Dagelijks (of wekelijks bij grote accounts)
12 * Niveau: Account
13 * Licentie: MIT
14 */
15
16// ===== CONFIGURATIE =====
17
18// Ontvangers van het rapport (komma-gescheiden). Verplicht voor een e-mailrapport.
19var EMAIL_RECIPIENTS = 'ads@voorbeeld.nl';
20
21// Optioneel: Google Sheet waar de resultaten (alle gecontroleerde URL's) naartoe gaan. Leeg = geen Sheet.
22var SPREADSHEET_URL = '';
23var SHEET_NAME = 'URL check';
24
25// Optioneel: Slack webhook voor een korte melding bij problemen.
26var SLACK_WEBHOOK_URL = '';
27
28// Welke bronnen gecontroleerd worden.
29var CHECK_SOURCES = {
30 ads: true, // final URL's van advertenties (ad_group_ad)
31 keywords: true, // final URL's op zoekwoordniveau
32 sitelinks: true // sitelink-assets
33};
34
35// Alleen campagnes waarvan de naam deze tekst bevat. Leeg = alle campagnes.
36var CAMPAIGN_NAME_CONTAINS = '';
37
38// Teksten die wijzen op een niet-verkoopbaar product (hoofdletterongevoelig).
39var OUT_OF_STOCK_TEXTS = ['niet op voorraad', 'uitverkocht', 'tijdelijk niet leverbaar', 'out of stock', 'sold out',
40 'pagina niet gevonden', 'page not found'];
41
42// Rate limiting en limieten (UrlFetchApp heeft dagelijkse quota; blijf ruim onder de 30 minuten runtime).
43var LIMITS = {
44 maxUrlsPerRun: 800, // maximum aantal unieke URL's per run
45 batchSize: 10, // aantal parallelle fetches per batch (fetchAll)
46 sleepBetweenBatchesMs: 1500,
47 maxRuntimeMinutes: 25 // stop netjes voordat Google het script na 30 min afbreekt
48};
49
50// Zet op true om alleen te loggen zonder e-mail/Sheet te schrijven (handig bij eerste test).
51var PREVIEW_MODE = false;
52
53// ===== EINDE CONFIGURATIE =====
54
55function main() {
56 var startTime = new Date().getTime();
57 try {
58 var account = AdsApp.currentAccount();
59 Logger.log('URL-check voor ' + account.getName() + ' (' + account.getCustomerId() + ')');
60
61 var urlMap = collectUrls();
62 var urls = Object.keys(urlMap);
63 Logger.log(urls.length + ' unieke URL\'s verzameld.');
64 if (urls.length > LIMITS.maxUrlsPerRun) {
65 Logger.log('Beperkt tot de eerste ' + LIMITS.maxUrlsPerRun + ' URL\'s (verhoog LIMITS.maxUrlsPerRun of splits per campagne).');
66 urls = urls.slice(0, LIMITS.maxUrlsPerRun);
67 }
68
69 var results = checkUrls(urls, urlMap, startTime);
70 var problems = results.filter(function (r) { return !r.ok; });
71 Logger.log(results.length + ' gecontroleerd, ' + problems.length + ' met problemen.');
72 problems.forEach(function (p) { Logger.log('PROBLEEM ' + p.status + ' ' + p.url + ' (' + p.reason + ')'); });
73
74 if (PREVIEW_MODE) {
75 Logger.log('PREVIEW_MODE: geen e-mail of Sheet geschreven.');
76 return;
77 }
78 writeSheet(results);
79 if (problems.length > 0) {
80 sendEmail(account, problems, results.length);
81 sendSlack(account, problems);
82 } else {
83 Logger.log('Geen problemen gevonden; geen e-mail verstuurd.');
84 }
85 } catch (e) {
86 Logger.log('FOUT in broken-url-checker: ' + e + (e.stack ? '\n' + e.stack : ''));
87 throw e;
88 }
89}
90
91/**
92 * Verzamelt final URL's uit advertenties, zoekwoorden en sitelinks.
93 * Retourneert een map url -> lijst van bronnen ("Advertentie | Campagne > Adgroep").
94 */
95function collectUrls() {
96 var urlMap = {};
97 var campaignFilter = CAMPAIGN_NAME_CONTAINS ? " AND campaign.name LIKE '%" + CAMPAIGN_NAME_CONTAINS.replace(/'/g, "\\'") + "%'" : '';
98
99 function add(url, source) {
100 var clean = stripTrackingTemplate(url);
101 if (!clean) { return; }
102 if (!urlMap[clean]) { urlMap[clean] = []; }
103 if (urlMap[clean].length < 5) { urlMap[clean].push(source); }
104 }
105
106 if (CHECK_SOURCES.ads) {
107 var adQuery =
108 'SELECT campaign.name, ad_group.name, ad_group_ad.ad.id, ad_group_ad.ad.final_urls, ad_group_ad.ad.final_mobile_urls ' +
109 'FROM ad_group_ad ' +
110 "WHERE ad_group_ad.status = 'ENABLED' AND ad_group.status = 'ENABLED' AND campaign.status = 'ENABLED'" + campaignFilter;
111 var adRows = AdsApp.search(adQuery);
112 var adCount = 0;
113 while (adRows.hasNext()) {
114 var r = adRows.next();
115 adCount++;
116 var finals = (r.adGroupAd.ad.finalUrls || []).concat(r.adGroupAd.ad.finalMobileUrls || []);
117 finals.forEach(function (u) { add(u, 'Advertentie ' + r.adGroupAd.ad.id + ' | ' + r.campaign.name + ' > ' + r.adGroup.name); });
118 }
119 Logger.log(adCount + ' advertenties gescand.');
120 }
121
122 if (CHECK_SOURCES.keywords) {
123 var kwQuery =
124 'SELECT campaign.name, ad_group.name, ad_group_criterion.keyword.text, ad_group_criterion.final_urls ' +
125 'FROM keyword_view ' +
126 "WHERE ad_group_criterion.status = 'ENABLED' AND ad_group.status = 'ENABLED' AND campaign.status = 'ENABLED'" + campaignFilter;
127 var kwRows = AdsApp.search(kwQuery);
128 var kwCount = 0;
129 while (kwRows.hasNext()) {
130 var k = kwRows.next();
131 var kwUrls = k.adGroupCriterion.finalUrls || [];
132 if (!kwUrls.length) { continue; }
133 kwCount++;
134 kwUrls.forEach(function (u) { add(u, 'Zoekwoord "' + k.adGroupCriterion.keyword.text + '" | ' + k.campaign.name + ' > ' + k.adGroup.name); });
135 }
136 Logger.log(kwCount + ' zoekwoorden met eigen final URL gescand.');
137 }
138
139 if (CHECK_SOURCES.sitelinks) {
140 // Sitelinks op campagneniveau en accountniveau.
141 var slQueries = [
142 "SELECT campaign.name, asset.sitelink_asset.link_text, asset.final_urls FROM campaign_asset WHERE asset.type = 'SITELINK' AND campaign_asset.status = 'ENABLED' AND campaign.status = 'ENABLED'" + campaignFilter,
143 "SELECT asset.sitelink_asset.link_text, asset.final_urls FROM customer_asset WHERE asset.type = 'SITELINK' AND customer_asset.status = 'ENABLED'"
144 ];
145 var slCount = 0;
146 slQueries.forEach(function (q) {
147 try {
148 var rows = AdsApp.search(q);
149 while (rows.hasNext()) {
150 var s = rows.next();
151 slCount++;
152 var label = s.campaign ? s.campaign.name : 'Account';
153 (s.asset.finalUrls || []).forEach(function (u) { add(u, 'Sitelink "' + s.asset.sitelinkAsset.linkText + '" | ' + label); });
154 }
155 } catch (e) {
156 Logger.log('Sitelink-query overgeslagen: ' + e);
157 }
158 });
159 Logger.log(slCount + ' sitelinks gescand.');
160 }
161
162 return urlMap;
163}
164
165/**
166 * Verwijdert ValueTrack-parameters die alleen in een klik ingevuld worden ({lpurl}, {ignore} etc.).
167 */
168function stripTrackingTemplate(url) {
169 if (!url) { return null; }
170 var u = String(url).trim();
171 if (u.indexOf('{lpurl') === 0 || u.indexOf('{unescapedlpurl') === 0) { return null; }
172 u = u.replace(/\{ignore\}/g, '').replace(/\{[a-zA-Z_]+\}/g, '');
173 u = u.replace(/[?&]$/, '');
174 if (!/^https?:\/\//i.test(u)) { return null; }
175 return u;
176}
177
178/**
179 * Controleert de URL's in batches met UrlFetchApp.fetchAll.
180 */
181function checkUrls(urls, urlMap, startTime) {
182 var results = [];
183 var maxMs = LIMITS.maxRuntimeMinutes * 60 * 1000;
184
185 for (var i = 0; i < urls.length; i += LIMITS.batchSize) {
186 if (new Date().getTime() - startTime > maxMs) {
187 Logger.log('Runtime-limiet bereikt; ' + (urls.length - i) + ' URL\'s niet gecontroleerd.');
188 break;
189 }
190 var batch = urls.slice(i, i + LIMITS.batchSize);
191 var requests = batch.map(function (u) {
192 return {
193 url: u,
194 method: 'get',
195 muteHttpExceptions: true,
196 followRedirects: true,
197 validateHttpsCertificates: true,
198 headers: { 'User-Agent': 'Mozilla/5.0 (compatible; GoogleAdsScriptUrlChecker/1.5)' }
199 };
200 });
201
202 var responses;
203 try {
204 responses = UrlFetchApp.fetchAll(requests);
205 } catch (e) {
206 // Als fetchAll faalt (bv. DNS-fout in één URL) controleren we de batch één voor één.
207 responses = batch.map(function (u) {
208 try { return UrlFetchApp.fetch(u, requests[0]); } catch (err) { return { error: String(err) }; }
209 });
210 }
211
212 for (var j = 0; j < batch.length; j++) {
213 results.push(evaluateResponse(batch[j], responses[j], urlMap[batch[j]]));
214 }
215 Utilities.sleep(LIMITS.sleepBetweenBatchesMs);
216 }
217 return results;
218}
219
220function evaluateResponse(url, response, sources) {
221 var result = { url: url, status: 0, ok: true, reason: 'OK', sources: (sources || []).join('; ') };
222 if (!response || response.error) {
223 result.ok = false;
224 result.status = 0;
225 result.reason = 'Geen antwoord: ' + (response && response.error ? response.error : 'time-out of DNS-fout');
226 return result;
227 }
228 var code = response.getResponseCode();
229 result.status = code;
230 if (code >= 400) {
231 result.ok = false;
232 result.reason = code === 404 ? 'Pagina niet gevonden (404)' : 'HTTP-fout ' + code;
233 return result;
234 }
235 var body;
236 try { body = String(response.getContentText() || '').toLowerCase(); } catch (e) { body = ''; }
237 for (var i = 0; i < OUT_OF_STOCK_TEXTS.length; i++) {
238 if (body.indexOf(OUT_OF_STOCK_TEXTS[i].toLowerCase()) !== -1) {
239 result.ok = false;
240 result.reason = 'Tekst gevonden: "' + OUT_OF_STOCK_TEXTS[i] + '"';
241 break;
242 }
243 }
244 return result;
245}
246
247function writeSheet(results) {
248 if (!SPREADSHEET_URL) { return; }
249 try {
250 var ss = SpreadsheetApp.openByUrl(SPREADSHEET_URL);
251 var sheet = ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME);
252 sheet.clearContents();
253 var header = ['Gecontroleerd op', 'URL', 'HTTP-status', 'Resultaat', 'Reden', 'Bronnen'];
254 var now = new Date();
255 var values = [header].concat(results.sort(function (a, b) { return (a.ok ? 1 : 0) - (b.ok ? 1 : 0); }).map(function (r) {
256 return [now, r.url, r.status, r.ok ? 'OK' : 'PROBLEEM', r.reason, r.sources];
257 }));
258 sheet.getRange(1, 1, values.length, header.length).setValues(values);
259 sheet.getRange(1, 1, 1, header.length).setFontWeight('bold').setBackground('#e8eaf6');
260 sheet.setFrozenRows(1);
261 for (var i = 1; i < values.length; i++) {
262 if (values[i][3] === 'PROBLEEM') { sheet.getRange(i + 1, 1, 1, header.length).setBackground('#fdecea'); }
263 }
264 Logger.log('Resultaten naar Sheet geschreven.');
265 } catch (e) {
266 Logger.log('Sheet schrijven mislukt: ' + e);
267 }
268}
269
270function sendEmail(account, problems, totalChecked) {
271 if (!EMAIL_RECIPIENTS) { return; }
272 var subject = '[Google Ads] ' + problems.length + ' kapotte URL(\'s) in ' + account.getName();
273 var html = '<div style="font-family:Arial,sans-serif;font-size:13px"><p>' + totalChecked + ' URL\'s gecontroleerd, ' +
274 problems.length + ' met problemen.</p><table cellpadding="5" style="border-collapse:collapse;border:1px solid #ddd">' +
275 '<tr style="background:#f5f5f5"><th align="left">URL</th><th>Status</th><th align="left">Reden</th><th align="left">Bronnen</th></tr>';
276 problems.forEach(function (p) {
277 html += '<tr><td><a href="' + escapeHtml(p.url) + '">' + escapeHtml(p.url) + '</a></td><td align="center">' + p.status +
278 '</td><td>' + escapeHtml(p.reason) + '</td><td>' + escapeHtml(p.sources) + '</td></tr>';
279 });
280 html += '</table>' + (SPREADSHEET_URL ? '<p><a href="' + SPREADSHEET_URL + '">Volledig overzicht in Sheet</a></p>' : '') + '</div>';
281 try {
282 MailApp.sendEmail({ to: EMAIL_RECIPIENTS, subject: subject, htmlBody: html });
283 Logger.log('E-mail verstuurd naar ' + EMAIL_RECIPIENTS);
284 } catch (e) {
285 Logger.log('E-mail versturen mislukt: ' + e);
286 }
287}
288
289function sendSlack(account, problems) {
290 if (!SLACK_WEBHOOK_URL) { return; }
291 var text = ':rotating_light: *' + problems.length + ' kapotte URL(\'s) in ' + account.getName() + '*\n' +
292 problems.slice(0, 10).map(function (p) { return '- ' + p.status + ' ' + p.url + ' (' + p.reason + ')'; }).join('\n') +
293 (problems.length > 10 ? '\n... en ' + (problems.length - 10) + ' meer.' : '');
294 try {
295 UrlFetchApp.fetch(SLACK_WEBHOOK_URL, { method: 'post', contentType: 'application/json', payload: JSON.stringify({ text: text }), muteHttpExceptions: true });
296 } catch (e) {
297 Logger.log('Slack versturen mislukt: ' + e);
298 }
299}
300
301function escapeHtml(text) {
302 return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
303}
304

Installatie & configuratie

Installatie

  1. Vul EMAIL_RECIPIENTS in; dit is de belangrijkste output.
  2. Optioneel: SPREADSHEET_URL voor een volledig overzicht van alle gecontroleerde URL's en SLACK_WEBHOOK_URL voor een korte melding.
  3. Kies in CHECK_SOURCES welke bronnen je controleert (advertenties, zoekwoorden, sitelinks).
  4. Pas OUT_OF_STOCK_TEXTS aan op de teksten die jouw webshop toont bij een uitverkocht product of een 404-pagina met status 200.
  5. Stel LIMITS in: maxUrlsPerRun (standaard 800), batchSize, sleepBetweenBatchesMs en maxRuntimeMinutes (blijf onder de 30 minuten van Google Ads Scripts).
  6. Zet PREVIEW_MODE op true voor de eerste run: dan wordt alleen gelogd.

Testen

Draai een preview met PREVIEW_MODE aan. In de logs zie je hoeveel URL's er verzameld zijn per bron en welke problemen er gevonden zijn. Controleer een paar gemelde URL's handmatig: sommige sites blokkeren bots en geven een 403 terwijl de pagina voor bezoekers werkt. Voeg zulke domeinen dan uit of accepteer de melding.

Output

Een e-mail met per probleem de URL, statuscode, reden en de bronnen (advertentie-ID, zoekwoord of sitelink met campagne en advertentiegroep). In de Sheet staan alle URL's, probleemregels rood gemarkeerd.

Grote accounts

Bij meer dan ~800 unieke URL's splits je het script per campagnegroep met CAMPAIGN_NAME_CONTAINS en plan je meerdere kopieën, of verhoog je de limiet en accepteer je dat niet alles in één run gecontroleerd wordt.