@danhachuel/thunderbolt 0.6.21 → 0.6.23

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.
@@ -8,11 +8,12 @@ from typing import Any, Callable, Mapping
8
8
  import requests
9
9
  import streamlit as st
10
10
 
11
- from hermes_ui.domain import create_channel, update_channel
11
+ from hermes_ui.domain import create_channel, delete_channel, update_channel
12
12
  from hermes_ui.influencers import InfluencerBackendError, STANDALONE_CONTENT_INFLUENCER_ID, get_repository
13
13
  from hermes_ui.storage import read_json, write_json
14
+ from hermes_ui.countries import COUNTRY_OPTIONS
14
15
  from hermes_ui.languages import LANGUAGE_CODES, language_code, language_label
15
- from integrations.instagram_public import fetch_public_instagram_posts, fetch_public_instagram_profile
16
+ from integrations.instagram_public import fetch_public_instagram_posts, fetch_public_instagram_profile, normalize_instagram_bio
16
17
  from integrations.meta_social import test_facebook_pages_api_card, test_instagram_api_card
17
18
 
18
19
 
@@ -42,6 +43,20 @@ def _language_index(value: Any) -> int:
42
43
  return list(LANGUAGE_CODES).index(code) if code in LANGUAGE_CODES else list(LANGUAGE_CODES).index("pt")
43
44
 
44
45
 
46
+ def _country_options() -> tuple[str, ...]:
47
+ return ("",) + COUNTRY_OPTIONS
48
+
49
+
50
+ def _country_index(value: Any) -> int:
51
+ options = _country_options()
52
+ value = _clean(value)
53
+ return options.index(value) if value in options else 0
54
+
55
+
56
+ def _country_label(value: str) -> str:
57
+ return value or "Não definido"
58
+
59
+
45
60
  def _instagram_posts_key(profile_id: str) -> str:
46
61
  return f"instagram_posts_{profile_id}"
47
62
 
@@ -298,7 +313,7 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
298
313
  with header_cols[1]:
299
314
  st.write(f"**{_clean(profile.get('name')) or 'Sem nome'}**")
300
315
  st.caption(f"{_clean(profile.get('handle')) or _clean(profile.get('url')) or 'sem handler'}")
301
- bio = _clean(profile.get("bio"))
316
+ bio = normalize_instagram_bio(profile.get("bio"))
302
317
  if bio:
303
318
  st.caption(f"Bio: {bio}")
304
319
  st.caption(f"{_clean(profile.get('country')) or 'País não definido'} · {_clean(profile.get('language')) or 'Idioma não definido'}")
@@ -326,17 +341,34 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
326
341
  if st.button("Editar", key=f"edit_instagram_button_{profile_id}", use_container_width=True):
327
342
  st.session_state[edit_key] = True
328
343
  st.rerun()
344
+ if st.button("Apagar", key=f"delete_instagram_button_{profile_id}", use_container_width=True):
345
+ st.session_state[f"confirm_delete_instagram_{profile_id}"] = True
346
+ st.rerun()
329
347
  url = _clean(profile.get("url"))
330
348
  if url:
331
349
  st.link_button("Abrir Instagram", url, use_container_width=True)
332
350
 
351
+ if st.session_state.get(f"confirm_delete_instagram_{profile_id}"):
352
+ st.warning("Apagar este card remove o cadastro da conta Instagram, mas não os ficheiros associados.")
353
+ confirm_cols = st.columns(2)
354
+ with confirm_cols[0]:
355
+ if st.button("Confirmar apagar", type="primary", key=f"confirm_delete_instagram_button_{profile_id}", use_container_width=True):
356
+ delete_channel(profile_id)
357
+ st.session_state.pop(f"confirm_delete_instagram_{profile_id}", None)
358
+ st.success("Conta Instagram apagada.")
359
+ st.rerun()
360
+ with confirm_cols[1]:
361
+ if st.button("Cancelar", key=f"cancel_delete_instagram_button_{profile_id}", use_container_width=True):
362
+ st.session_state.pop(f"confirm_delete_instagram_{profile_id}", None)
363
+ st.rerun()
364
+
333
365
  if st.session_state.get(edit_key):
