Kontoprüfer

Werkzeugsymbol

Viele Werbetreibende strukturieren ihre Konten am liebsten konsistent. Einige Werbetreibende strukturieren beispielsweise jede Kampagne so, dass sie eine feste Anzahl von Anzeigengruppen enthält, und jede Anzeigengruppe so, dass sie eine feste Anzahl von Anzeigen und Keywords enthält. Andere Werbetreibende verwenden Labels, um ihre Konten zu organisieren. Dabei sollte jede Kampagne oder Anzeigengruppe mindestens ein Label aus einer kurzen Liste haben. Wieder andere wiederum organisieren ihre Anzeigengruppen oder Anzeigen so, dass jeweils nur eine Anzeigengruppe oder Anzeige aktiviert ist. Es kann zeitaufwendig sein, manuell dafür zu sorgen, dass Ihr Konto mit der gewünschten Struktur konsistent bleibt.

Mit dem Account Auditor können Sie die Struktur Ihrer Kampagnen, Anzeigengruppen, Anzeigen und Keywords überprüfen. Sie definieren die gewünschte Struktur mithilfe flexibler Regeln in einer Tabelle. Das Skript analysiert Ihr Konto und meldet alle Entitäten, die Ihre Regeln nicht erfüllt haben, auf einem separaten Tab der Tabelle.

Die Funktion des Account Auditors ähnelt der Funktion Automatisierte Regeln in Google Ads. Mit dieser leistungsfähigen Funktion können Werbetreibende anhand von Regeln Änderungen an ihrem Konto vornehmen oder E-Mail-Benachrichtigungen senden. Der Hauptunterschied besteht darin, dass Sie mit dem Kontoprüftool strukturelle Bedingungen für Ihre Konten überprüfen können, z. B. dass jede aktivierte Kampagne mindestens eine Mindestanzahl von aktivierten Anzeigengruppen enthält, während automatisierte Regeln den Schwerpunkt primär auf Leistungsmesswerte legen.

Konfiguration

Grundlagen

Sie konfigurieren das Skript "Kontoprüfung", indem Sie Regeln in einer Tabelle festlegen. Erstellen Sie eine Kopie dieser Tabellenvorlage und ersetzen, ändern oder ergänzen Sie die Beispielregeln durch Ihre eigenen.

Tabelle mit dem Kontoprüfer

Das obige Beispiel zeigt verschiedene wichtige Funktionen:

  • Jede Zeile enthält eine einzelne Regel. Eine Regel besteht aus drei Teilen: einer eindeutigen ID (erforderlich), einem Trigger (optional) und einem Test (erforderlich).
  • Ein Trigger oder Test enthält einen Entitätstyp (erforderlich) und Bedingungen (optional). Ein Test gibt auch die erwartete Anzahl von Testentitäten an, die die Testbedingungen erfüllen müssen (erforderlich).
  • Ein Trigger oder Test kann mehrere Bedingungen haben, indem jede Bedingung in eine eigene Zeile innerhalb derselben Zelle eingefügt wird (nicht in eine separate Zeile).
  • Ein Test kann angeben, dass alle Entitäten die Bedingungen erfüllen oder dass eine einfache numerische Bedingung erfüllt sein muss, z. B. > 5 Entitäten.

Regeln

