Audience Assistant

Tools icon

The Audience Assistant script for Google Ads accounts can run in either manager or single accounts.

As a Google Ads account evolves, new campaigns are continuously added. Keeping a Google Ads campaign in sync with an audience setup can be a repetitive task.

The Audience Assistant solution is a Google Ads script to add your favorite Remarketing Lists and Similar Audiences within an account to the campaigns missing them. This solution does have the following caveats:

  • Only RLSA and Similar Audiences are supported at the moment.
  • Audience setups at the campaign level can be processed, and the script has an execution time limit. This limits the rate at which audiences can be checked per execution.

Configuration options

The script's main options can be set in the spreadsheet.

Spreadsheet screenshot

  • Scope selects whether the script adds audiences in observation mode (recommended) or targeting mode, and which accounts are to be processed. Most users should include all of their search and shopping accounts.

  • You can exclude specific campaigns by specifying a label in cell G2. If the label doesn't exist in one of the accounts from the sheet, the script will create it but will not apply it to any campaigns.

Scheduling

Each time the script runs, it automatically detects whether it should resume a cycle across all accounts already in progress, or start a new one. As a result, regardless of how often you want to launch a fresh cycle, schedule the script Hourly.

How it works

The script uses Google Ads Reports to build up a list of all enabled campaigns and a list of campaigns with their Audiences. Since Manager Account scripts can only process 50 accounts in parallel, the script processes accounts across several executions until all accounts are inspected.

Setup

Configuration consists of the following steps:

  1. Set up a spreadsheet-based script with the source code below. Make a copy of this template spreadsheet.
  2. Insert the account IDs (for example, 123-456-7890) in column A, one per row and no ID more than once. Don't remove the hypens (-) in the account IDs.
  3. Insert your relevant Audience IDs, comma-separated without spaces in column B. You can find those IDs in the Audience Manager if you click on a Remarketing List or Similar Audience.
  4. Create a new script and copy over the source code.
    1. Sign in to your Google Ads account.
    2. Click the Tools icon and select Scripts under BULK ACTIONS.
    3. Press the + icon to add a new script.
    4. Name it, remove the existing code, and paste the Audience Assistant source code.
    5. When prompted, click AUTHORIZE so the script can access the account on your behalf. This has to be done once for each script.
  5. Update INSERT_SPREADSHEET_URL_HERE and INSERT_UNIQUE_FILENAME_HERE in the source code.
  6. Click PREVIEW to run the script in preview mode: Results will appear in the CHANGES / LOGS panel.

Source code

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

/**
 * @name Audience Assistant
 *
 * @fileoverview The Audience Assistant will read a set of accounts and audience
 * lists from an external spreadsheet and ensures that the lists are opted in
 * for all enabled campaigns. The script will also set the targeting to either
 * 'target & bid: Targeting' or 'bid only: Observation'
 *
 * It will never remove any audience list from campaigns.
 *
 * @author Google Ads Scripts Team [adwords-scripts@googlegroups.com]
 *
 * @version 2.0
 *
 * @changelog
 * - version 2.0
 *   - Updated to use new Google Ads scripts features.
 *   - Processes accounts in a random order rather then ordered by stats,
 *     since fetching accounts by stats is no longer supported.
 * - version 1.2
 *   - Removed display and video campaigns due to quite different setup criteria
 * - version 1.1
 *   - Added display and video campaigns
 *   - users can exclude specific campaigns with a label
 * - version 1.0
 *   - Released initial version.
 */

const SPREADSHEET_URL = 'INSERT_SPREADSHEET_URL_HERE';

// The name of the file that will be created on Drive to store data
// between executions of the script. You must use a different
// filename for each each script running in the account, or data
// from different scripts may overwrite one another.
const UNIQUE_FILENAME = 'INSERT_UNIQUE_FILENAME_HERE';

// The possible statuses for the script as a whole or an individual account.
const STATUSES = {
  NOT_STARTED: 'Not Started',
  STARTED: 'Started',
  FAILED: 'Failed',
  COMPLETE: 'Complete'
};
// The configuration loaded from the spreadsheet <SPREADSHEET_URL>
const CONFIG = readInput();

// Possible campaign types
const CAMPAIGN_TYPE = {
  search: 'SEARCH',
  shopping: 'SHOPPING'
};

/**
 * Your main logic for initializing a cycle for your script.
 *
 * @param {Array.<string>!} customerIds The customerIds that this cycle
 *     will process.
 */
function initializeCycle(customerIds) {
  // This example prints the accounts that will be processed in
  // this cycle.
  console.log('Accounts to be processed this cycle:');
  for (const customerId of customerIds) {
    console.log(customerId);
  }
}

/**
 * Your main logic for initializing a single execution of the script.
 *
 * @param {Array.<string>!} customerIds The customerIds that this
 *     execution will process.
 */
function initializeExecution(customerIds) {
  // This example prints the accounts that will be processed in
  // this execution.
  console.log('Accounts to be processed this execution:');
  for (const customerId of customerIds) {
    console.log(customerId);
  }
}

/**
 * This function is executed for every account selected and selects the type of
 * campaigns that have to be processed.
 *
 * @return {Object!} information for callback
 */
