@danhachuel/thunderbolt 0.5.53 → 0.5.55

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
@@ -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 ""
@@ -1541,20 +1549,25 @@ def render_growth_youtube():
1541
1549
  else:
1542
1550
  st.button("BAIXAR ANALISE COMPLETA", disabled=True, use_container_width=True, key="growth_download_disabled")
1543
1551
  st.subheader("Dashboard de Growth")
1544
- st.caption("Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100. Clique em ANALISAR CANAL para recolher dados públicos actualizados.")
1545
- for row_start in range(0, len(metrics), 4):
1546
- cols = st.columns(4, gap="small")
1547
- for col, metric in zip(cols, metrics[row_start:row_start + 4]):
1548
- with col:
1549
- score = metric["score"]
1550
- st.markdown(
1551
- f'<div style="min-height:142px;border:1px solid #263447;border-radius:10px;padding:14px;background:#0d141e;">'
1552
- f'<div style="font-size:13px;color:#cbd5e1;min-height:34px;">{metric["label"]}</div>'
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>'
1554
- f'<div style="font-size:12px;color:#e2e8f0;margin-top:5px;">{metric["value"]}</div>'
1555
- f'<div style="font-size:11px;color:#64748b;margin-top:5px;">{metric.get("diagnosis", "")[:100]}</div></div>',
1556
- unsafe_allow_html=True,
1557
- )
1552
+ st.caption("Visão híbrida: os 3 pilares críticos em destaque e os 5 pilares operacionais abaixo. Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100.")
1553
+ source_names = {"public": "PÚBLICO", "paligemma": "PALIGEMMA", "estimated": "DADO ESTIMADO", "unavailable": "INDISPONÍVEL", "youtube_analytics_oauth": "ANALYTICS OAUTH"}
1554
+ featured, secondary = metrics[:3], metrics[3:]
1555
+
1556
+ def growth_card(metric: dict[str, Any], featured_card: bool = False) -> str:
1557
+ score = int(metric.get("score", 0))
1558
+ source = source_names.get(str(metric.get("source", "unknown")), "A VERIFICAR")
1559
+ height = "min-height:238px" if featured_card else "min-height:190px"
1560
+ return (
1561
+ f'<div style="{height};border:1px solid #2b3b52;border-radius:14px;padding:18px;background:linear-gradient(145deg,#111c2b,#0b121c);box-shadow:0 8px 22px rgba(0,0,0,.16);">'
1562
+ f'<div style="font-size:11px;font-weight:800;letter-spacing:.08em;color:#7dd3fc;text-transform:uppercase;">{source}</div>'
1563
+ f'<div style="font-size:{"18px" if featured_card else "15px"};font-weight:750;color:#f8fafc;margin-top:9px;min-height:40px;">{metric.get("label", "Métrica")}</div>'
1564
+ f'<div style="display:flex;align-items:baseline;gap:5px;margin-top:7px;"><span style="font-size:{"42px" if featured_card else "32px"};font-weight:850;color:{_growth_score_color(score)};">{score}</span><span style="font-size:13px;color:#94a3b8;">/100</span></div>'
1565
+ f'<div style="border-top:1px solid #263447;margin-top:10px;padding-top:10px;font-size:13px;color:#e2e8f0;"><b>KPI observado</b><br>{metric.get("value", "—")}</div>'
1566
+ f'<div style="font-size:11px;color:#94a3b8;margin-top:9px;line-height:1.35;">{metric.get("diagnosis", "")[:150]}</div></div>'
1567
+ )
1568
+
1569
+ st.markdown('<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:14px 0 18px;">' + "".join(growth_card(metric, True) for metric in featured) + '</div>', unsafe_allow_html=True)
1570
+ st.markdown('<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:0 0 18px;">' + "".join(growth_card(metric) for metric in secondary) + '</div>', unsafe_allow_html=True)
1558
1571
  st.divider()
1559
1572
  history_tab, dashboard_tab = st.tabs(["Últimas análises", "Leitura estratégica"])
1560
1573
  with dashboard_tab:
@@ -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
- 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 | Resultado |", "|---|---:|---|---|"]
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.append(f"| **{pillar_names.get(metric['label'], metric['label'])}** | {metric['score']}/100 | {metric['value']} | {metric.get('source', 'unknown')} | {metric['diagnosis']} |")
190
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.", "", "## 5. 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
+ 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
 
@@ -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": "Sem vídeo/curva de retenção", "diagnosis": "Requer gráfico de retenção do YouTube Studio."},
252
- {"label": "Origem do tráfego", "score": 50, "value": "Sem dados públicos", "diagnosis": "Requer YouTube Studio."},
253
- {"label": "Conversão em inscritos", "score": 50, "value": "Sem inscritos por vídeo", "diagnosis": "Requer Analytics autorizado."},
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
- overall = round(sum(metric["score"] for metric in metrics) / len(metrics))
257
- 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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.5.53",
3
+ "version": "0.5.55",
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",