Im Allgemeinen kann eine Zeile so interpretiert werden: „Für jeden Trigger-Entitätstyp, der den Triggerbedingungen entspricht, sollte die erwartete Anzahl der zugehörigen Entitäten vom Typ „Test“ mit den Testbedingungen übereinstimmen.“ Sie können sich den Trigger als Filter vorstellen, der bestimmt, auf welche Entitäten der Test angewendet wird. Die im obigen Beispiel definierten Regeln sind unten aufgeführt.

  • IDs 1, 2, 3:Jede aktivierte Kampagne muss mindestens zwei aktivierte Anzeigengruppen enthalten. Jede aktivierte Anzeigengruppe muss mindestens fünf aktivierte Anzeigen enthalten. Außerdem muss jede aktivierte Anzeigengruppe mindestens zehn aktivierte Keywords enthalten.
  • ID 4: Das Budget jeder aktivierten Kampagne darf nicht null sein.
  • ID 5:Jede aktivierte Anzeigengruppe mit dem Label „Immer ausliefern“ muss Teil einer aktivierten Kampagne sein.
  • ID 6:Jede aktivierte Textanzeige, deren Anzeigentitel "Schuhe" enthält, muss mit allen aktivierten Keywords verknüpft sein, die "Schuhe" enthalten, also zur selben Anzeigengruppe gehören.
  • ID 7:Jede aktivierte erweiterte Textanzeige, deren erster Anzeigentitel den Teil „Schuhe“ enthält, muss mit Keywords verknüpft sein, die alle aktivierten Keywords enthalten, also „Schuhe“ enthalten, also zur selben Anzeigengruppe gehören.
  • ID 8:Jedes Keyword mit einem niedrigeren Qualitätsfaktor als 3 darf nicht aktiviert werden. Es sind also nicht alle Keywords aktiviert.
  • ID 9:Alle Keywords müssen „Meine Marke“ enthalten.

Der Trigger ist optional. Bei Auslassung wie bei Regel-ID 9 gilt der Test für alle Entitäten des Testentitätstyps.

Format

Die Bedingungen für einen Trigger oder Test müssen dem Format der Methode withCondition(condition) des zugehörigen Entitätstyps entsprechen, wie in der folgenden Tabelle gezeigt:

Entitätstyp Format der Bedingung
Kampagne CampaignSelector.withCondition(condition)
AdGroup AdGroupSelector.withCondition(condition)
Werbeunterbrechung AdSelector.withCondition(condition)
Keyword KeywordSelector.withCondition(condition)

Sie können mehrere Bedingungen angeben, indem Sie jede Bedingung in eine eigene Zeile in dieselbe Zelle einfügen (nicht in eine separate Zeile). Mehrere Bedingungen werden durch AND miteinander verknüpft.

Die erwartete Anzahl von Testentitäten, die die Testbedingungen erfüllen, muss einem einfachen Format entsprechen:

  • Das spezielle Wort All bedeutet, dass alle Testentitäten die Testbedingungen erfüllen müssen.
  • Eine Zahl bedeutet, dass genau so viele Testentitäten die Testbedingungen erfüllen müssen. Die Zahl muss eine Ganzzahl größer oder gleich null sein.
  • Es können einfache Ausdrücke im Format Operator Number wie >= 3 oder != 0 verwendet werden. Zulässige Operatoren sind =, !=, <, >, <= und >=. Die Verwendung des Operators = entspricht dem Weglassen des Operators.

Wenn der Trigger-Entitätstyp dem Test-Entitätstyp entspricht oder spezifischer als dieser ist, muss die im Test angegebene erwartete Anzahl All sein.

Wird geplant

Planen Sie das Skript so, dass es immer dann ausgeführt wird, wenn Sie wesentliche strukturelle Änderungen an Ihrem Konto vornehmen (z. B. Kampagnen, Anzeigengruppen, Anzeigen und Keywords erstellen, entfernen, aktivieren oder pausieren), für Entitäten, die Sie auf bestimmte Weise organisieren. Wenn Sie nur selten strukturelle Änderungen an Ihrem Konto vornehmen, reicht es wahrscheinlich aus, das Skript wöchentlich zu planen. Wenn Sie häufig selbst oder mithilfe anderer Skripts strukturelle Änderungen vornehmen, sollten Sie das Skript täglich oder sogar stündlich planen.

Funktionsweise

Das Skript lädt und parst nacheinander jede Regel in der Tabelle und stoppt bei der ersten Zeile, die keine ID hat. Anschließend prüft es jede Regel einzeln, indem es die relevanten Entitäten abruft und untersucht. Der Trigger-Entitätstyp kann breiter oder enger als der Testentitätstyp, gleichgeordnet oder gleich sein. Das Skript kennt die Hierarchie der Google Ads-Entitäten und wendet jede Regel entsprechend an.

