PageSpeed Insights: Mobile Analysis

Reports icon

Ensuring a positive experience for mobile users when they visit landing pages requires that sites are built with mobile in mind. A site well-optimized for mobile ensures that users remain engaged with your site, making your ads more effective.

Mobile analysis using PageSpeed Insights provides a report suggesting ways to improve the landing page experience on mobile devices. The solution also provides the ability to perform desktop analysis, instead of mobile, using a simple settings change.

Mobile pagespeed spreadsheet

How it works

The script examines URLs from the account's ads, keywords, and sitelinks using mobile final URLs if available, defaulting to standard URLs otherwise.

The first stage of the process is to build up a dictionary of as many URLs as time allows. Once this completes, the script looks to identify URLs that are as different as possible.

It's possible that a large number of URLs in an account return very similar pages, even pages from the same template, for example:

  • http://www.example.com/product?id=123
  • http://www.example.com/product?id=456

To this end, we classify differences as follows:

Most different

Hosts are different. For example:

http://www.example.com/path and http://www.test.com/path

More different

Hosts are the same, but paths differ. For example:

http://www.example.com/shop and http://www.example.com/blog

Least different

Hosts and paths are the same, but parameters differ. For example:

http://www.example.com/shop?product=1 and http://www.example.com/shop?product=2

A smaller subset of URLs (with the aim of being as different as possible by the above classification) are then selected and retrieved using the PageSpeed Insights API.

The results are presented in a Google Spreadsheet that is sent by email.

Setup

Setup instructions:

  • In Google Ads, navigate to Scripts by clicking the Accounts tab, then choose Bulk Operations > Scripts in the left navigation.
  • Create a new script and give it a name. Authorize the script.
  • Copy-paste the complete source code into the script.
  • Update EMAIL_RECIPIENTS in the code.
  • Update API_KEY in the code.
  • If necessary, schedule the script. Schedule screen

Obtaining an API key:

  1. Visit the PageSpeed Insights Get Started page.
  2. Under Acquiring and using an API key, click the Get a Key button.
  3. In the subsequent dialog, select or create a project. Click Next.
  4. Copy the issued API key and use it for the value of the API_KEY variable in your script.
    var API_KEY = 'INSERT_PAGESPEED_API_KEY_HERE';

Source code

// Copyright 2015, 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.

/**
 * @fileoverview Mobile Performance from PageSpeed Insights - Single Account
 *
 * Produces a report showing how well landing pages are set up for mobile
 * devices and highlights potential areas for improvement. See :
 * https://developers.google.com/google-ads/scripts/docs/solutions/mobile-pagespeed
 * for more details.
 *
 * @author Google Ads Scripts Team [adwords-scripts@googlegroups.com]
 *
 * @version 2.0
 *
 * @changelog
 * - version 2.0
 *   - Updated to use new Google Ads scripts features.
 * - version 1.3.3
 *   - Added guidance for desktop analysis.
 * - version 1.3.2
 *   - Bugfix to improve table sorting comparator.
 * - version 1.3.1
 *   - Bugfix for handling the absence of optional values in PageSpeed response.
 * - version 1.3
 *   - Removed the need for the user to take a copy of the spreadsheet.
 *   - Added the ability to customize the Campaign and Ad Group limits.
 * - version 1.2.1
 *   - Improvements to time zone handling.
 * - version 1.2
 *   - Bug fix for handling empty results from URLs.
 *   - Error reporting in spreadsheet for failed URL fetches.
 * - version 1.1
 *   - Updated the comments to be in sync with the guide.
 * - version 1.0
 *   - Released initial version.
 */

// See "Obtaining an API key" at
// https://developers.google.com/google-ads/scripts/docs/solutions/mobile-pagespeed
const API_KEY = 'INSERT_PAGESPEED_API_KEY_HERE';
const EMAIL_RECIPIENTS = 'INSERT_EMAIL_ADDRESS_HERE';