334
366
  with st.form(f"edit_instagram_profile_form_{profile_id}"):
335
367
  edit_cols = st.columns(2)
336
368
  with edit_cols[0]:
337
369
  name = st.text_input("Nome", value=_clean(profile.get("name")), key=f"instagram_edit_name_{profile_id}")
338
370
  handle = st.text_input("handler", value=_clean(profile.get("handle")), key=f"instagram_edit_handle_{profile_id}")
339
- country = st.text_input("País", value=_clean(profile.get("country")), key=f"instagram_edit_country_{profile_id}")
371
+ country = st.selectbox("País", _country_options(), index=_country_index(profile.get("country")), format_func=_country_label, key=f"instagram_edit_country_{profile_id}")
340
372
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=_language_index(profile.get("language")), format_func=language_label, key=f"instagram_edit_language_{profile_id}")
341
373
  with edit_cols[1]:
342
374
  posts = st.number_input("posts", min_value=0, value=int(profile.get("post_count") or 0), key=f"instagram_edit_posts_{profile_id}")
@@ -404,6 +436,9 @@ def render_social_networks(settings: dict[str, Any]) -> None:
404
436
  with preview_cols[1]:
405
437
  st.write(f"**{_clean(data.get('name')) or _clean(data.get('username'))}**")
406
438
  st.caption(_clean(data.get("handle")))
439
+ bio = normalize_instagram_bio(data.get("bio"))
440
+ if bio:
441
+ st.caption(f"Bio: {bio}")
407
442
  with preview_cols[2]:
408
443
  st.metric("posts", _metric(data.get("post_count")))
409
444
  with preview_cols[3]:
@@ -414,7 +449,7 @@ def render_social_networks(settings: dict[str, Any]) -> None:
414
449
  form_cols = st.columns(2)
415
450
  with form_cols[0]:
416
451
  name = st.text_input("Nome", value=_clean(data.get("name")), key="social_instagram_result_name")
417
- country = st.text_input("País", value=_clean(data.get("country")), key="social_instagram_result_country")
452
+ country = st.selectbox("País", _country_options(), index=_country_index(data.get("country")), format_func=_country_label, key="social_instagram_result_country")
418
453
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=_language_index(data.get("language")), format_func=language_label, key="social_instagram_result_language")
419
454
  with form_cols[1]:
420
455
  posts = st.number_input("posts", min_value=0, value=int(data.get("post_count") or 0), key="social_instagram_result_posts")
