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:
@@ -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:
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
Reference in New Issue
Block a user