@danhachuel/thunderbolt 0.5.43 → 0.5.44

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/app/main.py CHANGED
@@ -1563,14 +1563,17 @@ def render_growth_youtube():
1563
1563
  if not records:
1564
1564
  st.info("Ainda não existem análises guardadas.")
1565
1565
  for record in reversed(records[-20:]):
1566
- row = st.columns([2, 1, 2, 1, 1.4])
1566
+ row = st.columns([2, 1, 2.2, 1.2, 1, 1.4])
1567
1567
  row[0].write(f"**{record.get('channel_name', 'Canal')}**")
1568
1568
  row[1].write(str(record.get("platform", "YouTube")))
1569
1569
  row[2].caption(str(record.get("code", "")))
1570
- row[3].write(f"{record.get('overall_score', 0)}/100")
1570
+ row[3].caption(str(record.get("created_at", "")).replace("T", " ")[:19])
1571
+ row[4].write(f"{record.get('overall_score', '—')}/100" if record.get("overall_score") is not None else "—")
1571
1572
  report_path = Path(str(record.get("report_path") or ""))
1572
1573
  if report_path.is_file():
1573
- row[4].download_button("Análise", report_path.read_bytes(), file_name=report_path.name, mime="text/markdown", key=f"growth_history_{record.get('code')}")
1574
+ row[5].download_button("Análise", report_path.read_bytes(), file_name=report_path.name, mime="text/markdown", key=f"growth_history_{record.get('code')}")
1575
+ elif record.get("status") == "failed":
1576
+ row[5].caption("Falhou")
1574
1577
 
1575
1578
 
1576
1579
  def render_dashboard():
@@ -71,9 +71,24 @@ def _download_thumbnail(video: Mapping[str, Any], directory: Path) -> str:
71
71
  directory.mkdir(parents=True, exist_ok=True)
72
72
  destination = directory / f"{video['id']}.jpg"
73
73
  if not destination.exists():
74
- response = requests.get(str(video.get("thumbnail_url") or ""), timeout=30)
75
- response.raise_for_status()
76
- destination.write_bytes(response.content)
74
+ try:
75
+ import yt_dlp
76
+ options = {
77
+ "quiet": True,
78
+ "skip_download": True,
79
+ "writethumbnail": True,
80
+ "outtmpl": str(directory / f"{video['id']}.%(ext)s"),
81
+ "noplaylist": True,
82
+ }
83
+ with yt_dlp.YoutubeDL(options) as downloader:
84
+ downloader.download([str(video.get("url") or f"https://www.youtube.com/watch?v={video['id']}")])
85
+ generated = next(directory.glob(f"{video['id']}.*"), None)
86
+ if generated and generated != destination:
87
+ generated.replace(destination)
88
+ except Exception as exc:
89
+ raise RuntimeError(f"Não foi possível baixar a thumbnail {video['id']} com yt-dlp: {exc}") from exc
90
+ if not destination.exists():
91
+ raise RuntimeError(f"yt-dlp não gerou a thumbnail do vídeo {video['id']}.")
77
92
  return str(destination)
78
93
 
79
94
 
@@ -145,7 +160,9 @@ def _vision_prompt(video: Mapping[str, Any]) -> str:
145
160
 
146
161
  def analyse_thumbnail_with_paligemma(path: str, card: Mapping[str, Any]) -> dict[str, Any]:
147
162
  key = str(card.get("api_key") or "").strip()
148
- base = str(card.get("base_url") or "https://integrate.api.nvidia.com/v1").rstrip("/")
163
+ base = str(card.get("base_url") or "https://ai.api.nvidia.com/v1/vlm/google/paligemma").rstrip("/")
164
+ if base.endswith("/v1"):
165
+ base = f"{base}/vlm/google/paligemma"
149
166
  model = str(card.get("model") or "google/paligemma").strip()
150
167
  if not key:
151
168
  return {"score": 50, "diagnosis": "API key NVIDIA NIM Paligemma não configurada.", "status": "sem_dados"}
@@ -154,7 +171,7 @@ def analyse_thumbnail_with_paligemma(path: str, card: Mapping[str, Any]) -> dict
154
171
  {"type": "text", "text": "Evaluate this thumbnail for YouTube Growth. Return JSON with score, diagnosis, contrast, mobile_readability, text_words, hierarchy, emotion, overlap_risk."},
155
172
  {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{data}"}},
156
173
  ]}], "max_tokens": 500, "temperature": 0.1}
157
- response = requests.post(f"{base}/chat/completions", headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, json=payload, timeout=120)
174
+ response = requests.post(base, headers={"Authorization": f"Bearer {key}", "Accept": "application/json", "Content-Type": "application/json"}, json=payload, timeout=120)
158
175
  response.raise_for_status()
159
176
  content = response.json().get("choices", [{}])[0].get("message", {}).get("content", "")
160
177
  match = re.search(r"\{.*\}", str(content), flags=re.S)
@@ -165,13 +182,14 @@ def analyse_thumbnail_with_paligemma(path: str, card: Mapping[str, Any]) -> dict
165
182
 
166
183
 
167
184
  def _report_markdown(record: Mapping[str, Any]) -> str:
168
- lines = [f"# CHANNEL AUDIT REPORT: {record.get('channel_name', 'Canal')}", "", f"- Código: `{record.get('code')}`", f"- Plataforma: YouTube", f"- Data: {record.get('created_at')}", f"- Nota geral: **{record.get('overall_score', 0)}/100**", "", "## Diagnóstico por métrica", "", "| Métrica | Nota | Evidência | Diagnóstico |", "|---|---:|---|---|"]
185
+ lines = [f"# CHANNEL AUDIT REPORT: {record.get('channel_name', 'Canal')}", "", f"- Código: `{record.get('code')}`", "- Plataforma: YouTube", f"- Data: {record.get('created_at')}", f"- Nota geral: **{record.get('overall_score', 0)}/100**", "", "## 1. Projecção financeira e contexto", "", "A análise pública não expõe RPM, país da audiência nem horas de visualização. O relatório não inventa estes valores. Para referência do agente, o YPP usa 1.000 inscritos e 4.000 horas de vídeos longos em 12 meses ou 10 milhões de visualizações de Shorts em 90 dias até 31/01/2027; a partir de 01/02/2027 a meta de Shorts indicada pelo agente é 20 milhões.", "", "## 2. Resumo do diagnóstico", "", "| Pilar | Nota | Métrica observada | Resultado |", "|---|---:|---|---|"]
186
+ pillar_names = {"Demanda validada": "Demanda", "Qualidade da thumbnail": "Thumbnail", "Qualidade do título": "Título", "Hook — retenção inicial": "Hook", "Ritmo e edição": "Pacing", "Origem do tráfego": "Tráfego", "Conversão em inscritos": "CTA", "Cadência de publicação": "Cadência"}
169
187
  for metric in record.get("metrics", []):
170
- lines.append(f"| {metric['label']} | {metric['score']}/100 | {metric['value']} | {metric['diagnosis']} |")
171
- lines += ["", "## Últimos 10 vídeos", "", "| Título | Visualizações | Thumbnail | Transcrição | Nota do título |", "|---|---:|---|---|---:|"]
188
+ lines.append(f"| **{pillar_names.get(metric['label'], metric['label'])}** | {metric['score']}/100 | {metric['value']} | {metric['diagnosis']} |")
189
+ lines += ["", "## 3. Últimos 10 vídeos", "", "| Título | Visualizações | Thumbnail | Transcrição | Nota do título |", "|---|---:|---|---|---:|"]
172
190
  for video in record.get("videos", []):
173
191
  lines.append(f"| {video.get('title', '').replace('|', '/')} | {video.get('view_count', 0):,} | {video.get('thumbnail_status', '')} | {video.get('transcript_status', '')} | {video.get('title_score', 0)}/100 |")
174
- lines += ["", "## Top 3 prioridades", "", "1. Melhorar primeiro thumbnails e títulos abaixo de 70, aumentando contraste e reduzindo texto.", "2. Reforçar o hook inicial com o resultado ou promessa nos primeiros segundos.", "3. Introduzir CTA contextual e pattern interrupts ao longo do vídeo.", "", "## Limitações", "", "CTR, retenção real, origem de tráfego, RPM e horas de visualização exigem dados autorizados do YouTube Studio e não são inventados a partir da página pública."]
192
+ lines += ["", "## 4. Actionable Roadmap — Top 3 prioridades", "", "1. **REMAKE THUMBNAILS** dos vídeos abaixo de 70: aumentar contraste, sujeito claro, emoção forte e máximo de três palavras legíveis no telemóvel.", "2. **REWRITE TITLES** com Curiosity, Clarity e Urgency; colocar a keyword principal no início e usar números/adjectivos de poder quando forem verdadeiros.", "3. **EDIT HOOK**: começar pelo resultado, promessa ou estatística forte e introduzir pattern interrupts a cada 5–8 segundos.", "", "## 5. Estratégia de longo prazo", "", "Construir uma biblioteca de pelo menos 50 vídeos evergreen, publicar com cadência semanal fixa e procurar que Browse Features se torne a principal origem de tráfego. Para um criador solo, consistência supera frequência; melhorar Thumbnail + Título antes de alterar a produção.", "", "## Limitações", "", "CTR, retenção real, origem de tráfego, RPM e horas de visualização exigem dados autorizados do YouTube Studio e não são inventados a partir da página pública."]
175
193
  return "\n".join(lines) + "\n"
176
194
 
177
195
 
@@ -184,15 +202,32 @@ def run_audit(channel: Mapping[str, Any], settings: Mapping[str, Any], *, vision
184
202
  if not channel_url:
185
203
  raise RuntimeError("O canal seleccionado não tem URL pública ou handle YouTube.")
186
204
  notify("A buscar o canal e os 10 vídeos mais recentes…")
187
- videos = _public_videos(channel_url, 10)
205
+ try:
206
+ videos = _public_videos(channel_url, 10)
207
+ except Exception as exc:
208
+ failed = {"code": code, "channel_id": str(channel.get("id") or ""), "channel_name": str(channel.get("name") or "Canal"), "platform": "YouTube", "created_at": datetime.now(timezone.utc).isoformat(), "status": "failed", "error": str(exc)[:500]}
209
+ analyses = read_json(ANALYSES_FILENAME, [])
210
+ if not isinstance(analyses, list): analyses = []
211
+ analyses.append(failed)
212
+ write_json(ANALYSES_FILENAME, analyses[-100:])
213
+ raise
188
214
  if not videos:
189
- raise RuntimeError("Não foram encontrados vídeos públicos no canal seleccionado.")
215
+ failed = {"code": code, "channel_id": str(channel.get("id") or ""), "channel_name": str(channel.get("name") or "Canal"), "platform": "YouTube", "created_at": datetime.now(timezone.utc).isoformat(), "status": "failed", "error": "Não foram encontrados vídeos públicos no canal seleccionado."}
216
+ analyses = read_json(ANALYSES_FILENAME, [])
217
+ if not isinstance(analyses, list): analyses = []
218
+ analyses.append(failed)
219
+ write_json(ANALYSES_FILENAME, analyses[-100:])
220
+ raise RuntimeError(failed["error"])
190
221
  title_scores: list[int] = []
191
222
  transcript_scores: list[int] = []
192
223
  for index, video in enumerate(videos, 1):
193
224
  notify(f"A analisar vídeo {index}/{len(videos)}…")
194
- video["thumbnail_path"] = _download_thumbnail(video, root / "thumbnails")
195
- video["thumbnail_status"] = "baixada"
225
+ try:
226
+ video["thumbnail_path"] = _download_thumbnail(video, root / "thumbnails")
227
+ video["thumbnail_status"] = "baixada"
228
+ except Exception as exc:
229
+ video["thumbnail_path"] = ""
230
+ video["thumbnail_status"] = f"erro: {str(exc)[:120]}"
196
231
  text, status = _transcript(str(video["id"]))
197
232
  video["transcript_status"] = status
198
233
  video["transcript_path"] = str(root / "transcripts" / f"{video['id']}.txt")
@@ -202,7 +237,7 @@ def run_audit(channel: Mapping[str, Any], settings: Mapping[str, Any], *, vision
202
237
  video["transcript_score"], video["transcript_diagnosis"] = _analyse_transcript_with_llm(text, settings)
203
238
  title_scores.append(video["title_score"])
204
239
  transcript_scores.append(video["transcript_score"])
205
- if vision_card:
240
+ if vision_card and video.get("thumbnail_path"):
206
241
  try: video["vision"] = analyse_thumbnail_with_paligemma(video["thumbnail_path"], vision_card)
207
242
  except Exception as exc: video["vision"] = {"score": 50, "status": "erro", "diagnosis": str(exc)[:220]}
208
243
  else: video["vision"] = {"score": 50, "status": "sem_dados", "diagnosis": "Provider Paligemma não configurado."}
@@ -55,7 +55,7 @@ LLM_PROVIDER_CATALOG: tuple[LlmProviderDefinition, ...] = (
55
55
  "nvidia_nim_paligemma",
56
56
  "OpenAI / NVIDIA NIM (Paligemma)",
57
57
  show_base_url=True,
58
- default_base_url="https://integrate.api.nvidia.com/v1",
58
+ default_base_url="https://ai.api.nvidia.com/v1/vlm/google/paligemma",
59
59
  supports_model_discovery=False,
60
60
  excluded_from_normal_pool=True,
61
61
  description="Provider exclusivo do Analista Growth YouTube para análise visual de thumbnails.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.5.43",
3
+ "version": "0.5.44",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",