Initial commit
This commit is contained in:
391
skills/google-ads-scripts/assets/bid-manager-template.js
Normal file
391
skills/google-ads-scripts/assets/bid-manager-template.js
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user