Coordinamento programma TV

Icona delle offerte

Questo script ti consente di coordinare l'utilizzo dei modificatori di offerta nelle offerte della campagna con una pianificazione predefinita in un foglio di lavoro.

Un caso d'uso tipico è coordinare un incremento delle offerte con campagne pubblicitarie basate sulla TV o una serie di eventi sportivi in programma.

Come funziona lo script

Lo script legge un foglio di lavoro precompilato con le date e le ore dei periodi dell'incremento. Il modificatore di offerta esatto e la durata dell'incremento possono essere specificati nel foglio di lavoro o impostati a livello globale.

Lo script viene eseguito ogni ora per aggiornare le pianificazioni delle campagne. Il foglio di lavoro può essere aggiornato in qualsiasi momento e le modifiche verranno applicate alla successiva esecuzione dello script.

Avvertenze

Modalità di offerta
Le campagne che utilizzano l'offerta automatica potrebbero non funzionare con questo script poiché potrebbero non essere compatibili con le pianificazioni degli annunci. Consulta la pagina relativa all'idoneità per gli aggiustamenti delle offerte.
Utilizzo esistente delle pianificazioni degli annunci
Se una campagna si basa su pianificazioni degli annunci preesistenti o altri script che si basano sull'impostazione delle pianificazioni degli annunci, non deve essere utilizzata con questo script.
Intervalli di impostazioni
La modifica delle offerte può variare da -90% a +900%. La durata dell'incremento può essere compresa tra 15 e 180 minuti.
Fuso orario
Viene utilizzato il fuso orario dell'account. Dovresti controllare se l'account è configurato per riconoscere l'ora legale.

Configurazione

  1. Crea un'etichetta nel tuo account Google Ads denominata TV Scheduled.

  2. Applica questa etichetta alle campagne per cui vuoi aggiustare i modificatori di offerta in base alla pianificazione.

  3. Crea un foglio di lavoro in cui inserire le informazioni sulla pianificazione. Puoi utilizzare questo modello o consultare la pagina Configurazione del foglio di lavoro se ne hai già uno.

  4. Crea lo script nell'account Google Ads.

  5. Aggiungi il link al foglio di lavoro, sostituendo INSERT_SPREADSHEET_URL_HERE con l'URL del foglio di lavoro.

  6. Sostituisci INSERT_EMAIL_ADDRESS_HERE con il tuo indirizzo email per ricevere una notifica in caso di errori.

Test

Fai clic su Anteprima per eseguire lo script. Se l'operazione va a buon fine, le date del foglio di lavoro vengono stampate nella finestra del log. In caso contrario, consulta Configurazione del foglio di lavoro. Controlla anche l'account email indicato per ricevere i dettagli degli eventuali errori.

Ripeti l'anteprima fino a quando non hai la certezza che le date vengano lette correttamente.

Pubblicazione in corso...

Pianifica lo script in modo che venga eseguito ogni ora.

È essenziale eseguire lo script ogni ora per elaborare correttamente le campagne.

Disinstallazione in corso

