@danhachuel/thunderbolt 0.6.34 → 0.6.36

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.
@@ -591,6 +591,7 @@ def render_social_networks(settings: dict[str, Any]) -> None:
591
591
  data = st.session_state.get("social_instagram_result", {}) if st.session_state.get("social_instagram_ok") else {}
592
592
  if data:
593
593
  characters, _ = _characters(settings)
594
+ result_widget_id = _instagram_profile_storage_id(data)
594
595
  with st.container(border=True):
595
596
  st.subheader("Conta Instagram encontrada")
596
597
  preview_cols = st.columns([0.8, 2.2, 1.2, 1.2, 1.2])
@@ -607,20 +608,20 @@ def render_social_networks(settings: dict[str, Any]) -> None:
607
608
  st.metric("Seguidores", _metric(_profile_metric(data, "subscriber_count", "followers_count", "follower_count", "followers", "edge_followed_by")))
608
609
  with preview_cols[4]:
609
610
  st.metric("seguindo", _metric(_profile_metric(data, "following_count", "following", "follows", "followingCount", "edge_follow")))
610
- with st.form("social_instagram_save_public_profile"):
611
+ with st.form(f"social_instagram_save_public_profile_{result_widget_id}"):
611
612
  form_cols = st.columns(2)
612
613
  with form_cols[0]:
613
- name = st.text_input("Nome", value=_clean(data.get("name")), key="social_instagram_result_name")
614
- bio = st.text_area("Bio", value=_profile_bio(data), key="social_instagram_result_bio")
615
- country = st.selectbox("País", _country_options(), index=_country_index(_profile_country(data)), format_func=_country_label, key="social_instagram_result_country")
616
- language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=_language_index(data.get("language")), format_func=language_label, key="social_instagram_result_language")
614
+ name = st.text_input("Nome", value=_clean(data.get("name")), key=f"social_instagram_result_name_{result_widget_id}")
615
+ bio = st.text_area("Bio", value=_profile_bio(data), key=f"social_instagram_result_bio_{result_widget_id}")
616
+ country = st.selectbox("País", _country_options(), index=_country_index(_profile_country(data)), format_func=_country_label, key=f"social_instagram_result_country_{result_widget_id}")
617
+ language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=_language_index(data.get("language")), format_func=language_label, key=f"social_instagram_result_language_{result_widget_id}")
617
618
  with form_cols[1]:
618
- posts = st.number_input("posts", min_value=0, value=_metric_input(_profile_metric(data, "post_count", "posts", "media_count")), placeholder="Não encontrado", key="social_instagram_result_posts")
619
- followers = st.number_input("Seguidores", min_value=0, value=_metric_input(_profile_metric(data, "subscriber_count", "followers_count", "follower_count", "followers", "edge_followed_by")), placeholder="Não encontrado", key="social_instagram_result_followers")
620
- following = st.number_input("seguindo", min_value=0, value=_metric_input(_profile_metric(data, "following_count", "following", "follows", "followingCount", "edge_follow")), placeholder="Não encontrado", key="social_instagram_result_following")
619
+ posts = st.number_input("posts", min_value=0, value=_metric_input(_profile_metric(data, "post_count", "posts", "media_count")), placeholder="Não encontrado", key=f"social_instagram_result_posts_{result_widget_id}")
620
+ followers = st.number_input("Seguidores", min_value=0, value=_metric_input(_profile_metric(data, "subscriber_count", "followers_count", "follower_count", "followers", "edge_followed_by")), placeholder="Não encontrado", key=f"social_instagram_result_followers_{result_widget_id}")
621
+ following = st.number_input("seguindo", min_value=0, value=_metric_input(_profile_metric(data, "following_count", "following", "follows", "followingCount", "edge_follow")), placeholder="Não encontrado", key=f"social_instagram_result_following_{result_widget_id}")
621
622
  character_options = [""] + [_clean(item.get("id")) for item in characters if _clean(item.get("id"))]
622
623
  character_labels = {"": "Sem personagem associado"} | {_clean(item.get("id")): _clean(item.get("name")) or _clean(item.get("id")) for item in characters}
623
- selected_character = st.selectbox("Personagem", character_options, format_func=lambda value: character_labels.get(value, value), key="social_instagram_result_character")
624
+ selected_character = st.selectbox("Personagem", character_options, format_func=lambda value: character_labels.get(value, value), key=f"social_instagram_result_character_{result_widget_id}")
624
625
  save_profile = st.form_submit_button("Cadastrar conta Instagram", type="primary", use_container_width=True)
625
626
  if save_profile:
626
627
  profile_data = dict(data)
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
  import json
5
+ import os
5
6
  import shutil
