@danhachuel/thunderbolt 0.5.43 → 0.5.45
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 +6 -3
- package/hermes_ui/growth_youtube.py +49 -14
- package/hermes_ui/llm_providers.py +1 -1
- package/package.json +1 -1
- package/scripts/install.mjs +6 -2
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].
|
|
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[
|
|
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
|
-
|
|
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
package/scripts/install.mjs
CHANGED
|
@@ -433,13 +433,17 @@ function installRequirementIfNeeded(requirementsPath, stateKey, modules, label)
|
|
|
433
433
|
const state = readDependencyState();
|
|
434
434
|
const importsOk = importsAvailable(modules);
|
|
435
435
|
const hashMatches = state[stateKey] === currentHash;
|
|
436
|
-
|
|
436
|
+
const markerPath = join(venvPath, `.${stateKey}.sha256`);
|
|
437
|
+
const markerHash = existsSync(markerPath) ? readFileSync(markerPath, "utf8").trim() : "";
|
|
438
|
+
const persistedHashMatches = hashMatches || markerHash === currentHash;
|
|
439
|
+
if (!forceDeps && importsOk && persistedHashMatches) {
|
|
437
440
|
console.log(`${label}: dependências detectadas e reutilizadas; nenhuma reinstalação necessária.`);
|
|
438
441
|
writeDependencyState({ [stateKey]: currentHash });
|
|
439
442
|
return;
|
|
440
443
|
}
|
|
441
444
|
console.log(`${label}: dependências ausentes, incompletas ou alteradas; a instalar agora.`);
|
|
442
445
|
run(pythonBin, ["-m", "pip", "install", "-r", requirementsPath]);
|
|
446
|
+
writeFileSync(markerPath, `${currentHash}\n`, "utf8");
|
|
443
447
|
writeDependencyState({ [stateKey]: currentHash });
|
|
444
448
|
}
|
|
445
449
|
|
|
@@ -461,7 +465,7 @@ function writeSettings(moneyprinterPath) {
|
|
|
461
465
|
|
|
462
466
|
function installThunderboltDependencies(python) {
|
|
463
467
|
if (!existsSync(pythonBin)) run(python.command, [...python.args, "-m", "venv", venvPath]);
|
|
464
|
-
installRequirementIfNeeded(join(root, "requirements.txt"), "thunderbolt_requirements_sha256", ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "edge_tts", "google.auth", "google_auth_oauthlib", "googleapiclient"], "Thunderbolt");
|
|
468
|
+
installRequirementIfNeeded(join(root, "requirements.txt"), "thunderbolt_requirements_sha256", ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "edge_tts", "google.auth", "google_auth_oauthlib", "googleapiclient", "yt_dlp", "youtube_transcript_api"], "Thunderbolt");
|
|
465
469
|
}
|
|
466
470
|
|
|
467
471
|
function installMoneyPrinterDependencies(moneyprinterPath) {
|