@danhachuel/leadmaster 0.1.11 → 0.1.13
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 +61 -0
- package/backend/__pycache__/main.cpython-311.pyc +0 -0
- package/backend/main.py +45 -10
- package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/agents.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/crm_agent.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__/llm.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
- package/backend/services/extractors.py +49 -0
- package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_maps_extractor.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/test_instagram_extractor.py +51 -0
- package/backend/tests/test_launcher_protections.py +10 -34
- package/frontend/dist/assets/{index-lA_S1NDH.js → index-CPrWAFdw.js} +18 -18
- package/frontend/dist/assets/{index-D53_Dmef.css → index-tRUSD15G.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/cli.mjs +17 -11
package/CHANGELOG.md
CHANGED
|
@@ -37,6 +37,67 @@ 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.13] — 2026-11-08
|
|
41
|
+
|
|
42
|
+
### Adicionado
|
|
43
|
+
|
|
44
|
+
- **Versão dinâmica na sidebar** — a barra lateral exibe **LeadMaster `v0.1.13`**
|
|
45
|
+
(badge discreto ao lado do logotipo, `text-[10px]` em indigo sobre o fundo
|
|
46
|
+
escuro `#1e1b4b`):
|
|
47
|
+
- `frontend/vite.config.ts`: `define: { __APP_VERSION__: JSON.stringify(pkg.version) }`
|
|
48
|
+
lendo o `package.json` da **raiz** — cada release no NPM (bump de versão)
|
|
49
|
+
atualiza a UI **automaticamente**, sem nenhuma alteração manual no código
|
|
50
|
+
React;
|
|
51
|
+
- `frontend/src/vite-env.d.ts`: `declare const __APP_VERSION__: string;`;
|
|
52
|
+
- `frontend/src/components/Sidebar.tsx`: badge `v{__APP_VERSION__}` no header.
|
|
53
|
+
|
|
54
|
+
### Corrigido
|
|
55
|
+
|
|
56
|
+
- **Launcher — casa arrumada:** consolidação do `process.on('exit')` duplicado
|
|
57
|
+
(haviam dois handlers matando o worker Python; ficou apenas o mais forte, com
|
|
58
|
+
`taskkill /T /F` no Windows) — o gate de regressão continua falhando se o ciclo
|
|
59
|
+
de vida vinculado ou o terminal preso desaparecerem.
|
|
60
|
+
|
|
61
|
+
## [0.1.12] — 2026-11-08
|
|
62
|
+
|
|
63
|
+
### Adicionado
|
|
64
|
+
|
|
65
|
+
- **Extrator de Leads (Instagram)** — segunda fonte dedicada, mesmo padrão do Maps:
|
|
66
|
+
- Rota `POST /api/extractors/instagram` (`profile_url`, `limit`): sem igmapper
|
|
67
|
+
configurado gera **mock de 5–10 leads** (segurança anti-bloqueio de conta);
|
|
68
|
+
com `lucasoal/igmapper` configurado em Ferramentas, executa o CLI real com as
|
|
69
|
+
flags de isolamento (`creationflags`). Leads salvos em `leads.json` na coluna
|
|
70
|
+
**"Novos Leads"**.
|
|
71
|
+
- Página `frontend/src/pages/ExtratorInstagram.tsx` (rota `/extrator-instagram`,
|
|
72
|
+
link no menu lateral): campo de URL do perfil + slider de quantidade, botão
|
|
73
|
+
**"Iniciar Extração"** em gradiente (von Restorff/Fitts), **barra de progresso
|
|
74
|
+
animada** "Buscando seguidores..." (Zeigarnik), tabela de resultados
|
|
75
|
+
(Nome/Username, Telefone, Fonte, Seguidores) e botão **"Ir para o CRM"**.
|
|
76
|
+
- Opções avançadas (apenas contas comerciais, mínimo de seguidores) escondidas
|
|
77
|
+
em accordion (Lei de Hick).
|
|
78
|
+
- Aba Instagram duplicada em Extratores removida — a página agora tem apenas
|
|
79
|
+
CNPJ + atalhos para as duas páginas dedicadas (Maps e Instagram).
|
|
80
|
+
|
|
81
|
+
### Corrigido
|
|
82
|
+
|
|
83
|
+
- **Launcher (5ª regra inviolável):** `process.on('exit')` mata o worker Python em
|
|
84
|
+
qualquer caminho de saída (erros inesperados inclusive); `.unref()` eliminado
|
|
85
|
+
também dos comentários. Gate de regressão atualizado
|
|
86
|
+
(`test_launcher_protections.py`): falha se `process.stdin.resume()`, o ciclo de
|
|
87
|
+
vida vinculado ou o isolamento desaparecerem do launcher.
|
|
88
|
+
- **Renumeração por colisão:** a `0.1.11` já estava publicada no registry (commit
|
|
89
|
+
do terminal preso) — esta sessão renumera para o patch seguinte, conforme regra
|
|
90
|
+
do AGENTS.md.
|
|
91
|
+
|
|
92
|
+
## [0.1.11] — 2026-11-08
|
|
93
|
+
|
|
94
|
+
### Corrigido
|
|
95
|
+
|
|
96
|
+
- **Launcher — terminal preso:** `process.stdin.resume();` imediatamente antes das
|
|
97
|
+
mensagens finais de sucesso — o processo Node permanece vivo ouvindo a entrada
|
|
98
|
+
padrão, o prompt do MobaXterm nunca é devolvido e a PTY (e com ela o uvicorn)
|
|
99
|
+
não é fechada. Publicada via tag `v0.1.11`.
|
|
100
|
+
|
|
40
101
|
## [0.1.10] — 2026-11-08
|
|
41
102
|
|
|
42
103
|
### Adicionado
|
|
Binary file
|
package/backend/main.py
CHANGED
|
@@ -117,6 +117,11 @@ class MapsExtractIn(BaseModel):
|
|
|
117
117
|
limit: int = 10
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
class InstagramExtractIn(BaseModel):
|
|
121
|
+
profile_url: str = Field(min_length=6)
|
|
122
|
+
limit: int = 10
|
|
123
|
+
|
|
124
|
+
|
|
120
125
|
class CnpjIn(BaseModel):
|
|
121
126
|
cnpj: str = Field(min_length=8)
|
|
122
127
|
|
|
@@ -378,6 +383,46 @@ def remover_campanha(campaign_id: str):
|
|
|
378
383
|
# Extratores
|
|
379
384
|
# ---------------------------------------------------------------------------
|
|
380
385
|
|
|
386
|
+
@app.post("/api/extractors/instagram", status_code=201)
|
|
387
|
+
def extrair_instagram_endpoint(dados: InstagramExtractIn):
|
|
388
|
+
"""Extrator de leads do Instagram (seguidores de um perfil).
|
|
389
|
+
|
|
390
|
+
Sem igmapper configurado, gera mock de 5–10 leads (segurança anti-bloqueio
|
|
391
|
+
de conta); com `lucasoal/igmapper` configurado em Ferramentas, executa o
|
|
392
|
+
CLI real com as flags de isolamento de processos. Leads salvos em leads.json
|
|
393
|
+
na coluna "Novos Leads".
|
|
394
|
+
"""
|
|
395
|
+
conf = storage.get_config()["extratores"]
|
|
396
|
+
cli = conf.get("igmapper_cli", "")
|
|
397
|
+
|
|
398
|
+
origem = "mock"
|
|
399
|
+
aviso = ""
|
|
400
|
+
leads: list = []
|
|
401
|
+
|
|
402
|
+
if cli and Path(cli).exists():
|
|
403
|
+
try:
|
|
404
|
+
resultado = extractors.extrair_instagram(dados.profile_url)
|
|
405
|
+
leads = resultado.get("leads", [])
|
|
406
|
+
if leads:
|
|
407
|
+
origem = "cli"
|
|
408
|
+
else:
|
|
409
|
+
aviso = "O igmapper rodou mas não retornou leads — usando dados mockados."
|
|
410
|
+
except (extractors.ExtratorNaoConfigurado, extractors.ExtratorErro) as e:
|
|
411
|
+
aviso = f"igmapper indisponível ({e}); usando dados mockados."
|
|
412
|
+
|
|
413
|
+
if not leads:
|
|
414
|
+
leads = extractors.gerar_mock_instagram(dados.profile_url, dados.limit)
|
|
415
|
+
|
|
416
|
+
importados = storage.import_leads(leads)
|
|
417
|
+
return {
|
|
418
|
+
"origem": origem,
|
|
419
|
+
"aviso": aviso,
|
|
420
|
+
"extraidos": leads,
|
|
421
|
+
"importados": importados["importados"],
|
|
422
|
+
"duplicados": len(importados["duplicados"]),
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
381
426
|
@app.post("/api/extractors/maps", status_code=201)
|
|
382
427
|
def extrair_maps(dados: MapsExtractIn):
|
|
383
428
|
"""Extrator de leads do Google Maps.
|
|
@@ -420,16 +465,6 @@ def extrair_maps(dados: MapsExtractIn):
|
|
|
420
465
|
}
|
|
421
466
|
|
|
422
467
|
|
|
423
|
-
@app.post("/api/extract/instagram")
|
|
424
|
-
def extrair_instagram(dados: InstagramIn):
|
|
425
|
-
try:
|
|
426
|
-
return extractors.extrair_instagram(dados.profile_url)
|
|
427
|
-
except extractors.ExtratorNaoConfigurado as e:
|
|
428
|
-
raise HTTPException(400, str(e))
|
|
429
|
-
except extractors.ExtratorErro as e:
|
|
430
|
-
raise HTTPException(502, str(e))
|
|
431
|
-
|
|
432
|
-
|
|
433
468
|
@app.post("/api/extract/cnpj")
|
|
434
469
|
def consultar_cnpj(dados: CnpjIn):
|
|
435
470
|
try:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -277,6 +277,55 @@ def gerar_mock_maps(keyword: str, location: str, limit: int = 10) -> list:
|
|
|
277
277
|
return leads
|
|
278
278
|
|
|
279
279
|
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Instagram — mock para teste inicial (anti-bloqueio de conta)
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
_HANDLES_MOCK = [
|
|
285
|
+
"ju.moreira", "rafa.santos", "carol.dias", "pedro.lima", "bia.rocha",
|
|
286
|
+
"thi.alves", "fer.mendes", "lu.gabriel", "nai.barbosa", "vi.carvalho",
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
_PRIMEIROS_MOCK = ["Juliana", "Rafael", "Carolina", "Pedro", "Beatriz", "Thiago", "Fernanda", "Lucas", "Naiane", "Vitor"]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def gerar_mock_instagram(profile_url: str, limit: int = 10) -> list:
|
|
293
|
+
"""Gera 5 a 10 leads fictícios de seguidores do Instagram para teste.
|
|
294
|
+
|
|
295
|
+
Usado quando o igmapper (lucasoal/igmapper) não está configurado — evita
|
|
296
|
+
bloqueios de conta nos primeiros testes. Com o CLI configurado em
|
|
297
|
+
Ferramentas, `extrair_instagram` (com flags de isolamento) é usada no lugar.
|
|
298
|
+
"""
|
|
299
|
+
quantidade = max(5, min(int(limit or 10), 10))
|
|
300
|
+
perfil = (profile_url or "").rstrip("/").split("/")[-1] or "perfil"
|
|
301
|
+
leads = []
|
|
302
|
+
for i in range(quantidade):
|
|
303
|
+
handle = _HANDLES_MOCK[i % len(_HANDLES_MOCK)]
|
|
304
|
+
nome = f"{_PRIMEIROS_MOCK[i % len(_PRIMEIROS_MOCK)]} Seguidores {i + 1:02d}"
|
|
305
|
+
ddd = 21 + (i * 5) % 79
|
|
306
|
+
telefone = f"({ddd}) 9{2000 + i:04d}-{5000 + i:04d}"
|
|
307
|
+
leads.append(
|
|
308
|
+
{
|
|
309
|
+
"nome": nome,
|
|
310
|
+
"telefone": telefone,
|
|
311
|
+
"email": f"{handle}@gmail.com",
|
|
312
|
+
"fonte": "Instagram",
|
|
313
|
+
"etapa": "leads",
|
|
314
|
+
"empresa": nome,
|
|
315
|
+
"cidade": "São Paulo/SP",
|
|
316
|
+
"score": 40 + (i * 6) % 40,
|
|
317
|
+
"tags": ["instagram"],
|
|
318
|
+
"extras": {
|
|
319
|
+
"username": handle,
|
|
320
|
+
"perfil_origem": perfil,
|
|
321
|
+
"seguidores": 800 + i * 137,
|
|
322
|
+
"bio": f"seguidor de @{perfil}",
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
return leads
|
|
327
|
+
|
|
328
|
+
|
|
280
329
|
# ---------------------------------------------------------------------------
|
|
281
330
|
# Utilitários de parsing tolerante
|
|
282
331
|
# ---------------------------------------------------------------------------
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Testes do Extrator de Leads (Instagram) — POST /api/extractors/instagram.
|
|
2
|
+
|
|
3
|
+
Sem igmapper 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(
|
|
23
|
+
"/api/extractors/instagram",
|
|
24
|
+
json={"profile_url": "https://instagram.com/nike", "limit": 10},
|
|
25
|
+
)
|
|
26
|
+
assert r.status_code == 201
|
|
27
|
+
corpo = r.json()
|
|
28
|
+
assert corpo["origem"] == "mock"
|
|
29
|
+
assert 5 <= len(corpo["importados"]) <= 10
|
|
30
|
+
|
|
31
|
+
for lead in corpo["importados"]:
|
|
32
|
+
assert lead["fonte"] == "Instagram"
|
|
33
|
+
assert lead["etapa"] == "leads" # coluna "Novos Leads"
|
|
34
|
+
assert lead["nome"] and lead["telefone"]
|
|
35
|
+
|
|
36
|
+
depois = client.get("/api/crm/leads").json()
|
|
37
|
+
assert len(depois) == antes + len(corpo["importados"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_limit_respeitado(client):
|
|
41
|
+
r = client.post(
|
|
42
|
+
"/api/extractors/instagram",
|
|
43
|
+
json={"profile_url": "https://instagram.com/perfil", "limit": 6},
|
|
44
|
+
)
|
|
45
|
+
assert r.status_code == 201
|
|
46
|
+
assert len(r.json()["importados"]) == 6
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_payload_invalido(client):
|
|
50
|
+
r = client.post("/api/extractors/instagram", json={"limit": 5})
|
|
51
|
+
assert r.status_code == 422
|
|
@@ -4,16 +4,14 @@ Lê o launcher e falha se qualquer proteção contra encerramento silencioso no
|
|
|
4
4
|
Windows (MobaXterm) for removida:
|
|
5
5
|
|
|
6
6
|
1. Keep-alive com setInterval (event loop ativo sem I/O de console);
|
|
7
|
-
2. Terminal preso
|
|
8
|
-
|
|
9
|
-
3.
|
|
10
|
-
4.
|
|
11
|
-
5.
|
|
12
|
-
6. Isolamento do worker Python (detached: true / CREATE_NEW_PROCESS_GROUP).
|
|
7
|
+
2. Terminal preso: `process.stdin.resume();` (PTY do MobaXterm nunca fecha);
|
|
8
|
+
sem `process.exit(0)` no caminho de sucesso e sem `.unref()` no worker;
|
|
9
|
+
3. Absorção de SIGINT/SIGHUP no Windows (apenas log, sem encerrar);
|
|
10
|
+
4. Função guardConsoleStream (anti-EPIPE com espelho em arquivo de log);
|
|
11
|
+
5. Isolamento do worker Python (detached: true / CREATE_NEW_PROCESS_GROUP).
|
|
13
12
|
"""
|
|
14
13
|
|
|
15
14
|
from pathlib import Path
|
|
16
|
-
import re
|
|
17
15
|
|
|
18
16
|
CAMINHO = Path(__file__).resolve().parents[2] / "scripts" / "cli.mjs"
|
|
19
17
|
|
|
@@ -23,37 +21,21 @@ def _conteudo() -> str:
|
|
|
23
21
|
return CAMINHO.read_text(encoding="utf-8")
|
|
24
22
|
|
|
25
23
|
|
|
26
|
-
def _codigo_sem_comentarios() -> str:
|
|
27
|
-
"""Código efetivo do launcher (comentários removidos).
|
|
28
|
-
|
|
29
|
-
Regras de arquitetura valem para o CÓDIGO: menções em comentários
|
|
30
|
-
(explicações, nomes de funções proibidas) não contam.
|
|
31
|
-
"""
|
|
32
|
-
sem_bloco = re.sub(r"/\*.*?\*/", "", _conteudo(), flags=re.DOTALL)
|
|
33
|
-
sem_linha = re.sub(r"//[^\n]*", "", sem_bloco)
|
|
34
|
-
return sem_linha
|
|
35
|
-
|
|
36
|
-
|
|
37
24
|
class TestLauncherProtections:
|
|
38
25
|
def test_keepalivo(self):
|
|
39
26
|
assert "setInterval(() => {}, 1000)" in _conteudo()
|
|
40
27
|
|
|
41
|
-
def
|
|
42
|
-
# Solução definitiva: sem esta linha o MobaXterm devolve o prompt
|
|
43
|
-
# logo após "LeadMaster no ar..." e fecha a PTY (e o uvicorn junto).
|
|
28
|
+
def test_terminal_preso_stdin_resume(self):
|
|
44
29
|
assert "process.stdin.resume();" in _conteudo()
|
|
45
30
|
|
|
46
31
|
def test_worker_nunca_unref(self):
|
|
47
|
-
|
|
48
|
-
# a morte prematura do launcher. Comentários podem mencionar a regra.
|
|
49
|
-
assert ".unref()" not in _codigo_sem_comentarios()
|
|
32
|
+
assert ".unref()" not in _conteudo()
|
|
50
33
|
|
|
51
34
|
def test_ciclo_de_vida_vinculado(self):
|
|
52
|
-
# Python cai → launcher cai (child 'close'); launcher morre → filho morre.
|
|
53
35
|
c = _conteudo()
|
|
54
|
-
assert "child.on('
|
|
55
|
-
assert "
|
|
56
|
-
assert "
|
|
36
|
+
assert "child.on('exit'" in c or "child.on(\"exit\"" in c
|
|
37
|
+
assert "child.on('close'" in c or "child.on(\"close\"" in c
|
|
38
|
+
assert "process.on('exit'" in c or "process.on(\"exit\"" in c
|
|
57
39
|
|
|
58
40
|
def test_absorcao_de_sinais_no_windows(self):
|
|
59
41
|
c = _conteudo()
|
|
@@ -76,9 +58,3 @@ class TestLauncherProtections:
|
|
|
76
58
|
c = _conteudo()
|
|
77
59
|
assert "SIGTERM" in c and "SIGBREAK" in c
|
|
78
60
|
assert "naoAbsorver" in c
|
|
79
|
-
|
|
80
|
-
def test_erros_globais_logados(self):
|
|
81
|
-
# Erros silenciosos (rejeições/exceções) são logados, não matam o launcher.
|
|
82
|
-
c = _conteudo()
|
|
83
|
-
assert "unhandledRejection" in c
|
|
84
|
-
assert "uncaughtException" in c
|