@danhachuel/thunderbolt 0.6.9 → 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 +13 -4
- package/hermes_ui/growth_tiktok.py +261 -0
- package/hermes_ui/storage.py +1 -0
- package/integrations/composio_upload.py +40 -8
- package/integrations/upload_routing.py +1 -0
- package/integrations/youtube_upload.py +26 -10
- package/package.json +1 -1
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
|
|
@@ -124,6 +125,7 @@ from integrations.youtube_direct_credentials import delete_credentials_document,
|
|
|
124
125
|
from integrations.session_info_health import check_account_session_info_health
|
|
125
126
|
from integrations.youtube_session_manager import renew_account_session
|
|
126
127
|
from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels, loopback_redirect_uri
|
|
128
|
+
from integrations.youtube_upload import authorize_youtube_agent
|
|
127
129
|
from integrations.youtube_growth_api import find_account_for_channel as find_youtube_growth_account
|
|
128
130
|
from integrations.local_runtime import MoneyPrinterRuntime
|
|
129
131
|
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
@@ -6767,7 +6769,7 @@ def render_google_accounts(*, include_innertube: bool = True):
|
|
|
6767
6769
|
document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
|
|
6768
6770
|
|
|
6769
6771
|
account_status = youtube_batch_account_status(batch_account, STORAGE)
|
|
6770
|
-
status_cols = st.columns(
|
|
6772
|
+
status_cols = st.columns(4)
|
|
6771
6773
|
with status_cols[0]:
|
|
6772
6774
|
(st.success if account_status.ok else st.warning)(account_status.message)
|
|
6773
6775
|
with status_cols[1]:
|
|
@@ -6777,6 +6779,12 @@ def render_google_accounts(*, include_innertube: bool = True):
|
|
|
6777
6779
|
if result.ok:
|
|
6778
6780
|
st.rerun()
|
|
6779
6781
|
with status_cols[2]:
|
|
6782
|
+
if st.button("Autorizar upload YouTube", key=f"batch_authorize_upload_{account_id}", use_container_width=True, help="Autoriza esta conta com o escopo de publicação YouTube; é separado da autorização de leitura/listagem dos canais." ):
|
|
6783
|
+
result = authorize_youtube_agent(settings, STORAGE, account=batch_account)
|
|
6784
|
+
(st.success if result.ok else st.error)(result.message)
|
|
6785
|
+
if result.ok:
|
|
6786
|
+
st.rerun()
|
|
6787
|
+
with status_cols[3]:
|
|
6780
6788
|
if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
|
|
6781
6789
|
delete_youtube_batch_token(batch_account, STORAGE)
|
|
6782
6790
|
delete_credentials_document(STORAGE, batch_account)
|
|
@@ -8034,7 +8042,8 @@ def render_settings():
|
|
|
8034
8042
|
composio_enabled = st.checkbox("Activar Composio", value=bool(settings.get("composio_enabled", False)), key="upload_composio_enabled")
|
|
8035
8043
|
composio_auto_upload = st.checkbox("Usar Composio por defeito na Automação Youtube", value=bool(settings.get("composio_auto_upload", True)), key="upload_composio_auto_upload", help="Quando estiver configurado, Composio é tentado antes da API Oficial, Upload directo e Postiz.")
|
|
8036
8044
|
composio_api_key = st.text_input("Composio API key", value=str(settings.get("composio_api_key") or ""), type="password", key="upload_composio_api_key", help="Use a API key de projecto do Composio Platform. Nunca coloque esta chave no GitHub, em URLs ou em mensagens.")
|
|
8037
|
-
composio_user_id = st.text_input("Composio user ID", value=str(settings.get("composio_user_id") or "thunderbolt-local"), key="upload_composio_user_id", help="
|
|
8045
|
+
composio_user_id = st.text_input("Composio user ID", value=str(settings.get("composio_user_id") or "thunderbolt-local"), key="upload_composio_user_id", help="Tem de ser o mesmo user ID usado quando as contas YouTube foram ligadas no Composio.")
|
|
8046
|
+
composio_connected_account_id = st.text_input("Connected account YouTube (ID ou alias)", value=str(settings.get("composio_connected_account_id") or ""), key="upload_composio_connected_account_id", help="Opcional. Selecciona uma conta activa do Composio. Use o ID exacto ou alias configurado.")
|
|
8038
8047
|
current_tool_slug = str(settings.get("composio_tool_slug") or "upload_video")
|
|
8039
8048
|
if current_tool_slug not in composio_operation_slugs:
|
|
8040
8049
|
current_tool_slug = "upload_video"
|
|
@@ -8068,7 +8077,7 @@ def render_settings():
|
|
|
8068
8077
|
with composio_action_cols[1]:
|
|
8069
8078
|
test_composio = st.button("Testar configuração", use_container_width=True, key="upload_composio_test")
|
|
8070
8079
|
if save_composio:
|
|
8071
|
-
settings.update({"composio_enabled": bool(composio_enabled), "composio_auto_upload": bool(composio_auto_upload), "composio_api_key": composio_api_key.strip(), "composio_user_id": composio_user_id.strip() or "thunderbolt-local", "composio_toolkit": composio_toolkit, "composio_tool_slug": composio_tool_slug, "composio_file_field": "videoFilePath", "composio_channel_field": composio_channel_field, "composio_privacy_status": composio_privacy_status, "composio_category_id": composio_category_id, "composio_language": composio_language, "composio_privacy_field": "privacyStatus", "composio_category_field": "categoryId", "composio_language_field": "defaultLanguage", "composio_arguments_json": composio_arguments_json or "{}"})
|
|
8080
|
+
settings.update({"composio_enabled": bool(composio_enabled), "composio_auto_upload": bool(composio_auto_upload), "composio_api_key": composio_api_key.strip(), "composio_user_id": composio_user_id.strip() or "thunderbolt-local", "composio_connected_account_id": composio_connected_account_id.strip(), "composio_toolkit": composio_toolkit, "composio_tool_slug": composio_tool_slug, "composio_file_field": "videoFilePath", "composio_channel_field": composio_channel_field, "composio_privacy_status": composio_privacy_status, "composio_category_id": composio_category_id, "composio_language": composio_language, "composio_privacy_field": "privacyStatus", "composio_category_field": "categoryId", "composio_language_field": "defaultLanguage", "composio_arguments_json": composio_arguments_json or "{}"})
|
|
8072
8081
|
write_json("settings.json", settings)
|
|
8073
8082
|
st.success("Configuração Composio guardada.")
|
|
8074
8083
|
st.rerun()
|
|
@@ -9060,7 +9069,7 @@ def main():
|
|
|
9060
9069
|
"UGC Products": lambda: render_ugc_products(read_json("settings.json", {})),
|
|
9061
9070
|
"Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
|
|
9062
9071
|
"Analista Growth Youtube": render_growth_youtube,
|
|
9063
|
-
"Analista Growth Tiktok":
|
|
9072
|
+
"Analista Growth Tiktok": render_growth_tiktok,
|
|
9064
9073
|
"Analista Growth Instagram": lambda: render_edit_placeholder("Analista Growth Instagram", ""),
|
|
9065
9074
|
"Analista Facebook Pages": lambda: render_edit_placeholder("Analista Facebook Pages", ""),
|
|
9066
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/hermes_ui/storage.py
CHANGED
|
@@ -291,6 +291,7 @@ DEFAULTS: dict[str, Any] = {
|
|
|
291
291
|
"composio_auto_upload": True,
|
|
292
292
|
"composio_api_key": "",
|
|
293
293
|
"composio_user_id": "thunderbolt-local",
|
|
294
|
+
"composio_connected_account_id": "",
|
|
294
295
|
"composio_toolkit": "upload_video",
|
|
295
296
|
"composio_tool_slug": "upload_video",
|
|
296
297
|
"composio_file_field": "videoFilePath",
|
|
@@ -111,6 +111,35 @@ def _client(api_key: str, *, upload_dir: Path | None = None):
|
|
|
111
111
|
return Composio(**kwargs)
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
def _connected_account_id(client: Any, user_id: str, toolkit: str, selector: str) -> str:
|
|
115
|
+
"""Resolve a connected-account ID from an ID or UI alias."""
|
|
116
|
+
value = str(selector or "").strip()
|
|
117
|
+
if not value:
|
|
118
|
+
return ""
|
|
119
|
+
try:
|
|
120
|
+
response = client.connected_accounts.list(user_ids=[_require_user_id(user_id)], statuses=["ACTIVE"])
|
|
121
|
+
raw = _safe_value(response)
|
|
122
|
+
items = raw.get("items", []) if isinstance(raw, dict) else raw
|
|
123
|
+
if not isinstance(items, list):
|
|
124
|
+
items = []
|
|
125
|
+
wanted = value.casefold()
|
|
126
|
+
for item in items:
|
|
127
|
+
if not isinstance(item, dict):
|
|
128
|
+
continue
|
|
129
|
+
toolkit_value = item.get("toolkit")
|
|
130
|
+
if isinstance(toolkit_value, dict):
|
|
131
|
+
toolkit_value = toolkit_value.get("slug") or toolkit_value.get("name")
|
|
132
|
+
candidates = [item.get("id"), item.get("nanoid"), item.get("alias"), item.get("name")]
|
|
133
|
+
if str(toolkit or "").strip() and toolkit.casefold() not in str(toolkit_value or "").casefold():
|
|
134
|
+
continue
|
|
135
|
+
if any(str(candidate or "").strip().casefold() == wanted for candidate in candidates):
|
|
136
|
+
return str(item.get("id") or item.get("nanoid") or value).strip()
|
|
137
|
+
except Exception:
|
|
138
|
+
# Preserve the original selector so Composio returns its actionable error.
|
|
139
|
+
return value
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
|
|
114
143
|
def discover_tools(api_key: str, user_id: str, query: str, toolkit: str = "") -> list[dict[str, Any]]:
|
|
115
144
|
client = _client(api_key)
|
|
116
145
|
try:
|
|
@@ -183,7 +212,7 @@ def parse_arguments(arguments_json: str) -> dict[str, Any]:
|
|
|
183
212
|
return parsed
|
|
184
213
|
|
|
185
214
|
|
|
186
|
-
def execute_upload(api_key: str, user_id: str, slug: str, video_path: str, file_field: str, arguments_json: str = "") -> dict[str, Any]:
|
|
215
|
+
def execute_upload(api_key: str, user_id: str, slug: str, video_path: str, file_field: str, arguments_json: str = "", connected_account_id: str = "") -> dict[str, Any]:
|
|
187
216
|
path = Path(str(video_path or "").strip()).expanduser()
|
|
188
217
|
slug = str(slug or "").strip()
|
|
189
218
|
file_field = str(file_field or "").strip()
|
|
@@ -201,13 +230,16 @@ def execute_upload(api_key: str, user_id: str, slug: str, video_path: str, file_
|
|
|
201
230
|
arguments[file_field] = str(path.resolve())
|
|
202
231
|
client = _client(api_key, upload_dir=path.parent)
|
|
203
232
|
try:
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)
|
|
233
|
+
execute_kwargs: dict[str, Any] = {
|
|
234
|
+
"arguments": arguments,
|
|
235
|
+
"user_id": _require_user_id(user_id),
|
|
236
|
+
"version": "latest",
|
|
237
|
+
"dangerously_skip_version_check": True,
|
|
238
|
+
}
|
|
239
|
+
selected_account = _connected_account_id(client, user_id, "youtube", connected_account_id)
|
|
240
|
+
if selected_account:
|
|
241
|
+
execute_kwargs["connected_account_id"] = selected_account
|
|
242
|
+
result = client.tools.execute(slug, **execute_kwargs)
|
|
211
243
|
response = _response(result)
|
|
212
244
|
if not response["successful"] and not response["error"]:
|
|
213
245
|
response["error"] = f"A ferramenta `{slug}` devolveu uma resposta sem sucesso."
|
|
@@ -338,6 +338,7 @@ def _composio_upload(settings: dict[str, Any], *, channel: dict[str, Any], **kwa
|
|
|
338
338
|
str(kwargs.get("video_path") or ""),
|
|
339
339
|
file_field,
|
|
340
340
|
json.dumps(parsed_arguments, ensure_ascii=False),
|
|
341
|
+
str(settings.get("composio_connected_account_id") or channel.get("composio_connected_account_id") or "").strip(),
|
|
341
342
|
)
|
|
342
343
|
except ComposioUploadError as exc:
|
|
343
344
|
return IntegrationResult(False, str(exc), {})
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Any, Callable
|
|
9
9
|
|
|
10
10
|
from integrations.platforms import IntegrationResult
|
|
11
|
-
from integrations.youtube_batch import loopback_host, loopback_port, loopback_redirect_uri, token_path as batch_token_path
|
|
11
|
+
from integrations.youtube_batch import account_key, loopback_host, loopback_port, loopback_redirect_uri, token_path as batch_token_path
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
# O fluxo de upload precisa apenas do scope de publicação; Analytics e leitura
|
|
@@ -27,6 +27,13 @@ def _safe_text(value: Any, default: str = "") -> str:
|
|
|
27
27
|
return str(value if value is not None else default).strip()
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def _upload_account_token_path(storage_root: Path, account: dict[str, Any] | None, purpose: str) -> Path | None:
|
|
31
|
+
if not isinstance(account, dict) or not (account.get("id") or account.get("email")):
|
|
32
|
+
return None
|
|
33
|
+
safe_purpose = str(purpose or "upload").strip().lower() or "upload"
|
|
34
|
+
return Path(storage_root) / "state" / "youtube_upload_tokens" / f"{account_key(account)}-{safe_purpose}.json"
|
|
35
|
+
|
|
36
|
+
|
|
30
37
|
def _path_value(value: Any) -> Path | None:
|
|
31
38
|
if isinstance(value, dict):
|
|
32
39
|
value = value.get("path") or value.get("file") or value.get("filepath")
|
|
@@ -103,8 +110,16 @@ class _GoogleYouTubeBase:
|
|
|
103
110
|
return any(path.exists() and path.stat().st_size > 0 for path in self._token_candidates())
|
|
104
111
|
|
|
105
112
|
def _token_candidates(self) -> list[Path]:
|
|
106
|
-
paths = [
|
|
113
|
+
paths: list[Path] = []
|
|
114
|
+
account_token = _upload_account_token_path(self.token_path.parents[1], self.account, self.__class__.__name__)
|
|
115
|
+
if account_token is not None:
|
|
116
|
+
paths.append(account_token)
|
|
117
|
+
if not self.account:
|
|
118
|
+
paths.append(self.token_path)
|
|
107
119
|
if self.account:
|
|
120
|
+
# Compatibilidade com autorizações de publicação antigas guardadas
|
|
121
|
+
# no caminho global; o token de leitura/listagem continua separado.
|
|
122
|
+
paths.append(self.token_path)
|
|
108
123
|
paths.append(batch_token_path(self.token_path.parents[1], self.account))
|
|
109
124
|
if self.alternate_token_path and self.alternate_token_path not in paths:
|
|
110
125
|
paths.append(self.alternate_token_path)
|
|
@@ -146,19 +161,20 @@ class _GoogleYouTubeBase:
|
|
|
146
161
|
return credentials
|
|
147
162
|
|
|
148
163
|
def _save_credentials(self, credentials: Any, *, nested: bool = False) -> None:
|
|
149
|
-
self.token_path.
|
|
164
|
+
destination = _upload_account_token_path(self.token_path.parents[1], self.account, self.__class__.__name__) or self.token_path
|
|
165
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
150
166
|
payload = json.loads(credentials.to_json())
|
|
151
167
|
if nested:
|
|
152
168
|
payload = {"youtube": payload}
|
|
153
|
-
temporary =
|
|
169
|
+
temporary = destination.with_name(f".{destination.name}.{secrets.token_hex(6)}.tmp")
|
|
154
170
|
temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
155
171
|
try:
|
|
156
172
|
os.chmod(temporary, 0o600)
|
|
157
173
|
except OSError:
|
|
158
174
|
pass
|
|
159
|
-
temporary.replace(
|
|
175
|
+
temporary.replace(destination)
|
|
160
176
|
try:
|
|
161
|
-
os.chmod(
|
|
177
|
+
os.chmod(destination, 0o600)
|
|
162
178
|
except OSError:
|
|
163
179
|
pass
|
|
164
180
|
|
|
@@ -382,12 +398,12 @@ def upload_youtube_with_fallback(
|
|
|
382
398
|
return IntegrationResult(False, f"O agente de upload falhou e o fallback OAuth também falhou. Agente: {primary_result.message} Fallback: {fallback_result.message}", {"status": "all_upload_mechanisms_failed", "attempts": attempts})
|
|
383
399
|
|
|
384
400
|
|
|
385
|
-
def authorize_youtube_agent(settings: dict[str, Any], storage_root: Path, *, open_browser: bool = True) -> IntegrationResult:
|
|
386
|
-
return YouTubeAutomationAgentUploader(settings, storage_root).authorize(open_browser=open_browser)
|
|
401
|
+
def authorize_youtube_agent(settings: dict[str, Any], storage_root: Path, *, account: dict[str, Any] | None = None, open_browser: bool = True) -> IntegrationResult:
|
|
402
|
+
return YouTubeAutomationAgentUploader(settings, storage_root, account=account).authorize(open_browser=open_browser)
|
|
387
403
|
|
|
388
404
|
|
|
389
|
-
def authorize_youtube_fallback(settings: dict[str, Any], storage_root: Path, *, open_browser: bool = True) -> IntegrationResult:
|
|
390
|
-
return DirectYouTubeOAuthUploader(settings, storage_root).authorize(open_browser=open_browser)
|
|
405
|
+
def authorize_youtube_fallback(settings: dict[str, Any], storage_root: Path, *, account: dict[str, Any] | None = None, open_browser: bool = True) -> IntegrationResult:
|
|
406
|
+
return DirectYouTubeOAuthUploader(settings, storage_root, account=account).authorize(open_browser=open_browser)
|
|
391
407
|
|
|
392
408
|
|
|
393
409
|
def youtube_upload_status(settings: dict[str, Any], storage_root: Path) -> dict[str, IntegrationResult]:
|
package/package.json
CHANGED