initial-commit
This commit is contained in:
50
.env.example
Normal file
50
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
138
README.md
Normal file
138
README.md
Normal 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
1800
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
187
src/bot.js
Normal 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
74
src/config.js
Normal 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
53
src/index.js
Normal 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
161
src/scrapers/aliexpress.js
Normal 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
158
src/scrapers/amazon.js
Normal 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
38
src/scrapers/index.js
Normal 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 };
|
||||||
205
src/scrapers/mercadolivre.js
Normal file
205
src/scrapers/mercadolivre.js
Normal 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
199
src/scrapers/shopee.js
Normal 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
135
src/utils/browser.js
Normal 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
133
src/utils/dedup.js
Normal 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
160
src/utils/embed.js
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user