fix(brandbook): no meter nombres de campaña en pilares/estilos
Los insights de ads solo aportan copy humano y ángulos semánticos; se limpian etiquetas ops ya guardadas al parsear y al enriquecer. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,17 @@ import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||
import { resolveGoogleAdsAccessToken } from '@/lib/google-ads-auth';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import type { Brandbook } from '@/lib/brandbook';
|
||||
import {
|
||||
looksLikeCampaignOpsLabel,
|
||||
scrubCampaignOpsFromBrandbook,
|
||||
scrubOpsNoiseList,
|
||||
} from '@/lib/brandbook-sanitize';
|
||||
|
||||
export {
|
||||
looksLikeCampaignOpsLabel,
|
||||
scrubCampaignOpsFromBrandbook,
|
||||
scrubOpsNoiseList,
|
||||
} from '@/lib/brandbook-sanitize';
|
||||
|
||||
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||
@@ -432,29 +443,110 @@ LIMIT 50
|
||||
return { headlines, descriptions, count };
|
||||
}
|
||||
|
||||
/** Ángulos a partir de nombres de campaña (TOFU/MOFU/BOFU, producto, etc.). */
|
||||
/** Texto de copy publicitario legible (claim), no nombre interno. */
|
||||
function looksLikeHumanAdCopy(raw: string): boolean {
|
||||
const s = String(raw || '').trim();
|
||||
if (s.length < 8 || s.length > 120) return false;
|
||||
if (looksLikeCampaignOpsLabel(s)) return false;
|
||||
// Prefer phrases with spaces / punctuation over codes
|
||||
if (!/\s/.test(s) && s.length > 24) return false;
|
||||
if (/^[a-z0-9]+([_-][a-z0-9]+){2,}$/i.test(s)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ángulos de embudo SOLO mapeados desde convenciones de nombre.
|
||||
* Nunca se copian los nombres crudos de campaña al brandbook.
|
||||
*/
|
||||
function anglesFromCampaignNames(names: string[]): string[] {
|
||||
const angles: string[] = [];
|
||||
for (const n of names) {
|
||||
const lower = n.toLowerCase();
|
||||
if (/tofu|awareness|traf[ií]co|alcance/.test(lower)) {
|
||||
angles.push('TOFU / tráfico / awareness');
|
||||
if (/tofu|awareness|traf[ií]co|alcance|reach|prospect/.test(lower)) {
|
||||
angles.push('Awareness / tráfico (TOFU)');
|
||||
}
|
||||
if (/mofu|consideraci|interacci|engagement/.test(lower)) {
|
||||
angles.push('MOFU / consideración');
|
||||
if (/mofu|consideraci|interacci|engagement|traffic.?warm/.test(lower)) {
|
||||
angles.push('Consideración / engagement (MOFU)');
|
||||
}
|
||||
if (/bofu|conversi|leads?|mensaje|whatsapp|ventas?/.test(lower)) {
|
||||
angles.push('BOFU / conversión / leads');
|
||||
if (/bofu|conversi|leads?|mensaje|whatsapp|ventas?|purchase|sales/.test(lower)) {
|
||||
angles.push('Conversión / leads (BOFU)');
|
||||
}
|
||||
if (/retarget|remarket|rmkt|warm.?audi/.test(lower)) {
|
||||
angles.push('Retargeting / audiencia caliente');
|
||||
}
|
||||
if (/brand|branding|awareness.?brand/.test(lower)) {
|
||||
angles.push('Branding / notoriedad');
|
||||
}
|
||||
if (/retarget|remarket|rmkt/.test(lower)) angles.push('Retargeting');
|
||||
// keep short campaign fragments as angle hints
|
||||
const clean = n
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (clean.length >= 8 && clean.length <= 60) angles.push(clean);
|
||||
}
|
||||
return uniq(angles, 12);
|
||||
return uniq(angles, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ángulos creativos derivados del copy observado (no de nombres de campaña).
|
||||
*/
|
||||
function creativeAnglesFromCopy(texts: string[]): string[] {
|
||||
const blob = texts.join('\n').toLowerCase();
|
||||
const angles: string[] = [];
|
||||
if (/prueba|gratis|demo|sin compromiso|trial/.test(blob)) {
|
||||
angles.push('Oferta de prueba / demo');
|
||||
}
|
||||
if (/garant[ií]a|devoluci[oó]n|riesgo\s*cero/.test(blob)) {
|
||||
angles.push('Garantía / riesgo cero');
|
||||
}
|
||||
if (
|
||||
/antes\s*y\s*despu[eé]s|resultados?|casos?\s*de\s*[eé]xito|\d+\s*%|\+\d+|clientes?\s+(satisfechos|reales)/.test(
|
||||
blob
|
||||
)
|
||||
) {
|
||||
angles.push('Resultados / prueba social');
|
||||
}
|
||||
if (/urgencia|hoy|solo\s+hasta|plazas?\s+limit|ultima?\s+hora|24\s*h/.test(blob)) {
|
||||
angles.push('Urgencia / escasez');
|
||||
}
|
||||
if (/precio|dto\.?|descuento|oferta|promo|2x1|ahorra/.test(blob)) {
|
||||
angles.push('Precio / promoción');
|
||||
}
|
||||
if (/dolor|problema|cansad|hart[oa]|frustr|sin tiempo|no encuentra/.test(blob)) {
|
||||
angles.push('Dolor / problema del cliente');
|
||||
}
|
||||
if (/c[oó]mo\s+|gu[ií]a|tips?|consejos?|aprende|descubre/.test(blob)) {
|
||||
angles.push('Educativo / cómo hacerlo');
|
||||
}
|
||||
if (/whatsapp|reserva|pide\s+cita|agenda|ll[aá]manos|contacta/.test(blob)) {
|
||||
angles.push('CTA directo / conversión');
|
||||
}
|
||||
return uniq(angles, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pilares de mensaje = temas editoriales recurrentes en el copy.
|
||||
* Nunca nombres de campaña ni etiquetas TOFU/MOFU/BOFU.
|
||||
*/
|
||||
function messagingPillarsFromCopy(texts: string[]): string[] {
|
||||
const blob = texts.join('\n').toLowerCase();
|
||||
const pillars: string[] = [];
|
||||
if (/educa|aprende|consejo|gu[ií]a|tips?|c[oó]mo\s+/.test(blob)) {
|
||||
pillars.push('Educación / utilidad');
|
||||
}
|
||||
if (/testimon|opini[oó]n|rese[nñ]a|cliente\s+real|caso\s+de\s+[eé]xito/.test(blob)) {
|
||||
pillars.push('Prueba social / testimonios');
|
||||
}
|
||||
if (/producto|servicio|tratamiento|soluci[oó]n|beneficio/.test(blob)) {
|
||||
pillars.push('Producto / beneficio');
|
||||
}
|
||||
if (/oferta|promo|descuento|lanzamiento|novedad/.test(blob)) {
|
||||
pillars.push('Oferta / novedades');
|
||||
}
|
||||
if (/marca|valores?|compromiso|misi[oó]n|historia|somos/.test(blob)) {
|
||||
pillars.push('Marca / valores');
|
||||
}
|
||||
if (/comunidad|junto|contigo|familia|equipo/.test(blob)) {
|
||||
pillars.push('Comunidad / cercanía');
|
||||
}
|
||||
if (/b2b|empresa|profesional|negocio|equipos?\s+de\s+trabajo/.test(blob)) {
|
||||
pillars.push('B2B / profesional');
|
||||
}
|
||||
return uniq(pillars, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -551,28 +643,44 @@ export async function collectAdsInsightsForTenant(
|
||||
);
|
||||
|
||||
const ads_ctas = uniq([...metaCtas], 10);
|
||||
const preferred_angles = anglesFromCampaignNames(campaignNames);
|
||||
const ads_angles = preferred_angles;
|
||||
const slogan_variants = uniq(
|
||||
[...metaHeadlines, ...gadsHeadlines].map((h) => clip(h, 60)),
|
||||
|
||||
// Ángulos: embudo (mapeado, sin nombres crudos) + ángulos creativos del copy
|
||||
const copyPool = [
|
||||
...metaBodies,
|
||||
...metaHeadlines,
|
||||
...gadsHeadlines,
|
||||
...gadsDescs,
|
||||
...igCaptions,
|
||||
...fbPosts,
|
||||
];
|
||||
const preferred_angles = uniq(
|
||||
[
|
||||
...creativeAnglesFromCopy(copyPool),
|
||||
...anglesFromCampaignNames(campaignNames),
|
||||
],
|
||||
12
|
||||
);
|
||||
const ads_angles = preferred_angles;
|
||||
|
||||
// USPs cortos: titulares Meta/Google (son claims ya usados en ads)
|
||||
const ads_usps = uniq(
|
||||
// Solo titulares que parecen claims humanos (nunca nombres de campaña)
|
||||
const claimHeadlines = uniq(
|
||||
[...metaHeadlines, ...gadsHeadlines]
|
||||
.filter((h) => h.length >= 8 && h.length <= 90)
|
||||
.filter(looksLikeHumanAdCopy)
|
||||
.map((h) => clip(h, 90)),
|
||||
10
|
||||
12
|
||||
);
|
||||
const slogan_variants = claimHeadlines.slice(0, 12);
|
||||
const ads_usps = claimHeadlines.slice(0, 10);
|
||||
|
||||
const linkedin_themes = uniq(
|
||||
calendarSocial.linkedin
|
||||
.map((t) => clip(t.split(/[·|]/)[0] || t, 80))
|
||||
.filter((t) => t.length >= 8),
|
||||
.filter((t) => t.length >= 8 && !looksLikeCampaignOpsLabel(t)),
|
||||
8
|
||||
);
|
||||
|
||||
const messaging_pillars = messagingPillarsFromCopy(copyPool);
|
||||
|
||||
const lines: string[] = [
|
||||
'=== COPY REAL DESDE ADS / RRSS (no inventado) ===',
|
||||
`Meta ads: ${metaCount} · Google ads: ${gadsCount} · IG captions: ${igCaptions.length} · FB posts: ${fbPosts.length} · Calendario RRSS: ${calendarSocial.texts.length}`,
|
||||
@@ -582,13 +690,27 @@ export async function collectAdsInsightsForTenant(
|
||||
}
|
||||
if (winning_hooks.length) {
|
||||
lines.push('', 'Hooks / primary texts / titulares observados:');
|
||||
winning_hooks.forEach((h, i) => lines.push(`${i + 1}. ${h}`));
|
||||
winning_hooks
|
||||
.filter((h) => !looksLikeCampaignOpsLabel(h))
|
||||
.forEach((h, i) => lines.push(`${i + 1}. ${h}`));
|
||||
}
|
||||
if (ads_ctas.length) {
|
||||
lines.push('', `CTAs Meta observados: ${ads_ctas.join(', ')}`);
|
||||
}
|
||||
if (preferred_angles.length) {
|
||||
lines.push('', `Ángulos (nombres de campaña): ${preferred_angles.join(' · ')}`);
|
||||
lines.push(
|
||||
'',
|
||||
`Ángulos (derivados de copy / embudo, no nombres crudos): ${preferred_angles.join(' · ')}`
|
||||
);
|
||||
}
|
||||
if (messaging_pillars.length) {
|
||||
lines.push('', `Pilares de mensaje (temas en copy): ${messaging_pillars.join(' · ')}`);
|
||||
}
|
||||
if (campaignNames.length) {
|
||||
lines.push(
|
||||
'',
|
||||
`Nombres de campaña (solo contexto interno, NO van a pilares/estilo): ${uniq(campaignNames, 8).join(' · ')}`
|
||||
);
|
||||
}
|
||||
if (igCaptions.length) {
|
||||
lines.push('', 'Captions IG recientes (muestra):');
|
||||
@@ -611,12 +733,16 @@ export async function collectAdsInsightsForTenant(
|
||||
}
|
||||
if (gadsHeadlines.length) {
|
||||
lines.push('', 'Headlines Google Ads (muestra):');
|
||||
uniq(gadsHeadlines, 10).forEach((h, i) => lines.push(`${i + 1}. ${h}`));
|
||||
uniq(gadsHeadlines.filter(looksLikeHumanAdCopy), 10).forEach((h, i) =>
|
||||
lines.push(`${i + 1}. ${h}`)
|
||||
);
|
||||
}
|
||||
|
||||
const partial: Partial<Brandbook> = {};
|
||||
if (winning_hooks.length) partial.winning_hooks = winning_hooks;
|
||||
if (example_good.length) partial.example_good = example_good;
|
||||
const cleanHooks = winning_hooks.filter((h) => !looksLikeCampaignOpsLabel(h));
|
||||
const cleanExamples = example_good.filter((h) => !looksLikeCampaignOpsLabel(h));
|
||||
if (cleanHooks.length) partial.winning_hooks = cleanHooks;
|
||||
if (cleanExamples.length) partial.example_good = cleanExamples;
|
||||
if (ads_ctas.length) {
|
||||
partial.ads_ctas = ads_ctas;
|
||||
partial.allowed_ctas = ads_ctas;
|
||||
@@ -629,27 +755,22 @@ export async function collectAdsInsightsForTenant(
|
||||
if (ads_usps.length) partial.ads_usps = ads_usps;
|
||||
if (linkedin_themes.length) partial.linkedin_themes = linkedin_themes;
|
||||
|
||||
// Campos editoriales derivados SOLO de copy observado (relleno si vacíos en apply)
|
||||
// Editoriales: solo claims humanos / temas de copy (nunca nombres de campaña)
|
||||
if (ads_usps.length) {
|
||||
partial.usp_differentiators = ads_usps.slice(0, 5).join(' · ');
|
||||
partial.differentiators = ads_usps.slice(0, 8);
|
||||
partial.value_proposition = ads_usps.slice(0, 3).join(' · ');
|
||||
}
|
||||
if (preferred_angles.length) {
|
||||
partial.messaging_pillars = preferred_angles.slice(0, 6);
|
||||
} else if (ads_usps.length) {
|
||||
partial.messaging_pillars = ads_usps.slice(0, 5);
|
||||
if (messaging_pillars.length) {
|
||||
partial.messaging_pillars = messaging_pillars;
|
||||
}
|
||||
const proof = uniq(
|
||||
[...metaBodies, ...gadsDescs, ...igCaptions]
|
||||
.filter((t) => /\d/.test(t) && t.length >= 20)
|
||||
.filter((t) => /\d/.test(t) && t.length >= 20 && !looksLikeCampaignOpsLabel(t))
|
||||
.map((t) => clip(t, 160)),
|
||||
6
|
||||
);
|
||||
if (proof.length) partial.proof_points = proof;
|
||||
if (slogan_variants.length && !partial.tagline) {
|
||||
// no: tagline no se inventa desde un headline suelto
|
||||
}
|
||||
|
||||
return {
|
||||
partial,
|
||||
@@ -669,6 +790,7 @@ export async function collectAdsInsightsForTenant(
|
||||
* - Por defecto rellena vacíos.
|
||||
* - Si `overwriteAdsFields`, pisa hooks/CTAs/ángulos/usps/ejemplos con copy real
|
||||
* (prioridad sobre síntesis editorial previa).
|
||||
* - Siempre limpia etiquetas operativas de campaña en campos editoriales.
|
||||
*/
|
||||
export function applyAdsInsightsToBrandbook(
|
||||
book: Brandbook,
|
||||
@@ -677,20 +799,26 @@ export function applyAdsInsightsToBrandbook(
|
||||
): Brandbook {
|
||||
const overwrite = opts?.overwriteAdsFields !== false;
|
||||
const p = bundle.partial;
|
||||
const out: Brandbook = { ...book };
|
||||
let out: Brandbook = scrubCampaignOpsFromBrandbook({ ...book });
|
||||
|
||||
const applyArr = (
|
||||
key: keyof Brandbook,
|
||||
next?: string[],
|
||||
mode: 'overwrite' | 'empty' = 'overwrite'
|
||||
mode: 'overwrite' | 'empty' | 'replace_if_contaminated' = 'overwrite'
|
||||
) => {
|
||||
if (!next?.length) return;
|
||||
const cur = out[key] as string[] | undefined;
|
||||
const cur = (out[key] as string[] | undefined) || [];
|
||||
const curClean = scrubOpsNoiseList(cur);
|
||||
if (mode === 'overwrite') {
|
||||
if (overwrite || !cur?.length) {
|
||||
if (overwrite || !curClean.length) {
|
||||
(out as Record<string, unknown>)[key] = next;
|
||||
}
|
||||
} else if (!cur?.length) {
|
||||
} else if (mode === 'replace_if_contaminated') {
|
||||
// Si el campo estaba vacío o solo tenía ruido de campañas, sustituir
|
||||
if (!curClean.length) {
|
||||
(out as Record<string, unknown>)[key] = next;
|
||||
}
|
||||
} else if (!curClean.length) {
|
||||
(out as Record<string, unknown>)[key] = next;
|
||||
}
|
||||
};
|
||||
@@ -698,10 +826,12 @@ export function applyAdsInsightsToBrandbook(
|
||||
const applyStrEmpty = (key: keyof Brandbook, next?: string) => {
|
||||
if (!next?.trim()) return;
|
||||
const cur = String(out[key] || '').trim();
|
||||
if (!cur) (out as Record<string, unknown>)[key] = next.trim();
|
||||
if (!cur || looksLikeCampaignOpsLabel(cur)) {
|
||||
(out as Record<string, unknown>)[key] = next.trim();
|
||||
}
|
||||
};
|
||||
|
||||
// Copy de ads: prioridad (overwrite)
|
||||
// Copy de ads: prioridad (overwrite) — ya filtrado en collect
|
||||
applyArr('winning_hooks', p.winning_hooks, 'overwrite');
|
||||
applyArr('example_good', p.example_good, 'overwrite');
|
||||
applyArr('ads_ctas', p.ads_ctas, 'overwrite');
|
||||
@@ -712,12 +842,13 @@ export function applyAdsInsightsToBrandbook(
|
||||
applyArr('ads_usps', p.ads_usps, 'overwrite');
|
||||
applyArr('linkedin_themes', p.linkedin_themes, 'overwrite');
|
||||
|
||||
// Editoriales: solo si vacíos
|
||||
// Editoriales: rellenar si vacío O si solo había nombres de campaña
|
||||
applyStrEmpty('usp_differentiators', p.usp_differentiators);
|
||||
applyStrEmpty('value_proposition', p.value_proposition);
|
||||
applyArr('differentiators', p.differentiators, 'empty');
|
||||
applyArr('messaging_pillars', p.messaging_pillars, 'empty');
|
||||
applyArr('proof_points', p.proof_points, 'empty');
|
||||
applyArr('differentiators', p.differentiators, 'replace_if_contaminated');
|
||||
applyArr('messaging_pillars', p.messaging_pillars, 'replace_if_contaminated');
|
||||
applyArr('proof_points', p.proof_points, 'replace_if_contaminated');
|
||||
|
||||
out = scrubCampaignOpsFromBrandbook(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
applyAdsInsightsToBrandbook,
|
||||
collectAdsInsightsForTenant,
|
||||
} from '@/lib/brandbook-ads-insights';
|
||||
import {
|
||||
looksLikeCampaignOpsLabel,
|
||||
scrubCampaignOpsFromBrandbook,
|
||||
scrubOpsNoiseList,
|
||||
} from '@/lib/brandbook-sanitize';
|
||||
import {
|
||||
driveConfigured,
|
||||
getTenantDriveFolderId,
|
||||
@@ -607,13 +612,15 @@ async function calendarSeriesHints(
|
||||
.pieces)
|
||||
: [];
|
||||
for (const p of pieces) {
|
||||
if (p.series) series.add(String(p.series).trim());
|
||||
if (p.pillar) pillars.add(String(p.pillar).trim());
|
||||
const s = String(p.series || '').trim();
|
||||
const pillar = String(p.pillar || '').trim();
|
||||
if (s && !looksLikeCampaignOpsLabel(s)) series.add(s);
|
||||
if (pillar && !looksLikeCampaignOpsLabel(pillar)) pillars.add(pillar);
|
||||
}
|
||||
}
|
||||
return {
|
||||
series: [...series].filter(Boolean).slice(0, 24),
|
||||
pillars: [...pillars].filter(Boolean).slice(0, 16),
|
||||
series: scrubOpsNoiseList([...series]).slice(0, 24),
|
||||
pillars: scrubOpsNoiseList([...pillars]).slice(0, 16),
|
||||
};
|
||||
} catch {
|
||||
return { series: [], pillars: [] };
|
||||
@@ -996,6 +1003,9 @@ export async function enrichTenantBrandbook(opts: {
|
||||
// Alias note for Superhuman / Drive matching (helps operators find docs)
|
||||
sources.push('superhuman_alias');
|
||||
|
||||
// Quitar nombres de campaña / etiquetas ops que hayan contaminado pilares, estilos, etc.
|
||||
book = scrubCampaignOpsFromBrandbook(book);
|
||||
|
||||
book.brand_name = book.brand_name || tenant.name;
|
||||
book.updated_at = new Date().toISOString();
|
||||
|
||||
|
||||
149
src/lib/brandbook-sanitize.ts
Normal file
149
src/lib/brandbook-sanitize.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Sanitiza campos de brandbook: no deben contener nombres internos de campaña
|
||||
* (TOFU_LAL_ES_2024, CBO|IG, etc.) ni embudo como si fueran pilares editoriales.
|
||||
*/
|
||||
import type { Brandbook } from '@/lib/brandbook';
|
||||
|
||||
function uniq(arr: string[], max = 24): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of arr) {
|
||||
const s = String(raw || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (s.length < 3) continue;
|
||||
const key = s.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(s);
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ángulos/pilares semánticos humanos (no ruido de naming). */
|
||||
const ALLOWED_SEMANTIC_LABELS =
|
||||
/^(awareness|consideraci[oó]n|conversi[oó]n|retargeting|branding|oferta de prueba|garant[ií]a|resultados|urgencia|precio|dolor|educativ|cta directo|educaci[oó]n|prueba social|producto|oferta \/|marca \/|comunidad|b2b)\b/i;
|
||||
|
||||
/**
|
||||
* Detecta etiquetas operativas de campañas/ads (nombres internos).
|
||||
* Ej.: "TOFU_Prospecting_LAL_ES_2024", "CBO Conversión IG", "ASC | Leads WA".
|
||||
*/
|
||||
export function looksLikeCampaignOpsLabel(raw: string): boolean {
|
||||
const s = String(raw || '').trim();
|
||||
if (s.length < 3) return true;
|
||||
if (ALLOWED_SEMANTIC_LABELS.test(s)) return false;
|
||||
|
||||
const lower = s.toLowerCase();
|
||||
const unders = (s.match(/_/g) || []).length;
|
||||
const pipes = (s.match(/\|/g) || []).length;
|
||||
if (unders >= 2 || pipes >= 1) return true;
|
||||
if (/^[A-Z0-9][A-Z0-9_\-]{6,}$/.test(s) && /_/.test(s)) return true;
|
||||
if (/\bact_\d+/i.test(s)) return true;
|
||||
if (/^(camp(aign)?|ad[\s_-]?set|conjunto)\b/i.test(s)) return true;
|
||||
|
||||
if (/^(tofu|mofu|bofu)([_\-\s].*)?$/i.test(s) && unders + pipes > 0) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/\b(tofu|mofu|bofu|cbo|abo|asc|rmkt|lal)\b/i.test(lower) &&
|
||||
(unders >= 1 ||
|
||||
pipes >= 1 ||
|
||||
/\d{4}/.test(s) ||
|
||||
/\b(es|pt|en|ig|fb|meta|gads)\b/i.test(lower))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
/^(tofu|mofu|bofu)\s*\/\s*(tr[aá]fico|consideraci|conversi)/i.test(lower) &&
|
||||
!/awareness|engagement|leads|audiencia/i.test(lower)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
/\b(20\d{2}|q[1-4])\b/i.test(lower) &&
|
||||
/\b(camp|ads?|meta|gads|fb|ig|google|cbo|asc)\b/i.test(lower)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^[a-z0-9]+([_-][a-z0-9]+){2,}$/i.test(s)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function scrubOpsNoiseList(items: string[] | undefined): string[] {
|
||||
if (!items?.length) return [];
|
||||
return uniq(
|
||||
items.filter(
|
||||
(x) => !looksLikeCampaignOpsLabel(x) && String(x).trim().length >= 3
|
||||
),
|
||||
24
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeFunnelNotContentPillar(raw: string): boolean {
|
||||
const s = String(raw || '').trim();
|
||||
if (looksLikeCampaignOpsLabel(s)) return true;
|
||||
return /\b(tofu|mofu|bofu|retargeting|remarketing|cbo|abo|asc|lookalike|prospecting)\b/i.test(
|
||||
s
|
||||
);
|
||||
}
|
||||
|
||||
function scrubContentPillars(items: string[] | undefined): string[] {
|
||||
if (!items?.length) return [];
|
||||
return uniq(
|
||||
items.filter(
|
||||
(x) =>
|
||||
!looksLikeFunnelNotContentPillar(x) && String(x).trim().length >= 3
|
||||
),
|
||||
16
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia campos editoriales contaminados con nombres de campaña / ops.
|
||||
* Seguro en cada parse/enrich: solo quita ruido operativo.
|
||||
*/
|
||||
export function scrubCampaignOpsFromBrandbook(book: Brandbook): Brandbook {
|
||||
const out: Brandbook = { ...book };
|
||||
out.messaging_pillars = scrubContentPillars(out.messaging_pillars);
|
||||
out.pillars_rrss = scrubContentPillars(out.pillars_rrss);
|
||||
|
||||
const listKeys: (keyof Brandbook)[] = [
|
||||
'preferred_angles',
|
||||
'ads_angles',
|
||||
'slogan_variants',
|
||||
'ads_usps',
|
||||
'differentiators',
|
||||
'winning_hooks',
|
||||
'example_good',
|
||||
'proof_points',
|
||||
'linkedin_themes',
|
||||
'series_recurrentes',
|
||||
'power_words',
|
||||
];
|
||||
for (const key of listKeys) {
|
||||
const cur = out[key];
|
||||
if (!Array.isArray(cur)) continue;
|
||||
(out as Record<string, unknown>)[key] = scrubOpsNoiseList(cur as string[]);
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
'usp_differentiators',
|
||||
'value_proposition',
|
||||
'photography_style',
|
||||
'illustration_style',
|
||||
'icon_style',
|
||||
'tone',
|
||||
] as const) {
|
||||
const cur = String(out[key] || '').trim();
|
||||
if (cur && looksLikeCampaignOpsLabel(cur)) {
|
||||
(out as Record<string, unknown>)[key] = '';
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
brandSiblingNote,
|
||||
resolveBrandSlug,
|
||||
} from '@/lib/tenant-brand-aliases';
|
||||
import { scrubCampaignOpsFromBrandbook } from '@/lib/brandbook-sanitize';
|
||||
|
||||
export const BRANDBOOK_DOC_TYPE = 'brandbook';
|
||||
export const BRANDBOOK_DOC_TITLE = 'Brandbook';
|
||||
@@ -1740,7 +1741,7 @@ export function parseBrandbook(raw: unknown): Brandbook | null {
|
||||
return String(v);
|
||||
};
|
||||
|
||||
return {
|
||||
const parsed: Brandbook = {
|
||||
...empty,
|
||||
...partial,
|
||||
version: 2,
|
||||
@@ -1887,6 +1888,9 @@ export function parseBrandbook(raw: unknown): Brandbook | null {
|
||||
testimonials_policy: String(partial.testimonials_policy || ''),
|
||||
competitors_never_mention: arr(partial.competitors_never_mention),
|
||||
};
|
||||
|
||||
// Nombres de campaña / etiquetas ops no deben vivir en pilares, estilos, USPs, etc.
|
||||
return scrubCampaignOpsFromBrandbook(parsed);
|
||||
}
|
||||
|
||||
function arr(v: unknown): string[] {
|
||||
|
||||
Reference in New Issue
Block a user