Tool für die Umstellung auf Zielgruppen auf Kampagnenebene

Werkzeugsymbol

In Google Ads werden jetzt Zielgruppen für Such- und Shopping-Kampagnen auf Kampagnenebene unterstützt. Bisher mussten Zielgruppen auf alle Anzeigengruppen der Kampagne angewendet werden. Mit dem Tool zur Umstellung von Zielgruppen auf Kampagnenebene können Sie die Kontoeinrichtung vereinfachen, indem Sie Ihre Zielgruppen auf Anzeigengruppenebene scannen und geeignete Zielgruppen auf Kampagnenebene hochstufen.

Funktionsweise

Das Skript liest zunächst Konfigurationsinformationen aus einer Google-Tabelle:

Anschließend werden die Kampagnen im Konto verarbeitet. Für jede verarbeitete Kampagne listet das Skript zuerst jede Anzeigengruppe und ihre Zielgruppen auf. Anschließend werden die infrage kommenden Zielgruppen auf Kampagnenebene hochgestuft und die Zielgruppen der Anzeigengruppe werden entfernt.

Standardmäßig werden alle ENABLED- und PAUSED-Kampagnen in Ihrem Konto verarbeitet. Sie können dieses Verhalten ändern, indem Sie einer Teilmenge Ihrer Kampagnen ein Label zuweisen und dieses in der Tabelle angeben.

Das Skript exportiert die neuen Zielgruppenziele der Kampagne auf den Tab Ausgabe der Tabelle. Sie erhalten auch eine E-Mail mit einer Zusammenfassung der vorgenommenen Änderungen.

So werden Zielgruppen für Werbung identifiziert

Das Skript kann in zwei Modi ausgeführt werden.

Im Basismodus wird die Werbung für eine Zielgruppe in folgenden Fällen berücksichtigt:

  • Es wird auf alle Anzeigengruppen in einer Kampagne ausgerichtet.
  • Die Gebotsanpassung für die Zielgruppe ist für alle Anzeigengruppen innerhalb dieser Kampagne gleich.
  • Die Ausrichtungseinstellungen für Anzeigengruppen ("Nur Gebot" oder "Ausrichtung und Gebot") sind für alle Anzeigengruppen innerhalb dieser Kampagne identisch.
  • Die Zielgruppe wird nicht auf Kampagnenebene ausgeschlossen.

Im erweiterten Modus wird für eine Zielgruppe Werbung berücksichtigt, wenn Folgendes zutrifft:

  • Es ist auf mindestens eine Anzeigengruppe in einer Kampagne ausgerichtet.
  • Die Zielgruppe wird nicht auf Kampagnenebene ausgeschlossen.
  • Anzeigengruppen ohne Zielgruppen werden nur dann beworben, wenn als Ausrichtungseinstellungen alle „Nur Gebot“ ausgewählt ist. So soll verhindert werden, dass die Anzahl der Zugriffe bei Anzeigengruppen ohne Zielgruppen vor der Umstellung zurückgeht.

Dieses Verhalten wird durch die Einstellung Zielgruppen auf Anzeigengruppenebene umstellen? in der Konfigurationstabelle gesteuert.

  • „Nein“ ist der Basismodus
  • „Ja“ ist der erweiterte Modus

So wird die Gebotsanpassung für die beworbene Zielgruppe berechnet

Im Basismodus wird die Gebotsanpassung auf Anzeigengruppenebene auch auf Kampagnenebene verwendet.

Im erweiterten Modus können Sie den Algorithmus zur Berechnung der Gebotsanpassung für die beworbene Zielgruppe auswählen. Folgende Optionen sind verfügbar:

Algorithmus Bedeutung
AVERAGE Der Durchschnitt der Gebotsanpassungen für alle Anzeigengruppen, auf die diese Zielgruppe ausgerichtet ist, wird verwendet.
WEIGHTED_SPEND_AVERAGE (Standardeinstellung) Der gewichtete Durchschnitt nach Ausgaben für Gebotsanpassungen für alle Anzeigengruppen, auf die diese Zielgruppe ausgerichtet ist, wird verwendet. Es werden die Statistiken der letzten 30 Tage verwendet. Falls für keine der Anzeigengruppen Statistiken vorhanden sind, wird die Methode AVERAGE verwendet.
WEIGHTED_IMPRESSION_AVERAGE Der gewichtete Durchschnitt nach Impressionen der Gebotsanpassungen für alle Anzeigengruppen, auf die diese Zielgruppe ausgerichtet ist, wird verwendet. Es werden die Statistiken der letzten 30 Tage verwendet. Falls für keine der Anzeigengruppen Statistiken vorhanden sind, wird die Methode AVERAGE verwendet.

Wird geplant

Sie können dieses Skript einmal ausführen, um die erforderlichen Änderungen vorzunehmen. Eine Planung ist also nicht erforderlich.

Einrichtung

  • Erstellen Sie ein Skript mit dem unten stehenden Quellcode. Verwenden Sie eine Kopie dieser Tabellenvorlage.
  • Geben Sie die Skriptkonfiguration auf dem Tab Konfiguration der Tabelle an.
  • Passen Sie die Variable „SPREADSHEET_URL“ im Script an.

