Intermediate Free

Search Term N-Grams (Standard Shopping)

Turn your search terms into nGrams. Use as a reporting tool, alert system, or add negative keywords directly to a Negative Keyword List.

Updated: 2026-01-20

Turn your search terms into nGrams

This script will create 1, 2, 3, and 4 word nGrams from search terms.

Important: This script is for Standard Shopping campaigns only. It will not work for Performance Max. See our PMax Search Term N-Grams script for Performance Max campaigns.

What can this script do?

  • 📊 Reporting - Generate detailed nGram reports in Google Sheets
  • 🚨 Alerts - Send an email if an nGram gets over X clicks without a conversion
  • âž– Add Negative Keywords - Automatically add nGrams as negative keywords to a Negative Keyword List (optional)

What's an nGram?

Let's say we have two search terms:

  • google ads api development
  • bing ads api development

The 1 word nGrams will be: google, bing, ads, api, development

The 2 word nGrams will be: google ads, ads api, api development, bing ads

Importantly, metrics will also be combined to provide total clicks, cost, conversions etc. per nGram.

Use Cases

Use this script to:

  • Run a report to analyze search term patterns
  • Set up alerts e.g. send an email if an nGram gets over 100 clicks without a conversion
  • Automatically add poor-performing nGrams as negative keywords

What if I have suggestions?

Please let me know! Hearing your pain points is the number one way we can make improvements.

The Template

Google Sheets Template

Make a copy of the template, copy your new Sheet's URL, and paste it atop the Script.

The Script

Search Term N-Grams (Standard Shopping)
/**
 * Account Search Term NGrams
 * @author Charles Bannister
 * @version 1.0.0
 * Free updates & support at  https://shabba.io/script/4
 * 
**/

// Template: https://docs.google.com/spreadsheets/d/1L6ty0u7OtD3Ed5h4SBN4mCwkbAqYsaw_18kOLQDr6Tk
// File > Make a copy or visit https://docs.google.com/spreadsheets/d/1L6ty0u7OtD3Ed5h4SBN4mCwkbAqYsaw_18kOLQDr6Tk/copy
let INPUT_SHEET_URL = "YOUR_SPREADSHEET_URL_HERE";





var INPUT_TAB_NAME = "Settings";

var h = new Helper();
var s = new Setting();

var masterListSheetName = "Already Added";

const SCRIPT_NAME = "nGrams";
const SHABBA_SCRIPT_ID = 4;

function runScript(SETTINGS) {
  processRowSettings(SETTINGS);

  log(JSON.stringify(SETTINGS));

  checkSettings(SETTINGS);

  new setupOutputSheet(SETTINGS);

  addNegatives(SETTINGS);

  let nGramsFound = getQueriesAndWriteToSheet(SETTINGS);

  SETTINGS.LOGS.push("The script ran successfully");
  updateControlSheet("", SETTINGS);

  if (nGramsFound && SETTINGS['NOTIFY']) {
    sendEmail(SETTINGS);
  }

  log("Finished");
}

function sendEmail(SETTINGS) {
  if (SETTINGS.PREVIEW_MODE) return;
  log("Sending email")

  //Send email
  var SUB =
    SETTINGS['NAME'] +
    " - nGrams Script has Results";
  var MSG =
    `Hi,<br><br>nGrams were found for the "${SETTINGS['NAME']}" account
        <br>
        You can view the nGrams here: ${SETTINGS['LOG_SHEET_URL']}
        <br>
        You can view the settings sheet here: ${INPUT_SHEET_URL}
        `;

  var emails = SETTINGS.EMAILS;

  for (var i in emails) {
    MailApp.sendEmail({
      to: emails[i],
      subject: SUB,
      htmlBody: MSG
    });
  }
}

function addNegatives(SETTINGS) {
  log(
    `Adding negative keywords to the '${SETTINGS["NEGATIVE_KEYWORD_LIST"]}' list...`
  );
  var nGrams = getNegativesFromSheet(SETTINGS);

  log(nGrams);

  if (nGrams.length == 0) return;

  let negativeList = getNegativeList(SETTINGS["NEGATIVE_KEYWORD_LIST"]);

  let negativeKeywords = [];

  for (var nGramIndex in nGrams) {
    var row = nGrams[nGramIndex];
    // log(JSON.stringify(row))
    let negativeKeyword = addMatchType(row.nGram, row.matchType);
    negativeKeywords.push(negativeKeyword);
  }
  negativeList.addNegativeKeywords(negativeKeywords);
}

function getNegativeList(listName) {
  var listIter = AdWordsApp.negativeKeywordLists()
    .withCondition("Name = '" + listName + "'")
    .get();

  if (listIter.hasNext()) {
    return listIter.next();
  } else {
    throw "The shared negative list ('" + listName + "') can't be found";
  }
}

function getNegativesFromSheet(SETTINGS) {
  var sheetNames = SETTINGS.tabNames.slice(1);

  var nGrams = [];

  for (var s in sheetNames) {
    var sheetName = sheetNames[s];

    var sheet = SETTINGS.logSS.getSheetByName(sheetName);

    var data = sheet.getDataRange().getValues();
    var header = data.shift();
    let addAsNegativeIndex = header.indexOf("Add as negative?");
    let matchTypeIndex = header.indexOf("Match type");
    // log('addAsNegativeIndex: ' + addAsNegativeIndex)
    for (var d in data) {
      var row = data[d];
      var nGram = String(row[header.indexOf("nGram")]);
      var addBool = row[addAsNegativeIndex];
      var matchType = row[matchTypeIndex];

      if (!addBool) continue;
      nGrams.push({
        nGram,
        matchType
      });
    }
  }

  if (!SETTINGS.PREVIEW_MODE) {
    addCheckedQueriesToMasterSheet(SETTINGS, nGrams);
  } else {
    SETTINGS.LOGS.push(
      "Running in preview mode, so the 'Already Added' list won't be updated"
    );
  }

  return nGrams;
}

function addMatchType(keyword, matchType) {
  matchType = matchType.toLowerCase();
  if (matchType === "exact") return "[" + keyword + "]";
  if (matchType === "phrase") return '"' + keyword + '"';
  if (matchType === "broad") return keyword;

  throw "Match type '" +
  matchType +
  "' not recognised. Please check the settings.";
}

function addCheckedQueriesToMasterSheet(SETTINGS, nGrams) {
  var len = Object.keys(nGrams).length;
  if (len === 0) return;

  SETTINGS.LOGS.push(
    "Adding " +
    String(parseInt(len)) +
    " checked nGrams to the 'Already Added' list"
  );

  var logArray = [];

  for (var nGramIndex in nGrams) {
    var row = nGrams[nGramIndex];
    let negativeKeyword = addMatchType(row.nGram, row.matchType);
    logArray.push([negativeKeyword, SETTINGS["NEGATIVE_KEYWORD_LIST"]]);
  }

  var sheet = SETTINGS.logSS.getSheetByName(masterListSheetName);
  sheet
    .getRange(
      sheet.getLastRow() + 1,
      1,
      logArray.length,
      logArray[0].length
    )
    .setValues(logArray);
}

function getQueryWhereString(SETTINGS) {
  var where = "where CampaignStatus in [ENABLED,PAUSED,REMOVED]  ";

  var whereArray = [];

  for (var i in SETTINGS.AD_GROUP_NAME_CONTAINS) {
    whereArray.push(
      " and AdGroupName CONTAINS_IGNORE_CASE '" +
      SETTINGS.AD_GROUP_NAME_CONTAINS[i].trim() +
      "'"
    );
  }

  if (SETTINGS.CAMPAIGN_NAME_EQUALS === "") {
    for (var i in SETTINGS.CAMPAIGN_NAME_CONTAINS) {
      whereArray.push(
        " and CampaignName CONTAINS_IGNORE_CASE '" +
        SETTINGS.CAMPAIGN_NAME_CONTAINS[i].trim() +
        "'"
      );
    }

    for (var i in SETTINGS.CAMPAIGN_NAME_NOT_CONTAINS) {
      whereArray.push(
        " and CampaignName DOES_NOT_CONTAIN_IGNORE_CASE '" +
        SETTINGS.CAMPAIGN_NAME_NOT_CONTAINS[i].trim() +
        "'"
      );
    }
  } else {
    where +=
      " and CampaignName = '" +
      SETTINGS.CAMPAIGN_NAME_EQUALS.trim() +
      "'";
  }

  for (var i in SETTINGS.QUERY_NOT_CONTAINS) {
    whereArray.push(
      " and Query DOES_NOT_CONTAIN_IGNORE_CASE '" +
      SETTINGS.QUERY_NOT_CONTAINS[i].trim() +
      "'"
    );
  }

  if (String(SETTINGS["MIN_QUERY_CLICKS"]).trim() != "") {
    whereArray.push(`and Clicks > ${SETTINGS["MIN_QUERY_CLICKS"]}`);
  }

  if (String(SETTINGS["MIN_QUERY_IMPRESSIONS"]).trim() != "") {
    whereArray.push(
      `and Impressions > ${SETTINGS["MIN_QUERY_IMPRESSIONS"]}`
    );
  }

  where += whereArray.join(" ");

  return where;
}

