@danhachuel/thunderbolt 0.6.53 → 0.6.55

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.
@@ -17,7 +17,13 @@ from hermes_ui.influencers import InfluencerBackendError, STANDALONE_CONTENT_INF
17
17
  from hermes_ui.storage import read_json, write_json
18
18
  from hermes_ui.countries import COUNTRY_OPTIONS
19
19
  from hermes_ui.languages import LANGUAGE_CODES, language_code, language_label
20
- from integrations.instagram_public import fetch_public_instagram_posts, fetch_public_instagram_profile, normalize_instagram_bio, normalize_instagram_metric
20
+ from integrations.instagram_public import (
21
+ extract_public_instagram_country,
22
+ fetch_public_instagram_posts,
23
+ fetch_public_instagram_profile,
24
+ normalize_instagram_bio,
25
+ normalize_instagram_metric,
26
+ )
21
27
  from integrations.meta_social import test_facebook_pages_api_card, test_instagram_api_card
22
28
 
23
29
 
@@ -72,11 +78,35 @@ def _country_key(value: Any) -> str:
72
78
  COUNTRY_ALIASES = {
73
79
  "brazil": "Brasil",
74
80
  "brasil": "Brasil",
81
+ "br": "Brasil",
75
82
  "usa": "Estados Unidos",
76
83
  "us": "Estados Unidos",
77
84
  "united states": "Estados Unidos",
78
85
  "united states of america": "Estados Unidos",
86
+ "pt": "Portugal",
79
87
  "portugal": "Portugal",
88
+ "uk": "Reino Unido",
89
+ "gb": "Reino Unido",
90
+ "united kingdom": "Reino Unido",
91
+ "england": "Reino Unido",
92
+ "germany": "Alemanha",
93
+ "de": "Alemanha",
94
+ "france": "França",
95
+ "fr": "França",
96
+ "italy": "Itália",
97
+ "it": "Itália",
98
+ "spain": "Espanha",
99
+ "es": "Espanha",
100
+ "canada": "Canadá",
101
+ "ca": "Canadá",
102
+ "australia": "Austrália",
103
+ "au": "Austrália",
104
+ "japan": "Japão",
105
+ "jp": "Japão",
106
+ "mexico": "México",
107
+ "mx": "México",
108
+ "argentina": "Argentina",
109
+ "ar": "Argentina",
80
110
  }
81
111
 
82
112
 
@@ -94,11 +124,17 @@ def _normalise_country(value: Any) -> str:
94
124
 
95
125
 
96
126
  def _profile_country(profile: Mapping[str, Any]) -> str:
97
- for key in ("country", "country_name", "country_of_registration", "account_country", "account_based_in"):
98
- country = _normalise_country(profile.get(key))
99
- if country:
100
- return country
101
- return ""
127
+ for key in ("country", "country_name", "country_of_registration", "account_country", "account_based_in", "country_code"):
128
+ raw_value = profile.get(key)
129
+ candidates = [raw_value]
130
+ if isinstance(raw_value, Mapping):
131
+ candidates.extend(raw_value.get(child) for child in ("name", "label", "display_name", "country", "country_name", "code"))
132
+ for candidate in candidates:
133
+ country = _normalise_country(candidate)
134
+ if country:
135
+ return country
136
+ extracted = _normalise_country(extract_public_instagram_country(profile))
137
+ return extracted
102
138
 
103
139
 
104
140
  def _language_index(value: Any) -> int:
@@ -130,9 +166,13 @@ def _instagram_profile_storage_id(profile: Mapping[str, Any]) -> str:
130
166
 
131
167
 
132
168
  def _clear_instagram_result_widget_state(profile: Mapping[str, Any]) -> None:
169
+ """Clear every previous widget key for this profile before rebuilding the form."""
133
170
  widget_id = _instagram_profile_storage_id(profile)
134
171
  for field in ("name", "bio", "country", "language", "posts", "followers", "following", "character"):
135
- st.session_state.pop(f"social_instagram_result_{field}_{widget_id}", None)
172
+ prefix = f"social_instagram_result_{field}_{widget_id}"
173
+ for key in list(st.session_state.keys()):
174
+ if str(key).startswith(prefix):
175
+ st.session_state.pop(key, None)
136
176
 
