@danhachuel/thunderbolt 0.6.23 → 0.6.25
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/social_networks_ui.py +25 -13
- package/hermes_ui/domain.py +1 -0
- package/integrations/instagram_public.py +63 -35
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@ from hermes_ui.influencers import InfluencerBackendError, STANDALONE_CONTENT_INF
|
|
|
13
13
|
from hermes_ui.storage import read_json, write_json
|
|
14
14
|
from hermes_ui.countries import COUNTRY_OPTIONS
|
|
15
15
|
from hermes_ui.languages import LANGUAGE_CODES, language_code, language_label
|
|
16
|
-
from integrations.instagram_public import fetch_public_instagram_posts, fetch_public_instagram_profile, normalize_instagram_bio
|
|
16
|
+
from integrations.instagram_public import fetch_public_instagram_posts, fetch_public_instagram_profile, normalize_instagram_bio, normalize_instagram_metric
|
|
17
17
|
from integrations.meta_social import test_facebook_pages_api_card, test_instagram_api_card
|
|
18
18
|
|
|
19
19
|
|
|
@@ -38,6 +38,10 @@ def _profile_metric(profile: Mapping[str, Any], *keys: str) -> Any:
|
|
|
38
38
|
return None
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def _metric_input(value: Any) -> int | None:
|
|
42
|
+
return normalize_instagram_metric(value)
|
|
43
|
+
|
|
44
|
+
|
|
41
45
|
def _language_index(value: Any) -> int:
|
|
42
46
|
code = language_code(value)
|
|
43
47
|
return list(LANGUAGE_CODES).index(code) if code in LANGUAGE_CODES else list(LANGUAGE_CODES).index("pt")
|
|
@@ -288,6 +292,7 @@ def _save_public_profile(data: Mapping[str, Any], *, country: str, language: str
|
|
|
288
292
|
"name": name,
|
|
289
293
|
"url": url,
|
|
290
294
|
"handle": _clean(data.get("handle")),
|
|
295
|
+
"bio": normalize_instagram_bio(data.get("bio")),
|
|
291
296
|
"country": country.strip(),
|
|
292
297
|
"language": language.strip(),
|
|
293
298
|
"post_count": data.get("post_count"),
|
|
@@ -300,6 +305,14 @@ def _save_public_profile(data: Mapping[str, Any], *, country: str, language: str
|
|
|
300
305
|
return create_channel(name, url, metadata)
|
|
301
306
|
|
|
302
307
|
|
|
308
|
+
def _merge_instagram_refresh(existing: Mapping[str, Any], refreshed: Mapping[str, Any]) -> dict[str, Any]:
|
|
309
|
+
merged = dict(refreshed)
|
|
310
|
+
for field in ("subscriber_count", "following_count", "post_count", "bio", "avatar_url"):
|
|
311
|
+
if merged.get(field) in (None, "") and existing.get(field) not in (None, ""):
|
|
312
|
+
merged[field] = existing[field]
|
|
313
|
+
return merged
|
|
314
|
+
|
|
315
|
+
|
|
303
316
|
def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, Any]], settings: Mapping[str, Any]) -> None:
|
|
304
317
|
profile_id = _clean(profile.get("id"))
|
|
305
318
|
edit_key = f"edit_instagram_profile_{profile_id}"
|
|
@@ -329,10 +342,7 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
|
|
|
329
342
|
if st.button("↻", help="Actualizar posts, seguidores e seguindo", key=f"refresh_instagram_{profile_id}"):
|
|
330
343
|
result = fetch_public_instagram_profile(_clean(profile.get("url")) or _clean(profile.get("handle")))
|
|
331
344
|
if result.ok:
|
|
332
|
-
refreshed =
|
|
333
|
-
for field in ("subscriber_count", "following_count", "post_count", "bio", "avatar_url"):
|
|
334
|
-
if refreshed.get(field) in (None, "") and profile.get(field) not in (None, ""):
|
|
335
|
-
refreshed[field] = profile[field]
|
|
345
|
+
refreshed = _merge_instagram_refresh(profile, result.data)
|
|
336
346
|
update_channel(profile_id, {**refreshed, "platform": "instagram", "country": profile.get("country", ""), "language": profile.get("language", ""), "character_id": profile.get("character_id", "")})
|
|
337
347
|
st.success("Métricas Instagram actualizadas.")
|
|
338
348
|
st.rerun()
|
|
@@ -368,15 +378,16 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
|
|
|
368
378
|
with edit_cols[0]:
|
|
369
379
|
name = st.text_input("Nome", value=_clean(profile.get("name")), key=f"instagram_edit_name_{profile_id}")
|
|
370
380
|
handle = st.text_input("handler", value=_clean(profile.get("handle")), key=f"instagram_edit_handle_{profile_id}")
|
|
381
|
+
bio = st.text_area("Bio", value=normalize_instagram_bio(profile.get("bio")), key=f"instagram_edit_bio_{profile_id}")
|
|
371
382
|
country = st.selectbox("País", _country_options(), index=_country_index(profile.get("country")), format_func=_country_label, key=f"instagram_edit_country_{profile_id}")
|
|
372
383
|
language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=_language_index(profile.get("language")), format_func=language_label, key=f"instagram_edit_language_{profile_id}")
|
|
373
384
|
with edit_cols[1]:
|
|
374
|
-
posts = st.number_input("posts", min_value=0, value=
|
|
375
|
-
followers = st.number_input("Seguidores", min_value=0, value=
|
|
376
|
-
following = st.number_input("seguindo", min_value=0, value=
|
|
385
|
+
posts = st.number_input("posts", min_value=0, value=_metric_input(profile.get("post_count")), placeholder="Não encontrado", key=f"instagram_edit_posts_{profile_id}")
|
|
386
|
+
followers = st.number_input("Seguidores", min_value=0, value=_metric_input(profile.get("subscriber_count")), placeholder="Não encontrado", key=f"instagram_edit_followers_{profile_id}")
|
|
387
|
+
following = st.number_input("seguindo", min_value=0, value=_metric_input(profile.get("following_count")), placeholder="Não encontrado", key=f"instagram_edit_following_{profile_id}")
|
|
377
388
|
save_edit = st.form_submit_button("Guardar alterações", type="primary", use_container_width=True)
|
|
378
389
|
if save_edit:
|
|
379
|
-
update_channel(profile_id, {"name": name.strip(), "handle": handle.strip(), "country": country.strip(), "language": language, "post_count":
|
|
390
|
+
update_channel(profile_id, {"name": name.strip(), "handle": handle.strip(), "bio": normalize_instagram_bio(bio), "country": country.strip(), "language": language, "post_count": _metric_input(posts), "subscriber_count": _metric_input(followers), "following_count": _metric_input(following)})
|
|
380
391
|
st.session_state.pop(edit_key, None)
|
|
381
392
|
st.success("Conta Instagram actualizada.")
|
|
382
393
|
st.rerun()
|
|
@@ -449,19 +460,20 @@ def render_social_networks(settings: dict[str, Any]) -> None:
|
|
|
449
460
|
form_cols = st.columns(2)
|
|
450
461
|
with form_cols[0]:
|
|
451
462
|
name = st.text_input("Nome", value=_clean(data.get("name")), key="social_instagram_result_name")
|
|
463
|
+
bio = st.text_area("Bio", value=normalize_instagram_bio(data.get("bio")), key="social_instagram_result_bio")
|
|
452
464
|
country = st.selectbox("País", _country_options(), index=_country_index(data.get("country")), format_func=_country_label, key="social_instagram_result_country")
|
|
453
465
|
language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=_language_index(data.get("language")), format_func=language_label, key="social_instagram_result_language")
|
|
454
466
|
with form_cols[1]:
|
|
455
|
-
posts = st.number_input("posts", min_value=0, value=
|
|
456
|
-
followers = st.number_input("Seguidores", min_value=0, value=
|
|
457
|
-
following = st.number_input("seguindo", min_value=0, value=
|
|
467
|
+
posts = st.number_input("posts", min_value=0, value=_metric_input(data.get("post_count")), placeholder="Não encontrado", key="social_instagram_result_posts")
|
|
468
|
+
followers = st.number_input("Seguidores", min_value=0, value=_metric_input(data.get("subscriber_count")), placeholder="Não encontrado", key="social_instagram_result_followers")
|
|
469
|
+
following = st.number_input("seguindo", min_value=0, value=_metric_input(data.get("following_count")), placeholder="Não encontrado", key="social_instagram_result_following")
|
|
458
470
|
character_options = [""] + [_clean(item.get("id")) for item in characters if _clean(item.get("id"))]
|
|
459
471
|
character_labels = {"": "Sem personagem associado"} | {_clean(item.get("id")): _clean(item.get("name")) or _clean(item.get("id")) for item in characters}
|
|
460
472
|
selected_character = st.selectbox("Personagem", character_options, format_func=lambda value: character_labels.get(value, value), key="social_instagram_result_character")
|
|
461
473
|
save_profile = st.form_submit_button("Cadastrar conta Instagram", type="primary", use_container_width=True)
|
|
462
474
|
if save_profile:
|
|
463
475
|
profile_data = dict(data)
|
|
464
|
-
profile_data.update({"name": name, "post_count":
|
|
476
|
+
profile_data.update({"name": name, "bio": normalize_instagram_bio(bio), "post_count": _metric_input(posts), "subscriber_count": _metric_input(followers), "following_count": _metric_input(following)})
|
|
465
477
|
_save_public_profile(profile_data, country=country, language=language, character_id=selected_character)
|
|
466
478
|
st.success("Conta Instagram cadastrada em Redes Sociais.")
|
|
467
479
|
for key in ("social_instagram_result", "social_instagram_ok", "social_instagram_message"):
|
package/hermes_ui/domain.py
CHANGED
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
5
7
|
from dataclasses import dataclass
|
|
6
8
|
from datetime import datetime, timezone
|
|
7
9
|
from html import unescape
|
|
@@ -96,29 +98,52 @@ def _structured_metric_from_json(document: str, *keys: str) -> int | None:
|
|
|
96
98
|
|
|
97
99
|
|
|
98
100
|
def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
101
|
+
headers = {
|
|
102
|
+
'x-ig-app-id': '936619743392459',
|
|
103
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
|
|
104
|
+
'Accept': 'application/json, text/plain, */*',
|
|
105
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
106
|
+
}
|
|
107
|
+
# The www host is the current public web endpoint. The legacy i host is
|
|
108
|
+
# retained as a fallback because Instagram rate-limits the two hosts
|
|
109
|
+
# independently and their availability can vary by region.
|
|
110
|
+
endpoints = (
|
|
111
|
+
f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
|
|
112
|
+
f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
|
|
113
|
+
)
|
|
114
|
+
for endpoint in endpoints:
|
|
115
|
+
try:
|
|
116
|
+
response = requests.get(endpoint, headers=headers, timeout=15)
|
|
117
|
+
if response.status_code >= 400:
|
|
118
|
+
continue
|
|
119
|
+
payload = response.json()
|
|
120
|
+
except (requests.RequestException, ValueError, AttributeError):
|
|
121
|
+
continue
|
|
122
|
+
user = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
|
|
123
|
+
if isinstance(user, dict):
|
|
124
|
+
return user
|
|
125
|
+
curl = shutil.which('curl')
|
|
126
|
+
if curl:
|
|
127
|
+
try:
|
|
128
|
+
completed = subprocess.run(
|
|
129
|
+
[curl, '-L', '--max-time', '20', '-sS', '-A', headers['User-Agent'], '-H', f"x-ig-app-id: {headers['x-ig-app-id']}", endpoint],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
timeout=25,
|
|
133
|
+
check=False,
|
|
134
|
+
)
|
|
135
|
+
payload = json.loads(completed.stdout) if completed.returncode == 0 and completed.stdout else None
|
|
136
|
+
user = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
|
|
137
|
+
if isinstance(user, dict):
|
|
138
|
+
return user
|
|
139
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
140
|
+
pass
|
|
141
|
+
return None
|
|
117
142
|
|
|
118
143
|
|
|
119
144
|
def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
|
|
120
|
-
followers =
|
|
121
|
-
following =
|
|
145
|
+
followers = _profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
|
|
146
|
+
following = _profile_metric(user, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
|
|
122
147
|
followers = followers if followers is not None else _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
|
|
123
148
|
following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
|
|
124
149
|
media = ((user.get('edge_owner_to_timeline_media') or {}).get('count') if isinstance(user.get('edge_owner_to_timeline_media'), dict) else None)
|
|
@@ -138,18 +163,21 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
|
|
|
138
163
|
}
|
|
139
164
|
|
|
140
165
|
|
|
141
|
-
def
|
|
142
|
-
"""
|
|
166
|
+
def normalize_instagram_metric(value: Any) -> int | None:
|
|
167
|
+
"""Normalize an Instagram counter without turning an unknown value into zero."""
|
|
168
|
+
if isinstance(value, Mapping):
|
|
169
|
+
value = value.get('count') or value.get('value')
|
|
170
|
+
if value in (None, ''):
|
|
171
|
+
return None
|
|
172
|
+
digits = re.sub(r'[^0-9]', '', str(value))
|
|
173
|
+
return int(digits) if digits else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _profile_metric(user: Mapping[str, Any], *keys: str) -> int | None:
|
|
143
177
|
for key in keys:
|
|
144
|
-
value = user.get(key)
|
|
145
|
-
if
|
|
146
|
-
|
|
147
|
-
if value in (None, ''):
|
|
148
|
-
continue
|
|
149
|
-
try:
|
|
150
|
-
return int(value)
|
|
151
|
-
except (TypeError, ValueError):
|
|
152
|
-
continue
|
|
178
|
+
value = normalize_instagram_metric(user.get(key))
|
|
179
|
+
if value is not None:
|
|
180
|
+
return value
|
|
153
181
|
return None
|
|
154
182
|
|
|
155
183
|
|
|
@@ -158,15 +186,15 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
|
|
|
158
186
|
reference = normalize_instagram_reference(source)
|
|
159
187
|
except ValueError as exc:
|
|
160
188
|
return IntegrationResult(False, str(exc), {})
|
|
189
|
+
api_user = _fetch_web_profile_user(reference['username'])
|
|
190
|
+
if api_user:
|
|
191
|
+
return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', _profile_data_from_api(api_user, reference))
|
|
161
192
|
try:
|
|
162
193
|
response = requests.get(reference['url'], headers={'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8'}, timeout=12)
|
|
163
194
|
except requests.RequestException as exc:
|
|
164
195
|
return IntegrationResult(False, f'Não foi possível consultar o perfil público do Instagram: {exc}', reference)
|
|
165
196
|
if response.status_code >= 400:
|
|
166
197
|
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))
|
|
170
198
|
title = _meta(response.text, 'og:title') or reference['username']
|
|
171
199
|
description = _meta(response.text, 'og:description')
|
|
172
200
|
avatar_url = _meta(response.text, 'og:image')
|
|
@@ -283,4 +311,4 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
|
|
|
283
311
|
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))]})
|
|
284
312
|
|
|
285
313
|
|
|
286
|
-
__all__ = ['IntegrationResult', 'fetch_public_instagram_posts', 'fetch_public_instagram_profile', 'normalize_instagram_bio', 'normalize_instagram_reference']
|
|
314
|
+
__all__ = ['IntegrationResult', 'fetch_public_instagram_posts', 'fetch_public_instagram_profile', 'normalize_instagram_bio', 'normalize_instagram_metric', 'normalize_instagram_reference']
|
package/package.json
CHANGED