/** * Negative Keyword Finder * @Author Charles Bannister (shabba.io) * @Version 1.3 * View the script at https://shabba.io/script/7 **/ // Template: https://docs.google.com/spreadsheets/d/1MvCwzNCOIG3AO34b5yVe5VjcNFDwdvhCJdhrZQRXz58 // File > Make a copy or visit https://docs.google.com/spreadsheets/d/1MvCwzNCOIG3AO34b5yVe5VjcNFDwdvhCJdhrZQRXz58/copy let INPUT_SHEET_URL = "YOUR_SPREADSHEET_URL_HERE"; //TODO // Contains / not contains (update template too) - done // Auto-create output sheets (name it based on the rule number) // Slack // Notification frequency // Subject prefix // List negatives (grab from the main settings) // Manager version // Master filters? const INPUT_TAB_NAME = "Settings"; //set to true to send emails regardless of preview status const EMAIL_DURING_PREVIEW = false; // Change this number if more filters are added const NUMBER_OF_FILTERS = 6; const SCRIPT_NAME = "Negative Keyword Finder" const SHABBA_SCRIPT_ID = 7; const VERSION = "1.3"; //No need to edit anything below this line const h = new Helper(); const s = new Setting(); const OPTIONS = { includeZeroImpressions: true }; const SINGLE_ACCOUNT_HEADER_TYPES = { NAME: "normal", EMAILS: "csv", FLAG: "bool", EMAIL_ALERT: "bool", CHANGES_EMAIL: "bool", TAB_NAME: "normal", N: "normal", CAMPAIGN_NAME_CONTAINS: "csv", CAMPAIGN_NAME_NOT_CONTAINS: "csv", QUERY_TARGETING_STATUS: "normal", DEFAULT_MATCH_TYPE: "normal", DEFAULT_ACTION: "normal", }; const SINGLE_ACCOUNT_HEADER_TYPES_AFTER = { LOG_SHEET_URL: "normal", LOGS_COLUMN: "normal", }; const CUSTOM_METRICS = { //metric, metric, operator (divide or multiply, whether high or low is good) Ctr: ["Clicks", "Impressions", "divide", "high"], Roas: ["ConversionValue", "Cost", "divide", "high"], Cos: ["Cost", "ConversionValue", "divide", "low"], Cpa: ["Cost", "Conversions", "divide", "low"], AverageCpc: ["Cost", "Clicks", "divide", "low"], ConversionRate: ["Conversions", "Clicks", "divide", "high"], Rpc: ["ConversionValue", "Clicks", "divide", "high"], }; function runScript(SETTINGS) { log("Script Started"); console.log(JSON.stringify(SETTINGS)); SETTINGS.SPREADSHEET = SpreadsheetApp.openByUrl(INPUT_SHEET_URL); SETTINGS.CONTROL_SHEET = SETTINGS.SPREADSHEET.getSheetByName(INPUT_TAB_NAME); checkSettings(SETTINGS); processRowSettings(SETTINGS); addLogSheetInfo(SETTINGS); log(`Settings: ${JSON.stringify(SETTINGS)}`); const queries = getQueriesFromSheet(SETTINGS); // log(JSON.stringify(queries)) addKeywords(SETTINGS, queries); //check queries and add them to the sheet addQueriesToSheet(SETTINGS); SETTINGS.LOGS.push("The script ran successfully"); updateControlSheet("", SETTINGS); log("Finished"); } function getQueriesFromSheet(SETTINGS) { var map = { adgroup_ids: [], rows: {} }; var sheet = SETTINGS.KEYWORD_LOG_SHEET; var data = sheet.getDataRange().getValues(); data.shift(); data.shift(); var header = data.shift(); for (var d in data) { var row = data[d]; var query = String(row[header.indexOf("Search Term")]); var addToAdGroup = row[header.indexOf("Add as Keyword")]; var match_type = row[header.indexOf("Match Type")]; var this_is_ok = row[header.indexOf("Ignore")]; var add_as_negative = row[header.indexOf("Add as Ad Group Negative")]; if (!addToAdGroup && !add_as_negative & !this_is_ok) continue; var adgroup_id = row[header.indexOf("Ad Group Id")]; var campaignName = row[header.indexOf("Campaign Name")]; var adGroupName = row[header.indexOf("Adgroup")]; var averageCpc = parseFloat(row[header.indexOf("Cost")]) / parseFloat(row[header.indexOf("Clicks")]); var clicks = row[header.indexOf("Clicks")]; var cost = row[header.indexOf("Cost")]; var conversions = row[header.indexOf("Conversions")]; var cpa = row[header.indexOf("Cost / Conv.")]; var impressions = row[header.indexOf("Impr.")]; var conversion_rate = row[header.indexOf("Conv. Rate")]; if (map["adgroup_ids"].indexOf(adgroup_id) === -1) map["adgroup_ids"].push(adgroup_id); map["rows"][adgroup_id] = map["rows"][adgroup_id] || {}; map["rows"][adgroup_id][query] = {}; map["rows"][adgroup_id][query]["query"] = query; map["rows"][adgroup_id][query]["add_to_adgroup"] = addToAdGroup; map["rows"][adgroup_id][query]["add_as_negative"] = add_as_negative; map["rows"][adgroup_id][query]["match_type"] = match_type; map["rows"][adgroup_id][query]["campaignName"] = campaignName; map["rows"][adgroup_id][query]["adGroupName"] = adGroupName; map["rows"][adgroup_id][query]["adGroupId"] = adgroup_id; map["rows"][adgroup_id][query]["averageCpc"] = averageCpc; map["rows"][adgroup_id][query]["clicks"] = clicks; map["rows"][adgroup_id][query]["cost"] = cost; map["rows"][adgroup_id][query]["conversions"] = conversions; map["rows"][adgroup_id][query]["cpa"] = cpa; map["rows"][adgroup_id][query]["impressions"] = impressions; map["rows"][adgroup_id][query]["conversion_rate"] = conversion_rate; map["rows"][adgroup_id][query]["this_is_ok"] = this_is_ok; } return map; } function addLogSheetInfo(SETTINGS) { SETTINGS.LOG_SHEET_URL = INPUT_SHEET_URL; var logSS = SpreadsheetApp.openByUrl(SETTINGS.LOG_SHEET_URL); SETTINGS.logSS = logSS; SETTINGS.KEYWORD_LOG_SHEET = logSS.getSheetByName(SETTINGS["TAB_NAME"]); } function sendChangesEmail(SETTINGS) { if (SETTINGS.PREVIEW_MODE && !EMAIL_DURING_PREVIEW) return; //Send email var SUB = AdsApp.currentAccount().getName() + " - " + SCRIPT_NAME + " script."; var MSG = "Hi,

