@danhachuel/thunderbolt 0.6.13 → 0.6.15

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
@@ -70,6 +70,7 @@ from app.modules.token_optimizer.compressor import check_installation
70
70
  from app.modules.token_optimizer.config import DEFAULTS as TOKEN_OPTIMIZER_DEFAULTS
71
71
  from app.modules.token_optimizer.metrics import get_stats as get_token_optimizer_stats
72
72
  from app.influencers_ui import render_ai_influencer_characters, render_ai_influencer_content, render_ai_influencers_api_status, render_motion_control, render_ugc_products
73
+ from app.social_networks_ui import render_meta_api_cards, render_social_networks
73
74
  from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
74
75
  from hermes_ui.thumbnail_blueprints import generate_thumbnail_blueprint, list_thumbnail_blueprint_documents, resolve_thumbnail_blueprint, save_thumbnail_blueprint, save_thumbnail_blueprint_pair, thumbnail_blueprint_catalog, thumbnail_blueprint_for_blueprint, thumbnail_blueprint_for_channel
75
76
  from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
@@ -112,6 +113,7 @@ from hermes_ui.growth_youtube import list_analyses, run_audit
112
113
  from hermes_ui.growth_tiktok import render_growth_tiktok
113
114
  from hermes_ui.growth_instagram import render_growth_instagram
114
115
  from hermes_ui.growth_facebook_pages import render_growth_facebook_pages
116
+ from hermes_ui.growth_bilibili import render_growth_bilibili
115
117
  from hermes_ui.canva_auth import authorization_url, create_pkce_pair, create_state, exchange_code
116
118
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
117
119
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
@@ -8034,6 +8036,10 @@ def render_settings():
8034
8036
  st.rerun()
8035
8037
  with st.expander("API Tiktok", expanded=False):
8036
8038
  render_tiktok_api_cards(settings)
8039
+ with st.expander("API Instagram", expanded=False):
8040
+ render_meta_api_cards(settings, "instagram")
8041
+ with st.expander("API Facebook Pages", expanded=False):
8042
+ render_meta_api_cards(settings, "facebook_pages")
8037
8043
  with st.expander("API Bilibili", expanded=False):
8038
8044
  bilibili_cards = settings.get("bilibili_api_cards") if isinstance(settings.get("bilibili_api_cards"), list) else []
8039
8045
  bilibili_ready = any(bool(card.get("active", True) and card.get("sessdata") and card.get("bili_jct") and card.get("buvid3")) for card in bilibili_cards if isinstance(card, dict))
@@ -9071,12 +9077,12 @@ def main():
9071
9077
  "Geração de Conteúdo IA": lambda: render_ai_influencer_content(read_json("settings.json", {})),
9072
9078
  "Motion Control": lambda: render_motion_control(read_json("settings.json", {})),
9073
9079
  "UGC Products": lambda: render_ugc_products(read_json("settings.json", {})),
9074
- "Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
9080
+ "Redes Sociais": lambda: render_social_networks(read_json("settings.json", {})),
9075
9081
  "Analista Growth Youtube": render_growth_youtube,
9076
9082
  "Analista Growth Tiktok": render_growth_tiktok,
9077
9083
  "Analista Growth Instagram": render_growth_instagram,
9078
9084
  "Analista Facebook Pages": render_growth_facebook_pages,
9079
- "Analista Bilibili": lambda: render_edit_placeholder("Analista Bilibili", ""),
9085
+ "Analista Bilibili": render_growth_bilibili,
9080
9086
  "Documentação": lambda: render_edit_placeholder("Documentação", "Seleccione um tutorial no menu expansível."),
9081
9087
  "Tutorial Meta": render_models_ai_tutorial,