function writePotentialQueriesToSheet(SETTINGS, potentialQueries, sheet) {
  sheet.clear();

  var checkBoxCol = 3;

  sheet
    .getRange(2, checkBoxCol, sheet.getMaxRows(), 1)
    .clearDataValidations();

  if (potentialQueries.length < 2) return;

  //write the data

  sheet
    .getRange(1, 1, potentialQueries.length, potentialQueries[0].length)
    .setValues(potentialQueries);

  //sort by cost
  sheet.sort(8, false);

  //format
  sheet
    .getRange(1, 5, sheet.getLastRow(), 6)
    .setNumberFormat("0");
  sheet
    .getRange(1, 8, sheet.getLastRow(), sheet.getLastColumn())
    .setNumberFormat("0.00");
  sheet.getRange(2, 7, sheet.getLastRow(), 1).setNumberFormat("0.00%");

  var enforceCheckbox = SpreadsheetApp.newDataValidation();
  enforceCheckbox.requireCheckbox();
  enforceCheckbox.setAllowInvalid(true);
  enforceCheckbox.build();

  var range = sheet.getRange(2, checkBoxCol, potentialQueries.length - 1, 1);
  range.setDataValidation(enforceCheckbox);

  var enforceDropdown = SpreadsheetApp.newDataValidation();
  enforceDropdown.requireValueInList(["Exact", "Phrase", "Broad"], true);
  enforceDropdown.setAllowInvalid(true);
  enforceDropdown.build();
  range = sheet.getRange(2, checkBoxCol + 1, potentialQueries.length - 1, 1);
  range.setDataValidation(enforceDropdown);
}

/**
 * Returns true if ngrams were found
 */
function getQueriesAndWriteToSheet(SETTINGS) {

  var sheetNames = SETTINGS.tabNames;
  sheetNames.shift();

  var alreadyAddedNegatives = SETTINGS.logSS
    .getSheetByName(masterListSheetName)
    .getDataRange()
    .getValues()
    .map(function (x) {
      return String(x[0])
        .replace("[", "")
        .replace("]", "")
        .replace('"', "")
        .replace('"', "")
        .toLowerCase();
    });
  alreadyAddedNegatives.shift();
  alreadyAddedNegatives.shift();
  log("alreadyAddedNegatives: " + alreadyAddedNegatives);

  var map = getNGramMap(SETTINGS);

  let nGramsFound = false

  for (var s in sheetNames) {
    var logArray = [
      [
        "Date",
        "nGram",
        "Add as negative?",
        "Match type",
        "Clicks",
        "Impressions",
        "Ctr",
        "Cost",
        "Conversions",
        "Cpa",
        "Conversion Value",
        "ROAS"
      ]
    ];

    var sheetName = sheetNames[s];
    var sheet = SETTINGS.logSS.getSheetByName(sheetName);
    var nGrams = map[sheetName];
    for (var nGram in nGrams) {
      var row = nGrams[nGram];

      if (
        alreadyAddedNegatives.indexOf(nGram) > -1 &&
        SETTINGS["DONT_LOG_ADDED_NEGATIVES"]
      )
        continue;

      row.CTR =
        row.Impressions == 0
          ? 0
          : round(row.Clicks / row.Impressions, 4);
      row.CPA =
        row.Conversions == 0 ? 0 : round(row.Cost / row.Conversions, 2);
      row.ROAS =
        row.ConversionValue == 0
          ? 0
          : round(row.ConversionValue / row.Cost, 2);
      row.CVR = row.Conversions > 0 ? row.Conversions / row.Clicks : 0;

      if (filterNGram(row, SETTINGS)) continue;
      nGramsFound = true
      var logRow = [
        SETTINGS.NOW,
        String(nGram),
        "",
        SETTINGS.NEGATIVE_KEYWORD_DEFAULT_MATCH_TYPE,
        row.Clicks,
        row.Impressions,
        row.CTR,
        row.Cost,
        row.Conversions,
        row.CPA,
        row.ConversionValue,
        row.ROAS
      ];
      logArray.push(logRow);
    }

    log("Found " + String(parseInt(logArray.length) - 1) + " " + sheetName);
    writePotentialQueriesToSheet(SETTINGS, logArray, sheet);
  }
  return nGramsFound
}

function filterNGram(row, SETTINGS) {
  var filter = false; //whether to filter out the ngram

  if (SETTINGS.MIN_CPA === "" || row.CPA > SETTINGS.MIN_CPA) {
    //leave false
  } else {
    return true;
  }
  if (SETTINGS.MAX_ROAS === "" || row.ROAS <= SETTINGS.MAX_ROAS) {
    //leave false
  } else {
    return true;
  }

  if (
    SETTINGS.MAX_CONVERSIONS === "" ||
    row.Conversions < SETTINGS.MAX_CONVERSIONS
  ) {
    //leave false
  } else {
    return true;
  }

  if (SETTINGS.MIN_CLICKS === "" || row.Clicks >= SETTINGS.MIN_CLICKS) {
    //leave false
  } else {
    return true;
  }

  return filter;
}

