链接检查器 - 单一帐号

This script is for a single account. For operating on multiple accounts in a Manager Account, use the Manager Account version of the script.

随着网站的发展,新网页不断增加,旧网页不断淘汰,链接不断破坏和修复。对许多广告客户来说,保持 AdWords 广告系列与网站同步是一场永不停息的战斗。正在投放的广告可能指向不存在的网页,而广告客户最终发现自己是在为触发 404 错误的点击付费。

链接检查器可以解决这个问题:该工具会对您的所有广告、关键字和附加链接进行迭代,检查它们的网址是否会生成“找不到网页”或其他类型的错误响应,在发现错误响应时向您发送电子邮件,并将其分析结果保存到电子表格。

由于存在执行时间限制配额,某些有大量网址的帐号可能无法在一次运行中完成全部检查。该脚本对这种情况的处理方式是,跟踪多次运行的进度,只检查之前没有检查的网址。该脚本使用术语“分析”来指代对所有网址的一次完整遍历过程,即便这需要多次运行来完成。

配置

可在电子表格中设置脚本的主要选项。

  • Scope(范围):选择脚本是否检查广告、关键字和/或附加链接,以及脚本是否会在它们已暂停时仍对其进行检查。大多数用户应加入所有这三项,以确保所有网址都得到检查,但通常不必检查已暂停的实体。
  • Valid Response Codes(有效响应代码):列出脚本应该认定为有效响应的 HTTP 响应代码。大部分用户只应该将 200 认定为有效响应代码
  • Email after each script execution(每次执行脚本后发送电子邮件):如果启用该选项,该脚本将在每次运行后通过电子邮件向您发送所检查网址的摘要,由此可以就网址错误早早向您发出预警,而不必等到所有网址检查完毕(这可能需要多次运行)。
  • Email after finishing entire analysis(完成全部分析后发送电子邮件):如果启用该选项,该脚本将在完成对每个网址的检查后通过电子邮件向您发送一份汇总摘要。
  • Email even if no errors are found(即使未发现错误也会发送电子邮件):如果启用该选项,即使没有发现任何错误,该脚本也会向您发送电子邮件(基于以上两个选项)。大多数用户更希望只在发生错误时收到电子邮件通知,但即使没有错误也接收电子邮件,对于确保脚本如期运行很有用。
  • Save OK URLs(将正确的网址保存到电子表格):如果启用该选项,该脚本会将所检查的每个网址都保存到电子表格,而不仅仅保存有错误的那些网址。大多数用户希望只保存损坏的网址,但有些用户希望对脚本所检查的网址全部进行填充。
  • Days between analyses(分析之间的天数):使用该选项来控制脚本多久启动一次对所有网址的全新分析。注意,此选项管理的是分析之间的最少天数。如果分析需要花费更多时间(例如,因为您有大量网址),实际数字可能会更大一些。有关详情,请参阅下面的“运行时间设置”。

运行时间设置

脚本每次运行时,会自动检测是应该继续已在进行的分析,还是上一次分析已完成,现在是时候启动新的分析(根据 Days between analyses(分析之间的天数)选项)。由此,无论您希望多久启动一次新的分析,请安排脚本每小时 (Hourly) 运行一次,使每一次分析能够尽快完成对网址的迭代。

例如,将 Days between analyses(分析之间的天数)设置为 1,使脚本每天启动一个新分析不超过一次。如果脚本被安排为每小时 (Hourly) 运行一次,并且能在不到一天的时间内检查完所有的网址,则后续运行将立即终止,直到第二天开始新的分析。

工作原理

为跟踪进展情况,该脚本会创建标签,并在完成检查后将其应用于您的广告、关键字和附加链接。由此,脚本即可在下次运行时识别出检查过的网址。一旦完成分析(检查了所有网址),该脚本会清除标签,准备进行新的分析。

该脚本使用 UrlFetchApp 检查最终到达网址和最终到达移动网址。

链接检查器会忽略跟踪模板,直接检查最终到达网址和最终到达移动网址。它还会忽略 ValueTrack 参数自定义参数。如果您的着陆页要求用户检查跟踪模板,或者需要填充某些 ValueTrack 或自定义参数,您可能要考虑其他解决方案。

常见问题解答

问:请问这个脚本能否用于 AdWords 经理帐号?
答:这个版本不能。请改为使用专门的经理帐号版本

