@danhachuel/leadmaster 0.1.22 → 0.1.24

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 (32) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/backend/__pycache__/main.cpython-311.pyc +0 -0
  3. package/backend/main.py +15 -13
  4. package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
  5. package/backend/services/__pycache__/agents.cpython-311.pyc +0 -0
  6. package/backend/services/__pycache__/crm_agent.cpython-311.pyc +0 -0
  7. package/backend/services/__pycache__/extractors.cpython-311.pyc +0 -0
  8. package/backend/services/__pycache__/flags.cpython-311.pyc +0 -0
  9. package/backend/services/__pycache__/llm.cpython-311.pyc +0 -0
  10. package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
  11. package/backend/services/extractors.py +49 -9
  12. package/backend/services/storage.py +4 -1
  13. package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
  14. package/backend/tests/__pycache__/test_agents_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  15. package/backend/tests/__pycache__/test_campaigns_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  16. package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  17. package/backend/tests/__pycache__/test_config.cpython-311-pytest-8.4.2.pyc +0 -0
  18. package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  19. package/backend/tests/__pycache__/test_extractors_real.cpython-311-pytest-8.4.2.pyc +0 -0
  20. package/backend/tests/__pycache__/test_funnel_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  21. package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  22. package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
  23. package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  24. package/backend/tests/__pycache__/test_metrics.cpython-311-pytest-8.4.2.pyc +0 -0
  25. package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
  26. package/backend/tests/__pycache__/test_webhook_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  27. package/backend/tests/test_extractors_real.py +65 -0
  28. package/backend/tests/test_launcher_protections.py +31 -0
  29. package/frontend/dist/assets/{index-CX_h90mG.js → index-B_SzrvJP.js} +17 -17
  30. package/frontend/dist/index.html +1 -1
  31. package/package.json +1 -1
  32. package/scripts/cli.mjs +10 -0
package/CHANGELOG.md CHANGED
@@ -37,6 +37,62 @@ normaliza para a forma canônica (ex.: `0.1.0`).
37
37
  falha se `process.stdin.resume();` desaparecer, se `.unref()` aparecer, se o
38
38
  ciclo de vida vinculado for removido ou se os handlers globais sumirem.
39
39
 
40
+ ## [0.1.24] — 2026-11-09
41
+
42
+ ### Corrigido
43
+
44
+ - **Launcher — regressão do terminal preso investigada e blindagem reforçada** —
45
+ usuário reportou o prompt do MobaXterm voltando logo após "LeadMaster no ar"
46
+ na 0.1.21 (runtime permanente). A investigação mostrou que o fluxo publicado
47
+ **mantém** as proteções (`process.stdin.resume();`, `setInterval`,
48
+ `child.on('exit')`/`close`, `process.on('exit')`, sem `.unref()`) e que uma
49
+ morte silenciosa assim — sem nenhum log do handler de saída — só ocorre por
50
+ `SIGKILL`/`taskkill /F` (ex.: outra execução `install`/`start` matando a
51
+ instância anterior pelo kill switch, ou fechamento forçado da aba). Mesmo
52
+ assim, o launcher foi reforçado e os gates agora validam o **fluxo**:
53
+ - **Rede de segurança no `cmdStart`:** `process.on('beforeExit')` re-arma o
54
+ `setInterval` se o event loop esvaziar (stdin em EOF, timer perdido,
55
+ surpresa de PTY) — o Node jamais encerra sozinho; erros do stream de
56
+ stdin (`process.stdin.on('error')`) são ignorados;
57
+ - **Gates de fluxo (`test_launcher_protections.py`):** novo helper
58
+ `_corpo_cmd_start()` extrai o corpo da função para garantir que
59
+ `spawn`, `process.stdin.resume();`, `setInterval`, `child.on('exit')`,
60
+ `child.on('close')`, `process.on('exit')` e `beforeExit` existam **dentro
61
+ do `start`** (não no `install` nem em função morta) e que **nenhum
62
+ `process.exit(`** apareça após o terminal ser preso — a saída só por ciclo
63
+ de vida do filho ou por `SIGTERM`/`SIGBREAK`;
64
+ - **AGENTS.md:** regra 2 do launcher documenta a rede de segurança e a
65
+ exigência de posição (após o spawn, no fluxo do start).
66
+ - **Diagnóstico rápido para o usuário:** com a regra acima, se o prompt voltar
67
+ sem as linhas "Encerrando LeadMaster..." (SIGTERM/SIGBREAK) ou "Servidor
68
+ local encerrou com código N" (Python caiu), a causa é externa ao launcher
69
+ (outra execução ou fechamento forçado) — o espelho
70
+ `storage/logs/launcher_console.log` mostra exatamente qual caminho ocorreu.
71
+
72
+ ## [0.1.23] — 2026-11-09
73
+
74
+ ### Adicionado
75
+
76
+ - **Extratores conectados de verdade (fim da dependência de mock quando
77
+ configurado):**
78
+ - **Google Maps:** executa o binário real (`gosom/google-maps-scraper`) com as
79
+ flags de isolamento (`creationflags`, sem janelas de CMD) e **timeout de
80
+ segurança configurável** (`timeout_maps_segundos`, padrão 300s).
81
+ - **Instagram:** passa **URL + limite** ao CLI (`igmapper_args` agora inclui
82
+ `{limit}`) com timeout configurável (`timeout_instagram_segundos`, padrão
83
+ 120s).
84
+ - **CNPJ:** URL base da **BrasilAPI** vem do `config.json`
85
+ (`cnpj_api_url`) — permite trocar o provedor sem alterar código.
86
+ - **Regra de erro:** scraper **configurado** que falha (código ≠ 0, timeout,
87
+ executável inválido) retorna **HTTP 500 com mensagem clara** e **não salva
88
+ nada** (sem mock disfarçado, sem dados parciais). Sem scraper configurado,
89
+ o modo mock segue protegendo os primeiros testes.
90
+ - Ferramentas → Extratores ganhou os campos **URL base da BrasilAPI** e
91
+ **timeouts de segurança** (Maps/Instagram).
92
+ - **4 novos testes pytest** (`backend/tests/test_extractors_real.py`): scraper
93
+ configurado falhando → 500 sem salvar, timeout → 500 "Timeout na extração",
94
+ falha do igmapper → 500 sem salvar, e mock preservado sem configuração.
95
+
40
96
  ## [0.1.22] — 2026-11-09
