@danhachuel/thunderbolt 0.2.22 → 0.2.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +10 -10
- package/README.md +8 -8
- package/app/main.py +9 -8
- package/integrations/platforms.py +153 -28
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
|
|
4
4
|
|
|
5
|
-
> **Versão deste manual:** 0.2.
|
|
5
|
+
> **Versão deste manual:** 0.2.23
|
|
6
6
|
> **Pacote npm:** `@danhachuel/thunderbolt`
|
|
7
7
|
> **Porta padrão da UI:** `localhost:3030`
|
|
8
8
|
> **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
|
|
@@ -100,13 +100,13 @@ Execute:
|
|
|
100
100
|
Windows PowerShell ou MobaXterm:
|
|
101
101
|
|
|
102
102
|
```powershell
|
|
103
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
103
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.23 install
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Linux/macOS:
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
npx --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
+
npx --yes @danhachuel/thunderbolt@0.2.23 install
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
|
|
@@ -339,20 +339,20 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
|
|
339
339
|
|
|
340
340
|
## 8. Configuração inicial da UI
|
|
341
341
|
|
|
342
|
-
Na primeira execução, abra **Configurações** e reveja
|
|
342
|
+
Na primeira execução, abra **Configurações** e reveja. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica numa área opcional, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
|
|
343
343
|
|
|
344
344
|
| Configuração | Finalidade |
|
|
345
345
|
|---|---|
|
|
346
346
|
| Porta Streamlit | Definir a porta local da UI |
|
|
347
347
|
| Pasta MoneyPrinterTurbo | Indicar o clone local que será sincronizado com `config.toml` |
|
|
348
|
-
| YouTube Data API Key | Permitir
|
|
349
|
-
| YouTube OAuth Client ID | Identificar a aplicação OAuth 2.0 do Google para operações autenticadas |
|
|
350
|
-
| YouTube OAuth Client Secret | Secret do mesmo cliente OAuth 2.0; não é
|
|
348
|
+
| YouTube Data API Key (opcional) | Permitir o método oficial Data API para métricas públicas; não é necessária para a página pública nem para OAuth |
|
|
349
|
+
| YouTube OAuth Client ID | Identificar a aplicação OAuth 2.0 do Google para autorizar operações autenticadas, como upload |
|
|
350
|
+
| YouTube OAuth Client Secret | Secret do mesmo cliente OAuth 2.0; não é uma Data API Key nem um token de acesso |
|
|
351
351
|
| YouTube upload principal | Lógica do `youtube-automation-agent` adaptada e executada dentro do Thunderbolt |
|
|
352
352
|
| OAuth directo de redundância | Caminho alternativo accionado automaticamente se o agente falhar |
|
|
353
353
|
| TikTok Client ID/Secret | Credenciais da aplicação; Redirect URI, scopes e autorização ficam no TikTok for Developers Playground |
|
|
354
354
|
|
|
355
|
-
As
|
|
355
|
+
As credenciais devem ser inseridas apenas na configuração local. 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.
|
|
356
356
|
|
|
357
357
|
## 9. Testar as áreas principais
|
|
358
358
|
|
|
@@ -361,10 +361,10 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
361
361
|
1. **Dashboard:** confirme que a UI abre e mostra o estado local.
|
|
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
|
-
4. **Canais:** em **Importar do YouTube**, use o método **Página pública — sem API Key** ou a Data API opcional; em **Cadastro manual**, preencha os dados sem qualquer consulta externa.
|
|
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
365
|
5. **Novo vídeo:** teste primeiro o modo **Canal específico** e depois os modos de lote.
|
|
366
366
|
6. **Novo vídeo > Vídeos:** verifique o estado `to_do` e os botões **Iniciar** e **Parar** dentro da subaba.
|
|
367
|
-
7. **Upload:** 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 é exclusivamente para consultas públicas.
|
|
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
368
|
8. **Limpador de metadado:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos da aba Novo vídeo não são aceites nesta área.
|
|
369
369
|
9. **Configurações:** confirme que os caminhos e credenciais estão locais e não aparecem no Git.
|
|
370
370
|
10. **Novo vídeo:** confirme que `Pexels/Pixabay` substitui o label antigo, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
20
20
|
| 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
21
|
| MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
|
|
22
22
|
| Limpador de metadado | Upload isolado de vídeos terceiros, limpeza FFmpeg, edição de título/descrição/tags e manifesto JSON |
|
|
23
|
-
| Configurações | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, YouTube, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
23
|
+
| Configurações | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, Data API Key opcional, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
24
24
|
| Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
|
|
25
25
|
|
|
26
26
|
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.
|
|
@@ -104,9 +104,9 @@ thunderbolt
|
|
|
104
104
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
105
105
|
|
|
106
106
|
```powershell
|
|
107
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
108
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
107
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.23 install
|
|
108
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.23 doctor
|
|
109
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.23
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -167,15 +167,15 @@ A descrição segue a composição do workflow de referência: preview, secção
|
|
|
167
167
|
|
|
168
168
|
## Canais YouTube
|
|
169
169
|
|
|
170
|
-
A aba **Canais** está dividida em dois fluxos independentes. Em **Importar do YouTube**, o método padrão **Página pública — sem API Key** consulta a página pública do canal e preenche nome, ID, handle, URL, descrição e thumbnail quando esses dados estão disponíveis. A opção **YouTube Data API — API Key opcional** pode ser escolhida para métricas oficiais quando
|
|
170
|
+
A aba **Canais** está dividida em dois fluxos independentes. Em **Importar do YouTube**, o método padrão **Página pública — sem API Key** consulta a página pública do canal e preenche nome, ID, handle, URL, descrição e thumbnail quando esses dados estão disponíveis. A opção **YouTube Data API — API Key opcional** pode ser escolhida para métricas oficiais quando uma **YouTube Data API Key própria** estiver configurada em **Configurações** ou em `YOUTUBE_API_KEY`; essa chave é distinta do OAuth Client ID e Client Secret.
|
|
171
171
|
|
|
172
|
-
Em **Cadastro manual**, nenhum pedido ao YouTube é feito e não existe qualquer dependência de API Key. O utilizador pode preencher nome, URL, handle, descrição, métricas, thumbnail, idioma, estilo, Blueprint padrão, voz padrão, `DELEGATED_SESSION_ID` e configuração de Automação. A importação pública nunca grava automaticamente: os dados aparecem num formulário de revisão antes de guardar. Cada cartão de canal tem **Activo** e, logo abaixo, **Apagar canal**, com confirmação; tarefas e artefactos não são apagados.
|
|
172
|
+
Em **Cadastro manual**, nenhum pedido ao YouTube é feito e não existe qualquer dependência de API Key. O utilizador pode preencher nome, URL, handle, descrição, métricas, thumbnail, idioma, estilo, Blueprint padrão, voz padrão, `DELEGATED_SESSION_ID` e configuração de Automação. A importação pública nunca grava automaticamente: os dados aparecem num formulário de revisão antes de guardar. A resolução pública aceita URLs `/channel/UC...`, handles e subpáginas; quando existe um ID mas o HTML não traz todos os dados, tenta o feed RSS público. Se o YouTube responder que o canal não existe ou não fornecer metadados, a UI mostra uma mensagem clara e não mantém o formulário de uma pesquisa anterior. Cada cartão de canal tem **Activo** e, logo abaixo, **Apagar canal**, com confirmação; tarefas e artefactos não são apagados.
|
|
173
173
|
|
|
174
174
|
## Upload YouTube
|
|
175
175
|
|
|
176
176
|
O Upload usa como caminho **principal** a lógica do `PublishingSchedulingAgent` do [youtube-automation-agent](https://github.com/darkzOGx/youtube-automation-agent), adaptada para Python e executada dentro do processo Streamlit do Thunderbolt. Não é necessário instalar ou iniciar um segundo servidor Node. O fluxo valida o MP4 real, constrói `snippet/status`, faz upload resumível e tenta enviar thumbnail e legendas.
|
|
177
177
|
|
|
178
|
-
Na aba **Upload**, configure primeiro o **YouTube OAuth Client ID** e o **YouTube OAuth Client Secret** em **Configurações**. Em seguida, use **Autorizar agente YouTube**. O navegador local abrirá a autorização Google e o token será guardado apenas em `storage/state/youtube_agent_tokens.json`. Se a publicação principal falhar, o Thunderbolt tenta automaticamente o **OAuth directo de redundância**, usando o token local compatível; a Data API Key nunca é usada para publicar.
|
|
178
|
+
Na aba **Upload**, configure primeiro apenas o **YouTube OAuth Client ID** e o **YouTube OAuth Client Secret** em **Configurações**. Em seguida, use **Autorizar agente YouTube**. O navegador local abrirá a autorização Google e o token será guardado apenas em `storage/state/youtube_agent_tokens.json`. Se a publicação principal falhar, o Thunderbolt tenta automaticamente o **OAuth directo de redundância**, usando o token local compatível; a Data API Key nunca é usada para autorizar ou publicar.
|
|
179
179
|
|
|
180
180
|
Use **Autorizar fallback OAuth** apenas se precisar de uma autorização separada para o caminho de redundância. Os resultados guardam no histórico local qual mecanismo foi utilizado e as tentativas realizadas, sem guardar segredos.
|
|
181
181
|
|
|
@@ -203,7 +203,7 @@ A mesma aba permite **Guardar skill localmente**, copiando `moneyprinterturbo-vi
|
|
|
203
203
|
|
|
204
204
|
A subaba **Vídeos**, dentro de **Novo vídeo**, mostra o backlog e permite iniciar/parar tarefas; deixou de existir como botão principal na barra lateral.
|
|
205
205
|
|
|
206
|
-
A aba **Configurações** contém apenas o TikTok Client ID e o TikTok Client Secret.
|
|
206
|
+
A aba **Configurações** contém apenas o TikTok Client ID e o TikTok Client Secret para TikTok. Para YouTube, o bloco principal pede o OAuth Client ID e Client Secret; a Data API Key, se usada, fica numa área opcional separada. Client ID + Client Secret não formam uma Data API Key nem um token OAuth.
|
|
207
207
|
Redirect URI, scopes, autorização OAuth e tokens são geridos no TikTok for Developers Playground. O adaptador rejeita o upload quando faltam credenciais ou OAuth, em vez de indicar sucesso falso.
|
|
208
208
|
|
|
209
209
|
## Segurança
|
package/app/main.py
CHANGED
|
@@ -335,7 +335,7 @@ def render_channels():
|
|
|
335
335
|
st.rerun()
|
|
336
336
|
if st.session_state.get("yt_message"):
|
|
337
337
|
(st.success if st.session_state.get("yt_ok") else st.warning)(st.session_state["yt_message"])
|
|
338
|
-
imported = st.session_state.get("yt_import", {})
|
|
338
|
+
imported = st.session_state.get("yt_import", {}) if st.session_state.get("yt_ok") else {}
|
|
339
339
|
blueprint_items = blueprint_catalog()
|
|
340
340
|
blueprint_ids = [item[0] for item in blueprint_items]
|
|
341
341
|
blueprint_labels = {item[0]: item[1] for item in blueprint_items}
|
|
@@ -818,15 +818,16 @@ def render_settings():
|
|
|
818
818
|
st.subheader("Execução local")
|
|
819
819
|
port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
|
|
820
820
|
moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
|
|
821
|
-
st.markdown("**YouTube —
|
|
822
|
-
st.caption("
|
|
823
|
-
youtube_cols = st.columns(
|
|
821
|
+
st.markdown("**YouTube — OAuth 2.0 e consulta pública**")
|
|
822
|
+
st.caption("Para autorizar uploads, preencha apenas o YouTube OAuth Client ID e o YouTube OAuth Client Secret. Depois, autorize o agente na aba Upload. Estes dados identificam a aplicação OAuth; não são uma Data API Key nem um token de acesso.")
|
|
823
|
+
youtube_cols = st.columns(2)
|
|
824
824
|
with youtube_cols[0]:
|
|
825
|
-
|
|
825
|
+
youtube_client_id = text_setting("YouTube OAuth Client ID", "youtube_client_id", help_text="Client ID do OAuth 2.0 criado no Google Cloud. É usado para iniciar a autorização da conta YouTube.")
|
|
826
826
|
with youtube_cols[1]:
|
|
827
|
-
|
|
828
|
-
with
|
|
829
|
-
|
|
827
|
+
youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0. Não é uma API Key.")
|
|
828
|
+
with st.expander("Consulta oficial de métricas — opcional"):
|
|
829
|
+
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.")
|
|
830
|
+
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.")
|
|
830
831
|
|
|
831
832
|
with st.expander("Upload directo — sessão YouTube Frontend API"):
|
|
832
833
|
st.caption("Campos usados apenas pelo método Upload directo. Não são necessários para o youtube-automation-agent nem para a Data API pública.")
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import xml.etree.ElementTree as ET
|
|
7
|
+
from html import unescape
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
@@ -121,6 +122,69 @@ def _meta_content(document: str, *names: str) -> str:
|
|
|
121
122
|
return ""
|
|
122
123
|
|
|
123
124
|
|
|
125
|
+
def _channel_id_from_source(source: str) -> str:
|
|
126
|
+
"""Extract a channel ID from a direct channel URL or a raw UC... value."""
|
|
127
|
+
value = (source or "").strip()
|
|
128
|
+
match = re.search(r"(?<![A-Za-z0-9_-])(UC[A-Za-z0-9_-]{3,})(?![A-Za-z0-9_-])", value)
|
|
129
|
+
return match.group(1) if match else ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _canonical_link(document: str) -> str:
|
|
133
|
+
patterns = [
|
|
134
|
+
r'<link[^>]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)["\']',
|
|
135
|
+
r'<link[^>]+href=["\']([^"\']+)["\'][^>]+rel=["\']canonical["\']',
|
|
136
|
+
]
|
|
137
|
+
for pattern in patterns:
|
|
138
|
+
match = re.search(pattern, document, flags=re.IGNORECASE)
|
|
139
|
+
if match:
|
|
140
|
+
return unescape(match.group(1)).strip()
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _jsonld_channel_data(document: str) -> dict[str, Any]:
|
|
145
|
+
"""Read channel-like fields from JSON-LD without requiring a fixed YouTube renderer."""
|
|
146
|
+
for raw in re.findall(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', document, flags=re.IGNORECASE | re.DOTALL):
|
|
147
|
+
try:
|
|
148
|
+
payload = json.loads(unescape(raw.strip()))
|
|
149
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
150
|
+
continue
|
|
151
|
+
candidates = payload if isinstance(payload, list) else [payload]
|
|
152
|
+
for candidate in candidates:
|
|
153
|
+
if not isinstance(candidate, dict):
|
|
154
|
+
continue
|
|
155
|
+
candidate_type = candidate.get("@type", "")
|
|
156
|
+
if candidate_type in {"Person", "Organization", "Brand", "ProfilePage"} or any(key in candidate for key in ("channelId", "identifier", "sameAs")):
|
|
157
|
+
image = candidate.get("image", "")
|
|
158
|
+
if isinstance(image, dict):
|
|
159
|
+
image = image.get("url", "")
|
|
160
|
+
identifier = candidate.get("channelId") or candidate.get("identifier", "")
|
|
161
|
+
if isinstance(identifier, dict):
|
|
162
|
+
identifier = identifier.get("value", "")
|
|
163
|
+
same_as = candidate.get("sameAs", [])
|
|
164
|
+
if isinstance(same_as, str):
|
|
165
|
+
same_as = [same_as]
|
|
166
|
+
return {
|
|
167
|
+
"name": _text_from_node(candidate.get("name")),
|
|
168
|
+
"description": _text_from_node(candidate.get("description")),
|
|
169
|
+
"url": _text_from_node(candidate.get("url")),
|
|
170
|
+
"thumbnail_url": _text_from_node(image),
|
|
171
|
+
"youtube_id": _text_from_node(identifier),
|
|
172
|
+
"same_as": same_as if isinstance(same_as, list) else [],
|
|
173
|
+
}
|
|
174
|
+
return {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _public_error_from_initial_data(initial_data: dict[str, Any]) -> str:
|
|
178
|
+
alerts = _find_first_key(initial_data, "alerts")
|
|
179
|
+
if not isinstance(alerts, list):
|
|
180
|
+
return ""
|
|
181
|
+
for alert in alerts:
|
|
182
|
+
text = _text_from_node(_find_first_key(alert, "text"))
|
|
183
|
+
if text:
|
|
184
|
+
return text
|
|
185
|
+
return ""
|
|
186
|
+
|
|
187
|
+
|
|
124
188
|
def _public_page_candidates(source: str) -> list[str]:
|
|
125
189
|
source = source.strip()
|
|
126
190
|
if source.startswith(("http://", "https://")):
|
|
@@ -197,73 +261,134 @@ class YouTubeAdapter:
|
|
|
197
261
|
source = (value or "").strip()
|
|
198
262
|
if not source:
|
|
199
263
|
return IntegrationResult(False, "Introduza o nome, handle, URL ou ID do canal.", {})
|
|
264
|
+
if source.startswith(("http://", "https://")):
|
|
265
|
+
host = (urlparse(source).hostname or "").lower()
|
|
266
|
+
if host != "youtube.com" and not host.endswith(".youtube.com"):
|
|
267
|
+
return IntegrationResult(False, "O link deve pertencer ao YouTube. Use um URL youtube.com, um handle @nome ou um ID UC... .", {"url": source, "public_lookup": True})
|
|
200
268
|
headers = {
|
|
201
269
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
|
202
270
|
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
|
|
203
271
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
204
272
|
}
|
|
273
|
+
direct_id = _channel_id_from_source(source)
|
|
274
|
+
feed_cache: dict[str, dict[str, Any]] = {}
|
|
275
|
+
|
|
276
|
+
def feed_for(channel_id: str) -> dict[str, Any]:
|
|
277
|
+
if channel_id and channel_id not in feed_cache:
|
|
278
|
+
feed_cache[channel_id] = _public_feed_data(channel_id, headers)
|
|
279
|
+
return feed_cache.get(channel_id, {})
|
|
280
|
+
|
|
281
|
+
if direct_id:
|
|
282
|
+
direct_feed = feed_for(direct_id)
|
|
283
|
+
if direct_feed.get("name"):
|
|
284
|
+
feed_name = str(direct_feed.get("name", ""))
|
|
285
|
+
else:
|
|
286
|
+
feed_name = ""
|
|
287
|
+
else:
|
|
288
|
+
feed_name = ""
|
|
205
289
|
last_url = source
|
|
206
290
|
last_error = ""
|
|
207
291
|
for page_url in _public_page_candidates(source):
|
|
208
292
|
last_url = page_url
|
|
209
293
|
try:
|
|
210
|
-
response = requests.get(page_url, headers=headers, timeout=20)
|
|
294
|
+
response = requests.get(page_url, headers=headers, timeout=20, allow_redirects=True)
|
|
211
295
|
response.raise_for_status()
|
|
212
296
|
except requests.RequestException as exc:
|
|
213
|
-
last_error =
|
|
297
|
+
last_error = f"Falha HTTP: {exc}"
|
|
214
298
|
continue
|
|
215
299
|
document = response.text or ""
|
|
216
300
|
initial_data = _extract_json_assignment(document, "ytInitialData") or {}
|
|
301
|
+
public_error = _public_error_from_initial_data(initial_data)
|
|
217
302
|
metadata = _find_first_key(initial_data, "channelMetadataRenderer") or {}
|
|
218
303
|
header = _find_first_key(initial_data, "pageHeaderViewModel") or {}
|
|
219
|
-
|
|
304
|
+
jsonld = _jsonld_channel_data(document)
|
|
305
|
+
response_url = str(getattr(response, "url", "") or "")
|
|
306
|
+
canonical_url = (
|
|
307
|
+
_meta_content(document, "og:url", "twitter:url")
|
|
308
|
+
or _canonical_link(document)
|
|
309
|
+
or (response_url if "youtube.com" in response_url else "")
|
|
310
|
+
or jsonld.get("url", "")
|
|
311
|
+
or page_url.split("?", 1)[0].rstrip("/")
|
|
312
|
+
)
|
|
220
313
|
owner_urls = metadata.get("ownerUrls", []) if isinstance(metadata, dict) else []
|
|
221
314
|
if owner_urls:
|
|
222
315
|
canonical_url = str(owner_urls[0])
|
|
223
|
-
canonical_url = canonical_url.replace("http://", "https://").
|
|
316
|
+
canonical_url = canonical_url.replace("http://", "https://").split("?", 1)[0].rstrip("/")
|
|
224
317
|
youtube_id = str(metadata.get("externalId", "")) if isinstance(metadata, dict) else ""
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
feed =
|
|
228
|
-
|
|
229
|
-
if not
|
|
230
|
-
|
|
231
|
-
if not
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
318
|
+
youtube_id = youtube_id or _channel_id_from_source(canonical_url) or _channel_id_from_document(document, canonical_url)
|
|
319
|
+
youtube_id = youtube_id or _channel_id_from_source(jsonld.get("youtube_id", "")) or direct_id
|
|
320
|
+
feed = feed_for(youtube_id)
|
|
321
|
+
page_title = _text_from_node(metadata.get("title")) if isinstance(metadata, dict) else ""
|
|
322
|
+
if not page_title and isinstance(header, dict):
|
|
323
|
+
page_title = _text_from_node(header.get("title"))
|
|
324
|
+
if not page_title:
|
|
325
|
+
page_title = _text_from_node(_find_first_key(header, "dynamicTextViewModel"))
|
|
326
|
+
page_title = page_title or jsonld.get("name", "") or _meta_content(document, "og:title", "twitter:title")
|
|
327
|
+
title = page_title or feed.get("name", "") or feed_name
|
|
328
|
+
page_description = _text_from_node(metadata.get("description")) if isinstance(metadata, dict) else ""
|
|
329
|
+
page_description = page_description or jsonld.get("description", "") or _meta_content(document, "description", "og:description")
|
|
330
|
+
description = page_description
|
|
331
|
+
page_thumbnail = _thumbnail_from_metadata(metadata) or jsonld.get("thumbnail_url", "") or _meta_content(document, "og:image", "twitter:image")
|
|
332
|
+
thumbnail_url = page_thumbnail
|
|
333
|
+
handle = ""
|
|
334
|
+
for handle_source in (canonical_url, *jsonld.get("same_as", []), document):
|
|
335
|
+
handle_match = re.search(r"/@([^/?\"'\\]+)", str(handle_source))
|
|
336
|
+
if handle_match:
|
|
337
|
+
handle = f"@{handle_match.group(1)}"
|
|
338
|
+
break
|
|
243
339
|
subscriber_value = _find_first_key(initial_data, "subscriberCountText")
|
|
244
340
|
video_value = _find_first_key(initial_data, "videoCountText")
|
|
245
341
|
data = {
|
|
246
342
|
"youtube_id": youtube_id or feed.get("youtube_id", ""),
|
|
247
343
|
"name": title,
|
|
248
344
|
"handle": handle,
|
|
249
|
-
"url": canonical_url,
|
|
345
|
+
"url": canonical_url or (f"https://www.youtube.com/channel/{youtube_id}" if youtube_id else source),
|
|
250
346
|
"description": description,
|
|
251
347
|
"thumbnail_url": thumbnail_url,
|
|
252
348
|
"subscriber_count": _parse_public_count(subscriber_value),
|
|
253
349
|
"video_count": _parse_public_count(video_value) or feed.get("video_count"),
|
|
254
350
|
"view_count": None,
|
|
255
|
-
"metrics_source": "youtube_public_page",
|
|
351
|
+
"metrics_source": "youtube_public_page" if (page_title or page_description or page_thumbnail) else ("youtube_public_feed" if feed.get("name") else "youtube_public_page"),
|
|
256
352
|
"public_lookup": True,
|
|
257
353
|
}
|
|
258
|
-
if
|
|
354
|
+
if public_error and any(marker in public_error.lower() for marker in ("não existe", "nao existe", "não encontrado", "nao encontrado", "not found", "does not exist")):
|
|
355
|
+
data["public_error"] = public_error
|
|
356
|
+
return IntegrationResult(False, f"O YouTube indicou que este canal não existe ou não está disponível: {public_error}", data)
|
|
357
|
+
has_metadata = bool(
|
|
358
|
+
data["name"]
|
|
359
|
+
or data["handle"]
|
|
360
|
+
or data["description"]
|
|
361
|
+
or data["thumbnail_url"]
|
|
362
|
+
or data["subscriber_count"] is not None
|
|
363
|
+
or data["video_count"] is not None
|
|
364
|
+
)
|
|
365
|
+
if has_metadata:
|
|
259
366
|
return IntegrationResult(True, "Canal encontrado publicamente no YouTube, sem API Key. Reveja os dados antes de guardar.", data)
|
|
260
|
-
last_error = "
|
|
261
|
-
|
|
367
|
+
last_error = "O YouTube respondeu, mas não forneceu metadados públicos reconhecíveis para este canal."
|
|
368
|
+
if direct_id and feed_name:
|
|
369
|
+
data = {
|
|
370
|
+
"youtube_id": direct_id,
|
|
371
|
+
"name": feed_name,
|
|
372
|
+
"handle": "",
|
|
373
|
+
"url": f"https://www.youtube.com/channel/{direct_id}",
|
|
374
|
+
"description": "",
|
|
375
|
+
"thumbnail_url": "",
|
|
376
|
+
"subscriber_count": None,
|
|
377
|
+
"video_count": feed_cache.get(direct_id, {}).get("video_count"),
|
|
378
|
+
"view_count": None,
|
|
379
|
+
"metrics_source": "youtube_public_feed",
|
|
380
|
+
"public_lookup": True,
|
|
381
|
+
}
|
|
382
|
+
return IntegrationResult(True, "Canal encontrado no feed público do YouTube, sem API Key. Reveja os dados antes de guardar.", data)
|
|
383
|
+
data = {"url": last_url, "public_lookup": True}
|
|
384
|
+
if direct_id:
|
|
385
|
+
data["youtube_id"] = direct_id
|
|
386
|
+
return IntegrationResult(False, f"Não foi possível obter dados públicos do YouTube sem API Key. {last_error or 'Confirme o URL/handle ou use Cadastro manual.'}".strip(), data)
|
|
262
387
|
|
|
263
388
|
def fetch_channel(self, value: str) -> IntegrationResult:
|
|
264
389
|
ref = self.extract_channel_ref(value)
|
|
265
390
|
if not self.api_key:
|
|
266
|
-
return IntegrationResult(False, "
|
|
391
|
+
return IntegrationResult(False, "A YouTube Data API Key própria não está configurada. Escolha Página pública — sem API Key ou adicione uma chave separada em Configurações; o OAuth Client ID e Secret não substituem esta credencial.", {"url": value, "handle": ref, "status": "api_key_not_configured"})
|
|
267
392
|
try:
|
|
268
393
|
params = {"part": "snippet,statistics", "key": self.api_key}
|
|
269
394
|
if ref.startswith("UC"):
|
package/package.json
CHANGED