6
7
  import subprocess
7
8
  from dataclasses import dataclass
@@ -13,6 +14,31 @@ from urllib.parse import urlparse
13
14
  import requests
14
15
 
15
16
 
17
+ ABOUT_ACCOUNT_URL = 'https://i.instagram.com/api/v1/bloks/apps/com.instagram.interactions.about_this_account/'
18
+ ABOUT_ACCOUNT_BLOKS_VERSION = '8ca96ca267e30c02cf90888d91eeff09627f0e3fd2bd9df472278c9a6c022cbb'
19
+
20
+
21
+ def _instagram_cookies() -> dict[str, str]:
22
+ cookies = {}
23
+ sessionid = os.getenv('INSTAGRAM_SESSIONID') or os.getenv('IG_SESSIONID')
24
+ if sessionid:
25
+ cookies['sessionid'] = sessionid.strip()
26
+ for name in ('csrftoken', 'ds_user_id', 'mid', 'ig_did'):
27
+ value = os.getenv(f'INSTAGRAM_{name.upper()}') or os.getenv(f'IG_{name.upper()}')
28
+ if value:
29
+ cookies[name] = value.strip()
30
+ return cookies
31
+
32
+
33
+ def _instagram_headers() -> dict[str, str]:
34
+ return {
35
+ 'x-ig-app-id': '936619743392459',
36
+ 'User-Agent': 'Instagram 390.0.0.0.50 Android',
37
+ 'Accept': 'application/json, text/plain, */*',
38
+ 'Accept-Language': 'en-US,en;q=0.9',
39
+ }
40
+
41
+
16
42
  @dataclass
17
43
  class IntegrationResult:
18
44
  ok: bool
@@ -104,12 +130,8 @@ def _structured_metric_from_json(document: str, *keys: str) -> int | None:
104
130
 
105
131
 
106
132
  def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
107
- headers = {
108
- 'x-ig-app-id': '936619743392459',
109
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
110
- 'Accept': 'application/json, text/plain, */*',
111
- 'Accept-Language': 'en-US,en;q=0.9',
112
- }
133
+ headers = _instagram_headers()
134
+ headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36'
113
135
  # The www host is the current public web endpoint. The legacy i host is
114
136
  # retained as a fallback because Instagram rate-limits the two hosts
115
137
  # independently and their availability can vary by region.
@@ -117,12 +139,40 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
117
139
  f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
118
140
  f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
119
141
  )
142
+ def profile_quality(user: Mapping[str, Any]) -> int:
143
+ return sum(bool(user.get(key)) for key in ('biography', 'edge_follow', 'edge_followed_by', 'edge_owner_to_timeline_media', 'username'))
144
+
145
+ def merge_profile(base: dict[str, Any] | None, candidate: Mapping[str, Any]) -> dict[str, Any]:
146
+ merged = dict(base or {})
147
+ for key, value in candidate.items():
148
+ if value not in (None, '', [], {}):
149
+ if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
150
+ merged[key] = {**merged[key], **value}
151
+ else:
152
+ merged[key] = value
153
+ return merged
154
+
155
+ best_user: dict[str, Any] | None = None
156
+ for endpoint in endpoints:
157
+ try:
158
+ response = requests.get(endpoint, headers=headers, timeout=15)
159
+ if response.status_code >= 400:
160
+ continue
161
+ payload = response.json()
162
+ except (requests.RequestException, ValueError, AttributeError):
163
+ continue
164
+ user = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
165
+ if isinstance(user, dict):
166
+ best_user = merge_profile(best_user, user)
167
+ if profile_quality(best_user) >= 4:
168
+ return best_user
169
+
120
170
  curl = shutil.which('curl')
121
171
  if curl:
122
172
  for endpoint in endpoints:
123
173
  try:
