Showing posts with label csv. Show all posts
Showing posts with label csv. Show all posts

Sunday, October 20, 2013

Manage Your AdWords Creatives Using Excel

Since my last post, Google has been busy releasing a ton of new features for AdWords scripts.  Among them was the ability to interact directly with your Google Drive files.  This is awesome for a few reasons and one of them is that it allows you to manage your account through spreadsheets that are automatically synced back to your Google AdWords account every hour.

There is the opportunity to create keywords, manage Ads, AdGroups, or Ad Extensions all using the power of Excel spreadsheets. Still not convinced? You can also have all your account metrics automatically delivered to your Google Drive and automatically downloaded to your computer each day.

So I thought I would demonstrate how you can manage your Creatives using this new functionality.

The first thing you need to do is install Google Drive onto your computer. It's pretty easy to do but does require Admin rights on your machine.  If you can't install it right now, no need to worry.  You can manage your SiteLinks by updating the files in Google Drive via your browser.

Now take a look at the script below. There are a few config options that you should set.  FOLDER_PATH is the path to the folder where you want to store these files.  For example, 'AdWords/Creatives'. If the folder path doesn't exist, this script will create it for you.

The next parameter is FILE_NAME, which would be the file that the Creative data will be stored in. In this example, the file must be written as a .CSV.  Google makes it pretty easy to parse CSV files using the Utilities.parseCsv function.

This script allows you to make changes to headlines, descriptions, urls, status, and device preferences all from the csv file using Excel.  Then, simply save the file back to Google drive and the next time the script runs (daily or hourly), your changes will be reflected.

What other AdWords features might be easier to manage through Excel rather than through some of the UIs out there?


 * Manage AdWords Ads Using Excel
 * Version 1.0
 * Author: Russ Savage
