@danhachuel/thunderbolt 0.2.28 → 0.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.2.28
5
+ > **Versão deste manual:** 0.2.30
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.28 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.30 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.28 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.30 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. **Pipeline > Criação de Vídeos:** teste primeiro o modo **Canal específico** e depois os modos de lote.
366
- 6. **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.
367
- 7. **Upload:** configure o OAuth Client ID e Secret, autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key, se configurada, é exclusivamente para consultas oficiais públicas e nunca substitui OAuth.
368
- 8. **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.
369
- 9. **Configurações > Configurações Técnicas:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
370
- 10. **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`.
371
- 11. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. A UI não inicia jobs em segundo plano nesta entrega.
372
- 12. **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.
373
- 13. **Upload directo:** configure manualmente cookies, sessionInfo e INNERTUBE_API_KEY, atribua o `DELEGATED_SESSION_ID` no canal e teste apenas com um vídeo de validação. O método envia chunks de 256 KiB e não extrai cookies do navegador.
374
- 14. **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.
375
- 15. **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.
376
- 16. **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.
365
+ 5. **Niche Finder:** abra a página na sidebar e aguarde a preparação automática. Confirme que não aparece upload de CSV, botão de download de dataset ou selector de ficheiros. A análise inicial deve carregar directamente métricas, clusters, gráfico Plotly, regras de associação e dados analisados. Altere país, engagement, datas e tags e confirme que **Analisar Nichos** recalcula os resultados.
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
+ Ao abrir a página, o Thunderbolt prepara automaticamente os dados públicos através do KaggleHub e inicia a análise. O utilizador não precisa de baixar datasets, carregar CSVs, preparar planilhas ou escolher ficheiros. A interface apresenta directamente os parâmetros da busca e os resultados.
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. A análise inicial é automática; o botão **Analisar Nichos** recalcula os resultados depois de alterar os filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
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.30 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 | Preparação e análise automáticas, 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.28
29
+ ## Navegação da UI 0.2.30
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. Niche Finder permanece como placeholder vazio nesta versão.
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.28 install
112
- npx.cmd --yes @danhachuel/thunderbolt@0.2.28 doctor
113
- npx.cmd --yes @danhachuel/thunderbolt@0.2.28
112
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.30 install
113
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.30 doctor
114
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.30
114
115
  ```
115
116
 
