Automatically add high-performing search queries as keywords to your paid search campaign. This script looks through your account, finds any converting search queries that would make great keywords and automatically adds them to your account. Sounds as good as it is.

Why did we create this script?

No matter how much keyword research you do, you can never be 100% sure what people are going to type into Google. Since people are always searching for things in different ways, there’s plenty of potential traffic that isn’t targeted every day, simply because those long-tail searches are not being targeted. 

This is why we created this script (affectionately referred to as “Dave” at Impression HQ, find out why at the bottom of this post). It works by scouring an account, finding keywords that both convert and are being actively searched for by users and adding them into a campaign for you. The script constantly monitors all of the search terms existing in the account and adds new, high-performing keywords in seconds, based on parameters set by you. Jump straight to the code here.

The script

/*******************************************************************************
*
* [DAVE] Query To Keyword
* Author: Lauren Capon (@PPCLoz) & Nathan Ifill (@nathanifill), Impression
*
* This script adds search terms with a cost per acquisition (CPA) lower than
* target as either an exact, phrase or broad match modifier keyword.
*
* v1.12
*
* Any suggestions? Email nathan.ifill@impression.co.uk
*
* Change History:
* v1.12
* - NI stopped email sending if there are no new keywords
*
* v1.11
* - NI added another fix to avoid DSA search terms because the first one in
* version 1.8 was clearly rubbish and didn't work
*
* v1.10
* - NI added a clean log
*
* v1.9
* - NI added impressions as a metric
* - NI added email option to send log once finished
*
* v1.8
* - NI added more stats and details to output - campaign, ad group, clicks, ctr,
* conv. rate, CPA, revenue, roas
* - NI added fix to avoid DSA campaigns and ad groups
* - NI fixed target CPA to be less than or equals to instead of just less than
*
* v1.7
* - NI added parseInt functions to parse CPAs and CPCs and integers
* - NI added support for minimum number of conversions
*
* v1.6
* - NI added function to add keywords with bid set to average CPA
* - NI added error handling for keywords which are longer than 80 chars
* - NI added error handling for keywords with more than 10 words
*
* v1.5
* - NI added support for phrase match
*
* v1.4
* - NI added BMM and exact split
*
* v1.3
* - NI restricted selection to search queries which have not been added or
* excluded in enabled ad groups and campaigns
*
* v1.2
* - NI added new longQuery to speed up script and extend lookback period
*
* v1.1
* - LC fixed CPA bug
*
*******************************************************************************/
var maxCPA = 100;
// Enter the maximum all-time CPA the search term requires above.
// To only consider search terms with a £10 CPA, enter 10. To only consider
// search terms with a £1.50 CPA, enter 1.50.
// Example: var maxCPA = 10;
// Example: var maxCPA = 1.50;

var minROAS = 0;
// Enter the minimum all-time ROAS the search term requires above.
// To only consider search terms with at least a 3.5:1 all-time ROAS, enter 3.5.
// To only consider search terms with at least a 5 to 1 all-time ROAS, enter 5.
// Example: var minROAS = 3.5;
// Example: var minROAS = 5;

var minImpressions = 0;
// Enter the minimum number of all-time impressions the search term requires
// above as an integer. To only consider search terms with 2 or more
// all-time impressions, enter 2.
// Example: var minImpressions = 2;

var minConversions = 0.01;
// Enter the minimum number of all-time conversions the search term requires
// above. To only consider search terms with 2 or more all-time conversions,
// enter 2. To only consider search terms with 0.5 or more all-time
// conversions, enter 0.5.
// Example: var minConversions = 2;
// Example: var minConversions = 0.5;

var yourEmail = "";
// Enter your email above if you'd like to be emailed the log when the script
// has finished running. If you'd rather not receive the email log, leave this
// blank. You can add multiple emails using commas, if you want.
// Example: var yourEmail = "james.jameson@impression.co.uk";
// Example: var yourEmail = "dave@impression.co.uk, becky@impression.co.uk";

/******************************************************************************//*                       DON'T TOUCH THE STUFF BELOW                          *//******************************************************************************/
minImpressions--;
minConversions -= 0.01;
minROAS -= 0.01;

var fullCleanLog = ""; // initialise fullCleanLog
var keywordCount = 0; // intialise keyword count