function processAccount() {
  let numCampaigns = 0, numShoppingCampaigns = 0;
  let numShoppingAdGroups = 0, numAdGroups = 0;
  const accountId = AdsApp.currentAccount().getCustomerId();
  if (CONFIG.accountAudienceListMap[accountId] == undefined) {
    return {numCampaigns: 0, numShoppingCampaigns: 0,
           numAdGroups: 0, numShoppingAdGroups: 0};
  }
  const accountContainsShopping =
      (AdsApp.shoppingCampaigns().get().totalNumEntities() > 0) ? true : false;
  const accountContainsSearch =
      (AdsApp.campaigns().get().totalNumEntities() > 0) ? true : false;

  if (accountContainsSearch) {
    [numCampaigns, numAdGroups] =
      processCampaignsAndAdGroups(CAMPAIGN_TYPE.search);
  }
  if (accountContainsShopping) {
    [numShoppingCampaigns, numShoppingAdGroups] =
      processCampaignsAndAdGroups(CAMPAIGN_TYPE.shopping);
  }

  return {numCampaigns, numAdGroups,
          numShoppingCampaigns, numShoppingAdGroups};
}

/**
 * This processes all the campaigns for a combination of audience lists and
 * campaigns. It will focus on a single type of campaign per call. The function
 * uses the report functionality to build an object that contains all targeted
 * campaigns per audience list id and generates a complement of these campaigns
 * and the complete list of enabled campaigns.
 * On this complement all the action is focussed
 *
 * @param {string} campaignType The campaign type currently in focus.
 * @return {Object!} The number of processed campaigns and ad groups
 */
function processCampaignsAndAdGroups(campaignType) {
  const accountId = AdsApp.currentAccount().getCustomerId();
  const audienceListIds = CONFIG.accountAudienceListMap[accountId];
  let touchedCampaigns = 0;
  let touchedAdGroups = 0;
  const targetingString =
      CONFIG.bidOnly ? 'TARGET_ALL_TRUE' : 'TARGET_ALL_FALSE';

  // For each audienceList defined in the configuration we are interested in all
  // campaigns that have not yet been targeted in order to be added to the
  // audience list. Therefore, we are using a list of all enabled campaigns and
  // subtract all campaigns that are already targeted.
  const allEnabledCampaignIds = getAllEnabledCampaigns(campaignType);
  const allEnabledAdGroupIds = getAllEnabledAdGroups(campaignType);

  const audienceToCampaignMap = getAudienceListCampaignMap(
      audienceListIds, allEnabledCampaignIds, accountId);
  const audienceToAdGroupMap = getAudienceListAdGroupMap(
      audienceListIds, allEnabledAdGroupIds, accountId);

  // For every audienceList from the configuration we are iterating over all
  // relevant campaigns to add the audienceList.
  for (const audienceList in audienceToCampaignMap) {
    const campaignAudienceOperations = [];
    const campaignIds = getRelevantCampaignIds(
        allEnabledCampaignIds, audienceToCampaignMap[audienceList]);
    if (campaignIds && campaignIds.length > 0) {
      // In case of a large list of campaigns we need to split the list into
      // smaller parts not exceeding 10,000.
      for (const ids of partition(campaignIds)) {
        const campaignsSelector =
            getCampaignSelectorByCampaignType(campaignType)
            .withIds(ids)
            .withCondition('campaign.status = ENABLED');
        for (const campaign of campaignsSelector) {
          campaignAudienceOperations.push({
            audienceListIdString: audienceList.toString(),
            campaign: campaign,
            audienceListBuilder:
                campaign.targeting().newUserListBuilder().withAudienceId(
                    parseInt(audienceList)).build()
          });
        }
        // Process the list of audienceOperations.
        touchedCampaigns +=
              processCampaignAudienceOperations(
          campaignAudienceOperations, targetingString);
      }
    }
  }

  for (const audienceList in audienceToAdGroupMap) {
      const adGroupAudienceOperations = [];
      const adGroupIds = getRelevantAdGroupIds(
          allEnabledAdGroupIds, audienceToAdGroupMap[audienceList]);
      if (adGroupIds && adGroupIds.length > 0) {
        // In case of a large list of ad groups, we need to split the list into
        // smaller parts not exceeding 10,000.
        for (const ids of partition(adGroupIds)) {
          const adGroupsSelector =
              getAdGroupSelectorByCampaignType(campaignType)
              .withIds(ids)
              .withCondition('ad_group.status = ENABLED')
              .get();
          for (const adGroup of adGroupsSelector) {
            adGroupAudienceOperations.push({
              audienceListIdString: audienceList.toString(),
              adGroup: adGroup,
              audienceListBuilder:
                  adGroup.targeting().newUserListBuilder().withAudienceId(
                      parseInt(audienceList)).build()
            });
          }
          // Process the list of audienceOperations.
          touchedAdGroups += processAdGroupAudienceOperations(
            adGroupAudienceOperations, targetingString);
        }
      }
    }

  return [touchedCampaigns, touchedAdGroups];
}

