@danhachuel/thunderbolt 0.6.12 → 0.6.14

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
@@ -111,6 +111,8 @@ from hermes_ui.media_generation import MediaGenerationError, format_media_genera
111
111
  from hermes_ui.growth_youtube import list_analyses, run_audit
112
112
  from hermes_ui.growth_tiktok import render_growth_tiktok
113
113
  from hermes_ui.growth_instagram import render_growth_instagram
114
+ from hermes_ui.growth_facebook_pages import render_growth_facebook_pages
115
+ from hermes_ui.growth_bilibili import render_growth_bilibili
114
116
  from hermes_ui.canva_auth import authorization_url, create_pkce_pair, create_state, exchange_code
115
117
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
116
118
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
@@ -9074,8 +9076,8 @@ def main():
9074
9076
  "Analista Growth Youtube": render_growth_youtube,
9075
9077
  "Analista Growth Tiktok": render_growth_tiktok,
9076
9078
  "Analista Growth Instagram": render_growth_instagram,
9077
- "Analista Facebook Pages": lambda: render_edit_placeholder("Analista Facebook Pages", ""),
9078
- "Analista Bilibili": lambda: render_edit_placeholder("Analista Bilibili", ""),
9079
+ "Analista Facebook Pages": render_growth_facebook_pages,
9080
+ "Analista Bilibili": render_growth_bilibili,
9079
9081
  "Documentação": lambda: render_edit_placeholder("Documentação", "Seleccione um tutorial no menu expansível."),
9080
9082
  "Tutorial Meta": render_models_ai_tutorial,
