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 (PMax-specific GAQL table) to pull
 * actual placement data, filters against bad TLDs and junk keywords,
 * and adds matches to an account-level placement exclusion list.
 *
 * Schedule: Weekly (Monday morning recommended)
 * Requires: No external dependencies
 */

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

const EXCLUSION_LIST_NAME = 'PMax Auto-Excluded Placements';

const LOOKBACK_WINDOW = 30; // days to look back for placement data

const MIN_IMPRESSIONS = 5; // ignore placements with fewer impressions than this

// 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 domain name (SLD) that suggest junk/spam placements
const SPAMMY_KEYWORDS = [
  // Games / entertainment
  'game', 'games', 'arcade', 'play', 'fun', 'quiz', 'puzzle', 'trivia',
  // Kids content
  'kids', 'child', 'baby', 'cartoon',
  // Lyrics / media
  'lyrics', 'ringtone', 'wallpaper',
  // Scam / clickbait patterns
  'free', 'cheap', 'earn', 'win', 'cash', 'prize', 'bonus', 'deal',
  'bitcoin', 'crypto', 'forex', 'loan', 'payday', 'profit', 'invest',
  // Adult
  'xxx', 'adult', 'porn', 'sex', 'escort',
  // Phishing patterns
  'login', 'verify', 'recover', 'reset', 'support', 'help'
];

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

function main() {
  const exclusionList = getOrCreateExclusionList(EXCLUSION_LIST_NAME);
  const alreadyExcluded = getExistingExclusions(exclusionList);

  Logger.log('Existing exclusions in list: ' + alreadyExcluded.size);

  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
  `;

  let checked = 0;
  let excluded = 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
      };

      const check = checkPlacement(placement);

      if (check.shouldExclude) {
        if (alreadyExcluded.has(placement.targetUrl)) {
          skipped++;
        } else {
          try {
            exclusionList.addExcludedPlacement(placement.targetUrl);
            alreadyExcluded.add(placement.targetUrl);
            Logger.log(
              'EXCLUDED: ' + placement.targetUrl +
              ' | Type: '        + placement.placementType +
              ' | Impressions: ' + placement.impressions +
              ' | Reason: '      + check.reason
            );
            excluded++;
          } catch(e) {
            Logger.log('ERROR adding ' + placement.targetUrl + ': ' + e);
          }
        }
      }
    }
  } catch(e) {
    Logger.log('QUERY ERROR: ' + e);
  }

  Logger.log('──────────────────────────────────');
  Logger.log('Placements checked   : ' + checked);
  Logger.log('New exclusions added : ' + excluded);
  Logger.log('Already excluded     : ' + skipped);
  Logger.log('Total in list        : ' + alreadyExcluded.size);
}

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

function checkPlacement(placement) {

  // Always exclude mobile app placements — very low conversion value
  if (placement.placementType === 'MOBILE_APPLICATION') {
    return { shouldExclude: true, reason: 'Mobile app placement' };
  }

  // Website checks
  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 domain keyword: ' + sldCheck };
    }

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

  return { shouldExclude: false };
}

/**
 * Returns the TLD if it is not in SAFE_TLDS, otherwise 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;
  }

  const tld = '.' + domain.split('.').pop();
  return tld;
}

/**
 * Returns the matching spammy keyword if found in the SLD, otherwise false.
 */
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;
}

/**
 * Flags domains with suspicious numeric patterns, repeated chars,
 * punycode, or unusual SLD length.
 */
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; // 3+ numbers in a row
  if (/(.)\1{2,}/.test(sld))             return true; // same char 3+ times
  if (sld.includes('xn--'))              return true; // punycode
  if (sld.length < 3 || sld.length > 25) return true; // suspicious length

  return false;
}

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

/**
 * Returns the named exclusion list, creating it if it does not exist.
 * Uses shared_set.name condition per current API docs.
 */
function getOrCreateExclusionList(name) {
  const iterator = AdsApp.excludedPlacementLists()
    .withCondition("shared_set.name = '" + name + "'")
    .get();

  if (iterator.hasNext()) {
    const list = iterator.next();
    Logger.log('Using existing exclusion list: ' + name);
    return list;
  }

  Logger.log('Creating new exclusion list: ' + name);
  return AdsApp.newExcludedPlacementListBuilder()
    .withName(name)
    .build()
    .getResult();
}

/**
 * Returns a Set of URLs already in the exclusion list to prevent duplicates.
 */
function getExistingExclusions(list) {
  const existing = new Set();
  const iterator = list.excludedPlacements().get();
  while (iterator.hasNext()) {
    existing.add(iterator.next().getUrl().toLowerCase());
  }
  return existing;
}

/**
 * Returns start and end date strings for the lookback window.
 */
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')
  };
}