Se decidi di interrompere l'utilizzo dello script, segui questi passaggi per assicurarti che le campagne tornino a funzionare normalmente:

  1. Cambia la riga nello script che legge var UNINSTALL = false; in modo che legga var UNINSTALL = true;

  2. Esegui lo script (non solo l'anteprima).

  3. Rimuovi la pianificazione oraria per lo script.

Configurazione del foglio di lavoro

È disponibile un modello di foglio di lavoro, ma puoi anche utilizzare il tuo.

Se non utilizzi il modello, assicurati di assegnare al foglio il nome Schedule.

La configurazione del foglio di lavoro è nello script e ha questo aspetto:

const SPREADSHEET_CONFIG = {
  hasHeader: true,
  date: 'A',
  bidModifier: 'B',
  duration: 'C'
};

Il campo hasHeader indica se la prima riga deve essere saltata. Gli altri valori fanno riferimento alla colonna del foglio di lavoro in cui è disponibile il valore. La modifica di questi valori in altre colonne, ad esempio D o E, comporta la selezione di una colonna diversa.

Specificare valori fissi

Se il foglio di lavoro non contiene un modificatore di offerta o una durata, puoi correggere questi valori utilizzando il segno uguale (=). Ad esempio, questa configurazione ha un modificatore di offerta +50% per tutte le voci del foglio di lavoro:

const SPREADSHEET_CONFIG = {
  hasHeader: true,
  date: 'A',
  bidModifier: '=1.5',
  duration: 'C'
};

Questo esempio ha anche una durata fissa dell'incremento di 30 minuti:

const SPREADSHEET_CONFIG = {
  hasHeader: true,
  date: 'A',
  bidModifier: '=1.5',
  duration: '=30'
};

Risolvere i problemi relativi alle date

Possono funzionare diversi formati di data e ora, ma YYYY/mm/DD HH:MM è la scelta consigliata.

Ecco alcuni suggerimenti per impostare le date correttamente:

  • Se copi da un'altra origine, ad esempio un foglio di lavoro Excel o CSV, utilizza le opzioni dello strumento per formattare le date in modo non ambiguo prima di copiarle e incollarle.

  • Verifica se Fogli ha riconosciuto il valore come data facendo doppio clic sul valore per modificarlo: se si tratta di una data, viene visualizzato il selettore della data.

  • Utilizza un'anteprima nello script fino a quando non hai la certezza che le date vengano lette in modo affidabile.

Spiegazione dettagliata dello script

Lo script fa ampio uso delle pianificazioni degli annunci. Le pianificazioni degli annunci in Google Ads hanno le seguenti proprietà:

  • Fino a sei elementi di pianificazione per campagna al giorno.
  • Le voci di pianificazione vengono impostate per giorno (lunedì-domenica), non per data.
  • La granularità temporale è di 15 minuti, quindi gli elementi di pianificazione possono iniziare all'ora o a 15, 30 o 45 minuti dopo.

Utilizza il seguente approccio per impostare modificatori di offerta che non rientrano in un modello ricorrente da lunedì a domenica o che potrebbero richiedere più di sei periodi di incremento diversi per un determinato giorno:

  • Ogni ora lo script legge il foglio di lavoro.
  • Vengono letti solo gli elementi pertinenti alle prossime due ore.
  • Tutti gli elementi di pianificazione esistenti per quel giorno per quella campagna vengono rimossi.
  • Vengono aggiunte le voci della programmazione che riguardano le prossime due ore.

In questo modo, puoi utilizzare una pianificazione arbitraria per aggiustare i modificatori di offerta.

Lo script gestisce la granularità temporale nel seguente modo:

  • Se l'inizio di un elemento nel foglio di lavoro non rientra in un limite di 15 minuti, viene arrotondato indietro.
  • Se la fine dell'elemento nel foglio di lavoro non rientra in un limite di 15 minuti, viene arrotondata in avanti.

Lo script utilizza il seguente approccio per i modificatori di offerta:

  • In caso di elementi sovrapposti nel foglio di lavoro, ha la priorità l'elemento con il modificatore di offerta più alto.

Codice sorgente

// 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.

/**
 * @name TV Advert bid coordination.
 *
 * @overview Allows Google Ads and TV-based advert schedules to be coordinated.
 *     Visit
 *     https://developers.google.com/google-ads/scripts/docs/solutions/tv-schedule
 *     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.1.1
 *   - Added validation for external spreadsheet setup.
 * - version 1.1
 *   - Fixed minor bug with error messages.
 * - version 1.0
 *   - Released initial version.
 */

// The URL for the spreadsheet of TV advert dates and times. This spreadsheet
// can be used as a template:
//
// https://docs.google.com/spreadsheets/d/1Ps3x3cQ7ixBysVfDS8VObbKofcVrOJtXaWvk4eMjAeA
//
const SPREADSHEET_URL = 'INSERT_SPREADSHEET_URL_HERE';

// Email addresses for notification of any errors encountered.
const EMAIL_RECIPIENTS = ['INSERT_EMAIL_ADDRESS_HERE'];

// Spreadsheet configuration.
const SPREADSHEET_CONFIG = {
  hasHeader: true,
  date: 'A',
  bidModifier: 'B',
  duration: 'C'
};

// Flag to set for uninstalling the script. Instructions at
//
// https://developers.google.com/google-ads/scripts/docs/solutions/tv-schedule
//
const UNINSTALL = false;

// The label applied to all TV scheduled campaigns.
const TV_CAMPAIGN_LABEL = 'TV Scheduled';

const DAYS = [
  'SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'
];

const MILLIS_PER_MIN = 1000 * 60;
const MILLIS_PER_DAY = MILLIS_PER_MIN * 60 * 24;
const SLOT_MINS = 15;
const SLOTS_PER_HOUR = 60 / SLOT_MINS;
const SLOTS_PER_DAY = 60 * 24 / SLOT_MINS;

// The Google Ads limit on the number schedule entries per campaign per day.
const MAX_SCHEDULE_ITEMS_PER_DAY = 6;

const MIN_BID_MODIFIER = 0.1;
const MAX_BID_MODIFIER = 9.0;
const MIN_DURATION_MINS = 0;
const MAX_DURATION_MINS = 180;

const ROUND_FORWARD = 1;
const ROUND_BACKWARD = 0;

// The 'range' specifies how far ahead, when setting schedule items to affect
// the immediate future, items should be inserted for.
const DEFAULT_RANGE = MAX_SCHEDULE_ITEMS_PER_DAY * SLOT_MINS;

// Only manual bidding strategies support Ad Scheduling bid adjustments.
const MANUAL_BIDDING_STRATEGIES = ['MANUAL_CPC', 'MANUAL_CPC'];

const SCHEDULE_SHEET_NAME = 'Schedule';

// Variables used to hold Dates for the three days relevant to this execution.
let yesterday = null;
let today = null;
let tomorrow = null;

/**
 * Main entry point
 */
function main() {
  let execDateTime = SimpleDate.fromDate(
      localDate(new Date(), AdsApp.currentAccount().getTimeZone()));
  const tvScheduleCreator = new TvScheduleCreator();
  const spreadsheetReader = new SpreadsheetReader(
    SPREADSHEET_URL, SPREADSHEET_CONFIG);
  const errors = spreadsheetReader.getErrors();

  if (UNINSTALL) {
    removeAllSchedulesForUninstall(errors);
  } else {
    // Form relevant dates.
    today = SimpleDate.fromDate(new Date());
    yesterday = SimpleDate.fromDate(getDatePlusDays(new Date(), -1));
    tomorrow = SimpleDate.fromDate(getDatePlusDays(new Date(), 1));

    // Only add items from the spreadsheet if no errors were encountered.
    if (errors.length === 0) {
      const rows = spreadsheetReader.getRows();
      for (let i = 0; i < rows.length; i++) {
        let row = rows[i];
        tvScheduleCreator.addTvAd(row[0], row[1], row[2]);
      }
    }
    let scheduleItems = tvScheduleCreator.getScheduleItemsToAdd(
      execDateTime);
    const campaigns = getCampaigns();
    for (let i = 0; i < campaigns.length; i++) {
      const campaign = campaigns[i];
      const biddingStrategy = campaign.bidding().getStrategyType();
      if (MANUAL_BIDDING_STRATEGIES.indexOf(biddingStrategy) >= 0) {
        applySchedulesToCampaign(campaign, scheduleItems);
      } else {
        storeAndLogError(errors, `"${campaign.getName()}"` +
            ` does not use manual bidding, so cannot use Ad Scheduling. ` +
            `Visit https://support.google.com/google-ads/answer/2732132`);
      }
    }
  }
  if (errors.length > 0) {
    validateEmailAddresses();
    sendErrorEmail(errors);
  }
}

/**
 * Returns a Date formatted for the specified timezone.
 *
 * @param {Date} date The date to convert.
 * @param {String} timeZone The desired time zone.
 * @return {!Date} The newly-constructed Date.
 */
function localDate(date, timeZone) {
  return new Date(Utilities.formatDate(date, timeZone, 'yyyy MMM dd HH:mm:ss'));
}

/**
 * Creates a Date +/- a given number of days from the specified Date.
 *
 * @param {Date} date The date to start from.
 * @param {number} days The number of days to add (or subtract if negative).
 * @return {!Date} The newly created Date.
 */
function getDatePlusDays(date, days) {
  const newDate = new Date(date.getTime());
  newDate.setTime(date.getTime() + days * MILLIS_PER_DAY);
  return newDate;
}

/**
 * SimpleDate represents a date/time with granularity of 15 mins.
 */
class SimpleDate {

  /**
   * @param {number} year Year e.g. 2015.
   * @param {number} month 0-indexed month, e.g. 0 = January.
   * @param {number} day 1-31 day of the month.
   * @param {number} hours 0-23
   * @param {number} minutes 0-59, will be rounded to multiple of 15.
   * @param {number} roundingMode Whether to round back (default) or forward.
   */
  constructor (year, month, day, hours, minutes, roundingMode) {
    let roundedMins = null;
    if (roundingMode === ROUND_FORWARD) {
      roundedMins = Math.ceil(minutes / SLOT_MINS) * SLOT_MINS;
    } else {
        roundedMins = Math.floor(minutes / SLOT_MINS) * SLOT_MINS;
    }
    this.datetime = new Date(year, month, day, hours, roundedMins);
  }

  /**
   * Returns the slot that this time falls in (for example, between 0 and 95).
   *
   * @return {number} The 15-min slot that the given time falls into.
   */
  getSlotIndex() {
    return this.datetime.getHours() * SLOTS_PER_HOUR +
      Math.floor(this.datetime.getMinutes() / SLOT_MINS);
  }

  /**
   * Returns the day of week represented in numbers.
   *
   * @return {number} The day of the week represented in numbers.
   */
  getDayOfWeek() {
    return this.datetime.getDay();
  }

  /**
   * Returns the hour that this time falls in.
   *
   * @return {number} The hour that the given time falls into.
   */
  getHours() {
    return this.datetime.getHours();
  }

  /**
   * Returns the minute that this time falls in.
   *
   * @return {number} The minute that the given time falls into.
   */
  getMinutes() {
    return this.datetime.getMinutes();
  }

  /**
   * Returns true if .
   *
   * @param {!Date} simpleDate The given date.
   * @return {boolean} Whether this constraint is met or not.
   */
  equals(simpleDate) {
    return this.valueOf() === simpleDate.valueOf();
  }

  /**
   * Returns the time divided by millisecond per minute.
   *
   * @return {number} The time divided by millisecond per minute.
   */
  valueOf() {
    return this.datetime.getTime() / MILLIS_PER_MIN;
  }

  /**
   * Returns the date and time in desired string format.
   *
   * @return {string} The date and time in desired format.
   */
  toString() {
    return Utilities.formatString('%d-%02d-%02d %02d:%02d',
      this.datetime.getFullYear(), this.datetime.getMonth() + 1,
      this.datetime.getDate(), this.datetime.getHours(),
      this.datetime.getMinutes());
  }

  /**
   * Returns the date and time in desired string format.
   *
   * @param {!object} simpleTime The datetime object
   * @return {boolean} Whether the constraint is met or not.
   */
  isSameDay(simpleTime) {
    return this.datetime.getFullYear() === simpleTime.datetime.getFullYear() &&
      this.datetime.getMonth() === simpleTime.datetime.getMonth() &&
      this.datetime.getDate() === simpleTime.datetime.getDate();
  }

  /**
   * Creates a SimpleDate from a given Date object
   *
   * @param {Date} date The date to convert.
   * @param {number} roundingMode Whether to round forward or back.
   * @return {SimpleDate} The new SimpleDate, or null if not a Date or invalid.
   */
  static fromDate(date, roundingMode) {
    if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
      return null;
    }
    return new SimpleDate(date.getFullYear(), date.getMonth(), date.getDate(),
        date.getHours(), date.getMinutes(), roundingMode);
  }

  /**
   * Creates a SimpleDate from the given SimpleDate and slot index.
   *
   * @param {SimpleDate} simpleDate The SimpleDate to copy year/month/day from.
   * @param {number} index The 15-min slot index (e.g. 0-95) to represent time.
   * @return {!SimpleDate} The new SimpleDate.
   */
  static fromSimpleDateIndex(simpleDate, index) {
    return new SimpleDate(simpleDate.datetime.getFullYear(),
        simpleDate.datetime.getMonth(), simpleDate.datetime.getDate(),
        Math.floor(index / SLOTS_PER_HOUR),
                         index % SLOTS_PER_HOUR * SLOT_MINS);
  }
}

