@danhachuel/thunderbolt 0.6.10 → 0.6.11

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
@@ -109,6 +109,7 @@ from hermes_ui.draft_video import DRAFT_SETTING_SECTIONS, missing_content_fields
109
109
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_thumbnail_prompt, generate_title_and_keywords, generate_topic_for_channel, generate_video_description, generate_video_keywords, generate_video_update_metadata
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
+ from hermes_ui.growth_tiktok import render_growth_tiktok
112
113
  from hermes_ui.canva_auth import authorization_url, create_pkce_pair, create_state, exchange_code
113
114
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
114
115
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
@@ -9068,7 +9069,7 @@ def main():
9068
9069
  "UGC Products": lambda: render_ugc_products(read_json("settings.json", {})),
9069
9070
  "Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
9070
9071
  "Analista Growth Youtube": render_growth_youtube,
9071
- "Analista Growth Tiktok": lambda: render_edit_placeholder("Analista Growth Tiktok", ""),
9072
+ "Analista Growth Tiktok": render_growth_tiktok,
9072
9073
  "Analista Growth Instagram": lambda: render_edit_placeholder("Analista Growth Instagram", ""),
9073
9074
  "Analista Facebook Pages": lambda: render_edit_placeholder("Analista Facebook Pages", ""),
9074
9075
  "Analista Bilibili": lambda: render_edit_placeholder("Analista Bilibili", ""),
