Google Ads Script: Ad Copy Freshness Checker

Foto van Matinique Roelse
Matinique Roelse

Inhoudsopgave

Free Google Ads script to find outdated ad copy, expired promotions, and old years in your Google Ads account. Single account and MCC version included.

What The Google Ads script does

πŸ‘‰ Fully customizable β€” flag old years, seasonal promos, or any term you want
πŸ‘‰ Uses a Google Sheet as config β€” add or remove flagged terms anytime
πŸ‘‰ Sends you an email report with all issues found, grouped by type
πŸ‘‰ Scans RSA headlines and descriptions for outdated text πŸ‘‰ Checks sitelinks (link text + both description lines)
πŸ‘‰ Scans at both campaign and account level
πŸ‘‰ MCC version included for agencies
πŸ‘‰ Checks callout extensions

Installing The Google Ads Script – Single account

  1. Go to your Google Ads account and navigate to Tools & Settings > Bulk Actions > Scripts
  2. Click + to create a new script and paste the Single Account script below
  3. Click Preview β€” a config Google Sheet is created automatically 4. Copy the Sheet URL from the logs into the SHEET_URL variable
  4. Add your email address to EMAIL_ADDRESSES
  5. Click Preview again to verify
  6. Save and schedule weekly

Installing The Google Ads Script – MCC account

  1. Go to your MCC and navigate to Tools & Settings > Bulk Actions > Scripts
  2. Click + to create a new script and paste the MCC script below
  3. Click Preview β€” a config Google Sheet is created automatically
  4. Copy the Sheet URL from the logs into the SHEET_URL variable
  5. Add your email address to EMAIL_ADDRESSES
  6. Optional: add 1-2 accounts to INCLUDED_ACCOUNTS to test first
  7. Click Preview again to verify the report
  8. Clear INCLUDED_ACCOUNTS for full MCC run
  9. Save and schedule weekly

Script Introduction

There’s one thing that quietly hurts your Google Ads performance and almost nobody catches it: outdated ad copy.

I’m talking about headlines that still say “2025 Collection”, sitelinks promoting last year’s Black Friday deal, or callouts referencing a summer sale from six months ago.

It’s the kind of thing you don’t notice until a client (or worse, their customer) points it out.

The problem is that Google Ads doesn’t warn you about this. Your ads keep running, your sitelinks keep showing, and nobody flags that the copy is stale.

Especially if you manage multiple accounts, it’s nearly impossible to manually check every headline, description, sitelink, and callout across all of them.

That’s why I built this script. It automatically scans your entire account (or all accounts in your MCC) for text that matches a list of flagged terms you define. Think of terms like:

– Old years (2024, 2025)

– Seasonal events (Black Friday, Christmas, Easter, Valentine’s Day)

– Expired campaigns or promotions

– Any custom term you want to flag

You manage the flagged terms in a simple Google Sheet. Add a row, done. The script sends you a clean email report listing every match it found, grouped by element type. No more guessing, no more manual checks.

Both a single account version and an MCC version are included below. The MCC version adds account filtering (include/exclude specific accounts) and an impression lookback filter so you only check elements that actually served recently.

Script Code – Single account

/**
 * ============================================================
 * AD COPY FRESHNESS CHECKER (Single Account)
 * Written by Matinique Roelse from Adcrease
 * Website: https://adcrease.nl
 * Linkedin: https://www.linkedin.com/in/matiniqueroelse/
 *
 * Senior-only Google Ads agency
 * ===========================
 *
 * WHAT DOES THIS SCRIPT DO?
 * Scans your Google Ads account for outdated text in ads,
 * sitelinks, and callouts. Think of:
 * - Old years (2024, 2023)
 * - Expired promotions (Black Friday, Christmas, Valentine's, etc.)
 * - Any custom terms you add yourself
 *
 * You'll receive an email with all issues found, grouped by
 * element type. Keeps your ad copy fresh at all times.
 *
 * WHAT GETS CHECKED?
 * - RSA headlines & descriptions
 * - Sitelinks (link text + descriptions)
 * - Callouts
 * At both campaign and account level.
 *
 * ============================================================
 * SETUP TO-DO:
 * ============================================================
 *
 * 1. Paste this script in Google Ads at account level
 * 2. Run the script once β†’ a config sheet is created automatically
 * 3. Copy the sheet URL and paste it below at SHEET_URL
 * 4. Fill in your email address(es) at EMAIL_ADDRESSES
 * 5. Run again β†’ you'll receive an email with results
 * 6. Schedule the script weekly (or after major seasonal events)
 *
 * CONFIG SHEET:
 * Column A = search term (e.g. "Black Friday")
 * Column B = reason (e.g. "Seasonal - check if still relevant")
 * Add or remove rows to manage your flagged terms.
 *
 * ============================================================
 */

