feat: otimizar app para campanhas de conversão low-ticket

Refina o pipeline de criação de campanhas para produtos de entrada
(ticket baixo, Pix/cartão) com foco em otimização por compra e controle
de custo, evitando prejuízo.

- scraper: lê sites SPA/JS (og tags, JSON-LD com preço/oferta, fallback
  de renderização via r.jina.ai) — antes só lia o <title>
- skins: nova skin "Festa Infantil / Produto de Entrada" com copy-rules
  de tripwire (Pix = acesso imediato, preço-âncora) e default em Vendas
- create: campanha de Vendas exige Pixel/Dataset e otimiza por
  OFFSITE_CONVERSIONS (antes caía para LINK_CLICKS), evento de conversão
  selecionável e estratégia Cost Cap (teto de custo por compra)
- corrige bid_strategy pré-mapeado errado no cliente que anulava o Bid Cap
- settings: persiste Pixel/Dataset ID e pré-preenche no formulário
- copy/analyzer: usam oferta, preço e formas de pagamento; variações A/B
  (preço / benefício / dor) para resposta direta
- creative: opção de estampar preço/oferta no criativo

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcio Bevervanso 2026-06-16 09:27:58 -03:00
parent 9bda154730
commit a995424725
12 changed files with 497 additions and 84 deletions

View file