137
177
 
138
178
  def _instagram_result_widget_id(profile: Mapping[str, Any]) -> str:
@@ -143,9 +183,23 @@ def _instagram_result_widget_id(profile: Mapping[str, Any]) -> str:
143
183
 
144
184
 
145
185
  def _normalise_instagram_posts(value: Any) -> list[dict[str, Any]]:
186
+ if isinstance(value, Mapping):
187
+ value = value.get("posts") or value.get("items") or value.get("edges") or []
146
188
  if not isinstance(value, list):
147
189
  return []
148
- return [dict(post) for post in value if isinstance(post, Mapping) and _clean(post.get("id") or post.get("url"))]
190
+ normalised: list[dict[str, Any]] = []
191
+ seen: set[str] = set()
192
+ for item in value:
193
+ post = item.get("node") if isinstance(item, Mapping) and isinstance(item.get("node"), Mapping) else item
194
+ if not isinstance(post, Mapping):
195
+ continue
196
+ data = dict(post)
197
+ identifier = _clean(data.get("id") or data.get("shortcode") or data.get("url"))
198
+ if not identifier or identifier in seen:
199
+ continue
200
+ seen.add(identifier)
201
+ normalised.append(data)
202
+ return normalised
149
203
 
150
204
 
151
205
  def _stored_instagram_posts(profile: Mapping[str, Any]) -> list[dict[str, Any]]:
@@ -219,10 +273,9 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
219
273
  # DEBUG WINDOWS INSTAGRAM: manter até a validação final do carregamento público.
220
274
  st.write({"instagram_posts_ok": posts_ok, "instagram_posts_message": posts_message, "instagram_posts_count": len(loaded_posts)})
221
275
  if posts_ok:
222
- posts = loaded_posts
276
+ posts = _normalise_instagram_posts(loaded_posts)
223
277
  _persist_instagram_posts(profile, posts)
224
278
  st.success(f"{len(posts)} posts carregados.")
225
- st.rerun()
226
279
  else:
227
280
  st.warning(posts_message)
228
281
  posts = st.session_state.get(state_key, posts)
@@ -243,9 +296,10 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
243
296
  if st.button("Mostrar + 10", key=f"more_instagram_posts_{profile_id}", use_container_width=True):
244
297
  posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=len(posts) + 10)
245
298
  if posts_ok:
246
- _persist_instagram_posts(profile, loaded_posts)
247
- st.rerun()
248
- st.warning(posts_message)
299
+ posts = _normalise_instagram_posts(loaded_posts)
300
+ _persist_instagram_posts(profile, posts)
301
+ else:
302
+ st.warning(posts_message)
249
303
 
250
304
 
251
305
  def _api_card_defaults(kind: str) -> tuple[str, list[str], Callable[[Mapping[str, Any]], dict[str, Any]]]:
@@ -25,6 +25,7 @@ except ImportError: # pragma: no cover - Streamlit is a runtime dependency
25
25
 
26
26
  LOGGER = logging.getLogger(__name__)
27
27
  _WINDOWS_PLAYWRIGHT_CHECKED = False
28
+ _INSTALOADER_INSTALL_ATTEMPTED = False
28
29
 
29
30
 
30
31
  ABOUT_ACCOUNT_URL = 'https://i.instagram.com/api/v1/bloks/apps/com.instagram.interactions.about_this_account/'
@@ -131,8 +132,10 @@ def _instagram_playwright_headless() -> bool:
131
132
 
132
133
 
133
134
  def _save_instagram_debug_html(username: str, document: str) -> None:
134
- path = Path(os.getenv('INSTAGRAM_DEBUG_PROFILE_HTML', str(Path(__file__).resolve().parents[1] / 'debug_profile.html')))
135
+ storage_dir = Path(__file__).resolve().parents[1] / 'storage'
136
+ path = Path(os.getenv('INSTAGRAM_DEBUG_PROFILE_HTML', str(storage_dir / 'debug_instagram.html')))
135
137
  try:
