@danhachuel/thunderbolt 0.6.54 → 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]]]:
@@ -191,6 +191,14 @@ def _fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
191
191
  )
192
192
  profile = instaloader.Profile.from_username(loader.context, username)
193
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
+ )
194
202
  return {
195
203
  'id': str(getattr(profile, 'userid', '') or ''),
196
204
  'username': str(getattr(profile, 'username', '') or username),
@@ -199,6 +207,7 @@ def _fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
199
207
  'follower_count': getattr(profile, 'follower_count', getattr(profile, 'followers', None)),
200
208
  'following_count': getattr(profile, 'following_count', getattr(profile, 'followees', None)),
201
209
  'edge_owner_to_timeline_media': {'count': getattr(profile, 'mediacount', None)},
210
+ 'country': country,
202
211
  'is_private': bool(getattr(profile, 'is_private', False)),
203
212
  '_instagram_source': 'instaloader',
204
213
  }
@@ -230,6 +239,10 @@ def _fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, A
230
239
  continue
231
240
  image_url = str(getattr(post, 'url', '') or '')
232
241
  timestamp = getattr(post, 'date_utc', '') or ''
242
+ if hasattr(timestamp, 'isoformat'):
243
+ timestamp = timestamp.isoformat()
244
+ else:
245
+ timestamp = str(timestamp)
233
246
  posts.append({
234
247
  'id': str(getattr(post, 'mediaid', '') or shortcode),
235
248
  'shortcode': shortcode,
@@ -367,8 +380,17 @@ def _extract_profile_user(payload: Any) -> Mapping[str, Any] | None:
367
380
  (payload.get('data') or {}).get('user') if isinstance(payload.get('data'), Mapping) else None,
368
381
  (payload.get('graphql') or {}).get('user') if isinstance(payload.get('graphql'), Mapping) else None,
369
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,
370
384
  ]
371
- 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
372
394
 
373
395
 
374
396
  def _shared_data_documents(document: str) -> list[Any]:
@@ -417,7 +439,7 @@ def _profile_users_from_document(document: str) -> list[Mapping[str, Any]]:
417
439
 
418
440
 
419
441
  def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | None = None, cookies: Mapping[str, str] | None = None) -> dict[str, Any] | None:
420
- """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."""
421
443
  headers = headers or _instagram_headers()
422
444
  cookies = cookies or _instagram_cookies()
423
445
  captured_user: dict[str, Any] | None = None
@@ -438,16 +460,20 @@ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | N
438
460
 
439
461
  def capture_profile_response(response: Any) -> None:
440
462
  nonlocal captured_user
441
- 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')):
442
465
  return
466
+ status = getattr(response, 'status', None)
467
+ LOGGER.info('Instagram Playwright API response URL=%s status=%s', response_url, status)
443
468
  try:
444
469
  payload = response.json()
445
- user = ((payload.get('data') or {}).get('user') if isinstance(payload, Mapping) else None)
470
+ user = _extract_profile_user(payload)
446
471
  if isinstance(user, Mapping):
447
472
  captured_user = _merge_profile(captured_user, user)
448
- 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))
449
474
  except (ValueError, TypeError, AttributeError) as exc:
450
- 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
+
451
477
 
452
478
  page.on('response', capture_profile_response)
453
479
  try:
@@ -458,21 +484,36 @@ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | N
458
484
  except Exception as exc:
459
485
  LOGGER.debug('Instagram Playwright não carregou a página inicial: %s', exc)
460
486
  url = f'https://www.instagram.com/{username}/'
461
- response = page.goto(url, wait_until='domcontentloaded', timeout=30000)
462
- LOGGER.debug('Instagram Playwright profile URL=%s status=%s', url, response.status if response else None)
463
- if response is None or response.status >= 400:
464
- LOGGER.warning('Instagram Playwright perfil HTTP status=%s URL=%s', response.status if response else None, url)
465
- return captured_user
487
+ response = None
488
+ status = None
466
489
  try:
467
- _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
468
492
  except Exception as exc:
469
- LOGGER.debug('Não foi possível guardar HTML de diagnóstico: %s', exc)
470
- page.wait_for_timeout(5000)
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
471
499
  try:
472
500
  page.wait_for_load_state('networkidle', timeout=10000)
473
501
  except (PlaywrightTimeoutError, TimeoutError):
474
- LOGGER.debug('Instagram Playwright networkidle excedido; usando resposta API já capturada.')
475
- 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)
476
517
  finally:
477
518
  browser.close()
478
519
  except Exception as exc:
@@ -631,6 +672,30 @@ def _country_from_bio_fallback(bio: str) -> str:
631
672
  return ''
632
673
 
633
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
+
634
699
  def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
635
700
  followers = _profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
636
701
  following_node = user.get('edge_follow')
@@ -638,10 +703,12 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
638
703
  following = following if following is not None else _profile_metric(user, 'follows', 'following', 'following_count', 'followingCount')
639
704
  followers = followers if followers is not None else _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
640
705
  following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
641
- 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)
642
708
  media = media if media is not None else _profile_metric(user, 'posts', 'post_count', 'media_count')
643
709
  biography = str(user.get('biography') or user.get('bio') or user.get('description') or '').strip()
644
- 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'))
645
712
  country = country or _country_from_bio_fallback(biography)
646
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 '—')
647
714
  return {
@@ -659,7 +726,7 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
659
726
  'public_lookup': True,
660
727
  'metrics_source': 'instagram_web_profile_info',
661
728
  'last_public_lookup_at': datetime.now(timezone.utc).isoformat(),
662
- '_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)],
663
730
  }
664
731
 
665
732
 
@@ -674,10 +741,14 @@ def normalize_instagram_metric(value: Any) -> int | None:
674
741
 
675
742
 
676
743
  PUBLIC_COUNTRY_KEYS = {
677
- 'about_this_account_country', 'account_based_in', 'account_country', 'country_name',
678
- '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',
679
751
  }
680
- PUBLIC_TRANSPARENCY_KEYS = {'about', 'about_account', 'account_transparency', 'transparency', 'account_info'}
681
752
 
682
753
 
683
754
  def _country_value(value: Any) -> str:
@@ -848,18 +919,15 @@ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cooki
848
919
  page = context.new_page()
849
920
 
850
921
  def capture_profile_posts(response: Any) -> None:
851
- 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')):
852
924
  return
925
+ LOGGER.info('Instagram Playwright posts response URL=%s status=%s', response_url, getattr(response, 'status', None))
853
926
  try:
854
927
  payload = response.json()
855
- user = ((payload.get('data') or {}).get('user') if isinstance(payload, Mapping) else None)
856
- media = user.get('edge_owner_to_timeline_media') if isinstance(user, Mapping) else None
857
- edges = media.get('edges') if isinstance(media, Mapping) else []
858
- for edge in edges or []:
859
- node = edge.get('node') if isinstance(edge, Mapping) else None
860
- if not isinstance(node, dict):
861
- continue
862
- 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))
863
931
  if post and post['id'] not in seen:
864
932
  seen.add(post['id'])
865
933
  posts.append(post)
@@ -913,12 +981,10 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
913
981
  api_user = _fetch_web_profile_user(reference['username'])
914
982
  if api_user:
915
983
  api_posts: list[dict[str, Any]] = []
916
- for edge in ((api_user.get('edge_owner_to_timeline_media') or {}).get('edges') or []):
917
- node = edge.get('node') if isinstance(edge, dict) else None
918
- if isinstance(node, dict):
919
- post = _post_from_node(node)
920
- if post:
921
- 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)
922
988
  if api_posts:
923
989
  LOGGER.debug('Instagram API posts extracted=%s response=JSON', len(api_posts))
924
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.54",
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",