fix: creatividades — Google siempre con fallback; Meta preview 9:16 y textos reales

OAuth Ads caducado: aviso amable + campañas warehouse. Meta deja de volcar todas las variantes del asset_feed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-28 18:46:24 +02:00
parent 6d8dfaa79c
commit 331c19e873
8 changed files with 435 additions and 314 deletions

View File

@@ -22,10 +22,13 @@ const CREATIVE_FIELDS = [
'effective_object_story_id',
].join(',');
export type CreativeAspect = '9:16' | '4:5' | '16:9' | '1:1' | 'unknown';
export type CreativeAsset = {
kind: 'image' | 'video' | 'logo' | 'youtube';
url: string;
label?: string;
aspect?: CreativeAspect;
youtubeId?: string;
title?: string;
};
@@ -82,6 +85,28 @@ function uniq(arr: string[]): string[] {
return [...new Set(arr.filter(Boolean))];
}
export function inferCreativeAspect(
label?: string,
width?: number,
height?: number
): CreativeAspect {
const L = (label || '').toLowerCase();
if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16';
if (/4\s*:?\s*5|portrait|feed_portrait/.test(L)) return '4:5';
if (/16\s*:?\s*9|1\.91|landscape|horizontal|right_column|video/.test(L)) {
return '16:9';
}
if (/1\s*:?\s*1|square/.test(L)) return '1:1';
if (width && height && width > 0 && height > 0) {
const r = width / height;
if (r <= 0.62) return '9:16';
if (r <= 0.9) return '4:5';
if (r >= 1.5) return '16:9';
if (Math.abs(r - 1) < 0.12) return '1:1';
}
return 'unknown';
}
function detectMediaType(creative: Record<string, unknown>): string {
if (creative.video_id) return 'video';
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
@@ -113,74 +138,105 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
const s = str(h);
if (/^[a-f0-9]{16,64}$/i.test(s)) hashes.push(s);
};
const pushImg = (url: unknown, label?: string) => {
const pushImg = (
url: unknown,
label?: string,
width?: number,
height?: number
) => {
const u = str(url);
if (!/^https?:\/\//i.test(u)) return;
if (assets.some((a) => a.url === u)) return;
assets.push({ kind: 'image', url: u, label });
assets.push({
kind: 'image',
url: u,
label,
aspect: inferCreativeAspect(label, width, height),
});
};
if (creative.title) headlines.push(str(creative.title));
if (creative.body) descriptions.push(str(creative.body));
pushImg(creative.image_url, 'image');
pushImg(creative.thumbnail_url, 'thumbnail');
pushHash(creative.image_hash);
// Primary serving copy = object_story_spec (what Ads Manager shows as the ad).
// Do NOT dump every asset_feed_spec A/B variant into the card.
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
const link = oss?.link_data as Record<string, unknown> | undefined;
const video = oss?.video_data as Record<string, unknown> | undefined;
const photo = oss?.photo_data as Record<string, unknown> | undefined;
if (link?.name) headlines.push(str(link.name));
if (link?.message) descriptions.push(str(link.message));
if (link?.description) descriptions.push(str(link.description));
if (link?.caption) descriptions.push(str(link.caption));
pushImg(link?.picture, 'story');
pushImg(link?.image_url, 'story');
else if (link?.description) descriptions.push(str(link.description));
pushImg(link?.picture, '4:5 story', 1080, 1350);
pushImg(link?.image_url, '4:5 story', 1080, 1350);
pushHash(link?.image_hash);
if (video?.title) headlines.push(str(video.title));
if (video?.message) descriptions.push(str(video.message));
if (video?.link_description) descriptions.push(str(video.link_description));
pushImg(video?.image_url, 'video_poster');
else if (video?.link_description) descriptions.push(str(video.link_description));
pushImg(video?.image_url, 'video_poster 9:16', 1080, 1920);
pushHash(video?.image_hash);
pushImg(photo?.url, 'photo');
pushImg(photo?.url, 'photo 4:5', 1080, 1350);
pushHash(photo?.image_hash);
if (!headlines.length && creative.title) headlines.push(str(creative.title));
if (!descriptions.length && creative.body) descriptions.push(str(creative.body));
pushImg(creative.image_url, 'image 4:5', 1080, 1350);
pushImg(creative.thumbnail_url, 'thumbnail 9:16', 1080, 1920);
pushHash(creative.image_hash);
// Carousel: keep images; only first card's name as optional secondary headline.
if (Array.isArray(link?.child_attachments)) {
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
pushImg(c.picture || c.image_url, `carousel-${i + 1}`);
pushImg(c.picture || c.image_url, `carousel-${i + 1} 4:5`, 1080, 1350);
pushHash(c.image_hash);
if (c.name) headlines.push(str(c.name));
if (c.description) descriptions.push(str(c.description));
if (c.message) descriptions.push(str(c.message));
if (i === 0 && c.name && !headlines.length) headlines.push(str(c.name));
if (i === 0 && c.description && !descriptions.length) {
descriptions.push(str(c.description));
}
});
}
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
if (feed) {
for (const t of (feed.titles as { text?: string }[]) || []) {
if (t?.text) headlines.push(str(t.text));
}
for (const b of (feed.bodies as { text?: string }[]) || []) {
if (b?.text) descriptions.push(str(b.text));
}
for (const d of (feed.descriptions as { text?: string }[]) || []) {
if (d?.text) descriptions.push(str(d.text));
}
// Images/videos from dynamic creative — label formats; texts only if still empty.
for (const img of (feed.images as Record<string, unknown>[]) || []) {
pushImg(img.url || img.permalink_url, 'feed');
const labels = Array.isArray(img.adlabels)
? (img.adlabels as { name?: string }[])
.map((l) => str(l.name))
.filter(Boolean)
.join(' ')
: '';
const tag = labels || 'feed';
pushImg(
img.url || img.permalink_url,
tag,
n(img.width) || undefined,
n(img.height) || undefined
);
pushHash(img.hash);
}
for (const v of (feed.videos as Record<string, unknown>[]) || []) {
pushImg(v.thumbnail_url || v.picture, 'video_feed');
pushImg(v.thumbnail_url || v.picture, 'video_feed 9:16', 1080, 1920);
if (v.video_id) {
assets.push({
kind: 'video',
url: str(v.thumbnail_url || v.picture),
label: 'video',
label: 'video 9:16',
title: str(v.video_id),
aspect: '9:16',
});
}
}
if (!headlines.length) {
const t = (feed.titles as { text?: string }[] | undefined)?.[0]?.text;
if (t) headlines.push(str(t));
}
if (!descriptions.length) {
const b =
(feed.bodies as { text?: string }[] | undefined)?.[0]?.text ||
(feed.descriptions as { text?: string }[] | undefined)?.[0]?.text;
if (b) descriptions.push(str(b));
}
}
const linkUrl =
@@ -192,8 +248,8 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
const cta = str(creative.call_to_action_type) || str(ctaRaw);
return {
headlines: uniq(headlines),
descriptions: uniq(descriptions),
headlines: uniq(headlines).slice(0, 3),
descriptions: uniq(descriptions).slice(0, 3),
assets,
linkUrl,
cta,
@@ -201,15 +257,20 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
};
}
/** Prefer portrait/vertical-looking assets; else first image. */
/** Prefer 9:16 story; then 4:5; else first image. */
function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string {
const images = assets.filter((a) => a.kind === 'image' && a.url);
const portrait = images.find((a) =>
/portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
const nine = images.find((a) => a.aspect === '9:16');
if (nine) return nine.url;
const portrait = images.find(
(a) =>
a.aspect === '4:5' ||
/portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
);
if (portrait) return portrait.url;
// Meta often serves taller thumbnails when requested 1080x1920 — thumbnail label last
const nonSquare = images.find((a) => a.label === 'thumbnail' || a.label === 'video_poster');
const nonSquare = images.find(
(a) => a.label?.includes('thumbnail') || a.label?.includes('video_poster')
);
if (nonSquare) return nonSquare.url;
return images[0]?.url || fallback;
}