@@ -0,0 +1,261 @@
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.tiktok_public import fetch_public_tiktok_profile
14
+ from .storage import STORAGE, read_json, write_json
15
+
16
+ ANALYSES_FILENAME = "tiktok_growth_analyses.json"
17
+ ANALYSES_DIR = STORAGE / "growth" / "tiktok"
18
+ AGENT_METRICS = (
19
+ "Demanda e viabilidade do nicho", "Hook — primeiros 3 segundos", "Watch time e retenção",
20
+ "CTR e embalagem", "Ritmo e edição", "Tráfego FYP", "Engajamento", "Conversão em seguidores", "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"GTA-{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.tiktok.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")),
71
+ "duration": _safe_int(item.get("duration")),
72
+ "upload_date": str(item.get("upload_date") or ""),
73
+ })
74
+ return videos[:limit]
75
+
76
+
77
+ def _score_from_rate(rate: float | None, thresholds: tuple[float, float]) -> int:
78
+ if rate is None:
79
+ return 50
80
+ low, high = thresholds
81
+ if rate < low:
82
+ return 30
83
+ if rate < high:
84
+ return 60
85
+ return 85
86
+
87
+
88
+ def _report_markdown(record: Mapping[str, Any]) -> str:
89
+ profile = record.get("profile") or {}
90
+ lines = [
91
+ f"# TIKTOK CHANNEL AUDIT REPORT: {record.get('channel_name', 'Canal')}", "",
92
+ f"- Código: `{record.get('code')}`", "- Plataforma: TikTok",
93
+ f"- Data: {record.get('created_at')}", f"- Nota geral: **{record.get('overall_score', 0)}/100**", "",
94
+ "## Resumo executivo", "",
95
+ f"- Elegível para Creator Rewards: **{record.get('monetization_status', 'A verificar')}**",
96
+ f"- Seguidores públicos: **{profile.get('subscriber_count', '—')}**",
97
+ f"- Visualizações públicas na amostra: **{record.get('sample_views', '—')}**", "",
98
+ "## Diagnóstico", "", "| Pilar | Nota | Métrica observada | Origem | Diagnóstico |", "|---|---:|---|---|---|",
99
+ ]
100
+ for metric in record.get("metrics", []):
101
+ lines.append(f"| **{metric['label']}** | {metric['score']}/100 | {metric['value']} | {metric.get('source', 'unknown')} | {metric['diagnosis']} |")
102
+ lines += ["", "## Últimos vídeos públicos", "", "| Título | Visualizações | Gostos | Comentários | Duração |", "|---|---:|---:|---:|---:|"]
103
+ for video in record.get("videos", []):
104
+ 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 |")
105
+ 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 micro-hook a cada 5–8 segundos e cliffhanger a cada 15–20 segundos.", "3. **PUBLISH CONSISTENTLY**: manter pelo menos 3 vídeos por semana, priorizando originalidade, vídeos com 60+ segundos e qualidade sobre volume.", "", "## Limitações", "", "CTR, retenção de 3 segundos, curva de retenção, origem FYP, RPM, seguidores ganhos e elegibilidade completa exigem dados autorizados do TikTok Studio. Estes valores não são inventados a partir de uma página pública."]
106
+ return "\n".join(lines) + "\n"
107
+
108
+
109
+ def run_audit(channel: Mapping[str, Any], settings: Mapping[str, Any] | None = None, *, progress: Callable[[str], None] | None = None) -> dict[str, Any]:
110
+ del settings
111
+ code = analysis_code()
112
+ root = ANALYSES_DIR / code
113
+ root.mkdir(parents=True, exist_ok=True)
114
+ notify = progress or (lambda _message: None)
115
+ source = str(channel.get("url") or channel.get("handle") or "").strip()
116
+ if not source:
117
+ raise RuntimeError("A conta seleccionada não tem URL pública ou handle TikTok.")
118
+ notify("A consultar o perfil público TikTok…")
119
+ profile_result = fetch_public_tiktok_profile(source)
120
+ profile = dict(profile_result.data or {})
121
+ if not profile_result.ok and not profile:
122
+ raise RuntimeError(profile_result.message)
123
+ notify("A recolher os últimos 5 vídeos públicos…")
124
+ try:
125
+ videos = _public_videos(source, 5)
126
+ except Exception as exc:
127
+ videos = []
128
+ profile["video_lookup_error"] = str(exc)[:240]
129
+ views = sum(_safe_int(item.get("view_count")) for item in videos)
130
+ likes = sum(_safe_int(item.get("like_count")) for item in videos)
131
+ comments = sum(_safe_int(item.get("comment_count")) for item in videos)
132
+ shares = sum(_safe_int(item.get("repost_count")) for item in videos)
133
+ engagement = ((likes + comments + shares) / views * 100) if views else None
134
+ avg_views = views / len(videos) if videos else None
135
+ durations = [_safe_int(item.get("duration")) for item in videos if _safe_int(item.get("duration")) > 0]
136
+ avg_duration = sum(durations) / len(durations) if durations else None
137
+ followers = _number(profile.get("subscriber_count"))
138
+ conversion = (followers / views * 100) if followers is not None and views else None
139
+ cadence_score = 70 if len(videos) >= 3 else 40 if videos else 0
140
+ niche = str(channel.get("niche") or channel.get("description") or "—")
141
+ metrics = [
142
+ {"label": "Demanda e viabilidade do nicho", "score": 50 if videos else 0, "value": f"{len(videos)} vídeos públicos", "source": "public", "diagnosis": "Amostra pública recolhida; concorrência e Creative Center requerem dados externos autorizados."},
143
+ {"label": "Hook — primeiros 3 segundos", "score": 50, "value": "Retenção de 3s indisponível", "source": "unavailable", "diagnosis": "TikTok Studio é necessário para a retenção inicial."},
144
+ {"label": "Watch time e retenção", "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 curva de retenção."},
145
+ {"label": "CTR e embalagem", "score": 50, "value": "CTR não exposto publicamente", "source": "unavailable", "diagnosis": "CTR e impressões exigem TikTok Studio."},
146
+ {"label": "Ritmo e edição", "score": 50, "value": "Quedas de retenção indisponíveis", "source": "unavailable", "diagnosis": "A análise de pacing depende da curva de retenção."},
147
+ {"label": "Tráfego FYP", "score": 50, "value": "Origem FYP indisponível", "source": "unavailable", "diagnosis": "FYP, Search, Followers e External exigem TikTok Studio."},
148
+ {"label": "Engajamento", "score": _score_from_rate(engagement, (3, 6)), "value": f"{engagement:.2f}% na amostra" if engagement is not None else "Sem interacções públicas", "source": "public", "diagnosis": "Likes, comentários e reposts agregados nos vídeos públicos recolhidos."},
149
+ {"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 por vídeo indisponíveis", "source": "estimated" if conversion is not None else "unavailable", "diagnosis": "Proxy não equivale a seguidores ganhos por vídeo."},
150
+ {"label": "Cadência", "score": cadence_score, "value": f"{len(videos)} vídeos na amostra", "source": "public", "diagnosis": "A frequência semanal precisa de datas públicas válidas ou TikTok Studio."},
151
+ ]
152
+ overall = round(sum(item["score"] for item in metrics) / len(metrics)) if metrics else 0
153
+ monetization = "A verificar — faltam dados privados" if followers is None or followers < 10000 else "Possível — confirmar 100.000 views/30 dias e conta elegível"
154
+ record = {"code": code, "channel_id": str(channel.get("id") or ""), "channel_name": str(channel.get("name") or profile.get("name") or "Conta TikTok"), "platform": "TikTok", "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, "monetization_status": monetization, "status": "completed"}
155
+ report_path = root / f"{code}.md"
156
+ report_path.write_text(_report_markdown(record), encoding="utf-8")
157
+ record["report_path"] = str(report_path)
158
+ analyses = read_json(ANALYSES_FILENAME, [])
159
+ if not isinstance(analyses, list): analyses = []
160
+ analyses.append(record)
161
+ write_json(ANALYSES_FILENAME, analyses[-100:])
162
+ return record
163
+
164
+
165
+ def list_analyses() -> list[dict[str, Any]]:
166
+ value = read_json(ANALYSES_FILENAME, [])
167
+ return [dict(item) for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
168
+
169
+
170
+ def _card(title: str, score: Any, featured: bool, rows: list[tuple[str, str, str, str]]) -> str:
171
+ score_int = _safe_int(score)
172
+ 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)
173
+ 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>'
174
+
175
+
176
+ def render_growth_tiktok() -> None:
177
+ st.title("Analista Growth Tiktok")
178
+ st.caption("Auditoria pública baseada nos 3 pilares críticos e nas métricas secundárias do agente Growth TikTok.")
179
+ 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() == "tiktok"]
180
+ if not channels:
181
+ st.info("Cadastre pelo menos uma conta TikTok em Canais Tiktok para iniciar a análise.")
182
+ return
183
+ selector_col, action_col = st.columns([1.05, 1.15], gap="small")
184
+ with selector_col:
185
+ 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_tiktok_channel")
186
+ with action_col:
187
+ st.write("")
188
+ analyse_clicked = st.button("ANALISAR CONTA", type="primary", use_container_width=True, key="growth_tiktok_analyse")
189
+ st.markdown("**Estado das APIs de Growth**")
190
+ api_cols = st.columns(2, gap="small")
191
+ with api_cols[0]:
192
+ st.caption("TikTok página pública: perfil e amostra de vídeos")
193
+ st.markdown('<span style="color:#22c55e;font-weight:700;">● Disponível quando a página pública responder</span>', unsafe_allow_html=True)
194
+ with api_cols[1]:
195
+ st.caption("TikTok Studio: retenção, FYP, CTR e conversão")
196
+ st.markdown('<span style="color:#94a3b8;font-weight:700;">● Requer dados privados autorizados</span>', unsafe_allow_html=True)
197
+ if analyse_clicked:
198
+ with st.status("A preparar a análise pública…", expanded=True) as status:
199
+ try:
200
+ record = run_audit(selected, read_json("settings.json", {}), progress=st.write)
201
+ st.session_state["growth_tiktok_last_code"] = record["code"]
202
+ status.update(label="Análise concluída", state="complete")
203
+ except Exception as exc:
204
+ status.update(label="Análise falhou", state="error")
205
+ st.error(str(exc)[:500])
206
+ records = list_analyses()
207
+ selected_record = next((item for item in reversed(records) if str(item.get("channel_id")) == str(selected.get("id"))), None)
208
+ metric_by_label = {str(item.get("label")): item for item in (selected_record.get("metrics", []) if selected_record else [])}
209
+ metrics = selected_record.get("metrics", []) if selected_record else [{"label": label, "score": 0, "value": "Aguardando análise", "source": "unknown"} for label in AGENT_METRICS]
210
+ score = selected_record.get("overall_score") if selected_record else None
211
+ score_color = "#64748b" if score is None else ("#ef4444" if score <= 30 else "#f59e0b" if score < 70 else "#22c55e")
212
+ summary_col, download_col = st.columns([3.25, 1], gap="large")
213
+ with summary_col:
214
+ 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 TikTok"))}</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)
215
+ with download_col:
216
+ st.write("")
217
+ report_path = Path(str(selected_record.get("report_path") or "")) if selected_record else Path()
218
+ if report_path.is_file():
219
+ st.download_button("BAIXAR ANALISE COMPLETA", report_path.read_bytes(), file_name=report_path.name, mime="text/markdown", use_container_width=True, key=f"tiktok_download_{selected_record['code']}")
220
+ else:
221
+ st.button("BAIXAR ANALISE COMPLETA", disabled=True, use_container_width=True, key="tiktok_download_disabled")
222
+ st.subheader("Dashboard de Growth")
223
+ st.caption("3 pilares críticos em destaque e camadas operacionais abaixo. Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100.")
224
+ def item(label: str, target: str, available: bool = False) -> tuple[str, str, str, str, str]:
225
+ metric = metric_by_label.get(label, {})
226
+ value = str(metric.get("value", "Aguardando análise"))
227
+ return (label, value, target, "A verificar" if not available else "Meta atingida", "#94a3b8" if not available else "#22c55e")
228
+ cards = [
229
+ ("Demanda e viabilidade do nicho", True, [item("Demanda e viabilidade do nicho", "> 500k concorrência", True), ("Nicho Detectado", str(selected.get("niche") or "—"), "Viável", "A verificar", "#94a3b8")]),
230
+ ("Hook — primeiros 3 segundos", True, [item("Hook — primeiros 3 segundos", "> 70% retenção 3s"), ("Primeiro frame", "Dados privados", "Hook visível", "A verificar", "#94a3b8")]),
231
+ ("Watch time e retenção", True, [item("Watch time e retenção", "> 60% aos 60s"), ("Micro-hook", "Dados privados", "a cada 5–8s", "A verificar", "#94a3b8")]),
232
+ ("CTR e embalagem", False, [item("CTR e embalagem", "> 8% CTR")]),
233
+ ("Ritmo e edição", False, [item("Ritmo e edição", "Refresh 5–8s")]),
234
+ ("Tráfego FYP", False, [item("Tráfego FYP", "> 70% FYP")]),
235
+ ("Engajamento", False, [item("Engajamento", "> 6%")]),
236
+ ("Conversão em seguidores", False, [item("Conversão em seguidores", "> 1%")]),
237
+ ("Cadência", False, [item("Cadência", "3–5 vídeos/semana")]),
238
+ ]
239
+ 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)
240
+ 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)
241
+ st.caption("Legenda: ● Verde = meta atingida · ● Amarelo = atenção · ● Vermelho = crítico · Cinzento = dado não disponível na fonte autorizada.")
242
+ st.divider()
243
+ history_tab, dashboard_tab = st.tabs(["Últimas análises", "Leitura estratégica"])
244
+ with dashboard_tab:
245
+ st.write("Priorize Hook, retenção e cadência. CTR, FYP, retenção real e seguidores ganhos ficam pendentes de dados autorizados do TikTok Studio.")
246
+ with history_tab:
247
+ if not records:
248
+ st.info("Ainda não existem análises guardadas.")
249
+ for record in reversed(records[-20:]):
250
+ row = st.columns([2, 1, 2.2, 1.2, 1, 1.4])
251
+ row[0].write(f"**{record.get('channel_name', 'Conta TikTok')}**")
252
+ row[1].write("TikTok")
253
+ row[2].caption(str(record.get("code", "")))
254
+ row[3].caption(str(record.get("created_at", "")).replace("T", " ")[:19])
255
+ row[4].write(f"{record.get('overall_score', '—')}/100")
256
+ path = Path(str(record.get("report_path") or ""))
257
+ if path.is_file():
258
+ row[5].download_button("Análise", path.read_bytes(), file_name=path.name, mime="text/markdown", key=f"tiktok_history_{record.get('code')}")
259
+
260
+
261
+ __all__ = ["AGENT_METRICS", "ANALYSES_DIR", "analysis_code", "list_analyses", "run_audit", "render_growth_tiktok"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.10",
3
+ "version": "0.6.11",
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",