const SHEET_URL = ''; // If empty, creates a new config sheet with example terms
const CONFIG_TAB = 'Flagged Terms';
const EMAIL_ADDRESSES = ''; // Comma-separated, e.g. 'you@example.com,team@example.com'

// Case-insensitive matching by default
const CASE_SENSITIVE = false;

function main() {
  // Load flagged terms from config sheet
  const config = loadConfig();
  const flaggedTerms = config.terms;

  if (flaggedTerms.length === 0) {
    Logger.log('No flagged terms found in config sheet. Add terms to column A.');
    return;
  }

  const accountName = AdsApp.currentAccount().getName();
  const accountId = AdsApp.currentAccount().getCustomerId();

  Logger.log(`Loaded ${flaggedTerms.length} flagged terms: ${flaggedTerms.map(t => t.term).join(', ')}`);
  Logger.log(`\nChecking: ${accountName} (${accountId})`);

  // Process the account
  const findings = processAccount(flaggedTerms);

  Logger.log(`\n${findings.length} issues found.`);

  // Send email report
  if (findings.length > 0) {
    sendEmailReport(findings, accountName, accountId);
  } else {
    Logger.log('No outdated ad copy found. No email sent.');
  }
}

/**
 * Loads flagged terms from the config Google Sheet.
 * Column A = term to search for, Column B = reason/note (optional)
 * Creates a new sheet with example terms if SHEET_URL is empty.
 */
function loadConfig() {
  let ss;

  if (!SHEET_URL) {
    ss = SpreadsheetApp.create('Ad Copy Freshness - Config');
    Logger.log('Created config sheet: ' + ss.getUrl());
    Logger.log('Add your flagged terms there, then update SHEET_URL in the script.');

    // Create example config
    let sheet;
    if (ss.getSheetByName(CONFIG_TAB)) {
      sheet = ss.getSheetByName(CONFIG_TAB);
    } else {
      sheet = ss.insertSheet(CONFIG_TAB);
    }

    const examples = [
      ['Term', 'Reason'],
      ['2025', 'Outdated year'],
      ['2024', 'Outdated year'],
      ['2023', 'Outdated year'],
      ['Black Friday', 'Seasonal - check if still relevant'],
      ['Cyber Monday', 'Seasonal - check if still relevant'],
      ['Kerst', 'Seasonal - check if still relevant'],
      ['Christmas', 'Seasonal - check if still relevant'],
      ['Sinterklaas', 'Seasonal - check if still relevant'],
      ['Valentijn', 'Seasonal - check if still relevant'],
      ['Valentine', 'Seasonal - check if still relevant'],
      ['Pasen', 'Seasonal - check if still relevant'],
      ['Easter', 'Seasonal - check if still relevant'],
      ['Moederdag', 'Seasonal - check if still relevant'],
      ['Vaderdag', 'Seasonal - check if still relevant'],
      ['Zomeruitverkoop', 'Seasonal - check if still relevant'],
      ['Summer Sale', 'Seasonal - check if still relevant'],
      ['Winter Sale', 'Seasonal - check if still relevant'],
      ['Nieuwjaar', 'Seasonal - check if still relevant'],
      ['New Year', 'Seasonal - check if still relevant']
    ];

    sheet.getRange(1, 1, examples.length, 2).setValues(examples);

    // Remove default Sheet1 if it exists
    const defaultSheet = ss.getSheetByName('Sheet1');
    if (defaultSheet) {
      ss.deleteSheet(defaultSheet);
    }
  } else {
    ss = SpreadsheetApp.openByUrl(SHEET_URL);
  }

  const sheet = ss.getSheetByName(CONFIG_TAB);
  if (!sheet) {
    Logger.log(`Tab "${CONFIG_TAB}" not found in sheet.`);
    return { terms: [] };
  }

  const data = sheet.getDataRange().getValues();
  const terms = [];

  // Skip header row (row 0)
  for (let i = 1; i < data.length; i++) {
    const term = String(data[i][0]).trim();
    const reason = String(data[i][1] || '').trim();

    if (term) {
      terms.push({ term: term, reason: reason });
    }
  }

  return { terms: terms };
}

/**
 * Processes a single account: checks RSAs, sitelinks, and callouts.
 */
function processAccount(flaggedTerms) {
  const findings = [];

  checkRSAs(flaggedTerms, findings);
  checkSitelinks(flaggedTerms, findings);
  checkCallouts(flaggedTerms, findings);

  return findings;
}

/**
 * Checks Responsive Search Ad headlines and descriptions.
 *
 * AdsApp.search() returns nested camelCase objects, NOT flat dot-paths.
 * Access pattern: row.adGroupAd.ad.responsiveSearchAd.headlines
 */
