@danhachuel/thunderbolt 0.2.27 → 0.2.29

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.2.27
5
+ > **Versão deste manual:** 0.2.29
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.2.27 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.29 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.27 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.29 install
110
110
  ```
111
111
 
112
112
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -339,7 +339,7 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
339
339
 
340
340
  ## 8. Configuração inicial da UI
341
341
 
342
- Na primeira execução, abra **Configurações** e reveja. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica numa área opcional, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
342
+ Na primeira execução, a barra lateral apresenta **Início**, **Pipeline**, **Automação**, **Niche Finder** e **Configurações**. Abra **Configurações > Configurações Técnicas** e reveja. O menu **Pipeline** contém **Criação de Vídeos**, **Criação de Músicas**, **Upload** e **Limpador de Metadados**. O menu **Configurações** contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica numa área opcional, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
343
343
 
344
344
  | Configuração | Finalidade |
345
345
  |---|---|
@@ -358,30 +358,41 @@ As credenciais devem ser inseridas apenas na configuração local. A Data API Ke
358
358
 
359
359
  Após iniciar a aplicação, valide o seguinte percurso:
360
360
 
361
- 1. **Dashboard:** confirme que a UI abre e mostra o estado local.
361
+ 1. **Início:** confirme que a UI abre, mostra o estado local, as métricas e os cards das filas do Pipeline, sem botões de acções rápidas.
362
362
  2. **Blueprints:** coloque um JSON em `~/.thunderbolt/storage/blueprints/importados/` (Windows: `%LOCALAPPDATA%\\THUNDERBOLT\storage\blueprints\importados\`) ou use o carregador da interface.
363
363
  3. **Brandings:** abra a subaba **Brandings** e confirme a listagem dos ficheiros JSON.
364
364
  4. **Canais:** em **Importar do YouTube**, use o método **Página pública — sem API Key** com um URL `/channel/UC...`, um handle ou uma subpágina `/videos`; o parser resolve o ID, consulta a página pública e tenta o RSS quando necessário. Confirme que o resultado abre o formulário de revisão sem Data API Key. Se o canal não existir ou não fornecer metadados, confirme a mensagem clara e que o formulário de uma pesquisa anterior desaparece. A Data API é opcional e fica separada; em **Cadastro manual**, preencha os dados sem qualquer consulta externa.
365
- 5. **Novo vídeo:** teste primeiro o modo **Canal específico** e depois os modos de lote.
366
- 6. **Novo vídeo > Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar** dentro da subaba.
367
- 7. **Upload:** configure o OAuth Client ID e Secret, autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key, se configurada, é exclusivamente para consultas oficiais públicas e nunca substitui OAuth.
368
- 8. **Limpador de metadado:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos da aba Novo vídeo não são aceites nesta área.
369
- 9. **Configurações:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
370
- 10. **Novo vídeo:** confirme que `Pexels/Pixabay` substitui o label antigo, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
371
- 11. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. A UI não inicia jobs em segundo plano nesta entrega.
372
- 12. **Teste de vozes:** em Configurações, teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
373
- 13. **Upload directo:** configure manualmente cookies, sessionInfo e INNERTUBE_API_KEY, atribua o `DELEGATED_SESSION_ID` no canal e teste apenas com um vídeo de validação. O método envia chunks de 256 KiB e não extrai cookies do navegador.
374
- 14. **MCP — Client MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
375
- 15. **MCP Servidor MCP:** abra a subaba, mantenha o host `127.0.0.1`, active **Servidor MCP ON** e clique em **Guardar e iniciar Servidor MCP**. Confirme o endpoint `/mcp` e o health endpoint `/health`. Mantenha **Permitir ferramentas de escrita** desactivado até precisar que um agente crie lotes.
376
- 16. **MCP Skill:** clique em **Guardar skill localmente** e confirme o ficheiro em `storage/skills/moneyprinterturbo-video.md`; opcionalmente use **Descarregar skill .md** para obter a cópia através do navegador.
377
-
378
- ## Novo vídeo, Automação e música
365
+ 5. **Niche Finder:** abra a página na sidebar, clique em **Baixar Dataset Kaggle** ou carregue um CSV próprio e confirme o ficheiro em `storage/data/niches/`. Com um CSV que contenha `title`, `publish_date`, `country`, `view_count`, `like_count` e `comment_count`, clique em **Usar CSV carregado**, escolha 3 clusters e suporte 0,05 e execute **Analisar Nichos**. Confirme as métricas, a tabela de clusters, o gráfico Plotly, as regras de associação e os dados filtrados. Altere país, engagement, datas e tags e confirme que a análise é recalculada.
366
+ 6. **Pipeline > Criação de Vídeos:** teste primeiro o modo **Canal específico** e depois os modos de lote.
367
+ 7. **Criação de Vídeos > Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar** dentro da subaba. Confirme também que **Criação de Músicas** mostra o mesmo fluxo com título próprio.
368
+ 8. **Upload:** configure o OAuth Client ID e Secret, autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key, se configurada, é exclusivamente para consultas oficiais públicas e nunca substitui OAuth.
369
+ 9. **Pipeline > Limpador de Metadados:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos das páginas de criação não são aceites nesta área.
370
+ 10. **Configurações > Configurações Técnicas:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
371
+ 11. **Criação de Vídeos:** confirme que `Pexels/Pixabay` substitui o label antigo, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
372
+ 12. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. A UI não inicia jobs em segundo plano nesta entrega.
373
+ 13. **Configurações > Configurações Técnicas > Teste de vozes:** teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
374
+ 14. **Upload directo:** configure manualmente cookies, sessionInfo e INNERTUBE_API_KEY, atribua o `DELEGATED_SESSION_ID` no canal e teste apenas com um vídeo de validação. O método envia chunks de 256 KiB e não extrai cookies do navegador.
375
+ 15. **Configurações > MCP > Client MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
376
+ 16. **Configurações > MCP > Servidor MCP:** abra a subaba, mantenha o host `127.0.0.1`, active **Servidor MCP ON** e clique em **Guardar e iniciar Servidor MCP**. Confirme o endpoint `/mcp` e o health endpoint `/health`. Mantenha **Permitir ferramentas de escrita** desactivado até precisar que um agente crie lotes.
377
+ 17. **Configurações > MCP > Skill:** clique em **Guardar skill localmente** e confirme o ficheiro em `storage/skills/moneyprinterturbo-video.md`; opcionalmente use **Descarregar skill .md** para obter a cópia através do navegador.
378
+
379
+ ## Niche Finder
380
+
381
+ A página **Niche Finder** integra a lógica adaptada do projecto open source [johanfortus/Niche-Finder](https://github.com/johanfortus/Niche-Finder), cujo projecto original usa K-Means e FP-Growth sobre o dataset público [Trending Youtube Video Statistics (113 Countries)](https://www.kaggle.com/datasets/asaniczka/trending-youtube-videos-113-countries). No Thunderbolt não existe Flask, rota HTTP adicional, template HTML, JavaScript D3 ou segundo processo; toda a análise é síncrona no Streamlit.
382
+
383
+ O botão **Baixar Dataset Kaggle** guarda a cópia em `storage/data/niches/`. O carregador usa KaggleHub para obter o dataset público; não existem credenciais hardcoded. Também é possível carregar um CSV próprio e clicar em **Usar CSV carregado**. O esquema mínimo é `title`, `publish_date`, `country`, `view_count`, `like_count` e `comment_count`; `video_tags` é opcional para obter itemsets e regras de associação. A cache utiliza `st.cache_data` para evitar leituras repetidas do mesmo ficheiro.
384
+
385
+ Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados filtrados; o gráfico de dispersão é criado nativamente com Plotly. A cache de dados e os CSVs locais são ignorados pelo Git.
386
+
387
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.29 install`; o instalador detecta e reutiliza o que já estiver válido.
388
+
389
+ ## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
379
390
 
380
391
  O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`, o selector `Estilo IA` apresenta os 12 estilos disponíveis. Ao seleccionar `Apenas Música`, a UI obriga a escolher uma música existente, carregar um ficheiro ou solicitar uma música a um endpoint Suno configurado. O vídeo recebe `background_mode=none`, sem fundo Pexels/Pixabay ou IA.
381
392
 
382
393
  A aba **Automação** lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, guarda **Automação ON** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais** e são copiados para novas tarefas. É apenas uma camada de UI nesta versão, sem execução de worker.
383
394
 
384
- A área **Teste de vozes**, dentro de Configurações, é isolada da pipeline. O preview pode ser reproduzido e descarregado de `storage/voice_previews/`. Caminhos vazios, directórios, ficheiros vazios e previews sem permissões de leitura são ignorados com uma mensagem clara, sem traceback. Se uma instalação antiga mostrar que `edge-tts` está em falta, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o detector reinstala apenas a dependência ausente.
395
+ A área **Teste de vozes**, dentro de **Configurações > Configurações Técnicas**, é isolada da pipeline. O preview pode ser reproduzido e descarregado de `storage/voice_previews/`. Caminhos vazios, directórios, ficheiros vazios e previews sem permissões de leitura são ignorados com uma mensagem clara, sem traceback. Se uma instalação antiga mostrar que `edge-tts` está em falta, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o detector reinstala apenas a dependência ausente.
385
396
 
386
397
  ## Upload directo
387
398
 
@@ -389,19 +400,19 @@ A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https:/
389
400
 
390
401
  ## MCP e integrações externas
391
402
 
392
- A subaba **Client MCP** é um configurador local de quatro serviços opcionais. As portas iniciais são `3123` para Short Video Maker, `3001` para AutoVio, `8000` como referência editável para OpenMontage e `8787` para a API do OpenCut. A página consulta apenas `http://127.0.0.1:<porta>/` com timeout curto; não clona, instala ou inicia processos externos.
403
+ A subaba **Client MCP**, em **Configurações > MCP**, é um configurador local de quatro serviços opcionais. As portas iniciais são `3123` para Short Video Maker, `3001` para AutoVio, `8000` como referência editável para OpenMontage e `8787` para a API do OpenCut. A página consulta apenas `http://127.0.0.1:<porta>/` com timeout curto; não clona, instala ou inicia processos externos.
393
404
 
394
- A subaba **Servidor MCP** disponibiliza, após activação explícita, `http://127.0.0.1:3031/mcp` por JSON-RPC sobre HTTP POST. Para configurar um agente compatível, use esse URL como endpoint MCP. Se o host for alterado para uma interface externa, preencha primeiro um token e trate-o como segredo. O servidor disponibiliza ferramentas de leitura por padrão; a criação de lotes só é exposta quando **Permitir ferramentas de escrita** está activado.
405
+ A subaba **Servidor MCP**, em **Configurações > MCP**, disponibiliza, após activação explícita, `http://127.0.0.1:3031/mcp` por JSON-RPC sobre HTTP POST. Para configurar um agente compatível, use esse URL como endpoint MCP. Se o host for alterado para uma interface externa, preencha primeiro um token e trate-o como segredo. O servidor disponibiliza ferramentas de leitura por padrão; a criação de lotes só é exposta quando **Permitir ferramentas de escrita** está activado.
395
406
 
396
- A subaba **Skill** contém as acções para guardar a skill anexada em `storage/skills/` ou descarregá-la como Markdown. Esta pasta fica fora dos estados JSON e é criada pelo instalador e pelo launcher. Os quatro repositórios continuam fora do pacote npm.
407
+ A subaba **Skill**, em **Configurações > MCP**, contém as acções para guardar a skill anexada em `storage/skills/` ou descarregá-la como Markdown. Esta pasta fica fora dos estados JSON e é criada pelo instalador e pelo launcher. Os quatro repositórios continuam fora do pacote npm.
397
408
 
398
- ## 12. Limpador de metadado
409
+ ## 12. Limpador de Metadados
399
410
 
400
- A aba **Limpador de metadado** recebe somente vídeos externos já prontos. O ficheiro enviado é copiado para `storage/metadata_cleaner/originals/`; a aplicação não altera o original. Ao aplicar a operação, o FFmpeg remove os metadados existentes do contentor e cria uma nova versão em `storage/metadata_cleaner/outputs/` com o título, descrição, tags, idioma e outros campos preenchidos.
411
+ A aba **Limpador de Metadados** recebe somente vídeos externos já prontos. O ficheiro enviado é copiado para `storage/metadata_cleaner/originals/`; a aplicação não altera o original. Ao aplicar a operação, o FFmpeg remove os metadados existentes do contentor e cria uma nova versão em `storage/metadata_cleaner/outputs/` com o título, descrição, tags, idioma e outros campos preenchidos.
401
412
 
402
413
  A descrição combina **Preview**, **Links** e **Timestamps**, seguindo a estrutura do workflow `YTBMetadataGenerator.json`. O preview recomendado tem entre 100 e 200 caracteres; os capítulos devem começar por `00:00`. O manifesto JSON descarregado contém os campos para um fluxo posterior de upload. Esta adaptação local não faz scraping automático via RSS/Apify nem publica directamente no YouTube.
403
414
 
404
- Se a limpeza falhar, confirme a disponibilidade do FFmpeg em **Configurações > Caminho FFmpeg** ou execute `doctor`. O processo usa `-map_metadata -1` para remover os metadados existentes e `-c copy` para evitar uma re-encodificação desnecessária sempre que o contentor permitir.
415
+ Se a limpeza falhar, confirme a disponibilidade do FFmpeg em **Configurações > Configurações Técnicas > Caminho FFmpeg** ou execute `doctor`. O processo usa `-map_metadata -1` para remover os metadados existentes e `-c copy` para evitar uma re-encodificação desnecessária sempre que o contentor permitir.
405
416
 
406
417
  ## 11. Problemas frequentes
407
418
 
package/README.md CHANGED
@@ -10,21 +10,26 @@ A primeira versão implementa a camada UI independente com:
10
10
 
11
11
  | Área | Incluído |
12
12
  |---|---|
13
- | Dashboard | Resumo de canais, tarefas, backlog, execução e falhas |
14
- | Pipeline | Filas por etapa |
13
+ | Início | Resumo de canais, tarefas, backlog, execução e falhas, com as filas do Pipeline inline |
14
+ | Pipeline | Menu expansível com Criação de Vídeos, Criação de Músicas, Upload e Limpador de Metadados |
15
15
  | Blueprints | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
16
16
  | Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
17
17
  | Canais | Subabas de importação pública sem API Key, Data API opcional e cadastro manual independente |
18
- | Novo vídeo | Subabas Criar vídeo e Vídeos; lotes; 51 rótulos de idioma; Pexels/Pixabay, full IA com Estilo IA e Apenas Música com agente musical |
18
+ | Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos; lotes; 51 rótulos de idioma; Pexels/Pixabay, full IA com Estilo IA e Apenas Música com agente musical; a segunda página reutiliza o mesmo fluxo com título próprio |
19
19
  | Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON e horário diário HH:MM; UI configurável sem worker em segundo plano |
20
+ | Niche Finder | Dataset Kaggle ou CSV próprio, cache local, filtros, K-Means, FP-Growth, regras de associação, clusters e gráfico Plotly nativo |
20
21
  | Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo experimental, TikTok, Instagram e Facebook Pages no front end |
21
22
  | MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
22
- | Limpador de metadado | Upload isolado de vídeos terceiros, limpeza FFmpeg, edição de título/descrição/tags e manifesto JSON |
23
- | Configurações | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Upload directo, TikTok Client ID/Secret e Upload-Post |
23
+ | Limpador de Metadados | Upload isolado de vídeos terceiros, limpeza FFmpeg, edição de título/descrição/tags e manifesto JSON |
24
+ | Configurações Técnicas | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Upload directo, TikTok Client ID/Secret e Upload-Post |
24
25
  | Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
25
26
 
26
27
  Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
27
28
 
29
+ ## Navegação da UI 0.2.29
30
+
31
+ A barra lateral mantém apenas os cinco níveis principais, nesta ordem: **Início**, **Pipeline**, **Automação**, **Niche Finder** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Upload** e **Limpador de Metadados**. **Configurações** é expansível e contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
32
+
28
33
  ## Instalação
29
34
 
30
35
  Recomenda-se Python 3.11 ou superior e Node.js 18 ou superior.
@@ -104,9 +109,9 @@ thunderbolt
104
109
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
105
110
 
106
111
  ```powershell
107
- npx.cmd --yes @danhachuel/thunderbolt@0.2.27 install
108
- npx.cmd --yes @danhachuel/thunderbolt@0.2.27 doctor
109
- npx.cmd --yes @danhachuel/thunderbolt@0.2.27
112
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.29 install
113
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.29 doctor
114
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.29
110
115
  ```
111
116
 
112
117
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -124,6 +129,16 @@ Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
124
129
  Após `install`, o launcher usa o ambiente virtual instalado em `~/.thunderbolt/.venv` — no Windows, `%LOCALAPPDATA%\\THUNDERBOLT\\.venv` — e inicia a UI em `localhost:3030`. No Windows, a raiz é `%LOCALAPPDATA%\\THUNDERBOLT`; no computador indicado, o caminho será `C:\\Users\\danha\\AppData\\Local\\THUNDERBOLT`. Isto evita a redirecção de `HOME` que pode ocorrer no MobaXterm.
125
130
  O instalador não instala drivers de GPU, Docker, chaves de API, modelos Whisper ou credenciais de plataformas; esses componentes continuam dependentes do sistema e da configuração do utilizador. Para desenvolvimento a partir do clone, continue a usar `node scripts/cli.mjs`.
126
131
 
132
+ ## Niche Finder
133
+
134
+ A página **Niche Finder** integra a lógica do projecto open source [johanfortus/Niche-Finder](https://github.com/johanfortus/Niche-Finder) directamente no processo Streamlit. O Thunderbolt não inicia Flask, não abre uma porta adicional e não copia templates HTML ou D3. A análise usa o dataset público [Trending Youtube Video Statistics (113 Countries)](https://www.kaggle.com/datasets/asaniczka/trending-youtube-videos-113-countries), mas também aceita um CSV próprio.
135
+
136
+ Na sidebar da página, use **Baixar Dataset Kaggle** para descarregar e guardar o dataset em `storage/data/niches/`, ou carregue um CSV e clique em **Usar CSV carregado**. A cache é mantida localmente e o carregador valida as colunas mínimas `title`, `publish_date`, `country`, `view_count`, `like_count` e `comment_count`. Não são necessárias credenciais Kaggle hardcoded: o KaggleHub obtém o dataset público e guarda o resultado na cache local.
137
+
138
+ O painel permite seleccionar número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, categoria de engagement, intervalo de datas e tags. **Analisar Nichos** aplica normalização, filtros, `log1p`, `StandardScaler`, K-Means e FP-Growth. Os resultados aparecem em DataFrames para clusters, itemsets frequentes, regras de associação e dados filtrados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters. O download/cache dos dados é feito de forma lazy e os CSVs não são versionados pelo Git.
139
+
140
+ As dependências adicionais são instaladas pelo fluxo normal do pacote: `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.29 install`; o instalador detecta e reutiliza componentes já válidos.
141
+
127
142
  ## Armazenamento local
128
143
 
129
144
  O sistema cria automaticamente:
@@ -142,6 +157,8 @@ storage/
142
157
  │ └── settings.json
143
158
  ├── music/ # músicas locais para o modo Apenas Música
144
159
  ├── voice_previews/ # amostras isoladas do teste de vozes
160
+ ├── data/
161
+ │ └── niches/ # dataset Kaggle cacheado e CSVs enviados ao Niche Finder
145
162
  ├── skills/ # skill MoneyPrinterTurbo guardada pelo utilizador
146
163
  ├── metadata_cleaner/
147
164
  │ ├── originals/ # cópias dos vídeos terceiros enviados
@@ -159,9 +176,9 @@ A mesma aba possui a subaba **Brandings**. No formulário **Criar blueprint a pa
159
176
 
160
177
  O fluxo foi modelado a partir do blueprint de clonagem com Branding anexado, incluindo a distinção entre entrada de canal/vídeo, normalização do link, metadados de nicho/idioma, perfil do canal, estratégia de conteúdo, pesquisa, identidade visual e brand pack. Placeholders de serviços externos são tratados como configuração local; chaves presentes em workflows importados não devem ser commitadas.
161
178
 
162
- ## Limpador de metadado
179
+ ## Limpador de Metadados
163
180
 
164
- A aba **Limpador de metadado** foi adaptada do workflow `YTBMetadataGenerator.json`. Ela recebe apenas um vídeo externo já pronto, guarda uma cópia original separada, remove os metadados do contentor com FFmpeg e grava uma nova cópia com título, descrição, links, timestamps, tags e outros campos opcionais. O original nunca é alterado e os vídeos produzidos pela aba **Novo vídeo** não aparecem nesta área.
181
+ A aba **Limpador de Metadados** foi adaptada do workflow `YTBMetadataGenerator.json`. Ela recebe apenas um vídeo externo já pronto, guarda uma cópia original separada, remove os metadados do contentor com FFmpeg e grava uma nova cópia com título, descrição, links, timestamps, tags e outros campos opcionais. O original nunca é alterado e os vídeos produzidos pelas páginas **Criação de Vídeos** e **Criação de Músicas** não aparecem nesta área.
165
182
 
166
183
  A descrição segue a composição do workflow de referência: preview, secção de links e capítulos/timestamps. O resultado inclui um manifesto JSON para utilizar os metadados num fluxo posterior de upload. A versão local não usa o trigger RSS, o Apify ou a actualização automática de um vídeo YouTube; em vez disso, o utilizador fornece directamente o vídeo terceiro e controla a edição antes de publicar.
167
184
 
@@ -181,15 +198,16 @@ Use **Autorizar fallback OAuth** apenas se precisar de uma autorização separad
181
198
 
182
199
  A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Ela usa cookies, `sessionInfo`, `INNERTUBE_API_KEY` e `DELEGATED_SESSION_ID` fornecidos manualmente pelo utilizador, cria o vídeo através do endpoint interno e envia o ficheiro em chunks de 256 KiB. Este caminho é experimental, não extrai cookies automaticamente e fica separado do agente YouTube principal.
183
200
 
184
- ## Novo vídeo, Automação e música
201
+ ## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
185
202
 
186
- Em **Novo vídeo**, o estilo visual `Pexels/Pixabay` é o modo de materiais, `full_ia` abre o selector **Estilo IA** com os 12 estilos solicitados e **Apenas Música** exige um áudio local, um upload musical ou um pedido ao endpoint Suno configurado. Neste último modo, a task fica com `background_mode=none`: não são gerados fundos Pexels/Pixabay nem fundos IA.
203
+ Em **Criação de Vídeos**, o estilo visual `Pexels/Pixabay` é o modo de materiais, `full_ia` abre o selector **Estilo IA** com os 12 estilos solicitados e **Apenas Música** exige um áudio local, um upload musical ou um pedido ao endpoint Suno configurado. **Criação de Músicas** reutiliza o mesmo conteúdo e formulário com título próprio. Neste último modo, a task fica com `background_mode=none`: não são gerados fundos Pexels/Pixabay nem fundos IA.
187
204
 
188
205
  O agente musical guarda os ficheiros em `storage/music/`, aceita formatos de áudio comuns e pode descarregar uma URL de áudio devolvida por um endpoint Suno compatível. Em **Canais**, abra **Definir Blueprint e voz padrão** no cartão do canal para guardar os defaults; em **Automação**, os mesmos dois selectores aparecem no cartão e são sincronizados. Esses valores são usados automaticamente em novas tarefas criadas para o canal. A aba **Automação** também guarda `Automação ON` e um horário diário `HH:MM` por canal e lista os vídeos cadastrados; por definição, esta entrega não executa workers de fundo.
189
206
 
190
207
  ## Teste de vozes
191
208
 
192
- A área **Configurações > Teste de vozes** é isolada da pipeline. Permite escolher Edge/Azure Speech ou um provider HTTP configurado, seleccionar voz/ID, alterar a velocidade, sintetizar uma amostra, reproduzi-la e descarregá-la. O áudio é guardado em `storage/voice_previews/` e nunca altera vídeos ou tarefas. Se uma instalação antiga não tiver `edge-tts`, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o instalador detecta a dependência em falta sem reinstalar componentes válidos.
209
+ A área **Configurações > Configurações Técnicas > Teste de vozes** é isolada da pipeline.
210
+ Permite escolher Edge/Azure Speech ou um provider HTTP configurado, seleccionar voz/ID, alterar a velocidade, sintetizar uma amostra, reproduzi-la e descarregá-la. O áudio é guardado em `storage/voice_previews/` e nunca altera vídeos ou tarefas. Se uma instalação antiga não tiver `edge-tts`, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o instalador detecta a dependência em falta sem reinstalar componentes válidos.
193
211
 
194
212
  ## MCP e integrações externas opcionais
195
213
 
@@ -204,9 +222,10 @@ As portas iniciais são `3123` para Short Video Maker, `3001` para a API backend
204
222
 
205
223
  ## TikTok
206
224
 
207
- A subaba **Vídeos**, dentro de **Novo vídeo**, mostra o backlog e permite iniciar/parar tarefas; deixou de existir como botão principal na barra lateral.
225
+ A subaba **Vídeos**, dentro de **Criação de Vídeos** ou **Criação de Músicas**, mostra o backlog e permite iniciar/parar tarefas; deixou de existir como botão principal na barra lateral.
208
226
 
209
- A aba **Configurações** contém apenas o TikTok Client ID e o TikTok Client Secret para TikTok. Para YouTube, o bloco principal pede o OAuth Client ID e Client Secret; a Data API Key, se usada, fica numa área opcional separada. Client ID + Client Secret não formam uma Data API Key nem um token OAuth.
227
+ A página **Configurações Técnicas** contém apenas o TikTok Client ID e o TikTok Client Secret para TikTok.
228
+ Para YouTube, o bloco principal pede o OAuth Client ID e Client Secret; a Data API Key, se usada, fica numa área opcional separada. Client ID + Client Secret não formam uma Data API Key nem um token OAuth.
210
229
  Redirect URI, scopes, autorização OAuth e tokens são geridos no TikTok for Developers Playground. O adaptador rejeita o upload quando faltam credenciais ou OAuth, em vez de indicar sucesso falso.
211
230
 
212
231
  ## Segurança
@@ -0,0 +1,11 @@
1
+ # Third-party notices
2
+
3
+ ## Niche-Finder
4
+
5
+ Thunderbolt adapts the data-analysis ideas and parts of the clustering/tag-association logic from [johanfortus/Niche-Finder](https://github.com/johanfortus/Niche-Finder), Copyright (c) 2024 Johan Fortus and Vincent Milland. The original project is licensed under the MIT License. Thunderbolt does not include the original Flask server, HTML templates, JavaScript/D3 visualizations or route layer; the functionality was adapted to the existing Streamlit process.
6
+
7
+ > Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
8
+ >
9
+ > The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
10
+ >
11
+ > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/app/main.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import re
5
+ from datetime import date
5
6
  import sys
6
7
  from pathlib import Path
7
8
 
@@ -18,6 +19,8 @@ except (OSError, json.JSONDecodeError):
18
19
 
19
20
  from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel
20
21
  from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
22
+ from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
23
+ from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset, list_cached_datasets, load_dataframe, save_uploaded_csv
21
24
  from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
22
25
  from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
23
26
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
@@ -125,6 +128,13 @@ st.markdown("""
125
128
  [data-testid="stSidebar"] [data-testid="stBaseButton-primary"]:hover { background:#343434; color:#ffffff; }
126
129
  [data-testid="stSidebar"] [data-testid="stBaseButton-primary"] span { color:#ffffff; }
127
130
  [data-testid="stSidebar"] [data-testid="stBaseButton-secondary"] span { color:#e7edf2; }
131
+ [data-testid="stSidebar"] [data-testid="stExpander"] { border:0 !important; background:transparent !important; margin:0.02rem 0 !important; }
132
+ [data-testid="stSidebar"] [data-testid="stExpander"] details { border:0 !important; background:transparent !important; }
133
+ [data-testid="stSidebar"] [data-testid="stExpander"] summary { min-height:1.72rem; padding:0.10rem 0.52rem !important; border-radius:7px; color:#e7edf2; font-size:0.86rem; font-weight:650; }
134
+ [data-testid="stSidebar"] [data-testid="stExpander"] summary:hover { background:#1c252e; }
135
+ [data-testid="stSidebar"] [data-testid="stExpander"] summary p { margin:0; line-height:1; }
136
+ [data-testid="stSidebar"] [data-testid="stExpander"] > div { padding:0 0 0 0.42rem !important; }
137
+ [data-testid="stSidebar"] [data-testid="stExpander"] > div [data-testid="stButton"] button { padding-left:0.92rem; font-size:0.83rem; min-height:1.62rem; height:1.62rem; }
128
138
  .content-card { padding: 1rem 1.1rem; border:1px solid #20384d; border-radius:14px; background:rgba(18,27,38,.92); min-height:110px; }
129
139
  .content-label { color:#8ba6bb; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
130
140
  .content-value { color:#f4f8fb; font-size:1.8rem; font-weight:700; margin-top:.3rem; }
@@ -212,27 +222,18 @@ def render_dashboard():
212
222
  with col:
213
223
  card(label, value, note)
214
224
  st.divider()
215
- left, right = st.columns([1.5, 1])
216
- with left:
217
- st.subheader("Pipeline")
218
- tasks = read_json("tasks.json", [])
219
- counts = {stage: sum(1 for t in tasks if t.get("stage") == stage and t.get("state") not in {"done", "cancelled"}) for stage in STAGES}
220
- for stage in STAGES:
221
- st.markdown(f'<div class="stage"><strong>{stage.title()}</strong> <span class="small-muted">{counts[stage]} tarefa(s)</span></div>', unsafe_allow_html=True)
222
- with right:
223
- st.subheader("Acções rápidas")
224
- if st.button("Criar novo vídeo", use_container_width=True):
225
- st.session_state["page"] = "Novo vídeo"
226
- st.rerun()
227
- if st.button("Importar canal", use_container_width=True):
228
- st.session_state["page"] = "Canais"
229
- st.rerun()
230
- if st.button("Abrir Blueprints", use_container_width=True):
231
- st.session_state["page"] = "Blueprints"
232
- st.rerun()
233
- if st.button("Abrir Upload", use_container_width=True):
234
- st.session_state["page"] = "Upload"
235
- st.rerun()
225
+ st.subheader("Pipeline")
226
+ st.caption("Filas locais e dependências da cascata")
227
+ queues = read_json("queues.json", {})
228
+ blueprint_count = len(list_blueprint_files())
229
+ for row_start in range(0, len(STAGES), 3):
230
+ queue_cols = st.columns(3)
231
+ for col, stage in zip(queue_cols, STAGES[row_start:row_start + 3]):
232
+ with col:
233
+ if stage == "blueprint":
234
+ card("Blueprints", blueprint_count, f"na biblioteca · {len(queues.get(stage, []))} tarefa(s) na fila")
235
+ else:
236
+ card(stage.title(), len(queues.get(stage, [])), "fila")
236
237
 
237
238
 
238
239
  def render_blueprints():
@@ -537,8 +538,8 @@ def render_channels():
537
538
  st.rerun()
538
539
 
539
540
 
540
- def render_new_video():
541
- st.title("Novo vídeo")
541
+ def render_new_video(page_title: str = "Criação de Vídeos"):
542
+ st.title(page_title)
542
543
  create_tab, videos_tab = st.tabs(["Criar vídeo", "Vídeos"])
543
544
  with create_tab:
544
545
  channels = channel_options()
@@ -623,6 +624,171 @@ def render_new_video():
623
624
  render_videos()
624
625
 
625
626
 
627
+ def render_music_creation():
628
+ """Expose the complete video-creation UI under the music-oriented navigation entry without changing the original page."""
629
+ render_new_video(page_title="Criação de Músicas")
630
+
631
+
632
+ @st.cache_data(show_spinner=False)
633
+ def _cached_niche_dataset(path_str: str, modified_ns: int):
634
+ return load_dataframe(path_str)
635
+
636
+
637
+ @st.cache_data(show_spinner=False)
638
+ def _cached_niche_download():
639
+ return str(download_kaggle_dataset())
640
+
641
+
642
+ def _niche_tag_options(frame) -> list[str]:
643
+ tags: set[str] = set()
644
+ for value in frame.get("video_tags", []):
645
+ text = str(value or "").replace('"', "").replace("'", "")
646
+ for tag in re.split(r"[|,]", text.strip("[]")):
647
+ tag = re.sub(r"\s+", " ", tag).strip().lower()
648
+ if tag and tag not in {"nan", "none", "null"}:
649
+ tags.add(tag)
650
+ return sorted(tags)
651
+
652
+
653
+ def _niche_date_bounds(frame):
654
+ dates = frame["publish_date"].dropna().astype(str)
655
+ if dates.empty:
656
+ return None, None
657
+ return dates.min(), dates.max()
658
+
659
+
660
+ def render_niche_finder():
661
+ st.title("Niche Finder")
662
+ st.caption("Busca de nichos baseada no projecto open source Niche-Finder, integrada no processo Streamlit do Thunderbolt.")
663
+ st.info("A análise é executada localmente, sem Flask ou segundo servidor. O dataset padrão é guardado em `storage/data/niches/` e pode ser substituído por um CSV próprio.")
664
+
665
+ with st.sidebar:
666
+ st.subheader("Fonte de dados")
667
+ uploaded_file = st.file_uploader("Carregar CSV próprio", type=["csv"], key="niche_uploaded_csv")
668
+ if st.button("Baixar Dataset Kaggle", use_container_width=True, key="niche_download_dataset"):
669
+ try:
670
+ downloaded = _cached_niche_download()
671
+ st.session_state["niche_dataset_path"] = downloaded
672
+ st.success("Dataset Kaggle guardado no storage local.")
673
+ st.rerun()
674
+ except DatasetError as exc:
675
+ st.error(str(exc))
676
+ cached = list_cached_datasets()
677
+ if cached:
678
+ labels = {str(path): path.relative_to(path.parents[2]).as_posix() if len(path.parents) > 2 else path.name for path in cached}
679
+ current_path = st.session_state.get("niche_dataset_path", str(cached[0]))
680
+ if current_path not in labels:
681
+ current_path = str(cached[0])
682
+ selected_path = st.selectbox("Dataset local", list(labels), index=list(labels).index(current_path), format_func=lambda item: labels[item], key="niche_dataset_selector")
683
+ if selected_path != st.session_state.get("niche_dataset_path"):
684
+ st.session_state["niche_dataset_path"] = selected_path
685
+ st.session_state.pop("niche_results", None)
686
+ st.rerun()
687
+ if uploaded_file is not None and st.button("Usar CSV carregado", use_container_width=True, key="niche_use_uploaded"):
688
+ try:
689
+ uploaded_path = save_uploaded_csv(uploaded_file.getvalue(), uploaded_file.name)
690
+ st.session_state["niche_dataset_path"] = str(uploaded_path)
691
+ st.session_state.pop("niche_results", None)
692
+ st.success(f"CSV guardado em `{uploaded_path}`.")
693
+ st.rerun()
694
+ except DatasetError as exc:
695
+ st.error(str(exc))
696
+
697
+ dataset_path = st.session_state.get("niche_dataset_path")
698
+ frame = None
699
+ if dataset_path:
700
+ path = Path(dataset_path)
701
+ if path.exists():
702
+ try:
703
+ frame = _cached_niche_dataset(str(path), path.stat().st_mtime_ns)
704
+ except (DatasetError, OSError) as exc:
705
+ st.error(str(exc))
706
+ else:
707
+ st.warning("O dataset seleccionado já não existe no storage local. Baixe-o novamente ou carregue um CSV.")
708
+ if frame is None:
709
+ st.subheader("Começar uma análise")
710
+ st.write("Baixe o dataset público do Kaggle ou carregue um CSV com as colunas `title`, `publish_date`, `country`, `view_count`, `like_count` e `comment_count`.")
711
+ return
712
+
713
+ min_date, max_date = _niche_date_bounds(frame)
714
+ countries = ["Todos"] + sorted(str(value) for value in frame["country"].dropna().unique())
715
+ tag_options = _niche_tag_options(frame)
716
+ with st.sidebar:
717
+ st.subheader("Parâmetros")
718
+ n_clusters = st.slider("Número de Clusters", 2, 10, 5, key="niche_n_clusters")
719
+ min_support = st.slider("Suporte Mínimo", 0.01, 0.5, 0.05, 0.01, format="%.2f", key="niche_min_support")
720
+ country = st.selectbox("País", countries, key="niche_country")
721
+ engagement = st.selectbox("Engagement", ["Todos", "High", "Moderate", "Low"], key="niche_engagement")
722
+ start_date = st.date_input("Data inicial", value=date.fromisoformat(min_date), min_value=date.fromisoformat(min_date), max_value=date.fromisoformat(max_date), key="niche_start_date") if min_date else None
723
+ end_date = st.date_input("Data final", value=date.fromisoformat(max_date), min_value=date.fromisoformat(min_date), max_value=date.fromisoformat(max_date), key="niche_end_date") if max_date else None
724
+ selected_tags = st.multiselect("Tags opcionais", tag_options, key="niche_tags")
725
+ analyse = st.button("Analisar Nichos", type="primary", use_container_width=True, key="niche_analyse")
726
+
727
+ if analyse:
728
+ try:
729
+ if start_date and end_date and start_date > end_date:
730
+ raise NicheAnalysisError("A data inicial não pode ser posterior à data final.")
731
+ with st.spinner("A analisar clusters e associações…"):
732
+ results = run_niche_analysis(
733
+ str(dataset_path),
734
+ n_clusters=n_clusters,
735
+ min_support=min_support,
736
+ start_date=start_date.isoformat() if start_date else None,
737
+ end_date=end_date.isoformat() if end_date else None,
738
+ country=country,
739
+ engagement=engagement,
740
+ tags=selected_tags,
741
+ )
742
+ st.session_state["niche_results"] = results
743
+ except (NicheAnalysisError, DatasetError, OSError) as exc:
744
+ st.error(str(exc))
745
+
746
+ results = st.session_state.get("niche_results")
747
+ if not results:
748
+ st.subheader("Dataset carregado")
749
+ st.dataframe(frame.head(100), use_container_width=True, hide_index=True)
750
+ st.caption(f"{len(frame):,} registos válidos carregados. Ajuste os parâmetros na barra lateral e clique em Analisar Nichos.")
751
+ return
752
+
753
+ summary = results.get("summary", {})
754
+ metric_cols = st.columns(4)
755
+ for col, (label, value) in zip(metric_cols, [("Registos filtrados", summary.get("rows_filtered", 0)), ("Clusters", summary.get("cluster_count", 0)), ("Itemsets frequentes", summary.get("frequent_item_count", 0)), ("Regras", summary.get("association_rule_count", 0))]):
756
+ with col:
757
+ card(label, value)
758
+
759
+ cluster_table = results["clusters"]
760
+ rules_table = results["association_rules"]
761
+ items_table = results["frequent_items"]
762
+ points = results["cluster_points"].copy()
763
+ keyword = st.text_input("Filtrar palavras-chave nos clusters", key="niche_cluster_keyword", placeholder="Ex.: música, gaming, receitas")
764
+ if keyword.strip():
765
+ cluster_table = cluster_table[cluster_table["palavras"].str.contains(keyword.strip(), case=False, na=False)]
766
+ tab_clusters, tab_rules, tab_data = st.tabs(["Clusters encontrados", "Regras de associação", "Dados filtrados"])
767
+ with tab_clusters:
768
+ st.dataframe(cluster_table, use_container_width=True, hide_index=True)
769
+ if not points.empty:
770
+ try:
771
+ import plotly.express as px
772
+ points["cluster"] = points["cluster_id"].astype(str)
773
+ hover = [column for column in ["title", "channel_name", "country", "view_count", "engagement_rate"] if column in points.columns]
774
+ figure = px.scatter(points, x="x", y="y", color="cluster", hover_data=hover, title="Distribuição dos clusters")
775
+ figure.update_layout(legend_title_text="Cluster")
776
+ st.plotly_chart(figure, use_container_width=True)
777
+ except ImportError:
778
+ st.warning("A visualização Plotly não está instalada. Execute a instalação incremental do Thunderbolt.")
779
+ with tab_rules:
780
+ if rules_table.empty:
781
+ st.info("Não foram encontradas regras com o suporte e lift actuais. Reduza o suporte mínimo ou escolha outro filtro.")
782
+ else:
783
+ st.dataframe(rules_table, use_container_width=True, hide_index=True)
784
+ if not items_table.empty:
785
+ st.subheader("Itemsets frequentes")
786
+ st.dataframe(items_table, use_container_width=True, hide_index=True)
787
+ with tab_data:
788
+ st.dataframe(results["raw_data"], use_container_width=True, hide_index=True)
789
+
790
+
791
+
626
792
  def render_videos():
627
793
  st.subheader("Vídeos e backlog")
628
794
  st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
@@ -870,7 +1036,7 @@ def render_upload_conventional():
870
1036
 
871
1037
 
872
1038
  def render_settings():
873
- st.title("Configurações do Thunderbolt")
1039
+ st.title("Configurações Técnicas")
874
1040
  st.caption("Configuração do motor de vídeo e dos serviços usados pelo Thunderbolt. As credenciais ficam no storage local e não são enviadas para o GitHub.")
875
1041
  settings = read_json("settings.json", {})
876
1042
 
@@ -1216,7 +1382,7 @@ def render_mcp():
1216
1382
 
1217
1383
 
1218
1384
  def render_metadata_cleaner():
1219
- st.title("Limpador de metadado")
1385
+ st.title("Limpador de Metadados")
1220
1386
  st.caption("Limpeza e edição de metadados para vídeos de terceiros que já estão prontos.")
1221
1387
  st.warning("Esta área aceita exclusivamente vídeos externos. Vídeos criados na aba Novo vídeo não são listados nem processados aqui.")
1222
1388
 
@@ -1344,43 +1510,74 @@ def render_pipeline():
1344
1510
 
1345
1511
 
1346
1512
  def main():
1347
- pages = [
1348
- ("Dashboard", ":material/home:", "Início"),
1349
- ("Pipeline", ":material/account_tree:", "Pipeline"),
1350
- ("Blueprints", ":material/library_books:", "Blueprints"),
1351
- ("Canais", ":material/ondemand_video:", "Canais"),
1352
- ("Novo vídeo", ":material/add_circle:", "Novo vídeo"),
1353
- ("Automação", ":material/schedule:", "Automação"),
1513
+ pipeline_items = [
1514
+ ("Criação de Vídeos", ":material/add_circle:", "Criação de Vídeos"),
1515
+ ("Criação de Músicas", ":material/music_note:", "Criação de Músicas"),
1354
1516
  ("Upload", ":material/cloud_upload:", "Upload"),
1517
+ ("Limpador de Metadados", ":material/edit_note:", "Limpador de Metadados"),
1518
+ ]
1519
+ settings_items = [
1520
+ ("Canais", ":material/ondemand_video:", "Canais"),
1521
+ ("Blueprints", ":material/library_books:", "Blueprints"),
1355
1522
  ("MCP", ":material/hub:", "MCP"),
1356
- ("Limpador de metadado", ":material/edit_note:", "Limpador de metadado"),
1523
+ ("Configurações Técnicas", ":material/settings:", "Configurações Técnicas"),
1524
+ ]
1525
+ top_pages = [
1526
+ ("Início", ":material/home:", "Início"),
1527
+ ("Pipeline", ":material/account_tree:", "Pipeline"),
1528
+ ("Automação", ":material/schedule:", "Automação"),
1529
+ ("Niche Finder", ":material/search:", "Niche Finder"),
1357
1530
  ("Configurações", ":material/settings:", "Configurações"),
1358
1531
  ]
1359
- current_page = st.session_state.get("page", "Dashboard")
1360
- if current_page == "Vídeos":
1361
- current_page = "Novo vídeo"
1362
- st.session_state["page"] = current_page
1532
+ aliases = {
1533
+ "Dashboard": "Início",
1534
+ "Novo vídeo": "Criação de Vídeos",
1535
+ "Vídeos": "Criação de Vídeos",
1536
+ "Limpador de metadado": "Limpador de Metadados",
1537
+ "Configurações Técnicas": "Configurações Técnicas",
1538
+ }
1539
+ current_page = aliases.get(st.session_state.get("page", "Início"), st.session_state.get("page", "Início"))
1540
+ if current_page not in {item[0] for item in top_pages + pipeline_items + settings_items}:
1541
+ current_page = "Início"
1542
+ st.session_state["page"] = current_page
1543
+
1544
+ def navigate(target: str):
1545
+ st.session_state["page"] = target
1546
+ st.rerun()
1547
+
1548
+ def render_nav_button(target: str, icon: str, label: str, *, child: bool = False):
1549
+ if st.button(label, key=f"nav_{target}", icon=icon, use_container_width=True, type="primary" if current_page == target else "secondary"):
1550
+ navigate(target)
1551
+
1363
1552
  with st.sidebar:
1364
1553
  version_markup = f'<span class="tb-brand-version">{APP_VERSION}</span>' if APP_VERSION else ""
1365
1554
  st.markdown(f'<div class="tb-brand"><span class="tb-brand-name">Thunderbolt</span>{version_markup}</div>', unsafe_allow_html=True)
1366
- for target, icon, label in pages:
1367
- if st.button(label, key=f"nav_{target}", icon=icon, use_container_width=True, type="primary" if current_page == target else "secondary"):
1368
- st.session_state["page"] = target
1369
- st.rerun()
1555
+ for target, icon, label in top_pages:
1556
+ if target == "Pipeline":
1557
+ with st.expander("Pipeline", expanded=current_page in {item[0] for item in pipeline_items}, icon=":material/account_tree:"):
1558
+ for child_target, child_icon, child_label in pipeline_items:
1559
+ render_nav_button(child_target, child_icon, child_label, child=True)
1560
+ elif target == "Configurações":
1561
+ with st.expander("Configurações", expanded=current_page in {item[0] for item in settings_items}, icon=":material/settings:"):
1562
+ for child_target, child_icon, child_label in settings_items:
1563
+ render_nav_button(child_target, child_icon, child_label, child=True)
1564
+ else:
1565
+ render_nav_button(target, icon, label)
1566
+
1370
1567
  renderers = {
1371
- "Dashboard": render_dashboard,
1372
- "Pipeline": render_pipeline,
1373
- "Blueprints": render_blueprints,
1374
- "Canais": render_channels,
1375
- "Novo vídeo": render_new_video,
1568
+ "Início": render_dashboard,
1569
+ "Criação de Vídeos": render_new_video,
1570
+ "Criação de Músicas": render_music_creation,
1376
1571
  "Automação": render_automation,
1572
+ "Niche Finder": render_niche_finder,
1377
1573
  "Upload": render_upload,
1574
+ "Limpador de Metadados": render_metadata_cleaner,
1575
+ "Canais": render_channels,
1576
+ "Blueprints": render_blueprints,
1378
1577
  "MCP": render_mcp,
1379
- "Limpador de metadado": render_metadata_cleaner,
1380
- "Configurações": render_settings,
1578
+ "Configurações Técnicas": render_settings,
1381
1579
  }
1382
1580
  renderers.get(current_page, render_dashboard)()
1383
1581
 
1384
-
1385
1582
  if __name__ == "__main__":
1386
1583
  main()
@@ -0,0 +1 @@
1
+ """Reusable Streamlit modules bundled with Thunderbolt."""
@@ -0,0 +1,14 @@
1
+ """Streamlit-native Niche Finder integration for Thunderbolt."""
2
+
3
+ from .core import NicheAnalysisError, run_niche_analysis
4
+ from .data_loader import DATA_DIR, DatasetError, download_kaggle_dataset, list_cached_datasets, save_uploaded_csv
5
+
6
+ __all__ = [
7
+ "DATA_DIR",
8
+ "DatasetError",
9
+ "NicheAnalysisError",
10
+ "download_kaggle_dataset",
11
+ "list_cached_datasets",
12
+ "run_niche_analysis",
13
+ "save_uploaded_csv",
14
+ ]
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import json
5
+ import re
6
+ from collections import Counter
7
+ from typing import Any, Iterable
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from .data_loader import DatasetError, download_kaggle_dataset, load_dataframe
13
+ from .models import NicheAnalysisResult
14
+
15
+
16
+ EMPTY_CLUSTER_COLUMNS = [
17
+ "cluster_id",
18
+ "palavras",
19
+ "tamanho",
20
+ "media_visualizacoes",
21
+ "media_engagement",
22
+ ]
23
+ EMPTY_ITEM_COLUMNS = ["itemsets", "support", "item_count"]
24
+ EMPTY_RULE_COLUMNS = ["antecedents", "consequents", "support", "confidence", "lift"]
25
+
26
+
27
+ class NicheAnalysisError(ValueError):
28
+ """Raised when a valid dataset cannot produce a meaningful analysis."""
29
+
30
+
31
+ def _parse_tags(value: Any) -> list[str]:
32
+ if value is None or (isinstance(value, float) and np.isnan(value)):
33
+ return []
34
+ if isinstance(value, (list, tuple, set)):
35
+ raw_items = value
36
+ else:
37
+ text = str(value).strip()
38
+ if not text or text.lower() in {"nan", "none", "null", "[]"}:
39
+ return []
40
+ raw_items: Iterable[Any]
41
+ if text.startswith("[") and text.endswith("]"):
42
+ try:
43
+ parsed = json.loads(text)
44
+ except json.JSONDecodeError:
45
+ try:
46
+ parsed = ast.literal_eval(text)
47
+ except (ValueError, SyntaxError):
48
+ parsed = None
49
+ raw_items = parsed if isinstance(parsed, (list, tuple, set)) else re.split(r"[|,]", text.strip("[]"))
50
+ else:
51
+ raw_items = re.split(r"[|,]", text)
52
+ tags: list[str] = []
53
+ for item in raw_items:
54
+ tag = re.sub(r"\s+", " ", str(item).replace('"', "").replace("'", "")).strip().lower()
55
+ if tag and tag not in {"nan", "none", "null"}:
56
+ tags.append(tag)
57
+ return list(dict.fromkeys(tags))
58
+
59
+
60
+ def _engagement_label(value: str | int | None) -> str:
61
+ if value is None or str(value).strip().lower() in {"", "todos", "all"}:
62
+ return "Todos"
63
+ if isinstance(value, str) and value in {"High", "Moderate", "Low"}:
64
+ return value
65
+ try:
66
+ numeric = int(value)
67
+ except (TypeError, ValueError):
68
+ return "Todos"
69
+ if numeric >= 67:
70
+ return "High"
71
+ if numeric >= 33:
72
+ return "Moderate"
73
+ return "Low"
74
+
75
+
76
+ def _filter_frame(
77
+ frame: pd.DataFrame,
78
+ *,
79
+ start_date: str | None,
80
+ end_date: str | None,
81
+ country: str | None,
82
+ engagement: str | int | None,
83
+ tags: Iterable[str] | None,
84
+ ) -> pd.DataFrame:
85
+ filtered = frame.copy()
86
+ if start_date:
87
+ filtered = filtered[filtered["publish_date"] >= str(start_date)]
88
+ if end_date:
89
+ filtered = filtered[filtered["publish_date"] <= str(end_date)]
90
+ if country and str(country).strip().lower() not in {"", "todos", "all"}:
91
+ filtered = filtered[filtered["country"].str.casefold() == str(country).strip().casefold()]
92
+
93
+ with np.errstate(divide="ignore", invalid="ignore"):
94
+ filtered["engagement_rate"] = np.where(
95
+ filtered["view_count"] > 0,
96
+ ((filtered["like_count"] + filtered["comment_count"]) / filtered["view_count"]) * 100,
97
+ 0.0,
98
+ )
99
+ filtered["engagement_rate"] = filtered["engagement_rate"].replace([np.inf, -np.inf], np.nan).fillna(0.0)
100
+ label = _engagement_label(engagement)
101
+ if label == "High":
102
+ filtered = filtered[filtered["engagement_rate"] >= 7]
103
+ elif label == "Moderate":
104
+ filtered = filtered[(filtered["engagement_rate"] >= 3) & (filtered["engagement_rate"] < 7)]
105
+ elif label == "Low":
106
+ filtered = filtered[filtered["engagement_rate"] < 3]
107
+
108
+ selected_tags = {str(tag).strip().casefold() for tag in (tags or []) if str(tag).strip()}
109
+ if selected_tags:
110
+ filtered = filtered[filtered["video_tags"].map(lambda value: bool(selected_tags.intersection(_parse_tags(value))))]
111
+ return filtered.drop_duplicates(subset="title", keep="first").reset_index(drop=True)
112
+
113
+
114
+ def _empty_frame(columns: list[str]) -> pd.DataFrame:
115
+ return pd.DataFrame(columns=columns)
116
+
117
+
118
+ def _cluster_analysis(frame: pd.DataFrame, requested_clusters: int) -> tuple[pd.DataFrame, pd.DataFrame]:
119
+ if frame.empty:
120
+ return _empty_frame(EMPTY_CLUSTER_COLUMNS), pd.DataFrame()
121
+ from sklearn.cluster import KMeans
122
+ from sklearn.decomposition import PCA
123
+ from sklearn.preprocessing import StandardScaler
124
+
125
+ features = frame[["view_count", "like_count"]].clip(lower=0).astype(float)
126
+ transformed = np.log1p(features)
127
+ scaled = StandardScaler().fit_transform(transformed)
128
+ n_clusters = max(1, min(int(requested_clusters), len(frame)))
129
+ if n_clusters == 1:
130
+ labels = np.zeros(len(frame), dtype=int)
131
+ else:
132
+ labels = KMeans(n_clusters=n_clusters, random_state=42, n_init=10).fit_predict(scaled)
133
+ points = frame.copy()
134
+ points["cluster_id"] = labels.astype(int)
135
+ if len(points) >= 2:
136
+ coordinates = PCA(n_components=2, random_state=42).fit_transform(scaled)
137
+ else:
138
+ coordinates = np.column_stack([scaled[:, 0], np.zeros(len(points))])
139
+ points["x"] = coordinates[:, 0]
140
+ points["y"] = coordinates[:, 1]
141
+
142
+ summaries: list[dict[str, Any]] = []
143
+ for cluster_id, group in points.groupby("cluster_id", sort=True):
144
+ counter = Counter(tag for value in group["video_tags"] for tag in _parse_tags(value))
145
+ words = ", ".join(tag for tag, _ in counter.most_common(8))
146
+ summaries.append(
147
+ {
148
+ "cluster_id": int(cluster_id),
149
+ "palavras": words,
150
+ "tamanho": int(len(group)),
151
+ "media_visualizacoes": round(float(group["view_count"].mean()), 2),
152
+ "media_engagement": round(float(group["engagement_rate"].mean()), 2),
153
+ }
154
+ )
155
+ return pd.DataFrame(summaries, columns=EMPTY_CLUSTER_COLUMNS), points
156
+
157
+
158
+ def _frequent_item_analysis(frame: pd.DataFrame, min_support: float) -> tuple[pd.DataFrame, pd.DataFrame]:
159
+ if frame.empty:
160
+ return _empty_frame(EMPTY_ITEM_COLUMNS), _empty_frame(EMPTY_RULE_COLUMNS)
161
+ from mlxtend.frequent_patterns import association_rules, fpgrowth
162
+
163
+ tag_lists = frame["video_tags"].map(_parse_tags)
164
+ tag_counts = Counter(tag for tags in tag_lists for tag in tags)
165
+ if not tag_counts:
166
+ return _empty_frame(EMPTY_ITEM_COLUMNS), _empty_frame(EMPTY_RULE_COLUMNS)
167
+ frequent_tags = {tag for tag, count in tag_counts.items() if count > len(frame) * 0.5}
168
+ tag_lists = tag_lists.map(lambda tags: [tag for tag in tags if tag not in frequent_tags])
169
+ all_tags = sorted({tag for tags in tag_lists for tag in tags})
170
+ if not all_tags:
171
+ return _empty_frame(EMPTY_ITEM_COLUMNS), _empty_frame(EMPTY_RULE_COLUMNS)
172
+ matrix = pd.DataFrame(
173
+ [{tag: tag in tags for tag in all_tags} for tags in tag_lists],
174
+ columns=all_tags,
175
+ ).astype(bool)
176
+ itemsets = fpgrowth(matrix, min_support=float(min_support), use_colnames=True, max_len=3)
177
+ if itemsets.empty:
178
+ return _empty_frame(EMPTY_ITEM_COLUMNS), _empty_frame(EMPTY_RULE_COLUMNS)
179
+ itemsets = itemsets.copy()
180
+ itemsets["item_count"] = itemsets["itemsets"].map(len)
181
+ itemsets["itemsets"] = itemsets["itemsets"].map(lambda values: ", ".join(sorted(values)))
182
+ itemsets = itemsets[["itemsets", "support", "item_count"]].sort_values(["support", "item_count"], ascending=[False, True]).reset_index(drop=True)
183
+
184
+ raw_itemsets = fpgrowth(matrix, min_support=float(min_support), use_colnames=True, max_len=3)
185
+ try:
186
+ rules = association_rules(raw_itemsets, metric="confidence", min_threshold=0.5)
187
+ except TypeError: # mlxtend compatibility across versions
188
+ rules = association_rules(raw_itemsets, num_itemsets=len(matrix), metric="confidence", min_threshold=0.5)
189
+ if rules.empty:
190
+ return itemsets, _empty_frame(EMPTY_RULE_COLUMNS)
191
+ rules = rules[rules["lift"] > 1.0].copy()
192
+ if rules.empty:
193
+ return itemsets, _empty_frame(EMPTY_RULE_COLUMNS)
194
+ for column in ("antecedents", "consequents"):
195
+ rules[column] = rules[column].map(lambda values: ", ".join(sorted(values)))
196
+ wanted = [column for column in EMPTY_RULE_COLUMNS if column in rules.columns]
197
+ rules = rules[wanted].sort_values(["lift", "confidence"], ascending=False).reset_index(drop=True)
198
+ return itemsets, rules
199
+
200
+
201
+ def run_niche_analysis(
202
+ dataset_path: str | None = None,
203
+ n_clusters: int = 5,
204
+ min_support: float = 0.01,
205
+ *,
206
+ start_date: str | None = None,
207
+ end_date: str | None = None,
208
+ country: str | None = None,
209
+ engagement: str | int | None = None,
210
+ tags: Iterable[str] | None = None,
211
+ ) -> dict[str, Any]:
212
+ """Run the adapted Niche-Finder analysis synchronously in the caller process.
213
+
214
+ The first three parameters preserve the integration contract described in
215
+ ``exemplo.md``. Additional keyword-only filters retain the original project's
216
+ date, country, engagement and tag controls for the Thunderbolt UI.
217
+ """
218
+ if not 2 <= int(n_clusters) <= 10:
219
+ raise NicheAnalysisError("O número de clusters deve estar entre 2 e 10.")
220
+ if not 0.001 <= float(min_support) <= 0.5:
221
+ raise NicheAnalysisError("O suporte mínimo deve estar entre 0,001 e 0,5.")
222
+ path = download_kaggle_dataset() if dataset_path is None else dataset_path
223
+ try:
224
+ frame = load_dataframe(path)
225
+ except DatasetError as exc:
226
+ raise NicheAnalysisError(str(exc)) from exc
227
+ filtered = _filter_frame(
228
+ frame,
229
+ start_date=start_date,
230
+ end_date=end_date,
231
+ country=country,
232
+ engagement=engagement,
233
+ tags=tags,
234
+ )
235
+ if filtered.empty:
236
+ raise NicheAnalysisError("Nenhum vídeo corresponde aos filtros seleccionados.")
237
+ clusters, points = _cluster_analysis(filtered, int(n_clusters))
238
+ frequent_items, rules = _frequent_item_analysis(filtered, float(min_support))
239
+ result = NicheAnalysisResult(
240
+ clusters=clusters,
241
+ frequent_items=frequent_items,
242
+ association_rules=rules,
243
+ raw_data=filtered,
244
+ cluster_points=points,
245
+ summary={
246
+ "rows_input": int(len(frame)),
247
+ "rows_filtered": int(len(filtered)),
248
+ "cluster_count": int(len(clusters)),
249
+ "frequent_item_count": int(len(frequent_items)),
250
+ "association_rule_count": int(len(rules)),
251
+ "top_tags": [str(value) for value in frequent_items["itemsets"].head(10).tolist()] if not frequent_items.empty else [],
252
+ },
253
+ )
254
+ return result.as_dict()
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import re
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import BinaryIO
8
+
9
+ import pandas as pd
10
+
11
+ from hermes_ui.storage import STORAGE
12
+
13
+ try:
14
+ import kagglehub
15
+ except ImportError: # pragma: no cover - exercised in installations before the extra dependency is installed
16
+ kagglehub = None
17
+
18
+
19
+ DATA_DIR = STORAGE / "data" / "niches"
20
+ DEFAULT_DATASET_SLUG = "asaniczka/trending-youtube-videos-113-countries"
21
+ DEFAULT_DATASET_FILENAME = "trending_yt_videos_113_countries.csv"
22
+ DEFAULT_DATASET_PATH = DATA_DIR / DEFAULT_DATASET_FILENAME
23
+
24
+ REQUIRED_COLUMNS = (
25
+ "title",
26
+ "publish_date",
27
+ "country",
28
+ "view_count",
29
+ "like_count",
30
+ "comment_count",
31
+ )
32
+ OPTIONAL_COLUMNS = (
33
+ "video_tags",
34
+ "channel_name",
35
+ "thumbnail_url",
36
+ "video_id",
37
+ "channel_id",
38
+ )
39
+
40
+
41
+ class DatasetError(RuntimeError):
42
+ """Raised when a Niche Finder dataset cannot be read or validated."""
43
+
44
+
45
+ def ensure_data_dir() -> Path:
46
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
47
+ (DATA_DIR / "uploads").mkdir(parents=True, exist_ok=True)
48
+ return DATA_DIR
49
+
50
+
51
+ def _safe_filename(name: str) -> str:
52
+ stem = re.sub(r"[^A-Za-z0-9._-]+", "-", Path(name or "dataset.csv").name).strip(".-")
53
+ if not stem:
54
+ stem = "dataset.csv"
55
+ if not stem.lower().endswith(".csv"):
56
+ stem += ".csv"
57
+ return stem
58
+
59
+
60
+ def _find_csv(root: Path) -> Path | None:
61
+ candidates = sorted(root.rglob("*.csv"))
62
+ if not candidates:
63
+ return None
64
+ for candidate in candidates:
65
+ if candidate.name.lower() == DEFAULT_DATASET_FILENAME.lower():
66
+ return candidate
67
+ return candidates[0]
68
+
69
+
70
+ def _copy_csv(source: Path, destination: Path) -> Path:
71
+ destination.parent.mkdir(parents=True, exist_ok=True)
72
+ if source.resolve() != destination.resolve():
73
+ shutil.copy2(source, destination)
74
+ return destination
75
+
76
+
77
+ def _download_with_kagglehub() -> Path | None:
78
+ if kagglehub is None:
79
+ return None
80
+ data_dir = ensure_data_dir()
81
+ try:
82
+ downloaded = kagglehub.dataset_download(DEFAULT_DATASET_SLUG, output_dir=str(data_dir))
83
+ except TypeError:
84
+ downloaded = kagglehub.dataset_download(DEFAULT_DATASET_SLUG)
85
+ except Exception as exc: # pragma: no cover - depends on network/provider state
86
+ raise DatasetError(f"O KaggleHub não conseguiu descarregar o dataset: {exc}") from exc
87
+ source = _find_csv(Path(downloaded))
88
+ if source is None:
89
+ raise DatasetError("O download KaggleHub terminou sem encontrar um ficheiro CSV.")
90
+ return _copy_csv(source, DEFAULT_DATASET_PATH)
91
+
92
+
93
+ def download_kaggle_dataset() -> Path:
94
+ """Download and cache the public Niche-Finder dataset in Thunderbolt storage."""
95
+ ensure_data_dir()
96
+ if DEFAULT_DATASET_PATH.exists() and DEFAULT_DATASET_PATH.stat().st_size > 0:
97
+ return DEFAULT_DATASET_PATH
98
+ if kagglehub is None:
99
+ raise DatasetError("A dependência kagglehub não está instalada. Execute novamente a instalação do Thunderbolt.")
100
+ downloaded = _download_with_kagglehub()
101
+ if downloaded is None:
102
+ raise DatasetError("O KaggleHub não devolveu um caminho de dataset válido.")
103
+ return downloaded
104
+
105
+
106
+ def save_uploaded_csv(payload: bytes | BinaryIO, filename: str) -> Path:
107
+ """Validate and persist an uploaded CSV under the user's local data directory."""
108
+ ensure_data_dir()
109
+ raw = payload.read() if hasattr(payload, "read") else payload
110
+ if not isinstance(raw, (bytes, bytearray)) or not raw:
111
+ raise DatasetError("O ficheiro CSV enviado está vazio.")
112
+ try:
113
+ frame = pd.read_csv(io.BytesIO(raw))
114
+ validate_dataset(frame)
115
+ except (OSError, UnicodeDecodeError, pd.errors.ParserError, pd.errors.EmptyDataError) as exc:
116
+ raise DatasetError(f"Não foi possível ler o CSV enviado: {exc}") from exc
117
+ destination = DATA_DIR / "uploads" / _safe_filename(filename)
118
+ destination.write_bytes(raw)
119
+ return destination
120
+
121
+
122
+ def validate_dataset(frame: pd.DataFrame) -> pd.DataFrame:
123
+ """Validate the minimal schema and return a normalised copy."""
124
+ if frame is None or frame.empty:
125
+ raise DatasetError("O dataset não contém linhas.")
126
+ cleaned = frame.copy()
127
+ cleaned.columns = [str(column).strip() for column in cleaned.columns]
128
+ missing = [column for column in REQUIRED_COLUMNS if column not in cleaned.columns]
129
+ if missing:
130
+ raise DatasetError("O CSV precisa das colunas: " + ", ".join(missing) + ".")
131
+ if "video_tags" not in cleaned.columns:
132
+ cleaned["video_tags"] = ""
133
+ for column in ("view_count", "like_count", "comment_count"):
134
+ cleaned[column] = pd.to_numeric(cleaned[column], errors="coerce")
135
+ cleaned["publish_date"] = pd.to_datetime(cleaned["publish_date"], errors="coerce").dt.strftime("%Y-%m-%d")
136
+ cleaned["country"] = cleaned["country"].astype("string").str.strip()
137
+ cleaned["title"] = cleaned["title"].astype("string").fillna("").str.strip()
138
+ cleaned = cleaned.dropna(subset=["publish_date", "view_count", "like_count", "comment_count"])
139
+ cleaned = cleaned[cleaned["title"] != ""].copy()
140
+ if cleaned.empty:
141
+ raise DatasetError("O dataset não contém linhas válidas depois da normalização.")
142
+ return cleaned.reset_index(drop=True)
143
+
144
+
145
+ def load_dataframe(path: str | Path) -> pd.DataFrame:
146
+ try:
147
+ frame = pd.read_csv(path)
148
+ except (OSError, UnicodeDecodeError, pd.errors.ParserError, pd.errors.EmptyDataError) as exc:
149
+ raise DatasetError(f"Não foi possível ler o dataset: {exc}") from exc
150
+ return validate_dataset(frame)
151
+
152
+
153
+ def list_cached_datasets() -> list[Path]:
154
+ ensure_data_dir()
155
+ return sorted(DATA_DIR.glob("**/*.csv"))
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pandas as pd
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class DatasetInfo:
12
+ """Metadata about a cached or uploaded Niche Finder dataset."""
13
+
14
+ path: Path
15
+ source: str
16
+ rows: int
17
+ columns: tuple[str, ...]
18
+
19
+
20
+ @dataclass
21
+ class NicheAnalysisResult:
22
+ """Structured result returned by ``run_niche_analysis``.
23
+
24
+ The dictionaries are intentionally DataFrame-based so the Streamlit renderer
25
+ can display, filter and export each result without depending on Flask or HTML.
26
+ """
27
+
28
+ clusters: pd.DataFrame
29
+ frequent_items: pd.DataFrame
30
+ association_rules: pd.DataFrame
31
+ raw_data: pd.DataFrame
32
+ cluster_points: pd.DataFrame
33
+ summary: dict[str, Any]
34
+
35
+ def as_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "clusters": self.clusters,
38
+ "frequent_items": self.frequent_items,
39
+ "association_rules": self.association_rules,
40
+ "raw_data": self.raw_data,
41
+ "cluster_points": self.cluster_points,
42
+ "summary": self.summary,
43
+ }
@@ -12,6 +12,7 @@ ROOT = Path(__file__).resolve().parents[1]
12
12
  STORAGE = Path(os.getenv("THUNDERBOLT_STORAGE_DIR") or ROOT / "storage")
13
13
  STATE = STORAGE / "state"
14
14
  BLUEPRINTS = STORAGE / "blueprints"
15
+ NICHES_DATA = STORAGE / "data" / "niches"
15
16
  SEED_BLUEPRINTS = ROOT / "seed" / "blueprints"
16
17
 
17
18
  DEFAULTS: dict[str, Any] = {
@@ -218,7 +219,7 @@ def seed_blueprints() -> None:
218
219
 
219
220
 
220
221
  def ensure_storage() -> None:
221
- for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews"]:
222
+ for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", NICHES_DATA, NICHES_DATA / "uploads"]:
222
223
  path.mkdir(parents=True, exist_ok=True)
223
224
  seed_blueprints()
224
225
  for filename, default in DEFAULTS.items():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "app/main.py",
12
+ "app/modules/**/*.py",
12
13
  "hermes_ui/*.py",
13
14
  "integrations/*.py",
14
15
  "integrations/data/*.json",
@@ -18,6 +19,7 @@
18
19
  "seed/skills/*.md",
19
20
  "README.md",
20
21
  "MANUAL-INSTALACAO.md",
22
+ "THIRD-PARTY-NOTICES.md",
21
23
  "requirements.txt",
22
24
  "package.json"
23
25
  ],
package/requirements.txt CHANGED
@@ -7,3 +7,9 @@ google-auth>=2.29,<3
7
7
  google-auth-oauthlib>=1.2,<2
8
8
  google-api-python-client>=2.120,<3
9
9
  edge-tts>=7.0,<8
10
+ scikit-learn>=1.3,<2
11
+ mlxtend>=0.23,<1
12
+ plotly>=5.18,<7
13
+ seaborn>=0.13,<1
14
+ matplotlib>=3.7,<4
15
+ kagglehub>=0.3,<1
package/scripts/cli.mjs CHANGED
@@ -79,6 +79,9 @@ function ensureRuntimeStorage() {
79
79
  join(storageRoot, "skills"),
80
80
  join(storageRoot, "music"),
81
81
  join(storageRoot, "voice_previews"),
82
+ join(storageRoot, "data"),
83
+ join(storageRoot, "data", "niches"),
84
+ join(storageRoot, "data", "niches", "uploads"),
82
85
  ];
83
86
  for (const directory of directories) mkdirSync(directory, { recursive: true });
84
87
  const seedRoot = resolve(root, "seed", "blueprints");
@@ -98,7 +101,7 @@ function check() {
98
101
  console.error(`Python não encontrado. Execute: npx.cmd --yes @danhachuel/thunderbolt install`);
99
102
  process.exit(1);
100
103
  }
101
- const requiredModules = ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "edge_tts"];
104
+ const requiredModules = ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "edge_tts", "sklearn", "mlxtend", "plotly", "seaborn", "kagglehub"];
102
105
  const missing = requiredModules.filter((moduleName) => !moduleAvailable(moduleName));
103
106
  const ffmpeg = moduleAvailable("imageio_ffmpeg");
104
107
  const mptPath = configuredMoneyPrinterPath();
@@ -110,6 +110,9 @@ function ensureDirs() {
110
110
  join(storageRoot, "skills"),
111
111
  join(storageRoot, "music"),
112
112
  join(storageRoot, "voice_previews"),
113
+ join(storageRoot, "data"),
114
+ join(storageRoot, "data", "niches"),
115
+ join(storageRoot, "data", "niches", "uploads"),
113
116
  ];
114
117
  for (const directory of directories) mkdirSync(directory, { recursive: true });
115
118
  copySeedBlueprints(storageRoot);