function getNGramMap(SETTINGS) {
  var sheetNames = SETTINGS.tabNames;

  var checkedQueries = SETTINGS.logSS
    .getSheetByName(sheetNames[0])
    .getDataRange()
    .getValues()
    .map(function (x) {
      return x[0];
    });

  checkedQueries.shift();

  var OPTIONS = { includeZeroImpressions: false };
  var cols = [
    "Query",
    "ConversionValue",
    "Impressions",
    "Clicks",
    "Cost",
    "Conversions"
  ];

  var reportName = "SEARCH_QUERY_PERFORMANCE_REPORT";

  var query = [
    "select",
    cols.join(","),
    "from",
    reportName,
    getQueryWhereString(SETTINGS),
    "during",
    SETTINGS.DATE_RANGE
  ].join(" ");

  log("Query: " + query);

  var map = { "1-grams": {}, "2-grams": {}, "3-grams": {}, "4-grams": {} };

  let queryCount = 0

  var reportIter = AdWordsApp.report(query, OPTIONS).rows();
  while (reportIter.hasNext()) {
    var row = reportIter.next();
    queryCount++
    row.Impressions = parseInt(row.Impressions, 10);
    row.Clicks = parseInt(row.Clicks, 10);
    row.Conversions = parseFloat(row.Conversions);
    row.Cost = parseFloat(row.Cost.toString().replace(/,/g, ""));
    row.ConversionValue = parseFloat(
      row.ConversionValue.toString().replace(/,/g, "")
    );

    var nGrams = row.Query.split(" ");
    var metrics = [
      "Cost",
      "Impressions",
      "Clicks",
      "Conversions",
      "ConversionValue"
    ];

    for (n in nGrams) {
      //1 grams
      var nGram = nGrams[n];

      if (checkedQueries.indexOf(nGram) > -1) continue;
      map["1-grams"][nGram] = map["1-grams"][nGram] || {};
      for (var m in metrics) {
        map["1-grams"][nGram][metrics[m]] =
          map["1-grams"][nGram][metrics[m]] + row[metrics[m]] ||
          row[metrics[m]];
      }

      //2 words
      if (nGrams[parseInt(n) + 1]) {
        var biGram = nGrams[n] + " " + nGrams[parseInt(n) + 1];
        if (checkedQueries.indexOf(biGram) > -1) continue;
        map["2-grams"] = map["2-grams"] || {};
        map["2-grams"][biGram] = map["2-grams"][biGram] || {};
        for (var m in metrics) {
          map["2-grams"][biGram][metrics[m]] =
            map["2-grams"][biGram][metrics[m]] + row[metrics[m]] ||
            row[metrics[m]];
        }
      }

      //3 words
      if (nGrams[parseInt(n) + 1] && nGrams[parseInt(n) + 2]) {
        var biGram =
          nGrams[n] +
          " " +
          nGrams[parseInt(n) + 1] +
          " " +
          nGrams[parseInt(n) + 2];
        if (checkedQueries.indexOf(biGram) > -1) continue;
        map["3-grams"] = map["3-grams"] || {};
        map["3-grams"][biGram] = map["3-grams"][biGram] || {};
        for (var m in metrics) {
          map["3-grams"][biGram][metrics[m]] =
            map["3-grams"][biGram][metrics[m]] + row[metrics[m]] ||
            row[metrics[m]];
        }
      }

      //4 words
      if (
        nGrams[parseInt(n) + 1] &&
        nGrams[parseInt(n) + 2] &&
        nGrams[parseInt(n) + 3]
      ) {
        var biGram =
          nGrams[n] +
          " " +
          nGrams[parseInt(n) + 1] +
          " " +
          nGrams[parseInt(n) + 2] +
          " " +
          nGrams[parseInt(n) + 3];
        if (checkedQueries.indexOf(biGram) > -1) continue;
        map["4-grams"] = map["4-grams"] || {};
        map["4-grams"][biGram] = map["4-grams"][biGram] || {};
        for (var m in metrics) {
          map["4-grams"][biGram][metrics[m]] =
            map["4-grams"][biGram][metrics[m]] + row[metrics[m]] ||
            row[metrics[m]];
        }
      }
    }
  }

  //log(JSON.stringify(map))
  log(`${queryCount} queries were found for consideration under the date range and settings provided.`)
  return map;
}

