fix: split Google Ads creatives by Search / PMax / Display

Fetch display/RDA assets, classify Brand vs Generic Search, and group
the Ad Tracker gallery so previews are not a single undifferentiated image.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-05 17:56:05 +02:00
parent f3380b9650
commit ce6dc4e0f0
4 changed files with 278 additions and 15 deletions

View File

@@ -2,7 +2,11 @@
import { googleAdsConfigured } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta';
import type {
AdCreativeCard,
CreativeAsset,
GoogleCreativeChannel,
} from '@/lib/ad-creatives-meta';
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
import {
friendlyGoogleCreativeWarning,
@@ -12,7 +16,12 @@ import {
const GADS_API = 'https://googleads.googleapis.com/v22';
type GaqlRow = {
campaign?: { id?: string; name?: string; status?: string };
campaign?: {
id?: string;
name?: string;
status?: string;
advertisingChannelType?: string;
};
adGroup?: { id?: string; name?: string };
adGroupAd?: {
status?: string;
@@ -37,6 +46,7 @@ type GaqlRow = {
imageAd?: { imageUrl?: string; name?: string };
};
};
adGroupAdAssetView?: { fieldType?: string };
assetGroup?: {
id?: string;
name?: string;
@@ -63,6 +73,44 @@ type GaqlRow = {
};
};
/** Classify Google creative into gallery buckets (Search brand/generic, PMax, Display). */
export function classifyGoogleChannel(opts: {
campaignName?: string;
channelType?: string;
mediaType?: string;
adsetName?: string;
}): GoogleCreativeChannel {
const camp = `${opts.campaignName || ''} ${opts.adsetName || ''}`.toUpperCase();
const ct = (opts.channelType || '').toUpperCase();
const mt = (opts.mediaType || '').toLowerCase();
if (
mt === 'pmax' ||
ct.includes('PERFORMANCE_MAX') ||
/P[\s_-]?MAX|PERFORMANCE[\s_-]?MAX/.test(camp)
) {
return 'pmax';
}
if (
mt === 'rda' ||
mt === 'image' ||
ct.includes('DISPLAY') ||
ct.includes('DEMAND_GEN') ||
/DISPLAY|REMARKETING|REMARK|RMKT|RETARGET|DISCOVERY|DEMAND/.test(camp)
) {
return 'display';
}
if (/BRAND|MARCA|\bBRND\b/.test(camp)) return 'search_brand';
if (
mt === 'rsa' ||
ct.includes('SEARCH') ||
/SEARCH|B[ÚU]SQUEDA|GENERIC|GEN[EÉ]RICO/.test(camp)
) {
return 'search_generic';
}
return 'other';
}
function n(v: unknown): number {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
@@ -176,6 +224,7 @@ type AgBucket = {
adName: string;
campaignId: string;
campaignName: string;
channelType: string;
status: string;
finalUrls: string[];
headlines: string[];
@@ -244,7 +293,7 @@ export async function fetchGoogleCreativeCards(opts: {
until: string;
limit?: number;
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
const limit = opts.limit ?? 24;
const limit = opts.limit ?? 48;
const warehouseFallback = (warning: string) => {
const ads = cardsFromDaily(
opts.dailyRows,
@@ -284,7 +333,7 @@ export async function fetchGoogleCreativeCards(opts: {
const dateWhere = `segments.date BETWEEN '${opts.since}' AND '${opts.until}'`;
try {
const [agAssets, agMetrics, adRows, adMetrics] = await Promise.all([
const [agAssets, agMetrics, adRows, adMetrics, adAssets] = await Promise.all([
searchGoogle(
customerId,
accessToken,
@@ -292,6 +341,7 @@ export async function fetchGoogleCreativeCards(opts: {
SELECT
campaign.id,
campaign.name,
campaign.advertising_channel_type,
asset_group.id,
asset_group.name,
asset_group.status,
@@ -330,6 +380,7 @@ WHERE ${dateWhere}
SELECT
campaign.id,
campaign.name,
campaign.advertising_channel_type,
ad_group.id,
ad_group.name,
ad_group_ad.status,
@@ -351,7 +402,7 @@ SELECT
FROM ad_group_ad
WHERE ad_group_ad.status != 'REMOVED'
AND campaign.status != 'REMOVED'
LIMIT 80
LIMIT 120
`
),
searchGoogle(
@@ -368,8 +419,60 @@ FROM ad_group_ad
WHERE ${dateWhere}
`
),
searchGoogle(
customerId,
accessToken,
`
SELECT
ad_group_ad.ad.id,
ad_group_ad_asset_view.field_type,
asset.image_asset.full_size.url,
asset.youtube_video_asset.youtube_video_id,
asset.youtube_video_asset.youtube_video_title
FROM ad_group_ad_asset_view
WHERE ad_group_ad.status != 'REMOVED'
AND campaign.status != 'REMOVED'
AND ad_group_ad_asset_view.field_type IN (
'MARKETING_IMAGE',
'SQUARE_MARKETING_IMAGE',
'PORTRAIT_MARKETING_IMAGE',
'LOGO',
'LANDSCAPE_LOGO',
'YOUTUBE_VIDEO'
)
LIMIT 400
`
).catch(() => [] as GaqlRow[]),
]);
const assetsByAdId = new Map<string, CreativeAsset[]>();
for (const row of adAssets) {
const adId = str(row.adGroupAd?.ad?.id);
if (!adId) continue;
const list = assetsByAdId.get(adId) || [];
const ft = enumName(row.adGroupAdAssetView?.fieldType).toUpperCase();
const img = str(row.asset?.imageAsset?.fullSize?.url);
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle);
if (img && !list.some((a) => a.url === img)) {
list.push({
kind: ft.includes('LOGO') ? 'logo' : 'image',
url: img,
label: ft || 'MARKETING_IMAGE',
});
}
if (yt && !list.some((a) => a.youtubeId === yt)) {
list.push({
kind: 'youtube',
url: ytThumb(yt),
label: 'YOUTUBE_VIDEO',
youtubeId: yt,
title: ytTitle || yt,
});
}
assetsByAdId.set(adId, list);
}
const agMetricMap = new Map<
string,
{ spend: number; impressions: number; clicks: number; conversions: number }
@@ -433,6 +536,7 @@ WHERE ${dateWhere}
adName: str(row.assetGroup?.name) || `Asset group ${agid}`,
campaignId: str(row.campaign?.id),
campaignName: str(row.campaign?.name),
channelType: enumName(row.campaign?.advertisingChannelType),
status: enumName(row.assetGroup?.status) || 'ENABLED',
finalUrls: (row.assetGroup?.finalUrls || []).map(str).filter(Boolean),
headlines: [],
@@ -509,6 +613,11 @@ WHERE ${dateWhere}
conversions: Math.round(conversions * 100) / 100,
cpl: conversions > 0 ? spend / conversions : null,
mediaType: 'pmax',
channel: classifyGoogleChannel({
campaignName: entry.campaignName,
channelType: entry.channelType,
mediaType: 'pmax',
}),
thumbnailUrl: preview,
imageUrl: preview,
headline: [...headlines, ...longHeadlines].join('\n'),
@@ -548,26 +657,50 @@ WHERE ${dateWhere}
...(rda?.descriptions || []).map((d) => str(d.text)),
]);
const imageUrl = str(ad?.imageAd?.imageUrl);
const assets: CreativeAsset[] = [];
if (imageUrl) assets.push({ kind: 'image', url: imageUrl, label: 'IMAGE_AD' });
const linkedAssets = assetsByAdId.get(adId) || [];
const assets: CreativeAsset[] = [...linkedAssets];
if (imageUrl && !assets.some((a) => a.url === 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 || linkedAssets.length
? 'image'
: adType;
const paths = [str(rsa?.path1), str(rsa?.path2)].filter(Boolean);
const channelType = enumName(row.campaign?.advertisingChannelType);
const campaignName = str(row.campaign?.name);
const adsetName = str(row.adGroup?.name);
const preview =
pickVerticalGoogle(assets) ||
assets.find((a) => a.kind === 'image')?.url ||
assets.find((a) => a.kind === 'youtube')?.url ||
'';
const firstYt = assets.find((a) => a.youtubeId)?.youtubeId;
cards.push({
platform: 'google',
adId,
adName: str(ad?.name) || headlines[0] || `Ad ${adId}`,
campaignName: str(row.campaign?.name),
adsetName: str(row.adGroup?.name),
campaignName,
adsetName,
spend: Math.round(spend * 100) / 100,
impressions,
clicks,
conversions: Math.round(conversions * 100) / 100,
cpl: conversions > 0 ? spend / conversions : null,
mediaType,
thumbnailUrl: imageUrl,
imageUrl,
channel: classifyGoogleChannel({
campaignName,
channelType,
mediaType,
adsetName,
}),
thumbnailUrl: preview,
imageUrl: preview,
headline: [...headlines, ...longHeadlines].join('\n'),
primaryText: descriptions.join('\n'),
headlines,
@@ -579,6 +712,7 @@ WHERE ${dateWhere}
cta: str(rda?.callToActionText) || undefined,
businessName: str(rda?.businessName) || undefined,
status: enumName(row.adGroupAd?.status),
youtubeId: firstYt,
});
if (paths.length) {
cards[cards.length - 1].bodies = [

View File

@@ -33,6 +33,14 @@ export type CreativeAsset = {
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;
@@ -45,6 +53,8 @@ export type AdCreativeCard = {
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;