PageSpeed ​​Insights: мобильный анализ

Значок отчетов

Чтобы обеспечить положительный опыт для мобильных пользователей при посещении целевых страниц, необходимо, чтобы сайты создавались с учетом мобильных устройств. Сайт, хорошо оптимизированный для мобильных устройств, гарантирует, что пользователи будут продолжать пользоваться вашим сайтом, что делает вашу рекламу более эффективной.

Мобильный анализ с помощью PageSpeed ​​Insights предоставляет отчет, предлагающий способы улучшения качества целевой страницы на мобильных устройствах. Решение также предоставляет возможность выполнять анализ настольных компьютеров, а не мобильных устройств, используя простое изменение настроек.

Таблица скорости загрузки страниц для мобильных устройств

Как это работает

Скрипт проверяет URL-адреса объявлений, ключевых слов и дополнительных ссылок аккаунта, используя конечные мобильные URL-адреса, если они доступны, в противном случае по умолчанию используются стандартные URL-адреса.

Первым этапом процесса является создание словаря из как можно большего количества URL-адресов. После завершения скрипт ищет максимально отличающиеся URL-адреса.

Возможно, что большое количество URL-адресов в учетной записи возвращают очень похожие страницы, даже страницы из одного и того же шаблона, например:

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

С этой целью мы классифицируем различия следующим образом:

Самые разные

Хозяева разные. Например:

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

Более разные

Хосты одни и те же, но пути разные. Например:

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

Наименее разные

Хосты и пути одинаковы, но параметры различаются. Например:

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

Затем отбирается и извлекается с помощью PageSpeed ​​Insights API меньшее подмножество URL-адресов (с целью максимально отличаться друг от друга согласно приведенной выше классификации).

Результаты представлены в электронной таблице Google, которая отправляется по электронной почте.

Настраивать

Инструкции по настройке:

  • В Google Рекламе перейдите к разделу «Скрипты», щелкнув вкладку «Аккаунты» , затем выберите «Массовые операции» > «Скрипты» на левой панели навигации.
  • Создайте новый скрипт и дайте ему имя. Авторизуйте скрипт.
  • Скопируйте и вставьте полный исходный код в скрипт.
  • Обновите EMAIL_RECIPIENTS в коде.
  • Обновите API_KEY в коде.
  • При необходимости запланируйте сценарий. Экран расписания

Получение API-ключа:

  • Посетите страницу «Начало работы» PageSpeed ​​Insights .
  • В разделе «Получение и использование ключа API» нажмите кнопку «Получить ключ» .
  • В последующем диалоговом окне выберите или создайте проект. Нажмите "Далее .
  • Скопируйте выданный ключ API и используйте его в качестве значения переменной API_KEY в своем скрипте. javascript const API_KEY = 'INSERT_PAGESPEED_API_KEY_HERE';

Исходный код

// 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.1
 *   - Fixed bug with fetching ad URLs.
 * - 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
  };
}