@@ -0,0 +1,43 @@
1
+ """Canonical country catalog for social-account metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ COUNTRY_OPTIONS: tuple[str, ...] = (
6
+ "Abecásia", "Afeganistão", "África do Sul", "Albânia", "Alemanha", "Andorra", "Angola",
7
+ "Antígua e Barbuda", "Arábia Saudita", "Argélia", "Argentina", "Armênia", "Austrália", "Áustria",
8
+ "Azerbaijão", "Bahamas", "Bahrein", "Bangladesh", "Barbados", "Bélgica", "Belize", "Benim",
9
+ "Bielorrússia", "Bolívia", "Bósnia e Herzegovina", "Botsuana", "Brasil", "Brunei", "Bulgária",
10
+ "Burquina Fasso", "Burundi", "Butão", "Cabo Verde", "Camarões", "Camboja", "Canadá", "Catar",
11
+ "Cazaquistão", "Chade", "Chile", "China", "Chipre", "Chipre do Norte", "Cingapura", "Colômbia",
12
+ "Comores", "Congo", "Coreia do Norte", "Coreia do Sul", "Costa do Marfim", "Costa Rica", "Croácia",
13
+ "Cuba", "Dinamarca", "Djibouti", "Dominica", "República Dominicana", "Egito", "El Salvador",
14
+ "Emirados Árabes Unidos", "Equador", "Eritreia", "Eslováquia", "Eslovênia", "Espanha",
15
+ "Estados Federados da Micronésia", "Estados Unidos", "Estônia", "Essuatíni (Suazilândia)", "Etiópia",
16
+ "Fiji", "Filipinas", "Finlândia", "França", "Gabão", "Gâmbia", "Gana", "Geórgia", "Granada",
17
+ "Grécia", "Guatemala", "Guiana", "Guiné", "Guiné-Bissau", "Guiné Equatorial", "Haiti", "Honduras",
18
+ "Hungria", "Iêmen", "Ilhas Cook", "Índia", "Indonésia", "Irã", "Irlanda", "Islândia", "Israel",
19
+ "Itália", "Jamaica", "Japão", "Jordânia", "Kiribati", "Kosovo", "Kuwait", "Laos", "Lesoto",
20
+ "Letônia", "Líbano", "Libéria", "Líbia", "Liechtenstein", "Lituânia", "Luxemburgo", "Macedônia do Norte",
21
+ "Madagascar", "Malásia", "Malawi", "Maldivas", "Mali", "Malta", "Marrocos", "Ilhas Marshall",
22
+ "Maurício", "Mauritânia", "México", "Mianmar", "Moçambique", "Moldávia", "Mônaco", "Mongólia",
23
+ "Montenegro", "Namíbia", "Nauru", "Nepal", "Nicarágua", "Níger", "Nigéria", "Niue", "Noruega",
24
+ "Nova Zelândia", "Omã", "Ossétia do Sul", "Países Baixos", "Palau", "Palestina", "Panamá",
25
+ "Papua-Nova Guiné", "Paquistão", "Paraguai", "Peru", "Polônia", "Portugal", "Quênia", "Quirguistão",
26
+ "Reino Unido", "República Centro-Africana", "República Checa", "República Democrática do Congo", "Romênia",
27
+ "Ruanda", "Rússia", "Saara Ocidental (República Árabe Saharaui Democrática)", "Samoa", "San Marino",
28
+ "Santa Lúcia", "São Cristóvão e Nevis", "São Tomé e Príncipe", "São Vicente e Granadinas", "Senegal",
29
+ "Serra Leoa", "Sérvia", "Seicheles", "Somália", "Somalilândia", "Sri Lanka", "Sudão", "Sudão do Sul",
30
+ "Suécia", "Suíça", "Suriname", "Síria", "Tajiquistão", "Tailândia", "Taiwan", "Tanzânia",
31
+ "Timor-Leste", "Togo", "Tonga", "Transnístria", "Trinidad e Tobago", "Tunísia", "Turcomenistão",
32
+ "Turquia", "Tuvalu", "Ucrânia", "Uganda", "Uruguai", "Uzbequistão", "Vanuatu", "Vaticano (Santa Sé)",
33
+ "Venezuela", "Vietnã", "Zâmbia", "Zimbábue",
34
+ )
35
+
36
+
37
+ def country_index(value: object) -> int:
38
+ """Return the selector index while keeping legacy/empty values usable."""
39
+ value = str(value or "").strip()
40
+ return COUNTRY_OPTIONS.index(value) if value in COUNTRY_OPTIONS else 0
41
+
42
+
43
+ __all__ = ["COUNTRY_OPTIONS", "country_index"]
@@ -18,6 +18,16 @@ class IntegrationResult:
18
18
  data: dict[str, Any]
19
19
 
20
20
 
21
+ def normalize_instagram_bio(value: Any) -> str:
22
+ """Keep the profile biography, never Instagram's metrics summary."""
23
+ bio = str(value or '').strip()
24
+ if not bio:
25
+ return ''
26
+ if re.search(r'\b(?:followers|following|seguidores|seguindo|posts|publicações)\b', bio, flags=re.IGNORECASE) and re.search(r'\d', bio):
27
+ return ''
28
+ return bio
29
+
30
+
21
31
  def normalize_instagram_reference(source: str) -> dict[str, str]:
22
32
  value = str(source or '').strip()
23
33
  if not value:
@@ -107,14 +117,16 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
107
117
 
108
118
 
109
119
  def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