41
97
 
42
98
  ### Adicionado
package/backend/main.py CHANGED
@@ -430,15 +430,16 @@ def extrair_instagram_endpoint(dados: InstagramExtractIn):
430
430
  leads: list = []
431
431
 
432
432
  if cli and Path(cli).exists():
433
+ # Scraper REAL configurado: executa e propaga erro amigavel (HTTP 500) se
434
+ # falhar — nunca salvamos dados parciais/corrompidos.
433
435
  try:
434
- resultado = extractors.extrair_instagram(dados.profile_url)
436
+ resultado = extractors.extrair_instagram(dados.profile_url, dados.limit)
435
437
  leads = resultado.get("leads", [])
436
- if leads:
437
- origem = "cli"
438
- else:
439
- aviso = "O igmapper rodou mas não retornou leads — usando dados mockados."
440
- except (extractors.ExtratorNaoConfigurado, extractors.ExtratorErro) as e:
441
- aviso = f"igmapper indisponível ({e}); usando dados mockados."
438
+ origem = "cli"
439
+ except extractors.ExtratorNaoConfigurado as e:
440
+ raise HTTPException(500, f"Scraper nao configurado corretamente: {e}")
441
+ except extractors.ExtratorErro as e:
442
+ raise HTTPException(500, str(e))
442
443
 
443
444
  if not leads:
444
445
  leads = extractors.gerar_mock_instagram(dados.profile_url, dados.limit)
@@ -513,15 +514,16 @@ def extrair_maps(dados: MapsExtractIn):
513
514
  leads: list = []
514
515
 
515
516
  if cli and Path(cli).exists():
517
+ # Scraper REAL configurado: executa e propaga erro amigavel (HTTP 500)
518
+ # se falhar — nunca salvamos dados parciais/corrompidos.
516
519
  try:
517
520
  resultado = extractors.extrair_google_maps(dados.keyword, dados.location, max_resultados)
518
521
  leads = resultado.get("leads", [])
519
- if leads:
520
- origem = "cli"
521
- else:
522
- aviso = "O scraper rodou mas não retornou leads — usando dados mockados."
523
- except (extractors.ExtratorNaoConfigurado, extractors.ExtratorErro) as e:
524
- aviso = f"Scraper indisponível ({e}); usando dados mockados."
522
+ origem = "cli"
523
+ except extractors.ExtratorNaoConfigurado as e:
524
+ raise HTTPException(500, f"Scraper nao configurado corretamente: {e}")
525
+ except extractors.ExtratorErro as e:
526
+ raise HTTPException(500, str(e))
525
527
 
