Koordination der Gebote für das TV-Programm

Gebotssymbol

Mit diesem Skript können Sie die Verwendung von Gebotsanpassungen für Ihre Kampagnengebote mit einem vordefinierten Zeitplan in einer Tabelle koordinieren.

Ein typischer Anwendungsfall ist die Koordinierung einer Gebotserhöhung mit TV-basierten Werbekampagnen oder einer Reihe geplanter Sportveranstaltungen.

Funktionsweise des Skripts

Das Skript liest eine Tabelle, die bereits mit den Daten und Uhrzeiten der Steigerungszeiträume ausgefüllt ist. Die genaue Gebotsanpassung und die Dauer der Steigerung können in der Tabelle angegeben oder global festgelegt werden.

Das Skript wird stündlich ausgeführt, um die Zeitpläne für die Kampagnen zu aktualisieren. Die Tabelle kann jederzeit aktualisiert werden und die Änderungen werden bei der nächsten Skriptausführung berücksichtigt.

Wichtige Hinweise

Gebotsmodus
Kampagnen mit automatischer Gebotseinstellung funktionieren mit diesem Script möglicherweise nicht, da sie möglicherweise nicht mit Werbezeitplanern kompatibel sind. Weitere Informationen zu den Voraussetzungen für Gebotsanpassungen
Bestehende Nutzung von Werbezeitplanern
Wenn für eine Kampagne bereits vorhandene Werbezeitplaner oder andere Skripts erforderlich sind, für die Werbezeitplaner festgelegt werden, sollte dieses Skript nicht für die Kampagne verwendet werden.
Einstellungsbereiche
Die Gebotsanpassung kann zwischen -90 und +900 % liegen. Die Dauer der Steigerung kann zwischen 15 und 180 Minuten liegen.
Zeitzone
Die Zeitzone des Kontos wird verwendet. Sie sollten prüfen, ob das Konto so eingerichtet ist, dass die Sommerzeit erkannt wird.

Einrichtung

  1. Erstellen Sie in Ihrem Google Ads-Konto ein Label namens TV Scheduled.

  2. Wenden Sie dieses Label auf die Kampagnen an, für die Sie Gebotsanpassungen gemäß dem Zeitplan vornehmen möchten.

  3. Erstellen Sie eine Tabelle für die Zeitplaninformationen. Sie können diese Vorlage verwenden oder den Abschnitt Tabelleneinrichtung aufrufen, wenn Sie bereits eine Tabelle haben.

  4. Erstellen Sie das Skript in Ihrem Google Ads-Konto.

  5. Fügen Sie den Link Ihrer Tabelle hinzu und ersetzen Sie INSERT_SPREADSHEET_URL_HERE durch die Tabellen-URL.

  6. Ersetzen Sie INSERT_EMAIL_ADDRESS_HERE durch Ihre E-Mail-Adresse, um über Fehler informiert zu werden.

Testen

Klicken Sie auf Vorschau, um das Skript auszuführen. Ist der Vorgang erfolgreich, werden die Daten aus Ihrer Tabellenkalkulation im Protokollfenster ausgegeben. Ist dies nicht der Fall, lesen Sie die Informationen unter Tabelleneinrichtung. Überprüfen Sie auch das von Ihnen angegebene E-Mail-Konto, da Details zu eventuellen Fehlern dorthin gesendet werden.

Wiederholen Sie die Vorschau, bis Sie sicher sind, dass die Datumsangaben korrekt gelesen werden.

Läuft live

Planen Sie das Skript für eine stündliche Ausführung.

Es ist unerlässlich, das Skript stündlich auszuführen, damit die Kampagnen richtig verarbeitet werden.

Wird deinstalliert

Wenn Sie das Skript nicht mehr verwenden möchten, führen Sie die folgenden Schritte aus, um sicherzustellen, dass die Kampagnen wieder normal laufen:

  1. Ändern Sie die Zeile im Skript mit dem Text var UNINSTALL = false; in var UNINSTALL = true;.

  2. Führen Sie das Skript aus, nicht nur eine Vorschau.

  3. Entfernen Sie die stündliche Ausführung des Skripts.

