Free Google Ads script to negativate Ngrams on exact SKAGs

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.

On this post
    Free Google Ads Script to Negativate Ngrams on Exact SKAGs

    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 ngrams, 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.

    Identify all exact SKAGs where we can add negative keywords.

    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.

    Load search term data for relevant SKAGs.

    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.

    Relevant keyword and query data to derive negative keywords.

    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 is 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 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 will not work in this case.

    Script output: Add negative keywords for (a) non-matching search parts (N-gram) and (b) too generic full queries.

    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()
            //var accountSelector = AdsManagerApp.accounts().withIds(['000-000-000'])
        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(` = '${labelName}'`)
        if (labelIterator.hasNext()) {
        } else {
        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''
        var adgroupStatus = {}
        var dataAll = {}
        var report ="SELECT keyword_view.resource_name,ad_group_criterion.keyword.match_type, ad_group_criterion.criterion_id,, ad_group_criterion.keyword.text,, metrics.cost_micros, metrics.conversions, metrics.clicks, metrics.impressions FROM keyword_view WHERE " + date_range);
        var rows = report.rows();
        while (rows.hasNext()) {
            var row =;
            if ((row['ad_group_criterion.keyword.match_type'] == 'EXACT')) {
                if (adgroupStatus[row['']]) {
                    adgroupStatus[row['']]["count"] = adgroupStatus[row['']]["count"] + 1
                    delete dataAll[row['keyword_view.resource_name'].replace('keywordViews', 'adGroupCriteria')]
                } else {
                    adgroupStatus[row['']] = { count: 1 }
                    dataAll[row['keyword_view.resource_name'].replace('keywordViews', 'adGroupCriteria')] = {
                        accId: '',
                        accName: '',
                        search_term: {},
                        keyword: {
                            id: row['ad_group_criterion.criterion_id'],
                            date: row[''],
                            text: row['ad_group_criterion.keyword.text']
                        negatives: []
        var report ="SELECT search_term_view.search_term,, search_term_view.resource_name, segments.keyword.ad_group_criterion,,,,, 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 " + date_range);
        var rows = report.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 =;
            if (adgroupStatus[row['']] && adgroupStatus[row['']]["count"] == 1 && dataAll[row['segments.keyword.ad_group_criterion']]["keyword"]) {
                var keyword = row[''].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['']
                var ngramsToNegativate = [...difference]
                dataAll[row['segments.keyword.ad_group_criterion']]["negatives"] = ngramsToNegativate
                const adGroupIterator = AdsApp.adGroups()
                    .withCondition(` = "${row['']}"`)
                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 =;
                if (ngramsToNegativate.length > 0) {
                    count = count + ngramsToNegativate.length
                    console.log(`Ad group : ${row['']}, Keyword : ${keyword}, Search Term : ${query}`)
                    console.log(`--->Found ${ngramsToNegativate.length} ngrams to negativate : ${ngramsToNegativate.join(", ")}`)
                    ngramsToNegativate.forEach(n => {
                        if (!negativatedKeywords[n]) {
                            negativatedKeywords[n] = 1
                            if (!AdsApp.getExecutionInfo().isPreview()) {
                        } else {
                } else {
                    console.log(`---> No ngrams found to negativate`)
                    if (query.split(' ').length < keyword.split(' ').length) {
                        console.log("---> Search term is more generic than keyword. Add'ng generic search term as exact negative : [" + query + ']')
                        negativatedKeywords[`[${query}]`] = 1
                        if (!AdsApp.getExecutionInfo().isPreview()) {
        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:

    More Similar Posts