@danhachuel/leadmaster 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,322 @@
1
+ """Extratores de leads: Google Maps, Instagram e CNPJ.
2
+
3
+ Princípios:
4
+ - Nenhuma extração importa automaticamente: o frontend pré-visualiza e o
5
+ usuário confirma a importação.
6
+ - Toda integração externa falha graciosamente com instruções acionáveis.
7
+ - Os comandos de CLI usam templates de argumentos configuráveis, porque as
8
+ flags dos projetos upstream podem variar entre versões.
9
+ """
10
+
11
+ import csv
12
+ import json
13
+ import subprocess
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ import httpx
18
+
19
+ from . import storage
20
+
21
+
22
+ class ExtratorNaoConfigurado(Exception):
23
+ pass
24
+
25
+
26
+ class ExtratorErro(Exception):
27
+ pass
28
+
29
+
30
+ def _conf() -> dict:
31
+ return storage.get_config()["extratores"]
32
+
33
+
34
+ def _novo_lead_parcial() -> dict:
35
+ return {"tags": [], "observacoes": "", "score": 50}
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Google Maps (gosom/google-maps-scraper via CLI)
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def extrair_google_maps(palavra_chave: str, localizacao: str, max_resultados: int = 100) -> dict:
43
+ conf = _conf()
44
+ cli = conf.get("google_maps_cli", "")
45
+ if not cli or not Path(cli).exists():
46
+ raise ExtratorNaoConfigurado(
47
+ "Google Maps Scraper não configurado. Instale https://github.com/gosom/google-maps-scraper "
48
+ "e informe o caminho do binário em Ferramentas → Extratores."
49
+ )
50
+ with tempfile.TemporaryDirectory(prefix="leadmaster-maps-") as tmp:
51
+ outdir = Path(tmp)
52
+ args_template = conf.get(
53
+ "google_maps_args",
54
+ '--kw "{palavra_chave}" --loc "{localizacao}" --lang pt --email --max {max_resultados} --outdir "{outdir}"',
55
+ )
56
+ cmd = [cli] + _formatar_args(
57
+ args_template,
58
+ palavra_chave=palavra_chave,
59
+ localizacao=localizacao,
60
+ max_resultados=max_resultados,
61
+ outdir=str(outdir),
62
+ )
63
+ try:
64
+ resultado = subprocess.run(cmd, capture_output=True, text=True, timeout=1800, shell=False)
65
+ except FileNotFoundError as e:
66
+ raise ExtratorNaoConfigurado(f"Não foi possível executar o CLI do Google Maps Scraper: {e}") from e
67
+ if resultado.returncode != 0:
68
+ raise ExtratorErro(
69
+ f"Google Maps Scraper terminou com código {resultado.returncode}: {resultado.stderr[-400:]}. "
70
+ "Confira as flags do template em Ferramentas → Extratores."
71
+ )
72
+ leads = _parse_resultados_diretorio(outdir, fonte="Google Maps")
73
+ return {"leads": leads, "comando": " ".join(cmd[:4]) + " ..."}
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Instagram (lucasoal/igmapper via CLI)
78
+ # ---------------------------------------------------------------------------
79
+
80
+ def extrair_instagram(profile_url: str) -> dict:
81
+ conf = _conf()
82
+ cli = conf.get("igmapper_cli", "")
83
+ if not cli or not Path(cli).exists():
84
+ raise ExtratorNaoConfigurado(
85
+ "igmapper não configurado. Instale https://github.com/lucasoal/igmapper "
86
+ "e informe o caminho em Ferramentas → Extratores."
87
+ )
88
+ with tempfile.TemporaryDirectory(prefix="leadmaster-ig-") as tmp:
89
+ outdir = Path(tmp)
90
+ args_template = conf.get("igmapper_args", "--profile {url} --outdir {outdir}")
91
+ cmd = [cli] + _formatar_args(args_template, url=profile_url, outdir=str(outdir))
92
+ try:
93
+ resultado = subprocess.run(cmd, capture_output=True, text=True, timeout=900, shell=False)
94
+ except FileNotFoundError as e:
95
+ raise ExtratorNaoConfigurado(f"Não foi possível executar o igmapper: {e}") from e
96
+ if resultado.returncode != 0:
97
+ raise ExtratorErro(f"igmapper terminou com código {resultado.returncode}: {resultado.stderr[-400:]}")
98
+ leads = _parse_resultados_diretorio(outdir, fonte="Instagram")
99
+ if not leads:
100
+ raise ExtratorErro(
101
+ "igmapper rodou mas não retornou dados reconhecíveis. Confira o formato de saída "
102
+ "(esperado CSV/JSON com username, full_name, followers, phone, email)."
103
+ )
104
+ return {"leads": leads, "comando": " ".join(cmd[:4]) + " ..."}
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # CNPJ — consulta direta (BrasilAPI) e busca por CNAE/UF (provedor configurável)
109
+ # ---------------------------------------------------------------------------
110
+
111
+ def _so_digitos(texto: str) -> str:
112
+ return "".join(c for c in texto or "" if c.isdigit())
113
+
114
+
115
+ def consulta_cnpj(cnpj: str) -> dict:
116
+ cnpj = _so_digitos(cnpj)
117
+ if len(cnpj) != 14:
118
+ raise ExtratorErro("CNPJ inválido: use 14 dígitos (com ou sem pontuação).")
119
+ try:
120
+ r = httpx.get(f"https://brasilapi.com.br/api/cnpj/v1/{cnpj}", timeout=30)
121
+ except httpx.HTTPError as e:
122
+ raise ExtratorErro(f"Falha ao consultar a BrasilAPI: {e}") from e
123
+ if r.status_code == 404:
124
+ raise ExtratorErro("CNPJ não encontrado na BrasilAPI.")
125
+ if r.status_code >= 400:
126
+ raise ExtratorErro(f"BrasilAPI respondeu HTTP {r.status_code}: {r.text[:200]}")
127
+ d = r.json()
128
+ endereco = " ".join(
129
+ p
130
+ for p in [
131
+ d.get("logradouro"),
132
+ d.get("numero"),
133
+ d.get("bairro"),
134
+ ]
135
+ if p
136
+ )
137
+ lead = _novo_lead_parcial()
138
+ lead.update(
139
+ {
140
+ "nome": d.get("razao_social") or d.get("nome_fantasia") or "",
141
+ "empresa": d.get("nome_fantasia") or d.get("razao_social") or "",
142
+ "cnpj": cnpj,
143
+ "telefone": d.get("ddd_telefone_1") or "",
144
+ "endereco": endereco,
145
+ "cidade": f"{d.get('municipio', '')}/{d.get('uf', '')}",
146
+ "email": "",
147
+ "fonte": "CNPJ",
148
+ "etapa": "leads",
149
+ }
150
+ )
151
+ lead["score"] = 55
152
+ return lead
153
+
154
+
155
+ def buscar_cnpj_cnae(cnae: str, uf: str, quantidade: int = 20) -> dict:
156
+ """Busca empresas por CNAE + UF no provedor configurável (padrão: OpenCNPJ).
157
+
158
+ O formato pode variar; o parser é tolerante (aceita lista, {"results": ...},
159
+ {"data": ...} e vários nomes de campos).
160
+ """
161
+ conf = _conf()
162
+ base = (conf.get("cnpj_provedor_busca") or "https://api.opencnpj.org").rstrip("/")
163
+ params = {"cnae": _so_digitos(cnae), "uf": uf.strip().upper(), "quantidade": str(max(1, min(quantidade, 100)))}
164
+ try:
165
+ r = httpx.get(base, params=params, timeout=60)
166
+ except httpx.HTTPError as e:
167
+ raise ExtratorErro(
168
+ f"Falha ao consultar o provedor de busca ({base}): {e}. "
169
+ "Verifique a URL do provedor em Ferramentas → Extratores."
170
+ ) from e
171
+ if r.status_code >= 400:
172
+ raise ExtratorErro(
173
+ f"Provedor de busca ({base}) respondeu HTTP {r.status_code}: {r.text[:200]}. "
174
+ "O provedor é configurável em Ferramentas → Extratores."
175
+ )
176
+ try:
177
+ corpo = r.json()
178
+ except ValueError:
179
+ raise ExtratorErro(f"Provedor de busca devolveu resposta não-JSON: {r.text[:200]}")
180
+ itens = []
181
+ if isinstance(corpo, list):
182
+ itens = corpo
183
+ elif isinstance(corpo, dict):
184
+ itens = corpo.get("results") or corpo.get("data") or corpo.get("items") or []
185
+ leads = []
186
+ for item in itens[:quantidade]:
187
+ if not isinstance(item, dict):
188
+ continue
189
+ lead = _novo_lead_parcial()
190
+ lead.update(
191
+ {
192
+ "nome": _primeiro(item, "razao_social", "nome", "razaoSocial", "company_name"),
193
+ "empresa": _primeiro(item, "nome_fantasia", "fantasia", "nomeFantasia", "trade_name") or "",
194
+ "cnpj": _so_digitos(_primeiro(item, "cnpj", "cnpj_raiz", "document")),
195
+ "telefone": _primeiro(item, "telefone", "phone", "ddd_telefone_1") or "",
196
+ "email": _primeiro(item, "email", "contact_email") or "",
197
+ "cidade": f"{_primeiro(item, 'municipio', 'city', 'municipio') or ''}/{_primeiro(item, 'uf', 'state', 'uf') or ''}",
198
+ "fonte": "CNPJ",
199
+ "etapa": "leads",
200
+ }
201
+ )
202
+ lead["score"] = 45
203
+ leads.append(lead)
204
+ return {"leads": leads, "provedor": base}
205
+
206
+
207
+ def enriquecer_com_site(website: str) -> dict:
208
+ """Scraping profundo de contatos no site da empresa, via Scrapling (opcional)."""
209
+ conf = _conf()
210
+ cli = conf.get("scrapling_cli", "")
211
+ if not cli or not Path(cli).exists():
212
+ raise ExtratorNaoConfigurado(
213
+ "Scrapling não configurado. Instale https://github.com/D4Vinci/Scrapling "
214
+ "e informe o caminho em Ferramentas → Extratores."
215
+ )
216
+ try:
217
+ resultado = subprocess.run(
218
+ [cli, "extract", website],
219
+ capture_output=True,
220
+ text=True,
221
+ timeout=300,
222
+ shell=False,
223
+ )
224
+ except FileNotFoundError as e:
225
+ raise ExtratorNaoConfigurado(f"Não foi possível executar o Scrapling: {e}") from e
226
+ if resultado.returncode != 0:
227
+ raise ExtratorErro(f"Scrapling falhou: {resultado.stderr[-400:]}")
228
+ return {"saidas": resultado.stdout[-2000:]}
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Utilitários de parsing tolerante
233
+ # ---------------------------------------------------------------------------
234
+
235
+ def _primeiro(d: dict, *chaves: str):
236
+ for chave in chaves:
237
+ if d.get(chave):
238
+ return d[chave]
239
+ return ""
240
+
241
+
242
+ def _formatar_args(template: str, **valores: str) -> list[str]:
243
+ """Converte '--kw "a b" --n 5' em ['--kw', 'a b', '--n', '5'] respeitando aspas."""
244
+ texto = template.format(**valores)
245
+ partes, atual, aspas = [], "", None
246
+ for ch in texto:
247
+ if ch in "\"'":
248
+ aspas = None if aspas == ch else ch
249
+ continue
250
+ if ch == " " and not aspas:
251
+ if atual:
252
+ partes.append(atual)
253
+ atual = ""
254
+ else:
255
+ atual += ch
256
+ if atual:
257
+ partes.append(atual)
258
+ return partes
259
+
260
+
261
+ def _mapear_linha(linha: dict, fonte: str) -> dict:
262
+ lead = _novo_lead_parcial()
263
+ nome = _primeiro(linha, "nome", "name", "title", "razao_social", "full_name", "username")
264
+ lead.update(
265
+ {
266
+ "nome": nome or "Sem nome",
267
+ "telefone": _primeiro(linha, "telefone", "phone", "phone_number", "public_phone", "ddd_telefone_1"),
268
+ "email": _primeiro(linha, "email", "email_address", "business_email"),
269
+ "empresa": _primeiro(linha, "empresa", "company", "site_name", "nome_fantasia"),
270
+ "endereco": _primeiro(linha, "endereco", "address", "full_address", "street"),
271
+ "cidade": _primeiro(linha, "cidade", "city", "locality"),
272
+ "site": _primeiro(linha, "site", "website", "web_site"),
273
+ "fonte": fonte,
274
+ "etapa": "leads",
275
+ }
276
+ )
277
+ extras = {}
278
+ if _primeiro(linha, "rating", "nota"):
279
+ extras["avaliacao"] = linha.get("rating") or linha.get("nota")
280
+ if _primeiro(linha, "reviews", "avaliacoes"):
281
+ extras["qtd_avaliacoes"] = linha.get("reviews") or linha.get("avaliacoes")
282
+ if _primeiro(linha, "followers", "seguidores"):
283
+ extras["seguidores"] = linha.get("followers") or linha.get("seguidores")
284
+ if _primeiro(linha, "biography", "bio"):
285
+ extras["bio"] = (linha.get("biography") or linha.get("bio"))[:200]
286
+ lead["extras"] = extras
287
+ lead["score"] = 40 + (10 if lead["telefone"] else 0) + (10 if lead["email"] else 0)
288
+ return lead
289
+
290
+
291
+ def _parse_resultados_diretorio(outdir: Path, fonte: str) -> list:
292
+ leads = []
293
+ for arquivo in sorted(outdir.iterdir()):
294
+ if arquivo.suffix.lower() == ".csv":
295
+ with arquivo.open(encoding="utf-8-sig", newline="") as fh:
296
+ for linha in csv.DictReader(fh):
297
+ lead = _mapear_linha(linha, fonte)
298
+ if lead["nome"] != "Sem nome":
299
+ leads.append(lead)
300
+ elif arquivo.suffix.lower() == ".json":
301
+ try:
302
+ dados = json.loads(arquivo.read_text(encoding="utf-8"))
303
+ except (ValueError, OSError):
304
+ continue
305
+ itens = dados if isinstance(dados, list) else [dados]
306
+ for item in itens:
307
+ if isinstance(item, dict):
308
+ lead = _mapear_linha(item, fonte)
309
+ if lead["nome"] != "Sem nome":
310
+ leads.append(lead)
311
+ # dedup por telefone/email
312
+ vistos, unicos = set(), []
313
+ for lead in leads:
314
+ chave = (lead.get("telefone") or "", (lead.get("email") or "").lower())
315
+ if chave[0] and chave[0] in vistos:
316
+ continue
317
+ if chave[1] and chave[1] in vistos:
318
+ continue
319
+ vistos.add(chave[0])
320
+ vistos.add(chave[1])
321
+ unicos.append(lead)
322
+ return unicos
@@ -0,0 +1,69 @@
1
+ """Cliente LLM (API OpenAI-compatível) para os agentes do LeadMaster.
2
+
3
+ A configuração fica em `config.json → llm` (provedor, base_url, api_key, modelo).
4
+ Usado pelo agente de CRM (enriquecimento/sugestões) e pelo agente de atendimento.
5
+ Se o LLM não estiver configurado, as heurísticas locais assumem e a mensagem
6
+ sempre informa o que fazer — nunca fingimos uma resposta de IA.
7
+ """
8
+
9
+ import httpx
10
+
11
+ from . import storage
12
+
13
+
14
+ class LLMNaoConfigurado(Exception):
15
+ """LLM não configurado — mensagem acionável para Ferramentas → LLM."""
16
+
17
+
18
+ class LLMErro(Exception):
19
+ """LLM configurado, mas a chamada falhou."""
20
+
21
+
22
+ def _conf() -> dict:
23
+ return storage.get_config().get("llm", {})
24
+
25
+
26
+ def configurado() -> bool:
27
+ c = _conf()
28
+ return bool(c.get("api_key") and c.get("base_url"))
29
+
30
+
31
+ def chat(mensagens: list, temperature: float = 0.7, max_tokens: int = 2000) -> str:
32
+ c = _conf()
33
+ if not configurado():
34
+ raise LLMNaoConfigurado(
35
+ "LLM não configurado. Vá em Ferramentas → LLM, informe base_url, api_key e modelo."
36
+ )
37
+ url = c["base_url"].rstrip("/") + "/chat/completions"
38
+ try:
39
+ r = httpx.post(
40
+ url,
41
+ headers={"Authorization": f"Bearer {c['api_key']}"},
42
+ json={
43
+ "model": c.get("modelo", "gpt-4o-mini"),
44
+ "messages": mensagens,
45
+ "temperature": temperature,
46
+ "max_tokens": max_tokens,
47
+ },
48
+ timeout=180,
49
+ )
50
+ r.raise_for_status()
51
+ return r.json()["choices"][0]["message"]["content"]
52
+ except httpx.HTTPStatusError as e:
53
+ raise LLMErro(f"LLM respondeu HTTP {e.response.status_code}: {e.response.text[:300]}") from e
54
+ except httpx.HTTPError as e:
55
+ raise LLMErro(f"Falha de conexão com o LLM ({url}): {e}") from e
56
+
57
+
58
+ def testar_conexao() -> dict:
59
+ try:
60
+ resposta = chat(
61
+ [{"role": "user", "content": "Responda apenas com: OK"}],
62
+ temperature=0,
63
+ max_tokens=8,
64
+ )
65
+ return {"ok": True, "resposta": resposta.strip()[:100]}
66
+ except LLMNaoConfigurado as e:
67
+ return {"ok": False, "mensagem": str(e)}
68
+ except LLMErro as e:
69
+ return {"ok": False, "mensagem": str(e)}