NKL cleaner

Eenvoudig opschonen van negatieve zoekwoordenlijsten met één druk op de knop!

Start nu!
Clean your negative keyword lists in just one click
NKL cleaner Get started!

Negatieve zoekwoordenlijsten zijn vaak het resultaat van jarenlang zoekwoorden toevoegen en verwijderen. Hierdoor sluipen er schoonheidsfoutjes in de lijsten en bevatten ze vaak overbodige negatives. Dit script voert 3 hygiëne taken uit op de lijsten, waarbij de werking van de lijst onveranderd blijft.

Difficulty


Tags

1. Corrigeren hoofdlettergebruik

Het gebruik van hoofdletters in negatieve (én positieve) zoekwoorden staat - mening van auteur 😉 - slordig. Het heeft geen effect op de werking van het negatieve zoekwoord, maar maakt het mogelijk om een negatief zoekwoord dubbel toe te voegen. Dit verzwaart de lijsten en maakt het laden van accounts en het uitvoeren van scripts trager. Het script verwijdert negatieve zoekwoorden met hoofdletters en voegt ze opnieuw toe in kleine letters.

Lees meer over het gebruik van hoofdletters en leestekens in negatieve zoekwoorden in dit Google Ads Help artikel.

2. Corrigeren phrase match negatives bestaande uit één woord

De match types bij negatieve zoekwoorden werken wezenlijk anders dan bij positieve zoekwoorden. Mocht je 15 minuten hebben, bekijk vooral deze video van Brad Geddes over de werking van match types bij negatieve zoekwoorden. Na het zien zul je begrijpen dat een negatief zoekwoord bestaande uit 1 woord met als match type "phrase" (zinsdeel) niet functioneel is en werkt als een broad match. Het script corrigeert dit en past bijvoorbeeld <"gratis"> aan naar <gratis>. 

3. Verwijderen overbodige negatives

In de loop van de tijd worden vaak negatieve zoekwoorden aan een lijst toegevoegd die al door een ander zoekwoord worden uitgesloten. Als het negatieve zoekwoord <vis> al in de lijst staat, dan kan <vis winkel> verwijderd worden. <viswinkel> (zonder spatie) moet echter behouden blijven. Wees niet verbaasd als tot 90% van de zoekwoorden uit de lijst overbodig blijken zijn! Het verminderen van het aantal zoekwoorden heeft een aantal voordelen:

  • Voorkomen overschrijden accountlimieten
  • Sneller laden van accounts in de Google Ads Editor
  • Voorkomen runtime errors bij uitvoeren van Google Ads Scripts, zoals bijv. het Negative Keyword Conflicts script van Google
  • Meer overzicht, en daarmee - vaak - minder fouten

Scope

Het script kan lijsten opschonen waartoe het account (waarin je het script draait) toegang heeft. Dat wil zeggen: alle lijsten van het account en van het MCC. Draai je het script op MCC niveau, dan worden alleen de lijsten op MCC niveau opgeschoond.

Waarschuwing

Dit script is uitvoerig getest voor TUI en VodafoneZiggo, maar bedenk dat negatieve zoekwoorden een hele krachtige tool zijn. Controleer en dubbelcheck dus altijd of het script de gewenste output levert. Dit doe je door het script eerst te previewen en vervolgens bij de LOGS de output zorgvuldig te bestuderen.

Credits

Dit script maakt gebruik van onderdelen van het Negative Keyword Conflicts script van Google.

Settings

Instellen van het script is eenvoudig. Kopieer deze code van deze pagina en maak een nieuw script aan. Vervolgens zijn er een aantal variabelen die je kan instellen:

  • LOG: stel in op "true" als je de output wil loggen (dit is aanbevolen!)
  • LIST_NAME_CONTAINS: beperk het script tot specifieke lijsten
  • LIST_NAME_DOES_NOT_CONTAIN: sluit specifieke lijsten uit van het script
  • CORRECT_CAPITALIZATION: zet op 'true' voor het corrigeren van hoofdletters
  • CORRECT_ONE_WORD_PHRASES: zet op 'true' voor het omzetten van 1-woord phrase negatives naar broad match
  • REMOVE_REDUNDANT_NEGATIVES: zet op 'true' voor het verwijderen van overbodige negatives