问:请问,如果我的帐号的网址超过 20,000 个,还能使用这个脚本吗?
答:能,虽然 UrlFetchApp 限制为每天进行 20,000 次调用,但如果您的网址超过 20,000 个,仍可以使用该脚本。每次该脚本运行时,它会处理那些尚未检查的网址子集。多次运行后,该脚本将最终遍历所有网址。
问:我收到错误“You do not have permissions to access the requested document.”。
答:请务必在脚本中将 SPREADSHEET_URL 设置为电子表格模板副本,而不是模板本身。此外,务必让创建您的电子表格模板副本的 Google 用户给予运行脚本的用户编辑权限
问:如何只检查已启用的广告、关键字和附加链接的链接?
答:该脚本始终检查已启用的广告、关键字和附加链接。为了确保它不会同时检查已暂停的实体,务必将电子表格的“Include paused ads?”(包含已暂停的广告?)、“Include paused keywords?”(包含已暂停的关键字?)和“Include paused sitelinks?”(包含已暂停的附加链接?)选项全部设置为“包含已暂停的附加链接?”(否)。
问:我收到类似以下错误:“Label LinkChecker_Done is missing and cannot be created in preview mode. Please run the script or create the label manually.”
答:该脚本使用标签来跟踪已经检查的实体,如果尚未存在标签,请创建。如果在预览模式中运行脚本,则无法创建标签。解决这个问题的最简单方法是直接运行该脚本,而不是使用预览模式。
问:如何防止脚本超时?
答:这个脚本的旧版本可能会超时。请升级到最新版本。如果您所使用的最新版本仍会超时,请将 TIMEOUT_BUFFER 选项设置为一个较大的值,以确保该脚本有足够的时间来将其输出写到电子表格。
问:“Good and Bad Urls”说明了什么?
答:这会出现在旧版本的脚本中。请升级到最新版本。
问:我收到类似以下错误:“TypeError: Cannot call method 'getValue' of null.”
答:这一错误发生在旧版本的脚本中。请升级到最新版本。

设置

  • 使用下面的源代码设置基于电子表格的脚本。使用链接检查器电子表格模板
  • 切勿忘记更新代码中的 YOUR_SPREADSHEET_URL
  • 将脚本设为每小时 (Hourly) 运行。

源代码

// Copyright 2016, Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @name Link Checker
 *
 * @overview The Link Checker script iterates through the ads, keywords, and
 *     sitelinks in your account and makes sure their URLs do not produce "Page
 *     not found" or other types of error responses. See
 *     https://developers.google.com/adwords/scripts/docs/solutions/link-checker
 *     for more details.
 *
 * @author AdWords Scripts Team [adwords-scripts@googlegroups.com]
 *
 * @version 2.1
 *
 * @changelog
 * - version 2.1
 *   - Added expansion of conditional ValueTrack parameters (e.g. ifmobile).
 *   - Added expanded text ad and other ad format support.
 * - version 2.0.3
 *   - Added validation for external spreadsheet setup.
 * - version 2.0.2
 *   - Allow the custom tracking label to include spaces.
 * - version 2.0.1
 *   - Catch and output all UrlFetchApp exceptions.
 * - version 2.0
 *   - Completely revised the script to work on larger accounts.
 *   - Check URLs in campaign and ad group sitelinks.
 * - version 1.2
 *   - Released initial version.
 */

var CONFIG = {
  // URL of the spreadsheet template.
  // This should be a copy of https://goo.gl/8YLeMj.
  SPREADSHEET_URL: 'YOUR_SPREADSHEET_URL',

  // Array of addresses to be alerted via email if issues are found.
  RECIPIENT_EMAILS: [
    'YOUR_EMAIL_HERE'
  ],

  // Label to use when a link has been checked.
  LABEL: 'LinkChecker_Done',

  // Number of milliseconds to sleep after each URL request. If your URLs are
  // all on one or a few domains, use this throttle to reduce the load that the
  // script imposes on your web server(s).
  THROTTLE: 0,

  // Number of seconds before timeout that the script should stop checking URLs
  // to make sure it has time to output its findings.
  TIMEOUT_BUFFER: 120
};

/**
 * Parameters controlling the script's behavior after hitting a UrlFetchApp
 * QPS quota limit.
 */
var QUOTA_CONFIG = {
  INIT_SLEEP_TIME: 250,
  BACKOFF_FACTOR: 2,
  MAX_TRIES: 5
};

/**
 * Exceptions that prevent the script from finishing checking all URLs in an
 * account but allow it to resume next time.
 */
var EXCEPTIONS = {
  QPS: 'Reached UrlFetchApp QPS limit',
  LIMIT: 'Reached UrlFetchApp daily quota',
  TIMEOUT: 'Approached script execution time limit'
};

/**
 * Named ranges in the spreadsheet.
 */
var NAMES = {
  CHECK_AD_URLS: 'checkAdUrls',
  CHECK_KEYWORD_URLS: 'checkKeywordUrls',
  CHECK_SITELINK_URLS: 'checkSitelinkUrls',
  CHECK_PAUSED_ADS: 'checkPausedAds',
  CHECK_PAUSED_KEYWORDS: 'checkPausedKeywords',
  CHECK_PAUSED_SITELINKS: 'checkPausedSitelinks',
  VALID_CODES: 'validCodes',
  EMAIL_EACH_RUN: 'emailEachRun',
  EMAIL_NON_ERRORS: 'emailNonErrors',
  EMAIL_ON_COMPLETION: 'emailOnCompletion',
  SAVE_ALL_URLS: 'saveAllUrls',
  FREQUENCY: 'frequency',
  DATE_STARTED: 'dateStarted',
  DATE_COMPLETED: 'dateCompleted',
  DATE_EMAILED: 'dateEmailed',
  NUM_ERRORS: 'numErrors',
  RESULT_HEADERS: 'resultHeaders',
  ARCHIVE_HEADERS: 'archiveHeaders'
};