526
528
  if not leads:
527
529
  leads = extractors.gerar_mock_maps(dados.keyword, dados.location, dados.limit)
@@ -32,6 +32,14 @@ def _conf() -> dict:
32
32
  return storage.get_config()["extratores"]
33
33
 
34
34
 
35
+ def _timeout_extrator(chave: str, padrao: int) -> int:
36
+ """Timeout de segurança configurável por extrator (Ferramentas → Extratores)."""
37
+ try:
38
+ return max(5, int(_conf().get(chave, padrao)))
39
+ except (TypeError, ValueError):
40
+ return padrao
41
+
42
+
35
43
  def _novo_lead_parcial() -> dict:
36
44
  return {"tags": [], "observacoes": "", "score": 50}
37
45
 
@@ -62,7 +70,19 @@ def extrair_google_maps(palavra_chave: str, localizacao: str, max_resultados: in
62
70
  outdir=str(outdir),
63
71
  )
64
72
  try:
65
- resultado = subprocess.run(cmd, capture_output=True, text=True, timeout=1800, shell=False, creationflags=proc_flags.creationflags())
73
+ resultado = subprocess.run(
74
+ cmd,
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=_timeout_extrator("timeout_maps_segundos", 300),
78
+ shell=False,
79
+ creationflags=proc_flags.creationflags(),
80
+ )
81
+ except subprocess.TimeoutExpired as e:
82
+ raise ExtratorErro(
83
+ f"Timeout na extração do Google Maps após {_timeout_extrator('timeout_maps_segundos', 300)}s. "
84
+ "Aumente o timeout em Ferramentas → Extratores ou reduza a quantidade de resultados."
85
+ ) from e
66
86
  except FileNotFoundError as e:
67
87
  raise ExtratorNaoConfigurado(f"Não foi possível executar o CLI do Google Maps Scraper: {e}") from e
68
88
  if resultado.returncode != 0:
@@ -78,7 +98,7 @@ def extrair_google_maps(palavra_chave: str, localizacao: str, max_resultados: in
78
98
  # Instagram (lucasoal/igmapper via CLI)
79
99
  # ---------------------------------------------------------------------------
80
100
 
81
- def extrair_instagram(profile_url: str) -> dict:
101
+ def extrair_instagram(profile_url: str, limit: int = 10) -> dict:
82
102
  conf = _conf()
83
103
  cli = conf.get("igmapper_cli", "")
84
104
  if not cli or not Path(cli).exists():
@@ -88,10 +108,27 @@ def extrair_instagram(profile_url: str) -> dict:
88
108
  )
89
109
  with tempfile.TemporaryDirectory(prefix="leadmaster-ig-") as tmp:
90
110
  outdir = Path(tmp)
91
- args_template = conf.get("igmapper_args", "--profile {url} --outdir {outdir}")
92
- cmd = [cli] + _formatar_args(args_template, url=profile_url, outdir=str(outdir))
111
+ args_template = conf.get("igmapper_args", "--profile {url} --limit {limit} --outdir {outdir}")
112
+ cmd = [cli] + _formatar_args(
113
+ args_template,
114
+ url=profile_url,
115
+ limit=max(1, int(limit or 10)),
116
+ outdir=str(outdir),
117
+ )
93
118
  try:
94
- resultado = subprocess.run(cmd, capture_output=True, text=True, timeout=900, shell=False, creationflags=proc_flags.creationflags())
119
+ resultado = subprocess.run(
120
+ cmd,
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=_timeout_extrator("timeout_instagram_segundos", 120),
124
+ shell=False,
125
+ creationflags=proc_flags.creationflags(),
126
+ )
127
+ except subprocess.TimeoutExpired as e:
128
+ raise ExtratorErro(
129
+ f"Timeout na extração do Instagram após {_timeout_extrator('timeout_instagram_segundos', 120)}s. "
130
+ "Aumente o timeout em Ferramentas → Extratores."
131
+ ) from e
95
132
  except FileNotFoundError as e:
96
133
  raise ExtratorNaoConfigurado(f"Não foi possível executar o igmapper: {e}") from e
97
134
  if resultado.returncode != 0:
@@ -113,6 +150,7 @@ def extrair_instagram(profile_url: str) -> dict:
113
150
  # for este valor, o extrator trabalha em modo MOCK (5–10 empresas fictícias) —