The script
// NKL cleaner
//
// ABOUT THE SCRIPT
// Perform 3 hygiene action for your Negative Keyword Lists:
// - Correct capitalization
// - Correct one word phrase negatives
// - Remove redundant negatives
//
// Created By: Martijn Kraan
// With parts from Google's "Negative Keyword Conflicts" script
// Brightstep.nl
// 
// Created: 17-02-2020
//
////////////////////////////////////////////////////////////////////

var config = {

    LOG: true,
    // Set to 'true' if you want to log the changes

    LIST_NAME_CONTAINS: [],
    // Only one value allowed
    // For example ['Brand'] would only look at lists containing 'Brand'
    // Leave empty [] to include all campaigns.  

    LIST_NAME_DOES_NOT_CONTAIN: [],
    // Only one value allowed
    // For example ['Generic'] would skip lists containing 'Generic'
    // Leave empty [] to include all campaigns.

    CORRECT_CAPITALIZATION: true,
    // Set this to 'true' transform all negative keywords to lowercase
    // since (negative) keywords are not case sensitive
    // For example "Free" will be converted to "free"

    CORRECT_ONE_WORD_PHRASES: true,
    // Set this to 'true' to change one-word phrase match negatives to broad match.
    // For example "free" will be converted to free, but "for free" will stay unchanged

    REMOVE_REDUNDANT_NEGATIVES: true,
    // Set this to true if you want to remove redundant negative keywords
    // For example: if "free" (broad match) is already in the list,
    // the negative "for free" (broad, phrase or exact match) can be removed

}

////////////////////////////////////////////////////////////////////

function main() {

    // Get all the negative keyword lists according to the selection in the config
    var negativeKeywordLists = getNKLs();

    // Loop through the selected negative keyword lists
    while (negativeKeywordLists.hasNext()) {

        var negativeKeywordList = negativeKeywordLists.next();
        if (config.LOG === true) {
            Logger.log('Processing list "' + negativeKeywordList.getName() + '"');
            Logger.log('-----------');
        }

        // Check for capitalization
        if (config.CORRECT_CAPITALIZATION) {
            if (config.LOG === true) {
                Logger.log('-> Checking for capitalization:');
            }

            // Get the negatives from the list as objects
            var negatives = getNegatives(negativeKeywordList);

            // Loop through all the negatives
            for (var neg in negatives) {
                var negative = negatives[neg];

                // If negative contains a capital...
                if (checkForCapitals(negative.display)) {

                    //...remove the negative...
                    negative.neg.remove();

                    //...and add the negative again, without capitals
                    negativeKeywordList.addNegativeKeyword(negative.display.toLowerCase());

                    if (config.LOG === true) {
                        Logger.log('Set ' + negative.display + ' (' + negative.matchType + ') to lowercase');
                    }
                }
            }
        }

        // Check for one word phrase match negatives
        if (config.CORRECT_ONE_WORD_PHRASES) {

            if (config.LOG === true) {
                Logger.log('-> Checking for one-word PHRASE negatives:');
            }

            // Get the negatives from the list as objects (again)
            var negatives = getNegatives(negativeKeywordList);

            // Loop through all the negatives
            for (var neg in negatives) {
                var negative = negatives[neg];

                // If negative match type is phrase and number of words is 1...
                if (negative.matchType === 'PHRASE' && negative.wordCount === 1) {

                    //...remove the negative...
                    negative.neg.remove();

                    //...and add the negative again as a broad match negative
                    negativeKeywordList.addNegativeKeyword(negative.raw);

                    if (config.LOG === true) {
                        Logger.log('Changed negative keyword ' + negative.display + ' from phrase to broad match');
                    }
                }
            }
        }

        // Check for redundant negatives
        if (config.REMOVE_REDUNDANT_NEGATIVES) {

            if (config.LOG === true) {
                Logger.log('-> Checking for redundant negatives:');
            }

            // Get the negatives from the list as objects (again)          
            var negatives = getNegatives(negativeKeywordList);
            // Sort the negatives so that the broad match negatives are first
            negatives.sort(compare);

            // Loop through all the negatives
            for (var i = 0; i < negatives.length; i++) {
                var negative = negatives[i];

                switch (negative.matchType) {

                    // If negative match type is broad...
                    case 'BROAD':

                        //...compare the negative with all the other negatives from the list
                        for (var y = 0; y < negatives.length; y++) {

                            // Check if the negative broad matches the other (redundant) negative
                            var match = hasAllTokens(negative.raw, negatives[y].raw)

                            // If there's a match (and it's not the same negative)...
                            if (match && negative.display != negatives[y].display) {

                                //...remove the matched redundant negative
                                negatives[y].neg.remove();

                                if (config.LOG === true) {
                                    Logger.log('"' + negatives[y].raw + '" (' + negatives[y].matchType + ') can be removed because the negative keyword "' + negative.raw + '" (' + negative.matchType + ') will block related queries already');
                                }

                                // Correct the increments of both loops
                                negatives.splice(y, 1);
                                if (i > y) {
                                    i--;
                                }
                                y--;
                            }
                        }
                        break;

                        // If negative match type is phrase...                    
                    case 'PHRASE':

                        //...compare the negative with all the other negatives from the list                    
                        for (var y = 0; y < negatives.length; y++) {

                            // Check if the negative phrase matches the other (redundant) negative                          
                            var match = isSubsequence(negative.raw, negatives[y].raw)

                            // If there's a match (and it's not the same negative)...                            
                            if (match && negative.raw != negatives[y].raw) {

                                //...remove the matched redundant negative                              
                                negatives[y].neg.remove();

                                if (config.LOG === true) {
                                    Logger.log('"' + negatives[y].raw + '" (' + negatives[y].matchType + ') can be removed because the negative keyword "' + negative.raw + '" (' + negative.matchType + ') will block related queries already');
                                }

                                // Correct the increments of both loops                              
                                negatives.splice(y, 1);
                                if (i > y) {
                                    i--;
                                }
                                y--;
                            }
                        }
                        break;

                        // If negative match type is exact...                    
                    case 'EXACT':
                        //...no action needed because an exact match can only block itself
                        break;
                }
            }
        }
        Logger.log(' ');
    }
}

