Dan Antony

Google Ads Script to Track Sitelink Clicks with Custom Parameters

PPC & Paid Search·Google Ads Script·Google Ads

What it does

Slugifies every enabled campaign and ad group name into {_campaign} and {_adgroup} custom parameters, then applies a tracking template to sitelinks attached to enabled campaigns. Sitelink clicks get utm_campaign, utm_adgroup, and utm_content=sitelink so you can see sitelink performance separately in analytics instead of lumping it with the ad URL. Runs in preview mode by default (APPLY_CHANGES: false) so you can see exactly what would change before anything is written.

The code

/**
 * Google Ads Script: Sitelink Tracking via Custom Parameters
 * 
 * 1. Sets {_campaign} custom param on all enabled campaigns (slugified name)
 * 2. Sets {_adgroup} custom param on all enabled ad groups (slugified name)
 * 3. Applies tracking template to sitelinks attached to enabled campaigns
 * 
 * Schedule: Run every 30 min alongside your existing suffix script,
 * or daily if campaign/ad group names don't change often.
 */

var CONFIG = {
  APPLY_CHANGES: true, // Preview mode. Flip to true when ready.

  SITELINK_TRACKING_TEMPLATE: '{lpurl}?utm_source=google&utm_medium=cpc&utm_campaign={_campaign}&gclid={gclid}&utm_adgroup={_adgroup}&utm_term={keyword}&utm_content=sitelink&utm_device={device}&utm_matchtype={matchtype}&utm_network={network}&utm_location={loc_physical_ms}',

  // Set true to also update sitelinks that already have a tracking template
  OVERWRITE_EXISTING_SITELINK_TEMPLATES: false,

  // Only process enabled entities
  SKIP_PAUSED: true,
};

function main() {
  var stats = {
    campaignsUpdated: 0,
    campaignsSkipped: 0,
    adGroupsUpdated: 0,
    adGroupsSkipped: 0,
    sitelinksUpdated: 0,
    sitelinksSkipped: 0,
  };

  // --- Step 1: Set {_campaign} on all campaigns ---
  Logger.log('========== CAMPAIGNS ==========');

  var campaignSelector = AdsApp.campaigns();
  if (CONFIG.SKIP_PAUSED) {
    campaignSelector = campaignSelector.withCondition('Status = ENABLED');
  }
  var campaigns = campaignSelector.get();

  while (campaigns.hasNext()) {
    var campaign = campaigns.next();
    var name = campaign.getName();
    var slug = slugify(name);

    if (slug.length > 200) {
      slug = slug.substring(0, 200);
    }

    var params = campaign.urls().getCustomParameters();
    if (params['campaign'] === slug) {
      stats.campaignsSkipped++;
      continue;
    }

    if (CONFIG.APPLY_CHANGES) {
      params['campaign'] = slug;
      campaign.urls().setCustomParameters(params);
      Logger.log('CAMPAIGN SET: "' + name + '" -> {_campaign} = ' + slug);
    } else {
      Logger.log('CAMPAIGN PREVIEW: "' + name + '" -> {_campaign} = ' + slug);
    }
    stats.campaignsUpdated++;
  }

  // --- Step 2: Set {_adgroup} on all ad groups ---
  Logger.log('========== AD GROUPS ==========');

  var adGroupSelector = AdsApp.adGroups();
  if (CONFIG.SKIP_PAUSED) {
    adGroupSelector = adGroupSelector.withCondition('Status = ENABLED');
  }
  var adGroups = adGroupSelector.get();

  while (adGroups.hasNext()) {
    var adGroup = adGroups.next();
    var agName = adGroup.getName();
    var agSlug = slugify(agName);

    if (agSlug.length > 200) {
      agSlug = agSlug.substring(0, 200);
    }

    var agParams = adGroup.urls().getCustomParameters();
    if (agParams['adgroup'] === agSlug) {
      stats.adGroupsSkipped++;
      continue;
    }

    if (CONFIG.APPLY_CHANGES) {
      agParams['adgroup'] = agSlug;
      adGroup.urls().setCustomParameters(agParams);
      Logger.log('ADGROUP SET: "' + adGroup.getCampaign().getName() + ' > ' + agName + '" -> {_adgroup} = ' + agSlug);
    } else {
      Logger.log('ADGROUP PREVIEW: "' + adGroup.getCampaign().getName() + ' > ' + agName + '" -> {_adgroup} = ' + agSlug);
    }
    stats.adGroupsUpdated++;
  }

  // --- Step 3: Apply tracking template to sitelinks on enabled campaigns ---
  Logger.log('========== SITELINKS ==========');

  var processedSitelinks = {};
  var enabledCampaigns = AdsApp.campaigns()
    .withCondition('Status = ENABLED')
    .get();

  while (enabledCampaigns.hasNext()) {
    var camp = enabledCampaigns.next();
    var campSitelinks = camp.extensions().sitelinks().get();

    while (campSitelinks.hasNext()) {
      var sitelink = campSitelinks.next();
      var linkText = sitelink.getLinkText();
      var finalUrl = sitelink.urls().getFinalUrl();
      var dedupeKey = linkText + '|' + finalUrl;

      // Skip if already processed (shared sitelink across campaigns)
      if (processedSitelinks[dedupeKey]) {
        continue;
      }
      processedSitelinks[dedupeKey] = true;

      var existingTemplate = sitelink.urls().getTrackingTemplate();

      if (existingTemplate && existingTemplate.length > 0 && !CONFIG.OVERWRITE_EXISTING_SITELINK_TEMPLATES) {
        stats.sitelinksSkipped++;
        Logger.log('SITELINK SKIP: "' + linkText + '" (has template)');
        continue;
      }

      if (CONFIG.APPLY_CHANGES) {
        sitelink.urls().setTrackingTemplate(CONFIG.SITELINK_TRACKING_TEMPLATE);
        Logger.log('SITELINK SET: "' + linkText + '" (via ' + camp.getName() + ')');
      } else {
        Logger.log('SITELINK PREVIEW: "' + linkText + '" (via ' + camp.getName() + ')');
      }
      stats.sitelinksUpdated++;
    }
  }

  // --- Summary ---
  Logger.log('========== SUMMARY ==========');
  Logger.log('Mode: ' + (CONFIG.APPLY_CHANGES ? 'LIVE' : 'PREVIEW'));
  Logger.log('Campaigns  -> Updated: ' + stats.campaignsUpdated + ' | Skipped: ' + stats.campaignsSkipped);
  Logger.log('Ad Groups  -> Updated: ' + stats.adGroupsUpdated + ' | Skipped: ' + stats.adGroupsSkipped);
  Logger.log('Sitelinks  -> Updated: ' + stats.sitelinksUpdated + ' | Skipped: ' + stats.sitelinksSkipped);
}