Quellcode

// 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 Campaign Level Audiences Transition Tool
 *
 * @overview Campaign Level Audiences Transition Tool analyzes audiences at
 *     AdGroup level, identifies potential duplicates, and transitions them to
 *     campaign level audiences. See
 *     https://developers.google.com/google-ads/scripts/docs/solutions/campaign-audience-transition-tool
 *     for more details.
 *
 * @author Google Ads Scripts Team [adwords-scripts@googlegroups.com]
 *
 * @version 2.0
 *
 * @changelog
 * - version 2.0
 *   - Updated to use new Google Ads scripts features.
 * - version 1.0
 *   - Released initial version.
 * - version 1.1
 *   - Performance improvements.
 *   - Proper handling of campaign-level excluded audiences.
 * - version 1.2
 *   - Better handling of potential timeouts for large campaigns.
 *   - Only process campaigns with ad group-level audiences.
 * - version 1.3
 *   - Ignore campaigns that use audiences other than remarketing lists.
 */

/**
 * Specifies the URL of the spreadsheet from which configuration is read and
 * results are exported. This should be a copy of https://goo.gl/WhrhS7.
 */
const SPREADSHEET_URL = 'INSERT_SPREADSHEET_URL';

/* Constants for accessing spreadsheet settings. */

/**
 * Name of the spreadsheet range corresponds to the PromoteUniqueAudiences
 * setting. This setting determines whether any audience found at ad group level
 * should be promoted or not.
 */
const SHOULD_PROMOTE_UNIQUE_AUDIENCES_RANGE_NAME =
    'PromoteUniqueAudiences';

/**
 * Name of the spreadsheet range that corresponds to the
 * UniqueAudienceBidModiferTechnology setting. This setting specifies how to
 * calculate the campaign level bid modifier when unique audience targeting ad
 * groups are promoted.
 */
const UNIQUE_AUDIENCE_BID_MODIFIER_TECH_RANGE_NAME =
    'UniqueAudienceBidModiferTechnology';

/**
 * Name of the spreadsheet range that corresponds to the EmailAddress setting.
 * This setting provides the list of users that the script will email upon
 * completion.
 */
const EMAIL_ADDRESS_RANGE_NAME = 'EmailAddress';

/**
 * Name of the spreadsheet range that corresponds to the CampaignLabel setting.
 * This setting determines the list of campaigns in an account that the script
 * will process.
 */
const CAMPAIGN_LABEL_RANGE_NAME = 'CampaignLabel';

const CUSTOMER_ID_RANGE_NAME = 'CustomerId';
const LAST_RUN_RANGE_NAME = 'LastRun';

const BidModifierTech = {
  Average: 'AVERAGE',
  WeightedSpendAverage: 'WEIGHTED_SPEND_AVERAGE',
  WeightedImpressionAverage: 'WEIGHTED_IMPRESSION_AVERAGE',
};

const SheetNames = {
  Output: 'Output',
  Configuration: 'Configuration'
};

/**
 * Configuration to be used for running reports.
 */
const REPORTING_OPTIONS = {
  // Comment out the following line to default to the latest reporting version.
  apiVersion: 'v11'
};

/**
 * The main method.
 */
function main() {
  const spreadsheetOutput = [];

  const spreadsheet = validateSpreadsheet();
  const outputSheet = loadOutputsheet(spreadsheet);
  const config = loadConfiguration(spreadsheet);
  ensureCampaignLabelExists(config);

  const campaignMap = getCampaigns(config);

  let processedCount = 0;
  for (const campaignId in campaignMap) {
    try {
      processCampaign(campaignId, campaignMap[campaignId], config,
          spreadsheetOutput);
      processedCount++;
    } catch (err) {
      console.error(err);
    }

    // Stop processing if we only have 5 minutes left.
    if (AdsApp.getExecutionInfo().getRemainingTime() < 300) {
      console.warn('Less than 5 mins. of execution time remaining. ' +
          'Stopping now.');
      break;
    }
  }
  writeToSpreadsheet(spreadsheetOutput, outputSheet);
  sendEmailReport(config, processedCount);
}

function prepareAudienceLookupMap(adGroupMap, campaignDetail) {
  const audienceListLookupMap = {};
  const excludedCampaignAudienceListLookupMap = {};

  for (const adGroupId in adGroupMap) {
    const adGroupDetail = adGroupMap[adGroupId];
    for (const audienceId in adGroupDetail['Audiences']) {
      audienceListLookupMap[audienceId] = 1;
    }
  }

  for (const excludedAudienceId in campaignDetail['ExcludedAudiences']) {
    excludedCampaignAudienceListLookupMap[excludedAudienceId] = 1;
  }

  removeExcludedCriteriaFromPromotionlist(audienceListLookupMap,
      excludedCampaignAudienceListLookupMap);
  return audienceListLookupMap;
}

/**
 * Gets a campaign by its ID and channel type.
 *
 * @param {number} campaignId the campaign ID to retrieve.
 * @param {string} channelType the advertising channel type for the campaign.
 *    Search or Shopping.
 *
 * @return {Campaign|ShoppingCampaign} a campaign object.
 */