138
+ path.parent.mkdir(parents=True, exist_ok=True)
136
139
  path.write_text(document, encoding='utf-8')
137
140
  LOGGER.info('HTML salvo em %s', path)
138
141
  except OSError as exc:
@@ -140,16 +143,38 @@ def _save_instagram_debug_html(username: str, document: str) -> None:
140
143
 
141
144
 
142
145
  def _instagram_instaloader():
143
- """Load the optional anonymous fallback without mutating the user's environment."""
146
+ """Load the anonymous fallback, installing it once when the requirement is missing."""
147
+ global _INSTALOADER_INSTALL_ATTEMPTED
144
148
  try:
145
149
  import instaloader
150
+ return instaloader
146
151
  except ImportError:
147
- LOGGER.warning('Fallback instaloader indisponível; instale com: python -m pip install instaloader')
152
+ if _INSTALOADER_INSTALL_ATTEMPTED:
153
+ LOGGER.warning('Fallback instaloader indisponível; instale com: python -m pip install instaloader')
154
+ return None
155
+ _INSTALOADER_INSTALL_ATTEMPTED = True
156
+ try:
157
+ LOGGER.info('Instaloader ausente; a tentar instalar a dependência automaticamente.')
158
+ completed = subprocess.run(
159
+ [sys.executable, '-m', 'pip', 'install', 'instaloader>=4.14,<5'],
160
+ capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=180, check=False,
161
+ )
162
+ if completed.returncode == 0:
163
+ import importlib
164
+ importlib.invalidate_caches()
165
+ try:
166
+ import instaloader
167
+ return instaloader
168
+ except ImportError:
169
+ LOGGER.warning('Instaloader foi instalado, mas não ficou disponível neste interpretador.')
170
+ LOGGER.warning('Instalação automática do instaloader falhou: %s', (completed.stderr or completed.stdout)[-1000:])
171
+ except (OSError, subprocess.SubprocessError) as exc:
172
+ LOGGER.warning('Não foi possível instalar instaloader automaticamente: %s', exc)
173
+ _streamlit_message('error', 'O fallback instaloader não está disponível. Instale com: python -m pip install instaloader', 'instaloader_missing')
148
174
  return None
149
- return instaloader
150
175
 
151
176
 
152
- def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
177
+ def _fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
153
178
  """Fetch a public profile anonymously through Instaloader when Playwright fails."""
154
179
  instaloader = _instagram_instaloader()
155
180
  if instaloader is None:
@@ -157,6 +182,8 @@ def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
157
182
  try:
158
183
  loader = instaloader.Instaloader(
159
184
  quiet=True,
185
+ request_timeout=30,
186
+ max_connection_attempts=1,
160
187
  download_pictures=False,
161
188
  download_videos=False,
162
189
  download_video_thumbnails=False,
@@ -164,14 +191,23 @@ def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
164
191
  )
165
192
  profile = instaloader.Profile.from_username(loader.context, username)
166
193
  LOGGER.info('Fallback instaloader activado para o perfil @%s', username)
194
+ country = next(
195
+ (
196
+ str(getattr(profile, attribute, '') or '').strip()
197
+ for attribute in ('country', 'country_name', 'country_of_registration', 'account_country', 'account_based_in')
198
+ if str(getattr(profile, attribute, '') or '').strip()
199
+ ),
200
+ '',
201
+ )
167
202
  return {
168
203
  'id': str(getattr(profile, 'userid', '') or ''),
169
204
  'username': str(getattr(profile, 'username', '') or username),
170
205
  'full_name': str(getattr(profile, 'full_name', '') or ''),
171
206
  'biography': str(getattr(profile, 'biography', '') or ''),
172
- 'follower_count': getattr(profile, 'followers', None),
173
- 'following_count': getattr(profile, 'followees', None),
207
+ 'follower_count': getattr(profile, 'follower_count', getattr(profile, 'followers', None)),
208
+ 'following_count': getattr(profile, 'following_count', getattr(profile, 'followees', None)),
174
209
  'edge_owner_to_timeline_media': {'count': getattr(profile, 'mediacount', None)},
210
+ 'country': country,
175
211
  'is_private': bool(getattr(profile, 'is_private', False)),
176
212
  '_instagram_source': 'instaloader',
177
213
  }
