@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 +34 -0
- package/README.md +4 -4
- package/backend/requirements-test.txt +2 -0
- package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/extractors.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/flags.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
- package/backend/services/extractors.py +4 -2
- package/backend/services/flags.py +40 -0
- package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/conftest.py +6 -0
- package/backend/tests/test_process_flags.py +88 -0
- package/package.json +1 -1
- package/scripts/cli.mjs +8 -1
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@
|
|
17
|
-
npx.cmd --yes --prefer-online @danhachuel/leadmaster@
|
|
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@
|
|
23
|
-
npx --yes @danhachuel/leadmaster@
|
|
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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
Binary 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
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(
|
|
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
|
|