@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.
Files changed (23) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/backend/__pycache__/main.cpython-311.pyc +0 -0
  3. package/backend/main.py +47 -0
  4. package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
  5. package/backend/services/__pycache__/agents.cpython-311.pyc +0 -0
  6. package/backend/services/__pycache__/crm_agent.cpython-311.pyc +0 -0
  7. package/backend/services/__pycache__/extractors.cpython-311.pyc +0 -0
  8. package/backend/services/__pycache__/flags.cpython-311.pyc +0 -0
  9. package/backend/services/__pycache__/llm.cpython-311.pyc +0 -0
  10. package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
  11. package/backend/services/extractors.py +68 -1
  12. package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
  13. package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  14. package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  15. package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  16. package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
  17. package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  18. package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
  19. package/backend/tests/test_cnpj_extractor.py +64 -0
  20. package/frontend/dist/assets/{index-tRUSD15G.css → index-Bf28IekR.css} +1 -1
  21. package/frontend/dist/assets/{index-CPrWAFdw.js → index-DgsUzQ92.js} +18 -18
  22. package/frontend/dist/index.html +2 -2
  23. 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
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.
@@ -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 "https://api.opencnpj.org").rstrip("/")
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()
@@ -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