Skip to content

The script, in full

This is the file your dashboard fills with an account token. It reads search terms, campaign names, and enabled keywords, posts them to JunkTerms, and adds the negatives you approved: account-wide terms on a shared list named JunkTerms, and campaign-only terms on that campaign.
  • The first install is a dry run. It reads and posts, and it does not write negatives until JUNKTERMS_DRY_RUN is false.

  • On a manager account it walks client accounts with AdsManagerApp.

  • It does not change bids, budgets, ads, or existing keywords.

junkterms.gs · Public preview, token removed
/**
 * JunkTerms
 * The first free analysis reads the last 90 days, up to the free cap.
 * Later runs read the last 7 days, up to the plan cap.
 * Posts those search terms to JunkTerms.
 * Account negatives go on a shared list named JunkTerms.
 * Campaign negatives are added only on that campaign.
 * Does not change bids, budgets, ads, or existing keywords.
 *
 * Schedule: hourly (Google Ads > Tools > Bulk actions > Scripts).
 * Search-term analysis is sent about once every 6 days.
 * Approved negatives are applied on the hourly runs.
 *
 * GAQL search_term_view is the current replacement for
 * SEARCH_QUERY_PERFORMANCE_REPORT.
 * Reference: https://developers.google.com/google-ads/scripts/docs/reference/adsapp/adsapp
 */
var JUNKTERMS_ENDPOINT = 'https://junkterms.vercel.app';
var JUNKTERMS_TOKEN = 'YOUR_ACCOUNT_TOKEN';
var SHARED_LIST_NAME = 'JunkTerms';
// First install only reads and posts. Set this to false after a Preview run looks right.
var JUNKTERMS_DRY_RUN = true;
var ANALYSIS_INTERVAL_MS = 6 * 24 * 60 * 60 * 1000;
var SEARCH_TERM_LIMIT = 5000;
var KEYWORD_LIMIT = 2000;

function main() {
  if (typeof AdsManagerApp !== 'undefined') {
    var iterator = AdsManagerApp.accounts().withLimit(50).get();
    var count = 0;
    while (iterator.hasNext()) {
      var account = iterator.next();
      AdsManagerApp.select(account);
      count++;
      try {
        runForCurrentAccount();
      } catch (err) {
        Logger.log('JunkTerms failed for ' + account.getCustomerId() + ': ' + err);
      }
    }
    if (count === 0) {
      Logger.log('JunkTerms: this manager account has no client accounts.');
    }
    return;
  }
  runForCurrentAccount();
}

function isDryRun() {
  try {
    var flag = PropertiesService.getScriptProperties().getProperty('JUNKTERMS_DRY_RUN');
    if (flag === 'false') return false;
    if (flag === 'true') return true;
  } catch (err) {
    Logger.log('JunkTerms could not read the dry-run flag: ' + err);
  }
  return JUNKTERMS_DRY_RUN !== false;
}