function main() {
  var spreadsheet = validateAndGetSpreadsheet(CONFIG.SPREADSHEET_URL);
  validateEmailAddresses(CONFIG.RECIPIENT_EMAILS);
  spreadsheet.setSpreadsheetTimeZone(AdWordsApp.currentAccount().getTimeZone());

  var options = loadOptions(spreadsheet);
  var status = loadStatus(spreadsheet);

  if (!status.dateStarted) {
    // This is the very first execution of the script.
    startNewAnalysis(spreadsheet);
  } else if (status.dateStarted > status.dateCompleted) {
    Logger.log('Resuming work from a previous execution.');
  } else if (dayDifference(status.dateStarted, new Date()) <
             options.frequency) {
    Logger.log('Waiting until ' + options.frequency +
               ' days have elapsed since the start of the last analysis.');
    return;
  } else {
    // Enough time has passed since the last analysis to start a new one.
    removeLabels([CONFIG.LABEL]);
    startNewAnalysis(spreadsheet);
  }

  var results = analyzeAccount(options);
  outputResults(results, options);
}

/**
 * Checks as many new URLs as possible that have not previously been checked,
 * subject to quota and time limits.
 *
 * @param {Object} options Dictionary of options.
 * @return {Object} An object with fields for the URLs checked and an indication
 *     if the analysis was completed (no remaining URLs to check).
 */
function analyzeAccount(options) {
  // Ensure the label exists before attempting to retrieve already checked URLs.
  ensureLabels([CONFIG.LABEL]);

  var checkedUrls = getAlreadyCheckedUrls(options);
  var urlChecks = [];
  var didComplete = false;

  try {
    // If the script throws an exception, didComplete will remain false.
    didComplete = checkUrls(checkedUrls, urlChecks, options);
  } catch(e) {
    if (e == EXCEPTIONS.QPS ||
        e == EXCEPTIONS.LIMIT ||
        e == EXCEPTIONS.TIMEOUT) {
      Logger.log('Stopped checking URLs early because: ' + e);
      Logger.log('Checked URLs will still be output.');
    } else {
      throw e;
    }
  }

  return {
    urlChecks: urlChecks,
    didComplete: didComplete
  };
}

/**
 * Outputs the results to a spreadsheet and sends emails if appropriate.
 *
 * @param {Object} results An object with fields for the URLs checked and an
 *     indication if the analysis was completed (no remaining URLs to check).
 * @param {Object} options Dictionary of options.
 */
function outputResults(results, options) {
  var spreadsheet = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);

  var numErrors = countErrors(results.urlChecks, options);
  Logger.log('Found ' + numErrors + ' this execution.');

  saveUrlsToSpreadsheet(spreadsheet, results.urlChecks, options);

  // Reload the status to get the total number of errors for the entire
  // analysis, which is calculated by the spreadsheet.
  status = loadStatus(spreadsheet);

  if (results.didComplete) {
    spreadsheet.getRangeByName(NAMES.DATE_COMPLETED).setValue(new Date());
    Logger.log('Found ' + status.numErrors + ' across the entire analysis.');
  }

  if (CONFIG.RECIPIENT_EMAILS) {
    if (!results.didComplete && options.emailEachRun &&
        (options.emailNonErrors || numErrors > 0)) {
      sendIntermediateEmail(spreadsheet, numErrors);
    }

    if (results.didComplete &&
        (options.emailEachRun || options.emailOnCompletion) &&
        (options.emailNonErrors || status.numErrors > 0)) {
      sendFinalEmail(spreadsheet, status.numErrors);
    }
  }
}

/**
 * Loads data from a spreadsheet based on named ranges. Strings 'Yes' and 'No'
 * are converted to booleans. One-dimensional ranges are converted to arrays
 * with blank cells omitted. Assumes each named range exists.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 * @param {Array.<string>} names A list of named ranges that should be loaded.
 * @return {Object} A dictionary with the names as keys and the values
 *     as the cell values from the spreadsheet.
 */
function loadDatabyName(spreadsheet, names) {
  var data = {};

  for (var i = 0; i < names.length; i++) {
    var name = names[i];
    var range = spreadsheet.getRangeByName(name);

    if (range.getNumRows() > 1 && range.getNumColumns() > 1) {
      // Name refers to a 2d range, so load it as a 2d array.
      data[name] = range.getValues();
    } else if (range.getNumRows() == 1 && range.getNumColumns() == 1) {
      // Name refers to a single cell, so load it as a value and replace
      // Yes/No with boolean true/false.
      data[name] = range.getValue();
      data[name] = data[name] === 'Yes' ? true : data[name];
      data[name] = data[name] === 'No' ? false : data[name];
    } else {
      // Name refers to a 1d range, so load it as an array (regardless of
      // whether the 1d range is oriented horizontally or vertically).
      var isByRow = range.getNumRows() > 1;
      var limit = isByRow ? range.getNumRows() : range.getNumColumns();
      var cellValues = range.getValues();

      data[name] = [];
      for (var j = 0; j < limit; j++) {
        var cellValue = isByRow ? cellValues[j][0] : cellValues[0][j];
        if (cellValue) {
          data[name].push(cellValue);
        }
      }
    }
  }

  return data;
}