var FOLDER_PATH = 'AdWordsData'; //The path where the file will be stored on GDrive
var FILE_NAME = 'creatives.csv'; //The name of the file on GDrive
var INCLUDE_STATS = true; // Set to false to remove stats from the file
var DATE_RANGE = 'LAST_30_DAYS'; //The date range for the stats
var INCLUDE_PAUSED_ADS = true; //Set to false to only report on active ads
var DELETE_ORIGINAL_ADS = true; //When set to false, the original ads will be paused instead of deleted
function main() {
  var file = getFile(FILE_NAME,FOLDER_PATH);
  if(!file) {
    file = createNewFile(FILE_NAME,FOLDER_PATH,buildCreativesFile());
  var fileContent = file.getBlob().getDataAsString();
  var creatives = {};
  if(fileContent) {
    creatives = parseFileContent(fileContent);
  var creativeIter = getAdIterator();
  while(creativeIter.hasNext()) {
    var creative =;
    var adId = creative.getId();
    if(creatives[adId]) {
      //ok we found the ad.
      //Checking status
      var isEnabledFile = (creatives[adId]['Status'] === 'Enabled');
      if(creative.isEnabled() != isEnabledFile) {
        if(isEnabledFile) { 
        } else { 
      if(hadAdChanged(creative,creatives[adId])) {
        } else {
//Helper function to create a new ad
function createNewAd(ag,newAd) {
  var optArgs = {
    isMobilePreferred: (newAd['Device'] === 'Mobile') ? true : false
//This checks to see if the ad has been changed
function hadAdChanged(ad,oldAd) {
  var newAdText = [ad.getHeadline(),
                   (ad.isMobilePreferred()) ? 'Mobile' : 'Desktop'].join('~~!~~');
  var oldAdText = [oldAd['Headline'],
  return (newAdText !== oldAdText);
//This builds the creatives file from all the ads in the account.
function buildCreativesFile() {
  var report = getReportColumns();
  var creativeIter = getAdIterator();
  while(creativeIter.hasNext()) {
    var creative =;
    report += getReportRow(creative);
  return report;
//This returns the ad iterator based on options.
function getAdIterator() {
  var adSelector ="Type = 'TEXT_AD'"); 
    adSelector = adSelector.withCondition('CampaignStatus = ENABLED')
                           .withCondition('AdGroupStatus = ENABLED')
                           .withCondition('Status = ENABLED');
  return adSelector.get();
//This returns a CSV fow for the report.
function getReportRow(ad) {
  var retVal = [
    ad.getCampaign().getName(),(ad.getCampaign().isPaused()) ? 'Paused' : 'Enabled',
    ad.getAdGroup().getName(),(ad.getAdGroup().isPaused()) ? 'Paused' : 'Enabled',
    (ad.isPaused()) ? 'Paused' : 'Enabled',
    (ad.isMobilePreferred()) ? 'Mobile' : 'Desktop',
    var stats = ad.getStatsFor(DATE_RANGE);
    retVal = retVal.concat([
  return '"' + retVal.join('","') + '"\n';
//This returns the column headings used for the report.
function getReportColumns() {
  var columnHeadings = [
    columnHeadings = columnHeadings.concat([
  return '"' + columnHeadings.join('","') + '"\n';
//This function parses the creatives file into an object for processing
function parseFileContent(fileContent) {
  var headers = [];
  var idHash = {};
  var data = Utilities.parseCsv(fileContent);
  for(var i in data) {
    var cells = data[i]
    if(cells.length == 1) { continue; } //skip any empty rows
    if(i == 0) { 
      headers = cells; 
    } else {
      var rowMap = {};
      for(var x in headers) {
        headers[x] = headers[x];
        cells[x] = cells[x];
        rowMap[headers[x]] = cells[x];
      idHash[rowMap['AdId']] = rowMap;
  return idHash;
//This function gets the file from GDrive
function getFile(fileName,folderPath) {
  var folder = getFolder(folderPath);
  if(folder.getFilesByName(fileName).hasNext()) {
    return folder.getFilesByName(fileName).next();
  } else {
    return null;
//This function creates a new file on GDrive
function createNewFile(fileName,folderPath,content) {
  if(!fileName) { throw 'createNewFile: Missing filename.'; }
  var folder = getFolder(folderPath);
  return folder.createFile(fileName, content);
//This function finds the folder for the file and creates folders if needed
function getFolder(folderPath) {
  var folder = DriveApp.getRootFolder();
  if(folderPath) {
    var pathArray = folderPath.split('/');
    for(var i in pathArray) {
      var folderName = pathArray[i];
      if(folder.getFoldersByName(folderName).hasNext()) {
        folder = folder.getFoldersByName(folderName).next();
      } else {
        folder = folder.createFolder(folderName);
  return folder;

Monday, June 17, 2013

Finding Anomalies In Your Keywords, Adgroups, and Ads

UPDATE 2013-07-23: Added the ability to add labels to your ads as well. This might be helpful for creative testing.

In a recent post from SearchEngineLand, Larry Kim wants PPC Managers to stop being lazy. Link baiting aside, he has some good points. I think much of the time spent pouring over client spreadsheets is really trying to answer the simple question of "Where Are The Anomalies?"

Anomalies are keywords or AdGroups that seem to be performing very differently than their brothers and sisters in the same AdGroup or Campaign. Maybe you have an AdGroup with 15 keywords and one or two keywords seem to be getting nearly all of the clicks. Or maybe you just have an AdGroup that seems to be eating up a good chunk of your budget and you want to have more control over it. In either of these cases, you need some way to quickly identify which Keywords or AdGroups you want to take action on.

So I created the following script to help me do just that. It has a little bit of statistics embedded in it to calculate the mean and standard deviation of a set of entities (AdGroups or Keywords). Then I apply a label to any entity that seems to be more than two standard deviations away from the mean, which would indicate it is performing much better or much worse than its siblings. That way, I can take action on those entities pretty easily in my account.

This script will also send you an email each day with a summary of the entities that it deems to be anomalies. Your goal is to fix these issues by moving them into their own Campaigns and AdGroups or maybe even getting rid of them altogether (possibly negatives).

Right now, the script will check every metric that is available in the AdWordsApp.stats object. It could be easily modified to check for things like cost per conversion or profit per impression. You will see in the code below where you can remove stats that don't interest you as well.

One note of warning is that I do not claim to be a statistics guy, so this may or may not be a valid way to look at things. Hopefully, it will help you locate the issues in an account quickly and fix them so that you can spend more time being lazy :)

Also, I am open to your comments and suggestions for this script. Is it useful to anyone else?


* Find the Anomalies
* Created By: Russ Savage
* Version: 1.2
* Changelog v1.2
*  - Fixed divide by 0 errors
* Changelog v1.1
*  - Added ability to tag ad anomalies as well
var TO = [''];
function main() {
  // This will add labels to and send emails about adgroups, keywords and ads. Remove any if you like.
  var levels_to_tag = ['adgroup','keyword','ad'];
  for(var x in levels_to_tag) {
    var report = getContentRows(levels_to_tag[x]);
    var entity_map = buildEntityMap(levels_to_tag[x]);
    for(var parent_id in entity_map) {
      var child_list = entity_map[parent_id];
      var stats_list = Object.keys(child_list[0].stats);
      for(var i in stats_list) {
        var mean = getMean(child_list,stats_list[i]);
        var stand_dev = getStandardDev(child_list,mean,stats_list[i]);
        var label_name = stats_list[i]+"_anomaly";
        report += addLabelToAnomalies(child_list,mean,stand_dev,stats_list[i],label_name,levels_to_tag[x]);
//Takes a report and the level of reporting and sends and email
//with the report as an attachment.
function sendResultsViaEmail(report,level) {
  var rows = report.match(/\n/g).length - 1;
  if(rows == 0) { return; }
  var options = { attachments: [Utilities.newBlob(report, 'text/csv', level+"_anomalies_"+_getDateString()+'.csv')] };
  var email_body = "There are " + rows + " " + level + "s that have abnormal performance. See attachment for details.";
  var subject = 'Abnormal ' + _initCap(level) + ' Entities Report - ' + _getDateString();
  for(var i in TO) {
    MailApp.sendEmail(TO[i], subject, email_body, options);
//Helper function to return a single row of the report formatted correctly
function toReportRow(entity,level,label_name) {
  var ret_val = [AdWordsApp.currentAccount().getCustomerId(),
  ret_val.push( (level == 'adgroup') ? entity.getName() : entity.getAdGroup().getName() );
  if(level == 'keyword') {
    ret_val = ret_val.concat([entity.getText(),entity.getMatchType()]); 
  } else if(level == 'ad') {
    ret_val = ret_val.concat([entity.getHeadline(),entity.getDescription1(),entity.getDescription2(),entity.getDisplayUrl()]); 
  return '"' + ret_val.join('","') + '"\n';
//Helper function to return the column headings for the report
function getContentRows(level) {
  var ret_val = ['AccountId','CampaignName','AdGroupName'];
  if(level == 'keyword') {
    ret_val = ret_val.concat(['KeywordText','MatchType']); 
  } else if(level == 'ad') {
    ret_val = ret_val.concat(['Headline','Description1','Description2','DisplayUrl']);
  return '"' + ret_val.join('","') + '"\n';
//Function to add the labels to the entities based on the standard deviation and mean.
//It returns a csv formatted string for reporting
function addLabelToAnomalies(entites,mean,sd,stat_key,label_name,level) {
  var report = '';
  for(var i in entites) {
    var entity = entites[i]['entity'];
    var deviation = Math.abs(entites[i]['stats'][stat_key] - mean);
    if(sd != 0 && deviation/sd >= STANDARD_DEVIATIONS) {
      report += toReportRow(entity,level,label_name);
    } else {
  return report;
//This is a helper function to create the label if it does not already exist
function createLabelIfNeeded(name) {
  if(!AdWordsApp.labels().withCondition("Name = '"+name+"'").get().hasNext()) {
//This function returns the standard deviation for a set of entities
//The stat key determines which stat to calculate it for
function getStandardDev(entites,mean,stat_key) {
  var total = 0;
  for(var i in entites) {
    total += Math.pow(entites[i]['stats'][stat_key] - mean,2);
  if(Math.sqrt(entites.length-1) == 0) {
    return 0;
  return round(Math.sqrt(total)/Math.sqrt(entites.length-1));
//Returns the mean (average) for the set of entities
//Again, stat key determines which stat to calculate this for
function getMean(entites,stat_key) {
  var total = 0;
  for(var i in entites) {
    total += entites[i]['stats'][stat_key];
  if(entites.length == 0) {
    return 0;
  return round(total/entites.length);
//This function returns a map of the entities that I am processing.
//The format for the map can be found on the first line.
//It is meant to work on AdGroups and Keywords
function buildEntityMap(entity_type) {
  var map = {}; // { parent_id : [ { entity : entity, stats : entity_stats } ], ... }
  var iter = getIterator(entity_type);
  while(iter.hasNext()) {
    var entity =;
    var stats = entity.getStatsFor(DATE_RANGE);
    var stats_map = getStatsMap(stats);
    var parent_id = getParentId(entity_type,entity);
    if(map[parent_id]) { 
      map[parent_id].push({entity : entity, stats : stats_map});
    } else {
      map[parent_id] = [{entity : entity, stats : stats_map}];
  return map;
//Given an entity type (adgroup or keyword) this will return the parent id
function getParentId(entity_type,entity) {
  switch(entity_type) {
    case 'adgroup' :
      return entity.getCampaign().getId();
    case 'keyword':
      return entity.getAdGroup().getId();
    case 'ad':
      return entity.getAdGroup().getId();
//Given an entity type this will return the iterator for that.
function getIterator(entity_type) {
  switch(entity_type) {
    case 'adgroup' :
      return AdWordsApp.adGroups().forDateRange(DATE_RANGE).withCondition("Impressions > 0").get();
    case 'keyword' :
      return AdWordsApp.keywords().forDateRange(DATE_RANGE).withCondition("Impressions > 0").get();
    case 'ad' :
      return"Impressions > 0").get();
//This returns a map of all the stats for a given entity.
//You can comment out the things you don't really care about.
function getStatsMap(stats) {
  return { // You can comment these out as needed.
          avg_cpc : stats.getAverageCpc(),
          avg_cpm : stats.getAverageCpm(),
          avg_pv : stats.getAveragePageviews(),
          avg_pos : stats.getAveragePosition(),
          avg_tos : stats.getAverageTimeOnSite(),
          bounce : stats.getBounceRate(),
          clicks : stats.getClicks(),
          cv : stats.getConversionRate(),
          conv : stats.getConversions(),
          cost : stats.getCost(),
          ctr : stats.getCtr(),
          imps : stats.getImpressions()
//Helper function to format todays date
function _getDateString() {
  return Utilities.formatDate((new Date()), AdWordsApp.currentAccount().getTimeZone(), "yyyy-MM-dd");
//Helper function to capitalize the first letter of a string.
function _initCap(str) {
  return str.replace(/(?:^|\s)\S/g, function(a) { return a.toUpperCase(); });

// A helper function to make rounding a little easier
function round(value) {
  var decimals = Math.pow(10,DECIMAL_PLACES);
  return Math.round(value*decimals)/decimals;

Saturday, March 30, 2013

Automate Your Ads Based on Your Baseball Team's Schedule

I know everyone is focused on March Madness right now, but opening day for Baseball season is right around the corner. I wondered if it were possible to enable and disable specific ads based on the schedules of your local MLB team.

It turns out, the MLB has every team's full schedule in an easy to parse CSV format. All you need to do is search Google for " downloadable schedule" and look for the page that ends in "downloadable.jsp". It will look something like this:

The link you need is circled in the image above. You can see from the url what the team_id value should be in the code below. And if you right-click and download that .csv file, you will be able to see what value to use for the home_field as well. In the example below, I used my hometown teams of the Cubs and the White Sox, but you can use any teams you want.

The script itself will run through and look for a home game for your team. If the team is playing today and it is a home game, it will enable any ads that are tagged with the label you configured in the TEAM_INFO. If the team isn't playing a home game today, it will pause those same ads.

In the example below, I only used a portion of the data in the csv files. It would also be possible to enable ads only during the actual game, or enable ads for all game days, not just home games.

I encourage you to play around with the data and see what you can come up with. If you find anything useful, please leave a comment.


// Enable/Disable Ads Based on the MLB Schedule
// Created By: Russ Savage
function main() {
  var TEAM_INFO = [
    { team_id : 112, home_field : 'Wrigley Field', label : 'cubs' }, // Cubs
    { team_id : 145, home_field : 'U.S. Cellular Field', label : 'whitesox' } // White Sox
  //hopefully you've already created and tagged some ads with these labels
  //but just in case...
  var SEASON = (new Date()).getFullYear();
  var is_home = false, is_game_day = false;
  for(var t in TEAM_INFO) {
    var team = TEAM_INFO[t];
    var url = ""+team.team_id+"&season="+SEASON;
    var html = UrlFetchApp.fetch(url).getContentText();
    var date_list = html.split("\r\n");
    for(var i in date_list) {
      if(i == 0) {continue;}
      var [start_date,start_time,start_time_et,
           end_date,end_date_et,end_time,end_time_et] = date_list[i].split(",");
      var today = new Date();
      var game_day = new Date();
      is_home = (location == team.home_field);
      is_game_day = (diffDays(game_day,today) == 0);
      if(is_home && is_game_day) {
    if(!(is_home && is_game_day)) {

function enableBaseballAds(label) {
  Logger.log("Enabling all ads with the "+label+" label.");
  var ads ="LabelNames CONTAINS_ALL ['"+label+"']").get();
  while(ads.hasNext()) {; 

function disableBaseballAds(label) {
  Logger.log("Disabling all ads with the "+label+" label.");
  var ads ="LabelNames CONTAINS_ALL ['"+label+"']").get();
  while(ads.hasNext()) {; 

function createLabelsIfNeeded(team_info) {
  var label_iter = AdWordsApp.labels().get();
  var label_list = [];
  while(label_iter.hasNext()) {
  for(var i in team_info) {
    if(label_list.indexOf(team_info[i].label) == -1) {

//A helper function to compare dates.
//Copied from:
function diffDays(firstDate,secondDate) {
  var oneDay = 24*60*60*1000; // hours*minutes*seconds*milliseconds
  return Math.round(Math.abs((firstDate.getTime() - secondDate.getTime())/(oneDay)));