function checkRSAs(flaggedTerms, findings) {
  const query = `
    SELECT
      campaign.name,
      ad_group.name,
      ad_group_ad.ad.id,
      ad_group_ad.ad.responsive_search_ad.headlines,
      ad_group_ad.ad.responsive_search_ad.descriptions
    FROM ad_group_ad
    WHERE ad_group_ad.status = 'ENABLED'
    AND campaign.status = 'ENABLED'
    AND ad_group.status = 'ENABLED'
    AND ad_group_ad.ad.type = 'RESPONSIVE_SEARCH_AD'
  `;

  try {
    const rows = AdsApp.search(query);
    let adCount = 0;

    while (rows.hasNext()) {
      const row = rows.next();
      adCount++;

      const campaignName = n(row, 'campaign', 'name') || '';
      const adGroupName = n(row, 'adGroup', 'name') || '';

      const headlines = n(row, 'adGroupAd', 'ad', 'responsiveSearchAd', 'headlines');
      const descriptions = n(row, 'adGroupAd', 'ad', 'responsiveSearchAd', 'descriptions');

      // Check headlines
      if (headlines) {
        for (let i = 0; i < headlines.length; i++) {
          const text = headlines[i].text || '';
          const matched = findMatch(text, flaggedTerms);
          if (matched) {
            findings.push({
              type: 'RSA Headline',
              campaign: campaignName,
              adGroup: adGroupName,
              text: text,
              matchedTerm: matched.term,
              reason: matched.reason
            });
          }
        }
      }

      // Check descriptions
      if (descriptions) {
        for (let i = 0; i < descriptions.length; i++) {
          const text = descriptions[i].text || '';
          const matched = findMatch(text, flaggedTerms);
          if (matched) {
            findings.push({
              type: 'RSA Description',
              campaign: campaignName,
              adGroup: adGroupName,
              text: text,
              matchedTerm: matched.term,
              reason: matched.reason
            });
          }
        }
      }
    }
    if (adCount > 0) {
      Logger.log(`  Checked ${adCount}x RSA`);
    }
  } catch (e) {
    Logger.log('Error checking RSAs: ' + e);
  }
}

/**
 * Safely navigates nested objects. Returns null if any level is missing.
 * Short name 'n' for readability since it's used everywhere.
 * Usage: n(row, 'adGroupAd', 'ad', 'responsiveSearchAd', 'headlines')
 */
function n(obj) {
  let current = obj;
  for (let i = 1; i < arguments.length; i++) {
    if (current === null || current === undefined) return null;
    current = current[arguments[i]];
  }
  return current;
}

/**
 * Checks sitelink assets at all levels.
 */
function checkSitelinks(flaggedTerms, findings) {
  // Campaign-level sitelinks
  const campaignQuery = `
    SELECT
      campaign.name,
      campaign.status,
      asset.sitelink_asset.link_text,
      asset.sitelink_asset.description1,
      asset.sitelink_asset.description2
    FROM campaign_asset
    WHERE campaign_asset.status = 'ENABLED'
    AND campaign.status = 'ENABLED'
    AND asset.type = 'SITELINK'
  `;

  checkAssetQuery(campaignQuery, 'Sitelink', flaggedTerms, findings, function(row) {
    const texts = [];
    const linkText = n(row, 'asset', 'sitelinkAsset', 'linkText') || '';
    const desc1 = n(row, 'asset', 'sitelinkAsset', 'description1') || '';
    const desc2 = n(row, 'asset', 'sitelinkAsset', 'description2') || '';

    if (linkText) texts.push({ label: 'Link text', value: linkText });
    if (desc1) texts.push({ label: 'Description 1', value: desc1 });
    if (desc2) texts.push({ label: 'Description 2', value: desc2 });

    return texts;
  });

  // Account-level sitelinks
  const customerQuery = `
    SELECT
      asset.sitelink_asset.link_text,
      asset.sitelink_asset.description1,
      asset.sitelink_asset.description2
    FROM customer_asset
    WHERE customer_asset.status = 'ENABLED'
    AND asset.type = 'SITELINK'
  `;

  checkAssetQuery(customerQuery, 'Sitelink (Account)', flaggedTerms, findings, function(row) {
    const texts = [];
    const linkText = n(row, 'asset', 'sitelinkAsset', 'linkText') || '';
    const desc1 = n(row, 'asset', 'sitelinkAsset', 'description1') || '';
    const desc2 = n(row, 'asset', 'sitelinkAsset', 'description2') || '';

    if (linkText) texts.push({ label: 'Link text', value: linkText });
    if (desc1) texts.push({ label: 'Description 1', value: desc1 });
    if (desc2) texts.push({ label: 'Description 2', value: desc2 });

    return texts;
  });
}

/**
 * Checks callout assets at all levels.
 */