Das Skript nutzt die assoziative Indexierung, um die erforderlichen Methoden für Google Ads-Entitäten flexibel zu ermitteln. Beispielsweise entspricht der Ausdruck im Skript trigger[testInfo.selector](), wobei trigger eine Google Ads-Skriptentität und testInfo.selector der Name einer Methode zum Abrufen eines Selektors wie campaigns oder adGroups ist, dem bekannteren trigger.campaigns() oder trigger.adGroups(). Dies macht sich die Tatsache zunutze, dass Methoden für alle Entitätstypen in Google Ads-Skripts einheitlich benannt sind.

Wenn das Skript Regelverstöße feststellt, werden die Details auf dem Tab Berichte der Tabelle ausgegeben und eine E-Mail-Benachrichtigung an eine Liste der Empfänger gesendet. Die Ausgabe auf dem Tab Berichte zeigt die Entität, die die Regel ausgelöst hat, und den Grund, aus dem der Test fehlgeschlagen ist. Der Grund ist entweder eine bestimmte Entität, die die Testbedingungen nicht erfüllt hat (wenn erwartet wird, dass All Testentitäten die Testbedingungen erfüllen) oder die Anzahl der Entitäten, die die Testbedingungen tatsächlich erfüllen (wenn die erwartete Anzahl ein Ausdruck ist).

Einrichtung

  • Erstellen Sie ein Skript mit dem unten stehenden Quellcode. Verwenden Sie dazu eine Kopie dieser Tabellenvorlage.
  • Fügen Sie der Tabelle Regeln hinzu, wie oben unter Konfiguration beschrieben.
  • Passen Sie die Variablen SPREADSHEET_URL und RECIPIENT_EMAILS in Ihrem Skript an.
  • Planen Sie das Skript.

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 Account Auditor
 *
 * @overview The Account Auditor script helps you keep your accounts
 *     organized as you intend by automatically checking many types of
 *     structural conditions on your campaigns, ad groups, ads, and keywords.
 *     You define your intended structure using flexible "rules" in a
 *     spreadsheet, and the script analyzes your account and reports any
 *     entities that failed to satisfy your rules on a separate tab of the
 *     spreadsheet. See
 *     https://developers.google.com/google-ads/scripts/docs/solutions/account-auditor
 *     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
 *   - Added support for different ad types, including expanded text ads.
 * - version 1.0.1
 *   - Improvements to time zone handling. Minor bug fixes.
 * - version 1.0
 *   - Released initial version.
 */

const CONFIG = {
  // URL of the spreadsheet template.
  // This should be a copy of https://goo.gl/GeK8kF.
  SPREADSHEET_URL: 'YOUR_SPREADSHEET_URL',

  // Whether to output results to a copy of the above spreadsheet (true) or to
  // the spreadsheet directly, overwriting previous results (false).
  COPY_SPREADSHEET: false,

  // Array of addresses to be alerted via email if rule failures are found.
  RECIPIENT_EMAILS: [
    'YOUR_EMAIL_HERE'
  ]
};

// See applyRule() below for examples of each type.
const RULE_TYPES = {
  NO_TRIGGER: 'Rule applies to all entities of test entity type',
  BROAD_TRIGGER: 'Trigger entity type is broader than test entity type',
  NARROW_TRIGGER: 'Test entity type is broader than trigger entity type',
  SAME_TYPE: 'Trigger and test entity types are the same',
  SIBLING_TYPE: 'Trigger and test entity types are different but the same level'
};