// If you wish to add extra URLs to checked, list them here as a
// comma-separated list eg. ['http://abc.xyz', 'http://www.google.com']
const EXTRA_URLS_TO_CHECK = [];

// By default, the script returns analysis of how the site performs on mobile.
// Change the following from 'mobile' to 'desktop' to perform desktop analysis.
const PLATFORM_TYPE = 'mobile';

/**
 * The URL of the template spreadsheet for each report created.
 */
const SPREADSHEET_TEMPLATE =
    'https://docs.google.com/spreadsheets/d/1SKLXUiorvgs2VuPKX7NGvcL68pv3xEqD7ZcqsEwla4M/edit';

const PAGESPEED_URL =
    'https://www.googleapis.com/pagespeedonline/v5/runPagespeed?';

/*
 * The maximum number of Campaigns to sample within the account.
 */
const CAMPAIGN_LIMIT = 50000;

/*
 * The maximum number of Ad Groups to sample within the account.
 */
const ADGROUP_LIMIT = 50000;

/**
 * These are the sampling limits for how many of each element will be examined
 * in each AdGroup.
 */
const KEYWORD_LIMIT = 20;
const SITELINK_LIMIT = 20;
const AD_LIMIT = 30;

/**
 * Specifies the amount of time in seconds required to do the URL fetching and
 * result generation. As this is the last step, entities in the account will be
 * iterated over until this point.
 */
const URL_FETCH_TIME_SECS = 8 * 60;

/**
 * Specifies the amount of time in seconds required to write to and format the
 * spreadsheet.
 */
const SPREADSHEET_PREP_TIME_SECS = 4 * 60;

/**
 * Represents the number of retries to use with the PageSpeed service.
 */
const MAX_RETRIES = 3;

/**
 * Represents the regex to validate the url.
 */
