Files
4ventures-client-portal/src/lib/ad-creatives-meta.ts
4Ventures 3ddf722912 fix: Meta previews vertical by default; show all Google ad formats
Meta cards use a 9:16 frame (carousel 1:1 then 4:5). Google gallery balances Search/PMax/Display and surfaces PMax multi-format thumbs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 18:22:01 +02:00

947 lines
27 KiB
TypeScript

/** Meta ad creatives for Ad Tracker (vertical previews + full copy/assets). */
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { leadFromMetaActions, linkClicksFromMetaActions } from '@/lib/meta-leads';
const META_GRAPH = 'https://graph.facebook.com/v21.0';
const CREATIVE_FIELDS = [
'id',
'name',
'title',
'body',
'call_to_action_type',
'link_url',
'thumbnail_url',
'image_url',
'image_hash',
'video_id',
'object_story_spec',
'asset_feed_spec',
'effective_object_story_id',
].join(',');
export type CreativeAspect = '9:16' | '4:5' | '16:9' | '1:1' | 'unknown';
export type CreativeAsset = {
kind: 'image' | 'video' | 'logo' | 'youtube';
url: string;
label?: string;
aspect?: CreativeAspect;
youtubeId?: string;
title?: string;
};
/** Google Ads channel bucket for gallery grouping. */
export type GoogleCreativeChannel =
| 'search_brand'
| 'search_generic'
| 'pmax'
| 'display'
| 'other';
export type AdCreativeCard = {
platform: 'meta' | 'google';
adId: string;
adName: string;
campaignName: string;
adsetName?: string;
spend: number;
impressions: number;
clicks: number;
conversions: number;
cpl: number | null;
mediaType: string;
/** Google: search_brand | search_generic | pmax | display | other */
channel?: GoogleCreativeChannel;
/** Best preview URL (prefer vertical / portrait). */
thumbnailUrl: string;
imageUrl: string;
/** Joined for backwards compat; prefer headlines/bodies/descriptions arrays. */
headline: string;
primaryText: string;
/** Titulares (Meta titles / Google headlines), máx. 5. */
headlines: string[];
/** Textos principales (Meta bodies), máx. 5. */
bodies: string[];
/** Descripción del enlace (Meta descriptions), suele ser 1. */
descriptions: string[];
longHeadlines?: string[];
assets: CreativeAsset[];
linkUrl?: string;
cta?: string;
businessName?: string;
status?: string;
youtubeId?: string;
};
type DailyAgg = {
adId: string;
adName: string;
campaignName: string;
spend: number;
impressions: number;
clicks: number;
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 uniq(arr: string[]): string[] {
return [...new Set(arr.filter(Boolean))];
}
export function inferCreativeAspect(
label?: string,
width?: number,
height?: number
): CreativeAspect {
const L = (label || '').toLowerCase();
// Dimensiones primero: labels tipo "video" no deben forzar landscape.
if (width && height && width > 0 && height > 0) {
const r = width / height;
if (r <= 0.62) return '9:16';
if (r <= 0.9) return '4:5';
if (r >= 1.5) return '16:9';
if (Math.abs(r - 1) < 0.12) return '1:1';
}
if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16';
if (/4\s*:?\s*5|portrait|feed_portrait/.test(L)) return '4:5';
if (/16\s*:?\s*9|1\.91|landscape|horizontal|right_column|youtube/.test(L)) {
return '16:9';
}
if (/1\s*:?\s*1|square/.test(L)) return '1:1';
return 'unknown';
}
function detectMediaType(creative: Record<string, unknown>): string {
if (creative.video_id) return 'video';
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
if (feed?.videos || feed?.video_ids) return 'video';
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
const link = oss?.link_data as Record<string, unknown> | undefined;
if (Array.isArray(link?.child_attachments) && (link!.child_attachments as unknown[]).length > 1) {
return 'carousel';
}
if (creative.image_url || creative.image_hash || creative.thumbnail_url) return 'image';
return 'unknown';
}
type Extracted = {
headlines: string[];
bodies: string[];
descriptions: string[];
assets: CreativeAsset[];
linkUrl: string;
cta: string;
hashes: string[];
};
function extractCreativePack(creative: Record<string, unknown>): Extracted {
const headlines: string[] = [];
const bodies: 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,
width?: number,
height?: number
) => {
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,
aspect: inferCreativeAspect(label, width, height),
});
};
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;
const photo = oss?.photo_data as Record<string, unknown> | undefined;
// Story (anuncio publicado): 1 titular + texto + descripción de enlace.
if (link?.name) headlines.push(str(link.name));
if (link?.message) bodies.push(str(link.message));
if (link?.description) descriptions.push(str(link.description));
pushImg(link?.picture, '4:5 story', 1080, 1350);
pushImg(link?.image_url, '4:5 story', 1080, 1350);
pushHash(link?.image_hash);
if (video?.title) headlines.push(str(video.title));
if (video?.message) bodies.push(str(video.message));
if (video?.link_description) descriptions.push(str(video.link_description));
pushImg(video?.image_url, 'video_poster 9:16', 1080, 1920);
pushHash(video?.image_hash);
pushImg(photo?.url, 'photo 4:5', 1080, 1350);
pushHash(photo?.image_hash);
if (creative.title) headlines.push(str(creative.title));
if (creative.body) bodies.push(str(creative.body));
pushImg(creative.image_url, 'image 4:5', 1080, 1350);
pushImg(creative.thumbnail_url, 'thumbnail 9:16', 1080, 1920);
pushHash(creative.image_hash);
if (Array.isArray(link?.child_attachments)) {
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
// Carrusel: preferimos 1:1; si el asset es más alto, se reclasifica por ratio.
pushImg(c.picture || c.image_url, `carousel-${i + 1} 1:1`, 1080, 1080);
pushHash(c.image_hash);
if (c.name) headlines.push(str(c.name));
if (c.message) bodies.push(str(c.message));
if (c.description) descriptions.push(str(c.description));
});
}
// Dynamic creative / Advantage+: hasta 5 titles, 5 bodies, descriptions.
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
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) bodies.push(str(b.text));
}
for (const d of (feed.descriptions as { text?: string }[]) || []) {
if (d?.text) descriptions.push(str(d.text));
}
for (const img of (feed.images as Record<string, unknown>[]) || []) {
const labels = Array.isArray(img.adlabels)
? (img.adlabels as { name?: string }[])
.map((l) => str(l.name))
.filter(Boolean)
.join(' ')
: '';
pushImg(
img.url || img.permalink_url,
labels || 'feed',
n(img.width) || undefined,
n(img.height) || undefined
);
pushHash(img.hash);
}
for (const v of (feed.videos as Record<string, unknown>[]) || []) {
pushImg(v.thumbnail_url || v.picture, 'video_feed 9:16', 1080, 1920);
if (v.video_id) {
assets.push({
kind: 'video',
url: str(v.thumbnail_url || v.picture),
label: 'video 9:16',
title: str(v.video_id),
aspect: '9:16',
});
}
}
}
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 {
headlines: uniq(headlines).slice(0, 5),
bodies: uniq(bodies).slice(0, 5),
descriptions: uniq(descriptions).slice(0, 5),
assets,
linkUrl,
cta,
hashes: uniq(hashes),
};
}
/**
* Meta preview picker:
* - Default: vertical 9:16 (vídeo/imagen), then 4:5
* - Carousel: 1:1, then 4:5
*/
export function pickMetaPreviewAsset(
assets: CreativeAsset[],
mediaType: string,
fallbackUrl = ''
): { url: string; aspect: CreativeAspect } {
const withUrl = assets.filter((a) => a.url);
const aspectOf = (a: CreativeAsset) =>
a.aspect && a.aspect !== 'unknown'
? a.aspect
: inferCreativeAspect(a.label);
if (mediaType === 'carousel') {
const square = withUrl.find((a) => aspectOf(a) === '1:1');
if (square) return { url: square.url, aspect: '1:1' };
const fourFive = withUrl.find((a) => aspectOf(a) === '4:5');
if (fourFive) return { url: fourFive.url, aspect: '4:5' };
const carousel = withUrl.find((a) => /carousel/i.test(a.label || ''));
if (carousel) return { url: carousel.url, aspect: '1:1' };
if (withUrl[0]) return { url: withUrl[0].url, aspect: '1:1' };
return { url: fallbackUrl, aspect: '1:1' };
}
// Prefer video poster/frame labeled 9:16, then any 9:16 image, then 4:5
const videoNine = withUrl.find(
(a) =>
(a.kind === 'video' || /video|reel|story/i.test(a.label || '')) &&
aspectOf(a) === '9:16'
);
if (videoNine) return { url: videoNine.url, aspect: '9:16' };
const nine = withUrl.find((a) => aspectOf(a) === '9:16');
if (nine) return { url: nine.url, aspect: '9:16' };
const fourFive = withUrl.find((a) => aspectOf(a) === '4:5');
if (fourFive) return { url: fourFive.url, aspect: '4:5' };
const thumb = withUrl.find((a) =>
/thumbnail|video_poster|video_feed/i.test(a.label || '')
);
if (thumb) return { url: thumb.url, aspect: '9:16' };
if (withUrl[0]) {
return { url: withUrl[0].url, aspect: aspectOf(withUrl[0]) || '9:16' };
}
return { url: fallbackUrl, aspect: '9:16' };
}
async function sleep(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}
async function graphGet<T>(
path: string,
params: Record<string, string>,
token: string
): Promise<T> {
const q = new URLSearchParams({ ...params, access_token: token });
const url = `${META_GRAPH}/${path}?${q}`;
let lastMsg = 'fetch failed';
for (let attempt = 0; attempt < 3; attempt++) {
try {
const r = await fetch(url, {
cache: 'no-store',
signal: AbortSignal.timeout(28_000),
});
if (r.ok) return (await r.json()) as T;
lastMsg = (await r.text()).slice(0, 220);
// Rate limit / transient
if (r.status === 429 || r.status >= 500) {
await sleep(500 * (attempt + 1));
continue;
}
break;
} catch (e) {
lastMsg = e instanceof Error ? e.message : String(e);
await sleep(400 * (attempt + 1));
}
}
throw new Error(`Meta ${path}: ${lastMsg}`);
}
function cleanMetaAdName(name: string): string {
if (/^__lc:/.test(name)) return '';
return name;
}
/** Campaign-level cards from warehouse — always available, never throws. */
export function buildMetaWarehouseCards(
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
since: string,
until: string,
limit = 16
): AdCreativeCard[] {
return metaCardsFromDaily(dailyRows, since, until, limit);
}
function metaCardsFromDaily(
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
since: string,
until: string,
limit: number
): AdCreativeCard[] {
return aggregateDailyByAd(dailyRows, 'meta', since, until)
.slice(0, limit)
.map((agg) => {
const adName =
cleanMetaAdName(agg.adName) || agg.campaignName || agg.adId;
return {
platform: 'meta' as const,
adId: agg.adId,
adName,
campaignName: agg.campaignName,
spend: Math.round(agg.spend * 100) / 100,
impressions: agg.impressions,
clicks: agg.clicks,
conversions: Math.round(agg.conversions * 100) / 100,
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
mediaType: 'campaign',
thumbnailUrl: '',
imageUrl: '',
headline: adName,
primaryText: agg.campaignName,
headlines: adName ? [adName] : [],
bodies: [],
descriptions: agg.campaignName ? [agg.campaignName] : [],
assets: [] as CreativeAsset[],
status: undefined,
};
});
}
async function resolveMetaAccountId(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.meta_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();
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: {
platform?: string;
ad_id?: string | null;
ad_name?: string | null;
campaign_name?: string | null;
spend?: number;
impressions?: number;
clicks?: number;
platform_leads?: number;
conversions?: number;
date?: string;
}[],
platform: 'meta' | 'google',
since: string,
until: string
): DailyAgg[] {
const map = new Map<string, DailyAgg>();
for (const r of rows) {
if ((r.platform || '').toLowerCase() !== platform) continue;
const d = (r.date || '').slice(0, 10);
if (d < since || d > until) continue;
const adId = str(r.ad_id);
if (!adId) continue;
const cur = map.get(adId) || {
adId,
adName: str(r.ad_name) || adId,
campaignName: str(r.campaign_name),
spend: 0,
impressions: 0,
clicks: 0,
conversions: 0,
};
cur.spend += n(r.spend);
cur.impressions += n(r.impressions);
cur.clicks += n(r.clicks);
cur.conversions += n(r.platform_leads ?? r.conversions);
if (str(r.ad_name)) cur.adName = str(r.ad_name);
if (str(r.campaign_name)) cur.campaignName = str(r.campaign_name);
map.set(adId, cur);
}
return [...map.values()]
.filter(
(a) =>
a.spend > 0 || a.impressions > 0 || a.clicks > 0 || a.conversions > 0
)
.sort((a, b) => b.spend - a.spend);
}
async function fetchMetaCreativeBundle(
adId: string,
token: string
): Promise<{
adId: string;
adName: string;
campaignName: string;
adsetName: string;
status: string;
creative: Record<string, unknown>;
} | null> {
try {
const raw = await graphGet<{
id?: string;
name?: string;
effective_status?: string;
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: '1920',
},
token
);
let creative = (raw.creative || {}) as Record<string, unknown>;
const cid = str(creative.id);
if (cid) {
try {
const detail = await graphGet<Record<string, unknown>>(
cid,
{
fields: CREATIVE_FIELDS,
thumbnail_width: '1080',
thumbnail_height: '1920',
},
token
);
creative = { ...creative, ...detail };
} catch {
/* keep */
}
}
return {
adId: str(raw.id) || adId,
adName: str(raw.name),
campaignName: str(raw.campaign?.name),
adsetName: str(raw.adset?.name),
status: str(raw.effective_status),
creative,
};
} catch {
return null;
}
}
type MetaAdInsight = {
adId: string;
adName: string;
campaignId: string;
campaignName: string;
spend: number;
impressions: number;
clicks: number;
conversions: number;
};
/**
* ad_daily is campaign-level for Meta (ad_id === campaign_id).
* Pull real ad-level insights for the range so creatives resolve correctly.
* Splits long ranges into ~14d windows (Meta often fails on large ad-level pulls).
*/
async function fetchMetaAdLevelInsights(
accountId: string,
token: string,
since: string,
until: string,
limit: number
): Promise<MetaAdInsight[]> {
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
const windows: { since: string; until: string }[] = [];
{
const start = new Date(since + 'T00:00:00Z');
const end = new Date(until + 'T00:00:00Z');
let cur = new Date(start);
while (cur <= end) {
const wEnd = new Date(cur);
wEnd.setUTCDate(wEnd.getUTCDate() + 13);
if (wEnd > end) wEnd.setTime(end.getTime());
windows.push({
since: cur.toISOString().slice(0, 10),
until: wEnd.toISOString().slice(0, 10),
});
cur = new Date(wEnd);
cur.setUTCDate(cur.getUTCDate() + 1);
}
}
const byAd = new Map<string, MetaAdInsight>();
for (const w of windows) {
let after: string | undefined;
for (let page = 0; page < 8; page++) {
const params: Record<string, string> = {
level: 'ad',
time_increment: 'all_days',
time_range: JSON.stringify({ since: w.since, until: w.until }),
fields:
'ad_id,ad_name,campaign_id,campaign_name,impressions,clicks,spend,actions',
limit: '100',
};
if (after) params.after = after;
const raw = await graphGet<{
data?: {
ad_id?: string;
ad_name?: string;
campaign_id?: string;
campaign_name?: string;
impressions?: string;
clicks?: string;
spend?: string;
actions?: { action_type?: string; value?: string }[];
}[];
paging?: { cursors?: { after?: string }; next?: string };
}>(`${act}/insights`, params, token);
for (const row of raw.data || []) {
const adId = str(row.ad_id);
if (!adId) continue;
const conversions = leadFromMetaActions(row.actions);
const cur = byAd.get(adId) || {
adId,
adName: str(row.ad_name),
campaignId: str(row.campaign_id),
campaignName: str(row.campaign_name),
spend: 0,
impressions: 0,
clicks: 0,
conversions: 0,
};
cur.adName = str(row.ad_name) || cur.adName;
cur.campaignName = str(row.campaign_name) || cur.campaignName;
cur.spend += Math.round(n(row.spend) * 100) / 100;
cur.impressions += n(row.impressions);
cur.clicks += n(row.clicks);
cur.conversions += conversions;
byAd.set(adId, cur);
}
after = raw.paging?.cursors?.after;
if (!raw.paging?.next || !after) break;
}
}
return [...byAd.values()]
.filter(
(a) =>
a.spend > 0 || a.impressions > 0 || a.clicks > 0 || a.conversions > 0
)
.sort((a, b) => b.spend - a.spend)
.slice(0, limit);
}
function cardFromBundle(
agg: {
adId: string;
adName: string;
campaignName: string;
spend: number;
impressions: number;
clicks: number;
conversions: number;
},
b: Awaited<ReturnType<typeof fetchMetaCreativeBundle>>,
pack: Extracted,
hashMap: Map<string, string>
): AdCreativeCard {
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 mediaType = detectMediaType(creative);
const picked = pickMetaPreviewAsset(assets, mediaType, fallback);
const headlines = pack.headlines;
const bodies = pack.bodies;
const descriptions = pack.descriptions;
return {
platform: 'meta',
adId: b?.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,
thumbnailUrl: picked.url,
imageUrl: picked.url,
headline: headlines.join('\n'),
primaryText: bodies.join('\n') || descriptions.join('\n'),
headlines,
bodies,
descriptions,
assets,
linkUrl: pack.linkUrl || undefined,
cta: pack.cta || undefined,
status: b?.status,
};
}
/**
* Build Meta creative cards: live ad-level insights + creative assets.
* Never fails the UI: on any Graph error → campaign ads → warehouse cards.
* Soft-fail warnings are omitted when we still return ads.
*/
export async function fetchMetaCreativeCards(opts: {
tenantId: string;
dailyRows: Parameters<typeof aggregateDailyByAd>[0];
since: string;
until: string;
limit?: number;
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
const warehouse = () =>
metaCardsFromDaily(opts.dailyRows, opts.since, opts.until, opts.limit ?? 16);
try {
return await fetchMetaCreativeCardsInner(opts, warehouse);
} catch {
const ads = warehouse();
return ads.length
? { ads }
: { ads: [], warning: 'Sin actividad Meta en este periodo' };
}
}
async function fetchMetaCreativeCardsInner(
opts: {
tenantId: string;
dailyRows: Parameters<typeof aggregateDailyByAd>[0];
since: string;
until: string;
limit?: number;
},
warehouse: () => AdCreativeCard[]
): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
const token = await resolveMetaAccessToken();
if (!token) {
const ads = warehouse();
return ads.length
? { ads }
: { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' };
}
const account = await resolveMetaAccountId(opts.tenantId);
if (!account) {
const ads = warehouse();
return ads.length
? { ads }
: { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' };
}
const limit = opts.limit ?? 16;
let targets: {
adId: string;
adName: string;
campaignName: string;
spend: number;
impressions: number;
clicks: number;
conversions: number;
}[] = [];
try {
targets = await fetchMetaAdLevelInsights(
account,
token,
opts.since,
opts.until,
limit
);
} catch {
/* fall through — never surface ad-level Graph errors */
}
// Fallback: expand campaigns from ad_daily → ads under each campaign
if (!targets.length) {
const campaigns = aggregateDailyByAd(
opts.dailyRows,
'meta',
opts.since,
opts.until
).slice(0, 8);
for (const camp of campaigns) {
try {
const raw = await graphGet<{
data?: { id?: string; name?: string; effective_status?: string }[];
}>(
`${camp.adId}/ads`,
{
fields: 'id,name,effective_status',
limit: '6',
filtering: JSON.stringify([
{
field: 'effective_status',
operator: 'IN',
value: ['ACTIVE', 'PAUSED', 'CAMPAIGN_PAUSED', 'ADSET_PAUSED'],
},
]),
},
token
);
const ads = raw.data || [];
const share = ads.length || 1;
for (const ad of ads) {
const id = str(ad.id);
if (!id) continue;
targets.push({
adId: id,
adName:
str(ad.name) ||
cleanMetaAdName(camp.adName) ||
camp.campaignName,
campaignName: camp.campaignName,
spend: camp.spend / share,
impressions: Math.round(camp.impressions / share),
clicks: Math.round(camp.clicks / share),
conversions: camp.conversions / share,
});
}
} catch {
/* skip campaign */
}
if (targets.length >= limit) break;
}
targets = targets.slice(0, limit);
}
// Last resort: warehouse campaign cards (always succeeds if there is spend)
if (!targets.length) {
const ads = warehouse();
return ads.length
? { ads }
: { ads: [], warning: 'Sin actividad Meta en este periodo' };
}
const bundles: (Awaited<ReturnType<typeof fetchMetaCreativeBundle>>)[] = [];
for (let i = 0; i < targets.length; i += 6) {
const chunk = targets.slice(i, i + 6);
const part = await Promise.all(
chunk.map(async (a) => {
try {
return await fetchMetaCreativeBundle(a.adId, token);
} catch {
return null;
}
})
);
bundles.push(...part);
}
const allHashes: string[] = [];
const packs = bundles.map((b) => {
const pack = extractCreativePack(b?.creative || {});
allHashes.push(...pack.hashes);
return pack;
});
let hashMap = new Map<string, string>();
try {
hashMap = await resolveImageHashes(account, allHashes, token);
} catch {
/* previews may still work from creative URLs */
}
const out: AdCreativeCard[] = targets.map((agg, idx) =>
cardFromBundle(agg, bundles[idx], packs[idx], hashMap)
);
if (!out.length) {
return { ads: warehouse() };
}
return { ads: out };
}
/**
* Account-level Meta clicks for the period:
* - clicks = todos (Ads Manager)
* - linkClicks = clics en el enlace
*/
export async function fetchMetaAccountClickSplit(opts: {
tenantId: string;
since: string;
until: string;
}): Promise<{ clicks: number; linkClicks: number; spend: number } | null> {
const token = await resolveMetaAccessToken();
if (!token) return null;
const account = await resolveMetaAccountId(opts.tenantId);
if (!account) return null;
const act = account.startsWith('act_') ? account : `act_${account}`;
const timeRange = JSON.stringify({ since: opts.since, until: opts.until });
try {
const raw = await graphGet<{
data?: {
spend?: string;
clicks?: string;
actions?: { action_type?: string; value?: string }[];
}[];
}>(
`${act}/insights`,
{
fields: 'spend,clicks,actions',
level: 'account',
time_increment: 'all_days',
time_range: timeRange,
limit: '1',
},
token
);
const row = raw.data?.[0];
if (!row) return null;
const clicks = n(row.clicks);
return {
clicks,
linkClicks: linkClicksFromMetaActions(row.actions, clicks),
spend: Math.round(n(row.spend) * 100) / 100,
};
} catch {
return null;
}
}