@danhachuel/leadmaster 0.1.0 → 0.1.2

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 CHANGED
@@ -6,6 +6,58 @@ 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.02] — 2026-10-24
10
+
11
+ ### Corrigido
12
+
13
+ - **Poluição do terminal por logs de polling** (`GET /api/metrics`):
14
+ - Launcher (`scripts/cli.mjs`) e `npm run dev` iniciam o uvicorn com
15
+ `--no-access-log`.
16
+ - Filtro granular de logging no backend silencia o access-log **apenas** de
17
+ `/api/metrics` — erros, exceções e os demais endpoints continuam logados
18
+ normalmente (vale também para execuções manuais do uvicorn).
19
+ - Painel migrado para **TanStack Query** (`@tanstack/react-query`,
20
+ `refetchInterval` de 10s): ciclo de vida e cleanup automáticos, pausa
21
+ quando a aba fica ociosa e retoma ao focar; indicador "atualiza a cada
22
+ 10s" visível nos blocos de gráficos.
23
+ - Polling de campanhas (Disparos) também via TanStack Query: sonda a cada
24
+ 4s apenas enquanto existe campanha em andamento, sem `setInterval` manual.
25
+ - Verificado: nenhum outro endpoint é alvo de polling — `/api/config` e
26
+ demais chamadas são carregamentos pontuais no início da página.
27
+
28
+ ### Alterado
29
+
30
+ - **O sistema nunca abre o navegador.** Removida a abertura automática de janela do
31
+ launcher — o LeadMaster apenas imprime a URL no terminal e quem abre é o usuário.
32
+
33
+ ## [0.1.01] — 2026-10-23
34
+
35
+ ### Removido
36
+
37
+ - **Pipeline de vídeos faceless** — o LeadMaster agora é 100% focado em prospecção
38
+ (CRM, extratores de leads e agentes de IA). Removidos:
39
+ - Aba lateral "Pipeline de Vídeos", página e rota `/pipeline`.
40
+ - Integrações com MoneyPrinterTurbo, Short-Video-Maker, OpenMontage e upload para
41
+ YouTube: serviços backend, endpoints `/api/pipeline`, cards e testes em
42
+ Ferramentas, menções no Painel, tutorial do header, FAQ do Suporte e
43
+ diagnóstico.
44
+ - Motor do pipeline, agendador de publicação e contadores diários de upload.
45
+ - `pipeline.json` do armazenamento local — removido automaticamente das
46
+ instalações existentes, junto com as chaves de vídeo do `config.json`
47
+ (`integracoes.moneyprinter`, `short_video_maker`, `openmontage`,
48
+ `youtube_upload`, `uploads`) e o evento de webhook `video.publicado`.
49
+
50
+ ### Adicionado
51
+
52
+ - Migração automática de configuração: chaves de vídeo de versões antigas são
53
+ limpas na primeira execução da nova versão.
54
+ - Card "Status da Conexão" do Painel passa a mostrar LLM e OpenOutreach.
55
+
56
+ ### Alterado
57
+
58
+ - Backend de desenvolvimento (uvicorn) agora usa a porta **8010** — a 8000 costuma
59
+ ser usada por outros serviços locais (ex.: PandaNews), o que causava conflito.
60
+
9
61
  ## [0.1.00] — 2026-10-23
10
62
 
11
63
  ### Adicionado
package/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # LeadMaster
2
2
 
3
- **SaaS local de prospecção e automação de conteúdo.** CRM, extratores de leads, agentes de
4
- IA e um pipeline completo de geração de vídeos faceless (YouTube e Shorts) — instalável via
3
+ **SaaS local de prospecção.** CRM, extratores de leads e agentes de IA — instalável via
5
4
  `npx`, 100% local, sem nuvem e sem banco de dados externo.
6
5
 
