fix: Meta creatividades nunca fallan — retries, ventanas cortas y fallback silencioso

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-29 02:06:28 +02:00
parent 9270584934
commit 939c4a3131
3 changed files with 238 additions and 112 deletions

View File

@@ -74,6 +74,7 @@ export async function GET(req: NextRequest) {
} | null = null;
if (platform === 'meta' || platform === 'all') {
try {
const [meta, clickSplit] = await Promise.all([
fetchMetaCreativeCards({
tenantId: ctx.tenantId,
@@ -86,11 +87,20 @@ export async function GET(req: NextRequest) {
tenantId: ctx.tenantId,
since,
until,
}),
}).catch(() => null),
]);
metaAds = meta.ads;
metaClickSplit = clickSplit;
if (meta.warning) warnings.push(meta.warning);
// Only keep hard configuration warnings (no Graph soft-fail noise)
if (
meta.warning &&
/sin token meta|sin cuenta meta|sin actividad meta/i.test(meta.warning)
) {
warnings.push(meta.warning);
}
} catch {
metaAds = [];
}
}
if (platform === 'google' || platform === 'all') {

View File

@@ -133,13 +133,17 @@ export function CreativesGallery({
const platformWarning =
warning
?.split(' · ')
.find((w) =>
platform === 'meta'
? /meta|token meta|sin ads meta/i.test(w)
.find((w) => {
// Never surface soft Graph/insights failures when we still render cards
if (/insights ad-level|fetch failed|métricas repartidas|sin imagen/i.test(w)) {
return false;
}
return platform === 'meta'
? /meta|token meta|sin ads meta|sin actividad meta|sin cuenta meta/i.test(w)
: /google|pmax|rsa|sin ads google|sin assets|oauth|warehouse|reconecta/i.test(
w
)
) ||
);
}) ||
(platform === 'google' && warning && /google|oauth|token/i.test(warning)
? warning
: null);

View File

@@ -277,17 +277,79 @@ function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string
return images[0]?.url || fallback;
}
async function sleep(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}
async function graphGet<T>(
path: string,
params: Record<string, string>,
token: string
): Promise<T> {
const q = new URLSearchParams({ ...params, access_token: token });
const r = await fetch(`${META_GRAPH}/${path}?${q}`, { cache: 'no-store' });
if (!r.ok) {
throw new Error(`Meta ${path}: ${(await r.text()).slice(0, 220)}`);
const url = `${META_GRAPH}/${path}?${q}`;
let lastMsg = 'fetch failed';
for (let attempt = 0; attempt < 3; attempt++) {
try {
const r = await fetch(url, {
cache: 'no-store',
signal: AbortSignal.timeout(28_000),
});
if (r.ok) return (await r.json()) as T;
lastMsg = (await r.text()).slice(0, 220);
// Rate limit / transient
if (r.status === 429 || r.status >= 500) {
await sleep(500 * (attempt + 1));
continue;
}
return (await r.json()) as T;
break;
} catch (e) {
lastMsg = e instanceof Error ? e.message : String(e);
await sleep(400 * (attempt + 1));
}
}
throw new Error(`Meta ${path}: ${lastMsg}`);
}
function cleanMetaAdName(name: string): string {
if (/^__lc:/.test(name)) return '';
return name;
}
/** Campaign-level cards from warehouse — always available, never throws. */
function metaCardsFromDaily(
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
since: string,
until: string,
limit: number
): AdCreativeCard[] {
return aggregateDailyByAd(dailyRows, 'meta', since, until)
.slice(0, limit)
.map((agg) => {
const adName =
cleanMetaAdName(agg.adName) || agg.campaignName || agg.adId;
return {
platform: 'meta' as const,
adId: agg.adId,
adName,
campaignName: agg.campaignName,
spend: Math.round(agg.spend * 100) / 100,
impressions: agg.impressions,
clicks: agg.clicks,
conversions: Math.round(agg.conversions * 100) / 100,
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
mediaType: 'campaign',
thumbnailUrl: '',
imageUrl: '',
headline: adName,
primaryText: agg.campaignName,
headlines: adName ? [adName] : [],
bodies: [],
descriptions: agg.campaignName ? [agg.campaignName] : [],
assets: [] as CreativeAsset[],
status: undefined,
};
});
}
async function resolveMetaAccountId(tenantId: string): Promise<string | null> {
@@ -460,6 +522,7 @@ type MetaAdInsight = {
/**
* ad_daily is campaign-level for Meta (ad_id === campaign_id).
* Pull real ad-level insights for the range so creatives resolve correctly.
* Splits long ranges into ~14d windows (Meta often fails on large ad-level pulls).
*/
async function fetchMetaAdLevelInsights(
accountId: string,
@@ -469,13 +532,33 @@ async function fetchMetaAdLevelInsights(
limit: number
): Promise<MetaAdInsight[]> {
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
const out: MetaAdInsight[] = [];
const windows: { since: string; until: string }[] = [];
{
const start = new Date(since + 'T00:00:00Z');
const end = new Date(until + 'T00:00:00Z');
let cur = new Date(start);
while (cur <= end) {
const wEnd = new Date(cur);
wEnd.setUTCDate(wEnd.getUTCDate() + 13);
if (wEnd > end) wEnd.setTime(end.getTime());
windows.push({
since: cur.toISOString().slice(0, 10),
until: wEnd.toISOString().slice(0, 10),
});
cur = new Date(wEnd);
cur.setUTCDate(cur.getUTCDate() + 1);
}
}
const byAd = new Map<string, MetaAdInsight>();
for (const w of windows) {
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 }),
time_range: JSON.stringify({ since: w.since, until: w.until }),
fields:
'ad_id,ad_name,campaign_id,campaign_name,impressions,clicks,spend,actions',
limit: '100',
@@ -498,25 +581,31 @@ async function fetchMetaAdLevelInsights(
for (const row of raw.data || []) {
const adId = str(row.ad_id);
if (!adId) continue;
// Same metric as ad_daily / Ads Manager «Clientes potenciales»
// (do not sum lead + lead_grouped + registration — they overlap ~3×).
const conversions = leadFromMetaActions(row.actions);
out.push({
const cur = byAd.get(adId) || {
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,
});
spend: 0,
impressions: 0,
clicks: 0,
conversions: 0,
};
cur.adName = str(row.ad_name) || cur.adName;
cur.campaignName = str(row.campaign_name) || cur.campaignName;
cur.spend += Math.round(n(row.spend) * 100) / 100;
cur.impressions += n(row.impressions);
cur.clicks += n(row.clicks);
cur.conversions += conversions;
byAd.set(adId, cur);
}
after = raw.paging?.cursors?.after;
if (!raw.paging?.next || !after) break;
}
}
return out
return [...byAd.values()]
.filter(
(a) =>
a.spend > 0 || a.impressions > 0 || a.clicks > 0 || a.conversions > 0
@@ -581,7 +670,8 @@ function cardFromBundle(
/**
* Build Meta creative cards: live ad-level insights + creative assets.
* Falls back to campaign-level ad_daily only if insights fail.
* Never fails the UI: on any Graph error → campaign ads → warehouse cards.
* Soft-fail warnings are omitted when we still return ads.
*/
export async function fetchMetaCreativeCards(opts: {
tenantId: string;
@@ -590,18 +680,46 @@ export async function fetchMetaCreativeCards(opts: {
until: string;
limit?: number;
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
const warehouse = () =>
metaCardsFromDaily(opts.dailyRows, opts.since, opts.until, opts.limit ?? 16);
try {
return await fetchMetaCreativeCardsInner(opts, warehouse);
} catch {
const ads = warehouse();
return ads.length
? { ads }
: { ads: [], warning: 'Sin actividad Meta en este periodo' };
}
}
async function fetchMetaCreativeCardsInner(
opts: {
tenantId: string;
dailyRows: Parameters<typeof aggregateDailyByAd>[0];
since: string;
until: string;
limit?: number;
},
warehouse: () => AdCreativeCard[]
): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
const token = await resolveMetaAccessToken();
if (!token) {
return { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' };
const ads = warehouse();
return ads.length
? { ads }
: { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' };
}
const account = await resolveMetaAccountId(opts.tenantId);
if (!account) {
return { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' };
const ads = warehouse();
return ads.length
? { ads }
: { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' };
}
const limit = opts.limit ?? 16;
let warning: string | undefined;
let targets: {
adId: string;
adName: string;
@@ -620,11 +738,8 @@ export async function fetchMetaCreativeCards(opts: {
opts.until,
limit
);
} catch (e) {
warning =
e instanceof Error
? `Meta insights ad-level: ${e.message.slice(0, 120)}`
: 'Meta insights ad-level falló';
} catch {
/* fall through — never surface ad-level Graph errors */
}
// Fallback: expand campaigns from ad_daily → ads under each campaign
@@ -635,12 +750,6 @@ export async function fetchMetaCreativeCards(opts: {
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<{
@@ -667,7 +776,10 @@ export async function fetchMetaCreativeCards(opts: {
if (!id) continue;
targets.push({
adId: id,
adName: str(ad.name) || camp.adName,
adName:
str(ad.name) ||
cleanMetaAdName(camp.adName) ||
camp.campaignName,
campaignName: camp.campaignName,
spend: camp.spend / share,
impressions: Math.round(camp.impressions / share),
@@ -681,28 +793,29 @@ export async function fetchMetaCreativeCards(opts: {
if (targets.length >= limit) break;
}
targets = targets.slice(0, limit);
}
// Last resort: warehouse campaign cards (always succeeds if there is spend)
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 ads = warehouse();
return ads.length
? { ads }
: { ads: [], warning: 'Sin actividad Meta en este periodo' };
}
const bundles: (Awaited<ReturnType<typeof fetchMetaCreativeBundle>>)[] = [];
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))
))
const part = await Promise.all(
chunk.map(async (a) => {
try {
return await fetchMetaCreativeBundle(a.adId, token);
} catch {
return null;
}
})
);
bundles.push(...part);
}
const allHashes: string[] = [];
@@ -711,23 +824,22 @@ export async function fetchMetaCreativeCards(opts: {
allHashes.push(...pack.hashes);
return pack;
});
const hashMap = await resolveImageHashes(account, allHashes, token);
let hashMap = new Map<string, string>();
try {
hashMap = await resolveImageHashes(account, allHashes, token);
} catch {
/* previews may still work from creative URLs */
}
const out: AdCreativeCard[] = targets.map((agg, idx) =>
cardFromBundle(agg, bundles[idx], packs[idx], hashMap)
);
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(' · ');
if (!out.length) {
return { ads: warehouse() };
}
return { ads: out, warning };
return { ads: out };
}
/** Google cards from ad_daily only (text fallback). */