UNPKG

@weppa-cloud/mcp-google-ads

Version:

Google Ads MCP server for growth marketing - campaign optimization, keyword research, and ROI tracking

375 lines 16.6 kB
import { z } from 'zod'; const keywordResearchSchema = z.object({ seedKeywords: z.array(z.string()).min(1).max(10).describe('Keywords semilla para generar ideas'), language: z.string().default('es').describe('Código de idioma (es, en, etc)'), location: z.string().default('2724').describe('Código de ubicación (2724 = España)'), }); const keywordPerformanceSchema = z.object({ campaignId: z.string().optional().describe('ID de campaña específica (opcional)'), minImpressions: z.number().default(100).describe('Mínimo de impresiones'), timeframe: z.enum(['7days', '30days', '90days']).default('30days'), orderBy: z.enum(['cost', 'conversions', 'ctr', 'impressions']).default('cost'), }); const negativeKeywordSuggestionsSchema = z.object({ campaignId: z.string().describe('ID de la campaña a analizar'), conversionThreshold: z.number().default(0).describe('Máximo de conversiones para sugerir como negativo'), costThreshold: z.number().default(50).describe('Mínimo de costo sin conversiones'), }); export const keywordTools = [ { name: 'keyword_performance', description: '🔍 Analiza el rendimiento de tus keywords y encuentra oportunidades', inputSchema: keywordPerformanceSchema, handler: async (args, auth) => { const validated = keywordPerformanceSchema.parse(args); const { startDate, endDate } = getDateRange(validated.timeframe); let campaignFilter = ''; if (validated.campaignId) { campaignFilter = `AND campaign.id = ${validated.campaignId}`; } const query = ` SELECT ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group.id, ad_group.name, campaign.id, campaign.name, metrics.cost_micros, metrics.clicks, metrics.impressions, metrics.conversions, metrics.conversions_value, metrics.average_cpc, metrics.search_impression_share, ad_group_criterion.quality_score.quality_score FROM keyword_view WHERE segments.date BETWEEN '${startDate}' AND '${endDate}' AND ad_group_criterion.status = 'ENABLED' AND metrics.impressions >= ${validated.minImpressions} ${campaignFilter} ORDER BY metrics.${validated.orderBy === 'cost' ? 'cost_micros' : validated.orderBy} DESC LIMIT 50 `; const response = await auth.query(query); const keywords = response.map((row) => { const cost = row.metrics.cost_micros / 1_000_000; const conversionsValue = row.metrics.conversions_value || 0; const roas = cost > 0 ? conversionsValue / cost : 0; const ctr = row.metrics.impressions > 0 ? (row.metrics.clicks / row.metrics.impressions) * 100 : 0; const conversionRate = row.metrics.clicks > 0 ? (row.metrics.conversions / row.metrics.clicks) * 100 : 0; return { keyword: row.ad_group_criterion.keyword.text, matchType: row.ad_group_criterion.keyword.match_type, campaign: row.campaign.name, adGroup: row.ad_group.name, qualityScore: row.ad_group_criterion.quality_score?.quality_score || 'N/A', metrics: { cost: cost, clicks: row.metrics.clicks || 0, impressions: row.metrics.impressions || 0, conversions: row.metrics.conversions || 0, ctr: ctr, conversionRate: conversionRate, avgCpc: row.metrics.average_cpc ? row.metrics.average_cpc / 1_000_000 : 0, roas: roas, impressionShare: row.metrics.search_impression_share || 0, }, insights: generateKeywordInsights(row, ctr, conversionRate, roas), }; }); // Agrupar insights const opportunities = { lowQualityScore: keywords.filter(k => typeof k.qualityScore === 'number' && k.qualityScore < 5), highCostNoConversions: keywords.filter(k => k.metrics.cost > 20 && k.metrics.conversions === 0), highPerformers: keywords.filter(k => k.metrics.roas > 3 && k.metrics.conversions > 0), }; return { summary: { totalKeywords: keywords.length, avgQualityScore: calculateAvgQualityScore(keywords), totalCost: keywords.reduce((sum, k) => sum + k.metrics.cost, 0), totalConversions: keywords.reduce((sum, k) => sum + k.metrics.conversions, 0), }, topKeywords: keywords.slice(0, 20), opportunities: { toImprove: opportunities.lowQualityScore.slice(0, 5), toNegative: opportunities.highCostNoConversions.slice(0, 5), toExpand: opportunities.highPerformers.slice(0, 5), }, recommendations: generateKeywordRecommendations(opportunities), }; }, }, { name: 'negative_keyword_suggestions', description: '🚫 Encuentra keywords que deberías añadir como negativos para ahorrar dinero', inputSchema: negativeKeywordSuggestionsSchema, handler: async (args, auth) => { const validated = negativeKeywordSuggestionsSchema.parse(args); const { startDate, endDate } = getDateRange('30days'); // Buscar search terms con bajo rendimiento const query = ` SELECT search_term_view.search_term, campaign.id, campaign.name, metrics.cost_micros, metrics.clicks, metrics.impressions, metrics.conversions, metrics.conversions_value FROM search_term_view WHERE segments.date BETWEEN '${startDate}' AND '${endDate}' AND campaign.id = ${validated.campaignId} AND metrics.conversions <= ${validated.conversionThreshold} AND metrics.cost_micros >= ${validated.costThreshold * 1_000_000} ORDER BY metrics.cost_micros DESC LIMIT 100 `; const response = await auth.query(query); const negativeKeywordSuggestions = response.map((row) => { const cost = row.metrics.cost_micros / 1_000_000; const ctr = row.metrics.impressions > 0 ? (row.metrics.clicks / row.metrics.impressions) * 100 : 0; return { searchTerm: row.search_term_view.search_term, reason: determineNegativeReason(row), metrics: { cost: cost, clicks: row.metrics.clicks || 0, impressions: row.metrics.impressions || 0, conversions: row.metrics.conversions || 0, ctr: ctr, }, potentialSavings: cost, matchType: suggestNegativeMatchType(row.search_term_view.search_term), }; }); // Agrupar por razón const byReason = negativeKeywordSuggestions.reduce((acc, suggestion) => { if (!acc[suggestion.reason]) { acc[suggestion.reason] = []; } acc[suggestion.reason].push(suggestion); return acc; }, {}); const totalPotentialSavings = negativeKeywordSuggestions.reduce((sum, s) => sum + s.potentialSavings, 0); return { summary: { totalSuggestions: negativeKeywordSuggestions.length, totalPotentialSavings: totalPotentialSavings, campaignName: response[0]?.campaign?.name || 'Unknown', }, suggestions: negativeKeywordSuggestions.slice(0, 30), byReason: Object.entries(byReason).map(([reason, terms]) => ({ reason, count: terms.length, examples: terms.slice(0, 5), })), implementation: { quickWin: `Añade estos ${Math.min(10, negativeKeywordSuggestions.length)} términos como negativos para ahorrar $${totalPotentialSavings.toFixed(2)}/mes`, priority: totalPotentialSavings > 100 ? 'HIGH' : 'MEDIUM', }, }; }, }, { name: 'keyword_opportunities', description: '💎 Descubre nuevas keywords rentables basadas en tus mejores performers', inputSchema: z.object({ minConversions: z.number().default(5).describe('Mínimo de conversiones para considerar'), minRoas: z.number().default(2).describe('ROAS mínimo para keywords semilla'), }), handler: async (args, auth) => { const validated = args; const { startDate, endDate } = getDateRange('30days'); // Primero, encontrar las mejores keywords actuales const query = ` SELECT ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, metrics.conversions, metrics.conversions_value, metrics.cost_micros, metrics.impressions FROM keyword_view WHERE segments.date BETWEEN '${startDate}' AND '${endDate}' AND ad_group_criterion.status = 'ENABLED' AND metrics.conversions >= ${validated.minConversions} AND metrics.conversions_value > metrics.cost_micros * ${validated.minRoas} ORDER BY metrics.conversions_value DESC LIMIT 20 `; const response = await auth.query(query); const topKeywords = response.map((row) => { const cost = row.metrics.cost_micros / 1_000_000; const value = row.metrics.conversions_value || 0; return { keyword: row.ad_group_criterion.keyword.text, matchType: row.ad_group_criterion.keyword.match_type, conversions: row.metrics.conversions, roas: cost > 0 ? value / cost : 0, }; }); // Generar variaciones y sugerencias const opportunities = []; for (const kw of topKeywords) { // Sugerir diferentes match types if (kw.matchType === 'BROAD') { opportunities.push({ suggestion: `"${kw.keyword}"`, type: 'PHRASE_MATCH', basedOn: kw.keyword, reason: 'Keyword broad con buen rendimiento - prueba phrase match', expectedImpact: 'Mayor control y CTR', }); } // Sugerir variaciones const variations = generateKeywordVariations(kw.keyword); variations.forEach(v => { opportunities.push({ suggestion: v, type: 'VARIATION', basedOn: kw.keyword, reason: 'Variación de keyword exitosa', expectedImpact: 'Capturar búsquedas similares', }); }); } return { summary: { topPerformersAnalyzed: topKeywords.length, newOpportunities: opportunities.length, }, topPerformers: topKeywords.slice(0, 10), opportunities: opportunities.slice(0, 20), implementation: { step1: 'Añade estas keywords en grupos de anuncios separados', step2: 'Comienza con pujas conservadoras (80% del CPC promedio)', step3: 'Monitorea durante 2 semanas y ajusta', }, }; }, }, ]; 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 generateKeywordInsights(keyword, ctr, conversionRate, roas) { const insights = []; if (keyword.ad_group_criterion.quality_score?.quality_score < 5) { insights.push('⚠️ Quality Score bajo - mejora relevancia del anuncio'); } if (ctr < 2) { insights.push('📉 CTR bajo - revisa copy del anuncio'); } if (conversionRate === 0 && keyword.metrics.clicks > 20) { insights.push('🚫 Sin conversiones - revisa landing page'); } if (roas > 4) { insights.push('🌟 Alto ROAS - considera aumentar pujas'); } return insights; } function calculateAvgQualityScore(keywords) { const validScores = keywords .map(k => k.qualityScore) .filter(score => typeof score === 'number'); if (validScores.length === 0) return 0; return validScores.reduce((sum, score) => sum + score, 0) / validScores.length; } function generateKeywordRecommendations(opportunities) { const recommendations = []; if (opportunities.lowQualityScore.length > 0) { recommendations.push(`🔧 ${opportunities.lowQualityScore.length} keywords con QS bajo - optimiza anuncios y landing pages`); } if (opportunities.highCostNoConversions.length > 0) { recommendations.push(`💰 ${opportunities.highCostNoConversions.length} keywords gastando sin convertir - añade como negativos`); } if (opportunities.highPerformers.length > 0) { recommendations.push(`🚀 ${opportunities.highPerformers.length} keywords estrella - aumenta presupuesto y crea variaciones`); } return recommendations; } function determineNegativeReason(searchTerm) { const cost = searchTerm.metrics.cost_micros / 1_000_000; if (searchTerm.metrics.conversions === 0 && cost > 100) { return 'Alto costo sin conversiones'; } if (searchTerm.metrics.clicks > 50 && searchTerm.metrics.conversions === 0) { return 'Muchos clicks sin conversiones'; } const ctr = searchTerm.metrics.impressions > 0 ? (searchTerm.metrics.clicks / searchTerm.metrics.impressions) * 100 : 0; if (ctr < 0.5) { return 'CTR extremadamente bajo'; } return 'Bajo rendimiento general'; } function suggestNegativeMatchType(searchTerm) { // Si tiene más de 3 palabras, usar exact match if (searchTerm.split(' ').length > 3) { return 'EXACT'; } // Si es una palabra genérica, usar broad if (searchTerm.split(' ').length === 1) { return 'BROAD'; } // Por defecto, phrase match return 'PHRASE'; } function generateKeywordVariations(keyword) { const variations = []; const words = keyword.toLowerCase().split(' '); // Variaciones con sinónimos comunes const synonyms = { 'comprar': ['adquirir', 'conseguir', 'obtener'], 'barato': ['económico', 'low cost', 'oferta'], 'mejor': ['top', 'premium', 'calidad'], 'online': ['internet', 'web', 'digital'], }; // Generar variaciones words.forEach((word, index) => { if (synonyms[word]) { synonyms[word].forEach(syn => { const newWords = [...words]; newWords[index] = syn; variations.push(newWords.join(' ')); }); } }); // Añadir modificadores comunes const modifiers = ['mejor', 'comprar', 'precio', 'oferta']; modifiers.forEach(mod => { if (!keyword.includes(mod)) { variations.push(`${mod} ${keyword}`); variations.push(`${keyword} ${mod}`); } }); return variations.slice(0, 5); } //# sourceMappingURL=keywords.js.map