initial-commit

This commit is contained in:
axel
2026-04-06 20:49:50 -03:00
commit 9ddee2bb18
16 changed files with 3514 additions and 0 deletions

50
.env.example Normal file
View File

@@ -0,0 +1,50 @@
# ============================================
# Configuração do Bot Discord de Ofertas
# ============================================
# Token do bot Discord (obrigatório)
# Obtenha em: https://discord.com/developers/applications
DISCORD_TOKEN=SEU_TOKEN_AQUI
# ID do canal onde as ofertas serão enviadas (obrigatório)
# Clique com botão direito no canal > Copiar ID do canal
DISCORD_CHANNEL_ID=SEU_CHANNEL_ID_AQUI
# ============================================
# URLs de Monitoramento (opcionais - já há padrões)
# ============================================
# Amazon Brasil - Ofertas do Dia
AMAZON_URL=https://www.amazon.com.br/deals
# AliExpress - Ofertas Relâmpago
ALIEXPRESS_URL=https://pt.aliexpress.com/campaign/wow/gcp-plus/ae/right/shareon
# Shopee - Flash Sale
SHOPEE_URL=https://shopee.com.br/flash_sale
# Mercado Livre - Ofertas do Dia
MERCADOLIVRE_URL=https://www.mercadolivre.com.br/ofertas
# ============================================
# Configurações do Scraping
# ============================================
# Intervalo entre execuções do scraping (em minutos)
SCRAPE_INTERVAL_MINUTES=30
# Número máximo de ofertas por site por execução
MAX_OFFERS_PER_SITE=5
# Tag de afiliado Amazon (opcional)
AMAZON_AFFILIATE_TAG=
# ============================================
# Configurações do Puppeteer
# ============================================
# Executar navegador em modo headless (true/false)
PUPPETEER_HEADLESS=true
# Timeout para carregamento de página (em ms)
PAGE_TIMEOUT=30000

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.env
data/
*.log

138
README.md Normal file
View File

