@danhachuel/thunderbolt 0.2.34 → 0.2.36

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.34
5
+ > **Versão deste manual:** 0.2.36
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.34 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.36 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.34 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.36 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.
@@ -345,7 +345,7 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
345
345
 
346
346
  ## 8. Configuração inicial da UI
347
347
 
348
- Na primeira execução, a barra lateral apresenta **Início**, **Pipeline**, **Automação**, **Niche Finder** e **Configurações**. Abra **Configurações > Configurações Técnicas** e reveja. O menu **Pipeline** contém **Criação de Vídeos**, **Criação de Músicas**, **Upload** e **Limpador de Metadados**. O menu **Configurações** contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica numa área opcional, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
348
+ Na primeira execução, a barra lateral apresenta **Início**, **Pipeline**, **Automação**, **Niche Finder** e **Configurações**. O menu expansível **Niche Finder** contém **Niche Finder Kaggle** e **Niche Finder Apify**. A aba **Niche Finder Kaggle** mantém a análise do dataset Kaggle; **Niche Finder Apify** é uma segunda alternativa independente, com actor, dataset, parâmetros, credencial, execução e resultados próprios. Abra **Configurações > Configurações Técnicas** e reveja. O menu **Pipeline** contém **Criação de Vídeos**, **Criação de Músicas**, **Upload** e **Limpador de Metadados**. O menu **Configurações** contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica numa área opcional, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
349
349
 
350
350
  | Configuração | Finalidade |
351
351
  |---|---|
@@ -360,8 +360,11 @@ Na primeira execução, a barra lateral apresenta **Início**, **Pipeline**, **A
360
360
  | Kaggle Username | Nome da conta Kaggle usada para publicar e executar a kernel remota |
361
361
  | Kaggle API Key | Chave da conta Kaggle, guardada mascarada apenas em `storage/state/settings.json` |
362
362
  | Slug da kernel Kaggle | Identificador da kernel remota, por padrão `thunderbolt-niche-finder` |
363
+ | Apify API Token | Token pessoal da Apify, guardado mascarado no storage local e usado apenas pela alternativa Apify |
364
+ | Apify Actor ID | Actor usado pela alternativa Apify, por padrão `streamers~youtube-scraper` |
365
+ | Apify polling/timeout | Intervalo de consulta e limite máximo da execução do actor |
363
366
 
364
- As credenciais devem ser inseridas apenas na configuração local. A Kaggle API Key é usada somente pelo executor remoto para publicar a kernel, consultar o estado e obter os resultados pequenos; o dataset não é descarregado para `storage/data/niches`. A Data API Key, o OAuth Client ID e o OAuth Client Secret são valores diferentes; Client ID + Secret não geram uma API Key nem um token OAuth até a conta ser autorizada. Não coloque nenhum deles no GitHub, no `package.json`, em blueprints ou em ficheiros de estado versionados.
367
+ As credenciais devem ser inseridas apenas na configuração local. A Kaggle API Key pertence exclusivamente à alternativa Kaggle. O Apify API Token pertence exclusivamente à alternativa Apify e não é usado para Kaggle; os resultados e histórico Apify ficam separados em `niche_apify_runs.json`. A Data API Key, o OAuth Client ID e o OAuth Client Secret são valores diferentes; Client ID + Secret não geram uma API Key nem um token OAuth até a conta ser autorizada. Não coloque nenhum deles no GitHub, no `package.json`, em blueprints ou em ficheiros de estado versionados.
365
368
 
366
369
  ## 9. Testar as áreas principais
367
370
 
