@danhachuel/leadmaster 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +1 -1
  3. package/backend/__pycache__/main.cpython-311.pyc +0 -0
  4. package/backend/main.py +46 -14
  5. package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
  6. package/backend/services/__pycache__/agents.cpython-311.pyc +0 -0
  7. package/backend/services/__pycache__/crm_agent.cpython-311.pyc +0 -0
  8. package/backend/services/__pycache__/extractors.cpython-311.pyc +0 -0
  9. package/backend/services/__pycache__/flags.cpython-311.pyc +0 -0
  10. package/backend/services/__pycache__/llm.cpython-311.pyc +0 -0
  11. package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
  12. package/backend/services/extractors.py +47 -0
  13. package/backend/services/storage.py +5 -2
  14. package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
  15. package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  16. package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
  17. package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  18. package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
  19. package/backend/tests/test_launcher_protections.py +46 -0
  20. package/backend/tests/test_maps_extractor.py +45 -0
  21. package/frontend/dist/assets/{index-DVFFIa5O.css → index-D53_Dmef.css} +1 -1
  22. package/frontend/dist/assets/{index-ZsRPRMCF.js → index-lA_S1NDH.js} +18 -18
  23. package/frontend/dist/index.html +2 -2
  24. package/package.json +1 -1
  25. package/scripts/cli.mjs +70 -6
package/CHANGELOG.md CHANGED
@@ -6,6 +6,62 @@ O formato é baseado no [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.
6
6
  A etiqueta visual de versão usa patch de dois dígitos (ex.: `0.1.00`); o registry NPM
7
7
  normaliza para a forma canônica (ex.: `0.1.0`).
8
8
 
9
+ ## [0.1.10] — 2026-11-08
10
+
11
+ ### Adicionado
12
+
13
+ - **Extrator de Leads (Google Maps)** — primeira página dedicada de extração:
14
+ - Rota `POST /api/extractors/maps` (`keyword`, `location`, `limit`): sem scraper
15
+ configurado gera **mock de 5–10 leads** (segurança anti-bloqueio de IP);
16
+ com `gosom/google-maps-scraper` configurado em Ferramentas, executa o CLI
17
+ real com as flags de isolamento (`creationflags` da 0.1.5). Os leads são
18
+ salvos em `leads.json` na coluna **"Novos Leads"** e retornados na resposta.
19
+ - Página `frontend/src/pages/ExtratorMaps.tsx` (rota `/extrator-maps`, link no
20
+ menu lateral): formulário limpo (palavra-chave, localização, slider de
21
+ quantidade), **modal/accordion** com opções avançadas escondidas (Lei de
22
+ Hick), botão **"Iniciar Extração"** em gradiente (von Restorff/Fitts),
23
+ **barra de progresso animada** durante a extração (Zeigarnik), tabela de
24
+ resultados (Nome, Telefone, Localização, Avaliação) e botão **"Ir para o
25
+ CRM"**. A aba Maps duplicada dentro de Extratores foi removida (substituída
26
+ por um atalho para a página dedicada).
27
+
28
+ ### Corrigido
29
+
30
+ - **Bug latente em `storage.import_leads`** (NameError na deduplicação) —
31
+ exposto pelos novos testes e corrigido; importação em massa agora funciona.
32
+
33
+ ### Testes
34
+
35
+ - 3 novos testes pytest da rota (`backend/tests/test_maps_extractor.py`):
36
+ mock/persistência, limite respeitado e payload inválido (422).
37
+ Suíte completa: **21 passed**.
38
+
39
+ ## [0.1.09] — 2026-11-08
40
+
41
+ ### Corrigido
42
+
43
+ - **Encerramento silencioso do launcher no MobaXterm (Windows)** — o app morria
44
+ sozinho por sinais espúrios (perda de foco/minimização) e por EPIPE ao fechar o
45
+ terminal. Correções no `scripts/cli.mjs`:
46
+ - **Keep-alive:** `setInterval(() => {}, 1000);` mantém o event loop ativo mesmo
47
+ sem I/O de console.
48
+ - **Absorção de sinais no Windows:** `SIGINT`/`SIGHUP` agora são apenas logados
49
+ (`[LAUNCHER] Sinal absorvido no Windows`) e **não** encerram o launcher;
50
+ `SIGTERM`/`SIGBREAK` (Ctrl+Break) seguem como paradas legítimas. No
51
+ Linux/macOS o comportamento gracioso é mantido.
52
+ - **Guarda da consola (anti-EPIPE):** `guardConsoleStream()` ignora erros `EPIPE`
53
+ no `'error'` dos streams e espelha toda a saída em
54
+ `storage/logs/launcher_console.log`; escrita original com exceção retorna
55
+ silenciosamente. Aplicada a `process.stdout`/`process.stderr` antes de
56
+ qualquer escrita do launcher.
57
+ - Isolamento mantido: worker Python com `detached: true` + `windowsHide: true`
58
+ (`CREATE_NEW_PROCESS_GROUP`).
59
+ - **AGENTS.md:** nova seção *🚨 REGRA INVIOLÁVEL: ARQUITETURA DO LAUNCHER* com as
60
+ 4 proteções obrigatórias.
61
+ - **Gate de regressão:** `backend/tests/test_launcher_protections.py` falha se
62
+ keep-alive, absorção de sinais, `guardConsoleStream` ou isolamento forem
63
+ removidos do launcher.
64
+
9
65
  ## [0.1.08] — 2026-11-08
