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
Paste the code into a new script
Tools & Settings → Bulk Actions → Scripts → + New Script. Paste the full file.
- 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
Run once with DRY_RUN: true
Confirms the query and thresholds are catching the right terms before anything is actually excluded.
- 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.