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.
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
1/**2 * Broken URL Checker3 *4 * Verzamelt alle unieke final URL's van actieve advertenties, zoekwoorden en5 * sitelink-assets, roept ze aan met UrlFetchApp (met rate limiting en batches) en6 * rapporteert URL's die een 4xx/5xx-status geven, een time-out veroorzaken of een7 * "niet op voorraad"-tekst bevatten. Het rapport gaat per e-mail en optioneel naar8 * een Google Sheet. Het script wijzigt niets in het account.9 *10 * Versie: 1.5.011 * Frequentie: Dagelijks (of wekelijks bij grote accounts)12 * Niveau: Account13 * Licentie: MIT14 */1516// ===== CONFIGURATIE =====1718// Ontvangers van het rapport (komma-gescheiden). Verplicht voor een e-mailrapport.19var EMAIL_RECIPIENTS = 'ads@voorbeeld.nl';2021// Optioneel: Google Sheet waar de resultaten (alle gecontroleerde URL's) naartoe gaan. Leeg = geen Sheet.22var SPREADSHEET_URL = '';23var SHEET_NAME = 'URL check';2425// Optioneel: Slack webhook voor een korte melding bij problemen.26var SLACK_WEBHOOK_URL = '';2728// 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 zoekwoordniveau32 sitelinks: true // sitelink-assets33};3435// Alleen campagnes waarvan de naam deze tekst bevat. Leeg = alle campagnes.36var CAMPAIGN_NAME_CONTAINS = '';3738// 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'];4142// 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 run45 batchSize: 10, // aantal parallelle fetches per batch (fetchAll)46 sleepBetweenBatchesMs: 1500,47 maxRuntimeMinutes: 25 // stop netjes voordat Google het script na 30 min afbreekt48};4950// Zet op true om alleen te loggen zonder e-mail/Sheet te schrijven (handig bij eerste test).51var PREVIEW_MODE = false;5253// ===== EINDE CONFIGURATIE =====5455function main() {56 var startTime = new Date().getTime();57 try {58 var account = AdsApp.currentAccount();59 Logger.log('URL-check voor ' + account.getName() + ' (' + account.getCustomerId() + ')');6061 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 }6869 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 + ')'); });7374 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}9091/**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, "\\'") + "%'" : '';9899 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 }105106 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 }121122 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 }138139 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 }161162 return urlMap;163}164165/**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}177178/**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;184185 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 });201202 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 }211212 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}219220function 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}246247function 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}269270function 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}288289function 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}300301function escapeHtml(text) {302 return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');303}304Installatie & configuratie
Installatie
- Vul EMAIL_RECIPIENTS in; dit is de belangrijkste output.
- Optioneel: SPREADSHEET_URL voor een volledig overzicht van alle gecontroleerde URL's en SLACK_WEBHOOK_URL voor een korte melding.
- Kies in CHECK_SOURCES welke bronnen je controleert (advertenties, zoekwoorden, sitelinks).
- Pas OUT_OF_STOCK_TEXTS aan op de teksten die jouw webshop toont bij een uitverkocht product of een 404-pagina met status 200.
- Stel LIMITS in: maxUrlsPerRun (standaard 800), batchSize, sleepBetweenBatchesMs en maxRuntimeMinutes (blijf onder de 30 minuten van Google Ads Scripts).
- 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.