@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.
@@ -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 = dict(result.data)
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=int(profile.get("post_count") or 0), key=f"instagram_edit_posts_{profile_id}")
375
- followers = st.number_input("Seguidores", min_value=0, value=int(profile.get("subscriber_count") or 0), key=f"instagram_edit_followers_{profile_id}")
376
- following = st.number_input("seguindo", min_value=0, value=int(profile.get("following_count") or 0), key=f"instagram_edit_following_{profile_id}")
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": int(posts), "subscriber_count": int(followers), "following_count": int(following)})
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=int(data.get("post_count") or 0), key="social_instagram_result_posts")
456
- followers = st.number_input("Seguidores", min_value=0, value=int(data.get("subscriber_count") or 0), key="social_instagram_result_followers")
457
- following = st.number_input("seguindo", min_value=0, value=int(data.get("following_count") or 0), key="social_instagram_result_following")
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": int(posts), "subscriber_count": int(followers), "following_count": int(following)})
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"):
@@ -36,6 +36,7 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
36
36
  "platform": "youtube",
37
37
  "social_network": "YouTube",
38
38
  "handle": "",
39
+ "bio": "",
39
40
  "description": "",
40
41
  "description_source": "",
41
42
  "niche": "",
@@ -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
- try:
100
- response = requests.get(
101
- f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
102
- headers={
103
- 'x-ig-app-id': '936619743392459',
104
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
105
- 'Accept': '*/*',
106
- 'Accept-Language': 'en-US,en;q=0.9',
107
- },
108
- timeout=15,
109
- )
110
- if response.status_code >= 400:
111
- return None
112
- payload = response.json()
113
- except (requests.RequestException, ValueError, AttributeError):
114
- return None
115
- user = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
116
- return user if isinstance(user, dict) else None
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 = _direct_profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
121
- following = _direct_profile_metric(user, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
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 _direct_profile_metric(user: Mapping[str, Any], *keys: str) -> int | None:
142
- """Read common profile counters before falling back to recursive JSON parsing."""
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 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
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.23",
3
+ "version": "0.6.25",
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",