@@ -371,7 +374,7 @@ Após iniciar a aplicação, valide o seguinte percurso:
371
374
  2. **Blueprints:** coloque um JSON em `~/.thunderbolt/storage/blueprints/importados/` (Windows: `%LOCALAPPDATA%\\THUNDERBOLT\storage\blueprints\importados\`) ou use o carregador da interface.
372
375
  3. **Brandings:** abra a subaba **Brandings** e confirme a listagem dos ficheiros JSON.
373
376
  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.
374
- 5. **Niche Finder:** abra a página na sidebar, defina os parâmetros dentro do conteúdo principal e confirme que não há preparação automática. Antes do clique, não deve existir download de dataset nem análise. Clique em **Analisar Nichos** para iniciar a preparação dos dados e a análise; depois altere país, engagement, datas e tags e clique novamente para aplicar os novos parâmetros.
377
+ 5. **Niche Finder Kaggle:** abra o menu expansível **Niche Finder**, seleccione **Niche Finder Kaggle**, defina os parâmetros dentro do conteúdo principal e confirme que não há preparação automática. Antes do clique, não deve existir download de dataset nem análise. Clique em **Analisar Nichos** para iniciar a preparação dos dados e a análise; depois altere país, engagement, datas e tags e clique novamente para aplicar os novos parâmetros. Em seguida, abra a alternativa independente **Niche Finder Apify**, configure as palavras-chave e filtros na própria aba e confirme que nada é executado antes de clicar em **Pesquisar no Apify**.
375
378
  6. **Pipeline > Criação de Vídeos:** teste primeiro o modo **Canal específico** e depois os modos de lote.
376
379
  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.
377
380
  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.
@@ -385,15 +388,19 @@ Após iniciar a aplicação, valide o seguinte percurso:
385
388
  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.
386
389
  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.
387
390
 
388
- ## Niche Finder
391
+ ## Niche Finder Kaggle e Niche Finder Apify
389
392
 
390
- 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.
393
+ A página **Niche Finder Kaggle** 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.
391
394
 
392
395
  Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega o dataset e não inicia a análise. A instalação das dependências continua automática, mas a operação é manual: os parâmetros ficam dentro da própria aba e o utilizador deve clicar em **Analisar Nichos**. Só depois desse clique o KaggleHub prepara ou reutiliza a cache local; não há upload de CSV, botão de download manual ou selector de ficheiros.
393
396
 
394
397
  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, todos dentro da área principal da aba. 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. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos 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.
395
398
 
396
- 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.34 install`; o instalador detecta e reutiliza o que já estiver válido.
399
+ 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.36 install`; o instalador detecta e reutiliza o que já estiver válido.
400
+
401
+ ### Niche Finder Apify
402
+
403
+ A alternativa Apify não usa o dataset, parâmetros, execução ou estado da alternativa Kaggle. Configure o **Apify API Token**, o **Apify Actor ID**, o intervalo de consulta e o limite da execução em **Configurações Técnicas**. Na aba, informe até três palavras-chave, período, máximo de resultados, Shorts, duração, idioma das legendas, ordenação e filtros de legendas. O botão **Pesquisar no Apify** inicia manualmente o actor `streamers~youtube-scraper`, consulta o estado do run, carrega o dataset, normaliza os vídeos, limpa SRT, calcula VSC Ratio, tenta resumir transcrições através do LLM configurado e disponibiliza resultados JSON/CSV. O histórico resumido é guardado em `storage/state/niche_apify_runs.json`; não há gravação automática no Airtable do workflow anexado.
397
404
 
398
405
  ## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
399
406
 
package/README.md CHANGED
@@ -17,18 +17,18 @@ 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, horário diário HH:MM e worker local baseado no relógio do computador |
20
- | Niche Finder | Parâmetros dentro da própria aba, início manual após clique, K-Means, FP-Growth, regras de associação, clusters e gráfico Plotly nativo |
20
+ | Niche Finder | Menu expansível com duas alternativas independentes: Niche Finder Kaggle e Niche Finder Apify, com parâmetros, execução e resultados separados |
21
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 |
22
22
  | MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
23
23
  | Limpador de Metadados | Upload isolado de vídeos terceiros, limpeza FFmpeg, edição de título/descrição/tags e manifesto JSON |
24
- | Configurações Técnicas | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Kaggle Username/API Key, Upload directo, TikTok Client ID/Secret e Upload-Post |
24
+ | Configurações Técnicas | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
25
25
  | Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
26
26
 
27
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.
28
28
 
29
- ## Navegação da UI 0.2.34
29
+ ## Navegação da UI 0.2.36
30
30
 
31
- A barra lateral mantém apenas os cinco níveis principais, nesta ordem: **Início**, **Pipeline**, **Automação**, **Niche Finder** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Upload** e **Limpador de Metadados**. **Configurações** é expansível e contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
31
+ A barra lateral mantém os 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**. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
32
32
 
33
33
  ## Instalação
34
34
 
@@ -115,9 +115,9 @@ thunderbolt
115
115
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
116
116
 
117
117
  ```powershell
118
- npx.cmd --yes @danhachuel/thunderbolt@0.2.34 install
119
- npx.cmd --yes @danhachuel/thunderbolt@0.2.34 doctor
120
- npx.cmd --yes @danhachuel/thunderbolt@0.2.34
118
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.36 install
119
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.36 doctor
120
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.36
121
121
  ```
122
122
 
123
123
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -137,11 +137,11 @@ Após `install`, o launcher usa o ambiente virtual instalado em `~/.thunderbolt/
137
137
 
138
138
  ## Niche Finder
139
139
 
140
- 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 Thunderbolt não descarrega dados, não prepara o dataset e não executa análises. A instalação das dependências continua automática, mas a operação é manual: o utilizador define os parâmetros dentro da própria aba e clica em **Analisar Nichos**. Só nesse momento o KaggleHub prepara ou reutiliza a cache local do dataset e a análise é executada.
140
+ O menu expansível **Niche Finder** contém duas alternativas independentes. **Niche Finder Kaggle** é a página actual de análise e 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 Thunderbolt não descarrega dados, não prepara o dataset e não executa análises. A instalação das dependências continua automática, mas a operação é manual: o utilizador define os parâmetros dentro da própria aba e clica em **Analisar Nichos**. Só nesse momento o KaggleHub prepara ou reutiliza a cache local do dataset e a análise é executada.
141
141
 
142
142
  A interface apresenta dentro da aba 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 página permanece sem resultados até o primeiro clique em **Analisar Nichos**; depois, se os parâmetros forem alterados, mostra os resultados anteriores e pede novo clique para aplicar os filtros actuais. 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.
143
143
 
144
- 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.34 install`; o instalador detecta e reutiliza componentes já válidos.
144
+ **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.36 install`; o instalador detecta e reutiliza componentes já válidos.
145
145
 
146
146
  ## Armazenamento local
147
147
 
package/app/main.py CHANGED
@@ -20,8 +20,10 @@ except (OSError, json.JSONDecodeError):
20
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
21
21
  from hermes_ui.automation_worker import load_worker_status
22
22
  from hermes_ui.storage import BLUEPRINTS, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
23
+ from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
23
24
  from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
24
25
  from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
26
+ from app.modules.niche_finder.summarizer import summarize_items
25
27
  from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
26
28
  from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
27
29
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
@@ -637,7 +639,7 @@ def _cached_niche_download():
637
639
 
638
640
 
639
641
  def render_niche_finder():
640
- st.title("Niche Finder")
642
+ st.title("Niche Finder Kaggle")
641
643
  st.caption("Busca de padrões, nichos e tags para orientar canais faceless.")
642
644
  st.info("A instalação das dependências é automática. A operação não é: defina os parâmetros abaixo e clique em **Analisar Nichos** para iniciar.")
643
645
 
@@ -743,6 +745,143 @@ def render_niche_finder():
743
745
  st.dataframe(results["raw_data"], use_container_width=True, hide_index=True)
744
746
 
745
747
 
748
+ def render_niche_finder_apify():
749
+ st.title("Niche Finder Apify")
750
+ st.caption("Alternativa independente ao Niche Finder Kaggle: pesquisa vídeos outlier no YouTube através do actor Apify e organiza dados, legendas e estrutura dos vídeos.")
751
+ st.info("Esta página não usa o dataset, os filtros, a execução ou os resultados do Kaggle. A instalação das dependências é automática, mas a operação é manual: defina os parâmetros nesta aba e clique em **Pesquisar no Apify** para iniciar.")
752
+ settings = read_json("settings.json", {})
753
+
754
+ active_run = st.session_state.get("niche_apify_active_run")
755
+ if active_run:
756
+ st.warning(f"Existe uma execução Apify pendente: `{active_run.get('run_id', 'sem ID')}`.")
757
+ if st.button("Cancelar execução actual", key="niche_apify_cancel"):
758
+ try:
759
+ abort_actor_run(settings.get("apify_api_token", ""), active_run.get("run_id", ""))
760
+ st.session_state.pop("niche_apify_active_run", None)
761
+ st.success("Execução Apify cancelada.")
762
+ st.rerun()
763
+ except ApifyError as exc:
764
+ st.error(str(exc))
765
+
766
+ with st.container(border=True):
767
+ st.subheader("Parâmetros da pesquisa")
768
+ with st.form("niche_apify_parameters", clear_on_submit=False):
769
+ keyword_cols = st.columns(3)
770
+ with keyword_cols[0]:
771
+ keyword1 = st.text_input("Palavra-chave 1", key="niche_apify_keyword1", placeholder="Ex.: healthy food")
772
+ with keyword_cols[1]:
773
+ keyword2 = st.text_input("Palavra-chave 2", key="niche_apify_keyword2", placeholder="Ex.: meal prep healthy")
774
+ with keyword_cols[2]:
775
+ keyword3 = st.text_input("Palavra-chave 3", key="niche_apify_keyword3", placeholder="Ex.: high protein snack")
776
+ search_cols = st.columns(4)
777
+ with search_cols[0]:
778
+ date_filter = st.selectbox("Período", ["week", "month", "3months", "year", "all"], index=0, key="niche_apify_date_filter")
779
+ with search_cols[1]:
780
+ max_results = st.number_input("Máximo por pesquisa", min_value=1, max_value=100, value=3, step=1, key="niche_apify_max_results")
781
+ with search_cols[2]:
782
+ max_results_shorts = st.number_input("Máximo de Shorts", min_value=0, max_value=100, value=0, step=1, key="niche_apify_max_results_shorts")
783
+ with search_cols[3]:
784
+ length_filter = st.selectbox("Duração", ["between420", "any", "short", "long"], index=0, key="niche_apify_length_filter")
785
+ filter_cols = st.columns(4)
786
+ with filter_cols[0]:
787
+ subtitles_language = st.selectbox("Idioma das legendas", ["en", "pt", "es", "fr", "de"], index=0, key="niche_apify_subtitles_language")
788
+ with filter_cols[1]:
789
+ sorting_order = st.selectbox("Ordenação", ["relevance", "date", "viewCount", "rating"], index=0, key="niche_apify_sorting_order")
790
+ with filter_cols[2]:
791
+ download_subtitles = st.checkbox("Descarregar legendas", value=True, key="niche_apify_download_subtitles")
792
+ with filter_cols[3]:
793
+ has_cc = st.checkbox("Apenas vídeos com CC", value=False, key="niche_apify_has_cc")
794
+ analyse_apify = st.form_submit_button("Pesquisar no Apify", type="primary", use_container_width=True)
795
+
796
+ if analyse_apify:
797
+ try:
798
+ actor_input = build_actor_input(
799
+ [keyword1, keyword2, keyword3],
800
+ date_filter=date_filter,
801
+ max_results=int(max_results),
802
+ max_results_shorts=int(max_results_shorts),
803
+ length_filter=length_filter,
804
+ subtitles_language=subtitles_language,
805
+ download_subtitles=download_subtitles,
806
+ sorting_order=sorting_order,
807
+ has_cc=has_cc,
808
+ )
809
+ token = str(settings.get("apify_api_token", "") or "").strip()
810
+ actor_id = str(settings.get("apify_actor_id", DEFAULT_ACTOR_ID) or DEFAULT_ACTOR_ID).strip()
811
+ st.session_state["niche_apify_active_run"] = {"run_id": "a iniciar", "started_at": now()}
812
+ progress = st.progress(0, text="A iniciar o actor Apify…")
813
+ run = start_actor_run(token, actor_id, actor_input)
814
+ st.session_state["niche_apify_active_run"] = {"run_id": run.run_id, "started_at": now(), "status": run.status}
815
+ progress.progress(15, text=f"Actor iniciado: {run.run_id}. A aguardar dataset…")
816
+
817
+ def update_progress(current_run):
818
+ st.session_state["niche_apify_active_run"] = {"run_id": current_run.run_id, "started_at": st.session_state["niche_apify_active_run"].get("started_at", now()), "status": current_run.status}
819
+ progress.progress(35 if current_run.status not in {"SUCCEEDED"} else 55, text=f"Estado Apify: {current_run.status}")
820
+
821
+ finished = wait_for_actor_run(
822
+ token,
823
+ run,
824
+ poll_interval=int(settings.get("apify_poll_interval_seconds", 10)),
825
+ timeout_seconds=int(settings.get("apify_run_timeout_seconds", 900)),
826
+ on_status=update_progress,
827
+ )
828
+ progress.progress(60, text="Dataset recebido. A carregar vídeos…")
829
+ raw_items = get_dataset_items(token, finished.dataset_id, limit=int(max_results) * max(1, len([value for value in [keyword1, keyword2, keyword3] if value.strip()])))
830
+ items = normalize_video_items(raw_items)
831
+ progress.progress(70, text=f"{len(items)} vídeo(s) carregado(s). A preparar transcrições…")
832
+ items = summarize_items(items, settings, on_item=lambda current, total, item: progress.progress(70 + int((current / max(total, 1)) * 25), text=f"A resumir vídeo {current}/{total}…"))
833
+ st.session_state["niche_apify_results"] = items
834
+ st.session_state["niche_apify_last_run"] = {"run_id": finished.run_id, "status": finished.status, "dataset_id": finished.dataset_id, "created_at": now(), "item_count": len(items), "parameters": actor_input}
835
+ history = read_json("niche_apify_runs.json", [])
836
+ if not isinstance(history, list):
837
+ history = []
838
+ history.insert(0, st.session_state["niche_apify_last_run"])
839
+ write_json("niche_apify_runs.json", history[:20])
840
+ st.session_state.pop("niche_apify_active_run", None)
841
+ progress.progress(100, text="Pesquisa Apify concluída.")
842
+ st.success(f"Pesquisa concluída: {len(items)} vídeo(s) recebido(s).")
843
+ except ApifyError as exc:
844
+ st.error(str(exc))
845
+ except Exception as exc:
846
+ st.error(f"A pesquisa Apify terminou com um erro inesperado: {exc}")
847
+
848
+ results = st.session_state.get("niche_apify_results", [])
849
+ if not results:
850
+ st.caption("Ainda não existe uma pesquisa nesta sessão. Ajuste os parâmetros e clique em **Pesquisar no Apify**.")
851
+ return
852
+
853
+ st.subheader("Vídeos encontrados")
854
+ result_filter = st.text_input("Filtrar resultados", placeholder="Título, canal ou palavra no resumo", key="niche_apify_result_filter")
855
+ status_filter = st.selectbox("Estado da sumarização", ["Todos", "concluído", "sem transcrição", "não aplicável", "erro"], key="niche_apify_status_filter")
856
+ visible_results = results
857
+ if result_filter.strip():
858
+ term = result_filter.strip().casefold()
859
+ visible_results = [item for item in visible_results if term in str(item.get("title", "")).casefold() or term in str(item.get("channel_name", "")).casefold() or term in str(item.get("summary", "")).casefold()]
860
+ if status_filter != "Todos":
861
+ visible_results = [item for item in visible_results if str(item.get("summary_status", "")).startswith(status_filter)]
862
+ try:
863
+ import pandas as pd
864
+ result_frame = pd.DataFrame(visible_results)
865
+ display_columns = ["title", "channel_name", "duration", "view_count", "subscriber_count", "comments_count", "vsc_ratio", "url", "transcript_status", "summary_status", "summary"]
866
+ display_columns = [column for column in display_columns if column in result_frame.columns]
867
+ st.dataframe(result_frame[display_columns], use_container_width=True, hide_index=True)
868
+ export_frame = result_frame.drop(columns=["transcript"], errors="ignore")
869
+ export_json = export_frame.to_json(orient="records", force_ascii=False, indent=2)
870
+ export_csv = export_frame.to_csv(index=False).encode("utf-8")
871
+ export_cols = st.columns(2)
872
+ with export_cols[0]:
873
+ st.download_button("Exportar JSON", data=export_json, file_name="niche-finder-apify-results.json", mime="application/json", use_container_width=True, key="niche_apify_export_json")
874
+ with export_cols[1]:
875
+ st.download_button("Exportar CSV", data=export_csv, file_name="niche-finder-apify-results.csv", mime="text/csv", use_container_width=True, key="niche_apify_export_csv")
876
+ except ImportError:
877
+ st.dataframe(visible_results, use_container_width=True, hide_index=True)
878
+
879
+ last_run = st.session_state.get("niche_apify_last_run", {})
880
+ if last_run:
881
+ with st.expander("Detalhes da última execução"):
882
+ st.json({key: value for key, value in last_run.items() if key != "parameters"})
883
+
884
+
746
885
  def render_videos():
747
886
  st.subheader("Vídeos e backlog")
748
887
  st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
@@ -1035,6 +1174,18 @@ def render_settings():
1035
1174
  with kaggle_cols[2]:
1036
1175
  kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
1037
1176
 
1177
+ with st.expander("Niche Finder — execução através da Apify", expanded=True):
1178
+ st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
1179
+ apify_cols = st.columns(4)
1180
+ with apify_cols[0]:
1181
+ apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
1182
+ with apify_cols[1]:
1183
+ apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
1184
+ with apify_cols[2]:
1185
+ apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
1186
+ with apify_cols[3]:
1187
+ apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
1188
+
1038
1189
  with st.expander("Consulta oficial de métricas — opcional"):
1039
1190
  st.caption("A YouTube Data API Key é uma credencial Google Cloud separada do OAuth. Só é necessária se escolher o método YouTube Data API para consultar métricas oficiais. Não é necessária para Página pública — sem API Key, para autorizar OAuth ou para fazer upload.")
1040
1191
  youtube_api_key = text_setting("YouTube Data API Key (opcional)", "youtube_api_key", secret=True, help_text="Credencial separada, criada em Google Cloud > APIs e serviços > Credenciais > Chave de API. Não cole aqui o Client ID nem o Client Secret.")
@@ -1156,6 +1307,7 @@ def render_settings():
1156
1307
  "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
1157
1308
  "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
1158
1309
  "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
1310
+ "apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
1159
1311
  "direct_cookie_sid": direct_cookie_sid, "direct_cookie_ssid": direct_cookie_ssid, "direct_cookie_hsid": direct_cookie_hsid, "direct_cookie_apisid": direct_cookie_apisid, "direct_cookie_sapisid": direct_cookie_sapisid, "direct_session_info": direct_session_info, "direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
1160
1312
  "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
1161
1313
  "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
@@ -1498,6 +1650,10 @@ def main():
1498
1650
  ("MCP", ":material/hub:", "MCP"),
1499
1651
  ("Configurações Técnicas", ":material/settings:", "Configurações Técnicas"),
1500
1652
  ]