function main() {

  // Campaign Broad
  var broadCampaignReport = gimmeMyReport("Campaign", "Broad");
  cpaCheck(broadCampaignReport, "broad");
  cleanLog(" ");

  var bmmCampaignReport = gimmeMyReport("Campaign", "BMM");
  cpaCheck(bmmCampaignReport, "broad");
  cleanLog(" ");

  // Ad Group Broad
  var broadAdGroupReport = gimmeMyReport("Ad Group", "Broad");
  cpaCheck(broadAdGroupReport, "broad");
  cleanLog(" ");

  var bmmAdGroupReport = gimmeMyReport("Ad Group", "BMM");
  cpaCheck(bmmAdGroupReport, "broad");
  cleanLog(" ");

  // Campaign Phrase
  var phraseCampaignReport = gimmeMyReport("Campaign", "Phrase");
  cpaCheck(phraseCampaignReport, "phrase");
  cleanLog(" ");

  // Ad Group Phrase
  var phraseAdGroupReport = gimmeMyReport("Ad Group", "Phrase");
  cpaCheck(phraseAdGroupReport, "phrase");
  cleanLog(" ");

  // Campaign Exact
  var exactCampaignReport = gimmeMyReport("Campaign", "Exact");
  cpaCheck(exactCampaignReport, "exact");
  cleanLog(" ");

  // Ad Group Exact
  var exactAdGroupReport = gimmeMyReport("Ad Group", "Exact");
  cpaCheck(exactAdGroupReport, "exact");
  cleanLog(" ");

  if (keywordCount > 0) {
    try {
      var subject = "[DAVE] Keyword Report for " + AdsApp.currentAccount().getName();
      var body = fullCleanLog;
      MailApp.sendEmail(yourEmail, subject, body);
    } catch (e) {
      cleanLog("Unable to send keyword report email. Please check the email "
      + "addresses provided are valid.");
    }
  }

  Logger.log("Added " + keywordCount + " keyword(s).");
}

// Gets the report at either campaign or ad group level of search queries which:
// - have at least the minimum threshold of conversions
// - are not currently being targeted in the account
// - are in enabled ad groups and campaigns
// - have at least the minimum threshold of impressions
function gimmeMyReport(level, matchType) {
  if (level.toLowerCase() == "campaign") {
    reportLevel = "CampaignName";
  } else if (level.toLowerCase() == "ad group") {
    reportLevel = "AdGroupName";
  }
  cleanLog("Level: " + level + ". Match Type: " + matchType);

  var report = AdWordsApp.report(
    "SELECT Query, Conversions, Cost, AdGroupId, QueryTargetingStatus,"
    + " CampaignStatus, AdGroupStatus, CampaignName, AdGroupName, Impressions,"
    + " Clicks, Ctr, ConversionRate, CostPerConversion, ConversionValue,"
    + " QueryMatchTypeWithVariant, KeywordTextMatchingQuery, AverageCpc"
    + " FROM SEARCH_QUERY_PERFORMANCE_REPORT"
    + " WHERE Conversions > "  + minConversions
    + " AND QueryTargetingStatus = 'NONE' AND"
    + " CampaignStatus = 'ENABLED' AND AdGroupStatus = 'ENABLED'"
    + " AND Impressions > " + minImpressions
    + " AND KeywordTextMatchingQuery DOES_NOT_CONTAIN 'URL=='"
    + " AND KeywordTextMatchingQuery DOES_NOT_CONTAIN 'CATEGORY=='"
    + " AND KeywordTextMatchingQuery != '*'"
    + " AND " + reportLevel + " CONTAINS_IGNORE_CASE '" + matchType + "'"
  );

  return report;
}