function getCampaign(campaignId, channelType) {
  let selector = null;

  switch (channelType) {
    case 'SEARCH':
      selector = AdsApp.campaigns();
      break;
    case 'SHOPPING':
      selector = AdsApp.shoppingCampaigns();
      break;
    default:
      return null;
  }

  return selector.withIds([campaignId]).get().next();
}

/**
 * Gets the new bid modifier for an audience.
 *
 * @param {Object<string, Object>} adGroupMap the map with key as ad group ID,
 *      and value as ad group details.
 * @param {number} audienceId the audience ID.
 * @param {Object} config the configuration object loaded from spreadsheet.
 *
 * @return {number} the new bid modifier.
 */
function getNewBidModifierForAudience(adGroupMap, audienceId, config) {
  let bidModifier = 0;

  if (config[SHOULD_PROMOTE_UNIQUE_AUDIENCES_RANGE_NAME]) {
    switch (config[UNIQUE_AUDIENCE_BID_MODIFIER_TECH_RANGE_NAME]) {
      case BidModifierTech.Average:
        bidModifier = getAverageBidModifier(adGroupMap, audienceId);
        break;
      case BidModifierTech.WeightedSpendAverage:
        bidModifier = getWeightedStatBidModifier(adGroupMap,
            audienceId, 'metrics.cost_micros');
        break;
      case BidModifierTech.WeightedImpressionAverage:
        bidModifier = getWeightedStatBidModifier(adGroupMap,
            audienceId, 'metrics.impressions');
        break;
      default:
        throw new Error(`Unknown value for ` +
            `${UNIQUE_AUDIENCE_BID_MODIFIER_TECH_RANGE_NAME} : ` +
            `"${config[UNIQUE_AUDIENCE_BID_MODIFIER_TECH_RANGE_NAME]}".`);
    }
  } else {
    bidModifier = getMatchingBidModifier(adGroupMap, audienceId);
  }
  return bidModifier;
}

/**
 * Gets a selector for audiences.
 *
 * @param {string} channelType the advertising channel type for the campaign.
 *    Search or Shopping.
 * @param {number} campaignId the campaign ID to retrieve audiences from.
 *
 * @return {Selector} a selector for getting the audiences.
 */
function getAudienceSelector(channelType, campaignId) {
  let selector = null;

  switch (channelType) {
    case 'SEARCH':
      selector = AdsApp.adGroupTargeting();
      break;
    case 'SHOPPING':
      selector = AdsApp.shoppingAdGroupTargeting();
      break;
    default:
      return null;
  }
  selector = selector.audiences()
    .withCondition(`ad_group_criterion.status IN ('ENABLED', 'PAUSED')`)
    .withCondition(`campaign.id = ${campaignId}`)
    .withCondition(`ad_group.status IN ('ENABLED','PAUSED')`)
    .withLimit(500000);
  return selector;
}

/**
 * Removes the promoted audiences from the ad group.
 *
 * @param {number} campaignId the campaign ID to remove promoted audiences
 *    from.
 * @param {string} channelType the advertising channel type for the campaign.
 *    Search or Shopping.
 */
function removeAudiencesFromAdGroup(campaignId, channelType) {
  for (const audience of getAudienceSelector(channelType, campaignId)) {
    audience.remove();
  }

  // This is a temporary workaround to clear any batch caching so
  // ad group level removes are not combined with campaign level additions.
  getAudienceSelector(channelType, campaignId).get();
}

/**
 * Add promoted audiences to a campaign.
 *
 * @param {Object<string, number>} promotedAudienceMap a map with key as the
 *      audience ID, and value as the proposed bid modifier for that audience.
 * @param {Campaign} campaign the campaign to which new audiences are added.
 *
 * @return {!Array.<Array.<string>>} the output details of promoted audiences,
 *      to be appended to the spreadsheet.
 */
function addPromotedAudiencesToCampaign(promotedAudienceMap, campaign) {
  const spreadsheetOutput = [];

  for (let audienceId in promotedAudienceMap) {
    // Add campaign level criteria.
    const bidModifier = promotedAudienceMap[audienceId];

    campaign.targeting()
        .newUserListBuilder()
        .withAudienceId(audienceId)
        .withBidModifier(bidModifier)
        .build();

    spreadsheetOutput.push([campaign.getName(), audienceId, bidModifier]);
  }
  return spreadsheetOutput;
}

function appendSpreadsheetOutput(promotedAudiencesDetails,
                  spreadsheetOutput, targetSetting) {
  for (const audienceDetail of promotedAudiencesDetails) {
    audienceDetail.push(targetSetting);
    spreadsheetOutput.push(audienceDetail);
  }
}

/**
 * Gets the audience list for promotion.
 *
 * @param {Object<string, Object>} adGroupMap the map with key as ad group ID,
 *      and value as ad group details.
 * @param {Object} config the configuration object loaded from spreadsheet.
 * @param {Object} campaignDetail details of the campaign that is being
 *      processed.
 *
 * @return {!Object<string, number>} a map with key as the audience ID to be
 *      promoted, and value as its proposed bid modifier.
 */
