Google Ads Performance Max Placement Exclusion Script

If you’ve ever worked with Google Ads Performance Max campaigns you’ve probably noticed the lack of control you have when it comes to excluding placements. For instance you can’t set a rule to block all .ru domains!?!? I’m sure we can all guess why Google wouldn’t want to release such as tool, but alas, it’s needed. so here you go. Please edit it as you see fit.

In Ads, go to Bulk Actions -> Scripts, then add a new one, you can test it by clicking preview. Once you’re happy, run it, save it, then schedule it to run daily.

You can fine tune it for minimum impressions, domain tld’s and keywords that you want excluded.

That should save you a ton of wasted spend on spammy domains.

/**
 * Google Ads PMax Placement Excluder
 *
 * Uses performance_max_placement_view to pull placement data,
 * filters against bad TLDs and junk keywords, and writes exclusions
 * directly to account-level CustomerNegativeCriteria via AdsApp.mutateAll().
 * This writes to Content Suitability → Excluded placements, which applies
 * to ALL campaigns including PMax. No developer token required.
 *
 * Schedule: Weekly (Monday morning recommended)
 */

// ─── CONFIGURATION ────────────────────────────────────────────────────────────

const LOOKBACK_WINDOW = 30; // days to look back for placement data
const MIN_IMPRESSIONS = 5;  // ignore placements below this threshold

// TLDs considered SAFE — anything not on this list will be flagged
const SAFE_TLDS = [
  '.com', '.edu', '.org', '.net',
  '.co.uk', '.gov', '.io', '.co'
];

// Keywords in the SLD that suggest junk/spam placements
const SPAMMY_KEYWORDS = [
  'game', 'games', 'arcade', 'play', 'fun', 'quiz', 'puzzle', 'trivia',
  'kids', 'child', 'baby', 'cartoon',
  'lyrics', 'ringtone', 'wallpaper',
  'free', 'cheap', 'earn', 'win', 'cash', 'prize', 'bonus',
  'bitcoin', 'crypto', 'forex', 'loan', 'payday', 'profit', 'invest',
  'xxx', 'adult', 'porn', 'sex', 'escort',
  'login', 'verify', 'recover', 'reset'
];

// ─── MAIN ─────────────────────────────────────────────────────────────────────

