From 9d2ab731ee24408693aacf33e52fbe22f7c37817 Mon Sep 17 00:00:00 2001 From: tiago Date: Thu, 1 Oct 2026 11:00:47 -0300 Subject: [PATCH] Initial commit --- .dockerignore | 8 + .env.example | 23 + .env.production.example | 24 + .gitignore | 8 + Caddyfile | 7 + DEPLOY.md | 52 + Dockerfile | 51 + Schema/ARQUITETURA-SCHEMA.md | 217 + Schema/LEIA-ME.md | 114 + Schema/PROGRESSO.md | 32 + .../etapas/01-setup-projeto-docker-flyway.md | 49 + Schema/etapas/02-modelagem-entidades-jpa.md | 33 + Schema/etapas/03-motor-scraper-playwright.md | 33 + Schema/etapas/04-normalizador-padronizacao.md | 43 + .../etapas/05-pool-proxies-anti-bloqueio.md | 36 + Schema/etapas/06-api-rest-backend.md | 41 + Schema/etapas/07-frontend-setup-dashboard.md | 37 + Schema/etapas/08-frontend-integracao-api.md | 33 + Schema/etapas/09-docker-compose-deploy.md | 64 + Schema/etapas/10-testes-validacao-final.md | 40 + docker-compose.prod.yml | 71 + docker-compose.yml | 59 + frontend/.dockerignore | 4 + frontend/Dockerfile | 17 + frontend/eslint.config.js | 25 + frontend/index.html | 12 + frontend/nginx.conf | 20 + frontend/package-lock.json | 4612 +++++++++++++++++ frontend/package.json | 43 + frontend/postcss.config.js | 6 + frontend/src/App.tsx | 27 + frontend/src/components/ProtectedRoute.tsx | 16 + frontend/src/components/StatusBadge.tsx | 30 + frontend/src/components/layout/AppLayout.tsx | 107 + frontend/src/components/ui/badge.tsx | 14 + frontend/src/components/ui/button.tsx | 39 + frontend/src/components/ui/card.tsx | 22 + frontend/src/components/ui/input.tsx | 14 + frontend/src/components/ui/select.tsx | 14 + frontend/src/components/ui/table.tsx | 26 + frontend/src/hooks/useAuth.tsx | 62 + frontend/src/hooks/useMockAuth.tsx | 40 + frontend/src/index.css | 44 + frontend/src/lib/api.ts | 114 + frontend/src/lib/mock-data.ts | 160 + frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 21 + frontend/src/pages/BuscasPage.tsx | 175 + frontend/src/pages/CuradoriaRamosPage.tsx | 122 + frontend/src/pages/DashboardPage.tsx | 110 + frontend/src/pages/LeadsPage.tsx | 171 + frontend/src/pages/LoginPage.tsx | 79 + frontend/src/pages/NovaBuscaPage.tsx | 105 + frontend/src/types/domain.ts | 66 + frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.ts | 47 + frontend/tsconfig.app.json | 22 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 13 + frontend/vite.config.ts | 9 + pom.xml | 125 + scripts/bootstrap-commands.sh | 23 + scripts/install-commands.sh | 9 + scripts/install-prod.sh | 60 + scripts/seconectaiescavador.install | 47 + scripts/seconectaiescavador.update | 13 + scripts/update-prod.sh | 25 + .../leadscraper/LeadScraperApplication.java | 14 + .../leadscraper/config/ScraperConfig.java | 16 + .../leadscraper/config/SecurityConfig.java | 45 + .../controller/AuthController.java | 47 + .../controller/BuscaController.java | 124 + .../controller/LeadController.java | 229 + .../controller/RamoController.java | 67 + .../seconectai/leadscraper/domain/Busca.java | 85 + .../leadscraper/domain/HistoricoContato.java | 41 + .../seconectai/leadscraper/domain/Lead.java | 119 + .../seconectai/leadscraper/domain/Proxy.java | 52 + .../seconectai/leadscraper/domain/Ramo.java | 53 + .../leadscraper/domain/StatusBusca.java | 8 + .../leadscraper/domain/StatusLead.java | 9 + .../leadscraper/domain/StatusProxy.java | 7 + .../leadscraper/domain/Telefone.java | 41 + .../leadscraper/domain/Usuario.java | 43 + .../leadscraper/normalizer/CnpjValidator.java | 33 + .../normalizer/DedupHashCalculator.java | 29 + .../normalizer/LeadNormalizadoDTO.java | 15 + .../normalizer/LeadNormalizerService.java | 49 + .../normalizer/NomeNormalizer.java | 54 + .../leadscraper/normalizer/RamoMapper.java | 49 + .../normalizer/TelefoneNormalizer.java | 29 + .../leadscraper/proxy/BackoffService.java | 30 + .../leadscraper/proxy/BloqueioDetector.java | 45 + .../leadscraper/proxy/BloqueioException.java | 8 + .../leadscraper/proxy/ProxyPoolService.java | 41 + .../proxy/ProxySettingsProperties.java | 18 + .../repository/BuscaRepository.java | 12 + .../HistoricoContatoRepository.java | 10 + .../repository/LeadRepository.java | 32 + .../repository/ProxyRepository.java | 23 + .../repository/RamoRepository.java | 10 + .../repository/TelefoneRepository.java | 10 + .../repository/UsuarioRepository.java | 10 + .../scraper/BuscaOrchestrator.java | 194 + .../scraper/DelayHumanizadoService.java | 30 + .../scraper/GoogleMapsScraperProperties.java | 27 + .../scraper/GoogleMapsScraperService.java | 73 + .../leadscraper/scraper/LeadBrutoDTO.java | 13 + .../leadscraper/scraper/LeadRawExtractor.java | 240 + .../scraper/NeighborhoodExpansionService.java | 135 + .../scraper/OverpassScraperProperties.java | 12 + .../scraper/OverpassScraperService.java | 234 + .../scraper/ScraperFingerprint.java | 9 + .../scraper/ScraperFingerprintService.java | 19 + .../leadscraper/scraper/ScraperSession.java | 20 + .../scraper/ScrollExtractorService.java | 118 + .../security/DatabaseUserDetailsService.java | 32 + .../security/JwtAuthenticationFilter.java | 55 + .../leadscraper/security/JwtProperties.java | 7 + .../leadscraper/security/JwtService.java | 51 + .../service/BuscaProgressService.java | 39 + .../service/LeadPersistenceService.java | 65 + .../leadscraper/worker/BuscaWorker.java | 70 + src/main/resources/application.yml | 56 + .../db/migration/V10__seed_admin_user.sql | 8 + .../migration/V11__update_admin_password.sql | 3 + ...12__update_bootstrap_admin_credentials.sql | 12 + .../V13__add_busca_neighborhood_expansion.sql | 2 + .../V14__add_busca_region_summary.sql | 5 + src/main/resources/db/migration/V1__init.sql | 1 + .../db/migration/V2__create_usuarios.sql | 8 + .../db/migration/V3__create_ramos.sql | 7 + .../db/migration/V4__create_proxies.sql | 9 + .../db/migration/V5__create_buscas.sql | 14 + .../db/migration/V6__create_leads.sql | 33 + .../db/migration/V7__create_telefones.sql | 7 + .../V8__create_historico_contato.sql | 8 + .../db/migration/V9__add_proxy_usage_flag.sql | 2 + .../LeadScraperApplicationTests.java | 11 + ...RepositoryPersistenceIntegrationTests.java | 120 + .../AuthControllerIntegrationTests.java | 57 + .../normalizer/CnpjValidatorTests.java | 21 + .../normalizer/DedupHashCalculatorTests.java | 19 + ...LeadNormalizerServiceIntegrationTests.java | 67 + .../normalizer/NomeNormalizerTests.java | 22 + .../normalizer/TelefoneNormalizerTests.java | 22 + .../proxy/BackoffServiceTests.java | 27 + .../scraper/LeadRawExtractorTests.java | 43 + .../ScraperFingerprintServiceTests.java | 18 + 149 files changed, 11351 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 Schema/ARQUITETURA-SCHEMA.md create mode 100644 Schema/LEIA-ME.md create mode 100644 Schema/PROGRESSO.md create mode 100644 Schema/etapas/01-setup-projeto-docker-flyway.md create mode 100644 Schema/etapas/02-modelagem-entidades-jpa.md create mode 100644 Schema/etapas/03-motor-scraper-playwright.md create mode 100644 Schema/etapas/04-normalizador-padronizacao.md create mode 100644 Schema/etapas/05-pool-proxies-anti-bloqueio.md create mode 100644 Schema/etapas/06-api-rest-backend.md create mode 100644 Schema/etapas/07-frontend-setup-dashboard.md create mode 100644 Schema/etapas/08-frontend-integracao-api.md create mode 100644 Schema/etapas/09-docker-compose-deploy.md create mode 100644 Schema/etapas/10-testes-validacao-final.md create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/components/StatusBadge.tsx create mode 100644 frontend/src/components/layout/AppLayout.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/hooks/useAuth.tsx create mode 100644 frontend/src/hooks/useMockAuth.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/mock-data.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/BuscasPage.tsx create mode 100644 frontend/src/pages/CuradoriaRamosPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/LeadsPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/NovaBuscaPage.tsx create mode 100644 frontend/src/types/domain.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 pom.xml create mode 100755 scripts/bootstrap-commands.sh create mode 100755 scripts/install-commands.sh create mode 100755 scripts/install-prod.sh create mode 100755 scripts/seconectaiescavador.install create mode 100755 scripts/seconectaiescavador.update create mode 100755 scripts/update-prod.sh create mode 100644 src/main/java/com/seconectai/leadscraper/LeadScraperApplication.java create mode 100644 src/main/java/com/seconectai/leadscraper/config/ScraperConfig.java create mode 100644 src/main/java/com/seconectai/leadscraper/config/SecurityConfig.java create mode 100644 src/main/java/com/seconectai/leadscraper/controller/AuthController.java create mode 100644 src/main/java/com/seconectai/leadscraper/controller/BuscaController.java create mode 100644 src/main/java/com/seconectai/leadscraper/controller/LeadController.java create mode 100644 src/main/java/com/seconectai/leadscraper/controller/RamoController.java create mode 100644 src/main/java/com/seconectai/leadscraper/domain/Busca.java create mode 100644 src/main/java/com/seconectai/leadscraper/domain/HistoricoContato.java create mode 100644 src/main/java/com/seconectai/leadscraper/domain/Lead.java create mode 100644 src/main/java/com/seconectai/leadscraper/domain/Proxy.java create mode 100644 src/main/java/com/seconectai/leadscraper/domain/Ramo.java create mode 100644 src/main/java/com/seconectai/leadscraper/domain/StatusBusca.java create mode 100644 src/main/java/com/seconectai/leadscraper/domain/StatusLead.java create mode 100644 src/main/java/com/seconectai/leadscraper/domain/StatusProxy.java create mode 100644 src/main/java/com/seconectai/leadscraper/domain/Telefone.java create mode 100644 src/main/java/com/seconectai/leadscraper/domain/Usuario.java create mode 100644 src/main/java/com/seconectai/leadscraper/normalizer/CnpjValidator.java create mode 100644 src/main/java/com/seconectai/leadscraper/normalizer/DedupHashCalculator.java create mode 100644 src/main/java/com/seconectai/leadscraper/normalizer/LeadNormalizadoDTO.java create mode 100644 src/main/java/com/seconectai/leadscraper/normalizer/LeadNormalizerService.java create mode 100644 src/main/java/com/seconectai/leadscraper/normalizer/NomeNormalizer.java create mode 100644 src/main/java/com/seconectai/leadscraper/normalizer/RamoMapper.java create mode 100644 src/main/java/com/seconectai/leadscraper/normalizer/TelefoneNormalizer.java create mode 100644 src/main/java/com/seconectai/leadscraper/proxy/BackoffService.java create mode 100644 src/main/java/com/seconectai/leadscraper/proxy/BloqueioDetector.java create mode 100644 src/main/java/com/seconectai/leadscraper/proxy/BloqueioException.java create mode 100644 src/main/java/com/seconectai/leadscraper/proxy/ProxyPoolService.java create mode 100644 src/main/java/com/seconectai/leadscraper/proxy/ProxySettingsProperties.java create mode 100644 src/main/java/com/seconectai/leadscraper/repository/BuscaRepository.java create mode 100644 src/main/java/com/seconectai/leadscraper/repository/HistoricoContatoRepository.java create mode 100644 src/main/java/com/seconectai/leadscraper/repository/LeadRepository.java create mode 100644 src/main/java/com/seconectai/leadscraper/repository/ProxyRepository.java create mode 100644 src/main/java/com/seconectai/leadscraper/repository/RamoRepository.java create mode 100644 src/main/java/com/seconectai/leadscraper/repository/TelefoneRepository.java create mode 100644 src/main/java/com/seconectai/leadscraper/repository/UsuarioRepository.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/BuscaOrchestrator.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/DelayHumanizadoService.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/GoogleMapsScraperProperties.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/GoogleMapsScraperService.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/LeadBrutoDTO.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/LeadRawExtractor.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/NeighborhoodExpansionService.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/OverpassScraperProperties.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/OverpassScraperService.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/ScraperFingerprint.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/ScraperFingerprintService.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/ScraperSession.java create mode 100644 src/main/java/com/seconectai/leadscraper/scraper/ScrollExtractorService.java create mode 100644 src/main/java/com/seconectai/leadscraper/security/DatabaseUserDetailsService.java create mode 100644 src/main/java/com/seconectai/leadscraper/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/seconectai/leadscraper/security/JwtProperties.java create mode 100644 src/main/java/com/seconectai/leadscraper/security/JwtService.java create mode 100644 src/main/java/com/seconectai/leadscraper/service/BuscaProgressService.java create mode 100644 src/main/java/com/seconectai/leadscraper/service/LeadPersistenceService.java create mode 100644 src/main/java/com/seconectai/leadscraper/worker/BuscaWorker.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration/V10__seed_admin_user.sql create mode 100644 src/main/resources/db/migration/V11__update_admin_password.sql create mode 100644 src/main/resources/db/migration/V12__update_bootstrap_admin_credentials.sql create mode 100644 src/main/resources/db/migration/V13__add_busca_neighborhood_expansion.sql create mode 100644 src/main/resources/db/migration/V14__add_busca_region_summary.sql create mode 100644 src/main/resources/db/migration/V1__init.sql create mode 100644 src/main/resources/db/migration/V2__create_usuarios.sql create mode 100644 src/main/resources/db/migration/V3__create_ramos.sql create mode 100644 src/main/resources/db/migration/V4__create_proxies.sql create mode 100644 src/main/resources/db/migration/V5__create_buscas.sql create mode 100644 src/main/resources/db/migration/V6__create_leads.sql create mode 100644 src/main/resources/db/migration/V7__create_telefones.sql create mode 100644 src/main/resources/db/migration/V8__create_historico_contato.sql create mode 100644 src/main/resources/db/migration/V9__add_proxy_usage_flag.sql create mode 100644 src/test/java/com/seconectai/leadscraper/LeadScraperApplicationTests.java create mode 100644 src/test/java/com/seconectai/leadscraper/RepositoryPersistenceIntegrationTests.java create mode 100644 src/test/java/com/seconectai/leadscraper/controller/AuthControllerIntegrationTests.java create mode 100644 src/test/java/com/seconectai/leadscraper/normalizer/CnpjValidatorTests.java create mode 100644 src/test/java/com/seconectai/leadscraper/normalizer/DedupHashCalculatorTests.java create mode 100644 src/test/java/com/seconectai/leadscraper/normalizer/LeadNormalizerServiceIntegrationTests.java create mode 100644 src/test/java/com/seconectai/leadscraper/normalizer/NomeNormalizerTests.java create mode 100644 src/test/java/com/seconectai/leadscraper/normalizer/TelefoneNormalizerTests.java create mode 100644 src/test/java/com/seconectai/leadscraper/proxy/BackoffServiceTests.java create mode 100644 src/test/java/com/seconectai/leadscraper/scraper/LeadRawExtractorTests.java create mode 100644 src/test/java/com/seconectai/leadscraper/scraper/ScraperFingerprintServiceTests.java diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..48cab1e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +target/ +frontend/node_modules/ +frontend/dist/ +.git/ +.idea/ +.vscode/ +*.log +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a2b6263 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..21bc2d3 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7e48ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +target/ +frontend/node_modules/ +frontend/dist/ +*.log +.idea/ +.vscode/ +*.iml diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..8412de8 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,7 @@ +escavador.seconectai.com.br { + encode gzip zstd + + handle { + reverse_proxy frontend:80 + } +} diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..ed70ce8 --- /dev/null +++ b/DEPLOY.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4230139 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Schema/ARQUITETURA-SCHEMA.md b/Schema/ARQUITETURA-SCHEMA.md new file mode 100644 index 0000000..0e3467f --- /dev/null +++ b/Schema/ARQUITETURA-SCHEMA.md @@ -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 diff --git a/Schema/LEIA-ME.md b/Schema/LEIA-ME.md new file mode 100644 index 0000000..6941818 --- /dev/null +++ b/Schema/LEIA-ME.md @@ -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 diff --git a/Schema/PROGRESSO.md b/Schema/PROGRESSO.md new file mode 100644 index 0000000..05e8413 --- /dev/null +++ b/Schema/PROGRESSO.md @@ -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. diff --git a/Schema/etapas/01-setup-projeto-docker-flyway.md b/Schema/etapas/01-setup-projeto-docker-flyway.md new file mode 100644 index 0000000..c71e1ed --- /dev/null +++ b/Schema/etapas/01-setup-projeto-docker-flyway.md @@ -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: + 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 diff --git a/Schema/etapas/02-modelagem-entidades-jpa.md b/Schema/etapas/02-modelagem-entidades-jpa.md new file mode 100644 index 0000000..2744ed6 --- /dev/null +++ b/Schema/etapas/02-modelagem-entidades-jpa.md @@ -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` diff --git a/Schema/etapas/03-motor-scraper-playwright.md b/Schema/etapas/03-motor-scraper-playwright.md new file mode 100644 index 0000000..cf7c942 --- /dev/null +++ b/Schema/etapas/03-motor-scraper-playwright.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 diff --git a/Schema/etapas/04-normalizador-padronizacao.md b/Schema/etapas/04-normalizador-padronizacao.md new file mode 100644 index 0000000..35ed582 --- /dev/null +++ b/Schema/etapas/04-normalizador-padronizacao.md @@ -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 diff --git a/Schema/etapas/05-pool-proxies-anti-bloqueio.md b/Schema/etapas/05-pool-proxies-anti-bloqueio.md new file mode 100644 index 0000000..101827c --- /dev/null +++ b/Schema/etapas/05-pool-proxies-anti-bloqueio.md @@ -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 diff --git a/Schema/etapas/06-api-rest-backend.md b/Schema/etapas/06-api-rest-backend.md new file mode 100644 index 0000000..20d600d --- /dev/null +++ b/Schema/etapas/06-api-rest-backend.md @@ -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 diff --git a/Schema/etapas/07-frontend-setup-dashboard.md b/Schema/etapas/07-frontend-setup-dashboard.md new file mode 100644 index 0000000..52d3822 --- /dev/null +++ b/Schema/etapas/07-frontend-setup-dashboard.md @@ -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 diff --git a/Schema/etapas/08-frontend-integracao-api.md b/Schema/etapas/08-frontend-integracao-api.md new file mode 100644 index 0000000..1828fac --- /dev/null +++ b/Schema/etapas/08-frontend-integracao-api.md @@ -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 diff --git a/Schema/etapas/09-docker-compose-deploy.md b/Schema/etapas/09-docker-compose-deploy.md new file mode 100644 index 0000000..c2f7456 --- /dev/null +++ b/Schema/etapas/09-docker-compose-deploy.md @@ -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 diff --git a/Schema/etapas/10-testes-validacao-final.md b/Schema/etapas/10-testes-validacao-final.md new file mode 100644 index 0000000..7caba69 --- /dev/null +++ b/Schema/etapas/10-testes-validacao-final.md @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..9c862e2 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..952515b --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..dd8fe26 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.env diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9d0ba34 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5b0cd9c --- /dev/null +++ b/frontend/eslint.config.js @@ -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 }], + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..eb1d1bb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + LeadScraper + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..831c1c7 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ec18391 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4612 @@ +{ + "name": "leadscraper-dashboard", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "leadscraper-dashboard", + "version": "0.0.1", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.103.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.2.tgz", + "integrity": "sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.103.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.2.tgz", + "integrity": "sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.103.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/autoprefixer": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.433", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", + "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.3.tgz", + "integrity": "sha512-8unmvA0qrfpFWBYUate9BTGtQdKHWBYt+KdAIkag7Vvxe7wz8Q2nJg7fLMC0bJqe0bjP31kca7Ckaawrs4Istg==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hook-form": { + "version": "7.88.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz", + "integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9b1bd1d --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6c1056d --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + + + } /> + + ); +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..001bc06 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,16 @@ +import { Outlet } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; + +export function ProtectedRoute() { + const { isLoading } = useAuth(); + + if (isLoading) { + return ( +
+ Carregando... +
+ ); + } + + return ; +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..4f62b4c --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,30 @@ +import type { StatusBusca, StatusLead } from "../types/domain"; +import { Badge } from "./ui/badge"; + +const styles: Record = { + 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 = { + 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 {labels[status]}; +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..97db85d --- /dev/null +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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 = { + "/": "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 ( +
+ + +
+
+
+ +
+

+ {pageTitles[location.pathname] ?? "LeadScraper"} +

+

{userEmail}

+
+
+
+ +
+
+ +
+
+
+ + +
+ ); +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..c0fbb53 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,14 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +export function Badge({ className, ...props }: HTMLAttributes) { + return ( + + ); +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..2df60bc --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean; +} + +export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { + const Comp = asChild ? Slot : "button"; + return ; +} diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..4369cd0 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,22 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +export function Card({ className, ...props }: HTMLAttributes) { + return
; +} + +export function CardHeader({ className, ...props }: HTMLAttributes) { + return
; +} + +export function CardTitle({ className, ...props }: HTMLAttributes) { + return

; +} + +export function CardDescription({ className, ...props }: HTMLAttributes) { + return

; +} + +export function CardContent({ className, ...props }: HTMLAttributes) { + return

; +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..064e74e --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,14 @@ +import type { InputHTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +export function Input({ className, ...props }: InputHTMLAttributes) { + return ( + + ); +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..f67de74 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,14 @@ +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +export function Select({ className, ...props }: SelectHTMLAttributes) { + return ( + setStatus(event.target.value as StatusBusca | "")}> + + {statusOptions.map((option) => ( + + ))} + + + + + + + + Histórico + + + + + + ID + Termo + Localização + Ramo + Status + Regiões + Únicos salvos + Telefones + Criada em + Ações + + + + {buscas.map((busca) => ( + + #{busca.id} + {busca.termoBusca} + {busca.localizacao} + {busca.ramoId ? `#${busca.ramoId}` : "Sem ramo"} + + + + + + + {busca.totalEncontrados ?? 0} + {busca.telefonesCapturados ?? 0} + {new Date(busca.criadoEm).toLocaleString("pt-BR")} + + + + + ))} + {buscasQuery.isLoading && ( + + Carregando buscas... + + )} + {buscasQuery.isError && ( + + + Não foi possível carregar as buscas. + + + )} + +
+
+
+
+ ); +} + +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 ( +
+
+ Preparando bairros + 0% +
+
+
+
+
+ ); + } + return -; + } + + const detalhes = busca.detalheRegioes + ?.split("\n") + .map((linha) => linha.trim()) + .filter(Boolean); + + return ( +
+
+ + {processadas}/{total} bairros + + {progresso}% +
+
+
+
+
+
+
+
+ {total} bairros + + {sucesso} ok + + {erro} erro +
+ {!!detalhes?.length && ( +
+ Ver detalhes +
+
Por bairro: retornos brutos. Total: únicos salvos.
+ {detalhes.join("\n")} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/CuradoriaRamosPage.tsx b/frontend/src/pages/CuradoriaRamosPage.tsx new file mode 100644 index 0000000..84ecdd8 --- /dev/null +++ b/frontend/src/pages/CuradoriaRamosPage.tsx @@ -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>({}); + 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 ( +
+ + + Novo ramo + + + setNomeCanonico(event.target.value)} /> + setAlias(event.target.value)} /> + + + + + + + Leads sem ramo + + + + + + Empresa + Categoria bruta + Cidade + Mapear para + Ação + + + + {pendentes.map((lead) => ( + + {lead.nomeFantasia} + {lead.categoriaBruta} + + {lead.cidade}, {lead.uf} + + + + + + + + + ))} + {leadsQuery.isLoading && ( + + Carregando leads pendentes... + + )} + {leadsQuery.isError && ( + + + Não foi possível carregar os leads sem ramo. + + + )} + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..eea8c86 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -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 ( +
+
+ + + + +
+ +
+ + + Leads por ramo + + +
+ + + + + + + + + +
+
+
+ + + + Buscas recentes + + + + + + Termo + Status + Leads + Telefones + + + + {buscas.map((busca) => ( + + +

{busca.termoBusca}

+

{busca.localizacao}

+
+ + + + {busca.totalEncontrados ?? 0} + {busca.telefonesCapturados ?? 0} +
+ ))} + {(resumoQuery.isError || buscasQuery.isError) && ( + + + Não foi possível carregar o resumo. + + + )} +
+
+
+
+
+
+ ); +} + +function Metric({ + title, + value, + icon: Icon, +}: { + title: string; + value: string | number; + icon: typeof Users; +}) { + return ( + + +
+

{title}

+

{value}

+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/LeadsPage.tsx b/frontend/src/pages/LeadsPage.tsx new file mode 100644 index 0000000..c485f03 --- /dev/null +++ b/frontend/src/pages/LeadsPage.tsx @@ -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 ( +
+ {buscaId && ( + + +

+ Mostrando leads da busca #{buscaId}. +

+ +
+
+ )} + + + + setCidade(event.target.value)} /> + + +
+ + +
+
+
+ + + + Leads capturados + + + + + + Empresa + Telefone + Cidade + Ramo + Status + Busca + + + + {leads.map((lead) => ( + + +

{lead.nomeFantasia}

+

{lead.site ?? lead.enderecoCompleto}

+
+ {lead.telefoneE164 ?? lead.telefoneBruto ?? "-"} + + {lead.cidade}, {lead.uf} + + {lead.ramoId ? ramos.find((ramo) => ramo.id === lead.ramoId)?.nomeCanonico ?? `#${lead.ramoId}` : "Sem ramo"} + + + + #{lead.buscaId} +
+ ))} + {leadsQuery.isLoading && ( + + Carregando leads... + + )} + {leadsQuery.isError && ( + + + Não foi possível carregar os leads. + + + )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..4185a78 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -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; + +export function LoginPage() { + const { isAuthenticated, login } = useAuth(); + const navigate = useNavigate(); + const { register, handleSubmit, formState, setError } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + email: "", + senha: "", + }, + }); + + if (isAuthenticated) { + return ; + } + + 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 ( +
+ + +
+
+ LeadScraper + Acesso ao dashboard interno de prospecção. +
+ +
+ + + + {formState.errors.root &&

{formState.errors.root.message}

} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/NovaBuscaPage.tsx b/frontend/src/pages/NovaBuscaPage.tsx new file mode 100644 index 0000000..fed33b0 --- /dev/null +++ b/frontend/src/pages/NovaBuscaPage.tsx @@ -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(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) { + 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 ( +
+ + + Disparar busca + A busca entra como pendente e será processada pelo worker. + + +
+ + + + +
+ + {formError &&

{formError}

} + {criarBusca.isError && ( +

+ Não foi possível criar a busca. Atualize a página e tente novamente. +

+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/types/domain.ts b/frontend/src/types/domain.ts new file mode 100644 index 0000000..0da2b66 --- /dev/null +++ b/frontend/src/types/domain.ts @@ -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 { + 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[]; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..4612d4e --- /dev/null +++ b/frontend/tailwind.config.ts @@ -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; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..7708ef2 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -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"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..1a526b6 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..a930ad5 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,9 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + }, +}); diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..107d7ef --- /dev/null +++ b/pom.xml @@ -0,0 +1,125 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + com.seconectai + leadscraper + 0.0.1-SNAPSHOT + LeadScraper + Motor de busca e prospeccao de leads via automacao de navegador. + + + 21 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + com.microsoft.playwright + playwright + 1.48.0 + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.mockito + mockito-core + + + org.mockito + mockito-junit-jupiter + + + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/scripts/bootstrap-commands.sh b/scripts/bootstrap-commands.sh new file mode 100755 index 0000000..66fd8f9 --- /dev/null +++ b/scripts/bootstrap-commands.sh @@ -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" diff --git a/scripts/install-commands.sh b/scripts/install-commands.sh new file mode 100755 index 0000000..97d7aad --- /dev/null +++ b/scripts/install-commands.sh @@ -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" diff --git a/scripts/install-prod.sh b/scripts/install-prod.sh new file mode 100755 index 0000000..b41050e --- /dev/null +++ b/scripts/install-prod.sh @@ -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" </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 diff --git a/scripts/seconectaiescavador.update b/scripts/seconectaiescavador.update new file mode 100755 index 0000000..670d39e --- /dev/null +++ b/scripts/seconectaiescavador.update @@ -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 diff --git a/scripts/update-prod.sh b/scripts/update-prod.sh new file mode 100755 index 0000000..f14d860 --- /dev/null +++ b/scripts/update-prod.sh @@ -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 diff --git a/src/main/java/com/seconectai/leadscraper/LeadScraperApplication.java b/src/main/java/com/seconectai/leadscraper/LeadScraperApplication.java new file mode 100644 index 0000000..526c985 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/LeadScraperApplication.java @@ -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); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/config/ScraperConfig.java b/src/main/java/com/seconectai/leadscraper/config/ScraperConfig.java new file mode 100644 index 0000000..7104813 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/config/ScraperConfig.java @@ -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 { +} diff --git a/src/main/java/com/seconectai/leadscraper/config/SecurityConfig.java b/src/main/java/com/seconectai/leadscraper/config/SecurityConfig.java new file mode 100644 index 0000000..e0772bf --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/config/SecurityConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/controller/AuthController.java b/src/main/java/com/seconectai/leadscraper/controller/AuthController.java new file mode 100644 index 0000000..2337ea6 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/controller/AuthController.java @@ -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) { + } +} diff --git a/src/main/java/com/seconectai/leadscraper/controller/BuscaController.java b/src/main/java/com/seconectai/leadscraper/controller/BuscaController.java new file mode 100644 index 0000000..cd3b005 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/controller/BuscaController.java @@ -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 listar(@RequestParam(required = false) StatusBusca status, Pageable pageable) { + Specification 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() + ); + } + } +} diff --git a/src/main/java/com/seconectai/leadscraper/controller/LeadController.java b/src/main/java/com/seconectai/leadscraper/controller/LeadController.java new file mode 100644 index 0000000..ae4541c --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/controller/LeadController.java @@ -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 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 leads = leadRepository.findAll(); + Map porRamo = leads.stream() + .collect(Collectors.groupingBy( + lead -> lead.getRamo() == null ? "Sem ramo" : lead.getRamo().getNomeCanonico(), + Collectors.counting() + )); + List 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 semRamo(Pageable pageable) { + Specification 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 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 equalsIfPresent(String path, Object value) { + if (value == null) { + return null; + } + return (root, query, cb) -> cb.equal(resolvePath(root, path), value); + } + + private Specification 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 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 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() + ); + } + } +} diff --git a/src/main/java/com/seconectai/leadscraper/controller/RamoController.java b/src/main/java/com/seconectai/leadscraper/controller/RamoController.java new file mode 100644 index 0000000..d362490 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/controller/RamoController.java @@ -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 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 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 aliases) { + } + + public record AdicionarAliasRequest(@NotBlank String alias) { + } + + public record RamoResponse(Long id, String nomeCanonico, List aliases) { + static RamoResponse from(Ramo ramo) { + return new RamoResponse(ramo.getId(), ramo.getNomeCanonico(), ramo.getAliases()); + } + } +} diff --git a/src/main/java/com/seconectai/leadscraper/domain/Busca.java b/src/main/java/com/seconectai/leadscraper/domain/Busca.java new file mode 100644 index 0000000..09254bf --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/domain/Busca.java @@ -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 leads = new ArrayList<>(); +} diff --git a/src/main/java/com/seconectai/leadscraper/domain/HistoricoContato.java b/src/main/java/com/seconectai/leadscraper/domain/HistoricoContato.java new file mode 100644 index 0000000..52e1b67 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/domain/HistoricoContato.java @@ -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; +} diff --git a/src/main/java/com/seconectai/leadscraper/domain/Lead.java b/src/main/java/com/seconectai/leadscraper/domain/Lead.java new file mode 100644 index 0000000..c5b4ce6 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/domain/Lead.java @@ -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 telefones = new ArrayList<>(); + + @OneToMany(mappedBy = "lead") + private List historicoContatos = new ArrayList<>(); +} diff --git a/src/main/java/com/seconectai/leadscraper/domain/Proxy.java b/src/main/java/com/seconectai/leadscraper/domain/Proxy.java new file mode 100644 index 0000000..9e1915b --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/domain/Proxy.java @@ -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 buscas = new ArrayList<>(); +} diff --git a/src/main/java/com/seconectai/leadscraper/domain/Ramo.java b/src/main/java/com/seconectai/leadscraper/domain/Ramo.java new file mode 100644 index 0000000..ae01a6d --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/domain/Ramo.java @@ -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 aliases = new ArrayList<>(); + + @Column(name = "criado_em", nullable = false, insertable = false, updatable = false) + private OffsetDateTime criadoEm; + + @OneToMany(mappedBy = "categoriaPai") + private List subcategorias = new ArrayList<>(); + + @OneToMany(mappedBy = "ramo") + private List buscas = new ArrayList<>(); + + @OneToMany(mappedBy = "ramo") + private List leads = new ArrayList<>(); +} diff --git a/src/main/java/com/seconectai/leadscraper/domain/StatusBusca.java b/src/main/java/com/seconectai/leadscraper/domain/StatusBusca.java new file mode 100644 index 0000000..acaefbe --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/domain/StatusBusca.java @@ -0,0 +1,8 @@ +package com.seconectai.leadscraper.domain; + +public enum StatusBusca { + pendente, + em_execucao, + concluida, + erro +} diff --git a/src/main/java/com/seconectai/leadscraper/domain/StatusLead.java b/src/main/java/com/seconectai/leadscraper/domain/StatusLead.java new file mode 100644 index 0000000..2ab271b --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/domain/StatusLead.java @@ -0,0 +1,9 @@ +package com.seconectai.leadscraper.domain; + +public enum StatusLead { + novo, + contatado, + qualificado, + convertido, + descartado +} diff --git a/src/main/java/com/seconectai/leadscraper/domain/StatusProxy.java b/src/main/java/com/seconectai/leadscraper/domain/StatusProxy.java new file mode 100644 index 0000000..c6e0bb6 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/domain/StatusProxy.java @@ -0,0 +1,7 @@ +package com.seconectai.leadscraper.domain; + +public enum StatusProxy { + ativo, + degradado, + bloqueado +} diff --git a/src/main/java/com/seconectai/leadscraper/domain/Telefone.java b/src/main/java/com/seconectai/leadscraper/domain/Telefone.java new file mode 100644 index 0000000..321d30a --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/domain/Telefone.java @@ -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; + } + } +} diff --git a/src/main/java/com/seconectai/leadscraper/domain/Usuario.java b/src/main/java/com/seconectai/leadscraper/domain/Usuario.java new file mode 100644 index 0000000..abc92e7 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/domain/Usuario.java @@ -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 buscas = new ArrayList<>(); +} diff --git a/src/main/java/com/seconectai/leadscraper/normalizer/CnpjValidator.java b/src/main/java/com/seconectai/leadscraper/normalizer/CnpjValidator.java new file mode 100644 index 0000000..97169e1 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/normalizer/CnpjValidator.java @@ -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; + } +} diff --git a/src/main/java/com/seconectai/leadscraper/normalizer/DedupHashCalculator.java b/src/main/java/com/seconectai/leadscraper/normalizer/DedupHashCalculator.java new file mode 100644 index 0000000..225f45c --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/normalizer/DedupHashCalculator.java @@ -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; + } +} diff --git a/src/main/java/com/seconectai/leadscraper/normalizer/LeadNormalizadoDTO.java b/src/main/java/com/seconectai/leadscraper/normalizer/LeadNormalizadoDTO.java new file mode 100644 index 0000000..d3e017b --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/normalizer/LeadNormalizadoDTO.java @@ -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 +) { +} diff --git a/src/main/java/com/seconectai/leadscraper/normalizer/LeadNormalizerService.java b/src/main/java/com/seconectai/leadscraper/normalizer/LeadNormalizerService.java new file mode 100644 index 0000000..1d400a8 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/normalizer/LeadNormalizerService.java @@ -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 + ); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/normalizer/NomeNormalizer.java b/src/main/java/com/seconectai/leadscraper/normalizer/NomeNormalizer.java new file mode 100644 index 0000000..9ee6897 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/normalizer/NomeNormalizer.java @@ -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 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); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/normalizer/RamoMapper.java b/src/main/java/com/seconectai/leadscraper/normalizer/RamoMapper.java new file mode 100644 index 0000000..17d6910 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/normalizer/RamoMapper.java @@ -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 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; + } +} diff --git a/src/main/java/com/seconectai/leadscraper/normalizer/TelefoneNormalizer.java b/src/main/java/com/seconectai/leadscraper/normalizer/TelefoneNormalizer.java new file mode 100644 index 0000000..16b5feb --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/normalizer/TelefoneNormalizer.java @@ -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; + } +} diff --git a/src/main/java/com/seconectai/leadscraper/proxy/BackoffService.java b/src/main/java/com/seconectai/leadscraper/proxy/BackoffService.java new file mode 100644 index 0000000..7a827be --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/proxy/BackoffService.java @@ -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(); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/proxy/BloqueioDetector.java b/src/main/java/com/seconectai/leadscraper/proxy/BloqueioDetector.java new file mode 100644 index 0000000..1efce0b --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/proxy/BloqueioDetector.java @@ -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); + }); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/proxy/BloqueioException.java b/src/main/java/com/seconectai/leadscraper/proxy/BloqueioException.java new file mode 100644 index 0000000..7724ae0 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/proxy/BloqueioException.java @@ -0,0 +1,8 @@ +package com.seconectai.leadscraper.proxy; + +public class BloqueioException extends RuntimeException { + + public BloqueioException(String message) { + super(message); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/proxy/ProxyPoolService.java b/src/main/java/com/seconectai/leadscraper/proxy/ProxyPoolService.java new file mode 100644 index 0000000..ee95e52 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/proxy/ProxyPoolService.java @@ -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 reservarDisponivel() { + Optional 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); + }); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/proxy/ProxySettingsProperties.java b/src/main/java/com/seconectai/leadscraper/proxy/ProxySettingsProperties.java new file mode 100644 index 0000000..a6bafa7 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/proxy/ProxySettingsProperties.java @@ -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 + ) { + } +} diff --git a/src/main/java/com/seconectai/leadscraper/repository/BuscaRepository.java b/src/main/java/com/seconectai/leadscraper/repository/BuscaRepository.java new file mode 100644 index 0000000..47cd03e --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/repository/BuscaRepository.java @@ -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, JpaSpecificationExecutor { + + List findByStatus(StatusBusca status); +} diff --git a/src/main/java/com/seconectai/leadscraper/repository/HistoricoContatoRepository.java b/src/main/java/com/seconectai/leadscraper/repository/HistoricoContatoRepository.java new file mode 100644 index 0000000..b8e8970 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/repository/HistoricoContatoRepository.java @@ -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 { + + List findByLeadId(Long leadId); +} diff --git a/src/main/java/com/seconectai/leadscraper/repository/LeadRepository.java b/src/main/java/com/seconectai/leadscraper/repository/LeadRepository.java new file mode 100644 index 0000000..7f5cdf6 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/repository/LeadRepository.java @@ -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, JpaSpecificationExecutor { + + Optional findByHashDedup(String hashDedup); + + Optional findByCnpj(String cnpj); + + Optional findByPlaceId(String placeId); + + List findByStatus(StatusLead status); + + List 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(); +} + diff --git a/src/main/java/com/seconectai/leadscraper/repository/ProxyRepository.java b/src/main/java/com/seconectai/leadscraper/repository/ProxyRepository.java new file mode 100644 index 0000000..bbf3347 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/repository/ProxyRepository.java @@ -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 { + + List 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 findNextAvailableForUpdate(); +} diff --git a/src/main/java/com/seconectai/leadscraper/repository/RamoRepository.java b/src/main/java/com/seconectai/leadscraper/repository/RamoRepository.java new file mode 100644 index 0000000..1a69a66 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/repository/RamoRepository.java @@ -0,0 +1,10 @@ +package com.seconectai.leadscraper.repository; + +import com.seconectai.leadscraper.domain.Ramo; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RamoRepository extends JpaRepository { + + Optional findByNomeCanonico(String nomeCanonico); +} diff --git a/src/main/java/com/seconectai/leadscraper/repository/TelefoneRepository.java b/src/main/java/com/seconectai/leadscraper/repository/TelefoneRepository.java new file mode 100644 index 0000000..09fb9d4 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/repository/TelefoneRepository.java @@ -0,0 +1,10 @@ +package com.seconectai.leadscraper.repository; + +import com.seconectai.leadscraper.domain.Telefone; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TelefoneRepository extends JpaRepository { + + List findByLeadId(Long leadId); +} diff --git a/src/main/java/com/seconectai/leadscraper/repository/UsuarioRepository.java b/src/main/java/com/seconectai/leadscraper/repository/UsuarioRepository.java new file mode 100644 index 0000000..dfc0134 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/repository/UsuarioRepository.java @@ -0,0 +1,10 @@ +package com.seconectai.leadscraper.repository; + +import com.seconectai.leadscraper.domain.Usuario; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UsuarioRepository extends JpaRepository { + + Optional findByEmail(String email); +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/BuscaOrchestrator.java b/src/main/java/com/seconectai/leadscraper/scraper/BuscaOrchestrator.java new file mode 100644 index 0000000..6963aa3 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/BuscaOrchestrator.java @@ -0,0 +1,194 @@ +package com.seconectai.leadscraper.scraper; + +import com.seconectai.leadscraper.domain.Busca; +import com.seconectai.leadscraper.domain.Proxy; +import com.seconectai.leadscraper.proxy.BackoffService; +import com.seconectai.leadscraper.proxy.BloqueioDetector; +import com.seconectai.leadscraper.proxy.BloqueioException; +import com.seconectai.leadscraper.proxy.ProxyPoolService; +import com.seconectai.leadscraper.service.BuscaProgressService; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +public class BuscaOrchestrator { + + private static final Logger log = LoggerFactory.getLogger(BuscaOrchestrator.class); + + private final GoogleMapsScraperService googleMapsScraperService; + private final ScrollExtractorService scrollExtractorService; + private final ProxyPoolService proxyPoolService; + private final BloqueioDetector bloqueioDetector; + private final BackoffService backoffService; + private final ScraperFingerprintService fingerprintService; + private final OverpassScraperService overpassScraperService; + private final NeighborhoodExpansionService neighborhoodExpansionService; + private final BuscaProgressService buscaProgressService; + + public BuscaOrchestrator( + GoogleMapsScraperService googleMapsScraperService, + ScrollExtractorService scrollExtractorService, + ProxyPoolService proxyPoolService, + BloqueioDetector bloqueioDetector, + BackoffService backoffService, + ScraperFingerprintService fingerprintService, + OverpassScraperService overpassScraperService, + NeighborhoodExpansionService neighborhoodExpansionService, + BuscaProgressService buscaProgressService + ) { + this.googleMapsScraperService = googleMapsScraperService; + this.scrollExtractorService = scrollExtractorService; + this.proxyPoolService = proxyPoolService; + this.bloqueioDetector = bloqueioDetector; + this.backoffService = backoffService; + this.fingerprintService = fingerprintService; + this.overpassScraperService = overpassScraperService; + this.neighborhoodExpansionService = neighborhoodExpansionService; + this.buscaProgressService = buscaProgressService; + } + + public List executar(Busca busca) { + Assert.notNull(busca, "busca nao pode ser nula"); + Assert.hasText(busca.getTermoBusca(), "termoBusca e obrigatorio"); + Assert.hasText(busca.getLocalizacao(), "localizacao e obrigatoria"); + + Optional proxyReservado = proxyPoolService.reservarDisponivel(); + proxyReservado.ifPresent(busca::setProxy); + + try { + List googleLeads = new ArrayList<>(); + List googleErrors = new ArrayList<>(); + List regioes = neighborhoodExpansionService.locationsFor( + busca.getLocalizacao(), + busca.getExpandirPorBairros() == null || busca.getExpandirPorBairros() + ); + List detalhesRegioes = new ArrayList<>(); + int regioesSucesso = 0; + int regioesErro = 0; + + busca.setRegioesTotal(regioes.size()); + busca.setRegioesSucesso(0); + busca.setRegioesErro(0); + busca.setDetalheRegioes(null); + if (busca.getId() != null) { + buscaProgressService.iniciarRegioes(busca.getId(), regioes.size()); + } + + for (String localizacao : regioes) { + try { + List leadsRegiao = executarGoogleMaps(busca, localizacao, proxyReservado); + googleLeads.addAll(leadsRegiao); + regioesSucesso++; + String detalhe = "OK | " + localizacao + " | " + leadsRegiao.size() + " retornos brutos"; + detalhesRegioes.add(detalhe); + if (busca.getId() != null) { + buscaProgressService.registrarRegiao(busca.getId(), true, detalhe); + } + } catch (RuntimeException ex) { + String erro = shortMessage(ex); + regioesErro++; + googleErrors.add(localizacao + ": " + erro); + String detalhe = "ERRO | " + localizacao + " | " + erro; + detalhesRegioes.add(detalhe); + if (busca.getId() != null) { + buscaProgressService.registrarRegiao(busca.getId(), false, detalhe); + } + log.warn("Falha ao buscar Google Maps em {}: {}", localizacao, ex.getMessage()); + } + } + + busca.setRegioesSucesso(regioesSucesso); + busca.setRegioesErro(regioesErro); + busca.setDetalheRegioes(String.join("\n", detalhesRegioes)); + + List leads = mergeLeads( + googleLeads, + overpassScraperService.buscar(busca.getTermoBusca(), busca.getLocalizacao()) + ); + busca.setTotalEncontrados(leads.size()); + if (!googleErrors.isEmpty()) { + busca.setDetalheErro("Google Maps falhou em algumas regioes; resultados consolidados com fontes disponiveis. " + + String.join(" | ", googleErrors)); + } + if (leads.isEmpty() && !googleErrors.isEmpty()) { + throw new IllegalStateException(busca.getDetalheErro()); + } + backoffService.registrarSucesso(); + return leads; + } catch (RuntimeException ex) { + if (busca.getDetalheErro() == null || busca.getDetalheErro().isBlank()) { + busca.setDetalheErro(ex.getMessage()); + } + throw ex; + } finally { + proxyReservado.ifPresent(proxyPoolService::liberar); + } + } + + private List executarGoogleMaps(Busca busca, String localizacao, Optional proxyReservado) { + try (ScraperSession session = googleMapsScraperService.openSearch( + busca.getTermoBusca(), + localizacao, + proxyReservado.orElse(null), + fingerprintService.next() + )) { + validarBloqueio(session, proxyReservado, busca); + googleMapsScraperService.waitForResults(session.page()); + validarBloqueio(session, proxyReservado, busca); + return scrollExtractorService.scrollAndExtractUntilExhausted(session.page()); + } + } + + private void validarBloqueio(ScraperSession session, Optional proxyReservado, Busca busca) { + if (bloqueioDetector.isBlocked(session.page())) { + proxyReservado.ifPresent(bloqueioDetector::registrarBloqueio); + long esperaMs = backoffService.registrarBloqueioECalcularEspera(); + busca.setDetalheErro("Bloqueio detectado pelo Google Maps. Proxima tentativa em " + esperaMs + "ms."); + throw new BloqueioException(busca.getDetalheErro()); + } + } + + private List mergeLeads(List googleLeads, List overpassLeads) { + Map merged = new LinkedHashMap<>(); + addAll(merged, googleLeads); + addAll(merged, overpassLeads); + return new ArrayList<>(merged.values()); + } + + private void addAll(Map merged, List leads) { + for (LeadBrutoDTO lead : leads) { + merged.putIfAbsent(dedupKey(lead), lead); + } + } + + private String dedupKey(LeadBrutoDTO lead) { + if (lead.placeId() != null && !lead.placeId().isBlank()) { + return "place:" + lead.placeId(); + } + return "lead:%s|%s|%s".formatted( + normalize(lead.nome()), + normalize(lead.telefoneBruto()), + normalize(lead.enderecoBruto()) + ); + } + + private String normalize(String value) { + return value == null ? "" : value.trim().toLowerCase(); + } + + private String shortMessage(RuntimeException ex) { + String message = ex.getMessage(); + if (message == null || message.isBlank()) { + return ex.getClass().getSimpleName(); + } + String singleLine = message.replaceAll("\\s+", " ").trim(); + return singleLine.length() > 180 ? singleLine.substring(0, 180) + "..." : singleLine; + } +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/DelayHumanizadoService.java b/src/main/java/com/seconectai/leadscraper/scraper/DelayHumanizadoService.java new file mode 100644 index 0000000..5d1e257 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/DelayHumanizadoService.java @@ -0,0 +1,30 @@ +package com.seconectai.leadscraper.scraper; + +import com.microsoft.playwright.Page; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.stereotype.Service; + +@Service +public class DelayHumanizadoService { + + private final GoogleMapsScraperProperties properties; + + public DelayHumanizadoService(GoogleMapsScraperProperties properties) { + this.properties = properties; + } + + public void aguardarEntreAcoes(Page page) { + page.waitForTimeout(randomBetween(properties.delays().minMs(), properties.delays().maxMs())); + } + + public int proximoPassoScroll() { + return randomBetween(properties.scroll().minPixels(), properties.scroll().maxPixels()); + } + + private int randomBetween(int min, int max) { + if (max <= min) { + return min; + } + return ThreadLocalRandom.current().nextInt(min, max + 1); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/GoogleMapsScraperProperties.java b/src/main/java/com/seconectai/leadscraper/scraper/GoogleMapsScraperProperties.java new file mode 100644 index 0000000..012964d --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/GoogleMapsScraperProperties.java @@ -0,0 +1,27 @@ +package com.seconectai.leadscraper.scraper; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "scraper.google-maps") +public record GoogleMapsScraperProperties( + String baseUrl, + boolean headless, + double timeoutMs, + int maxResults, + Scroll scroll, + Delays delays +) { + + public record Scroll( + int stagnantAttempts, + int minPixels, + int maxPixels + ) { + } + + public record Delays( + int minMs, + int maxMs + ) { + } +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/GoogleMapsScraperService.java b/src/main/java/com/seconectai/leadscraper/scraper/GoogleMapsScraperService.java new file mode 100644 index 0000000..c2261ff --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/GoogleMapsScraperService.java @@ -0,0 +1,73 @@ +package com.seconectai.leadscraper.scraper; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.options.WaitUntilState; +import com.seconectai.leadscraper.domain.Proxy; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.springframework.stereotype.Service; + +@Service +public class GoogleMapsScraperService { + + private static final String RESULTS_SELECTOR = "[role='feed'], div[aria-label][role='main']"; + private final GoogleMapsScraperProperties properties; + + public GoogleMapsScraperService(GoogleMapsScraperProperties properties) { + this.properties = properties; + } + + public ScraperSession openSearch( + String termoBusca, + String localizacao, + Proxy proxy, + ScraperFingerprint fingerprint + ) { + Playwright playwright = Playwright.create(); + BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions() + .setHeadless(properties.headless()); + browserExecutablePath().ifPresent(launchOptions::setExecutablePath); + if (proxy != null && proxy.getEndereco() != null && !proxy.getEndereco().isBlank()) { + launchOptions.setProxy(new com.microsoft.playwright.options.Proxy(proxy.getEndereco())); + } + + Browser browser = playwright.chromium().launch(launchOptions); + BrowserContext context = browser.newContext(new Browser.NewContextOptions() + .setLocale("pt-BR") + .setTimezoneId(fingerprint.timezoneId()) + .setUserAgent(fingerprint.userAgent()) + .setViewportSize(fingerprint.viewportWidth(), fingerprint.viewportHeight())); + Page page = context.newPage(); + page.setDefaultTimeout(properties.timeoutMs()); + page.navigate(buildSearchUrl(termoBusca, localizacao), + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + return new ScraperSession(playwright, browser, context, page); + } + + public void waitForResults(Page page) { + page.waitForSelector(RESULTS_SELECTOR, new Page.WaitForSelectorOptions().setTimeout(properties.timeoutMs())); + } + + private String buildSearchUrl(String termoBusca, String localizacao) { + String query = URLEncoder.encode(termoBusca + " " + localizacao, StandardCharsets.UTF_8); + return properties.baseUrl() + "/search/" + query; + } + + private java.util.Optional browserExecutablePath() { + String configured = System.getenv("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"); + if (configured != null && !configured.isBlank()) { + Path path = Path.of(configured); + if (Files.exists(path)) { + return java.util.Optional.of(path); + } + } + Path dockerPath = Path.of("/ms-playwright/chromium-1140/chrome-linux/chrome"); + return Files.exists(dockerPath) ? java.util.Optional.of(dockerPath) : java.util.Optional.empty(); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/LeadBrutoDTO.java b/src/main/java/com/seconectai/leadscraper/scraper/LeadBrutoDTO.java new file mode 100644 index 0000000..292d52c --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/LeadBrutoDTO.java @@ -0,0 +1,13 @@ +package com.seconectai.leadscraper.scraper; + +public record LeadBrutoDTO( + String nome, + String categoriaBruta, + String enderecoBruto, + String telefoneBruto, + String site, + String placeId, + String avaliacao, + String profileUrl +) { +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/LeadRawExtractor.java b/src/main/java/com/seconectai/leadscraper/scraper/LeadRawExtractor.java new file mode 100644 index 0000000..1b33f13 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/LeadRawExtractor.java @@ -0,0 +1,240 @@ +package com.seconectai.leadscraper.scraper; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +@Service +public class LeadRawExtractor { + + private static final String CARD_LINK_SELECTOR = "a[href*='/maps/place/']"; + private static final Pattern PHONE_PATTERN = Pattern.compile("(?:\\+?55\\s*)?(?:\\(?\\d{2}\\)?\\s*)?9?\\d{4}[-\\s]?\\d{4}"); + private static final Pattern RATING_PATTERN = Pattern.compile("\\b\\d(?:[,.]\\d)?\\b"); + private static final Pattern PLACE_ID_PATTERN = Pattern.compile("!1s([^!]+)"); + + public List extract(Page page) { + Locator links = page.locator(CARD_LINK_SELECTOR); + int total = links.count(); + List leads = new ArrayList<>(); + + for (int index = 0; index < total; index++) { + try { + Locator link = links.nth(index); + String href = link.getAttribute("href"); + String ariaLabel = clean(link.getAttribute("aria-label")); + String cardText = clean(cardText(link)); + LeadBrutoDTO lead = toLeadBruto(href, ariaLabel, cardText); + if (lead != null) { + leads.add(lead); + } + } catch (RuntimeException ignored) { + // A single unstable card should not abort the whole extraction. + } + } + + return leads; + } + + LeadBrutoDTO toLeadBruto(String href, String ariaLabel, String cardText) { + String nome = firstNonBlank(clean(ariaLabel), firstLine(cardText)); + if (nome == null) { + return null; + } + return new LeadBrutoDTO( + nome, + inferCategoria(cardText), + inferEndereco(cardText), + inferTelefone(cardText), + inferSite(cardText), + extractPlaceId(href), + inferAvaliacao(cardText), + href + ); + } + + + public LeadBrutoDTO enrichFromPlaceDetails(Page page, LeadBrutoDTO lead) { + String detailsText = clean(detailsText(page)); + if (detailsText == null) { + return lead; + } + return new LeadBrutoDTO( + lead.nome(), + firstNonBlank(lead.categoriaBruta(), inferCategoria(detailsText)), + firstNonBlank(lead.enderecoBruto(), inferEndereco(detailsText)), + firstNonBlank(lead.telefoneBruto(), inferTelefone(detailsText)), + firstNonBlank(lead.site(), inferSite(detailsText)), + lead.placeId(), + firstNonBlank(lead.avaliacao(), inferAvaliacao(detailsText)), + lead.profileUrl() + ); + } + + private String detailsText(Page page) { + return (String) page.locator("body").evaluate(""" + body => { + const seen = new Set(); + const parts = []; + const push = value => { + const text = (value || '').replace(/\s+/g, ' ').trim(); + if (!text || seen.has(text)) return; + seen.add(text); + parts.push(text); + }; + + push(body.innerText); + body.querySelectorAll('[aria-label], [data-tooltip], [data-item-id], a[href], button').forEach(node => { + push(node.getAttribute('aria-label')); + push(node.getAttribute('data-tooltip')); + push(node.getAttribute('data-item-id')); + push(node.textContent); + const href = node.getAttribute('href'); + if (href && href.startsWith('tel:')) push(href.replace(/^tel:/, '')); + }); + return parts.join('\\n'); + } + """); + } + + private String cardText(Locator link) { + return (String) link.evaluate(""" + element => { + const card = element.closest('[role="article"]') || element.closest('.Nv2PK') || element.parentElement; + const root = card || element; + const seen = new Set(); + const parts = []; + const push = value => { + const text = (value || '').replace(/\\s+/g, ' ').trim(); + if (!text || seen.has(text)) return; + seen.add(text); + parts.push(text); + }; + + push(root.getAttribute('aria-label')); + push(root.innerText); + root.querySelectorAll('[aria-label], [data-tooltip], a[href], button').forEach(node => { + push(node.getAttribute('aria-label')); + push(node.getAttribute('data-tooltip')); + push(node.textContent); + const href = node.getAttribute('href'); + if (href && href.startsWith('tel:')) push(href.replace(/^tel:/, '')); + }); + return parts.join('\\n'); + } + """); + } + + private String inferCategoria(String cardText) { + if (cardText == null) { + return null; + } + String[] lines = cardText.split("\\R"); + for (int index = 1; index < lines.length; index++) { + String line = lines[index]; + String cleaned = clean(line); + if (cleaned != null && !looksLikePhone(cleaned) && !looksLikeAddress(cleaned) && !looksLikeRating(cleaned)) { + return cleaned; + } + } + return null; + } + + private String inferEndereco(String cardText) { + if (cardText == null) { + return null; + } + for (String line : cardText.split("\\R")) { + String cleaned = clean(line); + if (looksLikeAddress(cleaned)) { + return cleaned; + } + } + return null; + } + + private String inferTelefone(String cardText) { + if (cardText == null) { + return null; + } + Matcher matcher = PHONE_PATTERN.matcher(cardText); + return matcher.find() ? clean(matcher.group()) : null; + } + + private String inferSite(String cardText) { + if (cardText == null) { + return null; + } + for (String line : cardText.split("\\R")) { + String cleaned = clean(line); + if (cleaned != null && (cleaned.startsWith("http") || cleaned.matches("(?i).+\\.(com|com\\.br|br|net|org).*"))) { + return cleaned; + } + } + return null; + } + + private String inferAvaliacao(String cardText) { + if (cardText == null) { + return null; + } + Matcher matcher = RATING_PATTERN.matcher(cardText); + return matcher.find() ? matcher.group().replace(',', '.') : null; + } + + private String extractPlaceId(String href) { + if (href == null) { + return null; + } + Matcher matcher = PLACE_ID_PATTERN.matcher(href); + if (matcher.find()) { + return URLDecoder.decode(matcher.group(1), StandardCharsets.UTF_8); + } + int placeIndex = href.indexOf("/maps/place/"); + if (placeIndex < 0) { + return null; + } + String slug = href.substring(placeIndex + "/maps/place/".length()); + int nextSlash = slug.indexOf('/'); + if (nextSlash >= 0) { + slug = slug.substring(0, nextSlash); + } + return URLDecoder.decode(slug, StandardCharsets.UTF_8); + } + + private String firstLine(String value) { + if (value == null) { + return null; + } + return clean(value.split("\\R", 2)[0]); + } + + private String firstNonBlank(String first, String second) { + return first != null ? first : second; + } + + private boolean looksLikePhone(String value) { + return value != null && PHONE_PATTERN.matcher(value).find(); + } + + private boolean looksLikeAddress(String value) { + return value != null && value.matches("(?i).*(rua|av\\.?|avenida|rodovia|estrada|praça|r\\.).*\\d+.*"); + } + + private boolean looksLikeRating(String value) { + return value != null && value.matches(".*\\d[,.]\\d.*"); + } + + private String clean(String value) { + if (value == null) { + return null; + } + String cleaned = value.replace('\u00a0', ' ').trim().replaceAll("\\s+", " "); + return cleaned.isBlank() ? null : cleaned; + } +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/NeighborhoodExpansionService.java b/src/main/java/com/seconectai/leadscraper/scraper/NeighborhoodExpansionService.java new file mode 100644 index 0000000..b1014dc --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/NeighborhoodExpansionService.java @@ -0,0 +1,135 @@ +package com.seconectai.leadscraper.scraper; + +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +public class NeighborhoodExpansionService { + + private static final Map> PRESETS = Map.of( + "sao paulo, sp", List.of( + "Centro, Sao Paulo, SP", + "Pinheiros, Sao Paulo, SP", + "Vila Madalena, Sao Paulo, SP", + "Jardins, Sao Paulo, SP", + "Itaim Bibi, Sao Paulo, SP", + "Moema, Sao Paulo, SP", + "Vila Mariana, Sao Paulo, SP", + "Tatuape, Sao Paulo, SP", + "Santana, Sao Paulo, SP", + "Lapa, Sao Paulo, SP", + "Barra Funda, Sao Paulo, SP", + "Santo Amaro, Sao Paulo, SP", + "Brooklin, Sao Paulo, SP", + "Morumbi, Sao Paulo, SP", + "Butanta, Sao Paulo, SP", + "Ipiranga, Sao Paulo, SP", + "Mooca, Sao Paulo, SP", + "Perdizes, Sao Paulo, SP", + "Vila Olimpia, Sao Paulo, SP", + "Bela Vista, Sao Paulo, SP" + ), + "campinas, sp", List.of( + "Cambui, Campinas, SP", + "Centro, Campinas, SP", + "Taquaral, Campinas, SP", + "Barao Geraldo, Campinas, SP", + "Sousas, Campinas, SP", + "Nova Campinas, Campinas, SP", + "Jardim Chapadao, Campinas, SP", + "Catedral, Campinas, SP" + ), + "londrina, pr", List.of( + "Centro, Londrina, PR", + "Gleba Palhano, Londrina, PR", + "Jardim Higienopolis, Londrina, PR", + "Bandeirantes, Londrina, PR", + "Vila Brasil, Londrina, PR", + "Jardim Quebec, Londrina, PR", + "Zona Norte, Londrina, PR", + "Zona Sul, Londrina, PR" + ) + ); + + private static final List FALLBACK_BAIRROS_BRASIL = List.of( + "Centro", + "Zona Norte", + "Zona Sul", + "Zona Leste", + "Zona Oeste", + "Setor Central", + "Jardim", + "Vila", + "Industrial", + "Comercial" + ); + + public List locationsFor(String localizacao, boolean expanded) { + if (!expanded) { + return List.of(localizacao); + } + + String normalized = normalize(localizacao); + List preset = PRESETS.get(normalized); + if (preset != null) { + return preset; + } + + LocationParts parts = parseLocation(localizacao); + if (parts == null) { + return List.of(localizacao); + } + + List regioes = new ArrayList<>(); + for (String bairro : FALLBACK_BAIRROS_BRASIL) { + regioes.add("%s, %s, %s".formatted(bairro, parts.cidade(), parts.uf())); + } + return regioes; + } + + private LocationParts parseLocation(String localizacao) { + if (!StringUtils.hasText(localizacao)) { + return null; + } + String cleaned = localizacao.replaceAll("\\s+", " ").trim(); + String[] commaParts = cleaned.split(","); + if (commaParts.length >= 2) { + String cidade = commaParts[0].trim(); + String uf = commaParts[1].trim().toUpperCase(Locale.ROOT); + if (StringUtils.hasText(cidade) && uf.matches("[A-Z]{2}")) { + return new LocationParts(cidade, uf); + } + } + + String[] parts = cleaned.split(" "); + if (parts.length >= 2) { + String uf = parts[parts.length - 1].trim().toUpperCase(Locale.ROOT); + if (uf.matches("[A-Z]{2}")) { + String cidade = cleaned.substring(0, cleaned.length() - parts[parts.length - 1].length()).trim().replaceAll(",+$", "").trim(); + if (StringUtils.hasText(cidade)) { + return new LocationParts(cidade, uf); + } + } + } + return null; + } + + private record LocationParts(String cidade, String uf) { + } + + private String normalize(String value) { + if (!StringUtils.hasText(value)) { + return ""; + } + return Normalizer.normalize(value, Normalizer.Form.NFD) + .replaceAll("\\p{M}", "") + .toLowerCase(Locale.ROOT) + .replaceAll("\\s+", " ") + .trim(); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/OverpassScraperProperties.java b/src/main/java/com/seconectai/leadscraper/scraper/OverpassScraperProperties.java new file mode 100644 index 0000000..dc8456d --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/OverpassScraperProperties.java @@ -0,0 +1,12 @@ +package com.seconectai.leadscraper.scraper; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "scraper.overpass") +public record OverpassScraperProperties( + boolean enabled, + String endpoint, + int timeoutSeconds, + int maxResults +) { +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/OverpassScraperService.java b/src/main/java/com/seconectai/leadscraper/scraper/OverpassScraperService.java new file mode 100644 index 0000000..db73c50 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/OverpassScraperService.java @@ -0,0 +1,234 @@ +package com.seconectai.leadscraper.scraper; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +public class OverpassScraperService { + + private static final Map TERM_TAG_FILTERS = Map.ofEntries( + Map.entry("advocacia", """ + nwr["office"="lawyer"](area.searchArea); + nwr["name"~"advocacia|advogado|advogada|advogados|juridico|juridica", i](area.searchArea); + """), + Map.entry("advogado", """ + nwr["office"="lawyer"](area.searchArea); + nwr["name"~"advocacia|advogado|advogada|advogados|juridico|juridica", i](area.searchArea); + """), + Map.entry("contabilidade", """ + nwr["office"="accountant"](area.searchArea); + nwr["name"~"contabilidade|contador|contabil|contabeis", i](area.searchArea); + """), + Map.entry("dentista", """ + nwr["amenity"="dentist"](area.searchArea); + nwr["healthcare"="dentist"](area.searchArea); + nwr["name"~"dentista|odontologia|odontologico|odontologica", i](area.searchArea); + """), + Map.entry("odontologia", """ + nwr["amenity"="dentist"](area.searchArea); + nwr["healthcare"="dentist"](area.searchArea); + nwr["name"~"dentista|odontologia|odontologico|odontologica", i](area.searchArea); + """), + Map.entry("farmacia", """ + nwr["amenity"="pharmacy"](area.searchArea); + nwr["shop"="chemist"](area.searchArea); + nwr["name"~"farmacia|drogaria", i](area.searchArea); + """), + Map.entry("restaurante", """ + nwr["amenity"="restaurant"](area.searchArea); + nwr["name"~"restaurante|restaurant", i](area.searchArea); + """), + Map.entry("restaurantes", """ + nwr["amenity"="restaurant"](area.searchArea); + nwr["name"~"restaurante|restaurant", i](area.searchArea); + """), + Map.entry("padaria", """ + nwr["shop"="bakery"](area.searchArea); + nwr["name"~"padaria|panificadora|bakery", i](area.searchArea); + """), + Map.entry("pizzaria", """ + nwr["cuisine"="pizza"](area.searchArea); + nwr["name"~"pizzaria|pizza", i](area.searchArea); + """) + ); + + private final OverpassScraperProperties properties; + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + + public OverpassScraperService(OverpassScraperProperties properties, ObjectMapper objectMapper) { + this.properties = properties; + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(properties.timeoutSeconds())) + .build(); + } + + public List buscar(String termoBusca, String localizacao) { + if (!properties.enabled()) { + return List.of(); + } + + LocationParts location = LocationParts.parse(localizacao); + if (!StringUtils.hasText(location.city())) { + return List.of(); + } + + try { + String query = buildQuery(termoBusca, location); + HttpRequest request = HttpRequest.newBuilder(URI.create(properties.endpoint())) + .timeout(Duration.ofSeconds(properties.timeoutSeconds() + 10L)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString("data=" + URLEncoder.encode(query, StandardCharsets.UTF_8))) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return List.of(); + } + + return parseElements(response.body(), location); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return List.of(); + } catch (RuntimeException | java.io.IOException ex) { + return List.of(); + } + } + + private String buildQuery(String termoBusca, LocationParts location) { + String term = normalizeTerm(termoBusca); + String filters = TERM_TAG_FILTERS.getOrDefault(term, genericNameFilter(termoBusca)); + + return """ + [out:json][timeout:%d]; + area["boundary"="administrative"]["name"="%s"]->.searchArea; + ( + %s + ); + out center tags %d; + """.formatted( + properties.timeoutSeconds(), + escapeOverpassString(location.city()), + filters, + properties.maxResults() + ); + } + + private String genericNameFilter(String termoBusca) { + String regex = Pattern.quote(StringUtils.hasText(termoBusca) ? termoBusca.trim() : ""); + return "nwr[\"name\"~\"" + escapeOverpassString(regex) + "\", i](area.searchArea);"; + } + + private List parseElements(String responseBody, LocationParts location) throws java.io.IOException { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode elements = root.path("elements"); + if (!elements.isArray()) { + return List.of(); + } + + List leads = new ArrayList<>(); + for (JsonNode element : elements) { + JsonNode tags = element.path("tags"); + String name = text(tags, "name"); + if (!StringUtils.hasText(name)) { + continue; + } + + leads.add(new LeadBrutoDTO( + name, + categoria(tags), + endereco(tags, location), + firstText(tags, "contact:phone", "phone"), + firstText(tags, "contact:website", "website"), + "osm:" + element.path("type").asText() + ":" + element.path("id").asText(), + null, + null + )); + + if (leads.size() >= properties.maxResults()) { + break; + } + } + return leads; + } + + private String categoria(JsonNode tags) { + return firstText(tags, "office", "amenity", "shop", "craft", "healthcare", "cuisine"); + } + + private String endereco(JsonNode tags, LocationParts location) { + String street = text(tags, "addr:street"); + String number = text(tags, "addr:housenumber"); + String suburb = firstText(tags, "addr:suburb", "addr:neighbourhood"); + String city = firstText(tags, "addr:city", "addr:municipality"); + + List parts = new ArrayList<>(); + if (StringUtils.hasText(street)) { + parts.add(StringUtils.hasText(number) ? street + ", " + number : street); + } + if (StringUtils.hasText(suburb)) { + parts.add(suburb); + } + parts.add(StringUtils.hasText(city) ? city : location.city()); + if (StringUtils.hasText(location.state())) { + parts.add(location.state()); + } + return String.join(" - ", parts); + } + + private String firstText(JsonNode tags, String... keys) { + for (String key : keys) { + String value = text(tags, key); + if (StringUtils.hasText(value)) { + return value; + } + } + return null; + } + + private String text(JsonNode tags, String key) { + JsonNode value = tags.path(key); + return value.isTextual() ? value.asText() : null; + } + + private String normalizeTerm(String value) { + if (value == null) { + return ""; + } + return java.text.Normalizer.normalize(value, java.text.Normalizer.Form.NFD) + .replaceAll("\\p{M}", "") + .toLowerCase(Locale.ROOT) + .trim(); + } + + private String escapeOverpassString(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private record LocationParts(String city, String state) { + static LocationParts parse(String localizacao) { + if (!StringUtils.hasText(localizacao)) { + return new LocationParts("", ""); + } + String[] parts = localizacao.split(","); + String city = parts[0].trim(); + String state = parts.length > 1 ? parts[1].trim() : ""; + return new LocationParts(city, state); + } + } +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/ScraperFingerprint.java b/src/main/java/com/seconectai/leadscraper/scraper/ScraperFingerprint.java new file mode 100644 index 0000000..67ea52a --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/ScraperFingerprint.java @@ -0,0 +1,9 @@ +package com.seconectai.leadscraper.scraper; + +public record ScraperFingerprint( + String userAgent, + int viewportWidth, + int viewportHeight, + String timezoneId +) { +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/ScraperFingerprintService.java b/src/main/java/com/seconectai/leadscraper/scraper/ScraperFingerprintService.java new file mode 100644 index 0000000..c6eecb4 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/ScraperFingerprintService.java @@ -0,0 +1,19 @@ +package com.seconectai.leadscraper.scraper; + +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.stereotype.Service; + +@Service +public class ScraperFingerprintService { + + private static final List FINGERPRINTS = List.of( + new ScraperFingerprint("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", 1366, 768, "America/Sao_Paulo"), + new ScraperFingerprint("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", 1440, 900, "America/Sao_Paulo"), + new ScraperFingerprint("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", 1536, 864, "America/Sao_Paulo") + ); + + public ScraperFingerprint next() { + return FINGERPRINTS.get(ThreadLocalRandom.current().nextInt(FINGERPRINTS.size())); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/ScraperSession.java b/src/main/java/com/seconectai/leadscraper/scraper/ScraperSession.java new file mode 100644 index 0000000..663bbe2 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/ScraperSession.java @@ -0,0 +1,20 @@ +package com.seconectai.leadscraper.scraper; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; + +public record ScraperSession( + Playwright playwright, + Browser browser, + BrowserContext context, + Page page +) implements AutoCloseable { + + @Override + public void close() { + browser.close(); + playwright.close(); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/scraper/ScrollExtractorService.java b/src/main/java/com/seconectai/leadscraper/scraper/ScrollExtractorService.java new file mode 100644 index 0000000..6a00624 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/scraper/ScrollExtractorService.java @@ -0,0 +1,118 @@ +package com.seconectai.leadscraper.scraper; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.WaitUntilState; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class ScrollExtractorService { + + private static final Logger log = LoggerFactory.getLogger(ScrollExtractorService.class); + + private static final String FEED_SELECTOR = "[role='feed']"; + private static final String CARD_SELECTOR = "a[href*='/maps/place/']"; + private final GoogleMapsScraperProperties properties; + private final DelayHumanizadoService delayHumanizadoService; + private final LeadRawExtractor leadRawExtractor; + + public ScrollExtractorService( + GoogleMapsScraperProperties properties, + DelayHumanizadoService delayHumanizadoService, + LeadRawExtractor leadRawExtractor + ) { + this.properties = properties; + this.delayHumanizadoService = delayHumanizadoService; + this.leadRawExtractor = leadRawExtractor; + } + + public List scrollAndExtractUntilExhausted(Page page) { + Locator feed = page.locator(FEED_SELECTOR).first(); + Map accumulated = new LinkedHashMap<>(); + int stagnantAttempts = 0; + collectVisible(page, accumulated); + + while (accumulated.size() < properties.maxResults() + && stagnantAttempts < properties.scroll().stagnantAttempts()) { + feed.evaluate("(element, pixels) => element.scrollBy(0, pixels)", delayHumanizadoService.proximoPassoScroll()); + delayHumanizadoService.aguardarEntreAcoes(page); + + int previousTotal = accumulated.size(); + collectVisible(page, accumulated); + if (accumulated.size() <= previousTotal) { + stagnantAttempts++; + } else { + stagnantAttempts = 0; + } + } + + return enrichFromPlaceDetails(page, new ArrayList<>(accumulated.values())); + } + + private List enrichFromPlaceDetails(Page page, List leads) { + List enriched = new ArrayList<>(); + int perfisVisitados = 0; + int telefonesEncontrados = 0; + + for (LeadBrutoDTO lead : leads) { + if (lead.profileUrl() == null || lead.profileUrl().isBlank()) { + enriched.add(lead); + continue; + } + try { + page.navigate(lead.profileUrl(), new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + page.waitForSelector("body", new Page.WaitForSelectorOptions().setTimeout(properties.timeoutMs())); + delayHumanizadoService.aguardarEntreAcoes(page); + + LeadBrutoDTO detalhado = leadRawExtractor.enrichFromPlaceDetails(page, lead); + perfisVisitados++; + if (detalhado.telefoneBruto() != null && !detalhado.telefoneBruto().isBlank()) { + telefonesEncontrados++; + } + enriched.add(detalhado); + } catch (RuntimeException ex) { + log.warn("Nao foi possivel abrir detalhe do lead '{}' em {}: {}", lead.nome(), lead.profileUrl(), shortMessage(ex)); + enriched.add(lead); + } + } + + log.info("Enriquecimento Google Maps finalizado: {} perfis visitados, {} telefones encontrados, {} leads totais", + perfisVisitados, telefonesEncontrados, leads.size()); + return enriched; + } + + private String shortMessage(RuntimeException ex) { + String message = ex.getMessage(); + if (message == null || message.isBlank()) { + return ex.getClass().getSimpleName(); + } + String singleLine = message.replaceAll("\\s+", " ").trim(); + return singleLine.length() > 180 ? singleLine.substring(0, 180) + "..." : singleLine; + } + + private void collectVisible(Page page, Map accumulated) { + for (LeadBrutoDTO lead : leadRawExtractor.extract(page)) { + if (accumulated.size() >= properties.maxResults()) { + return; + } + accumulated.putIfAbsent(dedupKey(lead), lead); + } + } + + private String dedupKey(LeadBrutoDTO lead) { + if (lead.placeId() != null && !lead.placeId().isBlank()) { + return "place:" + lead.placeId(); + } + return "lead:" + normalize(lead.nome()) + "|" + normalize(lead.enderecoBruto()); + } + + private String normalize(String value) { + return value == null ? "" : value.toLowerCase().replaceAll("\\s+", " ").trim(); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/security/DatabaseUserDetailsService.java b/src/main/java/com/seconectai/leadscraper/security/DatabaseUserDetailsService.java new file mode 100644 index 0000000..652c59e --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/security/DatabaseUserDetailsService.java @@ -0,0 +1,32 @@ +package com.seconectai.leadscraper.security; + +import com.seconectai.leadscraper.domain.Usuario; +import com.seconectai.leadscraper.repository.UsuarioRepository; +import java.util.List; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class DatabaseUserDetailsService implements UserDetailsService { + + private final UsuarioRepository usuarioRepository; + + public DatabaseUserDetailsService(UsuarioRepository usuarioRepository) { + this.usuarioRepository = usuarioRepository; + } + + @Override + public UserDetails loadUserByUsername(String email) { + Usuario usuario = usuarioRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("Usuario nao encontrado")); + return new User( + usuario.getEmail(), + usuario.getSenhaHash(), + List.of(new SimpleGrantedAuthority("ROLE_" + usuario.getPapel().toUpperCase())) + ); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/security/JwtAuthenticationFilter.java b/src/main/java/com/seconectai/leadscraper/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..54c127b --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/security/JwtAuthenticationFilter.java @@ -0,0 +1,55 @@ +package com.seconectai.leadscraper.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final DatabaseUserDetailsService userDetailsService; + + public JwtAuthenticationFilter(JwtService jwtService, DatabaseUserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader("Authorization"); + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = header.substring(7); + try { + String email = jwtService.extractSubject(token); + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + if (jwtService.isValid(token, userDetails.getUsername())) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + } catch (RuntimeException ignored) { + SecurityContextHolder.clearContext(); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/security/JwtProperties.java b/src/main/java/com/seconectai/leadscraper/security/JwtProperties.java new file mode 100644 index 0000000..5e806f9 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/security/JwtProperties.java @@ -0,0 +1,7 @@ +package com.seconectai.leadscraper.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "security.jwt") +public record JwtProperties(String secret, long expirationMinutes) { +} diff --git a/src/main/java/com/seconectai/leadscraper/security/JwtService.java b/src/main/java/com/seconectai/leadscraper/security/JwtService.java new file mode 100644 index 0000000..f32bb10 --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/security/JwtService.java @@ -0,0 +1,51 @@ +package com.seconectai.leadscraper.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + + private final JwtProperties properties; + private final SecretKey key; + + public JwtService(JwtProperties properties) { + this.properties = properties; + this.key = Keys.hmacShaKeyFor(properties.secret().getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(String email, String papel) { + Instant now = Instant.now(); + Instant expiresAt = now.plusSeconds(properties.expirationMinutes() * 60); + return Jwts.builder() + .subject(email) + .claim("papel", papel) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiresAt)) + .signWith(key) + .compact(); + } + + public String extractSubject(String token) { + return claims(token).getSubject(); + } + + public boolean isValid(String token, String email) { + Claims claims = claims(token); + return claims.getSubject().equals(email) && claims.getExpiration().after(new Date()); + } + + private Claims claims(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/service/BuscaProgressService.java b/src/main/java/com/seconectai/leadscraper/service/BuscaProgressService.java new file mode 100644 index 0000000..0d94cdf --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/service/BuscaProgressService.java @@ -0,0 +1,39 @@ +package com.seconectai.leadscraper.service; + +import com.seconectai.leadscraper.domain.Busca; +import com.seconectai.leadscraper.repository.BuscaRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class BuscaProgressService { + + private final BuscaRepository buscaRepository; + + public BuscaProgressService(BuscaRepository buscaRepository) { + this.buscaRepository = buscaRepository; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void iniciarRegioes(Long buscaId, int total) { + buscaRepository.findById(buscaId).ifPresent(busca -> { + busca.setRegioesTotal(total); + busca.setRegioesSucesso(0); + busca.setRegioesErro(0); + busca.setDetalheRegioes(null); + buscaRepository.save(busca); + }); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void registrarRegiao(Long buscaId, boolean sucesso, String detalhe) { + buscaRepository.findById(buscaId).ifPresent(busca -> { + busca.setRegioesSucesso((busca.getRegioesSucesso() == null ? 0 : busca.getRegioesSucesso()) + (sucesso ? 1 : 0)); + busca.setRegioesErro((busca.getRegioesErro() == null ? 0 : busca.getRegioesErro()) + (sucesso ? 0 : 1)); + String atual = busca.getDetalheRegioes(); + busca.setDetalheRegioes(atual == null || atual.isBlank() ? detalhe : atual + "\n" + detalhe); + buscaRepository.save(busca); + }); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/service/LeadPersistenceService.java b/src/main/java/com/seconectai/leadscraper/service/LeadPersistenceService.java new file mode 100644 index 0000000..72af0bd --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/service/LeadPersistenceService.java @@ -0,0 +1,65 @@ +package com.seconectai.leadscraper.service; + +import com.seconectai.leadscraper.domain.Busca; +import com.seconectai.leadscraper.domain.Lead; +import com.seconectai.leadscraper.domain.Ramo; +import com.seconectai.leadscraper.normalizer.LeadNormalizadoDTO; +import com.seconectai.leadscraper.repository.LeadRepository; +import com.seconectai.leadscraper.repository.RamoRepository; +import java.util.Optional; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +public class LeadPersistenceService { + + private final LeadRepository leadRepository; + private final RamoRepository ramoRepository; + + public LeadPersistenceService(LeadRepository leadRepository, RamoRepository ramoRepository) { + this.leadRepository = leadRepository; + this.ramoRepository = ramoRepository; + } + + @Transactional + public Lead salvarOuAtualizar(Busca busca, LeadNormalizadoDTO normalizado) { + Lead lead = findDuplicado(normalizado).orElseGet(Lead::new); + lead.setBusca(busca); + lead.setNomeFantasia(limit(normalizado.nomeFantasia(), 200)); + lead.setCategoriaBruta(limit(normalizado.categoriaBruta(), 150)); + lead.setTelefoneE164(limit(normalizado.telefoneE164(), 20)); + lead.setTelefoneBruto(limit(normalizado.telefoneBruto(), 40)); + lead.setCnpj(normalizado.cnpj()); + lead.setSite(limit(normalizado.site(), 255)); + lead.setEnderecoCompleto(normalizado.enderecoCompleto()); + lead.setPlaceId(limit(normalizado.placeId(), 150)); + lead.setHashDedup(normalizado.hashDedup()); + lead.setRamo(resolveRamo(normalizado.ramoId())); + return leadRepository.save(lead); + } + + private Optional findDuplicado(LeadNormalizadoDTO normalizado) { + if (StringUtils.hasText(normalizado.placeId())) { + Optional porPlaceId = leadRepository.findByPlaceId(normalizado.placeId()); + if (porPlaceId.isPresent()) { + return porPlaceId; + } + } + return leadRepository.findByHashDedup(normalizado.hashDedup()); + } + + private String limit(String value, int maxLength) { + if (!StringUtils.hasText(value) || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } + + private Ramo resolveRamo(Long ramoId) { + if (ramoId == null) { + return null; + } + return ramoRepository.findById(ramoId).orElse(null); + } +} diff --git a/src/main/java/com/seconectai/leadscraper/worker/BuscaWorker.java b/src/main/java/com/seconectai/leadscraper/worker/BuscaWorker.java new file mode 100644 index 0000000..00eee9c --- /dev/null +++ b/src/main/java/com/seconectai/leadscraper/worker/BuscaWorker.java @@ -0,0 +1,70 @@ +package com.seconectai.leadscraper.worker; + +import com.seconectai.leadscraper.domain.Busca; +import com.seconectai.leadscraper.domain.StatusBusca; +import com.seconectai.leadscraper.normalizer.LeadNormalizerService; +import com.seconectai.leadscraper.repository.BuscaRepository; +import com.seconectai.leadscraper.repository.LeadRepository; +import com.seconectai.leadscraper.scraper.BuscaOrchestrator; +import com.seconectai.leadscraper.service.LeadPersistenceService; +import java.time.OffsetDateTime; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix = "worker.busca", name = "enabled", havingValue = "true", matchIfMissing = true) +public class BuscaWorker { + + private final BuscaRepository buscaRepository; + private final BuscaOrchestrator buscaOrchestrator; + private final LeadNormalizerService leadNormalizerService; + private final LeadPersistenceService leadPersistenceService; + private final LeadRepository leadRepository; + + public BuscaWorker( + BuscaRepository buscaRepository, + BuscaOrchestrator buscaOrchestrator, + LeadNormalizerService leadNormalizerService, + LeadPersistenceService leadPersistenceService, + LeadRepository leadRepository + ) { + this.buscaRepository = buscaRepository; + this.buscaOrchestrator = buscaOrchestrator; + this.leadNormalizerService = leadNormalizerService; + this.leadPersistenceService = leadPersistenceService; + this.leadRepository = leadRepository; + } + + @Scheduled(fixedDelayString = "${worker.busca.fixed-delay-ms:15000}") + public void processarPendentes() { + buscaRepository.findAll((root, query, cb) -> cb.equal(root.get("status"), StatusBusca.pendente), + PageRequest.of(0, 1)) + .stream() + .findFirst() + .ifPresent(this::processar); + } + + public void processar(Busca busca) { + busca.setStatus(StatusBusca.em_execucao); + busca.setIniciadoEm(OffsetDateTime.now()); + busca.setDetalheErro(null); + buscaRepository.saveAndFlush(busca); + + try { + var leadsBrutos = buscaOrchestrator.executar(busca); + leadsBrutos.stream() + .map(leadNormalizerService::normalize) + .forEach(normalizado -> leadPersistenceService.salvarOuAtualizar(busca, normalizado)); + busca.setTotalEncontrados(Math.toIntExact(leadRepository.countByBuscaId(busca.getId()))); + busca.setStatus(StatusBusca.concluida); + } catch (RuntimeException ex) { + busca.setStatus(StatusBusca.erro); + busca.setDetalheErro(ex.getMessage()); + } finally { + busca.setFinalizadoEm(OffsetDateTime.now()); + buscaRepository.save(busca); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..e831238 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,56 @@ +spring: + application: + name: leadscraper + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5433/leadscraper} + username: ${SPRING_DATASOURCE_USERNAME:leadscraper} + password: ${SPRING_DATASOURCE_PASSWORD:leadscraper} + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + properties: + hibernate: + format_sql: true + flyway: + enabled: true + locations: classpath:db/migration + +server: + port: ${SERVER_PORT:8082} + +security: + jwt: + secret: ${JWT_SECRET} + expiration-minutes: ${JWT_EXPIRATION_MINUTES:480} + +worker: + busca: + enabled: ${BUSCA_WORKER_ENABLED:true} + fixed-delay-ms: ${BUSCA_WORKER_FIXED_DELAY_MS:15000} + +scraper: + google-maps: + base-url: https://www.google.com/maps + headless: ${SCRAPER_HEADLESS:true} + timeout-ms: ${SCRAPER_TIMEOUT_MS:300000} + max-results: ${SCRAPER_MAX_RESULTS:120} + scroll: + stagnant-attempts: ${SCRAPER_SCROLL_STAGNANT_ATTEMPTS:5} + min-pixels: ${SCRAPER_SCROLL_MIN_PIXELS:900} + max-pixels: ${SCRAPER_SCROLL_MAX_PIXELS:1800} + delays: + min-ms: ${SCRAPER_DELAY_MIN_MS:800} + max-ms: ${SCRAPER_DELAY_MAX_MS:2200} + overpass: + enabled: ${OVERPASS_ENABLED:true} + endpoint: ${OVERPASS_ENDPOINT:https://overpass-api.de/api/interpreter} + timeout-seconds: ${OVERPASS_TIMEOUT_SECONDS:90} + max-results: ${OVERPASS_MAX_RESULTS:1000} + proxy: + max-bloqueios: ${SCRAPER_PROXY_MAX_BLOQUEIOS:3} + limite-buscas-hora: ${SCRAPER_PROXY_LIMITE_BUSCAS_HORA:20} + backoff: + initial-ms: ${SCRAPER_PROXY_BACKOFF_INITIAL_MS:5000} + max-ms: ${SCRAPER_PROXY_BACKOFF_MAX_MS:60000} + multiplier: ${SCRAPER_PROXY_BACKOFF_MULTIPLIER:2.0} diff --git a/src/main/resources/db/migration/V10__seed_admin_user.sql b/src/main/resources/db/migration/V10__seed_admin_user.sql new file mode 100644 index 0000000..60dc625 --- /dev/null +++ b/src/main/resources/db/migration/V10__seed_admin_user.sql @@ -0,0 +1,8 @@ +INSERT INTO usuarios (nome, email, senha_hash, papel) +VALUES ( + 'Administrador', + 'admin@seconectai.local', + '$2a$10$8YmPnML9SfWnScN7TAQTuOHMlXz.O5/L0uOyN3BgVdoJRuKE7BU2m', + 'admin' +) +ON CONFLICT (email) DO NOTHING; diff --git a/src/main/resources/db/migration/V11__update_admin_password.sql b/src/main/resources/db/migration/V11__update_admin_password.sql new file mode 100644 index 0000000..0bf702e --- /dev/null +++ b/src/main/resources/db/migration/V11__update_admin_password.sql @@ -0,0 +1,3 @@ +UPDATE usuarios +SET senha_hash = '$2a$10$9YzUyAdW9Y9lVZYgMPnT/uFUHJsV5zdZlnXAxyp36A5CaAv1T37c2' +WHERE email = 'admin@seconectai.local'; diff --git a/src/main/resources/db/migration/V12__update_bootstrap_admin_credentials.sql b/src/main/resources/db/migration/V12__update_bootstrap_admin_credentials.sql new file mode 100644 index 0000000..705a88e --- /dev/null +++ b/src/main/resources/db/migration/V12__update_bootstrap_admin_credentials.sql @@ -0,0 +1,12 @@ +UPDATE usuarios +SET + email = 'tiago27.dossantos@gmail.com', + senha_hash = '$2a$10$EpTCRi5BLnBioKsi2zvDD.OsKTrNvcjxlibA/gyJQAViTuCWAPhb.', + nome = 'Tiago dos Santos' +WHERE email = 'admin@seconectai.local'; + +UPDATE usuarios +SET + senha_hash = '$2a$10$EpTCRi5BLnBioKsi2zvDD.OsKTrNvcjxlibA/gyJQAViTuCWAPhb.', + nome = 'Tiago dos Santos' +WHERE email = 'tiago27.dossantos@gmail.com'; diff --git a/src/main/resources/db/migration/V13__add_busca_neighborhood_expansion.sql b/src/main/resources/db/migration/V13__add_busca_neighborhood_expansion.sql new file mode 100644 index 0000000..cd23531 --- /dev/null +++ b/src/main/resources/db/migration/V13__add_busca_neighborhood_expansion.sql @@ -0,0 +1,2 @@ +ALTER TABLE buscas +ADD COLUMN expandir_por_bairros BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/src/main/resources/db/migration/V14__add_busca_region_summary.sql b/src/main/resources/db/migration/V14__add_busca_region_summary.sql new file mode 100644 index 0000000..2d560dc --- /dev/null +++ b/src/main/resources/db/migration/V14__add_busca_region_summary.sql @@ -0,0 +1,5 @@ +ALTER TABLE buscas +ADD COLUMN regioes_total INTEGER NOT NULL DEFAULT 0, +ADD COLUMN regioes_sucesso INTEGER NOT NULL DEFAULT 0, +ADD COLUMN regioes_erro INTEGER NOT NULL DEFAULT 0, +ADD COLUMN detalhe_regioes TEXT; diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..c146a1b --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1 @@ +-- Initial Flyway migration. Schema tables start in the next project stage. diff --git a/src/main/resources/db/migration/V2__create_usuarios.sql b/src/main/resources/db/migration/V2__create_usuarios.sql new file mode 100644 index 0000000..242641c --- /dev/null +++ b/src/main/resources/db/migration/V2__create_usuarios.sql @@ -0,0 +1,8 @@ +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', + criado_em TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/src/main/resources/db/migration/V3__create_ramos.sql b/src/main/resources/db/migration/V3__create_ramos.sql new file mode 100644 index 0000000..ef0a150 --- /dev/null +++ b/src/main/resources/db/migration/V3__create_ramos.sql @@ -0,0 +1,7 @@ +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() +); diff --git a/src/main/resources/db/migration/V4__create_proxies.sql b/src/main/resources/db/migration/V4__create_proxies.sql new file mode 100644 index 0000000..99c5335 --- /dev/null +++ b/src/main/resources/db/migration/V4__create_proxies.sql @@ -0,0 +1,9 @@ +CREATE TABLE proxies ( + id BIGSERIAL PRIMARY KEY, + endereco VARCHAR(255) NOT NULL, + tipo VARCHAR(20) DEFAULT 'residencial', + status VARCHAR(20) DEFAULT 'ativo', + total_bloqueios INTEGER DEFAULT 0, + ultima_utilizacao TIMESTAMPTZ, + criado_em TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/src/main/resources/db/migration/V5__create_buscas.sql b/src/main/resources/db/migration/V5__create_buscas.sql new file mode 100644 index 0000000..5714f15 --- /dev/null +++ b/src/main/resources/db/migration/V5__create_buscas.sql @@ -0,0 +1,14 @@ +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', + 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() +); diff --git a/src/main/resources/db/migration/V6__create_leads.sql b/src/main/resources/db/migration/V6__create_leads.sql new file mode 100644 index 0000000..5971326 --- /dev/null +++ b/src/main/resources/db/migration/V6__create_leads.sql @@ -0,0 +1,33 @@ +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', + 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; diff --git a/src/main/resources/db/migration/V7__create_telefones.sql b/src/main/resources/db/migration/V7__create_telefones.sql new file mode 100644 index 0000000..4628859 --- /dev/null +++ b/src/main/resources/db/migration/V7__create_telefones.sql @@ -0,0 +1,7 @@ +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) +); diff --git a/src/main/resources/db/migration/V8__create_historico_contato.sql b/src/main/resources/db/migration/V8__create_historico_contato.sql new file mode 100644 index 0000000..670899e --- /dev/null +++ b/src/main/resources/db/migration/V8__create_historico_contato.sql @@ -0,0 +1,8 @@ +CREATE TABLE historico_contato ( + id BIGSERIAL PRIMARY KEY, + lead_id BIGINT NOT NULL REFERENCES leads(id) ON DELETE CASCADE, + canal VARCHAR(30), + resultado VARCHAR(50), + observacao TEXT, + criado_em TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/src/main/resources/db/migration/V9__add_proxy_usage_flag.sql b/src/main/resources/db/migration/V9__add_proxy_usage_flag.sql new file mode 100644 index 0000000..d0b9691 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_proxy_usage_flag.sql @@ -0,0 +1,2 @@ +ALTER TABLE proxies + ADD COLUMN em_uso BOOLEAN NOT NULL DEFAULT false; diff --git a/src/test/java/com/seconectai/leadscraper/LeadScraperApplicationTests.java b/src/test/java/com/seconectai/leadscraper/LeadScraperApplicationTests.java new file mode 100644 index 0000000..0fe12c2 --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/LeadScraperApplicationTests.java @@ -0,0 +1,11 @@ +package com.seconectai.leadscraper; + +import org.junit.jupiter.api.Test; + +class LeadScraperApplicationTests { + + @Test + void applicationClassExists() { + new LeadScraperApplication(); + } +} diff --git a/src/test/java/com/seconectai/leadscraper/RepositoryPersistenceIntegrationTests.java b/src/test/java/com/seconectai/leadscraper/RepositoryPersistenceIntegrationTests.java new file mode 100644 index 0000000..3726e47 --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/RepositoryPersistenceIntegrationTests.java @@ -0,0 +1,120 @@ +package com.seconectai.leadscraper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.seconectai.leadscraper.domain.Busca; +import com.seconectai.leadscraper.domain.HistoricoContato; +import com.seconectai.leadscraper.domain.Lead; +import com.seconectai.leadscraper.domain.Proxy; +import com.seconectai.leadscraper.domain.Ramo; +import com.seconectai.leadscraper.domain.StatusBusca; +import com.seconectai.leadscraper.domain.StatusLead; +import com.seconectai.leadscraper.domain.StatusProxy; +import com.seconectai.leadscraper.domain.Telefone; +import com.seconectai.leadscraper.domain.Usuario; +import com.seconectai.leadscraper.repository.BuscaRepository; +import com.seconectai.leadscraper.repository.HistoricoContatoRepository; +import com.seconectai.leadscraper.repository.LeadRepository; +import com.seconectai.leadscraper.repository.ProxyRepository; +import com.seconectai.leadscraper.repository.RamoRepository; +import com.seconectai.leadscraper.repository.TelefoneRepository; +import com.seconectai.leadscraper.repository.UsuarioRepository; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@TestPropertySource(properties = { + "security.jwt.secret=test-secret-with-at-least-32-characters", + "worker.busca.enabled=false" +}) +@Transactional +class RepositoryPersistenceIntegrationTests { + + @Autowired + private UsuarioRepository usuarioRepository; + + @Autowired + private RamoRepository ramoRepository; + + @Autowired + private ProxyRepository proxyRepository; + + @Autowired + private BuscaRepository buscaRepository; + + @Autowired + private LeadRepository leadRepository; + + @Autowired + private TelefoneRepository telefoneRepository; + + @Autowired + private HistoricoContatoRepository historicoContatoRepository; + + @Test + void savesAndFindsSchemaEntities() { + String suffix = UUID.randomUUID().toString(); + + Usuario usuario = new Usuario(); + usuario.setNome("Operador Teste"); + usuario.setEmail("operador-" + suffix + "@example.com"); + usuario.setSenhaHash("hash"); + usuario = usuarioRepository.saveAndFlush(usuario); + + Ramo ramo = new Ramo(); + ramo.setNomeCanonico("Restaurantes " + suffix); + ramo.setAliases(List.of("restaurante", "comida")); + ramo = ramoRepository.saveAndFlush(ramo); + + Proxy proxy = new Proxy(); + proxy.setEndereco("http://127.0.0.1:8082/" + suffix); + proxy = proxyRepository.saveAndFlush(proxy); + + Busca busca = new Busca(); + busca.setTermoBusca("restaurante"); + busca.setLocalizacao("Londrina PR"); + busca.setRamo(ramo); + busca.setProxy(proxy); + busca.setCriadoPor(usuario); + busca = buscaRepository.saveAndFlush(busca); + + Lead lead = new Lead(); + lead.setNomeFantasia("Restaurante Teste"); + lead.setCnpj("12345678000195"); + lead.setTelefoneE164("+5543999999999"); + lead.setTelefoneBruto("(43) 99999-9999"); + lead.setCidade("Londrina"); + lead.setUf("PR"); + lead.setRamo(ramo); + lead.setBusca(busca); + lead.setHashDedup(UUID.randomUUID().toString().replace("-", "")); + lead = leadRepository.saveAndFlush(lead); + + Telefone telefone = new Telefone(); + telefone.setLead(lead); + telefone.setNumeroE164("+5543999999999"); + telefoneRepository.saveAndFlush(telefone); + + HistoricoContato historicoContato = new HistoricoContato(); + historicoContato.setLead(lead); + historicoContato.setCanal("whatsapp"); + historicoContato.setResultado("enviado"); + historicoContatoRepository.saveAndFlush(historicoContato); + + assertThat(usuarioRepository.findByEmail(usuario.getEmail())).isPresent(); + assertThat(ramoRepository.findByNomeCanonico(ramo.getNomeCanonico())).isPresent(); + assertThat(proxyRepository.findByStatus(StatusProxy.ativo)).extracting(Proxy::getId).contains(proxy.getId()); + assertThat(buscaRepository.findByStatus(StatusBusca.pendente)).extracting(Busca::getId).contains(busca.getId()); + assertThat(leadRepository.findByStatus(StatusLead.novo)).extracting(Lead::getId).contains(lead.getId()); + assertThat(leadRepository.findByHashDedup(lead.getHashDedup())).isPresent(); + assertThat(leadRepository.findByCnpj("12345678000195")).isPresent(); + assertThat(leadRepository.findByBuscaId(busca.getId())).extracting(Lead::getId).contains(lead.getId()); + assertThat(telefoneRepository.findByLeadId(lead.getId())).hasSize(1); + assertThat(historicoContatoRepository.findByLeadId(lead.getId())).hasSize(1); + } +} diff --git a/src/test/java/com/seconectai/leadscraper/controller/AuthControllerIntegrationTests.java b/src/test/java/com/seconectai/leadscraper/controller/AuthControllerIntegrationTests.java new file mode 100644 index 0000000..d98773e --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/controller/AuthControllerIntegrationTests.java @@ -0,0 +1,57 @@ +package com.seconectai.leadscraper.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(properties = { + "security.jwt.secret=test-secret-with-at-least-32-characters", + "worker.busca.enabled=false" +}) +class AuthControllerIntegrationTests { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void rejectsProtectedEndpointWithoutToken() throws Exception { + mockMvc.perform(get("/leads")) + .andExpect(status().isForbidden()); + } + + @Test + void loginReturnsJwtAndTokenAllowsProtectedEndpoint() throws Exception { + String body = objectMapper.writeValueAsString(Map.of( + "email", "tiago27.dossantos@gmail.com", + "senha", "10031992" + )); + + String token = objectMapper.readTree(mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").isNotEmpty()) + .andReturn() + .getResponse() + .getContentAsString()).get("token").asText(); + + mockMvc.perform(get("/ramos").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/seconectai/leadscraper/normalizer/CnpjValidatorTests.java b/src/test/java/com/seconectai/leadscraper/normalizer/CnpjValidatorTests.java new file mode 100644 index 0000000..db4adcd --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/normalizer/CnpjValidatorTests.java @@ -0,0 +1,21 @@ +package com.seconectai.leadscraper.normalizer; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class CnpjValidatorTests { + + private final CnpjValidator validator = new CnpjValidator(); + + @Test + void keepsOnlyValidCnpjDigits() { + assertThat(validator.normalize("11.222.333/0001-81")).isEqualTo("11222333000181"); + } + + @Test + void discardsInvalidCnpj() { + assertThat(validator.normalize("11.222.333/0001-82")).isNull(); + assertThat(validator.normalize("00.000.000/0000-00")).isNull(); + } +} diff --git a/src/test/java/com/seconectai/leadscraper/normalizer/DedupHashCalculatorTests.java b/src/test/java/com/seconectai/leadscraper/normalizer/DedupHashCalculatorTests.java new file mode 100644 index 0000000..bac3503 --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/normalizer/DedupHashCalculatorTests.java @@ -0,0 +1,19 @@ +package com.seconectai.leadscraper.normalizer; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DedupHashCalculatorTests { + + private final DedupHashCalculator calculator = new DedupHashCalculator(); + + @Test + void calculatesDeterministicSha256Hash() { + String first = calculator.calculate("Padaria Sol", "+5543999999999"); + String second = calculator.calculate("Padaria Sol", "+5543999999999"); + + assertThat(first).isEqualTo(second); + assertThat(first).hasSize(64); + } +} diff --git a/src/test/java/com/seconectai/leadscraper/normalizer/LeadNormalizerServiceIntegrationTests.java b/src/test/java/com/seconectai/leadscraper/normalizer/LeadNormalizerServiceIntegrationTests.java new file mode 100644 index 0000000..c457fed --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/normalizer/LeadNormalizerServiceIntegrationTests.java @@ -0,0 +1,67 @@ +package com.seconectai.leadscraper.normalizer; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.seconectai.leadscraper.domain.Ramo; +import com.seconectai.leadscraper.repository.RamoRepository; +import com.seconectai.leadscraper.scraper.LeadBrutoDTO; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@TestPropertySource(properties = { + "security.jwt.secret=test-secret-with-at-least-32-characters", + "worker.busca.enabled=false" +}) +@Transactional +class LeadNormalizerServiceIntegrationTests { + + @Autowired + private RamoRepository ramoRepository; + + @Autowired + private LeadNormalizerService leadNormalizerService; + + @Test + void mapsKnownRamoAliasAndKeepsUnknownCategoryPending() { + String suffix = UUID.randomUUID().toString(); + Ramo ramo = new Ramo(); + ramo.setNomeCanonico("Restaurantes " + suffix); + ramo.setAliases(List.of("Restaurante", "Comida")); + ramo = ramoRepository.saveAndFlush(ramo); + + LeadNormalizadoDTO mapped = leadNormalizerService.normalize(new LeadBrutoDTO( + "restaurante bom ltda", + " restaurante ", + "Rua das Flores, 123", + "(43) 99999-9999", + "restaurante.com.br", + "place-id", + "4,8", + "https://www.google.com/maps/place/teste" + )); + + LeadNormalizadoDTO unknown = leadNormalizerService.normalize(new LeadBrutoDTO( + "loja sem ramo", + "categoria nova", + "Rua A, 10", + "(43) 3333-4444", + null, + null, + null, + null + )); + + assertThat(mapped.nomeFantasia()).isEqualTo("Restaurante Bom LTDA"); + assertThat(mapped.telefoneE164()).isEqualTo("+5543999999999"); + assertThat(mapped.ramoId()).isEqualTo(ramo.getId()); + assertThat(mapped.hashDedup()).hasSize(64); + assertThat(unknown.ramoId()).isNull(); + assertThat(unknown.categoriaBruta()).isEqualTo("categoria nova"); + } +} diff --git a/src/test/java/com/seconectai/leadscraper/normalizer/NomeNormalizerTests.java b/src/test/java/com/seconectai/leadscraper/normalizer/NomeNormalizerTests.java new file mode 100644 index 0000000..919db30 --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/normalizer/NomeNormalizerTests.java @@ -0,0 +1,22 @@ +package com.seconectai.leadscraper.normalizer; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class NomeNormalizerTests { + + private final NomeNormalizer normalizer = new NomeNormalizer(); + + @Test + void normalizesSpacingCaseAndCorporateAcronyms() { + assertThat(normalizer.normalize(" mercado central ltda ME ")) + .isEqualTo("Mercado Central LTDA ME"); + } + + @Test + void removesControlAndEmojiCharacters() { + assertThat(normalizer.normalize("padaria\u0000 sol \uD83C\uDF5E")) + .isEqualTo("Padaria Sol"); + } +} diff --git a/src/test/java/com/seconectai/leadscraper/normalizer/TelefoneNormalizerTests.java b/src/test/java/com/seconectai/leadscraper/normalizer/TelefoneNormalizerTests.java new file mode 100644 index 0000000..bb31629 --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/normalizer/TelefoneNormalizerTests.java @@ -0,0 +1,22 @@ +package com.seconectai.leadscraper.normalizer; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class TelefoneNormalizerTests { + + private final TelefoneNormalizer normalizer = new TelefoneNormalizer(); + + @Test + void formatsValidBrazilianPhoneAsE164() { + assertThat(normalizer.normalize("(43) 99999-9999")).isEqualTo("+5543999999999"); + assertThat(normalizer.normalize("+55 43 3333-4444")).isEqualTo("+554333334444"); + } + + @Test + void returnsNullWhenPhoneHasNoDddOrInvalidLength() { + assertThat(normalizer.normalize("99999-9999")).isNull(); + assertThat(normalizer.normalize("123")).isNull(); + } +} diff --git a/src/test/java/com/seconectai/leadscraper/proxy/BackoffServiceTests.java b/src/test/java/com/seconectai/leadscraper/proxy/BackoffServiceTests.java new file mode 100644 index 0000000..6299906 --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/proxy/BackoffServiceTests.java @@ -0,0 +1,27 @@ +package com.seconectai.leadscraper.proxy; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class BackoffServiceTests { + + @Test + void increasesBackoffUntilConfiguredLimitAndResetsAfterSuccess() { + ProxySettingsProperties properties = new ProxySettingsProperties( + 3, + 20, + new ProxySettingsProperties.Backoff(1000, 3000, 2.0) + ); + BackoffService service = new BackoffService(properties); + + assertThat(service.registrarBloqueioECalcularEspera()).isEqualTo(1000); + assertThat(service.registrarBloqueioECalcularEspera()).isEqualTo(2000); + assertThat(service.registrarBloqueioECalcularEspera()).isEqualTo(3000); + + service.registrarSucesso(); + + assertThat(service.registrarBloqueioECalcularEspera()).isEqualTo(1000); + assertThat(service.limiteBuscasHoraPorProxy()).isEqualTo(20); + } +} diff --git a/src/test/java/com/seconectai/leadscraper/scraper/LeadRawExtractorTests.java b/src/test/java/com/seconectai/leadscraper/scraper/LeadRawExtractorTests.java new file mode 100644 index 0000000..c0d303e --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/scraper/LeadRawExtractorTests.java @@ -0,0 +1,43 @@ +package com.seconectai.leadscraper.scraper; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class LeadRawExtractorTests { + + private final LeadRawExtractor extractor = new LeadRawExtractor(); + + @Test + void parsesRawLeadFromCardText() { + LeadBrutoDTO lead = extractor.toLeadBruto( + "https://www.google.com/maps/place/Padaria+Sol/@-23,-51,17z/data=!1s0xabc123!3m1", + "Padaria Sol", + """ + Padaria Sol + Padaria + Rua das Flores, 123 - Centro + 4,7 + (43) 3333-4444 + padariasol.com.br + """ + ); + + assertThat(lead).isNotNull(); + assertThat(lead.nome()).isEqualTo("Padaria Sol"); + assertThat(lead.categoriaBruta()).isEqualTo("Padaria"); + assertThat(lead.enderecoBruto()).isEqualTo("Rua das Flores, 123 - Centro"); + assertThat(lead.telefoneBruto()).isEqualTo("(43) 3333-4444"); + assertThat(lead.site()).isEqualTo("padariasol.com.br"); + assertThat(lead.placeId()).isEqualTo("0xabc123"); + assertThat(lead.avaliacao()).isEqualTo("4.7"); + assertThat(lead.profileUrl()).contains("/maps/place/Padaria+Sol/"); + } + + @Test + void ignoresCardWithoutName() { + LeadBrutoDTO lead = extractor.toLeadBruto(null, null, null); + + assertThat(lead).isNull(); + } +} diff --git a/src/test/java/com/seconectai/leadscraper/scraper/ScraperFingerprintServiceTests.java b/src/test/java/com/seconectai/leadscraper/scraper/ScraperFingerprintServiceTests.java new file mode 100644 index 0000000..48559d6 --- /dev/null +++ b/src/test/java/com/seconectai/leadscraper/scraper/ScraperFingerprintServiceTests.java @@ -0,0 +1,18 @@ +package com.seconectai.leadscraper.scraper; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ScraperFingerprintServiceTests { + + @Test + void returnsCoherentBrowserFingerprint() { + ScraperFingerprint fingerprint = new ScraperFingerprintService().next(); + + assertThat(fingerprint.userAgent()).contains("Mozilla/5.0"); + assertThat(fingerprint.viewportWidth()).isGreaterThan(1000); + assertThat(fingerprint.viewportHeight()).isGreaterThan(700); + assertThat(fingerprint.timezoneId()).isEqualTo("America/Sao_Paulo"); + } +}