1653
+ niche_finder_items = [
1654
+ ("Niche Finder Kaggle", ":material/search:", "Niche Finder Kaggle"),
1655
+ ("Niche Finder Apify", ":material/api:", "Niche Finder Apify"),
1656
+ ]
1501
1657
  top_pages = [
1502
1658
  ("Início", ":material/home:", "Início"),
1503
1659
  ("Pipeline", ":material/account_tree:", "Pipeline"),
@@ -1510,10 +1666,11 @@ def main():
1510
1666
  "Novo vídeo": "Criação de Vídeos",
1511
1667
  "Vídeos": "Criação de Vídeos",
1512
1668
  "Limpador de metadado": "Limpador de Metadados",
1669
+ "Niche Finder": "Niche Finder Kaggle",
1513
1670
  "Configurações Técnicas": "Configurações Técnicas",
1514
1671
  }
1515
1672
  current_page = aliases.get(st.session_state.get("page", "Início"), st.session_state.get("page", "Início"))
1516
- if current_page not in {item[0] for item in top_pages + pipeline_items + settings_items}:
1673
+ if current_page not in {item[0] for item in top_pages + pipeline_items + niche_finder_items + settings_items}:
1517
1674
  current_page = "Início"
1518
1675
  st.session_state["page"] = current_page
