392 lines
12 KiB
JavaScript
392 lines
12 KiB
JavaScript
/**
|
|
* Bid Manager Template
|
|
*
|
|
* Template for automated keyword bid management based on performance metrics.
|
|
* Implements intelligent bidding strategies with safety controls.
|
|
*
|
|
* Features:
|
|
* - Bid optimization based on ROAS/CPA targets
|
|
* - Quality score-based bid adjustments
|
|
* - Conversion-driven bid modifications
|
|
* - Detailed audit logging
|
|
* - Safety limits and dry-run mode
|
|
*/
|
|
|
|
// ============================================================================
|
|
// CONFIGURATION
|
|
// ============================================================================
|
|
|
|
const CONFIG = {
|
|
// Bidding strategy
|
|
STRATEGY: 'ROAS', // 'ROAS' or 'CPA'
|
|
TARGET_ROAS: 3.0, // Target Return on Ad Spend (300%)
|
|
TARGET_CPA: 50.00, // Target Cost Per Acquisition (in currency)
|
|
|
|
// Bid adjustment factors
|
|
HIGH_PERFORMER_INCREASE: 1.10, // +10% for high performers
|
|
LOW_PERFORMER_DECREASE: 0.95, // -5% for low performers
|
|
QUALITY_SCORE_FACTOR: 0.05, // ±5% per quality score point deviation
|
|
|
|
// Performance thresholds
|
|
MIN_CONVERSIONS: 3, // Minimum conversions to evaluate
|
|
MIN_CLICKS: 50, // Minimum clicks to evaluate
|
|
HIGH_ROAS_THRESHOLD: 3.5, // ROAS above this = high performer
|
|
LOW_ROAS_THRESHOLD: 2.0, // ROAS below this = low performer
|
|
TARGET_QUALITY_SCORE: 7, // Target quality score
|
|
|
|
// Bid limits
|
|
MIN_BID_MICROS: 50000, // $0.05 minimum bid
|
|
MAX_BID_MICROS: 10000000, // $10.00 maximum bid
|
|
MAX_BID_CHANGE_PERCENT: 25, // Maximum 25% bid change per run
|
|
|
|
// Date range
|
|
DATE_RANGE: 'LAST_30_DAYS',
|
|
|
|
// Reporting
|
|
SPREADSHEET_ID: 'YOUR_SPREADSHEET_ID', // Replace with your Sheet ID
|
|
LOG_SHEET_NAME: 'Bid Manager Log',
|
|
NOTIFICATION_EMAIL: Session.getEffectiveUser().getEmail(),
|
|
|
|
// Safety
|
|
DRY_RUN: false, // Set to true to preview bid changes
|
|
MAX_KEYWORDS_TO_PROCESS: 5000 // Prevent accidental mass changes
|
|
};
|
|
|
|
// ============================================================================
|
|
// MAIN FUNCTION
|
|
// ============================================================================
|
|
|
|
function main() {
|
|
try {
|
|
Logger.log('Starting Bid Manager...');
|
|
Logger.log('Date: ' + new Date());
|
|
Logger.log('Strategy: ' + CONFIG.STRATEGY);
|
|
Logger.log('Mode: ' + (CONFIG.DRY_RUN ? 'DRY RUN (preview only)' : 'LIVE'));
|
|
|
|
// Initialize reporting
|
|
const sheet = initializeReportingSheet();
|
|
|
|
// Get keywords to optimize
|
|
const keywords = getKeywordsToOptimize();
|
|
Logger.log(`Found ${keywords.totalNumEntities()} keywords to evaluate`);
|
|
|
|
if (keywords.totalNumEntities() > CONFIG.MAX_KEYWORDS_TO_PROCESS) {
|
|
throw new Error(`Too many keywords (${keywords.totalNumEntities()}). Increase MAX_KEYWORDS_TO_PROCESS if intended.`);
|
|
}
|
|
|
|
// Process keywords
|
|
const results = processKeywords(keywords);
|
|
|
|
// Generate summary
|
|
logResults(sheet, results);
|
|
sendEmailNotification(results);
|
|
|
|
Logger.log('Bid Manager completed successfully');
|
|
|
|
} catch (error) {
|
|
handleError(error);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// KEYWORD PROCESSING
|
|
// ============================================================================
|
|
|
|
function getKeywordsToOptimize() {
|
|
return AdsApp.keywords()
|
|
.withCondition('keyword.status = ENABLED')
|
|
.withCondition(`keyword.metrics.clicks >= ${CONFIG.MIN_CLICKS}`)
|
|
.orderBy('keyword.metrics.cost DESC')
|
|
.get();
|
|
}
|
|
|
|
function processKeywords(keywords) {
|
|
const results = {
|
|
evaluated: 0,
|
|
increased: [],
|
|
decreased: [],
|
|
paused: [],
|
|
noChange: [],
|
|
errors: []
|
|
};
|
|
|
|
while (keywords.hasNext()) {
|
|
const keyword = keywords.next();
|
|
results.evaluated++;
|
|
|
|
try {
|
|
const decision = evaluateKeyword(keyword);
|
|
|
|
if (decision.action === 'PAUSE') {
|
|
if (!CONFIG.DRY_RUN) {
|
|
keyword.pause();
|
|
}
|
|
results.paused.push({
|
|
text: keyword.getText(),
|
|
campaign: keyword.getCampaign().getName(),
|
|
currentBid: keyword.getMaxCpc() / 1000000,
|
|
reason: decision.reason
|
|
});
|
|
} else if (decision.newBid && decision.newBid !== keyword.getMaxCpc()) {
|
|
const oldBid = keyword.getMaxCpc();
|
|
|
|
if (!CONFIG.DRY_RUN) {
|
|
keyword.setMaxCpc(decision.newBid);
|
|
}
|
|
|
|
const changeRecord = {
|
|
text: keyword.getText(),
|
|
campaign: keyword.getCampaign().getName(),
|
|
oldBid: oldBid / 1000000,
|
|
newBid: decision.newBid / 1000000,
|
|
change: ((decision.newBid - oldBid) / oldBid * 100).toFixed(1) + '%',
|
|
reason: decision.reason
|
|
};
|
|
|
|
if (decision.newBid > oldBid) {
|
|
results.increased.push(changeRecord);
|
|
} else {
|
|
results.decreased.push(changeRecord);
|
|
}
|
|
} else {
|
|
results.noChange.push(keyword.getText());
|
|
}
|
|
|
|
} catch (error) {
|
|
results.errors.push({
|
|
keyword: keyword.getText(),
|
|
error: error.message
|
|
});
|
|
Logger.log(`Error processing keyword ${keyword.getText()}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function evaluateKeyword(keyword) {
|
|
const stats = keyword.getStatsFor(CONFIG.DATE_RANGE);
|
|
const conversions = stats.getConversions();
|
|
const clicks = stats.getClicks();
|
|
const cost = stats.getCost();
|
|
const conversionValue = stats.getConversionValue();
|
|
const currentBid = keyword.getMaxCpc();
|
|
const qualityScore = keyword.getQualityScore();
|
|
|
|
// Insufficient data
|
|
if (conversions < CONFIG.MIN_CONVERSIONS) {
|
|
return { action: 'NONE', reason: `Insufficient conversions (${conversions})` };
|
|
}
|
|
|
|
// Calculate performance metrics
|
|
let performanceMultiplier = 1.0;
|
|
let reason = [];
|
|
|
|
if (CONFIG.STRATEGY === 'ROAS') {
|
|
const roas = cost > 0 ? conversionValue / (cost / 1000000) : 0;
|
|
|
|
if (roas >= CONFIG.HIGH_ROAS_THRESHOLD) {
|
|
performanceMultiplier *= CONFIG.HIGH_PERFORMER_INCREASE;
|
|
reason.push(`High ROAS (${roas.toFixed(2)})`);
|
|
} else if (roas < CONFIG.LOW_ROAS_THRESHOLD) {
|
|
performanceMultiplier *= CONFIG.LOW_PERFORMER_DECREASE;
|
|
reason.push(`Low ROAS (${roas.toFixed(2)})`);
|
|
}
|
|
} else if (CONFIG.STRATEGY === 'CPA') {
|
|
const cpa = conversions > 0 ? (cost / 1000000) / conversions : Infinity;
|
|
|
|
if (cpa <= CONFIG.TARGET_CPA * 0.8) {
|
|
performanceMultiplier *= CONFIG.HIGH_PERFORMER_INCREASE;
|
|
reason.push(`Low CPA ($${cpa.toFixed(2)})`);
|
|
} else if (cpa > CONFIG.TARGET_CPA * 1.2) {
|
|
performanceMultiplier *= CONFIG.LOW_PERFORMER_DECREASE;
|
|
reason.push(`High CPA ($${cpa.toFixed(2)})`);
|
|
}
|
|
}
|
|
|
|
// Quality score adjustment
|
|
if (qualityScore !== null) {
|
|
const qsDeviation = qualityScore - CONFIG.TARGET_QUALITY_SCORE;
|
|
const qsMultiplier = 1 + (qsDeviation * CONFIG.QUALITY_SCORE_FACTOR);
|
|
performanceMultiplier *= qsMultiplier;
|
|
reason.push(`QS ${qualityScore}`);
|
|
|
|
// Pause very low quality keywords
|
|
if (qualityScore < 3) {
|
|
return {
|
|
action: 'PAUSE',
|
|
reason: `Very low quality score (${qualityScore})`
|
|
};
|
|
}
|
|
}
|
|
|
|
// Calculate new bid
|
|
const proposedBid = Math.floor(currentBid * performanceMultiplier);
|
|
|
|
// Apply safety limits
|
|
const safeBid = applySafetyLimits(currentBid, proposedBid);
|
|
|
|
// No change needed
|
|
if (safeBid === currentBid) {
|
|
return { action: 'NONE', reason: 'No change needed' };
|
|
}
|
|
|
|
return {
|
|
action: 'UPDATE_BID',
|
|
newBid: safeBid,
|
|
reason: reason.join(', ')
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// BID CALCULATION & SAFETY
|
|
// ============================================================================
|
|
|
|
function applySafetyLimits(currentBid, proposedBid) {
|
|
// Enforce minimum bid
|
|
if (proposedBid < CONFIG.MIN_BID_MICROS) {
|
|
Logger.log(`Proposed bid ${proposedBid} below minimum, using ${CONFIG.MIN_BID_MICROS}`);
|
|
return CONFIG.MIN_BID_MICROS;
|
|
}
|
|
|
|
// Enforce maximum bid
|
|
if (proposedBid > CONFIG.MAX_BID_MICROS) {
|
|
Logger.log(`Proposed bid ${proposedBid} above maximum, using ${CONFIG.MAX_BID_MICROS}`);
|
|
return CONFIG.MAX_BID_MICROS;
|
|
}
|
|
|
|
// Enforce maximum change percentage
|
|
const changePercent = Math.abs((proposedBid - currentBid) / currentBid * 100);
|
|
if (changePercent > CONFIG.MAX_BID_CHANGE_PERCENT) {
|
|
const maxChange = currentBid * (CONFIG.MAX_BID_CHANGE_PERCENT / 100);
|
|
const cappedBid = proposedBid > currentBid
|
|
? Math.floor(currentBid + maxChange)
|
|
: Math.floor(currentBid - maxChange);
|
|
Logger.log(`Change ${changePercent.toFixed(1)}% exceeds max ${CONFIG.MAX_BID_CHANGE_PERCENT}%, capping to ${cappedBid}`);
|
|
return cappedBid;
|
|
}
|
|
|
|
return proposedBid;
|
|
}
|
|
|
|
// ============================================================================
|
|
// REPORTING
|
|
// ============================================================================
|
|
|
|
function initializeReportingSheet() {
|
|
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
|
|
let sheet = ss.getSheetByName(CONFIG.LOG_SHEET_NAME);
|
|
|
|
if (!sheet) {
|
|
sheet = ss.insertSheet(CONFIG.LOG_SHEET_NAME);
|
|
sheet.appendRow(['Timestamp', 'Mode', 'Action', 'Keyword', 'Campaign', 'Old Bid', 'New Bid', 'Change', 'Reason']);
|
|
sheet.getRange('A1:I1').setFontWeight('bold');
|
|
}
|
|
|
|
return sheet;
|
|
}
|
|
|
|
function logResults(sheet, results) {
|
|
const timestamp = new Date();
|
|
const mode = CONFIG.DRY_RUN ? 'DRY RUN' : 'LIVE';
|
|
|
|
// Log bid increases
|
|
results.increased.forEach(item => {
|
|
sheet.appendRow([
|
|
timestamp, mode, 'BID_INCREASED', item.text, item.campaign,
|
|
item.oldBid, item.newBid, item.change, item.reason
|
|
]);
|
|
});
|
|
|
|
// Log bid decreases
|
|
results.decreased.forEach(item => {
|
|
sheet.appendRow([
|
|
timestamp, mode, 'BID_DECREASED', item.text, item.campaign,
|
|
item.oldBid, item.newBid, item.change, item.reason
|
|
]);
|
|
});
|
|
|
|
// Log paused keywords
|
|
results.paused.forEach(item => {
|
|
sheet.appendRow([
|
|
timestamp, mode, 'PAUSED', item.text, item.campaign,
|
|
item.currentBid, 0, '-100%', item.reason
|
|
]);
|
|
});
|
|
|
|
// Log errors
|
|
results.errors.forEach(item => {
|
|
sheet.appendRow([
|
|
timestamp, mode, 'ERROR', item.keyword, '', '', '', '', item.error
|
|
]);
|
|
});
|
|
|
|
Logger.log(`Logged ${results.increased.length + results.decreased.length + results.paused.length + results.errors.length} actions to sheet`);
|
|
}
|
|
|
|
function sendEmailNotification(results) {
|
|
if (results.increased.length === 0 && results.decreased.length === 0 && results.paused.length === 0) {
|
|
Logger.log('No bid changes, skipping email notification');
|
|
return;
|
|
}
|
|
|
|
const subject = `Bid Manager Report - ${CONFIG.STRATEGY} - ${CONFIG.DRY_RUN ? 'DRY RUN' : 'LIVE'} - ${new Date().toDateString()}`;
|
|
|
|
const body = `
|
|
Bid Manager Results
|
|
===================
|
|
|
|
Mode: ${CONFIG.DRY_RUN ? 'DRY RUN (preview only)' : 'LIVE'}
|
|
Strategy: ${CONFIG.STRATEGY}
|
|
${CONFIG.STRATEGY === 'ROAS' ? `Target ROAS: ${CONFIG.TARGET_ROAS}` : `Target CPA: $${CONFIG.TARGET_CPA}`}
|
|
Date: ${new Date()}
|
|
Keywords Evaluated: ${results.evaluated}
|
|
|
|
Actions Taken:
|
|
--------------
|
|
Bids Increased: ${results.increased.length}
|
|
Bids Decreased: ${results.decreased.length}
|
|
Keywords Paused: ${results.paused.length}
|
|
No Change: ${results.noChange.length}
|
|
Errors: ${results.errors.length}
|
|
|
|
Top Bid Increases:
|
|
${results.increased.slice(0, 10).map(item =>
|
|
`- ${item.text} (${item.campaign}): $${item.oldBid.toFixed(2)} → $${item.newBid.toFixed(2)} (${item.change}) - ${item.reason}`
|
|
).join('\n')}
|
|
|
|
Top Bid Decreases:
|
|
${results.decreased.slice(0, 10).map(item =>
|
|
`- ${item.text} (${item.campaign}): $${item.oldBid.toFixed(2)} → $${item.newBid.toFixed(2)} (${item.change}) - ${item.reason}`
|
|
).join('\n')}
|
|
|
|
Paused Keywords:
|
|
${results.paused.map(item => `- ${item.text} (${item.campaign}): ${item.reason}`).join('\n')}
|
|
|
|
${results.errors.length > 0 ? `\nErrors:\n${results.errors.map(item => `- ${item.keyword}: ${item.error}`).join('\n')}` : ''}
|
|
|
|
---
|
|
Generated by Google Ads Script
|
|
`;
|
|
|
|
MailApp.sendEmail(CONFIG.NOTIFICATION_EMAIL, subject, body);
|
|
Logger.log('Email notification sent to ' + CONFIG.NOTIFICATION_EMAIL);
|
|
}
|
|
|
|
// ============================================================================
|
|
// ERROR HANDLING
|
|
// ============================================================================
|
|
|
|
function handleError(error) {
|
|
Logger.log('FATAL ERROR: ' + error.message);
|
|
Logger.log('Stack trace: ' + error.stack);
|
|
|
|
MailApp.sendEmail(
|
|
CONFIG.NOTIFICATION_EMAIL,
|
|
'Bid Manager Error',
|
|
`An error occurred in the Bid Manager script:\n\n${error.message}\n\nStack trace:\n${error.stack}`
|
|
);
|
|
|
|
throw error;
|
|
}
|