@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.
- package/MANUAL-INSTALACAO.md +37 -26
- package/README.md +34 -15
- package/THIRD-PARTY-NOTICES.md +11 -0
- package/app/main.py +246 -49
- package/app/modules/__init__.py +1 -0
- package/app/modules/niche_finder/__init__.py +14 -0
- package/app/modules/niche_finder/core.py +254 -0
- package/app/modules/niche_finder/data_loader.py +155 -0
- package/app/modules/niche_finder/models.py +43 -0
- package/hermes_ui/storage.py +2 -1
- package/package.json +3 -1
- package/requirements.txt +6 -0
- package/scripts/cli.mjs +4 -1
- package/scripts/install.mjs +3 -0
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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,
|
|
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. **
|
|
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. **
|
|
366
|
-
6. **
|
|
367
|
-
7. **
|
|
368
|
-
8. **
|
|
369
|
-
9. **
|
|
370
|
-
10. **
|
|
371
|
-
11. **
|
|
372
|
-
12. **
|
|
373
|
-
13. **
|
|
374
|
-
14. **
|
|
375
|
-
15. **MCP
|
|
376
|
-
16. **MCP
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
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
|
|
409
|
+
## 12. Limpador de Metadados
|
|
399
410
|
|
|
400
|
-
A aba **Limpador de
|
|
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
|
-
|
|
|
14
|
-
| Pipeline |
|
|
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
|
-
|
|
|
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
|
|
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.
|
|
108
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
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
|
|
179
|
+
## Limpador de Metadados
|
|
163
180
|
|
|
164
|
-
A aba **Limpador de
|
|
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
|
-
##
|
|
201
|
+
## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
|
|
185
202
|
|
|
186
|
-
Em **
|
|
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.
|
|
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 **
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
1348
|
-
("
|
|
1349
|
-
("
|
|
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
|
-
("
|
|
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
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
|
1367
|
-
if
|
|
1368
|
-
st.
|
|
1369
|
-
|
|
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
|
-
"
|
|
1372
|
-
"
|
|
1373
|
-
"
|
|
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
|
-
"
|
|
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
|
+
}
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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.
|
|
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
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();
|
package/scripts/install.mjs
CHANGED
|
@@ -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);
|