function checkCallouts(flaggedTerms, findings) {
  // Campaign-level callouts
  const campaignQuery = `
    SELECT
      campaign.name,
      campaign.status,
      asset.callout_asset.callout_text
    FROM campaign_asset
    WHERE campaign_asset.status = 'ENABLED'
    AND campaign.status = 'ENABLED'
    AND asset.type = 'CALLOUT'
  `;

  checkAssetQuery(campaignQuery, 'Callout', flaggedTerms, findings, function(row) {
    const text = n(row, 'asset', 'calloutAsset', 'calloutText') || '';
    return text ? [{ label: 'Callout', value: text }] : [];
  });

  // Account-level callouts
  const customerQuery = `
    SELECT
      asset.callout_asset.callout_text
    FROM customer_asset
    WHERE customer_asset.status = 'ENABLED'
    AND asset.type = 'CALLOUT'
  `;

  checkAssetQuery(customerQuery, 'Callout (Account)', flaggedTerms, findings, function(row) {
    const text = n(row, 'asset', 'calloutAsset', 'calloutText') || '';
    return text ? [{ label: 'Callout', value: text }] : [];
  });
}

/**
 * Generic asset query checker. Runs a query and checks extracted texts.
 */
function checkAssetQuery(query, assetType, flaggedTerms, findings, textExtractor) {
  try {
    const rows = AdsApp.search(query);
    let assetCount = 0;

    while (rows.hasNext()) {
      const row = rows.next();
      assetCount++;

      const campaignName = n(row, 'campaign', 'name') || 'Account level';
      const texts = textExtractor(row);

      for (const item of texts) {
        const matched = findMatch(item.value, flaggedTerms);
        if (matched) {
          findings.push({
            type: `${assetType} (${item.label})`,
            campaign: campaignName,
            adGroup: '-',
            text: item.value,
            matchedTerm: matched.term,
            reason: matched.reason
          });
        }
      }
    }
    if (assetCount > 0) Logger.log(`  Checked ${assetCount}x ${assetType}`);
  } catch (e) {
    Logger.log(`Error checking ${assetType}: ${e}`);
  }
}

/**
 * Checks if text contains any of the flagged terms using word-start matching.
 * Uses \b (word boundary) before the term so:
 *   "kerst" matches "Kerst", "kerstkorting" but NOT "lekkerste"
 *   "2024" matches "2024", "2024-collectie" but not random substrings
 * Returns the first matched term object, or null.
 */
function findMatch(text, flaggedTerms) {
  if (!text) return null;

  const flags = CASE_SENSITIVE ? '' : 'i';

  for (const item of flaggedTerms) {
    const escaped = item.term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const pattern = new RegExp('\\b' + escaped, flags);

    if (pattern.test(text)) {
      return item;
    }
  }

  return null;
}

/**
 * Sends a summary email for this account.
 */
function sendEmailReport(findings, accountName, accountId) {
  const totalIssues = findings.length;

  let body = '';
  body += '=== AD COPY FRESHNESS CHECK ===\n\n';
  body += `Account: ${accountName} (${accountId})\n`;
  body += `Total issues found: ${totalIssues}\n`;
  body += '\n' + '='.repeat(50) + '\n';

  // Group findings by type
  const byType = {};
  for (const f of findings) {
    if (!byType[f.type]) byType[f.type] = [];
    byType[f.type].push(f);
  }

  for (const type of Object.keys(byType)) {
    body += `\n  [${type}]\n`;
    for (const f of byType[type]) {
      body += `  Campaign: ${f.campaign}\n`;
      if (f.adGroup !== '-') body += `  Ad Group: ${f.adGroup}\n`;
      body += `  Text:     "${f.text}"\n`;
      body += `  Match:    "${f.matchedTerm}"`;
      if (f.reason) body += ` (${f.reason})`;
      body += '\n\n';
    }
  }

  body += '\n' + '='.repeat(50) + '\n';
  body += 'This is an automated report. Update outdated ad copy in Google Ads.\n';
  body += 'Manage flagged terms in your config sheet.\n';

  const subject = `[Ad Copy Check] ${totalIssues} outdated elements found in ${accountName}`;

  if (EMAIL_ADDRESSES) {
    MailApp.sendEmail(EMAIL_ADDRESSES, subject, body);
    Logger.log(`Email sent to ${EMAIL_ADDRESSES}`);
  } else {
    Logger.log('No EMAIL_ADDRESSES configured. Email content:');
    Logger.log(body);
  }
}

Script Code – MCC Account


