@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 +30 -18
- package/hermes_ui/growth_youtube.py +49 -14
- package/hermes_ui/llm_providers.py +1 -1
- package/package.json +1 -1
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
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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].
|
|
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[
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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://
|
|
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(
|
|
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')}`",
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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://
|
|
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