@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 +123 -1
- 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
|
|
@@ -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":
|
|
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))
|
|
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