@@ -180,7 +216,7 @@ def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
180
216
  return None
181
217
 
182
218
 
183
- def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, Any]]:
219
+ def _fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, Any]]:
184
220
  """Fetch public posts anonymously through Instaloader when Playwright fails."""
185
221
  instaloader = _instagram_instaloader()
186
222
  if instaloader is None:
@@ -189,6 +225,8 @@ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, An
189
225
  try:
190
226
  loader = instaloader.Instaloader(
191
227
  quiet=True,
228
+ request_timeout=30,
229
+ max_connection_attempts=1,
192
230
  download_pictures=False,
193
231
  download_videos=False,
194
232
  download_video_thumbnails=False,
@@ -199,13 +237,21 @@ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, An
199
237
  shortcode = str(getattr(post, 'shortcode', '') or '')
200
238
  if not shortcode:
201
239
  continue
240
+ image_url = str(getattr(post, 'url', '') or '')
241
+ timestamp = getattr(post, 'date_utc', '') or ''
242
+ if hasattr(timestamp, 'isoformat'):
243
+ timestamp = timestamp.isoformat()
244
+ else:
245
+ timestamp = str(timestamp)
202
246
  posts.append({
203
247
  'id': str(getattr(post, 'mediaid', '') or shortcode),
204
248
  'shortcode': shortcode,
205
249
  'url': f'https://www.instagram.com/p/{shortcode}/',
206
- 'image_url': str(getattr(post, 'url', '') or ''),
250
+ 'image_url': image_url,
251
+ 'display_url': image_url,
207
252
  'caption': str(getattr(post, 'caption', '') or '').strip(),
208
- 'published_at': getattr(post, 'date_utc', '') or '',
253
+ 'published_at': timestamp,
254
+ 'timestamp': timestamp,
209
255
  'is_video': bool(getattr(post, 'is_video', False)),
210
256
  })
211
257
  if len(posts) >= max(1, int(limit)):
@@ -216,6 +262,14 @@ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, An
216
262
  return posts
217
263
 
218
264
 
265
+ def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
266
+ return _fetch_profile_instaloader(username)
267
+
268
+
269
+ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, Any]]:
270
+ return _fetch_posts_instaloader(username, limit)
271
+
272
+
219
273
  @dataclass
220
274
  class IntegrationResult:
221
275
  ok: bool
@@ -326,8 +380,17 @@ def _extract_profile_user(payload: Any) -> Mapping[str, Any] | None:
326
380
  (payload.get('data') or {}).get('user') if isinstance(payload.get('data'), Mapping) else None,
327
381
  (payload.get('graphql') or {}).get('user') if isinstance(payload.get('graphql'), Mapping) else None,
328
382
  (payload.get('data') or {}).get('profile') if isinstance(payload.get('data'), Mapping) else None,
383
+ (payload.get('xdt_api__v1__users__web_profile_info') or {}).get('user') if isinstance(payload.get('xdt_api__v1__users__web_profile_info'), Mapping) else None,
329
384
  ]
330
- return next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
385
+ direct = next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
386
+ if direct is not None:
387
+ return direct
388
+ for node in _walk_json(payload):
389
+ if not isinstance(node, Mapping) or not node.get('username'):
390
+ continue
391
+ if any(key in node for key in ('biography', 'edge_follow', 'edge_followed_by', 'follower_count', 'following_count', 'full_name')):
392
+ return node
393
+ return None
331
394
 
332
395
 
333
396
  def _shared_data_documents(document: str) -> list[Any]:
@@ -376,7 +439,7 @@ def _profile_users_from_document(document: str) -> list[Mapping[str, Any]]:
376
439
 
377
440
 
378
441
  def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | None = None, cookies: Mapping[str, str] | None = None) -> dict[str, Any] | None:
379
- """Fetch the structured profile JSON requested by the public Instagram page."""
442
+ """Fetch a public profile using browser state, API responses and embedded HTML data."""
380
443
  headers = headers or _instagram_headers()