const urlRegex = /^(https?:\/\/[^\/]+)([^?#]*)(.*)$/;

/**
 * The main entry point for execution.
 */
function main() {
  if (!defaultsChanged()) {
    console.log('Please change the default configuration values and retry');
    return;
  }
  const accountName = AdsApp.currentAccount().getName();
  const urlStore = getUrlsFromAccount();
  const result = getPageSpeedResultsForUrls(urlStore);
  const spreadsheet = createPageSpeedSpreadsheet(accountName +
        ': PageSpeed Insights - Mobile Analysis', result);
  spreadsheet.addEditors([EMAIL_RECIPIENTS]);
  sendEmail(spreadsheet.getUrl());
}

/**
 * Sends an email to the user with the results of the run.
 *
 * @param {string} url URL of the spreadsheet.
 */
function sendEmail(url) {
  const footerStyle = 'color: #aaaaaa; font-style: italic;';
  const scriptsLink = 'https://developers.google.com/google-ads/scripts/';
  const subject = `Google Ads PageSpeed URL-Sampling Script Results - ` +
      `${getDateStringInTimeZone('dd MMM yyyy')}`;
  const htmlBody = `<html><body>` +
      `<p>Hello,</p>` +
      `<p>A Google Ads Script has run successfully and the output is ` +
      `available here:` +
      `<ul><li><a href="${url}` +
      `">Google Ads PageSpeed URL-Sampling Script Results</a></li></ul></p>` +
      `<p>Regards,</p>` +
      `<span style="${footerStyle}">This email was automatically ` +
      `generated by <a href="${scriptsLink}">Google Ads Scripts</a>.` +
      `</span></body></html>`;
  const body = `Please enable HTML to view this report.`;
  const options = {htmlBody: htmlBody};
  MailApp.sendEmail(EMAIL_RECIPIENTS, subject, body, options);
}

/**
 * Checks to see that placeholder defaults have been changed.
 *
 * @return {boolean} true if placeholders have been changed, false otherwise.
 */
function defaultsChanged() {
  if (API_KEY == 'INSERT_PAGESPEED_API_KEY_HERE' ||
      SPREADSHEET_TEMPLATE == 'INSERT_SPREADSHEET_URL_HERE' ||
      JSON.stringify(EMAIL_RECIPIENTS) ==
      JSON.stringify(['INSERT_EMAIL_ADDRESS_HERE'])) {
    return false;
  }
  return true;
}

/**
 * Creates a new PageSpeed spreadsheet and populates it with result data.
 *
 * @param {string} name The name to give to the spreadsheet.
 * @param {Object} pageSpeedResult The result from PageSpeed, and the number of
 *     URLs that could have been chosen from.
 * @return {Spreadsheet} The newly-created spreadsheet.
 */
function createPageSpeedSpreadsheet(name, pageSpeedResult) {
  const spreadsheet = SpreadsheetApp.openByUrl(SPREADSHEET_TEMPLATE).copy(name);
  spreadsheet.setSpreadsheetTimeZone(AdsApp.currentAccount().getTimeZone());
  const data = pageSpeedResult.table;
  const activeSheet = spreadsheet.getActiveSheet();
  const rowStub = spreadsheet.getRangeByName('ResultRowStub');
  const top = rowStub.getRow();
  const left = rowStub.getColumn();
  const cols = rowStub.getNumColumns();
  if (data.length > 2) { // No need to extend the template if num rows <= 2
    activeSheet.insertRowsAfter(
          spreadsheet.getRangeByName('EmptyUrlRow').getRow(), data.length);
    rowStub.copyTo(activeSheet.getRange(top + 1, left, data.length - 2, cols));
  }
  // Extend the formulas and headings to accommodate the data.
  if (data.length && data[0].length > 4) {
    const metricsRange = activeSheet
        .getRange(top - 6, left + cols, data.length + 5, data[0].length - 4);
    activeSheet.getRange(top - 6, left + cols - 1, data.length + 5)
        .copyTo(metricsRange);
    // Paste in the data values.
    activeSheet.getRange(top - 1, left, data.length, data[0].length)
        .setValues(data);
    // Move the 'Powered by Google Ads Scripts' to right corner of table.
    spreadsheet.getRangeByName('PoweredByText').moveTo(activeSheet.getRange(1,
      data[0].length + 1, 1, 1));
    // Set summary - date and number of URLs chosen from.
    const summaryDate = getDateStringInTimeZone('dd MMM yyyy');
    spreadsheet.getRangeByName('SummaryDate')
        .setValue(`Summary as of ${summaryDate}. ` +
                  `Results drawn from ${pageSpeedResult.totalUrls} URLs.`);
  }
  // Add errors if they exist
  if (pageSpeedResult.errors.length) {
    const nextRow = spreadsheet.getRangeByName('FirstErrorRow').getRow();
    const errorRange = activeSheet.getRange(nextRow, 2,
          pageSpeedResult.errors.length, 2);
    errorRange.setValues(pageSpeedResult.errors);
  }
  return spreadsheet;
}

/**
 * This function takes a collection of URLs as provided by the UrlStore object
 * and gets results from the PageSpeed service. However, two important things :
 *     (1) It only processes a handful, as determined by URL_LIMIT.
 *     (2) The URLs returned from iterating on the UrlStore are in a specific
 *     order, designed to produce as much variety as possible (removing the need
 *     to process all the URLs in an account.
 *
 * @param {UrlStore} urlStore Object containing URLs to process.
 * @return {Object} An object with three properties: 'table' - the 2d table of
 *     results, 'totalUrls' - the number of URLs chosen from, and errors.
 */
function getPageSpeedResultsForUrls(urlStore) {
  let count = 0;
  // Associative array for column headings and contextual help URLs.
  const headings = {};
  const errors = {};
  // Record results on a per-URL basis.
  const pageSpeedResults = {};
  let urlTotalCount = 0;
  let actualUrl;
  for (const url in urlStore) {
    if (url === 'manualUrls') {
      actualUrl = urlStore[url];
    }
    if (url === 'paths') {
      let urlValue = urlStore[url];
      for (const host in urlValue) {
        const values=urlValue[host];
        for (const value in values) {
          actualUrl = Object.values(values[value]);
         }
      }
    }
    if (hasRemainingTimeForUrlFetches()) {
      const result = getPageSpeedResultForSingleUrl(actualUrl);
      if (!result.error) {
        pageSpeedResults[actualUrl] = result.pageSpeedInfo;
        let columnsResult = result.columnsInfo;

        // Loop through each heading element; the PageSpeed Insights API
        // doesn't always return URLs for each column heading, so aggregate
        // these across each call to get the most complete list.
        let columnHeadings = Object.keys(columnsResult);
        for (const columnHeading of columnHeadings) {
          if (!headings[columnHeading] || (headings[columnHeading] &&
            headings[columnHeading].length <
            columnsResult[columnHeading].length)) {
            headings[columnHeading] = columnsResult[columnHeading];
          }
        }
      } else {
        errors[actualUrl] = result.error;
      }
      count++;
    }
    urlTotalCount++;
  }

  const tableHeadings = ['URL', 'Speed', 'Usability'];
  const headingKeys = Object.keys(headings);
  for (const headingKey of headingKeys) {
    tableHeadings.push(headingKey);
  }

  const table = [];
  const pageSpeedResultsUrls = Object.keys(pageSpeedResults);
  for (const pageSpeedResultsUrl of pageSpeedResultsUrls) {
    const resultUrl = pageSpeedResultsUrl;
    const row = [toPageSpeedHyperlinkFormula(resultUrl)];
    const data = pageSpeedResults[resultUrl];
    for (let j = 1, lenJ = tableHeadings.length; j < lenJ; j++) {
      row.push(data[tableHeadings[j]]);
    }
    table.push(row);
  }
  // Present the table back in the order worst-performing-first.
  table.sort(function(first, second) {
    const f1 = isNaN(parseInt(first[1])) ? 0 : parseInt(first[1]);
    const f2 = isNaN(parseInt(first[2])) ? 0 : parseInt(first[2]);
    const s1 = isNaN(parseInt(second[1])) ? 0 : parseInt(second[1]);
    const s2 = isNaN(parseInt(second[2])) ? 0 : parseInt(second[2]);

    if (f1 + f2 < s1 + s2) {
      return -1;
    } else if (f1 + f2 > s1 + s2) {
      return 1;
    }
    return 0;
  });

  // Add hyperlinks to all column headings where they are available.
  for (let tableHeading of tableHeadings) {
    // Sheets cannot have multiple links in a single cell at the moment :-/
    if (headings[tableHeading] &&
        typeof(headings[tableHeading]) === 'object') {
      tableHeading = `=HYPERLINK("${headings[tableHeading][0]}"` +
          `,"${tableHeading}")`;
    }
  }

  // Form table from errors
  const errorTable = [];
  const errorKeys = Object.keys(errors);
  for (const errorKey of errorKeys) {
    errorTable.push([errorKey, errors[errorKey]]);
  }
  table.unshift(tableHeadings);
  return {
    table: table,
    totalUrls: urlTotalCount,
    errors: errorTable
  };
}

/**
 * Given a URL, returns a spreadsheet formula that displays the URL yet links to
 * the PageSpeed URL for examining this.
 *
 * @param {string} url The URL to embed in the Hyperlink formula.
 * @return {string} A string representation of the spreadsheet formula.
 */
function toPageSpeedHyperlinkFormula(url) {
  return '=HYPERLINK("' +
         'https://developers.google.com/speed/pagespeed/insights/?url=' + url +
         '&tab=' + PLATFORM_TYPE +'","' + url + '")';
}

/**
 * Creates an object of results metrics from the parsed results of a call to
 * the PageSpeed service.
 *
 * @param {Object} parsedPageSpeedResponse The object returned from PageSpeed.
 * @return {Object} An associative array with entries for each metric.
 */
function extractResultRow(parsedPageSpeedResponse) {
  const urlScores = {};
  if (parsedPageSpeedResponse.lighthouseResult.categories) {
    const ruleGroups = parsedPageSpeedResponse.lighthouseResult.categories;
    // At least one of the SPEED or USABILITY properties will exist, but not
    // necessarily both.
    urlScores.Speed = ruleGroups.performance ?
        ruleGroups.performance.score : '-';
    urlScores.Usability = ruleGroups.accessibility ?
        ruleGroups.accessibility.score : '-';
  }
  if (parsedPageSpeedResponse.lighthouseResult &&
    parsedPageSpeedResponse.lighthouseResult.audits) {
    const resultParts = parsedPageSpeedResponse.lighthouseResult.audits;
    for (const partName in resultParts) {
      const part = resultParts[partName];
      urlScores[part.title] = part.score;
    }
  }
  return urlScores;
}

/**
 * Extracts the headings for the metrics returned from PageSpeed, and any
 * associated help URLs.
 *
 * @param {Object} parsedPageSpeedResponse The object returned from PageSpeed.
 * @return {Object} An associative array used to store column-headings seen in
 *     the response. This can take two forms:
 *     (1) {'heading':'heading', ...} - this form is where no help URLs are
 *     known.
 *     (2) {'heading': [url1, ...]} - where one or more URLs is returned that
 *     provides help on the particular heading item.
 */
function extractColumnsInfo(parsedPageSpeedResponse) {
  const columnsInfo = {};
  const performance_auditRefs =
      parsedPageSpeedResponse.lighthouseResult.categories.performance.auditRefs;
  if (parsedPageSpeedResponse.lighthouseResult &&
     parsedPageSpeedResponse.lighthouseResult.audits) {
    for (const performance_auditRef of performance_auditRefs) {
      if (performance_auditRef.weight > 0 &&
         performance_auditRef.id =='largest-contentful-paint'){
        const resultParts = parsedPageSpeedResponse.lighthouseResult.audits;
        for (const partName in resultParts) {
          for (const auditref of performance_auditRef.relevantAudits) {
            if (partName === auditref || partName === 'speed-index' ||
                partName === 'Interactive'){
              if (resultParts[partName].score &&
                  resultParts[partName].score !== undefined) {
                const part= resultParts[partName];
                if (!columnsInfo[part.title]) {
                  columnsInfo[part.title] = part.title;
                }
              }
            }
          }
        }
      }
    }
  }
  return columnsInfo;
}

/**
 * Extracts a suitable error message to display for a failed URL. The error
 * could be passed in the nested PageSpeed error format, or there could have
 * been a more fundamental error in the fetching of the URL. Extract the
 * relevant message in each case.
 *
 * @param {string} errorMessage The error string.
 * @return {string} A formatted error message.
 */
function formatErrorMessage(errorMessage) {
  let formattedMessage = null;
  if (!errorMessage) {
    formattedMessage = 'Unknown error message';
  } else {
    try {
      const parsedError = JSON.parse(errorMessage);
      // This is the nested structure expected from PageSpeed
      if (parsedError.error && parsedError.error.errors) {
        const firstError = parsedError.error.errors[0];
        formattedMessage = firstError.message;
      } else if (parsedError.message) {
        formattedMessage = parsedError.message;
      } else {
        formattedMessage = errorMessage.toString();
      }
    } catch (e) {
      formattedMessage = errorMessage.toString();
    }
  }
  return formattedMessage;
}

/**
 * Calls the PageSpeed API for a single URL, and attempts to parse the resulting
 * JSON. If successful, produces an object for the metrics returned, and an
 * object detailing the headings and help URLs seen.
 *
 * @param {string} url The URL to run PageSpeed for.
 * @return {Object} An object with pageSpeed metrics, column-heading info
 *     and error properties.
 */
function getPageSpeedResultForSingleUrl(url) {
  let parsedResponse = null;
  let errorMessage = null;
  let retries = 0;

  while ((!parsedResponse || parsedResponse.responseCode !== 200) &&
         retries < MAX_RETRIES) {
    errorMessage = null;
    const fetchResult = checkUrl(url);
    if (fetchResult.responseText) {
      try {
        parsedResponse = JSON.parse(fetchResult.responseText);
        break;
      } catch (e) {
        errorMessage = formatErrorMessage(e);
      }
    } else {
      errorMessage = formatErrorMessage(fetchResult.error);
    }
    retries++;
    Utilities.sleep(1000 * Math.pow(2, retries));
  }
  let columnsInfo;
  let urlScores;
  if (!errorMessage) {
     columnsInfo = extractColumnsInfo(parsedResponse);
     urlScores = extractResultRow(parsedResponse);
  }
  return {
    pageSpeedInfo: urlScores,
    columnsInfo: columnsInfo,
    error: errorMessage
  };
}

/**
 * Gets the most representative URL that would be used on a mobile device
 * taking into account Upgraded URLs.
 *
 * @param {Entity} entity A Google Ads entity such as an Ad, Keyword or
 *     Sitelink.
 * @return {string} The URL.
 */
function getMobileUrl(entity) {
  const urls = entity.urls();
  let url = null;
  if (urls) {
    if (urls.getMobileFinalUrl()) {
      url = urls.getMobileFinalUrl();
    } else if (urls.getFinalUrl()) {
      url = urls.getFinalUrl();
    }
  }
  if (!url) {
    switch (entity.getEntityType()) {
      case 'Ad':
        url = entity.urls.getFinalUrl();
        break;
      case 'Keyword':
        url = entity.urls().getFinalUrl();
        break;
      case 'Sitelink':
      case 'AdGroupSitelink':
      case 'CampaignSitelink':
        url = entity.getLinkUrl();
        break;
      default:
        console.warn('No URL found' + entity.getEntityType());
    }
  }
  if (url) {
    url = encodeURI(decodeURIComponent(url));
  }
  return url;
}

/**
 * Determines whether there is enough remaining time to continue iterating
 * through the account.
 *
 * @return {boolean} Returns true if there is enough time remaining to continue
 *     iterating.
 */
function hasRemainingTimeForAccountIteration() {
  const remainingTime = AdsApp.getExecutionInfo().getRemainingTime();
  return remainingTime > SPREADSHEET_PREP_TIME_SECS + URL_FETCH_TIME_SECS;
}

/**
 * Determines whether there is enough remaining time to continue fetching URLs.
 *
 * @return {boolean} Returns true if there is enough time remaining to continue
 *     fetching.
 */
function hasRemainingTimeForUrlFetches() {
  const remainingTime = AdsApp.getExecutionInfo().getRemainingTime();
  return remainingTime > SPREADSHEET_PREP_TIME_SECS;
}

/**
 * Iterates through all the available Campaigns and AdGroups, to a limit of
 * defined in CAMPAIGN_LIMIT and ADGROUP_LIMIT until the time limit is reached
 * allowing enough time for the post-iteration steps, e.g. fetching and
 * analysing URLs and building results.
 *
 * @return {UrlStore} An UrlStore object with URLs from the account.
 */
function getUrlsFromAccount() {
  const urlStore = new UrlStore(EXTRA_URLS_TO_CHECK);
  const campaigns = AdsApp.campaigns()
                      .forDateRange('LAST_30_DAYS')
                      .withCondition('campaign.status = "ENABLED"')
                      .orderBy('metrics.clicks DESC')
                      .withLimit(CAMPAIGN_LIMIT)
                      .get();
  while (campaigns.hasNext() && hasRemainingTimeForAccountIteration()) {
    const campaign = campaigns.next();
    let campaignUrls = getUrlsFromCampaign(campaign);
    urlStore.addUrls(campaignUrls);
  }

  const adGroups = AdsApp.adGroups()
                     .forDateRange('LAST_30_DAYS')
                     .withCondition('ad_group.status = "ENABLED"')
                     .orderBy('metrics.clicks DESC')
                     .withLimit(ADGROUP_LIMIT)
                     .get();
  while (adGroups.hasNext() && hasRemainingTimeForAccountIteration()) {
    const adGroup = adGroups.next();
    const adGroupUrls = getUrlsFromAdGroup(adGroup);
    urlStore.addUrls(adGroupUrls);
  }
  return urlStore;
}

/**
 * Work through an ad group's members in the account, but only up to the maximum
 * specified by the SITELINK_LIMIT.
 *
 * @param {AdGroup} adGroup The adGroup to process.
 * @return {!Array.<string>} A list of URLs.
 */
function getUrlsFromAdGroup(adGroup) {
  const uniqueUrls = {};
  const sitelinks =
      adGroup.extensions().sitelinks().withLimit(SITELINK_LIMIT).get();
  for (const sitelink of sitelinks) {
    const url = getMobileUrl(sitelink);
    if (url) {
      uniqueUrls[url] = true;
    }
  }
  return Object.keys(uniqueUrls);
}

/**
 * Work through a campaign's members in the account, but only up to the maximum
 * specified by the AD_LIMIT, KEYWORD_LIMIT and SITELINK_LIMIT.
 *
 * @param {Campaign} campaign The campaign to process.
 * @return {!Array.<string>} A list of URLs.
 */
function getUrlsFromCampaign(campaign) {
  const uniqueUrls = {};
  let url = null;
  const sitelinks = campaign
      .extensions().sitelinks().withLimit(SITELINK_LIMIT).get();
  for (const sitelink of sitelinks) {
    url = getMobileUrl(sitelink);
    if (url) {
      uniqueUrls[url] = true;
    }
  }
  const ads = AdsApp.ads().forDateRange('LAST_30_DAYS')
      .withCondition('ad_group_ad.status = "ENABLED"')
      .orderBy('metrics.clicks DESC')
      .withLimit(AD_LIMIT)
      .get();
  for (const ad of ads) {
    url = getMobileUrl(ad);
    if (url) {
      uniqueUrls[url] = true;
    }
  }

  const keywords =
     AdsApp.keywords().forDateRange('LAST_30_DAYS')
      .withCondition('campaign.status = "ENABLED"')
      .orderBy('metrics.clicks DESC')
      .withLimit(KEYWORD_LIMIT)
      .get();

  for (const keyword of keywords) {
    url = getMobileUrl(keyword);
    if (url) {
      uniqueUrls[url] = true;
    }
  }
  return Object.keys(uniqueUrls);
}

/**
 * Produces a formatted string representing a given date in a given time zone.
 *
 * @param {string} format A format specifier for the string to be produced.
 * @param {Date} date A date object. Defaults to the current date.
 * @param {string} timeZone A time zone. Defaults to the account's time zone.
 * @return {string} A formatted string of the given date in the given time zone.
 */
function getDateStringInTimeZone(format, date, timeZone) {
  date = date || new Date();
  timeZone = timeZone || AdsApp.currentAccount().getTimeZone();
  return Utilities.formatDate(date, timeZone, format);
}

/**
 * UrlStore - this is an object that takes URLs, added one by one, and then
 * allows them to be iterated through in a particular order, which aims to
 * maximise the variety between the returned URLs.
 *
 * This works by splitting the URL into three parts: host, path and params
 * In comparing two URLs, most weight is given if the hosts differ, then if the
 * paths differ, and finally if the params differ.
 *
 * UrlStore sets up a tree with 3 levels corresponding to the above. The full
 * URL exists at the leaf level. When a request is made for an iterator, a copy
 * is taken, and a path through the tree is taken, using the first host. Each
 * entry is removed from the tree as it is used, and the layers are rotated with
 * each call such that the next call will result in a different host being used
 * (where possible).
 *
 * Where manualUrls are supplied at construction time, these will take
 * precedence over URLs added subsequently to the object.
 */
class UrlStore {
  /**
   * @param {?Array.<string>=} manualUrls An optional list of URLs to check.
   */
  constructor (manualUrls) {
    this.manualUrls = manualUrls;
    this.paths = {};
  }

  /**
   * Adds a URL to the UrlStore.
   *
   * @param {string} url The URL to add.
   */
  addUrl(url) {
    if (!url || this.manualUrls.indexOf(url) > -1) {
      return;
    }
    const matches = urlRegex.exec(url);
    if (matches) {
      let host = matches[1];
      let path = matches[2];
      if (!this.paths[host]) {
        this.paths[host] = {};
      }
      let hostObj = this.paths[host];
      if (!path) {
        path = '/';
      }
      if (!hostObj[path]) {
        hostObj[path] = {};
      }
      let pathObj = hostObj[path];
      pathObj[url] = url;
    }
  }

  /**
   * Adds multiple URLs to the UrlStore.
   *
   * @param {!Array.<string>} urls The URLs to add.
   */
  addUrls(urls) {
    for (const url of urls) {
      this.addUrl(url);
    }
  }

  /**
   * Creates and returns an iterator that tries to iterate over all available
   * URLs.
   *
   * @return {!UrlStoreIterator} The new iterator object.
   */
  __iterator__ () {
    return new UrlStoreIterator(this.paths, this.manualUrls);
  }
}

/**
 * Creates and returns an iterator that tries to iterate over all available
 * URLs return them in an order to maximise the difference between them.
 *
 * @return {!UrlStoreIterator} The new iterator object.
 */
const UrlStoreIterator = (function() {
  function UrlStoreIterator(paths, manualUrls) {
    this.manualUrls = manualUrls.slice();
    this.urls = objectToArray_(paths);
  }
  UrlStoreIterator.prototype.next = function() {
    if (this.manualUrls.length) {
      return this.manualUrls.shift();
    }
    if (this.urls.length) {
      return pick_(this.urls);
    } else {
      throw StopIteration;
    }
  };
  function rotate_(a) {
    if (a.length < 2) {
      return a;
    } else {
      let e = a.pop();
      a.unshift(e);
    }
  }
  function pick_(a) {
    if (typeof a[0] === 'string') {
      return a.shift();
    } else {
      let element = pick_(a[0]);
      if (!a[0].length) {
        a.shift();
      } else {
        rotate_(a);
      }
      return element;
    }
  }

  function objectToArray_(obj) {
    if (typeof obj !== 'object') {
      return obj;
    }

    const a = [];
    for (let k in obj) {
      a.push(objectToArray_(obj[k]));
    }
    return a;
  }
  return UrlStoreIterator;
})();

/**
 * Runs the PageSpeed fetch.
 *
 * @param {string} url
 * @return {!Object} An object containing either the successful response from
 * the server, or an error message.
 */
function checkUrl(url) {
  let result = null;
  let error = null;
  const fullUrl = PAGESPEED_URL + 'key=' + API_KEY + '&url=' + encodeURI(url) +
                '&strategy=mobile&category=ACCESSIBILITY&category=PERFORMANCE';
  const params = {muteHttpExceptions: true};
  try {
    const pageSpeedResponse = UrlFetchApp.fetch(fullUrl, params);
    if (pageSpeedResponse.getResponseCode() === 200) {
      result = pageSpeedResponse.getContentText();
    } else {
      error = pageSpeedResponse.getContentText();
    }
  } catch (e) {
    error = e.message;
  }
  return {
    responseText: result,
    error: error
  };
}