/**
 * Provides the ability to read TV advert details from a Google Spreadsheet.
 */

class SpreadsheetReader {

  /**
   * Reads a Google Sheet, to obtain the TV ad schedules.
   *
   * @param {string} url The URL of the spreadsheet.
   * @param {object} config Configuration of which fields are in which columns.
   */
  constructor (url, config) {
    this.errors = [];
    let rawData = [];

    try {
      rawData = SpreadsheetReader.readDataFromGoogleSheet_(url);
    } catch (e) {
      storeAndLogError(this.errors, e.message);
    }
    if (rawData) {
      this.data = SpreadsheetReader.parseData_(this.errors, rawData, config);
    }
  }

  /**
   * Retrieves successfully parsed entries.
   *
   * @return {object[]} The rows
   * @return {Date} object[][0] The start date for the ad.
   * @return {number} object[][1] The bid modifier.
   * @return {number} object[][2] The duration in minutes.
   */
  getRows() {
    return this.data;
  }

  /**
   * Retrieves errors encountered in loading data from the spreadsheet.
   *
   * @return {!String[]} The list of errors.
   */
  getErrors() {
    return this.errors;
  }

  /**
   * Reads a two-dimensional list representing the Google Spreadsheet.
   * Individual cells can vary in their data type, being String, number or Date.
   *
   * @param {string} url The spreadsheet URL.
   * @return {object[][]} 2D representation of the data in the file.
   * @private
   */
  static readDataFromGoogleSheet_(url) {
    const spreadsheet = validateAndGetSpreadsheet(url);
    if (!spreadsheet) {
      throw Error(`Unable to open spreadsheet with URL: ${url}`);
    }
    const timeZone = spreadsheet.getSpreadsheetTimeZone();
    const sheet = spreadsheet.getSheetByName(SCHEDULE_SHEET_NAME);
    if (!sheet) {
      throw Error(`Spreadsheet must have a sheet named: "` +
          `${SCHEDULE_SHEET_NAME}"`);
    }
    const values = sheet.getDataRange().getValues();
    for (let i = 0; i < values.length; i++) {
      for (let j = 0; j < values[i].length; j++) {
        if (values[i][j] instanceof Date) {
          values[i][j] = localDate(values[i][j], timeZone);
        }
      }
    }
    return values;
  }

