@danhachuel/thunderbolt 0.2.28 → 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 +26 -15
- package/README.md +18 -5
- package/THIRD-PARTY-NOTICES.md +11 -0
- package/app/main.py +159 -1
- 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.
|
|
@@ -362,18 +362,29 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
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. **Criação de Vídeos
|
|
367
|
-
7. **
|
|
368
|
-
8. **
|
|
369
|
-
9. **
|
|
370
|
-
10. **
|
|
371
|
-
11. **
|
|
372
|
-
12. **
|
|
373
|
-
13. **
|
|
374
|
-
14. **
|
|
375
|
-
15. **Configurações > MCP >
|
|
376
|
-
16. **Configurações > MCP >
|
|
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.
|
|
377
388
|
|
|
378
389
|
## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
|
|
379
390
|
|
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
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
23
|
| Limpador de Metadados | Upload isolado de vídeos terceiros, limpeza FFmpeg, edição de título/descrição/tags e manifesto JSON |
|
|
@@ -25,9 +26,9 @@ A primeira versão implementa a camada UI independente com:
|
|
|
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
|
|
|
28
|
-
## Navegação da UI 0.2.
|
|
29
|
+
## Navegação da UI 0.2.29
|
|
29
30
|
|
|
30
|
-
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.
|
|
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.
|
|
31
32
|
|
|
32
33
|
## Instalação
|
|
33
34
|
|
|
@@ -108,9 +109,9 @@ thunderbolt
|
|
|
108
109
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
109
110
|
|
|
110
111
|
```powershell
|
|
111
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
112
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
113
|
-
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
|
|
114
115
|
```
|
|
115
116
|
|
|
116
117
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -128,6 +129,16 @@ Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
|
|
128
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.
|
|
129
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`.
|
|
130
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
|
+
|
|
131
142
|
## Armazenamento local
|
|
132
143
|
|
|
133
144
|
O sistema cria automaticamente:
|
|
@@ -146,6 +157,8 @@ storage/
|
|
|
146
157
|
│ └── settings.json
|
|
147
158
|
├── music/ # músicas locais para o modo Apenas Música
|
|
148
159
|
├── voice_previews/ # amostras isoladas do teste de vozes
|
|
160
|
+
├── data/
|
|
161
|
+
│ └── niches/ # dataset Kaggle cacheado e CSVs enviados ao Niche Finder
|
|
149
162
|
├── skills/ # skill MoneyPrinterTurbo guardada pelo utilizador
|
|
150
163
|
├── metadata_cleaner/
|
|
151
164
|
│ ├── originals/ # cópias dos vídeos terceiros enviados
|
|
@@ -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
|
|
@@ -626,9 +629,164 @@ def render_music_creation():
|
|
|
626
629
|
render_new_video(page_title="Criação de Músicas")
|
|
627
630
|
|
|
628
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
|
+
|
|
629
660
|
def render_niche_finder():
|
|
630
661
|
st.title("Niche Finder")
|
|
631
|
-
st.
|
|
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
|
+
|
|
632
790
|
|
|
633
791
|
|
|
634
792
|
def render_videos():
|
|
@@ -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);
|