@danhachuel/thunderbolt 0.5.41 → 0.5.43

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
@@ -107,6 +107,7 @@ from hermes_ui.thumbnails import (
107
107
  from hermes_ui.draft_video import DRAFT_SETTING_SECTIONS, missing_content_fields, missing_setting_sections, normalise_saved_script, setting_widget_suffixes
108
108
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_thumbnail_prompt, generate_title_and_keywords, generate_topic_for_channel, generate_video_description, generate_video_keywords, generate_video_update_metadata
109
109
  from hermes_ui.media_generation import MediaGenerationError, format_media_generation_error, generate_image_for_card, generate_video_for_card
110
+ from hermes_ui.growth_youtube import list_analyses, run_audit
110
111
  from hermes_ui.canva_auth import authorization_url, create_pkce_pair, create_state, exchange_code
111
112
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
112
113
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
@@ -1490,22 +1491,57 @@ def _growth_channel_metrics(channel: dict[str, Any]) -> tuple[list[dict[str, Any
1490
1491
 
1491
1492
  def render_growth_youtube():
1492
1493
  st.title("Analista Growth Youtube")
1493
- st.caption("Auditoria de crescimento baseada nos 3 pilares críticos e nas métricas secundárias do agente Growth.")
1494
+ st.caption("Auditoria pública baseada nos 3 pilares críticos e nas métricas secundárias do agente Growth.")
1494
1495
  channels = [item for item in read_json("channels.json", []) if isinstance(item, dict) and item.get("active", True) and is_youtube_channel_record(item)]
1495
1496
  if not channels:
1496
1497
  st.info("Cadastre pelo menos um canal YouTube em Canais YouTube para iniciar a análise.")
1497
1498
  return
1498
- selected = st.selectbox("Canal a analisar", channels, format_func=lambda item: str(item.get("name") or "Canal sem nome"), key="growth_youtube_channel")
1499
- metrics, overall = _growth_channel_metrics(selected)
1500
- color = _growth_score_color(overall)
1501
- st.markdown(
1502
- 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;">'
1503
- 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>'
1504
- 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>',
1505
- unsafe_allow_html=True,
1506
- )
1499
+ selector_col, action_col = st.columns([1.05, 1.15], gap="small")
1500
+ with selector_col:
1501
+ selected = st.selectbox("Canal a analisar", channels, format_func=lambda item: str(item.get("name") or "Canal sem nome"), key="growth_youtube_channel")
1502
+ with action_col:
1503
+ st.write("")
1504
+ analyse_clicked = st.button("ANALISAR CANAL", type="primary", use_container_width=True, key="growth_youtube_analyse")
1505
+ if analyse_clicked:
1506
+ settings = read_json("settings.json", {})
1507
+ llm_settings, _ = ensure_llm_provider_cards(settings)
1508
+ vision_card = next((card for card in llm_settings.get(LLM_CARDS_KEY, []) if isinstance(card, dict) and card.get("provider") == "nvidia_nim_paligemma" and card.get("enabled", True)), None)
1509
+ with st.status("A preparar a análise pública…", expanded=True) as status:
1510
+ try:
1511
+ record = run_audit(selected, settings, vision_card=vision_card, progress=st.write)
1512
+ st.session_state["growth_last_analysis_code"] = record["code"]
1513
+ status.update(label="Análise concluída", state="complete")
1514
+ except Exception as exc:
1515
+ status.update(label="Análise falhou", state="error")
1516
+ st.error(str(exc)[:500])
1517
+ records = list_analyses()
1518
+ selected_record = next((item for item in reversed(records) if str(item.get("channel_id")) == str(selected.get("id"))), None)
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")
1507
1543
  st.subheader("Dashboard de Growth")
1508
- st.caption("Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100. Métricas sem fonte local aparecem explicitamente como sem dados.")
1544
+ st.caption("Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100. Clique em ANALISAR CANAL para recolher dados públicos actualizados.")
1509
1545
  for row_start in range(0, len(metrics), 4):
1510
1546
  cols = st.columns(4, gap="small")
1511
1547
  for col, metric in zip(cols, metrics[row_start:row_start + 4]):
@@ -1516,12 +1552,25 @@ def render_growth_youtube():
1516
1552
  f'<div style="font-size:13px;color:#cbd5e1;min-height:34px;">{metric["label"]}</div>'
1517
1553
  f'<div style="font-size:30px;font-weight:800;color:{_growth_score_color(score)};">{score}<span style="font-size:13px;color:#94a3b8;">/100</span></div>'
1518
1554
  f'<div style="font-size:12px;color:#e2e8f0;margin-top:5px;">{metric["value"]}</div>'
1519
- f'<div style="font-size:11px;color:#64748b;margin-top:5px;">Fonte: {metric["source"]}</div></div>',
1555
+ f'<div style="font-size:11px;color:#64748b;margin-top:5px;">{metric.get("diagnosis", "")[:100]}</div></div>',
1520
1556
  unsafe_allow_html=True,
1521
1557
  )
1522
1558
  st.divider()
1523
- st.subheader("Leitura estratégica")
1524
- st.write("Priorize primeiro as métricas abaixo de 70. A referência do agente indica que Thumbnail + Título devem ser optimizados antes de alterações de conteúdo; Hook, Ritmo e Tráfego ficam pendentes de dados do YouTube Studio.")
1559
+ history_tab, dashboard_tab = st.tabs(["Últimas análises", "Leitura estratégica"])
1560
+ with dashboard_tab:
1561
+ st.write("Priorize primeiro as métricas abaixo de 70. Thumbnail + Título devem ser optimizados antes de alterações de conteúdo; Hook, Ritmo e Tráfego ficam pendentes de dados do YouTube Studio quando não houver dados públicos.")
1562
+ with history_tab:
1563
+ if not records:
1564
+ st.info("Ainda não existem análises guardadas.")
1565
+ for record in reversed(records[-20:]):
1566
+ row = st.columns([2, 1, 2, 1, 1.4])
1567
+ row[0].write(f"**{record.get('channel_name', 'Canal')}**")
1568
+ row[1].write(str(record.get("platform", "YouTube")))
1569
+ row[2].caption(str(record.get("code", "")))
1570
+ row[3].write(f"{record.get('overall_score', 0)}/100")
1571
+ report_path = Path(str(record.get("report_path") or ""))
1572
+ 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')}")
1525
1574
 
1526
1575
 
1527
1576
  def render_dashboard():
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Mapping
10
+
11
+ import requests
12
+
13
+ from .llm_providers import ensure_llm_provider_cards
14
+ from .provider_routing import ProviderRoutingError, route_llm_json
15
+ from .storage import STORAGE, read_json, write_json
16
+
17
+ ANALYSES_FILENAME = "youtube_growth_analyses.json"
18
+ ANALYSES_DIR = STORAGE / "growth" / "youtube"
19
+ AGENT_METRICS = (
20
+ "Demanda validada", "Qualidade da thumbnail", "Qualidade do título",
21
+ "Hook — retenção inicial", "Ritmo e edição", "Origem do tráfego",
22
+ "Conversão em inscritos", "Cadência de publicação",
23
+ )
24
+
25
+
26
+ def analysis_code(now: datetime | None = None) -> str:
27
+ moment = now or datetime.now(timezone.utc)
28
+ return f"GYA-{moment.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6].upper()}"
29
+
30
+
31
+ def _score_color(score: int | float) -> str:
32
+ return "red" if score <= 30 else "yellow" if score < 70 else "green"
33
+
34
+
35
+ def _safe_int(value: Any, default: int = 0) -> int:
36
+ try:
37
+ return int(value or default)
38
+ except (TypeError, ValueError):
39
+ return default
40
+
41
+
42
+ def _public_videos(channel_url: str, limit: int = 10) -> list[dict[str, Any]]:
43
+ try:
44
+ import yt_dlp
45
+ except ImportError as exc:
46
+ raise RuntimeError("yt-dlp não está instalado. Instale as dependências do Thunderbolt.") from exc
47
+ options = {"quiet": True, "skip_download": True, "extract_flat": True, "playlistend": limit, "noplaylist": False}
48
+ with yt_dlp.YoutubeDL(options) as downloader:
49
+ info = downloader.extract_info(channel_url, download=False)
50
+ entries = info.get("entries") if isinstance(info, Mapping) else []
51
+ videos: list[dict[str, Any]] = []
52
+ for item in entries or []:
53
+ if not isinstance(item, Mapping):
54
+ continue
55
+ video_id = str(item.get("id") or "").strip()
56
+ if not video_id:
57
+ continue
58
+ videos.append({
59
+ "id": video_id,
60
+ "title": str(item.get("title") or "Sem título").strip(),
61
+ "url": str(item.get("webpage_url") or f"https://www.youtube.com/watch?v={video_id}"),
62
+ "thumbnail_url": str(item.get("thumbnail") or f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg"),
63
+ "upload_date": str(item.get("upload_date") or ""),
64
+ "duration": _safe_int(item.get("duration")),
65
+ "view_count": _safe_int(item.get("view_count")),
66
+ })
67
+ return videos[:limit]
68
+
69
+
70
+ def _download_thumbnail(video: Mapping[str, Any], directory: Path) -> str:
71
+ directory.mkdir(parents=True, exist_ok=True)
72
+ destination = directory / f"{video['id']}.jpg"
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)
77
+ return str(destination)
78
+
79
+
80
+ def _transcript(video_id: str) -> tuple[str, str]:
81
+ try:
82
+ from youtube_transcript_api import YouTubeTranscriptApi
83
+ except ImportError:
84
+ return "", "ferramenta youtube-transcript-api não instalada"
85
+ try:
86
+ api = YouTubeTranscriptApi()
87
+ fetched = api.fetch(video_id)
88
+ text = " ".join(str(item.text if hasattr(item, "text") else item.get("text", "")) for item in fetched)
89
+ return text.strip(), "disponível" if text.strip() else "vazia"
90
+ except Exception as exc:
91
+ return "", str(exc)[:180]
92
+
93
+
94
+ def _title_score(title: str) -> tuple[int, str]:
95
+ words = re.findall(r"[\wÀ-ÿ'-]+", title, flags=re.UNICODE)
96
+ lower = title.lower()
97
+ points = 20
98
+ if 3 <= len(words) <= 12: points += 20
99
+ if any(token in lower for token in ("como", "por que", "why", "how", "what", "segredo", "mistério", "mistério")): points += 20
100
+ if re.search(r"\d", title): points += 15
101
+ if any(token in lower for token in ("novo", "incrível", "proven", "explosive", "urgente", "fatal")): points += 10
102
+ if len(title) <= 70: points += 15
103
+ return min(100, points), "Curiosidade, clareza, keyword inicial e uso de números avaliados heurísticamente."
104
+
105
+
106
+ def _transcript_score(text: str) -> tuple[int, str]:
107
+ if not text:
108
+ return 50, "Sem transcrição pública disponível; não foi inferida retenção."
109
+ lower = text.lower()
110
+ score = 45
111
+ if len(text) >= 400: score += 15
112
+ if any(token in lower[:1800] for token in ("hoje", "neste vídeo", "today", "in this video")): score += 15
113
+ if any(token in lower for token in ("inscreva", "subscribe", "comente", "comment")): score += 15
114
+ if text.count(".") >= 20: score += 10
115
+ return min(100, score), "Hook, clareza de abertura, CTA e cadência textual avaliados; retenção real requer YouTube Studio."
116
+
117
+
118
+ def _analyse_transcript_with_llm(text: str, settings: Mapping[str, Any]) -> tuple[int, str]:
119
+ if not text:
120
+ return _transcript_score(text)
121
+ try:
122
+ result = route_llm_json(
123
+ settings,
124
+ "You are a YouTube Growth Auditor. Return JSON only with score (0-100), diagnosis, hook, pacing, cta. Do not invent retention percentages.",
125
+ "Analyse this transcript against the Growth Auditor criteria. Focus on the first 30 seconds, pattern interrupts, clarity and CTA.\n\n" + text[:14000],
126
+ )
127
+ payload = result.payload
128
+ choices = payload.get("choices") if isinstance(payload, Mapping) else []
129
+ content = choices[0].get("message", {}).get("content", "") if choices and isinstance(choices[0], Mapping) else ""
130
+ match = re.search(r"\{.*\}", str(content), flags=re.S)
131
+ parsed = json.loads(match.group(0)) if match else {}
132
+ score = max(0, min(100, _safe_int(parsed.get("score"), _transcript_score(text)[0])))
133
+ diagnosis = str(parsed.get("diagnosis") or _transcript_score(text)[1])[:500]
134
+ return score, diagnosis
135
+ except (ProviderRoutingError, ValueError, TypeError, KeyError):
136
+ return _transcript_score(text)
137
+
138
+
139
+ def _vision_prompt(video: Mapping[str, Any]) -> str:
140
+ return ("Analyse this YouTube thumbnail for a Growth audit. Return concise JSON with score (0-100), "
141
+ "contrast, mobile_readability, text_words, hierarchy, emotion, overlap_risk, diagnosis. "
142
+ "Use the agent criteria: high contrast, max three words, clear subject, readable at 5-inch size. "
143
+ f"Video title: {video.get('title', '')}")
144
+
145
+
146
+ def analyse_thumbnail_with_paligemma(path: str, card: Mapping[str, Any]) -> dict[str, Any]:
147
+ key = str(card.get("api_key") or "").strip()
148
+ base = str(card.get("base_url") or "https://integrate.api.nvidia.com/v1").rstrip("/")
149
+ model = str(card.get("model") or "google/paligemma").strip()
150
+ if not key:
151
+ return {"score": 50, "diagnosis": "API key NVIDIA NIM Paligemma não configurada.", "status": "sem_dados"}
152
+ data = base64.b64encode(Path(path).read_bytes()).decode("ascii")
153
+ payload = {"model": model, "messages": [{"role": "user", "content": [
154
+ {"type": "text", "text": "Evaluate this thumbnail for YouTube Growth. Return JSON with score, diagnosis, contrast, mobile_readability, text_words, hierarchy, emotion, overlap_risk."},
155
+ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{data}"}},
156
+ ]}], "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)
158
+ response.raise_for_status()
159
+ content = response.json().get("choices", [{}])[0].get("message", {}).get("content", "")
160
+ match = re.search(r"\{.*\}", str(content), flags=re.S)
161
+ parsed = json.loads(match.group(0)) if match else {"diagnosis": str(content)[:1000]}
162
+ parsed["score"] = max(0, min(100, _safe_int(parsed.get("score"), 50)))
163
+ parsed["status"] = "analisado"
164
+ return parsed
165
+
166
+
167
+ 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 |", "|---|---:|---|---|"]
169
+ 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 |", "|---|---:|---|---|---:|"]
172
+ for video in record.get("videos", []):
173
+ 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."]
175
+ return "\n".join(lines) + "\n"
176
+
177
+
178
+ def run_audit(channel: Mapping[str, Any], settings: Mapping[str, Any], *, vision_card: Mapping[str, Any] | None = None, progress: Callable[[str], None] | None = None) -> dict[str, Any]:
179
+ code = analysis_code()
180
+ root = ANALYSES_DIR / code
181
+ root.mkdir(parents=True, exist_ok=True)
182
+ notify = progress or (lambda _message: None)
183
+ channel_url = str(channel.get("url") or channel.get("handle") or "").strip()
184
+ if not channel_url:
185
+ raise RuntimeError("O canal seleccionado não tem URL pública ou handle YouTube.")
186
+ notify("A buscar o canal e os 10 vídeos mais recentes…")
187
+ videos = _public_videos(channel_url, 10)
188
+ if not videos:
189
+ raise RuntimeError("Não foram encontrados vídeos públicos no canal seleccionado.")
190
+ title_scores: list[int] = []
191
+ transcript_scores: list[int] = []
192
+ for index, video in enumerate(videos, 1):
193
+ notify(f"A analisar vídeo {index}/{len(videos)}…")
194
+ video["thumbnail_path"] = _download_thumbnail(video, root / "thumbnails")
195
+ video["thumbnail_status"] = "baixada"
196
+ text, status = _transcript(str(video["id"]))
197
+ video["transcript_status"] = status
198
+ video["transcript_path"] = str(root / "transcripts" / f"{video['id']}.txt")
199
+ Path(video["transcript_path"]).parent.mkdir(parents=True, exist_ok=True)
200
+ Path(video["transcript_path"]).write_text(text, encoding="utf-8")
201
+ video["title_score"], video["title_diagnosis"] = _title_score(video["title"])
202
+ video["transcript_score"], video["transcript_diagnosis"] = _analyse_transcript_with_llm(text, settings)
203
+ title_scores.append(video["title_score"])
204
+ transcript_scores.append(video["transcript_score"])
205
+ if vision_card:
206
+ try: video["vision"] = analyse_thumbnail_with_paligemma(video["thumbnail_path"], vision_card)
207
+ except Exception as exc: video["vision"] = {"score": 50, "status": "erro", "diagnosis": str(exc)[:220]}
208
+ else: video["vision"] = {"score": 50, "status": "sem_dados", "diagnosis": "Provider Paligemma não configurado."}
209
+ thumb_scores = [int((video.get("vision") or {}).get("score", 50)) for video in videos]
210
+ metrics = [
211
+ {"label": "Demanda validada", "score": 50, "value": f"{len(videos)} vídeos públicos", "diagnosis": "Amostra pública recolhida; volume de pesquisa/concorrência não disponível."},
212
+ {"label": "Qualidade da thumbnail", "score": round(sum(thumb_scores) / len(thumb_scores)), "value": f"{len(videos)} thumbnails analisadas", "diagnosis": "Avaliação Paligemma/NIM ou sem dados quando não configurado."},
213
+ {"label": "Qualidade do título", "score": round(sum(title_scores) / len(title_scores)), "value": f"{len(videos)} títulos analisados", "diagnosis": "Curiosidade, clareza, urgência, palavras-chave e números."},
214
+ {"label": "Hook — retenção inicial", "score": round(sum(transcript_scores) / len(transcript_scores)), "value": "Análise textual das aberturas", "diagnosis": "Inferência textual; retenção percentual real não está disponível."},
215
+ {"label": "Ritmo e edição", "score": 50, "value": "Sem vídeo/curva de retenção", "diagnosis": "Requer gráfico de retenção do YouTube Studio."},
216
+ {"label": "Origem do tráfego", "score": 50, "value": "Sem dados públicos", "diagnosis": "Requer YouTube Studio."},
217
+ {"label": "Conversão em inscritos", "score": 50, "value": "Sem inscritos por vídeo", "diagnosis": "Requer Analytics autorizado."},
218
+ {"label": "Cadência de publicação", "score": 50, "value": "Datas públicas recolhidas", "diagnosis": "Cadência detalhada será calculada quando datas válidas forem fornecidas."},
219
+ ]
220
+ overall = round(sum(metric["score"] for metric in metrics) / len(metrics))
221
+ record = {"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(), "overall_score": overall, "score_color": _score_color(overall), "metrics": metrics, "videos": videos, "status": "completed"}
222
+ report_path = root / f"{code}.md"
223
+ report_path.write_text(_report_markdown(record), encoding="utf-8")
224
+ record["report_path"] = str(report_path)
225
+ analyses = read_json(ANALYSES_FILENAME, [])
226
+ if not isinstance(analyses, list): analyses = []
227
+ analyses.append(record)
228
+ write_json(ANALYSES_FILENAME, analyses[-100:])
229
+ return record
230
+
231
+
232
+ def list_analyses() -> list[dict[str, Any]]:
233
+ value = read_json(ANALYSES_FILENAME, [])
234
+ return [dict(item) for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
235
+
236
+
237
+ __all__ = ["AGENT_METRICS", "ANALYSES_DIR", "analysis_code", "analyse_thumbnail_with_paligemma", "list_analyses", "run_audit"]
@@ -37,6 +37,7 @@ class LlmProviderDefinition:
37
37
  supports_model_discovery: bool = True
38
38
  extra_fields: tuple[str, ...] = ()
39
39
  description: str = ""
40
+ excluded_from_normal_pool: bool = False
40
41
 
41
42
 
42
43
  # Só estão aqui providers que o adaptador OpenAI-compatible existente consegue
@@ -50,6 +51,15 @@ LLM_PROVIDER_CATALOG: tuple[LlmProviderDefinition, ...] = (
50
51
  default_base_url=DEFAULT_NVIDIA_NIM_BASE_URL,
51
52
  description="OpenAI ou NVIDIA NIM através do protocolo OpenAI-compatible.",
52
53
  ),
54
+ LlmProviderDefinition(
55
+ "nvidia_nim_paligemma",
56
+ "OpenAI / NVIDIA NIM (Paligemma)",
57
+ show_base_url=True,
58
+ default_base_url="https://integrate.api.nvidia.com/v1",
59
+ supports_model_discovery=False,
60
+ excluded_from_normal_pool=True,
61
+ description="Provider exclusivo do Analista Growth YouTube para análise visual de thumbnails.",
62
+ ),
53
63
  LlmProviderDefinition("openrouter", "OpenRouter", default_base_url="https://openrouter.ai/api/v1"),
54
64
  LlmProviderDefinition("moonshot", "Moonshot / Kimi", default_base_url="https://api.moonshot.ai/v1"),
55
65
  LlmProviderDefinition("shengsuanyun", "Shengsuan Cloud", show_base_url=True),
@@ -283,7 +283,9 @@ def enabled_cards(settings: Mapping[str, Any], pool: str) -> list[dict[str, Any]
283
283
  enabled = [
284
284
  (index, card)
285
285
  for index, card in enumerate(cards)
286
- if bool(card.get("enabled", True)) and not bool(card.get("telegram_llm", False))
286
+ if bool(card.get("enabled", True))
287
+ and not bool(card.get("telegram_llm", False))
288
+ and not provider_definition(card.get("provider")).excluded_from_normal_pool
287
289
  ]
288
290
  enabled.sort(key=lambda pair: (int(pair[1].get("priority", 1)), pair[0]))
289
291
  return [card for _index, card in enabled]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.5.41",
3
+ "version": "0.5.43",
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",
package/requirements.txt CHANGED
@@ -15,6 +15,7 @@ mlxtend>=0.23,<1
15
15
  plotly>=5.18,<7
16
16
  seaborn>=0.13,<1
17
17
  yt-dlp>=2025.1.15,<2027
18
+ youtube-transcript-api>=1.2.0,<2
18
19
  ytmusicapi>=1.12,<2
19
20
  pushtunes>=2.15,<3
20
21
  matplotlib>=3.7,<4