From d05df8c3714782829507ddbae50645d1fc2c9330 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Wed, 23 Sep 2026 18:25:48 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20previews=20Meta=20v=C3=ADa=20proxy=20y?= =?UTF-8?q?=20creatividades=20Google=20PMax/RSA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las imágenes Meta dejan de romperse en el navegador; Google trae assets en vivo. Co-authored-by: Cursor --- src/app/api/ad-tracker/creatives/route.ts | 14 +- src/app/api/ad-tracker/media/route.ts | 81 ++++ src/components/AdTrackerClient.tsx | 138 +++--- src/lib/ad-creatives-google.ts | 509 ++++++++++++++++++++++ src/lib/ad-creatives-meta.ts | 12 +- 5 files changed, 697 insertions(+), 57 deletions(-) create mode 100644 src/app/api/ad-tracker/media/route.ts create mode 100644 src/lib/ad-creatives-google.ts diff --git a/src/app/api/ad-tracker/creatives/route.ts b/src/app/api/ad-tracker/creatives/route.ts index c4823a1..c3ef26b 100644 --- a/src/app/api/ad-tracker/creatives/route.ts +++ b/src/app/api/ad-tracker/creatives/route.ts @@ -5,9 +5,9 @@ import { isAgencyRole, userRole } from '@/lib/agency'; import { AdDailyRow } from '@/lib/metrics'; import { AdCreativeCard, - buildGoogleCardsFromDaily, fetchMetaCreativeCards, } from '@/lib/ad-creatives-meta'; +import { fetchGoogleCreativeCards } from '@/lib/ad-creatives-google'; export const dynamic = 'force-dynamic'; @@ -73,14 +73,22 @@ export async function GET(req: NextRequest) { dailyRows: daily, since, until, - limit: 40, + limit: 16, }); metaAds = meta.ads; if (meta.warning) warnings.push(meta.warning); } if (platform === 'google' || platform === 'all') { - googleAds = buildGoogleCardsFromDaily(daily, since, until, 40); + const google = await fetchGoogleCreativeCards({ + tenantId: ctx.tenantId, + dailyRows: daily, + since, + until, + limit: 24, + }); + googleAds = google.ads; + if (google.warning) warnings.push(google.warning); } return NextResponse.json({ diff --git a/src/app/api/ad-tracker/media/route.ts b/src/app/api/ad-tracker/media/route.ts new file mode 100644 index 0000000..e21e01a --- /dev/null +++ b/src/app/api/ad-tracker/media/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export const dynamic = 'force-dynamic'; + +/** CDN prefixes safe to proxy for Ad Tracker previews. */ +const ALLOWED = [ + 'https://scontent.', + 'https://external.', + 'https://facebook.com/', + 'https://www.facebook.com/', + 'https://fbcdn.net/', + 'https://scontent.xx.fbcdn.net/', + 'https://tpc.googlesyndication.com/', + 'https://lh3.googleusercontent.com/', + 'https://i.ytimg.com/', + 'https://img.youtube.com/', +]; + +function allowed(url: string): boolean { + if (!url.startsWith('https://')) return false; + if (ALLOWED.some((p) => url.startsWith(p))) return true; + // Meta CDN hosts vary by region: scontent.fmad19-2.fna.fbcdn.net + try { + const host = new URL(url).hostname; + return ( + host.endsWith('.fbcdn.net') || + host.endsWith('.facebook.com') || + host.endsWith('.googleusercontent.com') || + host.endsWith('.googlesyndication.com') || + host === 'i.ytimg.com' || + host === 'img.youtube.com' + ); + } catch { + return false; + } +} + +/** + * GET /api/ad-tracker/media?url=… + * Proxies Meta/Google creative images so the browser always gets a same-origin preview. + */ +export async function GET(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + const url = req.nextUrl.searchParams.get('url') || ''; + if (!allowed(url)) { + return NextResponse.json({ error: 'url_not_allowed' }, { status: 400 }); + } + + try { + const res = await fetch(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (compatible; 4VenturesAdTracker/1.0; +https://app.4ventures.es)', + Accept: 'image/*,*/*;q=0.8', + }, + cache: 'force-cache', + next: { revalidate: 86400 }, + }); + if (!res.ok) { + return NextResponse.json({ error: 'upstream' }, { status: 502 }); + } + const contentType = res.headers.get('content-type') || 'image/jpeg'; + const body = await res.arrayBuffer(); + return new NextResponse(body, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'private, max-age=3600', + }, + }); + } catch { + return NextResponse.json({ error: 'fetch_failed' }, { status: 502 }); + } +} diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index 52ee94c..07e483b 100644 --- a/src/components/AdTrackerClient.tsx +++ b/src/components/AdTrackerClient.tsx @@ -304,6 +304,12 @@ function CampaignTable({ ); } +function proxiedMediaUrl(raw: string): string { + if (!raw) return ''; + if (raw.startsWith('/api/')) return raw; + return `/api/ad-tracker/media?url=${encodeURIComponent(raw)}`; +} + function CreativesGallery({ platform, ads, @@ -316,23 +322,42 @@ function CreativesGallery({ warning?: string | null; }) { const filtered = ads.filter((a) => a.platform === platform); + const platformWarning = + warning + ?.split(' · ') + .find((w) => + platform === 'meta' + ? /meta|token meta|sin ads meta/i.test(w) + : /google|pmax|rsa|sin ads google|sin assets/i.test(w) + ) || (platform === 'meta' || platform === 'google' ? null : null); + + // Prefer full warning on matching platform; otherwise only platform-relevant part + const shownWarning = + filtered.length === 0 && warning + ? platformWarning || + (ads.length === 0 ? warning : null) + : platformWarning && filtered.length === 0 + ? platformWarning + : null; + return (

- Creatividades · estilo Brava Ad Tracker + Creatividades · preview por anuncio

- {platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · preview por anuncio + {platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades

- Preview serving / asset + KPIs del rango. Meta tira creatividades en - vivo; Google muestra ficha (assets RSA en siguiente iteración). + {platform === 'meta' + ? 'Imagen / vídeo desde Meta Graph (proxy same-origin).' + : 'Assets PMax e RSA en vivo desde Google Ads API.'}

- {warning ? ( + {shownWarning ? (

- {warning} + {shownWarning}

) : null} {loading ? ( @@ -355,8 +380,21 @@ function CreativesGallery({ } function CreativeCard({ ad }: { ad: AdCreativeCard }) { - const preview = ad.imageUrl || ad.thumbnailUrl; - const isVideo = ad.mediaType === 'video'; + const raw = ad.imageUrl || ad.thumbnailUrl; + const ytPoster = ad.youtubeId + ? proxiedMediaUrl(`https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`) + : ''; + const preview = raw ? proxiedMediaUrl(raw) : ytPoster; + const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId); + const headlines = (ad.headline || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + const bodies = (ad.primaryText || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + return (
@@ -367,19 +405,23 @@ function CreativeCard({ ad }: { ad: AdCreativeCard }) { alt={ad.adName} className="w-full h-full object-cover max-h-[420px]" loading="lazy" - referrerPolicy="no-referrer" /> - ) : ad.platform === 'google' ? ( -
+ ) : ad.platform === 'google' && headlines.length ? ( +

Google Ads · {ad.mediaType || 'rsa'}

-

- {(ad.headline || ad.adName).split('\n')[0]} -

-

- {(ad.primaryText || ad.campaignName || '').split('\n')[0]} -

+ {headlines.slice(0, 3).map((h) => ( +

+ {h} +

+ ))} + {bodies[0] ? ( +

{bodies[0]}

+ ) : null}
) : (
@@ -424,17 +466,15 @@ function CreativeCard({ ad }: { ad: AdCreativeCard }) { ) : null}
- {(ad.headline || ad.primaryText) && ( + {(headlines.length > 0 || bodies.length > 0) && (
- {ad.headline ? ( + {headlines[0] ? (

- {ad.headline.split('\n')[0]} + {headlines[0]}

) : null} - {ad.primaryText ? ( -

- {ad.primaryText.split('\n')[0]} -

+ {bodies[0] ? ( +

{bodies[0]}

) : null}
)} @@ -731,6 +771,19 @@ export default function AdTrackerClient({
+ + +
@@ -809,19 +862,6 @@ export default function AdTrackerClient({ empty="Sin campañas Google activas en este periodo" />
- - -
)} @@ -836,6 +876,12 @@ export default function AdTrackerClient({ value={meta.cpl != null ? formatEur(meta.cpl) : '—'} /> +

Conversiones y CPL · Meta @@ -853,12 +899,6 @@ export default function AdTrackerClient({ rows={metaCampaigns} empty="Sin campañas Meta activas en este periodo" /> - )} @@ -873,6 +913,12 @@ export default function AdTrackerClient({ value={google.cpl != null ? formatEur(google.cpl) : '—'} /> +

Conversiones y CPL · Google @@ -890,12 +936,6 @@ export default function AdTrackerClient({ rows={googleCampaigns} empty="Sin campañas Google activas en este periodo" /> - )} diff --git a/src/lib/ad-creatives-google.ts b/src/lib/ad-creatives-google.ts new file mode 100644 index 0000000..5228383 --- /dev/null +++ b/src/lib/ad-creatives-google.ts @@ -0,0 +1,509 @@ +/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA). */ + +import { googleAdsConfigured } from '@/lib/ads-discovery'; +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; +import type { AdCreativeCard } from '@/lib/ad-creatives-meta'; +import { aggregateDailyByAd } from '@/lib/ad-creatives-meta'; + +const GADS_API = 'https://googleads.googleapis.com/v22'; + +type GaqlRow = { + campaign?: { id?: string; name?: string; status?: string }; + adGroup?: { id?: string; name?: string }; + adGroupAd?: { + status?: string; + ad?: { + id?: string; + name?: string; + type?: string; + finalUrls?: string[]; + responsiveSearchAd?: { + headlines?: { text?: string }[]; + descriptions?: { text?: string }[]; + }; + responsiveDisplayAd?: { + headlines?: { text?: string }[]; + longHeadline?: { text?: string }; + descriptions?: { text?: string }[]; + businessName?: string; + marketingImages?: { asset?: string }[]; + squareMarketingImages?: { asset?: string }[]; + }; + imageAd?: { imageUrl?: string; name?: string }; + }; + }; + assetGroup?: { + id?: string; + name?: string; + status?: string; + finalUrls?: string[]; + }; + assetGroupAsset?: { fieldType?: string; status?: string }; + asset?: { + id?: string; + type?: string; + textAsset?: { text?: string }; + imageAsset?: { fullSize?: { url?: string } }; + youtubeVideoAsset?: { + youtubeVideoId?: string; + youtubeVideoTitle?: string; + }; + }; + metrics?: { + costMicros?: string; + impressions?: string; + clicks?: string; + conversions?: number; + }; +}; + +function n(v: unknown): number { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +} + +function str(v: unknown): string { + return v == null ? '' : String(v).trim(); +} + +function enumName(v?: string): string { + return (v || '').replace(/^[A-Z0-9]+_/, '') || str(v); +} + +async function googleAccessToken(): Promise { + const refresh = + process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() || + process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim(); + const clientId = + process.env.GOOGLE_ADS_CLIENT_ID?.trim() || + process.env.GOOGLE_OAUTH_CLIENT_ID?.trim(); + const clientSecret = + process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() || + process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim(); + if (!refresh || !clientId || !clientSecret) { + throw new Error('Faltan credenciales Google Ads'); + } + const body = new URLSearchParams({ + refresh_token: refresh, + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + }); + const r = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!r.ok) throw new Error(`Google token: ${(await r.text()).slice(0, 180)}`); + return ((await r.json()) as { access_token: string }).access_token; +} + +function gadsHeaders( + accessToken: string, + loginCustomerId?: string +): Record { + const h: Record = { + Authorization: `Bearer ${accessToken}`, + 'developer-token': process.env.GOOGLE_ADS_DEVELOPER_TOKEN!.trim(), + 'Content-Type': 'application/json', + }; + const login = + loginCustomerId || + process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID?.trim()?.replace(/-/g, ''); + if (login) h['login-customer-id'] = login; + return h; +} + +async function resolveGoogleCustomerId(tenantId: string): Promise { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return null; + const r = await fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.google_ads&select=external_account_id,status&limit=1`, + { headers, cache: 'no-store' } + ); + if (!r.ok) return null; + const rows = (await r.json()) as { external_account_id?: string | null }[]; + const id = rows[0]?.external_account_id?.trim()?.replace(/-/g, ''); + return id || null; +} + +async function searchGoogle( + customerId: string, + accessToken: string, + query: string +): Promise { + const cid = customerId.replace(/-/g, ''); + const attempts = [ + gadsHeaders(accessToken), + gadsHeaders(accessToken, cid), + ]; + let lastErr = ''; + for (const headers of attempts) { + const r = await fetch(`${GADS_API}/customers/${cid}/googleAds:search`, { + method: 'POST', + headers, + body: JSON.stringify({ query }), + cache: 'no-store', + }); + if (r.ok) { + const j = (await r.json()) as { results?: GaqlRow[] }; + return j.results || []; + } + lastErr = await r.text(); + } + throw new Error(`Google Ads ${cid}: ${lastErr.slice(0, 220)}`); +} + +function ytThumb(youtubeId: string): string { + return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`; +} + +type AgBucket = { + adId: string; + adName: string; + campaignId: string; + campaignName: string; + status: string; + headlines: string[]; + descriptions: string[]; + images: string[]; + youtubeIds: string[]; +}; + +/** + * 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. + */ +export async function fetchGoogleCreativeCards(opts: { + tenantId: string; + dailyRows: Parameters[0]; + since: string; + until: string; + limit?: number; +}): Promise<{ ads: AdCreativeCard[]; warning?: string }> { + if (!googleAdsConfigured()) { + return { + ads: [], + warning: 'Google Ads API no configurada (GOOGLE_ADS_*)', + }; + } + + const customerId = await resolveGoogleCustomerId(opts.tenantId); + if (!customerId) { + return { ads: [], warning: 'Cliente sin cuenta Google Ads enlazada' }; + } + + let accessToken: string; + try { + accessToken = await googleAccessToken(); + } catch (e) { + return { + ads: [], + warning: e instanceof Error ? e.message : 'Token Google Ads falló', + }; + } + + const dateWhere = `segments.date BETWEEN '${opts.since}' AND '${opts.until}'`; + const limit = opts.limit ?? 24; + + try { + const [agAssets, agMetrics, adRows, adMetrics] = await Promise.all([ + searchGoogle( + customerId, + accessToken, + ` +SELECT + campaign.id, + campaign.name, + 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 +FROM asset_group_asset +WHERE asset_group.status != 'REMOVED' + AND asset_group_asset.status != 'REMOVED' +` + ), + searchGoogle( + customerId, + accessToken, + ` +SELECT + asset_group.id, + metrics.cost_micros, + metrics.impressions, + metrics.clicks, + metrics.conversions +FROM asset_group +WHERE ${dateWhere} +` + ), + searchGoogle( + customerId, + accessToken, + ` +SELECT + campaign.id, + campaign.name, + ad_group.id, + ad_group.name, + ad_group_ad.status, + ad_group_ad.ad.id, + ad_group_ad.ad.name, + ad_group_ad.ad.type, + 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_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.image_ad.image_url, + ad_group_ad.ad.image_ad.name +FROM ad_group_ad +WHERE ad_group_ad.status != 'REMOVED' + AND campaign.status != 'REMOVED' +LIMIT 80 +` + ), + searchGoogle( + customerId, + accessToken, + ` +SELECT + ad_group_ad.ad.id, + metrics.cost_micros, + metrics.impressions, + metrics.clicks, + metrics.conversions +FROM ad_group_ad +WHERE ${dateWhere} +` + ), + ]); + + const agMetricMap = new Map< + string, + { spend: number; impressions: number; clicks: number; conversions: number } + >(); + for (const row of agMetrics) { + const id = str(row.assetGroup?.id); + if (!id) continue; + const cur = agMetricMap.get(id) || { + spend: 0, + impressions: 0, + clicks: 0, + conversions: 0, + }; + cur.spend += n(row.metrics?.costMicros) / 1_000_000; + cur.impressions += n(row.metrics?.impressions); + cur.clicks += n(row.metrics?.clicks); + cur.conversions += n(row.metrics?.conversions); + agMetricMap.set(id, cur); + } + + const adMetricMap = new Map< + string, + { spend: number; impressions: number; clicks: number; conversions: number } + >(); + for (const row of adMetrics) { + const id = str(row.adGroupAd?.ad?.id); + if (!id) continue; + const cur = adMetricMap.get(id) || { + spend: 0, + impressions: 0, + clicks: 0, + conversions: 0, + }; + cur.spend += n(row.metrics?.costMicros) / 1_000_000; + cur.impressions += n(row.metrics?.impressions); + cur.clicks += n(row.metrics?.clicks); + cur.conversions += n(row.metrics?.conversions); + adMetricMap.set(id, cur); + } + + // Campaign-level spend from ad_daily as fallback for PMax + const dailyAggs = aggregateDailyByAd( + opts.dailyRows, + 'google', + opts.since, + opts.until + ); + const campaignSpend = new Map(); + for (const a of dailyAggs) { + campaignSpend.set(a.adId, a); // portal stores campaign id as ad_id + if (a.campaignName) campaignSpend.set(a.campaignName.toLowerCase(), a); + } + + const agMap = new Map(); + for (const row of agAssets) { + const agid = str(row.assetGroup?.id); + if (!agid) continue; + let entry = agMap.get(agid); + if (!entry) { + entry = { + adId: `ag_${agid}`, + adName: str(row.assetGroup?.name) || `Asset group ${agid}`, + campaignId: str(row.campaign?.id), + campaignName: str(row.campaign?.name), + status: enumName(row.assetGroup?.status) || 'ENABLED', + headlines: [], + descriptions: [], + images: [], + youtubeIds: [], + }; + agMap.set(agid, entry); + } + const ft = enumName(row.assetGroupAsset?.fieldType).toUpperCase(); + const text = str(row.asset?.textAsset?.text); + const img = str(row.asset?.imageAsset?.fullSize?.url); + const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId); + if (ft === 'HEADLINE' && text) entry.headlines.push(text); + else if (ft === 'LONG_HEADLINE' && text) entry.headlines.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 === 'YOUTUBE_VIDEO' && yt) { + if (!entry.youtubeIds.includes(yt)) entry.youtubeIds.push(yt); + } + } + + const cards: AdCreativeCard[] = []; + + for (const [agid, entry] of agMap) { + const m = agMetricMap.get(agid); + const camp = + campaignSpend.get(entry.campaignId) || + campaignSpend.get(entry.campaignName.toLowerCase()); + const spend = m?.spend ?? camp?.spend ?? 0; + 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) : ''); + cards.push({ + platform: 'google', + adId: entry.adId, + adName: entry.adName, + campaignName: entry.campaignName, + adsetName: 'Performance Max', + spend: Math.round(spend * 100) / 100, + impressions, + 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'), + status: entry.status, + youtubeId: yt || undefined, + }); + } + + for (const row of adRows) { + const ad = row.adGroupAd?.ad; + 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; + const conversions = m?.conversions ?? 0; + if (!m && cards.length >= limit) continue; + + const rsa = ad?.responsiveSearchAd; + const rda = ad?.responsiveDisplayAd; + const headlines = [ + ...(rsa?.headlines || []).map((h) => str(h.text)), + ...(rda?.headlines || []).map((h) => str(h.text)), + str(rda?.longHeadline?.text), + ].filter(Boolean); + const descriptions = [ + ...(rsa?.descriptions || []).map((d) => str(d.text)), + ...(rda?.descriptions || []).map((d) => str(d.text)), + ].filter(Boolean); + const imageUrl = str(ad?.imageAd?.imageUrl); + const adType = enumName(ad?.type).toLowerCase() || 'rsa'; + const mediaType = rsa + ? 'rsa' + : rda + ? 'rda' + : imageUrl + ? 'image' + : adType; + + cards.push({ + platform: 'google', + adId, + adName: str(ad?.name) || headlines[0] || `Ad ${adId}`, + campaignName: str(row.campaign?.name), + adsetName: str(row.adGroup?.name), + spend: Math.round(spend * 100) / 100, + impressions, + clicks, + conversions: Math.round(conversions * 100) / 100, + cpl: conversions > 0 ? spend / conversions : null, + mediaType, + thumbnailUrl: imageUrl, + imageUrl, + headline: [...new Set(headlines)].slice(0, 5).join('\n'), + primaryText: [...new Set(descriptions)].slice(0, 3).join('\n'), + status: enumName(row.adGroupAd?.status), + }); + } + + 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, + adName: agg.adName || agg.campaignName || agg.adId, + campaignName: agg.campaignName, + 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: 'campaign', + thumbnailUrl: '', + imageUrl: '', + headline: agg.campaignName || agg.adName, + primaryText: 'Sin assets creativos en la API', + status: undefined, + })); + return { + ads: fallback, + warning: fallback.length + ? 'Google: sin assets PMax/RSA; mostrando campañas de ad_daily' + : 'Sin ads Google en este periodo', + }; + } + + return { ads: sliced }; + } catch (e) { + return { + ads: [], + warning: e instanceof Error ? e.message : 'Error Google creatives', + }; + } +} diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts index d41ed95..fa0564a 100644 --- a/src/lib/ad-creatives-meta.ts +++ b/src/lib/ad-creatives-meta.ts @@ -36,6 +36,7 @@ export type AdCreativeCard = { headline: string; primaryText: string; status?: string; + youtubeId?: string; }; type DailyAgg = { @@ -193,8 +194,9 @@ async function fetchMetaCreativeBundle( }, token); let creative = (raw.creative || {}) as Record; + // Only hydrate creative detail when thumbnail/image missing const cid = str(creative.id); - if (cid) { + if (cid && !str(creative.thumbnail_url) && !str(creative.image_url)) { try { const detail = await graphGet>( cid, @@ -248,16 +250,16 @@ export async function fetchMetaCreativeCards(opts: { 'meta', opts.since, opts.until - ).slice(0, opts.limit ?? 40); + ).slice(0, opts.limit ?? 16); if (!aggs.length) { return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' }; } const out: AdCreativeCard[] = []; - // Sequential batches of 5 to respect rate limits - for (let i = 0; i < aggs.length; i += 5) { - const chunk = aggs.slice(i, i + 5); + // 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)) );