/**
 * ============================================================
 * AD COPY FRESHNESS CHECKER (MCC)
 * Written by Matinique Roelse from Adcrease
 * Website: https://adcrease.nl
 * Linkedin: https://www.linkedin.com/in/matiniqueroelse/
 *
 * Senior-only Google Ads agency
 * ===========================
 *
 * WHAT DOES THIS SCRIPT DO?
 * Scans all your Google Ads accounts for outdated text in ads,
 * sitelinks, and callouts. Think of:
 * - Old years (2024, 2023)
 * - Expired promotions (Black Friday, Christmas, Valentine's, etc.)
 * - Any custom terms you add yourself
 *
 * You'll receive an email with all issues found, grouped by
 * account and element type. Keeps your ad copy fresh at all times.
 *
 * WHAT GETS CHECKED?
 * - RSA headlines & descriptions
 * - Sitelinks (link text + descriptions)
 * - Callouts
 * At both campaign and account level.
 *
 * ============================================================
 * SETUP TO-DO:
 * ============================================================
 *
 * 1. Paste this script in Google Ads at MCC level
 * 2. Run the script once β†’ a config sheet is created automatically
 * 3. Copy the sheet URL and paste it below at SHEET_URL
 * 4. Fill in your email address(es) at EMAIL_ADDRESSES
 * 5. (Optional) Add 1-2 accounts to INCLUDED_ACCOUNTS for testing
 * 6. Run again β†’ you'll receive an email with results
 * 7. Clear INCLUDED_ACCOUNTS for the full MCC run
 * 8. Schedule the script weekly (or after major seasonal events)
 *
 * CONFIG SHEET:
 * Column A = search term (e.g. "Black Friday")
 * Column B = reason (e.g. "Seasonal - check if still relevant")
 * Add or remove rows to manage your flagged terms.
 *
 * ACCOUNT FILTERS:
 * Use account IDs (digits only, no dashes: '1234567890')
 * or account names (exact match: 'My Client Name').
 * Separate multiple entries with commas inside the array:
 *   ['1234567890', '0987654321']
 *   ['Client A', 'Client B']
 *
 * ============================================================
 */

const SHEET_URL = ''; // If empty, creates a new config sheet with example terms
const CONFIG_TAB = 'Flagged Terms';
const EMAIL_ADDRESSES = ''; // Comma-separated, e.g. 'you@example.com,team@example.com'

// Optional: only process these accounts. Leave empty to process all.
// Handy for testing before running on the full MCC.
// Example: ['1234567890', 'Client Name']
const INCLUDED_ACCOUNTS = [];

// Optional: skip these accounts (applied after inclusion filter).
// Example: ['0987654321', 'Test Account']
const EXCLUDED_ACCOUNTS = [];

// Case-insensitive matching by default
const CASE_SENSITIVE = false;

// Only check elements that actually served (had impressions) in this period.
// Filters out scheduled/enabled-but-not-serving elements.
const IMPRESSION_LOOKBACK_DAYS = 30;

function main() {
  // Load flagged terms from config sheet
  const config = loadConfig();
  const flaggedTerms = config.terms;

  if (flaggedTerms.length === 0) {
    Logger.log('No flagged terms found in config sheet. Add terms to column A.');
    return;
  }

  Logger.log(`Loaded ${flaggedTerms.length} flagged terms: ${flaggedTerms.map(t => t.term).join(', ')}`);

  // Process all managed accounts
  const allFindings = [];
  const accounts = AdsManagerApp.accounts().get();
  let accountCount = 0;

  while (accounts.hasNext()) {
    const account = accounts.next();
    const accountId = account.getCustomerId();
    const accountName = account.getName();

    // Inclusion filter: if set, only process these accounts
    if (INCLUDED_ACCOUNTS.length > 0 &&
        INCLUDED_ACCOUNTS.indexOf(accountId) === -1 &&
        INCLUDED_ACCOUNTS.indexOf(accountName) === -1) {
      continue;
    }

    // Exclusion filter: skip these accounts
    if (EXCLUDED_ACCOUNTS.indexOf(accountId) !== -1 ||
        EXCLUDED_ACCOUNTS.indexOf(accountName) !== -1) {
      Logger.log(`Skipping excluded account: ${accountName}`);
      continue;
    }

    AdsManagerApp.select(account);
    accountCount++;

    try {
      Logger.log(`\n${accountName} (${accountId}):`);
      const findings = processAccount(flaggedTerms);

      if (findings.length > 0) {
        allFindings.push({
          accountName: accountName,
          accountId: accountId,
          findings: findings
        });
        Logger.log(`  >> ${findings.length} issues found`);
      } else {
        Logger.log(`  >> Clean`);
      }
    } catch (e) {
      Logger.log(`  >> Error: ${e}`);
    }

    Utilities.sleep(500); // Rate limiting between accounts
  }

  Logger.log(`\nProcessed ${accountCount} accounts. ${allFindings.length} accounts with issues.`);

  // Send email report
  if (allFindings.length > 0) {
    sendEmailReport(allFindings, accountCount);
  } else {
    Logger.log('No outdated ad copy found. No email sent.');
  }
}

/**
 * Loads flagged terms from the config Google Sheet.
 * Column A = term to search for, Column B = reason/note (optional)
 * Creates a new sheet with example terms if SHEET_URL is empty.
 */