1519
1676
 
@@ -1533,6 +1690,10 @@ def main():
1533
1690
  with st.expander("Pipeline", expanded=current_page in {item[0] for item in pipeline_items}, icon=":material/account_tree:"):
1534
1691
  for child_target, child_icon, child_label in pipeline_items:
1535
1692
  render_nav_button(child_target, child_icon, child_label, child=True)
1693
+ elif target == "Niche Finder":
1694
+ with st.expander("Niche Finder", expanded=current_page in {item[0] for item in niche_finder_items}, icon=":material/search:"):
1695
+ for child_target, child_icon, child_label in niche_finder_items:
1696
+ render_nav_button(child_target, child_icon, child_label, child=True)
1536
1697
  elif target == "Configurações":
1537
1698
  with st.expander("Configurações", expanded=current_page in {item[0] for item in settings_items}, icon=":material/settings:"):
1538
1699
  for child_target, child_icon, child_label in settings_items:
@@ -1545,7 +1706,8 @@ def main():
1545
1706
  "Criação de Vídeos": render_new_video,
1546
1707
  "Criação de Músicas": render_music_creation,
1547
1708
  "Automação": render_automation,
1548
- "Niche Finder": render_niche_finder,
1709
+ "Niche Finder Kaggle": render_niche_finder,
1710
+ "Niche Finder Apify": render_niche_finder_apify,
1549
1711
  "Upload": render_upload,