// Static information for each type of entity.
// The keys in this table match the drop-down values in the spreadsheet and the
// values returned by getEntityType().
// selector: Name of the method used by a higher-level entity to retrieve a
//     selector for its descendant entities of this type.
// getter: Name of the method used by a lower-level entity to retrieve its
//     ancestor entity of this type.
// asText: Either:
//     (a) A string representing the method for this entity which returns a text
//         representation, OR
//     (b) A function, which when passed the entity as a single argument,
//         returns a text representation.
// level: Where the entity falls in the entity hierarchy. Lower numbers
//     represent broader entity types.
// parentType: The type of the entity's parent.
const ENTITY_TYPE_INFO = {
  Campaign: {
    selector: 'campaigns',
    getter: 'getCampaign',
    asText: 'getName',
    level: 1
  },
  AdGroup: {
    selector: 'adGroups',
    getter: 'getAdGroup',
    asText: 'getName',
    level: 2,
    parentType: 'Campaign'
  },
  Ad: {
    selector: 'ads',
    // For ads, asText references a specific function, not a method name. For
    // details refer to the above JSDoc or @see getEntityDetails definition.
    asText: getAdAsText,
    level: 3,
    parentType: 'AdGroup'
  },
  Keyword: {
    selector: 'keywords',
    asText: 'getText',
    level: 3,
    parentType: 'AdGroup'
  }
};

function main() {
  const failures = findFailures();

  let spreadsheet = validateAndGetSpreadsheet(CONFIG.SPREADSHEET_URL);
  if (CONFIG.COPY_SPREADSHEET) {
    spreadsheet = spreadsheet.copy('Account Auditor');
  }
  initializeSpreadsheet(spreadsheet);

  const hasFailures = outputFailures(spreadsheet,
    AdsApp.currentAccount().getCustomerId(), failures);

  if (hasFailures) {
    validateEmailAddresses();
    sendEmail(spreadsheet);
  }
}

/**
 * Checks the account against all of the rules in the spreadsheet.
 *
 * @return {Array.<Object>} A list of failures found.
 */
function findFailures() {
  const rules = loadRules();
  const failures = [];

  for (const rule of rules) {
    failures.push(...applyRule(rule));
  }

  return failures;
}

/**
 * Saves failures to a spreadsheet if present.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 * @param {string} customerId The account the failures are for.
 * @param {Array.<Object>} failures A list of failures.
 * @return {boolean} True if there were failures and false otherwise.
 */
function outputFailures(spreadsheet, customerId, failures) {
  if (failures.length > 0) {
    saveFailuresToSpreadsheet(spreadsheet, customerId, failures);
    console.log(`Rule failures were found for ${customerId}.` +
               ` See ${spreadsheet.getUrl()}`);
    return true;
  } else {
    console.log(`No rule failures were found for ${customerId}.`);
    return false;
  }
}

/**
 * Sets up the spreadsheet to receive output.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 */
function initializeSpreadsheet(spreadsheet) {
  // Make sure the spreadsheet is using the account's timezone.
  spreadsheet.setSpreadsheetTimeZone(AdsApp.currentAccount().getTimeZone());

  // Clear the last run date on the spreadsheet.
  spreadsheet.getRangeByName('RunDate').clearContent();

  // Clear all rows in the Report tab of the spreadsheet below the header row.
  spreadsheet.getRangeByName('ReportHeaders')
    .offset(1, 0, spreadsheet.getSheetByName('Report')
        .getDataRange().getLastRow())
    .clearContent();
}

/**
 * Saves failures for a particular account to the spreadsheet starting at the
 * first unused row.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 * @param {string} customerId The account that the failures are for.
 * @param {Array.<Object>} failures A list of failure objects.
 */
function saveFailuresToSpreadsheet(spreadsheet, customerId, failures) {
  // Find the first open row on the Report tab below the headers and create a
  // range large enough to hold all of the failures, one per row.
  const lastRow = spreadsheet.getSheetByName('Report')
    .getDataRange().getLastRow();
  const headers = spreadsheet.getRangeByName('ReportHeaders');
  const outputRange = headers
    .offset(lastRow - headers.getRow() + 1, 0, failures.length);

  // Build each row of output values in the order of the Report tab columns.
  const outputValues = [];
  for (const failure of failures) {
    outputValues.push([
      customerId,
      failure.ruleId,
      '',                               // blank column
      failure.trigger.entityType,
      failure.trigger.entityId,
      failure.trigger.entityText,
      '',                               // blank column
      failure.test.entityType,
      failure.test.entityId || '',
      failure.test.entityText || '',

      // Include a leading apostrophe to ensure test expressions like "= 0" are
      // not mistakenly treated as formulas.
      failure.test.expected && ('\'' + failure.test.op + ' ' +
                                failure.test.expected) || '',
      failure.test.actual || ''
    ]);
  }
  outputRange.setValues(outputValues);

  spreadsheet.getRangeByName('RunDate').setValue(new Date());

  for (const email of CONFIG.RECIPIENT_EMAILS) {
    spreadsheet.addEditor(email);
  }
}

