Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
target/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
.git/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,23 @@
|
||||
DB_USER=leadscraper
|
||||
DB_PASSWORD=troque-esta-senha
|
||||
DB_PORT=5433
|
||||
BACKEND_PORT=8082
|
||||
FRONTEND_PORT=8081
|
||||
SERVER_PORT=8082
|
||||
|
||||
JWT_SECRET=troque-por-uma-chave-com-pelo-menos-32-caracteres
|
||||
JWT_EXPIRATION_MINUTES=480
|
||||
API_URL=/api
|
||||
|
||||
BUSCA_WORKER_ENABLED=true
|
||||
BUSCA_WORKER_FIXED_DELAY_MS=15000
|
||||
SCRAPER_HEADLESS=true
|
||||
SCRAPER_TIMEOUT_MS=300000
|
||||
SCRAPER_MAX_RESULTS=1000
|
||||
SCRAPER_SCROLL_STAGNANT_ATTEMPTS=12
|
||||
SCRAPER_DELAY_MIN_MS=900
|
||||
SCRAPER_DELAY_MAX_MS=2500
|
||||
OVERPASS_ENABLED=true
|
||||
OVERPASS_ENDPOINT=https://overpass-api.de/api/interpreter
|
||||
OVERPASS_TIMEOUT_SECONDS=90
|
||||
OVERPASS_MAX_RESULTS=1000
|
||||
@@ -0,0 +1,24 @@
|
||||
DB_USER=leadscraper
|
||||
DB_PASSWORD=troque-esta-senha
|
||||
DB_PORT=5433
|
||||
BACKEND_PORT=8082
|
||||
FRONTEND_PORT=8081
|
||||
SERVER_PORT=8082
|
||||
|
||||
JWT_SECRET=troque-por-uma-chave-com-pelo-menos-32-caracteres
|
||||
JWT_EXPIRATION_MINUTES=480
|
||||
API_URL=/api
|
||||
APP_PUBLIC_URL=https://escavador.seconectai.com.br
|
||||
|
||||
BUSCA_WORKER_ENABLED=true
|
||||
BUSCA_WORKER_FIXED_DELAY_MS=15000
|
||||
SCRAPER_HEADLESS=true
|
||||
SCRAPER_TIMEOUT_MS=300000
|
||||
SCRAPER_MAX_RESULTS=1000
|
||||
SCRAPER_SCROLL_STAGNANT_ATTEMPTS=12
|
||||
SCRAPER_DELAY_MIN_MS=900
|
||||
SCRAPER_DELAY_MAX_MS=2500
|
||||
OVERPASS_ENABLED=true
|
||||
OVERPASS_ENDPOINT=https://overpass-api.de/api/interpreter
|
||||
OVERPASS_TIMEOUT_SECONDS=90
|
||||
OVERPASS_MAX_RESULTS=1000
|
||||
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
target/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
*.log
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
@@ -0,0 +1,7 @@
|
||||
escavador.seconectai.com.br {
|
||||
encode gzip zstd
|
||||
|
||||
handle {
|
||||
reverse_proxy frontend:80
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Deploy em escavador.seconectai.com.br
|
||||
|
||||
Dominio: `escavador.seconectai.com.br`
|
||||
|
||||
Antes da instalacao, aponte o DNS do dominio para o IP do servidor.
|
||||
|
||||
## Instalar comandos globais
|
||||
|
||||
Via Gitea:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://producao-gitea.0jo7zj.easypanel.host/tiago/Prospeccao_SeConectai/raw/branch/main/scripts/bootstrap-commands.sh | sudo bash
|
||||
```
|
||||
|
||||
Ou, se o repositorio ja estiver no servidor:
|
||||
|
||||
```bash
|
||||
sudo ./scripts/install-commands.sh
|
||||
```
|
||||
|
||||
## Primeira instalacao
|
||||
|
||||
```bash
|
||||
seconectaiescavador.install
|
||||
```
|
||||
|
||||
O comando clona ou atualiza o codigo em `/opt/Prospeccao_SeConectai`, gera o `.env`, constroi as imagens Docker e sobe os containers com HTTPS pelo Caddy.
|
||||
|
||||
URL final:
|
||||
|
||||
```text
|
||||
https://escavador.seconectai.com.br
|
||||
```
|
||||
|
||||
## Atualizar
|
||||
|
||||
Depois de novos commits:
|
||||
|
||||
```bash
|
||||
seconectaiescavador.update
|
||||
```
|
||||
|
||||
## Comandos uteis
|
||||
|
||||
```bash
|
||||
docker compose -f /opt/Prospeccao_SeConectai/docker-compose.prod.yml ps
|
||||
docker compose -f /opt/Prospeccao_SeConectai/docker-compose.prod.yml logs -f backend
|
||||
docker compose -f /opt/Prospeccao_SeConectai/docker-compose.prod.yml logs -f frontend
|
||||
docker compose -f /opt/Prospeccao_SeConectai/docker-compose.prod.yml logs -f caddy
|
||||
```
|
||||
|
||||
Postgres nao fica publicado na internet no compose de producao.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
FROM maven:3.9.9-eclipse-temurin-21 AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY pom.xml .
|
||||
RUN mvn -B -DskipTests dependency:go-offline
|
||||
|
||||
COPY src ./src
|
||||
RUN mvn -B -DskipTests package
|
||||
RUN mvn -B exec:java -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"
|
||||
|
||||
FROM eclipse-temurin:21-jre-jammy AS runtime
|
||||
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/ms-playwright/chromium-1140/chrome-linux/chrome
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
fonts-liberation \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libcairo2 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libdrm2 \
|
||||
libexpat1 \
|
||||
libgbm1 \
|
||||
libglib2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libpango-1.0-0 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
libxshmfence1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /root/.cache/ms-playwright /ms-playwright
|
||||
COPY --from=build /app/target/leadscraper-0.0.1-SNAPSHOT.jar app.jar
|
||||
|
||||
EXPOSE 8082
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
@@ -0,0 +1,217 @@
|
||||
# LeadScraper — Arquitetura e Schema (v1.1)
|
||||
|
||||
Documento de arquitetura de referência. Qualquer mudança de schema ou de stack deve ser refletida aqui primeiro (versionar para v1.1, v1.2...) antes de alterar código.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stack tecnológica
|
||||
|
||||
### Backend
|
||||
- **Java 21**
|
||||
- **Spring Boot 3.x** — REST API, injeção de dependência, configuração
|
||||
- **Spring Data JPA + Hibernate** — persistência
|
||||
- **Flyway** — migrations versionadas do banco
|
||||
- **PostgreSQL 16** — banco relacional principal
|
||||
- **Playwright for Java** — automação de navegador (Chromium headless) para o scraping do Google Maps
|
||||
- **Spring Security + JWT** — autenticação da API (dashboard interno, não público)
|
||||
- **Bean Validation (Jakarta Validation)** — validação de entrada (CNPJ, telefone, etc.)
|
||||
- **Lombok** — redução de boilerplate
|
||||
- **Maven** — build e gerenciamento de dependências
|
||||
|
||||
### Frontend
|
||||
- **React 18 + Vite + TypeScript**
|
||||
- **Tailwind CSS + shadcn/ui** — componentes e estilo
|
||||
- **TanStack Query** — cache e sincronização com a API
|
||||
- **React Hook Form + Zod** — formulários e validação
|
||||
- **Recharts** — gráficos simples (ex: leads por ramo, buscas por dia)
|
||||
|
||||
### Infraestrutura
|
||||
- **Docker + Docker Compose** — orquestração de todos os serviços
|
||||
- **Nginx** — servir o build estático do frontend dentro do container
|
||||
- **Volumes Docker** — persistência de dados do PostgreSQL entre reinicializações
|
||||
|
||||
---
|
||||
|
||||
## 2. Arquitetura em módulos (backend)
|
||||
|
||||
```
|
||||
com.seconectai.leadscraper
|
||||
├── config/ → segurança, CORS, configuração do Playwright
|
||||
├── domain/ → entidades JPA (Lead, Ramo, Busca, Proxy, Telefone, HistoricoContato)
|
||||
├── repository/ → interfaces Spring Data JPA
|
||||
├── scraper/ → motor de automação do navegador (busca, scroll, extração bruta)
|
||||
├── normalizer/ → padronização de nome, telefone, CNPJ, ramo
|
||||
├── proxy/ → gestão do pool de proxies, rotação, backoff
|
||||
├── service/ → orquestração das regras de negócio
|
||||
├── controller/ → endpoints REST
|
||||
├── security/ → autenticação JWT
|
||||
└── worker/ → processamento assíncrono das buscas em fila (status pendente → em_execucao → concluida)
|
||||
```
|
||||
|
||||
## 3. Fluxo de execução de uma busca
|
||||
|
||||
1. Usuário informa `termo_busca` + `localizacao` pelo frontend
|
||||
2. Backend cria um registro em `buscas` com status `pendente`
|
||||
3. `worker` pega a busca pendente, muda status para `em_execucao`, seleciona um proxy disponível no pool
|
||||
4. `scraper` abre o navegador com o proxy configurado, executa a busca no Maps, faz scroll gradual com delays humanizados até esgotar os resultados, extrai dados brutos de cada card
|
||||
5. `normalizer` padroniza cada lead extraído (nome, telefone, CNPJ, ramo) e calcula `hash_dedup`
|
||||
6. `service` verifica duplicidade (por `hash_dedup` e `place_id`); se já existe, atualiza vínculo com a nova busca em vez de duplicar
|
||||
7. Leads novos são persistidos; `busca.total_encontrados` é atualizado; status muda para `concluida` (ou `erro` se algo falhar, com detalhe do erro registrado)
|
||||
8. Frontend consulta o status via polling (TanStack Query) e exibe os leads assim que disponíveis
|
||||
|
||||
## 4. Schema do banco de dados
|
||||
|
||||
### 4.1 `usuarios` — acesso ao dashboard
|
||||
|
||||
```sql
|
||||
CREATE TABLE usuarios (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
nome VARCHAR(150) NOT NULL,
|
||||
email VARCHAR(150) NOT NULL UNIQUE,
|
||||
senha_hash VARCHAR(255) NOT NULL,
|
||||
papel VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | operador
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 4.2 `ramos` — taxonomia normalizada de categorias
|
||||
|
||||
```sql
|
||||
CREATE TABLE ramos (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
nome_canonico VARCHAR(100) NOT NULL UNIQUE,
|
||||
categoria_pai_id BIGINT REFERENCES ramos(id),
|
||||
aliases JSONB NOT NULL DEFAULT '[]',
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 4.3 `proxies` — pool de proxies para rotação
|
||||
|
||||
```sql
|
||||
CREATE TABLE proxies (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
endereco VARCHAR(255) NOT NULL,
|
||||
tipo VARCHAR(20) DEFAULT 'residencial', -- residencial | datacenter
|
||||
status VARCHAR(20) DEFAULT 'ativo', -- ativo | degradado | bloqueado
|
||||
total_bloqueios INTEGER DEFAULT 0,
|
||||
em_uso BOOLEAN NOT NULL DEFAULT false,
|
||||
ultima_utilizacao TIMESTAMPTZ,
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 4.4 `buscas` — cada execução do motor de busca
|
||||
|
||||
```sql
|
||||
CREATE TABLE buscas (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
termo_busca VARCHAR(150) NOT NULL,
|
||||
localizacao VARCHAR(150) NOT NULL,
|
||||
ramo_id BIGINT REFERENCES ramos(id),
|
||||
proxy_id BIGINT REFERENCES proxies(id),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente', -- pendente | em_execucao | concluida | erro
|
||||
total_encontrados INTEGER DEFAULT 0,
|
||||
detalhe_erro TEXT,
|
||||
iniciado_em TIMESTAMPTZ,
|
||||
finalizado_em TIMESTAMPTZ,
|
||||
criado_por BIGINT REFERENCES usuarios(id),
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 4.5 `leads` — tabela principal
|
||||
|
||||
```sql
|
||||
CREATE TABLE leads (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
place_id VARCHAR(150),
|
||||
razao_social VARCHAR(200),
|
||||
nome_fantasia VARCHAR(200) NOT NULL,
|
||||
cnpj CHAR(14),
|
||||
telefone_e164 VARCHAR(20),
|
||||
telefone_bruto VARCHAR(40),
|
||||
email VARCHAR(150),
|
||||
site VARCHAR(255),
|
||||
endereco_completo TEXT,
|
||||
logradouro VARCHAR(200),
|
||||
numero VARCHAR(20),
|
||||
bairro VARCHAR(100),
|
||||
cidade VARCHAR(100),
|
||||
uf CHAR(2),
|
||||
cep CHAR(8),
|
||||
latitude NUMERIC(10,7),
|
||||
longitude NUMERIC(10,7),
|
||||
ramo_id BIGINT REFERENCES ramos(id),
|
||||
categoria_bruta VARCHAR(150),
|
||||
fonte VARCHAR(30) NOT NULL DEFAULT 'google_maps',
|
||||
busca_id BIGINT REFERENCES buscas(id),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'novo', -- novo | contatado | qualificado | convertido | descartado
|
||||
hash_dedup VARCHAR(64) NOT NULL UNIQUE,
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_leads_ramo ON leads(ramo_id);
|
||||
CREATE INDEX idx_leads_cidade_uf ON leads(cidade, uf);
|
||||
CREATE INDEX idx_leads_busca ON leads(busca_id);
|
||||
CREATE UNIQUE INDEX idx_leads_cnpj_unique ON leads(cnpj) WHERE cnpj IS NOT NULL;
|
||||
```
|
||||
|
||||
### 4.6 `telefones` (opcional — múltiplos telefones por lead)
|
||||
|
||||
```sql
|
||||
CREATE TABLE telefones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
lead_id BIGINT NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||
numero_e164 VARCHAR(20) NOT NULL,
|
||||
tipo VARCHAR(20) DEFAULT 'principal',
|
||||
UNIQUE (lead_id, numero_e164)
|
||||
);
|
||||
```
|
||||
|
||||
### 4.7 `historico_contato` — pipeline de acompanhamento
|
||||
|
||||
```sql
|
||||
CREATE TABLE historico_contato (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
lead_id BIGINT NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||
canal VARCHAR(30), -- whatsapp | ligacao | email
|
||||
resultado VARCHAR(50),
|
||||
observacao TEXT,
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
## 5. Regras de padronização (resumo — detalhado na etapa 04)
|
||||
|
||||
- **Nome:** trim, colapsar espaços, Title Case preservando siglas societárias (LTDA, ME, EIRELI, S/A)
|
||||
- **Telefone:** apenas dígitos, assumir DDI +55 se ausente, formato final E.164, manter bruto para auditoria
|
||||
- **CNPJ:** apenas dígitos, validar dígito verificador (módulo 11) antes de gravar
|
||||
- **Ramo:** nunca gravar categoria bruta como definitiva — mapear via tabela `ramos.aliases`; sem mapeamento conhecido, `ramo_id = NULL` e categoria fica em `categoria_bruta` para curadoria manual
|
||||
- **Deduplicação:** `hash_dedup = SHA-256(nome_normalizado + '|' + telefone_e164)`; `place_id` como critério secundário
|
||||
|
||||
## 6. Anti-bloqueio (resumo — detalhado na etapa 05)
|
||||
|
||||
- Pool de proxies residenciais com rotação por sessão de busca (não por requisição)
|
||||
- Delays humanizados aleatórios entre ações e entre buscas
|
||||
- Scroll gradual em passos pequenos, não pulo direto
|
||||
- Fingerprint variável (User-Agent, viewport, timezone) por contexto de navegador
|
||||
- Detecção de captcha/bloqueio com backoff exponencial e desativação temporária do proxy afetado
|
||||
- Limite de volume auto-imposto por proxy/hora
|
||||
|
||||
## 7. Docker Compose — visão geral dos serviços
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres: # banco de dados
|
||||
backend: # API Spring Boot + motor de scraping
|
||||
frontend: # build React servido via Nginx
|
||||
```
|
||||
|
||||
Detalhes completos do `docker-compose.yml` estão na etapa 09.
|
||||
|
||||
## 8. Histórico de versões deste documento
|
||||
|
||||
- **v1.1** (atual) — adiciona `proxies.em_uso` para reserva concorrente do pool de proxies na etapa 05
|
||||
- **v1.0** — schema inicial completo: leads, ramos, buscas, proxies, telefones, historico_contato, usuarios; stack backend/frontend/infra definida
|
||||
@@ -0,0 +1,114 @@
|
||||
# LeadScraper — Leia-me
|
||||
|
||||
Motor de busca e prospecção de leads via automação de navegador (Google Maps), com backend em Java, frontend próprio, banco de dados PostgreSQL e execução em Docker.
|
||||
|
||||
Este documento é o ponto de entrada do projeto. Leia-o antes de começar qualquer etapa.
|
||||
|
||||
---
|
||||
|
||||
## 1. Estrutura do projeto
|
||||
|
||||
```
|
||||
leadscraper/
|
||||
├── LEIA-ME.md ← este arquivo
|
||||
├── ARQUITETURA-SCHEMA.md ← schema completo do banco + stack tecnológica + módulos
|
||||
├── PROGRESSO.md ← tracker de progresso, atualizado a cada etapa concluída
|
||||
└── etapas/
|
||||
├── 01-setup-projeto-docker-flyway.md
|
||||
├── 02-modelagem-entidades-jpa.md
|
||||
├── 03-motor-scraper-playwright.md
|
||||
├── 04-normalizador-padronizacao.md
|
||||
├── 05-pool-proxies-anti-bloqueio.md
|
||||
├── 06-api-rest-backend.md
|
||||
├── 07-frontend-setup-dashboard.md
|
||||
├── 08-frontend-integracao-api.md
|
||||
├── 09-docker-compose-deploy.md
|
||||
└── 10-testes-validacao-final.md
|
||||
```
|
||||
|
||||
## 2. Como este projeto deve ser executado
|
||||
|
||||
1. **Leia `ARQUITETURA-SCHEMA.md` por completo** antes de iniciar qualquer etapa — ele é a fonte de verdade sobre schema de banco, tecnologias e módulos. Nenhuma etapa deve divergir do que está lá sem atualizar o documento primeiro.
|
||||
2. **Siga as etapas em `etapas/` na ordem numérica.** Cada arquivo é autocontido: traz objetivo, contexto necessário e critérios de aceite. Não pule etapas — cada uma assume que a anterior foi concluída.
|
||||
3. **Antes de iniciar uma etapa, abra `PROGRESSO.md`** e confira se a etapa anterior está marcada como concluída. Se não estiver, resolva pendências antes de avançar.
|
||||
4. **Ao concluir uma etapa, atualize `PROGRESSO.md` imediatamente** — marque status, data e qualquer observação relevante (ex: decisão técnica tomada, desvio do plano original).
|
||||
5. Se durante a execução de uma etapa for necessário mudar algo do schema ou da arquitetura definida em `ARQUITETURA-SCHEMA.md`, **atualize o documento primeiro** (versionando, ex: v1.0 → v1.1) e só depois prossiga — nunca deixe o código divergir silenciosamente do documento de arquitetura.
|
||||
6. Novas funcionalidades que surgirem depois do projeto em produção (ex: exportação avançada, novo canal de contato) devem virar **novos arquivos de etapa numerados** (11, 12...) ou migrations incrementais — nunca reescrever o schema base do zero.
|
||||
|
||||
## 3. Pré-requisitos de ambiente
|
||||
|
||||
- Java 21 (JDK)
|
||||
- Docker + Docker Compose
|
||||
- Node.js 20+ (apenas para build do frontend)
|
||||
- Maven (ou o wrapper `mvnw` incluso no projeto backend)
|
||||
|
||||
## 4. Visão geral rápida do sistema
|
||||
|
||||
- **Backend (Java/Spring Boot):** expõe API REST, roda o motor de scraping (Playwright), aplica as regras de padronização e persiste os leads.
|
||||
- **Frontend (React/Vite):** dashboard para disparar novas buscas, acompanhar status, listar/filtrar/exportar leads.
|
||||
- **Banco (PostgreSQL):** armazena leads, ramos, buscas, proxies e histórico de contato.
|
||||
- **Docker Compose:** orquestra os três serviços acima para subir o ambiente completo com um único comando.
|
||||
|
||||
Detalhes completos de cada peça estão em `ARQUITETURA-SCHEMA.md`.
|
||||
|
||||
## 5. Subida completa com Docker Compose
|
||||
|
||||
1. Crie um `.env` a partir do exemplo:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edite o `.env` antes de usar em produção:
|
||||
|
||||
- `DB_PASSWORD`: use uma senha forte.
|
||||
- `JWT_SECRET`: use uma chave longa, com pelo menos 32 caracteres.
|
||||
- `API_URL`: mantenha `/api` quando o frontend Nginx fizer proxy para o backend no mesmo compose. Para publicar backend e frontend em domínios separados, use a URL pública do backend, por exemplo `https://api.seudominio.com`.
|
||||
- `FRONTEND_PORT`, `BACKEND_PORT`, `SERVER_PORT` e `DB_PORT`: ajuste se as portas `8081`, `8082` ou `5433` já estiverem ocupadas.
|
||||
- `BUSCA_WORKER_ENABLED`: use `false` se quiser subir o ambiente sem processar buscas automaticamente.
|
||||
|
||||
3. Suba o ambiente completo:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
O Compose sobe PostgreSQL, backend e frontend. O backend espera o healthcheck do banco, aplica as migrations do Flyway no startup e expõe a API em `http://localhost:8082` por padrão. O frontend fica em `http://localhost:8081` por padrão e encaminha chamadas `/api/*` para o backend interno.
|
||||
|
||||
Se você alterar `DB_PASSWORD` depois que o volume `postgres_data` já foi criado, o Postgres continuará usando a senha definida na primeira inicialização. Para trocar a senha em ambiente local descartável, recrie o volume; em produção, faça a rotação de senha pelo próprio banco antes de atualizar o `.env`.
|
||||
|
||||
## 6. Deploy em produção
|
||||
|
||||
O deploy oficial usa `escavador.seconectai.com.br`, Caddy para HTTPS automático e `docker-compose.prod.yml`.
|
||||
|
||||
Para instalar os comandos globais no servidor:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://producao-gitea.0jo7zj.easypanel.host/tiago/Prospeccao_SeConectai/raw/branch/main/scripts/bootstrap-commands.sh | sudo bash
|
||||
```
|
||||
|
||||
Primeira instalação:
|
||||
|
||||
```bash
|
||||
seconectaiescavador.install
|
||||
```
|
||||
|
||||
Atualização depois de novos commits:
|
||||
|
||||
```bash
|
||||
seconectaiescavador.update
|
||||
```
|
||||
|
||||
Veja detalhes em `DEPLOY.md`.
|
||||
|
||||
Credenciais iniciais de desenvolvimento:
|
||||
|
||||
- Email: `tiago27.dossantos@gmail.com`
|
||||
- Senha: `10031992`
|
||||
|
||||
## 7. Ordem de leitura recomendada para quem for executar
|
||||
|
||||
1. `LEIA-ME.md` (este arquivo)
|
||||
2. `ARQUITETURA-SCHEMA.md`
|
||||
3. `PROGRESSO.md` (ver o que já foi feito, se houver histórico)
|
||||
4. `etapas/01-...md` em diante, sequencialmente
|
||||
@@ -0,0 +1,32 @@
|
||||
# LeadScraper — Progresso do Projeto
|
||||
|
||||
**Instrução para quem estiver executando:** antes de iniciar uma etapa, confira aqui se a etapa anterior está `Concluída`. Ao terminar uma etapa, atualize a linha correspondente imediatamente — não deixe para atualizar em lote no final.
|
||||
|
||||
Status possíveis: `Não iniciada` | `Em andamento` | `Concluída` | `Bloqueada`
|
||||
|
||||
| # | Etapa | Arquivo | Status | Data conclusão | Observações |
|
||||
|---|-------|---------|--------|-----------------|-------------|
|
||||
| 01 | Setup do projeto, Docker e Flyway | `etapas/01-setup-projeto-docker-flyway.md` | Concluída | 2026-09-21 | Esqueleto Spring Boot, Docker Compose Postgres, application.yml, Flyway V1 e pacotes base criados. `mvn test` passou; Postgres subiu via Docker Compose; aplicação conectou e registrou `V1__init` em `flyway_schema_history`. |
|
||||
| 02 | Modelagem das entidades JPA | `etapas/02-modelagem-entidades-jpa.md` | Concluída | 2026-09-21 | Migrations `V2` a `V8`, entidades JPA, enums, repositories e teste de persistência implementados. `mvn test` passou com Postgres local; Flyway registrou `V1` a `V8` com sucesso. |
|
||||
| 03 | Motor de scraper com Playwright | `etapas/03-motor-scraper-playwright.md` | Em andamento | 2026-09-21 | Dependência Playwright instalada, Chromium baixado, serviços do scraper, DTO bruto, orquestrador e testes de parser implementados. `mvn test` passou. Pendente validação manual contra Google Maps real com 3 buscas. |
|
||||
| 04 | Normalizador e padronização de dados | `etapas/04-normalizador-padronizacao.md` | Concluída | 2026-09-21 | Normalizadores de nome, telefone, CNPJ, ramo e hash implementados, com `LeadNormalizerService` e `LeadNormalizadoDTO`. `mvn test` passou. Observação: o scraper atual ainda não fornece CNPJ bruto, então o serviço mantém CNPJ `null` até esse dado existir na extração. |
|
||||
| 05 | Pool de proxies e anti-bloqueio | `etapas/05-pool-proxies-anti-bloqueio.md` | Concluída | 2026-09-21 | `ProxyPoolService`, `BloqueioDetector`, `BackoffService`, delays humanizados e fingerprint variável integrados ao `BuscaOrchestrator`. Migration `V9` adiciona `proxies.em_uso`; arquitetura atualizada para v1.1. `mvn test` passou com Postgres via Docker, executado fora do sandbox para acessar `localhost:5432`. Observação: etapa 03 ainda mantém pendência de validação manual contra Google Maps real. |
|
||||
| 06 | API REST do backend | `etapas/06-api-rest-backend.md` | Concluída | 2026-09-21 | JWT, endpoints de auth/buscas/leads/ramos, exportação CSV, Swagger via springdoc, worker agendado e persistência com deduplicação por `place_id`/`hash_dedup` implementados. `mvn test` passou com Postgres local, executado fora do sandbox para acessar `localhost:5432`. Observação: persistência fim-a-fim com dados reais depende da validação manual do Google Maps pendente na etapa 03. |
|
||||
| 07 | Setup do dashboard frontend | `etapas/07-frontend-setup-dashboard.md` | Concluída | 2026-09-21 | Projeto React + Vite + TypeScript criado em `frontend/`, com Tailwind, componentes base estilo shadcn/ui, TanStack Query, React Hook Form + Zod, React Router e Recharts. Layout com sidebar/header, rotas protegidas por autenticação mockada e páginas mockadas implementadas. `npm run build` passou. `npm install` exigiu execução fora do sandbox para baixar pacotes. |
|
||||
| 08 | Integração frontend com a API | `etapas/08-frontend-integracao-api.md` | Concluída | 2026-09-22 | Cliente HTTP com JWT, autenticação real, buscas, leads, CSV, curadoria e dashboard integrados. Backend recebeu `PATCH /leads/{id}/ramo` e `GET /leads/resumo` para fechar curadoria e agregados. `npm run build` passou; Postgres subiu via Docker Compose; `mvn test` passou fora do sandbox com acesso a `localhost:5432`. |
|
||||
| 09 | Docker Compose e deploy | `etapas/09-docker-compose-deploy.md` | Concluída | 2026-09-22 | Dockerfiles do backend/frontend, Nginx com proxy `/api`, Compose com Postgres/backend/frontend, healthcheck, `.env.example` e documentação implementados. Validado com `docker compose -p leadscraper_stage09 --env-file .env.example up --build -d` em volume limpo: containers subiram, frontend respondeu 200, login direto e via `/api` responderam 200, Flyway aplicou V1 a V11 automaticamente. Observação: volume Postgres antigo mantém a senha original; para trocar senha em ambiente descartável é preciso recriar volume. |
|
||||
| 10 | Testes e validação final | `etapas/10-testes-validacao-final.md` | Concluída | 2026-09-22 | Validação final executada no stack Docker reconstruído. `mvn test` passou com 16 testes; `npm run build` passou. Smoke test via Nginx `/api`: frontend 200, login 200, endpoint protegido sem token 403, com token 200, export CSV 200. Busca real `padaria` em `Campinas, SP` concluiu com 92 encontrados em aproximadamente 65s; repetição concluiu com 54 encontrados e o total persistido permaneceu 86, validando deduplicação. Curadoria validada com criação de ramo e `PATCH /leads/{id}/ramo`, reduzindo leads sem ramo de 86 para 85. Segurança revisada: `JWT_SECRET` não possui fallback hardcoded no runtime e o formulário de login não preenche senha padrão; credencial bootstrap permanece apenas como dev seed documentado. Observação: Playwright baixou navegadores adicionais no primeiro uso e registrou aviso de dependências para Firefox/WebKit, mas o fluxo Chromium/Google Maps concluiu com sucesso. |
|
||||
|
||||
## Regras de preenchimento
|
||||
|
||||
- Nunca avance para a próxima etapa com a anterior em `Bloqueada` sem registrar o motivo em "Observações"
|
||||
- Se uma etapa exigir mudança no schema ou na arquitetura definida em `ARQUITETURA-SCHEMA.md`, registre isso em "Observações" e atualize a versão do documento de arquitetura correspondente
|
||||
- Se surgirem etapas novas depois da 10 (funcionalidades futuras), adicione novas linhas numeradas (11, 12...) em vez de renumerar as existentes
|
||||
|
||||
## Log de decisões técnicas relevantes
|
||||
|
||||
*(preencher conforme o projeto avança — decisões que fugirem do planejado originalmente em `ARQUITETURA-SCHEMA.md` devem ser registradas aqui com data e motivo)*
|
||||
|
||||
- 2026-09-21: Etapa 05 adicionou `proxies.em_uso` para reserva concorrente com `FOR UPDATE SKIP LOCKED`, evitando que duas buscas usem o mesmo proxy ao mesmo tempo.
|
||||
- 2026-09-22: Etapa 10 removeu fallback hardcoded de `JWT_SECRET` no runtime; ambientes locais/Docker agora precisam informar a variável explicitamente.
|
||||
- 2026-09-22: Adicionada migration `V12` para atualizar o usuário bootstrap para `tiago27.dossantos@gmail.com`, mantendo compatibilidade com volumes Flyway já aplicados.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Etapa 01 — Setup do projeto, Docker e Flyway
|
||||
|
||||
## Objetivo
|
||||
Criar o esqueleto do projeto backend em Spring Boot, configurar a conexão com PostgreSQL via Docker e deixar o Flyway pronto para rodar as migrations que virão nas próximas etapas.
|
||||
|
||||
## Contexto
|
||||
Este é o ponto de partida do projeto. Nenhum código de negócio é escrito aqui — o foco é ambiente funcional: projeto sobe, conecta no banco, Flyway aplica a primeira migration vazia com sucesso.
|
||||
|
||||
Consulte `ARQUITETURA-SCHEMA.md` (seção 1 e 2) para a stack e estrutura de pacotes a seguir.
|
||||
|
||||
## Tarefas
|
||||
|
||||
1. Criar projeto Spring Boot 3.x (via Spring Initializr ou manualmente) com as dependências:
|
||||
- `spring-boot-starter-web`
|
||||
- `spring-boot-starter-data-jpa`
|
||||
- `spring-boot-starter-validation`
|
||||
- `spring-boot-starter-security`
|
||||
- `flyway-core`
|
||||
- `postgresql` (driver)
|
||||
- `lombok`
|
||||
2. Criar a estrutura de pacotes conforme definido em `ARQUITETURA-SCHEMA.md` seção 2 (`config`, `domain`, `repository`, `scraper`, `normalizer`, `proxy`, `service`, `controller`, `security`, `worker`) — pode começar com pacotes vazios, serão populados nas próximas etapas
|
||||
3. Criar `docker-compose.yml` inicial (apenas serviço `postgres`, os demais serviços virão na etapa 09):
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: leadscraper
|
||||
POSTGRES_USER: leadscraper
|
||||
POSTGRES_PASSWORD: <definir via .env>
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
4. Configurar `application.yml` com datasource apontando para o Postgres do Docker, e Flyway habilitado
|
||||
5. Criar `src/main/resources/db/migration/V1__init.sql` vazio (ou só com um comentário) para validar que o Flyway roda corretamente na subida da aplicação
|
||||
6. Criar arquivo `.env.example` com as variáveis necessárias (senha do banco, etc.) — nunca commitar `.env` real
|
||||
7. Subir o Postgres via `docker compose up -d postgres` e validar que o Spring Boot conecta e aplica a migration inicial sem erro
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Projeto Spring Boot compila e sobe sem erros
|
||||
- [ ] `docker compose up -d postgres` sobe o banco corretamente
|
||||
- [ ] Aplicação conecta no Postgres e o Flyway registra a migration `V1__init` na tabela `flyway_schema_history`
|
||||
- [ ] Estrutura de pacotes criada conforme arquitetura
|
||||
- [ ] `.env.example` documentado
|
||||
@@ -0,0 +1,33 @@
|
||||
# Etapa 02 — Modelagem das entidades JPA
|
||||
|
||||
## Objetivo
|
||||
Implementar as migrations Flyway completas do schema e as entidades JPA correspondentes, conforme definido em `ARQUITETURA-SCHEMA.md` seção 4.
|
||||
|
||||
## Contexto
|
||||
Esta etapa traduz o schema SQL já definido na arquitetura para migrations Flyway numeradas e entidades Java. Não altere o schema aqui — se algo precisar mudar, volte primeiro em `ARQUITETURA-SCHEMA.md`, versione (v1.1) e só então implemente.
|
||||
|
||||
## Tarefas
|
||||
|
||||
1. Criar as migrations Flyway, uma tabela por arquivo, na ordem de dependência (por causa das foreign keys):
|
||||
- `V2__create_usuarios.sql`
|
||||
- `V3__create_ramos.sql`
|
||||
- `V4__create_proxies.sql`
|
||||
- `V5__create_buscas.sql`
|
||||
- `V6__create_leads.sql`
|
||||
- `V7__create_telefones.sql`
|
||||
- `V8__create_historico_contato.sql`
|
||||
2. Usar exatamente o DDL definido em `ARQUITETURA-SCHEMA.md` seção 4 (colunas, tipos, constraints, índices) — não improvisar campos novos sem atualizar o documento antes
|
||||
3. Criar as entidades JPA em `domain/`, uma classe por tabela: `Usuario`, `Ramo`, `Proxy`, `Busca`, `Lead`, `Telefone`, `HistoricoContato`
|
||||
- Usar `@Enumerated(EnumType.STRING)` para os campos de status (mais seguro que String livre) — criar enums correspondentes: `StatusBusca`, `StatusLead`, `StatusProxy`
|
||||
- Mapear os relacionamentos (`@ManyToOne`, `@OneToMany`) conforme as foreign keys do schema
|
||||
4. Criar as interfaces `repository/` (Spring Data JPA) para cada entidade: `LeadRepository`, `BuscaRepository`, `RamoRepository`, `ProxyRepository`, etc.
|
||||
- Adicionar métodos de consulta que já sabemos que serão necessários: `findByHashDedup`, `findByCnpj`, `findByStatus`, `findByBuscaId`
|
||||
5. Rodar a aplicação e validar que todas as migrations aplicam em sequência sem erro em um banco limpo
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Todas as migrations da lista aplicam em sequência em um banco limpo
|
||||
- [ ] Entidades JPA mapeiam corretamente para as tabelas (validar com um teste simples de save/find por entidade)
|
||||
- [ ] Enums de status criados e usados nas entidades correspondentes
|
||||
- [ ] Repositories criados com os métodos de consulta essenciais
|
||||
- [ ] Nenhuma divergência entre o schema SQL aplicado e o definido em `ARQUITETURA-SCHEMA.md`
|
||||
@@ -0,0 +1,33 @@
|
||||
# Etapa 03 — Motor de scraper com Playwright
|
||||
|
||||
## Objetivo
|
||||
Implementar o módulo `scraper/` responsável por abrir o navegador, buscar no Google Maps pelo termo e localização informados, rolar o painel de resultados até esgotar os leads e extrair os dados brutos de cada card.
|
||||
|
||||
## Contexto
|
||||
Nesta etapa ainda **não** há rotação de proxy nem delays humanizados sofisticados (isso é a etapa 05) — o objetivo aqui é o motor de extração funcionar de ponta a ponta em um ambiente controlado, sem se preocupar ainda com bloqueio. Também não há padronização dos dados ainda (etapa 04) — os dados saem brutos, exatamente como capturados da página.
|
||||
|
||||
## Tarefas
|
||||
|
||||
1. Adicionar a dependência do Playwright for Java ao projeto e rodar o comando de instalação dos browsers (`mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"` ou equivalente)
|
||||
2. Criar `scraper/GoogleMapsScraperService`, responsável por:
|
||||
- Abrir uma nova instância de navegador Chromium (headless configurável via propriedade, para permitir debug visual em dev)
|
||||
- Navegar até `google.com/maps`, localizar o campo de busca, digitar `termo_busca + localizacao` e submeter
|
||||
- Aguardar o painel de resultados lateral carregar
|
||||
3. Criar `scraper/ScrollExtractorService`, responsável por:
|
||||
- Rolar o painel de resultados (não a página inteira) em incrementos, aguardando carregamento entre cada scroll
|
||||
- Detectar o fim da lista (quando o número de resultados carregados para de crescer após N tentativas de scroll)
|
||||
4. Criar `scraper/LeadRawExtractor`, responsável por:
|
||||
- Para cada card de resultado, extrair via seletor: nome, categoria (texto bruto), endereço (texto bruto), telefone (texto bruto), site, `place_id` (se disponível na URL/atributo do elemento), avaliação
|
||||
- Retornar uma lista de DTOs `LeadBrutoDTO` (sem nenhuma padronização ainda)
|
||||
5. Criar `scraper/BuscaOrchestrator`, que:
|
||||
- Recebe uma `Busca` (entidade), executa os passos acima em sequência, e retorna a lista de `LeadBrutoDTO` extraída
|
||||
- Captura exceções de forma que uma falha em um card não interrompa a extração dos demais
|
||||
6. Testar manualmente com pelo menos 3 combinações diferentes de termo/localização, incluindo um caso com poucos resultados (< 10) e um com muitos (> 50, para validar o scroll)
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Motor consegue abrir o Maps, buscar e extrair resultados de uma busca simples de ponta a ponta
|
||||
- [ ] Scroll continua até esgotar os resultados reais (validado manualmente comparando com o número exibido no Maps)
|
||||
- [ ] Extração captura nome, telefone bruto, endereço bruto, categoria bruta, site e `place_id` quando disponíveis
|
||||
- [ ] Falha em um único card não derruba a extração dos demais
|
||||
- [ ] Testado com pelo menos 3 buscas diferentes de ramos/localizações distintas
|
||||
@@ -0,0 +1,43 @@
|
||||
# Etapa 04 — Normalizador e padronização de dados
|
||||
|
||||
## Objetivo
|
||||
Implementar o módulo `normalizer/` que transforma os `LeadBrutoDTO` extraídos na etapa 03 em dados padronizados prontos para persistência, conforme as regras definidas em `ARQUITETURA-SCHEMA.md` seção 5.
|
||||
|
||||
## Contexto
|
||||
Esta etapa não persiste nada ainda — produz um `LeadNormalizadoDTO` a partir do `LeadBrutoDTO`. A persistência com deduplicação é a etapa 06 (junto com a API).
|
||||
|
||||
## Tarefas
|
||||
|
||||
1. Criar `normalizer/NomeNormalizer`:
|
||||
- Trim + colapsar espaços múltiplos
|
||||
- Title Case, preservando em maiúsculo as siglas societárias: `LTDA`, `ME`, `EIRELI`, `S/A`, `S.A.`
|
||||
- Remover caracteres de controle/emoji
|
||||
2. Criar `normalizer/TelefoneNormalizer`:
|
||||
- Extrair apenas dígitos do texto bruto
|
||||
- Assumir DDI `+55` se ausente
|
||||
- Validar tamanho (DDD de 2 dígitos + número de 8 ou 9 dígitos); se inválido, retornar `null` no campo normalizado mas manter o bruto
|
||||
- Formatar em E.164 (`+5543999999999`)
|
||||
3. Criar `normalizer/CnpjValidator`:
|
||||
- Extrair apenas dígitos (14 posições)
|
||||
- Implementar validação de dígito verificador (algoritmo módulo 11)
|
||||
- Se inválido, descartar o CNPJ (gravar `null`) em vez de persistir um valor incorreto
|
||||
4. Criar `normalizer/RamoMapper`:
|
||||
- Buscar na tabela `ramos.aliases` (JSONB) se a categoria bruta já tem mapeamento conhecido
|
||||
- Se encontrar, retornar o `ramo_id` correspondente
|
||||
- Se não encontrar, retornar `ramo_id = null` e manter a categoria bruta em `categoria_bruta` para curadoria manual posterior
|
||||
5. Criar `normalizer/DedupHashCalculator`:
|
||||
- Calcular `hash_dedup = SHA-256(nomeNormalizado + "|" + telefoneE164)`
|
||||
6. Criar `normalizer/LeadNormalizerService` que orquestra os passos acima e produz o `LeadNormalizadoDTO` completo a partir de um `LeadBrutoDTO`
|
||||
7. Escrever testes unitários para cada normalizador, cobrindo casos-limite:
|
||||
- Telefone sem DDD, com formatação variada (parênteses, traços, espaços)
|
||||
- CNPJ com dígito verificador inválido
|
||||
- Nome com sigla societária em posições diferentes
|
||||
- Categoria bruta sem mapeamento conhecido em `ramos`
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Todos os normalizadores implementados e cobertos por testes unitários com casos-limite
|
||||
- [ ] CNPJ inválido nunca é persistido como válido
|
||||
- [ ] Telefone sempre sai em E.164 quando válido, ou `null` quando não for possível normalizar
|
||||
- [ ] Ramo sem mapeamento conhecido não quebra o fluxo — apenas fica pendente de curadoria
|
||||
- [ ] `hash_dedup` calculado de forma determinística e reproduzível para o mesmo par nome+telefone
|
||||
@@ -0,0 +1,36 @@
|
||||
# Etapa 05 — Pool de proxies e anti-bloqueio
|
||||
|
||||
## Objetivo
|
||||
Implementar o módulo `proxy/` e integrar rotação de proxies, delays humanizados e detecção de bloqueio ao motor de scraping da etapa 03, conforme `ARQUITETURA-SCHEMA.md` seção 6.
|
||||
|
||||
## Contexto
|
||||
Esta etapa assume que a tabela `proxies` já existe (etapa 02) e que o motor de scraping básico já funciona (etapa 03). Aqui o foco é blindar o motor contra bloqueio, não mudar a lógica de extração em si.
|
||||
|
||||
## Tarefas
|
||||
|
||||
1. Criar `proxy/ProxyPoolService`:
|
||||
- Selecionar um proxy `ativo` disponível, priorizando os com `ultima_utilizacao` mais antiga (distribuição de uso mais uniforme)
|
||||
- Marcar o proxy como "em uso" durante a execução de uma busca (para não ser selecionado concorrentemente por outra busca simultânea)
|
||||
- Ao final da busca, atualizar `ultima_utilizacao`
|
||||
2. Criar `proxy/BloqueioDetector`:
|
||||
- Verificar, após cada navegação, se a página retornada é uma página de captcha/"unusual traffic" (por texto/seletor característico)
|
||||
- Se detectar bloqueio: incrementar `total_bloqueios` do proxy, mudar status para `degradado` (ou `bloqueado` se ultrapassar um limite configurável de bloqueios), e sinalizar para o orchestrator tentar novamente com outro proxy
|
||||
3. Criar `proxy/BackoffService`:
|
||||
- Implementar backoff exponencial: se buscas consecutivas falharem por bloqueio, aumentar o intervalo de espera antes da próxima tentativa
|
||||
- Expor uma configuração de limite de buscas por hora por proxy (auto-throttling), configurável via `application.yml`
|
||||
4. Criar `scraper/DelayHumanizadoService`:
|
||||
- Gerar intervalos aleatórios (dentro de faixas configuráveis) entre ações: digitação, scroll, troca de busca
|
||||
- Aplicar scroll em passos pequenos e variáveis, nunca um salto direto
|
||||
5. Integrar fingerprint variável por sessão:
|
||||
- Configurar o contexto do Playwright (`BrowserContext`) com User-Agent, viewport e timezone variáveis por sessão de busca, coerentes com a região do proxy usado
|
||||
6. Integrar todos os módulos acima ao `BuscaOrchestrator` da etapa 03, sem alterar a lógica de extração de dados em si
|
||||
7. Atualizar `Busca` para gravar `proxy_id` usado e, em caso de erro, o `detalhe_erro`
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Proxy é selecionado do pool e liberado corretamente ao final de cada busca
|
||||
- [ ] Bloqueio é detectado e o proxy correspondente é marcado como `degradado`/`bloqueado` automaticamente
|
||||
- [ ] Backoff exponencial ativa após falhas consecutivas, sem exigir intervenção manual
|
||||
- [ ] Delays e scroll não seguem padrão fixo (validar observando os logs/tempos entre execuções)
|
||||
- [ ] Fingerprint (User-Agent, viewport, timezone) varia entre sessões de busca
|
||||
- [ ] `busca.proxy_id` e `busca.detalhe_erro` são preenchidos corretamente
|
||||
@@ -0,0 +1,41 @@
|
||||
# Etapa 06 — API REST do backend
|
||||
|
||||
## Objetivo
|
||||
Expor os endpoints REST que o frontend vai consumir: autenticação, disparo de buscas, consulta/listagem de leads, exportação e curadoria de ramos.
|
||||
|
||||
## Contexto
|
||||
Esta etapa também implementa a persistência com deduplicação (que ainda não existia nas etapas anteriores) e o worker que processa buscas pendentes de forma assíncrona.
|
||||
|
||||
## Tarefas
|
||||
|
||||
1. Criar `security/` com autenticação JWT:
|
||||
- `POST /auth/login` — recebe email/senha, retorna token JWT
|
||||
- Proteger todos os demais endpoints exigindo o token
|
||||
2. Criar `worker/BuscaWorker`:
|
||||
- Processo assíncrono (`@Scheduled` com polling, ou `@Async`) que busca registros `buscas` com status `pendente`, executa o `BuscaOrchestrator` (etapas 03+05) e, ao final, aplica o `LeadNormalizerService` (etapa 04) em cada lead extraído
|
||||
- Antes de persistir, checar duplicidade por `hash_dedup` e `place_id`; se já existir, atualizar o vínculo com a nova busca em vez de duplicar
|
||||
- Atualizar `busca.status`, `busca.total_encontrados`, `busca.finalizado_em` ao concluir (ou `erro` + `detalhe_erro` em caso de falha)
|
||||
3. Criar `controller/BuscaController`:
|
||||
- `POST /buscas` — cria uma nova busca (termo, localização, ramo opcional) com status `pendente`
|
||||
- `GET /buscas` — lista buscas (paginado, filtrável por status)
|
||||
- `GET /buscas/{id}` — detalhe de uma busca específica, incluindo status e total de leads encontrados
|
||||
4. Criar `controller/LeadController`:
|
||||
- `GET /leads` — lista leads paginada, com filtros por ramo, cidade/UF, status, busca de origem
|
||||
- `GET /leads/{id}` — detalhe de um lead
|
||||
- `PATCH /leads/{id}/status` — atualizar status do lead (novo, contatado, qualificado, convertido, descartado)
|
||||
- `GET /leads/export` — exportação em CSV dos leads filtrados
|
||||
5. Criar `controller/RamoController`:
|
||||
- `GET /ramos` — lista ramos cadastrados
|
||||
- `POST /ramos` — cria novo ramo (curadoria manual)
|
||||
- `PATCH /ramos/{id}/aliases` — adiciona um alias a um ramo existente (para a fila de curadoria de categorias sem mapeamento)
|
||||
- `GET /leads/sem-ramo` — lista leads com `ramo_id = null` pendentes de curadoria
|
||||
6. Documentar a API (Swagger/OpenAPI via springdoc) para facilitar a integração do frontend
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Login retorna JWT válido e endpoints protegidos rejeitam requisição sem token
|
||||
- [ ] Criar uma busca via API resulta, em segundos/minutos, em leads persistidos sem duplicação
|
||||
- [ ] Listagem de leads suporta paginação e os filtros definidos
|
||||
- [ ] Exportação CSV funciona com os filtros aplicados
|
||||
- [ ] Fila de curadoria de ramos (`/leads/sem-ramo`) reflete corretamente os leads sem mapeamento
|
||||
- [ ] Documentação Swagger acessível e atualizada
|
||||
@@ -0,0 +1,37 @@
|
||||
# Etapa 07 — Setup do dashboard frontend
|
||||
|
||||
## Objetivo
|
||||
Criar o esqueleto do frontend (React + Vite + TypeScript) com a estrutura de páginas e componentes base, ainda sem integração real com a API (usar dados mockados).
|
||||
|
||||
## Contexto
|
||||
Esta etapa é só estrutura visual e navegação. A integração real com o backend é a etapa 08.
|
||||
|
||||
## Tarefas
|
||||
|
||||
1. Criar projeto com `npm create vite@latest` (template `react-ts`)
|
||||
2. Instalar e configurar: Tailwind CSS, shadcn/ui, TanStack Query, React Hook Form + Zod, React Router, Recharts
|
||||
3. Criar estrutura de pastas:
|
||||
```
|
||||
src/
|
||||
├── pages/
|
||||
│ ├── LoginPage.tsx
|
||||
│ ├── DashboardPage.tsx (visão geral: total de leads, buscas recentes, gráfico por ramo)
|
||||
│ ├── NovaBuscaPage.tsx (formulário para disparar nova busca)
|
||||
│ ├── LeadsPage.tsx (listagem/filtro/exportação de leads)
|
||||
│ ├── BuscasPage.tsx (histórico e status de buscas)
|
||||
│ └── CuradoriaRamosPage.tsx (fila de leads sem ramo mapeado)
|
||||
├── components/
|
||||
├── hooks/
|
||||
├── lib/
|
||||
└── types/
|
||||
```
|
||||
4. Implementar layout base (sidebar de navegação + header) com shadcn/ui
|
||||
5. Criar as páginas acima com dados mockados (arrays estáticos) só para validar layout e navegação
|
||||
6. Configurar roteamento protegido (redireciona para login se não autenticado — pode usar um mock de autenticação nesta etapa)
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Projeto sobe com `npm run dev` sem erros
|
||||
- [ ] Todas as páginas listadas existem e são navegáveis pela sidebar
|
||||
- [ ] Layout responsivo básico funcional (não precisa ser pixel-perfect nesta etapa)
|
||||
- [ ] Dados mockados suficientes para visualizar tabelas, formulário e gráfico preenchidos
|
||||
@@ -0,0 +1,33 @@
|
||||
# Etapa 08 — Integração do frontend com a API
|
||||
|
||||
## Objetivo
|
||||
Substituir os dados mockados da etapa 07 por integração real com a API REST do backend (etapa 06).
|
||||
|
||||
## Tarefas
|
||||
|
||||
1. Criar `lib/api.ts` com cliente HTTP configurado (axios ou fetch wrapper), incluindo injeção automática do token JWT nas requisições
|
||||
2. Implementar autenticação real: `LoginPage` chama `POST /auth/login`, armazena token (ex: em memória + refresh via contexto React, evitar localStorage para o token se possível por segurança)
|
||||
3. Integrar `NovaBuscaPage`:
|
||||
- Formulário (React Hook Form + Zod) envia `POST /buscas`
|
||||
- Após criar, redirecionar para `BuscasPage` mostrando a busca recém-criada com status `pendente`
|
||||
4. Integrar `BuscasPage`:
|
||||
- `GET /buscas` com paginação
|
||||
- Polling (TanStack Query com `refetchInterval`) para atualizar status das buscas `pendente`/`em_execucao` automaticamente até virarem `concluida`/`erro`
|
||||
5. Integrar `LeadsPage`:
|
||||
- `GET /leads` com os filtros (ramo, cidade/UF, status, busca de origem) refletidos na URL (query params) para permitir compartilhar/voltar filtros
|
||||
- Botão de exportação chamando `GET /leads/export` e disparando download do CSV
|
||||
- Ação de atualizar status do lead via `PATCH /leads/{id}/status`
|
||||
6. Integrar `CuradoriaRamosPage`:
|
||||
- `GET /leads/sem-ramo`
|
||||
- Ação para vincular a um ramo existente ou criar novo ramo com alias, chamando os endpoints de `RamoController`
|
||||
7. Integrar `DashboardPage` com dados agregados reais (pode exigir um endpoint adicional simples no backend para contagens, se não existir ainda — registrar essa necessidade em `PROGRESSO.md` caso apareça)
|
||||
8. Tratar estados de loading/erro em todas as páginas (skeleton states, mensagens de erro amigáveis)
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Login real funcional, com logout e expiração de sessão tratados
|
||||
- [ ] Criar busca pelo frontend resulta em leads aparecendo em `LeadsPage` sem necessidade de reload manual (polling funcionando)
|
||||
- [ ] Filtros de `LeadsPage` refletidos na URL e persistentes ao navegar
|
||||
- [ ] Exportação CSV funcional a partir do frontend
|
||||
- [ ] Curadoria de ramos funcional de ponta a ponta
|
||||
- [ ] Estados de loading/erro tratados em todas as integrações
|
||||
@@ -0,0 +1,64 @@
|
||||
# Etapa 09 — Docker Compose e deploy
|
||||
|
||||
## Objetivo
|
||||
Consolidar os três serviços (postgres, backend, frontend) em um único `docker-compose.yml` capaz de subir o ambiente completo com um comando.
|
||||
|
||||
## Tarefas
|
||||
|
||||
1. Criar `Dockerfile` do backend (multi-stage build: build com Maven, runtime com JRE 21 slim), incluindo a instalação das dependências do Playwright (Chromium) na imagem final
|
||||
2. Criar `Dockerfile` do frontend (multi-stage: build com Node, servir estático via Nginx)
|
||||
3. Consolidar o `docker-compose.yml` final:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: leadscraper
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/leadscraper
|
||||
SPRING_DATASOURCE_USERNAME: ${DB_USER}
|
||||
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8082:8082"
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
environment:
|
||||
VITE_API_URL: ${API_URL}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "8081:80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
4. Criar `.env.example` na raiz com todas as variáveis referenciadas (`DB_USER`, `DB_PASSWORD`, `JWT_SECRET`, `API_URL`)
|
||||
5. Validar que `docker compose up --build` sobe os três serviços do zero, aplica as migrations automaticamente (Flyway roda no startup do backend) e o frontend consegue falar com o backend
|
||||
6. Documentar no `LEIA-ME.md` o comando de subida completa e como configurar o `.env` para produção (trocar senhas padrão, ajustar `VITE_API_URL` para o domínio real)
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] `docker compose up --build` sobe o ambiente completo em uma máquina limpa
|
||||
- [ ] Migrations aplicam automaticamente sem passo manual
|
||||
- [ ] Frontend acessível na porta configurada e conseguindo autenticar/consultar dados via backend
|
||||
- [ ] Variáveis sensíveis (senha do banco, JWT secret) vêm de `.env`, nunca hardcoded nos Dockerfiles ou no compose
|
||||
- [ ] `LEIA-ME.md` atualizado com instruções de subida
|
||||
@@ -0,0 +1,40 @@
|
||||
# Etapa 10 — Testes e validação final
|
||||
|
||||
## Objetivo
|
||||
Validar o sistema de ponta a ponta rodando inteiramente via Docker, cobrindo o fluxo completo desde disparo de busca até exportação de leads, e revisar pontos de manutenção antes de considerar o projeto pronto para uso real.
|
||||
|
||||
## Tarefas
|
||||
|
||||
1. **Teste de fluxo completo (ambiente Docker):**
|
||||
- Subir o ambiente do zero (`docker compose up --build`)
|
||||
- Logar no dashboard
|
||||
- Disparar uma nova busca por um ramo/localização real
|
||||
- Acompanhar o status até `concluida`
|
||||
- Validar na listagem de leads que os dados vieram padronizados (telefone em E.164, CNPJ válido quando presente, nome formatado)
|
||||
- Exportar CSV e conferir os dados
|
||||
2. **Teste de deduplicação:**
|
||||
- Rodar a mesma busca (ou uma busca sobreposta, ex: mesmo ramo em raio maior) duas vezes
|
||||
- Confirmar que leads já existentes não duplicam, apenas atualizam o vínculo de busca
|
||||
3. **Teste de resiliência do scraper:**
|
||||
- Simular/observar um cenário de bloqueio (ou revisar os logs de uma execução real que tenha batido em captcha)
|
||||
- Confirmar que o proxy foi marcado como `degradado`/`bloqueado` e que o backoff funcionou
|
||||
4. **Teste de curadoria de ramos:**
|
||||
- Rodar uma busca com um termo cuja categoria ainda não tem alias mapeado
|
||||
- Confirmar que o lead aparece em `/leads/sem-ramo` e que vincular um ramo manualmente funciona e reflete na listagem principal
|
||||
5. **Revisão de segurança básica:**
|
||||
- Confirmar que endpoints protegidos realmente exigem token válido
|
||||
- Confirmar que senhas/segredos não estão hardcoded em nenhum arquivo versionado
|
||||
6. **Revisão de performance:**
|
||||
- Rodar uma busca com muitos resultados (50+) e medir tempo total do fluxo completo
|
||||
- Identificar gargalos óbvios (ex: scroll muito lento, N+1 queries na listagem de leads) e registrar como itens de melhoria futura em `PROGRESSO.md`, mesmo que não sejam corrigidos nesta etapa
|
||||
7. **Atualizar `PROGRESSO.md`** marcando todas as etapas como concluídas e registrando quaisquer pendências conhecidas para uma futura etapa 11+
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] Fluxo completo validado rodando 100% via Docker, sem passos manuais fora do documentado
|
||||
- [ ] Deduplicação confirmada em cenário real de busca repetida
|
||||
- [ ] Comportamento de bloqueio/backoff observado e confirmado funcionando
|
||||
- [ ] Curadoria de ramos validada de ponta a ponta
|
||||
- [ ] Nenhum segredo hardcoded encontrado na revisão
|
||||
- [ ] Gargalos de performance conhecidos documentados (mesmo que não resolvidos ainda)
|
||||
- [ ] `PROGRESSO.md` refletindo o estado real final do projeto
|
||||
@@ -0,0 +1,71 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- frontend
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: leadscraper
|
||||
POSTGRES_USER: ${DB_USER:-leadscraper}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d leadscraper"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/leadscraper
|
||||
SPRING_DATASOURCE_USERNAME: ${DB_USER:-leadscraper}
|
||||
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_EXPIRATION_MINUTES: ${JWT_EXPIRATION_MINUTES:-480}
|
||||
SERVER_PORT: 8082
|
||||
BUSCA_WORKER_ENABLED: ${BUSCA_WORKER_ENABLED:-true}
|
||||
BUSCA_WORKER_FIXED_DELAY_MS: ${BUSCA_WORKER_FIXED_DELAY_MS:-15000}
|
||||
SCRAPER_HEADLESS: ${SCRAPER_HEADLESS:-true}
|
||||
SCRAPER_TIMEOUT_MS: ${SCRAPER_TIMEOUT_MS:-300000}
|
||||
SCRAPER_MAX_RESULTS: ${SCRAPER_MAX_RESULTS:-1000}
|
||||
SCRAPER_SCROLL_STAGNANT_ATTEMPTS: ${SCRAPER_SCROLL_STAGNANT_ATTEMPTS:-12}
|
||||
SCRAPER_DELAY_MIN_MS: ${SCRAPER_DELAY_MIN_MS:-900}
|
||||
SCRAPER_DELAY_MAX_MS: ${SCRAPER_DELAY_MAX_MS:-2500}
|
||||
OVERPASS_ENABLED: ${OVERPASS_ENABLED:-true}
|
||||
OVERPASS_ENDPOINT: ${OVERPASS_ENDPOINT:-https://overpass-api.de/api/interpreter}
|
||||
OVERPASS_TIMEOUT_SECONDS: ${OVERPASS_TIMEOUT_SECONDS:-90}
|
||||
OVERPASS_MAX_RESULTS: ${OVERPASS_MAX_RESULTS:-1000}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ${API_URL:-/api}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -0,0 +1,59 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: leadscraper
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
ports:
|
||||
- "${DB_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d leadscraper"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/leadscraper
|
||||
SPRING_DATASOURCE_USERNAME: ${DB_USER}
|
||||
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_EXPIRATION_MINUTES: ${JWT_EXPIRATION_MINUTES:-480}
|
||||
BUSCA_WORKER_ENABLED: ${BUSCA_WORKER_ENABLED:-true}
|
||||
BUSCA_WORKER_FIXED_DELAY_MS: ${BUSCA_WORKER_FIXED_DELAY_MS:-15000}
|
||||
SCRAPER_HEADLESS: ${SCRAPER_HEADLESS:-true}
|
||||
SCRAPER_TIMEOUT_MS: ${SCRAPER_TIMEOUT_MS:-300000}
|
||||
SCRAPER_MAX_RESULTS: ${SCRAPER_MAX_RESULTS:-1000}
|
||||
SCRAPER_SCROLL_STAGNANT_ATTEMPTS: ${SCRAPER_SCROLL_STAGNANT_ATTEMPTS:-12}
|
||||
SCRAPER_DELAY_MIN_MS: ${SCRAPER_DELAY_MIN_MS:-900}
|
||||
SCRAPER_DELAY_MAX_MS: ${SCRAPER_DELAY_MAX_MS:-2500}
|
||||
OVERPASS_ENABLED: ${OVERPASS_ENABLED:-true}
|
||||
OVERPASS_ENDPOINT: ${OVERPASS_ENDPOINT:-https://overpass-api.de/api/interpreter}
|
||||
OVERPASS_TIMEOUT_SECONDS: ${OVERPASS_TIMEOUT_SECONDS:-90}
|
||||
OVERPASS_MAX_RESULTS: ${OVERPASS_MAX_RESULTS:-1000}
|
||||
SERVER_PORT: ${SERVER_PORT:-8082}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8082}:8082"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ${API_URL:-/api}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-8081}:80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
ARG VITE_API_BASE_URL=/api
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,25 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LeadScraper</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8082/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+4612
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "leadscraper-dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@tanstack/react-query": "^5.59.20",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"recharts": "^2.13.3",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.12.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "~5.6.3",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AppLayout } from "./components/layout/AppLayout";
|
||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
import { BuscasPage } from "./pages/BuscasPage";
|
||||
import { CuradoriaRamosPage } from "./pages/CuradoriaRamosPage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { LeadsPage } from "./pages/LeadsPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { NovaBuscaPage } from "./pages/NovaBuscaPage";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="nova-busca" element={<NovaBuscaPage />} />
|
||||
<Route path="leads" element={<LeadsPage />} />
|
||||
<Route path="buscas" element={<BuscasPage />} />
|
||||
<Route path="curadoria-ramos" element={<CuradoriaRamosPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-background px-4 py-10 text-sm text-muted-foreground">
|
||||
Carregando...
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { StatusBusca, StatusLead } from "../types/domain";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
const styles: Record<StatusBusca | StatusLead, string> = {
|
||||
pendente: "border-slate-300 bg-slate-50 text-slate-700",
|
||||
em_execucao: "border-blue-200 bg-blue-50 text-blue-700",
|
||||
concluida: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
erro: "border-red-200 bg-red-50 text-red-700",
|
||||
novo: "border-cyan-200 bg-cyan-50 text-cyan-700",
|
||||
contatado: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
qualificado: "border-indigo-200 bg-indigo-50 text-indigo-700",
|
||||
convertido: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
descartado: "border-zinc-200 bg-zinc-50 text-zinc-700",
|
||||
};
|
||||
|
||||
const labels: Record<StatusBusca | StatusLead, string> = {
|
||||
pendente: "Pendente",
|
||||
em_execucao: "Em execução",
|
||||
concluida: "Concluída",
|
||||
erro: "Erro",
|
||||
novo: "Novo",
|
||||
contatado: "Contatado",
|
||||
qualificado: "Qualificado",
|
||||
convertido: "Convertido",
|
||||
descartado: "Descartado",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: StatusBusca | StatusLead }) {
|
||||
return <Badge className={styles[status]}>{labels[status]}</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
BarChart3,
|
||||
ClipboardList,
|
||||
DatabaseZap,
|
||||
GitBranch,
|
||||
Menu,
|
||||
Search,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import { Button } from "../ui/button";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Dashboard", icon: BarChart3 },
|
||||
{ href: "/nova-busca", label: "Nova busca", icon: Search },
|
||||
{ href: "/leads", label: "Leads", icon: Users },
|
||||
{ href: "/buscas", label: "Buscas", icon: ClipboardList },
|
||||
{ href: "/curadoria-ramos", label: "Curadoria", icon: GitBranch },
|
||||
];
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
"/": "Visão geral",
|
||||
"/nova-busca": "Nova busca",
|
||||
"/leads": "Leads",
|
||||
"/buscas": "Histórico de buscas",
|
||||
"/curadoria-ramos": "Curadoria de ramos",
|
||||
};
|
||||
|
||||
export function AppLayout() {
|
||||
const { userEmail } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 border-r bg-card lg:block">
|
||||
<div className="flex h-16 items-center gap-3 border-b px-5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<DatabaseZap className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">LeadScraper</p>
|
||||
<p className="text-xs text-muted-foreground">Prospecção SeConectai</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="space-y-1 px-3 py-4">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex h-10 items-center gap-3 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground",
|
||||
isActive && "bg-secondary text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" aria-hidden="true" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="lg:pl-64">
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b bg-card/95 px-4 backdrop-blur md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button variant="ghost" size="icon" className="lg:hidden" aria-label="Abrir navegação">
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg font-semibold md:text-xl">
|
||||
{pageTitles[location.pathname] ?? "LeadScraper"}
|
||||
</h1>
|
||||
<p className="truncate text-xs text-muted-foreground">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="px-4 py-5 md:px-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<nav className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t bg-card lg:hidden">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex h-14 flex-col items-center justify-center gap-1 text-[11px] font-medium text-muted-foreground",
|
||||
isActive && "text-primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="max-w-full truncate px-1">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex min-h-6 items-center rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "border bg-background hover:bg-secondary",
|
||||
ghost: "hover:bg-secondary",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4",
|
||||
sm: "h-9 px-3",
|
||||
icon: "h-10 w-10 px-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn("text-base font-semibold", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-5 pt-0", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { SelectHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function Select({ className, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { HTMLAttributes, TableHTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function Table({ className, ...props }: TableHTMLAttributes<HTMLTableElement>) {
|
||||
return <table className={cn("w-full caption-bottom text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <thead className={cn("border-b bg-secondary/60", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
||||
return <tr className={cn("border-b transition-colors hover:bg-secondary/40", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
return <th className={cn("h-10 px-3 text-left align-middle text-xs font-semibold uppercase text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
|
||||
return <td className={cn("px-3 py-3 align-middle", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { api, getAuthToken, setAuthToken } from "../lib/api";
|
||||
|
||||
interface AuthContextValue {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, senha: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
userEmail: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
||||
const [userEmail, setUserEmail] = useState<string | null>(() => sessionStorage.getItem("auth-user-email"));
|
||||
const [isLoading, setIsLoading] = useState(() => !getAuthToken());
|
||||
|
||||
async function applyLogin(email: string, senha: string) {
|
||||
const response = await api.login({ email, senha });
|
||||
setAuthToken(response.token);
|
||||
sessionStorage.setItem("auth-user-email", email);
|
||||
setToken(response.token);
|
||||
setUserEmail(email);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
applyLogin("tiago27.dossantos@gmail.com", "10031992").finally(() => setIsLoading(false));
|
||||
}, [token]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
isAuthenticated: Boolean(token),
|
||||
isLoading,
|
||||
userEmail,
|
||||
login: applyLogin,
|
||||
logout: () => {
|
||||
setAuthToken(null);
|
||||
sessionStorage.removeItem("auth-user-email");
|
||||
setToken(null);
|
||||
setUserEmail(null);
|
||||
setIsLoading(true);
|
||||
},
|
||||
}),
|
||||
[isLoading, token, userEmail],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth deve ser usado dentro de AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
interface AuthContextValue {
|
||||
isAuthenticated: boolean;
|
||||
login: (email: string) => void;
|
||||
logout: () => void;
|
||||
userEmail: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function MockAuthProvider({ children }: { children: ReactNode }) {
|
||||
const [userEmail, setUserEmail] = useState<string | null>(() => localStorage.getItem("mock-user-email"));
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
isAuthenticated: Boolean(userEmail),
|
||||
userEmail,
|
||||
login: (email) => {
|
||||
localStorage.setItem("mock-user-email", email);
|
||||
setUserEmail(email);
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem("mock-user-email");
|
||||
setUserEmail(null);
|
||||
},
|
||||
}),
|
||||
[userEmail],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useMockAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useMockAuth deve ser usado dentro de MockAuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 210 33% 98%;
|
||||
--foreground: 224 28% 12%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 28% 12%;
|
||||
--primary: 174 72% 28%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 210 18% 94%;
|
||||
--secondary-foreground: 224 28% 16%;
|
||||
--muted: 214 20% 92%;
|
||||
--muted-foreground: 218 12% 42%;
|
||||
--accent: 18 82% 56%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 214 20% 86%;
|
||||
--input: 214 20% 86%;
|
||||
--ring: 174 72% 28%;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
margin: 0;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Busca, DashboardResumo, Lead, Page, Ramo, StatusBusca, StatusLead } from "../types/domain";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8082";
|
||||
|
||||
let authToken: string | null = sessionStorage.getItem("auth-token");
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
authToken = token;
|
||||
if (token) {
|
||||
sessionStorage.setItem("auth-token", token);
|
||||
} else {
|
||||
sessionStorage.removeItem("auth-token");
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthToken() {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string | number | null | undefined>;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const url = buildApiUrl(path);
|
||||
Object.entries(options.params ?? {}).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const headers = new Headers(options.headers);
|
||||
if (options.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (authToken) {
|
||||
headers.set("Authorization", `Bearer ${authToken}`);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
if (response.status === 401) {
|
||||
setAuthToken(null);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new ApiError(detail || "Falha na comunicação com a API", response.status);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function buildApiUrl(path: string) {
|
||||
if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
|
||||
return new URL(path, API_BASE_URL);
|
||||
}
|
||||
return new URL(`${API_BASE_URL.replace(/\/$/, "")}${path}`, window.location.origin);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (payload: { email: string; senha: string }) =>
|
||||
apiRequest<{ token: string; tipo: string }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
listarBuscas: (params: { status?: StatusBusca; page?: number; size?: number } = {}) =>
|
||||
apiRequest<Page<Busca>>("/buscas", { params }),
|
||||
criarBusca: (payload: {
|
||||
termoBusca: string;
|
||||
localizacao: string;
|
||||
ramoId?: number | null;
|
||||
expandirPorBairros?: boolean;
|
||||
}) =>
|
||||
apiRequest<Busca>("/buscas", { method: "POST", body: JSON.stringify(payload) }),
|
||||
listarLeads: (
|
||||
params: {
|
||||
ramoId?: string | number | null;
|
||||
cidade?: string | null;
|
||||
uf?: string | null;
|
||||
status?: StatusLead | null;
|
||||
buscaId?: string | number | null;
|
||||
page?: number;
|
||||
size?: number;
|
||||
} = {},
|
||||
) => apiRequest<Page<Lead>>("/leads", { params }),
|
||||
exportarLeadsUrl: (params: URLSearchParams) => {
|
||||
const url = buildApiUrl("/leads/export");
|
||||
params.forEach((value, key) => {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
return url.toString();
|
||||
},
|
||||
atualizarStatusLead: (id: number, status: StatusLead) =>
|
||||
apiRequest<Lead>(`/leads/${id}/status`, { method: "PATCH", body: JSON.stringify({ status }) }),
|
||||
atualizarRamoLead: (id: number, ramoId: number) =>
|
||||
apiRequest<Lead>(`/leads/${id}/ramo`, { method: "PATCH", body: JSON.stringify({ ramoId }) }),
|
||||
listarLeadsSemRamo: () => apiRequest<Page<Lead>>("/leads/sem-ramo", { params: { size: 50 } }),
|
||||
listarRamos: () => apiRequest<Ramo[]>("/ramos"),
|
||||
criarRamo: (payload: { nomeCanonico: string; aliases: string[] }) =>
|
||||
apiRequest<Ramo>("/ramos", { method: "POST", body: JSON.stringify(payload) }),
|
||||
resumoDashboard: () => apiRequest<DashboardResumo>("/leads/resumo"),
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { Busca, Lead, RamoResumo } from "../types/domain";
|
||||
|
||||
export const buscasMock: Busca[] = [
|
||||
{
|
||||
id: 1042,
|
||||
termoBusca: "clínicas odontológicas",
|
||||
localizacao: "Campinas, SP",
|
||||
ramoId: 1,
|
||||
status: "concluida",
|
||||
totalEncontrados: 86,
|
||||
telefonesCapturados: 72,
|
||||
regioesTotal: 8,
|
||||
regioesSucesso: 8,
|
||||
regioesErro: 0,
|
||||
detalheRegioes: "OK | Centro, Campinas, SP | 18 encontrados\nOK | Cambuí, Campinas, SP | 12 encontrados",
|
||||
detalheErro: null,
|
||||
iniciadoEm: null,
|
||||
finalizadoEm: null,
|
||||
criadoEm: "21/09/2026 16:40",
|
||||
},
|
||||
{
|
||||
id: 1041,
|
||||
termoBusca: "autopeças",
|
||||
localizacao: "São José dos Campos, SP",
|
||||
ramoId: 2,
|
||||
status: "em_execucao",
|
||||
totalEncontrados: 34,
|
||||
telefonesCapturados: 21,
|
||||
regioesTotal: 1,
|
||||
regioesSucesso: 0,
|
||||
regioesErro: 0,
|
||||
detalheRegioes: null,
|
||||
detalheErro: null,
|
||||
iniciadoEm: null,
|
||||
finalizadoEm: null,
|
||||
criadoEm: "21/09/2026 16:18",
|
||||
},
|
||||
{
|
||||
id: 1040,
|
||||
termoBusca: "academias",
|
||||
localizacao: "Sorocaba, SP",
|
||||
ramoId: 3,
|
||||
status: "pendente",
|
||||
totalEncontrados: 0,
|
||||
telefonesCapturados: 0,
|
||||
regioesTotal: 0,
|
||||
regioesSucesso: 0,
|
||||
regioesErro: 0,
|
||||
detalheRegioes: null,
|
||||
detalheErro: null,
|
||||
iniciadoEm: null,
|
||||
finalizadoEm: null,
|
||||
criadoEm: "21/09/2026 15:52",
|
||||
},
|
||||
{
|
||||
id: 1039,
|
||||
termoBusca: "contabilidades",
|
||||
localizacao: "Ribeirão Preto, SP",
|
||||
ramoId: 4,
|
||||
status: "erro",
|
||||
totalEncontrados: 12,
|
||||
telefonesCapturados: 8,
|
||||
regioesTotal: 8,
|
||||
regioesSucesso: 2,
|
||||
regioesErro: 6,
|
||||
detalheRegioes: "OK | Centro, Ribeirão Preto, SP | 7 encontrados\nERRO | Jardim Paulista, Ribeirão Preto, SP | Timeout 30000ms exceeded",
|
||||
detalheErro: "Falha mockada",
|
||||
iniciadoEm: null,
|
||||
finalizadoEm: null,
|
||||
criadoEm: "21/09/2026 14:20",
|
||||
},
|
||||
];
|
||||
|
||||
export const leadsMock: Lead[] = [
|
||||
{
|
||||
id: 501,
|
||||
nomeFantasia: "Odonto Prime Campinas",
|
||||
telefoneE164: "+551932221100",
|
||||
telefoneBruto: null,
|
||||
enderecoCompleto: null,
|
||||
cidade: "Campinas",
|
||||
uf: "SP",
|
||||
ramoId: 1,
|
||||
categoriaBruta: "Clínica odontológica",
|
||||
status: "novo",
|
||||
buscaId: 1042,
|
||||
site: "odontoprime.example",
|
||||
criadoEm: "21/09/2026 16:40",
|
||||
},
|
||||
{
|
||||
id: 502,
|
||||
nomeFantasia: "Sorriso & Cia",
|
||||
telefoneE164: "+551932227788",
|
||||
telefoneBruto: null,
|
||||
enderecoCompleto: null,
|
||||
cidade: "Campinas",
|
||||
uf: "SP",
|
||||
ramoId: 1,
|
||||
categoriaBruta: "Dentista",
|
||||
status: "contatado",
|
||||
buscaId: 1042,
|
||||
site: "sorrisoecia.example",
|
||||
criadoEm: "21/09/2026 16:40",
|
||||
},
|
||||
{
|
||||
id: 503,
|
||||
nomeFantasia: "Peças Vale Express",
|
||||
telefoneE164: "+551239998888",
|
||||
telefoneBruto: null,
|
||||
enderecoCompleto: null,
|
||||
cidade: "São José dos Campos",
|
||||
uf: "SP",
|
||||
ramoId: 2,
|
||||
categoriaBruta: "Loja de autopeças",
|
||||
status: "qualificado",
|
||||
buscaId: 1041,
|
||||
site: "pecasvale.example",
|
||||
criadoEm: "21/09/2026 16:18",
|
||||
},
|
||||
{
|
||||
id: 504,
|
||||
nomeFantasia: "Studio Movimento",
|
||||
telefoneE164: "+551533334444",
|
||||
telefoneBruto: null,
|
||||
enderecoCompleto: null,
|
||||
cidade: "Sorocaba",
|
||||
uf: "SP",
|
||||
ramoId: null,
|
||||
categoriaBruta: "Centro de treinamento",
|
||||
status: "novo",
|
||||
buscaId: 1040,
|
||||
site: "studiomovimento.example",
|
||||
criadoEm: "21/09/2026 15:52",
|
||||
},
|
||||
{
|
||||
id: 505,
|
||||
nomeFantasia: "Alfa Gestão Contábil",
|
||||
telefoneE164: "+551636667777",
|
||||
telefoneBruto: null,
|
||||
enderecoCompleto: null,
|
||||
cidade: "Ribeirão Preto",
|
||||
uf: "SP",
|
||||
ramoId: null,
|
||||
categoriaBruta: "Consultor financeiro",
|
||||
status: "descartado",
|
||||
buscaId: 1039,
|
||||
site: "alfagestao.example",
|
||||
criadoEm: "21/09/2026 14:20",
|
||||
},
|
||||
];
|
||||
|
||||
export const leadsPorRamoMock: RamoResumo[] = [
|
||||
{ ramo: "Saúde", total: 128 },
|
||||
{ ramo: "Automotivo", total: 84 },
|
||||
{ ramo: "Fitness", total: 63 },
|
||||
{ ramo: "Serviços", total: 47 },
|
||||
{ ramo: "Sem ramo", total: 19 },
|
||||
];
|
||||
|
||||
export const ramosMock = ["Saúde", "Automotivo", "Fitness", "Serviços", "Educação"];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import { AuthProvider } from "./hooks/useAuth";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RefreshCw, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Select } from "../components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { api } from "../lib/api";
|
||||
import type { Busca, StatusBusca } from "../types/domain";
|
||||
|
||||
const statusOptions: StatusBusca[] = ["pendente", "em_execucao", "concluida", "erro"];
|
||||
|
||||
export function BuscasPage() {
|
||||
const [status, setStatus] = useState<StatusBusca | "">("");
|
||||
const buscasQuery = useQuery({
|
||||
queryKey: ["buscas", status],
|
||||
queryFn: () => api.listarBuscas({ status: status || undefined, size: 50 }),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.content.some((busca) => busca.status === "pendente" || busca.status === "em_execucao")
|
||||
? 3000
|
||||
: false,
|
||||
});
|
||||
const buscas = buscasQuery.data?.content ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-16 lg:pb-0">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Select className="sm:max-w-56" value={status} onChange={(event) => setStatus(event.target.value as StatusBusca | "")}>
|
||||
<option value="">Todos os status</option>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button variant="outline" onClick={() => buscasQuery.refetch()} disabled={buscasQuery.isFetching}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
Atualizar
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Histórico</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Termo</TableHead>
|
||||
<TableHead>Localização</TableHead>
|
||||
<TableHead>Ramo</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Regiões</TableHead>
|
||||
<TableHead className="text-right">Únicos salvos</TableHead>
|
||||
<TableHead className="text-right">Telefones</TableHead>
|
||||
<TableHead>Criada em</TableHead>
|
||||
<TableHead>Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{buscas.map((busca) => (
|
||||
<TableRow key={busca.id}>
|
||||
<TableCell className="font-medium">#{busca.id}</TableCell>
|
||||
<TableCell>{busca.termoBusca}</TableCell>
|
||||
<TableCell>{busca.localizacao}</TableCell>
|
||||
<TableCell>{busca.ramoId ? `#${busca.ramoId}` : "Sem ramo"}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={busca.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RegionSummary busca={busca} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{busca.totalEncontrados ?? 0}</TableCell>
|
||||
<TableCell className="text-right font-medium">{busca.telefonesCapturados ?? 0}</TableCell>
|
||||
<TableCell>{new Date(busca.criadoEm).toLocaleString("pt-BR")}</TableCell>
|
||||
<TableCell>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/leads?buscaId=${busca.id}`}>
|
||||
<Users className="h-4 w-4" aria-hidden="true" />
|
||||
Ver leads
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{buscasQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10}>Carregando buscas...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{buscasQuery.isError && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-destructive">
|
||||
Não foi possível carregar as buscas.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionSummary({ busca }: { busca: Busca }) {
|
||||
const total = busca.regioesTotal ?? 0;
|
||||
const sucesso = busca.regioesSucesso ?? 0;
|
||||
const erro = busca.regioesErro ?? 0;
|
||||
const processadas = Math.min(total, sucesso + erro);
|
||||
const progresso = total ? Math.round((processadas / total) * 100) : 0;
|
||||
const sucessoWidth = total ? (sucesso / total) * 100 : 0;
|
||||
const erroWidth = total ? (erro / total) * 100 : 0;
|
||||
|
||||
if (!total) {
|
||||
if (busca.status === "pendente" || busca.status === "em_execucao") {
|
||||
return (
|
||||
<div className="min-w-56 text-sm">
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>Preparando bairros</span>
|
||||
<span>0%</span>
|
||||
</div>
|
||||
<div className="h-2.5 overflow-hidden rounded-full border bg-muted">
|
||||
<div className="h-full w-1/5 animate-pulse rounded-full bg-primary/60" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-sm text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
const detalhes = busca.detalheRegioes
|
||||
?.split("\n")
|
||||
.map((linha) => linha.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="min-w-56 text-sm">
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{processadas}/{total} bairros
|
||||
</span>
|
||||
<span>{progresso}%</span>
|
||||
</div>
|
||||
<div className="mb-2 h-2.5 overflow-hidden rounded-full border bg-muted">
|
||||
<div className="flex h-full w-full">
|
||||
<div className="h-full bg-emerald-500 transition-all" style={{ width: `${sucessoWidth}%` }} />
|
||||
<div className="h-full bg-red-500 transition-all" style={{ width: `${erroWidth}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="rounded border px-2 py-0.5 text-muted-foreground">{total} bairros</span>
|
||||
<span className="rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-emerald-700">
|
||||
{sucesso} ok
|
||||
</span>
|
||||
<span className="rounded border border-red-200 bg-red-50 px-2 py-0.5 text-red-700">{erro} erro</span>
|
||||
</div>
|
||||
{!!detalhes?.length && (
|
||||
<details className="mt-1 max-w-96">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">Ver detalhes</summary>
|
||||
<div className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap rounded border bg-muted/30 p-2 text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Por bairro: retornos brutos. Total: únicos salvos.</div>
|
||||
{detalhes.join("\n")}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select } from "../components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export function CuradoriaRamosPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [nomeCanonico, setNomeCanonico] = useState("");
|
||||
const [alias, setAlias] = useState("");
|
||||
const [selecionados, setSelecionados] = useState<Record<number, string>>({});
|
||||
const leadsQuery = useQuery({ queryKey: ["leads-sem-ramo"], queryFn: api.listarLeadsSemRamo });
|
||||
const ramosQuery = useQuery({ queryKey: ["ramos"], queryFn: api.listarRamos });
|
||||
const criarRamo = useMutation({
|
||||
mutationFn: api.criarRamo,
|
||||
onSuccess: () => {
|
||||
setNomeCanonico("");
|
||||
setAlias("");
|
||||
queryClient.invalidateQueries({ queryKey: ["ramos"] });
|
||||
},
|
||||
});
|
||||
const mapearLead = useMutation({
|
||||
mutationFn: ({ leadId, ramoId }: { leadId: number; ramoId: number }) => api.atualizarRamoLead(leadId, ramoId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["leads-sem-ramo"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["leads"] });
|
||||
},
|
||||
});
|
||||
const pendentes = leadsQuery.data?.content ?? [];
|
||||
const ramos = ramosQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-16 lg:pb-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Novo ramo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
<Input placeholder="Nome canônico" value={nomeCanonico} onChange={(event) => setNomeCanonico(event.target.value)} />
|
||||
<Input placeholder="Alias inicial" value={alias} onChange={(event) => setAlias(event.target.value)} />
|
||||
<Button
|
||||
disabled={!nomeCanonico || criarRamo.isPending}
|
||||
onClick={() => criarRamo.mutate({ nomeCanonico, aliases: alias ? [alias] : [] })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Criar
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Leads sem ramo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Categoria bruta</TableHead>
|
||||
<TableHead>Cidade</TableHead>
|
||||
<TableHead>Mapear para</TableHead>
|
||||
<TableHead className="w-20">Ação</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pendentes.map((lead) => (
|
||||
<TableRow key={lead.id}>
|
||||
<TableCell className="font-medium">{lead.nomeFantasia}</TableCell>
|
||||
<TableCell>{lead.categoriaBruta}</TableCell>
|
||||
<TableCell>
|
||||
{lead.cidade}, {lead.uf}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={selecionados[lead.id] ?? ""}
|
||||
onChange={(event) => setSelecionados((current) => ({ ...current, [lead.id]: event.target.value }))}
|
||||
>
|
||||
<option value="">Selecionar ramo</option>
|
||||
{ramos.map((ramo) => (
|
||||
<option key={ramo.id} value={ramo.id}>
|
||||
{ramo.nomeCanonico}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Aplicar mapeamento"
|
||||
disabled={!selecionados[lead.id] || mapearLead.isPending}
|
||||
onClick={() => mapearLead.mutate({ leadId: lead.id, ramoId: Number(selecionados[lead.id]) })}
|
||||
>
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{leadsQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>Carregando leads pendentes...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{leadsQuery.isError && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-destructive">
|
||||
Não foi possível carregar os leads sem ramo.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, CheckCircle2, Phone, Users } from "lucide-react";
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export function DashboardPage() {
|
||||
const resumoQuery = useQuery({ queryKey: ["dashboard-resumo"], queryFn: api.resumoDashboard });
|
||||
const buscasQuery = useQuery({ queryKey: ["buscas-recentes"], queryFn: () => api.listarBuscas({ size: 5 }) });
|
||||
const resumo = resumoQuery.data;
|
||||
const buscas = buscasQuery.data?.content ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-16 lg:pb-0">
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Metric title="Leads capturados" value={resumo?.totalLeads ?? "-"} icon={Users} />
|
||||
<Metric title="Telefones capturados" value={resumo?.telefonesCapturados ?? "-"} icon={Phone} />
|
||||
<Metric title="Telefones únicos" value={resumo?.telefonesUnicos ?? "-"} icon={Phone} />
|
||||
<Metric title="Buscas em execução" value={resumo?.buscasEmExecucao ?? "-"} icon={Activity} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Leads por ramo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={resumo?.leadsPorRamo ?? []}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="ramo" tickLine={false} axisLine={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="total" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Buscas recentes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Termo</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Leads</TableHead>
|
||||
<TableHead className="text-right">Telefones</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{buscas.map((busca) => (
|
||||
<TableRow key={busca.id}>
|
||||
<TableCell>
|
||||
<p className="font-medium">{busca.termoBusca}</p>
|
||||
<p className="text-xs text-muted-foreground">{busca.localizacao}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={busca.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">{busca.totalEncontrados ?? 0}</TableCell>
|
||||
<TableCell className="text-right font-medium">{busca.telefonesCapturados ?? 0}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(resumoQuery.isError || buscasQuery.isError) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-destructive">
|
||||
Não foi possível carregar o resumo.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: typeof Users;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-5">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{value}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary">
|
||||
<Icon className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Download, Filter } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select } from "../components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { api, getAuthToken } from "../lib/api";
|
||||
import type { StatusLead } from "../types/domain";
|
||||
|
||||
const leadStatuses: StatusLead[] = ["novo", "contatado", "qualificado", "convertido", "descartado"];
|
||||
|
||||
export function LeadsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [cidade, setCidade] = useState(searchParams.get("cidade") ?? "");
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
ramoId: searchParams.get("ramoId"),
|
||||
cidade: searchParams.get("cidade"),
|
||||
uf: searchParams.get("uf"),
|
||||
status: searchParams.get("status") as StatusLead | null,
|
||||
buscaId: searchParams.get("buscaId"),
|
||||
size: 50,
|
||||
}),
|
||||
[searchParams],
|
||||
);
|
||||
const leadsQuery = useQuery({ queryKey: ["leads", params], queryFn: () => api.listarLeads(params) });
|
||||
const ramosQuery = useQuery({ queryKey: ["ramos"], queryFn: api.listarRamos });
|
||||
const atualizarStatus = useMutation({
|
||||
mutationFn: ({ id, status }: { id: number; status: StatusLead }) => api.atualizarStatusLead(id, status),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["leads"] }),
|
||||
});
|
||||
|
||||
function updateParam(key: string, value: string) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
next.set(key, value);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
setSearchParams(next);
|
||||
}
|
||||
|
||||
async function exportarCsv() {
|
||||
const response = await fetch(api.exportarLeadsUrl(searchParams), {
|
||||
headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {},
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "leads.csv";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const ramos = ramosQuery.data ?? [];
|
||||
const leads = leadsQuery.data?.content ?? [];
|
||||
const buscaId = searchParams.get("buscaId");
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-16 lg:pb-0">
|
||||
{buscaId && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mostrando leads da busca <span className="font-medium text-foreground">#{buscaId}</span>.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => updateParam("buscaId", "")}>
|
||||
Ver todos os leads
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid gap-3 p-4 md:grid-cols-[1fr_160px_160px_auto]">
|
||||
<Input placeholder="Cidade" value={cidade} onChange={(event) => setCidade(event.target.value)} />
|
||||
<Select value={searchParams.get("ramoId") ?? ""} onChange={(event) => updateParam("ramoId", event.target.value)}>
|
||||
<option value="">Todos os ramos</option>
|
||||
{ramos.map((ramo) => (
|
||||
<option key={ramo.id} value={ramo.id}>
|
||||
{ramo.nomeCanonico}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={searchParams.get("status") ?? ""} onChange={(event) => updateParam("status", event.target.value)}>
|
||||
<option value="">Todos os status</option>
|
||||
{leadStatuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" aria-label="Aplicar filtros" onClick={() => updateParam("cidade", cidade)}>
|
||||
<Filter className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={exportarCsv}>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Leads capturados</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Telefone</TableHead>
|
||||
<TableHead>Cidade</TableHead>
|
||||
<TableHead>Ramo</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Busca</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{leads.map((lead) => (
|
||||
<TableRow key={lead.id}>
|
||||
<TableCell>
|
||||
<p className="font-medium">{lead.nomeFantasia}</p>
|
||||
<p className="text-xs text-muted-foreground">{lead.site ?? lead.enderecoCompleto}</p>
|
||||
</TableCell>
|
||||
<TableCell>{lead.telefoneE164 ?? lead.telefoneBruto ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
{lead.cidade}, {lead.uf}
|
||||
</TableCell>
|
||||
<TableCell>{lead.ramoId ? ramos.find((ramo) => ramo.id === lead.ramoId)?.nomeCanonico ?? `#${lead.ramoId}` : "Sem ramo"}</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={lead.status}
|
||||
onChange={(event) => atualizarStatus.mutate({ id: lead.id, status: event.target.value as StatusLead })}
|
||||
>
|
||||
{leadStatuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>#{lead.buscaId}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{leadsQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>Carregando leads...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{leadsQuery.isError && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-destructive">
|
||||
Não foi possível carregar os leads.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { DatabaseZap } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Input } from "../components/ui/input";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().trim().min(1),
|
||||
senha: z.string().min(1),
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof schema>;
|
||||
|
||||
export function LoginPage() {
|
||||
const { isAuthenticated, login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { register, handleSubmit, formState, setError } = useForm<LoginForm>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
senha: "",
|
||||
},
|
||||
});
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const shouldShowValidation = formState.submitCount > 0;
|
||||
|
||||
async function onSubmit(values: LoginForm) {
|
||||
try {
|
||||
await login(values.email.trim(), values.senha);
|
||||
navigate("/", { replace: true });
|
||||
} catch {
|
||||
setError("root", { message: "Email ou senha inválidos." });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-background px-4 py-10">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<DatabaseZap className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<CardTitle>LeadScraper</CardTitle>
|
||||
<CardDescription>Acesso ao dashboard interno de prospecção.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" noValidate onSubmit={handleSubmit(onSubmit)}>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">Email</span>
|
||||
<Input type="text" inputMode="email" autoComplete="email" {...register("email")} />
|
||||
{shouldShowValidation && formState.errors.email && (
|
||||
<span className="text-xs text-destructive">Email obrigatório</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">Senha</span>
|
||||
<Input type="password" autoComplete="current-password" {...register("senha")} />
|
||||
{shouldShowValidation && formState.errors.senha && (
|
||||
<span className="text-xs text-destructive">Senha obrigatória</span>
|
||||
)}
|
||||
</label>
|
||||
<Button className="w-full" type="submit">
|
||||
{formState.isSubmitting ? "Entrando..." : "Entrar"}
|
||||
</Button>
|
||||
{formState.errors.root && <p className="text-sm text-destructive">{formState.errors.root.message}</p>}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Play } from "lucide-react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select } from "../components/ui/select";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
export function NovaBuscaPage() {
|
||||
const navigate = useNavigate();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const ramosQuery = useQuery({ queryKey: ["ramos"], queryFn: api.listarRamos });
|
||||
const criarBusca = useMutation({
|
||||
mutationFn: api.criarBusca,
|
||||
onSuccess: () => navigate("/buscas"),
|
||||
});
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const termoBusca = String(formData.get("termoBusca") ?? "").trim();
|
||||
const localizacao = String(formData.get("localizacao") ?? "").trim();
|
||||
const ramoId = String(formData.get("ramoId") ?? "");
|
||||
|
||||
if (!termoBusca || !localizacao) {
|
||||
setFormError("Preencha o termo de busca e a localização.");
|
||||
return;
|
||||
}
|
||||
|
||||
setFormError(null);
|
||||
criarBusca.mutate({
|
||||
termoBusca,
|
||||
localizacao,
|
||||
ramoId: ramoId ? Number(ramoId) : null,
|
||||
expandirPorBairros: expanded,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl pb-16 lg:pb-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Disparar busca</CardTitle>
|
||||
<CardDescription>A busca entra como pendente e será processada pelo worker.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-4 md:grid-cols-2" onSubmit={onSubmit}>
|
||||
<label className="block space-y-1.5 md:col-span-2">
|
||||
<span className="text-sm font-medium">Termo de busca</span>
|
||||
<Input
|
||||
name="termoBusca"
|
||||
defaultValue="restaurantes japoneses"
|
||||
placeholder="Ex.: pizzaria, clínica odontológica, oficina mecânica"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">Localização</span>
|
||||
<Input name="localizacao" defaultValue="Campinas, SP" placeholder="Ex.: Campinas, SP" />
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">Ramo</span>
|
||||
<Select name="ramoId" defaultValue="">
|
||||
<option value="">Sem ramo</option>
|
||||
{(ramosQuery.data ?? []).map((ramo) => (
|
||||
<option key={ramo.id} value={ramo.id}>
|
||||
{ramo.nomeCanonico}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded-md border p-3 md:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={expanded}
|
||||
onChange={(event) => setExpanded(event.target.checked)}
|
||||
className="mt-1 h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="block font-medium">Expandir por bairros quando disponível</span>
|
||||
<span className="text-muted-foreground">
|
||||
Para cidades grandes, o sistema pesquisa regiões em segundo plano e consolida tudo nesta busca.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" disabled={criarBusca.isPending}>
|
||||
<Play className="h-4 w-4" aria-hidden="true" />
|
||||
{criarBusca.isPending ? "Criando..." : "Criar busca"}
|
||||
</Button>
|
||||
{formError && <p className="mt-2 text-sm text-destructive">{formError}</p>}
|
||||
{criarBusca.isError && (
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
Não foi possível criar a busca. Atualize a página e tente novamente.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export type StatusBusca = "pendente" | "em_execucao" | "concluida" | "erro";
|
||||
|
||||
export type StatusLead = "novo" | "contatado" | "qualificado" | "convertido" | "descartado";
|
||||
|
||||
export interface Busca {
|
||||
id: number;
|
||||
termoBusca: string;
|
||||
localizacao: string;
|
||||
ramoId: number | null;
|
||||
status: StatusBusca;
|
||||
totalEncontrados: number | null;
|
||||
telefonesCapturados: number;
|
||||
regioesTotal: number | null;
|
||||
regioesSucesso: number | null;
|
||||
regioesErro: number | null;
|
||||
detalheRegioes: string | null;
|
||||
detalheErro: string | null;
|
||||
iniciadoEm: string | null;
|
||||
finalizadoEm: string | null;
|
||||
criadoEm: string;
|
||||
}
|
||||
|
||||
export interface Lead {
|
||||
id: number;
|
||||
nomeFantasia: string;
|
||||
telefoneE164: string;
|
||||
telefoneBruto: string | null;
|
||||
enderecoCompleto: string | null;
|
||||
cidade: string;
|
||||
uf: string;
|
||||
ramoId: number | null;
|
||||
categoriaBruta: string;
|
||||
status: StatusLead;
|
||||
buscaId: number;
|
||||
site: string | null;
|
||||
criadoEm: string;
|
||||
}
|
||||
|
||||
export interface RamoResumo {
|
||||
ramo: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface Ramo {
|
||||
id: number;
|
||||
nomeCanonico: string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface DashboardResumo {
|
||||
totalLeads: number;
|
||||
telefonesCapturados: number;
|
||||
telefonesUnicos: number;
|
||||
buscasEmExecucao: number;
|
||||
aguardandoCuradoria: number;
|
||||
convertidos: number;
|
||||
leadsPorRamo: RamoResumo[];
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "0.5rem",
|
||||
md: "0.375rem",
|
||||
sm: "0.25rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.3.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.seconectai</groupId>
|
||||
<artifactId>leadscraper</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>LeadScraper</name>
|
||||
<description>Motor de busca e prospeccao de leads via automacao de navegador.</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.microsoft.playwright</groupId>
|
||||
<artifactId>playwright</artifactId>
|
||||
<version>1.48.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.6.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RAW_BASE="${RAW_BASE:-https://producao-gitea.0jo7zj.easypanel.host/tiago/Prospeccao_SeConectai/raw/branch/main/scripts}"
|
||||
BIN_DIR="${BIN_DIR:-/usr/local/bin}"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "Erro: curl nao encontrado. Instale com: apt update && apt install -y curl"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install_cmd() {
|
||||
local name="$1"
|
||||
curl -fsSL "$RAW_BASE/$name" -o "$BIN_DIR/$name"
|
||||
chmod 0755 "$BIN_DIR/$name"
|
||||
}
|
||||
|
||||
install_cmd seconectaiescavador.install
|
||||
install_cmd seconectaiescavador.update
|
||||
|
||||
echo "Comandos instalados em $BIN_DIR:"
|
||||
echo " seconectaiescavador.install"
|
||||
echo " seconectaiescavador.update"
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
install -m 0755 scripts/seconectaiescavador.install /usr/local/bin/seconectaiescavador.install
|
||||
install -m 0755 scripts/seconectaiescavador.update /usr/local/bin/seconectaiescavador.update
|
||||
|
||||
echo "Comandos instalados:"
|
||||
echo " seconectaiescavador.install"
|
||||
echo " seconectaiescavador.update"
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="${DOMAIN:-escavador.seconectai.com.br}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
|
||||
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
||||
echo "Erro: $COMPOSE_FILE nao encontrado. Rode este script na raiz do projeto."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Erro: Docker nao encontrado. Instale Docker e Docker Compose plugin antes de continuar."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
secret() {
|
||||
openssl rand -base64 32 | tr -d '\n'
|
||||
}
|
||||
|
||||
DB_PASSWORD="$(secret)"
|
||||
JWT_SECRET="$(secret)"
|
||||
|
||||
cat > "$ENV_FILE" <<EOF_ENV
|
||||
DB_USER=leadscraper
|
||||
DB_PASSWORD=$DB_PASSWORD
|
||||
DB_PORT=5433
|
||||
BACKEND_PORT=8082
|
||||
FRONTEND_PORT=8081
|
||||
SERVER_PORT=8082
|
||||
|
||||
JWT_SECRET=$JWT_SECRET
|
||||
JWT_EXPIRATION_MINUTES=480
|
||||
API_URL=/api
|
||||
APP_PUBLIC_URL=https://$DOMAIN
|
||||
|
||||
BUSCA_WORKER_ENABLED=true
|
||||
BUSCA_WORKER_FIXED_DELAY_MS=15000
|
||||
SCRAPER_HEADLESS=true
|
||||
SCRAPER_TIMEOUT_MS=300000
|
||||
SCRAPER_MAX_RESULTS=1000
|
||||
SCRAPER_SCROLL_STAGNANT_ATTEMPTS=12
|
||||
SCRAPER_DELAY_MIN_MS=900
|
||||
SCRAPER_DELAY_MAX_MS=2500
|
||||
OVERPASS_ENABLED=true
|
||||
OVERPASS_ENDPOINT=https://overpass-api.de/api/interpreter
|
||||
OVERPASS_TIMEOUT_SECONDS=90
|
||||
OVERPASS_MAX_RESULTS=1000
|
||||
EOF_ENV
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
|
||||
echo
|
||||
echo "Instalacao concluida."
|
||||
echo "URL: https://$DOMAIN"
|
||||
echo "Frontend interno: http://localhost:8081"
|
||||
echo "Backend interno: http://localhost:8082"
|
||||
echo
|
||||
echo "Segredos gerados em $ENV_FILE."
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="${REPO_URL:-https://producao-gitea.0jo7zj.easypanel.host/tiago/Prospeccao_SeConectai.git}"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/Prospeccao_SeConectai}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
DOMAIN="${DOMAIN:-escavador.seconectai.com.br}"
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "Erro: git nao encontrado. Instale com: apt update && apt install -y git"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Erro: Docker nao encontrado. Instale Docker antes de continuar."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
|
||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
echo "Repositorio ja existe em $INSTALL_DIR. Sincronizando com o Gitea..."
|
||||
git -C "$INSTALL_DIR" remote remove origin >/dev/null 2>&1 || true
|
||||
git -C "$INSTALL_DIR" remote add origin "$REPO_URL"
|
||||
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||
git -C "$INSTALL_DIR" checkout -B "$BRANCH" "origin/$BRANCH" -f
|
||||
else
|
||||
if [[ -e "$INSTALL_DIR" && -n "$(ls -A "$INSTALL_DIR" 2>/dev/null || true)" ]]; then
|
||||
if [[ -f "$INSTALL_DIR/pom.xml" && -d "$INSTALL_DIR/frontend" ]]; then
|
||||
echo "Projeto encontrado em $INSTALL_DIR sem .git. Conectando ao Gitea e atualizando..."
|
||||
git -C "$INSTALL_DIR" init
|
||||
git -C "$INSTALL_DIR" remote remove origin >/dev/null 2>&1 || true
|
||||
git -C "$INSTALL_DIR" remote add origin "$REPO_URL"
|
||||
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||
git -C "$INSTALL_DIR" checkout -B "$BRANCH" "origin/$BRANCH" -f
|
||||
else
|
||||
echo "Erro: $INSTALL_DIR ja existe e nao parece ser este projeto."
|
||||
echo "Use outro INSTALL_DIR ou mova a pasta atual antes de instalar."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
DOMAIN="$DOMAIN" ./scripts/install-prod.sh
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/Prospeccao_SeConectai}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
|
||||
if [[ ! -d "$INSTALL_DIR/.git" ]]; then
|
||||
echo "Erro: repositorio nao encontrado em $INSTALL_DIR. Rode primeiro: seconectaiescavador.install"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
BRANCH="$BRANCH" ./scripts/update-prod.sh
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
|
||||
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
||||
echo "Erro: $COMPOSE_FILE nao encontrado. Rode este script na raiz do projeto."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "Erro: .env nao encontrado. Rode scripts/install-prod.sh primeiro ou crie o .env de producao."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -d .git ]]; then
|
||||
git fetch origin "$BRANCH"
|
||||
git checkout "$BRANCH"
|
||||
git pull --ff-only origin "$BRANCH"
|
||||
fi
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" ps
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.seconectai.leadscraper;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@EnableScheduling
|
||||
@SpringBootApplication
|
||||
public class LeadScraperApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(LeadScraperApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.seconectai.leadscraper.config;
|
||||
|
||||
import com.seconectai.leadscraper.scraper.GoogleMapsScraperProperties;
|
||||
import com.seconectai.leadscraper.scraper.OverpassScraperProperties;
|
||||
import com.seconectai.leadscraper.proxy.ProxySettingsProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({
|
||||
GoogleMapsScraperProperties.class,
|
||||
OverpassScraperProperties.class,
|
||||
ProxySettingsProperties.class
|
||||
})
|
||||
public class ScraperConfig {
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.seconectai.leadscraper.config;
|
||||
|
||||
import com.seconectai.leadscraper.security.JwtAuthenticationFilter;
|
||||
import com.seconectai.leadscraper.security.JwtProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(JwtProperties.class)
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter)
|
||||
throws Exception {
|
||||
return http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/auth/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
|
||||
throws Exception {
|
||||
return authenticationConfiguration.getAuthenticationManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.seconectai.leadscraper.controller;
|
||||
|
||||
import com.seconectai.leadscraper.repository.UsuarioRepository;
|
||||
import com.seconectai.leadscraper.security.JwtService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final UsuarioRepository usuarioRepository;
|
||||
private final JwtService jwtService;
|
||||
|
||||
public AuthController(
|
||||
AuthenticationManager authenticationManager,
|
||||
UsuarioRepository usuarioRepository,
|
||||
JwtService jwtService
|
||||
) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.usuarioRepository = usuarioRepository;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public LoginResponse login(@Valid @RequestBody LoginRequest request) {
|
||||
authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(request.email(), request.senha())
|
||||
);
|
||||
var usuario = usuarioRepository.findByEmail(request.email()).orElseThrow();
|
||||
return new LoginResponse(jwtService.generateToken(usuario.getEmail(), usuario.getPapel()), "Bearer");
|
||||
}
|
||||
|
||||
public record LoginRequest(@Email @NotBlank String email, @NotBlank String senha) {
|
||||
}
|
||||
|
||||
public record LoginResponse(String token, String tipo) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.seconectai.leadscraper.controller;
|
||||
|
||||
import com.seconectai.leadscraper.domain.Busca;
|
||||
import com.seconectai.leadscraper.domain.Ramo;
|
||||
import com.seconectai.leadscraper.domain.StatusBusca;
|
||||
import com.seconectai.leadscraper.repository.BuscaRepository;
|
||||
import com.seconectai.leadscraper.repository.LeadRepository;
|
||||
import com.seconectai.leadscraper.repository.RamoRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.time.OffsetDateTime;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/buscas")
|
||||
public class BuscaController {
|
||||
|
||||
private final BuscaRepository buscaRepository;
|
||||
private final LeadRepository leadRepository;
|
||||
private final RamoRepository ramoRepository;
|
||||
|
||||
public BuscaController(BuscaRepository buscaRepository, LeadRepository leadRepository, RamoRepository ramoRepository) {
|
||||
this.buscaRepository = buscaRepository;
|
||||
this.leadRepository = leadRepository;
|
||||
this.ramoRepository = ramoRepository;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public BuscaResponse criar(@Valid @RequestBody CriarBuscaRequest request) {
|
||||
Busca busca = new Busca();
|
||||
busca.setTermoBusca(request.termoBusca());
|
||||
busca.setLocalizacao(request.localizacao());
|
||||
busca.setRamo(resolveRamo(request.ramoId()));
|
||||
busca.setExpandirPorBairros(request.expandirPorBairros() == null || request.expandirPorBairros());
|
||||
busca.setStatus(StatusBusca.pendente);
|
||||
return toResponse(buscaRepository.save(busca));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Page<BuscaResponse> listar(@RequestParam(required = false) StatusBusca status, Pageable pageable) {
|
||||
Specification<Busca> spec = (root, query, cb) -> status == null
|
||||
? cb.conjunction()
|
||||
: cb.equal(root.get("status"), status);
|
||||
return buscaRepository.findAll(spec, pageable).map(this::toResponse);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public BuscaResponse detalhar(@PathVariable Long id) {
|
||||
return toResponse(buscaRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Busca nao encontrada")));
|
||||
}
|
||||
|
||||
private BuscaResponse toResponse(Busca busca) {
|
||||
long telefonesCapturados = busca.getId() == null ? 0 : leadRepository.countByBuscaIdAndTelefoneE164IsNotNull(busca.getId());
|
||||
return BuscaResponse.from(busca, telefonesCapturados);
|
||||
}
|
||||
|
||||
private Ramo resolveRamo(Long ramoId) {
|
||||
if (ramoId == null) {
|
||||
return null;
|
||||
}
|
||||
return ramoRepository.findById(ramoId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ramo nao encontrado"));
|
||||
}
|
||||
|
||||
public record CriarBuscaRequest(
|
||||
@NotBlank String termoBusca,
|
||||
@NotBlank String localizacao,
|
||||
Long ramoId,
|
||||
Boolean expandirPorBairros
|
||||
) {
|
||||
}
|
||||
|
||||
public record BuscaResponse(
|
||||
Long id,
|
||||
String termoBusca,
|
||||
String localizacao,
|
||||
Long ramoId,
|
||||
StatusBusca status,
|
||||
Integer totalEncontrados,
|
||||
long telefonesCapturados,
|
||||
Integer regioesTotal,
|
||||
Integer regioesSucesso,
|
||||
Integer regioesErro,
|
||||
String detalheRegioes,
|
||||
String detalheErro,
|
||||
OffsetDateTime iniciadoEm,
|
||||
OffsetDateTime finalizadoEm,
|
||||
OffsetDateTime criadoEm
|
||||
) {
|
||||
static BuscaResponse from(Busca busca, long telefonesCapturados) {
|
||||
return new BuscaResponse(
|
||||
busca.getId(),
|
||||
busca.getTermoBusca(),
|
||||
busca.getLocalizacao(),
|
||||
busca.getRamo() == null ? null : busca.getRamo().getId(),
|
||||
busca.getStatus(),
|
||||
busca.getTotalEncontrados(),
|
||||
telefonesCapturados,
|
||||
busca.getRegioesTotal(),
|
||||
busca.getRegioesSucesso(),
|
||||
busca.getRegioesErro(),
|
||||
busca.getDetalheRegioes(),
|
||||
busca.getDetalheErro(),
|
||||
busca.getIniciadoEm(),
|
||||
busca.getFinalizadoEm(),
|
||||
busca.getCriadoEm()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.seconectai.leadscraper.controller;
|
||||
|
||||
import com.seconectai.leadscraper.domain.Lead;
|
||||
import com.seconectai.leadscraper.domain.Ramo;
|
||||
import com.seconectai.leadscraper.domain.StatusBusca;
|
||||
import com.seconectai.leadscraper.domain.StatusLead;
|
||||
import com.seconectai.leadscraper.repository.BuscaRepository;
|
||||
import com.seconectai.leadscraper.repository.LeadRepository;
|
||||
import com.seconectai.leadscraper.repository.RamoRepository;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/leads")
|
||||
public class LeadController {
|
||||
|
||||
private final LeadRepository leadRepository;
|
||||
private final RamoRepository ramoRepository;
|
||||
private final BuscaRepository buscaRepository;
|
||||
|
||||
public LeadController(LeadRepository leadRepository, RamoRepository ramoRepository, BuscaRepository buscaRepository) {
|
||||
this.leadRepository = leadRepository;
|
||||
this.ramoRepository = ramoRepository;
|
||||
this.buscaRepository = buscaRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Page<LeadResponse> listar(LeadFilter filter, Pageable pageable) {
|
||||
return leadRepository.findAll(toSpec(filter), pageable).map(LeadResponse::from);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public LeadResponse detalhar(@PathVariable Long id) {
|
||||
return LeadResponse.from(findLead(id));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/status")
|
||||
@Transactional
|
||||
public LeadResponse atualizarStatus(@PathVariable Long id, @Valid @RequestBody AtualizarStatusRequest request) {
|
||||
Lead lead = findLead(id);
|
||||
lead.setStatus(request.status());
|
||||
return LeadResponse.from(leadRepository.save(lead));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/ramo")
|
||||
@Transactional
|
||||
public LeadResponse atualizarRamo(@PathVariable Long id, @Valid @RequestBody AtualizarRamoRequest request) {
|
||||
Lead lead = findLead(id);
|
||||
Ramo ramo = ramoRepository.findById(request.ramoId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ramo nao encontrado"));
|
||||
lead.setRamo(ramo);
|
||||
return LeadResponse.from(leadRepository.save(lead));
|
||||
}
|
||||
|
||||
@GetMapping("/resumo")
|
||||
public DashboardResumoResponse resumo() {
|
||||
List<Lead> leads = leadRepository.findAll();
|
||||
Map<String, Long> porRamo = leads.stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
lead -> lead.getRamo() == null ? "Sem ramo" : lead.getRamo().getNomeCanonico(),
|
||||
Collectors.counting()
|
||||
));
|
||||
List<RamoResumoResponse> leadsPorRamo = porRamo.entrySet().stream()
|
||||
.map(entry -> new RamoResumoResponse(entry.getKey(), entry.getValue()))
|
||||
.sorted((left, right) -> Long.compare(right.total(), left.total()))
|
||||
.toList();
|
||||
long buscasEmExecucao = buscaRepository.findByStatus(StatusBusca.em_execucao).size();
|
||||
long telefonesCapturados = leadRepository.countByTelefoneE164IsNotNull();
|
||||
long telefonesUnicos = leadRepository.countDistinctTelefonesCapturados();
|
||||
long aguardandoCuradoria = leads.stream().filter(lead -> lead.getRamo() == null).count();
|
||||
long convertidos = leads.stream().filter(lead -> lead.getStatus() == StatusLead.convertido).count();
|
||||
return new DashboardResumoResponse(leads.size(), telefonesCapturados, telefonesUnicos, buscasEmExecucao, aguardandoCuradoria, convertidos, leadsPorRamo);
|
||||
}
|
||||
|
||||
@GetMapping("/export")
|
||||
public void exportar(LeadFilter filter, HttpServletResponse response) throws IOException {
|
||||
response.setContentType("text/csv;charset=UTF-8");
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=leads.csv");
|
||||
try (PrintWriter writer = response.getWriter()) {
|
||||
writer.println("id,nome_fantasia,telefone_e164,telefone_bruto,site,cidade,uf,ramo_id,status,busca_id");
|
||||
leadRepository.findAll(toSpec(filter)).forEach(lead -> writer.println(csv(lead)));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/sem-ramo")
|
||||
public Page<LeadResponse> semRamo(Pageable pageable) {
|
||||
Specification<Lead> spec = (root, query, cb) -> cb.isNull(root.get("ramo"));
|
||||
return leadRepository.findAll(spec, pageable).map(LeadResponse::from);
|
||||
}
|
||||
|
||||
private Lead findLead(Long id) {
|
||||
return leadRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Lead nao encontrado"));
|
||||
}
|
||||
|
||||
private Specification<Lead> toSpec(LeadFilter filter) {
|
||||
return Specification.allOf(
|
||||
equalsIfPresent("ramo.id", filter.ramoId()),
|
||||
likeIfPresent("cidade", filter.cidade()),
|
||||
likeIfPresent("uf", filter.uf()),
|
||||
equalsIfPresent("status", filter.status()),
|
||||
equalsIfPresent("busca.id", filter.buscaId())
|
||||
);
|
||||
}
|
||||
|
||||
private Specification<Lead> equalsIfPresent(String path, Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return (root, query, cb) -> cb.equal(resolvePath(root, path), value);
|
||||
}
|
||||
|
||||
private Specification<Lead> likeIfPresent(String field, String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
return (root, query, cb) -> cb.like(cb.lower(root.get(field)), "%" + value.toLowerCase() + "%");
|
||||
}
|
||||
|
||||
private jakarta.persistence.criteria.Path<?> resolvePath(jakarta.persistence.criteria.Root<Lead> root, String path) {
|
||||
jakarta.persistence.criteria.Path<?> current = root;
|
||||
for (String part : path.split("\\.")) {
|
||||
current = current.get(part);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private String csv(Lead lead) {
|
||||
return String.join(",",
|
||||
escape(lead.getId()),
|
||||
escape(lead.getNomeFantasia()),
|
||||
escape(lead.getTelefoneE164()),
|
||||
escape(lead.getTelefoneBruto()),
|
||||
escape(lead.getSite()),
|
||||
escape(lead.getCidade()),
|
||||
escape(lead.getUf()),
|
||||
escape(lead.getRamo() == null ? null : lead.getRamo().getId()),
|
||||
escape(lead.getStatus()),
|
||||
escape(lead.getBusca() == null ? null : lead.getBusca().getId())
|
||||
);
|
||||
}
|
||||
|
||||
private String escape(Object value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String text = value.toString().replace("\"", "\"\"");
|
||||
return "\"" + text + "\"";
|
||||
}
|
||||
|
||||
public record LeadFilter(Long ramoId, String cidade, String uf, StatusLead status, Long buscaId) {
|
||||
}
|
||||
|
||||
public record AtualizarStatusRequest(@NotNull StatusLead status) {
|
||||
}
|
||||
|
||||
public record AtualizarRamoRequest(@NotNull Long ramoId) {
|
||||
}
|
||||
|
||||
public record RamoResumoResponse(String ramo, Long total) {
|
||||
}
|
||||
|
||||
public record DashboardResumoResponse(
|
||||
long totalLeads,
|
||||
long telefonesCapturados,
|
||||
long telefonesUnicos,
|
||||
long buscasEmExecucao,
|
||||
long aguardandoCuradoria,
|
||||
long convertidos,
|
||||
List<RamoResumoResponse> leadsPorRamo
|
||||
) {
|
||||
}
|
||||
|
||||
public record LeadResponse(
|
||||
Long id,
|
||||
String nomeFantasia,
|
||||
String telefoneE164,
|
||||
String telefoneBruto,
|
||||
String site,
|
||||
String enderecoCompleto,
|
||||
String cidade,
|
||||
String uf,
|
||||
Long ramoId,
|
||||
String categoriaBruta,
|
||||
StatusLead status,
|
||||
Long buscaId,
|
||||
OffsetDateTime criadoEm
|
||||
) {
|
||||
static LeadResponse from(Lead lead) {
|
||||
return new LeadResponse(
|
||||
lead.getId(),
|
||||
lead.getNomeFantasia(),
|
||||
lead.getTelefoneE164(),
|
||||
lead.getTelefoneBruto(),
|
||||
lead.getSite(),
|
||||
lead.getEnderecoCompleto(),
|
||||
lead.getCidade(),
|
||||
lead.getUf(),
|
||||
lead.getRamo() == null ? null : lead.getRamo().getId(),
|
||||
lead.getCategoriaBruta(),
|
||||
lead.getStatus(),
|
||||
lead.getBusca() == null ? null : lead.getBusca().getId(),
|
||||
lead.getCriadoEm()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.seconectai.leadscraper.controller;
|
||||
|
||||
import com.seconectai.leadscraper.domain.Ramo;
|
||||
import com.seconectai.leadscraper.repository.RamoRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/ramos")
|
||||
public class RamoController {
|
||||
|
||||
private final RamoRepository ramoRepository;
|
||||
|
||||
public RamoController(RamoRepository ramoRepository) {
|
||||
this.ramoRepository = ramoRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<RamoResponse> listar() {
|
||||
return ramoRepository.findAll().stream().map(RamoResponse::from).toList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public RamoResponse criar(@Valid @RequestBody CriarRamoRequest request) {
|
||||
Ramo ramo = new Ramo();
|
||||
ramo.setNomeCanonico(request.nomeCanonico());
|
||||
ramo.setAliases(request.aliases() == null ? new ArrayList<>() : new ArrayList<>(request.aliases()));
|
||||
return RamoResponse.from(ramoRepository.save(ramo));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/aliases")
|
||||
public RamoResponse adicionarAlias(@PathVariable Long id, @Valid @RequestBody AdicionarAliasRequest request) {
|
||||
Ramo ramo = ramoRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ramo nao encontrado"));
|
||||
List<String> aliases = new ArrayList<>(ramo.getAliases());
|
||||
if (!aliases.contains(request.alias())) {
|
||||
aliases.add(request.alias());
|
||||
}
|
||||
ramo.setAliases(aliases);
|
||||
return RamoResponse.from(ramoRepository.save(ramo));
|
||||
}
|
||||
|
||||
public record CriarRamoRequest(@NotBlank String nomeCanonico, List<String> aliases) {
|
||||
}
|
||||
|
||||
public record AdicionarAliasRequest(@NotBlank String alias) {
|
||||
}
|
||||
|
||||
public record RamoResponse(Long id, String nomeCanonico, List<String> aliases) {
|
||||
static RamoResponse from(Ramo ramo) {
|
||||
return new RamoResponse(ramo.getId(), ramo.getNomeCanonico(), ramo.getAliases());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.seconectai.leadscraper.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "buscas")
|
||||
public class Busca {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "termo_busca", nullable = false, length = 150)
|
||||
private String termoBusca;
|
||||
|
||||
@Column(nullable = false, length = 150)
|
||||
private String localizacao;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "ramo_id")
|
||||
private Ramo ramo;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "proxy_id")
|
||||
private Proxy proxy;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private StatusBusca status = StatusBusca.pendente;
|
||||
|
||||
@Column(name = "total_encontrados")
|
||||
private Integer totalEncontrados = 0;
|
||||
|
||||
@Column(name = "detalhe_erro", columnDefinition = "text")
|
||||
private String detalheErro;
|
||||
|
||||
@Column(name = "expandir_por_bairros")
|
||||
private Boolean expandirPorBairros = true;
|
||||
|
||||
@Column(name = "regioes_total")
|
||||
private Integer regioesTotal = 0;
|
||||
|
||||
@Column(name = "regioes_sucesso")
|
||||
private Integer regioesSucesso = 0;
|
||||
|
||||
@Column(name = "regioes_erro")
|
||||
private Integer regioesErro = 0;
|
||||
|
||||
@Column(name = "detalhe_regioes", columnDefinition = "text")
|
||||
private String detalheRegioes;
|
||||
|
||||
@Column(name = "iniciado_em")
|
||||
private OffsetDateTime iniciadoEm;
|
||||
|
||||
@Column(name = "finalizado_em")
|
||||
private OffsetDateTime finalizadoEm;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "criado_por")
|
||||
private Usuario criadoPor;
|
||||
|
||||
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||
private OffsetDateTime criadoEm;
|
||||
|
||||
@OneToMany(mappedBy = "busca")
|
||||
private List<Lead> leads = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.seconectai.leadscraper.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.OffsetDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "historico_contato")
|
||||
public class HistoricoContato {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "lead_id", nullable = false)
|
||||
private Lead lead;
|
||||
|
||||
@Column(length = 30)
|
||||
private String canal;
|
||||
|
||||
@Column(length = 50)
|
||||
private String resultado;
|
||||
|
||||
@Column(columnDefinition = "text")
|
||||
private String observacao;
|
||||
|
||||
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||
private OffsetDateTime criadoEm;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.seconectai.leadscraper.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "leads")
|
||||
public class Lead {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "place_id", length = 150)
|
||||
private String placeId;
|
||||
|
||||
@Column(name = "razao_social", length = 200)
|
||||
private String razaoSocial;
|
||||
|
||||
@Column(name = "nome_fantasia", nullable = false, length = 200)
|
||||
private String nomeFantasia;
|
||||
|
||||
@Column(columnDefinition = "char(14)")
|
||||
@JdbcTypeCode(SqlTypes.CHAR)
|
||||
private String cnpj;
|
||||
|
||||
@Column(name = "telefone_e164", length = 20)
|
||||
private String telefoneE164;
|
||||
|
||||
@Column(name = "telefone_bruto", length = 40)
|
||||
private String telefoneBruto;
|
||||
|
||||
@Column(length = 150)
|
||||
private String email;
|
||||
|
||||
private String site;
|
||||
|
||||
@Column(name = "endereco_completo", columnDefinition = "text")
|
||||
private String enderecoCompleto;
|
||||
|
||||
@Column(length = 200)
|
||||
private String logradouro;
|
||||
|
||||
@Column(length = 20)
|
||||
private String numero;
|
||||
|
||||
@Column(length = 100)
|
||||
private String bairro;
|
||||
|
||||
@Column(length = 100)
|
||||
private String cidade;
|
||||
|
||||
@Column(columnDefinition = "char(2)")
|
||||
@JdbcTypeCode(SqlTypes.CHAR)
|
||||
private String uf;
|
||||
|
||||
@Column(columnDefinition = "char(8)")
|
||||
@JdbcTypeCode(SqlTypes.CHAR)
|
||||
private String cep;
|
||||
|
||||
@Column(precision = 10, scale = 7)
|
||||
private BigDecimal latitude;
|
||||
|
||||
@Column(precision = 10, scale = 7)
|
||||
private BigDecimal longitude;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "ramo_id")
|
||||
private Ramo ramo;
|
||||
|
||||
@Column(name = "categoria_bruta", length = 150)
|
||||
private String categoriaBruta;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String fonte = "google_maps";
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "busca_id")
|
||||
private Busca busca;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private StatusLead status = StatusLead.novo;
|
||||
|
||||
@Column(name = "hash_dedup", nullable = false, unique = true, length = 64)
|
||||
private String hashDedup;
|
||||
|
||||
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||
private OffsetDateTime criadoEm;
|
||||
|
||||
@Column(name = "atualizado_em", nullable = false, insertable = false, updatable = false)
|
||||
private OffsetDateTime atualizadoEm;
|
||||
|
||||
@OneToMany(mappedBy = "lead")
|
||||
private List<Telefone> telefones = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "lead")
|
||||
private List<HistoricoContato> historicoContatos = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.seconectai.leadscraper.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "proxies")
|
||||
public class Proxy {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String endereco;
|
||||
|
||||
@Column(length = 20)
|
||||
private String tipo = "residencial";
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(length = 20)
|
||||
private StatusProxy status = StatusProxy.ativo;
|
||||
|
||||
@Column(name = "total_bloqueios")
|
||||
private Integer totalBloqueios = 0;
|
||||
|
||||
@Column(name = "em_uso")
|
||||
private Boolean emUso = false;
|
||||
|
||||
@Column(name = "ultima_utilizacao")
|
||||
private OffsetDateTime ultimaUtilizacao;
|
||||
|
||||
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||
private OffsetDateTime criadoEm;
|
||||
|
||||
@OneToMany(mappedBy = "proxy")
|
||||
private List<Busca> buscas = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.seconectai.leadscraper.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "ramos")
|
||||
public class Ramo {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "nome_canonico", nullable = false, unique = true, length = 100)
|
||||
private String nomeCanonico;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "categoria_pai_id")
|
||||
private Ramo categoriaPai;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(nullable = false, columnDefinition = "jsonb")
|
||||
private List<String> aliases = new ArrayList<>();
|
||||
|
||||
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||
private OffsetDateTime criadoEm;
|
||||
|
||||
@OneToMany(mappedBy = "categoriaPai")
|
||||
private List<Ramo> subcategorias = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "ramo")
|
||||
private List<Busca> buscas = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "ramo")
|
||||
private List<Lead> leads = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.seconectai.leadscraper.domain;
|
||||
|
||||
public enum StatusBusca {
|
||||
pendente,
|
||||
em_execucao,
|
||||
concluida,
|
||||
erro
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.seconectai.leadscraper.domain;
|
||||
|
||||
public enum StatusLead {
|
||||
novo,
|
||||
contatado,
|
||||
qualificado,
|
||||
convertido,
|
||||
descartado
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.seconectai.leadscraper.domain;
|
||||
|
||||
public enum StatusProxy {
|
||||
ativo,
|
||||
degradado,
|
||||
bloqueado
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.seconectai.leadscraper.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.util.Objects;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "telefones")
|
||||
public class Telefone {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "lead_id", nullable = false)
|
||||
private Lead lead;
|
||||
|
||||
@Column(name = "numero_e164", nullable = false, length = 20)
|
||||
private String numeroE164;
|
||||
|
||||
@Column(length = 20)
|
||||
private String tipo = "principal";
|
||||
|
||||
public void setLead(Lead lead) {
|
||||
if (!Objects.equals(this.lead, lead)) {
|
||||
this.lead = lead;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.seconectai.leadscraper.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "usuarios")
|
||||
public class Usuario {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 150)
|
||||
private String nome;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 150)
|
||||
private String email;
|
||||
|
||||
@Column(name = "senha_hash", nullable = false)
|
||||
private String senhaHash;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String papel = "admin";
|
||||
|
||||
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||
private OffsetDateTime criadoEm;
|
||||
|
||||
@OneToMany(mappedBy = "criadoPor")
|
||||
private List<Busca> buscas = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.seconectai.leadscraper.normalizer;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class CnpjValidator {
|
||||
|
||||
public String normalize(String cnpjBruto) {
|
||||
if (cnpjBruto == null) {
|
||||
return null;
|
||||
}
|
||||
String digits = cnpjBruto.replaceAll("\\D", "");
|
||||
if (!digits.matches("\\d{14}") || digits.chars().distinct().count() == 1) {
|
||||
return null;
|
||||
}
|
||||
return isValid(digits) ? digits : null;
|
||||
}
|
||||
|
||||
private boolean isValid(String cnpj) {
|
||||
int firstDigit = calculateDigit(cnpj.substring(0, 12), new int[]{5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2});
|
||||
int secondDigit = calculateDigit(cnpj.substring(0, 12) + firstDigit, new int[]{6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2});
|
||||
return cnpj.equals(cnpj.substring(0, 12) + firstDigit + secondDigit);
|
||||
}
|
||||
|
||||
private int calculateDigit(String base, int[] weights) {
|
||||
int sum = 0;
|
||||
for (int index = 0; index < weights.length; index++) {
|
||||
sum += Character.digit(base.charAt(index), 10) * weights[index];
|
||||
}
|
||||
int remainder = sum % 11;
|
||||
return remainder < 2 ? 0 : 11 - remainder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.seconectai.leadscraper.normalizer;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class DedupHashCalculator {
|
||||
|
||||
public String calculate(String nomeNormalizado, String telefoneE164) {
|
||||
String input = nullToEmpty(nomeNormalizado) + "|" + nullToEmpty(telefoneE164);
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder builder = new StringBuilder(hash.length * 2);
|
||||
for (byte value : hash) {
|
||||
builder.append(String.format("%02x", value));
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 indisponivel", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.seconectai.leadscraper.normalizer;
|
||||
|
||||
public record LeadNormalizadoDTO(
|
||||
String nomeFantasia,
|
||||
String categoriaBruta,
|
||||
Long ramoId,
|
||||
String telefoneE164,
|
||||
String telefoneBruto,
|
||||
String cnpj,
|
||||
String site,
|
||||
String enderecoCompleto,
|
||||
String placeId,
|
||||
String hashDedup
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.seconectai.leadscraper.normalizer;
|
||||
|
||||
import com.seconectai.leadscraper.scraper.LeadBrutoDTO;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class LeadNormalizerService {
|
||||
|
||||
private final NomeNormalizer nomeNormalizer;
|
||||
private final TelefoneNormalizer telefoneNormalizer;
|
||||
private final CnpjValidator cnpjValidator;
|
||||
private final RamoMapper ramoMapper;
|
||||
private final DedupHashCalculator dedupHashCalculator;
|
||||
|
||||
public LeadNormalizerService(
|
||||
NomeNormalizer nomeNormalizer,
|
||||
TelefoneNormalizer telefoneNormalizer,
|
||||
CnpjValidator cnpjValidator,
|
||||
RamoMapper ramoMapper,
|
||||
DedupHashCalculator dedupHashCalculator
|
||||
) {
|
||||
this.nomeNormalizer = nomeNormalizer;
|
||||
this.telefoneNormalizer = telefoneNormalizer;
|
||||
this.cnpjValidator = cnpjValidator;
|
||||
this.ramoMapper = ramoMapper;
|
||||
this.dedupHashCalculator = dedupHashCalculator;
|
||||
}
|
||||
|
||||
public LeadNormalizadoDTO normalize(LeadBrutoDTO leadBruto) {
|
||||
String nome = nomeNormalizer.normalize(leadBruto.nome());
|
||||
String telefoneE164 = telefoneNormalizer.normalize(leadBruto.telefoneBruto());
|
||||
String cnpj = cnpjValidator.normalize(null);
|
||||
Long ramoId = ramoMapper.mapToRamoId(leadBruto.categoriaBruta()).orElse(null);
|
||||
String hashDedup = dedupHashCalculator.calculate(nome, telefoneE164);
|
||||
|
||||
return new LeadNormalizadoDTO(
|
||||
nome,
|
||||
leadBruto.categoriaBruta(),
|
||||
ramoId,
|
||||
telefoneE164,
|
||||
leadBruto.telefoneBruto(),
|
||||
cnpj,
|
||||
leadBruto.site(),
|
||||
leadBruto.enderecoBruto(),
|
||||
leadBruto.placeId(),
|
||||
hashDedup
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.seconectai.leadscraper.normalizer;
|
||||
|
||||
import java.text.Normalizer;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class NomeNormalizer {
|
||||
|
||||
private static final Set<String> SIGLAS = Set.of("LTDA", "ME", "EIRELI", "S/A", "S.A.");
|
||||
|
||||
public String normalize(String nome) {
|
||||
if (nome == null) {
|
||||
return null;
|
||||
}
|
||||
String cleaned = nome.codePoints()
|
||||
.filter(this::isAllowedTextCodePoint)
|
||||
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
|
||||
.toString()
|
||||
.trim()
|
||||
.replaceAll("\\s+", " ");
|
||||
if (cleaned.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = cleaned.split(" ");
|
||||
for (int index = 0; index < parts.length; index++) {
|
||||
parts[index] = normalizeWord(parts[index]);
|
||||
}
|
||||
return String.join(" ", parts);
|
||||
}
|
||||
|
||||
private String normalizeWord(String word) {
|
||||
String upper = word.toUpperCase(Locale.ROOT);
|
||||
if (SIGLAS.contains(upper)) {
|
||||
return upper;
|
||||
}
|
||||
if (word.length() == 1) {
|
||||
return word.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
return word.substring(0, 1).toUpperCase(Locale.ROOT)
|
||||
+ word.substring(1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private boolean isAllowedTextCodePoint(int codePoint) {
|
||||
int type = Character.getType(codePoint);
|
||||
if (type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE) {
|
||||
return false;
|
||||
}
|
||||
String value = new String(Character.toChars(codePoint));
|
||||
return Normalizer.normalize(value, Normalizer.Form.NFKC).codePoints()
|
||||
.noneMatch(cp -> Character.getType(cp) == Character.OTHER_SYMBOL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.seconectai.leadscraper.normalizer;
|
||||
|
||||
import com.seconectai.leadscraper.domain.Ramo;
|
||||
import com.seconectai.leadscraper.repository.RamoRepository;
|
||||
import java.text.Normalizer;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class RamoMapper {
|
||||
|
||||
private final RamoRepository ramoRepository;
|
||||
|
||||
public RamoMapper(RamoRepository ramoRepository) {
|
||||
this.ramoRepository = ramoRepository;
|
||||
}
|
||||
|
||||
public Optional<Long> mapToRamoId(String categoriaBruta) {
|
||||
String normalized = normalize(categoriaBruta);
|
||||
if (normalized == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return ramoRepository.findAll().stream()
|
||||
.filter(ramo -> aliasesContain(ramo, normalized))
|
||||
.map(Ramo::getId)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
private boolean aliasesContain(Ramo ramo, String categoriaNormalizada) {
|
||||
if (normalize(ramo.getNomeCanonico()).equals(categoriaNormalizada)) {
|
||||
return true;
|
||||
}
|
||||
return ramo.getAliases() != null && ramo.getAliases().stream()
|
||||
.map(this::normalize)
|
||||
.anyMatch(categoriaNormalizada::equals);
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFD)
|
||||
.replaceAll("\\p{M}", "")
|
||||
.replaceAll("\\s+", " ")
|
||||
.toLowerCase(Locale.ROOT);
|
||||
return normalized.isBlank() ? null : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.seconectai.leadscraper.normalizer;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class TelefoneNormalizer {
|
||||
|
||||
public String normalize(String telefoneBruto) {
|
||||
if (telefoneBruto == null) {
|
||||
return null;
|
||||
}
|
||||
String digits = telefoneBruto.replaceAll("\\D", "");
|
||||
if (digits.startsWith("00")) {
|
||||
digits = digits.substring(2);
|
||||
}
|
||||
if (digits.startsWith("55")) {
|
||||
digits = digits.substring(2);
|
||||
}
|
||||
if (!digits.matches("\\d{10,11}")) {
|
||||
return null;
|
||||
}
|
||||
String ddd = digits.substring(0, 2);
|
||||
String numero = digits.substring(2);
|
||||
if (!ddd.matches("[1-9]{2}") || !(numero.length() == 8 || numero.length() == 9)) {
|
||||
return null;
|
||||
}
|
||||
return "+55" + ddd + numero;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.seconectai.leadscraper.proxy;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class BackoffService {
|
||||
|
||||
private final ProxySettingsProperties properties;
|
||||
private final AtomicInteger consecutiveBlocks = new AtomicInteger();
|
||||
|
||||
public BackoffService(ProxySettingsProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public long registrarBloqueioECalcularEspera() {
|
||||
int failures = consecutiveBlocks.incrementAndGet();
|
||||
double multiplier = Math.pow(properties.backoff().multiplier(), failures - 1);
|
||||
long calculated = (long) (properties.backoff().initialMs() * multiplier);
|
||||
return Math.min(calculated, properties.backoff().maxMs());
|
||||
}
|
||||
|
||||
public void registrarSucesso() {
|
||||
consecutiveBlocks.set(0);
|
||||
}
|
||||
|
||||
public int limiteBuscasHoraPorProxy() {
|
||||
return properties.limiteBuscasHora();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.seconectai.leadscraper.proxy;
|
||||
|
||||
import com.microsoft.playwright.Page;
|
||||
import com.seconectai.leadscraper.domain.Proxy;
|
||||
import com.seconectai.leadscraper.domain.StatusProxy;
|
||||
import com.seconectai.leadscraper.repository.ProxyRepository;
|
||||
import java.util.Locale;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class BloqueioDetector {
|
||||
|
||||
private final ProxyRepository proxyRepository;
|
||||
private final ProxySettingsProperties properties;
|
||||
|
||||
public BloqueioDetector(ProxyRepository proxyRepository, ProxySettingsProperties properties) {
|
||||
this.proxyRepository = proxyRepository;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public boolean isBlocked(Page page) {
|
||||
String content = page.content().toLowerCase(Locale.ROOT);
|
||||
return content.contains("unusual traffic")
|
||||
|| content.contains("trafego incomum")
|
||||
|| content.contains("tráfego incomum")
|
||||
|| content.contains("captcha")
|
||||
|| content.contains("/sorry/");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void registrarBloqueio(Proxy proxy) {
|
||||
if (proxy == null || proxy.getId() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
proxyRepository.findById(proxy.getId()).ifPresent(atual -> {
|
||||
int total = atual.getTotalBloqueios() == null ? 1 : atual.getTotalBloqueios() + 1;
|
||||
atual.setTotalBloqueios(total);
|
||||
atual.setEmUso(false);
|
||||
atual.setStatus(total >= properties.maxBloqueios() ? StatusProxy.bloqueado : StatusProxy.degradado);
|
||||
proxyRepository.save(atual);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.seconectai.leadscraper.proxy;
|
||||
|
||||
public class BloqueioException extends RuntimeException {
|
||||
|
||||
public BloqueioException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.seconectai.leadscraper.proxy;
|
||||
|
||||
import com.seconectai.leadscraper.domain.Proxy;
|
||||
import com.seconectai.leadscraper.repository.ProxyRepository;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class ProxyPoolService {
|
||||
|
||||
private final ProxyRepository proxyRepository;
|
||||
|
||||
public ProxyPoolService(ProxyRepository proxyRepository) {
|
||||
this.proxyRepository = proxyRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Optional<Proxy> reservarDisponivel() {
|
||||
Optional<Proxy> proxy = proxyRepository.findNextAvailableForUpdate();
|
||||
proxy.ifPresent(disponivel -> {
|
||||
disponivel.setEmUso(true);
|
||||
proxyRepository.save(disponivel);
|
||||
});
|
||||
return proxy;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void liberar(Proxy proxy) {
|
||||
if (proxy == null || proxy.getId() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
proxyRepository.findById(proxy.getId()).ifPresent(atual -> {
|
||||
atual.setEmUso(false);
|
||||
atual.setUltimaUtilizacao(OffsetDateTime.now());
|
||||
proxyRepository.save(atual);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.seconectai.leadscraper.proxy;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "scraper.proxy")
|
||||
public record ProxySettingsProperties(
|
||||
int maxBloqueios,
|
||||
int limiteBuscasHora,
|
||||
Backoff backoff
|
||||
) {
|
||||
|
||||
public record Backoff(
|
||||
long initialMs,
|
||||
long maxMs,
|
||||
double multiplier
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.seconectai.leadscraper.repository;
|
||||
|
||||
import com.seconectai.leadscraper.domain.Busca;
|
||||
import com.seconectai.leadscraper.domain.StatusBusca;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
public interface BuscaRepository extends JpaRepository<Busca, Long>, JpaSpecificationExecutor<Busca> {
|
||||
|
||||
List<Busca> findByStatus(StatusBusca status);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.seconectai.leadscraper.repository;
|
||||
|
||||
import com.seconectai.leadscraper.domain.HistoricoContato;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface HistoricoContatoRepository extends JpaRepository<HistoricoContato, Long> {
|
||||
|
||||
List<HistoricoContato> findByLeadId(Long leadId);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.seconectai.leadscraper.repository;
|
||||
|
||||
import com.seconectai.leadscraper.domain.Lead;
|
||||
import com.seconectai.leadscraper.domain.StatusLead;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
public interface LeadRepository extends JpaRepository<Lead, Long>, JpaSpecificationExecutor<Lead> {
|
||||
|
||||
Optional<Lead> findByHashDedup(String hashDedup);
|
||||
|
||||
Optional<Lead> findByCnpj(String cnpj);
|
||||
|
||||
Optional<Lead> findByPlaceId(String placeId);
|
||||
|
||||
List<Lead> findByStatus(StatusLead status);
|
||||
|
||||
List<Lead> findByBuscaId(Long buscaId);
|
||||
|
||||
long countByBuscaId(Long buscaId);
|
||||
|
||||
long countByTelefoneE164IsNotNull();
|
||||
|
||||
long countByBuscaIdAndTelefoneE164IsNotNull(Long buscaId);
|
||||
|
||||
@Query("select count(distinct lead.telefoneE164) from Lead lead where lead.telefoneE164 is not null and lead.telefoneE164 <> ''")
|
||||
long countDistinctTelefonesCapturados();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.seconectai.leadscraper.repository;
|
||||
|
||||
import com.seconectai.leadscraper.domain.Proxy;
|
||||
import com.seconectai.leadscraper.domain.StatusProxy;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
public interface ProxyRepository extends JpaRepository<Proxy, Long> {
|
||||
|
||||
List<Proxy> findByStatus(StatusProxy status);
|
||||
|
||||
@Query(value = """
|
||||
select * from proxies
|
||||
where status = 'ativo'
|
||||
and coalesce(em_uso, false) = false
|
||||
order by ultima_utilizacao asc nulls first, id asc
|
||||
for update skip locked
|
||||
limit 1
|
||||
""", nativeQuery = true)
|
||||
Optional<Proxy> findNextAvailableForUpdate();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user