function getAudienceListForPromotion(adGroupMap, config, campaignDetail) {
  const audienceListLookupMap = prepareAudienceLookupMap(
      adGroupMap, campaignDetail);

  let promotingAudienceList = [];
  if (config[SHOULD_PROMOTE_UNIQUE_AUDIENCES_RANGE_NAME]) {
    // We want to promote all the remaining audiences in
    // audienceListLookupMap (including audiences that do not exist
    // in all ad groups).
    promotingAudienceList = Object.keys(audienceListLookupMap);
  } else {
    // Skip all audiences that are missing in one or more ad groups.
    promotingAudienceList = getAudienceListForConservativePromotion(
      audienceListLookupMap, adGroupMap);
  }

  const promotedAudienceMap = {};

  if (promotingAudienceList.length !=
      Object.keys(audienceListLookupMap).length) {
    console.warn('Not all ad group level audiences can be promoted. ' +
        'Skipping this campaign.');
  } else {
    for (const audienceId of promotingAudienceList) {
      const bidModifier = getNewBidModifierForAudience(adGroupMap,
                                                     audienceId, config);

      // We could not find a valid bid modifier, so we won't promote this
      // audience.
      if (!bidModifier) {
        continue;
      }

      promotedAudienceMap[audienceId] = bidModifier;
    }
  }
  return promotedAudienceMap;
}

/**
 * Check and pause a campaign.
 *
 * @param {Campaign} campaign the campaign to be paused.
 *
 * @return {boolean} true if the campaign was paused, false otherwise.
 */
function checkAndPauseCampaign(campaign) {
  if (campaign.isEnabled()) {
    campaign.pause();
    return true;
  }
  return false;
}

/**
 * Updates the campaign settings after promotion.
 *
 * @param {Object<string, boolean>} promotedAudienceMap map with key as an
 *      audience ID to promote, and value as a boolean.
 * @param {Object} config the configuration object loaded from the spreadsheet.
 * @param {boolean} enableCampaign true if the campaign should be enabled,
 *      false otherwise.
 * @param {string} targetSetting the campaign's new target setting for
 *      user lists.
 * @param {Campaign} campaign the campaign to be updated.
 */
function updateCampaignSettingsAfterPromotion(promotedAudienceMap, config,
    enableCampaign, targetSetting, campaign) {
  if (Object.keys(promotedAudienceMap).length > 0) {
    // Set the campaign's target setting.
    campaign.targeting().setTargetingSetting('USER_INTEREST_AND_LIST',
                                             targetSetting);
  }

  // The campaign is ready to serve. Enable it if we had paused it earlier.
  if (enableCampaign) {
    campaign.enable();
  }
}

/**
 * Processes a campaign.
 *
 * @param {number} campaignId the campaign ID.
 * @param {Object} campaignDetail the campaign details object.
 * @param {Object} config the configuration object.
 * @param {Array.<Array.<string>>} spreadsheetOutput the contents to be written
 *     to the output sheet.
 */
function processCampaign(campaignId, campaignDetail, config,
    spreadsheetOutput) {
  console.log(`Processing campaign "${campaignDetail['Name']}".`);

  // Skip if there are no ad group-level audiences.
  if (campaignDetail['AdGroupAudiences'] === 0) {
    console.log(`Campaign "${campaignDetail['Name']}" has no ad group-level ` +
               `audiences.`);
    return;
  }

  // Skip if there are too many audiences to process due to limitations.
  if (campaignDetail['AdGroupAudiences'] > 200000) {
    console.log(`Campaign "${campaignDetail['Name']}" is too large to process.`);
    return;
  }

  getAdGroups(campaignDetail, campaignId);

  // Skip if there other audiences besides remarketing lists.
  if (campaignDetail['UsesOtherAudiences'] === true) {
    console.log(`Campaign "${campaignDetail['Name']}" has audiences besides ` +
               `remarketing and is being skipped.`);
    return;
  }

  populateCampaignExcludedAudienceDetails(campaignDetail, campaignId);

  const channelType = campaignDetail['ChannelType'];
  const adGroupMap = campaignDetail['AdGroups'];

  // 1. Get the targeting setting for the ad groups.
  const targetSetting = getTargetSettingForAdGroups(adGroupMap);

  if (!targetSetting) {
    console.warn(`Target setting of all ad groups don't match. Skipping ` +
               `this campaign.`);
    return;
  }

  const promotedAudienceMap = getAudienceListForPromotion(adGroupMap, config,
      campaignDetail);

  // Skip if there isn't enough time left based on campaign size. Using 7,500
  // operations per minute as the approximation.
  const estimatedTimeToProcessCampaign = campaignDetail['AdGroupAudiences']
      / (7500 / 60);
  if (estimatedTimeToProcessCampaign >=
      AdsApp.getExecutionInfo().getRemainingTime()) {
    console.warn(`Not enough time remaining to process campaign ` +
                 `"${campaignDetail['Name']}".`);
    return;
  }

  if (Object.keys(promotedAudienceMap).length === 0) {
    // Pause the campaign. This is to prevent the campaign serving to the wrong
    // audience or serving without bid adjustments during the changes.
    const campaign = getCampaign(campaignId, channelType);
    const enableCampaign = checkAndPauseCampaign(campaign);

    removeAudiencesFromAdGroup(campaignId, channelType);
    const promotedAudiencesDetails =
        addPromotedAudiencesToCampaign(promotedAudienceMap, campaign);
    appendSpreadsheetOutput(promotedAudiencesDetails,
                            spreadsheetOutput, targetSetting);

    updateCampaignSettingsAfterPromotion(promotedAudienceMap, config,
        enableCampaign, targetSetting, campaign);
  }
}

