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:
parent
9bda154730
commit
a995424725
12 changed files with 497 additions and 84 deletions
|
|
@ -4,7 +4,20 @@ import { generateAdCopy } from '@/lib/ai/copywriter';
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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) {
|
if (!context || !objective) {
|
||||||
return NextResponse.json(
|
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 });
|
return NextResponse.json({ success: true, data: variations });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { generateAdImage } from '@/lib/ai/creative';
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { context, style, dimensions, mood } = body;
|
const { context, style, dimensions, mood, offer_text } = body;
|
||||||
|
|
||||||
if (!context || !style) {
|
if (!context || !style) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
@ -25,6 +25,7 @@ export async function POST(request: NextRequest) {
|
||||||
style,
|
style,
|
||||||
dimensions: dim,
|
dimensions: dim,
|
||||||
mood,
|
mood,
|
||||||
|
offer_text,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
if (!finalBidAmount || finalBidAmount <= 0) {
|
||||||
|
const label = bid_strategy === 'COST_CAP' ? 'Custo por Resultado (Cost Cap)' : 'Bid Cap';
|
||||||
return NextResponse.json(
|
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 }
|
{ 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 finalOptimizationGoal = optimization_goal;
|
||||||
let finalPromotedObject = promoted_object;
|
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';
|
finalOptimizationGoal = 'OFFSITE_CONVERSIONS';
|
||||||
finalPromotedObject = {
|
finalPromotedObject = {
|
||||||
pixel_id: pixel_id,
|
pixel_id,
|
||||||
custom_event_type: 'PURCHASE',
|
custom_event_type: eventType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,8 +246,8 @@ export async function POST(req: Request) {
|
||||||
campaign_name,
|
campaign_name,
|
||||||
objective: finalObjective,
|
objective: finalObjective,
|
||||||
daily_budget: finalDailyBudget,
|
daily_budget: finalDailyBudget,
|
||||||
bid_strategy: bid_strategy === 'BID_CAP' ? 'LOWEST_COST_WITH_BID_CAP' : 'LOWEST_COST_WITHOUT_CAP',
|
bid_strategy: finalBidStrategy,
|
||||||
bid_amount: bid_amount,
|
bid_amount: finalBidStrategy === 'LOWEST_COST_WITHOUT_CAP' ? undefined : finalBidAmount,
|
||||||
targeting: {
|
targeting: {
|
||||||
...(targeting || {
|
...(targeting || {
|
||||||
geo_locations: {
|
geo_locations: {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export async function GET() {
|
||||||
// Mask secrets so we don't send them back to the client
|
// Mask secrets so we don't send them back to the client
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
metaAppId: settings.metaAppId,
|
metaAppId: settings.metaAppId,
|
||||||
|
metaPixelId: settings.metaPixelId,
|
||||||
hasMetaSecret: !!settings.metaAppSecret,
|
hasMetaSecret: !!settings.metaAppSecret,
|
||||||
hasOpenaiKey: !!settings.openaiApiKey,
|
hasOpenaiKey: !!settings.openaiApiKey,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export default function CreateCampaignPage() {
|
||||||
bid_amount: 10,
|
bid_amount: 10,
|
||||||
link: '',
|
link: '',
|
||||||
pixel_id: '',
|
pixel_id: '',
|
||||||
|
conversion_event: 'PURCHASE',
|
||||||
image_style: currentSkin.ai_guidelines.image_style,
|
image_style: currentSkin.ai_guidelines.image_style,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -70,6 +71,9 @@ export default function CreateCampaignPage() {
|
||||||
const [imageSource, setImageSource] =
|
const [imageSource, setImageSource] =
|
||||||
useState<ImageSource>('ai');
|
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] =
|
const [uploadedImageBytes, setUploadedImageBytes] =
|
||||||
useState<string | null>(null);
|
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
|
// Saves/updates the campaign draft in Supabase so generated copy and images
|
||||||
// survive errors during publish (e.g. Meta API failures) without needing
|
// survive errors during publish (e.g. Meta API failures) without needing
|
||||||
// to be regenerated from scratch.
|
// to be regenerated from scratch.
|
||||||
|
|
@ -352,6 +376,13 @@ export default function CreateCampaignPage() {
|
||||||
objective: form.objective,
|
objective: form.objective,
|
||||||
category: skinId,
|
category: skinId,
|
||||||
count: 3,
|
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({
|
body: JSON.stringify({
|
||||||
context: getContextString(),
|
context: getContextString(),
|
||||||
style: form.image_style,
|
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
|
form.daily_budget * 100
|
||||||
),
|
),
|
||||||
|
|
||||||
bid_strategy:
|
// Envia o token cru ('VOLUME' | 'COST_CAP' | 'BID_CAP'); a rota
|
||||||
form.bid_strategy ===
|
// converte para o enum da Meta e valida o bid_amount.
|
||||||
'BID_CAP'
|
bid_strategy: form.bid_strategy,
|
||||||
? 'LOWEST_COST_WITH_BID_CAP'
|
|
||||||
: undefined,
|
|
||||||
|
|
||||||
bid_amount:
|
bid_amount:
|
||||||
form.bid_strategy ===
|
form.bid_strategy === 'BID_CAP' ||
|
||||||
'BID_CAP'
|
form.bid_strategy === 'COST_CAP'
|
||||||
? Math.round(
|
? Math.round(form.bid_amount * 100)
|
||||||
form.bid_amount * 100
|
|
||||||
)
|
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
targeting:
|
targeting:
|
||||||
|
|
@ -510,7 +540,9 @@ export default function CreateCampaignPage() {
|
||||||
|
|
||||||
instagram_actor_id: selectedInstagramAccount?.id || undefined,
|
instagram_actor_id: selectedInstagramAccount?.id || undefined,
|
||||||
|
|
||||||
pixel_id: form.pixel_id,
|
pixel_id: form.pixel_id || undefined,
|
||||||
|
|
||||||
|
conversion_event: form.conversion_event,
|
||||||
|
|
||||||
image_urls:
|
image_urls:
|
||||||
imageSource === 'ai'
|
imageSource === 'ai'
|
||||||
|
|
@ -1076,6 +1108,10 @@ export default function CreateCampaignPage() {
|
||||||
Volume Máximo
|
Volume Máximo
|
||||||
</option>
|
</option>
|
||||||
|
|
||||||
|
<option value="COST_CAP">
|
||||||
|
Custo por Compra (Cost Cap)
|
||||||
|
</option>
|
||||||
|
|
||||||
<option value="BID_CAP">
|
<option value="BID_CAP">
|
||||||
Bid Cap
|
Bid Cap
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -1083,11 +1119,13 @@ export default function CreateCampaignPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BID */}
|
{/* BID */}
|
||||||
{form.bid_strategy ===
|
{(form.bid_strategy === 'BID_CAP' ||
|
||||||
'BID_CAP' && (
|
form.bid_strategy === 'COST_CAP') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs uppercase tracking-wider font-medium mb-2 text-green-400">
|
<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>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
@ -1111,17 +1149,31 @@ 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 só o ticket de entrada.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PIXEL ID */}
|
{/* PIXEL + CONVERSION EVENT (otimização por compra) */}
|
||||||
{form.objective === 'OUTCOME_SALES' && (
|
{form.objective === 'OUTCOME_SALES' && (
|
||||||
|
<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 só clica. Sem o Pixel/Dataset, o produto de entrada tende a dar prejuízo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs uppercase tracking-wider font-medium mb-2 text-blue-400">
|
<label className="block text-xs uppercase tracking-wider font-medium mb-2 text-blue-400">
|
||||||
ID do Pixel (Recomendado para Vendas)
|
Pixel / Dataset ID *
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className="input-field"
|
className="input-field"
|
||||||
value={form.pixel_id}
|
value={form.pixel_id}
|
||||||
|
|
@ -1133,6 +1185,34 @@ export default function CreateCampaignPage() {
|
||||||
}
|
}
|
||||||
placeholder="Ex: 1494357542144348"
|
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 há 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1245,6 +1325,23 @@ export default function CreateCampaignPage() {
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* BUTTON */}
|
{/* BUTTON */}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export default function SettingsPage() {
|
||||||
|
|
||||||
const [metaAppId, setMetaAppId] = useState('');
|
const [metaAppId, setMetaAppId] = useState('');
|
||||||
const [metaAppSecret, setMetaAppSecret] = useState('');
|
const [metaAppSecret, setMetaAppSecret] = useState('');
|
||||||
|
const [metaPixelId, setMetaPixelId] = useState('');
|
||||||
const [openaiKey, setOpenaiKey] = useState('');
|
const [openaiKey, setOpenaiKey] = useState('');
|
||||||
|
|
||||||
const [hasMetaSecret, setHasMetaSecret] = useState(false);
|
const [hasMetaSecret, setHasMetaSecret] = useState(false);
|
||||||
|
|
@ -21,6 +22,7 @@ export default function SettingsPage() {
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setMetaAppId(data.metaAppId || '');
|
setMetaAppId(data.metaAppId || '');
|
||||||
|
setMetaPixelId(data.metaPixelId || '');
|
||||||
setHasMetaSecret(data.hasMetaSecret);
|
setHasMetaSecret(data.hasMetaSecret);
|
||||||
setHasOpenaiKey(data.hasOpenaiKey);
|
setHasOpenaiKey(data.hasOpenaiKey);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -36,7 +38,7 @@ export default function SettingsPage() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const payload: Record<string, string> = { metaAppId };
|
const payload: Record<string, string> = { metaAppId, metaPixelId };
|
||||||
|
|
||||||
// Only send secrets if they were changed
|
// Only send secrets if they were changed
|
||||||
if (metaAppSecret) payload.metaAppSecret = metaAppSecret;
|
if (metaAppSecret) payload.metaAppSecret = metaAppSecret;
|
||||||
|
|
@ -137,6 +139,22 @@ export default function SettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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)' }}>
|
<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" />
|
<ShieldAlert size={16} className="text-blue-400 mt-0.5 shrink-0" />
|
||||||
<div className="text-[11px] text-blue-200">
|
<div className="text-[11px] text-blue-200">
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,39 @@ export const SYSTEM_SKINS: SkinConfig[] = [
|
||||||
targeting_template: 'broad_brazil',
|
targeting_template: 'broad_brazil',
|
||||||
bid_strategy: 'VOLUME',
|
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',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,14 +73,14 @@ ${JSON.stringify(skinsCatalog, null, 2)}
|
||||||
OBJETIVOS VÁLIDOS NO META ADS: ${VALID_OBJECTIVES.join(', ')}
|
OBJETIVOS VÁLIDOS NO META ADS: ${VALID_OBJECTIVES.join(', ')}
|
||||||
|
|
||||||
SUA TAREFA:
|
SUA TAREFA:
|
||||||
1. Identifique o "skin_id" mais adequado dentre os disponíveis.
|
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).
|
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]").
|
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).
|
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 só 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").
|
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).
|
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:
|
Retorne APENAS um JSON com esta estrutura exata:
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,19 @@ export interface CopyGenerationInput {
|
||||||
tone?: string;
|
tone?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
count?: number;
|
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[]> {
|
export async function generateAdCopy(input: CopyGenerationInput): Promise<AdCopyVariation[]> {
|
||||||
const openai = getAIClient();
|
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 categoryContext = input.category ? `Nicho/Categoria do Produto: ${input.category}\n` : '';
|
||||||
const rulesContext = input.copy_rules ? `Regras do Nicho: ${input.copy_rules}\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 = '';
|
let websiteContext = '';
|
||||||
if (input.link) {
|
if (input.link) {
|
||||||
const scrapedText = await scrapeWebsiteContext(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.
|
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).
|
Você gera textos otimizados para alta performance em anúncios do Meta (Facebook/Instagram).
|
||||||
|
|
||||||
REGRAS OBRIGATÓRIAS:
|
REGRAS OBRIGATÓRIAS:
|
||||||
- Headline: máximo 40 caracteres, impactante e direto
|
- 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
|
- 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)
|
- CTA: use um dos CTAs oficiais do Meta (LEARN_MORE, SHOP_NOW, SIGN_UP, SUBSCRIBE, CONTACT_US, GET_OFFER, BOOK_NOW)
|
||||||
- Idioma: ${language}
|
- Idioma: ${language}
|
||||||
- Tom: ${tone}
|
- Tom: ${tone}
|
||||||
- Foque em benefícios, não features
|
- 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
|
- Evite clickbait ou promessas impossíveis
|
||||||
|
${directResponseRules}
|
||||||
|
|
||||||
Gere ${count} variações de copy para um anúncio de Facebook Ads baseado no contexto abaixo:
|
Gere ${count} variações de copy para um anúncio de Facebook Ads baseado no contexto abaixo:
|
||||||
${input.context}
|
${input.context}
|
||||||
Objetivo da Campanha: ${input.objective}
|
Objetivo da Campanha: ${input.objective}
|
||||||
${categoryContext}${rulesContext}${websiteContext}
|
${categoryContext}${rulesContext}${offerContext}${websiteContext}
|
||||||
Retorne um JSON com a seguinte estrutura:
|
Retorne um JSON com a seguinte estrutura:
|
||||||
{
|
{
|
||||||
"variations": [
|
"variations": [
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ export interface ImageGenerationInput {
|
||||||
style: string;
|
style: string;
|
||||||
dimensions: '1080x1080' | '1080x1920' | '1200x628';
|
dimensions: '1080x1080' | '1080x1920' | '1200x628';
|
||||||
mood?: string;
|
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 {
|
export interface GeneratedImage {
|
||||||
|
|
@ -29,6 +32,10 @@ export async function generateAdImage(input: ImageGenerationInput): Promise<Gene
|
||||||
const mood = input.mood || 'moderno, clean, profissional';
|
const mood = input.mood || 'moderno, clean, profissional';
|
||||||
const size = sizeMap[input.dimensions] || '1024x1024';
|
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.
|
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:
|
O contexto do anúncio e as regras do nicho são os seguintes:
|
||||||
---
|
---
|
||||||
|
|
@ -39,8 +46,7 @@ Mood: ${mood}
|
||||||
|
|
||||||
Diretrizes Adicionais:
|
Diretrizes Adicionais:
|
||||||
- A imagem deve ser otimizada para anúncios, sem poluição visual.
|
- A imagem deve ser otimizada para anúncios, sem poluição visual.
|
||||||
- Foco em conversão e chamar a atenção nos primeiros segundos.
|
- Foco em conversão e chamar a atenção nos primeiros segundos.${offerInstruction}
|
||||||
- Não inclua muito texto na imagem, deixe o foco no visual (a Meta prefere menos texto).
|
|
||||||
- O visual deve conversar diretamente com o contexto especificado acima.`;
|
- O visual deve conversar diretamente com o contexto especificado acima.`;
|
||||||
|
|
||||||
const openai = getAIClient();
|
const openai = getAIClient();
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,224 @@
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Faz o fetch de uma URL e extrai o contexto principal (título, meta description e parágrafos)
|
* Lê uma URL e extrai o contexto principal (título, descrição, oferta/preço e
|
||||||
* para fornecer inteligência para a IA de Copywriting e Público-Alvo.
|
* 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 só 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> {
|
export async function scrapeWebsiteContext(url: string, maxLength: number = 2000): Promise<string> {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
|
|
||||||
// Validação básica de URL para garantir que tem http:// ou https://
|
|
||||||
let targetUrl = url;
|
let targetUrl = url;
|
||||||
if (!targetUrl.startsWith('http')) {
|
if (!targetUrl.startsWith('http')) {
|
||||||
targetUrl = `https://${targetUrl}`;
|
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 {
|
try {
|
||||||
const response = await fetch(targetUrl, {
|
const response = await fetch(targetUrl, {
|
||||||
headers: {
|
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(6000),
|
||||||
signal: AbortSignal.timeout(5000)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn(`[Scraper] Falha ao ler ${targetUrl}: ${response.statusText}`);
|
console.warn(`[Scraper] Falha ao ler ${targetUrl}: ${response.statusText}`);
|
||||||
return '';
|
return empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
// Remover tags desnecessárias que poluem o texto
|
const title = $('title').first().text().trim();
|
||||||
$('script, style, noscript, nav, footer, header, iframe, svg').remove();
|
|
||||||
|
|
||||||
const title = $('title').text().trim() || '';
|
|
||||||
const description = $('meta[name="description"]').attr('content')?.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[] = [];
|
const elementsText: string[] = [];
|
||||||
|
$('h1, h2, h3, h4, p, li, [class*="price" i], [class*="preco" i]').each((_, el) => {
|
||||||
$('h1, h2, h3, p').each((_, element) => {
|
const text = $(el).text().trim().replace(/\s+/g, ' ');
|
||||||
const text = $(element).text().trim().replace(/\s+/g, ' ');
|
if (text && text.length > 6 && text.length < 400 && !seen.has(text)) {
|
||||||
if (text && text.length > 10) {
|
seen.add(text);
|
||||||
elementsText.push(text);
|
elementsText.push(text);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let context = '';
|
const bodyText = elementsText.join('\n');
|
||||||
if (title) context += `Título do Site: ${title}\n`;
|
|
||||||
if (description) context += `Descrição (Meta): ${description}\n`;
|
|
||||||
|
|
||||||
if (elementsText.length > 0) {
|
return {
|
||||||
context += `\nTextos Principais da Página:\n`;
|
title,
|
||||||
context += elementsText.join('\n');
|
description,
|
||||||
}
|
ogTitle,
|
||||||
|
ogDescription,
|
||||||
// Limitar o contexto para economizar tokens e focar na primeira dobra/promessa principal
|
structured,
|
||||||
if (context.length > maxLength) {
|
bodyText,
|
||||||
context = context.substring(0, maxLength) + '...';
|
bodyTextLength: bodyText.length,
|
||||||
}
|
};
|
||||||
|
|
||||||
return context.trim();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[Scraper] Erro ao tentar processar a URL ${targetUrl}:`, error);
|
console.warn(`[Scraper] Erro ao processar ${targetUrl}:`, error);
|
||||||
return ''; // Falha silenciosa, a IA usará apenas o link cru como contexto
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback para SPAs: r.jina.ai renderiza a página (executa JS) e devolve o
|
||||||
|
* conteúdo já em markdown/texto limpo. Best-effort — se falhar, seguimos só 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,16 @@ export interface AppSettings {
|
||||||
metaAppId: string;
|
metaAppId: string;
|
||||||
metaAppSecret: string;
|
metaAppSecret: string;
|
||||||
openaiApiKey: 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 = {
|
const defaultSettings: AppSettings = {
|
||||||
metaAppId: '',
|
metaAppId: '',
|
||||||
metaAppSecret: '',
|
metaAppSecret: '',
|
||||||
openaiApiKey: '',
|
openaiApiKey: '',
|
||||||
|
metaPixelId: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSettings(): AppSettings {
|
export function getSettings(): AppSettings {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue