Negative keyword conflict finder
read-only
Finds enabled keywords that your own negatives are blocking, checking ad group negatives, campaign negatives and every shared negative list attached to the campaign, and reports which level each blocking negative came from.
- requires
- Search campaigns with keywords. Any account.
- writes
- Nothing. Read-only.
- schedule
- Monthly, and after any negative bulk upload
- change first
- EMAIL and MAX_REPORT at the top.
// Negative keyword conflict finder
// Finds enabled keywords that are blocked by your own negatives, at any of the three
// levels: ad group, campaign, and shared negative lists attached to the campaign.
// Shared lists are where the oldest and most destructive negatives usually live, and
// a check that reads campaign negatives only will miss them.
// Read-only: it reports the pairs. It never removes a negative.
var EMAIL = ''; // leave empty to log only
var MAX_REPORT = 200; // cap the output so the email stays readable
function main() {
var keywords = positiveKeywords();
Logger.log(keywords.length + ' enabled keywords in enabled ad groups and campaigns.');
if (!keywords.length) {
Logger.log('No enabled keywords found. Check that the account has active search campaigns.');
return;
}
var byAdGroup = adGroupNegatives();
var byCampaign = campaignNegatives();
var byList = sharedListNegatives();
var hits = [];
for (var i = 0; i < keywords.length; i++) {
var k = keywords[i];
var applicable = []
.concat(byAdGroup[k.adGroupId] || [])
.concat(byCampaign[k.campaignId] || [])
.concat(byList[k.campaignId] || []);
for (var j = 0; j < applicable.length; j++) {
var n = applicable[j];
if (blocks(n.text, n.matchType, k.text)) {
hits.push({
campaign: k.campaign,
adGroup: k.adGroup,
keyword: k.text,
kwMatch: k.matchType,
negative: n.text,
negMatch: n.matchType,
level: n.level
});
}
}
}
if (!hits.length) {
Logger.log('No conflicts found.');
return;
}
var lines = ['campaign\tad group\tkeyword\tmatch\tblocked by\tnegative match\tlevel'];
for (var h = 0; h < Math.min(hits.length, MAX_REPORT); h++) {
var x = hits[h];
lines.push([x.campaign, x.adGroup, x.keyword, x.kwMatch, x.negative, x.negMatch, x.level].join('\t'));
}
var body = AdsApp.currentAccount().getName() + '\n' +
hits.length + ' blocked keyword and negative pairs found' +
(hits.length > MAX_REPORT ? ', showing the first ' + MAX_REPORT : '') + '.\n\n' +
lines.join('\n') + '\n\n' +
'A conflict is not automatically a mistake. Check the level before removing anything: ' +
'a negative on a shared list was probably added for a different campaign.';
Logger.log(body);
if (EMAIL) {
MailApp.sendEmail(EMAIL, 'Negative conflicts (' + hits.length + ') - ' + AdsApp.currentAccount().getName(), body);
}
}
/* ---------- matching ---------- */
function words(s) {
return String(s).toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.split(/\s+/)
.filter(function (w) { return w.length > 0; });
}
function blocks(negText, negMatch, kwText) {
var n = words(negText);
var k = words(kwText);
if (!n.length || !k.length) { return false; }
if (negMatch === 'EXACT') { return n.join(' ') === k.join(' '); }
if (negMatch === 'PHRASE') { return containsSequence(k, n); }
// BROAD: every word of the negative must appear in the keyword, order ignored
for (var i = 0; i < n.length; i++) {
if (k.indexOf(n[i]) === -1) { return false; }
}
return true;
}
function containsSequence(haystack, needle) {
for (var i = 0; i + needle.length <= haystack.length; i++) {
var ok = true;
for (var j = 0; j < needle.length; j++) {
if (haystack[i + j] !== needle[j]) { ok = false; break; }
}
if (ok) { return true; }
}
return false;
}
/* ---------- reads ---------- */
function positiveKeywords() {
var list = [];
var rows = AdsApp.search(
'SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ' +
' ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ' +
' ad_group_criterion.negative ' +
'FROM ad_group_criterion ' +
'WHERE ad_group_criterion.type = "KEYWORD" ' +
' AND ad_group_criterion.status = "ENABLED" ' +
' AND ad_group.status = "ENABLED" ' +
' AND campaign.status = "ENABLED"'
);
while (rows.hasNext()) {
var r = rows.next();
if (r.adGroupCriterion.negative === true || r.adGroupCriterion.negative === 'true') { continue; }
list.push({
campaignId: String(r.campaign.id),
campaign: String(r.campaign.name),
adGroupId: String(r.adGroup.id),
adGroup: String(r.adGroup.name),
text: String(r.adGroupCriterion.keyword.text),
matchType: String(r.adGroupCriterion.keyword.matchType)
});
}
return list;
}
function adGroupNegatives() {
var out = {};
var rows = AdsApp.search(
'SELECT ad_group.id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type ' +
'FROM ad_group_criterion ' +
'WHERE ad_group_criterion.type = "KEYWORD" ' +
' AND ad_group_criterion.negative = TRUE'
);
while (rows.hasNext()) {
var r = rows.next();
var id = String(r.adGroup.id);
if (!out[id]) { out[id] = []; }
out[id].push({
text: String(r.adGroupCriterion.keyword.text),
matchType: String(r.adGroupCriterion.keyword.matchType),
level: 'ad group'
});
}
return out;
}
function campaignNegatives() {
var out = {};
var rows = AdsApp.search(
'SELECT campaign.id, campaign_criterion.keyword.text, campaign_criterion.keyword.match_type ' +
'FROM campaign_criterion ' +
'WHERE campaign_criterion.type = "KEYWORD" ' +
' AND campaign_criterion.negative = TRUE'
);
while (rows.hasNext()) {
var r = rows.next();
var id = String(r.campaign.id);
if (!out[id]) { out[id] = []; }
out[id].push({
text: String(r.campaignCriterion.keyword.text),
matchType: String(r.campaignCriterion.keyword.matchType),
level: 'campaign'
});
}
return out;
}
// Shared lists: one query for the criteria, one for which campaigns each list is on.
function sharedListNegatives() {
var criteria = {};
var rows = AdsApp.search(
'SELECT shared_set.id, shared_set.name, ' +
' shared_criterion.keyword.text, shared_criterion.keyword.match_type ' +
'FROM shared_criterion ' +
'WHERE shared_criterion.type = "KEYWORD"'
);
while (rows.hasNext()) {
var r = rows.next();
var id = String(r.sharedSet.id);
if (!criteria[id]) { criteria[id] = []; }
criteria[id].push({
text: String(r.sharedCriterion.keyword.text),
matchType: String(r.sharedCriterion.keyword.matchType),
level: 'list: ' + String(r.sharedSet.name)
});
}
var out = {};
var maps = AdsApp.search(
'SELECT campaign.id, shared_set.id ' +
'FROM campaign_shared_set ' +
'WHERE campaign.status != "REMOVED"'
);
while (maps.hasNext()) {
var m = maps.next();
var cid = String(m.campaign.id);
var sid = String(m.sharedSet.id);
if (!criteria[sid]) { continue; }
out[cid] = (out[cid] || []).concat(criteria[sid]);
}
return out;
}What it checks
Every enabled keyword, in an enabled ad group, in an enabled campaign, against every negative that applies to it from all three levels: the ad group, the campaign, and each shared negative list attached to that campaign.
The third one is the point. Most conflict checks read campaign negatives and stop, and shared lists are exactly where the problem lives — a broad negative added two years ago for one campaign, now applied account-wide, quietly blocking a keyword in a campaign that launched last month.
The matching rules it applies
| Negative match type | Blocks the keyword when |
|---|---|
| Broad | Every word of the negative appears somewhere in the keyword, in any order |
| Phrase | The negative appears as a contiguous word sequence inside the keyword |
| Exact | The negative and the keyword are the same words in the same order |
Punctuation is stripped and everything is lowercased before comparison. Close variants are deliberately not considered, because negative keywords do not match close variants — a negative for plumber does not block plumbers, and pretending otherwise would produce conflicts that do not exist.
Reading the output
Each row is a blocked pair with the level the negative came from. That last column decides the fix: an ad group negative is usually a local decision worth respecting, a campaign negative is worth a look, and a shared list negative blocking a keyword in an unrelated campaign is almost always the bug.
Sometimes a negative is deliberately blocking a keyword that was left enabled for its history. This script reports and never removes, because a script that deletes negatives automatically will eventually reopen traffic somebody spent a quarter shutting off.
Prioritising the list
Sort by what the blocked keyword used to earn, not by the number of conflicts. A keyword with no history that is now blocked probably deserves to be. A keyword that converted until a specific date and then stopped is the row worth acting on today.
The script does not pull historical performance, deliberately — it would triple the runtime for a column you only need on a handful of rows. Look those up in the interface once the list is short.
Notes and limits
- Search campaigns only, since keywords are a Search concept. Performance Max negatives operate differently and are not covered.
- Large accounts produce large intermediate arrays.
MAX_REPORTcaps the email, not the analysis, so the count in the first line is always the true number. - Run it monthly, and after any bulk negative upload.
Background: negative keyword conflicts explained.