@danhachuel/thunderbolt 0.5.40 → 0.5.42

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
@@ -1442,6 +1443,127 @@ def render_home_update_controls() -> None:
1442
1443
  st.rerun()
1443
1444
 
1444
1445
 
1446
+ def _growth_score_color(score: float) -> str:
1447
+ if score <= 30:
1448
+ return "#ef4444"
1449
+ if score < 70:
1450
+ return "#f59e0b"
1451
+ return "#22c55e"
1452
+
1453
+
1454
+ def _growth_channel_metrics(channel: dict[str, Any]) -> tuple[list[dict[str, Any]], float]:
1455
+ """Calculate transparent local growth notes without fabricating Studio metrics."""
1456
+ channel_id = str(channel.get("id") or "")
1457
+ tasks = [item for item in read_json("tasks.json", []) if isinstance(item, dict) and str(item.get("channel_id") or "") == channel_id]
1458
+ completed = [item for item in tasks if str(item.get("state") or item.get("status") or "").lower() == "done"]
1459
+ with_thumbnail = [item for item in tasks if str(item.get("thumbnail_path") or (item.get("artifacts") or {}).get("thumbnail") or "").strip()]
1460
+ with_title = [item for item in tasks if str(item.get("title") or "").strip()]
1461
+ subscriber_count = channel.get("subscriber_count")
1462
+ view_count = channel.get("view_count")
1463
+ video_count = channel.get("video_count")
1464
+
1465
+ def number(value: Any) -> float | None:
1466
+ try:
1467
+ return float(value) if value is not None and str(value).strip() else None
1468
+ except (TypeError, ValueError):
1469
+ return None
1470
+
1471
+ subscribers, views, videos = number(subscriber_count), number(view_count), number(video_count)
1472
+ demand = 70 if views is not None and views >= 1_000_000 else 55 if views is not None and views >= 100_000 else 50
1473
+ thumbnail = round((len(with_thumbnail) / len(tasks)) * 100) if tasks else 50
1474
+ title = round((len(with_title) / len(tasks)) * 100) if tasks else 50
1475
+ conversion = round(min(100, (subscribers / views) * 10000)) if subscribers is not None and views and views > 0 else 50
1476
+ cadence = round(min(100, len(tasks) * 12.5)) if tasks else 50
1477
+ metrics = [
1478
+ {"label": "Demanda validada", "score": demand, "value": f"{views:,.0f} visualizações" if views is not None else "Sem dados de visualizações", "source": "Canal YouTube" if views is not None else "Requer dados do canal"},
1479
+ {"label": "Qualidade da thumbnail", "score": thumbnail, "value": f"{len(with_thumbnail)}/{len(tasks)} tarefas com thumbnail" if tasks else "Sem tarefas analisadas", "source": "Artefactos locais"},
1480
+ {"label": "Qualidade do título", "score": title, "value": f"{len(with_title)}/{len(tasks)} tarefas com título" if tasks else "Sem tarefas analisadas", "source": "Artefactos locais"},
1481
+ {"label": "Hook — retenção inicial", "score": 50, "value": "Sem dados de retenção", "source": "Requer YouTube Studio"},
1482
+ {"label": "Ritmo e edição", "score": 50, "value": "Sem gráfico de retenção", "source": "Requer YouTube Studio"},
1483
+ {"label": "Origem do tráfego", "score": 50, "value": "Sem distribuição de tráfego", "source": "Requer YouTube Studio"},
1484
+ {"label": "Conversão em inscritos", "score": conversion, "value": f"{subscribers:,.0f} inscritos / {views:,.0f} visualizações" if subscribers is not None and views is not None else "Sem dados de inscritos e visualizações", "source": "Canal YouTube" if subscribers is not None and views is not None else "Requer dados do canal"},
1485
+ {"label": "Cadência de publicação", "score": cadence, "value": f"{len(tasks)} tarefas locais" if tasks else "Sem tarefas locais", "source": "Pipeline local"},
1486
+ ]
1487
+ available = [metric["score"] for metric in metrics if metric["source"] not in {"Requer YouTube Studio", "Requer dados do canal"}]
1488
+ overall = round(sum(available) / len(available)) if available else 50
1489
+ return metrics, overall
1490
+
1491
+
1492
+ def render_growth_youtube():
1493
+ st.title("Analista Growth Youtube")
1494
+ st.caption("Auditoria pública baseada nos 3 pilares críticos e nas métricas secundárias do agente Growth.")
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)]
1496
+ if not channels:
1497
+ st.info("Cadastre pelo menos um canal YouTube em Canais YouTube para iniciar a análise.")
1498
+ return
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
+ 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']}")
1534
+ st.subheader("Dashboard de Growth")
1535
+ st.caption("Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100. Clique em ANALISAR CANAL para recolher dados públicos actualizados.")
1536
+ for row_start in range(0, len(metrics), 4):
1537
+ cols = st.columns(4, gap="small")
1538
+ for col, metric in zip(cols, metrics[row_start:row_start + 4]):
1539
+ with col:
1540
+ score = metric["score"]
1541
+ st.markdown(
1542
+ f'<div style="min-height:142px;border:1px solid #263447;border-radius:10px;padding:14px;background:#0d141e;">'
1543
+ f'<div style="font-size:13px;color:#cbd5e1;min-height:34px;">{metric["label"]}</div>'
1544
+ 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>'
1545
+ f'<div style="font-size:12px;color:#e2e8f0;margin-top:5px;">{metric["value"]}</div>'
1546
+ f'<div style="font-size:11px;color:#64748b;margin-top:5px;">{metric.get("diagnosis", "")[:100]}</div></div>',
1547
+ unsafe_allow_html=True,
1548
+ )
1549
+ st.divider()
1550
+ history_tab, dashboard_tab = st.tabs(["Últimas análises", "Leitura estratégica"])
1551
+ with dashboard_tab:
1552
+ 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.")
1553
+ with history_tab:
1554
+ if not records:
1555
+ st.info("Ainda não existem análises guardadas.")
1556
+ for record in reversed(records[-20:]):
1557
+ row = st.columns([2, 1, 2, 1, 1.4])
1558
+ row[0].write(f"**{record.get('channel_name', 'Canal')}**")
1559
+ row[1].write(str(record.get("platform", "YouTube")))
1560
+ row[2].caption(str(record.get("code", "")))
1561
+ row[3].write(f"{record.get('overall_score', 0)}/100")
1562
+ report_path = Path(str(record.get("report_path") or ""))
1563
+ 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')}")
1565
+
1566
+
1445
1567
  def render_dashboard():
1446
1568
  ui_language = current_ui_language()
1447
1569
  st.title("Thunderbolt")
@@ -8668,7 +8790,7 @@ def main():
8668
8790
  "Motion Control": lambda: render_motion_control(read_json("settings.json", {})),
8669
8791
  "UGC Products": lambda: render_ugc_products(read_json("settings.json", {})),
8670
8792
  "Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
8671
- "Analista Growth Youtube": lambda: render_edit_placeholder("Analista Growth Youtube", ""),
8793
+ "Analista Growth Youtube": render_growth_youtube,
8672
8794
  "Analista Growth Tiktok": lambda: render_edit_placeholder("Analista Growth Tiktok", ""),
8673
8795
  "Analista Growth Instagram": lambda: render_edit_placeholder("Analista Growth Instagram", ""),
8674
8796
  "Analista Facebook Pages": lambda: render_edit_placeholder("Analista Facebook Pages", ""),
@@ -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.40",
3
+ "version": "0.5.42",
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