@danhachuel/thunderbolt 0.6.49 → 0.6.51

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.
@@ -216,8 +216,8 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
216
216
  if load_clicked or refresh_clicked:
217
217
  target = max(10, len(posts)) if refresh_clicked else 10
218
218
  posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=target)
219
- # Debug temporário removido após validação; manter para reactivar se necessário:
220
- # st.write({"posts_ok": posts_ok, "posts_message": posts_message, "posts_count": len(loaded_posts)})
219
+ # DEBUG WINDOWS INSTAGRAM: manter até a validação final do carregamento público.
220
+ st.write({"instagram_posts_ok": posts_ok, "instagram_posts_message": posts_message, "instagram_posts_count": len(loaded_posts)})
221
221
  if posts_ok:
222
222
  posts = loaded_posts
223
223
  _persist_instagram_posts(profile, posts)
@@ -132,18 +132,8 @@ class IntegrationResult:
132
132
 
133
133
 
134
134
  def normalize_instagram_bio(value: Any) -> str:
135
- """Keep the profile biography, never Instagram's metrics summary."""
135
+ """Keep the complete public biography exactly as returned by Instagram."""
136
136
  bio = str(value or '').strip()
137
- if not bio:
138
- return ''
139
- metric_words = r'followers|following|seguidores|seguindo|posts|publicações|publications'
140
- metric_hits = re.findall(
141
- rf'(?:\d[\d.,]*\s*(?:{metric_words})\b|\b(?:{metric_words})\s*\d[\d.,]*)',
142
- bio,
143
- flags=re.IGNORECASE,
144
- )
145
- if len(metric_hits) >= 2:
146
- return ''
147
137
  return bio
148
138
 
149
139
 
@@ -248,11 +238,55 @@ def _extract_profile_user(payload: Any) -> Mapping[str, Any] | None:
248
238
  return next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
249
239
 
250
240
 
251
- def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str], cookies: Mapping[str, str]) -> dict[str, Any] | None:
252
- endpoints = (
253
- f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
254
- f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
241
+ def _shared_data_documents(document: str) -> list[Any]:
242
+ """Extract JSON assigned to window._sharedData from the rendered profile HTML."""
243
+ decoder = json.JSONDecoder()
244
+ documents: list[Any] = []
245
+ patterns = (
246
+ r'window\s*\.\s*_sharedData\s*=\s*',
247
+ r'window\s*\[\s*["\']_sharedData["\']\s*\]\s*=\s*',
255
248
  )
249
+ for pattern in patterns:
250
+ for match in re.finditer(pattern, document, flags=re.IGNORECASE):
251
+ start = document.find('{', match.end())
252
+ if start < 0:
253
+ continue
254
+ try:
255
+ parsed, _ = decoder.raw_decode(document[start:])
256
+ except json.JSONDecodeError:
257
+ continue
258
+ documents.append(parsed)
259
+ return documents
260
+
261
+
262
+ def _profile_users_from_document(document: str) -> list[Mapping[str, Any]]:
263
+ """Return users found in legacy _sharedData and current embedded profile payloads."""
264
+ users: list[Mapping[str, Any]] = []
265
+ documents = _shared_data_documents(document) + _embedded_json_documents(document)
266
+ for payload in documents:
267
+ direct = _extract_profile_user(payload)
268
+ if isinstance(direct, Mapping):
269
+ users.append(direct)
270
+ for node in _walk_json(payload):
271
+ if not isinstance(node, Mapping):
272
+ continue
273
+ candidate = node.get('user')
274
+ if isinstance(candidate, Mapping) and any(key in candidate for key in ('biography', 'edge_follow', 'edge_followed_by', 'username')):
275
+ users.append(candidate)
276
+ unique: list[Mapping[str, Any]] = []
277
+ seen: set[int] = set()
278
+ for user in users:
279
+ marker = id(user)
280
+ if marker not in seen:
281
+ seen.add(marker)
282
+ unique.append(user)
283
+ return unique
284
+
285
+
286
+ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | None = None, cookies: Mapping[str, str] | None = None) -> dict[str, Any] | None:
287
+ """Fetch the public profile page and read its embedded structured profile data."""
288
+ headers = headers or _instagram_headers()
289
+ cookies = cookies or _instagram_cookies()
256
290
  best_user: dict[str, Any] | None = None
257
291
  try:
258
292
  from playwright.sync_api import sync_playwright
@@ -265,20 +299,15 @@ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str], co
265
299
  )
266
300
  if cookies:
267
301
  page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