function getNKLs() {
    var negativeKeywordListIterator;
    if (config.LIST_NAME_CONTAINS.length > 0) {
        var negativeKeywordListIterator = AdsApp.negativeKeywordLists()
            .withCondition('Name CONTAINS_IGNORE_CASE "' + config.LIST_NAME_CONTAINS + '"')
            .get();
    } else if (config.LIST_NAME_DOES_NOT_CONTAIN.length > 0) {
        var negativeKeywordListIterator = AdsApp.negativeKeywordLists()
            .withCondition('Name DOES_NOT_CONTAIN_IGNORE_CASE "' + config.LIST_NAME_DOES_NOT_CONTAIN + '"')
            .get();
    } else {
        var negativeKeywordListIterator = AdsApp.negativeKeywordLists()
            .get();
    }
    return negativeKeywordListIterator;
}

function getNegatives(negativeKeywordList) {

    var negativeKeywords = negativeKeywordList.negativeKeywords().get();
    var negatives = [];

    while (negativeKeywords.hasNext()) {
        var negative = negativeKeywords.next();
        negatives.push(
            normalizeKeyword(negative, negative.getText(), negative.getMatchType()));
    }
    return negatives;
}

/**
 * Normalizes a keyword by returning a raw and display version and consistent
 * match type. The raw version has no leading and trailing punctuation for
 * phrase and exact match keywords, no consecutive whitespace, is all
 * lowercase, and removes broad match qualifiers. The display version has no
 * consecutive whitespace and is all lowercase. The match type is uppercase.
 *
 * @param {string} text A keyword's text that should be normalized.
 * @param {string} matchType The keyword's match type.
 * @return {Object} An object with fields display, raw, and matchType.
 */