function runForCurrentAccount() {
  var account = AdsApp.currentAccount();
  var customerId = account.getCustomerId();
  var dryRun = isDryRun();
  Logger.log('JunkTerms running for ' + customerId + (dryRun ? ' (DRY-RUN, no negatives will be written)' : ''));

  var pending = apiGet('https://junkterms.vercel.app/api/script/pending?customerId=' + encodeURIComponent(customerId));
  if (pending && pending.paused) {
    Logger.log(pending.reason === 'plan_limit'
      ? 'JunkTerms: this plan has no room for another account.'
      : 'Free analysis sent; subscribe to analyse new terms.');
    return;
  } else if (dryRun) {
    var waiting = pending && pending.negatives ? pending.negatives.length : 0;
    Logger.log('JunkTerms DRY-RUN: would apply ' + waiting + ' negatives. Nothing was written. Set JUNKTERMS_DRY_RUN to false after Preview looks right.');
  } else if (pending && pending.negatives && pending.negatives.length) {
    applyNegatives(pending.negatives, customerId);
  }

  var plan = pending && pending.analysis ? pending.analysis : {};
  if (plan.due) {
    clearAnalyzed();
  } else if (!shouldAnalyze()) {
    Logger.log('JunkTerms: weekly analysis already sent. Applied approvals only.');
    return;
  }

  var dateRange = plan.dateRange === 'LAST_90_DAYS' || plan.dateRange === 'LAST_30_DAYS' || plan.dateRange === 'LAST_7_DAYS'
    ? plan.dateRange
    : 'LAST_7_DAYS';
  var termLimit = Number(plan.termLimit || SEARCH_TERM_LIMIT);
  if (!(termLimit > 0)) {
    Logger.log('JunkTerms: the term limit for this period is already used.');
    return;
  }

  var context = collectCampaignContext();
  var payload = {
    customerId: customerId,
    accountName: account.getName(),
    currency: account.getCurrencyCode(),
    searchTerms: fetchSearchTerms(dateRange, termLimit),
    activeKeywords: context.keywords,
    campaigns: context.campaigns
  };

  var result = apiPost('https://junkterms.vercel.app/api/script/ingest', payload);
  if (result && result.paused) {
    Logger.log('Free analysis sent; subscribe to analyse new terms.');
    return;
  }
  if (result && result.budgetStopped) {
    clearAnalyzed();
    Logger.log('JunkTerms classified with rules only because the model was unavailable. This was not counted. Run Preview again.');
    return;
  }
  markAnalyzed();
  var classified = result && result.classified != null ? result.classified : payload.searchTerms.length;
  var cached = result && result.cached ? result.cached : 0;
  Logger.log('JunkTerms classified ' + classified + ' of ' + payload.searchTerms.length + ' search terms.' + (cached ? ' ' + cached + ' were already classified.' : ''));
  if (result && result.free) {
    Logger.log('Free analysis sent; subscribe to analyse new terms.');
  }
}

function shouldAnalyze() {
  try {
    var props = PropertiesService.getScriptProperties();
    var last = Number(props.getProperty('JUNKTERMS_LAST_ANALYSIS_MS') || '0');
    return (Date.now() - last) >= ANALYSIS_INTERVAL_MS;
  } catch (err) {
    Logger.log('JunkTerms could not read script properties: ' + err);
    return true;
  }
}

function markAnalyzed() {
  try {
    PropertiesService.getScriptProperties().setProperty('JUNKTERMS_LAST_ANALYSIS_MS', String(Date.now()));
  } catch (err) {
    Logger.log('JunkTerms could not store last analysis time: ' + err);
  }
}

function clearAnalyzed() {
  try {
    PropertiesService.getScriptProperties().deleteProperty('JUNKTERMS_LAST_ANALYSIS_MS');
  } catch (err) {
    Logger.log('JunkTerms could not clear the last analysis time: ' + err);
  }
}

function dateFilter(dateRange) {
  if (dateRange === 'LAST_90_DAYS') {
    var zone = AdsApp.currentAccount().getTimeZone();
    var today = Utilities.formatDate(new Date(), zone, 'yyyy-MM-dd');
    // DURING LAST_N_DAYS excludes today. BETWEEN is the 90 days ending yesterday.
    return "segments.date BETWEEN '" + shiftIso(today, -90) + "' AND '" + shiftIso(today, -1) + "'";
  }
  var range = dateRange === 'LAST_30_DAYS' ? 'LAST_30_DAYS' : 'LAST_7_DAYS';
  return 'segments.date DURING ' + range;
}

function shiftIso(iso, days) {
  var parts = iso.split('-');
  var utc = Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
  return Utilities.formatDate(new Date(utc + days * 24 * 60 * 60 * 1000), 'UTC', 'yyyy-MM-dd');
}

