@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 =
|
|
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.
|
|
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.
|
|
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 =
|
|
111
|
-
following =
|
|
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':
|
|
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
|
-
|
|
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