Tabelleneinrichtung

Eine Vorlagentabelle ist verfügbar, Sie können aber auch Ihre eigene verwenden.

Wenn Sie die Vorlage nicht verwenden, geben Sie dem Tabellenblatt in der Tabelle den Namen Schedule.

Die Konfiguration der Tabelle erfolgt im Skript und sieht wie folgt aus:

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

Im Feld hasHeader wird angegeben, ob die erste Zeile übersprungen werden soll. Die anderen Werte verweisen auf die Spalte der Tabelle, in der der Wert gefunden werden kann. Wenn Sie diese Werte in andere Spalten ändern, z. B. D oder E, wird eine andere Spalte ausgewählt.

Feste Werte festlegen

Wenn die Tabelle keine Gebotsanpassung und keine Dauer enthält, können Sie diese Werte korrigieren, indem Sie das Gleichheitszeichen (=) verwenden. Diese Konfiguration umfasst beispielsweise eine Gebotsanpassung von +50 % für alle Einträge in der Tabelle:

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

In diesem Beispiel ist auch eine feste Dauer für die Gebotserhöhung von 30 Minuten angegeben:

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

Datumsfehler beheben

Verschiedene Datum-Uhrzeitformate können funktionieren, aber YYYY/mm/DD HH:MM ist die empfohlene Option.

Hier sind einige Tipps, damit die Datumsangaben richtig funktionieren:

  • Wenn Sie Daten aus einer anderen Quelle kopieren, z. B. einer Excel-Tabelle oder einer CSV-Datei, formatieren Sie die Datumsangaben vor dem Kopieren und Einfügen mithilfe der Optionen dieses Tools, um sie eindeutig zu formatieren.

  • Prüfen Sie, ob Google Tabellen den Wert als Datum erkannt hat. Klicken Sie dazu doppelt auf den Wert, um ihn zu bearbeiten: Wenn es sich um ein Datum handelt, wird die Datumsauswahl angezeigt.

  • Verwenden Sie die Vorschau für das Skript, bis Sie sicher sind, dass die Datumsangaben zuverlässig gelesen werden.

Ausführliche Erläuterung des Skripts

Im Skript werden Werbezeitplaner umfassend eingesetzt. Werbezeitplaner in Google Ads haben folgende Eigenschaften:

  • Bis zu sechs Zeitplanelemente pro Kampagne und Tag
  • Zeitplanelemente werden nach Tag (Montag–Sonntag) festgelegt, nicht nach Datum.
  • Die Zeit ist in Intervalle von 15 Minuten eingeteilt, sodass Zeitplanelemente zur vollen Stunde oder 15, 30 oder 45 Minuten danach starten können.

Verwenden Sie den folgenden Ansatz, um Gebotsanpassungen festzulegen, die nicht zu einem sich wiederholenden Muster von Montag bis Sonntag passen oder für die mehr als sechs unterschiedliche Steigerungsperioden für einen bestimmten Tag erforderlich sind:

  • Das Skript liest stündlich die Tabelle.
  • Es werden nur Elemente gelesen, die für die nächsten zwei Stunden relevant sind.
  • Alle vorhandenen Zeitplanelemente für diesen Tag und diese Kampagne werden entfernt.
  • Zeitplanelemente, die sich auf die nächsten zwei Stunden erstrecken, werden hinzugefügt.

Es kann also ein beliebiger Zeitplan verwendet werden, um Gebotsanpassungen festzulegen.

Das Skript verarbeitet die Zeitabstände wie folgt:

  • Wenn der Beginn eines Elements in der Tabelle nicht auf eine Grenze von 15 Minuten fällt, wird der Beginn zurück gerundet.
  • Wenn das Ende des Elements in der Tabelle nicht auf eine Grenze von 15 Minuten fällt, wird das Ende des Elements nach vorne gerundet.

Im Skript wird der folgende Ansatz für Gebotsanpassungen verwendet:

  • Wenn sich Elemente in der Tabelle überschneiden, hat das Element mit der höchsten Gebotsanpassung Vorrang.

Quellcode

// 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.');
  }
}