function setupOutputSheet(SETTINGS) {
  this.setupTabs = function (logSS, tabName, newSheet) {
    var outputTab = logSS.getSheetByName(tabName);

    if (tabName == masterListSheetName) {
      outputTab
        .getRange("A1")
        .setValue(
          "Previously added negative keywords will be stored here. They can optionally be used to prevent the same nGrams appearing again."
        );
      outputTab.getRange("A2").setValue("Negative Keyword");
      outputTab.getRange("B2").setValue("Negative Keyword List");
    }

    outputTab
      .getRange(1, 1, 1, outputTab.getMaxColumns())
      .setFontWeight("bold");

    var maxColumns = outputTab.getMaxColumns(); //total number of cols

    var lastColumn =
      outputTab.getLastColumn() < 7 ? 7 : outputTab.getLastColumn(); //number of populated cols

    var numCols = maxColumns - lastColumn;

    if (numCols > 0) {
      outputTab.deleteColumns(lastColumn + 1, numCols);
    }
  };

  this.createTabs = function (tabNames, logSS) {
    //attempt to rename
    var logSheets = logSS.getSheets();
    for (var l in logSheets) {
      var logSheet = logSheets[l];
      try {
        logSheet.setName(tabNames[l]);
      } catch (e) { }
    }
    //attempt to create
    for (var t in tabNames) {
      var tabName = tabNames[t];

      try {
        logSS.insertSheet(tabName);
      } catch (e) { }
    }
  };

  var reportName = SETTINGS.NAME + " - nGrams Output Sheet";

  var newSheet = SETTINGS.LOG_SHEET_URL == "";

  if (newSheet) {
    SETTINGS.LOGS.push("Creating new output sheet");

    var ss = SpreadsheetApp.create(reportName);
    SETTINGS.LOG_SHEET_URL = ss.getUrl();
  }

  var logSS = SpreadsheetApp.openByUrl(SETTINGS.LOG_SHEET_URL);

  if (logSS.getName() != reportName) {
    logSS.rename(reportName);
  }

  var editors = getEditorsFromSheet(
    SETTINGS.CONTROL_SHEET,
    SETTINGS.LOGS_COLUMN
  );
  h.addEditors(logSS, editors);

  SETTINGS.tabNames = [
    masterListSheetName,
    "1-grams",
    "2-grams",
    "3-grams",
    "4-grams"
  ];

  this.createTabs(SETTINGS.tabNames, logSS);

  //add days and hours to tabs
  for (var t in SETTINGS.tabNames) {
    this.setupTabs(logSS, SETTINGS.tabNames[t], newSheet);
  }

  SETTINGS.logSS = logSS;

  updateControlSheet("", SETTINGS);
}

/**
 * Get the editors from the sheet
 * @param {drive element} - main control (settings) sheet
 * @param {int} - Number of the column containing the logs
 * @return {array} editors
 **/
function getEditorsFromSheet(CONTROL_SHEET, logsColumn) {
  var editors = CONTROL_SHEET.getRange(1, logsColumn).getValue();
  if (editors == "") {
    return;
  }
  if (editors.indexOf(",") > -1) {
    editors = editors.split(",");
    for (var e in editors) {
      editors[e] = editors[e].trim().toLowerCase();
    }
  } else {
    editors = [editors.trim().toLowerCase()];
  }
  return editors;
}

/*

SETTINGS SECTION

*/

function getHeaderTypes() {
  var MCC_HEADER_TYPES = {};


  MCC_HEADER_TYPES = { ID: "normal" };

  var SINGLE_ACCOUNT_HEADER_TYPES = {
    NAME: "normal",
    EMAILS: "csv",
    NOTIFY: "bool",//notify if there are results?
    FLAG: "bool",
    NEGATIVE_KEYWORD_LIST: "normal",
    NEGATIVE_KEYWORD_DEFAULT_MATCH_TYPE: "normal",
    DONT_LOG_ADDED_NEGATIVES: "normal",
    NEGATIVE_KEYWORD_LIST: "normal",
    N: "normal",
    MIN_QUERY_IMPRESSIONS: "normal",
    MIN_QUERY_CLICKS: "normal",
    CAMPAIGN_NAME_CONTAINS: "csv",
    CAMPAIGN_NAME_NOT_CONTAINS: "csv",
    CAMPAIGN_NAME_EQUALS: "normal",
    QUERY_NOT_CONTAINS: "csv"
  };

  SINGLE_ACCOUNT_HEADER_TYPES2 = {
    MIN_CLICKS: "normal",
    MAX_CONVERSIONS: "normal",
    MIN_CPA: "normal",
    MAX_ROAS: "normal",
    PLACEHOLDER_1: "normal",
    PLACEHOLDER_2: "normal",
    PLACEHOLDER_3: "normal",
    PLACEHOLDER_4: "normal",
    PLACEHOLDER_5: "normal",
    LOG_SHEET_URL: "normal",
    LOGS_COLUMN: "normal"
  };

  var HEADER_TYPES = objectMerge(
    MCC_HEADER_TYPES,
    SINGLE_ACCOUNT_HEADER_TYPES,
    SINGLE_ACCOUNT_HEADER_TYPES2
  );

  return HEADER_TYPES;
}

