@danhachuel/thunderbolt 0.6.14 → 0.6.16
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 +6 -1
- package/app/social_networks_ui.py +343 -0
- package/hermes_ui/domain.py +8 -0
- package/integrations/instagram_public.py +32 -8
- package/integrations/meta_social.py +73 -0
- package/package.json +2 -1
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
|
|
@@ -8035,6 +8036,10 @@ def render_settings():
|
|
|
8035
8036
|
st.rerun()
|
|
8036
8037
|
with st.expander("API Tiktok", expanded=False):
|
|
8037
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")
|
|
8038
8043
|
with st.expander("API Bilibili", expanded=False):
|
|
8039
8044
|
bilibili_cards = settings.get("bilibili_api_cards") if isinstance(settings.get("bilibili_api_cards"), list) else []
|
|
8040
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))
|
|
@@ -9072,7 +9077,7 @@ def main():
|
|
|
9072
9077
|
"Geração de Conteúdo IA": lambda: render_ai_influencer_content(read_json("settings.json", {})),
|
|
9073
9078
|
"Motion Control": lambda: render_motion_control(read_json("settings.json", {})),
|
|
9074
9079
|
"UGC Products": lambda: render_ugc_products(read_json("settings.json", {})),
|
|
9075
|
-
"Redes Sociais": lambda:
|
|
9080
|
+
"Redes Sociais": lambda: render_social_networks(read_json("settings.json", {})),
|
|
9076
9081
|
"Analista Growth Youtube": render_growth_youtube,
|
|
9077
9082
|
"Analista Growth Tiktok": render_growth_tiktok,
|
|
9078
9083
|
"Analista Growth Instagram": render_growth_instagram,
|
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
profiles: list[dict[str, Any]] = []
|
|
156
|
+
for item in records:
|
|
157
|
+
if not isinstance(item, dict):
|
|
158
|
+
continue
|
|
159
|
+
platform = _clean(item.get("platform")).lower()
|
|
160
|
+
network = _clean(item.get("social_network")).lower()
|
|
161
|
+
url = _clean(item.get("url")).lower()
|
|
162
|
+
if platform == "instagram" or network == "instagram" or "instagram.com/" in url:
|
|
163
|
+
profiles.append(item)
|
|
164
|
+
return profiles
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _characters(settings: Mapping[str, Any]) -> tuple[list[dict[str, Any]], Any | None]:
|
|
168
|
+
try:
|
|
169
|
+
repository = get_repository(settings)
|
|
170
|
+
items = [item for item in repository.list_influencers() if _clean(item.get("id")) != STANDALONE_CONTENT_INFLUENCER_ID]
|
|
171
|
+
return items, repository
|
|
172
|
+
except (InfluencerBackendError, Exception):
|
|
173
|
+
return [], None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _save_public_profile(data: Mapping[str, Any], *, country: str, language: str, character_id: str = "") -> dict[str, Any]:
|
|
177
|
+
name = _clean(data.get("name")) or _clean(data.get("username")) or "Conta Instagram"
|
|
178
|
+
url = _clean(data.get("url"))
|
|
179
|
+
metadata = {
|
|
180
|
+
**dict(data),
|
|
181
|
+
"platform": "instagram",
|
|
182
|
+
"social_network": "Instagram",
|
|
183
|
+
"name": name,
|
|
184
|
+
"url": url,
|
|
185
|
+
"handle": _clean(data.get("handle")),
|
|
186
|
+
"country": country.strip(),
|
|
187
|
+
"language": language.strip(),
|
|
188
|
+
"post_count": data.get("post_count"),
|
|
189
|
+
"subscriber_count": data.get("subscriber_count"),
|
|
190
|
+
"following_count": data.get("following_count"),
|
|
191
|
+
"character_id": character_id.strip(),
|
|
192
|
+
"active": True,
|
|
193
|
+
"metrics_source": "instagram_public_page",
|
|
194
|
+
}
|
|
195
|
+
return create_channel(name, url, metadata)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, Any]], settings: Mapping[str, Any]) -> None:
|
|
199
|
+
profile_id = _clean(profile.get("id"))
|
|
200
|
+
edit_key = f"edit_instagram_profile_{profile_id}"
|
|
201
|
+
with st.container(border=True):
|
|
202
|
+
header_cols = st.columns([0.7, 3.1, 1.25, 1.25, 1.25, 1.65])
|
|
203
|
+
with header_cols[0]:
|
|
204
|
+
if _clean(profile.get("avatar_url")):
|
|
205
|
+
st.image(profile["avatar_url"], width=64)
|
|
206
|
+
else:
|
|
207
|
+
st.markdown("### IG")
|
|
208
|
+
with header_cols[1]:
|
|
209
|
+
st.write(f"**{_clean(profile.get('name')) or 'Sem nome'}**")
|
|
210
|
+
st.caption(f"{_clean(profile.get('handle')) or _clean(profile.get('url')) or 'sem handler'}")
|
|
211
|
+
st.caption(f"{_clean(profile.get('country')) or 'País não definido'} · {_clean(profile.get('language')) or 'Idioma não definido'}")
|
|
212
|
+
with header_cols[2]:
|
|
213
|
+
st.metric("posts", _metric(profile.get("post_count")))
|
|
214
|
+
with header_cols[3]:
|
|
215
|
+
st.metric("Seguidores", _metric(profile.get("subscriber_count")))
|
|
216
|
+
with header_cols[4]:
|
|
217
|
+
st.metric("seguindo", _metric(profile.get("following_count")))
|
|
218
|
+
with header_cols[5]:
|
|
219
|
+
refresh_col, edit_col = st.columns(2)
|
|
220
|
+
with refresh_col:
|
|
221
|
+
if st.button("↻", help="Actualizar posts, seguidores e seguindo", key=f"refresh_instagram_{profile_id}"):
|
|
222
|
+
result = fetch_public_instagram_profile(_clean(profile.get("url")) or _clean(profile.get("handle")))
|
|
223
|
+
if result.ok:
|
|
224
|
+
update_channel(profile_id, {**result.data, "platform": "instagram", "country": profile.get("country", ""), "language": profile.get("language", ""), "character_id": profile.get("character_id", "")})
|
|
225
|
+
st.success("Métricas Instagram actualizadas.")
|
|
226
|
+
st.rerun()
|
|
227
|
+
st.warning(result.message)
|
|
228
|
+
with edit_col:
|
|
229
|
+
if st.button("Editar", key=f"edit_instagram_button_{profile_id}", use_container_width=True):
|
|
230
|
+
st.session_state[edit_key] = True
|
|
231
|
+
st.rerun()
|
|
232
|
+
url = _clean(profile.get("url"))
|
|
233
|
+
if url:
|
|
234
|
+
st.link_button("Abrir Instagram", url, use_container_width=True)
|
|
235
|
+
|
|
236
|
+
if st.session_state.get(edit_key):
|
|
237
|
+
with st.form(f"edit_instagram_profile_form_{profile_id}"):
|
|
238
|
+
edit_cols = st.columns(2)
|
|
239
|
+
with edit_cols[0]:
|
|
240
|
+
name = st.text_input("Nome", value=_clean(profile.get("name")), key=f"instagram_edit_name_{profile_id}")
|
|
241
|
+
handle = st.text_input("handler", value=_clean(profile.get("handle")), key=f"instagram_edit_handle_{profile_id}")
|
|
242
|
+
country = st.text_input("País", value=_clean(profile.get("country")), key=f"instagram_edit_country_{profile_id}")
|
|
243
|
+
language = st.text_input("Idioma", value=_clean(profile.get("language")), key=f"instagram_edit_language_{profile_id}")
|
|
244
|
+
with edit_cols[1]:
|
|
245
|
+
posts = st.number_input("posts", min_value=0, value=int(profile.get("post_count") or 0), key=f"instagram_edit_posts_{profile_id}")
|
|
246
|
+
followers = st.number_input("Seguidores", min_value=0, value=int(profile.get("subscriber_count") or 0), key=f"instagram_edit_followers_{profile_id}")
|
|
247
|
+
following = st.number_input("seguindo", min_value=0, value=int(profile.get("following_count") or 0), key=f"instagram_edit_following_{profile_id}")
|
|
248
|
+
save_edit = st.form_submit_button("Guardar alterações", type="primary", use_container_width=True)
|
|
249
|
+
if save_edit:
|
|
250
|
+
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)})
|
|
251
|
+
st.session_state.pop(edit_key, None)
|
|
252
|
+
st.success("Conta Instagram actualizada.")
|
|
253
|
+
st.rerun()
|
|
254
|
+
else:
|
|
255
|
+
block_cols = st.columns(4, gap="small")
|
|
256
|
+
with block_cols[0]:
|
|
257
|
+
st.markdown(f"**Nome**\n\n{_clean(profile.get('name')) or '—'}")
|
|
258
|
+
with block_cols[1]:
|
|
259
|
+
st.markdown(f"**handler**\n\n{_clean(profile.get('handle')) or '—'}")
|
|
260
|
+
with block_cols[2]:
|
|
261
|
+
st.markdown(f"**País**\n\n{_clean(profile.get('country')) or '—'}")
|
|
262
|
+
with block_cols[3]:
|
|
263
|
+
st.markdown(f"**Idioma**\n\n{_clean(profile.get('language')) or '—'}")
|
|
264
|
+
|
|
265
|
+
character_options = [""] + [_clean(item.get("id")) for item in characters if _clean(item.get("id"))]
|
|
266
|
+
character_labels = {"": "Sem personagem associado"} | {_clean(item.get("id")): _clean(item.get("name")) or _clean(item.get("id")) for item in characters}
|
|
267
|
+
current_character = _clean(profile.get("character_id"))
|
|
268
|
+
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}")
|
|
269
|
+
if st.button("Atrelar Personagem", key=f"attach_instagram_character_{profile_id}", use_container_width=True):
|
|
270
|
+
update_channel(profile_id, {"character_id": selected_character})
|
|
271
|
+
st.success("Personagem associado à conta Instagram.")
|
|
272
|
+
st.rerun()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def render_social_networks(settings: dict[str, Any]) -> None:
|
|
276
|
+
st.title("Redes Sociais")
|
|
277
|
+
st.caption("Cadastre contas Instagram através de pesquisa pública e associe cada conta a um Personagem de AI Influencers.")
|
|
278
|
+
search_tab, accounts_tab = st.tabs(["Pesquisa pública Instagram", "Contas Instagram"])
|
|
279
|
+
with search_tab:
|
|
280
|
+
st.caption("A pesquisa consulta apenas a página pública do Instagram. API Key não é necessária para cadastrar o perfil.")
|
|
281
|
+
source = st.text_input("URL pública ou @handler Instagram", placeholder="https://www.instagram.com/conta/ ou @conta", key="social_instagram_source")
|
|
282
|
+
search_cols = st.columns(2)
|
|
283
|
+
with search_cols[0]:
|
|
284
|
+
search_clicked = st.button("Pesquisar conta pública", type="primary", use_container_width=True, key="social_instagram_search")
|
|
285
|
+
with search_cols[1]:
|
|
286
|
+
if st.button("Limpar pesquisa", use_container_width=True, key="social_instagram_clear"):
|
|
287
|
+
for key in ("social_instagram_result", "social_instagram_ok", "social_instagram_message"):
|
|
288
|
+
st.session_state.pop(key, None)
|
|
289
|
+
st.rerun()
|
|
290
|
+
if search_clicked:
|
|
291
|
+
result = fetch_public_instagram_profile(source)
|
|
292
|
+
st.session_state["social_instagram_result"] = result.data if isinstance(result.data, dict) else {}
|
|
293
|
+
st.session_state["social_instagram_ok"] = result.ok
|
|
294
|
+
st.session_state["social_instagram_message"] = result.message
|
|
295
|
+
if st.session_state.get("social_instagram_message"):
|
|
296
|
+
(st.success if st.session_state.get("social_instagram_ok") else st.warning)(st.session_state["social_instagram_message"])
|
|
297
|
+
data = st.session_state.get("social_instagram_result", {}) if st.session_state.get("social_instagram_ok") else {}
|
|
298
|
+
if data:
|
|
299
|
+
characters, _ = _characters(settings)
|
|
300
|
+
with st.container(border=True):
|
|
301
|
+
st.subheader("Conta Instagram encontrada")
|
|
302
|
+
preview_cols = st.columns([0.8, 2.2, 1.2, 1.2, 1.2])
|
|
303
|
+
with preview_cols[0]:
|
|
304
|
+
if _clean(data.get("avatar_url")):
|
|
305
|
+
st.image(data["avatar_url"], width=72)
|
|
306
|
+
with preview_cols[1]:
|
|
307
|
+
st.write(f"**{_clean(data.get('name')) or _clean(data.get('username'))}**")
|
|
308
|
+
st.caption(_clean(data.get("handle")))
|
|
309
|
+
with preview_cols[2]:
|
|
310
|
+
st.metric("posts", _metric(data.get("post_count")))
|
|
311
|
+
with preview_cols[3]:
|
|
312
|
+
st.metric("Seguidores", _metric(data.get("subscriber_count")))
|
|
313
|
+
with preview_cols[4]:
|
|
314
|
+
st.metric("seguindo", _metric(data.get("following_count")))
|
|
315
|
+
with st.form("social_instagram_save_public_profile"):
|
|
316
|
+
form_cols = st.columns(2)
|
|
317
|
+
with form_cols[0]:
|
|
318
|
+
name = st.text_input("Nome", value=_clean(data.get("name")), key="social_instagram_result_name")
|
|
319
|
+
country = st.text_input("País", value=_clean(data.get("country")), key="social_instagram_result_country")
|
|
320
|
+
language = st.text_input("Idioma", value=_clean(data.get("language")), key="social_instagram_result_language")
|
|
321
|
+
with form_cols[1]:
|
|
322
|
+
posts = st.number_input("posts", min_value=0, value=int(data.get("post_count") or 0), key="social_instagram_result_posts")
|
|
323
|
+
followers = st.number_input("Seguidores", min_value=0, value=int(data.get("subscriber_count") or 0), key="social_instagram_result_followers")
|
|
324
|
+
following = st.number_input("seguindo", min_value=0, value=int(data.get("following_count") or 0), key="social_instagram_result_following")
|
|
325
|
+
character_options = [""] + [_clean(item.get("id")) for item in characters if _clean(item.get("id"))]
|
|
326
|
+
character_labels = {"": "Sem personagem associado"} | {_clean(item.get("id")): _clean(item.get("name")) or _clean(item.get("id")) for item in characters}
|
|
327
|
+
selected_character = st.selectbox("Personagem", character_options, format_func=lambda value: character_labels.get(value, value), key="social_instagram_result_character")
|
|
328
|
+
save_profile = st.form_submit_button("Cadastrar conta Instagram", type="primary", use_container_width=True)
|
|
329
|
+
if save_profile:
|
|
330
|
+
profile_data = dict(data)
|
|
331
|
+
profile_data.update({"name": name, "post_count": int(posts), "subscriber_count": int(followers), "following_count": int(following)})
|
|
332
|
+
_save_public_profile(profile_data, country=country, language=language, character_id=selected_character)
|
|
333
|
+
st.success("Conta Instagram cadastrada em Redes Sociais.")
|
|
334
|
+
for key in ("social_instagram_result", "social_instagram_ok", "social_instagram_message"):
|
|
335
|
+
st.session_state.pop(key, None)
|
|
336
|
+
st.rerun()
|
|
337
|
+
with accounts_tab:
|
|
338
|
+
profiles = _instagram_profiles()
|
|
339
|
+
if not profiles:
|
|
340
|
+
st.info("Ainda não existem contas Instagram cadastradas. Use a pesquisa pública para adicionar a primeira conta.")
|
|
341
|
+
characters, _ = _characters(settings)
|
|
342
|
+
for profile in profiles:
|
|
343
|
+
_render_instagram_card(profile, characters, settings)
|
package/hermes_ui/domain.py
CHANGED
|
@@ -33,6 +33,8 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
|
|
|
33
33
|
"google_account_email": "",
|
|
34
34
|
"name": name.strip(),
|
|
35
35
|
"url": url.strip(),
|
|
36
|
+
"platform": "youtube",
|
|
37
|
+
"social_network": "YouTube",
|
|
36
38
|
"handle": "",
|
|
37
39
|
"description": "",
|
|
38
40
|
"description_source": "",
|
|
@@ -41,9 +43,15 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
|
|
|
41
43
|
"thumbnail_url": "",
|
|
42
44
|
"avatar_url": "",
|
|
43
45
|
"subscriber_count": None,
|
|
46
|
+
"post_count": None,
|
|
47
|
+
"following_count": None,
|
|
44
48
|
"video_count": None,
|
|
45
49
|
"view_count": None,
|
|
46
50
|
"metrics_source": "manual",
|
|
51
|
+
"public_lookup": False,
|
|
52
|
+
"last_public_lookup_at": None,
|
|
53
|
+
"country": "",
|
|
54
|
+
"character_id": "",
|
|
47
55
|
"last_youtube_sync": None,
|
|
48
56
|
"language": "Português",
|
|
49
57
|
"blueprint_id": "",
|
|
@@ -48,6 +48,23 @@ def _meta(document: str, name: str) -> str:
|
|
|
48
48
|
return ''
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def _structured_metric(document: str, *keys: str) -> int | None:
|
|
52
|
+
"""Read numeric Instagram counters embedded in the public HTML payload."""
|
|
53
|
+
for key in keys:
|
|
54
|
+
patterns = (
|
|
55
|
+
rf'"{re.escape(key)}"\s*:\s*\{{\s*"count"\s*:\s*(\d+)',
|
|
56
|
+
rf'"{re.escape(key)}"\s*:\s*(\d+)',
|
|
57
|
+
)
|
|
58
|
+
for pattern in patterns:
|
|
59
|
+
found = re.search(pattern, document, flags=re.IGNORECASE)
|
|
60
|
+
if found:
|
|
61
|
+
try:
|
|
62
|
+
return int(found.group(1))
|
|
63
|
+
except ValueError:
|
|
64
|
+
continue
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
51
68
|
def fetch_public_instagram_profile(source: str) -> IntegrationResult:
|
|
52
69
|
try:
|
|
53
70
|
reference = normalize_instagram_reference(source)
|
|
@@ -62,14 +79,21 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
|
|
|
62
79
|
title = _meta(response.text, 'og:title') or reference['username']
|
|
63
80
|
description = _meta(response.text, 'og:description')
|
|
64
81
|
avatar_url = _meta(response.text, 'og:image')
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
def metric(patterns: tuple[str, ...]) -> int | None:
|
|
83
|
+
for pattern in patterns:
|
|
84
|
+
found = re.search(pattern, description, flags=re.IGNORECASE)
|
|
85
|
+
if not found:
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
return int(re.sub(r'[^0-9]', '', found.group(1)))
|
|
89
|
+
except ValueError:
|
|
90
|
+
continue
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
followers = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:followers|seguidores)',)) or _structured_metric(response.text, 'edge_followed_by', 'followers', 'follower_count')
|
|
94
|
+
following = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:following|seguindo)',)) or _structured_metric(response.text, 'edge_follow', 'following', 'following_count')
|
|
95
|
+
posts = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:posts|publicações|publications)',)) or _structured_metric(response.text, 'edge_owner_to_timeline_media', 'posts', 'post_count')
|
|
96
|
+
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
97
|
return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
|
|
74
98
|
|
|
75
99
|
|
|
@@ -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.
|
|
3
|
+
"version": "0.6.16",
|
|
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",
|