@danhachuel/thunderbolt 0.5.80 → 0.5.82

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.
Files changed (2) hide show
  1. package/app/main.py +101 -41
  2. package/package.json +1 -1
package/app/main.py CHANGED
@@ -22,6 +22,7 @@ sys.stdout = _force_utf8_stream(sys.stdout)
22
22
  sys.stderr = _force_utf8_stream(sys.stderr)
23
23
 
24
24
  import hashlib
25
+ from html import escape
25
26
  import json
26
27
  import mimetypes
27
28
  import re
@@ -1501,8 +1502,13 @@ def render_growth_youtube():
1501
1502
  with selector_col:
1502
1503
  selected = st.selectbox("Canal a analisar", channels, format_func=lambda item: str(item.get("name") or "Canal sem nome"), key="growth_youtube_channel")
1503
1504
  with action_col:
1504
- st.write("")
1505
- analyse_clicked = st.button("ANALISAR CANAL", type="primary", use_container_width=True, key="growth_youtube_analyse")
1505
+ action_button_col, action_status_col = st.columns([1.15, 0.85], gap="small")
1506
+ with action_button_col:
1507
+ st.write("")
1508
+ analyse_clicked = st.button("ANALISAR CANAL", type="primary", use_container_width=True, key="growth_youtube_analyse")
1509
+ with action_status_col:
1510
+ if st.session_state.get("growth_analysis_status") == "complete":
1511
+ st.markdown('<div style="padding-top:9px;color:#22c55e;font-weight:700;white-space:nowrap;">✓ Análise concluída</div>', unsafe_allow_html=True)
1506
1512
  if analyse_clicked:
1507
1513
  settings = read_json("settings.json", {})
1508
1514
  llm_settings, _ = ensure_llm_provider_cards(settings)
@@ -1511,8 +1517,10 @@ def render_growth_youtube():
1511
1517
  try:
1512
1518
  record = run_audit(selected, settings, vision_card=vision_card, progress=st.write)
1513
1519
  st.session_state["growth_last_analysis_code"] = record["code"]
1520
+ st.session_state["growth_analysis_status"] = "complete"
1514
1521
  status.update(label="Análise concluída", state="complete")
1515
1522
  except Exception as exc:
1523
+ st.session_state["growth_analysis_status"] = "failed"
1516
1524
  status.update(label="Análise falhou", state="error")
1517
1525
  st.error(str(exc)[:500])
1518
1526
  records = list_analyses()
@@ -1552,23 +1560,74 @@ def render_growth_youtube():
1552
1560
  st.subheader("Dashboard de Growth")
1553
1561
  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.")
1554
1562
  source_names = {"public": "PÚBLICO", "paligemma": "PALIGEMMA", "estimated": "DADO ESTIMADO", "unavailable": "INDISPONÍVEL", "youtube_analytics_oauth": "ANALYTICS OAUTH"}