function fetchSearchTerms(dateRange, termLimit) {
  var limit = termLimit > 0 ? termLimit : SEARCH_TERM_LIMIT;
  var query = [
    'SELECT',
    'search_term_view.search_term,',
    'campaign.id,',
    'campaign.name,',
    'ad_group.name,',
    'metrics.clicks,',
    'metrics.impressions,',
    'metrics.cost_micros,',
    'metrics.conversions',
    'FROM search_term_view',
    'WHERE ' + dateFilter(dateRange),
    'AND metrics.impressions > 0',
    'ORDER BY metrics.cost_micros DESC, metrics.clicks DESC, metrics.impressions DESC',
    'LIMIT ' + limit
  ].join(' ');

  var terms = [];
  var rows = AdsApp.search(query);
  while (rows.hasNext()) {
    var row = rows.next();
    var costMicros = Number(readField(row, ['metrics', 'costMicros'], 'metrics.cost_micros') || 0);
    terms.push({
      term: String(readField(row, ['searchTermView', 'searchTerm'], 'search_term_view.search_term') || ''),
      campaign: String(readField(row, ['campaign', 'name'], 'campaign.name') || ''),
      campaignId: String(readField(row, ['campaign', 'id'], 'campaign.id') || ''),
      adGroup: String(readField(row, ['adGroup', 'name'], 'ad_group.name') || ''),
      clicks: Number(readField(row, ['metrics', 'clicks'], 'metrics.clicks') || 0),
      impressions: Number(readField(row, ['metrics', 'impressions'], 'metrics.impressions') || 0),
      cost: costMicros / 1000000,
      conversions: Number(readField(row, ['metrics', 'conversions'], 'metrics.conversions') || 0)
    });
  }
  return terms;
}

function readField(row, path, flatKey) {
  var current = row;
  for (var i = 0; i < path.length; i++) {
    if (current == null) break;
    current = current[path[i]];
  }
  if (current != null && typeof current !== 'object') return current;
  if (row && flatKey && row[flatKey] != null) return row[flatKey];
  return current == null ? '' : current;
}

function fetchActiveKeywords() {
  var out = [];
  var iter = AdsApp.keywords()
    .withCondition('Status = ENABLED')
    .withCondition('CampaignStatus = ENABLED')
    .withCondition('AdGroupStatus = ENABLED')
    .withLimit(KEYWORD_LIMIT)
    .get();
  while (iter.hasNext()) {
    var keyword = iter.next();
    try {
      var campaign = keyword.getCampaign();
      out.push({
        text: keyword.getText(),
        matchType: String(keyword.getMatchType()),
        campaign: campaign.getName(),
        campaignId: String(campaign.getId()),
        adGroup: keyword.getAdGroup().getName()
      });
    } catch (err) {
      Logger.log('JunkTerms skipped a keyword: ' + err);
    }
  }
  return out;
}

function collectCampaignContext() {
  var byName = {};
  function touch(id, name, channel) {
    var key = String(name || '');
    if (!key) return null;
    if (!byName[key]) {
      byName[key] = { id: String(id || ''), name: key, channel: String(channel || ''), adGroups: [], keywords: [] };
    }
    if (id && !byName[key].id) byName[key].id = String(id);
    if (channel && !byName[key].channel) byName[key].channel = String(channel);
    return byName[key];
  }
  function rememberGroup(profile, adGroup) {
    if (!profile || !adGroup) return;
    if (profile.adGroups.length >= 30) return;
    for (var i = 0; i < profile.adGroups.length; i++) {
      if (profile.adGroups[i] === adGroup) return;
    }
    profile.adGroups.push(adGroup);
  }
  var campaignIter = AdsApp.campaigns().withCondition('Status = ENABLED').withLimit(200).get();
  while (campaignIter.hasNext()) {
    var campaign = campaignIter.next();
    var channel = '';
    try {
      channel = String(campaign.getAdvertisingChannelType());
    } catch (err) {
      channel = '';
    }
    touch(campaign.getId(), campaign.getName(), channel);
  }
  if (typeof AdsApp.performanceMaxCampaigns === 'function') {
    try {
      var pmax = AdsApp.performanceMaxCampaigns().withCondition('Status = ENABLED').withLimit(100).get();
      while (pmax.hasNext()) {
        var pmaxCampaign = pmax.next();
        touch(pmaxCampaign.getId(), pmaxCampaign.getName(), 'PERFORMANCE_MAX');
      }
    } catch (err) {
      Logger.log('JunkTerms could not list Performance Max campaigns: ' + err);
    }
  }
  var keywords = fetchActiveKeywords();
  for (var k = 0; k < keywords.length; k++) {
    var keyword = keywords[k];
    var profile = touch(keyword.campaignId, keyword.campaign, '');
    rememberGroup(profile, keyword.adGroup);
    if (profile && profile.keywords.length < 40) {
      profile.keywords.push({ text: keyword.text, adGroup: keyword.adGroup, matchType: keyword.matchType });
    }
  }
  var campaigns = [];
  for (var name in byName) {
    if (byName.hasOwnProperty(name)) campaigns.push(byName[name]);
  }
  return { keywords: keywords, campaigns: campaigns };
}

