@danhachuel/thunderbolt 0.6.20 → 0.6.22

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.
@@ -12,7 +12,7 @@ from hermes_ui.domain import create_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
14
  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
15
+ from integrations.instagram_public import fetch_public_instagram_posts, fetch_public_instagram_profile, normalize_instagram_bio
16
16
  from integrations.meta_social import test_facebook_pages_api_card, test_instagram_api_card
17
17
 
18
18
 
@@ -298,7 +298,7 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
298
298
  with header_cols[1]:
299
299
  st.write(f"**{_clean(profile.get('name')) or 'Sem nome'}**")
300
300
  st.caption(f"{_clean(profile.get('handle')) or _clean(profile.get('url')) or 'sem handler'}")
301
- bio = _clean(profile.get("bio"))
301
+ bio = normalize_instagram_bio(profile.get("bio"))
302
302
  if bio:
303
303
  st.caption(f"Bio: {bio}")
304
304
  st.caption(f"{_clean(profile.get('country')) or 'País não definido'} · {_clean(profile.get('language')) or 'Idioma não definido'}")
@@ -5,7 +5,7 @@ import json
5
5
  from dataclasses import dataclass
6
6
  from datetime import datetime, timezone
7
7
  from html import unescape
8
- from typing import Any
8
+ from typing import Any, Mapping
9
9
  from urllib.parse import urlparse
10
10
 
11
11
  import requests
@@ -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:
@@ -85,6 +95,47 @@ def _structured_metric_from_json(document: str, *keys: str) -> int | None:
85
95
  return None
86
96
 
87
97
 
98
+ 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
117
+
118
+
119
+ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
120
+ followers = _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count')
121
+ following = _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count')
122
+ media = ((user.get('edge_owner_to_timeline_media') or {}).get('count') if isinstance(user.get('edge_owner_to_timeline_media'), dict) else None)
123
+ return {
124
+ 'id': f"instagram_{reference['username']}",
125
+ **reference,
126
+ 'name': str(user.get('full_name') or user.get('username') or reference['username']).strip(),
127
+ 'bio': normalize_instagram_bio(user.get('biography')),
128
+ 'avatar_url': str(user.get('profile_pic_url_hd') or user.get('profile_pic_url') or '').strip(),
129
+ 'subscriber_count': followers,
130
+ 'following_count': following,
131
+ 'post_count': media,
132
+ 'public_lookup': True,
133
+ 'metrics_source': 'instagram_web_profile_info',
134
+ 'last_public_lookup_at': datetime.now(timezone.utc).isoformat(),
135
+ '_api_posts': (((user.get('edge_owner_to_timeline_media') or {}).get('edges') or []) if isinstance(user.get('edge_owner_to_timeline_media'), dict) else []),
136
+ }
137
+
138
+
88
139
  def fetch_public_instagram_profile(source: str) -> IntegrationResult:
89
140
  try:
90
141
  reference = normalize_instagram_reference(source)
@@ -96,6 +147,9 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
96
147
  return IntegrationResult(False, f'Não foi possível consultar o perfil público do Instagram: {exc}', reference)
97
148
  if response.status_code >= 400:
98
149
  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})
150
+ api_user = _fetch_web_profile_user(reference['username'])
151
+ if api_user:
152
+ return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', _profile_data_from_api(api_user, reference))
99
153
  title = _meta(response.text, 'og:title') or reference['username']
100
154
  description = _meta(response.text, 'og:description')
101
155
  avatar_url = _meta(response.text, 'og:image')
@@ -113,7 +167,7 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
113
167
  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')
114
168
  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')
115
169
  posts = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:posts|publicações|publications)',)) or _structured_metric(response.text, 'edge_owner_to_timeline_media', 'posts', 'post_count')
116
- 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()}
170
+ 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()}
117
171
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
118
172
 
119
173
 
@@ -179,6 +233,17 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
179
233
  reference = normalize_instagram_reference(source)
180
234
  except ValueError as exc:
181
235
  return IntegrationResult(False, str(exc), {})
236
+ api_user = _fetch_web_profile_user(reference['username'])
237
+ if api_user:
238
+ api_posts: list[dict[str, Any]] = []
239
+ for edge in ((api_user.get('edge_owner_to_timeline_media') or {}).get('edges') or []):
240
+ node = edge.get('node') if isinstance(edge, dict) else None
241
+ if isinstance(node, dict):
242
+ post = _post_from_node(node)
243
+ if post:
244
+ api_posts.append(post)
245
+ if api_posts:
246
+ return IntegrationResult(True, 'Posts públicos encontrados.', reference | {'posts': api_posts[:max(1, int(limit))]})
182
247
  try:
183
248
  response = requests.get(reference['url'], headers={'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8'}, timeout=15)
184
249
  except requests.RequestException as exc:
@@ -201,4 +266,4 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
201
266
  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))]})
202
267
 
203
268
 
204
- __all__ = ['IntegrationResult', 'fetch_public_instagram_posts', 'fetch_public_instagram_profile', 'normalize_instagram_reference']
269
+ __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.20",
3
+ "version": "0.6.22",
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",