114
151
  # estabilidade e anti-bloqueio nesta fase. URL diferente = provedor real.
115
152
  CNPJ_PROVEDOR_PADRAO = "https://api.opencnpj.org"
153
+ CNPJ_API_PADRAO = "https://brasilapi.com.br/api/cnpj/v1"
116
154
 
117
155
 
118
156
  def _so_digitos(texto: str) -> str:
@@ -123,14 +161,16 @@ def consulta_cnpj(cnpj: str) -> dict:
123
161
  cnpj = _so_digitos(cnpj)
124
162
  if len(cnpj) != 14:
125
163
  raise ExtratorErro("CNPJ inválido: use 14 dígitos (com ou sem pontuação).")
164
+ base = (_conf().get("cnpj_api_url") or CNPJ_API_PADRAO).rstrip("/")
165
+ url = f"{base}/{cnpj}"
126
166
  try:
127
- r = httpx.get(f"https://brasilapi.com.br/api/cnpj/v1/{cnpj}", timeout=30)
167
+ r = httpx.get(url, timeout=30)
128
168
  except httpx.HTTPError as e:
129
- raise ExtratorErro(f"Falha ao consultar a BrasilAPI: {e}") from e
169
+ raise ExtratorErro(f"Falha ao consultar a API de CNPJ ({base}): {e}") from e
130
170
  if r.status_code == 404:
131
- raise ExtratorErro("CNPJ não encontrado na BrasilAPI.")
171
+ raise ExtratorErro("CNPJ não encontrado na API consultada.")
132
172
  if r.status_code >= 400:
133
- raise ExtratorErro(f"BrasilAPI respondeu HTTP {r.status_code}: {r.text[:200]}")
173
+ raise ExtratorErro(f"API de CNPJ ({base}) respondeu HTTP {r.status_code}: {r.text[:200]}")
134
174
  d = r.json()
135
175
  endereco = " ".join(
136
176
  p
@@ -114,9 +114,12 @@ DEFAULT_CONFIG = {
114
114
  '--kw "{palavra_chave}" --loc "{localizacao}" --lang pt --email --max {max_resultados} --outdir "{outdir}"'
115
115
  ),
116
116
  "igmapper_cli": "",
117
- "igmapper_args": "--profile {url} --outdir {outdir}",
117
+ "igmapper_args": "--profile {url} --limit {limit} --outdir {outdir}",
118
118
  "scrapling_cli": "",
119
+ "cnpj_api_url": "https://brasilapi.com.br/api/cnpj/v1",
119
120
  "cnpj_provedor_busca": "https://api.opencnpj.org",
121
+ "timeout_maps_segundos": 300,
122
+ "timeout_instagram_segundos": 120,
120
123
  },
