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
@@ -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