From 530b5ac5ae47e405b2f391899ad6ee4601fd8f3c Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Mon, 28 Sep 2026 14:19:58 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Social=20IG/FB=20con=20m=C3=A9tricas=20M?= =?UTF-8?q?eta=20vigentes=20y=20creatividades=20Ad=20Tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quita copy "Paid ads en Ads", migra Page Insights a page_media_view/page_follows y resuelve creatividades Meta a nivel anuncio. Co-authored-by: Cursor --- src/app/api/social/route.ts | 108 +++++++++- src/components/SocialDashboard.tsx | 10 +- src/lib/ad-creatives-meta.ts | 310 ++++++++++++++++++++++++----- 3 files changed, 359 insertions(+), 69 deletions(-) diff --git a/src/app/api/social/route.ts b/src/app/api/social/route.ts index d93ed54..66b25ba 100644 --- a/src/app/api/social/route.ts +++ b/src/app/api/social/route.ts @@ -252,10 +252,13 @@ async function fetchPageBundle( const sinceU = String(toUnix(since)); const untilU = String(toUnix(until, true)); + // Meta deprecated page_impressions / page_fans (Nov 2025). + // Use page_media_view + page_follows (+ unique views). const metricSets = [ - 'page_impressions,page_impressions_unique,page_post_engagements,page_fans,page_views_total,page_follows,page_daily_follows,page_actions_post_reactions_total', - 'page_impressions,page_impressions_unique,page_post_engagements,page_fans,page_views_total', - 'page_impressions,page_impressions_unique,page_post_engagements,page_fans', + 'page_media_view,page_total_media_view_unique,page_post_engagements,page_follows,page_views_total,page_daily_follows,page_actions_post_reactions_total', + 'page_media_view,page_total_media_view_unique,page_post_engagements,page_follows,page_views_total', + 'page_media_view,page_follows,page_views_total', + 'page_follows,page_views_total', ]; let insightData: { @@ -278,15 +281,50 @@ async function fetchPageBundle( insightsError = formatGraphError(ins.raw); } + // If bulk failed, try metrics one-by-one so one deprecated name doesn't wipe the rest + if (!insightData.length) { + const singles = [ + 'page_media_view', + 'page_total_media_view_unique', + 'page_post_engagements', + 'page_follows', + 'page_views_total', + 'page_daily_follows', + 'page_actions_post_reactions_total', + ]; + const merged: typeof insightData = []; + for (const metric of singles) { + const ins = await graphGet(`${encodeURIComponent(pageId)}/insights`, token, { + metric, + period: 'day', + since: sinceU, + until: untilU, + }); + if (ins.ok && Array.isArray(ins.json.data)) { + merged.push(...(ins.json.data as typeof insightData)); + } + } + if (merged.length) { + insightData = merged; + insightsError = undefined; + } + } + const series = { - impressions: insightSeries(insightData, 'page_impressions'), - reach: insightSeries(insightData, 'page_impressions_unique'), + impressions: insightSeries(insightData, 'page_media_view').length + ? insightSeries(insightData, 'page_media_view') + : insightSeries(insightData, 'page_impressions'), + reach: insightSeries(insightData, 'page_total_media_view_unique').length + ? insightSeries(insightData, 'page_total_media_view_unique') + : insightSeries(insightData, 'page_impressions_unique'), postEngagements: insightSeries(insightData, 'page_post_engagements'), - fans: insightSeries(insightData, 'page_fans'), - pageViews: insightSeries(insightData, 'page_views_total'), - follows: insightSeries(insightData, 'page_follows').length + fans: insightSeries(insightData, 'page_follows').length ? insightSeries(insightData, 'page_follows') - : insightSeries(insightData, 'page_daily_follows'), + : insightSeries(insightData, 'page_fans'), + pageViews: insightSeries(insightData, 'page_views_total'), + follows: insightSeries(insightData, 'page_daily_follows').length + ? insightSeries(insightData, 'page_daily_follows') + : insightSeries(insightData, 'page_follows'), reactions: insightSeries(insightData, 'page_actions_post_reactions_total'), }; @@ -487,6 +525,58 @@ async function fetchIgBundle( insightsError = formatGraphError(ins.raw); } + // Partial recovery: one bad metric in a comma-set fails the whole call + if (!insightData.length || insightData.length < 3) { + const singles = [ + 'reach', + 'views', + 'impressions', + 'profile_views', + 'website_clicks', + 'accounts_engaged', + 'follower_count', + 'total_interactions', + ]; + const byName = new Map(); + for (const row of insightData) { + if (row.name) byName.set(row.name, row); + } + for (const metric of singles) { + if (byName.has(metric)) continue; + const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, { + metric, + period: 'day', + since: sinceU, + until: untilU, + }); + if (ins.ok && Array.isArray(ins.json.data) && ins.json.data.length) { + for (const row of ins.json.data as typeof insightData) { + if (row.name) byName.set(row.name, row); + } + continue; + } + const ins2 = await graphGet( + `${encodeURIComponent(igId)}/insights`, + agencyToken, + { metric, period: 'day' } + ); + if (ins2.ok && Array.isArray(ins2.json.data)) { + for (const row of ins2.json.data as typeof insightData) { + const vals = (row.values || []).filter((v) => { + const d = (v.end_time || '').slice(0, 10); + return d >= since && d <= until; + }); + if (!vals.length || !row.name) continue; + byName.set(row.name, { ...row, values: vals }); + } + } + } + if (byName.size) { + insightData = [...byName.values()]; + insightsError = undefined; + } + } + const series = { reach: insightSeries(insightData, 'reach'), impressions: diff --git a/src/components/SocialDashboard.tsx b/src/components/SocialDashboard.tsx index e07a722..423abef 100644 --- a/src/components/SocialDashboard.tsx +++ b/src/components/SocialDashboard.tsx @@ -298,7 +298,7 @@ export default function SocialDashboard({ icon: Eye, }, { - label: 'Impresiones FB', + label: 'Visualizaciones FB', value: n(fb?.pageInsights?.impressions), icon: Eye, }, @@ -360,14 +360,14 @@ export default function SocialDashboard({ }, { title: 'FB · Alcance / día', - hint: 'page_impressions_unique', + hint: 'page_total_media_view_unique', series: fb?.series?.reach, total: fb?.pageInsights?.reach, color: 'bg-sky-400/80', }, { - title: 'FB · Impresiones', - hint: 'page_impressions', + title: 'FB · Visualizaciones', + hint: 'page_media_view', series: fb?.series?.impressions, total: fb?.pageInsights?.impressions, color: 'bg-blue-500/70', @@ -407,7 +407,7 @@ export default function SocialDashboard({ {err && ( diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts index b38020d..cb99033 100644 --- a/src/lib/ad-creatives-meta.ts +++ b/src/lib/ad-creatives-meta.ts @@ -325,6 +325,7 @@ async function fetchMetaCreativeBundle( adId: string, token: string ): Promise<{ + adId: string; adName: string; campaignName: string; adsetName: string; @@ -343,7 +344,6 @@ async function fetchMetaCreativeBundle( adId, { fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`, - // Prefer vertical crop for Stories / Reels style thumbnail_width: '1080', thumbnail_height: '1920', }, @@ -370,6 +370,7 @@ async function fetchMetaCreativeBundle( } return { + adId: str(raw.id) || adId, adName: str(raw.name), campaignName: str(raw.campaign?.name), adsetName: str(raw.adset?.name), @@ -381,8 +382,149 @@ async function fetchMetaCreativeBundle( } } +type MetaAdInsight = { + adId: string; + adName: string; + campaignId: string; + campaignName: string; + spend: number; + impressions: number; + clicks: number; + conversions: number; +}; + /** - * Build Meta creative cards for a tenant: metrics from ad_daily + live Meta creative assets. + * ad_daily is campaign-level for Meta (ad_id === campaign_id). + * Pull real ad-level insights for the range so creatives resolve correctly. + */ +async function fetchMetaAdLevelInsights( + accountId: string, + token: string, + since: string, + until: string, + limit: number +): Promise { + const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`; + const out: MetaAdInsight[] = []; + let after: string | undefined; + for (let page = 0; page < 8; page++) { + const params: Record = { + level: 'ad', + time_increment: 'all_days', + time_range: JSON.stringify({ since, until }), + fields: + 'ad_id,ad_name,campaign_id,campaign_name,impressions,clicks,spend,actions', + limit: '100', + }; + if (after) params.after = after; + const raw = await graphGet<{ + data?: { + ad_id?: string; + ad_name?: string; + campaign_id?: string; + campaign_name?: string; + impressions?: string; + clicks?: string; + spend?: string; + actions?: { action_type?: string; value?: string }[]; + }[]; + paging?: { cursors?: { after?: string }; next?: string }; + }>(`${act}/insights`, params, token); + + for (const row of raw.data || []) { + const adId = str(row.ad_id); + if (!adId) continue; + let conversions = 0; + for (const a of row.actions || []) { + const t = (a.action_type || '').toLowerCase(); + if ( + t === 'offsite_conversion.fb_pixel_complete_registration' || + t === 'lead' || + t === 'onsite_conversion.lead_grouped' || + t.includes('complete_registration') + ) { + conversions += n(a.value); + } + } + out.push({ + adId, + adName: str(row.ad_name), + campaignId: str(row.campaign_id), + campaignName: str(row.campaign_name), + spend: Math.round(n(row.spend) * 100) / 100, + impressions: n(row.impressions), + clicks: n(row.clicks), + conversions, + }); + } + after = raw.paging?.cursors?.after; + if (!raw.paging?.next || !after) break; + } + + return out + .filter( + (a) => + a.spend > 0 || a.impressions > 0 || a.clicks > 0 || a.conversions > 0 + ) + .sort((a, b) => b.spend - a.spend) + .slice(0, limit); +} + +function cardFromBundle( + agg: { + adId: string; + adName: string; + campaignName: string; + spend: number; + impressions: number; + clicks: number; + conversions: number; + }, + b: Awaited>, + pack: Extracted, + hashMap: Map +): AdCreativeCard { + const assets = [...pack.assets]; + for (const h of pack.hashes) { + const u = hashMap.get(h); + if (u && !assets.some((a) => a.url === u)) { + assets.push({ kind: 'image', url: u, label: 'hash' }); + } + } + const creative = b?.creative || {}; + const fallback = + str(creative.image_url) || str(creative.thumbnail_url) || ''; + const preview = pickVerticalPreview(assets, fallback); + const headlines = pack.headlines; + const descriptions = pack.descriptions; + return { + platform: 'meta', + adId: b?.adId || agg.adId, + adName: b?.adName || agg.adName, + campaignName: b?.campaignName || agg.campaignName, + adsetName: b?.adsetName, + spend: Math.round(agg.spend * 100) / 100, + impressions: agg.impressions, + clicks: agg.clicks, + conversions: agg.conversions, + cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null, + mediaType: detectMediaType(creative), + thumbnailUrl: preview, + imageUrl: preview, + headline: headlines.join('\n'), + primaryText: descriptions.join('\n'), + headlines, + descriptions, + assets, + linkUrl: pack.linkUrl || undefined, + cta: pack.cta || undefined, + status: b?.status, + }; +} + +/** + * Build Meta creative cards: live ad-level insights + creative assets. + * Falls back to campaign-level ad_daily only if insights fail. */ export async function fetchMetaCreativeCards(opts: { tenantId: string; @@ -401,22 +543,108 @@ export async function fetchMetaCreativeCards(opts: { return { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' }; } - const aggs = aggregateDailyByAd( - opts.dailyRows, - 'meta', - opts.since, - opts.until - ).slice(0, opts.limit ?? 16); + const limit = opts.limit ?? 16; + let warning: string | undefined; + let targets: { + adId: string; + adName: string; + campaignName: string; + spend: number; + impressions: number; + clicks: number; + conversions: number; + }[] = []; - if (!aggs.length) { - return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' }; + try { + targets = await fetchMetaAdLevelInsights( + account, + token, + opts.since, + opts.until, + limit + ); + } catch (e) { + warning = + e instanceof Error + ? `Meta insights ad-level: ${e.message.slice(0, 120)}` + : 'Meta insights ad-level falló'; + } + + // Fallback: expand campaigns from ad_daily → ads under each campaign + if (!targets.length) { + const campaigns = aggregateDailyByAd( + opts.dailyRows, + 'meta', + opts.since, + opts.until + ).slice(0, 8); + if (!campaigns.length) { + return { + ads: [], + warning: warning || 'Sin ads Meta en ad_daily para este periodo', + }; + } + for (const camp of campaigns) { + try { + const raw = await graphGet<{ + data?: { id?: string; name?: string; effective_status?: string }[]; + }>( + `${camp.adId}/ads`, + { + fields: 'id,name,effective_status', + limit: '6', + filtering: JSON.stringify([ + { + field: 'effective_status', + operator: 'IN', + value: ['ACTIVE', 'PAUSED', 'CAMPAIGN_PAUSED', 'ADSET_PAUSED'], + }, + ]), + }, + token + ); + const ads = raw.data || []; + const share = ads.length || 1; + for (const ad of ads) { + const id = str(ad.id); + if (!id) continue; + targets.push({ + adId: id, + adName: str(ad.name) || camp.adName, + campaignName: camp.campaignName, + spend: camp.spend / share, + impressions: Math.round(camp.impressions / share), + clicks: Math.round(camp.clicks / share), + conversions: camp.conversions / share, + }); + } + } catch { + /* skip campaign */ + } + if (targets.length >= limit) break; + } + targets = targets.slice(0, limit); + if (!targets.length) { + return { + ads: [], + warning: + warning || + 'No se pudieron resolver anuncios Meta (ad_daily es a nivel campaña)', + }; + } + if (!warning) { + warning = + 'Previews desde anuncios de campaña (métricas repartidas; ad_daily es campaign-level)'; + } } const bundles: (Awaited>)[] = []; - for (let i = 0; i < aggs.length; i += 6) { - const chunk = aggs.slice(i, i + 6); + for (let i = 0; i < targets.length; i += 6) { + const chunk = targets.slice(i, i + 6); bundles.push( - ...(await Promise.all(chunk.map((a) => fetchMetaCreativeBundle(a.adId, token)))) + ...(await Promise.all( + chunk.map((a) => fetchMetaCreativeBundle(a.adId, token)) + )) ); } @@ -428,49 +656,21 @@ export async function fetchMetaCreativeCards(opts: { }); const hashMap = await resolveImageHashes(account, allHashes, token); - const out: AdCreativeCard[] = []; - aggs.forEach((agg, idx) => { - const b = bundles[idx]; - const pack = packs[idx]; - const assets = [...pack.assets]; - for (const h of pack.hashes) { - const u = hashMap.get(h); - if (u && !assets.some((a) => a.url === u)) { - assets.push({ kind: 'image', url: u, label: 'hash' }); - } - } - const creative = b?.creative || {}; - const fallback = - str(creative.image_url) || str(creative.thumbnail_url) || ''; - const preview = pickVerticalPreview(assets, fallback); - const headlines = pack.headlines; - const descriptions = pack.descriptions; - out.push({ - platform: 'meta', - adId: agg.adId, - adName: b?.adName || agg.adName, - campaignName: b?.campaignName || agg.campaignName, - adsetName: b?.adsetName, - spend: Math.round(agg.spend * 100) / 100, - impressions: agg.impressions, - clicks: agg.clicks, - conversions: agg.conversions, - cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null, - mediaType: detectMediaType(creative), - thumbnailUrl: preview, - imageUrl: preview, - headline: headlines.join('\n'), - primaryText: descriptions.join('\n'), - headlines, - descriptions, - assets, - linkUrl: pack.linkUrl || undefined, - cta: pack.cta || undefined, - status: b?.status, - }); - }); + const out: AdCreativeCard[] = targets.map((agg, idx) => + cardFromBundle(agg, bundles[idx], packs[idx], hashMap) + ); - return { ads: out }; + const withPreview = out.filter((a) => a.thumbnailUrl || a.assets.length); + if (!withPreview.length && out.length) { + warning = [ + warning, + 'Anuncios sin imagen/vídeo resoluble en la API creativa', + ] + .filter(Boolean) + .join(' · '); + } + + return { ads: out, warning }; } /** Google cards from ad_daily only (text fallback). */