/**
 * Sends an email alert that failures were found.
 *
 * @param {Object} spreadsheet The spreadsheet object.
 */
function sendEmail(spreadsheet) {
  MailApp.sendEmail(CONFIG.RECIPIENT_EMAILS.join(','),
      'Account Auditor Script: Rule Failures Were Found',
      `The Account Auditor Script found cases where the entities in your ` +
      `Google Ads account(s) did not meet your rules. ` +
      `See the Report tab of ${spreadsheet.getUrl()} for details.`);
}

/**
 * Loads the rules from the spreadsheet.
 *
 * @return {Array.<Object>} A list of rule objects.
 */
function loadRules() {
  const rules = [];
  const spreadsheet = validateAndGetSpreadsheet(CONFIG.SPREADSHEET_URL);
  const lastRow = spreadsheet.getSheetByName('Rules').getDataRange().getLastRow();

  // Get all of the rows below the header row.
  const ruleData = spreadsheet.getRange('RuleHeaders')
      .offset(1, 0, lastRow).getValues();

  const ruleIds = {};
  let i = 0;

  for (const ruleDatum of ruleData){
    if(ruleDatum[0]){
      const rule = parseRule(ruleDatum);
      rules.push(rule);

      // Rule IDs must be unique.
      if (!ruleIds[rule.id]) {
        ruleIds[rule.id] = true;
      } else {
        throw `Multiple rules with ID ${rule.id}. Check the spreadsheet ` +
          `to make sure every rule has a unique ID.`;
      }

      console.log(`Loaded rule ${rule.id}.`);
      i++;
    }
  }

  console.log(`Total number of rules: ${i}.`);
  return rules;
}

/**
 * Parses a row from the spreadsheet into a rule object.
 *
 * @param {Array} ruleData A row from the spreadsheet.
 * @return {Object} A rule object.
 */
function parseRule(ruleData) {
  const rule = {
    id: ruleData[0].toString()
  };

  if (!rule.id) {
    throw 'Rule missing ID. Check the spreadsheet to make sure every rule ' +
        'has a unique ID.';
  }

  // Tests are required.
  if (!ruleData[5]) {
    throw `Rule ${rule.id} test is missing entity type.`;
  } else {
    rule.test = {
      entityType: ruleData[5],
      conditions: ruleData[6] && ruleData[6].split('\n')
    };
  }

  // Triggers are optional, but it is invalid to have a condition without an
  // entity type.
  if (ruleData[3] && !ruleData[2]) {
    throw `Rule ${rule.id} trigger has conditions without an entity type.`;
  } else if (ruleData[2]) {
    rule.trigger = {
      entityType: ruleData[2],
      conditions: ruleData[3] && ruleData[3].split('\n')
    };
  }

  // Count expressions are required.
  parsedExpr = parseCountExpr(ruleData[7]);
  if (!parsedExpr) {
    throw `Rule ${rule.id} has invalid expression for expected number.`;
  }
  rule.test.op = parsedExpr.op;
  rule.test.num = parsedExpr.num;

  rule.type = getRuleType(rule);

  // Certain rule types can only use 'All'.
  if ((rule.type == RULE_TYPES.NARROW_TRIGGER ||
       rule.type == RULE_TYPES.SAME_TYPE) &&
      rule.test.op != 'All') {
    throw `Rule ${rule.id} must use "All" for the expected number.`;
  }

  return rule;
}