  /**
   * Determines whether an TV ad satisfies constraints.
   *
   * @param {string} errors An array to store errors encountered.
   * @param {Date} date Represents the start date/time.
   * @param {number} bidModifier The bid modifier for the period.
   * @param {number} duration The duration of the modifier, in minutes.
   * @param {number} rowIndex The row number, counting from 1.
   * @return {boolean} Whether these constraints are met.
   * @private
   */
  static isValidEntry_(errors, date, bidModifier, duration, rowIndex) {
    if (AdsApp.getExecutionInfo().isPreview()) {
      console.log([
        `Row: ${rowIndex}`,
        `startDate (y-m-d h-m): ${SimpleDate.fromDate(date)}`,
        `bidModifier: ${bidModifier}`,
        `duration: ${duration}`
      ].join(' '));
    }
    if (bidModifier < MIN_BID_MODIFIER || bidModifier > MAX_BID_MODIFIER ||
        isNaN(bidModifier)) {
      storeAndLogError(errors, `Invalid bid modifier at row ${rowIndex}`);
      return false;
    } else if (duration <= MIN_DURATION_MINS || duration > MAX_DURATION_MINS) {
      storeAndLogError(errors, `Invalid duration at row ${rowIndex}`);
      return false;
    } else if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
      storeAndLogError(errors, `Invalid date/time at row ${rowIndex}`);
      return false;
    }
    return true;
  }

  /**
   * Converts an alphabet-based column index to a numeric one, e.g. "A" -> 0.
   * @param {string} alpha The single character to convert.
   * @return {number} The numerical representation.
   * @private
   */
  static alphaToCol_(alpha) {
    return alpha.charCodeAt() - 'A'.charCodeAt();
  }

  /**
   * Parses the raw data obtained from a Google Spreadsheet to obtain triples of
   * start date/time, bid modifier and duration.
   *
   * @param {string} errors An array to store errors encountered.
   * @param {!object} rawData 2D list of the raw data obtained from the file.
   * @param {object} config Configuration of which fields are in which columns.
   * @return {!object} The parsed row data.
   * @private
   */
  static parseData_(errors, rawData, config) {
    let parsedRows = [];
    for (let r = config.hasHeader ? 1 : 0; r < rawData.length; r++) {
      let date = null;
      let bidModifier = null;
      let duration = null;
      const row = rawData[r];

      // '=' prefix means fixed value, otherwise the value specifies the column.
      if (config.bidModifier[0] === '=') {
        bidModifier = config.bidModifier.substring(1).replace(',', '.');
      } else {
        bidModifier = parseFloat(
            String(row[SpreadsheetReader.alphaToCol_(
              config.bidModifier)]).replace(',', '.'));
      }
      if (config.duration[0] === '=') {
        duration = config.duration.substring(1);
      } else {
        duration = row[SpreadsheetReader.alphaToCol_(config.duration)];
      }

      if (config.date[0] === '=') {
        date = new Date(config.date.substring(1));
      } else {
        date = row[SpreadsheetReader.alphaToCol_(config.date)];
      }

      if (SpreadsheetReader.isValidEntry_(
        errors, date, bidModifier, duration, r + 1)) {
          parsedRows.push([date, bidModifier, duration]);
      }
    }
    return parsedRows;
  }
}