/**
 * Processes the audience operations for an audience lists
 *
 * @param {!Array} array the entity to be partitioned
 * @param {!int=} partitionSize the size in which the entity is to be sliced
 */
function* partition(array, partitionSize = 10000) {
  for (let i = 0; i < array.length; i += partitionSize) {
    yield array.slice(i, i + partitionSize);
  }
}

/**
 * Processes the campaign audience operations for an audience lists
 *
 * @param {Array.<Array.<string, string, Object!>!>!} campaignAudienceOperations
 * @param {string} targetingString
 * @return {Int!} the number of campaigns that received an additional audience
 */
function processCampaignAudienceOperations(
    campaignAudienceOperations, targetingString) {
  let touchedCampaigns = 0;
  // Object to remember campaigns that have already been set to the correct
  // targeting setting
  const campaignBiddingSet = {};
  const accountId = AdsApp.currentAccount().getCustomerId();

  // Iterate over all audience operations and set campaign targeting if
  // nescessary
  for (const campaignAudienceOperation of campaignAudienceOperations) {
    const campaign = campaignAudienceOperation.campaign;
    const campaignName = campaign.getName();
    if (campaignAudienceOperation.audienceListBuilder.isSuccessful()) {
      if (!campaignBiddingSet[campaignName]) {
        campaign.targeting().setTargetingSetting(
            'USER_INTEREST_AND_LIST', targetingString);
        campaignBiddingSet[campaignName] = true;
      }
      touchedCampaigns++;
    } else {
      console.log(
          `${accountId} - Could not add audience ` +
          `'${campaignAudienceOperation.audienceListIdString}' to campaign ` +
          `${campaignName}`);
    }
  }

  return touchedCampaigns;
}

/**
 * Processes the ad group audience operations for an audience lists
 *
 * @param {Array.<Array.<string, string, Object!>!>!} adGroupAudienceOperations
 * @param {string} targetingString
 * @return {Int!} the number of campaigns that received an additional audience
 */
function processAdGroupAudienceOperations(
    adGroupAudienceOperations, targetingString) {
  let touchedAdGroups = 0;
  // Object to remember campaigns that have already been set to the correct
  // targeting setting
  const adGroupBiddingSet = {};
  const accountId = AdsApp.currentAccount().getCustomerId();

  // Iterate over all audience operations and set campaign targeting if
  // nescessary
  for (const adGroupAudienceOperation of adGroupAudienceOperations) {
    const adGroup = adGroupAudienceOperation.adGroup;
    const adGroupName = adGroup.getName();
    if (adGroupAudienceOperation.audienceListBuilder.isSuccessful()) {
      if (!adGroupBiddingSet[adGroupName]) {
        adGroup.targeting().setTargetingSetting(
            'USER_INTEREST_AND_LIST', targetingString);
        adGroupBiddingSet[adGroupName] = true;
      }
      touchedAdGroups++;
    } else {
      console.log(
          `${accountId} - Could not add audience ` +
          `'${adGroupAudienceOperation.audienceListIdString}' to adGroup ` +
          `${adGroupName}`);
    }
  }

  return touchedAdGroups;
}

/**
 * Get the campaign selector by campaign type
 *
 * @param {string} campaignType
 * @return {Object!} the correct campaign selector
 */
function getCampaignSelectorByCampaignType(campaignType) {
  if (campaignType == CAMPAIGN_TYPE.search) {
    return AdsApp.campaigns();
  } else if (campaignType == CAMPAIGN_TYPE.shopping) {
    return AdsApp.shoppingCampaigns();
  }
}

/**
 * Get the ad group selector by campaign type
 *
 * @param {string} campaignType
 * @return {Object!} the correct ad group selector
 */
function getAdGroupSelectorByCampaignType(campaignType) {
  if (campaignType == CAMPAIGN_TYPE.search) {
    return AdsApp.adGroups();
  } else if (campaignType == CAMPAIGN_TYPE.shopping) {
    return AdsApp.shoppingAdGroups();
  }
}

/**
 * Get a list of campaign ids that are not yet targeted by the audience list
 * currently worked on. In case the audience list is not added to
 * any campaign all enabled campaigns are returned. In any other case the
 * difference between all enabled campaigns and the already targeted campaigns
 * is returned.
 *
 * @param {Array.<Int!>!} allEnabledCampaignIds
 * @param {Array.<Int!>!} targetedCampaignIds
 * @return {Array.<Int!>!} list of campaign ids
 */
function getRelevantCampaignIds(allEnabledCampaignIds, targetedCampaignIds) {
  if (allEnabledCampaignIds === targetedCampaignIds) {
    return allEnabledCampaignIds;
  } else {
    return complement(allEnabledCampaignIds, targetedCampaignIds);
  }
}

/**
 * Get a list of ad group ids that are not yet targeted by the audience list
 * currently worked on. In case the audience list is not added to
 * any ad group all enabled ad groups are returned. In any other case the
 * difference between all enabled ad groups and the already targeted ad groups
 * is returned.
 *
 * @param {Array.<Int!>!} allEnabledAdGroupIds
 * @param {Array.<Int!>!} targetedAdGroupIds
 * @return {Array.<Int!>!} list of ad group ids
 */
