Last active
June 9, 2026 07:06
-
-
Save PKBibi/4503042b0ddef2c1bd036a66fab554b1 to your computer and use it in GitHub Desktop.
TLD Auto-Exclusion Script - Google Ads Script | PPC Chief. For the background, read the blog post here: https://ppcchief.com/blog/gdn-exclusion-list
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // TLD Auto-Excluder (fixed) | |
| // Use safe builders for exclusions to avoid runtime "not a function" errors | |
| var CONFIG = { | |
| LOOKBACK_DAYS: 30, | |
| ZERO_CONV_SPEND_THRESHOLD: 10, | |
| MIN_CLICKS_THRESHOLD: 5, | |
| ACCOUNT_LABEL: "", | |
| AUDIT_SHEET_URL: "", | |
| AUDIT_SHEET_NAME: "TLD Exclusion Log", | |
| DRY_RUN: false, | |
| ALERT_EMAIL: "", | |
| }; | |
| var BAD_TLDS = [ | |
| ".xyz", | |
| ".buzz", | |
| ".one", | |
| ".cloud", | |
| ".top", | |
| ".click", | |
| ".link", | |
| ".gdn", | |
| ".bid", | |
| ".win", | |
| ".download", | |
| ".stream", | |
| ".racing", | |
| ".date", | |
| ".faith", | |
| ".review", | |
| ".science", | |
| ".party", | |
| ".trade", | |
| ".accountant", | |
| ".cricket", | |
| ".loan", | |
| ".men", | |
| ".work", | |
| ".icu", | |
| ".fun", | |
| ".site", | |
| ".online", | |
| ".space", | |
| ".website", | |
| ".host", | |
| ".press", | |
| ".pw", | |
| ".tk", | |
| ".ml", | |
| ".ga", | |
| ".cf", | |
| ".gq", | |
| ".cam", | |
| ".rest", | |
| ".fit", | |
| ".kim", | |
| ".guru", | |
| ".ninja", | |
| ".rocks", | |
| ".live", | |
| ".world", | |
| ".today", | |
| ".zone", | |
| ]; | |
| var BAD_KEYWORDS = [ | |
| "torrent", | |
| "pirate", | |
| "crack", | |
| "hack", | |
| "cheat", | |
| "keygen", | |
| "warez", | |
| "nulled", | |
| "cracked", | |
| "freevpn", | |
| "freeproxy", | |
| "freedownload", | |
| "freestreaming", | |
| "wallpaper", | |
| "ringtone", | |
| "screensaver", | |
| "emoji", | |
| "quiz", | |
| "personality-test", | |
| "lyrics", | |
| "proxy", | |
| "converter", | |
| "convertfree", | |
| "onlineconvert", | |
| "flashgame", | |
| "browsergame", | |
| "playgame", | |
| "freegame", | |
| "earnmoney", | |
| "makemoney", | |
| "getpaid", | |
| "cashback", | |
| "rewardpoint", | |
| "clickbait", | |
| "viralstory", | |
| "shocking", | |
| "unbelievable", | |
| "adultfriend", | |
| "hookup", | |
| "datenight", | |
| "singlemeet", | |
| "casinoonline", | |
| "slotmachine", | |
| "betfree", | |
| "gamblingfree", | |
| "spyware", | |
| "malware", | |
| "cleanpc", | |
| "boostpc", | |
| "fixpc", | |
| "fakeid", | |
| "buyfollower", | |
| "buylikes", | |
| ]; | |
| function main() { | |
| var selector = AdsManagerApp.accounts(); | |
| if (CONFIG.ACCOUNT_LABEL) | |
| selector = selector.withCondition( | |
| 'LabelNames CONTAINS "' + CONFIG.ACCOUNT_LABEL + '"', | |
| ); | |
| var childConfig = JSON.stringify({ | |
| lookbackDays: CONFIG.LOOKBACK_DAYS, | |
| zeroConvSpendThreshold: CONFIG.ZERO_CONV_SPEND_THRESHOLD, | |
| minClicksThreshold: CONFIG.MIN_CLICKS_THRESHOLD, | |
| auditSheetUrl: CONFIG.AUDIT_SHEET_URL, | |
| auditSheetName: CONFIG.AUDIT_SHEET_NAME, | |
| dryRun: CONFIG.DRY_RUN, | |
| badTlds: BAD_TLDS, | |
| badKeywords: BAD_KEYWORDS, | |
| }); | |
| selector.executeInParallel( | |
| "processChildAccount", | |
| "onAllComplete", | |
| childConfig, | |
| ); | |
| } | |
| function processChildAccount(serializedConfig) { | |
| var cfg = JSON.parse(serializedConfig); | |
| var account = AdsApp.currentAccount(); | |
| var accountName = account.getName(); | |
| var accountId = account.getCustomerId(); | |
| var result = { | |
| accountName: accountName, | |
| accountId: accountId, | |
| scanned: 0, | |
| byTld: 0, | |
| byKeyword: 0, | |
| byPerformance: 0, | |
| applied: 0, | |
| alreadyExcluded: 0, | |
| errors: [], | |
| topExclusions: [], | |
| }; | |
| var today = new Date(); | |
| var lookbackDate = new Date( | |
| today.getTime() - cfg.lookbackDays * 24 * 60 * 60 * 1000, | |
| ); | |
| var dateFrom = fmtDate(lookbackDate); | |
| var dateTo = fmtDate(today); | |
| var query = | |
| 'SELECT detail_placement_view.display_name, detail_placement_view.target_url, detail_placement_view.placement_type, campaign.name, campaign.id, campaign.advertising_channel_type, metrics.clicks, metrics.impressions, metrics.cost_micros, metrics.conversions FROM detail_placement_view WHERE segments.date BETWEEN "' + | |
| dateFrom + | |
| '" AND "' + | |
| dateTo + | |
| '" AND campaign.advertising_channel_type IN ("DISPLAY", "VIDEO") AND campaign.status != "REMOVED" AND metrics.impressions > 0 ORDER BY metrics.cost_micros DESC'; | |
| var report; | |
| try { | |
| report = AdsApp.report(query); | |
| } catch (e) { | |
| try { | |
| report = AdsApp.report( | |
| "SELECT Criteria, DisplayName, CampaignName, CampaignId, Clicks, Impressions, Cost, Conversions FROM PLACEMENT_PERFORMANCE_REPORT WHERE Impressions > 0 DURING " + | |
| dateFrom.replace(/-/g, "") + | |
| "," + | |
| dateTo.replace(/-/g, ""), | |
| ); | |
| } catch (e2) { | |
| result.errors.push("Report failed: " + e2.message); | |
| return JSON.stringify(result); | |
| } | |
| } | |
| var rows = report.rows(); | |
| var exclusionQueue = []; | |
| var seenPlacements = {}; | |
| while (rows.hasNext()) { | |
| var row = rows.next(); | |
| result.scanned++; | |
| var placement = String( | |
| row["detail_placement_view.target_url"] || row["Criteria"] || "", | |
| ) | |
| .trim() | |
| .toLowerCase(); | |
| var campaignId = row["campaign.id"] || row["CampaignId"]; | |
| var campaignName = row["campaign.name"] || row["CampaignName"]; | |
| var clicks = parseInt(row["metrics.clicks"] || row["Clicks"] || 0); | |
| var costMicros = parseInt(row["metrics.cost_micros"] || 0); | |
| var cost = | |
| costMicros > 0 ? costMicros / 1000000 : parseFloat(row["Cost"] || 0); | |
| var conversions = parseFloat( | |
| row["metrics.conversions"] || row["Conversions"] || 0, | |
| ); | |
| if (!placement || placement === "(not set)") continue; | |
| var placementKey = placement + "|" + campaignId; | |
| if (seenPlacements[placementKey]) continue; | |
| var excludeReason = null; | |
| for (var t = 0; t < cfg.badTlds.length; t++) { | |
| if (endsWithTld(placement, cfg.badTlds[t])) { | |
| excludeReason = "BAD_TLD: " + cfg.badTlds[t]; | |
| result.byTld++; | |
| break; | |
| } | |
| } | |
| if (!excludeReason) { | |
| for (var k = 0; k < cfg.badKeywords.length; k++) { | |
| if (placement.indexOf(cfg.badKeywords[k]) !== -1) { | |
| excludeReason = "BAD_KEYWORD: " + cfg.badKeywords[k]; | |
| result.byKeyword++; | |
| break; | |
| } | |
| } | |
| } | |
| if ( | |
| !excludeReason && | |
| clicks >= cfg.minClicksThreshold && | |
| cost >= cfg.zeroConvSpendThreshold && | |
| conversions === 0 | |
| ) { | |
| excludeReason = | |
| "ZERO_CONV: " + | |
| cost.toFixed(2) + | |
| " spent, " + | |
| clicks + | |
| " clicks, 0 conv"; | |
| result.byPerformance++; | |
| } | |
| if (excludeReason) { | |
| seenPlacements[placementKey] = true; | |
| exclusionQueue.push({ | |
| placement: placement, | |
| campaignId: campaignId, | |
| campaignName: campaignName, | |
| reason: excludeReason, | |
| spend: cost, | |
| clicks: clicks, | |
| conversions: conversions, | |
| }); | |
| } | |
| } | |
| // Apply exclusions safely | |
| for (var i = 0; i < exclusionQueue.length; i++) { | |
| var item = exclusionQueue[i]; | |
| try { | |
| if (!cfg.dryRun) { | |
| var campaign = AdsApp.campaigns().withIds([item.campaignId]).get(); | |
| var camp; | |
| if (campaign.hasNext()) { | |
| camp = campaign.next(); | |
| } else { | |
| var videoCampaign = AdsApp.videoCampaigns().withIds([item.campaignId]).get(); | |
| if (videoCampaign.hasNext()) { | |
| camp = videoCampaign.next(); | |
| } | |
| } | |
| if (camp) { | |
| applyPlacementExclusionSafe(camp, item.placement); | |
| } else { | |
| result.errors.push(item.placement + ": campaign not found"); | |
| continue; | |
| } | |
| } | |
| result.applied++; | |
| } catch (e) { | |
| if (e.message && e.message.indexOf("already exists") !== -1) { | |
| result.alreadyExcluded++; | |
| } else { | |
| result.errors.push(item.placement + ": " + e.message); | |
| } | |
| } | |
| } | |
| result.topExclusions = exclusionQueue | |
| .sort(function (a, b) { | |
| return b.spend - a.spend; | |
| }) | |
| .slice(0, 10) | |
| .map(function (item) { | |
| return ( | |
| item.placement + " | " + item.spend.toFixed(2) + " | " + item.reason | |
| ); | |
| }); | |
| if (cfg.auditSheetUrl && exclusionQueue.length > 0) { | |
| try { | |
| var ss = SpreadsheetApp.openByUrl(cfg.auditSheetUrl); | |
| var sheet = ss.getSheetByName(cfg.auditSheetName); | |
| if (!sheet) { | |
| sheet = ss.insertSheet(cfg.auditSheetName); | |
| sheet.appendRow([ | |
| "Date", | |
| "Account", | |
| "Placement", | |
| "Campaign", | |
| "Reason", | |
| "Spend", | |
| "Clicks", | |
| "Conversions", | |
| ]); | |
| } | |
| var now = new Date().toISOString(); | |
| for (var j = 0; j < exclusionQueue.length; j++) { | |
| var row = exclusionQueue[j]; | |
| sheet.appendRow([ | |
| now, | |
| accountName + " (" + accountId + ")", | |
| row.placement, | |
| row.campaignName, | |
| row.reason, | |
| row.spend.toFixed(2), | |
| row.clicks, | |
| row.conversions, | |
| ]); | |
| } | |
| } catch (e) { | |
| result.errors.push("Audit log: " + e.message); | |
| } | |
| } | |
| Logger.log( | |
| "[" + | |
| accountId + | |
| "] " + | |
| accountName + | |
| ": scanned " + | |
| result.scanned + | |
| ", excluded " + | |
| result.applied + | |
| " (TLD:" + | |
| result.byTld + | |
| " KW:" + | |
| result.byKeyword + | |
| " PERF:" + | |
| result.byPerformance + | |
| ")", | |
| ); | |
| return JSON.stringify(result); | |
| } | |
| function onAllComplete(results) { | |
| var totals = { | |
| accounts: 0, | |
| scanned: 0, | |
| applied: 0, | |
| byTld: 0, | |
| byKeyword: 0, | |
| byPerformance: 0, | |
| alreadyExcluded: 0, | |
| errors: 0, | |
| }; | |
| var summaryLines = []; | |
| for (var i = 0; i < results.length; i++) { | |
| var r = JSON.parse(results[i].getReturnValue()); | |
| totals.accounts++; | |
| totals.scanned += r.scanned; | |
| totals.applied += r.applied; | |
| totals.byTld += r.byTld; | |
| totals.byKeyword += r.byKeyword; | |
| totals.byPerformance += r.byPerformance; | |
| totals.alreadyExcluded += r.alreadyExcluded; | |
| totals.errors += r.errors.length; | |
| if (r.applied > 0 || r.errors.length > 0) | |
| summaryLines.push( | |
| r.accountName + | |
| " (" + | |
| r.accountId + | |
| "): +" + | |
| r.applied + | |
| " excluded, " + | |
| r.alreadyExcluded + | |
| " existing" + | |
| (r.errors.length > 0 ? ", " + r.errors.length + " errors" : ""), | |
| ); | |
| } | |
| Logger.log(""); | |
| Logger.log("=== TLD AUTO-EXCLUSION COMPLETE ==="); | |
| Logger.log("Accounts: " + totals.accounts); | |
| Logger.log("Placements scanned: " + totals.scanned); | |
| Logger.log( | |
| "Excluded: " + | |
| totals.applied + | |
| " (TLD:" + | |
| totals.byTld + | |
| " KW:" + | |
| totals.byKeyword + | |
| " PERF:" + | |
| totals.byPerformance + | |
| ")", | |
| ); | |
| Logger.log("Already excluded: " + totals.alreadyExcluded); | |
| Logger.log("Errors: " + totals.errors); | |
| if (summaryLines.length > 0) { | |
| Logger.log(""); | |
| Logger.log("--- Per-account ---"); | |
| for (var j = 0; j < summaryLines.length; j++) | |
| Logger.log(" " + summaryLines[j]); | |
| } | |
| if (CONFIG.ALERT_EMAIL && totals.applied > 0) { | |
| var subject = | |
| "TLD Auto-Exclusion: +" + | |
| totals.applied + | |
| " across " + | |
| totals.accounts + | |
| " accounts"; | |
| var body = | |
| "Run: " + | |
| new Date().toISOString() + | |
| "\n\n" + | |
| "Accounts: " + | |
| totals.accounts + | |
| "\n" + | |
| "Scanned: " + | |
| totals.scanned + | |
| "\n" + | |
| "Excluded: " + | |
| totals.applied + | |
| "\n" + | |
| " By TLD: " + | |
| totals.byTld + | |
| "\n" + | |
| " By keyword: " + | |
| totals.byKeyword + | |
| "\n" + | |
| " By performance: " + | |
| totals.byPerformance + | |
| "\n" + | |
| "Already excluded: " + | |
| totals.alreadyExcluded + | |
| "\n" + | |
| "Errors: " + | |
| totals.errors + | |
| "\n\n" + | |
| "Accounts with changes:\n" + | |
| summaryLines.join("\n"); | |
| MailApp.sendEmail(CONFIG.ALERT_EMAIL, subject, body); | |
| } | |
| } | |
| // helpers | |
| function endsWithTld(placement, tld) { | |
| var domain = placement | |
| .replace(/^https?:\/\//, "") | |
| .replace(/\/.*$/, "") | |
| .replace(/:\d+$/, ""); | |
| if (domain.length <= tld.length) return false; | |
| return domain.substring(domain.length - tld.length) === tld; | |
| } | |
| function fmtDate(date) { | |
| var y = date.getFullYear(); | |
| var m = ("0" + (date.getMonth() + 1)).slice(-2); | |
| var d = ("0" + date.getDate()).slice(-2); | |
| return y + "-" + m + "-" + d; | |
| } | |
| /** | |
| * Safely applies placement exclusion across Display or Video campaigns. | |
| */ | |
| function applyPlacementExclusionSafe(campaign, placement) { | |
| var lower = String(placement || "").trim().toLowerCase(); | |
| // 1. Mobile app category (e.g. mobileappcategory::69500) | |
| if (lower.indexOf("mobileappcategory::") === 0) { | |
| var catId = parseInt(lower.split("::")[1], 10); | |
| if (campaign.videoTargeting && typeof campaign.videoTargeting === "function") { | |
| var vt = campaign.videoTargeting(); | |
| if (vt.newMobileAppCategoryBuilder && typeof vt.newMobileAppCategoryBuilder === "function") { | |
| vt.newMobileAppCategoryBuilder() | |
| .withMobileAppCategoryId(catId) | |
| .exclude(); | |
| return; | |
| } | |
| } | |
| // For Display / other campaigns, mobile app categories are not supported via scripts at campaign level. | |
| throw new Error("Mobile app category exclusions are not supported via script for Display/PMax campaigns at campaign level. Exclude them manually at the Account level under Content Suitability."); | |
| } | |
| // 2. Mobile application (e.g. com.example.app, or prefixed with com. or org.) | |
| var isApp = (lower.indexOf("com.") === 0 || lower.indexOf("org.") === 0 || lower.indexOf("mobileapp::") === 0); | |
| if (isApp) { | |
| var appId = lower; | |
| if (campaign.videoTargeting && typeof campaign.videoTargeting === "function") { | |
| var vt2 = campaign.videoTargeting(); | |
| if (vt2.newMobileApplicationBuilder && typeof vt2.newMobileApplicationBuilder === "function") { | |
| vt2.newMobileApplicationBuilder() | |
| .withAppId(appId) | |
| .exclude(); | |
| return; | |
| } | |
| } | |
| throw new Error("Mobile app exclusions are not supported via script for Display/PMax campaigns at campaign level. Exclude them manually at the Account level under Content Suitability."); | |
| } | |
| // 3. Website placements | |
| // Try Display campaign builder first | |
| if (campaign.display && typeof campaign.display === "function") { | |
| var d = campaign.display(); | |
| if (d.newPlacementBuilder && typeof d.newPlacementBuilder === "function") { | |
| d.newPlacementBuilder() | |
| .withUrl(placement) | |
| .exclude(); | |
| return; | |
| } | |
| } | |
| // Try Video campaign builder | |
| if (campaign.videoTargeting && typeof campaign.videoTargeting === "function") { | |
| var vt3 = campaign.videoTargeting(); | |
| if (vt3.newPlacementBuilder && typeof vt3.newPlacementBuilder === "function") { | |
| vt3.newPlacementBuilder() | |
| .withUrl(placement) | |
| .exclude(); | |
| return; | |
| } | |
| } | |
| throw new Error("Campaign type not supported for placement exclusions via script."); | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
For the background, read the blog post here: https://ppcchief.com/blog/gdn-exclusion-list