/**
 * Parses a simple relational expression.
 *
 * @param {string} expr An expression of the form 'Op Num', where Op is one
 *     of '=', '!=', '<', '>', '<=', or '>=' and Num is a non-negative integer
 *     or the special expression 'All'. If Op is omitted it is assumed to be
 *     '='.
 * @return {Object} The parsed Op and Num, or undefined if the parse failed.
 */
function parseCountExpr(expr) {
  expr = expr.toString();

  // Check for the special expression 'All'.
  if (expr.match(/^\s*All\s*$/i)) {
    return {
      op: 'All'
    };
  }

  // If the operator is missing, prepend '=' as the default operator.
  if (expr.match(/^\s*\d*\s*$/)) {
    expr = '=' + expr;
  }

  const regex = /^\s*(\!?\=|\<\=?|\>\=?)\s*(\d+)\s*$/;
  const result = regex.exec(expr);

  if (result) {
    return {
      op: result[1],
      num: result[2]
    };
  }
}

/**
 * Determines the type of rule evaluation strategy to apply.
 *
 * @param {Object} rule A rule object.
 * @return {string} The type of rule evaluation strategy to apply.
 */
function getRuleType(rule) {
  if (!rule.trigger) {
    return RULE_TYPES.NO_TRIGGER;
  } else if (ENTITY_TYPE_INFO[rule.test.entityType].level >
      ENTITY_TYPE_INFO[rule.trigger.entityType].level) {
    return RULE_TYPES.BROAD_TRIGGER;
  } else if (ENTITY_TYPE_INFO[rule.test.entityType].level <
      ENTITY_TYPE_INFO[rule.trigger.entityType].level) {
    return RULE_TYPES.NARROW_TRIGGER;
  } else if (rule.test.entityType == rule.trigger.entityType) {
    return RULE_TYPES.SAME_TYPE;
  } else {
    return RULE_TYPES.SIBLING_TYPE;
  }
}

/**
 * Retrieves a text representation of an ad, casting the ad to the appropriate
 * type if necessary.
 *
 * @param {Ad} ad The ad object.
 * @return {string} The text representation.
 */
function getAdAsText(ad) {
  // There is no AdTypeSpace method for textAd
  let headline;
    switch (ad.ad.type) {
      case 'TEXT_AD':
        headline = ad.ad.text_ad.headline;
        break;
      case 'EXPANDED_TEXT_AD':
        headline = ad.ad.expandedTextAd.headlinePart1 + ' - '
        + ad.ad.expandedTextAd.headlinePart2;
        break;
      case 'RESPONSIVE_DISPLAY_AD':
        headline = ad.ad.responsiveDisplayAd.long_headline;
        break;
      case 'VIDEO_RESPONSIVE_AD':
        headline =
           ad.ad.videoResponsiveAd.headlines.
            map(asset => asset.text).join(', ');
        break;
      case 'RESPONSIVE_SEARCH_AD':
        headline =
           ad.ad.responsiveSearchAd.headlines.
            map(asset => asset.text).join(', ');
        break;
      case 'APP_ENGAGEMENT_AD':
        headline =
           ad.ad.appEngagementAd.headlines.map(asset => asset.text).join(', ');
        break;
      case 'APP_AD':
        headline = ad.ad.appAd.headlines.map(asset => asset.text).join(', ');
        break;
      case 'CALL_AD':
        headline = ad.ad.callAd.headline1 + ' - '
        + ad.ad.callAd.headline2;
        break;
      case 'GMAIL_AD':
        headline = ad.ad.gmailAd.marketingImageHeadline;
        break;
      case 'LEGACY_RESPONSIVE_DISPLAY_AD':
        headline = ad.ad.legacyResponsiveDisplayAd.long_headline;
        break;
      case 'LOCAL_AD':
        headline = ad.ad.localAd.headlines.map(asset => asset.text).join(', ');
        break;
      case 'SHOPPING_COMPARISON_LISTING_AD':
        headline = ad.ad.shoppingComparisonListingAd.headline;
        break;
      case 'SMART_CAMPAIGN_AD':
        headline =
           ad.ad.smartCampaignAd.headlines.map(asset => asset.text).join(', ');
        break;
      case 'VIDEO_AD':
        headline = ad.ad.videoAd.discovery.headline;
        break;
      default :
        headline = 'N/A';
    }
    return headline;
}