@@ -0,0 +1,138 @@
# 🛒 Bot de Ofertas - E-commerce para Discord
Bot automatizado que monitora os principais e-commerces brasileiros e envia ofertas diretamente para seu servidor Discord.
## 🏪 Lojas Monitoradas
| Loja | Página Monitorada |
|------|-------------------|
| 🛒 Amazon Brasil | Ofertas do Dia |
| 🌐 AliExpress | Super Ofertas / Flash Deals |
| 🛍️ Shopee | Flash Sale / Ofertas Relâmpago |
| 🤝 Mercado Livre | Ofertas do Dia |
## ✨ Funcionalidades
- **Monitoramento automático** a cada 30 minutos (configurável)
- **Embeds ricos** com imagem, preço, desconto e link direto
- **Deduplicação inteligente** — nunca envia a mesma oferta duas vezes no dia
- **Reset diário** automático do cache de ofertas
- **Detecção de cupons** quando disponíveis na página
- **Frete grátis** identificado automaticamente (Mercado Livre)
- **Suporte a afiliados** Amazon (tag configurável)
- **Retry automático** em caso de falha no scraping
- **Logs detalhados** de cada ciclo de verificação
## 📋 Pré-requisitos
- **Node.js** v18 ou superior
- **Token de Bot Discord** ([criar aqui](https://discord.com/developers/applications))
- **Chromium** (instalado automaticamente pelo Puppeteer)
## 🚀 Instalação
### 1. Clone ou baixe o projeto
```bash
cd "Nova pasta"
```
### 2. Instale as dependências
```bash
npm install
```
### 3. Configure o arquivo `.env`
```bash
# Copie o template
cp .env.example .env
# Edite com suas configurações
notepad .env
```
Preencha **obrigatoriamente**:
- `DISCORD_TOKEN` — Token do seu bot Discord
- `DISCORD_CHANNEL_ID` — ID do canal onde as ofertas serão enviadas
### 4. Inicie o bot
```bash
npm start
```
## ⚙️ Configurações
Todas as configurações ficam no arquivo `.env`:
| Variável | Descrição | Padrão |
|----------|-----------|--------|
| `DISCORD_TOKEN` | Token do bot Discord | *obrigatório* |
| `DISCORD_CHANNEL_ID` | ID do canal de ofertas | *obrigatório* |
| `SCRAPE_INTERVAL_MINUTES` | Intervalo entre verificações | `30` |
| `MAX_OFFERS_PER_SITE` | Máx. ofertas por site por ciclo | `5` |
| `AMAZON_AFFILIATE_TAG` | Tag de afiliado Amazon | *vazio* |
| `PUPPETEER_HEADLESS` | Navegador invisível | `true` |
| `PAGE_TIMEOUT` | Timeout de carregamento (ms) | `30000` |
## 📁 Estrutura do Projeto
```
├── .env.example # Template de configurações
├── .gitignore # Arquivos ignorados pelo Git
├── package.json # Dependências e scripts
├── README.md # Esta documentação
├── data/ # Cache de deduplicação (auto-criado)
│ └── sent_offers.json # Registro de ofertas enviadas
└── src/
├── index.js # Ponto de entrada
├── config.js # Configurações centralizadas
├── bot.js # Lógica principal do bot
├── scrapers/ # Módulos de scraping por loja
│ ├── index.js # Agregador de scrapers
│ ├── amazon.js # Scraper Amazon Brasil
│ ├── aliexpress.js # Scraper AliExpress
│ ├── shopee.js # Scraper Shopee
│ └── mercadolivre.js # Scraper Mercado Livre
└── utils/ # Utilitários
├── browser.js # Gerenciador Puppeteer
├── dedup.js # Sistema de deduplicação
└── embed.js # Construtor de embeds Discord
```
## 🤖 Criando o Bot no Discord
1. Acesse [Discord Developer Portal](https://discord.com/developers/applications)
2. Clique em **"New Application"** e dê um nome
3. Vá em **"Bot"** no menu lateral
4. Clique em **"Add Bot"**
5. Copie o **Token** e cole no `.env`
6. Em **"Privileged Gateway Intents"**, ative:
- ✅ Message Content Intent
7. Vá em **"OAuth2" > "URL Generator"**
- Selecione scope: `bot`
- Selecione permissões: `Send Messages`, `Embed Links`, `Attach Files`
8. Copie a URL gerada e abra no navegador para adicionar o bot ao seu servidor
## ⚠️ Observações Importantes
- **Sites podem mudar seus layouts** — os seletores CSS podem precisar de atualização periódica.
- **Rate limiting** — o bot respeita limites de taxa do Discord com delays entre envios.
- **Uso de recursos** — Puppeteer consome memória; recomenda-se ao menos 2GB RAM.
- **Termos de uso** — Verifique os termos de cada site antes de usar em produção.
## 🔧 Troubleshooting
| Problema | Solução |
|----------|---------|
| Bot não conecta | Verifique se o `DISCORD_TOKEN` está correto |
| Canal não encontrado | Verifique o `DISCORD_CHANNEL_ID` e se o bot tem acesso |
| Nenhuma oferta | Sites podem ter mudado layout; verifique logs |
| Erro de timeout | Aumente `PAGE_TIMEOUT` no `.env` |
| Alto uso de memória | Reduza `MAX_OFFERS_PER_SITE` ou aumente intervalo |
## 📄 Licença
MIT

1800
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "discord-ofertas-bot",
"version": "1.0.0",
"description": "Bot Discord para monitoramento de ofertas em e-commerces brasileiros",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"cheerio": "^1.0.0",
"discord.js": "^14.16.3",
"dotenv": "^16.4.7",
"puppeteer": "^23.11.1"
},
"engines": {
"node": ">=18.0.0"
}
}

187
src/bot.js Normal file
View File

@@ -0,0 +1,187 @@
/**
* bot.js - Lógica principal do Bot de Ofertas
*
* Gerencia o ciclo de scraping:
* 1. Executa todos os scrapers
* 2. Filtra ofertas duplicadas
* 3. Envia ofertas novas para o canal do Discord
* 4. Agenda próxima execução
*/
const { Client, GatewayIntentBits, Events } = require('discord.js');
const { config } = require('./config');
const { scrapers } = require('./scrapers');
const { buildOfferEmbed, buildSummaryEmbed, buildStatusEmbed } = require('./utils/embed');
const { filterNewOffers, markMultipleAsSent } = require('./utils/dedup');
const { closeBrowser } = require('./utils/browser');
// ── Instância do cliente Discord ────────────────────────
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
],
});
let scrapeInterval = null; // Referência ao setInterval para limpeza
/**
* Inicializa o bot e conecta ao Discord
*/
async function startBot() {
// Evento: Bot pronto
client.once(Events.ClientReady, async (readyClient) => {
console.log('');
console.log('═══════════════════════════════════════════════');
console.log(` 🤖 Bot conectado como: ${readyClient.user.tag}`);
console.log(` 📡 Canal de ofertas: ${config.discord.channelId}`);
console.log(` ⏱️ Intervalo: ${config.scraping.intervalMinutes} minutos`);
console.log(` 📦 Máx. ofertas/site: ${config.scraping.maxOffersPerSite}`);
console.log('═══════════════════════════════════════════════');
console.log('');
// Verificar se o canal existe e é acessível
const channel = await client.channels.fetch(config.discord.channelId).catch(() => null);
if (!channel) {
console.error('❌ Canal do Discord não encontrado! Verifique o DISCORD_CHANNEL_ID.');
process.exit(1);
}
console.log(`✅ Canal encontrado: #${channel.name}`);
// Enviar mensagem de status inicial
try {
const statusEmbed = buildStatusEmbed(
`🟢 Bot iniciado com sucesso!\n\n` +
`⏱️ Monitorando ofertas a cada **${config.scraping.intervalMinutes} minutos**.\n` +
`🏪 Lojas monitoradas: Amazon, AliExpress, Shopee, Mercado Livre\n\n` +
`Primeira verificação iniciando agora...`
);
await channel.send({ embeds: [statusEmbed] });
} catch (error) {
console.error('⚠️ Não foi possível enviar mensagem de status:', error.message);
}
// Executar primeiro scraping imediatamente
await runScrapingCycle(channel);
// Agendar execuções periódicas
const intervalMs = config.scraping.intervalMinutes * 60 * 1000;
scrapeInterval = setInterval(() => runScrapingCycle(channel), intervalMs);
console.log(`\n⏰ Próxima verificação em ${config.scraping.intervalMinutes} minutos.\n`);
});
// Evento: Erro do cliente
client.on(Events.Error, (error) => {
console.error('❌ Erro no cliente Discord:', error);
});
// Evento: Aviso do cliente
client.on(Events.Warn, (warning) => {
console.warn('⚠️ Aviso do Discord:', warning);
});
// Conectar ao Discord
console.log('🔌 Conectando ao Discord...');
await client.login(config.discord.token);
}
/**
* Executa um ciclo completo de scraping em todas as lojas
*
* @param {TextChannel} channel - Canal do Discord para enviar ofertas
*/
async function runScrapingCycle(channel) {
const startTime = Date.now();
console.log('');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`🔄 Iniciando ciclo de scraping - ${new Date().toLocaleString('pt-BR')}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
let totalNewOffers = 0;
const storesWithOffers = [];
// Executar cada scraper sequencialmente para evitar sobrecarga
for (const scraper of scrapers) {
try {
console.log(`\n📌 Processando: ${scraper.name}`);
// Executar o scraping
const rawOffers = await scraper.scrape();
if (rawOffers.length === 0) {
console.log(` ⚠️ Nenhuma oferta encontrada em ${scraper.name}.`);
continue;
}
// Filtrar ofertas já enviadas (deduplicação)
const newOffers = filterNewOffers(rawOffers);
if (newOffers.length === 0) {
console.log(` Todas as ofertas de ${scraper.name} já foram enviadas hoje.`);
continue;
}
// Enviar cada oferta nova para o Discord
for (const offer of newOffers) {
try {
const embed = buildOfferEmbed(offer);
await channel.send({ embeds: [embed] });
// Pequeno delay entre mensagens para evitar rate limiting
await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (sendError) {
console.error(` ❌ Erro ao enviar oferta "${offer.name}":`, sendError.message);
}
}
// Marcar ofertas como enviadas
markMultipleAsSent(newOffers);
totalNewOffers += newOffers.length;
storesWithOffers.push(scraper.name);
console.log(`${newOffers.length} ofertas de ${scraper.name} enviadas.`);
// Delay entre lojas para não sobrecarregar
await new Promise((resolve) => setTimeout(resolve, 3000));
} catch (scraperError) {
console.error(` ❌ Erro ao processar ${scraper.name}:`, scraperError.message);
}
}
// Fechar navegador após ciclo completo para liberar memória
await closeBrowser();
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`✅ Ciclo concluído em ${elapsed}s`);
console.log(` 📊 Total de novas ofertas: ${totalNewOffers}`);
console.log(` 🏪 Lojas com ofertas: ${storesWithOffers.join(', ') || 'Nenhuma'}`);
console.log(` ⏰ Próxima verificação em ${config.scraping.intervalMinutes} minutos`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('');
}
/**
* Encerra o bot de forma limpa
*/
async function stopBot() {
console.log('\n🛑 Encerrando bot...');
if (scrapeInterval) {
clearInterval(scrapeInterval);
}
await closeBrowser();
client.destroy();
console.log('👋 Bot encerrado com sucesso.');
process.exit(0);
}
module.exports = { startBot, stopBot };

74
src/config.js Normal file
View File

@@ -0,0 +1,74 @@
/**
* config.js - Configurações centralizadas do bot
*
* Carrega variáveis de ambiente e define valores padrão
* para todas as configurações necessárias.
*/
require('dotenv').config();
const config = {
// ── Discord ──────────────────────────────────────────
discord: {
token: process.env.DISCORD_TOKEN,
channelId: process.env.DISCORD_CHANNEL_ID,
},
// ── URLs de monitoramento ────────────────────────────
urls: {
amazon: process.env.AMAZON_URL || 'https://www.amazon.com.br/deals',
aliexpress: process.env.ALIEXPRESS_URL || 'https://pt.aliexpress.com/campaign/wow/gcp-plus/ae/right/shareon',
shopee: process.env.SHOPEE_URL || 'https://shopee.com.br/flash_sale',
mercadolivre: process.env.MERCADOLIVRE_URL || 'https://www.mercadolivre.com.br/ofertas',
},
// ── Scraping ─────────────────────────────────────────
scraping: {
intervalMinutes: parseInt(process.env.SCRAPE_INTERVAL_MINUTES, 10) || 30,
maxOffersPerSite: parseInt(process.env.MAX_OFFERS_PER_SITE, 10) || 5,
pageTimeout: parseInt(process.env.PAGE_TIMEOUT, 10) || 30000,
headless: process.env.PUPPETEER_HEADLESS !== 'false', // padrão: true
},
// ── Afiliados ────────────────────────────────────────
affiliate: {
amazonTag: process.env.AMAZON_AFFILIATE_TAG || '',
},
// ── Cores dos embeds por loja ────────────────────────
colors: {
amazon: 0xFF9900, // Laranja Amazon
aliexpress: 0xE43A2B, // Vermelho AliExpress
shopee: 0xEE4D2D, // Laranja Shopee
mercadolivre: 0xFFE600, // Amarelo Mercado Livre
},
// ── Emojis por loja ─────────────────────────────────
emojis: {
amazon: '🛒',
aliexpress: '🌐',
shopee: '🛍️',
mercadolivre: '🤝',
},
};
// Validação das configurações obrigatórias
function validateConfig() {
const errors = [];
if (!config.discord.token) {
errors.push('DISCORD_TOKEN não configurado no .env');
}
if (!config.discord.channelId) {
errors.push('DISCORD_CHANNEL_ID não configurado no .env');
}
if (errors.length > 0) {
console.error('❌ Erros de configuração:');
errors.forEach((err) => console.error(`${err}`));
console.error('\n📄 Copie o arquivo .env.example para .env e configure as variáveis.');
process.exit(1);
}
}
module.exports = { config, validateConfig };

53
src/index.js Normal file
View File

@@ -0,0 +1,53 @@
/**
* index.js - Ponto de entrada do Bot de Ofertas Discord
*
* ┌─────────────────────────────────────────────────────┐
* │ 🤖 Bot de Ofertas - E-commerce para Discord │
* │ │
* │ Monitora: Amazon BR, AliExpress, Shopee, ML │
* │ Envia: Ofertas formatadas em embeds ricos │
* │ Intervalo: Configurável (padrão 30min) │
* └─────────────────────────────────────────────────────┘
*
* Para iniciar:
* 1. Copie .env.example para .env
* 2. Configure DISCORD_TOKEN e DISCORD_CHANNEL_ID
* 3. Execute: npm start
*/
const { validateConfig } = require('./config');
const { startBot, stopBot } = require('./bot');
// ── Banner de inicialização ─────────────────────────────
console.log('');
console.log('╔═══════════════════════════════════════════════╗');
console.log('║ ║');
console.log('║ 🛒 Bot de Ofertas - E-commerce Discord ║');
console.log('║ ║');
console.log('║ Amazon BR │ AliExpress │ Shopee │ ML ║');
console.log('║ ║');
console.log('╚═══════════════════════════════════════════════╝');
console.log('');
// ── Validar configurações ───────────────────────────────
validateConfig();
// ── Tratamento de sinais para encerramento gracioso ─────
process.on('SIGINT', stopBot);
process.on('SIGTERM', stopBot);
// Tratamento de erros não capturados
process.on('unhandledRejection', (error) => {
console.error('❌ Erro não tratado (Promise):', error);
});
process.on('uncaughtException', (error) => {
console.error('❌ Erro não tratado (Exception):', error);
process.exit(1);
});
// ── Iniciar o bot ────────────────────────────────────────
startBot().catch((error) => {
console.error('❌ Falha ao iniciar o bot:', error);
process.exit(1);
});

161
src/scrapers/aliexpress.js Normal file
View File

@@ -0,0 +1,161 @@
/**
* aliexpress.js - Scraper para AliExpress
*
* Monitora a página de ofertas/promoções do AliExpress
* e extrai: nome, preço, preço original, desconto, imagem e link.
*/
const { config } = require('../config');
const { scrapeWithRetry } = require('../utils/browser');
/**
* Executa o scraping da página de ofertas do AliExpress
* @returns {Array<Object>} Lista de ofertas extraídas
*/
async function scrapeAliexpress() {
console.log('🌐 [AliExpress] Iniciando scraping...');
const offers = await scrapeWithRetry(async (page) => {
// Navegar para a página de ofertas
await page.goto(config.urls.aliexpress, {
waitUntil: 'domcontentloaded',
timeout: config.scraping.pageTimeout,
});
// Aguardar carregamento dos cards de produto
await page.waitForSelector('[class*="product-card"], [class*="card-out-wrapper"], .product-snippet_ProductSnippet, [class*="ProductCard"]', {
timeout: 15000,
}).catch(() => {
console.log(' ⚠️ [AliExpress] Seletores primários não encontrados, tentando alternativas...');
});
// Aguardar um pouco para conteúdo dinâmico carregar
await new Promise((resolve) => setTimeout(resolve, 3000));
// Scroll para ativar lazy-loading
await autoScroll(page);
// Extrair dados dos produtos
const products = await page.evaluate((maxOffers) => {
const items = [];
// Seletores possíveis para cards do AliExpress
const cardSelectors = [
'[class*="product-card"]',
'[class*="card-out-wrapper"]',
'.product-snippet_ProductSnippet',
'[class*="ProductCard"]',
'[class*="item-card"]',
'.search-item-card-wrapper-gallery',
];
let cards = [];
for (const selector of cardSelectors) {
cards = document.querySelectorAll(selector);
if (cards.length > 0) break;
}
cards.forEach((card) => {
if (items.length >= maxOffers) return;
try {
// Nome do produto
const nameEl = card.querySelector(
'[class*="title"], [class*="Title"], h1, h3, [class*="name"]'
);
const name = nameEl ? nameEl.textContent.trim() : '';
// Preço atual
const priceEl = card.querySelector(
'[class*="price-current"], [class*="price-sale"], [class*="Price"], [class*="price"]'
);
let price = priceEl ? priceEl.textContent.trim() : '';
// Limpar preço (remover textos extras)
price = price.replace(/[^\dR$.,\s]/g, '').trim();
// Preço original
const originalPriceEl = card.querySelector(
'[class*="price-original"], [class*="price-del"], [class*="OriginPrice"], del, s'
);
const originalPrice = originalPriceEl ? originalPriceEl.textContent.trim() : '';
// Desconto
const discountEl = card.querySelector(
'[class*="discount"], [class*="Discount"], [class*="off"]'
);
const discount = discountEl ? discountEl.textContent.trim() : '';
// Imagem
const imgEl = card.querySelector('img[src*="ae"], img[src*="alicdn"], img');
let image = imgEl ? (imgEl.getAttribute('src') || imgEl.getAttribute('data-src') || '') : '';
// Garantir protocolo HTTPS
if (image && image.startsWith('//')) {
image = 'https:' + image;
}
// Link do produto
const linkEl = card.querySelector('a[href*="aliexpress"], a[href*="/item/"], a[href]');
let link = linkEl ? linkEl.getAttribute('href') : '';
if (link && link.startsWith('//')) {
link = 'https:' + link;
}
if (link && !link.startsWith('http')) {
link = 'https://pt.aliexpress.com' + link;
}
// Cupom (AliExpress às vezes mostra cupons nos cards)
const couponEl = card.querySelector(
'[class*="coupon"], [class*="Coupon"], [class*="voucher"]'
);
const coupon = couponEl ? couponEl.textContent.trim() : null;
if (name && price && link) {
items.push({
name,
price,
originalPrice: originalPrice || null,
discount: discount || null,
image: image || null,
link,
coupon,
store: 'aliexpress',
});
}
} catch (e) {
// Ignorar cards com erro
}
});
return items;
}, config.scraping.maxOffersPerSite);
console.log(` ✅ [AliExpress] ${products.length} ofertas extraídas.`);
return products;
}, 'AliExpress');
return offers;
}
/**
* Scroll automático para ativar lazy-loading
*/
async function autoScroll(page) {
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 500;
const timer = setInterval(() => {
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= 4000) {
clearInterval(timer);
resolve();
}
}, 250);
});
});
await new Promise((resolve) => setTimeout(resolve, 2000));
}
module.exports = { scrapeAliexpress };

158
src/scrapers/amazon.js Normal file
View File

@@ -0,0 +1,158 @@
/**
* amazon.js - Scraper para Amazon Brasil
*
* Monitora a página de Ofertas do Dia da Amazon Brasil
* e extrai: nome, preço, preço original, desconto, imagem e link.
*/
const { config } = require('../config');
const { scrapeWithRetry } = require('../utils/browser');
/**
* Executa o scraping da página de ofertas da Amazon Brasil
* @returns {Array<Object>} Lista de ofertas extraídas
*/
async function scrapeAmazon() {
console.log('🛒 [Amazon] Iniciando scraping...');
const offers = await scrapeWithRetry(async (page) => {
// Navegar para a página de ofertas
await page.goto(config.urls.amazon, {
waitUntil: 'domcontentloaded',
timeout: config.scraping.pageTimeout,
});
// Aguardar carregamento dos cards de produto
await page.waitForSelector('[data-testid="deal-card"], .DealCard-module__dealCard_3BGIW, .a-section.dealCard', {
timeout: 10000,
}).catch(() => {
console.log(' ⚠️ [Amazon] Seletores de deal-card não encontrados, tentando alternativas...');
});
// Scroll para carregar mais produtos (lazy loading)
await autoScroll(page);
// Extrair dados dos produtos
const products = await page.evaluate((maxOffers, affiliateTag) => {
const items = [];
// Tentar múltiplos seletores (Amazon muda frequentemente o layout)
const cardSelectors = [
'[data-testid="deal-card"]',
'.DealCard-module__dealCard_3BGIW',
'.dealCard',
'.octopus-dlp-asin-section',
'[class*="DealCard"]',
];
let cards = [];
for (const selector of cardSelectors) {
cards = document.querySelectorAll(selector);
if (cards.length > 0) break;
}
// Se não encontrou cards específicos, tenta grid genérico
if (cards.length === 0) {
cards = document.querySelectorAll('.a-section .a-link-normal[href*="/dp/"]');
}
cards.forEach((card) => {
if (items.length >= maxOffers) return;
try {
// Nome do produto
const nameEl = card.querySelector(
'[class*="Title"], [class*="title"], .a-text-normal, .a-size-base'
);
const name = nameEl ? nameEl.textContent.trim() : '';
// Preço atual
const priceEl = card.querySelector(
'[class*="Price"], .a-price .a-offscreen, .a-price-whole, [class*="price"]'
);
let price = priceEl ? priceEl.textContent.trim() : '';
// Preço original
const originalPriceEl = card.querySelector(
'.a-text-price .a-offscreen, [class*="ListPrice"], [class*="originalPrice"]'
);
const originalPrice = originalPriceEl ? originalPriceEl.textContent.trim() : '';
// Desconto
const discountEl = card.querySelector(
'[class*="Discount"], [class*="discount"], .savingsPercentage'
);
const discount = discountEl ? discountEl.textContent.trim() : '';
// Imagem
const imgEl = card.querySelector('img[src*="images-amazon"], img[src*="m.media-amazon"]');
let image = imgEl ? (imgEl.getAttribute('src') || '') : '';
// Tentar pegar imagem de maior qualidade
if (image && image.includes('._')) {
image = image.replace(/\._.*_\./, '._SL500_.');
}
// Link do produto
const linkEl = card.querySelector('a[href*="/dp/"], a[href*="/deal/"], a[href]');
let link = linkEl ? linkEl.getAttribute('href') : '';
if (link && !link.startsWith('http')) {
link = 'https://www.amazon.com.br' + link;
}
// Adicionar tag de afiliado se configurada
if (link && affiliateTag) {
const separator = link.includes('?') ? '&' : '?';
link += `${separator}tag=${affiliateTag}`;
}
if (name && price && link) {
items.push({
name,
price,
originalPrice: originalPrice || null,
discount: discount || null,
image: image || null,
link,
coupon: null, // Amazon geralmente não tem cupons na página de ofertas
store: 'amazon',
});
}
} catch (e) {
// Ignorar cards com erro de extração
}
});
return items;
}, config.scraping.maxOffersPerSite, config.affiliate.amazonTag);
console.log(` ✅ [Amazon] ${products.length} ofertas extraídas.`);
return products;
}, 'Amazon');
return offers;
}
/**
* Scroll automático para ativar lazy-loading
*/
async function autoScroll(page) {
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= 3000) { // Limitar a ~3000px de scroll
clearInterval(timer);
resolve();
}
}, 200);
});
});
// Aguardar conteúdo carregar após scroll
await new Promise((resolve) => setTimeout(resolve, 2000));
}
module.exports = { scrapeAmazon };

38
src/scrapers/index.js Normal file
View File

@@ -0,0 +1,38 @@
/**
* index.js - Agregador de scrapers
*
* Exporta todos os scrapers disponíveis em um único módulo
* para facilitar importação e manutenção.
*/
const { scrapeAmazon } = require('./amazon');
const { scrapeAliexpress } = require('./aliexpress');
const { scrapeShopee } = require('./shopee');
const { scrapeMercadoLivre } = require('./mercadolivre');
// Mapa de scrapers disponíveis
// Cada entrada tem: nome legível, função de scraping e identificador da loja
const scrapers = [
{
name: 'Amazon Brasil',
store: 'amazon',
scrape: scrapeAmazon,
},
{
name: 'AliExpress',
store: 'aliexpress',
scrape: scrapeAliexpress,
},
{
name: 'Shopee',
store: 'shopee',
scrape: scrapeShopee,
},
{
name: 'Mercado Livre',
store: 'mercadolivre',
scrape: scrapeMercadoLivre,
},
];
module.exports = { scrapers };

View File

@@ -0,0 +1,205 @@
/**
* mercadolivre.js - Scraper para Mercado Livre
*
* Monitora a página de Ofertas do Dia do Mercado Livre
* e extrai: nome, preço, preço original, desconto, imagem e link.
*/
const { config } = require('../config');
const { scrapeWithRetry } = require('../utils/browser');
/**
* Executa o scraping da página de ofertas do Mercado Livre
* @returns {Array<Object>} Lista de ofertas extraídas
*/
async function scrapeMercadoLivre() {
console.log('🤝 [Mercado Livre] Iniciando scraping...');
const offers = await scrapeWithRetry(async (page) => {
// Navegar para a página de ofertas
await page.goto(config.urls.mercadolivre, {
waitUntil: 'domcontentloaded',
timeout: config.scraping.pageTimeout,
});
// Aguardar carregamento dos cards de produto
await page.waitForSelector('.promotion-item, .poly-card, .andes-card, [class*="poly-card"], .deal-card', {
timeout: 10000,
}).catch(() => {
console.log(' ⚠️ [Mercado Livre] Seletores primários não encontrados, tentando alternativas...');
});
// Scroll para carregar mais itens
await autoScroll(page);
// Extrair dados dos produtos
const products = await page.evaluate((maxOffers) => {
const items = [];
// Seletores para cards do Mercado Livre
const cardSelectors = [
'.poly-card',
'.promotion-item',
'.andes-card',
'[class*="poly-card"]',
'.deal-card',
'.ui-search-layout__item',
];
let cards = [];
for (const selector of cardSelectors) {
cards = document.querySelectorAll(selector);
if (cards.length > 0) break;
}
cards.forEach((card) => {
if (items.length >= maxOffers) return;
try {
// Nome do produto
const nameEl = card.querySelector(
'.poly-component__title, .promotion-item__title, .ui-search-item__title, [class*="title"], h2, h3'
);
const name = nameEl ? nameEl.textContent.trim() : '';
// Preço atual
const priceEl = card.querySelector(
'.poly-price__current .andes-money-amount, .andes-money-amount--cents-superscript, [class*="price"], .price-tag-fraction'
);
let price = '';
if (priceEl) {
// Mercado Livre separa reais e centavos em elementos diferentes
const fractionEl = priceEl.querySelector('.andes-money-amount__fraction, .price-tag-fraction');
const centsEl = priceEl.querySelector('.andes-money-amount__cents, .price-tag-cents');
const currencyEl = priceEl.querySelector('.andes-money-amount__currency-symbol');
if (fractionEl) {
const currency = currencyEl ? currencyEl.textContent.trim() : 'R$';
const fraction = fractionEl.textContent.trim();
const cents = centsEl ? centsEl.textContent.trim() : '00';
price = `${currency} ${fraction},${cents}`;
} else {
price = priceEl.textContent.trim();
}
}
// Preço original
const originalPriceEl = card.querySelector(
'.andes-money-amount--previous, [class*="original-price"], s .andes-money-amount, .price-tag-deleted'
);
let originalPrice = '';
if (originalPriceEl) {
const origFraction = originalPriceEl.querySelector('.andes-money-amount__fraction, .price-tag-fraction');
if (origFraction) {
originalPrice = `R$ ${origFraction.textContent.trim()}`;
} else {
originalPrice = originalPriceEl.textContent.trim();
}
}
// Desconto
const discountEl = card.querySelector(
'.poly-component__discount, [class*="discount"], .ui-search-price__second-line__label'
);
const discount = discountEl ? discountEl.textContent.trim() : '';
// Imagem
const imgEl = card.querySelector(
'img[src*="http2.mlstatic"], img[data-src*="http2.mlstatic"], img[src*="meli"], img'
);
let image = '';
if (imgEl) {
image = imgEl.getAttribute('src') || imgEl.getAttribute('data-src') || '';
// Garantir URL de imagem válida
if (image && image.startsWith('data:')) {
image = imgEl.getAttribute('data-src') || '';
}
}
// Link do produto
const linkEl = card.querySelector(
'a[href*="mercadolivre.com.br"], a[href*="produto.mercadolivre"], a[href*="/MLB-"], a[href]'
);
let link = linkEl ? linkEl.getAttribute('href') : '';
if (link && !link.startsWith('http')) {
link = 'https://www.mercadolivre.com.br' + link;
}
// Cupom (ML ocasionalmente mostra cupons)
const couponEl = card.querySelector(
'[class*="coupon"], [class*="cupom"], [class*="highlight"]'
);
let coupon = null;
if (couponEl) {
const couponText = couponEl.textContent.trim();
if (couponText.toLowerCase().includes('cupom') || couponText.toLowerCase().includes('off')) {
coupon = couponText;
}
}
// Frete grátis (informação extra útil)
const freeShippingEl = card.querySelector(
'[class*="free-shipping"], .poly-component__shipping, [class*="shipping"]'
);
const hasFreeShipping = freeShippingEl &&
freeShippingEl.textContent.toLowerCase().includes('grátis');
if (name && price && link) {
const offer = {
name,
price,
originalPrice: originalPrice || null,
discount: discount || null,
image: image || null,
link,
coupon,
store: 'mercadolivre',
};
// Adicionar info de frete grátis ao cupom/observação
if (hasFreeShipping && !coupon) {
offer.coupon = '🚚 Frete Grátis';
} else if (hasFreeShipping && coupon) {
offer.coupon += ' | 🚚 Frete Grátis';
}
items.push(offer);
}
} catch (e) {
// Ignorar cards com erro
}
});
return items;
}, config.scraping.maxOffersPerSite);
console.log(` ✅ [Mercado Livre] ${products.length} ofertas extraídas.`);
return products;
}, 'Mercado Livre');
return offers;
}
/**
* Scroll automático
*/
async function autoScroll(page) {
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= 3000) {
clearInterval(timer);
resolve();
}
}, 200);
});
});
await new Promise((resolve) => setTimeout(resolve, 2000));
}
module.exports = { scrapeMercadoLivre };

199
src/scrapers/shopee.js Normal file
View File

@@ -0,0 +1,199 @@
/**
* shopee.js - Scraper para Shopee Brasil
*
* Monitora a página de Flash Sale / Ofertas Relâmpago da Shopee
* e extrai: nome, preço, preço original, desconto, imagem e link.
*
* Nota: A Shopee é bastante dinâmica e carrega conteúdo via API.
* Este scraper usa Puppeteer para renderizar a página completa.
*/
const { config } = require('../config');
const { scrapeWithRetry } = require('../utils/browser');
/**
* Executa o scraping da página de ofertas da Shopee
* @returns {Array<Object>} Lista de ofertas extraídas
*/
async function scrapeShopee() {
console.log('🛍️ [Shopee] Iniciando scraping...');
const offers = await scrapeWithRetry(async (page) => {
// Navegar para a página de flash sale
await page.goto(config.urls.shopee, {
waitUntil: 'networkidle2',
timeout: config.scraping.pageTimeout,
});
// A Shopee pode mostrar popups - tentar fechar
await closePopups(page);
// Aguardar carregamento dos cards
await page.waitForSelector('[class*="flash-sale"], [class*="shopee-search-item"], .shopee-card-atelier-overlay, [data-sqe="item"]', {
timeout: 15000,
}).catch(() => {
console.log(' ⚠️ [Shopee] Seletores primários não encontrados, tentando alternativas...');
});
// Aguardar conteúdo dinâmico
await new Promise((resolve) => setTimeout(resolve, 3000));
// Scroll para carregar mais itens
await autoScroll(page);
// Extrair dados dos produtos
const products = await page.evaluate((maxOffers) => {
const items = [];
// Seletores para cards da Shopee
const cardSelectors = [
'[data-sqe="item"]',
'[class*="flash-sale-item"]',
'.shopee-search-item-result__item',
'[class*="product-card"]',
'[class*="item-card"]',
'.shop-search-result-view__item',
];
let cards = [];
for (const selector of cardSelectors) {
cards = document.querySelectorAll(selector);
if (cards.length > 0) break;
}
cards.forEach((card) => {
if (items.length >= maxOffers) return;
try {
// Nome do produto
const nameEl = card.querySelector(
'[class*="name"], [class*="title"], [data-sqe="name"]'
);
const name = nameEl ? nameEl.textContent.trim() : '';
// Preço atual
const priceEl = card.querySelector(
'[class*="price"], [class*="Price"]'
);
let price = '';
if (priceEl) {
// Shopee formata preços de formas variadas
price = priceEl.textContent.trim();
// Remover textos como "R$" duplicados ou ranges de preço
if (price.includes(' - ')) {
price = price.split(' - ')[0]; // Pegar menor preço
}
}
// Preço original
const originalPriceEl = card.querySelector(
'[class*="original"], del, s, [class*="before"]'
);
const originalPrice = originalPriceEl ? originalPriceEl.textContent.trim() : '';
// Desconto
const discountEl = card.querySelector(
'[class*="discount"], [class*="percent"]'
);
const discount = discountEl ? discountEl.textContent.trim() : '';
// Imagem
const imgEl = card.querySelector('img');
let image = imgEl ? (imgEl.getAttribute('src') || imgEl.getAttribute('data-src') || '') : '';
if (image && image.startsWith('//')) {
image = 'https:' + image;
}
// Tentar pegar imagem de melhor qualidade
if (image) {
image = image.replace(/_tn\b/, '');
}
// Link do produto
const linkEl = card.querySelector('a[href]');
let link = linkEl ? linkEl.getAttribute('href') : '';
if (link && !link.startsWith('http')) {
link = 'https://shopee.com.br' + link;
}
// Cupom (Shopee mostra vouchers em alguns produtos)
const couponEl = card.querySelector(
'[class*="voucher"], [class*="coupon"]'
);
const coupon = couponEl ? couponEl.textContent.trim() : null;
if (name && price && link) {
items.push({
name,
price,
originalPrice: originalPrice || null,
discount: discount || null,
image: image || null,
link,
coupon,
store: 'shopee',
});
}
} catch (e) {
// Ignorar cards com erro
}
});
return items;
}, config.scraping.maxOffersPerSite);
console.log(` ✅ [Shopee] ${products.length} ofertas extraídas.`);
return products;
}, 'Shopee');
return offers;
}
/**
* Tenta fechar popups que a Shopee frequentemente exibe
*/
async function closePopups(page) {
try {
// Fechar popup de login/cadastro
const closeButtonSelectors = [
'[class*="close-btn"]',
'[class*="closeBtn"]',
'.shopee-popup__close-btn',
'[aria-label="Close"]',
'button[class*="close"]',
];
for (const selector of closeButtonSelectors) {
const btn = await page.$(selector);
if (btn) {
await btn.click();
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
} catch {
// Ignorar erros ao fechar popups
}
}
/**
* Scroll automático
*/
async function autoScroll(page) {
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= 3000) {
clearInterval(timer);
resolve();
}
}, 200);
});
});
await new Promise((resolve) => setTimeout(resolve, 2000));
}
module.exports = { scrapeShopee };

135
src/utils/browser.js Normal file
View File

@@ -0,0 +1,135 @@
/**
* browser.js - Gerenciador do navegador Puppeteer
*
* Centraliza a criação e gerenciamento da instância do Puppeteer
* com configurações otimizadas para scraping.
*/
const puppeteer = require('puppeteer');
const { config } = require('../config');
let browserInstance = null;
/**
* Obtém ou cria uma instância do navegador Puppeteer
* Reutiliza a mesma instância para economizar recursos
*/
async function getBrowser() {
if (browserInstance && browserInstance.connected) {
return browserInstance;
}
console.log('🌐 Iniciando navegador Puppeteer...');
browserInstance = await puppeteer.launch({
headless: config.scraping.headless ? 'new' : false,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--window-size=1920,1080',
'--lang=pt-BR',
],
defaultViewport: {
width: 1920,
height: 1080,
},
});
console.log('✅ Navegador iniciado com sucesso.');
return browserInstance;
}
/**
* Cria uma nova página com configurações otimizadas
* - User-Agent realista para evitar bloqueios
* - Bloqueio de recursos desnecessários (fontes, mídia)
* - Timeout configurável
*/
async function createPage() {
const browser = await getBrowser();
const page = await browser.newPage();
// User-Agent realista (Chrome no Windows)
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
);
// Configurar idioma para português
await page.setExtraHTTPHeaders({
'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7',
});
// Timeout padrão para navegação
page.setDefaultNavigationTimeout(config.scraping.pageTimeout);
page.setDefaultTimeout(config.scraping.pageTimeout);
// Bloquear recursos desnecessários para acelerar o scraping
await page.setRequestInterception(true);
page.on('request', (req) => {
const blockedTypes = ['font', 'media'];
if (blockedTypes.includes(req.resourceType())) {
req.abort();
} else {
req.continue();
}
});
return page;
}
/**
* Fecha o navegador e libera recursos
*/
async function closeBrowser() {
if (browserInstance) {
await browserInstance.close();
browserInstance = null;
console.log('🔒 Navegador fechado.');
}
}
/**
* Executa scraping com retry automático
*
* @param {Function} scraperFn - Função de scraping que recebe (page)
* @param {string} storeName - Nome da loja para logs
* @param {number} maxRetries - Número máximo de tentativas
* @returns {Array} Lista de ofertas encontradas
*/
async function scrapeWithRetry(scraperFn, storeName, maxRetries = 2) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
let page = null;
try {
page = await createPage();
const results = await scraperFn(page);
return results;
} catch (error) {
console.error(` ⚠️ [${storeName}] Tentativa ${attempt}/${maxRetries} falhou: ${error.message}`);
if (attempt === maxRetries) {
console.error(` ❌ [${storeName}] Todas as tentativas falharam.`);
return [];
}
// Aguardar antes de tentar novamente
await new Promise((resolve) => setTimeout(resolve, 3000));
} finally {
if (page) {
try {
await page.close();
} catch {
// Página já pode ter sido fechada
}
}
}
}
return [];
}
module.exports = {
getBrowser,
createPage,
closeBrowser,
scrapeWithRetry,
};

133
src/utils/dedup.js Normal file
View File

@@ -0,0 +1,133 @@
/**
* dedup.js - Sistema de deduplicação de ofertas
*
* Evita que a mesma oferta seja enviada mais de uma vez no mesmo dia.
* Armazena um registro em disco (JSON) com hash das ofertas enviadas.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// Diretório para armazenar dados de deduplicação
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const DEDUP_FILE = path.join(DATA_DIR, 'sent_offers.json');
/**
* Gera um hash único para identificar uma oferta
* Combina nome do produto + loja para criar uma chave única
*/
function generateOfferHash(offer) {
const key = `${offer.store}:${offer.name}:${offer.price}`;
return crypto.createHash('md5').update(key).digest('hex');
}
/**
* Carrega o registro de ofertas já enviadas
* Limpa automaticamente ofertas de dias anteriores
*/
function loadSentOffers() {
try {
// Garante que o diretório existe
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
if (!fs.existsSync(DEDUP_FILE)) {
return { date: getTodayDate(), offers: [] };
}
const data = JSON.parse(fs.readFileSync(DEDUP_FILE, 'utf-8'));
// Se o registro é de um dia diferente, limpa tudo (reset diário)
if (data.date !== getTodayDate()) {
console.log('📅 Novo dia detectado — limpando registro de ofertas anteriores.');
return { date: getTodayDate(), offers: [] };
}
return data;
} catch (error) {
console.error('⚠️ Erro ao carregar registro de dedup:', error.message);
return { date: getTodayDate(), offers: [] };
}
}
/**
* Salva o registro de ofertas enviadas no disco
*/
function saveSentOffers(sentOffers) {
try {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
fs.writeFileSync(DEDUP_FILE, JSON.stringify(sentOffers, null, 2), 'utf-8');
} catch (error) {
console.error('⚠️ Erro ao salvar registro de dedup:', error.message);
}
}
/**
* Verifica se uma oferta já foi enviada hoje
*/
function isOfferAlreadySent(offer) {
const sentOffers = loadSentOffers();
const hash = generateOfferHash(offer);
return sentOffers.offers.includes(hash);
}
/**
* Marca uma oferta como enviada
*/
function markOfferAsSent(offer) {
const sentOffers = loadSentOffers();
const hash = generateOfferHash(offer);
if (!sentOffers.offers.includes(hash)) {
sentOffers.offers.push(hash);
saveSentOffers(sentOffers);
}
}
/**
* Filtra uma lista de ofertas, removendo as que já foram enviadas
*/
function filterNewOffers(offers) {
const sentOffers = loadSentOffers();
const newOffers = offers.filter((offer) => {
const hash = generateOfferHash(offer);
return !sentOffers.offers.includes(hash);
});
console.log(` 🔍 ${offers.length} ofertas encontradas → ${newOffers.length} novas`);
return newOffers;
}
/**
* Marca múltiplas ofertas como enviadas de uma vez (mais eficiente)
*/
function markMultipleAsSent(offers) {
const sentOffers = loadSentOffers();
offers.forEach((offer) => {
const hash = generateOfferHash(offer);
if (!sentOffers.offers.includes(hash)) {
sentOffers.offers.push(hash);
}
});
saveSentOffers(sentOffers);
}
/**
* Retorna a data de hoje no formato YYYY-MM-DD
*/
function getTodayDate() {
return new Date().toISOString().split('T')[0];
}
module.exports = {
isOfferAlreadySent,
markOfferAsSent,
filterNewOffers,
markMultipleAsSent,
};

160
src/utils/embed.js Normal file
View File

@@ -0,0 +1,160 @@
/**
* embed.js - Construtor de embeds do Discord para ofertas
*
* Formata as ofertas extraídas em embeds ricos e visualmente
* atraentes para envio nos canais do Discord.
*/
const { EmbedBuilder } = require('discord.js');
const { config } = require('../config');
/**
* Cria um embed do Discord para uma oferta individual
*
* @param {Object} offer - Dados da oferta
* @param {string} offer.name - Nome do produto
* @param {string} offer.price - Preço atual (já formatado)
* @param {string} [offer.originalPrice] - Preço original (se houver desconto)
* @param {string} [offer.discount] - Percentual de desconto
* @param {string} offer.link - URL do produto
* @param {string} [offer.image] - URL da imagem do produto
* @param {string} [offer.coupon] - Código do cupom (se disponível)
* @param {string} offer.store - Identificador da loja (amazon, aliexpress, etc.)
* @returns {EmbedBuilder} Embed formatado para envio
*/
function buildOfferEmbed(offer) {
const color = config.colors[offer.store] || 0x5865F2; // Cor padrão Discord
const emoji = config.emojis[offer.store] || '🏷️';
const storeName = getStoreName(offer.store);
const embed = new EmbedBuilder()
.setColor(color)
.setTitle(`${emoji} ${truncate(offer.name, 200)}`)
.setURL(offer.link)
.setTimestamp()
.setFooter({
text: `${storeName} • Encontrado pelo Bot de Ofertas`,
});
// ── Imagem do produto ──
if (offer.image && isValidUrl(offer.image)) {
embed.setImage(offer.image);
}
// ── Campos de preço ──
const priceFields = [];
// Preço atual em destaque
priceFields.push({
name: '💰 Preço Atual',
value: `**${offer.price}**`,
inline: true,
});
// Preço original (riscado) se houver desconto
if (offer.originalPrice) {
priceFields.push({
name: '💸 Preço Original',
value: `~~${offer.originalPrice}~~`,
inline: true,
});
}
// Percentual de desconto
if (offer.discount) {
priceFields.push({
name: '🔥 Desconto',
value: `**${offer.discount}**`,
inline: true,
});
}
embed.addFields(priceFields);
// ── Cupom de desconto ──
if (offer.coupon) {
embed.addFields({
name: '🎟️ Cupom de Desconto',
value: `\`${offer.coupon}\``,
inline: false,
});
}
// ── Link direto ──
embed.addFields({
name: '🔗 Comprar Agora',
value: `[Clique aqui para ver a oferta](${offer.link})`,
inline: false,
});
return embed;
}
/**
* Cria um embed de resumo/cabeçalho para uma rodada de scraping
*/
function buildSummaryEmbed(totalOffers, stores) {
const now = new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' });
return new EmbedBuilder()
.setColor(0x5865F2)
.setTitle('🔔 Novas Ofertas Encontradas!')
.setDescription(
`Foram encontradas **${totalOffers}** novas ofertas em **${stores.length}** loja(s).\n` +
`📅 Verificação: ${now}`
)
.setFooter({ text: 'Bot de Ofertas • Monitoramento Automático' });
}
/**
* Cria um embed de status/erro
*/
function buildStatusEmbed(message, isError = false) {
return new EmbedBuilder()
.setColor(isError ? 0xFF0000 : 0x00FF00)
.setTitle(isError ? '❌ Erro no Monitoramento' : '✅ Status do Bot')
.setDescription(message)
.setTimestamp();
}
// ── Funções auxiliares ──────────────────────────────────
/**
* Retorna o nome legível da loja
*/
function getStoreName(storeKey) {
const names = {
amazon: 'Amazon Brasil',
aliexpress: 'AliExpress',
shopee: 'Shopee',
mercadolivre: 'Mercado Livre',
};
return names[storeKey] || storeKey;
}
/**
* Trunca texto longo para caber nos limites do Discord
*/
function truncate(text, maxLength) {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
/**
* Valida se uma string é uma URL válida
*/
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch {
return false;
}
}
module.exports = {
buildOfferEmbed,
buildSummaryEmbed,
buildStatusEmbed,
};