function loadConfig() {
  let ss;

  if (!SHEET_URL) {
    ss = SpreadsheetApp.create('Ad Copy Freshness - Config');
    Logger.log('Created config sheet: ' + ss.getUrl());
    Logger.log('Add your flagged terms there, then update SHEET_URL in the script.');

    // Create example config
    let sheet;
    if (ss.getSheetByName(CONFIG_TAB)) {
      sheet = ss.getSheetByName(CONFIG_TAB);
    } else {
      sheet = ss.insertSheet(CONFIG_TAB);
    }

    const examples = [
      ['Term', 'Reason'],
      ['2025', 'Outdated year'],
      ['2024', 'Outdated year'],
      ['2023', 'Outdated year'],
      ['Black Friday', 'Seasonal - check if still relevant'],
      ['Cyber Monday', 'Seasonal - check if still relevant'],
      ['Kerst', 'Seasonal - check if still relevant'],
      ['Christmas', 'Seasonal - check if still relevant'],
      ['Sinterklaas', 'Seasonal - check if still relevant'],
      ['Valentijn', 'Seasonal - check if still relevant'],
      ['Valentine', 'Seasonal - check if still relevant'],
      ['Pasen', 'Seasonal - check if still relevant'],
      ['Easter', 'Seasonal - check if still relevant'],
      ['Moederdag', 'Seasonal - check if still relevant'],
      ['Vaderdag', 'Seasonal - check if still relevant'],
      ['Zomeruitverkoop', 'Seasonal - check if still relevant'],
      ['Summer Sale', 'Seasonal - check if still relevant'],
      ['Winter Sale', 'Seasonal - check if still relevant'],
      ['Nieuwjaar', 'Seasonal - check if still relevant'],
      ['New Year', 'Seasonal - check if still relevant']
    ];

    sheet.getRange(1, 1, examples.length, 2).setValues(examples);

    // Remove default Sheet1 if it exists
    const defaultSheet = ss.getSheetByName('Sheet1');
    if (defaultSheet) {
      ss.deleteSheet(defaultSheet);
    }
  } else {
    ss = SpreadsheetApp.openByUrl(SHEET_URL);
  }

  const sheet = ss.getSheetByName(CONFIG_TAB);
  if (!sheet) {
    Logger.log(`Tab "${CONFIG_TAB}" not found in sheet.`);
    return { terms: [] };
  }

  const data = sheet.getDataRange().getValues();
  const terms = [];

  // Skip header row (row 0)
  for (let i = 1; i < data.length; i++) {
    const term = String(data[i][0]).trim();
    const reason = String(data[i][1] || '').trim();

    if (term) {
      terms.push({ term: term, reason: reason });
    }
  }

  return { terms: terms };
}

/**
 * Processes a single account: checks RSAs, sitelinks, and callouts.
 */
function processAccount(flaggedTerms) {
  const findings = [];

  // Check RSA headlines and descriptions
  checkRSAs(flaggedTerms, findings);

  // Check sitelinks
  checkSitelinks(flaggedTerms, findings);

  // Check callouts
  checkCallouts(flaggedTerms, findings);

  return findings;
}

/**
 * Checks Responsive Search Ad headlines and descriptions.
 *
 * AdsApp.search() returns nested camelCase objects, NOT flat dot-paths.
 * Access pattern: row.adGroupAd.ad.responsiveSearchAd.headlines
 */
function checkRSAs(flaggedTerms, findings) {
  const query = `
    SELECT
      campaign.name,
      ad_group.name,
      ad_group_ad.ad.id,
      ad_group_ad.ad.responsive_search_ad.headlines,
      ad_group_ad.ad.responsive_search_ad.descriptions
    FROM ad_group_ad
    WHERE ad_group_ad.status = 'ENABLED'
    AND campaign.status = 'ENABLED'
    AND ad_group.status = 'ENABLED'
    AND ad_group_ad.ad.type = 'RESPONSIVE_SEARCH_AD'
    AND metrics.impressions > 0
    AND ${getDateRangeClause(IMPRESSION_LOOKBACK_DAYS)}
  `;

  try {
    const rows = AdsApp.search(query);
    let adCount = 0;

    while (rows.hasNext()) {
      const row = rows.next();
      adCount++;

      const campaignName = n(row, 'campaign', 'name') || '';
      const adGroupName = n(row, 'adGroup', 'name') || '';

      const headlines = n(row, 'adGroupAd', 'ad', 'responsiveSearchAd', 'headlines');
      const descriptions = n(row, 'adGroupAd', 'ad', 'responsiveSearchAd', 'descriptions');

      // Check headlines
      if (headlines) {
        for (let i = 0; i < headlines.length; i++) {
          const text = headlines[i].text || '';
          const matched = findMatch(text, flaggedTerms);
          if (matched) {
            findings.push({
              type: 'RSA Headline',
              campaign: campaignName,
              adGroup: adGroupName,
              text: text,
              matchedTerm: matched.term,
              reason: matched.reason
            });
          }
        }
      }

      // Check descriptions
      if (descriptions) {
        for (let i = 0; i < descriptions.length; i++) {
          const text = descriptions[i].text || '';
          const matched = findMatch(text, flaggedTerms);
          if (matched) {
            findings.push({
              type: 'RSA Description',
              campaign: campaignName,
              adGroup: adGroupName,
              text: text,
              matchedTerm: matched.term,
              reason: matched.reason
            });
          }
        }
      }
    }
    if (adCount > 0) {
      Logger.log(`  Checked ${adCount}x RSA`);
    }
  } catch (e) {
    Logger.log('Error checking RSAs: ' + e);
  }
}