9082
9088
  "Tutorial Supabase": render_supabase_tutorial,
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import Any, Callable, Mapping
5
+
6
+ import streamlit as st
7
+
8
+ from hermes_ui.domain import create_channel, update_channel
9
+ from hermes_ui.influencers import InfluencerBackendError, STANDALONE_CONTENT_INFLUENCER_ID, get_repository
10
+ from hermes_ui.storage import read_json, write_json
11
+ from integrations.instagram_public import fetch_public_instagram_profile
12
+ from integrations.meta_social import test_facebook_pages_api_card, test_instagram_api_card
13
+
14
+
15
+ def _clean(value: Any) -> str:
16
+ return str(value or "").strip()
17
+
18
+
19
+ def _metric(value: Any) -> str:
20
+ if value in (None, ""):
21
+ return "—"
22
+ try:
23
+ return f"{int(value):,}".replace(",", ".")
24
+ except (TypeError, ValueError):
25
+ return _clean(value) or "—"
26
+
27
+
28
+ def _api_card_defaults(kind: str) -> tuple[str, list[str], Callable[[Mapping[str, Any]], dict[str, Any]]]:
29
+ if kind == "instagram":
30
+ return "instagram_api_cards", ["id", "label", "account_id", "access_token"], test_instagram_api_card
31
+ return "facebook_pages_api_cards", ["id", "label", "page_id", "access_token"], test_facebook_pages_api_card
32
+
33
+
34
+ def _normalise_api_cards(settings: dict[str, Any], kind: str) -> list[dict[str, str]]:
35
+ key, fields, _ = _api_card_defaults(kind)
36
+ raw = settings.get(key)
37
+ cards: list[dict[str, str]] = []
38
+ if isinstance(raw, list):
39
+ for index, item in enumerate(raw):
40
+ if not isinstance(item, dict):
41
+ continue
42
+ card = {field: _clean(item.get(field)) for field in fields}
43
+ card["id"] = card["id"] or f"{kind}-api-{index + 1}"
44
+ cards.append(card)
45
+ if not cards:
46
+ legacy_key = "instagram_api_key" if kind == "instagram" else "facebook_pages_api_key"
47
+ legacy_id = "instagram_account_id" if kind == "instagram" else "facebook_page_id"
48
+ token = _clean(settings.get(legacy_key))
49
+ identifier = _clean(settings.get(legacy_id))
50
+ if token or identifier:
51
+ cards = [{"id": f"{kind}-api-1", "label": "Conta 1", "account_id" if kind == "instagram" else "page_id": identifier, "access_token": token}]
52
+ if cards and not isinstance(raw, list):
53
+ settings[key] = cards
54
+ write_json("settings.json", settings)
55
+ return cards
56
+
57
+
58
+ def _persist_api_cards(settings: dict[str, Any], kind: str, cards: list[dict[str, Any]]) -> None:
59
+ key, fields, _ = _api_card_defaults(kind)
60
+ clean_cards: list[dict[str, str]] = []
61
+ for index, item in enumerate(cards):
62
+ card = {field: _clean(item.get(field)) for field in fields}
63
+ card["id"] = card["id"] or f"{kind}-api-{index + 1}"
64
+ clean_cards.append(card)
65
+ settings[key] = clean_cards
66
+ if kind == "instagram":
67
+ first = next((item for item in clean_cards if item.get("account_id") and item.get("access_token")), clean_cards[0] if clean_cards else {})
68
+ settings["instagram_account_id"] = first.get("account_id", "")
69
+ settings["instagram_api_key"] = first.get("access_token", "")
70
+ else:
71
+ first = next((item for item in clean_cards if item.get("page_id") and item.get("access_token")), clean_cards[0] if clean_cards else {})
72
+ settings["facebook_page_id"] = first.get("page_id", "")
73
+ settings["facebook_pages_api_key"] = first.get("access_token", "")
74
+ write_json("settings.json", settings)
75
+
76
+
77
+ def _api_card_status(card: Mapping[str, Any], kind: str) -> tuple[str, str]:
78
+ identifier = _clean(card.get("account_id" if kind == "instagram" else "page_id"))
79
+ token = _clean(card.get("access_token"))
80
+ if not token:
81
+ return "missing", "Missing key"
82
+ if not identifier:
83
+ return "missing", "Missing configuration"
84
+ return "ready", "Configured"
85
+
86
+
87
+ def render_meta_api_cards(settings: dict[str, Any], kind: str) -> None:
88
+ is_instagram = kind == "instagram"
89
+ title = "API Instagram" if is_instagram else "API Facebook Pages"
90
+ entity_label = "Instagram Account ID" if is_instagram else "Facebook Page ID"
91
+ key, _, tester = _api_card_defaults(kind)
92
+ cards = _normalise_api_cards(settings, kind)
93
+ if not cards:
94
+ st.info(f"Ainda não existe nenhuma conta {title.replace('API ', '')} configurada. Use o botão abaixo para criar o primeiro card.")
95
+ for index, card in enumerate(cards):
96
+ card_id = _clean(card.get("id"))
97
+ status_kind, status_label = _api_card_status(card, kind)
98
+ with st.container(border=True):
99
+ header_cols = st.columns([3.2, 1.2])
100
+ with header_cols[0]:
101
+ st.subheader(f"{title} {index + 1}")
102
+ st.caption("Cada card representa uma conta independente; o token é guardado apenas no storage local.")
103
+ with header_cols[1]:
104
+ st.markdown(
105
+ f'<span class="tb-api-status tb-api-status--{status_kind}">{"✓" if status_kind == "ready" else "!"} {status_label}</span>',
106
+ unsafe_allow_html=True,
107
+ )
108
+ with st.form(f"{kind}_api_card_form_{card_id}"):
109
+ label = st.text_input("Nome da conta", value=_clean(card.get("label")) or f"Conta {index + 1}", key=f"{kind}_api_{card_id}_label")
110
+ identifier = st.text_input(entity_label, value=_clean(card.get("account_id" if is_instagram else "page_id")), key=f"{kind}_api_{card_id}_identifier")
111
+ token = st.text_input("Access Token", value=_clean(card.get("access_token")), type="password", key=f"{kind}_api_{card_id}_token")
112
+ action_cols = st.columns(3)
113
+ with action_cols[0]:
114
+ test_clicked = st.form_submit_button("Teste chamada API", use_container_width=True)
115
+ with action_cols[1]:
116
+ save_clicked = st.form_submit_button("Guardar Card", type="primary", use_container_width=True)
117
+ with action_cols[2]:
118
+ delete_clicked = st.form_submit_button("Apagar Card", use_container_width=True)
119
+ edited = {
120
+ "id": card_id,
121
+ "label": label.strip(),
122
+ "access_token": token.strip(),
123
+ ("account_id" if is_instagram else "page_id"): identifier.strip(),
124
+ }
125
+ if test_clicked:
126
+ cards[index] = edited
127
+ _persist_api_cards(settings, kind, cards)
128
+ result = tester(edited)
129
+ if result.get("status") == "success":
130
+ st.success(result.get("message") or "Chamada API concluída.")
131
+ elif result.get("status") == "missing":
132
+ st.warning(result.get("message") or "Missing configuration")
133
+ else:
134
+ st.error(result.get("message") or "A chamada API falhou.")
135
+ elif save_clicked:
136
+ cards[index] = edited
137
+ _persist_api_cards(settings, kind, cards)
138
+ st.success(f"{title} guardada.")
139
+ st.rerun()
140
+ elif delete_clicked:
141
+ cards = [item for item in cards if _clean(item.get("id")) != card_id]
142
+ _persist_api_cards(settings, kind, cards)
143
+ st.success(f"{title} apagada.")
144
+ st.rerun()
145
+ if st.button("Adicionar nova API", type="primary", use_container_width=True, key=f"add_{kind}_api_card"):
146
+ identifier_key = "account_id" if is_instagram else "page_id"
147
+ cards.append({"id": f"{kind}-api-{uuid.uuid4().hex[:10]}", "label": f"Conta {len(cards) + 1}", identifier_key: "", "access_token": ""})
148
+ _persist_api_cards(settings, kind, cards)
149
+ st.success(f"Novo card {title} criado.")
150
+ st.rerun()
151
+
152
+
153
+ def _instagram_profiles() -> list[dict[str, Any]]:
154
+ records = read_json("channels.json", [])
155
+ return [item for item in records if isinstance(item, dict) and _clean(item.get("platform")).lower() == "instagram"]
156
+
157
+
158
+ def _characters(settings: Mapping[str, Any]) -> tuple[list[dict[str, Any]], Any | None]:
159
+ try:
160
+ repository = get_repository(settings)
161
+ items = [item for item in repository.list_influencers() if _clean(item.get("id")) != STANDALONE_CONTENT_INFLUENCER_ID]
162
+ return items, repository
163
+ except (InfluencerBackendError, Exception):
164
+ return [], None
165
+
166
+
167
+ def _save_public_profile(data: Mapping[str, Any], *, country: str, language: str, character_id: str = "") -> dict[str, Any]:
168
+ name = _clean(data.get("name")) or _clean(data.get("username")) or "Conta Instagram"
169
+ url = _clean(data.get("url"))
170
+ metadata = {
171
+ **dict(data),
172
+ "platform": "instagram",
173
+ "social_network": "Instagram",
174
+ "name": name,
175
+ "url": url,
176
+ "handle": _clean(data.get("handle")),
177
+ "country": country.strip(),
178
+ "language": language.strip(),
179
+ "post_count": data.get("post_count"),
180
+ "subscriber_count": data.get("subscriber_count"),
181
+ "following_count": data.get("following_count"),
182
+ "character_id": character_id.strip(),
183
+ "active": True,
184
+ "metrics_source": "instagram_public_page",
185
+ }
186
+ return create_channel(name, url, metadata)
187
+
188
+
189
+ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, Any]], settings: Mapping[str, Any]) -> None:
190
+ profile_id = _clean(profile.get("id"))
191
+ edit_key = f"edit_instagram_profile_{profile_id}"
192
+ with st.container(border=True):
193
+ header_cols = st.columns([0.7, 3.1, 1.25, 1.25, 1.25, 1.65])
194
+ with header_cols[0]:
195
+ if _clean(profile.get("avatar_url")):
196
+ st.image(profile["avatar_url"], width=64)
197
+ else:
198
+ st.markdown("### IG")
199
+ with header_cols[1]:
200
+ st.write(f"**{_clean(profile.get('name')) or 'Sem nome'}**")
201
+ st.caption(f"{_clean(profile.get('handle')) or _clean(profile.get('url')) or 'sem handler'}")
202
+ st.caption(f"{_clean(profile.get('country')) or 'País não definido'} · {_clean(profile.get('language')) or 'Idioma não definido'}")
203
+ with header_cols[2]:
204
+ st.metric("posts", _metric(profile.get("post_count")))
205
+ with header_cols[3]:
206
+ st.metric("Seguidores", _metric(profile.get("subscriber_count")))
207
+ with header_cols[4]:
208
+ st.metric("seguindo", _metric(profile.get("following_count")))
209
+ with header_cols[5]:
210
+ refresh_col, edit_col = st.columns(2)
211
+ with refresh_col:
212
+ if st.button("↻", help="Actualizar posts, seguidores e seguindo", key=f"refresh_instagram_{profile_id}"):
213
+ result = fetch_public_instagram_profile(_clean(profile.get("url")) or _clean(profile.get("handle")))
214
+ if result.ok:
215
+ update_channel(profile_id, {**result.data, "platform": "instagram", "country": profile.get("country", ""), "language": profile.get("language", ""), "character_id": profile.get("character_id", "")})
216
+ st.success("Métricas Instagram actualizadas.")
217
+ st.rerun()
218
+ st.warning(result.message)
219
+ with edit_col:
220
+ if st.button("Editar", key=f"edit_instagram_button_{profile_id}", use_container_width=True):
221
+ st.session_state[edit_key] = True
222
+ st.rerun()
223
+ url = _clean(profile.get("url"))
224
+ if url:
225
+ st.link_button("Abrir Instagram", url, use_container_width=True)
226
+
227
+ if st.session_state.get(edit_key):
228
+ with st.form(f"edit_instagram_profile_form_{profile_id}"):
229
+ edit_cols = st.columns(2)
230
+ with edit_cols[0]:
231
+ name = st.text_input("Nome", value=_clean(profile.get("name")), key=f"instagram_edit_name_{profile_id}")
232
+ handle = st.text_input("handler", value=_clean(profile.get("handle")), key=f"instagram_edit_handle_{profile_id}")
233
+ country = st.text_input("País", value=_clean(profile.get("country")), key=f"instagram_edit_country_{profile_id}")
234
+ language = st.text_input("Idioma", value=_clean(profile.get("language")), key=f"instagram_edit_language_{profile_id}")
235
+ with edit_cols[1]:
236
+ posts = st.number_input("posts", min_value=0, value=int(profile.get("post_count") or 0), key=f"instagram_edit_posts_{profile_id}")
237
+ followers = st.number_input("Seguidores", min_value=0, value=int(profile.get("subscriber_count") or 0), key=f"instagram_edit_followers_{profile_id}")
238
+ following = st.number_input("seguindo", min_value=0, value=int(profile.get("following_count") or 0), key=f"instagram_edit_following_{profile_id}")
239
+ save_edit = st.form_submit_button("Guardar alterações", type="primary", use_container_width=True)
240
+ if save_edit:
241
+ update_channel(profile_id, {"name": name.strip(), "handle": handle.strip(), "country": country.strip(), "language": language.strip(), "post_count": int(posts), "subscriber_count": int(followers), "following_count": int(following)})
242
+ st.session_state.pop(edit_key, None)
243
+ st.success("Conta Instagram actualizada.")
244
+ st.rerun()
245
+ else:
246
+ block_cols = st.columns(4, gap="small")
247
+ with block_cols[0]:
248
+ st.markdown(f"**Nome**\n\n{_clean(profile.get('name')) or '—'}")
249
+ with block_cols[1]:
250
+ st.markdown(f"**handler**\n\n{_clean(profile.get('handle')) or '—'}")
251
+ with block_cols[2]:
252
+ st.markdown(f"**País**\n\n{_clean(profile.get('country')) or '—'}")
253
+ with block_cols[3]:
254
+ st.markdown(f"**Idioma**\n\n{_clean(profile.get('language')) or '—'}")
255
+
256
+ character_options = [""] + [_clean(item.get("id")) for item in characters if _clean(item.get("id"))]
257
+ character_labels = {"": "Sem personagem associado"} | {_clean(item.get("id")): _clean(item.get("name")) or _clean(item.get("id")) for item in characters}
258
+ current_character = _clean(profile.get("character_id"))
259
+ selected_character = st.selectbox("Personagem", character_options, index=character_options.index(current_character) if current_character in character_options else 0, format_func=lambda value: character_labels.get(value, value), key=f"instagram_character_{profile_id}")
260
+ if st.button("Atrelar Personagem", key=f"attach_instagram_character_{profile_id}", use_container_width=True):
261
+ update_channel(profile_id, {"character_id": selected_character})
262
+ st.success("Personagem associado à conta Instagram.")
263
+ st.rerun()
264
+
265
+
266
+ def render_social_networks(settings: dict[str, Any]) -> None:
267
+ st.title("Redes Sociais")
268
+ st.caption("Cadastre contas Instagram através de pesquisa pública e associe cada conta a um Personagem de AI Influencers.")
269
+ search_tab, accounts_tab = st.tabs(["Pesquisa pública Instagram", "Contas Instagram"])
270
+ with search_tab:
271
+ st.caption("A pesquisa consulta apenas a página pública do Instagram. API Key não é necessária para cadastrar o perfil.")
272
+ source = st.text_input("URL pública ou @handler Instagram", placeholder="https://www.instagram.com/conta/ ou @conta", key="social_instagram_source")
273
+ search_cols = st.columns(2)
274
+ with search_cols[0]:
275
+ search_clicked = st.button("Pesquisar conta pública", type="primary", use_container_width=True, key="social_instagram_search")
276
+ with search_cols[1]:
277
+ if st.button("Limpar pesquisa", use_container_width=True, key="social_instagram_clear"):
278
+ for key in ("social_instagram_result", "social_instagram_ok", "social_instagram_message"):
279
+ st.session_state.pop(key, None)
280
+ st.rerun()
281
+ if search_clicked:
282
+ result = fetch_public_instagram_profile(source)
283
+ st.session_state["social_instagram_result"] = result.data if isinstance(result.data, dict) else {}
284
+ st.session_state["social_instagram_ok"] = result.ok
285
+ st.session_state["social_instagram_message"] = result.message
286
+ if st.session_state.get("social_instagram_message"):
287
+ (st.success if st.session_state.get("social_instagram_ok") else st.warning)(st.session_state["social_instagram_message"])
288
+ data = st.session_state.get("social_instagram_result", {}) if st.session_state.get("social_instagram_ok") else {}
289
+ if data:
290
+ characters, _ = _characters(settings)
291
+ with st.container(border=True):
292
+ st.subheader("Conta Instagram encontrada")
293
+ preview_cols = st.columns([0.8, 2.2, 1.2, 1.2, 1.2])
294
+ with preview_cols[0]:
295
+ if _clean(data.get("avatar_url")):
296
+ st.image(data["avatar_url"], width=72)
297
+ with preview_cols[1]:
298
+ st.write(f"**{_clean(data.get('name')) or _clean(data.get('username'))}**")
299
+ st.caption(_clean(data.get("handle")))
300
+ with preview_cols[2]:
301
+ st.metric("posts", _metric(data.get("post_count")))
302
+ with preview_cols[3]:
303
+ st.metric("Seguidores", _metric(data.get("subscriber_count")))
304
+ with preview_cols[4]:
305
+ st.metric("seguindo", _metric(data.get("following_count")))
306
+ with st.form("social_instagram_save_public_profile"):
307
+ form_cols = st.columns(2)
308
+ with form_cols[0]:
309
+ name = st.text_input("Nome", value=_clean(data.get("name")), key="social_instagram_result_name")
310
+ country = st.text_input("País", value=_clean(data.get("country")), key="social_instagram_result_country")
311
+ language = st.text_input("Idioma", value=_clean(data.get("language")), key="social_instagram_result_language")
312
+ with form_cols[1]:
313
+ posts = st.number_input("posts", min_value=0, value=int(data.get("post_count") or 0), key="social_instagram_result_posts")
314
+ followers = st.number_input("Seguidores", min_value=0, value=int(data.get("subscriber_count") or 0), key="social_instagram_result_followers")
315
+ following = st.number_input("seguindo", min_value=0, value=int(data.get("following_count") or 0), key="social_instagram_result_following")
316
+ character_options = [""] + [_clean(item.get("id")) for item in characters if _clean(item.get("id"))]
317
+ character_labels = {"": "Sem personagem associado"} | {_clean(item.get("id")): _clean(item.get("name")) or _clean(item.get("id")) for item in characters}
318
+ selected_character = st.selectbox("Personagem", character_options, format_func=lambda value: character_labels.get(value, value), key="social_instagram_result_character")
319
+ save_profile = st.form_submit_button("Cadastrar conta Instagram", type="primary", use_container_width=True)
320
+ if save_profile:
321
+ profile_data = dict(data)
322
+ profile_data.update({"name": name, "post_count": int(posts), "subscriber_count": int(followers), "following_count": int(following)})
323
+ _save_public_profile(profile_data, country=country, language=language, character_id=selected_character)
324
+ st.success("Conta Instagram cadastrada em Redes Sociais.")
325
+ for key in ("social_instagram_result", "social_instagram_ok", "social_instagram_message"):
326
+ st.session_state.pop(key, None)
327
+ st.rerun()
328
+ with accounts_tab:
329
+ profiles = _instagram_profiles()
330
+ if not profiles:
331
+ st.info("Ainda não existem contas Instagram cadastradas. Use a pesquisa pública para adicionar a primeira conta.")
332
+ characters, _ = _characters(settings)
333
+ for profile in profiles:
334
+ _render_instagram_card(profile, characters, settings)
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ from html import escape
4
+ from typing import Any
5
+
6
+ import streamlit as st
7
+
8
+
9
+ BILIBILI_CARDS: tuple[tuple[str, int, bool, tuple[tuple[str, str, str, str], ...]], ...] = (
10
+ (
11
+ "Validação de Procura",
12
+ 70,
13
+ True,
14
+ (
15
+ ("Tecto dos concorrentes", "A medir", "> 500k views", "A verificar"),
16
+ ("Volume de pesquisa", "A medir", "Procura crescente", "A verificar"),
17
+ ("Tráfego de pesquisa", "A medir", "15–30%", "A verificar"),
18
+ ("Diagnóstico", "Validar antes de produzir", "Nicho validado", "A verificar"),
19
+ ),
20
+ ),
21
+ (
22
+ "Thumbnail",
23
+ 70,
24
+ True,
25
+ (
26
+ ("CTR", "A medir", "> 6%", "A verificar"),
27
+ ("Contraste", "Alto contraste", "Fundo escuro + cor viva", "Meta"),
28
+ ("Texto", "3–5 palavras", "Legível no telemóvel", "Meta"),
29
+ ("Elementos Bilibili", "ACG quando adequado", "Visual expressivo", "Meta"),
30
+ ),
31
+ ),
32
+ (
33
+ "Título e Intenção",
34
+ 70,
35
+ True,
36
+ (
37
+ ("Estrutura", "Curiosity · Clarity · Context", "Promessa clara", "Meta"),
38
+ ("Keyword principal", "No início", "Pesquisa Bilibili", "Meta"),
39
+ ("CTR + retenção", "A medir", "CTR alto sem queda brusca", "A verificar"),
40
+ ("Formato", "Why / How to / VS", "Intenção explícita", "Meta"),
41
+ ),
42
+ ),
43
+ (
44
+ "Hook e Retenção",
45
+ 70,
46
+ False,
47
+ (
48
+ ("Retenção aos 15s", "A medir", "> 50%", "A verificar"),
49
+ ("Retenção aos 30s", "A medir", "> 70%", "A verificar"),
50
+ ("Método", "Clímax primeiro", "Resultado antes da introdução", "Meta"),
51
+ ("Abertura", "Sem introdução longa", "Primeiro frame forte", "Meta"),
52
+ ),
53
+ ),
54
+ (
55
+ "Ritmo e Edição",
56
+ 70,
57
+ False,
58
+ (
59
+ ("Pattern interrupt", "A cada 5–8s", "Sem imagens estáticas > 4s", "Meta"),
60
+ ("Quedas intermédias", "A medir", "Quedas pequenas", "A verificar"),
61
+ ("Texto danmaku", "Usar com intenção", "Comentários sobrepostos", "Meta"),
62
+ ("Cadência visual", "A medir", "Plateau de retenção", "A verificar"),
63
+ ),
64
+ ),
65
+ (
66
+ "Engagement 三连",
67
+ 70,
68
+ False,
69
+ (
70
+ ("三连率", "A medir", "> 4%", "A verificar"),
71
+ ("Likes + Coins + Favorites", "Três cliques", "Sinal de qualidade", "Meta"),
72
+ ("Danmaku", "A medir", "> 5/min", "A verificar"),
73
+ ("Comentários", "A medir", "> 1/100 views", "A verificar"),
74
+ ),
75
+ ),
76
+ (
77
+ "Tráfego e Algoritmo",
78
+ 70,
79
+ False,
80
+ (
81
+ ("Recommendation 推荐", "A medir", "40–60%", "A verificar"),
82
+ ("Search 搜索", "A medir", "15–30%", "A verificar"),
83
+ ("Followers 粉丝", "A medir", "10–30%", "A verificar"),
84
+ ("External 外部", "A medir", "< 10%", "A verificar"),
85
+ ),
86
+ ),
87
+ (
88
+ "Conversão e Saúde",
89
+ 70,
90
+ False,
91
+ (
92
+ ("Conversão em followers", "A medir", "> 1%", "A verificar"),
93
+ ("Fan Playback", "A medir", "40–60%", "A verificar"),
94
+ ("Novos viewers", "A medir", "> 20%", "A verificar"),
95
+ ("Abandono mensal", "A medir", "< 5%", "A verificar"),
96
+ ),
97
+ ),
98
+ )
99
+
100
+
101
+ def _card_html(card: tuple[str, int, bool, tuple[tuple[str, str, str, str], ...]]) -> str:
102
+ title, score, featured, rows = card
103
+ row_html = "".join(
104
+ f'<tr><td>{escape(label)}</td><td><b>{escape(value)}</b></td><td>{escape(target)}</td>'
105
+ f'<td><span style="color:#94a3b8;font-weight:700;">● {escape(status)}</span></td></tr>'
106
+ for label, value, target, status in rows
107
+ )
108
+ height = "350px" if featured else "280px"
109
+ title_size = "19px" if featured else "16px"
110
+ score_size = "38px" if featured else "30px"
111
+ return (
112
+ f'<div style="min-height:{height};border:1px solid #2b3b52;border-radius:14px;padding:16px;'
113
+ f'background:linear-gradient(145deg,#111c2b,#0b121c);box-shadow:0 8px 22px rgba(0,0,0,.16);">'
114
+ f'<div style="font-size:11px;font-weight:800;letter-spacing:.08em;color:#7dd3fc;text-transform:uppercase;">BILIBILI GROWTH</div>'
115
+ f'<div style="font-size:{title_size};font-weight:750;color:#f8fafc;margin-top:8px;">{escape(title)}</div>'
116
+ f'<div style="display:flex;align-items:baseline;gap:5px;margin:8px 0 10px;">'
117
+ f'<span style="font-size:{score_size};font-weight:850;color:#22c55e;">{score}</span>'
118
+ f'<span style="font-size:13px;color:#94a3b8;">/100 · meta do agente</span></div>'
119
+ f'<div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:11px;color:#cbd5e1;">'
120
+ f'<thead><tr><th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">KPI</th>'
121
+ f'<th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Valor</th>'
122
+ f'<th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Meta</th>'
123
+ f'<th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Estado</th></tr></thead>'
124
+ f'<tbody>{row_html}</tbody></table></div></div>'
125
+ )
126
+
127
+
128
+ def render_growth_bilibili() -> None:
129
+ st.title("Analista Bilibili")
130
+ st.caption("Como o agente opera e reporta: procura, click funnel, retenção, engagement 三连 e saúde da audiência Bilibili.")
131
+ st.info("Esta aba define o protocolo e o dashboard de reporte do agente. Os valores privados são preenchidos quando os dados autorizados do Bilibili Studio estiverem disponíveis; não são inventados a partir de páginas públicas.")
132
+
133
+ summary_col, status_col = st.columns([3.25, 1], gap="large")
134
+ with summary_col:
135
+ st.markdown(
136
+ '<div style="border:1px solid #263447;border-radius:12px;padding:18px;margin:12px 0 20px;'
137
+ 'background:#101722;display:flex;align-items:center;justify-content:space-between;min-height:76px;">'
138
+ '<div><div style="font-size:13px;color:#94a3b8;text-transform:uppercase;letter-spacing:.08em;">Estado do agente</div>'
139
+ '<div style="font-size:18px;font-weight:700;margin-top:5px;">Pronto para auditoria Bilibili</div></div>'
140
+ '<div style="font-size:18px;font-weight:800;color:#22c55e;">PROTOCOLO ACTIVO</div></div>',
141
+ unsafe_allow_html=True,
142
+ )
143
+ with status_col:
144
+ st.write("")
145
+ st.button("ANALISAR CANAL", disabled=True, use_container_width=True, key="growth_bilibili_analyse_disabled")
146
+ st.caption("A ligação ao Studio será usada quando autorizada.")
147
+
148
+ st.subheader("Dashboard de Growth")
149
+ st.caption("Os três pilares críticos aparecem primeiro; os cinco blocos operacionais completam o diagnóstico. Verde: referência de execução · Cinzento: dado a verificar no Studio.")
150
+ st.markdown('<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:14px 0 18px;">' + "".join(_card_html(card) for card in BILIBILI_CARDS[:3]) + '</div>', unsafe_allow_html=True)
151
+ st.markdown('<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:0 0 18px;">' + "".join(_card_html(card) for card in BILIBILI_CARDS[3:]) + '</div>', unsafe_allow_html=True)
152
+ st.caption("Legenda: o agente compara os dados autorizados com as metas Bilibili; quando um dado não existe, apresenta “A verificar” em vez de estimar.")
153
+
154
+ st.divider()
155
+ operation_tab, report_tab, monetization_tab = st.tabs(["Como o agente vai operar", "Como vai reportar", "Contexto de monetização"])
156
+ with operation_tab:
157
+ st.subheader("Protocolo de auditoria em 10 passos")
158
+ st.markdown("""
159
+ 1. **Validar procura:** compara o tecto de concorrentes, pesquisa e origem do tráfego.
160
+ 2. **Avaliar thumbnail:** verifica CTR, contraste, legibilidade móvel, emoção e elementos ACG quando adequados.
161
+ 3. **Avaliar título:** cruza CTR com retenção inicial e aplica Curiosity, Clarity e Context.
162
+ 4. **Verificar engagement:** mede a taxa 三连 (Like + Coin + Favorite), danmaku e qualidade dos comentários.
163
+ 5. **Diagnosticar hook:** localiza a queda dos primeiros 15–30 segundos e aplica “clímax primeiro”.
164
+ 6. **Diagnosticar ritmo:** identifica quedas recorrentes e recomenda pattern interrupts a cada 5–8 segundos.
165
+ 7. **Ler o algoritmo:** compara Recommendation, Search, Followers e External com as metas da plataforma.
166
+ 8. **Medir conversão:** calcula novos followers por visualização e posiciona a CTA aos 40% do vídeo.
167
+ 9. **Verificar cadência:** acompanha 1–2 vídeos por semana, consistência e uso de posts/live para comunidade.
168
+ 10. **Avaliar saúde:** cruza Fan Playback, New Viewer, abandono mensal e sinais moonshot.
169
+ """)
170
+ with report_tab:
171
+ st.subheader("Formato do reporte entregue pelo agente")
172
+ st.markdown("""
173
+ O agente reporta sempre em cinco blocos: **projecção financeira e contexto**, **resumo dos dez pilares**, **Top 3 prioridades accionáveis**, **estratégia de longo prazo** e **checklist dos próximos passos**. Cada KPI recebe valor observado, meta, estado e origem do dado.
174
+
175
+ As três prioridades são escolhidas por impacto: primeiro **Thumbnail + Título**, depois **retenção e engagement**, e só depois produção ou expansão. O relatório distingue claramente `DADO OBSERVADO`, `A VERIFICAR` e `RECOMENDAÇÃO`, sem transformar uma meta em resultado real.
176
+
177
+ > Regra de reporte: um vídeo com conteúdo forte, mas sem click funnel, não é considerado optimizado. O agente corrige primeiro a combinação thumbnail + título e acompanha a evolução no ciclo seguinte.
178
+ """)
179
+ st.markdown("**Checklist padrão:** validar nicho · corrigir as últimas 5 thumbnails · reescrever títulos · cortar a introdução · inserir prompt danmaku · publicar em dia fixo · rever analytics em 30 dias.")
180
+ with monetization_tab:
181
+ st.subheader("Referências internas usadas pelo agente")
182
+ st.markdown("""
183
+ A projecção considera Video Incentive Program, Charging, Paid Subscriptions e Huahuo. O cálculo interno é: `Playbacks × Base Rate × Zone Multiplier × Quality Multiplier + Ad Revenue + Tips + Subscriptions`.
184
+
185
+ Para o diagnóstico inicial, o agente acompanha originalidade do conteúdo, verificação de identidade, estado da conta, direitos ≥ 3, score de crédito ≥ 80 e os limiares de audiência definidos no material Bilibili. Estes itens são **contexto de elegibilidade**, não promessa de rendimento.
186
+
187
+ Os sinais moonshot acompanhados são densidade de danmaku (> 5 por minuto), crescimento do Search Index (+20% mensal), Long-tail Traffic (> 30%), Shares (> 1%) e Collect Rate (> 5%).
188
+ """)
@@ -62,14 +62,21 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
62
62
  title = _meta(response.text, 'og:title') or reference['username']