110
- followers = _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count')
111
- following = _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count')
120
+ followers = _direct_profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
121
+ following = _direct_profile_metric(user, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
122
+ followers = followers if followers is not None else _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
123
+ following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
112
124
  media = ((user.get('edge_owner_to_timeline_media') or {}).get('count') if isinstance(user.get('edge_owner_to_timeline_media'), dict) else None)
113
125
  return {
114
126
  'id': f"instagram_{reference['username']}",
115
127
  **reference,
116
128
  'name': str(user.get('full_name') or user.get('username') or reference['username']).strip(),
117
- 'bio': str(user.get('biography') or '').strip(),
129
+ 'bio': normalize_instagram_bio(user.get('biography')),
118
130
  'avatar_url': str(user.get('profile_pic_url_hd') or user.get('profile_pic_url') or '').strip(),
119
131
  'subscriber_count': followers,
120
132
  'following_count': following,
@@ -126,6 +138,21 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
126
138
  }
127
139
 
128
140
 
141
+ def _direct_profile_metric(user: Mapping[str, Any], *keys: str) -> int | None:
142
+ """Read common profile counters before falling back to recursive JSON parsing."""
143
+ for key in keys:
144
+ value = user.get(key)
145
+ if isinstance(value, Mapping):
146
+ value = value.get('count') or value.get('value')
147
+ if value in (None, ''):
148
+ continue
149
+ try:
150
+ return int(value)
151
+ except (TypeError, ValueError):
152
+ continue
153
+ return None
154
+
155
+
129
156
  def fetch_public_instagram_profile(source: str) -> IntegrationResult:
130
157
  try:
131
158
  reference = normalize_instagram_reference(source)
@@ -137,6 +164,9 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
137
164
  return IntegrationResult(False, f'Não foi possível consultar o perfil público do Instagram: {exc}', reference)
138
165
  if response.status_code >= 400:
139
166
  return IntegrationResult(False, f'O Instagram devolveu HTTP {response.status_code}. Confirme o @handle ou use o cadastro manual.', reference | {'status_code': response.status_code})
167
+ api_user = _fetch_web_profile_user(reference['username'])
168
+ if api_user:
169
+ return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', _profile_data_from_api(api_user, reference))
140
170
  title = _meta(response.text, 'og:title') or reference['username']
141
171
  description = _meta(response.text, 'og:description')
142
172
  avatar_url = _meta(response.text, 'og:image')
@@ -154,11 +184,7 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
154
184
  followers = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:followers|seguidores)',)) or _structured_metric(response.text, 'edge_followed_by', 'followers', 'follower_count') or _structured_metric_from_json(response.text, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
155
185
  following = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:following|seguindo)',)) or _structured_metric(response.text, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount') or _structured_metric_from_json(response.text, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
156
186
  posts = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:posts|publicações|publications)',)) or _structured_metric(response.text, 'edge_owner_to_timeline_media', 'posts', 'post_count')
157
- if followers is None or following is None or not avatar_url:
158
- api_user = _fetch_web_profile_user(reference['username'])
159
- if api_user:
160
- return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', _profile_data_from_api(api_user, reference))
161
- 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()}
187
+ data = {'id': f"instagram_{reference['username']}", **reference, 'name': title.split('(')[0].strip() or reference['username'], 'bio': normalize_instagram_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()}
162
188
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
163
189
 
164
190
 
@@ -257,4 +283,4 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
257
283
  return IntegrationResult(bool(posts), 'Posts públicos encontrados.' if posts else 'Não foi possível encontrar posts públicos nesta página do Instagram.', reference | {'posts': posts[:max(1, int(limit))]})
258
284
 
259
285
 
260
- __all__ = ['IntegrationResult', 'fetch_public_instagram_posts', 'fetch_public_instagram_profile', 'normalize_instagram_reference']
286
+ __all__ = ['IntegrationResult', 'fetch_public_instagram_posts', 'fetch_public_instagram_profile', 'normalize_instagram_bio', 'normalize_instagram_reference']
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.21",
3
+ "version": "0.6.23",
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",