/**
 * Sends an email report.
 *
 * @param {Object} config the configuration object.
 * @param {number} processedCount the number of campaigns processed.
 */
function sendEmailReport(config, processedCount) {
  if (config[EMAIL_ADDRESS_RANGE_NAME]) {
    const customerId = AdsApp.currentAccount().getCustomerId();
    const message = [];
    message.push(`<p>Hi,</p>`,
                 `<p>Campaign level Audiences Transition tool processed <b>` +
                 `${processedCount}` +
                 `</b> campaigns for customer ID: <b>${customerId}` +
                 `</b>. See <a href=" ${SPREADSHEET_URL} ` +
                 `">the output spreadsheet</a> for detailed report.</p>`,
                 `<p>Cheers,<br />Google Ads Scripts Team.</p>`
                 );

    MailApp.sendEmail({
      to: config[EMAIL_ADDRESS_RANGE_NAME],
      subject: `Campaign level Audiences Transition tool (${customerId})`,
      htmlBody: message.join('\n'),
    });
  }
}

/**
 * Writes output and metadata to spreadsheet.
 *
 * @param {Array.<Array.<string>>} spreadsheetOutput A rectangular array of data
 *     to be written to the output sheet.
 * @param {Sheet} outputSheet the output sheet.
 */
function writeToSpreadsheet(spreadsheetOutput, outputSheet) {
  if (spreadsheetOutput.length > 0) {
    const lastRow = outputSheet.getLastRow();
    outputSheet.insertRowsAfter(outputSheet.getMaxRows(),
        spreadsheetOutput.length);
    outputSheet.getRange(lastRow + 1, 1, spreadsheetOutput.length, 4)
        .setValues(spreadsheetOutput);
  }
  const spreadsheet = outputSheet.getParent();
  spreadsheet.getRangeByName(CUSTOMER_ID_RANGE_NAME).setValue(
      AdsApp.currentAccount().getCustomerId());
  spreadsheet.getRangeByName(LAST_RUN_RANGE_NAME).setValue(new Date());
}

/**
 * Ensure that the spreadsheet is valid.
 *
 * @return {Sheet} the output sheet within the spreadsheet.
 *
 * @throws {Error} if spreadsheet is missing, or the Output sheet is missing.
 */
function validateSpreadsheet() {
  // Get spreadsheet by ID.
  let spreadsheet = null;
  try {
    spreadsheet = SpreadsheetApp.openByUrl(SPREADSHEET_URL);
  } catch (err) {
    console.error(err);
  }

  if (!spreadsheet) {
    throw new Error('Spreadsheet is missing or unavailable. Please ensure ' +
        'the URL is correct and that you have permission to edit.');
  }
  return spreadsheet;
}

/**
 * Loads the output sheet from the spreadsheet.
 *
 * @param {Spreadsheet} spreadsheet the spreadsheet object.
 *
 * @return {Sheet} the output sheet object.
 *
 * @throws {Error} if the sheet is missing.
 */
function loadOutputsheet(spreadsheet) {
  const outputSheet = spreadsheet.getSheetByName(SheetNames.Output);

  if (!outputSheet) {
    throw new Error(`A sheet named "${SheetNames.Output}" is missing ` +
        `in the spreadsheet. Make sure you create the spreadsheet as a ` +
        `copy of the template spreadsheet.`);
  }
  return outputSheet;
}

/**
 * Reads a setting from the spreadsheet.
 *
 * @param {Spreadsheet} spreadsheet the spreadsheet object.
 * @param {string} configName the setting name.
 *
 * @return {string} the setting value.
 */
function readSetting(spreadsheet, configName) {
  return spreadsheet.getRangeByName(configName).getValue();
}

/**
 * Loads configuration from the spreadsheet.
 *
 * @param {Spreadsheet} spreadsheet the spreadsheet object.
 *
 * @return {!Object} the configuration object from the spreadsheet.
 *
 * @throws {Error} if the configuration cannot be read.
 */
function loadConfiguration(spreadsheet) {
  let config = null;
  try {
    config = {};
    config[UNIQUE_AUDIENCE_BID_MODIFIER_TECH_RANGE_NAME] =
        readSetting(spreadsheet, UNIQUE_AUDIENCE_BID_MODIFIER_TECH_RANGE_NAME);
    config[SHOULD_PROMOTE_UNIQUE_AUDIENCES_RANGE_NAME] =
        readSetting(spreadsheet,
            SHOULD_PROMOTE_UNIQUE_AUDIENCES_RANGE_NAME) === 'Yes';
    config[CAMPAIGN_LABEL_RANGE_NAME] =
        readSetting(spreadsheet, CAMPAIGN_LABEL_RANGE_NAME);
    config[EMAIL_ADDRESS_RANGE_NAME] =
        readSetting(spreadsheet, EMAIL_ADDRESS_RANGE_NAME);
  } catch (err) {
    console.error(err);
  }

  if (!config) {
    throw new Error('Failed to read configuration from spreadsheet. Make ' +
        'sure you create the spreadsheet as a copy of the template ' +
        'spreadsheet.');
  }

  return config;
}

