@danhachuel/thunderbolt 0.5.52 → 0.5.54
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 +8 -0
- package/hermes_ui/growth_youtube.py +28 -17
- package/integrations/youtube_growth_api.py +89 -0
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -1521,6 +1521,14 @@ def render_growth_youtube():
|
|
|
1521
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
1522
|
]
|
|
1523
1523
|
overall = int(selected_record.get("overall_score", 0)) if selected_record else None
|
|
1524
|
+
if selected_record:
|
|
1525
|
+
analytics_state = (selected_record.get("analytics") or {}).get("status", "not_connected")
|
|
1526
|
+
if analytics_state == "ready":
|
|
1527
|
+
st.success("Dados internos activos: YouTube Analytics OAuth aplicado à análise.")
|
|
1528
|
+
elif analytics_state in {"requires_authorization", "not_connected"}:
|
|
1529
|
+
st.info("Dados públicos activos. Para CTR, retenção, tráfego, RPM e demografia, autorize a conta Google proprietária do canal; os valores privados não são estimados como reais.")
|
|
1530
|
+
else:
|
|
1531
|
+
st.warning("A consulta YouTube Analytics não ficou disponível; os cartões afectados estão marcados como estimados ou indisponíveis.")
|
|
1524
1532
|
color = _growth_score_color(overall) if overall is not None else "#64748b"
|
|
1525
1533
|
score_text = str(overall) if overall is not None else "—"
|
|
1526
1534
|
score_suffix = "/100" if overall is not None else ""
|
|
@@ -39,7 +39,7 @@ def _safe_int(value: Any, default: int = 0) -> int:
|
|
|
39
39
|
return default
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def _public_videos(channel_url: str, limit: int =
|
|
42
|
+
def _public_videos(channel_url: str, limit: int = 3) -> list[dict[str, Any]]:
|
|
43
43
|
try:
|
|
44
44
|
import yt_dlp
|
|
45
45
|
except ImportError as exc:
|
|
@@ -183,14 +183,15 @@ def analyse_thumbnail_with_paligemma(path: str, card: Mapping[str, Any]) -> dict
|
|
|
183
183
|
|
|
184
184
|
|
|
185
185
|
def _report_markdown(record: Mapping[str, Any]) -> str:
|
|
186
|
-
|
|
186
|
+
analytics = record.get("analytics") or {}
|
|
187
|
+
lines = [f"# CHANNEL AUDIT REPORT: {record.get('channel_name', 'Canal')}", "", f"- Código: `{record.get('code')}`", "- Plataforma: YouTube", f"- Data: {record.get('created_at')}", f"- Nota geral: **{record.get('overall_score', 0)}/100**", "", "## 1. Projecção financeira e contexto", "", "A análise pública não expõe RPM, país da audiência nem horas de visualização. O relatório não inventa estes valores. Para referência do agente, o YPP usa 1.000 inscritos e 4.000 horas de vídeos longos em 12 meses ou 10 milhões de visualizações de Shorts em 90 dias até 31/01/2027; a partir de 01/02/2027 a meta de Shorts indicada pelo agente é 20 milhões.", "", "## 2. Resumo do diagnóstico", "", "| Pilar | Nota | Métrica observada | Origem | Diagnóstico |", "|---|---:|---|---|---|"]
|
|
187
188
|
pillar_names = {"Demanda validada": "Demanda", "Qualidade da thumbnail": "Thumbnail", "Qualidade do título": "Título", "Hook — retenção inicial": "Hook", "Ritmo e edição": "Pacing", "Origem do tráfego": "Tráfego", "Conversão em inscritos": "CTA", "Cadência de publicação": "Cadência"}
|
|
188
189
|
for metric in record.get("metrics", []):
|
|
189
|
-
lines.append(f"| **{pillar_names.get(metric['label'], metric['label'])}** | {metric['score']}/100 | {metric['value']} | {metric['diagnosis']} |")
|
|
190
|
-
lines += ["", "## 3. Últimos
|
|
190
|
+
lines.append(f"| **{pillar_names.get(metric['label'], metric['label'])}** | {metric['score']}/100 | {metric['value']} | {metric.get('source', 'unknown')} | {metric['diagnosis']} |")
|
|
191
|
+
lines += ["", "## 3. Últimos 3 vídeos", "", "| Título | Visualizações | Thumbnail | Transcrição | Nota do título |", "|---|---:|---|---|---:|"]
|
|
191
192
|
for video in record.get("videos", []):
|
|
192
193
|
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 |")
|
|
193
|
-
lines += ["", "## 4. Actionable Roadmap — Top 3 prioridades", "", "1. **REMAKE THUMBNAILS** dos vídeos abaixo de 70: aumentar contraste, sujeito claro, emoção forte e máximo de três palavras legíveis no telemóvel.", "2. **REWRITE TITLES** com Curiosity, Clarity e Urgency; colocar a keyword principal no início e usar números/adjectivos de poder quando forem verdadeiros.", "3. **EDIT HOOK**: começar pelo resultado, promessa ou estatística forte e introduzir pattern interrupts a cada 5–8 segundos.", "", "##
|
|
194
|
+
lines += ["", "## 4. Dados internos do YouTube Studio", "", f"Estado Analytics: **{analytics.get('status', 'not_connected')}**. {analytics.get('message', 'Sem OAuth Analytics autorizado; os valores privados permanecem indisponíveis ou estimados.')}", "", "## 5. Actionable Roadmap — Top 3 prioridades", "", "1. **REMAKE THUMBNAILS** dos vídeos abaixo de 70: aumentar contraste, sujeito claro, emoção forte e máximo de três palavras legíveis no telemóvel.", "2. **REWRITE TITLES** com Curiosity, Clarity e Urgency; colocar a keyword principal no início e usar números/adjectivos de poder quando forem verdadeiros.", "3. **EDIT HOOK**: começar pelo resultado, promessa ou estatística forte e introduzir pattern interrupts a cada 5–8 segundos.", "", "## 6. Estratégia de longo prazo", "", "Construir uma biblioteca de pelo menos 50 vídeos evergreen, publicar com cadência semanal fixa e procurar que Browse Features se torne a principal origem de tráfego. Para um criador solo, consistência supera frequência; melhorar Thumbnail + Título antes de alterar a produção.", "", "## Limitações", "", "CTR, retenção real, origem de tráfego, RPM e horas de visualização exigem dados autorizados do YouTube Studio e não são inventados a partir da página pública."]
|
|
194
195
|
return "\n".join(lines) + "\n"
|
|
195
196
|
|
|
196
197
|
|
|
@@ -202,9 +203,9 @@ def run_audit(channel: Mapping[str, Any], settings: Mapping[str, Any], *, vision
|
|
|
202
203
|
channel_url = str(channel.get("url") or channel.get("handle") or "").strip()
|
|
203
204
|
if not channel_url:
|
|
204
205
|
raise RuntimeError("O canal seleccionado não tem URL pública ou handle YouTube.")
|
|
205
|
-
notify("A buscar o canal e os
|
|
206
|
+
notify("A buscar o canal e os 3 vídeos mais recentes…")
|
|
206
207
|
try:
|
|
207
|
-
videos = _public_videos(channel_url,
|
|
208
|
+
videos = _public_videos(channel_url, 3)
|
|
208
209
|
except Exception as exc:
|
|
209
210
|
failed = {"code": code, "channel_id": str(channel.get("id") or ""), "channel_name": str(channel.get("name") or "Canal"), "platform": "YouTube", "created_at": datetime.now(timezone.utc).isoformat(), "status": "failed", "error": str(exc)[:500]}
|
|
210
211
|
analyses = read_json(ANALYSES_FILENAME, [])
|
|
@@ -242,19 +243,29 @@ def run_audit(channel: Mapping[str, Any], settings: Mapping[str, Any], *, vision
|
|
|
242
243
|
try: video["vision"] = analyse_thumbnail_with_paligemma(video["thumbnail_path"], vision_card)
|
|
243
244
|
except Exception as exc: video["vision"] = {"score": 50, "status": "erro", "diagnosis": str(exc)[:220]}
|
|
244
245
|
else: video["vision"] = {"score": 50, "status": "sem_dados", "diagnosis": "Provider Paligemma não configurado."}
|
|
246
|
+
analytics: dict[str, Any]
|
|
247
|
+
try:
|
|
248
|
+
from integrations.youtube_growth_api import query_channel_analytics
|
|
249
|
+
analytics = query_channel_analytics(channel, settings, STORAGE)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
analytics = {"status": "query_failed", "message": str(exc)[:300]}
|
|
252
|
+
analytics_values = analytics.get("values", {}) if analytics.get("status") == "ready" else {}
|
|
253
|
+
analytics_source = "youtube_analytics_oauth" if analytics_values else "estimated_proxy"
|
|
254
|
+
analytics_note = "YouTube Analytics OAuth" if analytics_values else str(analytics.get("message") or "Sem dados internos; proxy estimado")
|
|
245
255
|
thumb_scores = [int((video.get("vision") or {}).get("score", 50)) for video in videos]
|
|
246
256
|
metrics = [
|
|
247
|
-
{"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."},
|
|
248
|
-
{"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."},
|
|
249
|
-
{"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."},
|
|
250
|
-
{"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."},
|
|
251
|
-
{"label": "Ritmo e edição", "score": 50, "value": "
|
|
252
|
-
{"label": "Origem do tráfego", "score": 50, "value": "
|
|
253
|
-
{"label": "Conversão em inscritos", "score": 50, "value": "
|
|
254
|
-
{"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."},
|
|
257
|
+
{"label": "Demanda validada", "score": 50, "value": f"{len(videos)} vídeos públicos", "source": "public", "diagnosis": "Amostra pública recolhida; volume de pesquisa/concorrência não disponível."},
|
|
258
|
+
{"label": "Qualidade da thumbnail", "score": round(sum(thumb_scores) / len(thumb_scores)), "value": f"{len(videos)} thumbnails analisadas", "source": "paligemma" if any((video.get("vision") or {}).get("status") == "analisado" for video in videos) else "estimated", "diagnosis": "Avaliação Paligemma/NIM ou sem dados quando não configurado."},
|
|
259
|
+
{"label": "Qualidade do título", "score": round(sum(title_scores) / len(title_scores)), "value": f"{len(videos)} títulos analisados", "source": "public", "diagnosis": "Curiosidade, clareza, urgência, palavras-chave e números."},
|
|
260
|
+
{"label": "Hook — retenção inicial", "score": round(sum(transcript_scores) / len(transcript_scores)), "value": "Análise textual das aberturas", "source": "estimated", "diagnosis": "Inferência textual; retenção percentual real não está disponível."},
|
|
261
|
+
{"label": "Ritmo e edição", "score": _safe_int(analytics_values.get("averageViewPercentage"), 50) if analytics_values else 50, "value": "Percentagem média assistida (OAuth)" if analytics_values else "Proxy: sem curva de retenção", "source": analytics_source, "diagnosis": analytics_note},
|
|
262
|
+
{"label": "Origem do tráfego", "score": 50, "value": "Dados de tráfego (OAuth)" if analytics_values else "Indisponível sem OAuth", "source": analytics_source if analytics_values else "unavailable", "diagnosis": analytics_note},
|
|
263
|
+
{"label": "Conversão em inscritos", "score": 50, "value": f"{analytics_values.get('subscribersGained', '—')} ganhos / {analytics_values.get('subscribersLost', '—')} perdidos" if analytics_values else "Indisponível sem Analytics", "source": analytics_source if analytics_values else "unavailable", "diagnosis": analytics_note},
|
|
264
|
+
{"label": "Cadência de publicação", "score": 50, "value": "Datas públicas recolhidas", "source": "public", "diagnosis": "Cadência detalhada será calculada quando datas válidas forem fornecidas."},
|
|
255
265
|
]
|
|
256
|
-
|
|
257
|
-
|
|
266
|
+
weights = {"Qualidade da thumbnail": 0.30, "Qualidade do título": 0.25, "Hook — retenção inicial": 0.25, "Conversão em inscritos": 0.20}
|
|
267
|
+
overall = round(sum(metric["score"] * weights.get(metric["label"], 0) for metric in metrics) / sum(weights.values()))
|
|
268
|
+
record = {"code": code, "channel_id": str(channel.get("youtube_channel_id") or 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), "score_weights": weights, "analytics": analytics, "metrics": metrics, "videos": videos, "status": "completed"}
|
|
258
269
|
report_path = root / f"{code}.md"
|
|
259
270
|
report_path.write_text(_report_markdown(record), encoding="utf-8")
|
|
260
271
|
record["report_path"] = str(report_path)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
from .youtube_batch import account_status, token_path
|
|
9
|
+
|
|
10
|
+
ANALYTICS_SCOPE = "https://www.googleapis.com/auth/yt-analytics.readonly"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _text(value: Any) -> str:
|
|
14
|
+
return str(value or "").strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _accounts(settings: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
18
|
+
value = settings.get("youtube_batch_accounts", [])
|
|
19
|
+
return [dict(item) for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find_account_for_channel(channel: Mapping[str, Any], settings: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
23
|
+
channel_id = _text(channel.get("youtube_channel_id") or channel.get("id"))
|
|
24
|
+
account_id = _text(channel.get("google_account_id"))
|
|
25
|
+
accounts = _accounts(settings)
|
|
26
|
+
for account in accounts:
|
|
27
|
+
if account_id and _text(account.get("id")) == account_id:
|
|
28
|
+
return account
|
|
29
|
+
known_channels = account.get("channels", [])
|
|
30
|
+
if isinstance(known_channels, list) and any(_text(item.get("youtube_channel_id") or item.get("id")) == channel_id for item in known_channels if isinstance(item, Mapping)):
|
|
31
|
+
return account
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_credentials(account: Mapping[str, Any], storage_root: Path) -> tuple[Any | None, str]:
|
|
36
|
+
path = token_path(storage_root, dict(account))
|
|
37
|
+
status = account_status(dict(account), storage_root)
|
|
38
|
+
if not status.ok:
|
|
39
|
+
return None, str(status.message)
|
|
40
|
+
try:
|
|
41
|
+
from google.auth.transport.requests import Request
|
|
42
|
+
from google.oauth2.credentials import Credentials
|
|
43
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
44
|
+
scopes = list(raw.get("scopes") or []) if isinstance(raw, dict) else []
|
|
45
|
+
if ANALYTICS_SCOPE not in scopes:
|
|
46
|
+
return None, "OAuth autorizado sem o scope YouTube Analytics; é necessária uma nova autorização condicional."
|
|
47
|
+
credentials = Credentials.from_authorized_user_info(raw, scopes)
|
|
48
|
+
if credentials.expired and credentials.refresh_token:
|
|
49
|
+
credentials.refresh(Request())
|
|
50
|
+
path.write_text(credentials.to_json(), encoding="utf-8")
|
|
51
|
+
return credentials, "ready"
|
|
52
|
+
except ImportError:
|
|
53
|
+
return None, "Dependências Google OAuth/API não instaladas."
|
|
54
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
55
|
+
return None, f"Token OAuth inválido: {exc}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def query_channel_analytics(channel: Mapping[str, Any], settings: Mapping[str, Any], storage_root: Path, *, days: int = 28) -> dict[str, Any]:
|
|
59
|
+
account = find_account_for_channel(channel, settings)
|
|
60
|
+
if not account:
|
|
61
|
+
return {"status": "not_connected", "message": "Nenhuma conta Google OAuth compatível com este canal."}
|
|
62
|
+
credentials, message = _load_credentials(account, storage_root)
|
|
63
|
+
if credentials is None:
|
|
64
|
+
return {"status": "requires_authorization", "message": message, "email": _text(account.get("email"))}
|
|
65
|
+
channel_id = _text(channel.get("youtube_channel_id") or channel.get("id"))
|
|
66
|
+
if not channel_id:
|
|
67
|
+
return {"status": "unavailable", "message": "O canal não tem YouTube channel ID."}
|
|
68
|
+
try:
|
|
69
|
+
from googleapiclient.discovery import build
|
|
70
|
+
analytics = build("youtubeAnalytics", "v2", credentials=credentials, cache_discovery=False)
|
|
71
|
+
end_date = date.today()
|
|
72
|
+
start_date = end_date - timedelta(days=max(1, days) - 1)
|
|
73
|
+
response = analytics.reports().query(
|
|
74
|
+
ids=f"channel=={channel_id}",
|
|
75
|
+
startDate=start_date.isoformat(),
|
|
76
|
+
endDate=end_date.isoformat(),
|
|
77
|
+
metrics="views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,subscribersGained,subscribersLost",
|
|
78
|
+
).execute()
|
|
79
|
+
rows = response.get("rows", [])
|
|
80
|
+
headers = [item.get("name") for item in response.get("columnHeaders", [])]
|
|
81
|
+
values = dict(zip(headers, rows[0])) if rows else {}
|
|
82
|
+
return {"status": "ready", "source": "youtube_analytics_oauth", "window_days": days, "values": values, "email": _text(account.get("email"))}
|
|
83
|
+
except ImportError:
|
|
84
|
+
return {"status": "missing_dependencies", "message": "A biblioteca Google API Client ainda não está instalada."}
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
return {"status": "query_failed", "message": str(exc)[:300], "email": _text(account.get("email"))}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["ANALYTICS_SCOPE", "find_account_for_channel", "query_channel_analytics"]
|
package/package.json
CHANGED