268
- for endpoint in endpoints:
269
- try:
270
- response = page.goto(endpoint, wait_until='domcontentloaded', timeout=30000)
271
- LOGGER.debug('Instagram Playwright profile URL=%s status=%s', endpoint, response.status if response else None)
272
- if response is None or response.status >= 400:
273
- continue
274
- payload = json.loads(page.locator('body').inner_text(timeout=5000))
275
- user = _extract_profile_user(payload)
276
- if isinstance(user, dict):
277
- best_user = _merge_profile(best_user, user)
278
- if _profile_quality(best_user) >= 4:
279
- return best_user
280
- except (ValueError, TimeoutError) as exc:
281
- LOGGER.warning('Falha ao interpretar resposta Playwright do perfil URL=%s: %s', endpoint, exc)
302
+ url = f'https://www.instagram.com/{username}/'
303
+ response = page.goto(url, wait_until='domcontentloaded', timeout=30000)
304
+ LOGGER.debug('Instagram Playwright profile URL=%s status=%s', url, response.status if response else None)
305
+ if response is None or response.status >= 400:
306
+ return None
307
+ document = page.content()
308
+ for user in _profile_users_from_document(document):
309
+ best_user = _merge_profile(best_user, user)
310
+ LOGGER.debug('Instagram Playwright _sharedData profile user=%s quality=%s', bool(best_user), _profile_quality(best_user or {}))
282
311
  finally:
283
312
  browser.close()
284
313
  except Exception as exc:
@@ -292,13 +321,14 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
292
321
  cookies = _instagram_cookies()
293
322
  headers = _instagram_headers()
294
323
  headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36'
324
+ if platform.system() == 'Windows':
325
+ LOGGER.debug('Windows detectado: perfil Instagram exclusivamente via Playwright.')
326
+ return _fetch_profile_with_playwright(username, headers, cookies)
327
+
295
328
  endpoints = (
296
329
  f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
297
330
  f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
298
331
  )
299
- if platform.system() == 'Windows':
300
- LOGGER.debug('Windows detectado: perfil Instagram exclusivamente via Playwright.')
301
- return _fetch_profile_with_playwright(username, headers, cookies)
302
332
 
303
333
  request_headers = [
304
334
  headers,
@@ -429,7 +459,9 @@ def _country_from_bio_fallback(bio: str) -> str:
429
459
 
430
460
  def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
431
461
  followers = _profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
432
- following = _profile_metric(user, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
462
+ following_node = user.get('edge_follow')
463
+ following = normalize_instagram_metric(following_node) if isinstance(following_node, Mapping) else None
464
+ following = following if following is not None else _profile_metric(user, 'follows', 'following', 'following_count', 'followingCount')
433
465
  followers = followers if followers is not None else _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
434
466
  following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
435
467
  media = ((user.get('edge_owner_to_timeline_media') or {}).get('count') if isinstance(user.get('edge_owner_to_timeline_media'), dict) else None)
@@ -437,7 +469,7 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
437
469
  biography = str(user.get('biography') or user.get('bio') or user.get('description') or '').strip()
438
470
  country = extract_public_instagram_country(user) or _fetch_instagram_about_country(user.get('id') or user.get('pk'))
439
471
  country = country or _country_from_bio_fallback(biography)
440
- LOGGER.debug('Instagram profile extracted bio=%s followers=%s following=%s posts=%s country=%s', bool(biography), followers, following, media, country or '—')
472
+ 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 '—')
441
473
  return {
442
474
  'id': f"instagram_{reference['username']}",
443
475
  **reference,
@@ -523,6 +555,8 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
523
555
  api_user = _fetch_web_profile_user(reference['username'])
524
556
  if api_user:
525
557
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', _profile_data_from_api(api_user, reference))
558
+ if platform.system() == 'Windows':
559
+ return IntegrationResult(False, 'Não foi possível extrair os dados públicos do Instagram com o Playwright.', reference)
526
560
  try:
527
561
  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)
528
562
  except requests.RequestException as exc:
@@ -616,9 +650,11 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
616
650
  }
617
651
 
618
652
 
619
- def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cookies: Mapping[str, str]) -> list[dict[str, Any]]:
653
+ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cookies: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
654
+ # CORREÇÃO WINDOWS: posts são extraídos exclusivamente do JSON/HTML obtido pelo Playwright.
620
655
  posts: list[dict[str, Any]] = []
621
656
  seen: set[str] = set()
657
+ cookies = cookies or _instagram_cookies()
622
658
  try:
623
659
  from playwright.sync_api import sync_playwright
624
660
  with sync_playwright() as playwright:
@@ -635,13 +671,16 @@ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cooki
635
671
  if response is None or response.status >= 400:
636
672
  return []
637
673
  browser_document = page.content()
638
- for document in _embedded_json_documents(browser_document):
674
+ LOGGER.debug('Instagram Playwright posts document_bytes=%s', len(browser_document))
675
+ documents = _shared_data_documents(browser_document) + _embedded_json_documents(browser_document)
676
+ for document in documents:
639
677
  for node in _walk_json(document):
640
678
  post = _post_from_node(node)
641
679
  if not post or post['id'] in seen:
642
680
  continue
643
681
  seen.add(post['id'])
644
682
  posts.append(post)
683
+ LOGGER.debug('Instagram Playwright post extracted id=%s', post['id'])
645
684
  if len(posts) >= max(1, int(limit)):
646
685
  return posts
647
686
  finally:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.49",
3
+ "version": "0.6.51",
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",