/**
 * Finds all cases where entities in the account fail to match a rule.
 *
 * @param {Object} rule A rule object.
 * @return {Array.<Object>} A list of failure objects, each describing a case
 *     where the rule was not met.
 */
function applyRule(rule) {
  console.log(`Applying rule ${rule.id}`);

  const failures = [];

  const testInfo = ENTITY_TYPE_INFO[rule.test.entityType];
  let triggerSelector = {};
  let triggers = {};

  // Get the trigger entities.
  if (rule.type != RULE_TYPES.NO_TRIGGER) {
    const triggerInfo = ENTITY_TYPE_INFO[rule.trigger.entityType];
    triggerSelector = AdsApp[triggerInfo.selector]();
    addConditions(triggerSelector, rule.trigger.conditions);
    triggers = triggerSelector.get();
  }

  // Helper method to get details about the entity associated with a failure.
  const getEntityDetails = function(entity) {
    const entityType = entity && entity.getEntityType();
    let text = 'N/A';

    if (entityType) {
      const fn = ENTITY_TYPE_INFO[entityType].asText;

      if (typeof fn === 'string') {
        // Specified as a string method name to get a text representation.
        text = entity[fn]();
      } else if (typeof fn === 'function') {
        // Specified as a function, to which the entity is passed to extract a
        // text representation. Used in the case of Ads, which can have a number
        // of underlying types.
        text = fn(entity);
      }
    }

    return {
      entityId: entity ? entity.getId() : 'N/A',
      entityType: entity ? entityType : 'N/A',
      entityText: text ? text : 'N/A'
    };
  };

  // Helper method to build failures for each entity in a selector that does
  // not match the test conditions.
  const checkAll = function(params) {
    const entities = findMissingEntities(params.selector, rule.test.conditions);

    for (const entity of entities) {

      // The trigger is either provided as a map, indicated to be the entity
      // itself, or provided directly (possibly null if there is no trigger).
      const trigger = params.triggerMap && params.triggerMap[entity.getId()] ||
        (params.trigger == 'entity' && entity || params.trigger);

      failures.push({
        ruleId: rule.id,
        trigger: getEntityDetails(trigger),
        test: getEntityDetails(entity)
      });
    }
  };

  // Helper method to build failures where the number of entities in a
  // selector does not match the test's expected number.
  const checkCount = function(params) {
    addConditions(params.selector, rule.test.conditions);
    const expected = rule.test.num;
    const actual = params.selector.get().totalNumEntities();
    const op = rule.test.op || '=';

    if (!compare(actual, expected, op)) {
      failures.push({
        ruleId: rule.id,
        trigger: getEntityDetails(params.trigger),
        test: {
          entityType: rule.test.entityType,
          expected: expected,
          op: op,
          actual: actual
        }
      });
    }
  };

  // Helper method to use the appropriate checker depending on the operator.
  const checkRule = function(params) {
    if (rule.test.op == 'All') {
      checkAll(params);
    } else {
      checkCount(params);
    }
  };

  switch (rule.type) {
    case RULE_TYPES.NO_TRIGGER:
      // Example: All campaigns are enabled (test).
      // Example: There are X enabled campaigns (test).
      checkRule({selector: AdsApp[testInfo.selector]()});
      break;

    case RULE_TYPES.NARROW_TRIGGER:
      // Example: For each enabled ad group (trigger), its campaign is enabled
      // (test).
      const testIds = [];
      const triggerMap = {};

      for (const trigger of triggers) {
        const testId = trigger[testInfo.getter]().getId();
        testIds.push(testId);
        triggerMap[testId] = trigger;
      }

      checkAll({
        triggerMap: triggerMap,
        selector: AdsApp[testInfo.selector]().withIds(testIds)
      });
      break;

    case RULE_TYPES.BROAD_TRIGGER:
      // Example: For each enabled campaign (trigger), all of its ad groups are
      // enabled (test).
      // Example: For each enabled campaign (trigger), it has at least X enabled
      // ad groups (test).
      for (const trigger of triggers) {
        const testSelector = trigger[testInfo.selector]();
        checkRule({trigger: trigger, selector: testSelector});
      }
      break;

    case RULE_TYPES.SAME_TYPE:
      // Example: For each ad group with label X (trigger), it is enabled
      // (test).
      checkAll({trigger: 'entity', selector: triggerSelector});
      break;

    case RULE_TYPES.SIBLING_TYPE:
      // Example: For each enabled ad (trigger), it is associated with at least
      // X approved keywords (test).
      const parentIds = {};

      for (const trigger of triggers) {
        const parent = trigger[ENTITY_TYPE_INFO[triggerInfo.parentType].getter]();
        const parentId = parent.getId();

        // If we have seen the parent already, it is unnecessary and inefficient
        // to check again since the siblings are the same. This means only the
        // first sibling will be listed as the trigger for any failures.
        if (!parentIds[parentId]) {
          parentIds[parentId] = true;
          const testSelector = parent[testInfo.selector]();
          checkRule({trigger: trigger, selector: testSelector});
        }
      }
      break;
  }

  return failures;
}

