@danhachuel/leadmaster 0.1.4 → 0.1.6

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,39 @@ 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.06] — 2026-11-08
10
+
11
+ ### Corrigido
12
+
13
+ - **Regra de comandos de instalação (AGENTS.md)** — redação ambígua permitia enviar
14
+ `@latest` nas mensagens de release. Agora é explícito: os comandos entregues ao
15
+ usuário usam **sempre a versão canônica confirmada no registry** (ex.: `0.1.6`) —
16
+ `@latest` é permitido apenas nas instruções permanentes (README, página NPM,
17
+ `legal/`).
18
+
19
+ ## [0.1.05] — 2026-11-08
20
+
21
+ ### Corrigido
22
+
23
+ - **Encerramento em cascata no Windows (STATUS_CONTROL_C_EXIT / 0xC000013A)** — o
24
+ launcher Node e os workers Python compartilhavam o mesmo grupo de processos,
25
+ então Ctrl+C no terminal (MobaXterm, VS Code, perda de foco) derrubava tudo
26
+ junto, arriscando corromper extrações e trabalhos em andamento.
27
+ - `scripts/cli.mjs`: o servidor FastAPI é spawnado com **`detached: true`**
28
+ (no Windows, `CREATE_NEW_PROCESS_GROUP` 0x00000200) — grupo de processos
29
+ próprio; sinais do terminal não cascateiam para o worker, que só é
30
+ encerrado pelo `kill()` controlado do launcher.
31
+ - `backend/services/flags.py` (novo): helper `creationflags()` com
32
+ `CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` (0x08000200) no Windows;
33
+ 0 no Linux/macOS.
34
+ - `backend/services/extractors.py`: todas as execuções de CLI (Google Maps
35
+ Scraper, igmapper, Scrapling) passam a usar as flags isoladas.
36
+ - Testes `backend/tests/test_process_flags.py` (pytest) verificam as flags sem
37
+ mockar `os.name` (o que quebraria `pathlib` nas duas direções): o helper
38
+ recebe a plataforma por parâmetro e os testes mockam o próprio helper —
39
+ passam idênticos no GitHub Actions (Linux) e no Windows local.
40
+ - CI: novo passo "Run backend tests (pytest)" no workflow de publicação.
41
+
9
42
  ## [0.1.04] — 2026-11-05
10
43
 
11
44
  ### Corrigido
@@ -0,0 +1,2 @@
1
+ -r requirements.txt
2
+ pytest>=8.0,<9.0
@@ -16,6 +16,7 @@ from pathlib import Path
16
16
 
17
17
  import httpx
18
18
 
19
+ from . import flags as proc_flags
19
20
  from . import storage
20
21
 
21
22
 
@@ -61,7 +62,7 @@ def extrair_google_maps(palavra_chave: str, localizacao: str, max_resultados: in
61
62
  outdir=str(outdir),
62
63
  )
63
64
  try:
64
- resultado = subprocess.run(cmd, capture_output=True, text=True, timeout=1800, shell=False)
65
+ resultado = subprocess.run(cmd, capture_output=True, text=True, timeout=1800, shell=False, creationflags=proc_flags.creationflags())
65
66
  except FileNotFoundError as e:
66
67
  raise ExtratorNaoConfigurado(f"Não foi possível executar o CLI do Google Maps Scraper: {e}") from e
67
68
  if resultado.returncode != 0:
@@ -90,7 +91,7 @@ def extrair_instagram(profile_url: str) -> dict:
90
91
  args_template = conf.get("igmapper_args", "--profile {url} --outdir {outdir}")
91
92
  cmd = [cli] + _formatar_args(args_template, url=profile_url, outdir=str(outdir))
92
93
  try:
93
- resultado = subprocess.run(cmd, capture_output=True, text=True, timeout=900, shell=False)
94
+ resultado = subprocess.run(cmd, capture_output=True, text=True, timeout=900, shell=False, creationflags=proc_flags.creationflags())
94
95
  except FileNotFoundError as e:
95
96
  raise ExtratorNaoConfigurado(f"Não foi possível executar o igmapper: {e}") from e
96
97
  if resultado.returncode != 0:
@@ -220,6 +221,7 @@ def enriquecer_com_site(website: str) -> dict:
220
221
  text=True,
