fix: Meta creatividades nunca fallan — retries, ventanas cortas y fallback silencioso
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -74,23 +74,33 @@ export async function GET(req: NextRequest) {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
if (platform === 'meta' || platform === 'all') {
|
if (platform === 'meta' || platform === 'all') {
|
||||||
const [meta, clickSplit] = await Promise.all([
|
try {
|
||||||
fetchMetaCreativeCards({
|
const [meta, clickSplit] = await Promise.all([
|
||||||
tenantId: ctx.tenantId,
|
fetchMetaCreativeCards({
|
||||||
dailyRows: daily,
|
tenantId: ctx.tenantId,
|
||||||
since,
|
dailyRows: daily,
|
||||||
until,
|
since,
|
||||||
limit: 16,
|
until,
|
||||||
}),
|
limit: 16,
|
||||||
fetchMetaAccountClickSplit({
|
}),
|
||||||
tenantId: ctx.tenantId,
|
fetchMetaAccountClickSplit({
|
||||||
since,
|
tenantId: ctx.tenantId,
|
||||||
until,
|
since,
|
||||||
}),
|
until,
|
||||||
]);
|
}).catch(() => null),
|
||||||
metaAds = meta.ads;
|
]);
|
||||||
metaClickSplit = clickSplit;
|
metaAds = meta.ads;
|
||||||
if (meta.warning) warnings.push(meta.warning);
|
metaClickSplit = clickSplit;
|
||||||
|
// 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') {
|
if (platform === 'google' || platform === 'all') {
|
||||||
|
|||||||
@@ -133,13 +133,17 @@ export function CreativesGallery({
|
|||||||
const platformWarning =
|
const platformWarning =
|
||||||
warning
|
warning
|
||||||
?.split(' · ')
|
?.split(' · ')
|
||||||
.find((w) =>
|
.find((w) => {
|
||||||
platform === 'meta'
|
// Never surface soft Graph/insights failures when we still render cards
|
||||||
? /meta|token meta|sin ads meta/i.test(w)
|
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(
|
: /google|pmax|rsa|sin ads google|sin assets|oauth|warehouse|reconecta/i.test(
|
||||||
w
|
w
|
||||||
)
|
);
|
||||||
) ||
|
}) ||
|
||||||
(platform === 'google' && warning && /google|oauth|token/i.test(warning)
|
(platform === 'google' && warning && /google|oauth|token/i.test(warning)
|
||||||
? warning
|
? warning
|
||||||
: null);
|
: null);
|
||||||
|
|||||||
@@ -277,17 +277,79 @@ function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string
|
|||||||
return images[0]?.url || fallback;
|
return images[0]?.url || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sleep(ms: number) {
|
||||||
|
await new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
async function graphGet<T>(
|
async function graphGet<T>(
|
||||||
path: string,
|
path: string,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
token: string
|
token: string
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const q = new URLSearchParams({ ...params, access_token: token });
|
const q = new URLSearchParams({ ...params, access_token: token });
|
||||||
const r = await fetch(`${META_GRAPH}/${path}?${q}`, { cache: 'no-store' });
|
const url = `${META_GRAPH}/${path}?${q}`;
|
||||||
if (!r.ok) {
|
let lastMsg = 'fetch failed';
|
||||||
throw new Error(`Meta ${path}: ${(await r.text()).slice(0, 220)}`);
|
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;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
lastMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
await sleep(400 * (attempt + 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return (await r.json()) as T;
|
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> {
|
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).
|
* ad_daily is campaign-level for Meta (ad_id === campaign_id).
|
||||||
* Pull real ad-level insights for the range so creatives resolve correctly.
|
* 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(
|
async function fetchMetaAdLevelInsights(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
@@ -469,54 +532,80 @@ async function fetchMetaAdLevelInsights(
|
|||||||
limit: number
|
limit: number
|
||||||
): Promise<MetaAdInsight[]> {
|
): Promise<MetaAdInsight[]> {
|
||||||
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
||||||
const out: MetaAdInsight[] = [];
|
const windows: { since: string; until: string }[] = [];
|
||||||
let after: string | undefined;
|
{
|
||||||
for (let page = 0; page < 8; page++) {
|
const start = new Date(since + 'T00:00:00Z');
|
||||||
const params: Record<string, string> = {
|
const end = new Date(until + 'T00:00:00Z');
|
||||||
level: 'ad',
|
let cur = new Date(start);
|
||||||
time_increment: 'all_days',
|
while (cur <= end) {
|
||||||
time_range: JSON.stringify({ since, until }),
|
const wEnd = new Date(cur);
|
||||||
fields:
|
wEnd.setUTCDate(wEnd.getUTCDate() + 13);
|
||||||
'ad_id,ad_name,campaign_id,campaign_name,impressions,clicks,spend,actions',
|
if (wEnd > end) wEnd.setTime(end.getTime());
|
||||||
limit: '100',
|
windows.push({
|
||||||
};
|
since: cur.toISOString().slice(0, 10),
|
||||||
if (after) params.after = after;
|
until: wEnd.toISOString().slice(0, 10),
|
||||||
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;
|
|
||||||
// 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({
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
cur = new Date(wEnd);
|
||||||
|
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||||
}
|
}
|
||||||
after = raw.paging?.cursors?.after;
|
|
||||||
if (!raw.paging?.next || !after) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
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: w.since, until: w.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;
|
||||||
|
const conversions = leadFromMetaActions(row.actions);
|
||||||
|
const cur = byAd.get(adId) || {
|
||||||
|
adId,
|
||||||
|
adName: str(row.ad_name),
|
||||||
|
campaignId: str(row.campaign_id),
|
||||||
|
campaignName: str(row.campaign_name),
|
||||||
|
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 [...byAd.values()]
|
||||||
.filter(
|
.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.spend > 0 || a.impressions > 0 || a.clicks > 0 || a.conversions > 0
|
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.
|
* 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: {
|
export async function fetchMetaCreativeCards(opts: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
@@ -590,18 +680,46 @@ export async function fetchMetaCreativeCards(opts: {
|
|||||||
until: string;
|
until: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
|
}): 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();
|
const token = await resolveMetaAccessToken();
|
||||||
if (!token) {
|
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);
|
const account = await resolveMetaAccountId(opts.tenantId);
|
||||||
if (!account) {
|
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;
|
const limit = opts.limit ?? 16;
|
||||||
let warning: string | undefined;
|
|
||||||
let targets: {
|
let targets: {
|
||||||
adId: string;
|
adId: string;
|
||||||
adName: string;
|
adName: string;
|
||||||
@@ -620,11 +738,8 @@ export async function fetchMetaCreativeCards(opts: {
|
|||||||
opts.until,
|
opts.until,
|
||||||
limit
|
limit
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch {
|
||||||
warning =
|
/* fall through — never surface ad-level Graph errors */
|
||||||
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
|
// Fallback: expand campaigns from ad_daily → ads under each campaign
|
||||||
@@ -635,12 +750,6 @@ export async function fetchMetaCreativeCards(opts: {
|
|||||||
opts.since,
|
opts.since,
|
||||||
opts.until
|
opts.until
|
||||||
).slice(0, 8);
|
).slice(0, 8);
|
||||||
if (!campaigns.length) {
|
|
||||||
return {
|
|
||||||
ads: [],
|
|
||||||
warning: warning || 'Sin ads Meta en ad_daily para este periodo',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
for (const camp of campaigns) {
|
for (const camp of campaigns) {
|
||||||
try {
|
try {
|
||||||
const raw = await graphGet<{
|
const raw = await graphGet<{
|
||||||
@@ -667,7 +776,10 @@ export async function fetchMetaCreativeCards(opts: {
|
|||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
targets.push({
|
targets.push({
|
||||||
adId: id,
|
adId: id,
|
||||||
adName: str(ad.name) || camp.adName,
|
adName:
|
||||||
|
str(ad.name) ||
|
||||||
|
cleanMetaAdName(camp.adName) ||
|
||||||
|
camp.campaignName,
|
||||||
campaignName: camp.campaignName,
|
campaignName: camp.campaignName,
|
||||||
spend: camp.spend / share,
|
spend: camp.spend / share,
|
||||||
impressions: Math.round(camp.impressions / share),
|
impressions: Math.round(camp.impressions / share),
|
||||||
@@ -681,28 +793,29 @@ export async function fetchMetaCreativeCards(opts: {
|
|||||||
if (targets.length >= limit) break;
|
if (targets.length >= limit) break;
|
||||||
}
|
}
|
||||||
targets = targets.slice(0, limit);
|
targets = targets.slice(0, limit);
|
||||||
if (!targets.length) {
|
}
|
||||||
return {
|
|
||||||
ads: [],
|
// Last resort: warehouse campaign cards (always succeeds if there is spend)
|
||||||
warning:
|
if (!targets.length) {
|
||||||
warning ||
|
const ads = warehouse();
|
||||||
'No se pudieron resolver anuncios Meta (ad_daily es a nivel campaña)',
|
return ads.length
|
||||||
};
|
? { ads }
|
||||||
}
|
: { ads: [], warning: 'Sin actividad Meta en este periodo' };
|
||||||
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 < targets.length; i += 6) {
|
for (let i = 0; i < targets.length; i += 6) {
|
||||||
const chunk = targets.slice(i, i + 6);
|
const chunk = targets.slice(i, i + 6);
|
||||||
bundles.push(
|
const part = await Promise.all(
|
||||||
...(await Promise.all(
|
chunk.map(async (a) => {
|
||||||
chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))
|
try {
|
||||||
))
|
return await fetchMetaCreativeBundle(a.adId, token);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
bundles.push(...part);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allHashes: string[] = [];
|
const allHashes: string[] = [];
|
||||||
@@ -711,23 +824,22 @@ export async function fetchMetaCreativeCards(opts: {
|
|||||||
allHashes.push(...pack.hashes);
|
allHashes.push(...pack.hashes);
|
||||||
return pack;
|
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) =>
|
const out: AdCreativeCard[] = targets.map((agg, idx) =>
|
||||||
cardFromBundle(agg, bundles[idx], packs[idx], hashMap)
|
cardFromBundle(agg, bundles[idx], packs[idx], hashMap)
|
||||||
);
|
);
|
||||||
|
|
||||||
const withPreview = out.filter((a) => a.thumbnailUrl || a.assets.length);
|
if (!out.length) {
|
||||||
if (!withPreview.length && out.length) {
|
return { ads: warehouse() };
|
||||||
warning = [
|
|
||||||
warning,
|
|
||||||
'Anuncios sin imagen/vídeo resoluble en la API creativa',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ads: out, warning };
|
return { ads: out };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google cards from ad_daily only (text fallback). */
|
/** Google cards from ad_daily only (text fallback). */
|
||||||
|
|||||||
Reference in New Issue
Block a user