1550
1712
  "Limpador de Metadados": render_metadata_cleaner,
1551
1713
  "Canais": render_channels,
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable, Iterable
6
+ from urllib.parse import quote
7
+
8
+ import requests
9
+
10
+ APIFY_API_BASE = "https://api.apify.com/v2"
11
+ DEFAULT_ACTOR_ID = "streamers~youtube-scraper"
12
+ TERMINAL_STATUSES = {"SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"}
13
+
14
+
15
+ class ApifyError(RuntimeError):
16
+ """Raised when an Apify run cannot be started or collected."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ApifyRun:
21
+ run_id: str
22
+ status: str
23
+ dataset_id: str = ""
24
+ actor_id: str = DEFAULT_ACTOR_ID
25
+
26
+
27
+ def _json_response(response: requests.Response, action: str) -> Any:
28
+ if response.status_code >= 400:
29
+ detail = response.text[:400].strip()
30
+ raise ApifyError(f"A Apify devolveu HTTP {response.status_code} ao {action}: {detail}")
31
+ try:
32
+ return response.json()
33
+ except ValueError as exc:
34
+ raise ApifyError(f"A Apify devolveu uma resposta inválida ao {action}.") from exc
35
+
36
+
37
+ def _headers(token: str) -> dict[str, str]:
38
+ token = str(token or "").strip()
39
+ if not token:
40
+ raise ApifyError("Configure o Apify API Token em Configurações Técnicas antes de iniciar.")
41
+ return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
42
+
43
+
44
+ def build_actor_input(
45
+ keywords: Iterable[str],
46
+ *,
47
+ date_filter: str = "week",
48
+ max_results: int = 3,
49
+ max_results_shorts: int = 0,
50
+ length_filter: str = "between420",
51
+ subtitles_language: str = "en",
52
+ download_subtitles: bool = True,
53
+ sorting_order: str = "relevance",
54
+ has_cc: bool = False,
55
+ ) -> dict[str, Any]:
56
+ values = [str(keyword or "").strip() for keyword in keywords]
57
+ values = [keyword for keyword in values if keyword]
58
+ if not values:
59
+ raise ApifyError("Introduza pelo menos uma palavra-chave para pesquisar no Apify.")
60
+ return {
61
+ "dateFilter": str(date_filter or "week"),
62
+ "downloadSubtitles": bool(download_subtitles),
63
+ "hasCC": bool(has_cc),
64
+ "hasLocation": False,
65
+ "hasSubtitles": False,
66
+ "is360": False,
67
+ "is3D": False,
68
+ "is4K": False,
69
+ "isBought": False,
70
+ "isHD": False,
71
+ "isHDR": False,
72
+ "isLive": False,
73
+ "isVR180": False,
74
+ "lengthFilter": str(length_filter or "between420"),
75
+ "maxResultStreams": 0,
76
+ "maxResults": max(1, min(int(max_results), 100)),
77
+ "maxResultsShorts": max(0, min(int(max_results_shorts), 100)),
78
+ "preferAutoGeneratedSubtitles": False,
79
+ "saveSubsToKVS": False,
80
+ "searchQueries": values,
81
+ "sortingOrder": str(sorting_order or "relevance"),
82
+ "subtitlesLanguage": str(subtitles_language or "en"),
83
+ }
84
+
85
+
86
+ def start_actor_run(token: str, actor_id: str, actor_input: dict[str, Any], *, timeout: int = 60) -> ApifyRun:
87
+ actor_id = str(actor_id or DEFAULT_ACTOR_ID).strip() or DEFAULT_ACTOR_ID
88
+ url = f"{APIFY_API_BASE}/acts/{quote(actor_id, safe='~')}/runs"
89
+ try:
90
+ response = requests.post(url, headers=_headers(token), json=actor_input, timeout=timeout)
91
+ except requests.RequestException as exc:
92
+ raise ApifyError(f"Não foi possível iniciar o actor da Apify: {exc}") from exc
93
+ body = _json_response(response, "iniciar o actor")
94
+ data = body.get("data", body) if isinstance(body, dict) else {}
95
+ run_id = str(data.get("id") or data.get("runId") or "").strip()
96
+ if not run_id:
97
+ raise ApifyError("A Apify iniciou o pedido, mas não devolveu um run ID.")
98
+ status = str(data.get("status") or "RUNNING").upper()
99
+ dataset_id = str(data.get("defaultDatasetId") or data.get("datasetId") or "").strip()
100
+ return ApifyRun(run_id=run_id, status=status, dataset_id=dataset_id, actor_id=actor_id)
101
+
102
+
103
+ def get_actor_run(token: str, run_id: str, *, timeout: int = 30) -> ApifyRun:
104
+ run_id = str(run_id or "").strip()
105
+ if not run_id:
106
+ raise ApifyError("A execução Apify não tem run ID.")
107
+ url = f"{APIFY_API_BASE}/actor-runs/{quote(run_id, safe='')}"
108
+ try:
109
+ response = requests.get(url, headers=_headers(token), timeout=timeout)
110
+ except requests.RequestException as exc:
111
+ raise ApifyError(f"Não foi possível consultar o estado da execução Apify: {exc}") from exc
112
+ body = _json_response(response, "consultar o estado da execução")
113
+ data = body.get("data", body) if isinstance(body, dict) else {}
114
+ status = str(data.get("status") or "UNKNOWN").upper()
115
+ dataset_id = str(data.get("defaultDatasetId") or data.get("datasetId") or "").strip()
116
+ actor_id = str(data.get("actId") or data.get("actorId") or DEFAULT_ACTOR_ID)
117
+ return ApifyRun(run_id=run_id, status=status, dataset_id=dataset_id, actor_id=actor_id)
118
+
119
+
120
+ def get_dataset_items(token: str, dataset_id: str, *, limit: int = 100, timeout: int = 60) -> list[dict[str, Any]]:
121
+ dataset_id = str(dataset_id or "").strip()
122
+ if not dataset_id:
123
+ raise ApifyError("A execução Apify terminou sem devolver um dataset ID.")
124
+ url = f"{APIFY_API_BASE}/datasets/{quote(dataset_id, safe='')}/items"
125
+ try:
126
+ response = requests.get(
127
+ url,
128
+ headers=_headers(token),
129
+ params={"format": "json", "clean": "true", "limit": max(1, min(int(limit), 1000))},
130
+ timeout=timeout,
131
+ )
132
+ except requests.RequestException as exc:
133
+ raise ApifyError(f"Não foi possível carregar o dataset Apify: {exc}") from exc
134
+ body = _json_response(response, "carregar o dataset")
135
+ if not isinstance(body, list):
136
+ raise ApifyError("O dataset Apify não devolveu uma lista de vídeos.")
137
+ return [item for item in body if isinstance(item, dict)]
138
+
139
+
140
+ def abort_actor_run(token: str, run_id: str, *, timeout: int = 30) -> None:
141
+ url = f"{APIFY_API_BASE}/actor-runs/{quote(str(run_id), safe='')}/abort"
142
+ try:
143
+ response = requests.post(url, headers=_headers(token), params={"gracefully": "0"}, timeout=timeout)
144
+ except requests.RequestException as exc:
145
+ raise ApifyError(f"Não foi possível cancelar a execução Apify: {exc}") from exc
146
+ _json_response(response, "cancelar a execução")
147
+
148
+
149
+ def wait_for_actor_run(
150
+ token: str,
151
+ run: ApifyRun,
152
+ *,
153
+ poll_interval: int = 10,
154
+ timeout_seconds: int = 900,
155
+ on_status: Callable[[ApifyRun], None] | None = None,
156
+ ) -> ApifyRun:
157
+ import time
158
+
159
+ started = time.monotonic()
160
+ current = run
161
+ while current.status not in TERMINAL_STATUSES:
162
+ if time.monotonic() - started >= max(10, int(timeout_seconds)):
163
+ raise ApifyError(f"A execução Apify excedeu o limite de {int(timeout_seconds)} segundos.")
164
+ if on_status:
165
+ on_status(current)
166
+ time.sleep(max(1, int(poll_interval)))
167
+ current = get_actor_run(token, run.run_id)
168
+ if on_status:
169
+ on_status(current)
170
+ if current.status != "SUCCEEDED":
171
+ raise ApifyError(f"A execução Apify terminou com o estado {current.status}.")
172
+ return current
173
+
174
+
175
+ def _first_value(item: dict[str, Any], *keys: str, default: Any = "") -> Any:
176
+ for key in keys:
177
+ value = item.get(key)
178
+ if value not in (None, ""):
179
+ return value
180
+ return default
181
+
182
+
183
+ def _number(value: Any) -> int | float:
184
+ if isinstance(value, bool):
185
+ return 0
186
+ if isinstance(value, (int, float)):
187
+ return value
188
+ text = str(value or "").replace(",", "").strip()
189
+ try:
190
+ number = float(text)
191
+ except ValueError:
192
+ return 0
193
+ return int(number) if number.is_integer() else number
194
+
195
+
196
+ def clean_srt(value: Any) -> str:
197
+ if isinstance(value, dict):
198
+ value = value.get("srt") or value.get("text") or value.get("content") or ""
199
+ if isinstance(value, list):
200
+ parts = [clean_srt(part) for part in value]
201
+ return " ".join(part for part in parts if part).strip()
202
+ text = str(value or "")
203
+ if not text.strip():
204
+ return ""
205
+ text = re.sub(r"^\s*\d+\s*$", "", text, flags=re.MULTILINE)
206
+ text = re.sub(r"^\s*\d{2}:\d{2}:\d{1,2},\d{1,3}\s*-->\s*\d{2}:\d{2}:\d{1,2},\d{1,3}.*$", "", text, flags=re.MULTILINE)
207
+ text = re.sub(r"\n{2,}", "\n", text)
208
+ return re.sub(r"\s+", " ", text.replace("\n", " ")).strip()
209
+
210
+
211
+ def extract_transcript(item: dict[str, Any]) -> str:
212
+ subtitles = item.get("subtitles")
213
+ if isinstance(subtitles, list):
214
+ for subtitle in subtitles:
215
+ text = clean_srt(subtitle)
216
+ if text:
217
+ return text
218
+ for key in ("srt", "transcript", "subtitlesText", "captionText"):
219
+ text = clean_srt(item.get(key))
220
+ if text:
221
+ return text
222
+ return ""
223
+
224
+
225
+ def vsc_ratio(view_count: Any, subscriber_count: Any, comments_count: Any) -> float:
226
+ views = float(_number(view_count) or 0)
227
+ subscribers = float(_number(subscriber_count) or 0)
228
+ comments = float(_number(comments_count) or 0)
229
+ subscriber_component = (views / subscribers) * 0.7 if subscribers else 0.0
230
+ comment_component = (comments / views) * 0.3 if views else 0.0
231
+ return round(subscriber_component + comment_component, 6)
232
+
233
+
234
+ def normalize_video_item(item: dict[str, Any]) -> dict[str, Any]:
235
+ views = _first_value(item, "viewCount", "views", default=0)
236
+ subscribers = _first_value(item, "numberOfSubscribers", "subscriberCount", "subscribers", default=0)
237
+ comments = _first_value(item, "commentsCount", "commentCount", "comments", default=0)
238
+ transcript = extract_transcript(item)
239
+ return {
240
+ "video_id": str(_first_value(item, "id", "videoId", default="")),
241
+ "url": str(_first_value(item, "url", "videoUrl", default="")),
242
+ "title": str(_first_value(item, "title", default="Sem título")),
243
+ "channel_name": str(_first_value(item, "channelName", "channelTitle", default="Sem canal")),
244
+ "channel_url": str(_first_value(item, "channelUrl", default="")),
245
+ "thumbnail_url": str(_first_value(item, "thumbnailUrl", "thumbnail", default="")),
246
+ "duration": str(_first_value(item, "duration", "durationText", default="")),
247
+ "publish_date": str(_first_value(item, "date", "publishedAt", "uploadDate", default="")),
248
+ "view_count": _number(views),
249
+ "subscriber_count": _number(subscribers),
250
+ "comments_count": _number(comments),
251
+ "vsc_ratio": vsc_ratio(views, subscribers, comments),
252
+ "transcript": transcript,
253
+ "transcript_status": "disponível" if transcript else "sem transcrição",
254
+ "summary": "",
255
+ "summary_status": "pendente" if transcript else "não aplicável",
256
+ }
257
+
258
+
259
+ def normalize_video_items(items: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
260
+ return [normalize_video_item(item) for item in items]
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import requests
6
+
7
+
8
+ DEFAULT_BASE_URLS = {
9
+ "openai": "https://api.openai.com/v1",
10
+ "moonshot": "https://api.moonshot.cn/v1",
11
+ "deepseek": "https://api.deepseek.com/v1",
12
+ "groq": "https://api.groq.com/openai/v1",
13
+ }
14
+
15
+
16
+ class SummarizationError(RuntimeError):
17
+ """Raised when the configured LLM cannot summarize a transcript."""
18
+
19
+
20
+ def _content_value(value: Any) -> str:
21
+ if isinstance(value, str):
22
+ return value.strip()
23
+ if isinstance(value, list):
24
+ parts: list[str] = []
25
+ for part in value:
26
+ if isinstance(part, str):
27
+ parts.append(part)
28
+ elif isinstance(part, dict) and isinstance(part.get("text"), str):
29
+ parts.append(part["text"])
30
+ return "".join(parts).strip()
31
+ return ""
32
+
33
+
34
+ def summarize_transcript(transcript: str, settings: dict[str, Any], *, timeout: int = 90) -> str:
35
+ transcript = str(transcript or "").strip()
36
+ if not transcript:
37
+ raise SummarizationError("A transcrição está vazia.")
38
+ provider = str(settings.get("llm_provider", "") or "").strip().lower()
39
+ api_key = str(settings.get(f"{provider}_api_key", "") or "").strip()
40
+ base_url = str(settings.get(f"{provider}_base_url", "") or "").strip().rstrip("/")
41
+ model = str(settings.get(f"{provider}_model_name", "") or "").strip()
42
+ if provider == "openai":
43
+ api_key = api_key or str(settings.get("openai_api_key", "") or "").strip()
44
+ base_url = base_url or str(settings.get("openai_base_url", "") or "").strip().rstrip("/")
45
+ model = model or str(settings.get("openai_model_name", "") or "").strip()
46
+ if not base_url:
47
+ base_url = DEFAULT_BASE_URLS.get(provider, "")
48
+ if not api_key or not base_url or not model:
49
+ raise SummarizationError(f"Configure provider, API key, Base URL e modelo para o LLM seleccionado ({provider or 'não definido'}).")
50
+ endpoint = base_url if base_url.endswith("/chat/completions") else f"{base_url}/chat/completions"
51
+ system_prompt = (
52
+ "És um assistente de análise de vídeos. Resume apenas informação presente na transcrição, "
53
+ "sem inventar, inferir ou acrescentar factos. Responde em português do Brasil e usa exactamente "
54
+ "esta estrutura: INTRODUÇÃO: um parágrafo sobre como o vídeo começa. ESTRUTURA: uma lista com "
55
+ "os pontos principais e subpontos da progressão do vídeo."
56
+ )
57
+ payload = {
58
+ "model": model,
59
+ "temperature": 0.2,
60
+ "max_tokens": 1200,
61
+ "messages": [
62
+ {"role": "system", "content": system_prompt},
63
+ {"role": "user", "content": transcript[:120000]},
64
+ ],
65
+ }
66
+ try:
67
+ response = requests.post(
68
+ endpoint,
69
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
70
+ json=payload,
71
+ timeout=timeout,
72
+ )
73
+ except requests.RequestException as exc:
74
+ raise SummarizationError(f"Não foi possível contactar o provider LLM: {exc}") from exc
75
+ if response.status_code >= 400:
76
+ raise SummarizationError(f"O provider LLM devolveu HTTP {response.status_code}: {response.text[:300]}")
77
+ try:
78
+ body = response.json()
79
+ except ValueError as exc:
80
+ raise SummarizationError("O provider LLM devolveu uma resposta JSON inválida.") from exc
81
+ choices = body.get("choices") if isinstance(body, dict) else None
82
+ if not isinstance(choices, list) or not choices:
83
+ raise SummarizationError("O provider LLM não devolveu escolhas de resposta.")
84
+ choice = choices[0] if isinstance(choices[0], dict) else {}
85
+ message = choice.get("message") if isinstance(choice, dict) else {}
86
+ content = _content_value(message.get("content") if isinstance(message, dict) else "")
87
+ if not content:
88
+ raise SummarizationError("O provider LLM devolveu uma resposta sem texto.")
89
+ return content
90
+
91
+
92
+ def summarize_items(items: list[dict[str, Any]], settings: dict[str, Any], *, on_item: Any = None) -> list[dict[str, Any]]:
93
+ enriched: list[dict[str, Any]] = []
94
+ for index, item in enumerate(items, start=1):
95
+ current = dict(item)
96
+ if current.get("transcript"):
97
+ try:
98
+ current["summary"] = summarize_transcript(current["transcript"], settings)
99
+ current["summary_status"] = "concluído"
100
+ except SummarizationError as exc:
101
+ current["summary"] = ""
102
+ current["summary_status"] = f"erro: {exc}"
103
+ else:
104
+ current["summary_status"] = "não aplicável"
105
+ enriched.append(current)
106
+ if on_item:
107
+ on_item(index, len(items), current)
108
+ return enriched
@@ -21,6 +21,7 @@ DEFAULTS: dict[str, Any] = {
21
21
  "queues.json": {"niche": [], "blueprint": [], "brand": [], "script": [], "title": [], "thumbnail": [], "video": [], "edit": [], "upload": []},
22
22
  "batches.json": [],
23
23
  "uploads.json": [],
24
+ "niche_apify_runs.json": [],
24
25
  "metadata_edits.json": [],
25
26
  "mcp_server.json": {
26
27
  "enabled": False,
@@ -84,6 +85,10 @@ DEFAULTS: dict[str, Any] = {
84
85
  "kaggle_username": "",
85
86
  "kaggle_api_key": "",
86
87
  "kaggle_kernel_slug": "thunderbolt-niche-finder",
88
+ "apify_api_token": "",
89
+ "apify_actor_id": "streamers~youtube-scraper",
90
+ "apify_poll_interval_seconds": 10,
91
+ "apify_run_timeout_seconds": 900,
87
92
  "llm_provider": "moonshot",
88
93
  "moonshot_api_key": "",
89
94
  "moonshot_base_url": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.34",
3
+ "version": "0.2.36",
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",