fix: previews Meta vía proxy y creatividades Google PMax/RSA

Las imágenes Meta dejan de romperse en el navegador; Google trae assets en vivo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 18:25:48 +02:00
parent 86225a701e
commit d05df8c371
5 changed files with 697 additions and 57 deletions

View File

@@ -5,9 +5,9 @@ import { isAgencyRole, userRole } from '@/lib/agency';
import { AdDailyRow } from '@/lib/metrics'; import { AdDailyRow } from '@/lib/metrics';
import { import {
AdCreativeCard, AdCreativeCard,
buildGoogleCardsFromDaily,
fetchMetaCreativeCards, fetchMetaCreativeCards,
} from '@/lib/ad-creatives-meta'; } from '@/lib/ad-creatives-meta';
import { fetchGoogleCreativeCards } from '@/lib/ad-creatives-google';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -73,14 +73,22 @@ export async function GET(req: NextRequest) {
dailyRows: daily, dailyRows: daily,
since, since,
until, until,
limit: 40, limit: 16,
}); });
metaAds = meta.ads; metaAds = meta.ads;
if (meta.warning) warnings.push(meta.warning); if (meta.warning) warnings.push(meta.warning);
} }
if (platform === 'google' || platform === 'all') { 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({ return NextResponse.json({

View File

@@ -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 });
}
}

View File

@@ -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({ function CreativesGallery({
platform, platform,
ads, ads,
@@ -316,23 +322,42 @@ function CreativesGallery({
warning?: string | null; warning?: string | null;
}) { }) {
const filtered = ads.filter((a) => a.platform === platform); 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 ( return (
<section className="space-y-3"> <section className="space-y-3">
<div> <div>
<p className="text-[10px] uppercase tracking-wider text-gray-500"> <p className="text-[10px] uppercase tracking-wider text-gray-500">
Creatividades · estilo Brava Ad Tracker Creatividades · preview por anuncio
</p> </p>
<h3 className="text-lg font-bold text-white"> <h3 className="text-lg font-bold text-white">
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · preview por anuncio {platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades
</h3> </h3>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Preview serving / asset + KPIs del rango. Meta tira creatividades en {platform === 'meta'
vivo; Google muestra ficha (assets RSA en siguiente iteración). ? 'Imagen / vídeo desde Meta Graph (proxy same-origin).'
: 'Assets PMax e RSA en vivo desde Google Ads API.'}
</p> </p>
</div> </div>
{warning ? ( {shownWarning ? (
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2"> <p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
{warning} {shownWarning}
</p> </p>
) : null} ) : null}
{loading ? ( {loading ? (
@@ -355,8 +380,21 @@ function CreativesGallery({
} }
function CreativeCard({ ad }: { ad: AdCreativeCard }) { function CreativeCard({ ad }: { ad: AdCreativeCard }) {
const preview = ad.imageUrl || ad.thumbnailUrl; const raw = ad.imageUrl || ad.thumbnailUrl;
const isVideo = ad.mediaType === 'video'; 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 ( return (
<article className="grid gap-4 rounded-2xl border border-gray-800 bg-[#111827] p-4 md:grid-cols-[minmax(220px,320px)_1fr]"> <article className="grid gap-4 rounded-2xl border border-gray-800 bg-[#111827] p-4 md:grid-cols-[minmax(220px,320px)_1fr]">
<div className="relative overflow-hidden rounded-xl bg-black/40 border border-gray-800 min-h-[220px] flex items-center justify-center"> <div className="relative overflow-hidden rounded-xl bg-black/40 border border-gray-800 min-h-[220px] flex items-center justify-center">
@@ -367,19 +405,23 @@ function CreativeCard({ ad }: { ad: AdCreativeCard }) {
alt={ad.adName} alt={ad.adName}
className="w-full h-full object-cover max-h-[420px]" className="w-full h-full object-cover max-h-[420px]"
loading="lazy" loading="lazy"
referrerPolicy="no-referrer"
/> />
) : ad.platform === 'google' ? ( ) : ad.platform === 'google' && headlines.length ? (
<div className="w-full p-4 space-y-2 text-left"> <div className="w-full p-4 space-y-2 text-left bg-gradient-to-b from-[#1a2332] to-[#0d121f]">
<p className="text-[10px] uppercase tracking-wider text-sky-400"> <p className="text-[10px] uppercase tracking-wider text-sky-400">
Google Ads · {ad.mediaType || 'rsa'} Google Ads · {ad.mediaType || 'rsa'}
</p> </p>
<p className="text-sm font-semibold text-blue-300 leading-snug"> {headlines.slice(0, 3).map((h) => (
{(ad.headline || ad.adName).split('\n')[0]} <p
</p> key={h}
<p className="text-xs text-gray-400 line-clamp-3"> className="text-sm font-semibold text-blue-300 leading-snug"
{(ad.primaryText || ad.campaignName || '').split('\n')[0]} >
{h}
</p> </p>
))}
{bodies[0] ? (
<p className="text-xs text-gray-400 line-clamp-3">{bodies[0]}</p>
) : null}
</div> </div>
) : ( ) : (
<div className="text-center text-gray-500 text-xs px-4"> <div className="text-center text-gray-500 text-xs px-4">
@@ -424,17 +466,15 @@ function CreativeCard({ ad }: { ad: AdCreativeCard }) {
) : null} ) : null}
</div> </div>
</div> </div>
{(ad.headline || ad.primaryText) && ( {(headlines.length > 0 || bodies.length > 0) && (
<div className="space-y-1 text-xs"> <div className="space-y-1 text-xs">
{ad.headline ? ( {headlines[0] ? (
<p className="text-gray-200 font-medium line-clamp-2"> <p className="text-gray-200 font-medium line-clamp-2">
{ad.headline.split('\n')[0]} {headlines[0]}
</p> </p>
) : null} ) : null}
{ad.primaryText ? ( {bodies[0] ? (
<p className="text-gray-500 line-clamp-3"> <p className="text-gray-500 line-clamp-3">{bodies[0]}</p>
{ad.primaryText.split('\n')[0]}
</p>
) : null} ) : null}
</div> </div>
)} )}
@@ -731,6 +771,19 @@ export default function AdTrackerClient({
</div> </div>
</section> </section>
<CreativesGallery
platform="meta"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
<CreativesGallery
platform="google"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 md:p-5"> <section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3"> <div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div> <div>
@@ -809,19 +862,6 @@ export default function AdTrackerClient({
empty="Sin campañas Google activas en este periodo" empty="Sin campañas Google activas en este periodo"
/> />
</div> </div>
<CreativesGallery
platform="meta"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
<CreativesGallery
platform="google"
ads={creatives}
loading={false}
warning={null}
/>
</div> </div>
)} )}
@@ -836,6 +876,12 @@ export default function AdTrackerClient({
value={meta.cpl != null ? formatEur(meta.cpl) : '—'} value={meta.cpl != null ? formatEur(meta.cpl) : '—'}
/> />
</div> </div>
<CreativesGallery
platform="meta"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4"> <section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
<h3 className="text-lg font-bold text-white mb-3"> <h3 className="text-lg font-bold text-white mb-3">
Conversiones y CPL · Meta Conversiones y CPL · Meta
@@ -853,12 +899,6 @@ export default function AdTrackerClient({
rows={metaCampaigns} rows={metaCampaigns}
empty="Sin campañas Meta activas en este periodo" empty="Sin campañas Meta activas en este periodo"
/> />
<CreativesGallery
platform="meta"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
</div> </div>
)} )}
@@ -873,6 +913,12 @@ export default function AdTrackerClient({
value={google.cpl != null ? formatEur(google.cpl) : '—'} value={google.cpl != null ? formatEur(google.cpl) : '—'}
/> />
</div> </div>
<CreativesGallery
platform="google"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4"> <section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
<h3 className="text-lg font-bold text-white mb-3"> <h3 className="text-lg font-bold text-white mb-3">
Conversiones y CPL · Google Conversiones y CPL · Google
@@ -890,12 +936,6 @@ export default function AdTrackerClient({
rows={googleCampaigns} rows={googleCampaigns}
empty="Sin campañas Google activas en este periodo" empty="Sin campañas Google activas en este periodo"
/> />
<CreativesGallery
platform="google"
ads={creatives}
loading={creativesLoading}
warning={creativesWarning}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -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<string> {
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<string, string> {
const h: Record<string, string> = {
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<string | null> {
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<GaqlRow[]> {
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<typeof aggregateDailyByAd>[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<string, (typeof dailyAggs)[number]>();
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<string, AgBucket>();
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',
};
}
}

View File

@@ -36,6 +36,7 @@ export type AdCreativeCard = {
headline: string; headline: string;
primaryText: string; primaryText: string;
status?: string; status?: string;
youtubeId?: string;
}; };
type DailyAgg = { type DailyAgg = {
@@ -193,8 +194,9 @@ async function fetchMetaCreativeBundle(
}, token); }, token);
let creative = (raw.creative || {}) as Record<string, unknown>; let creative = (raw.creative || {}) as Record<string, unknown>;
// Only hydrate creative detail when thumbnail/image missing
const cid = str(creative.id); const cid = str(creative.id);
if (cid) { if (cid && !str(creative.thumbnail_url) && !str(creative.image_url)) {
try { try {
const detail = await graphGet<Record<string, unknown>>( const detail = await graphGet<Record<string, unknown>>(
cid, cid,
@@ -248,16 +250,16 @@ export async function fetchMetaCreativeCards(opts: {
'meta', 'meta',
opts.since, opts.since,
opts.until opts.until
).slice(0, opts.limit ?? 40); ).slice(0, opts.limit ?? 16);
if (!aggs.length) { if (!aggs.length) {
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' }; return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' };
} }
const out: AdCreativeCard[] = []; const out: AdCreativeCard[] = [];
// Sequential batches of 5 to respect rate limits // Parallel batches of 8 — single Graph call per ad (thumbnail already in creative{})
for (let i = 0; i < aggs.length; i += 5) { for (let i = 0; i < aggs.length; i += 8) {
const chunk = aggs.slice(i, i + 5); const chunk = aggs.slice(i, i + 8);
const bundles = await Promise.all( const bundles = await Promise.all(
chunk.map((a) => fetchMetaCreativeBundle(a.adId, token)) chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))
); );