@danhachuel/thunderbolt 0.6.50 → 0.6.52

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.
@@ -238,15 +238,58 @@ def _extract_profile_user(payload: Any) -> Mapping[str, Any] | None:
238
238
  return next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
239
239
 
240
240
 
241
- def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str], cookies: Mapping[str, str]) -> dict[str, Any] | None:
242
- # CORREÇÃO WINDOWS: este helper é o único caminho de rede usado pelo perfil no Windows.
243
- endpoints = (
244
- f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
245
- 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*',
246
248
  )
247
- best_user: dict[str, Any] | None = None
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 structured profile JSON requested by the public Instagram page."""
288
+ headers = headers or _instagram_headers()
289
+ cookies = cookies or _instagram_cookies()
290
+ captured_user: dict[str, Any] | None = None
248
291
  try:
249
- from playwright.sync_api import sync_playwright
292
+ from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, sync_playwright
250
293
  with sync_playwright() as playwright:
251
294
  browser = playwright.chromium.launch(headless=True)
252
295
  try:
@@ -256,26 +299,37 @@ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str], co
256
299
  )
257
300
  if cookies:
258
301
  page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
259
- for endpoint in endpoints:
302
+
303
+ def capture_profile_response(response: Any) -> None:
304
+ nonlocal captured_user
305
+ if '/api/v1/users/web_profile_info/' not in response.url:
306
+ return
260
307
  try:
261
- response = page.goto(endpoint, wait_until='domcontentloaded', timeout=30000)
262
- LOGGER.debug('Instagram Playwright profile URL=%s status=%s', endpoint, response.status if response else None)
263
- if response is None or response.status >= 400:
264
- continue
265
- payload = json.loads(page.locator('body').inner_text(timeout=5000))
266
- user = _extract_profile_user(payload)
267
- if isinstance(user, dict):
268
- best_user = _merge_profile(best_user, user)
269
- if _profile_quality(best_user) >= 4:
270
- return best_user
271
- except (ValueError, TimeoutError) as exc:
272
- LOGGER.warning('Falha ao interpretar resposta Playwright do perfil URL=%s: %s', endpoint, exc)
308
+ payload = response.json()
309
+ user = ((payload.get('data') or {}).get('user') if isinstance(payload, Mapping) else None)
310
+ if isinstance(user, Mapping):
311
+ captured_user = _merge_profile(captured_user, user)
312
+ LOGGER.debug('Instagram Playwright API profile captured user=%s', username)
313
+ except (ValueError, TypeError, AttributeError) as exc:
314
+ LOGGER.debug('Não foi possível interpretar resposta web_profile_info: %s', exc)
315
+
316
+ page.on('response', capture_profile_response)
317
+ url = f'https://www.instagram.com/{username}/'
318
+ response = page.goto(url, wait_until='domcontentloaded', timeout=30000)
319
+ LOGGER.debug('Instagram Playwright profile URL=%s status=%s', url, response.status if response else None)
320
+ if response is None or response.status >= 400:
321
+ return None
322
+ try:
323
+ page.wait_for_load_state('networkidle', timeout=10000)
324
+ except (PlaywrightTimeoutError, TimeoutError):
325
+ LOGGER.debug('Instagram Playwright networkidle excedido; usando resposta API já capturada.')
326
+ LOGGER.debug('Instagram Playwright API profile user=%s quality=%s', bool(captured_user), _profile_quality(captured_user or {}))
273
327
  finally:
274
328
  browser.close()
275
329
  except Exception as exc:
276
330
  LOGGER.warning('Falha no Playwright do perfil Instagram: %s', exc, exc_info=True)
277
331
  _streamlit_message('error', f'Playwright não conseguiu consultar o perfil Instagram: {exc}', 'profile_playwright_error')
278
- return best_user
332
+ return captured_user
279
333
 
280
334
 
281
335
  def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
@@ -283,13 +337,14 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
283
337
  cookies = _instagram_cookies()
284
338
  headers = _instagram_headers()
285
339
  headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36'
340
+ if platform.system() == 'Windows':
341
+ LOGGER.debug('Windows detectado: perfil Instagram exclusivamente via Playwright.')
342
+ return _fetch_profile_with_playwright(username, headers, cookies)
343
+
286
344
  endpoints = (
287
345
  f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
288
346
  f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
289
347
  )
290
- if platform.system() == 'Windows':
291
- LOGGER.debug('Windows detectado: perfil Instagram exclusivamente via Playwright.')
292
- return _fetch_profile_with_playwright(username, headers, cookies)
293
348
 
294
349
  request_headers = [
295
350
  headers,
@@ -516,6 +571,8 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
516
571
  api_user = _fetch_web_profile_user(reference['username'])
517
572
  if api_user:
518
573
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', _profile_data_from_api(api_user, reference))
574
+ if platform.system() == 'Windows':
575
+ return IntegrationResult(False, 'Não foi possível extrair os dados públicos do Instagram com o Playwright.', reference)
519
576
  try:
520
577
  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)
521
578
  except requests.RequestException as exc:
@@ -609,12 +666,13 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
609
666
  }
610
667
 
611
668
 
612
- def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cookies: Mapping[str, str]) -> list[dict[str, Any]]:
613
- # CORREÇÃO WINDOWS: posts são extraídos exclusivamente do JSON/HTML obtido pelo Playwright.
669
+ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cookies: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
670
+ # CORREÇÃO WINDOWS: posts são extraídos exclusivamente da resposta API capturada pelo Playwright.
614
671
  posts: list[dict[str, Any]] = []
615
672
  seen: set[str] = set()
673
+ cookies = cookies or _instagram_cookies()
616
674
  try:
617
- from playwright.sync_api import sync_playwright
675
+ from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, sync_playwright
618
676
  with sync_playwright() as playwright:
619
677
  browser = playwright.chromium.launch(headless=True)
620
678
  try:
@@ -624,22 +682,37 @@ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cooki
624
682
  )
625
683
  if cookies:
626
684
  page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
685
+
686
+ def capture_profile_posts(response: Any) -> None:
687
+ if '/api/v1/users/web_profile_info/' not in response.url:
688
+ return
689
+ try:
690
+ payload = response.json()
691
+ user = ((payload.get('data') or {}).get('user') if isinstance(payload, Mapping) else None)
692
+ media = user.get('edge_owner_to_timeline_media') if isinstance(user, Mapping) else None
693
+ edges = media.get('edges') if isinstance(media, Mapping) else []
694
+ for edge in edges or []:
695
+ node = edge.get('node') if isinstance(edge, Mapping) else None
696
+ if not isinstance(node, dict):
697
+ continue
698
+ post = _post_from_node(node)
699
+ if post and post['id'] not in seen:
700
+ seen.add(post['id'])
701
+ posts.append(post)
702
+ except (ValueError, TypeError, AttributeError) as exc:
703
+ LOGGER.debug('Não foi possível interpretar posts web_profile_info: %s', exc)
704
+
705
+ page.on('response', capture_profile_posts)
627
706
  response = page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
628
707
  LOGGER.debug('Instagram Playwright posts URL=%s status=%s', reference['url'], response.status if response else None)
629
708
  if response is None or response.status >= 400:
630
709
  return []
631
- browser_document = page.content()
632
- LOGGER.debug('Instagram Playwright posts document_bytes=%s', len(browser_document))
633
- for document in _embedded_json_documents(browser_document):
634
- for node in _walk_json(document):
635
- post = _post_from_node(node)
636
- if not post or post['id'] in seen:
637
- continue
638
- seen.add(post['id'])
639
- posts.append(post)
640
- LOGGER.debug('Instagram Playwright post extracted id=%s', post['id'])
641
- if len(posts) >= max(1, int(limit)):
642
- return posts
710
+ try:
711
+ page.wait_for_load_state('networkidle', timeout=10000)
712
+ except (PlaywrightTimeoutError, TimeoutError):
713
+ LOGGER.debug('Instagram Playwright networkidle excedido; usando posts API já capturados.')
714
+ LOGGER.debug('Instagram Playwright API posts extracted=%s limit=%s', len(posts), limit)
715
+ return posts[:max(1, int(limit))]
643
716
  finally:
644
717
  browser.close()
645
718
  except Exception as exc:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.50",
3
+ "version": "0.6.52",
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",