@danhachuel/leadmaster 0.1.9 → 0.1.10
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 +30 -0
- package/README.md +1 -1
- package/backend/__pycache__/main.cpython-311.pyc +0 -0
- package/backend/main.py +46 -14
- 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 +47 -0
- package/backend/services/storage.py +5 -2
- 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_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_maps_extractor.py +45 -0
- package/frontend/dist/assets/{index-DVFFIa5O.css → index-D53_Dmef.css} +1 -1
- package/frontend/dist/assets/{index-ZsRPRMCF.js → index-lA_S1NDH.js} +18 -18
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,36 @@ 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.10] — 2026-11-08
|
|
10
|
+
|
|
11
|
+
### Adicionado
|
|
12
|
+
|
|
13
|
+
- **Extrator de Leads (Google Maps)** — primeira página dedicada de extração:
|
|
14
|
+
- Rota `POST /api/extractors/maps` (`keyword`, `location`, `limit`): sem scraper
|
|
15
|
+
configurado gera **mock de 5–10 leads** (segurança anti-bloqueio de IP);
|
|
16
|
+
com `gosom/google-maps-scraper` configurado em Ferramentas, executa o CLI
|
|
17
|
+
real com as flags de isolamento (`creationflags` da 0.1.5). Os leads são
|
|
18
|
+
salvos em `leads.json` na coluna **"Novos Leads"** e retornados na resposta.
|
|
19
|
+
- Página `frontend/src/pages/ExtratorMaps.tsx` (rota `/extrator-maps`, link no
|
|
20
|
+
menu lateral): formulário limpo (palavra-chave, localização, slider de
|
|
21
|
+
quantidade), **modal/accordion** com opções avançadas escondidas (Lei de
|
|
22
|
+
Hick), botão **"Iniciar Extração"** em gradiente (von Restorff/Fitts),
|
|
23
|
+
**barra de progresso animada** durante a extração (Zeigarnik), tabela de
|
|
24
|
+
resultados (Nome, Telefone, Localização, Avaliação) e botão **"Ir para o
|
|
25
|
+
CRM"**. A aba Maps duplicada dentro de Extratores foi removida (substituída
|
|
26
|
+
por um atalho para a página dedicada).
|
|
27
|
+
|
|
28
|
+
### Corrigido
|
|
29
|
+
|
|
30
|
+
- **Bug latente em `storage.import_leads`** (NameError na deduplicação) —
|
|
31
|
+
exposto pelos novos testes e corrigido; importação em massa agora funciona.
|
|
32
|
+
|
|
33
|
+
### Testes
|
|
34
|
+
|
|
35
|
+
- 3 novos testes pytest da rota (`backend/tests/test_maps_extractor.py`):
|
|
36
|
+
mock/persistência, limite respeitado e payload inválido (422).
|
|
37
|
+
Suíte completa: **21 passed**.
|
|
38
|
+
|
|
9
39
|
## [0.1.09] — 2026-11-08
|
|
10
40
|
|
|
11
41
|
### Corrigido
|
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ para `http://127.0.0.1:8010` (backend de desenvolvimento).
|
|
|
55
55
|
| **Painel** | Status da conexão, taxa de entrega, taxa de erro, total de leads; gráfico de barras (leads por etapa do CRM) e doughnut (campanhas: enviadas × erros). |
|
|
56
56
|
| **Leads** | CRUD completo, importação em massa a partir dos extratores, barra de progresso de preenchimento, enriquecimento por IA. |
|
|
57
57
|
| **CRM Kanban** | Drag-and-drop (@dnd-kit) nas colunas *Leads → Em contato → Proposta → Fechado*; o agente de IA sugere movimentações e enriquece os cards. |
|
|
58
|
-
| **Extratores** | **Google Maps** (palavra-chave + localização,
|
|
58
|
+
| **Extratores** | **Google Maps** (página dedicada `ExtratorMaps`: palavra-chave + localização + quantidade, com mock de 5–10 leads para teste seguro ou scraper real `gosom/google-maps-scraper` quando configurado — leads entram direto na coluna "Novos Leads" do CRM), **Instagram** (URL do perfil, via `lucasoal/igmapper`) e **CNPJ** (BrasilAPI + busca por CNAE/UF + `D4Vinci/Scrapling`). Toda extração gera pré-visualização antes de importar. |
|
|
59
59
|
| **Disparos** | Campanhas de disparo (texto/áudio) com templates configuráveis e execução via `eracle/OpenOutreach`. |
|
|
60
60
|
| **Agentes** | Agente de disparo (prompts/templates) e agente de atendimento (texto via LLM ou voz via `pipecat-ai/pipecat`). |
|
|
61
61
|
| **Ferramentas** | Chaves de API, URLs das integrações locais, testes de conexão — tudo salvo em `config.json` local. |
|
|
Binary file
|
package/backend/main.py
CHANGED
|
@@ -107,16 +107,16 @@ class CampaignIn(BaseModel):
|
|
|
107
107
|
lead_ids: list[str] = []
|
|
108
108
|
|
|
109
109
|
|
|
110
|
-
class GoogleMapsIn(BaseModel):
|
|
111
|
-
palavra_chave: str = Field(min_length=2)
|
|
112
|
-
localizacao: str = Field(min_length=2)
|
|
113
|
-
max_resultados: int = 100
|
|
114
|
-
|
|
115
|
-
|
|
116
110
|
class InstagramIn(BaseModel):
|
|
117
111
|
profile_url: str = Field(min_length=6)
|
|
118
112
|
|
|
119
113
|
|
|
114
|
+
class MapsExtractIn(BaseModel):
|
|
115
|
+
keyword: str = Field(min_length=2)
|
|
116
|
+
location: str = Field(min_length=2)
|
|
117
|
+
limit: int = 10
|
|
118
|
+
|
|
119
|
+
|
|
120
120
|
class CnpjIn(BaseModel):
|
|
121
121
|
cnpj: str = Field(min_length=8)
|
|
122
122
|
|
|
@@ -378,14 +378,46 @@ def remover_campanha(campaign_id: str):
|
|
|
378
378
|
# Extratores
|
|
379
379
|
# ---------------------------------------------------------------------------
|
|
380
380
|
|
|
381
|
-
@app.post("/api/
|
|
382
|
-
def
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
381
|
+
@app.post("/api/extractors/maps", status_code=201)
|
|
382
|
+
def extrair_maps(dados: MapsExtractIn):
|
|
383
|
+
"""Extrator de leads do Google Maps.
|
|
384
|
+
|
|
385
|
+
Se o scraper real (gosom/google-maps-scraper) estiver configurado em
|
|
386
|
+
Ferramentas, usa o CLI com as flags de isolamento de processos; caso
|
|
387
|
+
contrário, gera dados mockados (5 a 10 leads) para teste inicial, sem
|
|
388
|
+
risco de bloqueio de IP. Os leads são salvos em leads.json na coluna
|
|
389
|
+
"Novos Leads" (etapa inicial do Kanban).
|
|
390
|
+
"""
|
|
391
|
+
conf = storage.get_config()["extratores"]
|
|
392
|
+
cli = conf.get("google_maps_cli", "")
|
|
393
|
+
max_resultados = max(1, min(dados.limit, 500))
|
|
394
|
+
|
|
395
|
+
origem = "mock"
|
|
396
|
+
aviso = ""
|
|
397
|
+
leads: list = []
|
|
398
|
+
|
|
399
|
+
if cli and Path(cli).exists():
|
|
400
|
+
try:
|
|
401
|
+
resultado = extractors.extrair_google_maps(dados.keyword, dados.location, max_resultados)
|
|
402
|
+
leads = resultado.get("leads", [])
|
|
403
|
+
if leads:
|
|
404
|
+
origem = "cli"
|
|
405
|
+
else:
|
|
406
|
+
aviso = "O scraper rodou mas não retornou leads — usando dados mockados."
|
|
407
|
+
except (extractors.ExtratorNaoConfigurado, extractors.ExtratorErro) as e:
|
|
408
|
+
aviso = f"Scraper indisponível ({e}); usando dados mockados."
|
|
409
|
+
|
|
410
|
+
if not leads:
|
|
411
|
+
leads = extractors.gerar_mock_maps(dados.keyword, dados.location, dados.limit)
|
|
412
|
+
|
|
413
|
+
importados = storage.import_leads(leads)
|
|
414
|
+
return {
|
|
415
|
+
"origem": origem,
|
|
416
|
+
"aviso": aviso,
|
|
417
|
+
"extraidos": leads,
|
|
418
|
+
"importados": importados["importados"],
|
|
419
|
+
"duplicados": len(importados["duplicados"]),
|
|
420
|
+
}
|
|
389
421
|
|
|
390
422
|
|
|
391
423
|
@app.post("/api/extract/instagram")
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -230,6 +230,53 @@ def enriquecer_com_site(website: str) -> dict:
|
|
|
230
230
|
return {"saidas": resultado.stdout[-2000:]}
|
|
231
231
|
|
|
232
232
|
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Google Maps — mock para teste inicial (anti-bloqueio de IP)
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
_NOMES_MOCK = [
|
|
238
|
+
"Bella Massa", "Sabor & Cia", "Cantinho da Esquina", "Estrela do Bairro",
|
|
239
|
+
"Ponto Certo", "Casa Nova", "Primeira Opção", "Rei do Sabor", "Vila Feliz",
|
|
240
|
+
"Coracao Paulista",
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
_PREFIXOS_MOCK = ["Pizzaria", "Restaurante", "Lanches", "Comida", "Marmitaria"]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def gerar_mock_maps(keyword: str, location: str, limit: int = 10) -> list:
|
|
247
|
+
"""Gera 5 a 10 leads fictícios do Google Maps para teste inicial.
|
|
248
|
+
|
|
249
|
+
Usado quando o scraper real (gosom/google-maps-scraper) não está configurado
|
|
250
|
+
— evita bloqueios de IP nos primeiros testes. Quando o CLI está configurado,
|
|
251
|
+
`extrair_google_maps` (com flags de isolamento) é usada no lugar.
|
|
252
|
+
"""
|
|
253
|
+
quantidade = max(5, min(int(limit or 10), 10))
|
|
254
|
+
leads = []
|
|
255
|
+
for i in range(quantidade):
|
|
256
|
+
primeiro = _NOMES_MOCK[i % len(_NOMES_MOCK)]
|
|
257
|
+
prefixo = _PREFIXOS_MOCK[i % len(_PREFIXOS_MOCK)]
|
|
258
|
+
nome = f"{prefixo} {primeiro}"
|
|
259
|
+
ddd = 11 + (i * 3) % 88
|
|
260
|
+
telefone = f"({ddd}) 9{1000 + i:04d}-{4000 + i:04d}"
|
|
261
|
+
slug = primeiro.lower().replace(" ", "").replace("&", "e")
|
|
262
|
+
leads.append(
|
|
263
|
+
{
|
|
264
|
+
"nome": nome,
|
|
265
|
+
"telefone": telefone,
|
|
266
|
+
"email": f"contato@{slug}.com.br",
|
|
267
|
+
"fonte": "Google Maps",
|
|
268
|
+
"etapa": "leads",
|
|
269
|
+
"empresa": nome,
|
|
270
|
+
"endereco": f"Rua das Palmeiras, {100 + i * 7} - Centro",
|
|
271
|
+
"cidade": location,
|
|
272
|
+
"score": 45 + (i * 5) % 40,
|
|
273
|
+
"tags": ["google-maps"],
|
|
274
|
+
"extras": {"avaliacao": round(4.2 + (i % 8) * 0.1, 1), "qtd_avaliacoes": 30 + i * 9},
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
return leads
|
|
278
|
+
|
|
279
|
+
|
|
233
280
|
# ---------------------------------------------------------------------------
|
|
234
281
|
# Utilitários de parsing tolerante
|
|
235
282
|
# ---------------------------------------------------------------------------
|
|
@@ -280,8 +280,11 @@ def import_leads(novos: list, dedup: bool = True) -> dict:
|
|
|
280
280
|
with LOCK:
|
|
281
281
|
leads = list_leads()
|
|
282
282
|
existentes = {
|
|
283
|
-
(
|
|
284
|
-
|
|
283
|
+
(lead.get("telefone") or "").strip()
|
|
284
|
+
for lead in leads
|
|
285
|
+
} | {
|
|
286
|
+
(lead.get("email") or "").strip().lower()
|
|
287
|
+
for lead in leads
|
|
285
288
|
}
|
|
286
289
|
importados, duplicados = [], []
|
|
287
290
|
for novo in novos:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Testes do Extrator de Leads (Google Maps) — POST /api/extractors/maps.
|
|
2
|
+
|
|
3
|
+
Sem scraper 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("/api/extractors/maps", json={"keyword": "pizzaria", "location": "São Paulo, SP", "limit": 10})
|
|
23
|
+
assert r.status_code == 201
|
|
24
|
+
corpo = r.json()
|
|
25
|
+
assert corpo["origem"] == "mock"
|
|
26
|
+
assert 5 <= len(corpo["importados"]) <= 10
|
|
27
|
+
|
|
28
|
+
for lead in corpo["importados"]:
|
|
29
|
+
assert lead["fonte"] == "Google Maps"
|
|
30
|
+
assert lead["etapa"] == "leads" # coluna "Novos Leads"
|
|
31
|
+
assert lead["nome"] and lead["telefone"]
|
|
32
|
+
|
|
33
|
+
depois = client.get("/api/crm/leads").json()
|
|
34
|
+
assert len(depois) == antes + len(corpo["importados"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_limit_respeitado(client):
|
|
38
|
+
r = client.post("/api/extractors/maps", json={"keyword": "dentista", "location": "Rio de Janeiro, RJ", "limit": 7})
|
|
39
|
+
assert r.status_code == 201
|
|
40
|
+
assert len(r.json()["importados"]) == 7
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_payload_invalido(client):
|
|
44
|
+
r = client.post("/api/extractors/maps", json={"keyword": "x"})
|
|
45
|
+
assert r.status_code == 422
|