@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 +63 -14
- package/hermes_ui/growth_youtube.py +237 -0
- package/hermes_ui/llm_providers.py +10 -0
- package/hermes_ui/provider_routing.py +3 -1
- package/package.json +1 -1
- package/requirements.txt +1 -0
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
|
|
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
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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.
|
|
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;">
|
|
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.
|
|
1524
|
-
|
|
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))
|
|
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