fix: Meta creatividades — 5 titulares, 5 textos y descripción

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-28 18:50:56 +02:00
parent 331c19e873
commit 5e8fd66e35
4 changed files with 141 additions and 61 deletions

View File

@@ -16,19 +16,27 @@ export function proxiedMediaUrl(raw: string): string {
} }
function adHeadlines(ad: AdCreativeCard): string[] { function adHeadlines(ad: AdCreativeCard): string[] {
if (ad.headlines?.length) return ad.headlines; if (ad.headlines?.length) return ad.headlines.slice(0, 5);
return (ad.headline || '') return (ad.headline || '')
.split('\n') .split('\n')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean)
.slice(0, 5);
} }
function adDescriptions(ad: AdCreativeCard): string[] { function adBodies(ad: AdCreativeCard): string[] {
if (ad.descriptions?.length) return ad.descriptions; if (ad.bodies?.length) return ad.bodies.slice(0, 5);
// Legacy: primaryText used to hold bodies
return (ad.primaryText || '') return (ad.primaryText || '')
.split('\n') .split('\n')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean)
.slice(0, 5);
}
function adLinkDescriptions(ad: AdCreativeCard): string[] {
if (ad.descriptions?.length) return ad.descriptions.slice(0, 5);
return [];
} }
function adAssets(ad: AdCreativeCard): CreativeAsset[] { function adAssets(ad: AdCreativeCard): CreativeAsset[] {
@@ -186,8 +194,9 @@ function CreativeCard({
}) { }) {
const preview = previewUrlFor(ad); const preview = previewUrlFor(ad);
const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId); const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
const headline = adHeadlines(ad)[0] || ''; const headlines = adHeadlines(ad);
const body = adDescriptions(ad)[0] || ''; const bodies = adBodies(ad);
const linkDescs = adLinkDescriptions(ad);
const assets = adAssets(ad); const assets = adAssets(ad);
const extraCount = Math.max(0, assets.length - 1); const extraCount = Math.max(0, assets.length - 1);
@@ -206,14 +215,19 @@ function CreativeCard({
className="w-full h-full object-cover" className="w-full h-full object-cover"
loading="lazy" loading="lazy"
/> />
) : ad.platform === 'google' && headline ? ( ) : ad.platform === 'google' && headlines.length ? (
<div className="w-full h-full p-4 space-y-2 text-left bg-gradient-to-b from-[#1a2332] to-[#0d121f] flex flex-col justify-center"> <div className="w-full h-full p-4 space-y-2 text-left bg-gradient-to-b from-[#1a2332] to-[#0d121f] flex flex-col justify-center">
<p className="text-[10px] uppercase tracking-wider text-sky-400"> <p className="text-[10px] uppercase tracking-wider text-sky-400">
Google · {ad.mediaType || 'rsa'} Google · {ad.mediaType || 'rsa'}
</p> </p>
<p className="text-sm font-semibold text-blue-300 leading-snug line-clamp-4"> {headlines.slice(0, 3).map((h) => (
{headline} <p
key={h}
className="text-sm font-semibold text-blue-300 leading-snug line-clamp-2"
>
{h}
</p> </p>
))}
</div> </div>
) : ( ) : (
<div className="text-center text-gray-500 text-xs px-4"> <div className="text-center text-gray-500 text-xs px-4">
@@ -259,18 +273,55 @@ function CreativeCard({
<p className="text-xs text-gray-400">Conjunto: {ad.adsetName}</p> <p className="text-xs text-gray-400">Conjunto: {ad.adsetName}</p>
) : null} ) : null}
</div> </div>
{(headline || body) && ( {(headlines.length > 0 || bodies.length > 0 || linkDescs.length > 0) && (
<div className="space-y-1.5 text-xs border border-gray-200 rounded-xl p-3 bg-gray-50"> <div className="space-y-2.5 text-xs border border-gray-200 rounded-xl p-3 bg-gray-50">
{headline ? ( {headlines.length > 0 ? (
<p className="text-gray-900 font-medium leading-snug line-clamp-2"> <div>
{headline} <p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
Titulares ({headlines.length})
</p> </p>
<ul className="space-y-1">
{headlines.map((h) => (
<li
key={h}
className="text-gray-900 font-medium leading-snug"
>
{h}
</li>
))}
</ul>
</div>
) : null} ) : null}
{body ? ( {bodies.length > 0 ? (
<p className="text-gray-500 leading-relaxed line-clamp-3">{body}</p> <div>
<p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
Textos ({bodies.length})
</p>
<ul className="space-y-1.5">
{bodies.map((b) => (
<li key={b} className="text-gray-600 leading-relaxed">
{b}
</li>
))}
</ul>
</div>
) : null}
{linkDescs.length > 0 ? (
<div>
<p className="text-[9px] uppercase tracking-wider text-gray-500 mb-1">
Descripción
</p>
<ul className="space-y-1">
{linkDescs.map((d) => (
<li key={d} className="text-gray-500 leading-relaxed">
{d}
</li>
))}
</ul>
</div>
) : null} ) : null}
{ad.cta ? ( {ad.cta ? (
<p className="text-[10px] text-brand-700 uppercase tracking-wide pt-1"> <p className="text-[10px] text-brand-700 uppercase tracking-wide pt-0.5">
CTA · {ad.cta} CTA · {ad.cta}
</p> </p>
) : null} ) : null}
@@ -298,7 +349,8 @@ export function CreativeZoomModal({
onClose: () => void; onClose: () => void;
}) { }) {
const headlines = adHeadlines(ad); const headlines = adHeadlines(ad);
const bodies = adDescriptions(ad); const bodies = adBodies(ad);
const linkDescs = adLinkDescriptions(ad);
const longH = ad.longHeadlines || []; const longH = ad.longHeadlines || [];
const assets = adAssets(ad); const assets = adAssets(ad);
const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo'); const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo');
@@ -494,10 +546,10 @@ export function CreativeZoomModal({
{headlines.length > 0 ? ( {headlines.length > 0 ? (
<section> <section>
<h3 className="text-sm font-semibold text-gray-900 mb-2"> <h3 className="text-sm font-semibold text-gray-900 mb-2">
Titular Titulares ({headlines.length})
</h3> </h3>
<ul className="space-y-1.5 text-sm text-gray-800"> <ul className="space-y-1.5 text-sm text-gray-800">
{headlines.slice(0, 2).map((h) => ( {headlines.map((h) => (
<li <li
key={h} key={h}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2" className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
@@ -512,10 +564,10 @@ export function CreativeZoomModal({
{longH.length > 0 ? ( {longH.length > 0 ? (
<section> <section>
<h3 className="text-sm font-semibold text-gray-900 mb-2"> <h3 className="text-sm font-semibold text-gray-900 mb-2">
Titulares largos Titulares largos ({longH.length})
</h3> </h3>
<ul className="space-y-1.5 text-sm text-gray-800"> <ul className="space-y-1.5 text-sm text-gray-800">
{longH.slice(0, 2).map((h) => ( {longH.slice(0, 5).map((h) => (
<li <li
key={h} key={h}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2" className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
@@ -530,10 +582,10 @@ export function CreativeZoomModal({
{bodies.length > 0 ? ( {bodies.length > 0 ? (
<section> <section>
<h3 className="text-sm font-semibold text-gray-900 mb-2"> <h3 className="text-sm font-semibold text-gray-900 mb-2">
Texto Textos ({bodies.length})
</h3> </h3>
<ul className="space-y-2 text-sm text-gray-600"> <ul className="space-y-2 text-sm text-gray-600">
{bodies.slice(0, 2).map((b) => ( {bodies.map((b) => (
<li <li
key={b} key={b}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 leading-relaxed whitespace-pre-wrap" className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 leading-relaxed whitespace-pre-wrap"
@@ -544,6 +596,24 @@ export function CreativeZoomModal({
</ul> </ul>
</section> </section>
) : null} ) : null}
{linkDescs.length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Descripción
</h3>
<ul className="space-y-2 text-sm text-gray-600">
{linkDescs.map((d) => (
<li
key={d}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 leading-relaxed whitespace-pre-wrap"
>
{d}
</li>
))}
</ul>
</section>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -107,6 +107,9 @@ export default function DriveConnectPanel() {
<strong className="text-gray-800">Google Ads</strong>. <strong className="text-gray-800">Google Ads</strong>.
Sirve para carpetas Drive, GA4, GSC y creatividades Google Ads. Sirve para carpetas Drive, GA4, GSC y creatividades Google Ads.
Si ves error de token caducado en Ads, pulsa Reconectar Google. Si ves error de token caducado en Ads, pulsa Reconectar Google.
En Google Cloud el consent screen debe estar en{' '}
<strong className="text-gray-800">In production</strong> (si está
en Testing el refresh token caduca a los 7 días).
</p> </p>
</div> </div>
</div> </div>

View File

@@ -210,6 +210,7 @@ function cardsFromDaily(
headline: agg.campaignName || agg.adName, headline: agg.campaignName || agg.adName,
primaryText: description, primaryText: description,
headlines: [agg.campaignName || agg.adName].filter(Boolean), headlines: [agg.campaignName || agg.adName].filter(Boolean),
bodies: [],
descriptions: [description], descriptions: [description],
assets: [] as CreativeAsset[], assets: [] as CreativeAsset[],
status: undefined, status: undefined,
@@ -497,8 +498,9 @@ WHERE ${dateWhere}
headline: [...headlines, ...longHeadlines].join('\n'), headline: [...headlines, ...longHeadlines].join('\n'),
primaryText: descriptions.join('\n'), primaryText: descriptions.join('\n'),
headlines, headlines,
bodies: descriptions,
longHeadlines, longHeadlines,
descriptions, descriptions: [],
assets: entry.assets, assets: entry.assets,
linkUrl: entry.finalUrls[0], linkUrl: entry.finalUrls[0],
cta: uniq(entry.ctas)[0], cta: uniq(entry.ctas)[0],
@@ -553,8 +555,9 @@ WHERE ${dateWhere}
headline: [...headlines, ...longHeadlines].join('\n'), headline: [...headlines, ...longHeadlines].join('\n'),
primaryText: descriptions.join('\n'), primaryText: descriptions.join('\n'),
headlines, headlines,
bodies: descriptions,
longHeadlines, longHeadlines,
descriptions, descriptions: [],
assets, assets,
linkUrl: (ad?.finalUrls || []).map(str).filter(Boolean)[0], linkUrl: (ad?.finalUrls || []).map(str).filter(Boolean)[0],
cta: str(rda?.callToActionText) || undefined, cta: str(rda?.callToActionText) || undefined,
@@ -562,8 +565,7 @@ WHERE ${dateWhere}
status: enumName(row.adGroupAd?.status), status: enumName(row.adGroupAd?.status),
}); });
if (paths.length) { if (paths.length) {
// encode paths into descriptions note for zoom cards[cards.length - 1].bodies = [
cards[cards.length - 1].descriptions = [
...descriptions, ...descriptions,
`Path: /${paths.join('/')}`, `Path: /${paths.join('/')}`,
]; ];

View File

@@ -48,10 +48,14 @@ export type AdCreativeCard = {
/** Best preview URL (prefer vertical / portrait). */ /** Best preview URL (prefer vertical / portrait). */
thumbnailUrl: string; thumbnailUrl: string;
imageUrl: string; imageUrl: string;
/** Joined for backwards compat; prefer headlines/descriptions arrays. */ /** Joined for backwards compat; prefer headlines/bodies/descriptions arrays. */
headline: string; headline: string;
primaryText: string; primaryText: string;
/** Titulares (Meta titles / Google headlines), máx. 5. */
headlines: string[]; headlines: string[];
/** Textos principales (Meta bodies), máx. 5. */
bodies: string[];
/** Descripción del enlace (Meta descriptions), suele ser 1. */
descriptions: string[]; descriptions: string[];
longHeadlines?: string[]; longHeadlines?: string[];
assets: CreativeAsset[]; assets: CreativeAsset[];
@@ -122,6 +126,7 @@ function detectMediaType(creative: Record<string, unknown>): string {
type Extracted = { type Extracted = {
headlines: string[]; headlines: string[];
bodies: string[];
descriptions: string[]; descriptions: string[];
assets: CreativeAsset[]; assets: CreativeAsset[];
linkUrl: string; linkUrl: string;
@@ -131,6 +136,7 @@ type Extracted = {
function extractCreativePack(creative: Record<string, unknown>): Extracted { function extractCreativePack(creative: Record<string, unknown>): Extracted {
const headlines: string[] = []; const headlines: string[] = [];
const bodies: string[] = [];
const descriptions: string[] = []; const descriptions: string[] = [];
const assets: CreativeAsset[] = []; const assets: CreativeAsset[] = [];
const hashes: string[] = []; const hashes: string[] = [];
@@ -155,50 +161,56 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
}); });
}; };
// 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 oss = creative.object_story_spec as Record<string, unknown> | undefined;
const link = oss?.link_data 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 video = oss?.video_data as Record<string, unknown> | undefined;
const photo = oss?.photo_data as Record<string, unknown> | undefined; const photo = oss?.photo_data as Record<string, unknown> | undefined;
// Story (anuncio publicado): 1 titular + texto + descripción de enlace.
if (link?.name) headlines.push(str(link.name)); if (link?.name) headlines.push(str(link.name));
if (link?.message) descriptions.push(str(link.message)); if (link?.message) bodies.push(str(link.message));
else if (link?.description) descriptions.push(str(link.description)); if (link?.description) descriptions.push(str(link.description));
pushImg(link?.picture, '4:5 story', 1080, 1350); pushImg(link?.picture, '4:5 story', 1080, 1350);
pushImg(link?.image_url, '4:5 story', 1080, 1350); pushImg(link?.image_url, '4:5 story', 1080, 1350);
pushHash(link?.image_hash); pushHash(link?.image_hash);
if (video?.title) headlines.push(str(video.title)); if (video?.title) headlines.push(str(video.title));
if (video?.message) descriptions.push(str(video.message)); if (video?.message) bodies.push(str(video.message));
else if (video?.link_description) descriptions.push(str(video.link_description)); if (video?.link_description) descriptions.push(str(video.link_description));
pushImg(video?.image_url, 'video_poster 9:16', 1080, 1920); pushImg(video?.image_url, 'video_poster 9:16', 1080, 1920);
pushHash(video?.image_hash); pushHash(video?.image_hash);
pushImg(photo?.url, 'photo 4:5', 1080, 1350); pushImg(photo?.url, 'photo 4:5', 1080, 1350);
pushHash(photo?.image_hash); pushHash(photo?.image_hash);
if (!headlines.length && creative.title) headlines.push(str(creative.title)); if (creative.title) headlines.push(str(creative.title));
if (!descriptions.length && creative.body) descriptions.push(str(creative.body)); if (creative.body) bodies.push(str(creative.body));
pushImg(creative.image_url, 'image 4:5', 1080, 1350); pushImg(creative.image_url, 'image 4:5', 1080, 1350);
pushImg(creative.thumbnail_url, 'thumbnail 9:16', 1080, 1920); pushImg(creative.thumbnail_url, 'thumbnail 9:16', 1080, 1920);
pushHash(creative.image_hash); pushHash(creative.image_hash);
// Carousel: keep images; only first card's name as optional secondary headline.
if (Array.isArray(link?.child_attachments)) { if (Array.isArray(link?.child_attachments)) {
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => { (link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
pushImg(c.picture || c.image_url, `carousel-${i + 1} 4:5`, 1080, 1350); pushImg(c.picture || c.image_url, `carousel-${i + 1} 4:5`, 1080, 1350);
pushHash(c.image_hash); pushHash(c.image_hash);
if (i === 0 && c.name && !headlines.length) headlines.push(str(c.name)); if (c.name) headlines.push(str(c.name));
if (i === 0 && c.description && !descriptions.length) { if (c.message) bodies.push(str(c.message));
descriptions.push(str(c.description)); if (c.description) descriptions.push(str(c.description));
}
}); });
} }
// Dynamic creative / Advantage+: hasta 5 titles, 5 bodies, descriptions.
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined; const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
if (feed) { if (feed) {
// Images/videos from dynamic creative — label formats; texts only if still empty. 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) bodies.push(str(b.text));
}
for (const d of (feed.descriptions as { text?: string }[]) || []) {
if (d?.text) descriptions.push(str(d.text));
}
for (const img of (feed.images as Record<string, unknown>[]) || []) { for (const img of (feed.images as Record<string, unknown>[]) || []) {
const labels = Array.isArray(img.adlabels) const labels = Array.isArray(img.adlabels)
? (img.adlabels as { name?: string }[]) ? (img.adlabels as { name?: string }[])
@@ -206,10 +218,9 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
: ''; : '';
const tag = labels || 'feed';
pushImg( pushImg(
img.url || img.permalink_url, img.url || img.permalink_url,
tag, labels || 'feed',
n(img.width) || undefined, n(img.width) || undefined,
n(img.height) || undefined n(img.height) || undefined
); );
@@ -227,16 +238,6 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
}); });
} }
} }
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 = const linkUrl =
@@ -248,8 +249,9 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
const cta = str(creative.call_to_action_type) || str(ctaRaw); const cta = str(creative.call_to_action_type) || str(ctaRaw);
return { return {
headlines: uniq(headlines).slice(0, 3), headlines: uniq(headlines).slice(0, 5),
descriptions: uniq(descriptions).slice(0, 3), bodies: uniq(bodies).slice(0, 5),
descriptions: uniq(descriptions).slice(0, 5),
assets, assets,
linkUrl, linkUrl,
cta, cta,
@@ -549,6 +551,7 @@ function cardFromBundle(
str(creative.image_url) || str(creative.thumbnail_url) || ''; str(creative.image_url) || str(creative.thumbnail_url) || '';
const preview = pickVerticalPreview(assets, fallback); const preview = pickVerticalPreview(assets, fallback);
const headlines = pack.headlines; const headlines = pack.headlines;
const bodies = pack.bodies;
const descriptions = pack.descriptions; const descriptions = pack.descriptions;
return { return {
platform: 'meta', platform: 'meta',
@@ -565,8 +568,9 @@ function cardFromBundle(
thumbnailUrl: preview, thumbnailUrl: preview,
imageUrl: preview, imageUrl: preview,
headline: headlines.join('\n'), headline: headlines.join('\n'),
primaryText: descriptions.join('\n'), primaryText: bodies.join('\n') || descriptions.join('\n'),
headlines, headlines,
bodies,
descriptions, descriptions,
assets, assets,
linkUrl: pack.linkUrl || undefined, linkUrl: pack.linkUrl || undefined,
@@ -751,6 +755,7 @@ export function buildGoogleCardsFromDaily(
headline: agg.adName, headline: agg.adName,
primaryText: agg.campaignName, primaryText: agg.campaignName,
headlines: agg.adName ? [agg.adName] : [], headlines: agg.adName ? [agg.adName] : [],
bodies: [],
descriptions: agg.campaignName ? [agg.campaignName] : [], descriptions: agg.campaignName ? [agg.campaignName] : [],
assets: [], assets: [],
status: undefined, status: undefined,