UNPKG

facebook-ads-mcp-server

Version:

Universal Facebook Ads Server - Supports MCP, OpenAI Function Calling, and Gemini Function Calling

449 lines (361 loc) 14.8 kB
import { FacebookAPIClient } from '../utils/facebook-api.js'; import { createErrorResponse } from '../utils/error-handler.js'; import { ValidationSchemas, validateParameters } from '../utils/validation.js'; export async function getAccountInsights(args) { try { // Validate input parameters const validatedArgs = validateParameters(ValidationSchemas.accountInsights, args); const { act_id, fields, level, ...otherParams } = validatedArgs; const client = new FacebookAPIClient(); // Step 1: Query Performance Data + Configuration // Automatically include 'conversions' when 'actions' is requested for better conversion tracking let enhancedFields = [...fields]; // Log to both stderr and return in response for debugging const debugInfo = { originalFields: fields, includesActions: fields.includes('actions'), includesConversions: fields.includes('conversions'), willEnhance: fields.includes('actions') && !fields.includes('conversions') }; if (fields.includes('actions') && !fields.includes('conversions')) { enhancedFields.push('conversions'); debugInfo.addedConversions = true; debugInfo.enhancedFields = enhancedFields; } // Use parameters as provided - user must explicitly specify time_increment for daily breakdown let finalParams = { ...otherParams }; const insightsParams = { fields: enhancedFields.join(','), level: level || 'account', // Default to account level ...finalParams, }; // Remove undefined/null values and format arrays Object.keys(insightsParams).forEach(key => { if (insightsParams[key] === undefined || insightsParams[key] === null) { delete insightsParams[key]; } else if (Array.isArray(insightsParams[key])) { insightsParams[key] = insightsParams[key].join(','); } }); // Get performance data const insightsData = await client.makeRequest(`/${act_id}/insights`, insightsParams); // Format response with universal event detection let responseText = ''; if (insightsData.data && insightsData.data.length > 0) { responseText += formatInsightsWithBreakdowns( insightsData.data, level || 'account', validatedArgs ); responseText += `\n\n**Debug Info:**\n\`\`\`json\n${JSON.stringify(debugInfo, null, 2)}\n\`\`\``; responseText += `\n\n**Raw API Response:**\n\`\`\`json\n${JSON.stringify(insightsData, null, 2)}\n\`\`\``; } else { responseText = `No insights data found.\n\n**Debug Info:**\n\`\`\`json\n${JSON.stringify(debugInfo, null, 2)}\n\`\`\``; responseText += `\n\n**Raw API Response:**\n\`\`\`json\n${JSON.stringify(insightsData, null, 2)}\n\`\`\``; } return { content: [ { type: 'text', text: responseText, }, ], }; } catch (error) { return createErrorResponse(error); } } function formatInsightsWithBreakdowns(insightsData, level, requestParams) { let responseText = ''; // Detect breakdown dimensions from the data const breakdownFields = detectBreakdownFields(insightsData); const hasBreakdowns = breakdownFields.length > 0; if (hasBreakdowns) { responseText += `📊 **Performance Data with Breakdowns:**\n\n`; responseText += `🔍 **Breakdown Dimensions:** ${breakdownFields.join(', ')}\n\n`; // Show breakdown data - prioritize time-based multi-dimensional display if (breakdownFields.includes('date_start')) { responseText += formatTimeBasedBreakdown(insightsData, breakdownFields); } else { // Any other single dimension breakdown responseText += formatGenericBreakdown(insightsData, breakdownFields); } } else { // No breakdowns - use original event detection logic responseText += formatSimpleInsights(insightsData); } return responseText; } function detectBreakdownFields(insightsData) { if (!insightsData || insightsData.length === 0) return []; const firstRow = insightsData[0]; const potentialBreakdownFields = [ 'date_start', 'date_stop', 'placement', 'age', 'gender', 'country', 'region', 'device_platform', 'publisher_platform', 'platform_position', 'impression_device', 'product_id', 'dma' ]; return potentialBreakdownFields.filter(field => firstRow.hasOwnProperty(field)); } function groupBy(array, key) { return array.reduce((groups, item) => { const value = item[key]; if (!groups[value]) groups[value] = []; groups[value].push(item); return groups; }, {}); } function formatTimeBasedBreakdown(data, breakdownFields) { const nonTimeFields = breakdownFields.filter(f => !['date_start', 'date_stop'].includes(f)); if (nonTimeFields.length === 0) { // Pure daily breakdown - use existing function return formatDailyBreakdown(data); } // Multi-dimensional with time - group by date, then show other dimensions const dateGroups = groupBy(data, 'date_start'); let responseText = `📅 **Daily Performance`; if (nonTimeFields.length > 0) { responseText += ` by ${nonTimeFields.join(' × ')}`; } responseText += `:**\n\n`; Object.keys(dateGroups).sort().forEach(date => { const dayData = dateGroups[date]; const dateEnd = dayData[0]?.date_stop; // Show date header if (date === dateEnd) { responseText += `**${date}:**\n`; } else { responseText += `**${date} to ${dateEnd}:**\n`; } // Show each row with its breakdown dimensions dayData.forEach(row => { const dimensions = nonTimeFields .map(field => `${field}: ${row[field]}`) .join(', '); responseText += ` ${dimensions} - `; responseText += formatRowMetrics(row, '').replace(/\n/g, ', ').trim().replace(/,$/, '') + '\n'; // Show conversion summary for this row const conversionSummary = getConversionSummary([row]); if (conversionSummary) { responseText += ` 🎯 Conversions: ${conversionSummary}\n`; } }); responseText += '\n'; }); return responseText; } function formatDailyBreakdown(insightsData) { let responseText = `📅 **Daily Performance Breakdown:**\n\n`; // Group by date const dailyData = insightsData.reduce((acc, row) => { const date = row.date_start; if (!acc[date]) acc[date] = []; acc[date].push(row); return acc; }, {}); // Sort dates const sortedDates = Object.keys(dailyData).sort(); // Check if we have actual daily data or aggregated data const hasMultipleDates = sortedDates.length > 1; const firstRowDateRange = insightsData[0]?.date_start === insightsData[0]?.date_stop; if (!hasMultipleDates && !firstRowDateRange) { responseText += `⚠️ **Note:** Data appears to be aggregated over the entire date range rather than broken down daily.\n`; responseText += `To get daily breakdowns, ensure time_increment=1 is included in your request.\n\n`; } sortedDates.forEach(date => { const dayData = dailyData[date]; const dateEnd = dayData[0]?.date_stop; if (date === dateEnd) { responseText += `**${date}:**\n`; } else { responseText += `**${date} to ${dateEnd}:**\n`; } // Aggregate metrics for this date group if multiple rows if (dayData.length > 1) { const aggregated = aggregateMetrics(dayData); responseText += formatRowMetrics(aggregated, ' '); // Show conversion summary const conversionSummary = getConversionSummary(dayData); if (conversionSummary) { responseText += ` 🎯 **Conversions:** ${conversionSummary}\n`; } } else { responseText += formatRowMetrics(dayData[0], ' '); // Show conversion summary const conversionSummary = getConversionSummary(dayData); if (conversionSummary) { responseText += ` 🎯 **Conversions:** ${conversionSummary}\n`; } } responseText += `\n`; }); return responseText; } function formatPlacementBreakdown(insightsData) { let responseText = `📱 **Placement Performance Breakdown:**\n\n`; // Group by placement const placementData = insightsData.reduce((acc, row) => { const placement = row.placement || 'Unknown'; if (!acc[placement]) acc[placement] = []; acc[placement].push(row); return acc; }, {}); // Sort by spend (descending) const sortedPlacements = Object.keys(placementData).sort((a, b) => { const spendA = placementData[a].reduce((sum, row) => sum + parseFloat(row.spend || 0), 0); const spendB = placementData[b].reduce((sum, row) => sum + parseFloat(row.spend || 0), 0); return spendB - spendA; }); sortedPlacements.forEach(placement => { const placementRows = placementData[placement]; responseText += `**${placement}:**\n`; // Aggregate metrics for this placement const aggregated = aggregateMetrics(placementRows); responseText += formatRowMetrics(aggregated, ' '); // Show conversion events if available const conversionSummary = getConversionSummary(placementRows); if (conversionSummary) { responseText += ` 🎯 **Conversions:** ${conversionSummary}\n`; } responseText += `\n`; }); return responseText; } function formatDemographicBreakdown(insightsData) { let responseText = `👥 **Demographic Performance Breakdown:**\n\n`; // Group by age and/or gender const demoData = insightsData.reduce((acc, row) => { const age = row.age || 'Unknown Age'; const gender = row.gender || 'Unknown Gender'; const key = `${age} - ${gender}`; if (!acc[key]) acc[key] = []; acc[key].push(row); return acc; }, {}); // Sort by spend (descending) const sortedDemos = Object.keys(demoData).sort((a, b) => { const spendA = demoData[a].reduce((sum, row) => sum + parseFloat(row.spend || 0), 0); const spendB = demoData[b].reduce((sum, row) => sum + parseFloat(row.spend || 0), 0); return spendB - spendA; }); sortedDemos.forEach(demo => { const demoRows = demoData[demo]; responseText += `**${demo}:**\n`; // Aggregate metrics for this demographic const aggregated = aggregateMetrics(demoRows); responseText += formatRowMetrics(aggregated, ' '); // Show conversion events if available const conversionSummary = getConversionSummary(demoRows); if (conversionSummary) { responseText += ` 🎯 **Conversions:** ${conversionSummary}\n`; } responseText += `\n`; }); return responseText; } function formatGenericBreakdown(insightsData, breakdownFields) { let responseText = `📊 **Custom Breakdown Results:**\n\n`; insightsData.forEach((row, index) => { responseText += `**Row ${index + 1}:**\n`; // Show breakdown dimensions breakdownFields.forEach(field => { if (row[field] !== undefined) { responseText += ` ${field}: ${row[field]}\n`; } }); // Show metrics responseText += formatRowMetrics(row, ' '); // Show conversion events if available const conversionSummary = getConversionSummary([row]); if (conversionSummary) { responseText += ` 🎯 **Conversions:** ${conversionSummary}\n`; } responseText += `\n`; }); return responseText; } function formatSimpleInsights(insightsData) { let responseText = '📊 **Account Performance:**\n\n'; // Guard against empty data if (!insightsData || insightsData.length === 0) { return responseText + 'No data available.\n'; } const data = insightsData[0]; responseText += formatRowMetrics(data); // Show conversion events if available if (data && data.actions && Array.isArray(data.actions)) { responseText += '\n**Conversion Events:**\n'; data.actions.forEach(action => { responseText += `• ${action.action_type}: ${action.value}\n`; }); } return responseText; } function formatRowMetrics(row, indent = '') { let text = ''; // Guard against null/undefined row if (!row) return text; // Core metrics with null safety if (row.spend != null) text += `${indent}💰 Spend: $${parseFloat(row.spend || 0).toFixed(2)}\n`; if (row.impressions != null) text += `${indent}👁️ Impressions: ${parseInt(row.impressions || 0).toLocaleString()}\n`; if (row.clicks != null) text += `${indent}🖱️ Clicks: ${parseInt(row.clicks || 0).toLocaleString()}\n`; if (row.ctr != null) text += `${indent}📊 CTR: ${parseFloat(row.ctr || 0).toFixed(2)}%\n`; if (row.cpc != null) text += `${indent}💸 CPC: $${parseFloat(row.cpc || 0).toFixed(2)}\n`; if (row.cpm != null) text += `${indent}📈 CPM: $${parseFloat(row.cpm || 0).toFixed(2)}\n`; return text; } function aggregateMetrics(rows) { const aggregated = { spend: 0, impressions: 0, clicks: 0, actions: [], conversions: [] }; rows.forEach(row => { aggregated.spend += parseFloat(row.spend || 0); aggregated.impressions += parseInt(row.impressions || 0); aggregated.clicks += parseInt(row.clicks || 0); if (row.actions) { aggregated.actions = aggregated.actions.concat(row.actions); } if (row.conversions) { aggregated.conversions = aggregated.conversions.concat(row.conversions); } }); // Calculate derived metrics if (aggregated.impressions > 0) { aggregated.ctr = (aggregated.clicks / aggregated.impressions * 100).toFixed(2); aggregated.cpm = (aggregated.spend / aggregated.impressions * 1000).toFixed(2); } if (aggregated.clicks > 0) { aggregated.cpc = (aggregated.spend / aggregated.clicks).toFixed(2); } return aggregated; } function getConversionSummary(rows) { const conversionCounts = {}; rows.forEach(row => { // Process conversions (higher priority) if (row.conversions && Array.isArray(row.conversions)) { row.conversions.forEach(conv => { const type = conv.action_type; conversionCounts[type] = (conversionCounts[type] || 0) + parseFloat(conv.value || 0); }); } // Process actions (fallback) if (row.actions && Array.isArray(row.actions)) { row.actions.forEach(action => { const type = action.action_type; // Only add if not already in conversions if (!conversionCounts[type]) { conversionCounts[type] = (conversionCounts[type] || 0) + parseFloat(action.value || 0); } }); } }); const conversionEntries = Object.entries(conversionCounts).filter(([type, count]) => count > 0); if (conversionEntries.length === 0) return null; return conversionEntries .map(([type, count]) => `${type}: ${count}`) .join(', '); }