381
444
  cookies = cookies or _instagram_cookies()
382
445
  captured_user: dict[str, Any] | None = None
@@ -385,47 +448,72 @@ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | N
385
448
  with sync_playwright() as playwright:
386
449
  browser = playwright.chromium.launch(headless=_instagram_playwright_headless())
387
450
  try:
388
- page = browser.new_page(
389
- user_agent=headers['User-Agent'],
390
- extra_http_headers={'x-ig-app-id': headers['x-ig-app-id'], 'Accept-Language': 'en-US,en;q=0.9', 'Accept': 'application/json, text/plain, */*', 'Origin': 'https://www.instagram.com', 'Referer': 'https://www.instagram.com/'},
391
- )
451
+ headers_for_context = {'x-ig-app-id': headers['x-ig-app-id'], 'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7', 'Accept': 'application/json, text/plain, */*', 'Origin': 'https://www.instagram.com', 'Referer': 'https://www.instagram.com/'}
452
+ state_path = Path(__file__).resolve().parents[1] / 'storage' / 'instagram_storage_state.json'
453
+ context_kwargs = {'user_agent': headers['User-Agent'], 'extra_http_headers': headers_for_context}
454
+ if state_path.is_file():
455
+ context_kwargs['storage_state'] = str(state_path)
456
+ context = browser.new_context(**context_kwargs)
392
457
  if cookies:
393
- page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
458
+ context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
459
+ page = context.new_page()
394
460
 
395
461
  def capture_profile_response(response: Any) -> None:
396
462
  nonlocal captured_user
397
- if '/api/v1/users/web_profile_info/' not in response.url:
463
+ response_url = str(getattr(response, 'url', '') or '')
464
+ if not any(marker in response_url for marker in ('/api/v1/users/web_profile_info/', '/graphql/query')):
398
465
  return
466
+ status = getattr(response, 'status', None)
467
+ LOGGER.info('Instagram Playwright API response URL=%s status=%s', response_url, status)
399
468
  try:
400
469
  payload = response.json()
401
- user = ((payload.get('data') or {}).get('user') if isinstance(payload, Mapping) else None)
470
+ user = _extract_profile_user(payload)
402
471
  if isinstance(user, Mapping):
403
472
  captured_user = _merge_profile(captured_user, user)
404
- LOGGER.debug('Instagram Playwright API profile captured user=%s', username)
473
+ LOGGER.info('Instagram Playwright perfil capturado URL=%s status=%s quality=%s', response_url, status, _profile_quality(captured_user))
405
474
  except (ValueError, TypeError, AttributeError) as exc:
406
- LOGGER.debug('Não foi possível interpretar resposta web_profile_info: %s', exc)
475
+ LOGGER.warning('Não foi possível interpretar resposta Instagram URL=%s status=%s: %s', response_url, status, exc)
476
+
407
477
 
408
478
  page.on('response', capture_profile_response)
409
479
  try:
410
- page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=10000)
411
- LOGGER.debug('Instagram Playwright página inicial carregada para cookies.')
480
+ page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=30000)
481
+ state_path.parent.mkdir(parents=True, exist_ok=True)
482
+ context.storage_state(path=str(state_path))
483
+ LOGGER.debug('Instagram Playwright página inicial carregada; storage_state salvo em %s.', state_path)
412
484
  except Exception as exc:
413
485
  LOGGER.debug('Instagram Playwright não carregou a página inicial: %s', exc)
414
486
  url = f'https://www.instagram.com/{username}/'
415
- response = page.goto(url, wait_until='domcontentloaded', timeout=10000)
416
- LOGGER.debug('Instagram Playwright profile URL=%s status=%s', url, response.status if response else None)
417
- if response is None or response.status >= 400:
418
- LOGGER.warning('Instagram Playwright perfil HTTP status=%s URL=%s', response.status if response else None, url)
419
- return captured_user
487
+ response = None
488
+ status = None
420
489
  try:
421
- _save_instagram_debug_html(username, page.content())
490
+ response = page.goto(url, wait_until='domcontentloaded', timeout=30000)
491
+ status = response.status if response else None
422
492
  except Exception as exc:
423
- LOGGER.debug('Não foi possível guardar HTML de diagnóstico: %s', exc)
493
+ LOGGER.warning('Instagram Playwright navegação falhou URL=%s: %s', url, exc)
494
+ LOGGER.info('Instagram Playwright profile URL=%s status=%s', url, status)
495
+ try:
496
+ page.wait_for_timeout(5000)
497
+ except Exception:
498
+ pass
424
499
  try:
425
500
  page.wait_for_load_state('networkidle', timeout=10000)
426
501
  except (PlaywrightTimeoutError, TimeoutError):
427
- LOGGER.debug('Instagram Playwright networkidle excedido; usando resposta API já capturada.')
428
- LOGGER.info('Instagram Playwright API profile captured=%s quality=%s URL=%s', bool(captured_user), _profile_quality(captured_user or {}), url)
502
+ LOGGER.debug('Instagram Playwright networkidle excedido; usando dados já capturados.')
503
+ try:
504
+ document = page.content()
505
+ _save_instagram_debug_html(username, document)
506
+ for user in _profile_users_from_document(document):
507
+ if isinstance(user, Mapping):
508
+ candidate = _merge_profile(captured_user, user)
509
+ if _profile_quality(candidate) >= _profile_quality(captured_user or {}):
510
+ captured_user = candidate
511
+ except Exception as exc:
512
+ LOGGER.warning('Não foi possível guardar/analisar HTML de diagnóstico URL=%s: %s', url, exc)
513
+ if response is None or status is None or status >= 400:
514
+ LOGGER.warning('Instagram Playwright perfil HTTP status=%s URL=%s', status, url)
515
+ return captured_user
516
+ LOGGER.info('Instagram Playwright perfil final captured=%s quality=%s URL=%s', bool(captured_user), _profile_quality(captured_user or {}), url)
429
517
  finally:
430
518
  browser.close()
431
519
  except Exception as exc:
@@ -445,7 +533,12 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
445
533
  if playwright_user:
446
534
  return playwright_user
447
535
  LOGGER.warning('Fallback para instaloader activado no perfil @%s.', username)
448
- return fetch_profile_instaloader(username)
536
+ fallback_user = fetch_profile_instaloader(username)
537
+ if fallback_user:
538
+ _streamlit_message('info', 'Dados obtidos via fallback (instaloader). O país pode não estar disponível.', 'profile_instaloader_fallback')
539
+ else:
540
+ _streamlit_message('error', 'Não foi possível obter os dados do Instagram. Verifique sua conexão ou tente novamente mais tarde.', 'profile_instagram_failed')
541
+ return fallback_user
449
542
 