/**
 * Loads options from the spreadsheet.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 * @return {Object} A dictionary of options.
 */
function loadOptions(spreadsheet) {
  return loadDatabyName(spreadsheet,
      [NAMES.CHECK_AD_URLS, NAMES.CHECK_KEYWORD_URLS,
       NAMES.CHECK_SITELINK_URLS, NAMES.CHECK_PAUSED_ADS,
       NAMES.CHECK_PAUSED_KEYWORDS, NAMES.CHECK_PAUSED_SITELINKS,
       NAMES.VALID_CODES, NAMES.EMAIL_EACH_RUN,
       NAMES.EMAIL_NON_ERRORS, NAMES.EMAIL_ON_COMPLETION,
       NAMES.SAVE_ALL_URLS, NAMES.FREQUENCY]);
}

/**
 * Loads state information from the spreadsheet.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 * @return {Object} A dictionary of status information.
 */
function loadStatus(spreadsheet) {
  return loadDatabyName(spreadsheet,
      [NAMES.DATE_STARTED, NAMES.DATE_COMPLETED,
       NAMES.DATE_EMAILED, NAMES.NUM_ERRORS]);
}

/**
 * Saves the start date to the spreadsheet and archives results of the last
 * analysis to a separate sheet.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 */
function startNewAnalysis(spreadsheet) {
  Logger.log('Starting a new analysis.');

  spreadsheet.getRangeByName(NAMES.DATE_STARTED).setValue(new Date());

  // Helper method to get the output area on the results or archive sheets.
  var getOutputRange = function(rangeName) {
    var headers = spreadsheet.getRangeByName(rangeName);
    return headers.offset(1, 0, headers.getSheet().getDataRange().getLastRow());
  };

  getOutputRange(NAMES.ARCHIVE_HEADERS).clearContent();

  var results = getOutputRange(NAMES.RESULT_HEADERS);
  results.copyTo(getOutputRange(NAMES.ARCHIVE_HEADERS));

  getOutputRange(NAMES.RESULT_HEADERS).clearContent();
}

/**
 * Counts the number of errors in the results.
 *
 * @param {Array.<Object>} urlChecks A list of URL check results.
 * @param {Object} options Dictionary of options.
 * @return {number} The number of errors in the results.
 */
function countErrors(urlChecks, options) {
  var numErrors = 0;

  for (var i = 0; i < urlChecks.length; i++) {
    if (options.validCodes.indexOf(urlChecks[i].responseCode) == -1) {
      numErrors++;
    }
  }

  return numErrors;
}

/**
 * Saves URLs for a particular account to the spreadsheet starting at the first
 * unused row.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 * @param {Array.<Object>} urlChecks A list of URL check results.
 * @param {Object} options Dictionary of options.
 */
function saveUrlsToSpreadsheet(spreadsheet, urlChecks, options) {
  // Build each row of output values in the order of the columns.
  var outputValues = [];
  for (var i = 0; i < urlChecks.length; i++) {
    var urlCheck = urlChecks[i];

    if (options.saveAllUrls ||
        options.validCodes.indexOf(urlCheck.responseCode) == -1) {
      outputValues.push([
        urlCheck.customerId,
        new Date(urlCheck.timestamp),
        urlCheck.url,
        urlCheck.responseCode,
        urlCheck.entityType,
        urlCheck.campaign,
        urlCheck.adGroup,
        urlCheck.ad,
        urlCheck.keyword,
        urlCheck.sitelink
      ]);
    }
  }

  if (outputValues.length > 0) {
    // Find the first open row on the Results tab below the headers and create a
    // range large enough to hold all of the output, one per row.
    var headers = spreadsheet.getRangeByName(NAMES.RESULT_HEADERS);
    var lastRow = headers.getSheet().getDataRange().getLastRow();
    var outputRange = headers.offset(lastRow - headers.getRow() + 1,
                                     0, outputValues.length);
    outputRange.setValues(outputValues);
  }

  for (var i = 0; i < CONFIG.RECIPIENT_EMAILS.length; i++) {
    spreadsheet.addEditor(CONFIG.RECIPIENT_EMAILS[i]);
  }
}

/**
 * Sends an email to a list of email addresses with a link to the spreadsheet
 * and the results of this execution of the script.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 * @param {boolean} numErrors The number of errors found in this execution.
 */
function sendIntermediateEmail(spreadsheet, numErrors) {
  spreadsheet.getRangeByName(NAMES.DATE_EMAILED).setValue(new Date());

  MailApp.sendEmail(CONFIG.RECIPIENT_EMAILS.join(','),
      'Link Checker Results',
      'The Link Checker script found ' + numErrors + ' URLs with errors in ' +
      'an execution that just finished. See ' +
      spreadsheet.getUrl() + ' for details.');
}