/**
 * Ensure that campaign label exists in the account if specified.
 *
 * @param {Object} config the configuration object from the spreadsheet.
 *
 * @throws {Error} if campaign label is specified, but missing.
 */
function ensureCampaignLabelExists(config) {
  if (config[CAMPAIGN_LABEL_RANGE_NAME]) {
    const campaignLabelCount = AdsApp.labels()
    .withCondition(`label.name = "${config[CAMPAIGN_LABEL_RANGE_NAME]}"`)
    .get().totalNumEntities();

    if (campaignLabelCount == 0) {
      throw `Label named "${config[CAMPAIGN_LABEL_RANGE_NAME]}"` +
          ` is missing. Please check the campaign label setting in the ` +
          `spreadsheet.`;
    }
  }
}

/**
 * Gets the matching bid modifier for a given audience ID when it targets a set
 * of ad groups.
 *
 * @param {Object<number, Object>} adGroupLookupMap A map that stores the
 *     details of all the ad groups. The key is the ad group ID, and value is an
 *     object that stores the ad group details.
 * @param {number} audienceId The ID of the audience.
 *
 * @return {number} the Bid modifier for the audience.
 * @throws {Error} if the audience targeting is missing in the ad group, or
 *     if the adGroupLookupMap is empty.
 */
function getMatchingBidModifier(adGroupLookupMap, audienceId) {
  let bidModifier = 0;
  for (const adGroupId in adGroupLookupMap) {
    if (audienceId in adGroupLookupMap[adGroupId]['Audiences']) {
      const searchAudience =
          adGroupLookupMap[adGroupId]['Audiences'][audienceId];

      if (bidModifier == 0) {
        bidModifier = searchAudience['BidModifier'];
      } else if (searchAudience['BidModifier'] !== bidModifier) {
        return 0;
      }
    } else {
      throw new Error(Utilities.formatString(`AdGroup ID: ${adGroupId} ` +
          `doesn't target Audience ID: ${audienceId},`+
          `so no matching bid modifier was found.`));
    }
  }
  return bidModifier;
}

/**
 * Gets the bid modifier for a given audience ID using the weighted stats of a
 * given set of ad groups it targets.
 *
 * @param {Object<number, Object>} adGroupLookupMap A map that stores the
 *     details of all the ad groups. The key is the ad group ID, and value is an
 *     object that stores the ad group details.
 * @param {number} audienceId The ID of the audience.
 * @param {string} statName The name of the stat to use for calculating
 *     weighted averages.
 *
 * @return {number} the Bid modifier for the audience.
 * @throws {Error} if the calculated bid modifier is zero, or the totals of the
 *      stats is zero.
 */
function getWeightedStatBidModifier(adGroupLookupMap, audienceId,
    statName) {

  let bidModifier = 0;
  let totalStats = 0;

  for (const adGroupId in adGroupLookupMap) {
    const adGroupDetails = adGroupLookupMap[adGroupId];
    // Since we pick unique audience ID, this audience may not be targeting
    // ad group ID.
    if (audienceId in adGroupLookupMap[adGroupId]['Audiences']) {
      const searchAudience =
          adGroupLookupMap[adGroupId]['Audiences'][audienceId];
      const stat = parseFloat(adGroupDetails[statName]);
      bidModifier += searchAudience['BidModifier'] * stat;
      totalStats += stat;
    }
  }

  if (bidModifier == 0 || totalStats == 0) {
    console.log(Utilities.formatString(`No stats found for audience ID: ` +
        `${audienceId}. ` +
        `Using average bid modifier.`));
    return getAverageBidModifier(adGroupLookupMap, audienceId);
  }

  return Number((bidModifier / totalStats).toFixed(2));
}

/**
 * Gets the average bid modifier for a given audience ID for a given set of
 * ad groups it targets.
 *
 * @param {Object<number, Object>} adGroupLookupMap A map that stores the
 *     details of all the ad groups. The key is the ad group ID, and value is
 *     an object that stores the ad group details.
 * @param {number} audienceId The ID of the audience.
 *
 * @return {number} the Bid modifier for the audience.
 */
function getAverageBidModifier(adGroupLookupMap, audienceId) {
  let bidModifierSum = 0;
  let count = 0;

  for (const adGroupId in adGroupLookupMap) {
    // Since we pick unique audience ID, this audience may not be targeting
    // ad group ID.
    if (audienceId in adGroupLookupMap[adGroupId]['Audiences']) {
      const searchAudience =
          adGroupLookupMap[adGroupId]['Audiences'][audienceId];
      const bidModifier = searchAudience['BidModifier'];
      bidModifierSum += bidModifier;
      count++;
    }
  }

  if (count == 0 || bidModifierSum == 0) {
    console.log(Utilities.formatString(`Bid modifier for audience ID: `+
        `${audienceId} is zero.`));
    return 0;
  }

  // Round off bid modifier to 2 places.
  return Number((bidModifierSum / count).toFixed(2));
}