function scanForAccounts() {
  log("getting settings...");

  var map = {};
  var controlSheet = SpreadsheetApp.openByUrl(INPUT_SHEET_URL).getSheetByName(
    INPUT_TAB_NAME
  );
  var data = SpreadsheetApp.openByUrl(INPUT_SHEET_URL)
    .getSheetByName(INPUT_TAB_NAME)
    .getDataRange()
    .getValues();
  data.shift();
  data.shift();
  data.shift();
  //log(data)

  HEADER_TYPES = getHeaderTypes();

  // log(JSON.stringify(HEADER_TYPES))

  var HEADER = Object.keys(HEADER_TYPES);

  var LOGS_COLUMN = 0;
  var col = 5;
  while (controlSheet.getRange(3, col).getValue()) {
    LOGS_COLUMN =
      controlSheet.getRange(3, col).getValue() == "Logs" ? col : 0;
    if (LOGS_COLUMN > 0) {
      break;
    }
    col++;
  }

  var flagPosition = HEADER.indexOf("FLAG");

  for (var k in data) {
    //if "run script" is not set to "yes", continue.

    if (!data[k][flagPosition]) {
      continue;
    }
    var rowNum = parseInt(k, 10) + 4;
    var id = data[k][0];
    var rowId = id + "/" + rowNum;
    map[id] = map[id] || {};
    map[id][rowId] = { ROW_NUM: parseInt(k, 10) + 4 };
    for (var j in HEADER) {
      if (HEADER[j] == "LOGS_COLUMN") {
        map[id][rowId][HEADER[j]] = LOGS_COLUMN;
        continue;
      }
      map[id][rowId][HEADER[j]] = data[k][j];
    }
  }

  var previousOperator = "";
  for (var id in map) {
    for (var rowId in map[id]) {
      for (var key in map[id][rowId]) {
        var isFilterValue =
          key.indexOf("FILTER") > -1 && key.indexOf("VALUE") > -1;
        var filter_metric = !isFilterValue
          ? ""
          : map[id][rowId][key.replace("VALUE", "METRIC")];
        map[id][rowId][key] = processSetting(
          key,
          map[id][rowId][key],
          HEADER_TYPES,
          controlSheet,
          previousOperator,
          filter_metric
        );
        previousOperator =
          key.indexOf("OPERATOR") > -1 ? map[id][rowId][key] : "";
      }
    }
  }
  return map;
}

function objectMerge() {
  for (var i = 1; i < arguments.length; i++)
    for (var a in arguments[i]) arguments[0][a] = arguments[i][a];
  return arguments[0];
}

function processSetting(key, value, HEADER, CONTROL_SHEET) {
  var type = HEADER[key];
  if (key == "ROW_NUM") {
    return value;
  }
  switch (type) {
    case "label":
      return [
        CONTROL_SHEET.getRange(
          3,
          Object.keys(HEADER).indexOf(key) + 1
        ).getValue(),
        value
      ];
      break;
    case "normal":
      return value;
      break;
    case "bool"://checkbox
      return value;
      break;
    case "number":
      return value == "" ? 0 : value;
      break;
    case "csv":
      var ret = value.split(",");
      ret = ret[0] == "" && ret.length == 1 ? [] : ret;
      if (ret.length == 0) {
        return [];
      } else {
        for (var r in ret) {
          ret[r] = String(ret[r]).trim();
        }
      }
      return ret;
      break;
    default:
      throw "error setting type " + type + " not recognised for " + key;
  }
}

function processRowSettings(SETTINGS) {
  SETTINGS.NOW = Utilities.formatDate(
    new Date(),
    AdWordsApp.currentAccount().getTimeZone(),
    "MMM dd, yyyy HH:mm:ss"
  );

  SETTINGS.CONTROL_SHEET = SpreadsheetApp.openByUrl(
    INPUT_SHEET_URL
  ).getSheetByName(INPUT_TAB_NAME);
  SETTINGS.LOGS_COLUMN = h.getLogsColumn(SETTINGS.CONTROL_SHEET);
  SETTINGS.LOGS = [];

  // log(JSON.stringify(SETTINGS))

  var defaultNote =
    "Possible problems include: 1) There was an error (check the logs within Google Ads) 2) The script was stopped before completion";
  SETTINGS.CONTROL_SHEET.getRange(
    SETTINGS.ROW_NUM,
    SETTINGS.LOGS_COLUMN,
    1,
    1
  )
    .setValue(
      "The script is either still running or didn't finish successfully"
    )
    .setNote(defaultNote);

  parseDateRange(SETTINGS);
  SETTINGS.PREVIEW_MODE = AdWordsApp.getExecutionInfo().isPreview();
}

function parseDateRange(SETTINGS) {
  var YESTERDAY = getAdWordsFormattedDate(1, "yyyyMMdd");

  SETTINGS.DATE_RANGE =
    getAdWordsFormattedDate(SETTINGS.N, "yyyyMMdd") + "," + YESTERDAY;
}

/**
 * Checks the settings for issues
 * @returns nothing
 **/
function checkSettings(SETTINGS) {
  //check the settings here
}

function log(msg) {
  Logger.log(AdWordsApp.currentAccount().getName() + " - " + msg);
}

function round(num, n) {
  return +(Math.round(num + "e+" + n) + "e-" + n);
}