// Checks whether CPA is below target. If so, it checks if checks whether query
// is eligible to be added as a keyword and then adds it if so.
function cpaCheck(report, criterionType) {
  var rows = report.rows();

  while (rows.hasNext()) {

    var row = rows.next();
    var adGroupId = row["AdGroupId"];
    var searchQuery = row["Query"];
    var campaignName = row["CampaignName"];
    var adGroupName = row["AdGroupName"];
    var impr = row["Impressions"];
    var clicks = row["Clicks"];
    var ctr = row["Ctr"];
    var avgCpc = row["AverageCpc"];
    var cost = row["Cost"];
    var conv = row["Conversions"];
    var convRate = row["ConversionRate"];
    var CPA = row["CostPerConversion"]; // can this use CostPerConversion ?
    var revenue = row["ConversionValue"];
    var roas = revenue / cost;
    var kwText = row["KeywordTextMatchingQuery"];

    var stats = ["Search Query: " + searchQuery,
    "Campaign: " + campaignName,
    "Ad Group: " + adGroupName,
    "Keyword: " + kwText,
    "Impressions: " + impr,
    "Clicks: " + clicks,
    "CTR: " + ctr,
    "Avg. CPC: " + avgCpc,
    "Cost: " + cost,
    "Revenue: " + revenue,
    "ROAS: " + roas.toFixed(2),
    "Conversions: " + conv,
    "Conv. rate: " + convRate,
    "CPA: " + CPA];

    var adGroupSelector = AdsApp.adGroups().withIds([adGroupId]);

    var adGroupIterator = adGroupSelector.get();
    while (adGroupIterator.hasNext()) {
      var adGroup = adGroupIterator.next();

      if (CPA <= maxCPA) {
        if (roas >= minROAS) {
          try {
            if (canWeEvenDealWithThisTing(searchQuery)) {
              addThatTing(searchQuery, adGroup, criterionType, CPA, avgCpc, conv, stats);
              keywordCount++;
            }
          } catch (e) {
            cleanLog("Unable to add " + searchQuery + " as keyword.");
          }
        }
      }
    }
  }
}

// Adds the search term as a targeted keyword with either a broad match
// modifier, phrase or exact match type
function addThatTing(thatQuery, thatAdGroup, thatCriterionType,
  thatCPA, bid, thatConv, thatStats) {
    if (thatCriterionType == "broad") {
      var addedQuery = "+" + thatQuery.replace(/ /g," +");
    } else if (thatCriterionType == "phrase") {
      var addedQuery = '"' + thatQuery + '"';
    } else {
      var addedQuery = "[" + thatQuery + "]";
    }

    var keywordOperation = thatAdGroup.newKeywordBuilder()
    .withText(addedQuery)
    .withCpc(bid)
    .build();

    cleanLog(" ");

    // Logs statistics and details of the search query to the Logger
    thatStats.forEach(function(stat) {
      cleanLog("- " + stat);
    });
  }

// Logs a message and returns false if query is too long (more than 10 words or
// more than 80 characters) to be added as a targeted keyword
function canWeEvenDealWithThisTing(longQuery) {
  if ((longQuery.match(/\s/gi) || []).length > 10) {
    cleanLog(">> '" + longQuery
    + "' is more than 10 words so can't be added.");
    return false;
  } else if (longQuery.length > 80) {
    cleanLog(">> '" + longQuery
    + "' is longer than 80 characters so can't be " +
    "added.");
    return false;
  } else {
    return true;
  }
}

function cleanLog(input) {
  Logger.log(input);
  fullCleanLog += "\n" + input;
}

How does it work?

This script effectively looks through every search query that triggered your ads and led to conversions, such as contact form fills, phone calls, or transactions. Once it finds a sufficient candidate, it will add these to the target campaign and set relevant bids for you. 

Since search term performance can vary over time, it looks at all-time data to establish exactly which search terms are actually performing consistently. By running the script on a daily or weekly basis, you can keep your account up-to-date with the best performing keywords possible and automatically grow the account.

To decide what bid to give a keyword, this script sets the all-time Avg. CPC at a keyword-level. This won’t matter too much if you’re using an automated bidding strategy, but if you’re not, you can rest assured that Dave is setting appropriate bids at around the level that it has previously seen performance.

The script only adds search terms which haven’t been already added as targeted keywords or excluded with negative keywords. If you have a high-converting search term but have blocked it with a negative keyword, Dave will obediently ignore it.

Sometimes, the script will add misspellings and close variants which have been performing well. In our testing, the keywords that the script adds tend to be far less competitive than the ones you already have in the account, so not only can you appear where your competitors aren’t, but you should also end up with cheaper clicks and cheaper conversions too.

In fact, this query to keyword script is so good at finding these diamonds in the rough, that in one of our accounts it added an alternative keyword that lead to a 300% increase in conversions, a 17% decrease in Avg. CPC, a 76% decrease in cost per acquisition and a 257% increase in conversion rate. Tidy. 