1555
- featured, secondary = metrics[:3], metrics[3:]
1556
-
1557
- def growth_card(metric: dict[str, Any], featured_card: bool = False) -> str:
1558
- score = int(metric.get("score", 0))
1559
- source = source_names.get(str(metric.get("source", "unknown")), "A VERIFICAR")
1560
- height = "min-height:238px" if featured_card else "min-height:190px"
1561
- return (
1562
- 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);">'
1563
- f'<div style="font-size:11px;font-weight:800;letter-spacing:.08em;color:#7dd3fc;text-transform:uppercase;">{source}</div>'
1564
- 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>'
1565
- 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>'
1566
- 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>'
1567
- f'<div style="font-size:11px;color:#94a3b8;margin-top:9px;line-height:1.35;">{metric.get("diagnosis", "")[:150]}</div></div>'
1568
- )
1563
+ metric_by_label = {str(item.get("label")): item for item in metrics}
1564
+ videos = selected_record.get("videos", []) if selected_record else []
1565
+ analytics_values = (selected_record.get("analytics") or {}).get("values", {}) if selected_record else {}
1566
+
1567
+ def display_value(value: Any, suffix: str = "") -> str:
1568
+ if value is None or value == "" or value == "—":
1569
+ return "—"
1570
+ return f"{value}{suffix}"
1571
+
1572
+ def number_value(value: Any) -> float | None:
1573
+ try:
1574
+ return float(value)
1575
+ except (TypeError, ValueError):
1576
+ return None
1577
+
1578
+ def status_for(value: Any, target: Any = None, *, direction: str = "above", available: bool = True) -> tuple[str, str]:
1579
+ if not available or value is None or target is None:
1580
+ return "A verificar", "#94a3b8"
1581
+ current, goal = number_value(value), number_value(target)
1582
+ if current is None or goal is None:
1583
+ return "A verificar", "#94a3b8"
1584
+ passed = current >= goal if direction == "above" else current <= goal
1585
+ return ("Meta atingida", "#22c55e") if passed else ("Atenção", "#facc15")
1586
+
1587
+ def text_status(value: Any, expected: set[str], *, available: bool = True) -> tuple[str, str]:
1588
+ if not available or value is None or value == "—":
1589
+ return "A verificar", "#94a3b8"
1590
+ return ("Meta atingida", "#22c55e") if str(value).casefold() in {item.casefold() for item in expected} else ("Atenção", "#facc15")
1591
+
1592
+ def avg_video_field(field: str) -> float | None:
1593
+ values = [number_value(video.get(field)) for video in videos]
1594
+ values = [value for value in values if value is not None and value > 0]
1595
+ return sum(values) / len(values) if values else None
1596
+
1597
+ avg_views = avg_video_field("view_count")
1598
+ avg_thumb_score = number_value(metric_by_label.get("Qualidade da thumbnail", {}).get("score"))
1599
+ title_score = number_value(metric_by_label.get("Qualidade do título", {}).get("score"))
1600
+ hook_score = number_value(metric_by_label.get("Hook — retenção inicial", {}).get("score"))
1601
+ average_percentage = number_value(analytics_values.get("averageViewPercentage"))
1602
+ average_duration = number_value(analytics_values.get("averageViewDuration"))
1603
+ gained = number_value(analytics_values.get("subscribersGained"))
1604
+ views_total = number_value(analytics_values.get("views")) or sum(number_value(video.get("view_count")) or 0 for video in videos)
1605
+ conversion = (gained / views_total * 100) if gained is not None and views_total else None
1606
+ niche = str(selected.get("niche") or selected.get("description") or "—")
1607
+ vision_values = [video.get("vision") or {} for video in videos if (video.get("vision") or {}).get("status") == "analisado"]
1608
+ contrast = next((str(item.get("contrast")).strip() for item in vision_values if item.get("contrast")), None)
1609
+ emotion = next((str(item.get("emotion")).strip() for item in vision_values if item.get("emotion")), None)
1610
+ cards = [
1611
+ ("Validação de Nicho", metric_by_label.get("Demanda validada", {}).get("score", 0), True, [("Nicho Detectado", niche, "RPM > $7", text_status(niche, {niche}, available=niche != "—")), ("Teto da Concorrência", "—", "> 500k", status_for(None)), ("Média de Views (5 vídeos)", f"{avg_views:,.0f}" if avg_views is not None else "—", "> 50k", status_for(avg_views, 50000)), ("Impressões (48h iniciais)", display_value(analytics_values.get("impressions")), "> 10k", status_for(analytics_values.get("impressions"), 10000)), ("Tráfego de Pesquisa (%)", "—", "15–40%", status_for(None))]),
1612
+ ("Thumbnail", metric_by_label.get("Qualidade da thumbnail", {}).get("score", 0), True, [("CTR (Click-Through Rate)", "—", "> 6%", status_for(None)), ("Qualidade Visual (Thumb)", f"{avg_thumb_score / 10:.1f}/10" if avg_thumb_score is not None else "—", "> 7/10", status_for(avg_thumb_score, 70)), ("Contraste da Thumbnail", contrast or "—", "Alto", text_status(contrast, {"alto", "high"}, available=bool(contrast))), ("Emoção Predominante", emotion or "—", "Medo/Surpresa", text_status(emotion, {"medo", "surpresa", "fear", "surprise"}, available=bool(emotion)))]),
1613
+ ("Título dos Vídeos", metric_by_label.get("Qualidade do título", {}).get("score", 0), True, [("Score do Título (SEO)", f"{title_score / 10:.1f}/10" if title_score is not None else "—", "> 8/10", status_for(title_score, 80)), ("Gatilho Mental no Título", "—", "> 70%", status_for(None)), ("Palavra-chave no Título", "—", "Sim", text_status(None, {"sim"}, available=False))]),
1614
+ ("Hook e Retenção", metric_by_label.get("Hook — retenção inicial", {}).get("score", 0), False, [("Queda nos Primeiros 30s", "—", "< 20%", status_for(None)), ("Retenção Média (%)", display_value(average_percentage, "%"), "> 50%", status_for(average_percentage, 50)), ("Hook (Primeiros 15s)", "Forte" if hook_score is not None and hook_score >= 70 else "—", "Promessa clara", text_status("forte" if hook_score is not None and hook_score >= 70 else None, {"forte"}, available=hook_score is not None))]),
1615
+ ("Ritmo e Edição", metric_by_label.get("Ritmo e edição", {}).get("score", 0), False, [("Pico de Abandono", "—", "Queda suave", status_for(None)), ("Tempo Médio (AVD)", f"{average_duration / 60:.1f} min" if average_duration is not None else "—", "> 4 min", status_for(average_duration, 240)), ("Estrutura do Roteiro", "Clara" if hook_score is not None and hook_score >= 60 else "—", "Clara", text_status("clara" if hook_score is not None and hook_score >= 60 else None, {"clara"}, available=hook_score is not None)), ("Densidade de Palavras-chave", "—", "> 3", status_for(None))]),
1616
+ ("Tráfego e Algoritmo", metric_by_label.get("Origem do tráfego", {}).get("score", 0), False, [("Origem: Browse (%)", "—", "> 50%", status_for(None)), ("Origem: Inscritos (%)", "—", "< 30%", status_for(None))]),
1617
+ ("Conversão e Engajamento", metric_by_label.get("Conversão em inscritos", {}).get("score", 0), False, [("Taxa Conversão (Subs/Views)", display_value(conversion, "%") if conversion is not None else "—", "> 1.5%", status_for(conversion, 1.5)), ("Taxa de Likes (Likes/Views)", "—", "> 4%", status_for(None))]),
1618
+ ("Consistência e Saúde", metric_by_label.get("Cadência de publicação", {}).get("score", 0), False, [("Cadência de Postagem", "—", "≤ 7 dias", status_for(None)), ("Biblioteca Perene (%)", "—", "> 40%", status_for(None)), ("Crescimento Líquido de Subs", display_value((gained or 0) - (number_value(analytics_values.get("subscribersLost")) or 0)) if gained is not None else "—", "Positivo", ("Meta atingida", "#22c55e") if gained is not None and (gained - (number_value(analytics_values.get("subscribersLost")) or 0)) > 0 else status_for(None))]),
1619
+ ]
1620
+
1621
+ def growth_card(card_data: tuple[str, Any, bool, list[tuple[str, str, str, tuple[str, str]]]]) -> str:
1622
+ title, raw_score, featured_card, rows = card_data
1623
+ score = int(number_value(raw_score) or 0)
1624
+ source = source_names.get(str(metric_by_label.get(title, {}).get("source", "unknown")), "A VERIFICAR")
1625
+ row_html = "".join(f'<tr><td>{escape(label)}</td><td><b>{escape(str(value))}</b></td><td>{escape(target)}</td><td><span style="color:{colour};font-weight:700;">● {escape(status)}</span></td></tr>' for label, value, target, (status, colour) in rows)
1626
+ return f'<div style="min-height:{"350px" if featured_card else "280px"};border:1px solid #2b3b52;border-radius:14px;padding:16px;background:linear-gradient(145deg,#111c2b,#0b121c);box-shadow:0 8px 22px rgba(0,0,0,.16);"><div style="font-size:11px;font-weight:800;letter-spacing:.08em;color:#7dd3fc;text-transform:uppercase;">{escape(source)}</div><div style="font-size:{"19px" if featured_card else "16px"};font-weight:750;color:#f8fafc;margin-top:8px;">{escape(title)}</div><div style="display:flex;align-items:baseline;gap:5px;margin:8px 0 10px;"><span style="font-size:{"38px" if featured_card else "30px"};font-weight:850;color:{_growth_score_color(score)};">{score}</span><span style="font-size:13px;color:#94a3b8;">/100</span></div><div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:11px;color:#cbd5e1;"><thead><tr><th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">KPI</th><th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Valor</th><th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Meta</th><th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Status</th></tr></thead><tbody>{row_html}</tbody></table></div></div>'
1569
1627
 
