Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:32:43 +08:00
commit e959028259
8 changed files with 2925 additions and 0 deletions

View 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;
}