10
66
 
11
67
  ### Adicionado
package/README.md CHANGED
@@ -55,7 +55,7 @@ para `http://127.0.0.1:8010` (backend de desenvolvimento).
55
55
  | **Painel** | Status da conexão, taxa de entrega, taxa de erro, total de leads; gráfico de barras (leads por etapa do CRM) e doughnut (campanhas: enviadas × erros). |
56
56
  | **Leads** | CRUD completo, importação em massa a partir dos extratores, barra de progresso de preenchimento, enriquecimento por IA. |
57
57
  | **CRM Kanban** | Drag-and-drop (@dnd-kit) nas colunas *Leads → Em contato → Proposta → Fechado*; o agente de IA sugere movimentações e enriquece os cards. |
58
- | **Extratores** | **Google Maps** (palavra-chave + localização, via `gosom/google-maps-scraper`), **Instagram** (URL do perfil, via `lucasoal/igmapper`) e **CNPJ** (BrasilAPI + busca por CNAE/UF + `D4Vinci/Scrapling`). Toda extração gera pré-visualização antes de importar. |
58
+ | **Extratores** | **Google Maps** (página dedicada `ExtratorMaps`: palavra-chave + localização + quantidade, com mock de 5–10 leads para teste seguro ou scraper real `gosom/google-maps-scraper` quando configurado — leads entram direto na coluna "Novos Leads" do CRM), **Instagram** (URL do perfil, via `lucasoal/igmapper`) e **CNPJ** (BrasilAPI + busca por CNAE/UF + `D4Vinci/Scrapling`). Toda extração gera pré-visualização antes de importar. |
59
59
  | **Disparos** | Campanhas de disparo (texto/áudio) com templates configuráveis e execução via `eracle/OpenOutreach`. |
60
60
  | **Agentes** | Agente de disparo (prompts/templates) e agente de atendimento (texto via LLM ou voz via `pipecat-ai/pipecat`). |
61
61
  | **Ferramentas** | Chaves de API, URLs das integrações locais, testes de conexão — tudo salvo em `config.json` local. |
package/backend/main.py CHANGED
@@ -107,16 +107,16 @@ class CampaignIn(BaseModel):
107
107
  lead_ids: list[str] = []
108
108
 
109
109
 
110
- class GoogleMapsIn(BaseModel):
111
- palavra_chave: str = Field(min_length=2)
112
- localizacao: str = Field(min_length=2)
113
- max_resultados: int = 100
114
-
115
-
116
110
  class InstagramIn(BaseModel):
117
111
  profile_url: str = Field(min_length=6)
118
112
 
119
113
 
114
+ class MapsExtractIn(BaseModel):
115
+ keyword: str = Field(min_length=2)
116
+ location: str = Field(min_length=2)
117
+ limit: int = 10
118
+
119
+
120
120
  class CnpjIn(BaseModel):
121
121
  cnpj: str = Field(min_length=8)
122
122
 
@@ -378,14 +378,46 @@ def remover_campanha(campaign_id: str):
378
378
  # Extratores
379
379
  # ---------------------------------------------------------------------------
380
380
 
