@danhachuel/leadmaster 0.0.0-stage → 0.1.0
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 +59 -0
- package/LICENSE +21 -0
- package/README.md +128 -2
- package/backend/main.py +523 -0
- package/backend/requirements.txt +4 -0
- package/backend/services/__init__.py +1 -0
- package/backend/services/agents.py +235 -0
- package/backend/services/crm_agent.py +175 -0
- package/backend/services/extractors.py +322 -0
- package/backend/services/llm.py +189 -0
- package/backend/services/moneyprinter.py +102 -0
- package/backend/services/openmontage.py +62 -0
- package/backend/services/pipeline.py +515 -0
- package/backend/services/short_video_maker.py +72 -0
- package/backend/services/storage.py +460 -0
- package/backend/services/youtube_upload.py +163 -0
- package/frontend/dist/assets/fa-brands-400-D1LuMI3I.ttf +0 -0
- package/frontend/dist/assets/fa-brands-400-D_cYUPeE.woff2 +0 -0
- package/frontend/dist/assets/fa-regular-400-BjRzuEpd.woff2 +0 -0
- package/frontend/dist/assets/fa-regular-400-DZaxPHgR.ttf +0 -0
- package/frontend/dist/assets/fa-solid-900-CTAAxXor.woff2 +0 -0
- package/frontend/dist/assets/fa-solid-900-D0aA9rwL.ttf +0 -0
- package/frontend/dist/assets/fa-v4compatibility-C9RhG_FT.woff2 +0 -0
- package/frontend/dist/assets/fa-v4compatibility-CCth-dXg.ttf +0 -0
- package/frontend/dist/assets/index-CANBU7DH.js +93 -0
- package/frontend/dist/assets/index-eswbuT3S.css +5 -0
- package/frontend/dist/favicon.svg +20 -0
- package/frontend/dist/index.html +14 -0
- package/package.json +49 -4
- package/scripts/cli.mjs +512 -0
|
@@ -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,189 @@
|
|
|
1
|
+
"""Cliente LLM (API OpenAI-compatível) com fallback local para roteiros.
|
|
2
|
+
|
|
3
|
+
A configuração fica em `config.json → llm` (provedor, base_url, api_key, modelo).
|
|
4
|
+
Se o LLM não estiver configurado, os roteiros são gerados por um modelo local
|
|
5
|
+
(template estruturado) e a origem é sempre informada ao usuário — nunca fingimos
|
|
6
|
+
uma geração por 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 = 4000) -> 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)}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Roteiro (7k–9k caracteres)
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def gerar_roteiro(tema: str, titulo: str = "", tom: str = "informativo", duracao_min: int = 8) -> dict:
|
|
77
|
+
"""Gera o roteiro do vídeo faceless. Retorna {roteiro, origem, aviso}."""
|
|
78
|
+
if configurado():
|
|
79
|
+
sistema = (
|
|
80
|
+
"Você é um roteirista profissional de vídeos faceless para YouTube, "
|
|
81
|
+
"especialista em retenção. Escreva em português do Brasil."
|
|
82
|
+
)
|
|
83
|
+
usuario = (
|
|
84
|
+
f"Escreva um roteiro completo para um vídeo faceless de YouTube sobre: {tema}.\n"
|
|
85
|
+
f"Título de trabalho: {titulo or '(crie um título)'}\n"
|
|
86
|
+
f"Tom: {tom}. Duração estimada: {duracao_min} minutos.\n"
|
|
87
|
+
"REQUISITOS:\n"
|
|
88
|
+
"- Entre 7000 e 9000 caracteres de texto corrido (sem marcadores de cena).\n"
|
|
89
|
+
"- Comece com um gancho de 2 frases que prende na primeira.\n"
|
|
90
|
+
"- Use seções lógicas, exemplos concretos e números.\n"
|
|
91
|
+
"- Termine com CTA para inscrever-se no canal.\n"
|
|
92
|
+
"- Apenas o texto do roteiro, sem títulos em markdown."
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
texto = chat(
|
|
96
|
+
[{"role": "system", "content": sistema}, {"role": "user", "content": usuario}],
|
|
97
|
+
temperature=0.8,
|
|
98
|
+
max_tokens=6000,
|
|
99
|
+
)
|
|
100
|
+
return {"roteiro": texto.strip(), "origem": "llm", "aviso": ""}
|
|
101
|
+
except LLMErro as e:
|
|
102
|
+
raise # configurado mas falhou: etapa deve falhar com mensagem acionável
|
|
103
|
+
return {
|
|
104
|
+
"roteiro": _roteiro_local(tema, titulo, tom, duracao_min),
|
|
105
|
+
"origem": "local",
|
|
106
|
+
"aviso": "LLM não configurado — roteiro gerado por modelo local (rascunho). Configure o LLM em Ferramentas para roteiros completos de 7k–9k caracteres.",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _roteiro_local(tema: str, titulo: str, tom: str, duracao_min: int) -> str:
|
|
111
|
+
"""Rascunho estruturado, honesto sobre sua origem local."""
|
|
112
|
+
titulo = titulo or f"{tema}: o guia definitivo"
|
|
113
|
+
blocos = max(3, duracao_min)
|
|
114
|
+
linhas = [
|
|
115
|
+
f"[GANCHO] {titulo}. Nos próximos {duracao_min} minutos você vai entender exatamente "
|
|
116
|
+
f"como {tema.lower()} funciona na prática — e por que quase todo mundo erra nisso. Fica até o final.",
|
|
117
|
+
"",
|
|
118
|
+
"[INTRODUÇÃO]",
|
|
119
|
+
f"Antes de qualquer coisa: o que é {tema.lower()}? É a diferença entre agir no escuro "
|
|
120
|
+
"e agir com método. Neste vídeo eu vou te mostrar o passo a passo completo.",
|
|
121
|
+
"",
|
|
122
|
+
]
|
|
123
|
+
for i in range(1, blocos + 1):
|
|
124
|
+
linhas += [
|
|
125
|
+
f"[PARTE {i} — PONTO-CHAVE {i}]",
|
|
126
|
+
f"Primeiro, o contexto: a maior parte das pessoas que tenta {tema.lower()} falha porque "
|
|
127
|
+
"pula a etapa de preparação. Não faça isso. Comece pelo diagnóstico simples: onde você "
|
|
128
|
+
"está hoje e qual é o resultado mínimo que já mudaria seu jogo.",
|
|
129
|
+
f"Segundo, a execução: escolha UMA estratégia de {tema.lower()} e execute-a por 14 dias "
|
|
130
|
+
"sem mudar de rota. Registre os números. O que não é medido não melhora.",
|
|
131
|
+
f"Terceiro, o erro comum da parte {i}: fazer mais do mesmo esperando resultado diferente. "
|
|
132
|
+
"Ajuste uma variável por vez e observe.",
|
|
133
|
+
"",
|
|
134
|
+
]
|
|
135
|
+
linhas += [
|
|
136
|
+
"[ENCERRAMENTO E CTA]",
|
|
137
|
+
"Se esse vídeo te ajudou, se inscreve no canal e ativa o sininho — toda semana tem "
|
|
138
|
+
f"conteúdo novo sobre {tema.lower()}. Comenta aqui qual etapa você vai aplicar primeiro. Até o próximo!",
|
|
139
|
+
"",
|
|
140
|
+
"(Rascunho gerado localmente pelo LeadMaster, sem LLM. Configure seu LLM em "
|
|
141
|
+
"Ferramentas para gerar roteiros completos de 7.000 a 9.000 caracteres.)",
|
|
142
|
+
]
|
|
143
|
+
return "\n".join(linhas)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def gerar_titulo_e_thumbnail(tema: str, roteiro: str) -> dict:
|
|
147
|
+
"""Gera título otimizado + prompt de thumbnail. Retorna {titulo, titulo_alternativo, thumbnail_prompt, origem}."""
|
|
148
|
+
if configurado():
|
|
149
|
+
try:
|
|
150
|
+
resposta = chat(
|
|
151
|
+
[
|
|
152
|
+
{
|
|
153
|
+
"role": "system",
|
|
154
|
+
"content": (
|
|
155
|
+
"Você é especialista em SEO de YouTube. Responda APENAS com JSON válido, "
|
|
156
|
+
'sem markdown, no formato: {"titulo": "...", "titulo_alternativo": "...", '
|
|
157
|
+
'"thumbnail_prompt": "..."}. Títulos com até 60 caracteres, thumbnail_prompt '
|
|
158
|
+
"em português, descrevendo imagem sem texto legível."
|
|
159
|
+
),
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"role": "user",
|
|
163
|
+
"content": f"Tema do vídeo: {tema}\n\nRoteiro (início):\n{roteiro[:1500]}",
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
temperature=0.6,
|
|
167
|
+
max_tokens=500,
|
|
168
|
+
)
|
|
169
|
+
import json as _json
|
|
170
|
+
|
|
171
|
+
inicio = resposta.find("{")
|
|
172
|
+
dados = _json.loads(resposta[inicio:] if inicio >= 0 else resposta)
|
|
173
|
+
return {
|
|
174
|
+
"titulo": dados.get("titulo", ""),
|
|
175
|
+
"titulo_alternativo": dados.get("titulo_alternativo", ""),
|
|
176
|
+
"thumbnail_prompt": dados.get("thumbnail_prompt", ""),
|
|
177
|
+
"origem": "llm",
|
|
178
|
+
}
|
|
179
|
+
except (LLMErro, ValueError, KeyError):
|
|
180
|
+
pass # cai no fallback local abaixo
|
|
181
|
+
return {
|
|
182
|
+
"titulo": f"{tema}: o guia definitivo (passo a passo)",
|
|
183
|
+
"titulo_alternativo": f"Como dominar {tema} em pouco tempo",
|
|
184
|
+
"thumbnail_prompt": (
|
|
185
|
+
f"Imagem 16:9 vibrante sobre {tema}, estilo minimalista de alto contraste, "
|
|
186
|
+
"pessoa em silhueta olhando para resultado crescente, cores indigo e roxo, sem texto."
|
|
187
|
+
),
|
|
188
|
+
"origem": "local",
|
|
189
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Integração com o MoneyPrinterTurbo (produção de vídeos wide 16:9).
|
|
2
|
+
|
|
3
|
+
API esperada (padrão do projeto): POST http://localhost:8080/api/v1/videos
|
|
4
|
+
Cria a tarefa e consulta o progresso até o vídeo ficar pronto.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import storage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ServicoIndisponivel(Exception):
|
|
15
|
+
"""MoneyPrinterTurbo não está rodando — mensagem acionável."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProducaoErro(Exception):
|
|
19
|
+
"""O serviço respondeu, mas a produção falhou."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _conf() -> dict:
|
|
23
|
+
return storage.get_config()["integracoes"]["moneyprinter"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _url() -> str:
|
|
27
|
+
return _conf()["url"].rstrip("/")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def verificar() -> bool:
|
|
31
|
+
"""True se o serviço responde (qualquer código HTTP conta como 'vivo')."""
|
|
32
|
+
try:
|
|
33
|
+
httpx.get(_url() + "/docs", timeout=2)
|
|
34
|
+
return True
|
|
35
|
+
except httpx.HTTPError:
|
|
36
|
+
try:
|
|
37
|
+
httpx.get(_url(), timeout=2)
|
|
38
|
+
return True
|
|
39
|
+
except httpx.HTTPError:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def criar_video_wide(
|
|
44
|
+
subject: str,
|
|
45
|
+
script: str,
|
|
46
|
+
terms: str = "",
|
|
47
|
+
duracao_segundos: int = 60,
|
|
48
|
+
voz: str = "pt-BR-Standard",
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Cria a tarefa de vídeo wide (16:9). Retorna o task_id."""
|
|
51
|
+
payload = {
|
|
52
|
+
"video_subject": subject,
|
|
53
|
+
"video_script": script,
|
|
54
|
+
"video_terms": terms or subject,
|
|
55
|
+
"video_aspect": "16:9",
|
|
56
|
+
"video_clip_duration": min(duracao_segundos, 20),
|
|
57
|
+
"video_source_provider": "pexels",
|
|
58
|
+
"voice_volume": 1.0,
|
|
59
|
+
"voice_rate": 1.0,
|
|
60
|
+
}
|
|
61
|
+
try:
|
|
62
|
+
r = httpx.post(_url() + "/api/v1/videos", json=payload, timeout=120)
|
|
63
|
+
except httpx.HTTPError as e:
|
|
64
|
+
raise ServicoIndisponivel(
|
|
65
|
+
f"MoneyPrinterTurbo não respondeu em {_url()}. "
|
|
66
|
+
"Inicie o serviço (uvicorn app.server:app) e clique em Repetir."
|
|
67
|
+
) from e
|
|
68
|
+
if r.status_code >= 400:
|
|
69
|
+
raise ProducaoErro(f"MoneyPrinterTurbo rejeitou a tarefa (HTTP {r.status_code}): {r.text[:300]}")
|
|
70
|
+
task_id = (r.json().get("data") or {}).get("task_id")
|
|
71
|
+
if not task_id:
|
|
72
|
+
raise ProducaoErro(f"Resposta sem task_id: {r.text[:300]}")
|
|
73
|
+
return task_id
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def aguardar_video(task_id: str, timeout_min: int = 20, a_cada_progresso=None) -> dict:
|
|
77
|
+
"""Sonda a tarefa até concluir. Retorna {progresso, videos, saidas}."""
|
|
78
|
+
limite = time.time() + timeout_min * 60
|
|
79
|
+
while time.time() < limite:
|
|
80
|
+
try:
|
|
81
|
+
r = httpx.get(_url() + "/api/v1/tasks", params={"task_id": task_id}, timeout=15)
|
|
82
|
+
except httpx.HTTPError as e:
|
|
83
|
+
raise ServicoIndisponivel(
|
|
84
|
+
f"MoneyPrinterTurbo sumiu durante a produção: {e}. Clique em Repetir com o serviço ativo."
|
|
85
|
+
) from e
|
|
86
|
+
if r.status_code >= 500:
|
|
87
|
+
time.sleep(5)
|
|
88
|
+
continue
|
|
89
|
+
data = (r.json() or {}).get("data") or {}
|
|
90
|
+
task = data
|
|
91
|
+
if isinstance(data.get("tasks"), list) and data["tasks"]:
|
|
92
|
+
task = data["tasks"][0]
|
|
93
|
+
progresso = task.get("progress") or 0
|
|
94
|
+
if a_cada_progresso:
|
|
95
|
+
a_cada_progresso(progresso)
|
|
96
|
+
if task.get("videos"):
|
|
97
|
+
return {"progresso": 100, "videos": task["videos"], "saidas": task.get("progress_details", {})}
|
|
98
|
+
time.sleep(5)
|
|
99
|
+
raise ProducaoErro(
|
|
100
|
+
f"A tarefa {task_id} não ficou pronta em {timeout_min} minutos. "
|
|
101
|
+
"Verifique o MoneyPrinterTurbo e use Repetir."
|
|
102
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Integração com o OpenMontage (CLI local, fallback de produção de vídeos).
|
|
2
|
+
|
|
3
|
+
O caminho do executável é configurável em Ferramentas → OpenMontage.
|
|
4
|
+
Como as flags do CLI podem variar entre versões, usamos um template de
|
|
5
|
+
argumentos configurável com os placeholders {roteiro} e {saida}.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import storage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IntegracaoNaoConfigurada(Exception):
|
|
15
|
+
"""Caminho do CLI não configurado — mensagem acionável."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExecucaoErro(Exception):
|
|
19
|
+
"""O CLI falhou; stderr vai na mensagem."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _conf() -> dict:
|
|
23
|
+
return storage.get_config()["integracoes"]["openmontage"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def disponivel() -> bool:
|
|
27
|
+
cli = _conf().get("cli_path", "")
|
|
28
|
+
return bool(cli) and Path(cli).exists()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def produzir(job_id: str, roteiro_path: str) -> dict:
|
|
32
|
+
"""Executa o OpenMontage com o roteiro e devolve {video_path}."""
|
|
33
|
+
conf = _conf()
|
|
34
|
+
cli = conf.get("cli_path", "")
|
|
35
|
+
if not cli or not Path(cli).exists():
|
|
36
|
+
raise IntegracaoNaoConfigurada(
|
|
37
|
+
"OpenMontage não configurado. Informe o caminho do executável em "
|
|
38
|
+
"Ferramentas → OpenMontage (https://github.com/openmotional/OpenMontage) "
|
|
39
|
+
"ou mantenha o MoneyPrinterTurbo ativo."
|
|
40
|
+
)
|
|
41
|
+
saida = storage.videos_dir() / job_id
|
|
42
|
+
saida.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
try:
|
|
44
|
+
resultado = subprocess.run(
|
|
45
|
+
[cli, roteiro_path, "--output", str(saida)],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
timeout=1800,
|
|
49
|
+
shell=False,
|
|
50
|
+
)
|
|
51
|
+
except FileNotFoundError as e:
|
|
52
|
+
raise IntegracaoNaoConfigurada(f"Não foi possível executar o CLI do OpenMontage: {e}") from e
|
|
53
|
+
except subprocess.TimeoutExpired as e:
|
|
54
|
+
raise ExecucaoErro("OpenMontage passou de 30 minutos sem concluir.") from e
|
|
55
|
+
if resultado.returncode != 0:
|
|
56
|
+
raise ExecucaoErro(f"OpenMontage terminou com código {resultado.returncode}: {resultado.stderr[-400:]}")
|
|
57
|
+
mp4 = sorted(saida.glob("*.mp4"))
|
|
58
|
+
return {
|
|
59
|
+
"video_path": str(mp4[-1]) if mp4 else None,
|
|
60
|
+
"pasta_saida": str(saida),
|
|
61
|
+
"observacao": "" if mp4 else "Execução OK, mas nenhum .mp4 encontrado na pasta de saída.",
|
|
62
|
+
}
|