124
174
  completed = subprocess.run(
125
- [curl, '-L', '--max-time', '20', '-sS', '-A', headers['User-Agent'], '-H', f"x-ig-app-id: {headers['x-ig-app-id']}", '-H', 'Accept: application/json, text/plain, */*', endpoint],
175
+ [curl, '-L', '--max-time', '20', '-sS', '-A', headers['User-Agent'], '-H', f"x-ig-app-id: {headers['x-ig-app-id']}", '-H', 'Accept', 'application/json, text/plain, */*', endpoint],
126
176
  capture_output=True,
127
177
  text=True,
128
178
  encoding='utf-8',
@@ -133,21 +183,66 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
133
183
  payload = json.loads(completed.stdout) if completed.returncode == 0 and completed.stdout else None
134
184
  user = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
135
185
  if isinstance(user, dict):
136
- return user
186
+ best_user = merge_profile(best_user, user)
187
+ if profile_quality(best_user) >= 4:
188
+ return best_user
137
189
  except (OSError, subprocess.SubprocessError, ValueError):
138
190
  continue
139
- for endpoint in endpoints:
140
- try:
141
- response = requests.get(endpoint, headers=headers, timeout=15)
142
- if response.status_code >= 400:
143
- continue
144
- payload = response.json()
145
- except (requests.RequestException, ValueError, AttributeError):
146
- continue
147
- user = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
148
- if isinstance(user, dict):
149
- return user
150
- return None
191
+ return best_user
192
+
193
+
194
+ def _country_from_bloks(value: Any) -> str:
195
+ if isinstance(value, Mapping):
196
+ data_key = str(value.get('key') or '').casefold()
197
+ if data_key in PUBLIC_COUNTRY_KEYS:
198
+ serialized = value.get('initial_lispy') or value.get('value')
199
+ if isinstance(serialized, str) and 'bk.action.array.Make' in serialized:
200
+ country = _country_value(serialized.rsplit('bk.action.array.Make,', 1)[-1].split(')', 1)[0].replace('\\"', '"').replace('"', '').strip())
201
+ else:
202
+ country = _country_value(serialized)
203
+ if country:
204
+ return country
205
+ for key, candidate in value.items():
206
+ if str(key).casefold() in PUBLIC_COUNTRY_KEYS:
207
+ if isinstance(candidate, str) and 'bk.action.array.Make' in candidate:
208
+ country = _country_value(candidate.rsplit('bk.action.array.Make,', 1)[-1].split(')', 1)[0].replace('\\"', '"').replace('"', '').strip())
209
+ else:
210
+ country = _country_value(candidate)
211
+ if country:
212
+ return country
213
+ country = _country_from_bloks(candidate)
214
+ if country:
215
+ return country
216
+ elif isinstance(value, list):
217
+ for candidate in value:
218
+ country = _country_from_bloks(candidate)
219
+ if country:
220
+ return country
221
+ elif isinstance(value, str) and 'about_this_account_country' in value:
222
+ marker = 'bk.action.array.Make,'
223
+ if marker in value:
224
+ country = value.rsplit(marker, 1)[-1].split(')', 1)[0].replace('\\"', '"').replace('"', '').strip()
225
+ return _country_value(country)
226
+ return ''
227
+
228
+
229
+ def _fetch_instagram_about_country(user_id: Any) -> str:
230
+ cookies = _instagram_cookies()
231
+ if not cookies or not str(user_id or '').strip():
232
+ return ''
233
+ payload = {
234
+ 'referer_type': 'ProfileUsername',
235
+ 'target_user_id': str(user_id),
236
+ 'bk_client_context': json.dumps({'bloks_version': ABOUT_ACCOUNT_BLOKS_VERSION, 'style_id': 'instagram'}, separators=(',', ':')),
237
+ 'bloks_versioning_id': ABOUT_ACCOUNT_BLOKS_VERSION,
238
+ }
239
+ try:
240
+ response = requests.post(ABOUT_ACCOUNT_URL, data=payload, headers=_instagram_headers(), cookies=cookies, timeout=20)
241
+ if response.status_code >= 400:
242
+ return ''
243
+ return _country_from_bloks(response.json())
244
+ except (requests.RequestException, ValueError, TypeError):
245
+ return ''
151
246
 
152
247
 
153
248
  def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
@@ -157,7 +252,7 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
157
252
  following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
158
253
  media = ((user.get('edge_owner_to_timeline_media') or {}).get('count') if isinstance(user.get('edge_owner_to_timeline_media'), dict) else None)
159
254
  biography = str(user.get('biography') or '').strip()
160
- country = extract_public_instagram_country(user)
255
+ country = extract_public_instagram_country(user) or _fetch_instagram_about_country(user.get('id') or user.get('pk'))
161
256
  return {
162
257
  'id': f"instagram_{reference['username']}",
163
258
  **reference,
@@ -188,7 +283,7 @@ def normalize_instagram_metric(value: Any) -> int | None:
188
283
 
189
284
 
190
285
  PUBLIC_COUNTRY_KEYS = {
191
- 'account_based_in', 'account_country', 'country_name',
286
+ 'about_this_account_country', 'account_based_in', 'account_country', 'country_name',
192
287
  'country_of_origin', 'country_of_registration', 'location_country', 'transparency_country',
193
288
  }
194
289
  PUBLIC_TRANSPARENCY_KEYS = {'about', 'about_account', 'account_transparency', 'transparency', 'account_info'}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.34",
3
+ "version": "0.6.36",
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",