@danhachuel/leadmaster 0.1.3 → 0.1.5

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,40 @@ 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.05] — 2026-11-08
10
+
11
+ ### Corrigido
12
+
13
+ - **Encerramento em cascata no Windows (STATUS_CONTROL_C_EXIT / 0xC000013A)** — o
14
+ launcher Node e os workers Python compartilhavam o mesmo grupo de processos,
15
+ então Ctrl+C no terminal (MobaXterm, VS Code, perda de foco) derrubava tudo
16
+ junto, arriscando corromper extrações e trabalhos em andamento.
17
+ - `scripts/cli.mjs`: o servidor FastAPI é spawnado com **`detached: true`**
18
+ (no Windows, `CREATE_NEW_PROCESS_GROUP` 0x00000200) — grupo de processos
19
+ próprio; sinais do terminal não cascateiam para o worker, que só é
20
+ encerrado pelo `kill()` controlado do launcher.
21
+ - `backend/services/flags.py` (novo): helper `creationflags()` com
22
+ `CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` (0x08000200) no Windows;
23
+ 0 no Linux/macOS.
24
+ - `backend/services/extractors.py`: todas as execuções de CLI (Google Maps
25
+ Scraper, igmapper, Scrapling) passam a usar as flags isoladas.
26
+ - Testes `backend/tests/test_process_flags.py` (pytest) verificam as flags sem
27
+ mockar `os.name` (o que quebraria `pathlib` nas duas direções): o helper
28
+ recebe a plataforma por parâmetro e os testes mockam o próprio helper —
29
+ passam idênticos no GitHub Actions (Linux) e no Windows local.
30
+ - CI: novo passo "Run backend tests (pytest)" no workflow de publicação.
31
+
32
+ ## [0.1.04] — 2026-11-05
33
+
34
+ ### Corrigido
35
+
36
+ - **Instruções de instalação fixadas em versão antiga** — o README (GitHub e pacote NPM)
37
+ e a mensagem final do `install` usavam `@0.1.0`, instalando uma versão obsoleta.
38
+ - README e saída do launcher agora usam a tag **`@latest`** (CMD/MobaXterm e normal),
39
+ com a versão exata exibida ao lado na mensagem do `install`.
40
+ - AGENTS.md documenta a regra: instruções permanentes sempre `@latest`; comunicação
41
+ de release informa também a versão canônica do registry.
42
+
9
43
  ## [0.1.03] — 2026-10-25
10
44
 
11
45
  ### Adicionado
package/README.md CHANGED
@@ -13,14 +13,14 @@ Requisitos: **Node 18+** e **Python 3.11+** no PATH.
13
13
 
14
14
  ```cmd
15
15
  :: CMD / MobaXterm (Windows)
16
- npx.cmd --yes --prefer-online @danhachuel/leadmaster@0.1.0 install
17
- npx.cmd --yes --prefer-online @danhachuel/leadmaster@0.1.0
16
+ npx.cmd --yes --prefer-online @danhachuel/leadmaster@latest install
17
+ npx.cmd --yes --prefer-online @danhachuel/leadmaster@latest
18
18
  ```
19
19
 
20
20
  ```bash
21
21
  # Linux / macOS / PowerShell
22
- npx --yes @danhachuel/leadmaster@0.1.0 install
23
- npx --yes @danhachuel/leadmaster@0.1.0
22
+ npx --yes @danhachuel/leadmaster@latest install
23
+ npx --yes @danhachuel/leadmaster@latest
24
24
  ```
25
25
 
26
26
  - `install` prepara a pasta de dados e instala as dependências Python.
@@ -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.03",
3
+ "version": "0.1.05",
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
@@ -316,7 +316,8 @@ function cmdInstall(args) {
316
316
 
317
317
  console.log('');
318
318
  console.log('Instalação concluída. Para usar (comando único):');
319
- console.log(` npx --yes @danhachuel/leadmaster@${VERSION}`);
319
+ console.log(' npx --yes @danhachuel/leadmaster@latest');
320
+ console.log(` (equivalente fixado: npx --yes @danhachuel/leadmaster@${VERSION})`);
320
321
  console.log(' (o mesmo comando, sem "install", também verifica e prepara tudo sozinho)');
321
322
  }
322
323
 
@@ -373,6 +374,11 @@ function cmdStart(args) {
373
374
  console.warn('⚠ Interface construída não encontrada (frontend/dist).');
374
375
  }
375
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).
376
382
  const child = spawn(python.cmd, [...python.args, '-m', 'uvicorn', 'main:app', '--host', '127.0.0.1', '--port', String(porta), '--no-access-log'], {
377
383
  cwd: path.join(PKG_ROOT, 'backend'),
378
384
  env: {
@@ -384,6 +390,7 @@ function cmdStart(args) {
384
390
  PYTHONIOENCODING: 'utf-8',
385
391
  },
386
392
  stdio: ['ignore', 'pipe', 'pipe'],
393
+ detached: true,
387
394
  windowsHide: true,
388
395
  });
389
396