/**
 * Returns a GAQL date range clause for AdsApp.search().
 * AdsApp.search() does NOT support the DURING keyword (that's REST API only).
 * Uses segments.date BETWEEN with computed date strings instead.
 */
function getDateRangeClause(daysBack) {
  const tz = AdsApp.currentAccount().getTimeZone();
  const end = new Date();
  const start = new Date();
  start.setDate(end.getDate() - daysBack);
  const startStr = Utilities.formatDate(start, tz, 'yyyy-MM-dd');
  const endStr = Utilities.formatDate(end, tz, 'yyyy-MM-dd');
  return `segments.date BETWEEN '${startStr}' AND '${endStr}'`;
}

/**
 * Safely navigates nested objects. Returns null if any level is missing.
 * Short name 'n' for readability since it's used everywhere.
 * Usage: n(row, 'adGroupAd', 'ad', 'responsiveSearchAd', 'headlines')
 */
function n(obj) {
  let current = obj;
  for (let i = 1; i < arguments.length; i++) {
    if (current === null || current === undefined) return null;
    current = current[arguments[i]];
  }
  return current;
}

/**
 * Checks sitelink assets at all levels.
 */
function checkSitelinks(flaggedTerms, findings) {
  // Campaign-level sitelinks
  const campaignQuery = `
    SELECT
      campaign.name,
      campaign.status,
      asset.sitelink_asset.link_text,
      asset.sitelink_asset.description1,
      asset.sitelink_asset.description2
    FROM campaign_asset
    WHERE campaign_asset.status = 'ENABLED'
    AND campaign.status = 'ENABLED'
    AND asset.type = 'SITELINK'
    AND metrics.impressions > 0
    AND ${getDateRangeClause(IMPRESSION_LOOKBACK_DAYS)}
  `;

  checkAssetQuery(campaignQuery, 'Sitelink', flaggedTerms, findings, function(row) {
    const texts = [];
    const linkText = n(row, 'asset', 'sitelinkAsset', 'linkText') || '';
    const desc1 = n(row, 'asset', 'sitelinkAsset', 'description1') || '';
    const desc2 = n(row, 'asset', 'sitelinkAsset', 'description2') || '';

    if (linkText) texts.push({ label: 'Link text', value: linkText });
    if (desc1) texts.push({ label: 'Description 1', value: desc1 });
    if (desc2) texts.push({ label: 'Description 2', value: desc2 });

    return texts;
  });

  // Account-level sitelinks
  const customerQuery = `
    SELECT
      asset.sitelink_asset.link_text,
      asset.sitelink_asset.description1,
      asset.sitelink_asset.description2
    FROM customer_asset
    WHERE customer_asset.status = 'ENABLED'
    AND asset.type = 'SITELINK'
    AND metrics.impressions > 0
    AND ${getDateRangeClause(IMPRESSION_LOOKBACK_DAYS)}
  `;

  checkAssetQuery(customerQuery, 'Sitelink (Account)', flaggedTerms, findings, function(row) {
    const texts = [];
    const linkText = n(row, 'asset', 'sitelinkAsset', 'linkText') || '';
    const desc1 = n(row, 'asset', 'sitelinkAsset', 'description1') || '';
    const desc2 = n(row, 'asset', 'sitelinkAsset', 'description2') || '';

    if (linkText) texts.push({ label: 'Link text', value: linkText });
    if (desc1) texts.push({ label: 'Description 1', value: desc1 });
    if (desc2) texts.push({ label: 'Description 2', value: desc2 });

    return texts;
  });
}

/**
 * Checks callout assets at all levels.
 */