381
- @app.post("/api/extract/google-maps")
382
- def extrair_google_maps(dados: GoogleMapsIn):
383
- try:
384
- return extractors.extrair_google_maps(dados.palavra_chave, dados.localizacao, dados.max_resultados)
385
- except extractors.ExtratorNaoConfigurado as e:
386
- raise HTTPException(400, str(e))
387
- except extractors.ExtratorErro as e:
388
- raise HTTPException(502, str(e))
381
+ @app.post("/api/extractors/maps", status_code=201)
382
+ def extrair_maps(dados: MapsExtractIn):
383
+ """Extrator de leads do Google Maps.
384
+
385
+ Se o scraper real (gosom/google-maps-scraper) estiver configurado em
386
+ Ferramentas, usa o CLI com as flags de isolamento de processos; caso
387
+ contrário, gera dados mockados (5 a 10 leads) para teste inicial, sem
388
+ risco de bloqueio de IP. Os leads são salvos em leads.json na coluna
389
+ "Novos Leads" (etapa inicial do Kanban).
390
+ """
391
+ conf = storage.get_config()["extratores"]
392
+ cli = conf.get("google_maps_cli", "")
393
+ max_resultados = max(1, min(dados.limit, 500))
394
+
395
+ origem = "mock"
396
+ aviso = ""
397
+ leads: list = []
398
+
399
+ if cli and Path(cli).exists():
400
+ try:
401
+ resultado = extractors.extrair_google_maps(dados.keyword, dados.location, max_resultados)
402
+ leads = resultado.get("leads", [])
403
+ if leads:
404
+ origem = "cli"
405
+ else:
406
+ aviso = "O scraper rodou mas não retornou leads — usando dados mockados."
407
+ except (extractors.ExtratorNaoConfigurado, extractors.ExtratorErro) as e:
408
+ aviso = f"Scraper indisponível ({e}); usando dados mockados."
409
+
410
+ if not leads:
411
+ leads = extractors.gerar_mock_maps(dados.keyword, dados.location, dados.limit)
412
+
413
+ importados = storage.import_leads(leads)
414
+ return {
415
+ "origem": origem,
416
+ "aviso": aviso,
417
+ "extraidos": leads,
418
+ "importados": importados["importados"],
419
+ "duplicados": len(importados["duplicados"]),
420
+ }
389
421
 
390
422
 
391
423
  @app.post("/api/extract/instagram")
@@ -230,6 +230,53 @@ def enriquecer_com_site(website: str) -> dict:
230
230
  return {"saidas": resultado.stdout[-2000:]}
231
231
 
232
232
 
233
+ # ---------------------------------------------------------------------------
234
+ # Google Maps — mock para teste inicial (anti-bloqueio de IP)
235
+ # ---------------------------------------------------------------------------
236
+
237
+ _NOMES_MOCK = [
238
+ "Bella Massa", "Sabor & Cia", "Cantinho da Esquina", "Estrela do Bairro",
239
+ "Ponto Certo", "Casa Nova", "Primeira Opção", "Rei do Sabor", "Vila Feliz",
240
+ "Coracao Paulista",
241
+ ]
242
+
243
+ _PREFIXOS_MOCK = ["Pizzaria", "Restaurante", "Lanches", "Comida", "Marmitaria"]
244
+
245
+
246
+ def gerar_mock_maps(keyword: str, location: str, limit: int = 10) -> list:
247
+ """Gera 5 a 10 leads fictícios do Google Maps para teste inicial.
248
+
249
+ Usado quando o scraper real (gosom/google-maps-scraper) não está configurado
250
+ — evita bloqueios de IP nos primeiros testes. Quando o CLI está configurado,
251
+ `extrair_google_maps` (com flags de isolamento) é usada no lugar.
252
+ """
253
+ quantidade = max(5, min(int(limit or 10), 10))
254
+ leads = []
255
+ for i in range(quantidade):
256
+ primeiro = _NOMES_MOCK[i % len(_NOMES_MOCK)]
257
+ prefixo = _PREFIXOS_MOCK[i % len(_PREFIXOS_MOCK)]
258
+ nome = f"{prefixo} {primeiro}"
259
+ ddd = 11 + (i * 3) % 88
260
+ telefone = f"({ddd}) 9{1000 + i:04d}-{4000 + i:04d}"
261
+ slug = primeiro.lower().replace(" ", "").replace("&", "e")
262
+ leads.append(
263
+ {
264
+ "nome": nome,
265
+ "telefone": telefone,
266
+ "email": f"contato@{slug}.com.br",
267
+ "fonte": "Google Maps",
268
+ "etapa": "leads",
269
+ "empresa": nome,
270
+ "endereco": f"Rua das Palmeiras, {100 + i * 7} - Centro",
271
+ "cidade": location,
272
+ "score": 45 + (i * 5) % 40,
273
+ "tags": ["google-maps"],
274
+ "extras": {"avaliacao": round(4.2 + (i % 8) * 0.1, 1), "qtd_avaliacoes": 30 + i * 9},
275
+ }
276
+ )
277
+ return leads
278
+
279
+
233
280
  # ---------------------------------------------------------------------------
234
281
  # Utilitários de parsing tolerante
235
282
  # ---------------------------------------------------------------------------
