Production-ready prompts, scripts, frameworks and AI agents for Google Ads professionals. No payment required.
/******************************************************************************
* NEGATIVE KEYWORD GAP FINDER
*
* Generated by PPC.io Script Engine
* https://ppc.io
*
* Purpose: Find inconsistent negative keywords across campaigns
* Author: PPC.io
* Version: 2.0
* Updated: 2025-01-13
*
* SETUP INSTRUCTIONS:
* 1. Set SPREADSHEET_URL to 'CREATE_NEW' or paste existing URL
* 2. Run in Preview mode first to verify
* 3. Schedule: Monthly recommended
*
* USE CASE: "Which negative gaps are causing wasted spend? Prioritize by impact."
*
* OUTPUTS:
* - 1. Summary: Overview with AI prompts
* - 2. Negative Gaps: Gaps with Coverage % metric
* - 3. All Negatives: Complete list across campaigns
* - 4. Action Items: Copy-paste ready recommendations
*
* CHANGELOG:
* v2.0 - Added GAQL-first approach, Coverage %, numbered sheets
* v1.0 - Initial release
*
******************************************************************************/
/******************************************************************************
* CONFIGURATION - Adjust these values for your account
******************************************************************************/
var CONFIG = {
// ═══════════════════════════════════════════════════════════════════════════
// OUTPUT SETTINGS
// ═══════════════════════════════════════════════════════════════════════════
SPREADSHEET_URL: 'CREATE_NEW', // Or paste existing spreadsheet URL
// Email alerts (leave empty array to disable)
EMAIL_RECIPIENTS: [],
// Slack webhook (leave empty to disable)
SLACK_WEBHOOK_URL: '',
// ═══════════════════════════════════════════════════════════════════════════
// FILTERS
// ═══════════════════════════════════════════════════════════════════════════
CAMPAIGN_NAME_CONTAINS: '', // Filter campaigns (empty = all)
CAMPAIGN_NAME_EXCLUDES: '', // Exclude campaigns containing this
INCLUDE_PAUSED_CAMPAIGNS: false,
// ═══════════════════════════════════════════════════════════════════════════
// ANALYSIS SETTINGS
// ═══════════════════════════════════════════════════════════════════════════
// Minimum campaigns using a negative to flag gaps
// (e.g., 2 = flag if 2+ campaigns have a negative but others don't)
MIN_CAMPAIGNS_FOR_GAP: 2,
// Compare negatives across campaign types
// true = only compare within same campaign type (Search vs Search)
// false = compare all campaigns regardless of type
COMPARE_SAME_TYPE_ONLY: true,
// Include shared negative lists in analysis
INCLUDE_SHARED_LISTS: true,
// ═══════════════════════════════════════════════════════════════════════════
// EXECUTION SETTINGS
// ═══════════════════════════════════════════════════════════════════════════
LOG_LEVEL: 'INFO', // DEBUG, INFO, WARN, ERROR
TIME_LIMIT_MINUTES: 25, // Exit gracefully before this (max 30)
BATCH_SIZE: 500 // Rows per spreadsheet write
};
/******************************************************************************
* MAIN EXECUTION
******************************************************************************/
function main() {
var startTime = new Date();
log('INFO', 'Negative Keyword Gap Finder started: ' + startTime.toISOString());
try {
var ss = initializeSpreadsheet();
var results = analyzeNegativeGaps(startTime);
writeAllSheets(ss, results);
sendNotifications(results, ss.getUrl(), startTime);
logSummary(results, startTime);
} catch (error) {
handleFatalError(error, startTime);
}
}
/******************************************************************************
* DATA COLLECTION & ANALYSIS
******************************************************************************/
function analyzeNegativeGaps(startTime) {
var results = {
campaigns: [],
negativeMap: {}, // negative -> campaigns using it
campaignNegatives: {}, // campaign -> negatives
gaps: [], // gaps to fill
allNegatives: [], // complete negative list
sharedLists: [], // shared negative lists
summary: {
totalCampaigns: 0,
totalNegatives: 0,
uniqueNegatives: 0,
gapsFound: 0,
potentialWaste: 0
}
};
// Get all enabled search campaigns
var campaigns = getCampaigns();
results.campaigns = campaigns;
results.summary.totalCampaigns = campaigns.length;
if (campaigns.length === 0) {
log('WARN', 'No campaigns found matching criteria');
return results;
}
// Collect negatives for each campaign
collectCampaignNegatives(results, startTime);
// Collect shared negative lists
if (CONFIG.INCLUDE_SHARED_LISTS) {
collectSharedNegativeLists(results, startTime);
}
// Find gaps
findNegativeGaps(results);
// Build complete negative list
buildCompleteNegativeList(results);
log('INFO', 'Analysis complete: ' + results.summary.gapsFound + ' gaps found');
return results;
}
function getCampaigns() {
var campaigns = [];
var selector = AdsApp.campaigns()
.withCondition('AdvertisingChannelType = SEARCH');
if (!CONFIG.INCLUDE_PAUSED_CAMPAIGNS) {
selector = selector.withCondition('Status = ENABLED');
}
var iterator = selector.get();
while (iterator.hasNext()) {
var campaign = iterator.next();
var name = campaign.getName();
// Apply filters
if (CONFIG.CAMPAIGN_NAME_CONTAINS &&
name.toLowerCase().indexOf(CONFIG.CAMPAIGN_NAME_CONTAINS.toLowerCase()) === -1) {
continue;
}
if (CONFIG.CAMPAIGN_NAME_EXCLUDES &&
name.toLowerCase().indexOf(CONFIG.CAMPAIGN_NAME_EXCLUDES.toLowerCase()) !== -1) {
continue;
}
campaigns.push({
id: campaign.getId(),
name: name,
type: campaign.getAdvertisingChannelType(),
status: campaign.isEnabled() ? 'ENABLED' : 'PAUSED',
campaignObj: campaign
});
}
log('INFO', 'Found ' + campaigns.length + ' campaigns');
return campaigns;
}
function collectCampaignNegatives(results, startTime) {
var totalNegatives = 0;
// GAQL-first approach for campaign-level negatives
try {
var query = 'SELECT ' +
'campaign.name, ' +
'campaign.advertising_channel_type, ' +
'campaign_criterion.keyword.text, ' +
'campaign_criterion.keyword.match_type ' +
'FROM campaign_criterion ' +
'WHERE campaign_criterion.type = "KEYWORD" ' +
'AND campaign_criterion.negative = TRUE';
if (!CONFIG.INCLUDE_PAUSED_CAMPAIGNS) {
query += ' AND campaign.status = "ENABLED"';
}
var report = AdsApp.report(query);
var rows = report.rows();
var count = 0;
while (rows.hasNext()) {
var row = rows.next();
var campaignName = row['campaign.name'];
// Apply filters
if (CONFIG.CAMPAIGN_NAME_CONTAINS &&
campaignName.toLowerCase().indexOf(CONFIG.CAMPAIGN_NAME_CONTAINS.toLowerCase()) === -1) {
continue;
}
if (CONFIG.CAMPAIGN_NAME_EXCLUDES &&
campaignName.toLowerCase().indexOf(CONFIG.CAMPAIGN_NAME_EXCLUDES.toLowerCase()) !== -1) {
continue;
}
var text = (row['campaign_criterion.keyword.text'] || '').toLowerCase().replace(/[\[\]"]/g, '').trim();
var matchType = row['campaign_criterion.keyword.match_type'] || 'BROAD';
var campaignType = row['campaign.advertising_channel_type'];
if (!text) continue;
// Initialize campaign negatives array if needed
if (!results.campaignNegatives[campaignName]) {
results.campaignNegatives[campaignName] = [];
}
results.campaignNegatives[campaignName].push({
text: text,
matchType: matchType,
level: 'CAMPAIGN',
original: row['campaign_criterion.keyword.text']
});
// Track in negative map
var key = text + '|' + matchType;
if (!results.negativeMap[key]) {
results.negativeMap[key] = {
text: text,
matchType: matchType,
campaigns: [],
campaignTypes: {}
};
}
if (results.negativeMap[key].campaigns.indexOf(campaignName) === -1) {
results.negativeMap[key].campaigns.push(campaignName);
}
results.negativeMap[key].campaignTypes[campaignType] = true;
totalNegatives++;
count++;
if (count % 500 === 0) {
checkTimeLimit(startTime);
}
}
log('INFO', 'GAQL: Collected ' + totalNegatives + ' campaign-level negatives');
} catch (e) {
log('WARN', 'GAQL failed, using fallback: ' + e.message);
collectCampaignNegativesFallback(results, startTime);
totalNegatives = results.summary.totalNegatives || 0;
}
// Update campaign negative counts
for (var i = 0; i < results.campaigns.length; i++) {
var campaign = results.campaigns[i];
var negs = results.campaignNegatives[campaign.name] || [];
campaign.negativeCount = negs.length;
}
results.summary.totalNegatives = totalNegatives;
results.summary.uniqueNegatives = Object.keys(results.negativeMap).length;
log('INFO', 'Total: ' + totalNegatives + ' negatives (' + results.summary.uniqueNegatives + ' unique)');
}
// Fallback using standard API iteration
function collectCampaignNegativesFallback(results, startTime) {
var totalNegatives = 0;
for (var i = 0; i < results.campaigns.length; i++) {
var campaign = results.campaigns[i];
var negatives = [];
try {
var negKeywords = campaign.campaignObj.negativeKeywords().get();
while (negKeywords.hasNext()) {
var negKeyword = negKeywords.next();
var text = negKeyword.getText().toLowerCase().replace(/[\[\]"]/g, '').trim();
var matchType = negKeyword.getMatchType();
negatives.push({
text: text,
matchType: matchType,
level: 'CAMPAIGN',
original: negKeyword.getText()
});
var key = text + '|' + matchType;
if (!results.negativeMap[key]) {
results.negativeMap[key] = {
text: text,
matchType: matchType,
campaigns: [],
campaignTypes: {}
};
}
results.negativeMap[key].campaigns.push(campaign.name);
results.negativeMap[key].campaignTypes[campaign.type] = true;
totalNegatives++;
}
} catch (e) {
log('DEBUG', 'Error getting negatives for ' + campaign.name + ': ' + e.message);
}
results.campaignNegatives[campaign.name] = negatives;
campaign.negativeCount = negatives.length;
if ((i + 1) % 10 === 0) {
checkTimeLimit(startTime);
}
}
results.summary.totalNegatives = totalNegatives;
}
function collectSharedNegativeLists(results, startTime) {
try {
var lists = AdsApp.negativeKeywordLists().get();
while (lists.hasNext()) {
var list = lists.next();
var listName = list.getName();
var keywords = [];
var negIterator = list.negativeKeywords().get();
while (negIterator.hasNext()) {
var neg = negIterator.next();
keywords.push({
text: neg.getText(),
matchType: neg.getMatchType()
});
}
// Get campaigns using this list
var campaignsUsing = [];
var campaignIterator = list.campaigns().get();
while (campaignIterator.hasNext()) {
var camp = campaignIterator.next();
campaignsUsing.push(camp.getName());
}
results.sharedLists.push({
name: listName,
keywordCount: keywords.length,
campaignsUsing: campaignsUsing.length,
campaigns: campaignsUsing.join(', '),
keywords: keywords
});
}
log('INFO', 'Found ' + results.sharedLists.length + ' shared negative lists');
} catch (e) {
log('DEBUG', 'Could not fetch shared lists: ' + e.message);
}
}
function findNegativeGaps(results) {
var gaps = [];
var totalCampaigns = results.campaigns.length;
for (var key in results.negativeMap) {
var neg = results.negativeMap[key];
var campaignsUsing = neg.campaigns;
// Skip if not enough campaigns use this negative
if (campaignsUsing.length < CONFIG.MIN_CAMPAIGNS_FOR_GAP) {
continue;
}
// Find campaigns NOT using this negative
var campaignsWithoutThis = [];
var relevantCampaigns = 0;
for (var i = 0; i < results.campaigns.length; i++) {
var campaign = results.campaigns[i];
// If comparing same type only, skip different types
if (CONFIG.COMPARE_SAME_TYPE_ONLY) {
if (!neg.campaignTypes[campaign.type]) {
continue;
}
}
relevantCampaigns++;
// Check if this campaign has this negative
var hasNegative = false;
var campNegs = results.campaignNegatives[campaign.name] || [];
for (var j = 0; j < campNegs.length; j++) {
if (campNegs[j].text === neg.text && campNegs[j].matchType === neg.matchType) {
hasNegative = true;
break;
}
}
if (!hasNegative && campaignsUsing.indexOf(campaign.name) === -1) {
campaignsWithoutThis.push(campaign.name);
}
}
// If there are campaigns without this negative, it's a gap
if (campaignsWithoutThis.length > 0 && relevantCampaigns > 0) {
// Calculate Coverage % - what % of relevant campaigns have this negative
var coverage = ((campaignsUsing.length / relevantCampaigns) * 100).toFixed(1);
gaps.push({
'Negative Keyword': neg.text,
'Match Type': neg.matchType,
'Coverage %': coverage + '%',
'Campaigns WITH': campaignsUsing.length,
'Campaigns WITHOUT': campaignsWithoutThis.length,
'Using Campaigns': campaignsUsing.slice(0, 5).join(', ') + (campaignsUsing.length > 5 ? '...' : ''),
'Missing From': campaignsWithoutThis.slice(0, 5).join(', ') + (campaignsWithoutThis.length > 5 ? '...' : ''),
'All Missing Campaigns': campaignsWithoutThis.join(', '),
'Priority': calculateGapPriority(campaignsUsing.length, campaignsWithoutThis.length, neg.text)
});
}
}
// Sort by priority, then by coverage descending
gaps.sort(function(a, b) {
var priorityOrder = { 'HIGH': 0, 'MEDIUM': 1, 'LOW': 2 };
var priorityDiff = (priorityOrder[a.Priority] || 3) - (priorityOrder[b.Priority] || 3);
if (priorityDiff !== 0) return priorityDiff;
return parseFloat(b['Coverage %']) - parseFloat(a['Coverage %']);
});
results.gaps = gaps;
results.summary.gapsFound = gaps.length;
log('INFO', 'Found ' + gaps.length + ' negative keyword gaps');
}
function calculateGapPriority(campaignsUsing, campaignsWithout, keyword) {
// High priority: common exclusion words missing from multiple campaigns
var highPriorityWords = ['free', 'cheap', 'diy', 'how to', 'tutorial', 'reddit',
'youtube', 'jobs', 'career', 'salary', 'review',
'scam', 'complaint', 'lawsuit'];
for (var i = 0; i < highPriorityWords.length; i++) {
if (keyword.indexOf(highPriorityWords[i]) !== -1) {
return 'HIGH';
}
}
// High if majority of campaigns use it
var totalCampaigns = campaignsUsing + campaignsWithout;
var useRatio = campaignsUsing / totalCampaigns;
if (useRatio >= 0.7) {
return 'HIGH';
} else if (useRatio >= 0.4) {
return 'MEDIUM';
}
return 'LOW';
}
function buildCompleteNegativeList(results) {
var allNegatives = [];
for (var key in results.negativeMap) {
var neg = results.negativeMap[key];
allNegatives.push({
'Negative Keyword': neg.text,
'Match Type': neg.matchType,
'Campaign Count': neg.campaigns.length,
'Campaigns': neg.campaigns.join(', ')
});
}
// Sort by campaign count descending
allNegatives.sort(function(a, b) { return b['Campaign Count'] - a['Campaign Count']; });
results.allNegatives = allNegatives;
}
/******************************************************************************
* OUTPUT FUNCTIONS
******************************************************************************/
function initializeSpreadsheet() {
var ss;
if (!CONFIG.SPREADSHEET_URL || CONFIG.SPREADSHEET_URL === 'YOUR_SPREADSHEET_URL_HERE' || CONFIG.SPREADSHEET_URL === 'CREATE_NEW') {
ss = SpreadsheetApp.create('PPC.io Negative Gaps - ' +
AdsApp.currentAccount().getName() + ' - ' +
formatDate(new Date()));
log('INFO', 'Created spreadsheet: ' + ss.getUrl());
} else {
ss = SpreadsheetApp.openByUrl(CONFIG.SPREADSHEET_URL);
}
return ss;
}
function writeAllSheets(ss, results) {
// Summary sheet first (position 0)
writeSummarySheet(ss, results);
// Gap Analysis with Coverage %
if (results.gaps.length > 0) {
writeSheet(ss, '2. Negative Gaps', results.gaps,
['Negative Keyword', 'Match Type', 'Coverage %', 'Campaigns WITH', 'Campaigns WITHOUT',
'Using Campaigns', 'Missing From', 'Priority']);
}
// All Negatives
if (results.allNegatives.length > 0) {
writeSheet(ss, '3. All Negatives', results.allNegatives,
['Negative Keyword', 'Match Type', 'Campaign Count', 'Campaigns']);
}
// Action Items (gaps with all missing campaigns)
if (results.gaps.length > 0) {
var actionItems = results.gaps.map(function(gap) {
return {
'Negative Keyword': gap['Negative Keyword'],
'Match Type': gap['Match Type'],
'Coverage %': gap['Coverage %'],
'Add To These Campaigns': gap['All Missing Campaigns'],
'Priority': gap.Priority
};
});
writeSheet(ss, '4. Action Items', actionItems,
['Negative Keyword', 'Match Type', 'Coverage %', 'Add To These Campaigns', 'Priority']);
}
// Campaign Overview
var campaignOverview = results.campaigns.map(function(c) {
return {
'Campaign': c.name,
'Type': c.type,
'Status': c.status,
'Negative Count': c.negativeCount || 0
};
});
writeSheet(ss, '5. Campaign Overview', campaignOverview,
['Campaign', 'Type', 'Status', 'Negative Count']);
// Shared Lists
if (results.sharedLists.length > 0) {
var sharedListData = results.sharedLists.map(function(list) {
return {
'List Name': list.name,
'Keywords': list.keywordCount,
'Campaigns Using': list.campaignsUsing,
'Campaign Names': list.campaigns
};
});
writeSheet(ss, '6. Shared Lists', sharedListData,
['List Name', 'Keywords', 'Campaigns Using', 'Campaign Names']);
}
}
function writeSummarySheet(ss, results) {
var sheet = ss.getSheetByName('1. Summary');
if (!sheet) {
sheet = ss.insertSheet('1. Summary', 0);
} else {
sheet.clear();
}
var highPriority = results.gaps.filter(function(g) { return g.Priority === 'HIGH'; }).length;
var mediumPriority = results.gaps.filter(function(g) { return g.Priority === 'MEDIUM'; }).length;
var data = [
['NEGATIVE KEYWORD GAP FINDER', ''],
['Generated by PPC.io Script Engine', ''],
['https://ppc.io', ''],
['', ''],
['Account: ' + AdsApp.currentAccount().getName(), ''],
['Export Date: ' + new Date().toISOString(), ''],
['', ''],
['═══════════════════════════════════════════════════════════════', ''],
['OVERVIEW', ''],
['═══════════════════════════════════════════════════════════════', ''],
['Campaigns Analyzed', results.summary.totalCampaigns],
['Total Negatives Found', results.summary.totalNegatives],
['Unique Negatives', results.summary.uniqueNegatives],
['Shared Negative Lists', results.sharedLists.length],
['', ''],
['═══════════════════════════════════════════════════════════════', ''],
['GAPS FOUND', ''],
['═══════════════════════════════════════════════════════════════', ''],
['Total Gaps', results.summary.gapsFound],
['HIGH Priority', highPriority],
['MEDIUM Priority', mediumPriority],
['LOW Priority', results.summary.gapsFound - highPriority - mediumPriority],
['', ''],
['═══════════════════════════════════════════════════════════════', ''],
['TOP HIGH-PRIORITY GAPS', ''],
['═══════════════════════════════════════════════════════════════', '']
];
var topGaps = results.gaps.filter(function(g) { return g.Priority === 'HIGH'; }).slice(0, 10);
if (topGaps.length === 0) {
data.push(['No high-priority gaps found!', '']);
} else {
for (var i = 0; i < topGaps.length; i++) {
var gap = topGaps[i];
data.push([
gap['Negative Keyword'] + ' [' + gap['Match Type'] + ']',
'Missing from ' + gap['Campaigns WITHOUT'] + ' campaigns'
]);
}
}
data.push(['', '']);
data.push(['═══════════════════════════════════════════════════════════════', '']);
data.push(['HOW TO USE', '']);
data.push(['═══════════════════════════════════════════════════════════════', '']);
data.push(['1. Review the "Negative Gaps" sheet for inconsistencies', '']);
data.push(['2. HIGH priority gaps are most likely causing wasted spend', '']);
data.push(['3. Use "Action Items" sheet for a copy-paste ready list', '']);
data.push(['4. Consider creating a shared negative list for common terms', '']);
data.push(['', '']);
data.push(['═══════════════════════════════════════════════════════════════', '']);
data.push(['AI ANALYSIS PROMPTS', '']);
data.push(['═══════════════════════════════════════════════════════════════', '']);
data.push(['Paste the gap data into Claude with these prompts:', '']);
data.push(['', '']);
data.push(['1. "Which negative keyword gaps are most likely causing wasted spend?"', '']);
data.push(['2. "Create a shared negative keyword list strategy based on these gaps."', '']);
data.push(['3. "Are there any negatives that shouldn\'t be applied universally?"', '']);
sheet.getRange(1, 1, data.length, 2).setValues(data);
sheet.getRange(1, 1).setFontWeight('bold').setFontSize(14);
sheet.setColumnWidth(1, 400);
sheet.setColumnWidth(2, 300);
}
function writeSheet(ss, sheetName, data, columns) {
if (!data || data.length === 0) {
log('DEBUG', 'No data for sheet: ' + sheetName);
return;
}
var sheet = ss.getSheetByName(sheetName);
if (!sheet) {
sheet = ss.insertSheet(sheetName);
} else {
sheet.clear();
}
sheet.getRange(1, 1, 1, columns.length).setValues([columns]).setFontWeight('bold');
sheet.setFrozenRows(1);
var rows = data.map(function(row) {
return columns.map(function(col) {
var val = row[col];
return val !== null && val !== undefined ? val : '';
});
});
for (var i = 0; i < rows.length; i += CONFIG.BATCH_SIZE) {
var batch = rows.slice(i, Math.min(i + CONFIG.BATCH_SIZE, rows.length));
sheet.getRange(2 + i, 1, batch.length, columns.length).setValues(batch);
}
// Color code priority column
var priorityCol = columns.indexOf('Priority') + 1;
if (priorityCol > 0) {
var range = sheet.getRange(2, priorityCol, rows.length, 1);
var values = range.getValues();
var colors = values.map(function(row) {
if (row[0] === 'HIGH') return ['#f8d7da'];
if (row[0] === 'MEDIUM') return ['#fff3cd'];
return ['#d4edda'];
});
range.setBackgrounds(colors);
}
for (var col = 1; col <= Math.min(columns.length, 8); col++) {
sheet.autoResizeColumn(col);
}
}
/******************************************************************************
* NOTIFICATION FUNCTIONS
******************************************************************************/
function sendNotifications(results, spreadsheetUrl, startTime) {
var duration = ((new Date() - startTime) / 1000).toFixed(1);
var highPriority = results.gaps.filter(function(g) { return g.Priority === 'HIGH'; }).length;
var message = [
'Negative Keyword Gap Analysis Complete',
'',
'Account: ' + AdsApp.currentAccount().getName(),
'Duration: ' + duration + 's',
'',
'Summary:',
'- Campaigns Analyzed: ' + results.summary.totalCampaigns,
'- Unique Negatives: ' + results.summary.uniqueNegatives,
'- GAPS FOUND: ' + results.summary.gapsFound,
'- HIGH Priority: ' + highPriority,
'',
'Report: ' + spreadsheetUrl,
'',
'--',
'Generated by PPC.io Script Engine'
].join('\n');
if (CONFIG.EMAIL_RECIPIENTS && CONFIG.EMAIL_RECIPIENTS.length > 0) {
try {
MailApp.sendEmail({
to: CONFIG.EMAIL_RECIPIENTS.join(','),
subject: '[PPC.io] Negative Gaps - ' + results.summary.gapsFound + ' found (' + highPriority + ' high priority)',
body: message
});
log('INFO', 'Email sent');
} catch (e) {
log('ERROR', 'Failed to send email: ' + e.message);
}
}
if (CONFIG.SLACK_WEBHOOK_URL) {
var emoji = highPriority > 0 ? ':warning:' : ':white_check_mark:';
try {
UrlFetchApp.fetch(CONFIG.SLACK_WEBHOOK_URL, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({
text: emoji + ' *PPC.io Negative Gap Finder*\n```' + message + '```'
})
});
log('INFO', 'Slack sent');
} catch (e) {
log('ERROR', 'Failed to send Slack: ' + e.message);
}
}
}
/******************************************************************************
* UTILITY FUNCTIONS
******************************************************************************/
function checkTimeLimit(startTime) {
var elapsed = (new Date() - startTime) / 1000 / 60;
if (elapsed > CONFIG.TIME_LIMIT_MINUTES) {
throw new Error('TIME_LIMIT: Analysis stopped after ' + elapsed.toFixed(1) + ' minutes.');
}
}
function log(level, message) {
var levels = { 'DEBUG': 0, 'INFO': 1, 'WARN': 2, 'ERROR': 3 };
if (levels[level] >= levels[CONFIG.LOG_LEVEL]) {
Logger.log('[' + level + '] ' + message);
}
}
function logSummary(results, startTime) {
var duration = ((new Date() - startTime) / 1000).toFixed(1);
log('INFO', '════════════════════════════════════════');
log('INFO', 'NEGATIVE GAP ANALYSIS COMPLETE');
log('INFO', 'Duration: ' + duration + ' seconds');
log('INFO', 'Campaigns: ' + results.summary.totalCampaigns);
log('INFO', 'Unique Negatives: ' + results.summary.uniqueNegatives);
log('INFO', 'Gaps Found: ' + results.summary.gapsFound);
log('INFO', '════════════════════════════════════════');
}
function handleFatalError(error, startTime) {
log('ERROR', '════════════════════════════════════════');
log('ERROR', 'FATAL ERROR: ' + error.message);
log('ERROR', 'Stack: ' + error.stack);
log('ERROR', '════════════════════════════════════════');
if (CONFIG.EMAIL_RECIPIENTS && CONFIG.EMAIL_RECIPIENTS.length > 0) {
try {
MailApp.sendEmail({
to: CONFIG.EMAIL_RECIPIENTS.join(','),
subject: '[PPC.io ERROR] Negative Gap Finder Failed',
body: 'Error: ' + error.message + '\n\nStack:\n' + error.stack
});
} catch (e) {
log('ERROR', 'Could not send error email: ' + e.message);
}
}
}
function formatDate(date) {
return Utilities.formatDate(date, AdsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd');
}