@weppa-cloud/mcp-google-ads
Version:
Google Ads MCP server for growth marketing - campaign optimization, keyword research, and ROI tracking
588 lines • 24.4 kB
JavaScript
import { z } from 'zod';
const growthPulseSchema = z.object({
compareWithPrevious: z.boolean().default(true).describe('Comparar con período anterior'),
includeHourlyData: z.boolean().default(false).describe('Incluir análisis por hora del día'),
});
const conversionPathSchema = z.object({
lookbackDays: z.number().min(1).max(90).default(30).describe('Días de análisis'),
conversionAction: z.string().optional().describe('Acción de conversión específica'),
});
const roiAnalysisSchema = z.object({
groupBy: z.enum(['campaign', 'adgroup', 'keyword']).default('campaign'),
minSpend: z.number().default(100).describe('Gasto mínimo para incluir en análisis'),
timeframe: z.enum(['7days', '30days', '90days']).default('30days'),
});
export const performanceTools = [
{
name: 'growth_pulse',
description: '🚀 Dashboard ejecutivo con KPIs principales y alertas de crecimiento',
inputSchema: growthPulseSchema,
handler: async (args, auth) => {
const validated = growthPulseSchema.parse(args);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
// Período actual: últimos 7 días
const currentEnd = yesterday.toISOString().split('T')[0];
const currentStart = new Date(yesterday);
currentStart.setDate(currentStart.getDate() - 6);
// Período anterior: 7 días antes
const previousEnd = new Date(currentStart);
previousEnd.setDate(previousEnd.getDate() - 1);
const previousStart = new Date(previousEnd);
previousStart.setDate(previousStart.getDate() - 6);
// Query principal
const currentQuery = `
SELECT
metrics.cost_micros,
metrics.clicks,
metrics.impressions,
metrics.conversions,
metrics.conversions_value,
metrics.average_cpc,
metrics.cost_per_conversion,
metrics.conversion_rate,
segments.date,
segments.hour
FROM customer
WHERE segments.date BETWEEN '${currentStart.toISOString().split('T')[0]}'
AND '${currentEnd}'
`;
const currentData = await auth.query(currentQuery);
let previousData = [];
if (validated.compareWithPrevious) {
const previousQuery = `
SELECT
metrics.cost_micros,
metrics.clicks,
metrics.impressions,
metrics.conversions,
metrics.conversions_value
FROM customer
WHERE segments.date BETWEEN '${previousStart.toISOString().split('T')[0]}'
AND '${previousEnd.toISOString().split('T')[0]}'
`;
previousData = await auth.query(previousQuery);
}
// Calcular métricas actuales
const currentMetrics = calculatePeriodMetrics(currentData);
const previousMetrics = calculatePeriodMetrics(previousData);
// Calcular cambios
const changes = calculateChanges(currentMetrics, previousMetrics);
// Análisis por hora si se solicita
let hourlyInsights = null;
if (validated.includeHourlyData) {
hourlyInsights = analyzeHourlyPerformance(currentData);
}
// Generar alertas
const alerts = generatePerformanceAlerts(currentMetrics, changes);
// Detectar tendencias
const trends = detectTrends(currentData);
return {
period: {
current: `${currentStart.toISOString().split('T')[0]} to ${currentEnd}`,
previous: validated.compareWithPrevious
? `${previousStart.toISOString().split('T')[0]} to ${previousEnd.toISOString().split('T')[0]}`
: null,
},
metrics: {
current: currentMetrics,
changes: validated.compareWithPrevious ? changes : null,
},
alerts,
trends,
hourlyInsights,
quickActions: generateQuickActions(currentMetrics, alerts),
executiveSummary: generateExecutiveSummary(currentMetrics, changes, alerts),
};
},
},
{
name: 'conversion_path_analysis',
description: '🛤️ Analiza el customer journey y puntos de conversión',
inputSchema: conversionPathSchema,
handler: async (args, auth) => {
const validated = conversionPathSchema.parse(args);
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - validated.lookbackDays);
// Query para obtener datos de atribución
const query = `
SELECT
campaign.name,
ad_group.name,
segments.conversion_action_name,
metrics.conversions,
metrics.conversions_value,
metrics.view_through_conversions,
segments.conversion_lag_bucket,
segments.days_to_conversion
FROM ad_group
WHERE segments.date BETWEEN '${startDate.toISOString().split('T')[0]}'
AND '${endDate.toISOString().split('T')[0]}'
AND metrics.conversions > 0
${validated.conversionAction ? `AND segments.conversion_action_name = '${validated.conversionAction}'` : ''}
`;
const data = await auth.query(query);
// Analizar tiempo hasta conversión
const conversionLagAnalysis = analyzeConversionLag(data);
// Analizar path por campaña/ad group
const pathAnalysis = analyzeConversionPaths(data);
// Calcular atribución
const attributionAnalysis = calculateAttribution(data);
return {
summary: {
totalConversions: data.reduce((sum, row) => sum + (row.metrics.conversions || 0), 0),
avgDaysToConversion: calculateAvgDaysToConversion(data),
totalValue: data.reduce((sum, row) => sum + (row.metrics.conversions_value || 0), 0),
},
conversionLag: conversionLagAnalysis,
topPaths: pathAnalysis.slice(0, 10),
attribution: attributionAnalysis,
insights: generatePathInsights(conversionLagAnalysis, pathAnalysis),
recommendations: generatePathRecommendations(conversionLagAnalysis, pathAnalysis),
};
},
},
{
name: 'roi_deep_dive',
description: '💰 Análisis profundo de ROI y rentabilidad por segmento',
inputSchema: roiAnalysisSchema,
handler: async (args, auth) => {
const validated = roiAnalysisSchema.parse(args);
const { startDate, endDate } = getDateRange(validated.timeframe);
let groupByField = '';
let selectFields = '';
switch (validated.groupBy) {
case 'campaign':
groupByField = 'campaign';
selectFields = 'campaign.id, campaign.name';
break;
case 'adgroup':
groupByField = 'ad_group';
selectFields = 'ad_group.id, ad_group.name, campaign.name';
break;
case 'keyword':
groupByField = 'keyword_view';
selectFields = 'ad_group_criterion.keyword.text, ad_group.name, campaign.name';
break;
}
const query = `
SELECT
${selectFields},
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
metrics.clicks,
metrics.impressions,
metrics.all_conversions,
metrics.all_conversions_value
FROM ${groupByField}
WHERE segments.date BETWEEN '${startDate}' AND '${endDate}'
AND metrics.cost_micros >= ${validated.minSpend * 1_000_000}
ORDER BY metrics.conversions_value DESC
LIMIT 100
`;
const data = await auth.query(query);
// Calcular métricas de ROI para cada elemento
const roiAnalysis = data.map((row) => {
const cost = row.metrics.cost_micros / 1_000_000;
const revenue = row.metrics.conversions_value || 0;
const allRevenue = row.metrics.all_conversions_value || 0;
const roas = cost > 0 ? revenue / cost : 0;
const allRoas = cost > 0 ? allRevenue / cost : 0;
const profit = revenue - cost;
const margin = revenue > 0 ? (profit / revenue) * 100 : 0;
return {
name: extractName(row, validated.groupBy),
parentName: extractParentName(row, validated.groupBy),
metrics: {
spend: cost,
revenue: revenue,
profit: profit,
roas: roas,
allConversionsRoas: allRoas,
margin: margin,
conversions: row.metrics.conversions || 0,
cpa: row.metrics.conversions > 0 ? cost / row.metrics.conversions : 0,
clicks: row.metrics.clicks || 0,
impressions: row.metrics.impressions || 0,
},
classification: classifyROI(roas, profit),
};
});
// Análisis de distribución
const distribution = analyzeROIDistribution(roiAnalysis);
// Identificar oportunidades
const opportunities = identifyROIOpportunities(roiAnalysis);
// Cálculo de Pareto (80/20)
const paretoAnalysis = calculatePareto(roiAnalysis);
return {
summary: {
totalElements: roiAnalysis.length,
totalSpend: roiAnalysis.reduce((sum, item) => sum + item.metrics.spend, 0),
totalRevenue: roiAnalysis.reduce((sum, item) => sum + item.metrics.revenue, 0),
totalProfit: roiAnalysis.reduce((sum, item) => sum + item.metrics.profit, 0),
overallRoas: calculateOverallRoas(roiAnalysis),
},
topPerformers: roiAnalysis
.filter(item => item.classification === 'STAR')
.slice(0, 10),
bottomPerformers: roiAnalysis
.filter(item => item.classification === 'LOSS_MAKER')
.slice(0, 10),
distribution,
paretoAnalysis,
opportunities,
actionPlan: generateROIActionPlan(distribution, opportunities),
};
},
},
];
function getDateRange(timeframe) {
const endDate = new Date();
const startDate = new Date();
switch (timeframe) {
case '7days':
startDate.setDate(startDate.getDate() - 7);
break;
case '30days':
startDate.setDate(startDate.getDate() - 30);
break;
case '90days':
startDate.setDate(startDate.getDate() - 90);
break;
}
return {
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};
}
function calculatePeriodMetrics(data) {
const totals = data.reduce((acc, row) => {
acc.cost += row.metrics.cost_micros / 1_000_000;
acc.clicks += row.metrics.clicks || 0;
acc.impressions += row.metrics.impressions || 0;
acc.conversions += row.metrics.conversions || 0;
acc.conversionsValue += row.metrics.conversions_value || 0;
return acc;
}, {
cost: 0,
clicks: 0,
impressions: 0,
conversions: 0,
conversionsValue: 0,
});
return {
...totals,
ctr: totals.impressions > 0 ? (totals.clicks / totals.impressions) * 100 : 0,
conversionRate: totals.clicks > 0 ? (totals.conversions / totals.clicks) * 100 : 0,
avgCpc: totals.clicks > 0 ? totals.cost / totals.clicks : 0,
cpa: totals.conversions > 0 ? totals.cost / totals.conversions : 0,
roas: totals.cost > 0 ? totals.conversionsValue / totals.cost : 0,
};
}
function calculateChanges(current, previous) {
if (!previous || previous.cost === 0)
return null;
return {
cost: ((current.cost - previous.cost) / previous.cost) * 100,
clicks: ((current.clicks - previous.clicks) / previous.clicks) * 100,
conversions: ((current.conversions - previous.conversions) / previous.conversions) * 100,
conversionsValue: ((current.conversionsValue - previous.conversionsValue) / previous.conversionsValue) * 100,
roas: ((current.roas - previous.roas) / previous.roas) * 100,
};
}
function generatePerformanceAlerts(metrics, changes) {
const alerts = [];
if (metrics.roas < 1.5) {
alerts.push({
type: 'CRITICAL',
message: 'ROAS por debajo del umbral rentable (< 1.5)',
metric: 'ROAS',
value: metrics.roas.toFixed(2),
});
}
if (changes && changes.conversions < -20) {
alerts.push({
type: 'WARNING',
message: 'Caída significativa en conversiones',
metric: 'Conversions',
change: `${changes.conversions.toFixed(1)}%`,
});
}
if (metrics.cpa > 50 && changes && changes.cpa > 20) {
alerts.push({
type: 'WARNING',
message: 'CPA aumentando rápidamente',
metric: 'CPA',
value: `$${metrics.cpa.toFixed(2)}`,
change: `+${changes.cpa.toFixed(1)}%`,
});
}
if (changes && changes.cost > 50 && changes.conversionsValue < 20) {
alerts.push({
type: 'CRITICAL',
message: 'Gasto aumentando sin incremento proporcional en revenue',
details: 'Revisa eficiencia de campañas inmediatamente',
});
}
return alerts;
}
function detectTrends(data) {
const trends = [];
// Ordenar por fecha
const sortedData = [...data].sort((a, b) => new Date(a.segments.date).getTime() - new Date(b.segments.date).getTime());
// Calcular tendencia de conversiones
if (sortedData.length >= 3) {
const recentDays = sortedData.slice(-3);
const avgRecentConversions = recentDays.reduce((sum, d) => sum + (d.metrics.conversions || 0), 0) / 3;
const previousDays = sortedData.slice(-6, -3);
const avgPreviousConversions = previousDays.reduce((sum, d) => sum + (d.metrics.conversions || 0), 0) / 3;
if (avgRecentConversions > avgPreviousConversions * 1.2) {
trends.push({
type: 'POSITIVE',
metric: 'Conversions',
trend: 'ASCENDING',
description: 'Tendencia alcista en conversiones últimos 3 días',
});
}
else if (avgRecentConversions < avgPreviousConversions * 0.8) {
trends.push({
type: 'NEGATIVE',
metric: 'Conversions',
trend: 'DESCENDING',
description: 'Tendencia bajista en conversiones - requiere atención',
});
}
}
return trends;
}
function analyzeHourlyPerformance(data) {
const hourlyData = {};
data.forEach(row => {
const hour = row.segments.hour || 0;
if (!hourlyData[hour]) {
hourlyData[hour] = {
conversions: 0,
cost: 0,
clicks: 0,
};
}
hourlyData[hour].conversions += row.metrics.conversions || 0;
hourlyData[hour].cost += row.metrics.cost_micros / 1_000_000;
hourlyData[hour].clicks += row.metrics.clicks || 0;
});
// Encontrar mejores y peores horas
const hours = Object.entries(hourlyData).map(([hour, data]) => ({
hour: parseInt(hour),
...data,
cpa: data.conversions > 0 ? data.cost / data.conversions : 999,
}));
const sortedByCPA = [...hours].sort((a, b) => a.cpa - b.cpa);
return {
bestHours: sortedByCPA.slice(0, 3),
worstHours: sortedByCPA.slice(-3),
recommendation: 'Considera ajustar pujas por hora del día basándote en estos datos',
};
}
function generateQuickActions(metrics, alerts) {
const actions = [];
if (metrics.roas < 2) {
actions.push('🎯 Pausar keywords/campañas con ROAS < 1');
}
if (alerts.some(a => a.type === 'CRITICAL')) {
actions.push('🚨 Revisar y optimizar campañas críticas inmediatamente');
}
if (metrics.cpa > 50) {
actions.push('💰 Implementar bid adjustments para reducir CPA');
}
actions.push('📊 Revisar Search Terms Report para nuevos negativos');
return actions;
}
function generateExecutiveSummary(metrics, changes, alerts) {
const criticalAlerts = alerts.filter(a => a.type === 'CRITICAL').length;
const trend = changes && changes.conversionsValue > 0 ? 'positiva' : 'negativa';
return `Performance últimos 7 días: $${metrics.cost.toFixed(0)} invertidos, ` +
`${metrics.conversions} conversiones, ROAS ${metrics.roas.toFixed(2)}x. ` +
`Tendencia ${trend} vs semana anterior. ` +
`${criticalAlerts > 0 ? `⚠️ ${criticalAlerts} alertas críticas requieren atención.` : '✅ Sin alertas críticas.'}`;
}
function analyzeConversionLag(data) {
const lagBuckets = {};
data.forEach(row => {
const bucket = row.segments.conversion_lag_bucket || 'UNKNOWN';
lagBuckets[bucket] = (lagBuckets[bucket] || 0) + (row.metrics.conversions || 0);
});
return Object.entries(lagBuckets)
.map(([bucket, conversions]) => ({ bucket, conversions }))
.sort((a, b) => b.conversions - a.conversions);
}
function analyzeConversionPaths(data) {
const paths = {};
data.forEach(row => {
const campaign = row.campaign?.name || 'Unknown';
const adGroup = row.ad_group?.name || 'Unknown';
const path = `${campaign} → ${adGroup}`;
if (!paths[path]) {
paths[path] = {
path,
conversions: 0,
value: 0,
};
}
paths[path].conversions += row.metrics.conversions || 0;
paths[path].value += row.metrics.conversions_value || 0;
});
return Object.values(paths)
.sort((a, b) => b.value - a.value);
}
function calculateAttribution(data) {
const attribution = {};
data.forEach(row => {
const campaign = row.campaign?.name || 'Unknown';
if (!attribution[campaign]) {
attribution[campaign] = {
campaign,
directConversions: 0,
assistedConversions: 0,
totalValue: 0,
};
}
attribution[campaign].directConversions += row.metrics.conversions || 0;
attribution[campaign].assistedConversions += row.metrics.view_through_conversions || 0;
attribution[campaign].totalValue += row.metrics.conversions_value || 0;
});
return Object.values(attribution)
.sort((a, b) => b.totalValue - a.totalValue);
}
function calculateAvgDaysToConversion(data) {
let totalDays = 0;
let totalConversions = 0;
data.forEach(row => {
const days = row.segments.days_to_conversion || 0;
const conversions = row.metrics.conversions || 0;
totalDays += days * conversions;
totalConversions += conversions;
});
return totalConversions > 0 ? totalDays / totalConversions : 0;
}
function generatePathInsights(lagAnalysis, pathAnalysis) {
const insights = [];
const quickConversions = lagAnalysis.find(l => l.bucket === '0-1_DAYS');
if (quickConversions && quickConversions.conversions > 0) {
insights.push('⚡ Mayoría de conversiones ocurren en 24h - optimiza para decisiones rápidas');
}
if (pathAnalysis.length > 0 && pathAnalysis[0].conversions > 100) {
insights.push(`🏆 Top path: ${pathAnalysis[0].path} genera más conversiones`);
}
return insights;
}
function generatePathRecommendations(lagAnalysis, pathAnalysis) {
const recommendations = [];
recommendations.push('🎯 Aumenta presupuesto en los top 3 paths de conversión');
recommendations.push('📧 Implementa remarketing para conversiones > 7 días');
recommendations.push('🔄 Crea campañas específicas para cada etapa del funnel');
return recommendations;
}
function extractName(row, groupBy) {
switch (groupBy) {
case 'campaign':
return row.campaign?.name || 'Unknown';
case 'adgroup':
return row.ad_group?.name || 'Unknown';
case 'keyword':
return row.ad_group_criterion?.keyword?.text || 'Unknown';
default:
return 'Unknown';
}
}
function extractParentName(row, groupBy) {
switch (groupBy) {
case 'adgroup':
case 'keyword':
return row.campaign?.name || 'Unknown';
default:
return '';
}
}
function classifyROI(roas, profit) {
if (roas >= 4 && profit > 100)
return 'STAR';
if (roas >= 2 && profit > 0)
return 'PROFITABLE';
if (roas >= 1 && profit >= 0)
return 'BREAK_EVEN';
if (roas < 1 || profit < 0)
return 'LOSS_MAKER';
return 'UNKNOWN';
}
function analyzeROIDistribution(data) {
const distribution = {
STAR: 0,
PROFITABLE: 0,
BREAK_EVEN: 0,
LOSS_MAKER: 0,
};
data.forEach(item => {
distribution[item.classification]++;
});
return distribution;
}
function identifyROIOpportunities(data) {
return {
scaleUp: data
.filter(item => item.classification === 'STAR' && item.metrics.spend < 1000)
.slice(0, 5),
optimize: data
.filter(item => item.metrics.roas > 1 && item.metrics.roas < 2)
.slice(0, 5),
pauseOrFix: data
.filter(item => item.classification === 'LOSS_MAKER' && item.metrics.spend > 100)
.slice(0, 5),
};
}
function calculatePareto(data) {
const sorted = [...data].sort((a, b) => b.metrics.revenue - a.metrics.revenue);
const totalRevenue = sorted.reduce((sum, item) => sum + item.metrics.revenue, 0);
let cumulativeRevenue = 0;
let count80Percent = 0;
for (const item of sorted) {
cumulativeRevenue += item.metrics.revenue;
count80Percent++;
if (cumulativeRevenue >= totalRevenue * 0.8) {
break;
}
}
const percentage = (count80Percent / sorted.length) * 100;
return {
result: `${percentage.toFixed(1)}% de elementos generan 80% del revenue`,
topPerformers: sorted.slice(0, count80Percent),
insight: percentage < 20
? '✅ Excelente concentración - enfócate en estos top performers'
: '⚠️ Revenue muy distribuido - considera consolidar esfuerzos',
};
}
function calculateOverallRoas(data) {
const totals = data.reduce((acc, item) => {
acc.spend += item.metrics.spend;
acc.revenue += item.metrics.revenue;
return acc;
}, { spend: 0, revenue: 0 });
return totals.spend > 0 ? totals.revenue / totals.spend : 0;
}
function generateROIActionPlan(distribution, opportunities) {
const plan = [];
if (opportunities.scaleUp.length > 0) {
plan.push(`🚀 Aumenta presupuesto en ${opportunities.scaleUp.length} elementos estrella`);
}
if (opportunities.pauseOrFix.length > 0) {
plan.push(`🛑 Pausa o optimiza ${opportunities.pauseOrFix.length} elementos no rentables`);
}
if (distribution.LOSS_MAKER > distribution.STAR) {
plan.push('⚠️ Más elementos perdiendo que ganando - revisar estrategia general');
}
plan.push('📊 Implementa pruebas A/B en elementos "BREAK_EVEN" para mejorar ROI');
return plan;
}
//# sourceMappingURL=performance.js.map