UNPKG

@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
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