commit 9d2ab731ee24408693aacf33e52fbe22f7c37817 Author: tiago Date: Thu Oct 1 11:00:47 2026 -0300 Initial commit 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"); + } +}