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.
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
Make a copy of the template, copy your new Sheet's URL, and paste it atop the Script.
The Script
/**
* 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
- Copy the script using the button above
- Open your Google Ads account
- Go to Tools & Settings → Bulk Actions → Scripts
- Click the + button to create a new script
- Paste the code and click Authorize
- Configure any settings at the top of the script
- Click Preview to test, then Run when ready
