Link Checker - Manager Business relationship

The Link Checker script for Google Ads manager accounts extends the single account Link Checker script to run for multiple accounts nether a director account.

Equally a website evolves, new pages become added, old pages are taken down, and links get broken and stock-still. Keeping an Google Ads campaign in sync with the website is an ongoing boxing for many advertisers. Live advertisements may be pointing to not-existent pages, and the advertiser ends up paying for clicks that yield 404 errors.

Link Checker addresses this problem by iterating through all of your ads, keywords, and sitelinks, checking that their URLs practice non produce "Folio not institute" or other types of fault responses, emailing you when error responses are found, and saving the results of its assay to a spreadsheet.

Some accounts may have a large number of URLs that cannot all be checked in a single run due to execution time limits or quotas. The script is designed to handle this case past tracking its progress over multiple runs and only checking URLs that it has not checked previously. The script uses the term "analysis" to refer to 1 full pass through all of your URLs, even if it takes multiple runs to complete.

Configuration

The script's primary options tin be prepare in the spreadsheet.

  • Scope: Select whether the script will check ads, keywords, and/or sitelinks, and whether the script will check them even if they are paused. Near users should include all iii to ensure all of their URLs are checked, but it is typically non necessary to check paused entities.
  • Valid Response Codes: List the HTTP response codes that the script should consider a valid response. Most users should consider simply 200 a valid response lawmaking.
  • Email later on each script execution: If you enable this option, the script will email you lot with a summary of the URLs it checked later every run. This can provide you lot with an early on alert about URL errors instead of having to wait for all of your URLs to exist checked (which might take multiple runs).
  • Email after finishing entire analysis: If you enable this selection, the script will email yous a consolidated summary afterwards information technology finishes checking every URL.
  • Email even if no errors are found: If you enable this option, the script volition email you (based on the two options above) even if information technology did not find whatever errors. Well-nigh users prefer to be emailed only when there are errors, but receiving an email fifty-fifty if in that location are no errors tin be useful mode to ensure the script is running equally scheduled.
  • Save OK URLs to spreadsheet: If y'all enable this selection, the script volition salvage every URL it checks to the spreadsheet, not only the ones with errors. Well-nigh users prefer to save only broken URLs, but some users like to encounter the full population of URLs that the script checked.
  • Days between analyses: Use this option to command how oftentimes the script starts a fresh assay of all of your URLs. Note that this option controls the minimum number of days betwixt analyses. The actual number may be longer if an assay takes more than time (i.east., because you take a large number of URLs). See Scheduling below for more details.