/**
 * Adds a set of conditions to a selector. The provided selector is modified.
 *
 * @param {Object} selector A Google Ads scripts selector.
 * @param {Array.<string>} conditions An array of conditions to be applied to
 *     the selector using withCondition().
 */
function addConditions(selector, conditions) {
  if (conditions) {
    for (const condition of conditions) {
      selector = selector.withCondition(condition);
    }
  }
}

/**
 * Retrieves the IDs of a set of Google Ads scripts entities.
 *
 * @param {Object} iterator An iterator over a Google Ads scripts entity that
 *     has a getId() method.
 * @return {Array.<number>} An array of IDs of the entities.
 */
function getEntityIds(iterator) {
  const ids = [];

  for (const iteratorElement of iterator) {
    ids.push(iteratorElement.getId());
  }

  return ids;
}

/**
 * Identifies entities in a selector that do not match a set of conditions.
 * Modifies the given selector.
 *
 * @param {Object} selector A Google Ads scripts selector for an entity type.
 *     that has a getId() method.
 * @param {Array.<string>} conditions An array of conditions to be applied to
 *     the selector using withCondition().
 * @return {Array.<Object>} A list of Google Ads objects that did not meet the
 *     conditions.
 */
function findMissingEntities(selector, conditions) {
  const missing = [];

  // Get an iterator *before* applying the conditions.
  const entities = selector.get();

  // Get the IDs of the entities matching the conditions.
  addConditions(selector, conditions);
  const ids = getEntityIds(selector.get());

  for (entity of entities) {
    if (ids.indexOf(entity.getId()) == -1) {
      missing.push(entity);
    }
  }

  return missing;
}

/**
 * Compares two numbers using a given operator.
 *
 * @param {number} val1 The first number in the comparison.
 * @param {number} val2 The second number in the comparison.
 * @param {string} op The operator for the comparison.
 * @return {boolean} The result of the comparison 'val1 op val2'.
 */
function compare(val1, val2, op) {
  switch (op) {
    case '=':
      return val1 == val2;
    case '!=':
      return val1 != val2;
    case '<':
      return val1 < val2;
    case '>':
      return val1 > val2;
    case '<=':
      return val1 <= val2;
    case '>=':
      return val1 >= val2;
  }
}

/**
 * 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 == 'YOUR_SPREADSHEET_URL') {
    throw new Error('Please specify a valid Spreadsheet URL. You can find' +
        ' a link to a template in the associated guide for this script.');
  }
  const spreadsheet = SpreadsheetApp.openByUrl(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 (CONFIG.RECIPIENT_EMAILS &&
      CONFIG.RECIPIENT_EMAILS[0] == 'YOUR_EMAIL_HERE') {
    throw new Error('Please specify a valid email address.');
  }
}