The effectiveness of ad campaigns relies heavily on account structure and keyword targeting. That’s why, Single Keyword Ad Group (SKAG) aims to build a more successful and cleaner account structure by assigning only one keyword per ad group.
However, recent changes in Google’s match types have created challenges in maintaining SKAGs, especially in cleaning exact match keywords from close variants.
This article discusses the use of SKAGs in Google Ads and how to overcome the challenges through negative keyword layering and n-gram analysis.
What is a SKAG and should you still use it?
SKAG is one of the approaches that aims to build more successful and cleaner account structure. In SKAGs, each ad groups has only one keyword in it to get maximum control. Especially for exact match SKAGs, you can ensure that you have the perfect matching ad text and a super relevant landing page for a search term. However, things have changed with Google’s changes on how match types work: Even exact match keywords are currently triggering some wild close variant search terms that can often be considered as a bad performing noise. In addition to that, Google is pushing hard on moving away from granular account structures to makes things more easy for account managers.
Does it make sense to still use SKAGs in the future? Here is our opinion:
- Even exact match SKAGs aren’t clean in traffic anymore. Nevertheless, close variant matches will increase in the future. If you don’t use a layer of negative keywords for those SKAGs, you’ll have a complicated account structure with decreasing benefit. In that case, you should stop account structures as such.
- If you run Ads in a high competitive market where a small search variation can impact the performance heavily, you’re still in good company with exact SKAGs. Instead of trusting the google bidding algorithms which they adapt bids after (some hundred) observations (and a lot of wasted budget) on bad search patterns, you can keep your traffic clean with negative keywords. You know your business better than Google. Bear in mind that semantic similarity of search terms doesn’t mean that the expected performance will be the same. If you have high CPCs in your market and heavy competition go for exact SKAGs plus a layering strategy of negative keywords.
How to clean exact match keywords from close variants effectively
It isn’t that easy indeed. Here are some of the challenges:
- Google does not show all of the matching queries anymore.
- Every day there are new search terms that are matched to your keywords even for exact matches.
Just adding close variant queries 1:1 as negative keyword will not work that well:
- You aren’t covering hidden queries that are not shown in the search term performance report
- The coverage of future variations of search terms is quite low with negatives like that. You remain only reactive.
Instead of blocking complete search terms, you should use n-grams to cover search patterns:
- One n-gram can block a large amount of different search terms that share the same pattern
- N-grams have a good blocking coverage for hidden queries and also future variations that never happened before.
When you want more details about n-grams, make sure that you check out our article where we shared a how-to about n-gram analysis in PPC and have a free online n-gram tool available to get started quickly.
SKAG exact match cleaning script
This script will automatically run to negativate n-grams on exact SKAG ad groups. (Ad groups that contains only one keyword with exact match type.)
How does the SKAG exact match cleaning script work?
Instead of just posting a script, we try to describe what is happening within the code step by step. In the next sections, we will cover the essential parts and considerations for our running solution.
In the end, the script is generating negatives for 2 cases on AdGroup scope that will clean most of the traffic for your Exact match SKAGs:
- 1-gram broad negative keywords for words that are not a part of the original keyword
- Exact match negatives for searches that are too generic compared to the keyword, e.g. the keyword [network monitoring windows] triggered the query network monitoring
Get exact SKAGs with keyword details from keyword_view report
To start negativating ngrams on exact SKAGs, first we’ll need to get ad groups in our account with the keyword details. Thus, we need to fetch “keyword_view
” report and filter results so that we only will have exact SKAGs.
In other words, we need to loop through report rows and check if there is only one keyword belonging to the ad group in the row and is the match type of this keyword exact. Then, we’ll store each EXACT SKAGs in an object with the details of keywords.
Get search term details of the exact keyword of SKAGs
After we store each Exact SKAGs, we’ll need to fetch another report to get Search Term details related with keywords. We need to get data from “search_term_view
” report, because “keyword_view
” report does not contain the Search Term data we need.
Below, you can see an example flow for storing data in an object which will contain keyword and related search term of keyword of SKAG.
After we store both keyword & search term data in an object, we are ready to start negativating n-grams that are in search term but not in keyword.
Block non-matching n-grams and too generic queries with negative keywords
Since we now have search terms and keywords for Exact SKAGs, we can split them into n-grams to decide which n-grams should the script negativate. For this, we need to compare search term n-grams with keyword n-grams. Any n-grams that are found in the search term but not in the keyword must be negative. If there aren’t any n-grams found after this process, and if the search term is more generic than keyword, this means that we have to add this generic search term as exact negative since blocking n-grams won’t work in this case.
This way, after we extract the n-grams we need to make negative, we can set those N-grams as negative for each Ad group.
Final Script: Free Google Ads Script to add N-gram Negative Keywords for Exact SKAGs
Below, you can find the free script code, also see how the whole process works.
const labelName = "Has SKAG Negative" //Main function to start script function main() { Logger.log("Starting script") //Run script as MCC. Select accounts with condition. //var accountSelector = AdsManagerApp.accounts().withCondition() //https://developers.google.com/google-ads/scripts/docs/reference/adsmanagerapp/adsmanagerapp_managedaccountselector#withCondition_1 //var accountSelector = AdsManagerApp.accounts().withIds(['000-000-000']) //https://developers.google.com/google-ads/scripts/docs/reference/adsmanagerapp/adsmanagerapp_managedaccountselector#withIds_1 var accountSelector = AdsManagerApp.accounts().withLimit(50) accountSelector.executeInParallel("processClientAccount", "afterProcessAllClientAccounts"); } function processClientAccount() { if (AdsApp.getExecutionInfo().isPreview()) { console.log("You are running script in Preview Mode, Creating label will not work."); } var negativatedKeywords = {} var acc = AdWordsApp.currentAccount(); const labelIterator = AdsManagerApp.accountLabels() .withCondition(`label.name = '${labelName}'`) .get(); if (labelIterator.hasNext()) { } else { AdsManagerApp.createAccountLabel(labelName); } var accId = acc.getCustomerId().replace(/-/g, ''); var accName = acc.getName(); //Edit your date range. var date_range = 'DURING YESTERDAY' //var date_range = ' BETWEEN '2019-01-01' AND '2019-01-31'' //https://developers.google.com/google-ads/api/docs/query/date-ranges var adgroupStatus = {} var dataAll = {} var report = AdsApp.report("SELECT ad_group_criterion.resource_name, ad_group_criterion.keyword.match_type, ad_group_criterion.criterion_id, ad_group.id, ad_group_criterion.keyword.text FROM ad_group_criterion"); var rows = report.rows(); while (rows.hasNext()) { var row = rows.next(); if ((row['ad_group_criterion.keyword.match_type'] == 'EXACT')) { if (adgroupStatus[row['ad_group.id']]) { adgroupStatus[row['ad_group.id']]["count"] = adgroupStatus[row['ad_group.id']]["count"] + 1 adgroupStatus[row['ad_group.id']]['data'].push(row) delete dataAll[row['ad_group_criterion.resource_name'].replace('keywordViews', 'adGroupCriteria')] } else { adgroupStatus[row['ad_group.id']] = {count : 1} adgroupStatus[row['ad_group.id']]['data'] = [row] dataAll[row['ad_group_criterion.resource_name'].replace('keywordViews', 'adGroupCriteria')] = { accId: '', accName: '', search_term: {}, keyword: { id: row['ad_group_criterion.criterion_id'], text: row['ad_group_criterion.keyword.text'] }, negatives: [] } } } } console.log(JSON.stringify(dataAll)) console.log(JSON.stringify(adgroupStatus)) var report2 = AdsApp.report("SELECT search_term_view.search_term, segments.date, search_term_view.resource_name, segments.keyword.ad_group_criterion, segments.keyword.info.text, ad_group.id, ad_group.name, campaign.name, segments.search_term_match_type, metrics.clicks, metrics.conversions, metrics.cost_micros, metrics.impressions FROM search_term_view WHERE segments.search_term_match_type = 'NEAR_EXACT' AND segments.date " + date_range); rows = report2.rows(); var count = 0 var countTerm = 0 console.log(`Fetching reports for your date range : ${date_range} and account : ${accName} [${accId}]`) while (rows.hasNext()) { var row = rows.next(); if (adgroupStatus[row['ad_group.id']] && adgroupStatus[row['ad_group.id']]["count"] == 1 && dataAll[row['segments.keyword.ad_group_criterion']] && dataAll[row['segments.keyword.ad_group_criterion']]["keyword"]) { console.log('will process below ------------') console.log( JSON.stringify(adgroupStatus[row['ad_group.id']])) console.log(JSON.stringify(dataAll[row['segments.keyword.ad_group_criterion']])) var keyword = row['segments.keyword.info.text'].replace(/\+/g, '') var query = row['search_term_view.search_term'].replace(/\+/g, '') var keywordSet = new Set(keyword.split(' ')); var querySet = new Set(query.split(' ')); var difference = new Set( [...querySet].filter(x => !keywordSet.has(x))); dataAll[row['segments.keyword.ad_group_criterion']]["accId"] = accId dataAll[row['segments.keyword.ad_group_criterion']]["accName"] = accName dataAll[row['segments.keyword.ad_group_criterion']]["search_term"] = { id: row['search_term_view.resource_name'], text: row['search_term_view.search_term'], date: row['segments.date'] } var ngramsToNegativate = [...difference] dataAll[row['segments.keyword.ad_group_criterion']]["negatives"] = ngramsToNegativate const adGroupIterator = AdsApp.adGroups() .withCondition(`ad_group.id = "${row['ad_group.id']}"`) .get(); if (!adGroupIterator.hasNext()) { throw new Error(`Cannot find ad group with the name '${adGroupName}'`); } if (adGroupIterator.totalNumEntities() > 1) { console.warn(`Found more than one ad group named '${adGroupName}', using the first one.`); } const adGroup = adGroupIterator.next(); if (ngramsToNegativate.length > 0) { count = count + ngramsToNegativate.length console.log(`Ad group : ${row['ad_group.name']}, Keyword : ${keyword}, Search Term : ${query}`) console.log(`--->Found ${ngramsToNegativate.length} ngrams to negativate : ${ngramsToNegativate.join(", ")}`) ngramsToNegativate.forEach(n => { if (!negativatedKeywords[n]) { adGroup.createNegativeKeyword(n); negativatedKeywords[n] = 1 if (!AdsApp.getExecutionInfo().isPreview()) { adGroup.applyLabel(labelName) } } else { } }) } else { console.log(`---> No ngrams found to negativate`) if (query.split(' ').length < keyword.split(' ').length) { countTerm++ console.log("---> Search term is more generic than keyword. Add'ng generic search term as exact negative : [" + query + ']') adGroup.createNegativeKeyword(`[${query}]`); negativatedKeywords[`[${query}]`] = 1 if (!AdsApp.getExecutionInfo().isPreview()) { adGroup.applyLabel(labelName) } } } } } if (count == 0 && countTerm == 0) { console.log("No negative keywords found") } else { console.log(`${count} total ngrams and ${countTerm} generic search term will be negativated for account : ${accName} [${accId}]`) console.log(`${Object.keys(negativatedKeywords).join(', ')}`) } var dataExport = {} for (var data in dataAll) { if (dataAll[data]['search_term']['id']) { dataExport[data] = dataAll[data] } } return JSON.stringify(dataExport) } function afterProcessAllClientAccounts(results) { console.log(`Processing all client accounts completed`) }
In the diagram below, you can see how the whole process works: