@danhachuel/thunderbolt 0.5.42 → 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
@@ -1516,21 +1516,30 @@ def render_growth_youtube():
1516
1516
  st.error(str(exc)[:500])
1517
1517
  records = list_analyses()
1518
1518
  selected_record = next((item for item in reversed(records) if str(item.get("channel_id")) == str(selected.get("id"))), None)
1519
- if selected_record is None and records:
1520
- selected_record = records[-1]
1521
- metrics = selected_record.get("metrics", []) if selected_record else []
1522
- overall = int(selected_record.get("overall_score", 50)) if selected_record else 50
1523
- color = _growth_score_color(overall)
1524
- st.markdown(
1525
- f'<div style="border:1px solid #263447;border-radius:12px;padding:18px;margin:12px 0 20px;background:#101722;display:flex;align-items:center;justify-content:space-between;">'
1526
- f'<div><div style="font-size:13px;color:#94a3b8;text-transform:uppercase;letter-spacing:.08em;">Nota geral do canal</div><div style="font-size:18px;font-weight:700;margin-top:5px;">{str(selected.get("name") or "Canal")}</div></div>'
1527
- f'<div style="font-size:42px;font-weight:800;color:{color};line-height:1;">{overall}<span style="font-size:16px;color:#94a3b8;">/100</span></div></div>',
1528
- unsafe_allow_html=True,
1529
- )
1530
- if selected_record and selected_record.get("report_path"):
1531
- report_path = Path(str(selected_record["report_path"]))
1532
- if report_path.is_file():
1533
- st.download_button("BAIXAR ANALISE COMPLETA", report_path.read_bytes(), file_name=report_path.name, mime="text/markdown", key=f"growth_download_{selected_record['code']}")
1519
+ metrics = selected_record.get("metrics", []) if selected_record else [
1520
+ {"label": label, "score": 0, "value": "Aguardando análise", "diagnosis": "Clique em ANALISAR CANAL para preencher esta métrica."}
1521
+ for label in ("Demanda validada", "Qualidade da thumbnail", "Qualidade do título", "Hook — retenção inicial", "Ritmo e edição", "Origem do tráfego", "Conversão em inscritos", "Cadência de publicação")
1522
+ ]
1523
+ overall = int(selected_record.get("overall_score", 0)) if selected_record else None
1524
+ color = _growth_score_color(overall) if overall is not None else "#64748b"
1525
+ score_text = str(overall) if overall is not None else "—"
1526
+ score_suffix = "/100" if overall is not None else ""
1527
+ summary_col, download_col = st.columns([3.25, 1], gap="large")
1528
+ with summary_col:
1529
+ st.markdown(
1530
+ f'<div style="border:1px solid #263447;border-radius:12px;padding:18px;margin:12px 0 20px;background:#101722;display:flex;align-items:center;justify-content:space-between;min-height:76px;">'
1531
+ f'<div><div style="font-size:13px;color:#94a3b8;text-transform:uppercase;letter-spacing:.08em;">Nota geral do canal</div><div style="font-size:18px;font-weight:700;margin-top:5px;">{str(selected.get("name") or "Canal")}</div></div>'
1532
+ f'<div style="font-size:42px;font-weight:800;color:{color};line-height:1;">{score_text}<span style="font-size:16px;color:#94a3b8;">{score_suffix}</span></div></div>',
1533
+ unsafe_allow_html=True,
1534
+ )
1535
+ with download_col:
1536
+ st.write("")
1537
+ if selected_record and selected_record.get("report_path"):
1538
+ report_path = Path(str(selected_record["report_path"]))
1539
+ if report_path.is_file():
1540
+ st.download_button("BAIXAR ANALISE COMPLETA", report_path.read_bytes(), file_name=report_path.name, mime="text/markdown", use_container_width=True, key=f"growth_download_{selected_record['code']}")
1541
+ else:
1542
+ st.button("BAIXAR ANALISE COMPLETA", disabled=True, use_container_width=True, key="growth_download_disabled")
1534
1543
  st.subheader("Dashboard de Growth")
1535
1544
  st.caption("Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100. Clique em ANALISAR CANAL para recolher dados públicos actualizados.")
1536
1545
  for row_start in range(0, len(metrics), 4):
@@ -1554,14 +1563,17 @@ def render_growth_youtube():
1554
1563
  if not records:
1555
1564
  st.info("Ainda não existem análises guardadas.")
1556
1565
  for record in reversed(records[-20:]):
1557
- row = st.columns([2, 1, 2, 1, 1.4])
1566
+ row = st.columns([2, 1, 2.2, 1.2, 1, 1.4])
1558
1567
  row[0].write(f"**{record.get('channel_name', 'Canal')}**")
1559
1568
  row[1].write(str(record.get("platform", "YouTube")))
1560
1569
  row[2].caption(str(record.get("code", "")))
1561
- 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 "—")
1562
1572
  report_path = Path(str(record.get("report_path") or ""))
1563
1573
  if report_path.is_file():
1564
- 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")
1565
1577
 
1566
1578
 
1567
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.42",
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",