/**
 * Converts name to URL-safe string.
 * Spaces become %20 to match existing suffix script convention.
 */
function slugify(text) {
  return text
    .replace(/\s/g, '%20');
}

When to use this

Use this after your standard campaign/ad group tracking is in place, when you want sitelink extensions tracked independently (utm_content=sitelink) rather than falling through to the ad's template. It's the companion to a UTM tracking-template script and is especially useful when sitelinks are shared across campaigns and would otherwise lose their campaign/ad group context.

Prerequisites

  • Script access at the account or MCC level (Tools & Settings > Bulk Actions > Scripts).
  • Ability to authorize the script to run on your account.

How to implement

  1. 1

    Create a new script

    Tools & Settings > Bulk Actions > Scripts > + New Script. Paste the full file.

  2. 2

    Review the CONFIG block

    Check SITELINK_TRACKING_TEMPLATE matches your naming convention. Leave APPLY_CHANGES false for the first run.

  3. 3

    Run in preview mode

    Run the script with APPLY_CHANGES: false and check View > Logs. It prints every campaign, ad group, and sitelink that would be updated, so you can confirm the slugified names resolve correctly.

  4. 4

    Flip to live mode

    Set APPLY_CHANGES: true and run again. Re-running is idempotent — already-matching custom params and sitelinks with an existing template are skipped unless OVERWRITE_EXISTING_SITELINK_TEMPLATES is true.

  5. 5

    Schedule it

    Schedule every 30 minutes if campaign/ad group names change often, or daily if they rarely change.

Caveats

Sitelinks shared across multiple campaigns are only processed once (deduplicated by link text + final URL). Custom parameter values are the slugified name with spaces as %20, not a full URL-encoded string. If OVERWRITE_EXISTING_SITELINK_TEMPLATES is false, sitelinks that already have a tracking template are left untouched. Preview mode makes no changes at all — always review the log before going live.