diff --git a/src/app/api/ai/generate-copy/route.ts b/src/app/api/ai/generate-copy/route.ts index 93f3ae8..9e5d2dd 100644 --- a/src/app/api/ai/generate-copy/route.ts +++ b/src/app/api/ai/generate-copy/route.ts @@ -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) { diff --git a/src/app/api/ai/generate-image/route.ts b/src/app/api/ai/generate-image/route.ts index 98ee044..7294611 100644 --- a/src/app/api/ai/generate-image/route.ts +++ b/src/app/api/ai/generate-image/route.ts @@ -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, }) ); diff --git a/src/app/api/campaigns/create/route.ts b/src/app/api/campaigns/create/route.ts index c81f9c9..3f23f21 100644 --- a/src/app/api/campaigns/create/route.ts +++ b/src/app/api/campaigns/create/route.ts @@ -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: { diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index ab64288..386b98d 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -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, }); diff --git a/src/app/dashboard/create/page.tsx b/src/app/dashboard/create/page.tsx index d5cbc64..28de62c 100644 --- a/src/app/dashboard/create/page.tsx +++ b/src/app/dashboard/create/page.tsx @@ -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('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(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 + + @@ -1083,11 +1119,13 @@ export default function CreateCampaignPage() { {/* BID */} - {form.bid_strategy === - 'BID_CAP' && ( + {(form.bid_strategy === 'BID_CAP' || + form.bid_strategy === 'COST_CAP') && (
+ {form.bid_strategy === 'COST_CAP' && ( +

+ Teto que você aceita pagar por compra. 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. +

+ )}
)} - {/* PIXEL ID */} + {/* PIXEL + CONVERSION EVENT (otimização por compra) */} {form.objective === 'OUTCOME_SALES' && ( -
- +
+
+

+ Otimização por Compra (obrigatório para Vendas) +

+

+ O Meta vai aprender a entregar para quem compra, não para quem só clica. Sem o Pixel/Dataset, o produto de entrada tende a dar prejuízo. +

+
- - setForm({ - ...form, - pixel_id: e.target.value, - }) - } - placeholder="Ex: 1494357542144348" - /> +
+ + + setForm({ + ...form, + pixel_id: e.target.value, + }) + } + placeholder="Ex: 1494357542144348" + /> +

+ Como você envia a Compra via n8n/CAPI, use o mesmo Dataset ID que recebe o evento. Salve em Configurações para preencher automaticamente. +

+
+ +
+ + +

+ 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 Início de checkout e migre para Compra quando o volume crescer. +

+
)} @@ -1245,6 +1325,23 @@ export default function CreateCampaignPage() { )} + + {imageSource === 'ai' && ( + + )}
{/* BUTTON */} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 8a467b7..64d1aac 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -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 = { metaAppId }; - + const payload: Record = { 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() {

+
+ + setMetaPixelId(e.target.value)} + className="input-field" + placeholder="Ex: 1494357542144348" + /> +

+ Usado para otimizar campanhas por Compra (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. +

+
+
diff --git a/src/config/skins/index.ts b/src/config/skins/index.ts index 062a221..1fcd587 100644 --- a/src/config/skins/index.ts +++ b/src/config/skins/index.ts @@ -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', + } } ]; diff --git a/src/lib/ai/analyzer.ts b/src/lib/ai/analyzer.ts index 66ef64e..c22ab41 100644 --- a/src/lib/ai/analyzer.ts +++ b/src/lib/ai/analyzer.ts @@ -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 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"). 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: { diff --git a/src/lib/ai/copywriter.ts b/src/lib/ai/copywriter.ts index 1c7721b..7a79922 100644 --- a/src/lib/ai/copywriter.ts +++ b/src/lib/ai/copywriter.ts @@ -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 { const openai = getAIClient(); @@ -34,6 +45,20 @@ export async function generateAdCopy(input: CopyGenerationInput): Promise 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 { 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 { + 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(); 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 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 { + 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