function getRelevantAdGroupIds(allEnabledAdGroupIds, targetedAdGroupIds) {
  if (allEnabledAdGroupIds === targetedAdGroupIds) {
    return allEnabledAdGroupIds;
  } else {
    return complement(allEnabledAdGroupIds, targetedAdGroupIds);
  }
}

/**
 * Get a map of targeted campaigns for each campaign audience list provided by
 * the configuration object
 *
 * @param {Array.<int!>!} audienceListIds List of all audience lists defined by
 *     the user configuration
 * @param {Array.<int!>!} allEnabledCampaignIds List of all enabled campaign ids
 * @param {Int!} accountId The account id
 * @return {Object.<Array.<int!>!>!} Map with audience list id as key and list
 *     of campaign ids as value
 */
function getAudienceListCampaignMap(
    audienceListIds, allEnabledCampaignIds, accountId) {
  let audienceToCampaignMap = {};
  const reportQuery =
      `SELECT campaign.id, ` +
      `campaign_criterion.user_list.user_list, ` +
      `campaign_criterion.criterion_id ` +
      `FROM campaign_audience_view ` +
      `WHERE campaign_criterion.user_list.user_list IN ` +
      `("customers/${accountId}/userLists/${audienceListIds.join(',')}") ` +
      `AND campaign.status = "ENABLED"`;
  const audienceReportRows = AdsApp.report(reportQuery).rows();
  for (const row of audienceReportRows) {
    const tmpAudienceList = row['campaign_criterion.user_list.user_list'];
    const tmpAudienceListId =
        tmpAudienceList.replace(`customers/${accountId}/userLists/`, '');
    const campaignId = row['campaign.id'];
    if (!audienceToCampaignMap[tmpAudienceListId]) {
      audienceToCampaignMap[tmpAudienceListId] = [];
    }
    audienceToCampaignMap[tmpAudienceListId].push(parseInt(campaignId));
  }
  for (const audienceListId of audienceListIds) {
    if (audienceToCampaignMap[audienceListId] === undefined) {
      audienceToCampaignMap[audienceListId] = allEnabledCampaignIds;
    }
  }

  return audienceToCampaignMap;
}

/**
 * Get a map of targeted campaigns for each ad group audience list provided by
 * the configuration object
 *
 * @param {Array.<int!>!} audienceListIds List of all audience lists defined by
 *     the user configuration
 * @param {Array.<int!>!} allEnabledAdGroupIds List of all enabled campaign ids
 * @param {Int!} accountId The account id
 * @return {Object.<Array.<int!>!>!} Map with audience list id as key and list
 *     of campaign ids as value
 */
function getAudienceListAdGroupMap(
    audienceListIds, allEnabledAdGroupIds, accountId) {
  let audienceToAdGroupMap = {};
  const reportQuery = `SELECT ad_group.id, ` +
      `ad_group_criterion.user_list.user_list, ` +
      `ad_group_criterion.criterion_id ` +
      `FROM ad_group_audience_view ` +
      `WHERE ad_group_criterion.user_list.user_list IN ` +
      `("customers/${accountId}/userLists/${audienceListIds.join(',')}") ` +
      `AND ad_group.status = "ENABLED"`;
  const audienceReportRowsAdGroup = AdsApp.report(reportQuery).rows();
  for (const row of audienceReportRowsAdGroup) {
    const tmpAudienceListAdGroup =
        row['campaign_criterion.user_list.user_list'];
    const tmpAudienceListAdGroupId =
        tmpAudienceListAdGroup.replace(`customers/${accountId}/userLists/`, '');
    const adGroupId = row['ad_group.id'];
    if (!audienceToAdGroupMap[tmpAudienceListAdGroupId]) {
      audienceToAdGroupMap[tmpAudienceListAdGroupId] = [];
    }
    audienceToAdGroupMap[tmpAudienceListAdGroupId].push(parseInt(adGroupId));
  }
  for (const audienceListId of audienceListIds) {
    if (audienceToAdGroupMap[audienceListId] === undefined) {
      audienceToAdGroupMap[audienceListId] = allEnabledAdGroupIds;
    }
  }
  return audienceToAdGroupMap;
}

/**
 * Get all enabled campaigns for specified campaign type for a given account
 *
 * @param {string} campaignType The campaign type currently in focus.
 * @return {Array.<Int!>!} The list of all enabled campaigns
 */
function getAllEnabledCampaigns(campaignType) {
  const allEnabledCampaignIds = [];
  let reportRows;
  if (CONFIG.excludedCampaignLabel != '') {
    const labelId = getLabelId();
    reportRows = AdsApp.report(
        `SELECT campaign.id, ` +
        `campaign.labels ` +
        `FROM campaign ` +
        `WHERE campaign.labels CONTAINS NONE ("${labelId}") ` +
        `AND campaign.status = "ENABLED" ` +
        `AND campaign.advertising_channel_type = '${campaignType}'`)
        .rows();
  } else {
    reportRows =AdsApp.report(
        `SELECT campaign.id ` +
        `FROM campaign ` +
        `WHERE campaign.status = "ENABLED" ` +
        `AND campaign.advertising_channel_type = ${campaignType}`)
        .rows();
  }
  for (const row of reportRows) {
    const campaignId = row['campaign.id'];
    allEnabledCampaignIds.push(parseInt(campaignId));
  }

  return allEnabledCampaignIds;
}

