Dan Antony

Google Ads Script to Exclude Wasteful Search Terms

PPC & Paid Search·Google Ads Script·Google Ads

What it does

Pulls the search term report across all enabled campaigns for a configurable lookback window (30 days by default). Any search term that has spent more than your cost threshold with zero conversions gets added as a campaign-level exact-match negative keyword. A summary email lists every term that was flagged, what it cost, and which negatives were actually added.

The code

/**
 * Negative Keyword Waste Finder
 *
 * Scans search terms across all enabled campaigns for the last N days,
 * flags any search term that has spent more than a cost threshold with
 * zero conversions, adds it as a campaign-level negative exact match,
 * and emails a summary of what was excluded.
 *
 * Configure the constants below, then schedule this to run daily.
 */

const CONFIG = {
  LOOKBACK_DAYS: 30,
  MIN_COST_MICROS_WASTE: 20 * 1000000, // $20 spent with zero conversions
  NOTIFICATION_EMAIL: "you@example.com",
  DRY_RUN: false, // set true to preview without adding negatives
};

function main() {
  const dateRange = getDateRange(CONFIG.LOOKBACK_DAYS);
  const wastefulTerms = findWastefulSearchTerms(dateRange);

  if (wastefulTerms.length === 0) {
    Logger.log("No wasteful search terms found in the lookback window.");
    return;
  }

  const added = CONFIG.DRY_RUN ? [] : addNegatives(wastefulTerms);
  sendSummaryEmail(wastefulTerms, added);
}

function getDateRange(days) {
  const end = new Date();
  const start = new Date();
  start.setDate(end.getDate() - days);
  const fmt = (d) => Utilities.formatDate(d, AdsApp.currentAccount().getTimeZone(), "yyyyMMdd");
  return { start: fmt(start), end: fmt(end) };
}

function findWastefulSearchTerms(dateRange) {
  const query = `
    SELECT
      search_term_view.search_term,
      campaign.id,
      campaign.name,
      metrics.cost_micros,
      metrics.conversions
    FROM search_term_view
    WHERE segments.date BETWEEN '${dateRange.start}' AND '${dateRange.end}'
      AND campaign.status = 'ENABLED'
    ORDER BY metrics.cost_micros DESC
  `;

  const rows = AdsApp.search(query);
  const wasteful = [];

  while (rows.hasNext()) {
    const row = rows.next();
    const cost = row.metrics.costMicros;
    const conversions = row.metrics.conversions;

    if (cost >= CONFIG.MIN_COST_MICROS_WASTE && conversions === 0) {
      wasteful.push({
        term: row.searchTermView.searchTerm,
        campaignId: row.campaign.id,
        campaignName: row.campaign.name,
        costMicros: cost,
      });
    }
  }

  return wasteful;
}

function addNegatives(wastefulTerms) {
  const added = [];
  const byCampaign = {};

  wastefulTerms.forEach((row) => {
    if (!byCampaign[row.campaignId]) byCampaign[row.campaignId] = [];
    byCampaign[row.campaignId].push(row);
  });

  Object.keys(byCampaign).forEach((campaignId) => {
    const campaignIter = AdsApp.campaigns().withIds([campaignId]).get();
    if (!campaignIter.hasNext()) return;
    const campaign = campaignIter.next();

    byCampaign[campaignId].forEach((row) => {
      try {
        campaign.createNegativeKeyword(`[${row.term}]`);
        added.push(row);
      } catch (e) {
        Logger.log(`Failed to add negative "${row.term}": ${e}`);
      }
    });
  });

  return added;
}

function sendSummaryEmail(wastefulTerms, added) {
  const totalWasteMicros = wastefulTerms.reduce((sum, r) => sum + r.costMicros, 0);
  const totalWaste = (totalWasteMicros / 1000000).toFixed(2);

  const rows = wastefulTerms
    .map(
      (r) =>
        `${r.term}${r.campaignName} — $${(r.costMicros / 1000000).toFixed(2)}`
    )
    .join("\n");

  const subject = `[Google Ads] ${wastefulTerms.length} wasteful search terms found — $${totalWaste} spent, 0 conversions`;
  const body = `${
    CONFIG.DRY_RUN ? "DRY RUN — no negatives were added.\n\n" : `${added.length} negative keywords added.\n\n`
  }${rows}`;

  MailApp.sendEmail(CONFIG.NOTIFICATION_EMAIL, subject, body);
}

When to use this

Run this on accounts where search term waste is a recurring problem — broad match or phrase match campaigns with enough spend that manual search term review can't keep up. It's a safety net, not a replacement for a human reviewing search terms weekly.

Prerequisites

  • Script access at the account or MCC level (Tools & Settings → Bulk Actions → Scripts)
  • Ability to authorize MailApp for the notification email

How to implement

  1. 1

    Paste the code into a new script

    Tools & Settings → Bulk Actions → Scripts → + New Script. Paste the full file.

  2. 2

    Set your notification email and cost threshold

    Edit the CONFIG block at the top — NOTIFICATION_EMAIL and MIN_COST_MICROS_WASTE (in micros, so $20 = 20000000).

  3. 3

    Run once with DRY_RUN: true

    Confirms the query and thresholds are catching the right terms before anything is actually excluded.

  4. 4

    Set DRY_RUN: false and schedule it

    Daily is reasonable for most accounts. Weekly is fine for lower-spend accounts.

Caveats

Adds negatives at the campaign level, not ad group level — if you need finer control, adjust createNegativeKeyword to target a specific ad group. Exact match negatives only; it won't catch near-variants of a wasteful term.