function applyNegatives(negatives, customerId) {
  var sharedKeywords = [];
  var applied = [];
  var manual = [];
  for (var i = 0; i < negatives.length; i++) {
    var item = negatives[i];
    var keyword = formatNegative(item.text, item.matchType);
    if (!keyword) continue;
    if (!item.scope || item.scope === 'account') {
      sharedKeywords.push(keyword);
      applied.push({ suggestionId: item.suggestionId });
    } else {
      var result = applyCampaignNegative(item, keyword);
      if (result.manual) manual.push({ suggestionId: item.suggestionId, reason: result.reason });
      else if (result.applied) applied.push({ suggestionId: item.suggestionId });
    }
  }
  var campaignsAttached = 0;
  if (sharedKeywords.length) {
    var list = getOrCreateSharedList();
    addKeywordsSafely(list, missingFromList(list, sharedKeywords));
    campaignsAttached = attachSharedList(list);
  }
  apiPost('https://junkterms.vercel.app/api/script/ack', {
    customerId: customerId,
    applied: applied,
    manual: manual,
    campaignsAttached: campaignsAttached
  });
  Logger.log('JunkTerms applied ' + applied.length + ' negatives. Manual: ' + manual.length + '. Campaigns linked: ' + campaignsAttached);
}

function applyCampaignNegative(item, keyword) {
  var channel = String(item.channel || '');
  var campaign = findCampaign(item);
  if (!campaign) {
    return { manual: true, reason: 'Campaign not found: ' + String(item.campaign || item.campaignId || '') };
  }
  try {
    if (campaign.getAdvertisingChannelType) channel = String(campaign.getAdvertisingChannelType()) || channel;
  } catch (err) {
    channel = channel;
  }
  var pmax = channel === 'PERFORMANCE_MAX';
  if (typeof campaign.createNegativeKeyword !== 'function') {
    if (pmax) {
      return { manual: true, reason: 'Performance Max: add this negative in Google Ads. This script cannot write campaign negatives on that campaign type.' };
    }
    return { manual: true, reason: 'This campaign type does not support campaign-level negatives from the script.' };
  }
  if (campaignHasNegative(campaign, item)) return { applied: true };
  try {
    campaign.createNegativeKeyword(keyword);
    return { applied: true };
  } catch (err) {
    Logger.log('JunkTerms could not add a campaign negative: ' + err);
    if (pmax) {
      return { manual: true, reason: 'Performance Max rejected the negative. Add it in Google Ads: ' + err };
    }
    return { manual: true, reason: String(err) };
  }
}

function findCampaign(item) {
  var id = String(item.campaignId || '');
  var numeric = /^[0-9]+$/.test(id) ? Number(id) : null;
  if (numeric != null) {
    var byId = AdsApp.campaigns().withIds([numeric]).get();
    if (byId.hasNext()) return byId.next();
  }
  var name = String(item.campaign || '').split('"').join('');
  if (name) {
    var byName = AdsApp.campaigns().withCondition('Name = "' + name + '"').get();
    if (byName.hasNext()) return byName.next();
  }
  if (typeof AdsApp.performanceMaxCampaigns === 'function') {
    try {
      if (numeric != null) {
        var pmaxId = AdsApp.performanceMaxCampaigns().withIds([numeric]).get();
        if (pmaxId.hasNext()) return pmaxId.next();
      }
      if (name) {
        var pmaxName = AdsApp.performanceMaxCampaigns().withCondition('Name = "' + name + '"').get();
        if (pmaxName.hasNext()) return pmaxName.next();
      }
    } catch (err) {
      Logger.log('JunkTerms could not look up a Performance Max campaign: ' + err);
    }
  }
  return null;
}

function campaignHasNegative(campaign, item) {
  try {
    var clean = String(item.text || '').split('[').join('').split(']').join('').split('"').join('');
    clean = clean.replace(/\s+/g, ' ').replace(/^\s+|\s+$/g, '');
    var iter = campaign.negativeKeywords().withCondition('KeywordText = "' + clean.split('"').join('') + '"').get();
    var wantExact = String(item.matchType).toLowerCase() === 'exact';
    while (iter.hasNext()) {
      var existing = iter.next();
      var existingExact = String(existing.getMatchType()).toUpperCase().indexOf('EXACT') >= 0;
      if (existingExact === wantExact) return true;
    }
  } catch (err) {
    Logger.log('JunkTerms could not read existing campaign negatives: ' + err);
  }
  return false;
}