function main() {
  const customerId = AdsApp.currentAccount().getCustomerId().replace(/-/g, '');

  // Read existing account-level exclusions to avoid duplicates
  const existing = getExistingAccountExclusions();
  Logger.log('Existing account-level exclusions: ' + existing.size);

  // Query PMax placement data
  const dateRange = getDateRange(LOOKBACK_WINDOW);
  const query = `
    SELECT
      performance_max_placement_view.display_name,
      performance_max_placement_view.placement,
      performance_max_placement_view.placement_type,
      performance_max_placement_view.target_url,
      metrics.impressions
    FROM performance_max_placement_view
    WHERE
      metrics.impressions > ${MIN_IMPRESSIONS}
      AND segments.date BETWEEN '${dateRange.startDate}' AND '${dateRange.endDate}'
    ORDER BY metrics.impressions DESC
  `;

  const toExclude = [];
  let checked  = 0;
  let skipped  = 0;

  try {
    const result = AdsApp.search(query);

    while (result.hasNext()) {
      const row = result.next();
      checked++;

      const placement = {
        displayName  : (row.performanceMaxPlacementView.displayName  || '').toLowerCase(),
        placement    : (row.performanceMaxPlacementView.placement    || '').toLowerCase(),
        placementType:  row.performanceMaxPlacementView.placementType,
        targetUrl    : (row.performanceMaxPlacementView.targetUrl    || '').toLowerCase(),
        impressions  :  row.metrics.impressions
      };

      // Strip www. — account-level exclusions cover all subdomains automatically
      const cleanUrl = placement.targetUrl.replace(/^www\./, '').replace(/^https?:\/\//, '');

      if (!cleanUrl) continue;

      const check = checkPlacement(placement);

      if (check.shouldExclude) {
        if (existing.has(cleanUrl)) {
          skipped++;
        } else {
          toExclude.push({
            url   : cleanUrl,
            reason: check.reason,
            type  : placement.placementType,
            impr  : placement.impressions
          });
          existing.add(cleanUrl); // prevent duplicates within same batch
        }
      }
    }
  } catch(e) {
    Logger.log('QUERY ERROR: ' + e);
    return;
  }

  Logger.log('Placements checked   : ' + checked);
  Logger.log('Already excluded     : ' + skipped);
  Logger.log('New to exclude       : ' + toExclude.length);

  if (toExclude.length === 0) {
    Logger.log('Nothing new to add.');
    return;
  }

  // Build mutateAll operations in batches of 50 (safe limit)
  const BATCH_SIZE = 50;
  let totalAdded = 0;

  for (let i = 0; i < toExclude.length; i += BATCH_SIZE) {
    const batch = toExclude.slice(i, i + BATCH_SIZE);

    const operations = batch.map(function(item) {
      return {
        customerNegativeCriterionOperation: {
          create: {
            placement: {
              url: item.url
            }
          }
        }
      };
    });

    try {
      const responses = AdsApp.mutateAll(operations);
      batch.forEach(function(item) {
        Logger.log(
          'EXCLUDED: ' + item.url +
          ' | Type: '        + item.type +
          ' | Impressions: ' + item.impr +
          ' | Reason: '      + item.reason
        );
      });
      totalAdded += batch.length;
    } catch(e) {
      Logger.log('MUTATE ERROR on batch ' + (i / BATCH_SIZE + 1) + ': ' + e);
    }
  }

  Logger.log('──────────────────────────────────');
  Logger.log('New exclusions added : ' + totalAdded);
  Logger.log('Total in account     : ' + (existing.size));
}

// ─── ACCOUNT EXCLUSION READER ─────────────────────────────────────────────────

/**
 * Reads existing account-level placement exclusions via GAQL.
 * Returns a Set of clean URLs (no www., no protocol).
 */
function getExistingAccountExclusions() {
  const existing = new Set();
  const query = `
    SELECT
      customer_negative_criterion.placement.url,
      customer_negative_criterion.type
    FROM customer_negative_criterion
    WHERE customer_negative_criterion.type = 'PLACEMENT'
  `;

  try {
    const result = AdsApp.search(query);
    while (result.hasNext()) {
      const row = result.next();
      const url = (row.customerNegativeCriterion.placement.url || '')
        .toLowerCase()
        .replace(/^www\./, '')
        .replace(/^https?:\/\//, '');
      if (url) existing.add(url);
    }
  } catch(e) {
    Logger.log('Error reading existing exclusions: ' + e);
  }

  return existing;
}

// ─── PLACEMENT CHECKS ─────────────────────────────────────────────────────────

function checkPlacement(placement) {
  if (placement.placementType === 'MOBILE_APPLICATION') {
    return { shouldExclude: true, reason: 'Mobile app placement' };
  }

  if (placement.placementType === 'WEBSITE' && placement.targetUrl) {
    const tldCheck = hasUnsafeTLD(placement.targetUrl);
    if (tldCheck) {
      return { shouldExclude: true, reason: 'Unsafe TLD: ' + tldCheck };
    }

    const sldCheck = hasSpammySLD(placement.targetUrl);
    if (sldCheck) {
      return { shouldExclude: true, reason: 'Spammy keyword: ' + sldCheck };
    }

    if (hasSuspiciousDomainPattern(placement.targetUrl)) {
      return { shouldExclude: true, reason: 'Suspicious domain pattern' };
    }
  }

  return { shouldExclude: false };
}

function hasUnsafeTLD(url) {
  const domain = url.replace(/^https?:\/\//, '').split('/')[0];
  for (let i = 0; i < SAFE_TLDS.length; i++) {
    if (domain.endsWith(SAFE_TLDS[i])) return false;
  }
  return '.' + domain.split('.').pop();
}

function hasSpammySLD(url) {
  const domain = url.replace(/^https?:\/\//, '').split('/')[0];
  const parts  = domain.split('.');
  const sld    = parts.length >= 2 ? parts[parts.length - 2].toLowerCase() : '';
  for (let i = 0; i < SPAMMY_KEYWORDS.length; i++) {
    if (sld.includes(SPAMMY_KEYWORDS[i].toLowerCase())) return SPAMMY_KEYWORDS[i];
  }
  return false;
}

function hasSuspiciousDomainPattern(url) {
  const domain = url.replace(/^https?:\/\//, '').split('/')[0];
  const parts  = domain.split('.');
  const sld    = parts.length >= 2 ? parts[parts.length - 2] : '';
  if (/[0-9]{3,}/.test(sld))             return true;
  if (/(.)\1{2,}/.test(sld))             return true;
  if (sld.includes('xn--'))              return true;
  if (sld.length < 3 || sld.length > 25) return true;
  return false;
}

// ─── HELPERS ──────────────────────────────────────────────────────────────────

function getDateRange(lookbackDays) {
  const tz    = AdsApp.currentAccount().getTimeZone();
  const end   = new Date();
  end.setDate(end.getDate() - 1);
  const start = new Date();
  start.setDate(start.getDate() - lookbackDays);
  return {
    startDate: Utilities.formatDate(start, tz, 'yyyy-MM-dd'),
    endDate  : Utilities.formatDate(end,   tz, 'yyyy-MM-dd')
  };
}