/**
 * Creates a list of Google Ads schedule items based on TV ad times and details.
 */
class TvScheduleCreator {

  /**
   * Creates a TV schedule object. This is used for calculating which Google Ads
   * schedule items to create for a given set of TV ad details.
   */
  constructor () {
    this.daySlots = [];
    for (let d = 0; d < DAYS.length; d++) {
      let slots = [];
      for (let i = 0; i < SLOTS_PER_DAY; i++) {
        slots.push(null);
      }
      this.daySlots.push(slots);
    }
  }

  /**
   * Retrieves the schedule items that should be added to campaigns.
   *
   * @param {SimpleDate} curDate The date/time of current execution.
   * @param {number=} opt_range The range beyond curDate deemed relevant. If not
   *     specified, defaults to DEFAULT_RANGE.
   * @return {object[]} A list of schedule items for applying to campaigns.
   */
  getScheduleItemsToAdd(curDate, opt_range) {
    const todaySlots = this.daySlots[curDate.getDayOfWeek()];
    let curModifier = todaySlots[0];
    let start = 0;
    let schedule = [];
    let range = opt_range || DEFAULT_RANGE;

    // If there is a change in bid modifer from the last slot to this then this
    // represents the boundary of a schedule.
    for (let index = 0; index < todaySlots.length; index++) {
      if (todaySlots[index] !== curModifier) {
        TvScheduleCreator.addIfInBounds_(
          schedule, curDate, start, index, range, curModifier);
        start = index;
        curModifier = todaySlots[index];
      }
    }
    TvScheduleCreator.addIfInBounds_(
      schedule, curDate, start, SLOTS_PER_DAY, range, curModifier);

    // If the range of relevant schedule items possibly extends to tomorrow, add
    // results from that part of tomorrow too.
    if (SLOTS_PER_DAY - curDate.getSlotIndex() < range / SLOT_MINS) {
      range -= (SLOTS_PER_DAY - curDate.getSlotIndex()) * SLOT_MINS;
      const nextDay = getDatePlusDays(curDate.datetime, 1);
      nextDay.setHours(0);
      nextDay.setMinutes(0);
      const tomorrowFirstSlot = SimpleDate.fromDate(nextDay);
      const extraResults = this.getScheduleItemsToAdd(tomorrowFirstSlot, range);
      extraResults.forEach(function(entry) {
        schedule.push(entry);
      });
    }
    return schedule;
  }