/**
 * Get all enabled ad groups for specified campaign type for a given account
 *
 * @param {string} campaignType The campaign type currently in focus.
 * @return {Array.<Int!>!} The list of all enabled ad groups
 */
function getAllEnabledAdGroups(campaignType) {
  const allEnabledAdGroupIds = [];
  let reportRows;
  if (CONFIG.excludedCampaignLabel != '') {
    const labelId = getLabelId();
    reportRows = AdsApp.report(
        `SELECT ad_group.id, ` +
        `ad_group.labels ` +
        `FROM ad_group ` +
        `WHERE ad_group.labels CONTAINS NONE ("${labelId}") ` +
        `AND ad_group.status = "ENABLED" ` +
        `AND campaign.advertising_channel_type = '${campaignType}'`)
        .rows();
  } else {
    reportRows =AdsApp.report(
        `SELECT ad_group.id ` +
        `FROM ad_group ` +
        `WHERE ad_group.status = "ENABLED" ` +
        `AND campaign.advertising_channel_type = ${campaignType}`)
        .rows();
  }
  for (const row of reportRows) {
    const adGroupId = row['ad_group.id'];
    allEnabledAdGroupIds.push(parseInt(adGroupId));
  }

  return allEnabledAdGroupIds;
}

/**
 * Helper function to calculate the complement of two arrays.
 * Returns array a without array b
 *
 * @param{Array!} a first array
 * @param{Array!} b second array
 * @return{Array!} complement of two arrays
 */
function complement(a, b) {
  (b) || (b = a, a = this);
  return (Array.isArray(a) && Array.isArray(b)) ? a.filter(function(x) {
    return b.indexOf(x) === -1;
  }) : undefined;
}

/**
 * Your main logic for consolidating or outputting results after
 * a single execution of the script. These single execution results may
 * reflect the processing on only a subset of your accounts.
 *
 * @param {Object.<string, {
 *       status: string,
 *       returnValue: Object!,
 *       error: string
 *     }>!} results The results for the accounts processed in this
 *    execution of the script, keyed by customerId. The status will be
 *    STATUSES.COMPLETE if the account was processed successfully,
 *    STATUSES.FAILED if there was an error, and STATUSES.STARTED if it
 *    timed out. The returnValue field is present when the status is
 *    STATUSES.COMPLETE and corresponds to the object you returned in
 *    processAccount(). The error field is present when the status is
 *    STATUSES.FAILED.
 */
function processIntermediateResults(results) {
  // This example simply logs the number of campaigns and ad groups
  // in each of the accounts successfully processed in this execution.
  console.log('Results of this execution:');
  for (const customerId in results) {
    const result = results[customerId];
    if (result.status == STATUSES.COMPLETE) {
      console.log(
          `${customerId}: ${result.returnValue.numCampaigns}` +
          ` campaigns and ${result.returnValue.numShoppingCampaigns}` +
          ` shopping campaigns processed`);
      console.log(
          `${customerId}: ${result.returnValue.numAdGroups}` +
          ` ad groups and ${result.returnValue.numShoppingAdGroups}` +
          ` shopping ad groups processed`);
    } else if (result.status == STATUSES.STARTED) {
      console.warn(`${customerId}: timed out`);
    } else {
      console.error(`${customerId}: failed due to "${result.error}"`);
    }
  }
}

/**
 * Your main logic for consolidating or outputting results after
 * the script has executed a complete cycle across all of your accounts.
 * This function will only be called once per complete cycle.
 *
 * @param {Object.<string, {
 *       status: string,
 *       returnValue: Object!,
 *       error: string
 *     }>!} results The results for the accounts processed in this
 *    execution of the script, keyed by customerId. The status will be
 *    STATUSES.COMPLETE if the account was processed successfully,
 *    STATUSES.FAILED if there was an error, and STATUSES.STARTED if it
 *    timed out. The returnValue field is present when the status is
 *    STATUSES.COMPLETE and corresponds to the object you returned in
 *    processAccount(). The error field is present when the status is
 *    STATUSES.FAILED.
 */
function processFinalResults(results) {
  // This template simply logs the total number of campaigns and ad
  // groups across all accounts successfully processed in the cycle.
  let numCampaigns = 0;
  let numAdGroups = 0;
  let numShoppingCampaigns = 0;
  let numShoppingAdGroups = 0;

  console.log('Results of this cycle:');
  for (const customerId in results) {
    const result = results[customerId];
    if (result.status == STATUSES.COMPLETE) {
      console.log(`${customerId}: successful`);
      numCampaigns += result.returnValue.numCampaigns;
      numAdGroups += result.returnValue.numAdGroups;
      numShoppingCampaigns += result.returnValue.numShoppingCampaigns;
      numShoppingAdGroups += result.returnValue.numShoppingAdGroups;
    } else if (result.status == STATUSES.STARTED) {
      console.warn(`${customerId}: timed out`);
    } else {
      console.error(`${customerId}: failed due to "${result.error}"`);
    }
  }

  console.log(`Total number of campaigns processed: ${numCampaigns}`);
  console.log(
      `Total number of shopping campaigns processed: ${numShoppingCampaigns}`);
  console.log(`Total number of adGroups processed: ${numAdGroups}`);
  console.log(
      `Total number of shopping adGroups processed: ${numShoppingAdGroups}`);
}