/**
 * Sends an email to a list of email addresses with a link to the spreadsheet
 * and the results across the entire account.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 * @param {boolean} numErrors The number of errors found in the entire account.
 */
function sendFinalEmail(spreadsheet, numErrors) {
  spreadsheet.getRangeByName(NAMES.DATE_EMAILED).setValue(new Date());

  MailApp.sendEmail(CONFIG.RECIPIENT_EMAILS.join(','),
      'Link Checker Results',
      'The Link Checker script found ' + numErrors + ' URLs with errors ' +
      'across its entire analysis. See ' +
      spreadsheet.getUrl() + ' for details.');
}

/**
 * Retrieves all final URLs and mobile final URLs in the account across ads,
 * keywords, and sitelinks that were checked in a previous run, as indicated by
 * them having been labeled.
 *
 * @param {Object} options Dictionary of options.
 * @return {Object} A map of previously checked URLs with the URL as the key.
 */
function getAlreadyCheckedUrls(options) {
  var urlMap = {};

  var addToMap = function(items) {
    for (var i = 0; i < items.length; i++) {
      var urls = expandUrlModifiers(items[i]);
      urls.forEach(function(url) {
        urlMap[url] = true;
      });
    }
  };

  if (options.checkAdUrls) {
    addToMap(getUrlsBySelector(AdWordsApp.ads().
                               withCondition(labelCondition(true))));
  }

  if (options.checkKeywordUrls) {
    addToMap(getUrlsBySelector(AdWordsApp.keywords().
                               withCondition(labelCondition(true))));
  }

  if (options.checkSitelinkUrls) {
    addToMap(getAlreadyCheckedSitelinkUrls());
  }

  return urlMap;
}

/**
 * Retrieves all final URLs and mobile final URLs for campaign and ad group
 * sitelinks.
 *
 * @return {Array.<string>} An array of URLs.
 */
function getAlreadyCheckedSitelinkUrls() {
  var urls = [];

  // Helper method to get campaign or ad group sitelink URLs.
  var addSitelinkUrls = function(selector) {
    var iterator = selector.withCondition(labelCondition(true)).get();

    while (iterator.hasNext()) {
      var entity = iterator.next();
      var sitelinks = entity.extensions().sitelinks();
      urls = urls.concat(getUrlsBySelector(sitelinks));
    }
  };

  addSitelinkUrls(AdWordsApp.campaigns());
  addSitelinkUrls(AdWordsApp.adGroups());

  return urls;
}

/**
 * Retrieves all URLs in the entities specified by a selector.
 *
 * @param {Object} selector The selector specifying the entities to use.
 *     The entities should be of a type that has a urls() method.
 * @return {Array.<string>} An array of URLs.
 */
function getUrlsBySelector(selector) {
  var urls = [];
  var entities = selector.get();

  // Helper method to add the url to the list if it exists.
  var addToList = function(url) {
    if (url) {
      urls.push(url);
    }
  };

  while (entities.hasNext()) {
    var entity = entities.next();

    addToList(entity.urls().getFinalUrl());
    addToList(entity.urls().getMobileFinalUrl());
  }

  return urls;
}

/**
 * Retrieves all final URLs and mobile final URLs in the account across ads,
 * keywords, and sitelinks, and checks their response code. Does not check
 * previously checked URLs.
 *
 * @param {Object} checkedUrls A map of previously checked URLs with the URL as
 *     the key.
 * @param {Array.<Object>} urlChecks An array into which the results of each URL
 *     check will be inserted.
 * @param {Object} options Dictionary of options.
 * @return {boolean} True if all URLs were checked.
 */
function checkUrls(checkedUrls, urlChecks, options) {
  var didComplete = true;

  // Helper method to add common conditions to ad group and keyword selectors.
  var addConditions = function(selector, includePaused) {
    var statuses = ['ENABLED'];
    if (includePaused) {
      statuses.push('PAUSED');
    }

    var predicate = ' IN [' + statuses.join(',') + ']';
    return selector.withCondition(labelCondition(false)).
        withCondition('Status' + predicate).
        withCondition('CampaignStatus' + predicate).
        withCondition('AdGroupStatus' + predicate);
  };

  if (options.checkAdUrls) {
    didComplete = didComplete && checkUrlsBySelector(checkedUrls, urlChecks,
        addConditions(AdWordsApp.ads().withCondition('CreativeFinalUrls != ""'),
                      options.checkPausedAds));
  }

  if (options.checkKeywordUrls) {
    didComplete = didComplete && checkUrlsBySelector(checkedUrls, urlChecks,
        addConditions(AdWordsApp.keywords().withCondition('FinalUrls != ""'),
                      options.checkPausedKeywords));
  }

  if (options.checkSitelinkUrls) {
    didComplete = didComplete &&
        checkSitelinkUrls(checkedUrls, urlChecks, options);
  }

  return didComplete;
}