  /**
   * Adds the specified TV ad start/duration to the schedule. The modifier is
   * only updated where the existing modifier is less, or not specified.
   *
   * @param {Date} start The start time of the TV advert.
   * @param {Number} bidModifier The bid modifier to apply.
   * @param {Number} duration The duration of the uplift, in minutes.
   */
  addTvAd (start, bidModifier, duration) {
    // Only TV ads scheduled for yesterday, today or tomorrow can be relevant.
    const end = new Date(start.getTime() + duration * MILLIS_PER_MIN);

    const simpleStart = SimpleDate.fromDate(start, ROUND_BACKWARD);
    const simpleEnd = SimpleDate.fromDate(end, ROUND_FORWARD);

    if (!simpleStart.isSameDay(yesterday) && !simpleStart.isSameDay(today) &&
        !simpleStart.isSameDay(tomorrow)) {
      return;
    }

    const todaySlots = this.daySlots[simpleStart.getDayOfWeek()];
    const startIndex = simpleStart.getSlotIndex();
    let endIndex = simpleEnd.getSlotIndex();

    if (endIndex < startIndex) {
      // Adds ads for tomorrow, if the duration takes it into the next day.
      const tomorrowSlots = this.daySlots[(simpleStart.getDayOfWeek() + 1) % 7];
      TvScheduleCreator.applyModifierToRange_(
        tomorrowSlots, 0, endIndex, bidModifier);
      endIndex = SLOTS_PER_DAY;
    }
    TvScheduleCreator.applyModifierToRange_(
      todaySlots, startIndex, endIndex, bidModifier);
  }

