@danhachuel/thunderbolt 0.6.24 → 0.6.26

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.
@@ -42,6 +42,12 @@ def _metric_input(value: Any) -> int | None:
42
42
  return normalize_instagram_metric(value)
43
43
 
44
44
 
45
+ def _render_instagram_bio(value: Any) -> None:
46
+ bio = normalize_instagram_bio(value)
47
+ if bio:
48
+ st.markdown(bio.replace("\n", " \n"))
49
+
50
+
45
51
  def _language_index(value: Any) -> int:
46
52
  code = language_code(value)
47
53
  return list(LANGUAGE_CODES).index(code) if code in LANGUAGE_CODES else list(LANGUAGE_CODES).index("pt")
@@ -326,9 +332,7 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
326
332
  with header_cols[1]:
327
333
  st.write(f"**{_clean(profile.get('name')) or 'Sem nome'}**")
328
334
  st.caption(f"{_clean(profile.get('handle')) or _clean(profile.get('url')) or 'sem handler'}")
329
- bio = normalize_instagram_bio(profile.get("bio"))
330
- if bio:
331
- st.caption(f"Bio: {bio}")
335
+ _render_instagram_bio(profile.get("bio"))
332
336
  st.caption(f"{_clean(profile.get('country')) or 'País não definido'} · {_clean(profile.get('language')) or 'Idioma não definido'}")
333
337
  with header_cols[2]:
334
338
  st.metric("posts", _metric(profile.get("post_count")))
@@ -447,9 +451,7 @@ def render_social_networks(settings: dict[str, Any]) -> None:
447
451
  with preview_cols[1]:
448
452
  st.write(f"**{_clean(data.get('name')) or _clean(data.get('username'))}**")
449
453
  st.caption(_clean(data.get("handle")))
450
- bio = normalize_instagram_bio(data.get("bio"))
451
- if bio:
452
- st.caption(f"Bio: {bio}")
454
+ _render_instagram_bio(data.get("bio"))
453
455
  with preview_cols[2]:
454
456
  st.metric("posts", _metric(data.get("post_count")))
455
457
  with preview_cols[3]:
@@ -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,24 +98,47 @@ 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]:
@@ -131,6 +156,7 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
131
156
  'subscriber_count': followers,
132
157
  'following_count': following,
133
158
  'post_count': media,
159
+ 'is_private': bool(user.get('is_private')),
134
160
  'public_lookup': True,
135
161
  'metrics_source': 'instagram_web_profile_info',
136
162
  'last_public_lookup_at': datetime.now(timezone.utc).isoformat(),
@@ -161,15 +187,15 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
161
187
  reference = normalize_instagram_reference(source)
162
188
  except ValueError as exc:
163
189
  return IntegrationResult(False, str(exc), {})
190
+ api_user = _fetch_web_profile_user(reference['username'])
191
+ if api_user:
192
+ return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', _profile_data_from_api(api_user, reference))
164
193
  try:
165
194
  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)
166
195
  except requests.RequestException as exc:
167
196
  return IntegrationResult(False, f'Não foi possível consultar o perfil público do Instagram: {exc}', reference)
168
197
  if response.status_code >= 400:
169
198
  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})
170
- api_user = _fetch_web_profile_user(reference['username'])
171
- if api_user:
172
- return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', _profile_data_from_api(api_user, reference))
173
199
  title = _meta(response.text, 'og:title') or reference['username']
174
200
  description = _meta(response.text, 'og:description')
175
201
  avatar_url = _meta(response.text, 'og:image')
@@ -264,6 +290,8 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
264
290
  api_posts.append(post)
265
291
  if api_posts:
266
292
  return IntegrationResult(True, 'Posts públicos encontrados.', reference | {'posts': api_posts[:max(1, int(limit))]})
293
+ if api_user.get('is_private'):
294
+ return IntegrationResult(False, 'Esta conta é privada. O Instagram não disponibiliza posts sem uma sessão autenticada.', reference | {'posts': [], 'is_private': True})
267
295
  try:
268
296
  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)
269
297
  except requests.RequestException as exc:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.24",
3
+ "version": "0.6.26",
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",