The " + INPUT_TAB_NAME + " script ran successfully and changes were made. Here are the logs:

"; MSG += ""; MSG += "

Here's the sheet where you'll find the settings and Search Term data:
" + SETTINGS.LOG_SHEET_URL; MSG += "

Thanks,"; MSG += "

Charles"; MSG += "


shabba.io"; var emails = SETTINGS.EMAILS; for (var i in emails) { MailApp.sendEmail({ to: emails[i], subject: SUB, htmlBody: MSG, }); } } function sendEmailAlert(SETTINGS, numberOfSearchTerms) { if (SETTINGS.PREVIEW_MODE && !EMAIL_DURING_PREVIEW) return; SETTINGS.LOGS.push(`Rule name: ${SETTINGS["NAME"]}`); SETTINGS.LOGS.push(`${numberOfSearchTerms} search terms matched the rule`); //Send email var SUB = AdsApp.currentAccount().getName() + " - Search Terms Alert"; var MSG = "Hi,

The Search Terms Manager script has results. Here are the logs:

"; MSG += ""; MSG += "

Visit the Google Sheet to review the terms:
" + SETTINGS.LOG_SHEET_URL; MSG += "

Thanks,"; MSG += "


shabba.io"; var emails = SETTINGS.EMAILS; for (var i in emails) { MailApp.sendEmail({ to: emails[i], subject: SUB, htmlBody: MSG, }); } } 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 = [logString, SETTINGS.NOW]; SETTINGS.CONTROL_SHEET.getRange( SETTINGS.ROW_NUM, SETTINGS.LOGS_COLUMN, 1, 2 ).setValues([put]); SETTINGS.CONTROL_SHEET.getRange( SETTINGS.ROW_NUM, SETTINGS.LOGS_COLUMN, 1, 1 ).setNote(logString); } function swapLabelTextForIds(SETTINGS) { //swap label text for label ids for (var key in SETTINGS) { if (key.indexOf("FILTER") === -1 || key.indexOf("METRIC") === -1) { continue; } if (SETTINGS[key] !== "Labels") continue; log(key + " - " + SETTINGS[key]); var filter_value = SETTINGS[key.replace("METRIC", "VALUE")]; var value_split = String(filter_value).split(","); for (var v in value_split) { value_split[v] = getLabelIdFromString(value_split[v]); } SETTINGS[key.replace("METRIC", "VALUE")] = value_split.join(","); } return SETTINGS; } function getFilterWhereString(SETTINGS) { //custom metrics map //name : formula, whether high or low is good (e.g. ROAS high is good, CPA high is bad - used for Vs target calcs) var filters = Object.keys(SETTINGS).map(function (x) { if (x.indexOf("FILTER") > -1) { return x; } }); var filterMap = {}; var filterParts = ["metric", "operator", "value"]; var numberOfFilters = filters.length / filterParts.length; // log("number of filters: " + numberOfFilters) for (var i = 0; i < numberOfFilters; i++) { var filterName = "FILTER_" + (i + 1); if (Object.keys(SETTINGS).indexOf(filterName + "_METRIC") == -1) continue; if (SETTINGS[filterName + "_METRIC"] == "") continue; filterMap[filterName] = filterMap[filterName] || {}; for (var x in filterParts) { filterMap[filterName][filterParts[x]] = SETTINGS[filterName + "_" + filterParts[x].toUpperCase()]; } } var where = ""; var whereArray = []; //turn the filters object into a where statement string whereArray.push(filtersToWhereStatement(filterMap, filterParts)); function filtersToWhereStatement(filterMap, filterParts) { var str = []; for (var filter in filterMap) { //if the metric is a custom metric, continue if (Object.keys(CUSTOM_METRICS).indexOf(filterMap[filter]["metric"]) > -1) continue; str.push("and"); for (var p in filterParts) { str.push(filterMap[filter][filterParts[p]]); } } return str.join(" "); } where += whereArray.join(" "); return where; } function getLabelIdFromString(str) { var labels = AdsApp.labels() .withCondition("Name = '" + str.trim() + "'") .get(); if (labels.hasNext()) { var label = labels.next(); // log(label.getId()) return label.getId(); } else { checkLabel(str); return "0000"; } } //if the label doesn't exist, create it function checkLabel(labelName) { if (labelName == "") { return; } var labels = AdsApp.labels().get(); var exists = false; while (labels.hasNext()) { var label = labels.next(); if (label.getName() == labelName) { exists = true; } } if (!exists) { AdsApp.createLabel(labelName); } } function getQueryWhereString(SETTINGS) { var where = "where CampaignStatus = ENABLED and AdGroupStatus = ENABLED "; if (SETTINGS.QUERY_TARGETING_STATUS) { where += " and QueryTargetingStatus = " + SETTINGS.QUERY_TARGETING_STATUS; } var whereArray = []; 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() + "'" ); } where += whereArray.join(" "); return where + " " + getFilterWhereString(SETTINGS); } function addCustomMetricsToRow(row) { //CUSTOM_METRICS for (var metricName in CUSTOM_METRICS) { if (valueUndefined(row[CUSTOM_METRICS[metricName][1]])) throw "Error: Can't find the metric " + CUSTOM_METRICS[metricName][1]; if (valueUndefined(row[CUSTOM_METRICS[metricName][0]])) throw "Error: Can't find the metric " + CUSTOM_METRICS[metricName][0]; if (CUSTOM_METRICS[metricName][2] == "divide") { row[metricName] = row[CUSTOM_METRICS[metricName][0]] == 0 || row[CUSTOM_METRICS[metricName][1]] == 0 ? 0 : round( row[CUSTOM_METRICS[metricName][0]] / row[CUSTOM_METRICS[metricName][1]], 4 ); } if (CUSTOM_METRICS[metricName][2] == "multiply") { row[metricName] = row[CUSTOM_METRICS[metricName][0]] == 0 || row[CUSTOM_METRICS[metricName][1]] == 0 ? 0 : round( row[CUSTOM_METRICS[metricName][0]] * row[CUSTOM_METRICS[metricName][1]], 4 ); } } return row; } function getKeywords(SETTINGS, sheet_name) { let sheetOperations = new SheetOperations(SETTINGS); var sheet = sheetOperations.getSheetByName(sheet_name); var data = sheetOperations.getAllRowsData(sheet, 5, 3); var query_keys = []; for (var d in data) { var row = data[d]; if (row[0] === "") break; query_keys.push(row.join("")); } return query_keys; } function getPositiveKeywordsQueries(SETTINGS) { return getKeywords(SETTINGS, "Ignored Terms"); } function getNegativeKeywordsQueries(SETTINGS) { return getKeywords(SETTINGS, "Negative Keywords"); } function addQueriesToSheet(SETTINGS) { log(`Clearing the ${SETTINGS["TAB_NAME"]} sheet`); let sheetOperations = new SheetOperations(SETTINGS); var sheet = sheetOperations.getSheetByName(SETTINGS["TAB_NAME"]); if (sheet.getMaxRows() > 3) { const rangeToClear = sheet.getRange(4, 1, sheet.getMaxRows(), sheet.getMaxColumns()); rangeToClear.clear(); } sheet.setFrozenRows(3); SETTINGS["FILTER_MAP"] = getFilterMap(SETTINGS); var positive_keywords_query_keys = getPositiveKeywordsQueries(SETTINGS); var negative_keywords_query_keys = getNegativeKeywordsQueries(SETTINGS); var cols = [ "AdGroupId", "CampaignId", "CampaignName", "AdGroupName", "Query", "ConversionValue", "Impressions", "Clicks", "Cost", "Conversions", "TopImpressionPercentage", "AbsoluteTopImpressionPercentage", "QueryTargetingStatus", ]; var reportName = "SEARCH_QUERY_PERFORMANCE_REPORT"; var query = [ "select", cols.join(","), "from", reportName, getQueryWhereString(SETTINGS), "during", SETTINGS.DATE_RANGE, ].join(" "); log(reportName + " query: \n"); console.log(query.split('from')[0], '\n from', query.split('from')[1], '\n'); var logArray = []; var reportIter = AdsApp.report(query, OPTIONS).rows(); while (reportIter.hasNext()) { var row = reportIter.next(); row.Impressions = parseInt(row.Impressions, 10); row.Clicks = parseInt(row.Clicks, 10); row.Conversions = parseFloat(row.Conversions.toString().replace(/,/g, "")); row.Cost = parseFloat(row.Cost.toString().replace(/,/g, "")); row.ConversionValue = parseFloat( row.ConversionValue.toString().replace(/,/g, "") ); var query_key = row.CampaignName + row.AdGroupName + row.Query; if (positive_keywords_query_keys.indexOf(query_key) > -1) { continue; } if (negative_keywords_query_keys.indexOf(query_key) > -1) { continue; } row = addCustomMetricsToRow(row); if (skipEntity(row, SETTINGS)) continue; let addToAdgroup = SETTINGS.DEFAULT_ACTION.trim() === "Add Keyword" ? "TRUE" : "FALSE"; let addAsNegative = SETTINGS.DEFAULT_ACTION.trim() === "Add Negative Keyword" ? "TRUE" : "FALSE"; var logRow = [ row.CampaignName, row.AdGroupName, row.AdGroupId, row.Query, row.QueryTargetingStatus, addToAdgroup, SETTINGS["DEFAULT_MATCH_TYPE"], false, addAsNegative, row.Impressions, row.Clicks, row.Ctr, row.Cost, row.Conversions, row.Cpa, row.ConversionRate, row.ConversionValue, row.Roas, ]; logArray.push(logRow); // if(logArray.length > 7)break } log(logArray.length + " search terms found"); if (logArray.length === 0) return; sheetOperations.writeToSheet(logArray, SETTINGS["TAB_NAME"], 4); postWriteOperations(logArray, SETTINGS); if (SETTINGS["EMAIL_ALERT"]) { sendEmailAlert(SETTINGS, logArray.length); } } function postWriteOperations(logArray, SETTINGS) { var sheetOperations = new SheetOperations(SETTINGS); var sheet = sheetOperations.getSheetByName(SETTINGS["TAB_NAME"]); sheet .getRange("A2") .setValue( "Current Data For Lookback (" + SETTINGS.N + " days)" + " - " + SETTINGS.DATE_RANGE ); var header = sheet.getRange(3, 1, 1, sheet.getLastColumn()).getValues(); addValidation(logArray.length, sheet, header, sheetOperations); sheetOperations.sortColumn(sheet, parseInt(header[0].indexOf("Cost")) + 1); } function addValidation(rows, sheet, header, sheetOperations) { //add validaiton to the keywords sheet var checkbox_cols = []; var matchTypeColumn = 0; for (var value_index in header[0]) { var value = header[0][value_index]; if (value == "Add as Keyword") checkbox_cols.push(parseInt(value_index) + 1); if (value == "Ignore") checkbox_cols.push(parseInt(value_index) + 1); if (value == "Add as Ad Group Negative") { checkbox_cols.push(parseInt(value_index) + 1); } if (value == "Match Type") { matchTypeColumn = parseInt(value_index) + 1; } } if (matchTypeColumn === 0) { throw new Error(`There was a problem finding the "Match Type" column in the ${sheet.getName()} sheet. Please make sure the column exists and try again. It might help to grab the original template sheet from https://shabba.io/script/${SHABBA_SCRIPT_ID}`); } var start_row = 4; for (var checkbox_cols_index in checkbox_cols) { sheetOperations.addCheckBox( start_row, checkbox_cols[checkbox_cols_index], rows, sheet ); } // log('These columns should match the keywords sheet:') // log('Match type column: ' + matchTypeColumn) // log('Checkbox columns: ' + checkbox_cols) var validation_list = ["Broad", "Phrase", "Exact"]; sheetOperations.addDropdownValidation( validation_list, sheet, start_row, rows, matchTypeColumn ); } class SheetOperations { constructor(SETTINGS) { this.SETTINGS = SETTINGS; } addDropdownValidation(validation_list, sheet, start_row, rows, column) { var rule = SpreadsheetApp.newDataValidation() .requireValueInList(validation_list) .build(); sheet.getRange(start_row, column, rows, 1).setDataValidation(rule); } addCheckBox(start_row, column, rows, sheet) { var range = sheet.getRange(start_row, column, rows, 1); var enforceCheckbox = SpreadsheetApp.newDataValidation(); enforceCheckbox.requireCheckbox(); enforceCheckbox.setAllowInvalid(false); enforceCheckbox.build(); range.setDataValidation(enforceCheckbox); } getSheetByName(name) { try { return this.SETTINGS.logSS.getSheetByName(name); } catch (e) { var sheets = this.SETTINGS.logSS.getSheets(); var sheet_names = []; for (var sheet_key in sheets) { var sheet = sheets[sheet_key]; sheet_names.push(sheet.getName()); } log("There was a problem getting sheet '" + name + "'"); log("Here are the availble sheets: " + sheet_names.join(", ")); log("And here's the error: "); throw e; } } appendToSheet(logArray, sheetName, start_row) { if (logArray.length === 0) return; var sheet = this.getSheetByName(sheetName); sheet.insertRowsAfter(start_row - 1, logArray.length); var max_rows = 20000; if (sheet.getLastRow() > max_rows) { sheet.deleteRows(max_rows, sheet.getLastRow() - max_rows); } sheet .getRange(start_row, 1, logArray.length, logArray[0].length) .setValues(logArray); } getAllRowsData(sheet, start_row, num_columns) { var start_column = 1; return sheet .getRange(start_row, start_column, sheet.getLastRow(), num_columns) .getValues(); } writeToSheet(logArray, sheetName, start_row) { log("Writing to " + sheetName); if (logArray.length === 0) return; var sheet = this.getSheetByName(sheetName); var lastRow = this.getLastRow(sheet, 4); // log("lastRow: " + lastRow) if (lastRow < parseInt(logArray.length) + 20) { sheet.insertRowsAfter(lastRow, lastRow); } log("writing to sheet"); sheet .getRange(4, 1, logArray.length, logArray[0].length) .setValues(logArray); // Utilities.sleep(50000) } clearRange(sheet, row, column, rows, columns) { var values = []; for (var r = 0; r < rows; r++) { var this_row = []; for (var c = 0; c < columns; c++) { this_row.push(""); } values.push(this_row); } sheet.getRange(row, column, rows, columns).setValues(values); } /*Get the last row based on the first column's values */ getLastRow(sheet, startRow) { var row = startRow; while (sheet.getRange(row, 1).getValue()) { row++; } row = row - 1; if (row < 1) return 1; return row; } addNumberFormat(sheet_name, column, start_row) { // sheet.getRange(1,2,sheet.getLastRow(),sheet.getLastColumn()).setNumberFormat("0.00") // sheet.getRange(1,13,sheet.getLastRow(),sheet.getLastColumn()).setNumberFormat("0.00%") // sheet.getRange(1,7,sheet.getLastRow(),1).setNumberFormat("0.00%") // sheet.getRange(1,10,sheet.getLastRow(),1).setNumberFormat("0.00%") } sortColumn(sheet, columnNumber) { const startRow = 4; const numberOfRows = sheet.getLastRow() - startRow if (numberOfRows < 2) { return } sheet .getRange( startRow, 1, numberOfRows, sheet.getLastColumn() ) .sort({ column: columnNumber, ascending: false }); } } function getFilterMap(SETTINGS) { var filters = Object.keys(SETTINGS).map(function (x) { if (x.indexOf("FILTER") > -1) { return x; } }); var filterMap = {}; var filterParts = ["metric", "operator", "value"]; var numberOfFilters = filters.length / filterParts.length; // log("number of filters: " + numberOfFilters) for (var i = 0; i < numberOfFilters; i++) { var filterName = "FILTER_" + (i + 1); if (Object.keys(SETTINGS).indexOf(filterName + "_METRIC") == -1) continue; SETTINGS[filterName + "_METRIC"] = renameMetricToMatchApi(SETTINGS[filterName + "_METRIC"]); if (SETTINGS[filterName + "_METRIC"] == "") { continue; } filterMap[filterName] = filterMap[filterName] || {}; for (var x in filterParts) { filterMap[filterName][filterParts[x]] = SETTINGS[filterName + "_" + filterParts[x].toUpperCase()]; } } return filterMap; } /** * Where a user friendly metric/attribute name is used, * this function renames it to the API name */ function renameMetricToMatchApi(metric) { const metricMap = { "SearchTerm": "Query", } return metricMap[metric] || metric; } //Whether to skip the entity based on the stats and filters //Only check custom metrics e.g. cpa function skipEntity(row, SETTINGS) { //custom metrics map //name : formula, whether high or low is good (e.g. ROAS high is good, CPA high is bad - used for Vs target calcs) var filterMap = SETTINGS["FILTER_MAP"]; // log(JSON.stringify(filterMap)) // log(JSON.stringify(row)) for (var filter in filterMap) { var this_filter = filterMap[filter]; if (filterNotInCustomMetrics(this_filter.metric)) continue; var eval_string = row[this_filter.metric] + " " + this_filter.operator + " " + String(this_filter.value); try { if (!eval(eval_string)) { return true; } } catch (e) { log("eval_string: " + eval_string); throw e; } } function filterNotInCustomMetrics(metric) { return Object.keys(CUSTOM_METRICS).indexOf(metric) === -1; } return false; } function valueUndefined(val) { var result = false; if (val === 0) return false; if (!val) return true; if (typeof val === "undefined") return true; return result; } function calculateCpaBid(bid, target, actual, min) { if (actual === 0) return min; return bid + bid * getChangePercentage(actual, target); } function calculateRoasBid(bid, target, actual, min) { if (actual === 0) return min; return bid + bid * getChangePercentage(target, actual); } /** * add positive and negative keywords * @returns nothing **/ function addKeywords(SETTINGS, queries) { let adGroupIds = queries["adgroup_ids"]; if (adGroupIds.length === 0) { log("No negative keywords to add"); return; } const negativeKeywordLogArray = []; const positiveKeywordLogArray = []; const chunkSize = 10000; const types = ["search", "shopping"]; for (const typeNum in types) { const chunkedArray = []; for (var i = 0; i < adGroupIds.length; i += chunkSize) { chunkedArray.push(adGroupIds.slice(i, i + chunkSize)); } for (let i = 0; i < chunkedArray.length; i++) { let adGroups = types[typeNum] === "shopping" ? AdsApp.shoppingAdGroups() : AdsApp.adGroups(); adGroups = adGroups .withIds(chunkedArray[i]) .get(); if (!adGroups.hasNext()) { continue; } while (adGroups.hasNext()) { const adGroup = adGroups.next(); const adGroupId = adGroup.getId(); const adGroupQueries = queries["rows"][adGroupId]; var previewModeText = SETTINGS.PREVIEW_MODE ? " (Preview Mode) " : ""; for (const query in adGroupQueries) { const row = adGroupQueries[query]; if (row["this_is_ok"]) { const logRow = [ row["campaignName"], row["adGroupName"], query, row["match_type"], SETTINGS.NOW, previewModeText, ]; positiveKeywordLogArray.push(logRow); continue; } if (row["add_as_negative"]) { const keyword = addMatchType(query, row["match_type"]); adGroup.createNegativeKeyword(keyword); const logRow = [ row["campaignName"], row["adGroupName"], query, row["match_type"], SETTINGS.NOW, previewModeText, ]; negativeKeywordLogArray.push(logRow); continue; } if (row["add_to_adgroup"]) { const keyword = addMatchType(query, row["match_type"]); adGroup .newKeywordBuilder() .withText(keyword) .withCpc(row["cost"] / row["clicks"]) .build(); } } } } } const sheetOperations = new SheetOperations(SETTINGS); sheetOperations.appendToSheet( negativeKeywordLogArray, "Negative Keywords", 5 ); SETTINGS.LOGS.push( parseInt(negativeKeywordLogArray.length) + " negative keywords added" ); sheetOperations.appendToSheet(positiveKeywordLogArray, "Ignored Terms", 5); SETTINGS.LOGS.push( parseInt(positiveKeywordLogArray.length) + " positive keywords added" ); if ((positiveKeywordLogArray.length > 0 || negativeKeywordLogArray.length > 0) && SETTINGS['CHANGES_EMAIL']) { sendChangesEmail(SETTINGS); } } function addMatchType(text, matchType) { matchType = matchType.toLowerCase(); if (matchType === "exact") return "[" + text + "]"; if (matchType === "phrase") return '"' + text + '"'; if (matchType === "broad") return text; throw "Error: Match type not recognised"; //text.replace("[","").replace("]","").toLowerCase(); } function isToday(date) { return date.getDate() == new Date().getDate(); } //update the change log //return false if the Id has already been updated today function updateChangeLog(CampaignName, AdGroupName, Id, SETTINGS) { //grab the change log //if the id does not exist in the sheet, update the sheet and return true //if the id exists and it's today, return false //if the id exists but it's not today, update the sheet and return true if (AdsApp.getExecutionInfo().isPreview()) return true; var map = {}; var sheet = SETTINGS.CHANGE_LOG_SHEET; if (sheet.getLastRow() < 2) { log("No change log values"); map = updateMap(map, CampaignName, AdGroupName, Id, SETTINGS.NOW); writeMapToSheet(sheet, map); return true; } //grab the current details and update var data = sheet.getDataRange().getValues(); data.shift(); for (var d in data) { var row = data[d]; var campaign = row[0]; var adgroup = row[1]; var id = row[2]; var date = row[3]; map[campaign + adgroup + id] = {}; map[campaign + adgroup + id]["campaign"] = campaign; map[campaign + adgroup + id]["adgroup"] = adgroup; map[campaign + adgroup + id]["id"] = id; map[campaign + adgroup + id]["date"] = date; } var this_id = CampaignName + AdGroupName + Id; if (valueUndefined(map[this_id])) { // log("Change log id undefined: " + this_id) // log(JSON.stringify(map)) map = updateMap(map, CampaignName, AdGroupName, Id, SETTINGS.NOW); writeMapToSheet(sheet, map); return true; } var this_date = new Date(map[this_id]["date"]); //don't update the bid if the log contains today's date if (isToday(this_date)) { // log("Id already updated today") return false; //do not update } else { // log("Value updated but it wasn't today") map = updateMap(map, CampaignName, AdGroupName, Id, SETTINGS.NOW); writeMapToSheet(sheet, map); return true; } function writeMapToSheet(sheet, map) { // log("Adding " + Object.keys(map).length + " rows to the change log") // log("Writing this map: " + JSON.stringify(map)) sheet.clear(); var header = ["Campaign", "Ad group", "Id", "Last Updated"]; var logArray = [header]; for (var id in map) { logArray.push([ map[id]["campaign"], map[id]["adgroup"], map[id]["id"], map[id]["date"], ]); } writeArrayToSheet(logArray, sheet, 1); } function updateMap(map, CampaignName, AdGroupName, Id, NOW) { var this_id = CampaignName + AdGroupName + Id; map[this_id] = {}; map[this_id]["campaign"] = CampaignName; map[this_id]["adgroup"] = AdGroupName; map[this_id]["id"] = Id; map[this_id]["date"] = NOW; return map; } } function getChangePercentage(before, after) { if (before === 0 && after === 0) return 0; return (after - before) / before; } function parseDateRange(SETTINGS) { var YESTERDAY = getAdWordsFormattedDate(1, "yyyyMMdd"); SETTINGS.DATE_RANGE = getAdWordsFormattedDate(SETTINGS.N, "yyyyMMdd") + "," + YESTERDAY; } function getActualVsTarget(target_type, actual, target) { if (target_type === "CPA") { return target / actual; } return actual / target; } /* SETTINGS SECTION */ /** * 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; } function getFilterHeaderTypes() { var map = {}; for (var i = 1; i < NUMBER_OF_FILTERS + 1; i++) { map["FILTER_" + i + "_METRIC"] = "normal"; map["FILTER_" + i + "_OPERATOR"] = "filter_operator"; map["FILTER_" + i + "_VALUE"] = "filter_value"; } return map; } function getHeaderTypes() { const MCC_HEADER_TYPES = { 'ACCOUNT_ID': 'normal', 'ACCOUNT_NAME': 'normal', }; const FILTER_HEADER_TYPES = getFilterHeaderTypes(); let HEADER_TYPES = {}; if (isMCC()) { HEADER_TYPES = objectMerge( MCC_HEADER_TYPES, SINGLE_ACCOUNT_HEADER_TYPES, FILTER_HEADER_TYPES, SINGLE_ACCOUNT_HEADER_TYPES_AFTER ); } else { HEADER_TYPES = objectMerge( SINGLE_ACCOUNT_HEADER_TYPES, FILTER_HEADER_TYPES, SINGLE_ACCOUNT_HEADER_TYPES_AFTER ); } return HEADER_TYPES; } function buildTestSettings() { var map = {}; for (var key in SINGLE_ACCOUNT_HEADER_TYPES) { map[key] = "random value"; } return map; } function scanForAccounts() { log("getting settings..."); log( "The settings sheet should contain " + NUMBER_OF_FILTERS + " filter sets" ); var map = {}; var controlSheet = SpreadsheetApp.openByUrl(INPUT_SHEET_URL).getSheetByName(INPUT_TAB_NAME); var data = controlSheet.getDataRange().getValues(); data.shift(); data.shift(); data.shift(); const HEADER_TYPES = getHeaderTypes(); // console.log(`HEADER_TYPES: ${JSON.stringify(HEADER_TYPES)}`); const HEADER = Object.keys(HEADER_TYPES); // console.log(`HEADER: ${HEADER}`); 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++; } // console.log(`LOGS_COLUMN: ${LOGS_COLUMN}`); // log(HEADER) const flagIndex = 2; const ruleNameIndex = 0; for (const row of data) { //if "run script" is not set to "yes", continue. // log(data[k][flagIndex]) const rowNum = data.indexOf(row) + 4; if (row[ruleNameIndex] === "") { console.log(`Rule name is empty for row ${rowNum}. Skipping...`); continue; } if (row[flagIndex].toLowerCase() != "yes") { console.log(`Flag set to ${row[flagIndex]} for row ${rowNum}. Skipping...`); continue; } const id = row[0]; const rowId = id + "/" + rowNum; map[id] = map[id] || {}; map[id][rowId] = { ROW_NUM: rowNum }; for (const key in HEADER) { if (HEADER[key] == "LOGS_COLUMN") { map[id][rowId][HEADER[key]] = LOGS_COLUMN; continue; } map[id][rowId][HEADER[key]] = row[key]; } } if (Object.keys(map).length == 0) { console.warn("No rules were specified"); console.warn( "To run the script, add settings and set 'Run Script' to 'Yes'" ); return; } 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 isList(operator) { var list_operators = [ "IN", "NOT_IN", "CONTAINS_ANY", "CONTAINS_NONE", "CONTAINS_ALL", ]; return list_operators.indexOf(operator) > -1; } // log(formatFilterValue("Example campaign name, another example", "CONTAINS_ANY")) function formatFilterValue(value, operator, filter_metric) { if (h.isNumber(value)) return value; if (filter_metric === "Labels") { value = String(value) .split(",") .map(function (x) { return getLabelIdFromString(x); }) .join(","); } if (String(value).split(",").length === 1) { return isList(operator) ? "['" + value + "']" : "'" + value + "'"; } var arr = String(value).split(","); value = arr.length === 1 ? arr[0] : "[" + arr .map(function (x) { return "'" + x.trim() + "'"; }) .join(",") + "]"; return value; } // log(processSetting("FILTER_1", "world", {"FILTER_1":"filter_value"},"controlSheet")) function processSetting( key, value, HEADER, controlSheet, previousOperator, filter_metric ) { var type = HEADER[key]; if (key == "ROW_NUM") { return value; } switch (type) { case "filter_operator": return value.replace("'", ""); break; case "filter_value": value = formatFilterValue(value, previousOperator, filter_metric); 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; } } function processRowSettings(SETTINGS) { SETTINGS.NOW = Utilities.formatDate( new Date(), AdsApp.currentAccount().getTimeZone(), "MMM dd, yyyy HH:mm:ss" ); SETTINGS["QUERY_TARGETING_STATUS"] = SETTINGS["QUERY_TARGETING_STATUS"] == "ALL" ? "" : SETTINGS["QUERY_TARGETING_STATUS"]; SETTINGS.LOGS_COLUMN = getLogsColumn(SETTINGS.CONTROL_SHEET); SETTINGS.LOGS = []; SETTINGS.EMAILS_SHEET = true; 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 = AdsApp.getExecutionInfo().isPreview(); if (SETTINGS.PREVIEW_MODE) { var msg = "Running in preview mode. No changes will be made."; SETTINGS.LOGS.push(msg); } // checkLabel(SETTINGS.KEYWORD_LABEL) } /** * Checks the settings for issues * @returns nothing **/ function checkSettings(SETTINGS) { //check the settings here if (SETTINGS.N === "") { updateControlSheet("Please set a lookback window", SETTINGS); } if (!sheetExists(SETTINGS)) { const errorMessage = `The sheet ${SETTINGS["TAB_NAME"]} does not exist. Please create it by duplicating an existing sheet (aka tab).` throw (errorMessage) } } function sheetExists(SETTINGS) { let sheets = SETTINGS.SPREADSHEET.getSheets(); for (let sheet of sheets) { if (String(sheet.getName()) === String(SETTINGS["TAB_NAME"])) { return true; } } return false; } /** * Get AdWords Formatted date for n days back * @param {drive element} - drive element (such as folder or spreadsheet) * @param {drive element} - main control (settings) sheet * @param {int} - Number of the column containing the logs * @return nothing **/ function addEditors(spreadsheet, controlSheet, LOGS_COLUMN) { //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()); } var editors = controlSheet.getRange(1, LOGS_COLUMN).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()]; } for (var e in editors) { var index = currentEditorEmails.indexOf(editors[e]); if (currentEditorEmails.indexOf(editors[e]) == -1) { spreadsheet.addEditor(editors[e]); } } } /* SET AND FORGET FUNCTIONS */ function getLogsColumn(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; } function callBack() { // Do something here Logger.log("Finished"); } function stringifyLogs(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 **/ function getAdWordsFormattedDate(d, format) { var date = new Date(); date.setDate(date.getDate() - d); return Utilities.formatDate( date, AdsApp.currentAccount().getTimeZone(), format ); } function log(msg) { try { console.log(AdsApp.currentAccount().getName() + " - " + msg); } catch (e) { log(msg); } } function round(num, n) { return +(Math.round(parseFloat(num) + "e+" + n) + "e-" + n); } function main() { runTopLevelLogic(); } function runTopLevelLogic() { 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)); } else { var ALL_SETTINGS = scanForAccounts(); log(JSON.stringify(ALL_SETTINGS)) //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)[AdsApp.currentAccount().getCustomerId().toString()]; for (var rowId in SETTINGS) { runScript(SETTINGS[rowId]); } } 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 = String(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, AdsApp.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.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(), AdsApp.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; } }; }