@danhachuel/leadmaster 0.1.10 → 0.1.12
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 +71 -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 +17 -3
- package/frontend/dist/assets/{index-lA_S1NDH.js → index-CLRV7oRl.js} +18 -18
- package/frontend/dist/assets/{index-D53_Dmef.css → index-D6u6-7Rs.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/cli.mjs +61 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,77 @@ 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.11] — 2026-11-08
|
|
10
|
+
|
|
11
|
+
### Corrigido
|
|
12
|
+
|
|
13
|
+
- **Launcher ainda devolvia o prompt no MobaXterm — a 0.1.9 não resolveu** — o
|
|
14
|
+
processo Node encerrava seu event loop logo após imprimir "LeadMaster no ar...",
|
|
15
|
+
mesmo com o keep-alive `setInterval`. Sem o Node vivo, o MobaXterm fecha a
|
|
16
|
+
pseudo-console (PTY) e o uvicorn perde o terminal, encerrando junto. Correções
|
|
17
|
+
definitivas no `scripts/cli.mjs`:
|
|
18
|
+
- **Terminal preso:** `process.stdin.resume();` inserido **logo antes das
|
|
19
|
+
mensagens finais de sucesso** — o Node fica indefinidamente vivo ouvindo a
|
|
20
|
+
entrada padrão, sem depender de timers nem do wrapper do `npx`. O keep-alive
|
|
21
|
+
`setInterval` permanece como segunda camada de proteção.
|
|
22
|
+
- **Ciclo de vida vinculado nos dois sentidos:** `child.on('close')` encerra o
|
|
23
|
+
launcher quando o servidor Python cai (mensagem "Servidor encerrado"), e
|
|
24
|
+
`process.on('exit')` mata o filho quando o launcher morre — nenhum processo
|
|
25
|
+
fica órfão. O worker Python continua **sem `.unref()`** (o pai não pode
|
|
26
|
+
morrer sozinho).
|
|
27
|
+
- **Erros globais (debug):** handlers `unhandledRejection` e `uncaughtException`
|
|
28
|
+
logam o erro em vez de derrubar o launcher silenciosamente.
|
|
29
|
+
- **Sem `process.exit(0)` após o sucesso:** a saída do fluxo principal agora é
|
|
30
|
+
natural (via listeners do filho); chamadas de `process.exit` permanecem apenas
|
|
31
|
+
em caminhos de erro acionável, na parada legítima (`SIGTERM`/`SIGBREAK`) e no
|
|
32
|
+
aviso informativo "já está rodando".
|
|
33
|
+
- **AGENTS.md:** regra inviolável do launcher passa de quatro para **cinco
|
|
34
|
+
proteções** (nova: *Terminal preso*, incluindo a proibição de `process.exit(0)`
|
|
35
|
+
após o sucesso e a vedação do `.unref()`).
|
|
36
|
+
- **Gate de regressão reforçado:** `backend/tests/test_launcher_protections.py`
|
|
37
|
+
falha se `process.stdin.resume();` desaparecer, se `.unref()` aparecer, se o
|
|
38
|
+
ciclo de vida vinculado for removido ou se os handlers globais sumirem.
|
|
39
|
+
|
|
40
|
+
## [0.1.12] — 2026-11-08
|
|
41
|
+
|
|
42
|
+
### Adicionado
|
|
43
|
+
|
|
44
|
+
- **Extrator de Leads (Instagram)** — segunda fonte dedicada, mesmo padrão do Maps:
|
|
45
|
+
- Rota `POST /api/extractors/instagram` (`profile_url`, `limit`): sem igmapper
|
|
46
|
+
configurado gera **mock de 5–10 leads** (segurança anti-bloqueio de conta);
|
|
47
|
+
com `lucasoal/igmapper` configurado em Ferramentas, executa o CLI real com as
|
|
48
|
+
flags de isolamento (`creationflags`). Leads salvos em `leads.json` na coluna
|
|
49
|
+
**"Novos Leads"**.
|
|
50
|
+
- Página `frontend/src/pages/ExtratorInstagram.tsx` (rota `/extrator-instagram`,
|
|
51
|
+
link no menu lateral): campo de URL do perfil + slider de quantidade, botão
|
|
52
|
+
**"Iniciar Extração"** em gradiente (von Restorff/Fitts), **barra de progresso
|
|
53
|
+
animada** "Buscando seguidores..." (Zeigarnik), tabela de resultados
|
|
54
|
+
(Nome/Username, Telefone, Fonte, Seguidores) e botão **"Ir para o CRM"**.
|
|
55
|
+
- Opções avançadas (apenas contas comerciais, mínimo de seguidores) escondidas
|
|
56
|
+
em accordion (Lei de Hick).
|
|
57
|
+
- Aba Instagram duplicada em Extratores removida — a página agora tem apenas
|
|
58
|
+
CNPJ + atalhos para as duas páginas dedicadas (Maps e Instagram).
|
|
59
|
+
|
|
60
|
+
### Corrigido
|
|
61
|
+
|
|
62
|
+
- **Launcher (5ª regra inviolável):** `process.on('exit')` mata o worker Python em
|
|
63
|
+
qualquer caminho de saída (erros inesperados inclusive); `.unref()` eliminado
|
|
64
|
+
também dos comentários. Gate de regressão atualizado
|
|
65
|
+
(`test_launcher_protections.py`): falha se `process.stdin.resume()`, o ciclo de
|
|
66
|
+
vida vinculado ou o isolamento desaparecerem do launcher.
|
|
67
|
+
- **Renumeração por colisão:** a `0.1.11` já estava publicada no registry (commit
|
|
68
|
+
do terminal preso) — esta sessão renumera para o patch seguinte, conforme regra
|
|
69
|
+
do AGENTS.md.
|
|
70
|
+
|
|
71
|
+
## [0.1.11] — 2026-11-08
|
|
72
|
+
|
|
73
|
+
### Corrigido
|
|
74
|
+
|
|
75
|
+
- **Launcher — terminal preso:** `process.stdin.resume();` imediatamente antes das
|
|
76
|
+
mensagens finais de sucesso — o processo Node permanece vivo ouvindo a entrada
|
|
77
|
+
padrão, o prompt do MobaXterm nunca é devolvido e a PTY (e com ela o uvicorn)
|
|
78
|
+
não é fechada. Publicada via tag `v0.1.11`.
|
|
79
|
+
|
|
9
80
|
## [0.1.10] — 2026-11-08
|
|
10
81
|
|
|
11
82
|
### 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,9 +4,11 @@ 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.
|
|
8
|
-
|
|
9
|
-
|
|
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).
|
|
10
12
|
"""
|
|
11
13
|
|
|
12
14
|
from pathlib import Path
|
|
@@ -23,6 +25,18 @@ class TestLauncherProtections:
|
|
|
23
25
|
def test_keepalivo(self):
|
|
24
26
|
assert "setInterval(() => {}, 1000)" in _conteudo()
|
|
25
27
|
|
|
28
|
+
def test_terminal_preso_stdin_resume(self):
|
|
29
|
+
assert "process.stdin.resume();" in _conteudo()
|
|
30
|
+
|
|
31
|
+
def test_worker_nunca_unref(self):
|
|
32
|
+
assert ".unref()" not in _conteudo()
|
|
33
|
+
|
|
34
|
+
def test_ciclo_de_vida_vinculado(self):
|
|
35
|
+
c = _conteudo()
|
|
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
|
|
39
|
+
|
|
26
40
|
def test_absorcao_de_sinais_no_windows(self):
|
|
27
41
|
c = _conteudo()
|
|
28
42
|
assert "SIGINT" in c and "SIGTERM" in c and "SIGHUP" in c
|