@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,460 @@
|
|
|
1
|
+
"""Persistência 100% local em arquivos JSON.
|
|
2
|
+
|
|
3
|
+
Toda a escrita é atômica (arquivo temporário + replace) e protegida por um lock
|
|
4
|
+
global, para permitir o uso concorrente pelas rotas da API e pelos workers em
|
|
5
|
+
background (pipeline, campanhas, agendador de publicação).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
LOCK = threading.RLock()
|
|
15
|
+
|
|
16
|
+
# Campos que nunca podem vazar para o frontend.
|
|
17
|
+
SECRET_KEYS = {"api_key", "token", "senha", "password"}
|
|
18
|
+
|
|
19
|
+
LEADS_FILE = "leads.json"
|
|
20
|
+
CAMPAIGNS_FILE = "campaigns.json"
|
|
21
|
+
PIPELINE_FILE = "pipeline.json"
|
|
22
|
+
CONFIG_FILE = "config.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Caminhos
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def _repo_root() -> Path:
|
|
30
|
+
"""Raiz do repositório (pasta que contém backend/). storage.py está em
|
|
31
|
+
backend/services/, então sobe três níveis."""
|
|
32
|
+
return Path(__file__).resolve().parent.parent.parent
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_home() -> Path:
|
|
36
|
+
"""Pasta de dados (home). Ordem: env LEADMASTER_HOME → raiz do repo."""
|
|
37
|
+
env = os.environ.get("LEADMASTER_HOME")
|
|
38
|
+
if env:
|
|
39
|
+
return Path(env).resolve()
|
|
40
|
+
return _repo_root()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def storage_dir() -> Path:
|
|
44
|
+
d = get_home() / "storage"
|
|
45
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
return d
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def roteiros_dir() -> Path:
|
|
50
|
+
d = get_home() / "roteiros"
|
|
51
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
return d
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def videos_dir() -> Path:
|
|
56
|
+
d = get_home() / "videos"
|
|
57
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
return d
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def now_iso() -> str:
|
|
62
|
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Leitura/escrita atômica
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _read(name: str, default):
|
|
70
|
+
with LOCK:
|
|
71
|
+
path = storage_dir() / name
|
|
72
|
+
if not path.exists():
|
|
73
|
+
return default
|
|
74
|
+
try:
|
|
75
|
+
# utf-8-sig: tolera BOM (comum em arquivos editados no Windows
|
|
76
|
+
# por PowerShell/Notepad) e é idêntico a utf-8 quando não há BOM.
|
|
77
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
# Arquivo corrompido: preserva uma cópia para inspeção manual
|
|
80
|
+
# e devolve o padrão para não travar a aplicação.
|
|
81
|
+
try:
|
|
82
|
+
path.rename(path.with_suffix(path.suffix + ".corrompido"))
|
|
83
|
+
except OSError:
|
|
84
|
+
pass
|
|
85
|
+
return default
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _write(name: str, data) -> None:
|
|
89
|
+
with LOCK:
|
|
90
|
+
path = storage_dir() / name
|
|
91
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
92
|
+
tmp.write_text(
|
|
93
|
+
json.dumps(data, ensure_ascii=False, indent=2),
|
|
94
|
+
encoding="utf-8",
|
|
95
|
+
)
|
|
96
|
+
tmp.replace(path)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Configuração
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
DEFAULT_CONFIG = {
|
|
104
|
+
"perfil": {
|
|
105
|
+
"nome": "Dan",
|
|
106
|
+
"licenca": {
|
|
107
|
+
"ativa": True,
|
|
108
|
+
"expira_em": "31/12/2099 23:59",
|
|
109
|
+
"restam_dias": None,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
"llm": {
|
|
113
|
+
"provedor": "openai-compat",
|
|
114
|
+
"base_url": "https://api.openai.com/v1",
|
|
115
|
+
"api_key": "",
|
|
116
|
+
"modelo": "gpt-4o-mini",
|
|
117
|
+
},
|
|
118
|
+
"integracoes": {
|
|
119
|
+
"moneyprinter": {"url": "http://localhost:8080"},
|
|
120
|
+
"short_video_maker": {"url": "http://localhost:3123"},
|
|
121
|
+
"openmontage": {"cli_path": ""},
|
|
122
|
+
"youtube_upload": {
|
|
123
|
+
"url": "",
|
|
124
|
+
"api_key": "",
|
|
125
|
+
"upload_path": "/api/upload",
|
|
126
|
+
"publicar_path": "/api/videos/{video_id}/publicar",
|
|
127
|
+
"modo": "nao_listado_1h_publico",
|
|
128
|
+
"limite_diario": 50,
|
|
129
|
+
"espera_publicacao_horas": 1,
|
|
130
|
+
"uploader_mcp_path": "",
|
|
131
|
+
},
|
|
132
|
+
"openoutreach": {"url": "", "send_path": "/api/send"},
|
|
133
|
+
"pipecat": {"url": ""},
|
|
134
|
+
},
|
|
135
|
+
"extratores": {
|
|
136
|
+
"google_maps_cli": "",
|
|
137
|
+
"google_maps_args": (
|
|
138
|
+
'--kw "{palavra_chave}" --loc "{localizacao}" --lang pt --email --max {max_resultados} --outdir "{outdir}"'
|
|
139
|
+
),
|
|
140
|
+
"igmapper_cli": "",
|
|
141
|
+
"igmapper_args": "--profile {url} --outdir {outdir}",
|
|
142
|
+
"scrapling_cli": "",
|
|
143
|
+
"cnpj_provedor_busca": "https://api.opencnpj.org",
|
|
144
|
+
},
|
|
145
|
+
"agentes": {
|
|
146
|
+
"disparo": {
|
|
147
|
+
"templates": {
|
|
148
|
+
"texto": (
|
|
149
|
+
"Olá {{primeiro_nome}}! Sou da {{minha_empresa}} e ajudo empresas como "
|
|
150
|
+
"{{empresa}} a crescerem com conteúdo automatizado no YouTube. "
|
|
151
|
+
"Posso te enviar uma proposta rápida?"
|
|
152
|
+
),
|
|
153
|
+
"audio": "Olá {{primeiro_nome}}, tudo bem? Falo em nome da {{minha_empresa}}...",
|
|
154
|
+
},
|
|
155
|
+
"minha_empresa": "LeadMaster",
|
|
156
|
+
"delay_entre_envios_segundos": 3,
|
|
157
|
+
},
|
|
158
|
+
"atendimento": {"modo": "texto"},
|
|
159
|
+
},
|
|
160
|
+
"webhook": {
|
|
161
|
+
"url": "",
|
|
162
|
+
"eventos": ["lead.criado", "lead.etapa", "campanha.concluida", "video.publicado"],
|
|
163
|
+
},
|
|
164
|
+
"uploads": {"data": "", "contador": 0},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
169
|
+
out = dict(base)
|
|
170
|
+
for key, value in override.items():
|
|
171
|
+
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
172
|
+
out[key] = _deep_merge(out[key], value)
|
|
173
|
+
else:
|
|
174
|
+
out[key] = value
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _deep_merge_preserve_secrets(base: dict, override: dict) -> dict:
|
|
179
|
+
"""Deep merge que ignora valores vazios em campos de segredo (manter atual)."""
|
|
180
|
+
out = dict(base)
|
|
181
|
+
for key, value in override.items():
|
|
182
|
+
if key in SECRET_KEYS and (value == "" or value is None) and key in out:
|
|
183
|
+
continue
|
|
184
|
+
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
185
|
+
out[key] = _deep_merge_preserve_secrets(out[key], value)
|
|
186
|
+
else:
|
|
187
|
+
out[key] = value
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_config() -> dict:
|
|
192
|
+
return _deep_merge(DEFAULT_CONFIG, _read(CONFIG_FILE, {}))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def update_config(patch: dict) -> dict:
|
|
196
|
+
with LOCK:
|
|
197
|
+
atual = get_config()
|
|
198
|
+
novo = _deep_merge_preserve_secrets(atual, patch)
|
|
199
|
+
_write(CONFIG_FILE, novo)
|
|
200
|
+
return novo
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def redact_config(config: dict) -> dict:
|
|
204
|
+
"""Copia a config substituindo segredos por vazio + flag `_configurado`."""
|
|
205
|
+
out = {}
|
|
206
|
+
for key, value in config.items():
|
|
207
|
+
if isinstance(value, dict):
|
|
208
|
+
out[key] = redact_config(value)
|
|
209
|
+
elif key in SECRET_KEYS:
|
|
210
|
+
out[key] = ""
|
|
211
|
+
out[f"{key}_configurado"] = bool(value)
|
|
212
|
+
else:
|
|
213
|
+
out[key] = value
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Leads
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def list_leads() -> list:
|
|
222
|
+
return _read(LEADS_FILE, [])
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def save_leads(leads: list) -> None:
|
|
226
|
+
_write(LEADS_FILE, leads)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def add_lead(lead: dict) -> dict:
|
|
230
|
+
with LOCK:
|
|
231
|
+
leads = list_leads()
|
|
232
|
+
lead["criado_em"] = lead.get("criado_em") or now_iso()
|
|
233
|
+
lead["atualizado_em"] = now_iso()
|
|
234
|
+
leads.append(lead)
|
|
235
|
+
save_leads(leads)
|
|
236
|
+
return lead
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def update_lead(lead_id: str, patch: dict) -> dict | None:
|
|
240
|
+
with LOCK:
|
|
241
|
+
leads = list_leads()
|
|
242
|
+
for lead in leads:
|
|
243
|
+
if lead.get("id") == lead_id:
|
|
244
|
+
lead.update(patch)
|
|
245
|
+
lead["atualizado_em"] = now_iso()
|
|
246
|
+
save_leads(leads)
|
|
247
|
+
return lead
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def delete_lead(lead_id: str) -> bool:
|
|
252
|
+
with LOCK:
|
|
253
|
+
leads = list_leads()
|
|
254
|
+
restantes = [l for l in leads if l.get("id") != lead_id]
|
|
255
|
+
if len(restantes) == len(leads):
|
|
256
|
+
return False
|
|
257
|
+
save_leads(restantes)
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def import_leads(novos: list, dedup: bool = True) -> dict:
|
|
262
|
+
"""Importa leads em massa; retorna {importados, duplicados}."""
|
|
263
|
+
with LOCK:
|
|
264
|
+
leads = list_leads()
|
|
265
|
+
existentes = {
|
|
266
|
+
(l.get("telefone") or "").strip(),
|
|
267
|
+
(l.get("email") or "").strip().lower(),
|
|
268
|
+
}
|
|
269
|
+
importados, duplicados = [], []
|
|
270
|
+
for novo in novos:
|
|
271
|
+
tel = (novo.get("telefone") or "").strip()
|
|
272
|
+
email = (novo.get("email") or "").strip().lower()
|
|
273
|
+
if dedup and ((tel and tel in existentes) or (email and email in existentes)):
|
|
274
|
+
duplicados.append(novo)
|
|
275
|
+
continue
|
|
276
|
+
novo.setdefault("id", _novo_id())
|
|
277
|
+
novo["criado_em"] = now_iso()
|
|
278
|
+
novo["atualizado_em"] = now_iso()
|
|
279
|
+
leads.append(novo)
|
|
280
|
+
existentes.add(tel)
|
|
281
|
+
existentes.add(email)
|
|
282
|
+
importados.append(novo)
|
|
283
|
+
save_leads(leads)
|
|
284
|
+
return {"importados": importados, "duplicados": duplicados}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _novo_id() -> str:
|
|
288
|
+
import uuid
|
|
289
|
+
|
|
290
|
+
return uuid.uuid4().hex[:12]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Campanhas
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
def list_campaigns() -> list:
|
|
298
|
+
return _read(CAMPAIGNS_FILE, [])
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def save_campaigns(campaigns: list) -> None:
|
|
302
|
+
_write(CAMPAIGNS_FILE, campaigns)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def upsert_campaign(campaign: dict) -> dict:
|
|
306
|
+
with LOCK:
|
|
307
|
+
campaigns = list_campaigns()
|
|
308
|
+
for i, c in enumerate(campaigns):
|
|
309
|
+
if c.get("id") == campaign["id"]:
|
|
310
|
+
campaigns[i] = campaign
|
|
311
|
+
break
|
|
312
|
+
else:
|
|
313
|
+
campaigns.append(campaign)
|
|
314
|
+
save_campaigns(campaigns)
|
|
315
|
+
return campaign
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_campaign(campaign_id: str) -> dict | None:
|
|
319
|
+
for c in list_campaigns():
|
|
320
|
+
if c.get("id") == campaign_id:
|
|
321
|
+
return c
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def delete_campaign(campaign_id: str) -> bool:
|
|
326
|
+
with LOCK:
|
|
327
|
+
campaigns = list_campaigns()
|
|
328
|
+
restantes = [c for c in campaigns if c.get("id") != campaign_id]
|
|
329
|
+
if len(restantes) == len(campaigns):
|
|
330
|
+
return False
|
|
331
|
+
save_campaigns(restantes)
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
# Pipeline
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
def list_jobs() -> list:
|
|
340
|
+
data = _read(PIPELINE_FILE, {"jobs": []})
|
|
341
|
+
if isinstance(data, list): # tolerância a formato antigo
|
|
342
|
+
data = {"jobs": data}
|
|
343
|
+
return data.get("jobs", [])
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def save_jobs(jobs: list) -> None:
|
|
347
|
+
_write(PIPELINE_FILE, {"jobs": jobs})
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def get_job(job_id: str) -> dict | None:
|
|
351
|
+
for j in list_jobs():
|
|
352
|
+
if j.get("id") == job_id:
|
|
353
|
+
return j
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def upsert_job(job: dict) -> dict:
|
|
358
|
+
with LOCK:
|
|
359
|
+
jobs = list_jobs()
|
|
360
|
+
for i, j in enumerate(jobs):
|
|
361
|
+
if j.get("id") == job["id"]:
|
|
362
|
+
jobs[i] = job
|
|
363
|
+
break
|
|
364
|
+
else:
|
|
365
|
+
jobs.append(job)
|
|
366
|
+
save_jobs(jobs)
|
|
367
|
+
return job
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# Seeds (primeira execução)
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
def _seed_leads() -> list:
|
|
375
|
+
"""Leads de demonstração para o painel nascer com vida."""
|
|
376
|
+
modelos = [
|
|
377
|
+
("Construtora Horizonte Ltda", "(11) 3344-5566", "contato@horizonte.com.br", "CNPJ", "leads", "Construtora Horizonte", "São Paulo/SP", 62),
|
|
378
|
+
("Clínica Bem Viver", "(11) 98877-1122", "contato@bemviver.com.br", "Google Maps", "leads", "Clínica Bem Viver", "Campinas/SP", 48),
|
|
379
|
+
("Padaria do Largo", "(21) 2233-4455", "", "Google Maps", "leads", "Padaria do Largo", "Rio de Janeiro/RJ", 25),
|
|
380
|
+
("Academia Iron Fit", "(31) 99876-5544", "atendimento@ironfit.com.br", "Instagram", "leads", "Iron Fit", "Belo Horizonte/MG", 55),
|
|
381
|
+
("Escritório Delta Contábil", "(41) 3322-7788", "delta@deltacontabil.com.br", "CNPJ", "em_contato", "Delta Contábil", "Curitiba/PR", 74),
|
|
382
|
+
("Restaurante Sabor Caseiro", "(85) 98811-2233", "saborcaseiro@gmail.com", "Google Maps", "em_contato", "Sabor Caseiro", "Fortaleza/CE", 68),
|
|
383
|
+
("Ótica Visão Clara", "(51) 3445-6677", "contato@visaoclara.com.br", "Instagram", "proposta", "Visão Clara", "Porto Alegre/RS", 88),
|
|
384
|
+
("Mercado Central Atacado", "(62) 99555-1010", "compras@centralatacado.com.br", "CNPJ", "fechado", "Central Atacado", "Goiânia/GO", 95),
|
|
385
|
+
]
|
|
386
|
+
leads = []
|
|
387
|
+
for nome, telefone, email, fonte, etapa, empresa, cidade, score in modelos:
|
|
388
|
+
leads.append(
|
|
389
|
+
{
|
|
390
|
+
"id": _novo_id(),
|
|
391
|
+
"nome": nome,
|
|
392
|
+
"telefone": telefone,
|
|
393
|
+
"email": email,
|
|
394
|
+
"fonte": fonte,
|
|
395
|
+
"etapa": etapa,
|
|
396
|
+
"empresa": empresa,
|
|
397
|
+
"cidade": cidade,
|
|
398
|
+
"score": score,
|
|
399
|
+
"tags": [],
|
|
400
|
+
"observacoes": "",
|
|
401
|
+
"criado_em": now_iso(),
|
|
402
|
+
"atualizado_em": now_iso(),
|
|
403
|
+
}
|
|
404
|
+
)
|
|
405
|
+
return leads
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _seed_campaigns() -> list:
|
|
409
|
+
return [
|
|
410
|
+
{
|
|
411
|
+
"id": _novo_id(),
|
|
412
|
+
"nome": "Reativação Q4 (demonstração)",
|
|
413
|
+
"canal": "texto",
|
|
414
|
+
"template": "texto",
|
|
415
|
+
"lead_ids": [],
|
|
416
|
+
"status": "concluida",
|
|
417
|
+
"total": 50,
|
|
418
|
+
"enviadas": 47,
|
|
419
|
+
"erros": 3,
|
|
420
|
+
"criado_em": now_iso(),
|
|
421
|
+
"iniciada_em": now_iso(),
|
|
422
|
+
"concluida_em": now_iso(),
|
|
423
|
+
"eventos": [],
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
"id": _novo_id(),
|
|
427
|
+
"nome": "Boas-vindas Novos Leads (demonstração)",
|
|
428
|
+
"canal": "texto",
|
|
429
|
+
"template": "texto",
|
|
430
|
+
"lead_ids": [],
|
|
431
|
+
"status": "rascunho",
|
|
432
|
+
"total": 0,
|
|
433
|
+
"enviadas": 0,
|
|
434
|
+
"erros": 0,
|
|
435
|
+
"criado_em": now_iso(),
|
|
436
|
+
"eventos": [],
|
|
437
|
+
},
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def ensure_seeds() -> None:
|
|
442
|
+
"""Cria os arquivos de dados com seeds na primeira execução."""
|
|
443
|
+
with LOCK:
|
|
444
|
+
d = storage_dir()
|
|
445
|
+
if not (d / LEADS_FILE).exists():
|
|
446
|
+
_write(LEADS_FILE, _seed_leads())
|
|
447
|
+
if not (d / CAMPAIGNS_FILE).exists():
|
|
448
|
+
_write(CAMPAIGNS_FILE, _seed_campaigns())
|
|
449
|
+
if not (d / PIPELINE_FILE).exists():
|
|
450
|
+
_write(PIPELINE_FILE, {"jobs": []})
|
|
451
|
+
if not (d / CONFIG_FILE).exists():
|
|
452
|
+
_write(CONFIG_FILE, DEFAULT_CONFIG)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def limpar_seeds_demo() -> None:
|
|
456
|
+
"""Remove os dados de demonstração (usado quando o usuário pede)."""
|
|
457
|
+
with LOCK:
|
|
458
|
+
save_leads([])
|
|
459
|
+
save_campaigns([])
|
|
460
|
+
save_jobs([])
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Upload para YouTube com a estratégia do LeadMaster:
|
|
2
|
+
|
|
3
|
+
1. Upload primário via serviço local "YouTube-Video-Upload-Frontend-Api"
|
|
4
|
+
(URL e caminhos configuráveis em Ferramentas → Upload YouTube).
|
|
5
|
+
2. Fallback opcional via CLI do `youtube-uploader-mcp`.
|
|
6
|
+
3. Publicação como **não listado**, aguardando 1h e mudando para **público**
|
|
7
|
+
(agendamento feito pelo motor do pipeline).
|
|
8
|
+
4. Limite diário de 50 uploads (contagem local por data).
|
|
9
|
+
|
|
10
|
+
Erros são sempre acionáveis: como instalar/configurar cada peça.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
from datetime import date
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from . import storage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UploadNaoConfigurado(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LimiteDiarioExcedido(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UploadErro(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _conf() -> dict:
|
|
34
|
+
return storage.get_config()["integracoes"]["youtube_upload"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _contagem() -> dict:
|
|
38
|
+
return storage.get_config()["uploads"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def verificar() -> bool:
|
|
42
|
+
conf = _conf()
|
|
43
|
+
if not conf.get("url"):
|
|
44
|
+
return bool(conf.get("uploader_mcp_path"))
|
|
45
|
+
try:
|
|
46
|
+
httpx.get(conf["url"].rstrip("/"), timeout=2)
|
|
47
|
+
return True
|
|
48
|
+
except httpx.HTTPError:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def contagem_hoje() -> int:
|
|
53
|
+
c = _contagem()
|
|
54
|
+
hoje = date.today().isoformat()
|
|
55
|
+
return c["contador"] if c.get("data") == hoje else 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _incrementar_contagem() -> None:
|
|
59
|
+
config = storage.get_config()
|
|
60
|
+
hoje = date.today().isoformat()
|
|
61
|
+
uploads = config.setdefault("uploads", {"data": hoje, "contador": 0})
|
|
62
|
+
if uploads.get("data") != hoje:
|
|
63
|
+
uploads.update({"data": hoje, "contador": 0})
|
|
64
|
+
uploads["contador"] = int(uploads.get("contador", 0)) + 1
|
|
65
|
+
storage.update_config({"uploads": uploads})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def upload_video(titulo: str, descricao: str, arquivo: str = "", video_url: str = "", tags: str = "") -> dict:
|
|
69
|
+
"""Faz o upload como não listado. Retorna {video_id, url, modo, aviso}."""
|
|
70
|
+
conf = _conf()
|
|
71
|
+
|
|
72
|
+
limite = int(conf.get("limite_diario", 50))
|
|
73
|
+
if contagem_hoje() >= limite:
|
|
74
|
+
raise LimiteDiarioExcedido(
|
|
75
|
+
f"Limite diário de {limite} uploads atingido (hoje: {contagem_hoje()}). "
|
|
76
|
+
"O upload continua amanhã automaticamente — clique Repetir no job."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if conf.get("url"):
|
|
80
|
+
resultado = _upload_via_servico(conf, titulo, descricao, arquivo, video_url, tags)
|
|
81
|
+
elif conf.get("uploader_mcp_path"):
|
|
82
|
+
resultado = _upload_via_mcp(conf, titulo, descricao, arquivo, video_url)
|
|
83
|
+
else:
|
|
84
|
+
raise UploadNaoConfigurado(
|
|
85
|
+
"Nenhum uploader configurado. Em Ferramentas → Upload YouTube, informe a URL do "
|
|
86
|
+
"YouTube-Video-Upload-Frontend-Api (primário) ou o caminho do youtube-uploader-mcp (fallback)."
|
|
87
|
+
)
|
|
88
|
+
_incrementar_contagem()
|
|
89
|
+
return resultado
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _upload_via_servico(conf: dict, titulo: str, descricao: str, arquivo: str, video_url: str, tags: str) -> dict:
|
|
93
|
+
base = conf["url"].rstrip("/")
|
|
94
|
+
path = conf.get("upload_path", "/api/upload").lstrip("/")
|
|
95
|
+
payload = {
|
|
96
|
+
"title": titulo,
|
|
97
|
+
"description": descricao,
|
|
98
|
+
"tags": tags,
|
|
99
|
+
"privacy": "unlisted", # não listado → agenda de 1h → público
|
|
100
|
+
"video_url": video_url,
|
|
101
|
+
"video_file": arquivo,
|
|
102
|
+
"api_key": conf.get("api_key", ""),
|
|
103
|
+
}
|
|
104
|
+
try:
|
|
105
|
+
r = httpx.post(f"{base}/{path}", json=payload, timeout=600)
|
|
106
|
+
except httpx.HTTPError as e:
|
|
107
|
+
raise UploadErro(
|
|
108
|
+
f"Uploader local ({base}) não respondeu: {e}. Confirme que o "
|
|
109
|
+
"YouTube-Video-Upload-Frontend-Api está ativo e clique Repetir."
|
|
110
|
+
) from e
|
|
111
|
+
if r.status_code >= 400:
|
|
112
|
+
raise UploadErro(f"Uploader local rejeitou o vídeo (HTTP {r.status_code}): {r.text[:300]}")
|
|
113
|
+
try:
|
|
114
|
+
corpo = r.json()
|
|
115
|
+
except ValueError:
|
|
116
|
+
corpo = {"resposta_bruta": r.text[:300]}
|
|
117
|
+
video_id = corpo.get("video_id") or corpo.get("videoId") or corpo.get("id") or ""
|
|
118
|
+
return {
|
|
119
|
+
"video_id": video_id,
|
|
120
|
+
"url": corpo.get("url") or (f"https://youtu.be/{video_id}" if video_id else ""),
|
|
121
|
+
"modo": "servico_local",
|
|
122
|
+
"resposta": corpo,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _upload_via_mcp(conf: dict, titulo: str, descricao: str, arquivo: str, video_url: str) -> dict:
|
|
127
|
+
cli = conf.get("uploader_mcp_path", "")
|
|
128
|
+
if not arquivo:
|
|
129
|
+
raise UploadErro("O fallback youtube-uploader-mcp precisa do arquivo de vídeo local (produza o vídeo primeiro).")
|
|
130
|
+
try:
|
|
131
|
+
resultado = subprocess.run(
|
|
132
|
+
[cli, arquivo, "--title", titulo, "--description", descricao, "--privacy", "unlisted"],
|
|
133
|
+
capture_output=True,
|
|
134
|
+
text=True,
|
|
135
|
+
timeout=3600,
|
|
136
|
+
shell=False,
|
|
137
|
+
)
|
|
138
|
+
except FileNotFoundError as e:
|
|
139
|
+
raise UploadNaoConfigurado(f"Caminho do youtube-uploader-mcp inválido: {e}") from e
|
|
140
|
+
if resultado.returncode != 0:
|
|
141
|
+
raise UploadErro(f"youtube-uploader-mcp falhou: {resultado.stderr[-400:]}")
|
|
142
|
+
return {
|
|
143
|
+
"video_id": resultado.stdout.strip().splitlines()[-1] if resultado.stdout.strip() else "",
|
|
144
|
+
"url": resultado.stdout.strip().splitlines()[-1] if resultado.stdout.strip() else "",
|
|
145
|
+
"modo": "uploader_mcp",
|
|
146
|
+
"resposta": {"stdout": resultado.stdout[-500:]},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def tornar_publico(video_id: str) -> dict:
|
|
151
|
+
"""Muda o vídeo de não listado para público (chamado pelo agendador)."""
|
|
152
|
+
conf = _conf()
|
|
153
|
+
if not conf.get("url"):
|
|
154
|
+
raise UploadNaoConfigurado("Uploader local não configurado; publique manualmente o vídeo " f"{video_id}.")
|
|
155
|
+
base = conf["url"].rstrip("/")
|
|
156
|
+
path = conf.get("publicar_path", "/api/videos/{video_id}/publicar").lstrip("/")
|
|
157
|
+
try:
|
|
158
|
+
r = httpx.post(f"{base}/{path.format(video_id=video_id)}", json={"privacy": "public"}, timeout=60)
|
|
159
|
+
except httpx.HTTPError as e:
|
|
160
|
+
raise UploadErro(f"Falha ao publicar {video_id} via uploader local: {e}") from e
|
|
161
|
+
if r.status_code >= 400:
|
|
162
|
+
raise UploadErro(f"Uploader local recusou publicar {video_id} (HTTP {r.status_code}): {r.text[:300]}")
|
|
163
|
+
return {"ok": True, "resposta": r.text[:300]}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|