121
124
  "agentes": {
122
125
  "disparo": {
@@ -0,0 +1,65 @@
1
+ """Testes dos extratores REAIS (0.1.23): scraper configurado que falha retorna
2
+ HTTP 500 amigável e NÃO salva leads mockados/parciais; sem scraper configurado,
3
+ o modo mock segue funcionando. Usa o próprio executável do Python como falso
4
+ binário para simular falha de execução do CLI.
5
+ """
6
+
7
+ import subprocess
8
+ import sys
9
+ from unittest.mock import patch
10
+
11
+ import pytest
12
+ from fastapi.testclient import TestClient
13
+
14
+ import main
15
+
16
+
17
+ @pytest.fixture()
18
+ def client(tmp_path, monkeypatch):
19
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
20
+ with TestClient(main.app) as c:
21
+ yield c
22
+
23
+
24
+ def _completed(returncode, stderr=""):
25
+ return main.extractors.subprocess.CompletedProcess(args=[], returncode=returncode, stdout="", stderr=stderr)
26
+
27
+
28
+ def test_maps_scraper_falhar_500_sem_salvar(client):
29
+ # "Binário" configurado (o próprio python), mas a execução falha (returncode 1).
30
+ client.put("/api/config", json={"extratores": {"google_maps_cli": sys.executable}})
31
+ antes = len(client.get("/api/crm/leads").json())
32
+ with patch.object(main.extractors.subprocess, "run", return_value=_completed(1, "falha simulada")):
33
+ r = client.post("/api/extractors/maps", json={"keyword": "pizzaria", "location": "São Paulo, SP", "limit": 8})
34
+ assert r.status_code == 500
35
+ assert "código 1" in r.json()["detail"] or "falha simulada" in r.json()["detail"]
36
+ # Nada foi salvo no leads.json (sem mock/parcial).
37
+ depois = client.get("/api/crm/leads").json()
38
+ assert len(depois) == antes
39
+
40
+
41
+ def test_maps_scraper_timeout_500(client):
42
+ client.put("/api/config", json={"extratores": {"google_maps_cli": sys.executable}})
43
+ with patch.object(
44
+ main.extractors.subprocess, "run", side_effect=subprocess.TimeoutExpired(cmd="x", timeout=300)
45
+ ):
46
+ r = client.post("/api/extractors/maps", json={"keyword": "dentista", "location": "Rio, RJ", "limit": 8})
47
+ assert r.status_code == 500
48
+ assert "Timeout na extração" in r.json()["detail"]
49
+
50
+
51
+ def test_instagram_scraper_falhar_500_sem_salvar(client):
52
+ client.put("/api/config", json={"extratores": {"igmapper_cli": sys.executable}})
53
+ antes = len(client.get("/api/crm/leads").json())
54
+ with patch.object(main.extractors.subprocess, "run", return_value=_completed(2, "igmapper explodiu")):
55
+ r = client.post("/api/extractors/instagram", json={"profile_url": "https://instagram.com/x", "limit": 6})
56
+ assert r.status_code == 500
57
+ depois = client.get("/api/crm/leads").json()
58
+ assert len(depois) == antes
59
+
60
+
61
+ def test_maps_sem_scraper_mantem_mock(client):
62
+ r = client.post("/api/extractors/maps", json={"keyword": "pizzaria", "location": "Campinas, SP", "limit": 7})
63
+ assert r.status_code == 201
64
+ assert r.json()["origem"] == "mock"
65
+ assert len(r.json()["importados"]) == 7
@@ -23,6 +23,15 @@ def _conteudo() -> str:
23
23
  return CAMINHO.read_text(encoding="utf-8")
24
24
 
25
25
 
26
+ def _corpo_cmd_start() -> str:
27
+ """Corpo da função cmdStart — valida o FLUXO do start (não só strings)."""
28
+ c = _conteudo()
29
+ assert "function cmdStart(" in c, "função cmdStart não encontrada"
30
+ inicio = c.index("function cmdStart(")
31
+ fim = c.index("async function cmdDoctor")
32
+ return c[inicio:fim]
33
+
34
+
26
35
  class TestLauncherProtections:
27
36
  def test_keepalivo(self):
28
37
  assert "setInterval(() => {}, 1000)" in _conteudo()
@@ -44,6 +53,28 @@ class TestLauncherProtections:
44
53
  # Avisar que já existe instância no ar é proibido (impede atualizações).
45
54
  assert "já está rodando" not in c
46
55
 
56
+ def test_keepalive_no_fluxo_do_start(self):
57
+ # Gate de FLUXO (regressão da 0.1.21): as proteções precisam existir
58
+ # dentro do cmdStart — no install, em função morta ou em bloco errado
59
+ # não valem. A morte prematura do Node devolve o prompt do MobaXterm.
60
+ corpo = _corpo_cmd_start()
61
+ assert "spawn(" in corpo # worker Python é spawnado no start
62
+ assert "process.stdin.resume();" in corpo # terminal preso
63
+ assert "setInterval(() => {}, 1000)" in corpo # keep-alive
64
+ assert "beforeExit" in corpo # rede de segurança do event loop
65
+ assert "child.on('exit'" in corpo or 'child.on("exit"' in corpo
66
+ assert "child.on('close'" in corpo or 'child.on("close"' in corpo
67
+ assert "process.on('exit'" in corpo or 'process.on("exit"' in corpo
68
+
69
+ def test_start_nunca_chama_exit_apos_terminal_preso(self):
70
+ # Após o terminal ser preso, nenhum caminho de sucesso chama
71
+ # process.exit — a saída só ocorre pelo ciclo de vida do filho
72
+ # (child exit/close) ou por sinais de parada legítima.
73
+ corpo = _corpo_cmd_start()
74
+ pos = corpo.index("process.stdin.resume();")
75
+ fim_iife = corpo.index("})().catch(") # catch de erro fatal fica de fora
76
+ assert "process.exit(" not in corpo[pos:fim_iife]
77
+
47
78
  def test_ciclo_de_vida_vinculado(self):
48
79
  c = _conteudo()
49
80
  assert "child.on('exit'" in c or "child.on(\"exit\"" in c