For some use cases, it may non be enough merely to know that the URL is returning a valid folio. For case, consider the case where a production folio contains the text "product discontinued". It may make sense for this to exist reported as a broken link. For this and other use cases, the script offers two options:

  • Failure strings: Set up Utilize uncomplicated failure string matching to Aye to search for any occurrence of a list of strings defined in failure strings. Whatever occurrence plant in the spider web folio will be treated as a failure.

    Note that using Failure strings may result in slightly reduced performance owing to the need to search the text of each returned folio for whatsoever occurrences.

  • Custom validation: For even more flexibility, a custom JavaScript validation function tin be used on each URL and response. For example, yous may wish to confirm that the title of the webpage contains your brand name. To utilize this option:
    1. Set Use custom validation function to Yes.
    2. Insert your custom validation logic into the part starting:
      function isValidResponse(url, response, options, entityDetails) {   // Your custom logic here, returning true for valid links, false otherwise.   // ... }                          
      institute towards the top of the source code. A bones implementation is already in place, with examples of how to admission the response text, and other details.
  • Scheduling

    Each time the script runs, it automatically detects whether it should resume an analysis already in progress or whether the last analysis finished and it is time to start a fresh 1 (based on the Days betwixt analyses option). As a outcome, regardless of how ofttimes you want to launch a fresh analysis, schedule the script to run Hourly then that each analysis iterates through your URLs as speedily equally possible.

    For example, set up Days betwixt analyses to ane to cause the script to launch a new analysis no more than one time per day. If the script is scheduled to run Hourly and it finishes checking all of your URLs in less than a day, subsequent runs volition immediately terminate until the adjacent twenty-four hours when it is fourth dimension to beginning a fresh analysis.

    How information technology works

    To rail its progress, the script creates a characterization and applies it to your ads, keywords, and sitelinks subsequently it checks them. The script tin and so identify checked URLs on its next run. Once an analysis is complete (all URLs accept been checked), the script clears the characterization for a new analysis.

    The script uses UrlFetchApp to actually check your URLs. It checks both final URLs and mobile last URLs.

    Link Checker checks your final URLs and mobile final URLs exactly, ignoring any tracking templates, ValueTrack parameters (except for ifmobile and ifnotmobile), or custom parameters. Consider an alternate solution if your pages employ any of these elements.

    Frequently asked questions

    Q: Will this script work if my manager business relationship hierarchy has more than than 50 accounts?
    A: Yes, the script is designed to work even if you accept more than fifty accounts. Each time the script runs, information technology will process a subset of your accounts that accept not been fully checked notwithstanding. After many runs, the script will eventually work through all of your accounts.
    Q: How do I get the script to only bank check accounts that have a specific label?
    A: If you want to filter accounts, add a selector condition in the section of the script reserved for that purpose:
    ACCOUNT_CONDITIONS: [],
    Yous can add one selector status per line in that array.
    Q: Volition this script work if I take an account with more than 20,000 URLs?
    A: Yep, although UrlFetchApp is limited to 20,000 calls per 24-hour interval the script is designed to work even if you have more than than 20,000 URLs. Each time the script runs, it volition process a subset of your URLs that accept not been checked even so. After many runs, the script will eventually work through all of your URLs.
    Q: I'thousand getting the mistake: "You do not have permissions to access the requested document."
    A: Make sure to set the SPREADSHEET_URL in the script to a copy of the template spreadsheet, non to the template itself. Also, brand certain the Google user who created your copy of the template spreadsheet gives edit admission to the person who is running the script.
    Q: How practice I cheque links but for ENABLED ads, keywords, and sitelinks?
    A: The script always checks ENABLED ads, keywords, and sitelinks. To make sure it does not also check PAUSED entities, brand sure that the "Include paused ads?", "Include paused keywords?, and "Include paused sitelinks?" options in the spreadsheet are all ready to "No".
    Q: I'thousand getting the error: "Label LinkChecker_Done is missing and cannot be created in preview mode. Delight run the script or create the characterization manually."
    A: The script uses a characterization to runway the entities it has already checked, creating a label if it doesn't already be. If y'all run the script in preview fashion, it will non be able to create the label. The easiest way to fix this is to simply run the script instead of using preview way.
    Q: How practice I forestall the script from timing out?
    A: Older versions of this script may time out. Please upgrade to the latest version. If you are using the latest version and it still times out, fix the TIMEOUT_BUFFER choice to a larger value to ensure the script has enough time to write its output to the spreadsheet.
    Q: What does "Good and Bad Urls" testify?
    A: This appeared in an older version of the script. Please upgrade to the latest version.
    Q: I'm getting the error: "TypeError: Cannot phone call method 'getValue' of null."
    A: This fault occurred in an older version of the script. Please upgrade to the latest version.

    Setup

    • Fix a spreadsheet-based script with the source code beneath. Use the Link Checker template spreadsheet.
    • Don't forget to update YOUR_SPREADSHEET_URL in code.
    • Schedule the script Hourly.

    Source lawmaking

    // Copyright 2015, Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version ii.0 (the "License"); // yous 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 past applicable law or agreed to in writing, software // distributed under the License is distributed on an "Every bit IS" BASIS, // WITHOUT WARRANTIES OR Weather OF Whatsoever KIND, either express or implied. // See the License for the specific linguistic communication governing permissions and // limitations under the License.  /**  * @name Link Checker - Director Accounts  *  * @overview The Link Checker script iterates through the ads, keywords, and  *     sitelinks in your manager account hierarchy and makes certain their URLs  *     do not produce "Page not found" or other types of error responses. Encounter  *     https://developers.google.com/google-ads/scripts/docs/solutions/adsmanagerapp-link-checker  *     for more details.  *  * @writer Google Ads Scripts Team [adwords-scripts@googlegroups.com]  *  * @version 2.2  *  * @changelog  * - version two.2  *   - Added support for failure strings and custom validation functions.  * - version two.1  *   - Added expansion of conditional ValueTrack parameters (e.g. ifmobile).  *   - Added expanded text advertizement and other ad format support.  * - version 2.0.iii  *   - Added validation for external spreadsheet setup.  * - version two.0.2  *   - Let the custom tracking characterization to include spaces.  * - version 2.0.one  *   - Catch and output all UrlFetchApp exceptions.  *   - Completely revised the script to piece of work on larger accounts and hierarchies.  * - version 1.3  *   - Enhanced to include advertisement grouping sitelinks.  *   - Updated to track completion beyond runs and send at virtually 1 email  *     per day.  * - version ane.ii  *   - Remove label flushing lawmaking from the script.  * - version 1.1  *   - Remove some debug lawmaking.  * - version ane.0  *   - Released initial version.  */  var CONFIG = {   // URL of the spreadsheet template.   // This should be a re-create of https://docs.google.com/spreadsheets/d/1iO1iEGwlbe510qo3Li-j4KgyCeVSmodxU6J7M756ppk/copy.   SPREADSHEET_URL: 'YOUR_SPREADSHEET_URL',    // Array of addresses to exist alerted via electronic mail if bug are found.   RECIPIENT_EMAILS: [     'YOUR_EMAIL_HERE'   ],    // Label to utilise when a link has been checked.   Characterization: 'LinkChecker_Done',    // A list of ManagedAccountSelector conditions to restrict the population   // of kid accounts that will be processed. Leave blank or comment out   // to include all child accounts.   ACCOUNT_CONDITIONS: [],    // Number of milliseconds to sleep subsequently each URL request. Use this throttle   // to reduce the load that the script imposes on your web server(southward).   THROTTLE: 0,    // Number of seconds earlier timeout that the script should stop checking URLs   // to brand certain it has time to output its findings.   TIMEOUT_BUFFER: 120 };  /**  * Performs custom validation on a URL, with access to details such as the URL,  * the response from the server, configuration options and entity Details.  *  * To employ, the "Use Custom Validation" option in the configuration spreadsheet  * must be set to "Yep", and your custom validation code implemented within the  * below role.  *  * See the documentation for this solution for farther details.  *  * @param {string} url The URL being checked.  * @param {!HTTPResponse} response The response object for the request.  * @param {!Object} options Configuration options.  * @param {!Object} entityDetails Details of the associated Advertising / Keywords etc.  * @return {boolean} Return true if the URL and response are deemed valid.  */ office isValidResponse(url, response, options, entityDetails) {   /*      Some examples of data that tin be used in determining the validity of this      URL. This is non exhaustive and there are farther properties bachelor.   */    // The HTTP status code, e.g. 200, 404   // var responseCode = response.getResponseCode();    // The HTTP response body, east.g. HTML for spider web pages:   // var responseText = response.getContentText();    // The failure strings from the configuration spreadsheet, every bit an array:   // var failureStrings = options.failureStrings;    // The type of the entity associated with the URL, e.g. Ad, Keyword, Sitelink.   // var entityType = entityDetails.entityType;    // The campaign name   // var campaignName = entityDetails.campaign;    // The ad grouping name, if applicable   // var adGroupName = entityDetails.adGroup;    // The ad text, if applicable   // var adText = entityDetails.ad;    // The keyword text, if applicable   // var keywordText = entityDetails.keyword;    // The sitelink link text, if applicable   // var sitelinkText = entityDetails.sitelink;    /*    Remove comments and insert custom logic to make up one's mind whether this URL and    response are valid, using the data obtained above.     If valid, return true. If invalid, return false.   */    // Placeholder implementation treats all URLs as valid   return truthful; }  /**  * Parameters decision-making the script's beliefs after hitting a UrlFetchApp  * QPS quota limit.  */ var QUOTA_CONFIG = {   // Initial number of milliseconds to sleep.   INIT_SLEEP_TIME: 250,    // Multiplicative factor to increase sleep time by on each retry.   BACKOFF_FACTOR: 2,    // Maximum number of tries for a single URL.   MAX_TRIES: 5 };  /**  * Exceptions that forbid 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',   FAILURE_STRINGS: 'failureStrings',   FREQUENCY: 'frequency',   DATE_STARTED: 'dateStarted',   DATE_COMPLETED: 'dateCompleted',   DATE_EMAILED: 'dateEmailed',   NUM_ERRORS: 'numErrors',   RESULT_HEADERS: 'resultHeaders',   ARCHIVE_HEADERS: 'archiveHeaders',   USE_SIMPLE_FAILURE_STRINGS: 'useSimpleFailureStrings',   USE_CUSTOM_VALIDATION: 'useCustomValidation' };  part main() {   var spreadsheet = validateAndGetSpreadsheet(CONFIG.SPREADSHEET_URL);   validateEmailAddresses();   spreadsheet.setSpreadsheetTimeZone(AdsApp.currentAccount().getTimeZone());    var options = loadOptions(spreadsheet);   var status = loadStatus(spreadsheet);    if (!status.dateStarted) {     // This is the very commencement execution of the script.     startNewAnalysis(spreadsheet);   } else if (status.dateStarted > status.dateCompleted) {     Logger.log('Resuming work from a previous execution.');   } else if (dayDifference(condition.dateStarted, new Engagement()) <              options.frequency) {     Logger.log('Waiting until ' + options.frequency +                ' days have elapsed since the showtime of the last assay.');     return;   } else {     // Enough time has passed since the last assay to starting time a new one.     removeLabelsInAccounts();     removeAccountLabels([CONFIG.Label]);     startNewAnalysis(spreadsheet);   }    ensureAccountLabels([CONFIG.Label]);    // Become up to 50 accounts that have not all the same had all of their URLs checked.   var accountSelector = getAccounts(false).withLimit(fifty);   if (accountSelector.get().hasNext()) {     accountSelector.executeInParallel('processAccount', 'processResults',                                       JSON.stringify(options));   } else {     processResults([]);   } }  /**  * Retrieves all accounts that either have had their URLs checked or demand to  * have their URLs checked.  *  * @param {boolean} isChecked True to get accounts that have been checked  *     already, false to get accounts that have not accept been checked already.  *     Ignored if the label does not be.  * @render {Object} An business relationship selector.  */ function getAccounts(isChecked) {   var accountSelector = AdsManagerApp.accounts();    if (getAccountLabel(CONFIG.Label)) {     accountSelector = accountSelector.       withCondition('LabelNames ' +                     (isChecked ? 'CONTAINS' : 'DOES_NOT_CONTAIN') +                      ' "' + CONFIG.LABEL + '"');   }    if (CONFIG.ACCOUNT_CONDITIONS) {     for (var i = 0; i < CONFIG.ACCOUNT_CONDITIONS.length; i++) {       accountSelector =           accountSelector.withCondition(CONFIG.ACCOUNT_CONDITIONS[i]);     }   }    render accountSelector; }  /**  * Removes the tracking in each account that was previously analyzed, thereby  * clearing that account for a new analysis.  */ office removeLabelsInAccounts() {   var managerAccount = AdsApp.currentAccount();   var accounts = getAccounts(true).become();    while (accounts.hasNext()) {     AdsManagerApp.select(accounts.adjacent());     removeLabels([CONFIG.Label]);   }    AdsManagerApp.select(managerAccount); }  /**  * Performs the link checking analysis on the current account.  *  * @param {cord} options Options from the spreadsheet equally JSON.  * @return {string} JSON stringified results of the assay.  */ function processAccount(options) {   return JSON.stringify(analyzeAccount(JSON.parse(options))); }  /**  * Consolidates results from each account and outputs them.  *  * @param {Array.<Object>} executionResults A list of ExecutionResult objects.  */ office processResults(executionResults) {   var spreadsheet = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);   var options = loadOptions(spreadsheet);   var results = {     urlChecks: [],     didComplete: true   };    for (var i = 0; i < executionResults.length; i++) {     if (!executionResults[i].getError()) {       var accountResult = JSON.parse(executionResults[i].getReturnValue());       results.urlChecks = results.urlChecks.concat(accountResult.urlChecks);       results.didComplete = results.didComplete && accountResult.didComplete;        if (accountResult.didComplete) {         AdsManagerApp.accounts().withIds([executionResults[i].getCustomerId()]).become().             side by side().applyLabel(CONFIG.LABEL);       }     } else {       Logger.log('Processing for ' + executionResults[i].getCustomerId() +           ' failed.');     }   }    // The entire analysis is not consummate if at that place are whatever accounts that accept   // not been labeled (i.e., the account was not started, or non all URLs in   // the account have been checked).   results.didComplete = results.didComplete &&       !getAccounts(false).get().hasNext();    outputResults(results, options); }  /**  * Checks as many new URLs as possible that take not previously been checked,  * subject to quota and fourth dimension limits.  *  * @param {Object} options Dictionary of options.  * @render {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 remember 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);   } grab(e) {     if (e == EXCEPTIONS.QPS ||         e == EXCEPTIONS.LIMIT ||         eastward == EXCEPTIONS.TIMEOUT) {       Logger.log('Stopped checking URLs early because: ' + e);       Logger.log('Checked URLs volition even so be output.');     } else {       throw e;     }   }    return {     urlChecks: urlChecks,     didComplete: didComplete   }; }  /**  * Outputs the results to a spreadsheet and sends emails if advisable.  *  * @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.  */ office 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 full number of errors for the entire   // analysis, which is calculated past the spreadsheet.   status = loadStatus(spreadsheet);    if (results.didComplete) {     spreadsheet.getRangeByName(NAMES.DATE_COMPLETED).setValue(new Date());     Logger.log('Institute ' + status.numErrors + ' across the unabridged 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 || condition.numErrors > 0)) {       sendFinalEmail(spreadsheet, status.numErrors);     }   } }  /**  * Loads information 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 exist loaded.  * @return {Object} A dictionary with the names every bit keys and the values  *     as the cell values from the spreadsheet.  */ role loadDatabyName(spreadsheet, names) {   var data = {};    for (var i = 0; i < names.length; i++) {     var name = names[i];     var range = spreadsheet.getRangeByName(proper name);      if (range.getNumRows() > i && range.getNumColumns() > one) {       // Proper name refers to a 2d range, so load it every bit a 2d array.       data[proper name] = range.getValues();     } else if (range.getNumRows() == 1 && range.getNumColumns() == 1) {       // Name refers to a single cell, so load information technology as a value and supervene upon       // Yes/No with boolean true/imitation.       data[name] = range.getValue();       data[name] = data[name] === 'Yes' ? true : data[name];       data[proper noun] = data[name] === 'No' ? false : data[name];     } else {       // Name refers to a 1d range, then 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[proper noun] = [];       for (var j = 0; j < limit; j++) {         var cellValue = isByRow ? cellValues[j][0] : cellValues[0][j];         if (cellValue) {           data[proper noun].button(cellValue);         }       }     }   }    return information; }  /**  * Loads options from the spreadsheet.  *  * @param {Object} spreadsheet The spreadsheet object.  * @return {Object} A lexicon of options.  */ office 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,        NAMES.FAILURE_STRINGS, NAMES.USE_SIMPLE_FAILURE_STRINGS,        NAMES.USE_CUSTOM_VALIDATION]); }  /**  * 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 kickoff engagement to the spreadsheet and athenaeum results of the terminal  * assay to a divide sheet.  *  * @param {Object} spreadsheet The spreadsheet object.  */ part startNewAnalysis(spreadsheet) {   Logger.log('Starting a new assay.');    spreadsheet.getRangeByName(NAMES.DATE_STARTED).setValue(new Date());    // Helper method to get the output expanse on the results or annal 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 listing of URL check results.  * @param {Object} options Dictionary of options.  * @render {number} The number of errors in the results.  */ part 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 detail 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.button([         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) {     // Observe the first open up row on the Results tab below the headers and create a     // range large plenty to concord all of the output, ane 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 listing 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 plant in this execution.  */ role sendIntermediateEmail(spreadsheet, numErrors) {   spreadsheet.getRangeByName(NAMES.DATE_EMAILED).setValue(new Date());    MailApp.sendEmail(CONFIG.RECIPIENT_EMAILS.join(','),       'Link Checker Results',       'The Link Checker script plant ' + 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 unabridged account.  *  * @param {Object} spreadsheet The spreadsheet object.  * @param {boolean} numErrors The number of errors establish in the entire business relationship.  */ function sendFinalEmail(spreadsheet, numErrors) {   spreadsheet.getRangeByName(NAMES.DATE_EMAILED).setValue(new Appointment());    MailApp.sendEmail(CONFIG.RECIPIENT_EMAILS.bring together(','),       'Link Checker Results',       'The Link Checker script found ' + numErrors + ' URLs with errors ' +       'beyond its entire analysis. Meet ' +       spreadsheet.getUrl() + ' for details.'); }  /**  * Retrieves all final URLs and mobile final URLs in the business relationship 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 = office(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(AdsApp.ads().                                withCondition(labelCondition(true))));   }    if (options.checkKeywordUrls) {     addToMap(getUrlsBySelector(AdsApp.keywords().                                withCondition(labelCondition(true))));   }    if (options.checkSitelinkUrls) {     addToMap(getAlreadyCheckedSitelinkUrls());   }    return urlMap; }  /**  * Retrieves all final URLs and mobile final URLs for campaign and advertising group  * sitelinks.  *  * @return {Assortment.<string>} An array of URLs.  */ part 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(AdsApp.campaigns());   addSitelinkUrls(AdsApp.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 {Assortment.<string>} An assortment of URLs.  */ office getUrlsBySelector(selector) {   var urls = [];   var entities = selector.go();    // Helper method to add the url to the listing if information technology exists.   var addToList = part(url) {     if (url) {       urls.push(url);     }   };    while (entities.hasNext()) {     var entity = entities.adjacent();      addToList(entity.urls().getFinalUrl());     addToList(entity.urls().getMobileFinalUrl());   }    return urls; }  /**  * Retrieves all last URLs and mobile concluding URLs in the account beyond ads,  * keywords, and sitelinks, and checks their response lawmaking. Does not check  * previously checked URLs.  *  * @param {Object} checkedUrls A map of previously checked URLs with the URL every bit  *     the cardinal.  * @param {Array.<Object>} urlChecks An array into which the results of each URL  *     check will exist inserted.  * @param {Object} options Lexicon of options.  * @render {boolean} True if all URLs were checked.  */ function checkUrls(checkedUrls, urlChecks, options) {   var didComplete = true;    // Helper method to add mutual conditions to advertizing group and keyword selectors.   var addConditions = role(selector, includePaused) {     var statuses = ['ENABLED'];     if (includePaused) {       statuses.push('PAUSED');     }      var predicate = ' IN [' + statuses.join(',') + ']';     render selector.withCondition(labelCondition(imitation)).         withCondition('Status' + predicate).         withCondition('CampaignStatus' + predicate).         withCondition('AdGroupStatus' + predicate);   };    if (options.checkAdUrls) {     didComplete = didComplete && checkUrlsBySelector(checkedUrls, urlChecks,         addConditions(AdsApp.ads().withCondition('CreativeFinalUrls != ""'),                       options.checkPausedAds), options);   }    if (options.checkKeywordUrls) {     didComplete = didComplete && checkUrlsBySelector(checkedUrls, urlChecks,         addConditions(AdsApp.keywords().withCondition('FinalUrls != ""'),                       options.checkPausedKeywords), options);   }    if (options.checkSitelinkUrls) {     didComplete = didComplete &&         checkSitelinkUrls(checkedUrls, urlChecks, options);   }    render didComplete; }  /**  * Retrieves all final URLs and mobile final URLs in a selector and checks them  * for a valid response code. Does non 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 equally  *     the key.  * @param {Array.<Object>} urlChecks An array into which the results of each URL  *     bank check volition be inserted.  * @param {Object} selector The selector specifying the entities to employ.  *     The entities should be of a type that has a urls() method.  * @param {!Object} options Dictionary of options.  * @return {boolean} True if all URLs were checked.  */ function checkUrlsBySelector(checkedUrls, urlChecks, selector, options) {   var customerId = AdsApp.currentAccount().getCustomerId();   var iterator = selector.get();   var entities = [];    // Helper method to bank 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 entityType = entity.getEntityType();       var entityDetails = {         entityType: entityType,         campaign: entity.getCampaign ? entity.getCampaign().getName() : '',         adGroup: entity.getAdGroup ? entity.getAdGroup().getName() : '',         ad: entityType == 'Advertising' ? getAdAsText(entity) : '',         keyword: entityType == 'Keyword' ? entity.getText() : '',         sitelink: entityType.indexOf('Sitelink') != -one ?             entity.getLinkText() : ''       };        var responseCode = requestUrl(expandedUrl, options, entityDetails);        urlChecks.push({         customerId: customerId,         timestamp: new Date(),         url: expandedUrl,         responseCode: responseCode,         entityType: entityDetails.entityType,         entrada: entityDetails.campaign,         adGroup: entityDetails.adGroup,         ad: entityDetails.ad,         keyword: entityDetails.keyword,         sitelink: entityDetails.sitelink       });        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 practise not have labels.     if (entity.applyLabel) {       entity.applyLabel(CONFIG.Characterization);       checkTimeout();     }   }    // True only if we did not alienation an iterator limit.   return entities.length == iterator.totalNumEntities(); }  /**  * Retrieves a text representation of an advert, casting the ad to the appropriate  * blazon if necessary.  *  * @param {Advertisement} advert The advertising object.  * @return {string} The text representation.  */ part getAdAsText(advert) {   // There is no AdTypeSpace method for textAd   if (advert.getType() === 'TEXT_AD') {     render ad.getHeadline();   } else if (advertisement.isType().expandedTextAd()) {     var eta = advertisement.asType().expandedTextAd();     return eta.getHeadlinePart1() + ' - ' + eta.getHeadlinePart2();   } else if (ad.isType().gmailImageAd()) {     render ad.asType().gmailImageAd().getName();   } else if (advertisement.isType().gmailMultiProductAd()) {     return ad.asType().gmailMultiProductAd().getHeadline();   } else if (advertising.isType().gmailSinglePromotionAd()) {     return advertizement.asType().gmailSinglePromotionAd().getHeadline();   } else if (advertizement.isType().html5Ad()) {     return advertizing.asType().html5Ad().getName();   } else if (ad.isType().imageAd()) {     return advertizement.asType().imageAd().getName();   } else if (advertizement.isType().responsiveDisplayAd()) {     return advertizement.asType().responsiveDisplayAd().getLongHeadline();   }   return 'N/A'; }  /**  * Retrieves all terminal URLs and mobile final URLs for campaign and ad group  * sitelinks and checks them for a valid response code. Does not bank check  * previously checked URLs. Labels the containing campaign or advert group that it  * has been checked.  *  * @param {Object} checkedUrls A map of previously checked URLs with the URL as  *     the cardinal.  * @param {Array.<Object>} urlChecks An array into which the results of each URL  *     bank check will exist inserted.  * @param {Object} options Dictionary of options.  * @render {boolean} Truthful if all URLs were checked.  */ function checkSitelinkUrls(checkedUrls, urlChecks, options) {   var didComplete = true;    // Helper method to check URLs for sitelinks in a campaign or advert group   // selector.   var checkSitelinkUrls = part(selector) {     var iterator = selector.withCondition(labelCondition(false)).get();     var entities = [];      while (iterator.hasNext()) {       entities.push(iterator.adjacent());     }      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, options);         entity.applyLabel(CONFIG.LABEL);         checkTimeout();       }     }      // Truthful only if we did not alienation an iterator limit.     didComplete = didComplete &&         entities.length == iterator.totalNumEntities();   };    var statuses = ['ENABLED'];   if (options.checkPausedSitelinks) {     statuses.push('PAUSED');   }    var predicate = ' IN [' + statuses.join(',') + ']';   checkSitelinkUrls(AdsApp.campaigns().                     withCondition('Status' + predicate));   checkSitelinkUrls(AdsApp.adGroups().                     withCondition('Status' + predicate).                     withCondition('CampaignStatus' + predicate));    render 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:  *     ane. {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 brandish networks.  * Whatsoever other ValueTrack parameters or customer parameters are stripped out from  * the URL entirely.  *  * @param {string} url The URL which may contain ValueTrack parameters.  * @render {!Array.<cord>} 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, east.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 in that location are duplicates.     var combinations = {};     mobileCombinations.forEach(function(url) {       if (modifiers.ifsearch || modifiers.ifcontent) {         pairedUrlModifierReplace(modifiers, 'ifsearch', 'ifcontent', url)             .forEach(part(modifiedUrl) {               combinations[modifiedUrl] = true;             });       } else {         combinations[url] = true;       }     });     var modifiedUrls = Object.keys(combinations);   } else {     var modifiedUrls = [url];   }   // Remove any custom parameters   render modifiedUrls.map(part(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 {cord} 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 listing.  */ function pairedUrlModifierReplace(modifiers, modifier1, modifier2, url) {   render [     urlModifierReplace(modifiers, modifier1, modifier2, url),     urlModifierReplace(modifiers, modifier2, modifier1, url)   ]; }  /**  * Produces a URL where the commencement {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 {cord} mod2 The modifier to remove from the URL.  * @param {string} url The URL potentially containing ValueTrack parameters.  * @render {string} The resulting URL with substitions.  */ office urlModifierReplace(mods, mod1, mod2, url) {   var modUrl = mods[mod1] ?       url.supercede(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 bankroll 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.  * @param {!Object} options The options loaded from the configuration sheet.  * @param {!Object} entityDetails Details of the entity, e.m. type, name etc.  * @return {number|string} The response code received when requesting the URL,  *     or an error message.  */ function requestUrl(url, options, entityDetails) {   var responseCode;   var sleepTime = QUOTA_CONFIG.INIT_SLEEP_TIME;   var numTries = 0;    while (numTries < QUOTA_CONFIG.MAX_TRIES && !responseCode) {     effort {       // If UrlFetchApp.fetch() throws an exception, responseCode volition remain       // undefined.       var response = UrlFetchApp.fetch(url, {muteHttpExceptions: true});       responseCode = response.getResponseCode();        if (options.validCodes.indexOf(responseCode) !== -1) {         if (options.useSimpleFailureStrings &&             bodyContainsFailureStrings(response, options.failureStrings)) {           responseCode = 'Failure cord detected';         } else if (options.useCustomValidation && !isValidResponse(url,             response, options, entityDetails)) {           responseCode = "Custom validation failed";         }       }        if (CONFIG.THROTTLE > 0) {         Utilities.sleep(CONFIG.THROTTLE);       }     } grab(due east) {       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 as well many times:') != -ane) {         throw EXCEPTIONS.LIMIT;       } else {         return east.message;       }     }      numTries++;   }    if (!responseCode) {     throw EXCEPTIONS.QPS;   } else {     render responseCode;   } }  /**  * Searches the trunk of a HTTP response for any occurrence of a "failure cord"  * every bit defined in the configuration spreadsheet. For example, "Out of stock".  *  * @param {!HTTPResponse} response The response from the UrlFetchApp request.  * @param {!Array.<cord>} failureStrings A list of failure strings.  * @render {boolean} Returns truthful if at to the lowest degree ane failure string institute.  */ function bodyContainsFailureStrings(response, failureStrings) {   var contentText = response.getContentText() || '';   // Whilst searching for each separate failure cord across the trunk text   // separately may not be the most efficient, it is simple, and tests suggest   // information technology is not overly poor operation-wise.   return failureStrings.some(function(failureString) {     return contentText.indexOf(failureString) !== -1;   }); }  /**  * Throws an exception if the script is close to timing out.  */ function checkTimeout() {   if (AdsApp.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 than contempo) 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 * g); }  /**  * Builds a cord to exist used for withCondition() filtering for whether the  * characterization is present or not.  *  * @param {boolean} hasLabel True if the label should be present, false if the  *     label should non be present.  * @return {cord} A condition that tin can be used in withCondition().  */ part labelCondition(hasLabel) {   return 'LabelNames ' + (hasLabel ? 'CONTAINS_ANY' : 'CONTAINS_NONE') +       ' ["' + CONFIG.Label + '"]'; }  /**  * Retrieves an entity by name.  *  * @param {Object} selector A selector for an entity blazon with a Name field.  * @param {cord} proper name The proper noun to retrieve the entity by.  * @return {Object} The entity, if it exists, or null otherwise.  */ function getEntityByName(selector, name) {   var entities = selector.withCondition('Name = "' + proper name + '"').go();    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 nix otherwise.  */ function getLabel(labelName) {   return getEntityByName(AdsApp.labels(), labelName); }  /**  * Retrieves an AccountLabel object by name.  *  * @param {cord} labelName The label proper noun to retrieve.  * @return {Object} The AccountLabel object, if it exists, or nix otherwise.  */ function getAccountLabel(labelName) {   render getEntityByName(AdsManagerApp.accountLabels(), 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 characterization 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 characterization = getLabel(labelName);      if (!label) {       if (!AdsApp.getExecutionInfo().isPreview()) {         AdsApp.createLabel(labelName);       } else {         throw 'Label ' + labelName + ' is missing and cannot exist created in ' +             'preview manner. Delight run the script or create the label manually.';       }     }   } }  /**  * Checks that the account has all provided account 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 ensureAccountLabels(labelNames) {   for (var i = 0; i < labelNames.length; i++) {     var labelName = labelNames[i];     var label = getAccountLabel(labelName);      if (!label) {       if (!AdsApp.getExecutionInfo().isPreview()) {         AdsManagerApp.createAccountLabel(labelName);       } else {         throw 'Account label ' + labelName + ' is missing and cannot be ' +             'created in preview style. Please run the script or create the ' +             'characterization manually.';       }     }   } }  /**  * Removes all provided labels from the account. Since labels cannot be removed  * in preview way, throws an exception in preview fashion.  *  * @param {Array.<string>} labelNames An assortment of label names.  */ office removeLabels(labelNames) {   if (AdsApp.getExecutionInfo().isPreview()) {     throw 'Cannot remove labels in preview style. 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();     }   } }  /**  * Removes all provided business relationship labels from the account. Since labels cannot be  * removed in preview mode, throws an exception in preview fashion.  *  * @param {Array.<string>} labelNames An array of label names.  */ part removeAccountLabels(labelNames) {   if (AdsApp.getExecutionInfo().isPreview()) {     throw 'Cannot remove account labels in preview manner. Please run the ' +         'script or remove the labels manually.';   }    for (var i = 0; i < labelNames.length; i++) {     var label = getAccountLabel(labelNames[i]);      if (label) {       label.remove();     }   } }  /**  * Validates the provided spreadsheet URL to brand sure that it's prepare  * properly. Throws a descriptive mistake bulletin if validation fails.  *  * @param {cord} spreadsheeturl The URL of the spreadsheet to open.  * @render {Spreadsheet} The spreadsheet object itself, fetched from the URL.  * @throws {Mistake} If the spreadsheet URL hasn't been set  */ function validateAndGetSpreadsheet(spreadsheeturl) {   if (spreadsheeturl == 'YOUR_SPREADSHEET_URL') {     throw new Error('Delight specify a valid Spreadsheet URL. You tin can observe' +         ' a link to a template in the associated guide for this script.');   }   return SpreadsheetApp.openByUrl(spreadsheeturl); }  /**  * Validates the provided email address to brand sure it'southward not the default.  * Throws a descriptive error message if validation fails.  *  * @throws {Error} If the list of e-mail addresses is all the same the default  */ function validateEmailAddresses() {   if (CONFIG.RECIPIENT_EMAILS &&       CONFIG.RECIPIENT_EMAILS[0] == 'YOUR_EMAIL_HERE') {     throw new Error('Please either specify a valid e-mail accost or clear' +         ' the RECIPIENT_EMAILS field.');   } }