PageSpeed Insights: análise de dispositivos móveis

Ícone de relatórios

Para garantir uma experiência positiva aos usuários de dispositivos móveis quando eles acessam as páginas de destino, os sites precisam ser criados pensando nos dispositivos móveis. Um site bem otimizado para dispositivos móveis garante que os usuários permaneçam engajados, o que torna seus anúncios mais eficazes.

A análise de dispositivos móveis que usa o PageSpeed Insights fornece um relatório que sugere maneiras de melhorar a experiência na página de destino em dispositivos móveis. A solução também permite realizar análises em computadores, em vez de dispositivos móveis, usando uma simples alteração de configurações.

Planilha de velocidade da página para dispositivos móveis

Como funciona

O script examina os URLs dos anúncios, das palavras-chave e dos sitelinks da conta usando URLs finais para dispositivos móveis, se disponíveis. Caso contrário, o padrão será URLs padrão.

A primeira etapa do processo é criar um dicionário com o máximo de URLs permitido pelo tempo. Depois que isso for concluído, o script vai procurar identificar os URLs o mais diferentes possível.

É possível que um grande número de URLs em uma conta retorne páginas muito semelhantes, até mesmo páginas com o mesmo modelo. Por exemplo:

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

Para isso, classificamos as diferenças da seguinte maneira:

Mais diferentes

Os hosts são diferentes. Exemplo:

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

Mais opções

Os hosts são os mesmos, mas os caminhos são diferentes. Exemplo:

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

Menos diferente

Os hosts e os caminhos são os mesmos, mas os parâmetros são diferentes. Exemplo:

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

Um subconjunto menor de URLs (com o objetivo de ser o mais diferente possível de acordo com a classificação acima) é selecionado e recuperado por meio da API PageSpeed Insights.

Os resultados são apresentados em uma Planilha Google enviada por e-mail.

Instalação

Instruções de configuração:

  • No Google Ads, navegue até "Scripts" clicando na guia Contas e escolha Operações em massa > Scripts na navegação à esquerda.
  • Crie um novo script e dê um nome a ele. Autorize o script.
  • Copie e cole o código-fonte completo no script.
  • Atualize EMAIL_RECIPIENTS no código.
  • Atualize API_KEY no código.
  • Se necessário, programe o script. Programar tela

Como obter uma chave de API:

  • Acesse a página de primeiros passos do PageSpeed Insights.
  • Em Adquirir e usar uma chave de API, clique no botão Gerar uma chave.
  • Na caixa de diálogo seguinte, selecione ou crie um projeto. Clique em Próxima.
  • Copie a chave de API emitida e use-a no valor da variável API_KEY no seu script. javascript const API_KEY = 'INSERT_PAGESPEED_API_KEY_HERE';

Código-fonte

// 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
  };
}