/**
 * Retrieves all final URLs and mobile final URLs in a selector and checks them
 * for a valid response code. Does not check previously checked URLs. Labels the
 * entity that it was checked, if possible.
 *
 * @param {Object} checkedUrls A map of previously checked URLs with the URL as
 *     the key.
 * @param {Array.<Object>} urlChecks An array into which the results of each URL
 *     check will be inserted.
 * @param {Object} selector The selector specifying the entities to use.
 *     The entities should be of a type that has a urls() method.
 * @return {boolean} True if all URLs were checked.
 */
function checkUrlsBySelector(checkedUrls, urlChecks, selector) {
  var customerId = AdWordsApp.currentAccount().getCustomerId();
  var iterator = selector.get();
  var entities = [];

  // Helper method to check a URL.
  var checkUrl = function(entity, url) {
    if (!url) {
      return;
    }

    var urlsToCheck = expandUrlModifiers(url);

    for (var i = 0; i < urlsToCheck.length; i++) {
      var expandedUrl = urlsToCheck[i];
      if (checkedUrls[expandedUrl]) {
        continue;
      }

      var responseCode = requestUrl(expandedUrl);
      var entityType = entity.getEntityType();

      urlChecks.push({
        customerId: customerId,
        timestamp: new Date(),
        url: expandedUrl,
        responseCode: responseCode,
        entityType: entityType,
        campaign: entity.getCampaign ? entity.getCampaign().getName() : '',
        adGroup: entity.getAdGroup ? entity.getAdGroup().getName() : '',
        ad: entityType == 'Ad' ? getAdAsText(entity) : '',
        keyword: entityType == 'Keyword' ? entity.getText() : '',
        sitelink: entityType.indexOf('Sitelink') != -1 ?
            entity.getLinkText() : ''
      });

      checkedUrls[expandedUrl] = true;
    }
  };

  while (iterator.hasNext()) {
    entities.push(iterator.next());
  }

  for (var i = 0; i < entities.length; i++) {
    var entity = entities[i];

    checkUrl(entity, entity.urls().getFinalUrl());
    checkUrl(entity, entity.urls().getMobileFinalUrl());

    // Sitelinks do not have labels.
    if (entity.applyLabel) {
      entity.applyLabel(CONFIG.LABEL);
      checkTimeout();
    }
  }

  // True only if we did not breach an iterator limit.
  return entities.length == iterator.totalNumEntities();
}

/**
 * Retrieves a text representation of an ad, casting the ad to the appropriate
 * type if necessary.
 *
 * @param {Ad} ad The ad object.
 * @return {string} The text representation.
 */
function getAdAsText(ad) {
  // There is no AdTypeSpace method for textAd
  if (ad.getType() === 'TEXT_AD') {
    return ad.getHeadline();
  } else if (ad.isType().expandedTextAd()) {
    var eta = ad.asType().expandedTextAd();
    return eta.getHeadlinePart1() + ' - ' + eta.getHeadlinePart2();
  } else if (ad.isType().gmailImageAd()) {
    return ad.asType().gmailImageAd().getName();
  } else if (ad.isType().gmailMultiProductAd()) {
    return ad.asType().gmailMultiProductAd().getHeadline();
  } else if (ad.isType().gmailSinglePromotionAd()) {
    return ad.asType().gmailSinglePromotionAd().getHeadline();
  } else if (ad.isType().html5Ad()) {
    return ad.asType().html5Ad().getName();
  } else if (ad.isType().imageAd()) {
    return ad.asType().imageAd().getName();
  } else if (ad.isType().responsiveDisplayAd()) {
    return ad.asType().responsiveDisplayAd().getLongHeadline();
  }
  return 'N/A';
}

/**
 * Retrieves all final URLs and mobile final URLs for campaign and ad group
 * sitelinks and checks them for a valid response code. Does not check
 * previously checked URLs. Labels the containing campaign or ad group that it
 * has been checked.
 *
 * @param {Object} checkedUrls A map of previously checked URLs with the URL as
 *     the key.
 * @param {Array.<Object>} urlChecks An array into which the results of each URL
 *     check will be inserted.
 * @param {Object} options Dictionary of options.
 * @return {boolean} True if all URLs were checked.
 */
function checkSitelinkUrls(checkedUrls, urlChecks, options) {
  var didComplete = true;

  // Helper method to check URLs for sitelinks in a campaign or ad group
  // selector.
  var checkSitelinkUrls = function(selector) {
    var iterator = selector.withCondition(labelCondition(false)).get();
    var entities = [];

    while (iterator.hasNext()) {
      entities.push(iterator.next());
    }

    for (var i = 0; i < entities.length; i++) {
      var entity = entities[i];
      var sitelinks = entity.extensions().sitelinks();

      if (sitelinks.get().hasNext()) {
        didComplete = didComplete &&
            checkUrlsBySelector(checkedUrls, urlChecks, sitelinks);
        entity.applyLabel(CONFIG.LABEL);
        checkTimeout();
      }
    }

    // True only if we did not breach an iterator limit.
    didComplete = didComplete &&
        entities.length == iterator.totalNumEntities();
  };

  var statuses = ['ENABLED'];
  if (options.checkPausedSitelinks) {
    statuses.push('PAUSED');
  }

  var predicate = ' IN [' + statuses.join(',') + ']';
  checkSitelinkUrls(AdWordsApp.campaigns().
                    withCondition('Status' + predicate));
  checkSitelinkUrls(AdWordsApp.adGroups().
                    withCondition('Status' + predicate).
                    withCondition('CampaignStatus' + predicate));

  return didComplete;
}

