@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.
@@ -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.22
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.22 install
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.22 install
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 importar nome, handle e métricas públicas de canais |
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 é a Data API Key |
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 chaves devem ser inseridas apenas na configuração local. Não as coloque no GitHub, no `package.json`, em blueprints ou em ficheiros de estado versionados.
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.22 install
108
- npx.cmd --yes @danhachuel/thunderbolt@0.2.22 doctor
109
- npx.cmd --yes @danhachuel/thunderbolt@0.2.22
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 `youtube_api_key` estiver configurada em **Configurações** ou em `YOUTUBE_API_KEY`.
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 — API pública e OAuth 2.0**")
822
- st.caption("A Data API Key consulta canais e estatísticas públicas. O Client ID e o Client Secret são credenciais OAuth 2.0 separadas, usadas para autorizar operações na conta, como upload.")
823
- youtube_cols = st.columns(3)
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
- youtube_api_key = text_setting("YouTube Data API Key", "youtube_api_key", secret=True, help_text="Chave criada em Google Cloud > APIs e serviços > Credenciais > Chave de API.")
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
- youtube_client_id = text_setting("YouTube OAuth Client ID", "youtube_client_id", help_text="Client ID do OAuth 2.0 criado no Google Cloud.")
828
- with youtube_cols[2]:
829
- youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0.")
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. é 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 = str(exc)
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
- canonical_url = _meta_content(document, "og:url", "twitter:url") or page_url.split("?", 1)[0].rstrip("/")
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://").removesuffix("/about")
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
- if not youtube_id:
226
- youtube_id = _channel_id_from_document(document, canonical_url)
227
- feed = _public_feed_data(youtube_id, headers)
228
- title = _text_from_node(metadata.get("title")) if isinstance(metadata, dict) else ""
229
- if not title and isinstance(header, dict):
230
- title = _text_from_node(header.get("title"))
231
- if not title:
232
- title = _text_from_node(_find_first_key(header, "dynamicTextViewModel"))
233
- if not title:
234
- title = _meta_content(document, "og:title", "twitter:title") or feed.get("name", "")
235
- description = _text_from_node(metadata.get("description")) if isinstance(metadata, dict) else ""
236
- description = description or _meta_content(document, "description", "og:description")
237
- thumbnail_url = _thumbnail_from_metadata(metadata) or _meta_content(document, "og:image", "twitter:image")
238
- handle_match = re.search(r"/@([^/?]+)", canonical_url)
239
- handle = f"@{handle_match.group(1)}" if handle_match else ""
240
- if not handle:
241
- handle_match = re.search(r"/@([^/?]+)", document)
242
- handle = f"@{handle_match.group(1)}" if handle_match else ""
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 data["name"] or data["youtube_id"] or data["thumbnail_url"]:
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 = "A página respondeu sem metadados reconhecíveis."
261
- return IntegrationResult(False, f"Não foi possível obter dados públicos do YouTube sem API Key. Confirme o URL/handle ou use Cadastro manual. {last_error}".strip(), {"url": last_url, "public_lookup": True})
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, "YOUTUBE_API_KEY não configurada; preencha os dados manualmente.", {"url": value, "handle": ref})
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
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",