1570
- 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)
1571
- 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)
1628
+ st.markdown('<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:14px 0 18px;">' + "".join(growth_card(item) for item in cards[:3]) + '</div>', unsafe_allow_html=True)
1629
+ st.markdown('<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:0 0 18px;">' + "".join(growth_card(item) for item in cards[3:]) + '</div>', unsafe_allow_html=True)
1630
+ st.caption("Legenda: ● Verde = meta atingida · ● Amarelo = atenção · ● Vermelho = crítico · Cinzento = dado não disponível na fonte autorizada.")
1572
1631
  st.divider()
1573
1632
  history_tab, dashboard_tab = st.tabs(["Últimas análises", "Leitura estratégica"])
1574
1633
  with dashboard_tab:
@@ -5382,7 +5441,11 @@ def render_automation():
5382
5441
  for channel in channels:
5383
5442
  channel_id = channel["id"]
5384
5443
  with st.container(border=True):
5385
- header_cols = st.columns([0.55, 2.35, 1.35, 1.5, 1.35])
5444
+ blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
5445
+ automation_blueprint = current_blueprint
5446
+ automation_voice = current_voice
5447
+ automation_format = str(channel.get("format") or "wide")
5448
+ header_cols = st.columns([0.62, 2.15, 1.55, 1.25, 1.25, 1.45, 1.3], gap="small")
5386
5449
  with header_cols[0]:
5387
5450
  if channel.get("thumbnail_url"):
5388
5451
  st.image(channel["thumbnail_url"], width=48)
@@ -5392,18 +5455,24 @@ def render_automation():
5392
5455
  st.write(f"**{channel.get('name', 'Sem nome')}**")
5393
5456
  st.caption(channel.get("handle") or channel.get("url") or "sem URL")
5394
5457
  with header_cols[2]:
5395
- enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
5458
+ st.markdown("**Thumbnail Blueprint**")
5459
+ st.caption(str(paired_thumbnail.get("name") or "Generic_Thumbnail_Blueprint"))
5396
5460
  with header_cols[3]:
5397
- schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
5398
- blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
5399
- default_cols = st.columns([1.0, 1.0, 1.35, 1.45, 1.55, 1.0], gap="small")
5400
- with default_cols[0]:
5401
5461
  st.markdown("**Idioma do roteiro**")
5402
5462
  st.caption(video_language_label(normalize_video_language(channel.get("language") or "pt")))
5403
- with default_cols[1]:
5463
+ with header_cols[4]:
5404
5464
  st.markdown("**Nicho Padrão**")
5405
5465
  st.caption(channel_niche_label(channel))
5406
- with default_cols[3]:
5466
+ with header_cols[5]:
5467
+ st.markdown("**Fonte do vídeo**")
5468
+ st.caption(channel_video_source_value(channel.get("style_wide")))
5469
+ st.markdown("**Proporção do vídeo**")
5470
+ st.caption(str(channel.get("video_aspect_ratio") or "Landscape 16:9"))
5471
+ with header_cols[6]:
5472
+ schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
5473
+
5474
+ control_cols = st.columns([1.8, 1.8, 1.35, 1.2], gap="small")
5475
+ with control_cols[0]:
5407
5476
  automation_blueprint = st.selectbox(
5408
5477
  "Blueprint Padrão",
5409
5478
  blueprint_ids,
@@ -5411,19 +5480,8 @@ def render_automation():
5411
5480
  format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
5412
5481
  key=f"automation_blueprint_{channel_id}",
5413
5482
  )
5414
- with default_cols[2]:
5415
- paired_thumbnail = thumbnail_blueprint_for_blueprint(automation_blueprint)
5416
- st.markdown("**Fonte do vídeo**")
5417
- st.caption(channel_video_source_value(channel.get("style_wide")))
5418
- st.markdown("**Proporção do vídeo**")
5419
- st.caption(str(channel.get("video_aspect_ratio") or "Landscape 16:9"))
5420
- st.markdown("**Formato**")
5421
- st.caption(str(channel.get("format") or "wide"))
5422
- st.markdown("**Thumbnail Blueprint**")
5423
- st.caption(str(paired_thumbnail.get("name") or "Generic_Thumbnail_Blueprint"))
5424
- with default_cols[4]:
5425
- automation_format = st.selectbox("Formato", CHANNEL_FORMAT_OPTIONS, index=CHANNEL_FORMAT_OPTIONS.index(str(channel.get("format") or "wide")) if str(channel.get("format") or "wide") in CHANNEL_FORMAT_OPTIONS else 0, key=f"automation_format_{channel_id}")
5426
- with default_cols[5]:
5483
+ paired_thumbnail = thumbnail_blueprint_for_blueprint(automation_blueprint)
5484
+ with control_cols[1]:
5427
5485
  automation_voice = st.selectbox(
5428
5486
  "Narrador/Voz Padrão",
5429
5487
  voice_options,
@@ -5431,8 +5489,10 @@ def render_automation():
5431
5489
  format_func=lambda item: item or "Sem voz padrão",
5432
5490
  key=f"automation_voice_{channel_id}",
5433
5491
  )
5434
- with default_cols[5]:
5435
- if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True):
5492
+ with control_cols[2]:
5493
+ enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
5494
+ with control_cols[3]:
5495
+ if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True, type="primary"):
5436
5496
  if not valid_hhmm(schedule_time):
5437
5497
  st.error("Use o formato HH:MM, por exemplo 08:30.")
5438
5498
  else:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.5.80",
3
+ "version": "0.5.82",
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",