The keyword at the top is a keyword chosen by a human with years of PPC experience and familiarity with the clients industry. The far-better performing keyword below was added by Dave.

That is why we love this PPC script. 

Would you like to see similar performance from keywords in your account? Try it today!

How do I use it?

You can pretty much use this script without any customisation at all, if you like. Out of the box it’s ready to rock, suitable for the majority of accounts. Providing you’ve labelled your campaigns or ad groups with the keyword match types you’d like Dave to add (e.g. “Broad”, “Phrase” or “Exact”), the script should work from the off. 

Since Dave looks at the names of your campaigns or ad groups to work out which match type to add the keywords as, if you have the word “Broad” in your campaign or ad group, Dave will add the converting keywords as broad match modifier keywords (e.g. +nottingham +ppc +agency). Similarly, any campaigns or ad groups containing the word “Phrase” or “Exact” will have phrase (“nottingham ppc agency”) or exact ([nottingham ppc agency]) keywords added.

You can mix and match your keyword match-types too, for example, if you had “Broad, Phrase & Exact” in the campaign (or ad group), it would add the keyword as all three match types to the ad group that the converting search query converted in.

If you have different match types in the name of your campaign to the name of your ad group – for example, “Exact” at ad group level and “Broad” at campaign level – Dave will add both broad match modifier and exact match keywords to that ad group.

The only other thing to remember is that you’ll need conversion data for the script to work and the more conversion data you have, the better. If you haven’t set up conversion tracking yet, that’s the first thing that you need to do. Once you’ve got some conversions, you can set up Dave to keep its watchful eye over your account. It can’t build a campaign from scratch, basically. 

To set up the script, follow the steps below:

  1. Copy the script


    Go to your Google Ads account. Click Tools & Settings on the toolbar and then click “Scripts” underneath Bulk Operations. Make a new script, delete the code that Google creates and then paste our code in

  2. Set the options at the top of the code


    maxCPA allows you to set the maximum all-time cost per acquisition a search query is allowed to have for you to consider adding it as a keyword

    minROAS lets you set the minimum all-time return on ad spend (revenue divided by cost) that the search query should have before it should be added as a keyword. If you’re not tracking revenue/conversion value in your account (e.g. it’s a lead-generation account), just set this to 0.

    minImpressions is the minimum number of all-time impressions the search query must have before the script is allowed to add it as a targeted keyword. This helps prevent the script from adding lots of search queries that only get searched for once

    minConversions is the setting which allows you to tell Dave how many all-time conversions a search query must have before it can be added as a keyword

    yourEmail is where you can enter your email address so that the script emails you when it has finished running and lets you know of any keywords that it’s added. To add multiple email addresses, separate them by commas

  3. Save and preview the script


    It’ll probably ask you to authorise the script at least once. Once you’ve hit Preview, you’ll see the keywords that the script would add in the Changes or Log section. If everything looks good, run the script for real!

  4. Create a schedule so the script runs daily


    This will allow the script to constantly add new keywords as soon as they meet your set requirements

Extra credit: Why is it called Dave?

For those dying to know, the script was originally called “QTK” – Query to Keyword. Then we made a new version that added keywords based on their CPA and named it the even more confusing “QTKC” (Query To Keyword – CPA). 

Based on the “KC” part of the name, some members of staff said we should call it Casey. Others preferred the name Quentin, citing the “QT” in the name to back up their stance. Eventually, two factions formed and we ended up referring to the script under both names as well as occasionally referring to it as QTKC 1.12 in order to be more specific.

Needless to say, things got a little bit confusing, so eventually, we decided we’d just call it Dave, the name of one of our legendary security guards here at Impression HQ.

Of course, a few rebels at Impression still call it Quentin, but its name is definitely Dave and our security guard is absolutely chuffed to bits about it. (We are too!)


This post is part of a series of open source scripts . This collection of open-source scripts – available to use for personal and business purposes – contains a selection of various scripts and tools that have been created in-house by the Impression team to meet the demands and solve challenges we’ve personally faced when leading award-winning SEO and paid media campaigns

Nathan Ifill

PPC Executive

PPC Account Executive at Impression. Putting the ‘ting’ into ‘digital marketing’. Follow Nathan on Twitter: @nathanifill

Leave a Reply

Your email address will not be published. Required fields are marked *