ads-system.store

Final URL health check

read-only

Requests every final URL on enabled ads and keywords and reports the ones that do not return 200 or that redirect to another host.

requires
Any account with enabled ads or keywords.
writes
Nothing. Read-only.
schedule
Daily
change first
EMAIL and MAX_URLS at the top.
final-url-health-check.js
// Final URL health check
// Collects every unique final URL on enabled ads and keywords, requests each one
// following redirects, and reports anything that does not return 200 or that
// ends up on a different host than it started on.
// Read-only: it reports, it does not pause anything.

var EMAIL      = '';    // leave empty to log only
var MAX_URLS   = 300;   // scripts stop at 30 minutes; raise only if you have headroom
var USER_AGENT = 'Mozilla/5.0 (compatible; AdsScriptUrlCheck/1.0)';

function main() {
  var urls = {};

  collect(
    'SELECT ad_group_ad.ad.final_urls ' +
    'FROM ad_group_ad ' +
    'WHERE ad_group_ad.status = "ENABLED" ' +
    '  AND ad_group.status = "ENABLED" ' +
    '  AND campaign.status = "ENABLED"',
    function (r) { return r.adGroupAd && r.adGroupAd.ad ? r.adGroupAd.ad.finalUrls : null; },
    urls
  );

  collect(
    'SELECT ad_group_criterion.final_urls ' +
    'FROM keyword_view ' +
    'WHERE ad_group_criterion.status = "ENABLED" ' +
    '  AND ad_group.status = "ENABLED" ' +
    '  AND campaign.status = "ENABLED"',
    function (r) { return r.adGroupCriterion ? r.adGroupCriterion.finalUrls : null; },
    urls
  );

  var list = Object.keys(urls);
  Logger.log(list.length + ' unique final URLs found.');

  var bad     = [];
  var checked = 0;

  for (var i = 0; i < list.length && checked < MAX_URLS; i++) {
    var url = list[i];
    checked++;
    var result = check(url);
    if (result) { bad.push(result); }
  }

  if (bad.length === 0) {
    Logger.log('All ' + checked + ' URLs returned 200 on the same host.');
    return;
  }

  var lines = ['url\tstatus\tnote'];
  for (var k = 0; k < bad.length; k++) {
    lines.push(bad[k].url + '\t' + bad[k].status + '\t' + bad[k].note);
  }

  var body = AdsApp.currentAccount().getName() + '\n' +
             checked + ' URLs checked, ' + bad.length + ' need attention.\n\n' +
             lines.join('\n');

  Logger.log(body);
  if (EMAIL) {
    MailApp.sendEmail(EMAIL, 'Final URL check - ' + AdsApp.currentAccount().getName(), body);
  }
}

function collect(query, extract, into) {
  var rows = AdsApp.search(query);
  while (rows.hasNext()) {
    var final = extract(rows.next());
    if (!final || !final.length) { continue; }
    for (var i = 0; i < final.length; i++) {
      var u = String(final[i]).split('#')[0];
      if (u) { into[u] = true; }
    }
  }
}

function check(url) {
  try {
    var res = UrlFetchApp.fetch(url, {
      muteHttpExceptions: true,
      followRedirects:    true,
      validateHttpsCertificates: true,
      headers: { 'User-Agent': USER_AGENT }
    });

    var status = res.getResponseCode();
    if (status !== 200) {
      return { url: url, status: status, note: 'non-200 response' };
    }

    var landed = res.getAllHeaders()['Location'];
    if (landed && host(String(landed)) !== host(url)) {
      return { url: url, status: status, note: 'redirected to ' + host(String(landed)) };
    }

    return null;
  } catch (e) {
    return { url: url, status: 'error', note: String(e).substring(0, 120) };
  }
}

function host(url) {
  var m = url.match(/^https?:\/\/([^\/\?]+)/i);
  return m ? m[1].toLowerCase().replace(/^www\./, '') : '';
}

What breaks and why you do not notice

Landing pages are changed by people who do not have access to the ads account. A product is discontinued, a page is renamed, a staging redirect is left in place after a migration. Google does not stop serving because a page returns 404; it keeps charging for clicks that land on nothing, and the first signal is usually a drop in conversions two weeks later.

Why the host comparison matters

A 200 response is not proof the page is fine. A common failure after a site migration is a blanket redirect that sends every unmatched URL to the homepage: the request returns 200, the ad still runs, and every visitor lands somewhere that has nothing to do with what they clicked. Comparing the host the request started on with where it finished catches that class of problem, which a plain status check misses.

Limits

Scripts stop at thirty minutes. Three hundred URLs is comfortable; a large account with thousands of unique final URLs needs the list split across scheduled runs, or the check narrowed to one campaign at a time.

Some servers block requests that do not look like a browser. If a URL you know is fine comes back as an error, that is usually the reason rather than a real fault, and the user agent string at the top is where to start.