7
6
  ![Stack](https://img.shields.io/badge/React%2018-Vite%20%2B%20TS-61dafb) ![Backend](https://img.shields.io/badge/FastAPI-Python%203.11%2B-009688) ![Storage](https://img.shields.io/badge/storage-JSON%20local-orange)
@@ -25,26 +24,27 @@ npx --yes @danhachuel/leadmaster@0.1.0
25
24
  ```
26
25
 
27
26
  - `install` prepara a pasta de dados e instala as dependências Python.
28
- - Sem argumentos, o launcher sobe o servidor local (FastAPI na porta **5055**) e abre o
29
- navegador automaticamente. Se a porta 5055 estiver ocupada, o LeadMaster sobe
27
+ - Sem argumentos, o launcher sobe o servidor local (FastAPI na porta **5055**) e
28
+ **imprime a URL no terminal** — o LeadMaster **nunca abre janelas**: quem abre o
29
+ navegador é você. Se a porta 5055 estiver ocupada, o LeadMaster sobe
30
30
  automaticamente na próxima livre (5056, 5057, ...). Pressione `Ctrl+C` para encerrar.
31
31
  - Flags úteis: `--home=<caminho>` (pasta de dados), `--port=<numero>`,
32
32
  `doctor` (diagnóstico), `help`.
33
33
 
34
34
  A pasta de dados padrão é `Documents\REPOSITORIO\LeadMaster` (sobrescreva com
35
- `--home` ou `LEADMASTER_HOME`). Todos os dados — leads, campanhas, pipeline e
35
+ `--home` ou `LEADMASTER_HOME`). Todos os dados — leads, campanhas e
36
36
  configuração — ficam em `<home>\storage` como arquivos JSON.
37
37
 
38
38
  ### Modo desenvolvimento (a partir do repositório)
39
39
 
40
40
  ```bash
41
41
  npm install # dependências da raiz (concurrently)
42
- npm run dev # sobe backend (uvicorn :8000, reload) + frontend (Vite :5173)
42
+ npm run dev # sobe backend (uvicorn :8010, reload) + frontend (Vite :5055)
43
43
  ```
44
44
 
45
45
  O Vite dev server sobe a UI de desenvolvimento em `http://localhost:5055` (com
46
46
  auto-incremento para 5056, 5057... se a porta estiver ocupada) e faz proxy de `/api`
47
- para `http://127.0.0.1:8000` (backend de desenvolvimento).
47
+ para `http://127.0.0.1:8010` (backend de desenvolvimento).
48
48
 
49
49
  ---
50
50
 
@@ -56,7 +56,6 @@ para `http://127.0.0.1:8000` (backend de desenvolvimento).
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
58
  | **Extratores** | **Google Maps** (palavra-chave + localização, via `gosom/google-maps-scraper`), **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
- | **Pipeline de vídeos** | Fluxo de 5 etapas com Iniciar/Pausar/Parar/Repetir: **Roteiro** (7–9k caracteres via LLM) → **Thumbnail/Título** → **Produção Wide 16:9** (MoneyPrinterTurbo/OpenMontage) → **Produção Shorts 9:16** (Short-Video-Maker) → **Upload** (não listado → aguarda 1h → público; até 50/dia, com fallback `youtube-uploader-mcp`). |
60
59
  | **Disparos** | Campanhas de disparo (texto/áudio) com templates configuráveis e execução via `eracle/OpenOutreach`. |
61
60
  | **Agentes** | Agente de disparo (prompts/templates) e agente de atendimento (texto via LLM ou voz via `pipecat-ai/pipecat`). |
62
61
  | **Ferramentas** | Chaves de API, URLs das integrações locais, testes de conexão — tudo salvo em `config.json` local. |
@@ -67,14 +66,9 @@ para `http://127.0.0.1:8000` (backend de desenvolvimento).
67
66
 
68
67
  ```
69
68
  npx @danhachuel/leadmaster
70
- └─ scripts/cli.mjs # instala deps Python, sobe uvicorn, abre navegador
69
+ └─ scripts/cli.mjs # instala deps Python, sobe uvicorn, imprime a URL (nunca abre o navegador)
71
70
  └─ backend/main.py # FastAPI: API + serving da UI construída (frontend/dist)
72
- ├─ services/storage.py # JSON local (leads, campanhas, pipeline, config)
73
- ├─ services/pipeline.py # motor das 5 etapas (threads + checkpoints)
74
- ├─ services/moneyprinter.py # POST localhost:8080/api/v1/videos
75
- ├─ services/short_video_maker.py # POST localhost:3123/api/short-video
76
- ├─ services/openmontage.py # CLI local
77
- ├─ services/youtube_upload.py # upload + agendamento não listado → público
71
+ ├─ services/storage.py # JSON local (leads, campanhas, config)
78
72
  ├─ services/extractors.py # Maps / Instagram / CNPJ
79
73
  ├─ services/crm_agent.py # sugestões + enriquecimento
80
74
  ├─ services/agents.py # disparo (OpenOutreach) + atendimento
@@ -82,17 +76,14 @@ npx @danhachuel/leadmaster
82
76
  ```