  /**
   * Adds a schedule item if the start or end overlap with the period between
   * now and now + range.
   *
   * @param {!object} schedule The list of schedule items to add to.
   * @param {SimpleDate} date The current date and time of execution.
   * @param {number} startIdx The slot-index that this uplift starts at.
   * @param {number} endIdx The slot-index 1 past the end of the uplift.
   * @param {number} range Length of the period from now to consider in minutes.
   * @param {number} modifier The bid modifier to apply.
   * @private
   */
  static addIfInBounds_(schedule, date, startIdx, endIdx, range, modifier) {
    const start = SimpleDate.fromSimpleDateIndex(date, startIdx);
    const end = SimpleDate.fromSimpleDateIndex(date, endIdx);
    let endHours = end.getHours();
    if (end.getHours() === 0 && end.getMinutes() === 0) {
      endHours = 24;
    }
    if (start >= date && start - date < range || start < date && end > date) {
      schedule.push({
        dayOfWeek: DAYS[date.getDayOfWeek()],
        startHour: start.getHours(),
        startMinute: start.getMinutes(),
        endHour: endHours,
        endMinute: end.getMinutes(),
        bidModifier: modifier === null ? 1 : modifier
      });
    }
  }

  /**
   * Applies the bid modifier to the slots in the range where no modifier exists
   * or the existing modifier is less.
   *
   * @param {!object} scheduleSlots List holding the 96 time slots of the day.
   * @param {number} startIndex The starting index of the range.
   * @param {number} endIndex The final slot in the range, plus one.
   * @param {number} modifier The bid modifier to apply.
   * @private
   */
  static applyModifierToRange_(scheduleSlots, startIndex, endIndex,
      modifier) {
    for (let j = startIndex; j < endIndex; j++) {
      if (scheduleSlots[j] === null || modifier > scheduleSlots[j]) {
        scheduleSlots[j] = modifier;
      }
    }
  }
}

/**
 * Applies Google Ads schedule items to a given campaign, after first clearing
 * existing items.
 *
 * @param {Campaign} campaign The campaign to apply to.
 * @param {object[]} scheduleItems The schedule items to add.
 */
function applySchedulesToCampaign(campaign, scheduleItems) {
  removePastScheduleDay(campaign, yesterday.getDayOfWeek());
  removePastScheduleDay(campaign, today.getDayOfWeek());
  removePastScheduleDay(campaign, tomorrow.getDayOfWeek());
  for (let i = 0; i < scheduleItems.length; i++) {
    campaign.addAdSchedule(scheduleItems[i]);
  }
}