function updateControlSheet(errorMessage, SETTINGS) {
  //remove the unsuccessful note we added
  SETTINGS.CONTROL_SHEET.getRange(
    SETTINGS.ROW_NUM,
    SETTINGS.LOGS_COLUMN,
    1,
    1
  ).setNote("");

  if (errorMessage != "") {
    SETTINGS.CONTROL_SHEET.getRange(
      SETTINGS.ROW_NUM,
      SETTINGS.LOGS_COLUMN,
      1,
      2
    ).setValues([[errorMessage, SETTINGS.NOW]]);
    SETTINGS.CONTROL_SHEET.getRange(
      SETTINGS.ROW_NUM,
      SETTINGS.LOGS_COLUMN,
      1,
      1
    ).setNote(
      "Note: Some rows running on account ID " +
      SETTINGS.ID +
      " may not have completed sucessfully. Please see their respective logs and 'Last Run' times."
    );
    throw errorMessage;
  }

  //add final logs
  //stringify logs
  logString = h.stringifyLogs(SETTINGS.LOGS);
  //update control sheet
  var put = [SETTINGS.LOG_SHEET_URL, logString, SETTINGS.NOW];
  SETTINGS.CONTROL_SHEET.getRange(
    SETTINGS.ROW_NUM,
    SETTINGS.LOGS_COLUMN - 1,
    1,
    3
  ).setValues([put]);
  SETTINGS.CONTROL_SHEET.getRange(
    SETTINGS.ROW_NUM,
    SETTINGS.LOGS_COLUMN,
    1,
    1
  ).setNote(logString);
}

/**
 * Get AdWords Formatted date for n days back
 * @param {int} d - Numer of days to go back for start/end date
 * @return {String} - Formatted date yyyyMMdd
 **/
function getAdWordsFormattedDate(d, format) {
  var date = new Date();
  date.setDate(date.getDate() - d);
  return Utilities.formatDate(
    date,
    AdWordsApp.currentAccount().getTimeZone(),
    format
  );
}

function Helper() {
  /**
   * Check if a string is a number, used when grabbing numbers from the sheet
   * If the string contains anything but numbers and a full stop (.) it returns false
   * @param {number as a string}
   * @returns {bool}
   **/
  this.isNumber = function (n) {
    if (typeof n == "number") return true;
    n = n.trim();
    var digits = n.split("");
    for (var d in digits) {
      if (digits[d] == ".") {
        continue;
      }
      if (isNaN(digits[d])) {
        return false;
      }
    }
    return true;
  };

  /**
   * Calculate ROAS
   * @param {number} - Conv. Value
   * @param {number} - Cost
   * @returns {number}
   **/
  this.calculateRoas = function (ConversionValue, Cost) {
    if (Cost == 0) return 0;
    if (ConversionValue == 0) return 0;
    if (Cost > ConversionValue) return 0;
    return ConversionValue / Cost;
  };

  /**
   * Return the column number of the logs column
   * @param {google sheet} control/settings sheet
   * @return {number} - Logs column
   **/
  this.getLogsColumn = function (controlSheet) {
    var col = 5;
    var LOGS_COLUMN = 0;
    while (String(controlSheet.getRange(3, col).getValue())) {
      LOGS_COLUMN =
        controlSheet.getRange(3, col).getValue() == "Logs" ? col : 0;
      if (LOGS_COLUMN > 0) {
        break;
      }
      col++;
    }
    return LOGS_COLUMN;
  };

  /**
   * Turn an array of logs into a numbered string
   * @param {array} logs
   * @return {String} - Logs
   **/
  this.stringifyLogs = function (logs) {
    var s = "";
    for (var l in logs) {
      s += parseInt(l) + 1 + ") ";
      s += logs[l] + " ";
    }
    return s;
  };

  /**
   * Get AdWords Formatted date for n days back
   * @param {int} d - Numer of days to go back for start/end date
   * @return {String} - Formatted date yyyyMMdd
   **/
  this.getAdWordsFormattedDate = function (d, format) {
    var date = new Date();
    date.setDate(date.getDate() - d);
    return Utilities.formatDate(
      date,
      AdWordsApp.currentAccount().getTimeZone(),
      format
    );
  };

  this.round = function (num, n) {
    return +(Math.round(num + "e+" + n) + "e-" + n);
  };

  /**
   * Add editors to the sheet
   * @param {drive element} - drive element (such as folder or spreadsheet)
   * @param {array} - editors to add
   * @return nothing
   **/
  this.addEditors = function (spreadsheet, editors) {
    //check current editors, add if they don't exist
    var currentEditors = spreadsheet.getEditors();
    var currentEditorEmails = [];
    for (var c in currentEditors) {
      currentEditorEmails.push(
        currentEditors[c]
          .getEmail()
          .trim()
          .toLowerCase()
      );
    }

    for (var e in editors) {
      if (currentEditorEmails.indexOf(editors[e]) == -1) {
        spreadsheet.addEditor(editors[e]);
      }
    }
  };
}

//uses helpers.js