83
77
 
84
78
  - **Portas**: LeadMaster UI/API `:5055` (auto-incrementa: 5056, 5057, ... se ocupada) ·
85
- backend de desenvolvimento `:8000` · MoneyPrinterTurbo `:8080` · Short-Video-Maker `:3123`.
86
- - **Degradación graciosa**: se um serviço externo não estiver rodando, a etapa falha com
87
- mensagem acionável e um botão **Repetir** — nada de falsos sucessos.
79
+ backend de desenvolvimento `:8000`.
80
+ - **Degradación graciosa**: se um serviço externo não estiver configurado/rodando, a ação
81
+ falha com mensagem acionável — nada de falsos sucessos.
88
82
 
89
83
  ### Serviços externos (opcionais, todos locais)
90
84
 
91
85
  | Serviço | Como usar no LeadMaster |
92
86
  |---|---|
93
- | [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo) | Suba em `http://localhost:8080` e configure em *Ferramentas*. |
94
- | [Short-Video-Maker](https://github.com/lucasoal/short-video-maker) | Suba em `http://localhost:3123`. |
95
- | [OpenMontage](https://github.com/openmotional/OpenMontage) | Informe o caminho do CLI em *Ferramentas*. |
96
87
  | [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scraper) | Informe o caminho do binário em *Ferramentas → Extratores*. |
97
88
  | [lucasoal/igmapper](https://github.com/lucasoal/igmapper) | Informe o caminho em *Ferramentas → Extratores*. |
98
89
  | BrasilAPI / OpenCNPJ / Scrapling | Consulta por CNPJ e busca por CNAE/UF (configuráveis). |
@@ -106,11 +97,11 @@ npx @danhachuel/leadmaster
106
97
  A interface replica o protótipo `LeadMaster v1.0` (sidebar escura `#1e1b4b`, cards
107
98
  brancos, gradientes indigo/purple) e aplica as **7 Leis de Usabilidade**:
108
99
 
109
- 1. **Fitts** — CTAs grandes ("Iniciar Novo Vídeo", "Salvar").
100
+ 1. **Fitts** — CTAs grandes ("Nova Campanha", "Salvar").
110
101
  2. **Hick** — configurações avançadas em accordions.
111
- 3. **Zeigarnik** — barras de progresso (pipeline e preenchimento de leads).
102
+ 3. **Zeigarnik** — barras de progresso (preenchimento de leads e campanhas).
112
103
  4. **Jakob** — consistência com o modelo mental do protótipo (menu lateral fixo).
113
- 5. **Gradiente de Meta** — "Faltam 2 etapas para o upload 🚀".
104
+ 5. **Gradiente de Meta** — "Faltam 2 campos para o lead ficar completo".
114
105
  6. **von Restorff** — botão principal com gradiente sobre fundo neutro.
115
106
  7. **Miller** — métricas agrupadas em cards (chunking).
116
107
 
package/backend/main.py CHANGED
@@ -5,6 +5,7 @@ modo `npx`, onde um único servidor entrega painel e API na mesma porta.
5
5
  """
6
6
 
7
7
  import json
8
+ import logging
8
9
  import os
9
10
  import platform
10
11
  import sys
@@ -18,7 +19,7 @@ from fastapi.responses import FileResponse
18
19
  from fastapi.staticfiles import StaticFiles
19
20
  from pydantic import BaseModel, Field
20
21
 
21
- from services import agents, crm_agent, extractors, llm, moneyprinter, pipeline, short_video_maker, storage, youtube_upload
22
+ from services import agents, crm_agent, extractors, llm, storage
22
23
 
23
24
  REPO_ROOT = Path(__file__).resolve().parent.parent
24
25
  FRONTEND_DIST = REPO_ROOT / "frontend" / "dist"
@@ -35,14 +36,31 @@ def _versao() -> str:
35
36
 
36
37
 
37
38
  VERSION = _versao()
38
- engine = pipeline.PipelineEngine()
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Logs de acesso
43
+ #
44
+ # O endpoint /api/metrics é consultado por polling do Painel e inundava o
45
+ # terminal. O filtro abaixo silencia APENAS esse endpoint no access-log —
46
+ # erros, exceções e os demais endpoints continuam sendo logados normalmente.
47
+ # (O launcher e o "npm run dev" também passam --no-access-log ao uvicorn.)
48
+ # ---------------------------------------------------------------------------
49
+
50
+ class _FiltroAcessoPolling(logging.Filter):
51
+ def filter(self, record: logging.LogRecord) -> bool:
52
+ try:
53
+ return "/api/metrics" not in record.getMessage()
54
+ except Exception:
55
+ return True
56
+
57
+
58
+ logging.getLogger("uvicorn.access").addFilter(_FiltroAcessoPolling())
39
59
 
40
60
 
41
61
  @asynccontextmanager
42
62
  async def lifespan(_app: FastAPI):
43
63
  storage.ensure_seeds()
44
- engine.recuperar()
45
- engine.iniciar_agendador()
46
64
  yield
47
65
 
48
66
 
@@ -89,19 +107,6 @@ class CampaignIn(BaseModel):
89
107
  lead_ids: list[str] = []
90
108
 
91
109
 
92
- class JobIn(BaseModel):
93
- tema: str = Field(min_length=1)
94
- titulo: str = ""
95
- tom: str = "informativo"
96
- duracao_min: int = 8
97
- keyword_seo: str = ""
98
- formatos: list[str] = ["wide", "shorts"]
99
-
100
-
101
- class AcaoIn(BaseModel):
102
- acao: str
103
-
104
-
105
110
  class GoogleMapsIn(BaseModel):
106
111
  palavra_chave: str = Field(min_length=2)
107
112
  localizacao: str = Field(min_length=2)
@@ -156,12 +161,9 @@ def health():
156
161
  @app.get("/api/doctor")
157
162
  def doctor():
158
163
  integracoes = {
159
- "moneyprinter": moneyprinter.verificar(),
160
- "short_video_maker": short_video_maker.verificar(),
161
- "uploader_youtube": youtube_upload.verificar(),
162
164
  "llm_configurado": llm.configurado(),
163
- "openmontage": bool(storage.get_config()["integracoes"]["openmontage"].get("cli_path")),
164
165
  "openoutreach": agents.disparo_configurado(),
166
+ "pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
165
167
  }
166
168
  return {
167
169
  "ok": True,
@@ -170,7 +172,6 @@ def doctor():
170
172
  "sistema": platform.platform(),
171
173
  "dist_frontend": FRONTEND_DIST.exists(),
172
174
  "storage": str(storage.storage_dir()),
173
- "uploads_hoje": youtube_upload.contagem_hoje(),
174
175
  "integracoes": integracoes,
175
176
  }
176
177
 
@@ -192,14 +193,10 @@ def metrics():
192
193
  enviadas = sum(int(c.get("enviadas", 0)) for c in campaigns)
193
194
  erros = sum(int(c.get("erros", 0)) for c in campaigns)
194
195
  total_envios = enviadas + erros
195
- jobs = storage.list_jobs()
196
196
 
197
197
  return {
198
198
  "conexao": {
199
199
  "geral": True, # o backend local responde => conectado
200
- "moneyprinter": moneyprinter.verificar(),
201
- "short_video_maker": short_video_maker.verificar(),
202
- "uploader_youtube": youtube_upload.verificar(),
203
200
  "llm": llm.configurado(),
204
201
  "openoutreach": agents.disparo_configurado(),
205
202
  },
@@ -213,10 +210,6 @@ def metrics():
213
210
  for etapa in ("leads", "em_contato", "proposta", "fechado")
214
211
  },
215
212
  "campanhas": {"enviadas": enviadas, "erros": erros, "total": len(campaigns)},
216
- "pipeline": {
217
- "ativos": sum(1 for j in jobs if j.get("status") in ("em_andamento", "pausado")),
218
- "concluidos": sum(1 for j in jobs if j.get("status") == "concluido"),
219
- },
220
213
  "perfil": storage.get_config().get("perfil", {}),
221
214
  }
222
215
 
@@ -345,34 +338,6 @@ def remover_campanha(campaign_id: str):
345
338
  return {"ok": True}
346
339
 
347
340
 
348
- # ---------------------------------------------------------------------------
349
- # Pipeline de vídeos
350
- # ---------------------------------------------------------------------------
351
-
352
- @app.get("/api/pipeline")
353
- def listar_pipeline():
354
- return engine.listar()
355
-
356
-
357
- @app.post("/api/pipeline", status_code=201)
358
- def criar_job(job_in: JobIn):
359
- try:
360
- return engine.criar_job(job_in.model_dump())
361
- except ValueError as e:
362
- raise HTTPException(400, str(e))
363
-
364
-
365
- @app.post("/api/pipeline/{job_id}/actions")
366
- def agir_pipeline(job_id: str, acao_in: AcaoIn):
367
- try:
368
- resultado = engine.agir(job_id, acao_in.acao)
369
- except ValueError as e:
370
- raise HTTPException(400, str(e))
371
- if not resultado.get("ok"):
372
- raise HTTPException(400, resultado.get("mensagem", "Ação não permitida."))
373
- return resultado
374
-
375
-
376
341
  # ---------------------------------------------------------------------------
377
342
  # Extratores
378
343
  # ---------------------------------------------------------------------------
@@ -447,11 +412,7 @@ def testar_integracao(dados: IntegracaoTestIn):
447
412
  @app.get("/api/integrations/status")
448
413
  def status_integracoes():
449
414
  return {
450
- "moneyprinter": moneyprinter.verificar(),
451
- "short_video_maker": short_video_maker.verificar(),
452
- "uploader_youtube": youtube_upload.verificar(),
453
415
  "llm": llm.configurado(),
454
- "openmontage": bool(storage.get_config()["integracoes"]["openmontage"].get("cli_path")),
455
416
  "openoutreach": agents.disparo_configurado(),
456
417
  "pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
457
418
  }
@@ -517,6 +478,9 @@ if FRONTEND_DIST.exists():
517
478
 
518
479
  @app.get("/{full_path:path}", include_in_schema=False)
519
480
  def spa(full_path: str):
481
+ # Rotas de API inexistentes devolvem 404 JSON (não a página do SPA).
482
+ if full_path.startswith("api/") or full_path == "api":
483
+ raise HTTPException(404, f"Rota /{full_path} não existe.")
520
484
  alvo = FRONTEND_DIST / full_path
521
485
  if full_path and alvo.is_file():
522
486
  return FileResponse(alvo)
@@ -1,9 +1,9 @@
1
- """Cliente LLM (API OpenAI-compatível) com fallback local para roteiros.
1
+ """Cliente LLM (API OpenAI-compatível) para os agentes do LeadMaster.
2
2
 
3
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.
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
7
  """
8
8
 
9
9
  import httpx
@@ -28,7 +28,7 @@ def configurado() -> bool:
28
28
  return bool(c.get("api_key") and c.get("base_url"))
29
29
 
30
30
 
31
- def chat(mensagens: list, temperature: float = 0.7, max_tokens: int = 4000) -> str:
31
+ def chat(mensagens: list, temperature: float = 0.7, max_tokens: int = 2000) -> str:
32
32
  c = _conf()
33
33
  if not configurado():
34
34
  raise LLMNaoConfigurado(
@@ -67,123 +67,3 @@ def testar_conexao() -> dict:
67
67
  return {"ok": False, "mensagem": str(e)}
68
68
  except LLMErro as e:
69
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
- }
@@ -2,7 +2,7 @@
2
2
 
3
3
  Toda a escrita é atômica (arquivo temporário + replace) e protegida por um lock
4
4
  global, para permitir o uso concorrente pelas rotas da API e pelos workers em
5
- background (pipeline, campanhas, agendador de publicação).
5
+ background (campanhas de disparo).
6
6
  """
7
7
 
8
8
  import json
@@ -46,18 +46,6 @@ def storage_dir() -> Path:
46
46
  return d
47
47
 
48
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
49
  def now_iso() -> str:
62
50
  return datetime.now().astimezone().isoformat(timespec="seconds")
63
51
 
@@ -116,20 +104,7 @@ DEFAULT_CONFIG = {
116
104
  "modelo": "gpt-4o-mini",
117
105
  },
118
106
  "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"},
107
+ "openoutreach": {"url": "", "enabled": False, "send_path": "/api/send"},
133
108
  "pipecat": {"url": ""},
134
109
  },
135
110
  "extratores": {
@@ -147,7 +122,7 @@ DEFAULT_CONFIG = {
147
122
  "templates": {
148
123
  "texto": (
149
124
  "Olá {{primeiro_nome}}! Sou da {{minha_empresa}} e ajudo empresas como "
150
- "{{empresa}} a crescerem com conteúdo automatizado no YouTube. "
125
+ "{{empresa}} a crescerem com prospecção automatizada. "
151
126
  "Posso te enviar uma proposta rápida?"
152
127
  ),
153
128
  "audio": "Olá {{primeiro_nome}}, tudo bem? Falo em nome da {{minha_empresa}}...",
@@ -159,11 +134,20 @@ DEFAULT_CONFIG = {
159
134
  },
160
135
  "webhook": {
161
136
  "url": "",
162
- "eventos": ["lead.criado", "lead.etapa", "campanha.concluida", "video.publicado"],
137
+ "eventos": ["lead.criado", "lead.etapa", "campanha.concluida"],
163
138
  },
164
- "uploads": {"data": "", "contador": 0},
165
139
  }
166
140
 
141
+ # Chaves de versões antigas (o pipeline de vídeos foi retirado do produto) —
142
+ # são removidas automaticamente da configuração local na primeira leitura.
143
+ _CAMINHOS_REMOVIDOS = [
144
+ ("integracoes", "moneyprinter"),
145
+ ("integracoes", "short_video_maker"),
146
+ ("integracoes", "openmontage"),
147
+ ("integracoes", "youtube_upload"),
148
+ ("uploads",),
149
+ ]
150
+
167
151
 
168
152
  def _deep_merge(base: dict, override: dict) -> dict:
169
153
  out = dict(base)
@@ -188,8 +172,41 @@ def _deep_merge_preserve_secrets(base: dict, override: dict) -> dict:
188
172
  return out
189
173
 
190
174
 
175
+ def _limpar_deprecated(config: dict) -> dict:
176
+ """Migração: remove chaves de versões antigas (pipeline de vídeos)."""
177
+ mudou = False
178
+ for caminho in _CAMINHOS_REMOVIDOS:
179
+ secao = config
180
+ for chave in caminho[:-1]:
181
+ secao = secao.get(chave) if isinstance(secao, dict) else None
182
+ if not isinstance(secao, dict):
183
+ break
184
+ else:
185
+ ultimo = caminho[-1]
186
+ if isinstance(secao, dict) and ultimo in secao:
187
+ secao.pop(ultimo)
188
+ mudou = True
189
+ # Evento de webhook do recurso removido.
190
+ webhook = config.get("webhook")
191
+ if isinstance(webhook, dict) and isinstance(webhook.get("eventos"), list):
192
+ eventos = [e for e in webhook["eventos"] if e != "video.publicado"]
193
+ if eventos != webhook["eventos"]:
194
+ webhook["eventos"] = eventos
195
+ mudou = True
196
+ # Dados órfãos do pipeline de vídeos: o arquivo só existia para esse recurso.
197
+ pipeline_path = storage_dir() / PIPELINE_FILE
198
+ if pipeline_path.exists():
199
+ try:
200
+ pipeline_path.unlink()
201
+ except OSError:
202
+ pass
203
+ if mudou:
204
+ _write(CONFIG_FILE, config)
205
+ return config
206
+
207
+
191
208
  def get_config() -> dict:
192
- return _deep_merge(DEFAULT_CONFIG, _read(CONFIG_FILE, {}))
209
+ return _limpar_deprecated(_deep_merge(DEFAULT_CONFIG, _read(CONFIG_FILE, {})))
193
210
 
194
211
 
195
212
  def update_config(patch: dict) -> dict:
@@ -332,41 +349,6 @@ def delete_campaign(campaign_id: str) -> bool:
332
349
  return True
333
350
 
334
351
 
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
352
  # ---------------------------------------------------------------------------
371
353
  # Seeds (primeira execução)
372
354
  # ---------------------------------------------------------------------------
@@ -446,8 +428,6 @@ def ensure_seeds() -> None:
446
428
  _write(LEADS_FILE, _seed_leads())
447
429
  if not (d / CAMPAIGNS_FILE).exists():
448
430
  _write(CAMPAIGNS_FILE, _seed_campaigns())
449
- if not (d / PIPELINE_FILE).exists():
450
- _write(PIPELINE_FILE, {"jobs": []})
451
431
  if not (d / CONFIG_FILE).exists():
452
432
  _write(CONFIG_FILE, DEFAULT_CONFIG)
453
433