@danhachuel/thunderbolt 0.6.11 → 0.6.13

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
@@ -110,6 +110,8 @@ from hermes_ui.creative_generation import CreativeGenerationError, generate_crea
110
110
  from hermes_ui.media_generation import MediaGenerationError, format_media_generation_error, generate_image_for_card, generate_video_for_card
111
111
  from hermes_ui.growth_youtube import list_analyses, run_audit
112
112
  from hermes_ui.growth_tiktok import render_growth_tiktok
113
+ from hermes_ui.growth_instagram import render_growth_instagram
114
+ from hermes_ui.growth_facebook_pages import render_growth_facebook_pages
113
115
  from hermes_ui.canva_auth import authorization_url, create_pkce_pair, create_state, exchange_code
114
116
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
115
117
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
@@ -2198,6 +2200,8 @@ def classify_channel_platform(channel: Any) -> str:
2198
2200
  return "youtube"
2199
2201
  if value in {"tiktok", "tik-tok", "tt"}:
2200
2202
  return "tiktok"
2203
+ if value in {"instagram", "ig"}:
2204
+ return "instagram"
2201
2205
  def has_tiktok_marker(item: Any) -> bool:
2202
2206
  if isinstance(item, dict):
2203
2207
  return any(has_tiktok_marker(key) or has_tiktok_marker(value) for key, value in item.items())
@@ -9070,8 +9074,8 @@ def main():
9070
9074
  "Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
9071
9075
  "Analista Growth Youtube": render_growth_youtube,
9072
9076
  "Analista Growth Tiktok": render_growth_tiktok,
9073
- "Analista Growth Instagram": lambda: render_edit_placeholder("Analista Growth Instagram", ""),
9074
- "Analista Facebook Pages": lambda: render_edit_placeholder("Analista Facebook Pages", ""),
9077
+ "Analista Growth Instagram": render_growth_instagram,
9078
+ "Analista Facebook Pages": render_growth_facebook_pages,
9075
9079
  "Analista Bilibili": lambda: render_edit_placeholder("Analista Bilibili", ""),
9076
9080
  "Documentação": lambda: render_edit_placeholder("Documentação", "Seleccione um tutorial no menu expansível."),
9077
9081
  "Tutorial Meta": render_models_ai_tutorial,