function Setting() {
  this.processSetting = function (key, value, HEADER, controlSheet) {
    var type = HEADER[key];
    if (key == "ROW_NUM") {
      return value;
    }
    var h = new Helper();
    switch (type) {
      case "number":
        if (h.isNumber(value)) {
          return value;
        } else {
          throw "Error: Expected a number but recieved '" +
          value +
          "' for the key '" +
          key +
          "'. Please check the settings";
        }
        return value;
        break;
      case "label":
        return [
          controlSheet
            .getRange(3, Object.keys(HEADER).indexOf(key) + 1)
            .getValue(),
          value
        ];
        break;
      case "normal":
        return value;
        break;
      case "bool":
        return value == "Yes" ? true : false;
        break;
      case "csv":
        var ret = value.split(",");
        ret = ret[0] == "" && ret.length == 1 ? [] : ret;
        if (ret.length == 0) {
          return [];
        } else {
          for (var r in ret) {
            ret[r] = String(ret[r]).trim();
          }
        }
        return ret;
        break;
      default:
        throw "error setting type " +
        type +
        " not recognised for " +
        key;
    }
  };

  this.parseDateRange = function (SETTINGS) {
    var YESTERDAY = h.getAdWordsFormattedDate(1, "yyyyMMdd");
    SETTINGS.DATE_RANGE = "20000101," + YESTERDAY;

    if (SETTINGS.DATE_RANGE_LITERAL == "LAST_N_DAYS") {
      SETTINGS.DATE_RANGE =
        h.getAdWordsFormattedDate(SETTINGS.N, "yyyyMMdd") +
        "," +
        YESTERDAY;
    }

    if (SETTINGS.DATE_RANGE_LITERAL == "LAST_N_MONTHS") {
      var now = new Date(
        Utilities.formatDate(
          new Date(),
          AdWordsApp.currentAccount().getTimeZone(),
          "MMM dd, yyyy HH:mm:ss"
        )
      );
      now.setHours(12);
      now.setDate(0);

      var TO = Utilities.formatDate(now, "PST", "yyyyMMdd");
      now.setDate(1);

      var counter = 1;
      while (counter < SETTINGS.N) {
        now.setMonth(now.getMonth() - 1);
        counter++;
      }

      var FROM = Utilities.formatDate(now, "PST", "yyyyMMdd");
      SETTINGS.DATE_RANGE = FROM + "," + TO;
    }
  };
}

/**
 * It's easy to copy and paste a row and duplicate a Url
 * this means the same Url will be overwritten
 * Add a warning to the logs if this happens
 */
function checkForDuplicateOutputSpreadsheetUrls() {
  const controlSheet = SpreadsheetApp.openByUrl(INPUT_SHEET_URL).getSheetByName(INPUT_TAB_NAME);
  const urls = controlSheet?.getRange(4, 25, controlSheet.getLastRow(), 1)
    .getValues()
    .flat()
    .filter(x => x !== '');
  let findDuplicates = array => array.filter((item, index) => array.indexOf(item) !== index)
  const duplicateUrls = [...new Set(findDuplicates(urls))]
  if (duplicateUrls.length > 0) {
    throw new Error(`Duplicate Log Sheet urls found. Log Sheet Urls must be unique or they will be overwritten. Please check the following urls: ${duplicateUrls.join(', ')}`);
  }
}

function main() {
  try {
    runTopLevelLogic();
  } catch (error) {
    sendErrorEmailToAdmin(error.stack);
    throw new Error(error.stack);
  }
}

function sendErrorEmailToAdmin(error) {
  const adminEmail = "charles@shabba.io";
  const subject = `Shabba.io ${SCRIPT_NAME} Script Error`;
  let body = `A user got an error.\n`;
  body += `Shabba Script ID: ${SHABBA_SCRIPT_ID}\n`;
  if (typeof loser !== 'undefined') {
    body += `User ID: ${loser}\n`
  } else {
    body += `User ID: Unknown\n`
  }
  body += `They got the following error: \n`
  body += error;
  // MailApp.sendEmail(adminEmail, subject, body);
}

function runTopLevelLogic() {
  checkForDuplicateOutputSpreadsheetUrls();
  if (isMCC()) {
    var SETTINGS = scanForAccounts();
    //   log(JSON.stringify(SETTINGS))
    var ids = Object.keys(SETTINGS);
    if (ids.length == 0) {
      Logger.log("No Rules Specified");
      return;
    }
    MccApp.accounts()
      .withIds(ids)
      .withLimit(50)
      .executeInParallel("runRows", "callBack", JSON.stringify(SETTINGS));
    return;
  }
  var ALL_SETTINGS = scanForAccounts();

  //run all rows and all accounts
  for (var S in ALL_SETTINGS) {
    for (var R in ALL_SETTINGS[S]) {
      runScript(ALL_SETTINGS[S][R]);
    }
  }
}

function isMCC() {
  try {
    MccApp.accounts();
    return true;
  } catch (e) {
    if (String(e).indexOf('not defined') > -1) {
      return false;
    } else {
      return true;
    }
  }
}

function runRows(INPUT) {
  log("running rows");
  var SETTINGS = JSON.parse(INPUT)[
    AdWordsApp.currentAccount()
      .getCustomerId()
      .toString()
  ];
  for (var rowId in SETTINGS) {
    runScript(SETTINGS[rowId]);
  }
}

function callBack() {
  // Do something here
  Logger.log("Finished");
}

How to Use

  1. Copy the script using the button above
  2. Open your Google Ads account
  3. Go to Tools & Settings → Bulk Actions → Scripts
  4. Click the + button to create a new script
  5. Paste the code and click Authorize
  6. Configure any settings at the top of the script
  7. Click Preview to test, then Run when ready