/**
 * Expands a URL that contains ValueTrack parameters such as {ifmobile:mobile}
 * to all the combinations, and returns as an array. The following pairs of
 * ValueTrack parameters are currently expanded:
 *     1. {ifmobile:<...>} and {ifnotmobile:<...>} to produce URLs simulating
 *        clicks from either mobile or non-mobile devices.
 *     2. {ifsearch:<...>} and {ifcontent:<...>} to produce URLs simulating
 *        clicks on either the search or display networks.
 * Any other ValueTrack parameters or customer parameters are stripped out from
 * the URL entirely.
 *
 * @param {string} url The URL which may contain ValueTrack parameters.
 * @return {!Array.<string>} An array of one or more expanded URLs.
 */
function expandUrlModifiers(url) {
  var ifRegex = /({(if\w+):([^}]+)})/gi;
  var modifiers = {};
  var matches;
  while (matches = ifRegex.exec(url)) {
    // Tags are case-insensitive, e.g. IfMobile is valid.
    modifiers[matches[2].toLowerCase()] = {
      substitute: matches[0],
      replacement: matches[3]
    };
  }
  if (Object.keys(modifiers).length) {
    if (modifiers.ifmobile || modifiers.ifnotmobile) {
      var mobileCombinations =
          pairedUrlModifierReplace(modifiers, 'ifmobile', 'ifnotmobile', url);
    } else {
      var mobileCombinations = [url];
    }

    // Store in a map on the offchance that there are duplicates.
    var combinations = {};
    mobileCombinations.forEach(function(url) {
      if (modifiers.ifsearch || modifiers.ifcontent) {
        pairedUrlModifierReplace(modifiers, 'ifsearch', 'ifcontent', url)
            .forEach(function(modifiedUrl) {
              combinations[modifiedUrl] = true;
            });
      } else {
        combinations[url] = true;
      }
    });
    var modifiedUrls = Object.keys(combinations);
  } else {
    var modifiedUrls = [url];
  }
  // Remove any custom parameters
  return modifiedUrls.map(function(url) {
    return url.replace(/{[0-9a-zA-Z\_\+\:]+}/g, '');
  });
}

/**
 * Return a pair of URLs, where each of the two modifiers is mutually exclusive,
 * one for each combination. e.g. Evaluating ifmobile and ifnotmobile for a
 * mobile and a non-mobile scenario.
 *
 * @param {Object} modifiers A map of ValueTrack modifiers.
 * @param {string} modifier1 The modifier to honour in the URL.
 * @param {string} modifier2 The modifier to remove from the URL.
 * @param {string} url The URL potentially containing ValueTrack parameters.
 * @return {Array.<string>} A pair of URLs, as a list.
 */
function pairedUrlModifierReplace(modifiers, modifier1, modifier2, url) {
  return [
    urlModifierReplace(modifiers, modifier1, modifier2, url),
    urlModifierReplace(modifiers, modifier2, modifier1, url)
  ];
}

/**
 * Produces a URL where the first {if...} modifier is set, and the second is
 * deleted.
 *
 * @param {Object} mods A map of ValueTrack modifiers.
 * @param {string} mod1 The modifier to honour in the URL.
 * @param {string} mod2 The modifier to remove from the URL.
 * @param {string} url The URL potentially containing ValueTrack parameters.
 * @return {string} The resulting URL with substitions.
 */
function urlModifierReplace(mods, mod1, mod2, url) {
  var modUrl = mods[mod1] ?
      url.replace(mods[mod1].substitute, mods[mod1].replacement) :
      url;
  return mods[mod2] ? modUrl.replace(mods[mod2].substitute, '') : modUrl;
}

/**
 * Requests a given URL. Retries if the UrlFetchApp QPS limit was reached,
 * exponentially backing off on each retry. Throws an exception if it reaches
 * the maximum number of retries. Throws an exception if the UrlFetchApp daily
 * quota limit was reached.
 *
 * @param {string} url The URL to test.
 * @return {number|string} The response code received when requesting the URL,
 *     or an error message.
 */
function requestUrl(url) {
  var responseCode;
  var sleepTime = QUOTA_CONFIG.INIT_SLEEP_TIME;
  var numTries = 0;

  while (numTries < QUOTA_CONFIG.MAX_TRIES && !responseCode) {
    try {
      // If UrlFetchApp.fetch() throws an exception, responseCode will remain
      // undefined.
      responseCode =
        UrlFetchApp.fetch(url, {muteHttpExceptions: true}).getResponseCode();

      if (CONFIG.THROTTLE > 0) {
        Utilities.sleep(CONFIG.THROTTLE);
      }
    } catch(e) {
      if (e.message.indexOf('Service invoked too many times in a short time:')
          != -1) {
        Utilities.sleep(sleepTime);
        sleepTime *= QUOTA_CONFIG.BACKOFF_FACTOR;
      } else if (e.message.indexOf('Service invoked too many times:') != -1) {
        throw EXCEPTIONS.LIMIT;
      } else {
        return e.message;
      }
    }

    numTries++;
  }

  if (!responseCode) {
    throw EXCEPTIONS.QPS;
  } else {
    return responseCode;
  }
}

