Show every Google PMax text, image and YouTube asset in Ads zoom.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 13:38:24 +02:00
parent 8db642fa2a
commit b9ddc7b806
4 changed files with 350 additions and 134 deletions

View File

@@ -183,7 +183,7 @@ export async function GET(req: NextRequest) {
dailyRows: daily, dailyRows: daily,
since, since,
until, until,
limit: 48, limit: 80,
}); });
googleAds = google.ads.length googleAds = google.ads.length
? google.ads ? google.ads

View File

@@ -17,26 +17,29 @@ export function proxiedMediaUrl(raw: string): string {
} }
function adHeadlines(ad: AdCreativeCard): string[] { function adHeadlines(ad: AdCreativeCard): string[] {
if (ad.headlines?.length) return ad.headlines.slice(0, 5); const all = ad.headlines?.length
return (ad.headline || '') ? ad.headlines
: (ad.headline || '')
.split('\n') .split('\n')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean);
.slice(0, 5); if (ad.platform === 'google') return all;
return all.slice(0, 5);
} }
function adBodies(ad: AdCreativeCard): string[] { function adBodies(ad: AdCreativeCard): string[] {
if (ad.bodies?.length) return ad.bodies.slice(0, 5); const all = ad.bodies?.length
// Legacy: primaryText used to hold bodies ? ad.bodies
return (ad.primaryText || '') : (ad.primaryText || '')
.split('\n') .split('\n')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean);
.slice(0, 5); if (ad.platform === 'google') return all;
return all.slice(0, 5);
} }
function adLinkDescriptions(ad: AdCreativeCard): string[] { function adLinkDescriptions(ad: AdCreativeCard): string[] {
if (ad.descriptions?.length) return ad.descriptions.slice(0, 5); if (ad.descriptions?.length) return ad.descriptions;
return []; return [];
} }
@@ -158,19 +161,19 @@ function previewFor(ad: AdCreativeCard): { url: string; aspect: CreativeAspect }
function formatThumbs(ad: AdCreativeCard): { url: string; aspect: CreativeAspect; label: string }[] { function formatThumbs(ad: AdCreativeCard): { url: string; aspect: CreativeAspect; label: string }[] {
if (ad.mediaType !== 'pmax' && ad.channel !== 'pmax') return []; if (ad.mediaType !== 'pmax' && ad.channel !== 'pmax') return [];
const assets = adAssets(ad).filter( const assets = adAssets(ad).filter(
(a) => (a.kind === 'image' || a.kind === 'logo' || a.kind === 'youtube') && a.url (a) =>
(a.kind === 'image' || a.kind === 'logo' || a.kind === 'youtube') && a.url
); );
const order: CreativeAspect[] = ['1:1', '4:5', '16:9', '9:16'];
const out: { url: string; aspect: CreativeAspect; label: string }[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const asp of order) { const out: { url: string; aspect: CreativeAspect; label: string }[] = [];
const hit = assets.find((a) => resolveAspect(a) === asp && !seen.has(a.url)); for (const a of assets) {
if (!hit) continue; if (seen.has(a.url)) continue;
seen.add(hit.url); seen.add(a.url);
const asp = resolveAspect(a);
out.push({ out.push({
url: proxiedMediaUrl(hit.url), url: proxiedMediaUrl(a.url),
aspect: asp, aspect: asp === 'unknown' ? '1:1' : asp,
label: asp, label: a.label || asp,
}); });
} }
return out; return out;
@@ -451,15 +454,15 @@ function CreativeCard({
<Maximize2 className="w-3.5 h-3.5" /> Zoom <Maximize2 className="w-3.5 h-3.5" /> Zoom
</span> </span>
</button> </button>
{pmaxFormats.length > 1 ? ( {pmaxFormats.length > 0 ? (
<div className="flex flex-wrap gap-1.5 justify-center"> <div className="flex flex-wrap gap-1.5 justify-center">
{pmaxFormats.map((f) => ( {pmaxFormats.slice(0, 12).map((f, i) => (
<button <button
key={f.label} key={`${f.url}-${i}`}
type="button" type="button"
onClick={onZoom} onClick={onZoom}
className="w-12 overflow-hidden rounded-md border border-gray-200 bg-gray-50" className="w-12 overflow-hidden rounded-md border border-gray-200 bg-gray-50"
title={`Formato ${f.label}`} title={f.label}
> >
<div className={`${aspectClass(f.aspect)} w-full`}> <div className={`${aspectClass(f.aspect)} w-full`}>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -475,6 +478,11 @@ function CreativeCard({
</p> </p>
</button> </button>
))} ))}
{pmaxFormats.length > 12 ? (
<p className="w-full text-center text-[10px] text-gray-500">
+{pmaxFormats.length - 12} en Zoom
</p>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -585,21 +593,24 @@ export function CreativeZoomModal({
const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo'); const images = assets.filter((a) => a.kind === 'image' || a.kind === 'logo');
const videos = assets.filter((a) => a.kind === 'youtube' || a.kind === 'video'); const videos = assets.filter((a) => a.kind === 'youtube' || a.kind === 'video');
const { url: main, aspect: mainAspect } = previewFor(ad); const { url: main, aspect: mainAspect } = previewFor(ad);
const previewAssetUrl = main.replace(/^\/api\/ad-tracker\/media\?url=/, ''); const zoomImages =
const otherImages = images.filter((a) => { ad.platform === 'google'
? images.filter((a) => a.url)
: images.filter((a) => {
if (!a.url) return false; if (!a.url) return false;
const previewAssetUrl = main.replace(
/^\/api\/ad-tracker\/media\?url=/,
''
);
if (!previewAssetUrl) return true; if (!previewAssetUrl) return true;
try { try {
return decodeURIComponent(previewAssetUrl) !== a.url && a.url !== main; return (
decodeURIComponent(previewAssetUrl) !== a.url && a.url !== main
);
} catch { } catch {
return a.url !== main; return a.url !== main;
} }
}); });
// Prefer showing all format variants in zoom for Meta carousel / Google PMax
const zoomImages =
otherImages.length > 0
? otherImages
: images.filter((a) => a.url && proxiedMediaUrl(a.url) !== main);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -714,7 +725,9 @@ export function CreativeZoomModal({
{zoomImages.length > 0 ? ( {zoomImages.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">
Otras piezas ({zoomImages.length}) {ad.platform === 'google'
? `Imágenes y logos (${zoomImages.length})`
: `Otras piezas (${zoomImages.length})`}
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{zoomImages.map((asset, i) => { {zoomImages.map((asset, i) => {
@@ -810,7 +823,7 @@ export function CreativeZoomModal({
Titulares largos ({longH.length}) 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, 5).map((h) => ( {longH.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"
@@ -857,6 +870,89 @@ export function CreativeZoomModal({
</ul> </ul>
</section> </section>
) : null} ) : null}
{ad.businessName ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Nombre de negocio
</h3>
<p className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800">
{ad.businessName}
</p>
</section>
) : null}
{(ad.ctas || []).length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
CTA ({ad.ctas!.length})
</h3>
<ul className="flex flex-wrap gap-1.5">
{ad.ctas!.map((c) => (
<li
key={c}
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs text-gray-800"
>
{c}
</li>
))}
</ul>
</section>
) : null}
{(ad.sitelinks || []).length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Sitelinks ({ad.sitelinks!.length})
</h3>
<ul className="space-y-1.5 text-sm text-gray-800">
{ad.sitelinks!.map((s) => (
<li
key={s}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
{s}
</li>
))}
</ul>
</section>
) : null}
{(ad.callouts || []).length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Callouts ({ad.callouts!.length})
</h3>
<ul className="space-y-1.5 text-sm text-gray-800">
{ad.callouts!.map((s) => (
<li
key={s}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
{s}
</li>
))}
</ul>
</section>
) : null}
{(ad.snippets || []).length > 0 ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Structured snippets ({ad.snippets!.length})
</h3>
<ul className="space-y-1.5 text-sm text-gray-800">
{ad.snippets!.map((s) => (
<li
key={s}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
{s}
</li>
))}
</ul>
</section>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -8,7 +8,10 @@ import type {
CreativeAsset, CreativeAsset,
GoogleCreativeChannel, GoogleCreativeChannel,
} from '@/lib/ad-creatives-meta'; } from '@/lib/ad-creatives-meta';
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta'; import {
aggregateDailyByAd,
inferCreativeAspect,
} from '@/lib/ad-creatives-meta';
import { import {
friendlyGoogleCreativeWarning, friendlyGoogleCreativeWarning,
resolveGoogleAdsAccessToken, resolveGoogleAdsAccessToken,
@@ -58,13 +61,23 @@ type GaqlRow = {
asset?: { asset?: {
id?: string; id?: string;
type?: string; type?: string;
name?: string;
textAsset?: { text?: string }; textAsset?: { text?: string };
imageAsset?: { fullSize?: { url?: string } }; imageAsset?: {
fullSize?: { url?: string; widthPixels?: number; heightPixels?: number };
};
youtubeVideoAsset?: { youtubeVideoAsset?: {
youtubeVideoId?: string; youtubeVideoId?: string;
youtubeVideoTitle?: string; youtubeVideoTitle?: string;
}; };
callToActionAsset?: { callToAction?: string }; callToActionAsset?: { callToAction?: string };
sitelinkAsset?: {
linkText?: string;
description1?: string;
description2?: string;
};
calloutAsset?: { calloutText?: string };
structuredSnippetAsset?: { header?: string; values?: string[] };
}; };
metrics?: { metrics?: {
costMicros?: string; costMicros?: string;
@@ -211,9 +224,14 @@ function ytThumb(youtubeId: string): string {
function googleAspectFromFieldType(ft: string): CreativeAspect { function googleAspectFromFieldType(ft: string): CreativeAspect {
const u = ft.toUpperCase(); const u = ft.toUpperCase();
if (u.includes('PORTRAIT') || u.includes('TALL')) return '4:5'; if (u.includes('TALL_PORTRAIT') || u.includes('VERTICAL')) return '9:16';
if (u.includes('SQUARE') || u === 'LOGO') return '1:1'; if (u.includes('PORTRAIT')) return '4:5';
if (u.includes('YOUTUBE') || u.includes('LANDSCAPE') || u === 'MARKETING_IMAGE') { if (u.includes('SQUARE') || u === 'LOGO' || u === 'BUSINESS_LOGO') return '1:1';
if (
u.includes('YOUTUBE') ||
u.includes('LANDSCAPE') ||
u === 'MARKETING_IMAGE'
) {
return '16:9'; return '16:9';
} }
return 'unknown'; return 'unknown';
@@ -235,11 +253,6 @@ function pickVerticalGoogle(assets: CreativeAsset[]): string {
return images[0]?.url || ''; return images[0]?.url || '';
} }
function withAspect(asset: CreativeAsset, ft: string): CreativeAsset {
if (asset.aspect && asset.aspect !== 'unknown') return asset;
return { ...asset, aspect: googleAspectFromFieldType(ft) };
}
/** Keep Search / PMax / Display represented in the gallery, not only top spend. */ /** Keep Search / PMax / Display represented in the gallery, not only top spend. */
function balanceGoogleCards(cards: AdCreativeCard[], limit: number): AdCreativeCard[] { function balanceGoogleCards(cards: AdCreativeCard[], limit: number): AdCreativeCard[] {
const buckets: Record<string, AdCreativeCard[]> = { const buckets: Record<string, AdCreativeCard[]> = {
@@ -259,7 +272,7 @@ function balanceGoogleCards(cards: AdCreativeCard[], limit: number): AdCreativeC
const quotas: Record<string, number> = { const quotas: Record<string, number> = {
search_brand: Math.max(8, Math.floor(limit * 0.2)), search_brand: Math.max(8, Math.floor(limit * 0.2)),
search_generic: Math.max(8, Math.floor(limit * 0.2)), search_generic: Math.max(8, Math.floor(limit * 0.2)),
pmax: Math.max(12, Math.floor(limit * 0.35)), pmax: Math.max(24, Math.floor(limit * 0.4)),
display: Math.max(8, Math.floor(limit * 0.2)), display: Math.max(8, Math.floor(limit * 0.2)),
other: Math.max(4, Math.floor(limit * 0.1)), other: Math.max(4, Math.floor(limit * 0.1)),
}; };
@@ -295,9 +308,103 @@ type AgBucket = {
longHeadlines: string[]; longHeadlines: string[];
descriptions: string[]; descriptions: string[];
ctas: string[]; ctas: string[];
businessNames: string[];
sitelinks: string[];
callouts: string[];
snippets: string[];
assets: CreativeAsset[]; assets: CreativeAsset[];
}; };
function pushGoogleImage(
list: CreativeAsset[],
img: string,
ft: string,
kind: CreativeAsset['kind'],
width?: number,
height?: number
) {
if (!img || list.some((a) => a.url === img)) return;
const fromPx = inferCreativeAspect(ft, width, height);
const fromFt = googleAspectFromFieldType(ft);
list.push({
kind,
url: img,
label: ft || 'IMAGE',
aspect: fromPx !== 'unknown' ? fromPx : fromFt,
});
}
function pushGoogleYoutube(
list: CreativeAsset[],
yt: string,
title?: string
) {
if (!yt || list.some((a) => a.youtubeId === yt)) return;
list.push({
kind: 'youtube',
url: ytThumb(yt),
label: 'YOUTUBE_VIDEO',
youtubeId: yt,
title: title || yt,
aspect: '16:9',
});
}
const PMAX_ASSET_QUERY = `
SELECT
campaign.id,
campaign.name,
campaign.advertising_channel_type,
asset_group.id,
asset_group.name,
asset_group.status,
asset_group.final_urls,
asset_group_asset.field_type,
asset.id,
asset.type,
asset.name,
asset.text_asset.text,
asset.image_asset.full_size.url,
asset.image_asset.full_size.width_pixels,
asset.image_asset.full_size.height_pixels,
asset.youtube_video_asset.youtube_video_id,
asset.youtube_video_asset.youtube_video_title,
asset.call_to_action_asset.call_to_action,
asset.sitelink_asset.link_text,
asset.sitelink_asset.description1,
asset.sitelink_asset.description2,
asset.callout_asset.callout_text,
asset.structured_snippet_asset.header,
asset.structured_snippet_asset.values
FROM asset_group_asset
WHERE asset_group.status != 'REMOVED'
AND asset_group_asset.status != 'REMOVED'
`;
const PMAX_ASSET_QUERY_BASIC = `
SELECT
campaign.id,
campaign.name,
campaign.advertising_channel_type,
asset_group.id,
asset_group.name,
asset_group.status,
asset_group.final_urls,
asset_group_asset.field_type,
asset.id,
asset.type,
asset.text_asset.text,
asset.image_asset.full_size.url,
asset.image_asset.full_size.width_pixels,
asset.image_asset.full_size.height_pixels,
asset.youtube_video_asset.youtube_video_id,
asset.youtube_video_asset.youtube_video_title,
asset.call_to_action_asset.call_to_action
FROM asset_group_asset
WHERE asset_group.status != 'REMOVED'
AND asset_group_asset.status != 'REMOVED'
`;
function cardsFromDaily( function cardsFromDaily(
dailyRows: Parameters<typeof aggregateDailyByAd>[0], dailyRows: Parameters<typeof aggregateDailyByAd>[0],
since: string, since: string,
@@ -398,30 +505,8 @@ export async function fetchGoogleCreativeCards(opts: {
try { try {
const [agAssets, agMetrics, adRows, adMetrics, adAssets] = await Promise.all([ const [agAssets, agMetrics, adRows, adMetrics, adAssets] = await Promise.all([
searchGoogle( searchGoogle(customerId, accessToken, PMAX_ASSET_QUERY).catch(() =>
customerId, searchGoogle(customerId, accessToken, PMAX_ASSET_QUERY_BASIC)
accessToken,
`
SELECT
campaign.id,
campaign.name,
campaign.advertising_channel_type,
asset_group.id,
asset_group.name,
asset_group.status,
asset_group.final_urls,
asset_group_asset.field_type,
asset.id,
asset.type,
asset.text_asset.text,
asset.image_asset.full_size.url,
asset.youtube_video_asset.youtube_video_id,
asset.youtube_video_asset.youtube_video_title,
asset.call_to_action_asset.call_to_action
FROM asset_group_asset
WHERE asset_group.status != 'REMOVED'
AND asset_group_asset.status != 'REMOVED'
`
), ),
searchGoogle( searchGoogle(
customerId, customerId,
@@ -466,7 +551,6 @@ SELECT
FROM ad_group_ad FROM ad_group_ad
WHERE ad_group_ad.status != 'REMOVED' WHERE ad_group_ad.status != 'REMOVED'
AND campaign.status != 'REMOVED' AND campaign.status != 'REMOVED'
LIMIT 120
` `
), ),
searchGoogle( searchGoogle(
@@ -500,11 +584,13 @@ WHERE ad_group_ad.status != 'REMOVED'
'MARKETING_IMAGE', 'MARKETING_IMAGE',
'SQUARE_MARKETING_IMAGE', 'SQUARE_MARKETING_IMAGE',
'PORTRAIT_MARKETING_IMAGE', 'PORTRAIT_MARKETING_IMAGE',
'TALL_PORTRAIT_MARKETING_IMAGE',
'LOGO', 'LOGO',
'LANDSCAPE_LOGO', 'LANDSCAPE_LOGO',
'BUSINESS_LOGO',
'AD_IMAGE',
'YOUTUBE_VIDEO' 'YOUTUBE_VIDEO'
) )
LIMIT 400
` `
).catch(() => [] as GaqlRow[]), ).catch(() => [] as GaqlRow[]),
]); ]);
@@ -516,30 +602,21 @@ LIMIT 400
const list = assetsByAdId.get(adId) || []; const list = assetsByAdId.get(adId) || [];
const ft = enumName(row.adGroupAdAssetView?.fieldType).toUpperCase(); const ft = enumName(row.adGroupAdAssetView?.fieldType).toUpperCase();
const img = str(row.asset?.imageAsset?.fullSize?.url); const img = str(row.asset?.imageAsset?.fullSize?.url);
const w = n(row.asset?.imageAsset?.fullSize?.widthPixels) || undefined;
const h = n(row.asset?.imageAsset?.fullSize?.heightPixels) || undefined;
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId); const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle); const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
if (img && !list.some((a) => a.url === img)) { if (img) {
list.push( pushGoogleImage(
withAspect( list,
{ img,
kind: ft.includes('LOGO') ? 'logo' : 'image', ft || 'MARKETING_IMAGE',
url: img, ft.includes('LOGO') ? 'logo' : 'image',
label: ft || 'MARKETING_IMAGE', w,
}, h
ft || 'MARKETING_IMAGE'
)
); );
} }
if (yt && !list.some((a) => a.youtubeId === yt)) { if (yt) pushGoogleYoutube(list, yt, ytTitle);
list.push({
kind: 'youtube',
url: ytThumb(yt),
label: 'YOUTUBE_VIDEO',
youtubeId: yt,
title: ytTitle || yt,
aspect: '16:9',
});
}
assetsByAdId.set(adId, list); assetsByAdId.set(adId, list);
} }
@@ -613,6 +690,10 @@ LIMIT 400
longHeadlines: [], longHeadlines: [],
descriptions: [], descriptions: [],
ctas: [], ctas: [],
businessNames: [],
sitelinks: [],
callouts: [],
snippets: [],
assets: [], assets: [],
}; };
agMap.set(agid, entry); agMap.set(agid, entry);
@@ -620,37 +701,51 @@ LIMIT 400
const ft = enumName(row.assetGroupAsset?.fieldType).toUpperCase(); const ft = enumName(row.assetGroupAsset?.fieldType).toUpperCase();
const text = str(row.asset?.textAsset?.text); const text = str(row.asset?.textAsset?.text);
const img = str(row.asset?.imageAsset?.fullSize?.url); const img = str(row.asset?.imageAsset?.fullSize?.url);
const imgW = n(row.asset?.imageAsset?.fullSize?.widthPixels) || undefined;
const imgH = n(row.asset?.imageAsset?.fullSize?.heightPixels) || undefined;
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId); const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle); const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
const cta = enumName(row.asset?.callToActionAsset?.callToAction); const cta = enumName(row.asset?.callToActionAsset?.callToAction);
const sitelink = row.asset?.sitelinkAsset;
const callout = str(row.asset?.calloutAsset?.calloutText);
const snippet = row.asset?.structuredSnippetAsset;
if (ft === 'HEADLINE' && text) entry.headlines.push(text); if ((ft === 'HEADLINE' || ft === 'MANDATORY_AD_TEXT') && text) {
else if (ft === 'LONG_HEADLINE' && text) entry.longHeadlines.push(text); entry.headlines.push(text);
else if (ft === 'DESCRIPTION' && text) entry.descriptions.push(text); } else if (ft === 'LONG_HEADLINE' && text) {
else if (ft === 'CALL_TO_ACTION_SELECTION' && cta) entry.ctas.push(cta); entry.longHeadlines.push(text);
else if (ft.includes('MARKETING_IMAGE') && img) { } else if (ft === 'DESCRIPTION' && text) {
if (!entry.assets.some((a) => a.url === img)) { entry.descriptions.push(text);
entry.assets.push( } else if (ft === 'BUSINESS_NAME' && text) {
withAspect({ kind: 'image', url: img, label: ft }, ft) entry.businessNames.push(text);
} else if (ft === 'CALL_TO_ACTION_SELECTION' && cta) {
entry.ctas.push(cta);
} else if (ft === 'SITELINK' && sitelink) {
const line = [
str(sitelink.linkText),
str(sitelink.description1),
str(sitelink.description2),
]
.filter(Boolean)
.join(' · ');
if (line) entry.sitelinks.push(line);
} else if (ft === 'CALLOUT' && callout) {
entry.callouts.push(callout);
} else if (ft === 'STRUCTURED_SNIPPET' && snippet) {
const vals = (snippet.values || []).map(str).filter(Boolean).join(', ');
const line = [str(snippet.header), vals].filter(Boolean).join(': ');
if (line) entry.snippets.push(line);
} else if (img && (/IMAGE|LOGO/.test(ft) || row.asset?.type === 'IMAGE')) {
pushGoogleImage(
entry.assets,
img,
ft || 'IMAGE',
/LOGO/.test(ft) ? 'logo' : 'image',
imgW,
imgH
); );
} } else if (yt && (/VIDEO/.test(ft) || row.asset?.type === 'YOUTUBE_VIDEO')) {
} else if ((ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') && img) { pushGoogleYoutube(entry.assets, yt, ytTitle);
if (!entry.assets.some((a) => a.url === img)) {
entry.assets.push(
withAspect({ kind: 'logo', url: img, label: ft }, ft)
);
}
} else if (ft === 'YOUTUBE_VIDEO' && yt) {
if (!entry.assets.some((a) => a.youtubeId === yt)) {
entry.assets.push({
kind: 'youtube',
url: ytThumb(yt),
label: 'YOUTUBE_VIDEO',
youtubeId: yt,
title: ytTitle || yt,
aspect: '16:9',
});
}
} }
} }
@@ -665,11 +760,25 @@ LIMIT 400
const impressions = m?.impressions ?? camp?.impressions ?? 0; const impressions = m?.impressions ?? camp?.impressions ?? 0;
const clicks = m?.clicks ?? camp?.clicks ?? 0; const clicks = m?.clicks ?? camp?.clicks ?? 0;
const conversions = m?.conversions ?? camp?.conversions ?? 0; const conversions = m?.conversions ?? camp?.conversions ?? 0;
if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m) continue; const isPmax =
/PERFORMANCE_MAX/i.test(entry.channelType) ||
/P[\s_-]?MAX|PERFORMANCE[\s_-]?MAX/i.test(entry.campaignName);
const hasPack =
entry.assets.length > 0 ||
entry.headlines.length > 0 ||
entry.descriptions.length > 0;
if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m && !(isPmax && hasPack)) {
continue;
}
const headlines = uniq(entry.headlines); const headlines = uniq(entry.headlines);
const longHeadlines = uniq(entry.longHeadlines); const longHeadlines = uniq(entry.longHeadlines);
const descriptions = uniq(entry.descriptions); const descriptions = uniq(entry.descriptions);
const sitelinks = uniq(entry.sitelinks);
const callouts = uniq(entry.callouts);
const snippets = uniq(entry.snippets);
const ctas = uniq(entry.ctas);
const businessName = uniq(entry.businessNames)[0];
const preview = const preview =
pickVerticalGoogle(entry.assets) || pickVerticalGoogle(entry.assets) ||
entry.assets.find((a) => a.kind === 'youtube')?.url || entry.assets.find((a) => a.kind === 'youtube')?.url ||
@@ -701,9 +810,14 @@ LIMIT 400
bodies: descriptions, bodies: descriptions,
longHeadlines, longHeadlines,
descriptions: [], descriptions: [],
sitelinks,
callouts,
snippets,
ctas,
assets: entry.assets, assets: entry.assets,
linkUrl: entry.finalUrls[0], linkUrl: entry.finalUrls[0],
cta: uniq(entry.ctas)[0], cta: ctas[0],
businessName,
status: entry.status, status: entry.status,
youtubeId: firstYt, youtubeId: firstYt,
}); });
@@ -827,6 +941,8 @@ LIMIT 400
c.impressions > 0 || c.impressions > 0 ||
c.clicks > 0 || c.clicks > 0 ||
c.conversions > 0 || c.conversions > 0 ||
(c.mediaType === 'pmax' &&
((c.assets?.length || 0) > 0 || (c.headlines?.length || 0) > 0)) ||
(c.mediaType === 'rsa' && (c.headlines?.length || 0) > 0) || (c.mediaType === 'rsa' && (c.headlines?.length || 0) > 0) ||
((c.mediaType === 'rda' || c.mediaType === 'image') && ((c.mediaType === 'rda' || c.mediaType === 'image') &&
(c.assets?.length || 0) > 0) (c.assets?.length || 0) > 0)

View File

@@ -61,13 +61,17 @@ export type AdCreativeCard = {
/** Joined for backwards compat; prefer headlines/bodies/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. */ /** Titulares (Meta titles / Google headlines). */
headlines: string[]; headlines: string[];
/** Textos principales (Meta bodies), máx. 5. */ /** Textos principales (Meta bodies / Google descriptions). */
bodies: string[]; bodies: string[];
/** Descripción del enlace (Meta descriptions), suele ser 1. */ /** Descripción del enlace (Meta) u otros textos cortos. */
descriptions: string[]; descriptions: string[];
longHeadlines?: string[]; longHeadlines?: string[];
sitelinks?: string[];
callouts?: string[];
snippets?: string[];
ctas?: string[];
assets: CreativeAsset[]; assets: CreativeAsset[];
linkUrl?: string; linkUrl?: string;
cta?: string; cta?: string;