@danhachuel/leadmaster 0.1.13 → 0.1.14
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 +39 -0
- package/backend/__pycache__/main.cpython-311.pyc +0 -0
- package/backend/main.py +47 -0
- 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 +68 -1
- package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_cnpj_extractor.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_cnpj_extractor.py +64 -0
- package/frontend/dist/assets/{index-tRUSD15G.css → index-Bf28IekR.css} +1 -1
- package/frontend/dist/assets/{index-CPrWAFdw.js → index-DgsUzQ92.js} +18 -18
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -37,6 +37,45 @@ 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.14] — 2026-11-08
|
|
41
|
+
|
|
42
|
+
### Adicionado
|
|
43
|
+
|
|
44
|
+
- **Extrator de Leads (CNPJ) — terceira fonte dedicada, leads B2B:**
|
|
45
|
+
- Rota `POST /api/extractors/cnpj` (`cnae`, `uf`, `limit`): enquanto o provedor
|
|
46
|
+
de busca em Ferramentas → Extratores for o placeholder padrão, gera **mock de
|
|
47
|
+
5–10 empresas fictícias** (nome fantasia, CNPJ formatado, telefone/e-mail,
|
|
48
|
+
porte e capital social em `extras`) — estabilidade e anti-bloqueio nesta
|
|
49
|
+
fase. Com URL de provedor real configurada, consulta o provedor
|
|
50
|
+
(`buscar_cnpj_cnae`, parser tolerante) e **degrada para mock** em falha ou
|
|
51
|
+
rate-limit, com aviso acionável. Leads salvos em `leads.json` na coluna
|
|
52
|
+
**"Novos Leads"** e retornados na resposta (`extraidos`, `importados`,
|
|
53
|
+
`duplicados`, `origem`).
|
|
54
|
+
- Página `frontend/src/pages/ExtratorCNPJ.tsx` (rota `/extrator-cnpj`, link no
|
|
55
|
+
menu lateral): campo **CNAE**, **dropdown de UF** (27 estados), slider de
|
|
56
|
+
quantidade (5–10), botão **"Iniciar Extração"** em gradiente
|
|
57
|
+
(von Restorff/Fitts), **barra de progresso animada** "Buscando empresas..."
|
|
58
|
+
(Zeigarnik), tabela de resultados (Nome Fantasia, CNPJ, Cidade/UF,
|
|
59
|
+
Telefone, Fonte) e botão **"Ir para o CRM"**. Opções avançadas (porte da
|
|
60
|
+
empresa, capital social mínimo) escondidas em accordion (Lei de Hick) —
|
|
61
|
+
aplicam-se ao provedor real.
|
|
62
|
+
- **Consulta avulsa por CNPJ** (BrasilAPI) preservada dentro da nova página:
|
|
63
|
+
consulta e importa uma empresa específica para "Novos Leads".
|
|
64
|
+
- `frontend/src/pages/Extratores.tsx` virou hub de atalhos para as três
|
|
65
|
+
páginas dedicadas (Maps, Instagram, CNPJ) — aba CNPJ removida, mesmo
|
|
66
|
+
padrão das outras fontes.
|
|
67
|
+
- **Testes:** `backend/tests/test_cnpj_extractor.py` — mock gera e persiste
|
|
68
|
+
(fonte/etapa/campos), limite respeitado, UF normalizada (minúscula → "MG") e
|
|
69
|
+
payload inválido (422). Suíte completa: **31 passed**.
|
|
70
|
+
|
|
71
|
+
### Auditoria
|
|
72
|
+
|
|
73
|
+
- **0.1.13 (versão anterior):** Extrator Instagram entregue na 0.1.12 por sessão
|
|
74
|
+
simultânea — auditado contra a especificação (rota `POST /api/extractors/instagram`,
|
|
75
|
+
mock 5–10, página `/extrator-instagram`, accordion avançado, testes) **sem
|
|
76
|
+
alterações necessárias**; versão dinâmica na sidebar (`__APP_VERSION__`) e
|
|
77
|
+
consolidação do `process.on('exit')` do launcher conferidos e verdes.
|
|
78
|
+
|
|
40
79
|
## [0.1.13] — 2026-11-08
|
|
41
80
|
|
|
42
81
|
### Adicionado
|
|
Binary file
|
package/backend/main.py
CHANGED
|
@@ -132,6 +132,12 @@ class CnpjSearchIn(BaseModel):
|
|
|
132
132
|
quantidade: int = 20
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
class CnpjExtractIn(BaseModel):
|
|
136
|
+
cnae: str = Field(min_length=2)
|
|
137
|
+
uf: str = Field(min_length=2)
|
|
138
|
+
limit: int = 10
|
|
139
|
+
|
|
140
|
+
|
|
135
141
|
class ChatIn(BaseModel):
|
|
136
142
|
mensagens: list[dict]
|
|
137
143
|
|
|
@@ -423,6 +429,47 @@ def extrair_instagram_endpoint(dados: InstagramExtractIn):
|
|
|
423
429
|
}
|
|
424
430
|
|
|
425
431
|
|
|
432
|
+
@app.post("/api/extractors/cnpj", status_code=201)
|
|
433
|
+
def extrair_cnpj_endpoint(dados: CnpjExtractIn):
|
|
434
|
+
"""Extrator de leads B2B (empresas) por CNAE + UF.
|
|
435
|
+
|
|
436
|
+
Enquanto o provedor de busca em Ferramentas → Extratores for o placeholder
|
|
437
|
+
padrão, gera mock de 5–10 empresas fictícias (nome fantasia, CNPJ,
|
|
438
|
+
telefone/e-mail) — estabilidade e anti-bloqueio nesta fase. Com uma URL de
|
|
439
|
+
provedor real configurada, consulta o provedor e degrada para mock em
|
|
440
|
+
falha ou rate-limit. Leads salvos em leads.json na coluna "Novos Leads".
|
|
441
|
+
"""
|
|
442
|
+
conf = storage.get_config()["extratores"]
|
|
443
|
+
provedor = (conf.get("cnpj_provedor_busca") or "").strip()
|
|
444
|
+
|
|
445
|
+
origem = "mock"
|
|
446
|
+
aviso = ""
|
|
447
|
+
leads: list = []
|
|
448
|
+
|
|
449
|
+
if provedor and provedor != extractors.CNPJ_PROVEDOR_PADRAO:
|
|
450
|
+
try:
|
|
451
|
+
resultado = extractors.buscar_cnpj_cnae(dados.cnae, dados.uf, dados.limit)
|
|
452
|
+
leads = resultado.get("leads", [])
|
|
453
|
+
if leads:
|
|
454
|
+
origem = "provedor"
|
|
455
|
+
else:
|
|
456
|
+
aviso = "O provedor de busca não retornou leads — usando dados mockados."
|
|
457
|
+
except extractors.ExtratorErro as e:
|
|
458
|
+
aviso = f"Provedor de busca indisponível ({e}); usando dados mockados."
|
|
459
|
+
|
|
460
|
+
if not leads:
|
|
461
|
+
leads = extractors.gerar_mock_cnpj(dados.cnae, dados.uf, dados.limit)
|
|
462
|
+
|
|
463
|
+
importados = storage.import_leads(leads)
|
|
464
|
+
return {
|
|
465
|
+
"origem": origem,
|
|
466
|
+
"aviso": aviso,
|
|
467
|
+
"extraidos": leads,
|
|
468
|
+
"importados": importados["importados"],
|
|
469
|
+
"duplicados": len(importados["duplicados"]),
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
426
473
|
@app.post("/api/extractors/maps", status_code=201)
|
|
427
474
|
def extrair_maps(dados: MapsExtractIn):
|
|
428
475
|
"""Extrator de leads do Google Maps.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -109,6 +109,12 @@ def extrair_instagram(profile_url: str) -> dict:
|
|
|
109
109
|
# CNPJ — consulta direta (BrasilAPI) e busca por CNAE/UF (provedor configurável)
|
|
110
110
|
# ---------------------------------------------------------------------------
|
|
111
111
|
|
|
112
|
+
# Placeholder do provedor de busca: enquanto a URL em Ferramentas → Extratores
|
|
113
|
+
# for este valor, o extrator trabalha em modo MOCK (5–10 empresas fictícias) —
|
|
114
|
+
# estabilidade e anti-bloqueio nesta fase. URL diferente = provedor real.
|
|
115
|
+
CNPJ_PROVEDOR_PADRAO = "https://api.opencnpj.org"
|
|
116
|
+
|
|
117
|
+
|
|
112
118
|
def _so_digitos(texto: str) -> str:
|
|
113
119
|
return "".join(c for c in texto or "" if c.isdigit())
|
|
114
120
|
|
|
@@ -160,7 +166,7 @@ def buscar_cnpj_cnae(cnae: str, uf: str, quantidade: int = 20) -> dict:
|
|
|
160
166
|
{"data": ...} e vários nomes de campos).
|
|
161
167
|
"""
|
|
162
168
|
conf = _conf()
|
|
163
|
-
base = (conf.get("cnpj_provedor_busca") or
|
|
169
|
+
base = (conf.get("cnpj_provedor_busca") or CNPJ_PROVEDOR_PADRAO).rstrip("/")
|
|
164
170
|
params = {"cnae": _so_digitos(cnae), "uf": uf.strip().upper(), "quantidade": str(max(1, min(quantidade, 100)))}
|
|
165
171
|
try:
|
|
166
172
|
r = httpx.get(base, params=params, timeout=60)
|
|
@@ -205,6 +211,67 @@ def buscar_cnpj_cnae(cnae: str, uf: str, quantidade: int = 20) -> dict:
|
|
|
205
211
|
return {"leads": leads, "provedor": base}
|
|
206
212
|
|
|
207
213
|
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# CNPJ — mock para teste inicial (empresas fictícias por CNAE/UF)
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
_FANTASIAS_MOCK = [
|
|
219
|
+
"Tecno", "Solar", "Sabores", "Constrular", "Saúde Viva",
|
|
220
|
+
"Agro Forte", "Moda Urbana", "Log Mais", "Clean House", "InfoSul",
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
_SUFIXOS_MOCK = ["Ltda", "Serviços", "Comércio", "Indústria", "Tech", "Soluções"]
|
|
224
|
+
|
|
225
|
+
_PORTES_MOCK = ["ME", "EPP", "Médio", "Grande"]
|
|
226
|
+
|
|
227
|
+
_CIDADES_MOCK = ["São Paulo", "Campinas", "Santos", "Ribeirão Preto", "Sorocaba", "São José dos Campos"]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def gerar_mock_cnpj(cnae: str, uf: str, limit: int = 10) -> list:
|
|
231
|
+
"""Gera 5 a 10 empresas fictícias (leads B2B) por CNAE + UF para teste.
|
|
232
|
+
|
|
233
|
+
Usado enquanto o provedor de busca por CNAE não estiver configurado com uma
|
|
234
|
+
URL real em Ferramentas → Extratores — evita bloqueios e garante
|
|
235
|
+
estabilidade nesta fase. Com provedor real configurado, `buscar_cnpj_cnae`
|
|
236
|
+
é usada no lugar (degradação graciosa para mock em falha/rate-limit).
|
|
237
|
+
"""
|
|
238
|
+
quantidade = max(5, min(int(limit or 10), 10))
|
|
239
|
+
uf = (uf or "SP").strip().upper()[:2]
|
|
240
|
+
cnae_limpo = _so_digitos(cnae)
|
|
241
|
+
cnae_fmt = (cnae or "").strip() or (f"{cnae_limpo[:4]}-{cnae_limpo[4:5]}/{cnae_limpo[5:]}" if cnae_limpo else "6201-5/00")
|
|
242
|
+
leads = []
|
|
243
|
+
for i in range(quantidade):
|
|
244
|
+
fantasia = f"{_FANTASIAS_MOCK[i % len(_FANTASIAS_MOCK)]} {_SUFIXOS_MOCK[i % len(_SUFIXOS_MOCK)]}"
|
|
245
|
+
raiz = 10_000_000 + i * 1_373
|
|
246
|
+
ordem = f"{raiz:08d}0001{10 + i:02d}"
|
|
247
|
+
cnpj = f"{ordem[:2]}.{ordem[2:5]}.{ordem[5:8]}/{ordem[8:12]}-{ordem[12:]}"
|
|
248
|
+
ddd = 11 + (i * 7) % 88
|
|
249
|
+
capital = round(50_000 + i * 137_500, -3)
|
|
250
|
+
leads.append(
|
|
251
|
+
{
|
|
252
|
+
"nome": fantasia,
|
|
253
|
+
"empresa": fantasia,
|
|
254
|
+
"cnpj": cnpj,
|
|
255
|
+
"telefone": f"({ddd}) 3{2000 + i:04d}-{3000 + i:04d}",
|
|
256
|
+
"email": f"contato@{fantasia.split()[0].lower()}{i + 1}.com.br",
|
|
257
|
+
"endereco": f"Avenida Central, {100 + i * 13} - Centro",
|
|
258
|
+
"cidade": f"{_CIDADES_MOCK[i % len(_CIDADES_MOCK)]}/{uf}",
|
|
259
|
+
"fonte": "CNPJ",
|
|
260
|
+
"etapa": "leads",
|
|
261
|
+
"score": 45 + (i * 5) % 45,
|
|
262
|
+
"tags": ["cnpj", "b2b"],
|
|
263
|
+
"extras": {
|
|
264
|
+
"porte": _PORTES_MOCK[i % len(_PORTES_MOCK)],
|
|
265
|
+
"capital_social": capital,
|
|
266
|
+
"cnae": cnae_fmt,
|
|
267
|
+
"municipio": _CIDADES_MOCK[i % len(_CIDADES_MOCK)],
|
|
268
|
+
"uf": uf,
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
return leads
|
|
273
|
+
|
|
274
|
+
|
|
208
275
|
def enriquecer_com_site(website: str) -> dict:
|
|
209
276
|
"""Scraping profundo de contatos no site da empresa, via Scrapling (opcional)."""
|
|
210
277
|
conf = _conf()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Testes do Extrator de Leads (CNPJ) — POST /api/extractors/cnpj.
|
|
2
|
+
|
|
3
|
+
Sem provedor de busca configurado (placeholder padrão), a rota gera mock de
|
|
4
|
+
5–10 empresas fictícias e persiste em leads.json na coluna "Novos Leads".
|
|
5
|
+
LEADMASTER_HOME em diretório temporário.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from fastapi.testclient import TestClient
|
|
9
|
+
|
|
10
|
+
import main
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _client(tmp_path, monkeypatch):
|
|
14
|
+
monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
|
|
15
|
+
return TestClient(main.app)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_mock_gera_e_persiste(tmp_path, monkeypatch):
|
|
19
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
20
|
+
antes = len(client.get("/api/crm/leads").json())
|
|
21
|
+
r = client.post(
|
|
22
|
+
"/api/extractors/cnpj",
|
|
23
|
+
json={"cnae": "6201-5/01", "uf": "SP", "limit": 10},
|
|
24
|
+
)
|
|
25
|
+
assert r.status_code == 201
|
|
26
|
+
corpo = r.json()
|
|
27
|
+
assert corpo["origem"] == "mock"
|
|
28
|
+
assert 5 <= len(corpo["importados"]) <= 10
|
|
29
|
+
|
|
30
|
+
for lead in corpo["importados"]:
|
|
31
|
+
assert lead["fonte"] == "CNPJ"
|
|
32
|
+
assert lead["etapa"] == "leads" # coluna "Novos Leads"
|
|
33
|
+
assert lead["nome"] and lead["cnpj"] and lead["telefone"]
|
|
34
|
+
assert "/SP" in lead["cidade"]
|
|
35
|
+
|
|
36
|
+
depois = client.get("/api/crm/leads").json()
|
|
37
|
+
assert len(depois) == antes + len(corpo["importados"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_limit_respeitado(tmp_path, monkeypatch):
|
|
41
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
42
|
+
r = client.post(
|
|
43
|
+
"/api/extractors/cnpj",
|
|
44
|
+
json={"cnae": "6202-3/01", "uf": "RJ", "limit": 6},
|
|
45
|
+
)
|
|
46
|
+
assert r.status_code == 201
|
|
47
|
+
assert len(r.json()["importados"]) == 6
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_uf_normalizada(tmp_path, monkeypatch):
|
|
51
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
52
|
+
r = client.post(
|
|
53
|
+
"/api/extractors/cnpj",
|
|
54
|
+
json={"cnae": "4711-3/02", "uf": "mg", "limit": 5},
|
|
55
|
+
)
|
|
56
|
+
assert r.status_code == 201
|
|
57
|
+
leads = r.json()["importados"]
|
|
58
|
+
assert all("/MG" in lead["cidade"] for lead in leads)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_payload_invalido(tmp_path, monkeypatch):
|
|
62
|
+
with _client(tmp_path, monkeypatch) as client:
|
|
63
|
+
r = client.post("/api/extractors/cnpj", json={"uf": "SP", "limit": 5})
|
|
64
|
+
assert r.status_code == 422
|