@ -4,7 +4,20 @@ import { generateAdCopy } from '@/lib/ai/copywriter';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { context, copy_rules, objective, tone, count, category } = body;
const {
context,
copy_rules,
objective,
tone,
count,
category,
link,
offer,
price,
payment_methods,
guarantee,
urgency,
} = body;
if (!context || !objective) {
return NextResponse.json(
@ -13,7 +26,20 @@ export async function POST(request: NextRequest) {
);
}
const variations = await generateAdCopy({ context, copy_rules, objective, tone, count, category });
const variations = await generateAdCopy({
context,
copy_rules,
objective,
tone,
count,
category,
link,
offer,
price,
payment_methods,
guarantee,
urgency,
});
return NextResponse.json({ success: true, data: variations });
} catch (error) {

View file

@ -4,7 +4,7 @@ import { generateAdImage } from '@/lib/ai/creative';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { context, style, dimensions, mood } = body;
const { context, style, dimensions, mood, offer_text } = body;
if (!context || !style) {
return NextResponse.json(
@ -25,6 +25,7 @@ export async function POST(request: NextRequest) {
style,
dimensions: dim,
mood,
offer_text,
})
);

View file

@ -188,24 +188,57 @@ export async function POST(req: Request) {
);
}
if (bid_strategy === 'BID_CAP') {
if (bid_strategy === 'BID_CAP' || bid_strategy === 'COST_CAP') {
if (!finalBidAmount || finalBidAmount <= 0) {
const label = bid_strategy === 'COST_CAP' ? 'Custo por Resultado (Cost Cap)' : 'Bid Cap';
return NextResponse.json(
{ error: 'Você selecionou a estratégia de "Bid Cap", mas não informou o valor do lance (Bid). Por favor, volte e defina o valor do Bid Cap ou mude para "Volume Máximo".' },
{ error: `Você selecionou a estratégia de "${label}", mas não informou o valor. Volte e defina o teto em reais ou mude para "Volume Máximo".` },
{ status: 400 }
);
}
}
const { pixel_id } = body;
const { pixel_id, conversion_event } = body;
// Estratégia de lance:
// - BID_CAP → LOWEST_COST_WITH_BID_CAP (teto de lance no leilão)
// - COST_CAP → COST_CAP (teto de custo POR resultado/compra — ideal p/ não dar
// prejuízo em produto de entrada: você define o máximo que aceita pagar por compra)
// - default → LOWEST_COST_WITHOUT_CAP (volume máximo)
const finalBidStrategy =
bid_strategy === 'BID_CAP'
? 'LOWEST_COST_WITH_BID_CAP'
: bid_strategy === 'COST_CAP'
? 'COST_CAP'
: 'LOWEST_COST_WITHOUT_CAP';
let finalOptimizationGoal = optimization_goal;
let finalPromotedObject = promoted_object;
if (finalObjective === 'OUTCOME_SALES' && pixel_id) {
// Otimização por COMPRA: só funciona com Pixel/Dataset (CAPI). Sem isso, o
// Meta otimizaria por clique e o low-ticket sangra — então exigimos o pixel
// quando o objetivo é Vendas.
if (finalObjective === 'OUTCOME_SALES') {
if (!pixel_id) {
return NextResponse.json(
{
success: false,
error:
'Para campanhas de Vendas (otimização por compra) é obrigatório informar o Pixel/Dataset ID. Configure em Configurações ou no formulário. Sem ele, o Meta otimiza por clique e o produto de entrada tende a dar prejuízo.',
},
{ status: 400 }
);
}
const validEvents = ['PURCHASE', 'INITIATE_CHECKOUT', 'ADD_PAYMENT_INFO', 'ADD_TO_CART', 'LEAD'];
const eventType = validEvents.includes(String(conversion_event))
? conversion_event
: 'PURCHASE';
finalOptimizationGoal = 'OFFSITE_CONVERSIONS';
finalPromotedObject = {
pixel_id: pixel_id,
custom_event_type: 'PURCHASE',
pixel_id,
custom_event_type: eventType,
};
}
@ -213,8 +246,8 @@ export async function POST(req: Request) {
campaign_name,
objective: finalObjective,
daily_budget: finalDailyBudget,
bid_strategy: bid_strategy === 'BID_CAP' ? 'LOWEST_COST_WITH_BID_CAP' : 'LOWEST_COST_WITHOUT_CAP',
bid_amount: bid_amount,
bid_strategy: finalBidStrategy,
bid_amount: finalBidStrategy === 'LOWEST_COST_WITHOUT_CAP' ? undefined : finalBidAmount,
targeting: {
...(targeting || {
geo_locations: {

View file

@ -7,6 +7,7 @@ export async function GET() {
// Mask secrets so we don't send them back to the client
return NextResponse.json({
metaAppId: settings.metaAppId,
metaPixelId: settings.metaPixelId,
hasMetaSecret: !!settings.metaAppSecret,
hasOpenaiKey: !!settings.openaiApiKey,
});

View file

@ -51,6 +51,7 @@ export default function CreateCampaignPage() {
bid_amount: 10,
link: '',
pixel_id: '',
conversion_event: 'PURCHASE',
image_style: currentSkin.ai_guidelines.image_style,
});
@ -70,6 +71,9 @@ export default function CreateCampaignPage() {
const [imageSource, setImageSource] =
useState<ImageSource>('ai');
// Estampar o preço/oferta no criativo gerado por IA (bom p/ low-ticket DR).
const [stampOffer, setStampOffer] = useState(false);
const [uploadedImageBytes, setUploadedImageBytes] =
useState<string | null>(null);
@ -128,6 +132,26 @@ export default function CreateCampaignPage() {
};
}, []);
// Pré-preenche o Pixel/Dataset ID salvo em Configurações — para low-ticket,
// otimizar por COMPRA depende dele, então evitamos que o usuário esqueça.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch('/api/settings');
const data = await res.json();
if (!cancelled && data?.metaPixelId) {
setForm((prev) => (prev.pixel_id ? prev : { ...prev, pixel_id: data.metaPixelId }));
}
} catch {
// Sem bloqueio — o usuário pode digitar o Pixel manualmente.
}
})();
return () => {
cancelled = true;
};
}, []);
// Saves/updates the campaign draft in Supabase so generated copy and images
// survive errors during publish (e.g. Meta API failures) without needing
// to be regenerated from scratch.
@ -352,6 +376,13 @@ export default function CreateCampaignPage() {
objective: form.objective,
category: skinId,
count: 3,
link: form.link,
// Pistas de oferta para copy de resposta direta (low-ticket).
// Os ids batem com os campos da skin "festa_infantil".
offer: customFields['offer'],
payment_methods: customFields['payment'],
guarantee: customFields['bonus'],
urgency: customFields['bonus'],
}),
}
);
@ -383,6 +414,9 @@ export default function CreateCampaignPage() {
body: JSON.stringify({
context: getContextString(),
style: form.image_style,
offer_text: stampOffer
? customFields['offer'] || customFields['product_name']
: undefined,
}),
}
);
@ -483,18 +517,14 @@ export default function CreateCampaignPage() {
form.daily_budget * 100
),
bid_strategy:
form.bid_strategy ===
'BID_CAP'
? 'LOWEST_COST_WITH_BID_CAP'
: undefined,
// Envia o token cru ('VOLUME' | 'COST_CAP' | 'BID_CAP'); a rota
// converte para o enum da Meta e valida o bid_amount.
bid_strategy: form.bid_strategy,
bid_amount:
form.bid_strategy ===
'BID_CAP'
? Math.round(
form.bid_amount * 100
)
form.bid_strategy === 'BID_CAP' ||
form.bid_strategy === 'COST_CAP'
? Math.round(form.bid_amount * 100)
: undefined,
targeting:
@ -510,7 +540,9 @@ export default function CreateCampaignPage() {
instagram_actor_id: selectedInstagramAccount?.id || undefined,
pixel_id: form.pixel_id,
pixel_id: form.pixel_id || undefined,
conversion_event: form.conversion_event,
image_urls:
imageSource === 'ai'
@ -1076,6 +1108,10 @@ export default function CreateCampaignPage() {
Volume Máximo
</option>
<option value="COST_CAP">
Custo por Compra (Cost Cap)
</option>
<option value="BID_CAP">
Bid Cap
</option>
@ -1083,11 +1119,13 @@ export default function CreateCampaignPage() {
</div>
{/* BID */}
{form.bid_strategy ===
'BID_CAP' && (
{(form.bid_strategy === 'BID_CAP' ||
form.bid_strategy === 'COST_CAP') && (
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2 text-green-400">
Bid Cap (R$)
{form.bid_strategy === 'COST_CAP'
? 'Custo máx. por compra (R$)'
: 'Bid Cap (R$)'}
</label>
<input
@ -1111,28 +1149,70 @@ export default function CreateCampaignPage() {
})
}
/>
{form.bid_strategy === 'COST_CAP' && (
<p className="text-[11px] mt-1.5 text-white/50 leading-relaxed">
Teto que você aceita pagar <strong>por compra</strong>. O Meta tenta nunca passar disso ideal pra produto de entrada não dar prejuízo. Comece com um valor próximo ao seu lucro com o funil (order bump/upsell), não o ticket de entrada.
</p>
)}
</div>
)}
</div>
{/* PIXEL ID */}
{/* PIXEL + CONVERSION EVENT (otimização por compra) */}
{form.objective === 'OUTCOME_SALES' && (
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2 text-blue-400">
ID do Pixel (Recomendado para Vendas)
</label>
<div className="rounded-xl border border-blue-500/20 bg-blue-500/[0.04] p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-white mb-1">
Otimização por Compra (obrigatório para Vendas)
</h3>
<p className="text-[11px] text-white/50 leading-relaxed">
O Meta vai aprender a entregar para quem <strong>compra</strong>, não para quem clica. Sem o Pixel/Dataset, o produto de entrada tende a dar prejuízo.
</p>
</div>
<input
className="input-field"
value={form.pixel_id}
onChange={(e) =>
setForm({
...form,
pixel_id: e.target.value,
})
}
placeholder="Ex: 1494357542144348"
/>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2 text-blue-400">
Pixel / Dataset ID *
</label>
<input
className="input-field"
value={form.pixel_id}
onChange={(e) =>
setForm({
...form,
pixel_id: e.target.value,
})
}
placeholder="Ex: 1494357542144348"
/>
<p className="text-[11px] mt-1.5 text-white/40">
Como você envia a Compra via n8n/CAPI, use o mesmo Dataset ID que recebe o evento. Salve em Configurações para preencher automaticamente.
</p>
</div>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2 text-blue-400">
Evento de conversão para otimizar
</label>
<select
className="input-field"
value={form.conversion_event}
onChange={(e) =>
setForm({
...form,
conversion_event: e.target.value,
})
}
>
<option value="PURCHASE">Compra (Purchase) ideal quando volume</option>
<option value="INITIATE_CHECKOUT">Início de checkout bom p/ sair da fase de aprendizado</option>
<option value="ADD_PAYMENT_INFO">Adicionou pagamento</option>
<option value="ADD_TO_CART">Adicionou ao carrinho</option>
</select>
<p className="text-[11px] mt-1.5 text-white/40 leading-relaxed">
Com ticket baixo e orçamento pequeno, talvez não saiam ~50 compras/semana (mínimo pro Meta otimizar bem). Nesse caso, comece otimizando por <strong>Início de checkout</strong> e migre para <strong>Compra</strong> quando o volume crescer.
</p>
</div>
</div>
)}
@ -1245,6 +1325,23 @@ export default function CreateCampaignPage() {
</option>
</select>
)}
{imageSource === 'ai' && (
<label className="mt-3 flex items-start gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={stampOffer}
onChange={(e) => setStampOffer(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-[var(--accent)]"
/>
<span className="text-xs text-white/60 leading-relaxed">
Estampar o preço/oferta no criativo
<span className="block text-white/40">
Recomendado para produto de entrada preço visível costuma elevar o CTR. Usa o campo "Oferta + Preço".
</span>
</span>
</label>
)}
</div>
{/* BUTTON */}

View file

@ -11,6 +11,7 @@ export default function SettingsPage() {
const [metaAppId, setMetaAppId] = useState('');
const [metaAppSecret, setMetaAppSecret] = useState('');
const [metaPixelId, setMetaPixelId] = useState('');
const [openaiKey, setOpenaiKey] = useState('');
const [hasMetaSecret, setHasMetaSecret] = useState(false);
@ -21,6 +22,7 @@ export default function SettingsPage() {
.then(res => res.json())
.then(data => {
setMetaAppId(data.metaAppId || '');
setMetaPixelId(data.metaPixelId || '');
setHasMetaSecret(data.hasMetaSecret);
setHasOpenaiKey(data.hasOpenaiKey);
setLoading(false);
@ -36,8 +38,8 @@ export default function SettingsPage() {
setSaving(true);
setMessage(null);
const payload: Record<string, string> = { metaAppId };
const payload: Record<string, string> = { metaAppId, metaPixelId };
// Only send secrets if they were changed
if (metaAppSecret) payload.metaAppSecret = metaAppSecret;
if (openaiKey) payload.openaiApiKey = openaiKey;
@ -137,6 +139,22 @@ export default function SettingsPage() {
</p>
</div>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
Pixel / Dataset ID
</label>
<input
type="text"
value={metaPixelId}
onChange={e => setMetaPixelId(e.target.value)}
className="input-field"
placeholder="Ex: 1494357542144348"
/>
<p className="text-[11px] mt-1.5" style={{ color: 'var(--muted-fg)' }}>
Usado para otimizar campanhas por <strong>Compra</strong> (Conversions API). Preenche automaticamente o formulário de criação. Use o mesmo Dataset ID que recebe o evento de Compra do seu n8n/CAPI.
</p>
</div>
<div className="mt-4 p-3 rounded-lg flex items-start gap-3" style={{ background: 'rgba(59, 130, 246, 0.1)' }}>
<ShieldAlert size={16} className="text-blue-400 mt-0.5 shrink-0" />
<div className="text-[11px] text-blue-200">

View file

@ -200,6 +200,39 @@ export const SYSTEM_SKINS: SkinConfig[] = [
targeting_template: 'broad_brazil',
bid_strategy: 'VOLUME',
}
},
{
id: 'festa_infantil',
name: 'Festa Infantil / Produto de Entrada',
icon: '🎉',
themeColor: 'bg-pink-900/40 border-pink-500/50',
accentColor: '#ec4899',
fields: [
{ id: 'product_name', label: 'O que você vende? (produto de entrada)', placeholder: 'Ex: Kit Festa Mágica IA — convites, painéis e lembrancinhas personalizados', type: 'text', required: true },
{ id: 'offer', label: 'Oferta + Preço (o gancho do anúncio)', placeholder: 'Ex: Kit completo por apenas R$9,99 no Pix — acesso imediato', type: 'text', required: true },
{ id: 'payment', label: 'Formas de pagamento / vantagem', placeholder: 'Ex: Pix (libera na hora) ou cartão em até 12x', type: 'text' },
{ id: 'whats_included', label: 'O que está incluso / transformação', placeholder: 'Ex: convite animado, painel, topo de bolo, lembrancinhas — tudo editável e pronto em minutos', type: 'textarea', required: true },
{ id: 'pain_point', label: 'Dor / desejo do cliente', placeholder: 'Ex: quer uma festa linda sem gastar fortuna nem perder dias procurando decoração', type: 'textarea' },
{ id: 'bonus', label: 'Bônus / garantia / urgência (opcional)', placeholder: 'Ex: + 3 temas de bônus hoje · garantia de 7 dias · oferta de lançamento', type: 'text' },
],
ai_guidelines: {
// Copy de resposta direta para tripwire/low-ticket: o objetivo é a COMPRA
// de entrada barata, com o lucro vindo do funil (order bump/upsell).
copy_rules:
'Você escreve para um PRODUTO DE ENTRADA de ticket baixo (tripwire) voltado a pais e mães organizando festas infantis. Objetivo: gerar a COMPRA imediata, não cliques curiosos. ' +
'Lidere pelo preço-âncora e pela facilidade ("R$X no Pix e recebe na hora"). Destaque Pix = acesso imediato e cartão parcelado quando houver. ' +
'Use o contraste de valor (o que receberia vs. quanto custa), prova de praticidade ("pronto em minutos"), e uma urgência honesta (oferta de lançamento/bônus). ' +
'Fale a língua de pai/mãe: economia de tempo, festa encantadora, criança feliz, sem dor de cabeça. ' +
'Evite promessas impossíveis e clickbait. CTA de compra (SHOP_NOW ou GET_OFFER). Primary text curto, escaneável e específico — sem encher linguiça.',
image_style:
'Festa infantil mágica e encantadora, cores vibrantes e alegres, elementos lúdicos (balões, estrelas, brilho), tema de aniversário, visual limpo com destaque para o produto/oferta, alto contraste para parar o feed',
},
meta_defaults: {
// Low-ticket vive ou morre por otimização de COMPRA via Pixel/CAPI.
objective: 'OUTCOME_SALES',
targeting_template: 'parents_kids',
bid_strategy: 'VOLUME',
}
}
];

View file

@ -73,14 +73,14 @@ ${JSON.stringify(skinsCatalog, null, 2)}
OBJETIVOS VÁLIDOS NO META ADS: ${VALID_OBJECTIVES.join(', ')}
SUA TAREFA:
1. Identifique o "skin_id" mais adequado dentre os disponíveis.
2. Para CADA campo definido nesse skin (array "fields"), gere um valor coerente e específico baseado no conteúdo real do site (nunca deixe vazio; se a informação não existir explicitamente, infira de forma plausível a partir do contexto).
1. Identifique o "skin_id" mais adequado dentre os disponíveis. Se for um produto vendido diretamente com preço/checkout (especialmente ticket baixo, como kits, e-books, templates, "produto de entrada"), prefira o skin "festa_infantil" quando for festa/aniversário infantil, ou o mais próximo (ecommerce/infoproduct).
2. Para CADA campo definido nesse skin (array "fields"), gere um valor coerente e específico baseado no conteúdo real do site (nunca deixe vazio; se a informação não existir explicitamente, infira de forma plausível a partir do contexto). IMPORTANTE: extraia o PREÇO e as FORMAS DE PAGAMENTO (Pix, cartão, parcelamento) se aparecerem no conteúdo, e use-os nos campos de oferta/pagamento.
3. Sugira um "campaign_name" curto e profissional (ex: "Nome da Marca — Campanha [mês/ano ou ângulo]").
4. Descreva o público-alvo ideal em uma frase curta e direcionada (máx. 12 palavras).
5. Escolha o "objective" mais estratégico dentre os válidos para esse tipo de negócio.
5. Escolha o "objective" mais estratégico dentre os válidos. Se o site vende um produto diretamente (tem preço/checkout/compra), use "OUTCOME_SALES" para otimizar por compra. Use leads/tráfego quando não houver venda direta.
6. Defina o "tone" (tom de voz ideal para a copy, ex: "consultivo e confiável", "urgente e persuasivo").
7. Refine o "image_style" do skin escolhido com detalhes específicos do negócio analisado (cores, ambientação, elementos visuais que conversam com a marca).
8. Escreva um "summary" de 1-2 frases explicando por que você direcionou a campanha dessa forma.
8. Escreva um "summary" de 1-2 frases explicando por que você direcionou a campanha dessa forma (mencione preço/pagamento se for um produto de entrada).
Retorne APENAS um JSON com esta estrutura exata:
{

View file

@ -23,8 +23,19 @@ export interface CopyGenerationInput {
tone?: string;
language?: string;
count?: number;
// Pistas de resposta direta (low-ticket / tripwire). Quando presentes, a copy
// lidera pela oferta e facilidade de pagamento em vez de só curiosidade.
offer?: string;
price?: string;
payment_methods?: string;
guarantee?: string;
urgency?: string;
is_low_ticket?: boolean;
}
// Nichos tratados como resposta direta de ticket baixo por padrão.
const DIRECT_RESPONSE_CATEGORIES = ['festa_infantil', 'ecommerce', 'infoproduct', 'delivery'];
export async function generateAdCopy(input: CopyGenerationInput): Promise<AdCopyVariation[]> {
const openai = getAIClient();
@ -34,6 +45,20 @@ export async function generateAdCopy(input: CopyGenerationInput): Promise<AdCopy
const categoryContext = input.category ? `Nicho/Categoria do Produto: ${input.category}\n` : '';
const rulesContext = input.copy_rules ? `Regras do Nicho: ${input.copy_rules}\n` : '';
const isLowTicket =
input.is_low_ticket ??
(input.category ? DIRECT_RESPONSE_CATEGORIES.includes(input.category) : false);
// Bloco de oferta: só entra se houver dados reais (preço/pagamento/garantia).
const offerLines = [
input.offer ? `Oferta principal: ${input.offer}` : '',
input.price ? `Preço/Ticket: ${input.price}` : '',
input.payment_methods ? `Formas de pagamento: ${input.payment_methods}` : '',
input.guarantee ? `Garantia: ${input.guarantee}` : '',
input.urgency ? `Urgência/Bônus: ${input.urgency}` : '',
].filter(Boolean);
const offerContext = offerLines.length ? `\nOFERTA (use de forma central na copy):\n${offerLines.join('\n')}\n` : '';
let websiteContext = '';
if (input.link) {
const scrapedText = await scrapeWebsiteContext(input.link);
@ -42,24 +67,37 @@ export async function generateAdCopy(input: CopyGenerationInput): Promise<AdCopy
}
}
const directResponseRules = isLowTicket
? `
ESTRATÉGIA DE RESPOSTA DIRETA (PRODUTO DE ENTRADA / TICKET BAIXO):
- Este é um produto de ENTRADA: o objetivo do anúncio é a COMPRA imediata, não cliques curiosos.
- Lidere pela oferta concreta e pelo preço-âncora quando houver (ex: "Tudo isso por apenas R$X").
- Se houver Pix, destaque "Pix = acesso/entrega imediata"; se houver parcelamento no cartão, mencione.
- Use contraste de valor (o que a pessoa recebe vs. quanto custa) e prova de praticidade.
- Urgência honesta (oferta de lançamento, bônus por tempo limitado) nunca falsa escassez.
- Gere variações DIVERSAS para teste A/B: pelo menos uma liderada pelo PREÇO/OFERTA, uma liderada pelo BENEFÍCIO/TRANSFORMAÇÃO e uma liderada por DOR/URGÊNCIA.
- Prefira CTA de compra: SHOP_NOW ou GET_OFFER.`
: '';
const prompt = `Você é um copywriter especialista em Facebook Ads com anos de experiência em conversão.
Você gera textos otimizados para alta performance em anúncios do Meta (Facebook/Instagram).
REGRAS OBRIGATÓRIAS:
- Headline: máximo 40 caracteres, impactante e direto
- Primary Text: máximo 125 caracteres, gere curiosidade e urgência
- Primary Text: idealmente forte nos primeiros 125 caracteres (parte visível antes do "ver mais"); pode ter até ~500 caracteres se a oferta pedir mais detalhes. Não encha linguiça.
- Description: máximo 30 caracteres, complemento da headline
- CTA: use um dos CTAs oficiais do Meta (LEARN_MORE, SHOP_NOW, SIGN_UP, SUBSCRIBE, CONTACT_US, GET_OFFER, BOOK_NOW)
- Idioma: ${language}
- Tom: ${tone}
- Foque em benefícios, não features
- Use gatilhos mentais (escassez, prova social, autoridade)
- Use gatilhos mentais (escassez, prova social, autoridade) com honestidade
- Evite clickbait ou promessas impossíveis
${directResponseRules}
Gere ${count} variações de copy para um anúncio de Facebook Ads baseado no contexto abaixo:
${input.context}
Objetivo da Campanha: ${input.objective}
${categoryContext}${rulesContext}${websiteContext}
${categoryContext}${rulesContext}${offerContext}${websiteContext}
Retorne um JSON com a seguinte estrutura:
{
"variations": [

View file

@ -11,6 +11,9 @@ export interface ImageGenerationInput {
style: string;
dimensions: '1080x1080' | '1080x1920' | '1200x628';
mood?: string;
// Texto curto de oferta/preço para estampar no criativo (ex: "Só R$9,99 no Pix").
// Para low-ticket de resposta direta, preço no criativo costuma elevar o CTR.
offer_text?: string;
}
export interface GeneratedImage {
@ -29,6 +32,10 @@ export async function generateAdImage(input: ImageGenerationInput): Promise<Gene
const mood = input.mood || 'moderno, clean, profissional';
const size = sizeMap[input.dimensions] || '1024x1024';
const offerInstruction = input.offer_text
? `\nSELO DE OFERTA: inclua de forma elegante e legível um selo/badge com o texto "${input.offer_text}" (ex: balão ou faixa com o preço). Deve ser nítido, curto e em destaque, sem poluir o restante da imagem. Português correto, sem erros de ortografia.`
: `\n- Não inclua muito texto na imagem, deixe o foco no visual (a Meta prefere menos texto).`;
const prompt = `Crie um criativo de anúncio (Facebook/Instagram Ad) altamente persuasivo e visualmente impactante.
O contexto do anúncio e as regras do nicho são os seguintes:
---
@ -39,8 +46,7 @@ Mood: ${mood}
Diretrizes Adicionais:
- A imagem deve ser otimizada para anúncios, sem poluição visual.
- Foco em conversão e chamar a atenção nos primeiros segundos.
- Não inclua muito texto na imagem, deixe o foco no visual (a Meta prefere menos texto).
- Foco em conversão e chamar a atenção nos primeiros segundos.${offerInstruction}
- O visual deve conversar diretamente com o contexto especificado acima.`;
const openai = getAIClient();

View file

@ -1,68 +1,224 @@
import * as cheerio from 'cheerio';
/**
* Faz o fetch de uma URL e extrai o contexto principal (título, meta description e parágrafos)
* para fornecer inteligência para a IA de Copywriting e Público-Alvo.
* uma URL e extrai o contexto principal (título, descrição, oferta/preço e
* textos da página) para alimentar a IA de análise, copy e público-alvo.
*
* Muitas landing pages modernas (incl. landpage.festamagicaia.com.br) são SPAs
* renderizadas em JavaScript um fetch + cheerio puro enxerga o <title> e as
* meta tags. Para esses casos, além de extrair og/JSON-LD (que geralmente vêm
* no HTML mesmo em SPA), caímos para um leitor que executa o JS (r.jina.ai)
* quando o HTML cru retorna pouco texto.
*/
export async function scrapeWebsiteContext(url: string, maxLength: number = 2000): Promise<string> {
if (!url) return '';
// Validação básica de URL para garantir que tem http:// ou https://
let targetUrl = url;
if (!targetUrl.startsWith('http')) {
targetUrl = `https://${targetUrl}`;
}
// 1) Tentativa direta: rápida e suficiente para sites SSR/estáticos.
const direct = await scrapeStaticHtml(targetUrl);
// Heurística: se o corpo extraído for muito curto, provavelmente é uma SPA
// que não renderizou o conteúdo no HTML inicial. Aí vale o leitor com JS.
const bodyIsThin = direct.bodyTextLength < 300;
let renderedText = '';
if (bodyIsThin) {
renderedText = await scrapeRenderedText(targetUrl);
}
// 2) Monta o contexto combinando metadados estruturados (sempre úteis) com o
// melhor texto disponível (renderizado se houver, senão o estático).
const sections: string[] = [];
if (direct.title) sections.push(`Título do Site: ${direct.title}`);
if (direct.description) sections.push(`Descrição (Meta): ${direct.description}`);
if (direct.ogTitle && direct.ogTitle !== direct.title) sections.push(`OG Título: ${direct.ogTitle}`);
if (direct.ogDescription && direct.ogDescription !== direct.description) {
sections.push(`OG Descrição: ${direct.ogDescription}`);
}
if (direct.structured.length > 0) {
sections.push(`\nDados Estruturados (produto/oferta/preço):\n${direct.structured.join('\n')}`);
}
const mainText = renderedText || direct.bodyText;
if (mainText) {
sections.push(`\nTextos Principais da Página:\n${mainText}`);
}
let context = sections.join('\n').trim();
if (context.length > maxLength) {
context = context.substring(0, maxLength) + '...';
}
return context;
}
interface StaticScrapeResult {
title: string;
description: string;
ogTitle: string;
ogDescription: string;
structured: string[];
bodyText: string;
bodyTextLength: number;
}
/** Fetch + cheerio: pega metadados (og/twitter), JSON-LD e o texto visível. */
async function scrapeStaticHtml(targetUrl: string): Promise<StaticScrapeResult> {
const empty: StaticScrapeResult = {
title: '',
description: '',
ogTitle: '',
ogDescription: '',
structured: [],
bodyText: '',
bodyTextLength: 0,
};
try {
const response = await fetch(targetUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
// Timeout curto para evitar gargalo na geração se o site for muito lento
signal: AbortSignal.timeout(5000)
signal: AbortSignal.timeout(6000),
});
if (!response.ok) {
console.warn(`[Scraper] Falha ao ler ${targetUrl}: ${response.statusText}`);
return '';
return empty;
}
const html = await response.text();
const $ = cheerio.load(html);
// Remover tags desnecessárias que poluem o texto
$('script, style, noscript, nav, footer, header, iframe, svg').remove();
const title = $('title').text().trim() || '';
const title = $('title').first().text().trim();
const description = $('meta[name="description"]').attr('content')?.trim() || '';
const ogTitle = $('meta[property="og:title"]').attr('content')?.trim() || '';
const ogDescription = $('meta[property="og:description"]').attr('content')?.trim() || '';
// Coletar textos focados no conteúdo (Headers e Parágrafos)
// JSON-LD: costuma trazer Product/Offer com price/priceCurrency mesmo em SPA.
const structured = extractStructuredOffers($);
// Texto visível: remove ruído e captura headings + parágrafos + itens de lista
// e spans de destaque (preços, selos), que importam para low-ticket.
$('script, style, noscript, svg, iframe').remove();
const seen = new Set<string>();
const elementsText: string[] = [];
$('h1, h2, h3, p').each((_, element) => {
const text = $(element).text().trim().replace(/\s+/g, ' ');
if (text && text.length > 10) {
$('h1, h2, h3, h4, p, li, [class*="price" i], [class*="preco" i]').each((_, el) => {
const text = $(el).text().trim().replace(/\s+/g, ' ');
if (text && text.length > 6 && text.length < 400 && !seen.has(text)) {
seen.add(text);
elementsText.push(text);
}
});
let context = '';
if (title) context += `Título do Site: ${title}\n`;
if (description) context += `Descrição (Meta): ${description}\n`;
if (elementsText.length > 0) {
context += `\nTextos Principais da Página:\n`;
context += elementsText.join('\n');
}
const bodyText = elementsText.join('\n');
// Limitar o contexto para economizar tokens e focar na primeira dobra/promessa principal
if (context.length > maxLength) {
context = context.substring(0, maxLength) + '...';
}
return context.trim();
return {
title,
description,
ogTitle,
ogDescription,
structured,
bodyText,
bodyTextLength: bodyText.length,
};
} catch (error) {
console.warn(`[Scraper] Erro ao tentar processar a URL ${targetUrl}:`, error);
return ''; // Falha silenciosa, a IA usará apenas o link cru como contexto
console.warn(`[Scraper] Erro ao processar ${targetUrl}:`, error);
return empty;
}
}
/**
* Fallback para SPAs: r.jina.ai renderiza a página (executa JS) e devolve o
* conteúdo em markdown/texto limpo. Best-effort se falhar, seguimos com
* o que o HTML estático deu.
*/
async function scrapeRenderedText(targetUrl: string): Promise<string> {
try {
const readerUrl = `https://r.jina.ai/${targetUrl}`;
const response = await fetch(readerUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ADsPRO/1.0; +https://metaads.pro)',
// Pede só o conteúdo principal, sem links/imagens, pra economizar tokens.
'X-Return-Format': 'text',
},
signal: AbortSignal.timeout(12000),
});
if (!response.ok) {
console.warn(`[Scraper] Leitor JS falhou em ${targetUrl}: ${response.statusText}`);
return '';
}
const text = (await response.text()).trim();
// Colapsa linhas em branco repetidas para reduzir tokens.
return text.replace(/\n{3,}/g, '\n\n');
} catch (error) {
console.warn(`[Scraper] Erro no leitor JS para ${targetUrl}:`, error);
return '';
}
}
/** Varre todos os <script type="application/ld+json"> atrás de preço/oferta. */
function extractStructuredOffers($: cheerio.CheerioAPI): string[] {
const lines: string[] = [];
$('script[type="application/ld+json"]').each((_, el) => {
const raw = $(el).contents().text().trim();
if (!raw) return;
try {
const data = JSON.parse(raw);
collectOffers(data, lines);
} catch {
// JSON-LD malformado — ignora.
}
});
return [...new Set(lines)];
}
function collectOffers(node: unknown, out: string[]): void {
if (!node) return;
if (Array.isArray(node)) {
node.forEach((n) => collectOffers(n, out));
return;
}
if (typeof node !== 'object') return;
const obj = node as Record<string, unknown>;
const type = String(obj['@type'] || '').toLowerCase();
if (type.includes('product') || obj.name || obj.offers) {
const name = typeof obj.name === 'string' ? obj.name : '';
const desc = typeof obj.description === 'string' ? obj.description : '';
if (name) out.push(`Produto: ${name}`);
if (desc) out.push(`Descrição do produto: ${desc.slice(0, 300)}`);
}
const offers = obj.offers;
if (offers) {
const list = Array.isArray(offers) ? offers : [offers];
for (const offer of list) {
if (offer && typeof offer === 'object') {
const o = offer as Record<string, unknown>;
const price = o.price ?? o.lowPrice ?? o.highPrice;
const currency = o.priceCurrency ?? '';
if (price !== undefined) out.push(`Preço/Oferta: ${currency} ${price}`.trim());
}
}
}
// Desce recursivamente para grafos (@graph) e nós aninhados.
for (const value of Object.values(obj)) {
if (value && typeof value === 'object') collectOffers(value, out);
}
}

View file

@ -7,12 +7,16 @@ export interface AppSettings {
metaAppId: string;
metaAppSecret: string;
openaiApiKey: string;
// Pixel/Dataset ID usado para otimizar campanhas por COMPRA (Conversions API).
// Para low-ticket, é o que permite o Meta entregar para quem realmente compra.
metaPixelId: string;
}
const defaultSettings: AppSettings = {
metaAppId: '',
metaAppSecret: '',
openaiApiKey: '',
metaPixelId: '',
};
export function getSettings(): AppSettings {