function checkCallouts(flaggedTerms, findings) {
  // Campaign-level callouts
  const campaignQuery = `
    SELECT
      campaign.name,
      campaign.status,
      asset.callout_asset.callout_text
    FROM campaign_asset
    WHERE campaign_asset.status = 'ENABLED'
    AND campaign.status = 'ENABLED'
    AND asset.type = 'CALLOUT'
    AND metrics.impressions > 0
    AND ${getDateRangeClause(IMPRESSION_LOOKBACK_DAYS)}
  `;

  checkAssetQuery(campaignQuery, 'Callout', flaggedTerms, findings, function(row) {
    const text = n(row, 'asset', 'calloutAsset', 'calloutText') || '';
    return text ? [{ label: 'Callout', value: text }] : [];
  });

  // Account-level callouts
  const customerQuery = `
    SELECT
      asset.callout_asset.callout_text
    FROM customer_asset
    WHERE customer_asset.status = 'ENABLED'
    AND asset.type = 'CALLOUT'
    AND metrics.impressions > 0
    AND ${getDateRangeClause(IMPRESSION_LOOKBACK_DAYS)}
  `;

  checkAssetQuery(customerQuery, 'Callout (Account)', flaggedTerms, findings, function(row) {
    const text = n(row, 'asset', 'calloutAsset', 'calloutText') || '';
    return text ? [{ label: 'Callout', value: text }] : [];
  });
}

/**
 * Generic asset query checker. Runs a query and checks extracted texts.
 */
function checkAssetQuery(query, assetType, flaggedTerms, findings, textExtractor) {
  try {
    const rows = AdsApp.search(query);
    let assetCount = 0;

    while (rows.hasNext()) {
      const row = rows.next();
      assetCount++;

      const campaignName = n(row, 'campaign', 'name') || 'Account level';
      const texts = textExtractor(row);

      for (const item of texts) {
        const matched = findMatch(item.value, flaggedTerms);
        if (matched) {
          findings.push({
            type: `${assetType} (${item.label})`,
            campaign: campaignName,
            adGroup: '-',
            text: item.value,
            matchedTerm: matched.term,
            reason: matched.reason
          });
        }
      }
    }
    if (assetCount > 0) Logger.log(`  Checked ${assetCount}x ${assetType}`);
  } catch (e) {
    Logger.log(`Error checking ${assetType}: ${e}`);
  }
}

/**
 * Checks if text contains any of the flagged terms using word-start matching.
 * Uses \b (word boundary) before the term so:
 *   "kerst" matches "Kerst", "kerstkorting" but NOT "lekkerste"
 *   "2024" matches "2024", "2024-collectie" but not random substrings
 * Returns the first matched term object, or null.
 */
function findMatch(text, flaggedTerms) {
  if (!text) return null;

  const flags = CASE_SENSITIVE ? '' : 'i';

  for (const item of flaggedTerms) {
    const escaped = item.term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const pattern = new RegExp('\\b' + escaped, flags);

    if (pattern.test(text)) {
      return item;
    }
  }

  return null;
}

/**
 * Sends a summary email grouped by account.
 */
function sendEmailReport(allFindings, totalAccounts) {
  const totalIssues = allFindings.reduce((sum, a) => sum + a.findings.length, 0);
  const accountsWithIssues = allFindings.length;

  let body = '';
  body += '=== AD COPY FRESHNESS CHECK ===\n\n';
  body += `Accounts scanned: ${totalAccounts}\n`;
  body += `Accounts with issues: ${accountsWithIssues}\n`;
  body += `Total issues found: ${totalIssues}\n`;
  body += '\n' + '='.repeat(50) + '\n';

  for (const account of allFindings) {
    body += `\n>> ${account.accountName} (${account.accountId}) - ${account.findings.length} issues\n`;
    body += '-'.repeat(50) + '\n';

    // Group findings by type
    const byType = {};
    for (const f of account.findings) {
      if (!byType[f.type]) byType[f.type] = [];
      byType[f.type].push(f);
    }

    for (const type of Object.keys(byType)) {
      body += `\n  [${type}]\n`;
      for (const f of byType[type]) {
        body += `  Campaign: ${f.campaign}\n`;
        if (f.adGroup !== '-') body += `  Ad Group: ${f.adGroup}\n`;
        body += `  Text:     "${f.text}"\n`;
        body += `  Match:    "${f.matchedTerm}"`;
        if (f.reason) body += ` (${f.reason})`;
        body += '\n\n';
      }
    }
  }

  body += '\n' + '='.repeat(50) + '\n';
  body += 'This is an automated report. Update outdated ad copy in Google Ads.\n';
  body += 'Manage flagged terms in your config sheet.\n';

  const subject = `[Ad Copy Check] ${totalIssues} outdated elements found in ${accountsWithIssues} accounts`;

  if (EMAIL_ADDRESSES) {
    MailApp.sendEmail(EMAIL_ADDRESSES, subject, body);
    Logger.log(`Email sent to ${EMAIL_ADDRESSES}`);
  } else {
    Logger.log('No EMAIL_ADDRESSES configured. Email content:');
    Logger.log(body);
  }
}

That’s it! 

Any comments or add-ons?

Happy to hear!

Facebook
Twitter
LinkedIn

Lees meer van Adcrease

Direct een gratis Google Ads Check aanvragen!

Gratis advies over je Google Ads

Naam(Vereist)