@@ -280,8 +280,11 @@ def import_leads(novos: list, dedup: bool = True) -> dict:
280
280
  with LOCK:
281
281
  leads = list_leads()
282
282
  existentes = {
283
- (l.get("telefone") or "").strip(),
284
- (l.get("email") or "").strip().lower(),
283
+ (lead.get("telefone") or "").strip()
284
+ for lead in leads
285
+ } | {
286
+ (lead.get("email") or "").strip().lower()
287
+ for lead in leads
285
288
  }
286
289
  importados, duplicados = [], []
287
290
  for novo in novos:
@@ -0,0 +1,46 @@
1
+ """Gate de regressão da arquitetura do launcher (scripts/cli.mjs).
2
+
3
+ Lê o launcher e falha se qualquer proteção contra encerramento silencioso no
4
+ Windows (MobaXterm) for removida:
5
+
6
+ 1. Keep-alive com setInterval (event loop ativo sem I/O de console);
7
+ 2. Absorção de SIGINT/SIGHUP no Windows (apenas log, sem encerrar);
8
+ 3. Função guardConsoleStream (anti-EPIPE com espelho em arquivo de log);
9
+ 4. Isolamento do worker Python (detached: true / CREATE_NEW_PROCESS_GROUP).
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ CAMINHO = Path(__file__).resolve().parents[2] / "scripts" / "cli.mjs"
15
+
16
+
17
+ def _conteudo() -> str:
18
+ assert CAMINHO.exists(), f"launcher não encontrado em {CAMINHO}"
19
+ return CAMINHO.read_text(encoding="utf-8")
20
+
21
+
22
+ class TestLauncherProtections:
23
+ def test_keepalivo(self):
24
+ assert "setInterval(() => {}, 1000)" in _conteudo()
25
+
26
+ def test_absorcao_de_sinais_no_windows(self):
27
+ c = _conteudo()
28
+ assert "SIGINT" in c and "SIGTERM" in c and "SIGHUP" in c
29
+ assert "absorvido" in c
30
+
31
+ def test_guarda_anti_epipe(self):
32
+ c = _conteudo()
33
+ assert "guardConsoleStream" in c
34
+ assert "EPIPE" in c
35
+ assert "launcher_console.log" in c
36
+
37
+ def test_isolamento_do_worker(self):
38
+ c = _conteudo()
39
+ assert "detached: true" in c
40
+ assert "windowsHide: true" in c
41
+
42
+ def test_sigterm_nao_absorvido(self):
43
+ # SIGTERM/SIGBREAK são paradas legítimas: sempre encerram.
44
+ c = _conteudo()
45
+ assert "SIGTERM" in c and "SIGBREAK" in c
46
+ assert "naoAbsorver" in c
@@ -0,0 +1,45 @@
1
+ """Testes do Extrator de Leads (Google Maps) — POST /api/extractors/maps.
2
+
3
+ Sem scraper configurado, a rota gera mock (5–10 leads) e persiste em
4
+ leads.json na coluna "Novos Leads". LEADMASTER_HOME em diretório temporário.
5
+ """
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+
10
+ import main
11
+
12
+
13
+ @pytest.fixture()
14
+ def client(tmp_path, monkeypatch):
15
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
16
+ with TestClient(main.app) as c:
17
+ yield c
18
+
19
+
20
+ def test_mock_gera_e_persiste(client):
21
+ antes = len(client.get("/api/crm/leads").json())
22
+ r = client.post("/api/extractors/maps", json={"keyword": "pizzaria", "location": "São Paulo, SP", "limit": 10})
23
+ assert r.status_code == 201
24
+ corpo = r.json()
25
+ assert corpo["origem"] == "mock"
26
+ assert 5 <= len(corpo["importados"]) <= 10
27
+
28
+ for lead in corpo["importados"]:
29
+ assert lead["fonte"] == "Google Maps"
30
+ assert lead["etapa"] == "leads" # coluna "Novos Leads"
31
+ assert lead["nome"] and lead["telefone"]
32
+
33
+ depois = client.get("/api/crm/leads").json()
34
+ assert len(depois) == antes + len(corpo["importados"])
35
+
36
+
37
+ def test_limit_respeitado(client):
38
+ r = client.post("/api/extractors/maps", json={"keyword": "dentista", "location": "Rio de Janeiro, RJ", "limit": 7})
39
+ assert r.status_code == 201
40
+ assert len(r.json()["importados"]) == 7
41
+
42
+
43
+ def test_payload_invalido(client):
44
+ r = client.post("/api/extractors/maps", json={"keyword": "x"})
45
+ assert r.status_code == 422