/**
 * Sends an email of the errors that have been encountered in parsing the
 * spreadsheet file, which are supplied as a list of strings
 *
 * @param {String[]} errors The errors to list in the email.
 */
function sendErrorEmail(errors) {
  const emailDate = localDate(new Date(),
      AdsApp.currentAccount().getTimeZone());
  const footerStyle = 'color: #aaaaaa; font-style: italic;';
  const scriptsLink = 'https://developers.google.com/google-ads/scripts/';
  const subject = `[Errors] Google Ads TV Coordination Script: ${emailDate}`;
  let htmlBody = '<html><body><p>Hello,</p>' +
      '<p>A Google Ads script has run and these errors were encountered :<ul>';
  for (let i = 0; i < errors.length; i++) {
    htmlBody += `<li>${errors[i]}</li>`;
  }
  htmlBody += `</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 email.';
  const options = {htmlBody: htmlBody};
  MailApp.sendEmail(EMAIL_RECIPIENTS, subject, body, options);
}

/**
 * Retrieves a list of campaigns that have been targeted for TV scheduling.
 *
 * @return {Campaign[]}
 */
function getCampaigns() {
  const campaignList = [];
  const campaigns = AdsApp.campaigns().
      withCondition(`LabelNames CONTAINS_ANY ["${TV_CAMPAIGN_LABEL}"]`).
      withCondition('Status = "ENABLED"').get();
  for (const campaign of campaigns) {
    campaignList.push(campaign);
  }
  return campaignList;
}

/**
 * Removes schedules for specified day
 *
 * @param {Campaign} campaign The campaign to remove schedule items from.
 * @param {number} dayIndex The day in question (0=Sunday, 1=Monday) etc.
 */
function removePastScheduleDay(campaign, dayIndex) {
  const schedules = campaign.targeting().adSchedules().get();
  for (const schedule of schedules) {
    if (schedule.getDayOfWeek() === DAYS[dayIndex]) {
      schedule.remove();
    }
  }
}

/**
 * Removes schedules for all Campaigns, for use when ceasing use of the script.
 *
 * @param {string[]} errors Array to hold errors encountered.
 */
function removeAllSchedulesForUninstall(errors) {
  if (AdsApp.getExecutionInfo().isPreview()) {
    storeAndLogError(errors, 'Please run uninstall again in non-preview mode.');
    return;
  }
  const campaigns = getCampaigns();
  for (let i = 0; i < campaigns.length; i++) {
    const campaign = campaigns[i];
    const schedules = campaign.targeting().adSchedules().get();
    for (const schedule of schedules) {
      schedule.remove();
    }
  }
}

/**
 * Helper to both log and collect errors.
 *
 * @param {string[]} logArray The array to append entries to.
 * @param {string} errorMessage The message to append.
 */
function storeAndLogError(logArray, errorMessage) {
  console.error(errorMessage);
  logArray.push(errorMessage);
}

/**
 * DO NOT EDIT ANYTHING BELOW THIS LINE.
 * Please modify your spreadsheet URL and email addresses at the top of the file
 * only.
 */

/**
 * Validates the provided spreadsheet URL and email address
 * to make sure that they're set up properly. Throws a descriptive error message
 * if validation fails.
 *
 * @param {string} spreadsheeturl The URL of the spreadsheet to open.
 * @return {Spreadsheet} The spreadsheet object itself, fetched from the URL.
 * @throws {Error} If the spreadsheet URL or email hasn't been set
 */
function validateAndGetSpreadsheet(spreadsheeturl) {
  if (spreadsheeturl == 'INSERT_SPREADSHEET_URL_HERE') {
    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(spreadsheeturl);
  return spreadsheet;
}

/**
 * Validates the provided email address to make sure it's not the default.
 * Throws a descriptive error message if validation fails.
 *
 * @throws {Error} If the list of email addresses is still the default
 */
function validateEmailAddresses() {
  if (EMAIL_RECIPIENTS &&
      EMAIL_RECIPIENTS[0] == 'INSERT_EMAIL_ADDRESS_HERE') {
    throw new Error('Please specify a valid email address.');
  }
}