/**
 * reads the configurations from the
 * spreadsheet defined above
 *
 * @return {Object!} account/audience list mapping, targeting type flag and
 * excluded campaign label
 */
function readInput() {
  const accountAudienceListMap = {};
  if ('INSERT_SPREADSHEET_URL_HERE' === SPREADSHEET_URL) {
    throw new Error(
        'Please specify a valid Spreadsheet URL. You can find' +
        ' a link to a template in the associated guide for this script.');
  }
  const spreadsheet = SpreadsheetApp.openByUrl(SPREADSHEET_URL);
  const accountIds =
      spreadsheet.getRangeByName('ACCOUNTS_AND_AUDIENCES').getValues();
  const isBidOnly =
      spreadsheet.getRangeByName('TARGETING_SETTING').getValue() ==
          'bid only: Observation' ? true : false;
  const excludedCampaignLabel = spreadsheet.getRangeByName('EXCLUDED_LABEL')
                                          .getValue();

  for (const accountId of accountIds) {
    if (accountId[0] === accountId[1]) {
      break;
    }
    accountAudienceListMap[accountId[0]] =
        accountId[1]
            .replace(/\r?\n|\r/g, '')
            .split(',');  // remove new line characters
  }

  return {
    'bidOnly': isBidOnly,
    'accountAudienceListMap': accountAudienceListMap,
    'excludedCampaignLabel': excludedCampaignLabel
  };
}

/**
 * Ensure that campaign label exists in the account if specified.
 *
 * @return {id} The id of the specific label
 */
function getLabelId() {
  const labelIterator =
      AdsApp.labels()
          .withCondition(`Name = '${CONFIG.excludedCampaignLabel}'`)
          .get();
  if (labelIterator.hasNext()) {
    const label = labelIterator.next();
    const labelId = label.getId();
    return labelId;
  } else {
    AdsApp.createLabel(
        CONFIG.excludedCampaignLabel,
        'used to exclude specific campaigns from the Audience Assistant',
        '#808080');
    const labelIterator =
        AdsApp.labels()
            .withCondition(`Name = '${CONFIG.excludedCampaignLabel}'`)
            .get();
    if (labelIterator.hasNext()) {
      const label = labelIterator.next();
      const labelId = label.getId();
      return labelId;
    }
  }
}

// Whether or not the script is running in a manager account.
const IS_MANAGER = typeof AdsManagerApp !== 'undefined';

// The maximum number of accounts that can be processed when using
// executeInParallel().
const MAX_PARALLEL = 50;

// The possible modes in which the script can execute.
const Modes = {SINGLE: 'Single', MANAGER_PARALLEL: 'Manager Parallel'};

/**
 * Main method
 */
function main() {
  const mode = getMode();
  stateManager.loadState();

  // The last execution may have attempted the final set of accounts but
  // failed to actually complete the cycle because of a timeout in
  // processIntermediateResults(). In that case, complete the cycle now.
  if (stateManager.getAccountsWithStatus().length > 0) {
    completeCycleIfNecessary();
  }

  // If the cycle is complete and enough time has passed since the start of
  // the last cycle, reset it to begin a new cycle.
  if (stateManager.getStatus() == STATUSES.COMPLETE) {
    if (dayDifference(stateManager.getLastStartTime(), new Date()) > 0) {
      stateManager.resetState();
    } else {
      console.log(
          'Waiting until ' + 0 +
          ' days have elapsed since the start of the last cycle.');
      return;
    }
  }

  // Find accounts that have not yet been processed. If this is the
  // beginning of a new cycle, this will be all accounts.
  const customerIds = stateManager.getAccountsWithStatus(STATUSES.NOT_STARTED);

  // The status will be STATUSES.NOT_STARTED if this is the very first
  // execution or if the cycle was just reset. In either case, it is the
  // beginning of a new cycle.
  if (stateManager.getStatus() == STATUSES.NOT_STARTED) {
    stateManager.setStatus(STATUSES.STARTED);
    stateManager.saveState();

    initializeCycle(customerIds);
  }

  // Don't attempt to process more accounts than specified, and
  // enforce the limit on parallel execution if necessary.
  let accountLimit = 50;

  if (mode == Modes.MANAGER_PARALLEL) {
    accountLimit = Math.min(MAX_PARALLEL, accountLimit);
  }

  const customerIdsToProcess = customerIds.slice(0, accountLimit);

  // Save state so that we can detect when an account timed out by it still
  // being in the STARTED state.
  stateManager.setAccountsWithStatus(customerIdsToProcess, STATUSES.STARTED);
  stateManager.saveState();

  initializeExecution(customerIdsToProcess);
  executeByMode(mode, customerIdsToProcess);
}

