fix: Social IG/FB con métricas Meta vigentes y creatividades Ad Tracker

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 <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-28 14:19:58 +02:00
parent 4a9c2139f7
commit 530b5ac5ae
3 changed files with 359 additions and 69 deletions

View File

@@ -252,10 +252,13 @@ async function fetchPageBundle(
const sinceU = String(toUnix(since)); const sinceU = String(toUnix(since));
const untilU = String(toUnix(until, true)); const untilU = String(toUnix(until, true));
// Meta deprecated page_impressions / page_fans (Nov 2025).
// Use page_media_view + page_follows (+ unique views).
const metricSets = [ 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_media_view,page_total_media_view_unique,page_post_engagements,page_follows,page_views_total,page_daily_follows,page_actions_post_reactions_total',
'page_impressions,page_impressions_unique,page_post_engagements,page_fans,page_views_total', 'page_media_view,page_total_media_view_unique,page_post_engagements,page_follows,page_views_total',
'page_impressions,page_impressions_unique,page_post_engagements,page_fans', 'page_media_view,page_follows,page_views_total',
'page_follows,page_views_total',
]; ];
let insightData: { let insightData: {
@@ -278,15 +281,50 @@ async function fetchPageBundle(
insightsError = formatGraphError(ins.raw); 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 = { const series = {
impressions: insightSeries(insightData, 'page_impressions'), impressions: insightSeries(insightData, 'page_media_view').length
reach: insightSeries(insightData, 'page_impressions_unique'), ? 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'), postEngagements: insightSeries(insightData, 'page_post_engagements'),
fans: insightSeries(insightData, 'page_fans'), fans: insightSeries(insightData, 'page_follows').length
pageViews: insightSeries(insightData, 'page_views_total'),
follows: insightSeries(insightData, 'page_follows').length
? insightSeries(insightData, 'page_follows') ? 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'), reactions: insightSeries(insightData, 'page_actions_post_reactions_total'),
}; };
@@ -487,6 +525,58 @@ async function fetchIgBundle(
insightsError = formatGraphError(ins.raw); 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<string, (typeof insightData)[0]>();
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 = { const series = {
reach: insightSeries(insightData, 'reach'), reach: insightSeries(insightData, 'reach'),
impressions: impressions:

View File

@@ -298,7 +298,7 @@ export default function SocialDashboard({
icon: Eye, icon: Eye,
}, },
{ {
label: 'Impresiones FB', label: 'Visualizaciones FB',
value: n(fb?.pageInsights?.impressions), value: n(fb?.pageInsights?.impressions),
icon: Eye, icon: Eye,
}, },
@@ -360,14 +360,14 @@ export default function SocialDashboard({
}, },
{ {
title: 'FB · Alcance / día', title: 'FB · Alcance / día',
hint: 'page_impressions_unique', hint: 'page_total_media_view_unique',
series: fb?.series?.reach, series: fb?.series?.reach,
total: fb?.pageInsights?.reach, total: fb?.pageInsights?.reach,
color: 'bg-sky-400/80', color: 'bg-sky-400/80',
}, },
{ {
title: 'FB · Impresiones', title: 'FB · Visualizaciones',
hint: 'page_impressions', hint: 'page_media_view',
series: fb?.series?.impressions, series: fb?.series?.impressions,
total: fb?.pageInsights?.impressions, total: fb?.pageInsights?.impressions,
color: 'bg-blue-500/70', color: 'bg-blue-500/70',
@@ -407,7 +407,7 @@ export default function SocialDashboard({
<PageHeader <PageHeader
eyebrow={`Social · ${tenantLabel}`} eyebrow={`Social · ${tenantLabel}`}
title="Redes sociales" title="Redes sociales"
description={`Analytics orgánicos IG/FB (token Meta 4V2030) · periodo ${rangeLabel}. Paid ads en Ads.`} description={`Analytics orgánicos IG/FB · periodo ${rangeLabel}`}
/> />
{err && ( {err && (

View File

@@ -325,6 +325,7 @@ async function fetchMetaCreativeBundle(
adId: string, adId: string,
token: string token: string
): Promise<{ ): Promise<{
adId: string;
adName: string; adName: string;
campaignName: string; campaignName: string;
adsetName: string; adsetName: string;
@@ -343,7 +344,6 @@ async function fetchMetaCreativeBundle(
adId, adId,
{ {
fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`, fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`,
// Prefer vertical crop for Stories / Reels style
thumbnail_width: '1080', thumbnail_width: '1080',
thumbnail_height: '1920', thumbnail_height: '1920',
}, },
@@ -370,6 +370,7 @@ async function fetchMetaCreativeBundle(
} }
return { return {
adId: str(raw.id) || adId,
adName: str(raw.name), adName: str(raw.name),
campaignName: str(raw.campaign?.name), campaignName: str(raw.campaign?.name),
adsetName: str(raw.adset?.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<MetaAdInsight[]> {
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<string, string> = {
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<ReturnType<typeof fetchMetaCreativeBundle>>,
pack: Extracted,
hashMap: Map<string, string>
): 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: { export async function fetchMetaCreativeCards(opts: {
tenantId: string; tenantId: string;
@@ -401,22 +543,108 @@ export async function fetchMetaCreativeCards(opts: {
return { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' }; return { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' };
} }
const aggs = aggregateDailyByAd( const limit = opts.limit ?? 16;
opts.dailyRows, let warning: string | undefined;
'meta', let targets: {
opts.since, adId: string;
opts.until adName: string;
).slice(0, opts.limit ?? 16); campaignName: string;
spend: number;
impressions: number;
clicks: number;
conversions: number;
}[] = [];
if (!aggs.length) { try {
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' }; 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<ReturnType<typeof fetchMetaCreativeBundle>>)[] = []; const bundles: (Awaited<ReturnType<typeof fetchMetaCreativeBundle>>)[] = [];
for (let i = 0; i < aggs.length; i += 6) { for (let i = 0; i < targets.length; i += 6) {
const chunk = aggs.slice(i, i + 6); const chunk = targets.slice(i, i + 6);
bundles.push( 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 hashMap = await resolveImageHashes(account, allHashes, token);
const out: AdCreativeCard[] = []; const out: AdCreativeCard[] = targets.map((agg, idx) =>
aggs.forEach((agg, idx) => { cardFromBundle(agg, bundles[idx], packs[idx], hashMap)
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,
});
});
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). */ /** Google cards from ad_daily only (text fallback). */