63
63
  description = _meta(response.text, 'og:description')
64
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()}
65
+ def metric(patterns: tuple[str, ...]) -> int | None:
66
+ for pattern in patterns:
67
+ found = re.search(pattern, description, flags=re.IGNORECASE)
68
+ if not found:
69
+ continue
70
+ try:
71
+ return int(re.sub(r'[^0-9]', '', found.group(1)))
72
+ except ValueError:
73
+ continue
74
+ return None
75
+
76
+ followers = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:followers|seguidores)',))
77
+ following = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:following|seguindo)',))
78
+ posts = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:posts|publicações|publications)',))
79
+ data = {'id': f"instagram_{reference['username']}", **reference, 'name': title.split('(')[0].strip() or reference['username'], 'bio': description, 'avatar_url': avatar_url, 'subscriber_count': followers, 'following_count': following, 'post_count': posts, 'public_lookup': True, 'metrics_source': 'instagram_public_page', 'last_public_lookup_at': datetime.now(timezone.utc).isoformat()}
73
80
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
74
81
 
75
82
 
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Mapping
5
+
6
+ import requests
7
+
8
+
9
+ META_GRAPH_BASE_URL = "https://graph.facebook.com/v20.0"
10
+
11
+
12
+ def _result(status: str, message: str, *, status_code: int | None = None, data: Mapping[str, Any] | None = None) -> dict[str, Any]:
13
+ payload: dict[str, Any] = {
14
+ "status": status,
15
+ "message": message[:240],
16
+ "checked_at": datetime.now(timezone.utc).isoformat(),
17
+ }
18
+ if status_code is not None:
19
+ payload["status_code"] = int(status_code)
20
+ if data:
21
+ payload["data"] = dict(data)
22
+ return payload
23
+
24
+
25
+ def _get_graph(identifier: str, access_token: str, fields: str) -> dict[str, Any]:
26
+ identifier = str(identifier or "").strip()
27
+ access_token = str(access_token or "").strip()
28
+ if not access_token:
29
+ return _result("missing", "Informe a API key/token antes do teste.")
30
+ if not identifier:
31
+ return _result("missing", "Informe o identificador da conta antes do teste.")
32
+ try:
33
+ response = requests.get(
34
+ f"{META_GRAPH_BASE_URL}/{identifier}",
35
+ params={"fields": fields, "access_token": access_token},
36
+ timeout=15,
37
+ )
38
+ except requests.RequestException:
39
+ return _result("error", "Não foi possível contactar a Meta Graph API.")
40
+ try:
41
+ payload = response.json()
42
+ except ValueError:
43
+ payload = {}
44
+ if response.status_code >= 400 or payload.get("error"):
45
+ error = payload.get("error") if isinstance(payload, dict) else {}
46
+ message = str(error.get("message") or f"A Meta Graph API devolveu HTTP {response.status_code}.")
47
+ return _result("error", message, status_code=response.status_code)
48
+ return _result("success", "Chamada Meta Graph API concluída.", status_code=response.status_code, data={
49
+ "id": payload.get("id"),
50
+ "name": payload.get("name"),
51
+ "username": payload.get("username"),
52
+ })
53
+
54
+
55
+ def test_instagram_api_card(card: Mapping[str, Any]) -> dict[str, Any]:
56
+ """Validate an Instagram Graph API card without persisting its token."""
57
+ return _get_graph(
58
+ str(card.get("account_id") or ""),
59
+ str(card.get("access_token") or ""),
60
+ "id,username,name,profile_picture_url,followers_count,media_count",
61
+ )
62
+
63
+
64
+ def test_facebook_pages_api_card(card: Mapping[str, Any]) -> dict[str, Any]:
65
+ """Validate a Facebook Pages Graph API card without persisting its token."""
66
+ return _get_graph(
67
+ str(card.get("page_id") or ""),
68
+ str(card.get("access_token") or ""),
69
+ "id,name,fan_count,followers_count,link",
70
+ )
71
+
72
+
73
+ __all__ = ["META_GRAPH_BASE_URL", "test_instagram_api_card", "test_facebook_pages_api_card"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
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",
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "app/main.py",
13
13
  "app/influencers_ui.py",
14
+ "app/social_networks_ui.py",
14
15
  "app/modules/**/*.py",
15
16
  "hermes_ui/*.py",
16
17
  "integrations/*.py",