/**
 * Runs the script on a list of accounts in a given mode.
 *
 * @param {string} mode The mode the script should run in.
 * @param {Array.<string>!} customerIds The customerIds that this execution
 *     should process. If mode is Modes.SINGLE, customerIds must contain
 *     a single element which is the customerId of the Ads account.
 */
function executeByMode(mode, customerIds) {
  switch (mode) {
    case Modes.SINGLE:
      const results = {};
      results[customerIds[0]] = tryProcessAccount();
      completeExecution(results);
      break;

    case Modes.MANAGER_PARALLEL:
      if (customerIds.length == 0) {
        completeExecution({});
      } else {
        const accountSelector = AdsManagerApp.accounts().withIds(customerIds);
        accountSelector.executeInParallel(
            'parallelFunction', 'parallelCallback');
      }
      break;
  }
}

/**
 * Attempts to process the current Ads account.
 *
 * @return {Object!} The result of the processing if successful, or
 *     an object with status STATUSES.FAILED and the error message
 *     if unsuccessful.
 */
function tryProcessAccount() {
  try {
    return {status: STATUSES.COMPLETE, returnValue: processAccount()};
  } catch (e) {
    return {status: STATUSES.FAILED, error: e.message};
  }
}

/**
 * The function given to executeInParallel() when running in parallel mode.
 * This helper function is necessary so that the return value of
 * processAccount() is transformed into a string as required by
 * executeInParallel().
 *
 * @return {string} JSON string representing the return value of
 *     processAccount().
 */
function parallelFunction() {
  const returnValue = processAccount();

  return JSON.stringify(returnValue);
}

/**
 * The callback given to executeInParallel() when running in parallel mode.
 * Processes the execution results into the format used by all execution
 * modes.
 *
 * @param {Array.<Object!>!} executionResults An array of execution results
 *     from a parallel execution.
 */
function parallelCallback(executionResults) {
  const results = {};
  for (const executionResult of executionResults) {
    let status;
    if (executionResult.getStatus() == 'OK') {
      status = STATUSES.COMPLETE;
    } else if (executionResult.getStatus() == 'TIMEOUT') {
      status = STATUSES.STARTED;
    } else {
      status = STATUSES.FAILED;
    }
    results[executionResult.getCustomerId()] = {
      status: status,
      returnValue: JSON.parse(executionResult.getReturnValue()),
      error: executionResult.getError()
    };
  }
  // After executeInParallel(), variables in global scope are reevaluated,
  // so reload the state.
  stateManager.loadState();

  completeExecution(results);
}

/**
 * Completes a single execution of the script by saving the results and
 * calling the intermediate and final result handlers as necessary.
 *
 * @param {Object.<string, {
 *       status: string,
 *       returnValue: Object!,
 *       error: string
 *     }>!} results The results of the current execution of the script.
 */
function completeExecution(results) {
  for (const customerId in results) {
    const result = results[customerId];
    stateManager.setAccountWithResult(customerId, result);
  }
  stateManager.saveState();

  processIntermediateResults(results);
  completeCycleIfNecessary();
}

/**
 * Completes a full cycle of the script if all accounts have been attempted
 * but the cycle has not been marked as complete yet.
 */
function completeCycleIfNecessary() {
  if (stateManager.getAccountsWithStatus(STATUSES.NOT_STARTED).length == 0 &&
      stateManager.getStatus() != STATUSES.COMPLETE) {
    stateManager.setStatus(STATUSES.COMPLETE);
    stateManager.saveState();
    processFinalResults(stateManager.getResults());
  }
}

/**
 * Determines what mode the script should run in.
 *
 * @return {string} The mode to run in.
 */
function getMode() {
  if (IS_MANAGER) {
    return Modes.MANAGER_PARALLEL;
  } else {
    return Modes.SINGLE;
  }
}

/**
 * Finds all customer IDs that the script could process. For a single account,
 * this is simply the account itself.
 *
 * @return {Array.<string>!} A list of customer IDs.
 */
function getCustomerIdsPopulation() {
  if (IS_MANAGER) {
    const customerIds = [];

    const selector = AdsManagerApp.accounts();

    const accounts = selector.get();
    for (const account of accounts) {
      customerIds.push(account.getCustomerId());
    }

    return customerIds;
  } else {
    return [AdsApp.currentAccount().getCustomerId()];
  }
}

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

/**
 * Loads a JavaScript object previously saved as JSON to a file on Drive.
 *
 * @param {string} filename The name of the file in the account's root Drive
 *     folder where the object was previously saved.
 * @return {Object!} The JavaScript object, or null if the file was not found.
 */
function loadObject(filename) {
  if ('INSERT_UNIQUE_FILENAME_HERE' === filename) {
    throw new Error(
        'Please specify a unique filename for the script to create. You must ' +
        'use a different filename for each script running in the account.');
  }
  const files = DriveApp.getRootFolder().getFilesByName(filename);

  if (!files.hasNext()) {
    return null;
  } else {
    const file = files.next();

    if (files.hasNext()) {
      throwDuplicateFileException(filename);
    }

    return JSON.parse(file.getBlob().getDataAsString());
  }
}