450
543
  endpoints = (
451
544
  f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
@@ -579,6 +672,30 @@ def _country_from_bio_fallback(bio: str) -> str:
579
672
  return ''
580
673
 
581
674
 
675
+ def _profile_post_nodes(user: Mapping[str, Any]) -> list[Mapping[str, Any]]:
676
+ """Return post nodes from both legacy GraphQL edges and current items payloads."""
677
+ containers: list[Any] = [
678
+ user.get('edge_owner_to_timeline_media'),
679
+ user.get('timeline_media'),
680
+ user.get('media'),
681
+ user,
682
+ ]
683
+ nodes: list[Mapping[str, Any]] = []
684
+ for container in containers:
685
+ if not isinstance(container, Mapping):
686
+ continue
687
+ raw_items = container.get('edges') or container.get('items') or container.get('data') or []
688
+ if isinstance(raw_items, Mapping):
689
+ raw_items = raw_items.get('items') or raw_items.get('edges') or []
690
+ if not isinstance(raw_items, list):
691
+ continue
692
+ for item in raw_items:
693
+ node = item.get('node') if isinstance(item, Mapping) and isinstance(item.get('node'), Mapping) else item
694
+ if isinstance(node, Mapping):
695
+ nodes.append(node)
696
+ return nodes
697
+
698
+
582
699
  def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
583
700
  followers = _profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
584
701
  following_node = user.get('edge_follow')
@@ -586,10 +703,12 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
586
703
  following = following if following is not None else _profile_metric(user, 'follows', 'following', 'following_count', 'followingCount')
587
704
  followers = followers if followers is not None else _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
588
705
  following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
589
- media = ((user.get('edge_owner_to_timeline_media') or {}).get('count') if isinstance(user.get('edge_owner_to_timeline_media'), dict) else None)
706
+ media_node = user.get('edge_owner_to_timeline_media') or user.get('media')
707
+ media = (media_node.get('count') if isinstance(media_node, Mapping) else None)
590
708
  media = media if media is not None else _profile_metric(user, 'posts', 'post_count', 'media_count')
591
709
  biography = str(user.get('biography') or user.get('bio') or user.get('description') or '').strip()
592
- country = extract_public_instagram_country(user) or _fetch_instagram_about_country(user.get('id') or user.get('pk'))
710
+ direct_country = _country_value(user.get('country') or user.get('country_code'))
711
+ country = direct_country or extract_public_instagram_country(user) or _fetch_instagram_about_country(user.get('id') or user.get('pk'))
593
712
  country = country or _country_from_bio_fallback(biography)
594
713
  LOGGER.debug('Instagram profile extracted keys=%s edge_follow=%r bio=%s followers=%s following=%s posts=%s country=%s', sorted(str(key) for key in user.keys()), following_node, bool(biography), followers, following, media, country or '—')
595
714
  return {
@@ -607,7 +726,7 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
607
726
  'public_lookup': True,
608
727
  'metrics_source': 'instagram_web_profile_info',
609
728
  'last_public_lookup_at': datetime.now(timezone.utc).isoformat(),
610
- '_api_posts': (((user.get('edge_owner_to_timeline_media') or {}).get('edges') or []) if isinstance(user.get('edge_owner_to_timeline_media'), dict) else (user.get('posts') if isinstance(user.get('posts'), list) else [])),
729
+ '_api_posts': [dict(node) for node in _profile_post_nodes(user)],
611
730
  }
612
731
 
613
732
 
@@ -622,10 +741,14 @@ def normalize_instagram_metric(value: Any) -> int | None:
622
741
 
623
742
 
624
743
  PUBLIC_COUNTRY_KEYS = {
625
- 'about_this_account_country', 'account_based_in', 'account_country', 'country_name',
626
- 'country_of_origin', 'country_of_registration', 'location_country', 'transparency_country',
744
+ 'about_this_account_country', 'account_based_in', 'account_country',
745
+ 'country_name', 'country_of_origin', 'country_of_registration', 'location_country',
746
+ 'transparency_country',
747
+ }
748
+ PUBLIC_TRANSPARENCY_KEYS = {
749
+ 'about', 'about_account', 'about_this_account', 'about_this_account_data',
750
+ 'account_transparency', 'transparency', 'account_info', 'profile_context',
627
751
  }
628
- PUBLIC_TRANSPARENCY_KEYS = {'about', 'about_account', 'account_transparency', 'transparency', 'account_info'}
629
752
 
630
753
 
631
754
  def _country_value(value: Any) -> str:
@@ -785,26 +908,26 @@ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cooki
785
908
  with sync_playwright() as playwright:
786
909
  browser = playwright.chromium.launch(headless=_instagram_playwright_headless())
787
910
  try:
788
- page = browser.new_page(
789
- user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
790
- extra_http_headers={'x-ig-app-id': '936619743392459', 'Accept-Language': 'en-US,en;q=0.9', 'Accept': 'application/json, text/plain, */*', 'Origin': 'https://www.instagram.com', 'Referer': 'https://www.instagram.com/'},
791
- )
911
+ state_path = Path(__file__).resolve().parents[1] / 'storage' / 'instagram_storage_state.json'
912
+ headers_for_context = {'x-ig-app-id': '936619743392459', 'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7', 'Accept': 'application/json, text/plain, */*', 'Origin': 'https://www.instagram.com', 'Referer': 'https://www.instagram.com/'}
913
+ context_kwargs = {'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36', 'extra_http_headers': headers_for_context}
914
+ if state_path.is_file():
915
+ context_kwargs['storage_state'] = str(state_path)
916
+ context = browser.new_context(**context_kwargs)
792
917
  if cookies:
793
- page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
918
+ context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
919
+ page = context.new_page()
794
920
 
795
921
  def capture_profile_posts(response: Any) -> None:
796
- if '/api/v1/users/web_profile_info/' not in response.url:
922
+ response_url = str(getattr(response, 'url', '') or '')
923
+ if not any(marker in response_url for marker in ('/api/v1/users/web_profile_info/', '/graphql/query')):
797
924
  return
925
+ LOGGER.info('Instagram Playwright posts response URL=%s status=%s', response_url, getattr(response, 'status', None))
798
926
  try:
799
927
  payload = response.json()
800
- user = ((payload.get('data') or {}).get('user') if isinstance(payload, Mapping) else None)
801
- media = user.get('edge_owner_to_timeline_media') if isinstance(user, Mapping) else None
802
- edges = media.get('edges') if isinstance(media, Mapping) else []
803
- for edge in edges or []:
804
- node = edge.get('node') if isinstance(edge, Mapping) else None
805
- if not isinstance(node, dict):
806
- continue
807
- post = _post_from_node(node)
928
+ user = _extract_profile_user(payload)
929
+ for node in _profile_post_nodes(user) if isinstance(user, Mapping) else []:
930
+ post = _post_from_node(dict(node))
808
931
  if post and post['id'] not in seen:
809
932
  seen.add(post['id'])
810
933
  posts.append(post)
@@ -813,13 +936,16 @@ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cooki
813
936
 
814
937
  page.on('response', capture_profile_posts)
815
938
  try:
816
- page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=10000)
939
+ page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=30000)
940
+ state_path.parent.mkdir(parents=True, exist_ok=True)
941
+ context.storage_state(path=str(state_path))
817
942
  except Exception as exc:
818
943
  LOGGER.debug('Instagram Playwright não carregou a página inicial para posts: %s', exc)
819
- response = page.goto(reference['url'], wait_until='domcontentloaded', timeout=10000)
944
+ response = page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
820
945
  LOGGER.debug('Instagram Playwright posts URL=%s status=%s', reference['url'], response.status if response else None)
821
946
  if response is None or response.status >= 400:
822
947
  return []
948
+ page.wait_for_timeout(5000)
823
949
  try:
824
950
  page.wait_for_load_state('networkidle', timeout=10000)
825
951
  except (PlaywrightTimeoutError, TimeoutError):
@@ -847,16 +973,18 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
847
973
  if not posts:
848
974
  LOGGER.warning('Fallback para instaloader activado nos posts de @%s.', reference['username'])
849
975
  posts = fetch_posts_instaloader(reference['username'], limit)
976
+ if posts:
977
+ _streamlit_message('info', 'Dados obtidos via fallback (instaloader). O país pode não estar disponível.', 'posts_instaloader_fallback')
978
+ else:
979
+ _streamlit_message('error', 'Não foi possível obter os dados do Instagram. Verifique sua conexão ou tente novamente mais tarde.', 'posts_instagram_failed')
850
980
  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))]})
851
981
  api_user = _fetch_web_profile_user(reference['username'])
852
982
  if api_user:
853
983
  api_posts: list[dict[str, Any]] = []
854
- for edge in ((api_user.get('edge_owner_to_timeline_media') or {}).get('edges') or []):
855
- node = edge.get('node') if isinstance(edge, dict) else None
856
- if isinstance(node, dict):
857
- post = _post_from_node(node)
858
- if post:
859
- api_posts.append(post)
984
+ for node in _profile_post_nodes(api_user):
985
+ post = _post_from_node(dict(node))
986
+ if post:
987
+ api_posts.append(post)
860
988
  if api_posts:
861
989
  LOGGER.debug('Instagram API posts extracted=%s response=JSON', len(api_posts))
862
990
  return IntegrationResult(True, 'Posts públicos encontrados.', reference | {'posts': api_posts[:max(1, int(limit))]})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.53",
3
+ "version": "0.6.55",
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",