9081
9083
  "Tutorial Supabase": render_supabase_tutorial,
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ from html import escape
4
+ from typing import Any
5
+
6
+ import streamlit as st
7
+
8
+
9
+ BILIBILI_CARDS: tuple[tuple[str, int, bool, tuple[tuple[str, str, str, str], ...]], ...] = (
10
+ (
11
+ "Validação de Procura",
12
+ 70,
13
+ True,
14
+ (
15
+ ("Tecto dos concorrentes", "A medir", "> 500k views", "A verificar"),
16
+ ("Volume de pesquisa", "A medir", "Procura crescente", "A verificar"),
17
+ ("Tráfego de pesquisa", "A medir", "15–30%", "A verificar"),
18
+ ("Diagnóstico", "Validar antes de produzir", "Nicho validado", "A verificar"),
19
+ ),
20
+ ),
21
+ (
22
+ "Thumbnail",
23
+ 70,
24
+ True,
25
+ (
26
+ ("CTR", "A medir", "> 6%", "A verificar"),
27
+ ("Contraste", "Alto contraste", "Fundo escuro + cor viva", "Meta"),
28
+ ("Texto", "3–5 palavras", "Legível no telemóvel", "Meta"),
29
+ ("Elementos Bilibili", "ACG quando adequado", "Visual expressivo", "Meta"),
30
+ ),
31
+ ),
32
+ (
33
+ "Título e Intenção",
34
+ 70,
35
+ True,
36
+ (
37
+ ("Estrutura", "Curiosity · Clarity · Context", "Promessa clara", "Meta"),
38
+ ("Keyword principal", "No início", "Pesquisa Bilibili", "Meta"),
39
+ ("CTR + retenção", "A medir", "CTR alto sem queda brusca", "A verificar"),
40
+ ("Formato", "Why / How to / VS", "Intenção explícita", "Meta"),
41
+ ),
42
+ ),
43
+ (
44
+ "Hook e Retenção",
45
+ 70,
46
+ False,
47
+ (
48
+ ("Retenção aos 15s", "A medir", "> 50%", "A verificar"),
49
+ ("Retenção aos 30s", "A medir", "> 70%", "A verificar"),
50
+ ("Método", "Clímax primeiro", "Resultado antes da introdução", "Meta"),
51
+ ("Abertura", "Sem introdução longa", "Primeiro frame forte", "Meta"),
52
+ ),
53
+ ),
54
+ (
55
+ "Ritmo e Edição",
56
+ 70,
57
+ False,
58
+ (
59
+ ("Pattern interrupt", "A cada 5–8s", "Sem imagens estáticas > 4s", "Meta"),
60
+ ("Quedas intermédias", "A medir", "Quedas pequenas", "A verificar"),
61
+ ("Texto danmaku", "Usar com intenção", "Comentários sobrepostos", "Meta"),
62
+ ("Cadência visual", "A medir", "Plateau de retenção", "A verificar"),
63
+ ),
64
+ ),
65
+ (
66
+ "Engagement 三连",
67
+ 70,
68
+ False,
69
+ (
70
+ ("三连率", "A medir", "> 4%", "A verificar"),
71
+ ("Likes + Coins + Favorites", "Três cliques", "Sinal de qualidade", "Meta"),
72
+ ("Danmaku", "A medir", "> 5/min", "A verificar"),
73
+ ("Comentários", "A medir", "> 1/100 views", "A verificar"),
74
+ ),
75
+ ),
76
+ (
77
+ "Tráfego e Algoritmo",
78
+ 70,
79
+ False,
80
+ (
81
+ ("Recommendation 推荐", "A medir", "40–60%", "A verificar"),
82
+ ("Search 搜索", "A medir", "15–30%", "A verificar"),
83
+ ("Followers 粉丝", "A medir", "10–30%", "A verificar"),
84
+ ("External 外部", "A medir", "< 10%", "A verificar"),
85
+ ),
86
+ ),
87
+ (
88
+ "Conversão e Saúde",
89
+ 70,
90
+ False,
91
+ (
92
+ ("Conversão em followers", "A medir", "> 1%", "A verificar"),
93
+ ("Fan Playback", "A medir", "40–60%", "A verificar"),
94
+ ("Novos viewers", "A medir", "> 20%", "A verificar"),
95
+ ("Abandono mensal", "A medir", "< 5%", "A verificar"),
96
+ ),
97
+ ),
98
+ )
99
+
100
+
101
+ def _card_html(card: tuple[str, int, bool, tuple[tuple[str, str, str, str], ...]]) -> str:
102
+ title, score, featured, rows = card
103
+ row_html = "".join(
104
+ f'<tr><td>{escape(label)}</td><td><b>{escape(value)}</b></td><td>{escape(target)}</td>'
105
+ f'<td><span style="color:#94a3b8;font-weight:700;">● {escape(status)}</span></td></tr>'
106
+ for label, value, target, status in rows
107
+ )
108
+ height = "350px" if featured else "280px"
109
+ title_size = "19px" if featured else "16px"
110
+ score_size = "38px" if featured else "30px"
111
+ return (
112
+ f'<div style="min-height:{height};border:1px solid #2b3b52;border-radius:14px;padding:16px;'
113
+ f'background:linear-gradient(145deg,#111c2b,#0b121c);box-shadow:0 8px 22px rgba(0,0,0,.16);">'
114
+ f'<div style="font-size:11px;font-weight:800;letter-spacing:.08em;color:#7dd3fc;text-transform:uppercase;">BILIBILI GROWTH</div>'
115
+ f'<div style="font-size:{title_size};font-weight:750;color:#f8fafc;margin-top:8px;">{escape(title)}</div>'
116
+ f'<div style="display:flex;align-items:baseline;gap:5px;margin:8px 0 10px;">'
117
+ f'<span style="font-size:{score_size};font-weight:850;color:#22c55e;">{score}</span>'
118
+ f'<span style="font-size:13px;color:#94a3b8;">/100 · meta do agente</span></div>'
119
+ f'<div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:11px;color:#cbd5e1;">'
120
+ f'<thead><tr><th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">KPI</th>'
121
+ f'<th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Valor</th>'
122
+ f'<th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Meta</th>'
123
+ f'<th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Estado</th></tr></thead>'
124
+ f'<tbody>{row_html}</tbody></table></div></div>'
125
+ )
126
+
127
+
128
+ def render_growth_bilibili() -> None:
129
+ st.title("Analista Bilibili")
130
+ st.caption("Como o agente opera e reporta: procura, click funnel, retenção, engagement 三连 e saúde da audiência Bilibili.")
131
+ st.info("Esta aba define o protocolo e o dashboard de reporte do agente. Os valores privados são preenchidos quando os dados autorizados do Bilibili Studio estiverem disponíveis; não são inventados a partir de páginas públicas.")
132
+
133
+ summary_col, status_col = st.columns([3.25, 1], gap="large")
134
+ with summary_col:
135
+ st.markdown(
136
+ '<div style="border:1px solid #263447;border-radius:12px;padding:18px;margin:12px 0 20px;'
137
+ 'background:#101722;display:flex;align-items:center;justify-content:space-between;min-height:76px;">'
138
+ '<div><div style="font-size:13px;color:#94a3b8;text-transform:uppercase;letter-spacing:.08em;">Estado do agente</div>'
139
+ '<div style="font-size:18px;font-weight:700;margin-top:5px;">Pronto para auditoria Bilibili</div></div>'
140
+ '<div style="font-size:18px;font-weight:800;color:#22c55e;">PROTOCOLO ACTIVO</div></div>',
141
+ unsafe_allow_html=True,
142
+ )
143
+ with status_col:
144
+ st.write("")
145
+ st.button("ANALISAR CANAL", disabled=True, use_container_width=True, key="growth_bilibili_analyse_disabled")
146
+ st.caption("A ligação ao Studio será usada quando autorizada.")
147
+
148
+ st.subheader("Dashboard de Growth")
149
+ st.caption("Os três pilares críticos aparecem primeiro; os cinco blocos operacionais completam o diagnóstico. Verde: referência de execução · Cinzento: dado a verificar no Studio.")
150
+ st.markdown('<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:14px 0 18px;">' + "".join(_card_html(card) for card in BILIBILI_CARDS[:3]) + '</div>', unsafe_allow_html=True)
151
+ st.markdown('<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:0 0 18px;">' + "".join(_card_html(card) for card in BILIBILI_CARDS[3:]) + '</div>', unsafe_allow_html=True)
152
+ st.caption("Legenda: o agente compara os dados autorizados com as metas Bilibili; quando um dado não existe, apresenta “A verificar” em vez de estimar.")
153
+
154
+ st.divider()
155
+ operation_tab, report_tab, monetization_tab = st.tabs(["Como o agente vai operar", "Como vai reportar", "Contexto de monetização"])
156
+ with operation_tab:
157
+ st.subheader("Protocolo de auditoria em 10 passos")
158
+ st.markdown("""
159
+ 1. **Validar procura:** compara o tecto de concorrentes, pesquisa e origem do tráfego.
160
+ 2. **Avaliar thumbnail:** verifica CTR, contraste, legibilidade móvel, emoção e elementos ACG quando adequados.
161
+ 3. **Avaliar título:** cruza CTR com retenção inicial e aplica Curiosity, Clarity e Context.
162
+ 4. **Verificar engagement:** mede a taxa 三连 (Like + Coin + Favorite), danmaku e qualidade dos comentários.
163
+ 5. **Diagnosticar hook:** localiza a queda dos primeiros 15–30 segundos e aplica “clímax primeiro”.
164
+ 6. **Diagnosticar ritmo:** identifica quedas recorrentes e recomenda pattern interrupts a cada 5–8 segundos.
165
+ 7. **Ler o algoritmo:** compara Recommendation, Search, Followers e External com as metas da plataforma.
166
+ 8. **Medir conversão:** calcula novos followers por visualização e posiciona a CTA aos 40% do vídeo.
167
+ 9. **Verificar cadência:** acompanha 1–2 vídeos por semana, consistência e uso de posts/live para comunidade.
168
+ 10. **Avaliar saúde:** cruza Fan Playback, New Viewer, abandono mensal e sinais moonshot.
169
+ """)
170
+ with report_tab:
171
+ st.subheader("Formato do reporte entregue pelo agente")
172
+ st.markdown("""
173
+ O agente reporta sempre em cinco blocos: **projecção financeira e contexto**, **resumo dos dez pilares**, **Top 3 prioridades accionáveis**, **estratégia de longo prazo** e **checklist dos próximos passos**. Cada KPI recebe valor observado, meta, estado e origem do dado.
174
+
175
+ As três prioridades são escolhidas por impacto: primeiro **Thumbnail + Título**, depois **retenção e engagement**, e só depois produção ou expansão. O relatório distingue claramente `DADO OBSERVADO`, `A VERIFICAR` e `RECOMENDAÇÃO`, sem transformar uma meta em resultado real.
176
+
177
+ > Regra de reporte: um vídeo com conteúdo forte, mas sem click funnel, não é considerado optimizado. O agente corrige primeiro a combinação thumbnail + título e acompanha a evolução no ciclo seguinte.
178
+ """)
179
+ st.markdown("**Checklist padrão:** validar nicho · corrigir as últimas 5 thumbnails · reescrever títulos · cortar a introdução · inserir prompt danmaku · publicar em dia fixo · rever analytics em 30 dias.")
180
+ with monetization_tab:
181
+ st.subheader("Referências internas usadas pelo agente")
182
+ st.markdown("""
183
+ A projecção considera Video Incentive Program, Charging, Paid Subscriptions e Huahuo. O cálculo interno é: `Playbacks × Base Rate × Zone Multiplier × Quality Multiplier + Ad Revenue + Tips + Subscriptions`.
184
+
185
+ Para o diagnóstico inicial, o agente acompanha originalidade do conteúdo, verificação de identidade, estado da conta, direitos ≥ 3, score de crédito ≥ 80 e os limiares de audiência definidos no material Bilibili. Estes itens são **contexto de elegibilidade**, não promessa de rendimento.
186
+
187
+ Os sinais moonshot acompanhados são densidade de danmaku (> 5 por minuto), crescimento do Search Index (+20% mensal), Long-tail Traffic (> 30%), Shares (> 1%) e Collect Rate (> 5%).
188
+ """)
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+ from html import escape
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Mapping
9
+
10
+ import streamlit as st
11
+
12
+ from .storage import STORAGE, read_json, write_json
13
+
14
+ ANALYSES_FILENAME = "facebook_pages_growth_analyses.json"
15
+ ANALYSES_DIR = STORAGE / "growth" / "facebook_pages"
16
+ AGENT_METRICS = (
17
+ "Demanda validada", "Qualidade da thumbnail", "Qualidade do título",
18
+ "Hook — retenção inicial", "Ritmo e edição", "Origem Recommended", "Conversão em seguidores", "Cadência",
19
+ )
20
+
21
+
22
+ def analysis_code(now: datetime | None = None) -> str:
23
+ moment = now or datetime.now(timezone.utc)
24
+ return f"GFA-{moment.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6].upper()}"
25
+
26
+
27
+ def _score_color(score: int | float) -> str:
28
+ return "red" if score <= 30 else "yellow" if score < 70 else "green"
29
+
30
+
31
+ def _safe_int(value: Any, default: int = 0) -> int:
32
+ try:
33
+ return int(value or default)
34
+ except (TypeError, ValueError):
35
+ return default
36
+
37
+
38
+ def _public_posts(page: Mapping[str, Any], limit: int = 5) -> list[dict[str, Any]]:
39
+ posts = page.get("posts") or page.get("recent_posts") or page.get("public_posts") or []
40
+ if isinstance(posts, list):
41
+ return [dict(item) for item in posts[:limit] if isinstance(item, Mapping)]
42
+ return []
43
+
44
+
45
+ def _pages() -> list[dict[str, Any]]:
46
+ settings = read_json("settings.json", {})
47
+ candidates: list[Any] = []
48
+ for key in ("facebook_pages", "facebook_page_accounts", "meta_pages"):
49
+ value = settings.get(key, []) if isinstance(settings, Mapping) else []
50
+ candidates.extend(value if isinstance(value, list) else [value] if isinstance(value, Mapping) else [])
51
+ for channel in read_json("channels.json", []):
52
+ if isinstance(channel, dict) and str(channel.get("platform") or "").casefold() in {"facebook", "facebook_pages", "facebook page"}:
53
+ candidates.append(channel)
54
+ unique: dict[str, dict[str, Any]] = {}
55
+ for item in candidates:
56
+ if not isinstance(item, Mapping):
57
+ continue
58
+ page = dict(item)
59
+ key = str(page.get("id") or page.get("page_id") or page.get("name") or page.get("url") or "").strip()
60
+ if key:
61
+ unique[key] = page
62
+ return [item for item in unique.values() if item.get("active", True)]
63
+
64
+
65
+ def _report_markdown(record: Mapping[str, Any]) -> str:
66
+ lines = [f"# FACEBOOK GROWTH AUDIT REPORT: {record.get('page_name', 'Página')}", "", f"- Código: `{record.get('code')}`", "- Plataforma: Facebook Pages", f"- Data: {record.get('created_at')}", f"- Nota geral: **{record.get('overall_score', 0)}/100**", "", "## Projecção financeira e contexto", "", "A auditoria pública não expõe CPM, minutos de visualização, CTR ou dados de monetização. O relatório não inventa estes valores. A elegibilidade deve confirmar 10.000 seguidores, 600.000 minutos de watch time em 60 dias, conteúdo original e conta em país elegível.", "", "## Diagnóstico", "", "| Pilar | Nota | Métrica observada | Origem | Diagnóstico |", "|---|---:|---|---|---|"]
67
+ for metric in record.get("metrics", []):
68
+ lines.append(f"| **{metric['label']}** | {metric['score']}/100 | {metric['value']} | {metric.get('source', 'unknown')} | {metric['diagnosis']} |")
69
+ lines += ["", "## Últimas publicações públicas", "", "| Título | Visualizações | Reacções | Comentários | Partilhas |", "|---|---:|---:|---:|---:|"]
70
+ for post in record.get("posts", []):
71
+ lines.append(f"| {str(post.get('title') or post.get('message') or 'Sem título').replace('|', '/')} | {_safe_int(post.get('view_count') or post.get('views')):,} | {_safe_int(post.get('like_count') or post.get('reactions')):,} | {_safe_int(post.get('comment_count') or post.get('comments')):,} | {_safe_int(post.get('share_count') or post.get('shares')):,} |")
72
+ lines += ["", "## Roadmap accionável", "", "1. **REMAKE THUMBNAILS**: aumentar contraste, usar sujeito claro, emoção forte e no máximo três palavras legíveis no telemóvel.", "2. **REWRITE TITLES**: começar pela keyword principal e combinar curiosidade, clareza e urgência sem prometer algo que o vídeo não entrega.", "3. **EDIT HOOK**: abrir com o resultado ou estatística forte; adicionar pattern interrupt a cada 3–5 segundos e CTA visual a 40% do vídeo.", "", "## Limitações", "", "CTR, retenção de 3/15 segundos, tráfego Recommended, alcance, minutos de watch time, seguidores ganhos e monetização exigem dados autorizados do Facebook Page Insights/Creator Studio."]
73
+ return "\n".join(lines) + "\n"
74
+
75
+
76
+ def run_audit(page: Mapping[str, Any], settings: Mapping[str, Any] | None = None, *, progress: Callable[[str], None] | None = None) -> dict[str, Any]:
77
+ del settings
78
+ code = analysis_code()
79
+ root = ANALYSES_DIR / code
80
+ root.mkdir(parents=True, exist_ok=True)
81
+ notify = progress or (lambda _message: None)
82
+ notify("A preparar a auditoria pública da Facebook Page…")
83
+ posts = _public_posts(page, 5)
84
+ views = sum(_safe_int(item.get("view_count") or item.get("views")) for item in posts)
85
+ reactions = sum(_safe_int(item.get("like_count") or item.get("reactions")) for item in posts)
86
+ comments = sum(_safe_int(item.get("comment_count") or item.get("comments")) for item in posts)
87
+ shares = sum(_safe_int(item.get("share_count") or item.get("shares")) for item in posts)
88
+ engagement = ((reactions + comments + shares) / views * 100) if views else None
89
+ followers = _safe_int(page.get("followers") or page.get("follower_count") or page.get("fan_count")) or None
90
+ conversion = (followers / views * 100) if followers and views else None
91
+ cadence = 70 if len(posts) >= 3 else 40 if posts else 0
92
+ public_value = f"{len(posts)} publicações públicas" if posts else "Sem publicações públicas estruturadas"
93
+ metrics = [
94
+ {"label": "Demanda validada", "score": 50 if posts else 0, "value": public_value, "source": "public", "diagnosis": "Concorrência, pesquisa e Recommended exigem Page Insights."},
95
+ {"label": "Qualidade da thumbnail", "score": 50, "value": "CTR indisponível", "source": "unavailable", "diagnosis": "CTR privado; meta do agente: acima de 6%."},
96
+ {"label": "Qualidade do título", "score": 50, "value": "Retenção cruzada indisponível", "source": "unavailable", "diagnosis": "Avaliar os 3 C's: Curiosity, Clarity e Urgency."},
97
+ {"label": "Hook — retenção inicial", "score": 50, "value": "Retenção de 3/15s indisponível", "source": "unavailable", "diagnosis": "Creator Studio é necessário para a retenção absoluta."},
98
+ {"label": "Ritmo e edição", "score": 50, "value": "Quedas de retenção indisponíveis", "source": "unavailable", "diagnosis": "Aplicar pattern interrupt a cada 3–5 segundos."},
99
+ {"label": "Origem Recommended", "score": 50, "value": "Fontes de tráfego indisponíveis", "source": "unavailable", "diagnosis": "Meta: 40–70% Recommended/Suggestions."},
100
+ {"label": "Conversão em seguidores", "score": 50 if conversion is None else (85 if conversion > 1.5 else 60 if conversion >= .5 else 30), "value": f"{conversion:.2f}% proxy" if conversion is not None else "Seguidores ganhos por publicação indisponíveis", "source": "estimated" if conversion is not None else "unavailable", "diagnosis": "Meta: acima de 1,5%; proxy não substitui seguidores ganhos."},
101
+ {"label": "Cadência", "score": cadence, "value": f"{len(posts)} publicações na amostra", "source": "public", "diagnosis": "Meta do agente: 3–5 publicações/Reels por semana."},
102
+ ]
103
+ overall = round(sum(item["score"] for item in metrics) / len(metrics))
104
+ record = {"code": code, "page_id": str(page.get("page_id") or page.get("id") or ""), "page_name": str(page.get("name") or page.get("page_name") or "Facebook Page"), "platform": "Facebook Pages", "created_at": datetime.now(timezone.utc).isoformat(), "overall_score": overall, "score_color": _score_color(overall), "metrics": metrics, "posts": posts, "sample_views": views, "engagement_rate": engagement, "monetization_status": "A verificar — confirmar requisitos no Creator Studio", "status": "completed"}
105
+ report_path = root / f"{code}.md"
106
+ report_path.write_text(_report_markdown(record), encoding="utf-8")
107
+ record["report_path"] = str(report_path)
108
+ analyses = read_json(ANALYSES_FILENAME, [])
109
+ if not isinstance(analyses, list): analyses = []
110
+ analyses.append(record)
111
+ write_json(ANALYSES_FILENAME, analyses[-100:])
112
+ return record
113
+
114
+
115
+ def _card(title: str, score: Any, featured: bool, rows: list[tuple[str, str, str, str, str]]) -> str:
116
+ numeric = _safe_int(score)
117
+ colour = "#ef4444" if numeric <= 30 else "#f59e0b" if numeric < 70 else "#22c55e"
118
+ row_html = "".join(f'<tr><td>{escape(label)}</td><td><b>{escape(value)}</b></td><td>{escape(target)}</td><td style="color:{status_colour};font-weight:700;">● {escape(status)}</td></tr>' for label, value, target, status, status_colour in rows)
119
+ return f'<div style="min-height:{"350px" if featured 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:{"19px" if featured 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 else "30px"};font-weight:850;color:{colour};">{numeric}</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>'
120
+
121
+
122
+ def render_growth_facebook_pages() -> None:
123
+ st.title("Analista Facebook Pages")
124
+ st.caption("Auditoria de Facebook Pages baseada nos 3 pilares críticos e nas métricas secundárias do agente Growth.")
125
+ pages = _pages()
126
+ if not pages:
127
+ st.info("Configure pelo menos uma Facebook Page em Configuração API ou no registo de páginas para iniciar a análise.")
128
+ return
129
+ selector_col, action_col = st.columns([1.05, 1.15], gap="small")
130
+ with selector_col:
131
+ selected = st.selectbox("Página a analisar", pages, format_func=lambda item: str(item.get("name") or item.get("page_name") or "Página sem nome"), key="growth_facebook_page")
132
+ with action_col:
133
+ st.write("")
134
+ analyse_clicked = st.button("ANALISAR PÁGINA", type="primary", use_container_width=True, key="growth_facebook_analyse")
135
+ st.markdown("**Estado das APIs de Growth**")
136
+ api_cols = st.columns(2, gap="small")
137
+ with api_cols[0]:
138
+ st.caption("Dados públicos e registos locais")
139
+ st.markdown('<span style="color:#22c55e;font-weight:700;">● Disponível quando houver publicações guardadas</span>', unsafe_allow_html=True)
140
+ with api_cols[1]:
141
+ st.caption("Facebook Page Insights / Creator Studio")
142
+ st.markdown('<span style="color:#94a3b8;font-weight:700;">● Requer dados privados autorizados</span>', unsafe_allow_html=True)
143
+ if analyse_clicked:
144
+ with st.status("A preparar a análise…", expanded=True) as status:
145
+ try:
146
+ record = run_audit(selected, read_json("settings.json", {}), progress=st.write)
147
+ status.update(label="Análise concluída", state="complete")
148
+ except Exception as exc:
149
+ status.update(label="Análise falhou", state="error")
150
+ st.error(str(exc)[:500])
151
+ records = list_analyses()
152
+ selected_record = next((item for item in reversed(records) if str(item.get("page_id")) == str(selected.get("page_id") or selected.get("id"))), None)
153
+ metric_by_label = {str(item.get("label")): item for item in (selected_record.get("metrics", []) if selected_record else [])}
154
+ metrics = selected_record.get("metrics", []) if selected_record else [{"label": label, "score": 0, "value": "Aguardando análise", "source": "unknown"} for label in AGENT_METRICS]
155
+ score = selected_record.get("overall_score") if selected_record else None
156
+ colour = "#64748b" if score is None else "#ef4444" if score <= 30 else "#f59e0b" if score < 70 else "#22c55e"
157
+ summary_col, download_col = st.columns([3.25, 1], gap="large")
158
+ with summary_col:
159
+ st.markdown(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;"><div><div style="font-size:13px;color:#94a3b8;text-transform:uppercase;letter-spacing:.08em;">Nota geral da página</div><div style="font-size:18px;font-weight:700;margin-top:5px;">{escape(str(selected.get("name") or selected.get("page_name") or "Facebook Page"))}</div></div><div style="font-size:42px;font-weight:800;color:{colour};line-height:1;">{score if score is not None else "—"}<span style="font-size:16px;color:#94a3b8;">{"/100" if score is not None else ""}</span></div></div>', unsafe_allow_html=True)
160
+ with download_col:
161
+ st.write("")
162
+ path = Path(str(selected_record.get("report_path") or "")) if selected_record else Path()
163
+ if path.is_file():
164
+ st.download_button("BAIXAR ANALISE COMPLETA", path.read_bytes(), file_name=path.name, mime="text/markdown", use_container_width=True, key=f"facebook_download_{selected_record['code']}")
165
+ else:
166
+ st.button("BAIXAR ANALISE COMPLETA", disabled=True, use_container_width=True, key="facebook_download_disabled")
167
+ st.subheader("Dashboard de Growth")
168
+ st.caption("3 pilares críticos em destaque e camadas operacionais abaixo. Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100.")
169
+ def item(label: str, target: str, available: bool = False) -> tuple[str, str, str, str, str]:
170
+ metric = metric_by_label.get(label, {})
171
+ return (label, str(metric.get("value", "Aguardando análise")), target, "Meta atingida" if available else "A verificar", "#22c55e" if available else "#94a3b8")
172
+ cards = [("Demanda validada", True, [item("Demanda validada", "> 500k concorrência", True)]), ("Qualidade da thumbnail", True, [item("Qualidade da thumbnail", "> 6% CTR")]), ("Qualidade do título", True, [item("Qualidade do título", "3 C's")]), ("Hook — retenção inicial", False, [item("Hook — retenção inicial", "> 50% aos 3s")]), ("Ritmo e edição", False, [item("Ritmo e edição", "Interrupt 3–5s")]), ("Origem Recommended", False, [item("Origem Recommended", "40–70%")]), ("Conversão em seguidores", False, [item("Conversão em seguidores", "> 1.5%")]), ("Cadência", False, [item("Cadência", "3–5/semana")])]
173
+ st.markdown('<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:14px 0 18px;">' + "".join(_card(title, metric_by_label.get(title, {}).get("score", 0), featured, rows) for title, featured, rows in cards[:3]) + '</div>', unsafe_allow_html=True)
174
+ st.markdown('<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:0 0 18px;">' + "".join(_card(title, metric_by_label.get(title, {}).get("score", 0), featured, rows) for title, featured, rows in cards[3:]) + '</div>', unsafe_allow_html=True)
175
+ st.caption("Legenda: ● Verde = meta atingida · ● Amarelo = atenção · ● Vermelho = crítico · Cinzento = dado não disponível na fonte autorizada.")
176
+ st.divider()
177
+ history_tab, dashboard_tab = st.tabs(["Últimas análises", "Leitura estratégica"])
178
+ with dashboard_tab:
179
+ st.write("Priorize Thumbnail, Título e Hook. CTR, retenção, tráfego Recommended, minutos de watch time e monetização ficam pendentes do Page Insights/Creator Studio.")
180
+ with history_tab:
181
+ if not records:
182
+ st.info("Ainda não existem análises guardadas.")
183
+ for record in reversed(records[-20:]):
184
+ row = st.columns([2, 1, 2.2, 1.2, 1, 1.4])
185
+ row[0].write(f"**{record.get('page_name', 'Facebook Page')}**")
186
+ row[1].write("Facebook Pages")
187
+ row[2].caption(str(record.get("code", "")))
188
+ row[3].caption(str(record.get("created_at", "")).replace("T", " ")[:19])
189
+ row[4].write(f"{record.get('overall_score', '—')}/100")
190
+ path = Path(str(record.get("report_path") or ""))
191
+ if path.is_file():
192
+ row[5].download_button("Análise", path.read_bytes(), file_name=path.name, mime="text/markdown", key=f"facebook_history_{record.get('code')}")
193
+
194
+
195
+ def list_analyses() -> list[dict[str, Any]]:
196
+ value = read_json(ANALYSES_FILENAME, [])
197
+ return [dict(item) for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
198
+
199
+
200
+ __all__ = ["AGENT_METRICS", "ANALYSES_DIR", "analysis_code", "list_analyses", "run_audit", "render_growth_facebook_pages"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
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",