/**
 * Throws an exception if the script is close to timing out.
 */
function checkTimeout() {
  if (AdWordsApp.getExecutionInfo().getRemainingTime() <
      CONFIG.TIMEOUT_BUFFER) {
    throw EXCEPTIONS.TIMEOUT;
  }
}

/**
 * Returns the number of days between two dates.
 *
 * @param {Object} from The older Date object.
 * @param {Object} to The newer (more recent) Date object.
 * @return {number} The number of days between the given dates (possibly
 *     fractional).
 */
function dayDifference(from, to) {
  return (to.getTime() - from.getTime()) / (24 * 3600 * 1000);
}

/**
 * Builds a string to be used for withCondition() filtering for whether the
 * label is present or not.
 *
 * @param {boolean} hasLabel True if the label should be present, false if the
 *     label should not be present.
 * @return {string} A condition that can be used in withCondition().
 */
function labelCondition(hasLabel) {
  return 'LabelNames ' + (hasLabel ? 'CONTAINS_ANY' : 'CONTAINS_NONE') +
      ' ["' + CONFIG.LABEL + '"]';
}

/**
 * Retrieves an entity by name.
 *
 * @param {Object} selector A selector for an entity type with a Name field.
 * @param {string} name The name to retrieve the entity by.
 * @return {Object} The entity, if it exists, or null otherwise.
 */
function getEntityByName(selector, name) {
  var entities = selector.withCondition('Name = "' + name + '"').get();

  if (entities.hasNext()) {
    return entities.next();
  } else {
    return null;
  }
}

/**
 * Retrieves a Label object by name.
 *
 * @param {string} labelName The label name to retrieve.
 * @return {Object} The Label object, if it exists, or null otherwise.
 */
function getLabel(labelName) {
  return getEntityByName(AdWordsApp.labels(), labelName);
}

/**
 * Checks that the account has all provided labels and creates any that are
 * missing. Since labels cannot be created in preview mode, throws an exception
 * if a label is missing.
 *
 * @param {Array.<string>} labelNames An array of label names.
 */
function ensureLabels(labelNames) {
  for (var i = 0; i < labelNames.length; i++) {
    var labelName = labelNames[i];
    var label = getLabel(labelName);

    if (!label) {
      if (!AdWordsApp.getExecutionInfo().isPreview()) {
        AdWordsApp.createLabel(labelName);
      } else {
        throw 'Label ' + labelName + ' is missing and cannot be created in ' +
            'preview mode. Please run the script or create the label manually.';
      }
    }
  }
}

/**
 * Removes all provided labels from the account. Since labels cannot be removed
 * in preview mode, throws an exception in preview mode.
 *
 * @param {Array.<string>} labelNames An array of label names.
 */
function removeLabels(labelNames) {
  if (AdWordsApp.getExecutionInfo().isPreview()) {
    throw 'Cannot remove labels in preview mode. Please run the script or ' +
        'remove the labels manually.';
  }

  for (var i = 0; i < labelNames.length; i++) {
    var label = getLabel(labelNames[i]);

    if (label) {
      label.remove();
    }
  }
}

/**
 * Validates the provided spreadsheet URL to make sure that it's set up
 * properly. Throws a descriptive error message if validation fails.
 *
 * @param {string} spreadsheeturl The URL of the spreadsheet to open.
 * @return {Spreadsheet} The spreadsheet object itself, fetched from the URL.
 * @throws {Error} If the spreadsheet URL hasn't been set
 */
function validateAndGetSpreadsheet(spreadsheeturl) {
  if (spreadsheeturl == 'YOUR_SPREADSHEET_URL') {
    throw new Error('Please specify a valid Spreadsheet URL. You can find' +
        ' a link to a template in the associated guide for this script.');
  }
  return SpreadsheetApp.openByUrl(spreadsheeturl);
}

/**
 * Validates the provided email addresses to make sure it's not the default.
 * Throws a descriptive error message if validation fails.
 *
 * @param {Array.<string>} recipientEmails The list of email adresses.
 * @throws {Error} If the list of email addresses is still the default
 */
function validateEmailAddresses(recipientEmails) {
  if (recipientEmails &&
      recipientEmails[0] == 'YOUR_EMAIL_HERE') {
    throw new Error('Please either specify a valid email address or clear' +
        ' the RECIPIENT_EMAILS field.');
  }
}

    

Looking for the Manager Account (MCC) version? Click here

发送以下问题的反馈:

此网页
AdWords scripts
AdWords scripts
需要帮助?请访问我们的支持页面