@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 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: 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", {})),
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)
@@ -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
- 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()}
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.14",
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",