221
222
  timeout=300,
222
223
  shell=False,
224
+ creationflags=proc_flags.creationflags(),
223
225
  )
224
226
  except FileNotFoundError as e:
225
227
  raise ExtratorNaoConfigurado(f"Não foi possível executar o Scrapling: {e}") from e
@@ -0,0 +1,40 @@
1
+ """Flags de criação de subprocessos, isolados no Windows.
2
+
3
+ Problema que resolve: quando o terminal (MobaXterm, VS Code, perda de foco)
4
+ envia Ctrl+C, o evento de interrupção (STATUS_CONTROL_C_EXIT / 0xC000013A) é
5
+ propagado a todo o *grupo de processos* do terminal — derrubando launcher E
6
+ workers juntos, corrompendo extrações e trabalhos em andamento.
7
+
8
+ Solução: cada worker nasce em seu PRÓPRIO grupo de processos no Windows
9
+ (`CREATE_NEW_PROCESS_GROUP`) e sem janela (`CREATE_NO_WINDOW`); assim,
10
+ interrupções de terminal não cascateiam para os workers, e quem encerra o
11
+ worker é o launcher, de forma controlada.
12
+
13
+ No Linux/macOS o launcher já spawna com `detached: true` (sessão própria) e
14
+ `creationflags` retorna 0 — não há alteração de comportamento.
15
+
16
+ O parâmetro `so` existe para os testes: assim é possível verificar os dois
17
+ comportamentos sem mockar `os.name` globalmente (o que quebraria `pathlib`
18
+ nas duas direções — fingir "nt" no Linux e "posix" no Windows).
19
+ """
20
+
21
+ import os
22
+ import subprocess
23
+
24
+ # CREATE_NEW_PROCESS_GROUP = 0x00000200 (grupo isolado de sinais do console)
25
+ # CREATE_NO_WINDOW = 0x08000000 (não abrir janela de console)
26
+ # getattr com fallback literal: CREATE_NO_WINDOW não existe em todas as
27
+ # plataformas POSIX — o módulo precisa importar limpo no Linux (CI) também.
28
+ _WINDOWS_FLAGS = (
29
+ getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
30
+ | getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
31
+ )
32
+
33
+
34
+ def creationflags(so: str | None = None) -> int:
35
+ """Flags a passar em subprocess.run/Popen. 0 no Linux/macOS."""
36
+ if so is None:
37
+ so = os.name
38
+ if so == "nt":
39
+ return _WINDOWS_FLAGS
40
+ return 0
@@ -0,0 +1,6 @@
1
+ """Permite `import services...` quando o pytest roda da raiz do repositório."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -0,0 +1,88 @@
1
+ """Testes de isolamento de processos (Windows process groups).
2
+
3
+ Verificam que os workers são criados com CREATE_NEW_PROCESS_GROUP |
4
+ CREATE_NO_WINDOW no Windows e sem flags no POSIX — para que Ctrl+C do terminal
5
+ não derrube launcher e workers em cascata (STATUS_CONTROL_C_EXIT).
6
+
7
+ Nota de portabilidade: em vez de mockar `os.name` globalmente (o que quebra
8
+ `pathlib` nas duas direções — "nt" fingido no Linux e "posix" fingido no
9
+ Windows), os testes usam o parâmetro `so` do helper e mockam o próprio helper.
10
+ Assim passam idênticos no GitHub Actions (Linux) e no Windows local.
11
+ """
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ from unittest.mock import patch
17
+
18
+ from services import extractors
19
+ from services.flags import _WINDOWS_FLAGS, creationflags
20
+
21
+ FLAGS_WINDOWS = _WINDOWS_FLAGS # 0x00000200 | 0x08000000
22
+
23
+
24
+ def _config_fake() -> dict:
25
+ return {
26
+ "extratores": {
27
+ # sys.executable existe em qualquer plataforma — o run é mockado.
28
+ "google_maps_cli": sys.executable,
29
+ "google_maps_args": '--kw "{palavra_chave}" --loc "{localizacao}" --email --max {max_resultados} --outdir "{outdir}"',
30
+ "igmapper_cli": sys.executable,
31
+ "igmapper_args": "--profile {url} --outdir {outdir}",
32
+ }
33
+ }
34
+
35
+
36
+ def _completed_process(*_args, **_kwargs) -> subprocess.CompletedProcess:
37
+ return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
38
+
39
+
40
+ class TestFlags:
41
+ def test_windows_aplica_novo_grupo_e_sem_janela(self):
42
+ flags = creationflags("nt")
43
+ assert flags & 0x00000200, "CREATE_NEW_PROCESS_GROUP ausente"
44
+ assert flags & 0x08000000, "CREATE_NO_WINDOW ausente"
45
+ assert flags == FLAGS_WINDOWS
46
+
47
+ def test_posix_sem_flags(self):
48
+ assert creationflags("posix") == 0
49
+
50
+ def test_plataforma_real_sem_parametro(self):
51
+ esperado = FLAGS_WINDOWS if os.name == "nt" else 0
52
+ assert creationflags() == esperado
53
+
54
+
55
+ class TestExtratores:
56
+ def _extrair_maps(self):
57
+ return extractors.extrair_google_maps("dentista", "São Paulo SP", 10)
58
+
59
+ def _rodar_com_flags(self, valor_flags):
60
+ with (
61
+ patch.object(extractors.proc_flags, "creationflags", return_value=valor_flags),
62
+ patch.object(extractors.storage, "get_config", return_value=_config_fake()),
63
+ patch.object(extractors.subprocess, "run", side_effect=_completed_process) as run_mock,
64
+ ):
65
+ self._extrair_maps()
66
+ assert run_mock.called
67
+ return run_mock
68
+
69
+ def test_google_maps_repassa_flags_windows(self):
70
+ run_mock = self._rodar_com_flags(FLAGS_WINDOWS)
71
+ assert run_mock.call_args.kwargs["creationflags"] == FLAGS_WINDOWS
72
+
73
+ def test_google_maps_repassa_zero_posix(self):
74
+ run_mock = self._rodar_com_flags(0)
75
+ assert run_mock.call_args.kwargs["creationflags"] == 0
76
+
77
+ def test_instagram_repassa_flags_windows(self):
78
+ with (
79
+ patch.object(extractors.proc_flags, "creationflags", return_value=FLAGS_WINDOWS),
80
+ patch.object(extractors.storage, "get_config", return_value=_config_fake()),
81
+ patch.object(extractors.subprocess, "run", side_effect=_completed_process) as run_mock,
82
+ ):
83
+ try:
84
+ extractors.extrair_instagram("https://instagram.com/padaria.do.largo/")
85
+ except extractors.ExtratorErro:
86
+ pass # saída mockada vazia → "sem dados reconhecíveis" (esperado)
87
+ assert run_mock.called
88
+ assert run_mock.call_args.kwargs["creationflags"] == FLAGS_WINDOWS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/leadmaster",
3
- "version": "0.1.04",
3
+ "version": "0.1.06",
4
4
  "description": "SaaS local de prospecção e automação: CRM, extratores de leads e agentes de IA — 100% local.",
5
5
  "license": "MIT",
6
6
  "author": "Dan Hachuel",
package/scripts/cli.mjs CHANGED
@@ -374,6 +374,11 @@ function cmdStart(args) {
374
374
  console.warn('⚠ Interface construída não encontrada (frontend/dist).');
375
375
  }
376
376
 
377
+ // Workers nascem em grupo de processos próprio (detached: true =
378
+ // CREATE_NEW_PROCESS_GROUP no Windows): sinais de Ctrl+C do terminal
379
+ // (MobaXterm, VS Code, perda de foco) matam apenas o launcher — o
380
+ // servidor Python continua rodando e só é encerrado pelo kill()
381
+ // controlado abaixo (ou sobrevive para a próxima execução detectar).
377
382
  const child = spawn(python.cmd, [...python.args, '-m', 'uvicorn', 'main:app', '--host', '127.0.0.1', '--port', String(porta), '--no-access-log'], {
378
383
  cwd: path.join(PKG_ROOT, 'backend'),
379
384
  env: {
@@ -385,6 +390,7 @@ function cmdStart(args) {
385
390
  PYTHONIOENCODING: 'utf-8',
386
391
  },
387
392
  stdio: ['ignore', 'pipe', 'pipe'],
393
+ detached: true,
388
394
  windowsHide: true,
389
395
  });
390
396