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.
+
+ 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.
+
+ 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