/**
 * Removes excluded criteria from promotion list.
 *
 * @param {Object<number, bool>} audienceListLookupMap The map to store all the
 *     audiences found so far.
 * @param {Object<number, bool>} excludedCampaignAudienceListLookupMap The map
 *     to store all the campaign level excluded audiences found so far.
 */
function removeExcludedCriteriaFromPromotionlist(audienceListLookupMap,
    excludedCampaignAudienceListLookupMap) {
  // Remove all the excluded audiences from the promotion list.

  for (excludedAudienceId in excludedCampaignAudienceListLookupMap) {
    if (excludedAudienceId in audienceListLookupMap) {
      console.log(`--Audience ID: ` +
          `${excludedAudienceId}` +
          ` is excluded on the campaign, removing from promotion list.`);
      delete audienceListLookupMap[excludedAudienceId];
    }
  }
}

/**
 * Gets the common target setting for a list of ad groups.
 *
 * @param {Object<number, Object>} adGroupMap The map of ad groups being
 *     processed.
 *
 * @return {string} The common target setting, or null if no common target
 *     setting can be found.
 */
function getTargetSettingForAdGroups(adGroupMap) {
  // 1. See if all the ad groups have the same targeting setting.
  let targetSetting = null;
  let blankAdGroupCount = 0;
  let targetSettingMatches = true;
  for (adGroupId in adGroupMap) {

    const adGroupDetails = adGroupMap[adGroupId];

    // Skip checking the target settings for ad groups that have no search
    // audience criteria in them.
    const numCriteria = Object.keys(adGroupDetails['Audiences']).length;

    if (numCriteria == 0) {
      console.warn(`--Adgroup "${adGroupDetails['Name']}" has no search ` +
          `audience criteria, skipping...`,);
      blankAdGroupCount++;
      continue;
    }

    const tempTargetSetting = adGroupDetails['TargetingSetting'];

    if (!targetSetting) {
      targetSetting = tempTargetSetting;
    } else if (targetSetting !== tempTargetSetting) {
      targetSettingMatches = false;
      break;
    }
  }

  // Empty ad groups are skipped over only if the overall target setting
  // is "Bid Only".
  if (blankAdGroupCount != 0 && targetSetting !== 'TARGET_ALL_TRUE') {
    targetSettingMatches = false;
  }

  if (targetSettingMatches) {
    return targetSetting;
  } else {
    return null;
  }
}

/**
 * Gets the audience list for conservative promotion.
 *
 * @param {Object<number, bool>} audienceListLookupMap The map to keep track
 *     of all the audience IDs.
 * @param {Object<number, Object>} adGroupLookupMap The map to keep track of
 *     all the ad groups.
 *
 * @return {!Array.<number>} The list of all the audiences to be promoted.
 */
function getAudienceListForConservativePromotion(audienceListLookupMap,
    adGroupLookupMap) {
  const promotingAudienceList = [];

  for (const audienceId in audienceListLookupMap) {
    let audienceSettingsMatch = true;

    // Since bid modifier can be from 0.1 to 10.0, let's initialize the
    // variable to 0.
    var bidModifier = 0;
    for (adGroupId in adGroupLookupMap) {
      const adGroupDetails = adGroupLookupMap[adGroupId];

      if (audienceId in adGroupDetails['Audiences']) {
        const searchAudience = adGroupDetails['Audiences'][audienceId];

        if (!bidModifier) {
          bidModifier = searchAudience['BidModifier'];
        } else {
          const newBidModifier = searchAudience['BidModifier'];
          if (bidModifier !== newBidModifier) {
            console.log(`--Audience ID ${audienceId} has a mismatch on ` +
                       `bid modifier. Removing from promotion list.`);
            audienceSettingsMatch = false;
            break;
          }
        }
      } else {
        console.log(`--Audience ID ${audienceId} is missing in ad group: `+
                   `"${adGroupDetails['Name']}". Removing from promotion list.`);
        audienceSettingsMatch = false;
        break;
      }
    }

    if (audienceSettingsMatch) {
      promotingAudienceList.push(audienceId);
    }
  }
  return promotingAudienceList;
}

/**
 * Gets the list of campaigns to be processed.
 *
 * @param {Object} config the configuration object from the spreadsheet.
 *
 * @return {!Object.<string, Object>} a map with campaign ID as the key and
 *      campaign details as the value.
 */
function getCampaigns(config) {
  // Filter on label IDs rather than names for faster performance.
  let query = 'SELECT campaign.id, campaign.advertising_channel_type, ' +
      'campaign.name FROM campaign WHERE ' +
      'campaign.status IN ("ENABLED","PAUSED") ' +
      'AND campaign.advertising_channel_type IN ("SEARCH","SHOPPING") ' +
      'AND campaign.advertising_channel_sub_type NOT IN ' +
      '("SEARCH_EXPRESS","APP_CAMPAIGN")';

  if (config[CAMPAIGN_LABEL_RANGE_NAME]) {
    const includedLabel = AdsApp.labels()
        .withCondition(`label.name="${config[CAMPAIGN_LABEL_RANGE_NAME]}"`)
        .get().next();

    const customerId = AdsApp.currentAccount().getCustomerId();

    query += ` AND campaign.labels CONTAINS ALL ` +
             `("customers/${customerId}/labels/${includedLabel.getId()}")`;
  }

  const report = AdsApp.search(query);
  const retval = {};
  for (const row of report) {
    const campaignId = row.campaign.id;
    const campaignName = row.campaign.name;
    const channelType = row.campaign.advertisingChannelType;
    const adGroupAudienceCount = getAudienceSelector(channelType, campaignId)
        .get().totalNumEntities();
    retval[campaignId] = {
      'Id': campaignId,
      'Name': campaignName,
      'ChannelType': channelType,
      'AdGroupAudiences': adGroupAudienceCount,
      'UsesOtherAudiences': false,
      'AdGroups': {
       },
       'Audiences': {
       },
       'ExcludedAudiences': {
       }
    };
  }

  return retval;
}

/**
 * Populates individual campaign objects in a map with campaign audience
 *     targeting details.
 *
 * @param {Object} campaignDetail the campaign details object.
 * @param {number} campaignId the campaign ID.
 */
function populateCampaignExcludedAudienceDetails(campaignDetail, campaignId) {
  const excludedAudiences = AdsApp.targeting().excludedAudiences()
      .withCondition(`campaign.id = ${campaignId}`).get();

  for (const excludedAudience of excludedAudiences) {
    campaignDetail['ExcludedAudiences'][excludedAudience.getAudienceId()] = 1;
  }
}

/**
 * Populates individual campaign objects in a map with ad group details.
 *
 * @param {Object} campaignDetail the campaign details object.
 * @param {number} campaignId the campaign ID.
 */
function getAdGroups(campaignDetail, campaignId) {
  const query = `SELECT campaign.id, ad_group.id, ad_group.name, ` +
        `metrics.cost_micros FROM ad_group WHERE ad_group.status ` +
        `IN ("ENABLED","PAUSED") AND campaign.id = ${campaignId} ` +
        `AND segments.date DURING LAST_30_DAYS`;

  const report = AdsApp.search(query);

  const adGroupMap = {};

  for (const row of report) {
    const adGroupId = row.ad_group.id;
    const adGroupName = row.ad_group.name;
    const cost = row.metrics.cost_micros;
    const impressions = row.metrics.impressions;

    const adGroupDetails = {
      'Id': adGroupId,
      'Name': adGroupName,
      'Audiences': {
      },
      'ExcludedAudiences': {
      },
      'Cost': cost,
      'Impressions': impressions
    };

    campaignDetail['AdGroups'][adGroupId] = adGroupDetails;
    adGroupMap[adGroupId] = adGroupDetails;
  }

  getAdGroupAudiences(campaignId, adGroupMap, campaignDetail);
}

/**
 * Populates individual ad group objects in a map with ad group audience
 *     targeting details and target settings.
 *
 * @param {number} campaignId a single campaign ID to retrieve
 *     audience data for.
 * @param {Object.<string, Object>} adGroupMap a map with key as ad group ID
 *     and value as ad group details.
 * @param {Object} campaignDetail the campaign details object.
 */
function getAdGroupAudiences(campaignId, adGroupMap, campaignDetail) {

    let targetingSetting = '';
    const query = 'SELECT campaign.id, ad_group.id, ' +
      'ad_group.targeting_setting.target_restrictions, metrics.impressions, ' +
      'metrics.cost_micros, ad_group.status, ' +
      'ad_group_criterion.criterion_id, ' +
      'ad_group_criterion.user_list.user_list, ' +
      'ad_group_criterion.user_interest.user_interest_category,' +
      'ad_group_criterion.custom_affinity.custom_affinity,' +
      'ad_group_criterion.custom_intent.custom_intent,' +
      'ad_group_criterion.bid_modifier FROM ad_group_audience_view ' +
      'WHERE ad_group_criterion.status IN ("ENABLED","PAUSED") ' +
      'AND ad_group.status IN ("ENABLED","PAUSED") ' +
      'AND segments.date DURING LAST_30_DAYS';

    const report = AdsApp.search(query, REPORTING_OPTIONS);

    for (const row of report) {
      const adGroupId = row.ad_group.id;
      const id = row.ad_group_criterion.criterion_id;
      const criteria = row.campaign_criterion.keyword.text;
      let bidModifier = row.ad_group_criterion.bid_modifier;
      const impressions = row.metrics.impressions;
      const cost = row.metrics.cost_micros;
      const isRestrict = row.ad_group.targeting_setting.target_restrictions;

      if (isRestrict === 'true') {
        targetingSetting = 'TARGET_ALL_FALSE';
      } else {
        targetingSetting = 'TARGET_ALL_TRUE';
      }

      if (adGroupId in adGroupMap) {
        adGroupMap[adGroupId]['Audiences'][criteria] = {
          'Id': id,
          'Audience': criteria,
          'BidModifier': bidModifier,
          'Impressions': impressions,
          'Cost': cost
        };
        if (!adGroupMap[adGroupId]['TargetingSetting']) {
          adGroupMap[adGroupId]['TargetingSetting'] = targetingSetting;
        }
      }
    }
}