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