function normalizeKeyword(negative, text, matchType) {
    var display;
    var raw = text;
    matchType = matchType.toUpperCase();

    // Replace leading and trailing "" for phrase match keywords and [] for
    // exact match keywords, if it is there.
    if (matchType == 'PHRASE') {
        raw = trimKeyword(raw, '"', '"');
    } else if (matchType == 'EXACT') {
        raw = trimKeyword(raw, '[', ']');
    }

    // Collapse any runs of whitespace into single spaces.
    raw = raw.replace(new RegExp('\\s+', 'g'), ' ');

    // Set display version.
    display = raw;
    if (matchType == 'PHRASE') {
        display = '"' + display + '"';
    } else if (matchType == 'EXACT') {
        display = '[' + display + ']';
    }

    // Keywords are not case sensitive.
    raw = raw.toLowerCase();

    // Remove broad match modifier '+' sign.
    raw = raw.replace(new RegExp('\\s\\+', 'g'), ' ');

    // Check length
    var wordCount = raw.split(' ').length;

    return {
        neg: negative,
        display: display,
        raw: raw,
        matchType: matchType,
        wordCount: wordCount
    };
}

/**
 * Removes leading and trailing match type punctuation from the first and
 * last character of a keyword's text, if any.
 *
 * @param {string} text A keyword's text to remove punctuation from.
 * @param {string} open The character that may be the first character.
 * @param {string} close The character that may be the last character.
 * @return {Object} The same text, trimmed of open and close if present.
 */
function trimKeyword(text, open, close) {
    if (text.substring(0, 1) == open &&
        text.substring(text.length - 1) == close) {
        return text.substring(1, text.length - 1);
    }

    return text;
}

/**
 * Tests whether all of the tokens in one keyword's raw text appear in
 * the tokens of a second keyword's text.
 *
 * @param {string} keywordText1 the raw keyword text whose tokens may
 *     appear in the other keyword text.
 * @param {string} keywordText2 the raw keyword text which may contain
 *     the tokens of the other keyword.
 * @return {boolean} Whether all tokens in keywordText1 appear among
 *     the tokens of keywordText2.
 */
function hasAllTokens(keywordText1, keywordText2) {
    var keywordTokens1 = keywordText1.split(' ');
    var keywordTokens2 = keywordText2.split(' ');

    for (var i = 0; i < keywordTokens1.length; i++) {
        if (keywordTokens2.indexOf(keywordTokens1[i]) == -1) {
            return false;
        }
    }

    return true;
}

/**
 * Tests whether all of the tokens in one keyword's raw text appear in
 * order in the tokens of a second keyword's text.
 *
 * @param {string} keywordText1 the raw keyword text whose tokens may
 *     appear in the other keyword text.
 * @param {string} keywordText2 the raw keyword text which may contain
 *     the tokens of the other keyword in order.
 * @return {boolean} Whether all tokens in keywordText1 appear in order
 *     among the tokens of keywordText2.
 */

function isSubsequence(keywordText1, keywordText2) {
    return (' ' + keywordText2 + ' ').indexOf(' ' + keywordText1 + ' ') >= 0;
}

function checkForCapitals(str) {
    return str.match(/[A-Z]/);
}

function compare(a, b) {
    if (a.matchType < b.matchType) {
        return -1;
    }
    if (a.matchType > b.matchType) {
        return 1;
    }
    return 0;
}
Show whole script!
The Experts
Tibbe van Asten Head of PPC @ Increase
Nils Rooijmans Water Cooler Topics
Martijn Kraan Freelance PPC Specialist
Bas Baudoin Teamlead SEA @ Happy Leads
How about you? JOIN US!
Sharing Knowledge
Caring

Kennis delen

Adsscripts.com staat voor het delen van kennis. In de huidige markt houden SEA-specialisten de kennis en ervaring graag voor zich. Wij zijn er van overtuigd dat het delen van kennis ervoor kan zorgen dat iedereen beter wordt in haar of zijn werk. Daarom lopen wij hier graag in voorop, door onze kennis over scripts te delen met iedereen.

Wil jij ook graag een bijdrage leveren? Wij staan open voor nieuwe ideeën en feedback op alles wat je op Adsscripts.com vindt.

Neem contact op

Training &
Workshop
Neem contact op!
Adsscripts Training & Workshop