116
117
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -128,6 +129,14 @@ 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. Ao abrir a página, o próprio Thunderbolt prepara automaticamente os dados públicos necessários através do KaggleHub, guarda a cache localmente e executa a análise; o utilizador não precisa de baixar datasets, carregar CSVs ou preparar planilhas.
135
+
136
+ A interface apresenta directamente os parâmetros da busca: 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. A análise inicial é executada automaticamente e **Analisar Nichos** permite recalcular os resultados depois de alterar os filtros. O núcleo 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 analisados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters.
137
+
138
+ 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.30 install`; o instalador detecta e reutiliza componentes já válidos.
139
+
131
140
  ## Armazenamento local
132
141
 
133
142
  O sistema cria automaticamente:
@@ -146,6 +155,8 @@ storage/
146
155
  │ └── settings.json
147
156
  ├── music/ # músicas locais para o modo Apenas Música
148
157
  ├── voice_previews/ # amostras isoladas do teste de vozes
158
+ ├── data/
159
+ │ └── niches/ # dados automáticos cacheados para o Niche Finder
149
160
  ├── skills/ # skill MoneyPrinterTurbo guardada pelo utilizador
150
161
  ├── metadata_cleaner/
151
162
  │ ├── 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, load_dataframe
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,132 @@ 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 _load_automatic_niche_dataset():
643
+ dataset_path = _cached_niche_download()
644
+ path = Path(dataset_path)
645
+ return dataset_path, _cached_niche_dataset(dataset_path, path.stat().st_mtime_ns)
646
+
647
+
648
+ def _niche_tag_options(frame) -> list[str]:
649
+ tags: set[str] = set()
650
+ for value in frame.get("video_tags", []):
651
+ text = str(value or "").replace('"', "").replace("'", "")
652
+ for tag in re.split(r"[|,]", text.strip("[]")):
653
+ tag = re.sub(r"\s+", " ", tag).strip().lower()
654
+ if tag and tag not in {"nan", "none", "null"}:
655
+ tags.add(tag)
656
+ return sorted(tags)
657
+
658
+
659
+ def _niche_date_bounds(frame):
660
+ dates = frame["publish_date"].dropna().astype(str)
661
+ if dates.empty:
662
+ return None, None
663
+ return dates.min(), dates.max()
664
+
665
+
629
666
  def render_niche_finder():
630
667
  st.title("Niche Finder")
631
- st.info("Esta área está reservada para o Niche Finder e será implementada numa próxima etapa.")
668
+ st.caption("Busca automática de padrões, nichos e tags para orientar canais faceless.")
669
+ st.info("O Thunderbolt prepara e analisa automaticamente os dados necessários. Não é necessária configuração manual.")
670
+
671
+ try:
672
+ with st.spinner("A preparar automaticamente o Niche Finder…"):
673
+ dataset_path, frame = _load_automatic_niche_dataset()
674
+ except (DatasetError, OSError) as exc:
675
+ st.error("Não foi possível preparar automaticamente o Niche Finder neste momento. Verifique a ligação à Internet e tente novamente.")
676
+ with st.expander("Detalhes técnicos"):
677
+ st.caption(str(exc))
678
+ return
679
+
680
+ signature = f"{dataset_path}:{Path(dataset_path).stat().st_mtime_ns}"
681
+ if st.session_state.get("niche_results_signature") != signature:
682
+ st.session_state.pop("niche_results", None)
683
+ st.session_state["niche_results_signature"] = signature
684
+
685
+ min_date, max_date = _niche_date_bounds(frame)
686
+ countries = ["Todos"] + sorted(str(value) for value in frame["country"].dropna().unique())
687
+ tag_options = _niche_tag_options(frame)
688
+ with st.sidebar:
689
+ st.subheader("Parâmetros da busca")
690
+ n_clusters = st.slider("Número de Clusters", 2, 10, 5, key="niche_n_clusters")
691
+ min_support = st.slider("Suporte Mínimo", 0.01, 0.5, 0.05, 0.01, format="%.2f", key="niche_min_support")
692
+ country = st.selectbox("País", countries, key="niche_country")
693
+ engagement = st.selectbox("Engagement", ["Todos", "High", "Moderate", "Low"], key="niche_engagement")
694
+ 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
695
+ 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
696
+ selected_tags = st.multiselect("Tags opcionais", tag_options, key="niche_tags")
697
+ analyse = st.button("Analisar Nichos", type="primary", use_container_width=True, key="niche_analyse")
698
+
699
+ results = st.session_state.get("niche_results")
700
+ if results is None or analyse:
701
+ try:
702
+ if start_date and end_date and start_date > end_date:
703
+ raise NicheAnalysisError("A data inicial não pode ser posterior à data final.")
704
+ with st.spinner("A analisar nichos automaticamente…"):
705
+ results = run_niche_analysis(
706
+ str(dataset_path),
707
+ n_clusters=n_clusters,
708
+ min_support=min_support,
709
+ start_date=start_date.isoformat() if start_date else None,
710
+ end_date=end_date.isoformat() if end_date else None,
711
+ country=country,
712
+ engagement=engagement,
713
+ tags=selected_tags,
714
+ )
715
+ st.session_state["niche_results"] = results
716
+ except (NicheAnalysisError, DatasetError, OSError) as exc:
717
+ st.error("Não foi possível concluir a análise automática com os filtros actuais.")
718
+ with st.expander("Detalhes técnicos"):
719
+ st.caption(str(exc))
720
+ return
721
+
722
+ summary = results.get("summary", {})
723
+ metric_cols = st.columns(4)
724
+ for col, (label, value) in zip(metric_cols, [("Registos analisados", summary.get("rows_filtered", 0)), ("Clusters", summary.get("cluster_count", 0)), ("Itemsets frequentes", summary.get("frequent_item_count", 0)), ("Regras de associação", summary.get("association_rule_count", 0))]):
725
+ with col:
726
+ card(label, value)
727
+
728
+ cluster_table = results["clusters"]
729
+ rules_table = results["association_rules"]
730
+ items_table = results["frequent_items"]
731
+ points = results["cluster_points"].copy()
732
+ keyword = st.text_input("Filtrar palavras-chave nos clusters", key="niche_cluster_keyword", placeholder="Ex.: música, gaming, receitas")
733
+ if keyword.strip():
734
+ cluster_table = cluster_table[cluster_table["palavras"].str.contains(keyword.strip(), case=False, na=False)]
735
+ tab_clusters, tab_rules, tab_data = st.tabs(["Clusters encontrados", "Regras de associação", "Dados analisados"])
736
+ with tab_clusters:
737
+ st.dataframe(cluster_table, use_container_width=True, hide_index=True)
738
+ if not points.empty:
739
+ try:
740
+ import plotly.express as px
741
+ points["cluster"] = points["cluster_id"].astype(str)
742
+ hover = [column for column in ["title", "channel_name", "country", "view_count", "engagement_rate"] if column in points.columns]
743
+ figure = px.scatter(points, x="x", y="y", color="cluster", hover_data=hover, title="Distribuição dos clusters")
744
+ figure.update_layout(legend_title_text="Cluster")
745
+ st.plotly_chart(figure, use_container_width=True)
746
+ except ImportError:
747
+ st.error("A visualização da análise não está disponível nesta instalação.")
748
+ with tab_rules:
749
+ if rules_table.empty:
750
+ st.info("Não foram encontradas regras com os filtros actuais. Reduza o suporte mínimo ou escolha outro filtro.")
751
+ else:
752
+ st.dataframe(rules_table, use_container_width=True, hide_index=True)
753
+ if not items_table.empty:
754
+ st.subheader("Itemsets frequentes")
755
+ st.dataframe(items_table, use_container_width=True, hide_index=True)
756
+ with tab_data:
757
+ st.dataframe(results["raw_data"], use_container_width=True, hide_index=True)
632
758
 
633
759
 
634
760
  def render_videos():
@@ -0,0 +1 @@
1
+ """Reusable Streamlit modules bundled with Thunderbolt."""
@@ -0,0 +1,12 @@
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
5
+
6
+ __all__ = [
7
+ "DATA_DIR",
8
+ "DatasetError",
9
+ "NicheAnalysisError",
10
+ "download_kaggle_dataset",
11
+ "run_niche_analysis",
12
+ ]
@@ -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,121 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+
8
+ from hermes_ui.storage import STORAGE
9
+
10
+ try:
11
+ import kagglehub
12
+ except ImportError: # pragma: no cover - exercised in installations before the extra dependency is installed
13
+ kagglehub = None
14
+
15
+
16
+ DATA_DIR = STORAGE / "data" / "niches"
17
+ DEFAULT_DATASET_SLUG = "asaniczka/trending-youtube-videos-113-countries"
18
+ DEFAULT_DATASET_FILENAME = "trending_yt_videos_113_countries.csv"
19
+ DEFAULT_DATASET_PATH = DATA_DIR / DEFAULT_DATASET_FILENAME
20
+
21
+ REQUIRED_COLUMNS = (
22
+ "title",
23
+ "publish_date",
24
+ "country",
25
+ "view_count",
26
+ "like_count",
27
+ "comment_count",
28
+ )
29
+ OPTIONAL_COLUMNS = (
30
+ "video_tags",
31
+ "channel_name",
32
+ "thumbnail_url",
33
+ "video_id",
34
+ "channel_id",
35
+ )
36
+
37
+
38
+ class DatasetError(RuntimeError):
39
+ """Raised when the automatic Niche Finder dataset cannot be read or validated."""
40
+
41
+
42
+ def ensure_data_dir() -> Path:
43
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
44
+ return DATA_DIR
45
+
46
+
47
+ def _find_csv(root: Path) -> Path | None:
48
+ candidates = sorted(root.rglob("*.csv"))
49
+ if not candidates:
50
+ return None
51
+ for candidate in candidates:
52
+ if candidate.name.lower() == DEFAULT_DATASET_FILENAME.lower():
53
+ return candidate
54
+ return candidates[0]
55
+
56
+
57
+ def _copy_csv(source: Path, destination: Path) -> Path:
58
+ destination.parent.mkdir(parents=True, exist_ok=True)
59
+ if source.resolve() != destination.resolve():
60
+ shutil.copy2(source, destination)
61
+ return destination
62
+
63
+
64
+ def _download_with_kagglehub() -> Path | None:
65
+ if kagglehub is None:
66
+ return None
67
+ data_dir = ensure_data_dir()
68
+ try:
69
+ downloaded = kagglehub.dataset_download(DEFAULT_DATASET_SLUG, output_dir=str(data_dir))
70
+ except TypeError:
71
+ downloaded = kagglehub.dataset_download(DEFAULT_DATASET_SLUG)
72
+ except Exception as exc: # pragma: no cover - depends on network/provider state
73
+ raise DatasetError(f"O Thunderbolt não conseguiu preparar os dados automáticos: {exc}") from exc
74
+ source = _find_csv(Path(downloaded))
75
+ if source is None:
76
+ raise DatasetError("A preparação automática terminou sem encontrar dados compatíveis.")
77
+ return _copy_csv(source, DEFAULT_DATASET_PATH)
78
+
79
+
80
+ def download_kaggle_dataset() -> Path:
81
+ """Prepare and cache the public Niche-Finder dataset without user interaction."""
82
+ ensure_data_dir()
83
+ if DEFAULT_DATASET_PATH.exists() and DEFAULT_DATASET_PATH.stat().st_size > 0:
84
+ return DEFAULT_DATASET_PATH
85
+ if kagglehub is None:
86
+ raise DatasetError("O componente automático de dados não está disponível nesta instalação.")
87
+ downloaded = _download_with_kagglehub()
88
+ if downloaded is None:
89
+ raise DatasetError("O componente automático de dados não devolveu um caminho válido.")
90
+ return downloaded
91
+
92
+
93
+ def validate_dataset(frame: pd.DataFrame) -> pd.DataFrame:
94
+ """Validate the internal dataset schema and return a normalised copy."""
95
+ if frame is None or frame.empty:
96
+ raise DatasetError("A fonte automática de dados não contém registos.")
97
+ cleaned = frame.copy()
98
+ cleaned.columns = [str(column).strip() for column in cleaned.columns]
99
+ missing = [column for column in REQUIRED_COLUMNS if column not in cleaned.columns]
100
+ if missing:
101
+ raise DatasetError("A fonte automática não contém as colunas necessárias: " + ", ".join(missing) + ".")
102
+ if "video_tags" not in cleaned.columns:
103
+ cleaned["video_tags"] = ""
104
+ for column in ("view_count", "like_count", "comment_count"):
105
+ cleaned[column] = pd.to_numeric(cleaned[column], errors="coerce")
106
+ cleaned["publish_date"] = pd.to_datetime(cleaned["publish_date"], errors="coerce").dt.strftime("%Y-%m-%d")
107
+ cleaned["country"] = cleaned["country"].astype("string").str.strip()
108
+ cleaned["title"] = cleaned["title"].astype("string").fillna("").str.strip()
109
+ cleaned = cleaned.dropna(subset=["publish_date", "view_count", "like_count", "comment_count"])
110
+ cleaned = cleaned[cleaned["title"] != ""].copy()
111
+ if cleaned.empty:
112
+ raise DatasetError("A fonte automática não contém registos válidos depois da normalização.")
113
+ return cleaned.reset_index(drop=True)
114
+
115
+
116
+ def load_dataframe(path: str | Path) -> pd.DataFrame:
117
+ try:
118
+ frame = pd.read_csv(path)
119
+ except (OSError, UnicodeDecodeError, pd.errors.ParserError, pd.errors.EmptyDataError) as exc:
120
+ raise DatasetError(f"Não foi possível ler a fonte automática de dados: {exc}") from exc
121
+ return validate_dataset(frame)
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pandas as pd
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class DatasetInfo:
12
+ """Metadata about a cached or uploaded Niche Finder dataset."""
13
+
14
+ path: Path
15
+ source: str
16
+ rows: int
17
+ columns: tuple[str, ...]
18
+
19
+
20
+ @dataclass
21
+ class NicheAnalysisResult:
22
+ """Structured result returned by ``run_niche_analysis``.
23
+
24
+ The dictionaries are intentionally DataFrame-based so the Streamlit renderer
25
+ can display, filter and export each result without depending on Flask or HTML.
26
+ """
27
+
28
+ clusters: pd.DataFrame
29
+ frequent_items: pd.DataFrame
30
+ association_rules: pd.DataFrame
31
+ raw_data: pd.DataFrame
32
+ cluster_points: pd.DataFrame
33
+ summary: dict[str, Any]
34
+
35
+ def as_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "clusters": self.clusters,
38
+ "frequent_items": self.frequent_items,
39
+ "association_rules": self.association_rules,
40
+ "raw_data": self.raw_data,
41
+ "cluster_points": self.cluster_points,
42
+ "summary": self.summary,
43
+ }
@@ -12,6 +12,7 @@ ROOT = Path(__file__).resolve().parents[1]
12
12
  STORAGE = Path(os.getenv("THUNDERBOLT_STORAGE_DIR") or ROOT / "storage")
13
13
  STATE = STORAGE / "state"
14
14
  BLUEPRINTS = STORAGE / "blueprints"
15
+ NICHES_DATA = STORAGE / "data" / "niches"
15
16
  SEED_BLUEPRINTS = ROOT / "seed" / "blueprints"
16
17
 
17
18
  DEFAULTS: dict[str, Any] = {
@@ -218,7 +219,7 @@ def seed_blueprints() -> None:
218
219
 
219
220
 
220
221
  def ensure_storage() -> None:
221
- for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews"]:
222
+ for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", NICHES_DATA]:
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.28",
3
+ "version": "0.2.30",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "app/main.py",
12
+ "app/modules/**/*.py",
12
13
  "hermes_ui/*.py",
13
14
  "integrations/*.py",
14
15
  "integrations/data/*.json",
@@ -18,6 +19,7 @@
18
19
  "seed/skills/*.md",
19
20
  "README.md",
20
21
  "MANUAL-INSTALACAO.md",
22
+ "THIRD-PARTY-NOTICES.md",
21
23
  "requirements.txt",
22
24
  "package.json"
23
25
  ],
package/requirements.txt CHANGED
@@ -7,3 +7,9 @@ google-auth>=2.29,<3
7
7
  google-auth-oauthlib>=1.2,<2
8
8
  google-api-python-client>=2.120,<3
9
9
  edge-tts>=7.0,<8
10
+ scikit-learn>=1.3,<2
11
+ mlxtend>=0.23,<1
12
+ plotly>=5.18,<7
13
+ seaborn>=0.13,<1
14
+ matplotlib>=3.7,<4
15
+ kagglehub>=0.3,<1
package/scripts/cli.mjs CHANGED
@@ -79,6 +79,8 @@ 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"),
82
84
  ];
83
85
  for (const directory of directories) mkdirSync(directory, { recursive: true });
84
86
  const seedRoot = resolve(root, "seed", "blueprints");
@@ -98,7 +100,7 @@ function check() {
98
100
  console.error(`Python não encontrado. Execute: npx.cmd --yes @danhachuel/thunderbolt install`);
99
101
  process.exit(1);
100
102
  }
101
- const requiredModules = ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "edge_tts"];
103
+ const requiredModules = ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "edge_tts", "sklearn", "mlxtend", "plotly", "seaborn", "kagglehub"];
102
104
  const missing = requiredModules.filter((moduleName) => !moduleAvailable(moduleName));
103
105
  const ffmpeg = moduleAvailable("imageio_ffmpeg");
104
106
  const mptPath = configuredMoneyPrinterPath();
@@ -110,6 +110,8 @@ 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"),
113
115
  ];
114
116
  for (const directory of directories) mkdirSync(directory, { recursive: true });
115
117
  copySeedBlueprints(storageRoot);