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