@@ -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"]
@@ -0,0 +1,264 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from html import escape
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Mapping
10
+
11
+ import streamlit as st
12
+
13
+ from integrations.instagram_public import fetch_public_instagram_profile
14
+ from .storage import STORAGE, read_json, write_json
15
+
16
+ ANALYSES_FILENAME = "instagram_growth_analyses.json"
17
+ ANALYSES_DIR = STORAGE / "growth" / "instagram"
18
+ AGENT_METRICS = (
19
+ "Demanda e potencial de viralização", "Qualidade visual — stop the scroll", "Hook e storytelling",
20
+ "Ritmo e edição", "Tráfego Explore/Reels", "Engajamento e saves", "Conversão em seguidores", "Mix de formatos", "Cadência",
21
+ )
22
+
23
+
24
+ def analysis_code(now: datetime | None = None) -> str:
25
+ moment = now or datetime.now(timezone.utc)
26
+ return f"GIA-{moment.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6].upper()}"
27
+
28
+
29
+ def _score_color(score: int | float) -> str:
30
+ return "red" if score <= 30 else "yellow" if score < 70 else "green"
31
+
32
+
33
+ def _safe_int(value: Any, default: int = 0) -> int:
34
+ try:
35
+ return int(value or default)
36
+ except (TypeError, ValueError):
37
+ return default
38
+
39
+
40
+ def _number(value: Any) -> float | None:
41
+ try:
42
+ return float(value) if value not in (None, "", "—") else None
43
+ except (TypeError, ValueError):
44
+ return None
45
+
46
+
47
+ def _public_videos(source: str, limit: int = 5) -> list[dict[str, Any]]:
48
+ try:
49
+ import yt_dlp
50
+ except ImportError as exc:
51
+ raise RuntimeError("yt-dlp não está instalado. Instale as dependências do Thunderbolt.") from exc
52
+ options = {"quiet": True, "skip_download": True, "extract_flat": True, "playlistend": limit, "noplaylist": False}
53
+ with yt_dlp.YoutubeDL(options) as downloader:
54
+ info = downloader.extract_info(source, download=False)
55
+ entries = info.get("entries") if isinstance(info, Mapping) else []
56
+ videos: list[dict[str, Any]] = []
57
+ for item in entries or []:
58
+ if not isinstance(item, Mapping):
59
+ continue
60
+ video_id = str(item.get("id") or item.get("webpage_url") or "").strip()
61
+ if not video_id:
62
+ continue
63
+ videos.append({
64
+ "id": video_id,
65
+ "title": str(item.get("title") or "Sem título").strip(),
66
+ "url": str(item.get("webpage_url") or f"https://www.instagram.com/@{source.lstrip('@').split('/')[-1]}/video/{video_id}"),
67
+ "view_count": _safe_int(item.get("view_count")),
68
+ "like_count": _safe_int(item.get("like_count")),
69
+ "comment_count": _safe_int(item.get("comment_count")),
70
+ "repost_count": _safe_int(item.get("repost_count") or item.get("share_count")),
71
+ "save_count": _safe_int(item.get("save_count")),
72
+ "duration": _safe_int(item.get("duration")),
73
+ "upload_date": str(item.get("upload_date") or ""),
74
+ })
75
+ return videos[:limit]
76
+
77
+
78
+ def _score_from_rate(rate: float | None, thresholds: tuple[float, float]) -> int:
79
+ if rate is None:
80
+ return 50
81
+ low, high = thresholds
82
+ if rate < low:
83
+ return 30
84
+ if rate < high:
85
+ return 60
86
+ return 85
87
+
88
+
89
+ def _report_markdown(record: Mapping[str, Any]) -> str:
90
+ profile = record.get("profile") or {}
91
+ lines = [
92
+ f"# INSTAGRAM CHANNEL AUDIT REPORT: {record.get('channel_name', 'Canal')}", "",
93
+ f"- Código: `{record.get('code')}`", "- Plataforma: Instagram",
94
+ f"- Data: {record.get('created_at')}", f"- Nota geral: **{record.get('overall_score', 0)}/100**", "",
95
+ "## Resumo executivo", "",
96
+ f"- Elegível para ferramentas de monetização: **{record.get('monetization_status', 'A verificar')}**",
97
+ f"- Seguidores públicos: **{profile.get('subscriber_count', '—')}**",
98
+ f"- Visualizações públicas na amostra: **{record.get('sample_views', '—')}**", "",
99
+ "## Diagnóstico", "", "| Pilar | Nota | Métrica observada | Origem | Diagnóstico |", "|---|---:|---|---|---|",
100
+ ]
101
+ for metric in record.get("metrics", []):
102
+ lines.append(f"| **{metric['label']}** | {metric['score']}/100 | {metric['value']} | {metric.get('source', 'unknown')} | {metric['diagnosis']} |")
103
+ lines += ["", "## Últimos vídeos públicos", "", "| Título | Visualizações | Gostos | Comentários | Duração |", "|---|---:|---:|---:|---:|"]
104
+ for video in record.get("videos", []):
105
+ lines.append(f"| {str(video.get('title', '')).replace('|', '/')} | {video.get('view_count', 0):,} | {video.get('like_count', 0):,} | {video.get('comment_count', 0):,} | {video.get('duration', 0)}s |")
106
+ lines += ["", "## Roadmap accionável", "", "1. **REBUILD THE HOOK**: nos primeiros 3 segundos, começar com resultado, estatística, pergunta directa ou pattern interrupt; sem logo ou introdução lenta.", "2. **OPTIMIZE RETENTION**: renovar visual/texto a cada 3–5 segundos, inserir pattern interrupt a cada 5–8 segundos e evitar frames estáticos por mais de 3 segundos.", "3. **PUBLISH CONSISTENTLY**: manter 3–5 Reels por semana, combinando Reels para alcance, Carrosséis para saves e Stories para relacionamento.", "", "## Limitações", "", "Retenção de 3 segundos, skip rate, conclusão, alcance, impressões, fontes Explore/Reels, saves, RPM e seguidores ganhos exigem dados autorizados do Instagram Insights. Estes valores não são inventados a partir de uma página pública."]
107
+ return "\n".join(lines) + "\n"
108
+
109
+
110
+ def run_audit(channel: Mapping[str, Any], settings: Mapping[str, Any] | None = None, *, progress: Callable[[str], None] | None = None) -> dict[str, Any]:
111
+ del settings
112
+ code = analysis_code()
113
+ root = ANALYSES_DIR / code
114
+ root.mkdir(parents=True, exist_ok=True)
115
+ notify = progress or (lambda _message: None)
116
+ source = str(channel.get("url") or channel.get("handle") or "").strip()
117
+ if not source:
118
+ raise RuntimeError("A conta seleccionada não tem URL pública ou handle Instagram.")
119
+ notify("A consultar o perfil público Instagram…")
120
+ profile_result = fetch_public_instagram_profile(source)
121
+ profile = dict(profile_result.data or {})
122
+ if not profile_result.ok and not profile:
123
+ raise RuntimeError(profile_result.message)
124
+ notify("A recolher as últimas 5 publicações públicas…")
125
+ try:
126
+ videos = _public_videos(source, 5)
127
+ except Exception as exc:
128
+ videos = []
129
+ profile["video_lookup_error"] = str(exc)[:240]
130
+ views = sum(_safe_int(item.get("view_count")) for item in videos)
131
+ likes = sum(_safe_int(item.get("like_count")) for item in videos)
132
+ comments = sum(_safe_int(item.get("comment_count")) for item in videos)
133
+ shares = sum(_safe_int(item.get("repost_count")) for item in videos)
134
+ saves = sum(_safe_int(item.get("save_count")) for item in videos)
135
+ engagement = ((likes + comments + shares + saves) / views * 100) if views else None
136
+ save_rate = (saves / views * 100) if views else None
137
+ share_rate = (shares / views * 100) if views else None
138
+ avg_views = views / len(videos) if videos else None
139
+ durations = [_safe_int(item.get("duration")) for item in videos if _safe_int(item.get("duration")) > 0]
140
+ avg_duration = sum(durations) / len(durations) if durations else None
141
+ followers = _number(profile.get("subscriber_count"))
142
+ conversion = (followers / views * 100) if followers is not None and views else None
143
+ cadence_score = 70 if len(videos) >= 3 else 40 if videos else 0
144
+ metrics = [
145
+ {"label": "Demanda e potencial de viralização", "score": 50 if videos else 0, "value": f"{len(videos)} publicações públicas", "source": "public", "diagnosis": "Alcance, impressões e pesquisa exigem Instagram Insights."},
146
+ {"label": "Qualidade visual — stop the scroll", "score": 50, "value": "Retenção inicial indisponível", "source": "unavailable", "diagnosis": "Skip rate e retenção de 3 segundos exigem Instagram Insights."},
147
+ {"label": "Hook e storytelling", "score": 50, "value": f"Duração média {avg_duration:.0f}s" if avg_duration else "Curva de retenção indisponível", "source": "public" if avg_duration else "unavailable", "diagnosis": "A duração pública não substitui watch time ou conclusão."},
148
+ {"label": "Ritmo e edição", "score": 50, "value": "Quedas de retenção indisponíveis", "source": "unavailable", "diagnosis": "Pacing depende da curva de retenção."},
149
+ {"label": "Tráfego Explore/Reels", "score": 50, "value": "Origem Explore/Reels indisponível", "source": "unavailable", "diagnosis": "Fontes Explore, Reels, Search, Profile e External exigem Insights."},
150
+ {"label": "Engajamento e saves", "score": _score_from_rate(engagement, (2, 3)), "value": f"{engagement:.2f}% na amostra" if engagement is not None else "Sem interacções públicas", "source": "public", "diagnosis": f"Saves: {save_rate:.2f}% · Shares: {share_rate:.2f}%" if save_rate is not None and share_rate is not None else "Interacções públicas indisponíveis."},
151
+ {"label": "Conversão em seguidores", "score": _score_from_rate(conversion, (0.5, 1.0)), "value": f"{conversion:.2f}% proxy" if conversion is not None else "Seguidores ganhos indisponíveis", "source": "estimated" if conversion is not None else "unavailable", "diagnosis": "Proxy não equivale a seguidores ganhos por publicação."},
152
+ {"label": "Mix de formatos", "score": 50, "value": "Reels/Carrosséis indisponíveis", "source": "unavailable", "diagnosis": "A distribuição de formatos requer o catálogo autorizado da conta."},
153
+ {"label": "Cadência", "score": cadence_score, "value": f"{len(videos)} publicações na amostra", "source": "public", "diagnosis": "Meta do agente: 3–5 Reels por semana, com consistência."},
154
+ ]
155
+ overall = round(sum(item["score"] for item in metrics) / len(metrics)) if metrics else 0
156
+ monetization = "A verificar — faltam dados privados" if followers is None or followers < 10000 else "Possível — confirmar conta profissional, pública e estado elegível"
157
+ record = {"code": code, "channel_id": str(channel.get("id") or ""), "channel_name": str(channel.get("name") or profile.get("name") or "Conta Instagram"), "platform": "Instagram", "created_at": datetime.now(timezone.utc).isoformat(), "overall_score": overall, "score_color": _score_color(overall), "metrics": metrics, "videos": videos, "profile": profile, "sample_views": views, "engagement_rate": engagement, "save_rate": save_rate, "share_rate": share_rate, "monetization_status": monetization, "status": "completed"}
158
+ report_path = root / f"{code}.md"
159
+ report_path.write_text(_report_markdown(record), encoding="utf-8")
160
+ record["report_path"] = str(report_path)
161
+ analyses = read_json(ANALYSES_FILENAME, [])
162
+ if not isinstance(analyses, list): analyses = []
163
+ analyses.append(record)
164
+ write_json(ANALYSES_FILENAME, analyses[-100:])
165
+ return record
166
+
167
+
168
+ def list_analyses() -> list[dict[str, Any]]:
169
+ value = read_json(ANALYSES_FILENAME, [])
170
+ return [dict(item) for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
171
+
172
+
173
+ def _card(title: str, score: Any, featured: bool, rows: list[tuple[str, str, str, str]]) -> str:
174
+ score_int = _safe_int(score)
175
+ row_html = "".join(f'<tr><td>{escape(label)}</td><td><b>{escape(value)}</b></td><td>{escape(target)}</td><td style="color:{colour};font-weight:700;">● {escape(status)}</td></tr>' for label, value, target, status, colour in rows)
176
+ 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:{"#ef4444" if score_int <= 30 else "#f59e0b" if score_int < 70 else "#22c55e"};">{score_int}</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>'
177
+
178
+
179
+ def render_growth_instagram() -> None:
180
+ st.title("Analista Growth Instagram")
181
+ st.caption("Auditoria pública baseada nos 3 pilares críticos e nas métricas secundárias do agente Growth Instagram.")
182
+ channels = [item for item in read_json("channels.json", []) if isinstance(item, dict) and item.get("active", True) and str(item.get("platform") or "").casefold() == "instagram"]
183
+ if not channels:
184
+ st.info("Cadastre pelo menos uma conta Instagram em Canais Instagram para iniciar a análise.")
185
+ return
186
+ selector_col, action_col = st.columns([1.05, 1.15], gap="small")
187
+ with selector_col:
188
+ selected = st.selectbox("Conta a analisar", channels, format_func=lambda item: str(item.get("name") or item.get("handle") or "Conta sem nome"), key="growth_instagram_channel")
189
+ with action_col:
190
+ st.write("")
191
+ analyse_clicked = st.button("ANALISAR CONTA", type="primary", use_container_width=True, key="growth_instagram_analyse")
192
+ st.markdown("**Estado das APIs de Growth**")
193
+ api_cols = st.columns(2, gap="small")
194
+ with api_cols[0]:
195
+ st.caption("Instagram página pública: perfil e amostra de vídeos")
196
+ st.markdown('<span style="color:#22c55e;font-weight:700;">● Disponível quando a página pública responder</span>', unsafe_allow_html=True)
197
+ with api_cols[1]:
198
+ st.caption("Instagram Studio: retenção, FYP, CTR e conversão")
199
+ st.markdown('<span style="color:#94a3b8;font-weight:700;">● Requer dados privados autorizados</span>', unsafe_allow_html=True)
200
+ if analyse_clicked:
201
+ with st.status("A preparar a análise pública…", expanded=True) as status:
202
+ try:
203
+ record = run_audit(selected, read_json("settings.json", {}), progress=st.write)
204
+ st.session_state["growth_instagram_last_code"] = record["code"]
205
+ status.update(label="Análise concluída", state="complete")
206
+ except Exception as exc:
207
+ status.update(label="Análise falhou", state="error")
208
+ st.error(str(exc)[:500])
209
+ records = list_analyses()
210
+ selected_record = next((item for item in reversed(records) if str(item.get("channel_id")) == str(selected.get("id"))), None)
211
+ metric_by_label = {str(item.get("label")): item for item in (selected_record.get("metrics", []) if selected_record else [])}
212
+ metrics = selected_record.get("metrics", []) if selected_record else [{"label": label, "score": 0, "value": "Aguardando análise", "source": "unknown"} for label in AGENT_METRICS]
213
+ score = selected_record.get("overall_score") if selected_record else None
214
+ score_color = "#64748b" if score is None else ("#ef4444" if score <= 30 else "#f59e0b" if score < 70 else "#22c55e")
215
+ summary_col, download_col = st.columns([3.25, 1], gap="large")
216
+ with summary_col:
217
+ 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 conta</div><div style="font-size:18px;font-weight:700;margin-top:5px;">{escape(str(selected.get("name") or selected.get("handle") or "Conta Instagram"))}</div></div><div style="font-size:42px;font-weight:800;color:{score_color};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)
218
+ with download_col:
219
+ st.write("")
220
+ report_path = Path(str(selected_record.get("report_path") or "")) if selected_record else Path()
221
+ if report_path.is_file():
222
+ st.download_button("BAIXAR ANALISE COMPLETA", report_path.read_bytes(), file_name=report_path.name, mime="text/markdown", use_container_width=True, key=f"instagram_download_{selected_record['code']}")
223
+ else:
224
+ st.button("BAIXAR ANALISE COMPLETA", disabled=True, use_container_width=True, key="instagram_download_disabled")
225
+ st.subheader("Dashboard de Growth")
226
+ st.caption("3 pilares críticos em destaque e camadas operacionais abaixo. Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100.")
227
+ def item(label: str, target: str, available: bool = False) -> tuple[str, str, str, str, str]:
228
+ metric = metric_by_label.get(label, {})
229
+ value = str(metric.get("value", "Aguardando análise"))
230
+ return (label, value, target, "A verificar" if not available else "Meta atingida", "#94a3b8" if not available else "#22c55e")
231
+ cards = [
232
+ ("Demanda e potencial de viralização", True, [item("Demanda e potencial de viralização", "> 60% Explore/Reels", True), ("Alcance", "Dados privados", "Crescente", "A verificar", "#94a3b8")]),
233
+ ("Qualidade visual — stop the scroll", True, [item("Qualidade visual — stop the scroll", "> 70% retenção 3s"), ("Skip rate", "Dados privados", "< 60%", "A verificar", "#94a3b8")]),
234
+ ("Hook e storytelling", True, [item("Hook e storytelling", "> 30% conclusão"), ("Watch time", "Dados privados", "próximo da duração", "A verificar", "#94a3b8")]),
235
+ ("Ritmo e edição", False, [item("Ritmo e edição", "Pattern interrupt 5–8s")]),
236
+ ("Tráfego Explore/Reels", False, [item("Tráfego Explore/Reels", "> 60%")]),
237
+ ("Engajamento e saves", False, [item("Engajamento e saves", "> 3% engagement"), ("Save rate", "Dados privados", "> 2%", "A verificar", "#94a3b8")]),
238
+ ("Conversão em seguidores", False, [item("Conversão em seguidores", "> 1%")]),
239
+ ("Mix de formatos", False, [item("Mix de formatos", "80% Reels")]),
240
+ ("Cadência", False, [item("Cadência", "3–5 Reels/semana")]),
241
+ ]
242
+ 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)
243
+ 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)
244
+ st.caption("Legenda: ● Verde = meta atingida · ● Amarelo = atenção · ● Vermelho = crítico · Cinzento = dado não disponível na fonte autorizada.")
245
+ st.divider()
246
+ history_tab, dashboard_tab = st.tabs(["Últimas análises", "Leitura estratégica"])
247
+ with dashboard_tab:
248
+ st.write("Priorize Hook, retenção e cadência. CTR, FYP, retenção real e seguidores ganhos ficam pendentes de dados autorizados do Instagram Studio.")
249
+ with history_tab:
250
+ if not records:
251
+ st.info("Ainda não existem análises guardadas.")
252
+ for record in reversed(records[-20:]):
253
+ row = st.columns([2, 1, 2.2, 1.2, 1, 1.4])
254
+ row[0].write(f"**{record.get('channel_name', 'Conta Instagram')}**")
255
+ row[1].write("Instagram")
256
+ row[2].caption(str(record.get("code", "")))
257
+ row[3].caption(str(record.get("created_at", "")).replace("T", " ")[:19])
258
+ row[4].write(f"{record.get('overall_score', '—')}/100")
259
+ path = Path(str(record.get("report_path") or ""))
260
+ if path.is_file():
261
+ row[5].download_button("Análise", path.read_bytes(), file_name=path.name, mime="text/markdown", key=f"instagram_history_{record.get('code')}")
262
+
263
+
264
+ __all__ = ["AGENT_METRICS", "ANALYSES_DIR", "analysis_code", "list_analyses", "run_audit", "render_growth_instagram"]
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from html import unescape
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ import requests
11
+
12
+
13
+ @dataclass
14
+ class IntegrationResult:
15
+ ok: bool
16
+ message: str
17
+ data: dict[str, Any]
18
+
19
+
20
+ def normalize_instagram_reference(source: str) -> dict[str, str]:
21
+ value = str(source or '').strip()
22
+ if not value:
23
+ raise ValueError('Informe um @handle ou URL pública do Instagram.')
24
+ if value.startswith('@'):
25
+ username = value[1:]
26
+ elif value.startswith(('http://', 'https://')):
27
+ parsed = urlparse(value)
28
+ if (parsed.hostname or '').lower().removeprefix('www.') != 'instagram.com':
29
+ raise ValueError('Use uma URL pública do Instagram, por exemplo https://www.instagram.com/conta/.')
30
+ username = next((part for part in parsed.path.split('/') if part and not part.startswith(('p', 'reel', 'tv'))), '')
31
+ else:
32
+ username = value
33
+ username = username.strip().lstrip('@').split('/', 1)[0]
34
+ if not re.fullmatch(r'[A-Za-z0-9._]{1,64}', username):
35
+ raise ValueError('O @handle Instagram contém caracteres inválidos.')
36
+ return {'username': username, 'handle': f'@{username}', 'url': f'https://www.instagram.com/{username}/'}
37
+
38
+
39
+ def _meta(document: str, name: str) -> str:
40
+ patterns = (
41
+ rf'<meta[^>]+(?:name|property)=["\']{re.escape(name)}["\'][^>]+content=["\']([^"\']*)',
42
+ rf'<meta[^>]+content=["\']([^"\']*)["\'][^>]+(?:name|property)=["\']{re.escape(name)}["\']',
43
+ )
44
+ for pattern in patterns:
45
+ match = re.search(pattern, document, flags=re.IGNORECASE)
46
+ if match:
47
+ return unescape(match.group(1)).strip()
48
+ return ''
49
+
50
+
51
+ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
52
+ try:
53
+ reference = normalize_instagram_reference(source)
54
+ except ValueError as exc:
55
+ return IntegrationResult(False, str(exc), {})
56
+ try:
57
+ response = requests.get(reference['url'], headers={'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8'}, timeout=12)
58
+ except requests.RequestException as exc:
59
+ return IntegrationResult(False, f'Não foi possível consultar o perfil público do Instagram: {exc}', reference)
60
+ if response.status_code >= 400:
61
+ return IntegrationResult(False, f'O Instagram devolveu HTTP {response.status_code}. Confirme o @handle ou use o cadastro manual.', reference | {'status_code': response.status_code})
62
+ title = _meta(response.text, 'og:title') or reference['username']
63
+ description = _meta(response.text, 'og:description')
64
+ avatar_url = _meta(response.text, 'og:image')
65
+ followers = None
66
+ match = re.search(r'([\d,.]+)\s*(?:followers|seguidores)', description, flags=re.IGNORECASE)
67
+ if match:
68
+ try:
69
+ followers = int(re.sub(r'[^0-9]', '', match.group(1)))
70
+ except ValueError:
71
+ followers = None
72
+ data = {'id': f"instagram_{reference['username']}", **reference, 'name': title.split('(')[0].strip() or reference['username'], 'bio': description, 'avatar_url': avatar_url, 'subscriber_count': followers, 'public_lookup': True, 'metrics_source': 'instagram_public_page', 'last_public_lookup_at': datetime.now(timezone.utc).isoformat()}
73
+ return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
74
+
75
+
76
+ __all__ = ['IntegrationResult', 'fetch_public_instagram_profile', 'normalize_instagram_reference']
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
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",