/**
 * Saves a JavaScript object as JSON to a file on Drive. An existing file with
 * the same name is overwritten.
 *
 * @param {string} filename The name of the file in the account's root Drive
 *     folder where the object should be saved.
 * @param {Object!} obj The object to save.
 */
function saveObject(filename, obj) {
  const files = DriveApp.getRootFolder().getFilesByName(filename);

  if (!files.hasNext()) {
    DriveApp.createFile(filename, JSON.stringify(obj));
  } else {
    const file = files.next();

    if (files.hasNext()) {
      throwDuplicateFileException(filename);
    }

    file.setContent(JSON.stringify(obj));
  }
}

/**
 * Throws an exception if there are multiple files with the same name.
 *
 * @param {string} filename The filename that caused the error.
 */
function throwDuplicateFileException(filename) {
  throw `Multiple files named ${filename} detected. Please ensure ` +
      `there is only one file named ${filename} and try again.`;
}

const stateManager = (function() {
  /**
   * @type {{
   *   cycle: {
   *     status: string,
   *     lastUpdate: string,
   *     startTime: string
   *   },
   *   accounts: Object.<string, {
   *     status: string,
   *     lastUpdate: string,
   *     returnValue: Object!
   *   }>!
   * }}
   */
  let state;

  /**
   * Loads the saved state of the script. If there is no previously
   * saved state, sets the state to an initial default.
   */
  const loadState = function() {
    state = loadObject(UNIQUE_FILENAME);
    if (!state) {
      resetState();
    }
  };

  /**
   * Saves the state of the script to Drive.
   */
  const saveState = function() {
    saveObject(UNIQUE_FILENAME, state);
  };

  /**
   * Resets the state to an initial default.
   */
  const resetState = function() {
    state = {};
    const date = Date();

    state.cycle = {
      status: STATUSES.NOT_STARTED,
      lastUpdate: date,
      startTime: date
    };

    state.accounts = {};
    const customerIds = getCustomerIdsPopulation();

    for (const customerId of customerIds) {
      state.accounts[customerId] = {
        status: STATUSES.NOT_STARTED,
        lastUpdate: date
      };
    }
  };

  /**
   * Gets the status of the current cycle.
   *
   * @return {string} The status of the current cycle.
   */
  const getStatus = function() {
    return state.cycle.status;
  };

  /**
   * Sets the status of the current cycle.
   *
   * @param {string} status The status of the current cycle.
   */
  const setStatus = function(status) {
    const date = Date();

    if (status == STATUSES.IN_PROGRESS &&
        state.cycle.status == STATUSES.NOT_STARTED) {
      state.cycle.startTime = date;
    }

    state.cycle.status = status;
    state.cycle.lastUpdate = date;
  };

  /**
   * Gets the start time of the current cycle.
   *
   * @return {Object!} Date object for the start of the last cycle.
   */
  const getLastStartTime = function() {
    return new Date(state.cycle.startTime);
  };

  /**
   * Gets accounts in the current cycle with a particular status.
   *
   * @param {string} status The status of the accounts to get.
   *     If null, all accounts are retrieved.
   * @return {Array.<string>!} A list of matching customerIds.
   */
  const getAccountsWithStatus = function(status) {
    const customerIds = [];

    for (const customerId in state.accounts) {
      if (!status || state.accounts[customerId].status == status) {
        customerIds.push(customerId);
      }
    }

    return customerIds;
  };

  /**
   * Sets accounts in the current cycle with a particular status.
   *
   * @param {Array.<string>!} customerIds A list of customerIds.
   * @param {string} status A status to apply to those customerIds.
   */
  const setAccountsWithStatus = function(customerIds, status) {
    const date = Date();

    for (const customerId of customerIds) {

      if (state.accounts[customerId]) {
        state.accounts[customerId].status = status;
        state.accounts[customerId].lastUpdate = date;
      }
    }
  };

  /**
   * Registers the processing of a particular account with a result.
   *
   * @param {string} customerId The account that was processed.
   * @param {Object.<string, {
   *       status: string,
   *       returnValue: Object!,
   *       error: string
   *     }>!} result The object to save for that account.
   */
  const setAccountWithResult = function(customerId, result) {
    if (state.accounts[customerId]) {
      state.accounts[customerId].status = result.status;
      state.accounts[customerId].returnValue = result.returnValue;
      state.accounts[customerId].error = result.error;
      state.accounts[customerId].lastUpdate = Date();
    }
  };

  /**
   * Gets the current results of the cycle for all accounts.
   *
   * @return {Object.<string, {
   *       status: string,
   *       lastUpdate: string,
   *       returnValue: Object!,
   *       error: string
   *     }>!} The results processed by the script during the cycle,
   *    keyed by account.
   */
  const getResults = function() {
    return state.accounts;
  };

  return {
    loadState: loadState,
    saveState: saveState,
    resetState: resetState,
    getStatus: getStatus,
    setStatus: setStatus,
    getLastStartTime: getLastStartTime,
    getAccountsWithStatus: getAccountsWithStatus,
    setAccountsWithStatus: setAccountsWithStatus,
    setAccountWithResult: setAccountWithResult,
    getResults: getResults
  };
})();