feat: zoom de creatividades con assets verticales Meta y PMax completo
Preview 4:5 + textos en card; modal con todas las imágenes, vídeos y copy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA). */
|
||||
/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA, full assets). */
|
||||
|
||||
import { googleAdsConfigured } from '@/lib/ads-discovery';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
|
||||
import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta';
|
||||
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
|
||||
|
||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||
@@ -20,14 +20,15 @@ type GaqlRow = {
|
||||
responsiveSearchAd?: {
|
||||
headlines?: { text?: string }[];
|
||||
descriptions?: { text?: string }[];
|
||||
path1?: string;
|
||||
path2?: string;
|
||||
};
|
||||
responsiveDisplayAd?: {
|
||||
headlines?: { text?: string }[];
|
||||
longHeadline?: { text?: string };
|
||||
descriptions?: { text?: string }[];
|
||||
businessName?: string;
|
||||
marketingImages?: { asset?: string }[];
|
||||
squareMarketingImages?: { asset?: string }[];
|
||||
callToActionText?: string;
|
||||
};
|
||||
imageAd?: { imageUrl?: string; name?: string };
|
||||
};
|
||||
@@ -48,6 +49,7 @@ type GaqlRow = {
|
||||
youtubeVideoId?: string;
|
||||
youtubeVideoTitle?: string;
|
||||
};
|
||||
callToActionAsset?: { callToAction?: string };
|
||||
};
|
||||
metrics?: {
|
||||
costMicros?: string;
|
||||
@@ -70,6 +72,10 @@ function enumName(v?: string): string {
|
||||
return (v || '').replace(/^[A-Z0-9]+_/, '') || str(v);
|
||||
}
|
||||
|
||||
function uniq(arr: string[]): string[] {
|
||||
return [...new Set(arr.filter(Boolean))];
|
||||
}
|
||||
|
||||
async function googleAccessToken(): Promise<string> {
|
||||
const refresh =
|
||||
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
||||
@@ -159,21 +165,34 @@ function ytThumb(youtubeId: string): string {
|
||||
return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`;
|
||||
}
|
||||
|
||||
function pickVerticalGoogle(assets: CreativeAsset[]): string {
|
||||
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
||||
const portrait = images.find((a) =>
|
||||
/PORTRAIT|TALL|VERTICAL/i.test(a.label || '')
|
||||
);
|
||||
if (portrait) return portrait.url;
|
||||
const square = images.find((a) => /SQUARE/i.test(a.label || ''));
|
||||
if (square) return square.url;
|
||||
return images[0]?.url || '';
|
||||
}
|
||||
|
||||
type AgBucket = {
|
||||
adId: string;
|
||||
adName: string;
|
||||
campaignId: string;
|
||||
campaignName: string;
|
||||
status: string;
|
||||
finalUrls: string[];
|
||||
headlines: string[];
|
||||
longHeadlines: string[];
|
||||
descriptions: string[];
|
||||
images: string[];
|
||||
youtubeIds: string[];
|
||||
ctas: string[];
|
||||
assets: CreativeAsset[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Live Google creatives: Performance Max asset groups + RSA/RDA ads.
|
||||
* Metrics: prefer asset_group / ad metrics in range; else fall back to campaign ad_daily.
|
||||
* Returns full asset packs (images, videos, logos, texts) for zoom UI.
|
||||
*/
|
||||
export async function fetchGoogleCreativeCards(opts: {
|
||||
tenantId: string;
|
||||
@@ -226,7 +245,8 @@ SELECT
|
||||
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.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'
|
||||
@@ -262,10 +282,13 @@ SELECT
|
||||
ad_group_ad.ad.final_urls,
|
||||
ad_group_ad.ad.responsive_search_ad.headlines,
|
||||
ad_group_ad.ad.responsive_search_ad.descriptions,
|
||||
ad_group_ad.ad.responsive_search_ad.path1,
|
||||
ad_group_ad.ad.responsive_search_ad.path2,
|
||||
ad_group_ad.ad.responsive_display_ad.headlines,
|
||||
ad_group_ad.ad.responsive_display_ad.long_headline,
|
||||
ad_group_ad.ad.responsive_display_ad.descriptions,
|
||||
ad_group_ad.ad.responsive_display_ad.business_name,
|
||||
ad_group_ad.ad.responsive_display_ad.call_to_action_text,
|
||||
ad_group_ad.ad.image_ad.image_url,
|
||||
ad_group_ad.ad.image_ad.name
|
||||
FROM ad_group_ad
|
||||
@@ -330,7 +353,6 @@ WHERE ${dateWhere}
|
||||
adMetricMap.set(id, cur);
|
||||
}
|
||||
|
||||
// Campaign-level spend from ad_daily as fallback for PMax
|
||||
const dailyAggs = aggregateDailyByAd(
|
||||
opts.dailyRows,
|
||||
'google',
|
||||
@@ -339,7 +361,7 @@ WHERE ${dateWhere}
|
||||
);
|
||||
const campaignSpend = new Map<string, (typeof dailyAggs)[number]>();
|
||||
for (const a of dailyAggs) {
|
||||
campaignSpend.set(a.adId, a); // portal stores campaign id as ad_id
|
||||
campaignSpend.set(a.adId, a);
|
||||
if (a.campaignName) campaignSpend.set(a.campaignName.toLowerCase(), a);
|
||||
}
|
||||
|
||||
@@ -355,10 +377,12 @@ WHERE ${dateWhere}
|
||||
campaignId: str(row.campaign?.id),
|
||||
campaignName: str(row.campaign?.name),
|
||||
status: enumName(row.assetGroup?.status) || 'ENABLED',
|
||||
finalUrls: (row.assetGroup?.finalUrls || []).map(str).filter(Boolean),
|
||||
headlines: [],
|
||||
longHeadlines: [],
|
||||
descriptions: [],
|
||||
images: [],
|
||||
youtubeIds: [],
|
||||
ctas: [],
|
||||
assets: [],
|
||||
};
|
||||
agMap.set(agid, entry);
|
||||
}
|
||||
@@ -366,16 +390,31 @@ WHERE ${dateWhere}
|
||||
const text = str(row.asset?.textAsset?.text);
|
||||
const img = str(row.asset?.imageAsset?.fullSize?.url);
|
||||
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
|
||||
const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
|
||||
const cta = enumName(row.asset?.callToActionAsset?.callToAction);
|
||||
|
||||
if (ft === 'HEADLINE' && text) entry.headlines.push(text);
|
||||
else if (ft === 'LONG_HEADLINE' && text) entry.headlines.push(text);
|
||||
else if (ft === 'LONG_HEADLINE' && text) entry.longHeadlines.push(text);
|
||||
else if (ft === 'DESCRIPTION' && text) entry.descriptions.push(text);
|
||||
else if (
|
||||
(ft.includes('MARKETING_IMAGE') || ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') &&
|
||||
img
|
||||
) {
|
||||
if (!entry.images.includes(img)) entry.images.push(img);
|
||||
else if (ft === 'CALL_TO_ACTION_SELECTION' && cta) entry.ctas.push(cta);
|
||||
else if (ft.includes('MARKETING_IMAGE') && img) {
|
||||
if (!entry.assets.some((a) => a.url === img)) {
|
||||
entry.assets.push({ kind: 'image', url: img, label: ft });
|
||||
}
|
||||
} else if ((ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') && img) {
|
||||
if (!entry.assets.some((a) => a.url === img)) {
|
||||
entry.assets.push({ kind: 'logo', url: img, label: ft });
|
||||
}
|
||||
} else if (ft === 'YOUTUBE_VIDEO' && yt) {
|
||||
if (!entry.youtubeIds.includes(yt)) entry.youtubeIds.push(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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,11 +429,17 @@ WHERE ${dateWhere}
|
||||
const impressions = m?.impressions ?? camp?.impressions ?? 0;
|
||||
const clicks = m?.clicks ?? camp?.clicks ?? 0;
|
||||
const conversions = m?.conversions ?? camp?.conversions ?? 0;
|
||||
// Skip zero-activity asset groups when we have spend elsewhere, keep if any spend
|
||||
if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m) continue;
|
||||
|
||||
const yt = entry.youtubeIds[0] || '';
|
||||
const imageUrl = entry.images[0] || (yt ? ytThumb(yt) : '');
|
||||
const headlines = uniq(entry.headlines);
|
||||
const longHeadlines = uniq(entry.longHeadlines);
|
||||
const descriptions = uniq(entry.descriptions);
|
||||
const preview =
|
||||
pickVerticalGoogle(entry.assets) ||
|
||||
entry.assets.find((a) => a.kind === 'youtube')?.url ||
|
||||
'';
|
||||
const firstYt = entry.assets.find((a) => a.youtubeId)?.youtubeId;
|
||||
|
||||
cards.push({
|
||||
platform: 'google',
|
||||
adId: entry.adId,
|
||||
@@ -406,13 +451,19 @@ WHERE ${dateWhere}
|
||||
clicks,
|
||||
conversions: Math.round(conversions * 100) / 100,
|
||||
cpl: conversions > 0 ? spend / conversions : null,
|
||||
mediaType: yt && !entry.images[0] ? 'video' : entry.images[0] ? 'pmax' : 'pmax',
|
||||
thumbnailUrl: imageUrl,
|
||||
imageUrl,
|
||||
headline: [...new Set(entry.headlines)].slice(0, 5).join('\n'),
|
||||
primaryText: [...new Set(entry.descriptions)].slice(0, 3).join('\n'),
|
||||
mediaType: 'pmax',
|
||||
thumbnailUrl: preview,
|
||||
imageUrl: preview,
|
||||
headline: [...headlines, ...longHeadlines].join('\n'),
|
||||
primaryText: descriptions.join('\n'),
|
||||
headlines,
|
||||
longHeadlines,
|
||||
descriptions,
|
||||
assets: entry.assets,
|
||||
linkUrl: entry.finalUrls[0],
|
||||
cta: uniq(entry.ctas)[0],
|
||||
status: entry.status,
|
||||
youtubeId: yt || undefined,
|
||||
youtubeId: firstYt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -421,7 +472,6 @@ WHERE ${dateWhere}
|
||||
const adId = str(ad?.id);
|
||||
if (!adId) continue;
|
||||
const m = adMetricMap.get(adId);
|
||||
// Prefer ads with activity in range; keep a few without if needed
|
||||
const spend = m?.spend ?? 0;
|
||||
const impressions = m?.impressions ?? 0;
|
||||
const clicks = m?.clicks ?? 0;
|
||||
@@ -430,24 +480,21 @@ WHERE ${dateWhere}
|
||||
|
||||
const rsa = ad?.responsiveSearchAd;
|
||||
const rda = ad?.responsiveDisplayAd;
|
||||
const headlines = [
|
||||
const headlines = uniq([
|
||||
...(rsa?.headlines || []).map((h) => str(h.text)),
|
||||
...(rda?.headlines || []).map((h) => str(h.text)),
|
||||
str(rda?.longHeadline?.text),
|
||||
].filter(Boolean);
|
||||
const descriptions = [
|
||||
]);
|
||||
const longHeadlines = uniq([str(rda?.longHeadline?.text)]);
|
||||
const descriptions = uniq([
|
||||
...(rsa?.descriptions || []).map((d) => str(d.text)),
|
||||
...(rda?.descriptions || []).map((d) => str(d.text)),
|
||||
].filter(Boolean);
|
||||
]);
|
||||
const imageUrl = str(ad?.imageAd?.imageUrl);
|
||||
const assets: CreativeAsset[] = [];
|
||||
if (imageUrl) assets.push({ kind: 'image', url: imageUrl, label: 'IMAGE_AD' });
|
||||
const adType = enumName(ad?.type).toLowerCase() || 'rsa';
|
||||
const mediaType = rsa
|
||||
? 'rsa'
|
||||
: rda
|
||||
? 'rda'
|
||||
: imageUrl
|
||||
? 'image'
|
||||
: adType;
|
||||
const mediaType = rsa ? 'rsa' : rda ? 'rda' : imageUrl ? 'image' : adType;
|
||||
const paths = [str(rsa?.path1), str(rsa?.path2)].filter(Boolean);
|
||||
|
||||
cards.push({
|
||||
platform: 'google',
|
||||
@@ -463,17 +510,30 @@ WHERE ${dateWhere}
|
||||
mediaType,
|
||||
thumbnailUrl: imageUrl,
|
||||
imageUrl,
|
||||
headline: [...new Set(headlines)].slice(0, 5).join('\n'),
|
||||
primaryText: [...new Set(descriptions)].slice(0, 3).join('\n'),
|
||||
headline: [...headlines, ...longHeadlines].join('\n'),
|
||||
primaryText: descriptions.join('\n'),
|
||||
headlines,
|
||||
longHeadlines,
|
||||
descriptions,
|
||||
assets,
|
||||
linkUrl: (ad?.finalUrls || []).map(str).filter(Boolean)[0],
|
||||
cta: str(rda?.callToActionText) || undefined,
|
||||
businessName: str(rda?.businessName) || undefined,
|
||||
status: enumName(row.adGroupAd?.status),
|
||||
});
|
||||
if (paths.length) {
|
||||
// encode paths into descriptions note for zoom
|
||||
cards[cards.length - 1].descriptions = [
|
||||
...descriptions,
|
||||
`Path: /${paths.join('/')}`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
cards.sort((a, b) => b.spend - a.spend);
|
||||
const sliced = cards.slice(0, limit);
|
||||
|
||||
if (!sliced.length) {
|
||||
// Fallback: campaign cards from ad_daily (text only)
|
||||
const fallback = dailyAggs.slice(0, limit).map((agg) => ({
|
||||
platform: 'google' as const,
|
||||
adId: agg.adId,
|
||||
@@ -489,6 +549,9 @@ WHERE ${dateWhere}
|
||||
imageUrl: '',
|
||||
headline: agg.campaignName || agg.adName,
|
||||
primaryText: 'Sin assets creativos en la API',
|
||||
headlines: [agg.campaignName || agg.adName].filter(Boolean),
|
||||
descriptions: ['Sin assets creativos en la API'],
|
||||
assets: [] as CreativeAsset[],
|
||||
status: undefined,
|
||||
}));
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Meta ad creatives for Ad Tracker (Brava-style previews). */
|
||||
/** Meta ad creatives for Ad Tracker (vertical previews + full copy/assets). */
|
||||
|
||||
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
@@ -10,6 +10,8 @@ const CREATIVE_FIELDS = [
|
||||
'name',
|
||||
'title',
|
||||
'body',
|
||||
'call_to_action_type',
|
||||
'link_url',
|
||||
'thumbnail_url',
|
||||
'image_url',
|
||||
'image_hash',
|
||||
@@ -19,6 +21,14 @@ const CREATIVE_FIELDS = [
|
||||
'effective_object_story_id',
|
||||
].join(',');
|
||||
|
||||
export type CreativeAsset = {
|
||||
kind: 'image' | 'video' | 'logo' | 'youtube';
|
||||
url: string;
|
||||
label?: string;
|
||||
youtubeId?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type AdCreativeCard = {
|
||||
platform: 'meta' | 'google';
|
||||
adId: string;
|
||||
@@ -31,10 +41,19 @@ export type AdCreativeCard = {
|
||||
conversions: number;
|
||||
cpl: number | null;
|
||||
mediaType: string;
|
||||
/** Best preview URL (prefer vertical / portrait). */
|
||||
thumbnailUrl: string;
|
||||
imageUrl: string;
|
||||
/** Joined for backwards compat; prefer headlines/descriptions arrays. */
|
||||
headline: string;
|
||||
primaryText: string;
|
||||
headlines: string[];
|
||||
descriptions: string[];
|
||||
longHeadlines?: string[];
|
||||
assets: CreativeAsset[];
|
||||
linkUrl?: string;
|
||||
cta?: string;
|
||||
businessName?: string;
|
||||
status?: string;
|
||||
youtubeId?: string;
|
||||
};
|
||||
@@ -58,6 +77,10 @@ function str(v: unknown): string {
|
||||
return v == null ? '' : String(v).trim();
|
||||
}
|
||||
|
||||
function uniq(arr: string[]): string[] {
|
||||
return [...new Set(arr.filter(Boolean))];
|
||||
}
|
||||
|
||||
function detectMediaType(creative: Record<string, unknown>): string {
|
||||
if (creative.video_id) return 'video';
|
||||
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
||||
@@ -71,32 +94,125 @@ function detectMediaType(creative: Record<string, unknown>): string {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function extractTexts(creative: Record<string, unknown>): {
|
||||
headline: string;
|
||||
primaryText: string;
|
||||
} {
|
||||
const titles: string[] = [];
|
||||
const bodies: string[] = [];
|
||||
if (creative.title) titles.push(str(creative.title));
|
||||
if (creative.body) bodies.push(str(creative.body));
|
||||
type Extracted = {
|
||||
headlines: string[];
|
||||
descriptions: string[];
|
||||
assets: CreativeAsset[];
|
||||
linkUrl: string;
|
||||
cta: string;
|
||||
hashes: string[];
|
||||
};
|
||||
|
||||
function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
||||
const headlines: string[] = [];
|
||||
const descriptions: string[] = [];
|
||||
const assets: CreativeAsset[] = [];
|
||||
const hashes: string[] = [];
|
||||
const pushHash = (h: unknown) => {
|
||||
const s = str(h);
|
||||
if (/^[a-f0-9]{16,64}$/i.test(s)) hashes.push(s);
|
||||
};
|
||||
const pushImg = (url: unknown, label?: string) => {
|
||||
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 });
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
if (link?.name) titles.push(str(link.name));
|
||||
if (link?.message) bodies.push(str(link.message));
|
||||
if (video?.title) titles.push(str(video.title));
|
||||
if (video?.message) bodies.push(str(video.message));
|
||||
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');
|
||||
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');
|
||||
pushHash(video?.image_hash);
|
||||
pushImg(photo?.url, 'photo');
|
||||
pushHash(photo?.image_hash);
|
||||
|
||||
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}`);
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
||||
const feedTitles = feed?.titles as { text?: string }[] | undefined;
|
||||
const feedBodies = feed?.bodies as { text?: string }[] | undefined;
|
||||
for (const t of feedTitles || []) if (t?.text) titles.push(str(t.text));
|
||||
for (const b of feedBodies || []) if (b?.text) bodies.push(str(b.text));
|
||||
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));
|
||||
}
|
||||
for (const img of (feed.images as Record<string, unknown>[]) || []) {
|
||||
pushImg(img.url || img.permalink_url, 'feed');
|
||||
pushHash(img.hash);
|
||||
}
|
||||
for (const v of (feed.videos as Record<string, unknown>[]) || []) {
|
||||
pushImg(v.thumbnail_url || v.picture, 'video_feed');
|
||||
if (v.video_id) {
|
||||
assets.push({
|
||||
kind: 'video',
|
||||
url: str(v.thumbnail_url || v.picture),
|
||||
label: 'video',
|
||||
title: str(v.video_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const linkUrl =
|
||||
str(creative.link_url) ||
|
||||
str(link?.link) ||
|
||||
str(video?.link) ||
|
||||
'';
|
||||
const ctaRaw = (link?.call_to_action as { type?: string } | undefined)?.type;
|
||||
const cta = str(creative.call_to_action_type) || str(ctaRaw);
|
||||
|
||||
return {
|
||||
headline: [...new Set(titles.filter(Boolean))].slice(0, 5).join('\n'),
|
||||
primaryText: [...new Set(bodies.filter(Boolean))].slice(0, 3).join('\n'),
|
||||
headlines: uniq(headlines),
|
||||
descriptions: uniq(descriptions),
|
||||
assets,
|
||||
linkUrl,
|
||||
cta,
|
||||
hashes: uniq(hashes),
|
||||
};
|
||||
}
|
||||
|
||||
/** Prefer portrait/vertical-looking assets; 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 || '')
|
||||
);
|
||||
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');
|
||||
if (nonSquare) return nonSquare.url;
|
||||
return images[0]?.url || fallback;
|
||||
}
|
||||
|
||||
async function graphGet<T>(
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
@@ -124,6 +240,37 @@ async function resolveMetaAccountId(tenantId: string): Promise<string | null> {
|
||||
return id || null;
|
||||
}
|
||||
|
||||
async function resolveImageHashes(
|
||||
accountId: string,
|
||||
hashes: string[],
|
||||
token: string
|
||||
): Promise<Map<string, string>> {
|
||||
const out = new Map<string, string>();
|
||||
const unique = uniq(hashes).slice(0, 40);
|
||||
if (!unique.length) return out;
|
||||
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
||||
try {
|
||||
const raw = await graphGet<{
|
||||
data?: { hash?: string; url?: string; permalink_url?: string }[];
|
||||
}>(
|
||||
`${act}/adimages`,
|
||||
{
|
||||
hashes: JSON.stringify(unique),
|
||||
fields: 'hash,url,permalink_url',
|
||||
},
|
||||
token
|
||||
);
|
||||
for (const row of raw.data || []) {
|
||||
const h = str(row.hash);
|
||||
const u = str(row.url) || str(row.permalink_url);
|
||||
if (h && u) out.set(h, u);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Aggregate ad_daily rows by ad_id for a platform. */
|
||||
export function aggregateDailyByAd(
|
||||
rows: {
|
||||
@@ -187,29 +334,33 @@ async function fetchMetaCreativeBundle(
|
||||
campaign?: { name?: string };
|
||||
adset?: { name?: string };
|
||||
creative?: Record<string, unknown>;
|
||||
}>(adId, {
|
||||
fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`,
|
||||
thumbnail_width: '1080',
|
||||
thumbnail_height: '1080',
|
||||
}, token);
|
||||
}>(
|
||||
adId,
|
||||
{
|
||||
fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`,
|
||||
// Prefer vertical crop for Stories / Reels style
|
||||
thumbnail_width: '1080',
|
||||
thumbnail_height: '1920',
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
let creative = (raw.creative || {}) as Record<string, unknown>;
|
||||
// Only hydrate creative detail when thumbnail/image missing
|
||||
const cid = str(creative.id);
|
||||
if (cid && !str(creative.thumbnail_url) && !str(creative.image_url)) {
|
||||
if (cid) {
|
||||
try {
|
||||
const detail = await graphGet<Record<string, unknown>>(
|
||||
cid,
|
||||
{
|
||||
fields: CREATIVE_FIELDS,
|
||||
thumbnail_width: '1080',
|
||||
thumbnail_height: '1080',
|
||||
thumbnail_height: '1920',
|
||||
},
|
||||
token
|
||||
);
|
||||
creative = { ...creative, ...detail };
|
||||
} catch {
|
||||
/* keep lightweight */
|
||||
/* keep */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,48 +407,68 @@ export async function fetchMetaCreativeCards(opts: {
|
||||
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' };
|
||||
}
|
||||
|
||||
const out: AdCreativeCard[] = [];
|
||||
// Parallel batches of 8 — single Graph call per ad (thumbnail already in creative{})
|
||||
for (let i = 0; i < aggs.length; i += 8) {
|
||||
const chunk = aggs.slice(i, i + 8);
|
||||
const bundles = await Promise.all(
|
||||
chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))
|
||||
const bundles: (Awaited<ReturnType<typeof fetchMetaCreativeBundle>>)[] = [];
|
||||
for (let i = 0; i < aggs.length; i += 6) {
|
||||
const chunk = aggs.slice(i, i + 6);
|
||||
bundles.push(
|
||||
...(await Promise.all(chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))))
|
||||
);
|
||||
chunk.forEach((agg, idx) => {
|
||||
const b = bundles[idx];
|
||||
const creative = b?.creative || {};
|
||||
const texts = extractTexts(creative);
|
||||
const imageUrl =
|
||||
str(creative.image_url) ||
|
||||
str(creative.thumbnail_url) ||
|
||||
'';
|
||||
const thumbnailUrl =
|
||||
str(creative.thumbnail_url) || imageUrl;
|
||||
out.push({
|
||||
platform: 'meta',
|
||||
adId: agg.adId,
|
||||
adName: b?.adName || agg.adName,
|
||||
campaignName: b?.campaignName || agg.campaignName,
|
||||
adsetName: b?.adsetName,
|
||||
spend: Math.round(agg.spend * 100) / 100,
|
||||
impressions: agg.impressions,
|
||||
clicks: agg.clicks,
|
||||
conversions: agg.conversions,
|
||||
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
||||
mediaType: detectMediaType(creative),
|
||||
thumbnailUrl,
|
||||
imageUrl: imageUrl || thumbnailUrl,
|
||||
headline: texts.headline,
|
||||
primaryText: texts.primaryText,
|
||||
status: b?.status,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const allHashes: string[] = [];
|
||||
const packs = bundles.map((b) => {
|
||||
const pack = extractCreativePack(b?.creative || {});
|
||||
allHashes.push(...pack.hashes);
|
||||
return pack;
|
||||
});
|
||||
const hashMap = await resolveImageHashes(account, allHashes, token);
|
||||
|
||||
const out: AdCreativeCard[] = [];
|
||||
aggs.forEach((agg, idx) => {
|
||||
const b = bundles[idx];
|
||||
const pack = packs[idx];
|
||||
const assets = [...pack.assets];
|
||||
for (const h of pack.hashes) {
|
||||
const u = hashMap.get(h);
|
||||
if (u && !assets.some((a) => a.url === u)) {
|
||||
assets.push({ kind: 'image', url: u, label: 'hash' });
|
||||
}
|
||||
}
|
||||
const creative = b?.creative || {};
|
||||
const fallback =
|
||||
str(creative.image_url) || str(creative.thumbnail_url) || '';
|
||||
const preview = pickVerticalPreview(assets, fallback);
|
||||
const headlines = pack.headlines;
|
||||
const descriptions = pack.descriptions;
|
||||
out.push({
|
||||
platform: 'meta',
|
||||
adId: agg.adId,
|
||||
adName: b?.adName || agg.adName,
|
||||
campaignName: b?.campaignName || agg.campaignName,
|
||||
adsetName: b?.adsetName,
|
||||
spend: Math.round(agg.spend * 100) / 100,
|
||||
impressions: agg.impressions,
|
||||
clicks: agg.clicks,
|
||||
conversions: agg.conversions,
|
||||
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
||||
mediaType: detectMediaType(creative),
|
||||
thumbnailUrl: preview,
|
||||
imageUrl: preview,
|
||||
headline: headlines.join('\n'),
|
||||
primaryText: descriptions.join('\n'),
|
||||
headlines,
|
||||
descriptions,
|
||||
assets,
|
||||
linkUrl: pack.linkUrl || undefined,
|
||||
cta: pack.cta || undefined,
|
||||
status: b?.status,
|
||||
});
|
||||
});
|
||||
|
||||
return { ads: out };
|
||||
}
|
||||
|
||||
/** Google cards from ad_daily only (no asset pull yet — text fallback). */
|
||||
/** Google cards from ad_daily only (text fallback). */
|
||||
export function buildGoogleCardsFromDaily(
|
||||
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
|
||||
since: string,
|
||||
@@ -321,6 +492,9 @@ export function buildGoogleCardsFromDaily(
|
||||
imageUrl: '',
|
||||
headline: agg.adName,
|
||||
primaryText: agg.campaignName,
|
||||
headlines: agg.adName ? [agg.adName] : [],
|
||||
descriptions: agg.campaignName ? [agg.campaignName] : [],
|
||||
assets: [],
|
||||
status: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user