Initial commit

This commit is contained in:
tiago
2026-10-01 11:00:47 -03:00
commit 9d2ab731ee
149 changed files with 11351 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
target/
frontend/node_modules/
frontend/dist/
.git/
.idea/
.vscode/
*.log
.env
+23
View File
@@ -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
+24
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
.env
target/
frontend/node_modules/
frontend/dist/
*.log
.idea/
.vscode/
*.iml
+7
View File
@@ -0,0 +1,7 @@
escavador.seconectai.com.br {
encode gzip zstd
handle {
reverse_proxy frontend:80
}
}
+52
View File
@@ -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
View File
@@ -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"]
+217
View File
@@ -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
+114
View File
@@ -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
+32
View File
@@ -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
+41
View File
@@ -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
+64
View File
@@ -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
+71
View File
@@ -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:
+59
View File
@@ -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:
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.env
+17
View File
@@ -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
+25
View File
@@ -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 }],
},
},
);
+12
View File
@@ -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>
+20
View File
@@ -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;
}
}
+4612
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+27
View File
@@ -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 />;
}
+30
View File
@@ -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>
);
}
+14
View File
@@ -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}
/>
);
}
+39
View File
@@ -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} />;
}
+22
View File
@@ -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} />;
}
+14
View File
@@ -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}
/>
);
}
+14
View File
@@ -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}
/>
);
}
+26
View File
@@ -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} />;
}
+62
View File
@@ -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;
}
+40
View File
@@ -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;
}
+44
View File
@@ -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;
}
+114
View File
@@ -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"),
};
+160
View File
@@ -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"];
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+21
View File
@@ -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>,
);
+175
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+171
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+66
View File
@@ -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[];
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+47
View File
@@ -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;
+22
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+13
View File
@@ -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"]
}
+9
View File
@@ -0,0 +1,9 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
},
});
+125
View File
@@ -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>
+23
View File
@@ -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"
+9
View File
@@ -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"
+60
View File
@@ -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."
+47
View File
@@ -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
+13
View File
@@ -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
+25
View File
@@ -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