function missingFromList(list, keywords) {
  var have = {};
  try {
    var iter = list.negativeKeywords().get();
    while (iter.hasNext()) {
      var existing = iter.next();
      have[formatNegative(existing.getText(), existing.getMatchType())] = true;
    }
  } catch (err) {
    Logger.log('JunkTerms could not read the shared list: ' + err);
    return keywords;
  }
  var missing = [];
  for (var i = 0; i < keywords.length; i++) {
    if (!have[keywords[i]]) missing.push(keywords[i]);
  }
  return missing;
}

function formatNegative(text, matchType) {
  var clean = String(text || '').split('[').join('').split(']').join('').split('"').join('');
  clean = clean.replace(/\s+/g, ' ').replace(/^\s+|\s+$/g, '');
  if (!clean) return '';
  if (String(matchType).toLowerCase() === 'exact') return '[' + clean + ']';
  return '"' + clean + '"';
}

function addKeywordsSafely(list, keywords) {
  if (!keywords.length) return;
  try {
    list.addNegativeKeywords(keywords);
  } catch (err) {
    Logger.log('JunkTerms bulk add failed, trying one by one: ' + err);
    for (var i = 0; i < keywords.length; i++) {
      try {
        list.addNegativeKeywords([keywords[i]]);
      } catch (one) {
        Logger.log('JunkTerms skipped ' + keywords[i] + ': ' + one);
      }
    }
  }
}

function getOrCreateSharedList() {
  var existing = AdsApp.negativeKeywordLists()
    .withCondition('Name = "' + SHARED_LIST_NAME + '"')
    .get();
  if (existing.hasNext()) return existing.next();
  var operation = AdsApp.newNegativeKeywordListBuilder()
    .withName(SHARED_LIST_NAME)
    .build();
  return operation.getResult();
}

function attachSharedList(list) {
  var attached = 0;
  attached += attachFromSelector(list, AdsApp.campaigns()
    .withCondition('Status = ENABLED')
    .withCondition('AdvertisingChannelType = SEARCH'));
  if (typeof AdsApp.shoppingCampaigns === 'function') {
    attached += attachFromSelector(list, AdsApp.shoppingCampaigns().withCondition('Status = ENABLED'));
  }
  if (typeof AdsApp.performanceMaxCampaigns === 'function') {
    try {
      attached += attachFromSelector(list, AdsApp.performanceMaxCampaigns().withCondition('Status = ENABLED'));
    } catch (err) {
      Logger.log('JunkTerms could not scan Performance Max campaigns: ' + err);
    }
  }
  return attached;
}

function attachFromSelector(list, selector) {
  var attached = 0;
  var iter = selector.get();
  while (iter.hasNext()) {
    var campaign = iter.next();
    try {
      if (typeof campaign.addNegativeKeywordList !== 'function') continue;
      var already = campaign.negativeKeywordLists()
        .withCondition('Name = "' + SHARED_LIST_NAME + '"')
        .get();
      if (already.hasNext()) continue;
      campaign.addNegativeKeywordList(list);
      attached++;
    } catch (err) {
      Logger.log('JunkTerms could not attach the list to ' + campaign.getName() + ': ' + err);
    }
  }
  return attached;
}

function apiPost(path, body) {
  return apiRequest('post', path, body);
}

function apiGet(path) {
  return apiRequest('get', path, null);
}

function apiRequest(method, path, body) {
  var options = {
    method: method,
    muteHttpExceptions: true,
    headers: { Authorization: 'Bearer ' + JUNKTERMS_TOKEN },
    contentType: 'application/json'
  };
  if (body) options.payload = JSON.stringify(body);
  var response = UrlFetchApp.fetch(path, options);
  var code = response.getResponseCode();
  var text = response.getContentText() || '';
  if (code < 200 || code >= 300) {
    throw new Error('JunkTerms API ' + code + ' on ' + path + ' ' + text.substring(0, 300));
  }
  if (!text) return {};
  return JSON.parse(text);
}