@danhachuel/thunderbolt 0.6.48 → 0.6.49

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.
@@ -4,6 +4,7 @@ import re
4
4
  import json
5
5
  import logging
6
6
  import os
7
+ import platform
7
8
  import sys
8
9
  import shutil
9
10
  import subprocess
@@ -213,48 +214,91 @@ def _structured_metric_from_json(document: str, *keys: str) -> int | None:
213
214
  return None
214
215
 
215
216
 
217
+ def _profile_quality(user: Mapping[str, Any]) -> int:
218
+ groups = (
219
+ ('biography', 'bio', 'description'),
220
+ ('edge_follow', 'following', 'following_count', 'followingCount', 'follows'),
221
+ ('edge_followed_by', 'followers', 'follower_count', 'followers_count', 'followerCount'),
222
+ ('edge_owner_to_timeline_media', 'posts', 'post_count', 'media_count'),
223
+ ('username', 'user_name', 'handle'),
224
+ )
225
+ return sum(any(user.get(key) not in (None, '', {}, []) for key in group) for group in groups)
226
+
227
+
228
+ def _merge_profile(base: dict[str, Any] | None, candidate: Mapping[str, Any]) -> dict[str, Any]:
229
+ merged = dict(base or {})
230
+ for key, value in candidate.items():
231
+ if value not in (None, '', [], {}):
232
+ if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
233
+ merged[key] = {**merged[key], **value}
234
+ else:
235
+ merged[key] = value
236
+ return merged
237
+
238
+
239
+ def _extract_profile_user(payload: Any) -> Mapping[str, Any] | None:
240
+ if not isinstance(payload, Mapping):
241
+ return None
242
+ candidates = [
243
+ payload.get('user'),
244
+ (payload.get('data') or {}).get('user') if isinstance(payload.get('data'), Mapping) else None,
245
+ (payload.get('graphql') or {}).get('user') if isinstance(payload.get('graphql'), Mapping) else None,
246
+ (payload.get('data') or {}).get('profile') if isinstance(payload.get('data'), Mapping) else None,
247
+ ]
248
+ return next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
249
+
250
+
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}',
255
+ )
256
+ best_user: dict[str, Any] | None = None
257
+ try:
258
+ from playwright.sync_api import sync_playwright
259
+ with sync_playwright() as playwright:
260
+ browser = playwright.chromium.launch(headless=True)
261
+ try:
262
+ page = browser.new_page(
263
+ user_agent=headers['User-Agent'],
264
+ extra_http_headers={'x-ig-app-id': headers['x-ig-app-id'], 'Accept-Language': 'en-US,en;q=0.9'},
265
+ )
266
+ if cookies:
267
+ 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)
282
+ finally:
283
+ browser.close()
284
+ except Exception as exc:
285
+ LOGGER.warning('Falha no Playwright do perfil Instagram: %s', exc, exc_info=True)
286
+ _streamlit_message('error', f'Playwright não conseguiu consultar o perfil Instagram: {exc}', 'profile_playwright_error')
287
+ return best_user
288
+
289
+
216
290
  def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
217
291
  _ensure_windows_playwright()
218
292
  cookies = _instagram_cookies()
219
293
  headers = _instagram_headers()
220
294
  headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36'
221
- # The www host is the current public web endpoint. The legacy i host is
222
- # retained as a fallback because Instagram rate-limits the two hosts
223
- # independently and their availability can vary by region.
224
295
  endpoints = (
225
296
  f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
226
297
  f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
227
298
  )
228
- def profile_quality(user: Mapping[str, Any]) -> int:
229
- groups = (
230
- ('biography', 'bio', 'description'),
231
- ('edge_follow', 'following', 'following_count', 'followingCount', 'follows'),
232
- ('edge_followed_by', 'followers', 'follower_count', 'followers_count', 'followerCount'),
233
- ('edge_owner_to_timeline_media', 'posts', 'post_count', 'media_count'),
234
- ('username', 'user_name', 'handle'),
235
- )
236
- return sum(any(user.get(key) not in (None, '', {}, []) for key in group) for group in groups)
237
-
238
- def merge_profile(base: dict[str, Any] | None, candidate: Mapping[str, Any]) -> dict[str, Any]:
239
- merged = dict(base or {})
240
- for key, value in candidate.items():
241
- if value not in (None, '', [], {}):
242
- if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
243
- merged[key] = {**merged[key], **value}
244
- else:
245
- merged[key] = value
246
- return merged
247
-
248
- def extract_user(payload: Any) -> Mapping[str, Any] | None:
249
- if not isinstance(payload, Mapping):
250
- return None
251
- candidates = [
252
- payload.get('user'),
253
- (payload.get('data') or {}).get('user') if isinstance(payload.get('data'), Mapping) else None,
254
- (payload.get('graphql') or {}).get('user') if isinstance(payload.get('graphql'), Mapping) else None,
255
- (payload.get('data') or {}).get('profile') if isinstance(payload.get('data'), Mapping) else None,
256
- ]
257
- return next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
299
+ if platform.system() == 'Windows':
300
+ LOGGER.debug('Windows detectado: perfil Instagram exclusivamente via Playwright.')
301
+ return _fetch_profile_with_playwright(username, headers, cookies)
258
302
 
259
303
  request_headers = [
260
304
  headers,
@@ -276,10 +320,10 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
276
320
  if best_user:
277
321
  return best_user
278
322
  continue
279
- user = extract_user(payload)
323
+ user = _extract_profile_user(payload)
280
324
  if isinstance(user, dict):
281
- best_user = merge_profile(best_user, user)
282
- if profile_quality(best_user) >= 4:
325
+ best_user = _merge_profile(best_user, user)
326
+ if _profile_quality(best_user) >= 4:
283
327
  return best_user
284
328
 
285
329
  curl = shutil.which('curl')
@@ -298,10 +342,10 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
298
342
  )
299
343
  payload = json.loads(completed.stdout) if completed.returncode == 0 and completed.stdout else None
300
344
  LOGGER.debug('Instagram curl URL=%s returncode=%s stdout_bytes=%s stderr=%s', endpoint, completed.returncode, len(completed.stdout or ''), (completed.stderr or '').strip()[-500:])
301
- user = extract_user(payload)
345
+ user = _extract_profile_user(payload)
302
346
  if isinstance(user, dict):
303
- best_user = merge_profile(best_user, user)
304
- if profile_quality(best_user) >= 4:
347
+ best_user = _merge_profile(best_user, user)
348
+ if _profile_quality(best_user) >= 4:
305
349
  return best_user
306
350
  except (OSError, subprocess.SubprocessError, ValueError):
307
351
  LOGGER.warning('Falha no fallback curl do perfil Instagram URL=%s', endpoint, exc_info=True)
@@ -310,39 +354,7 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
310
354
  LOGGER.warning('curl não está disponível no PATH; a tentar Playwright.')
311
355
  _streamlit_message('warning', 'curl não foi encontrado no PATH do Windows; será tentado o fallback Playwright.', 'curl_missing')
312
356
 
313
- # Em algumas instalações Windows o Instagram devolve a página HTML aos
314
- # clientes HTTP, embora entregue o JSON completo a um navegador Chromium.
315
- # O fallback é opcional: a aplicação continua funcional sem Playwright.
316
- try:
317
- from playwright.sync_api import sync_playwright
318
- with sync_playwright() as playwright:
319
- browser = playwright.chromium.launch(headless=True)
320
- try:
321
- page = browser.new_page(
322
- user_agent=headers['User-Agent'],
323
- extra_http_headers={'x-ig-app-id': headers['x-ig-app-id'], 'Accept-Language': 'en-US,en;q=0.9'},
324
- )
325
- if cookies:
326
- page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
327
- for endpoint in endpoints:
328
- try:
329
- response = page.goto(endpoint, wait_until='domcontentloaded', timeout=30000)
330
- if response is None or response.status >= 400:
331
- continue
332
- payload = json.loads(page.locator('body').inner_text(timeout=5000))
333
- user = extract_user(payload)
334
- if isinstance(user, dict):
335
- best_user = merge_profile(best_user, user)
336
- if profile_quality(best_user) >= 4:
337
- return best_user
338
- except (ValueError, TimeoutError):
339
- continue
340
- finally:
341
- browser.close()
342
- except Exception as exc:
343
- LOGGER.warning('Falha no fallback Playwright do perfil Instagram: %s', exc, exc_info=True)
344
- _streamlit_message('error', f'Fallback Playwright do perfil Instagram falhou: {exc}. Execute: python -m playwright install chromium', 'profile_playwright_error')
345
- return best_user
357
+ return _fetch_profile_with_playwright(username, headers, cookies) or best_user
346
358
 
347
359
 
348
360
  def _country_from_bloks(value: Any) -> str:
@@ -604,6 +616,42 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
604
616
  }
605
617
 
606
618
 
619
+ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cookies: Mapping[str, str]) -> list[dict[str, Any]]:
620
+ posts: list[dict[str, Any]] = []
621
+ seen: set[str] = set()
622
+ try:
623
+ from playwright.sync_api import sync_playwright
624
+ with sync_playwright() as playwright:
625
+ browser = playwright.chromium.launch(headless=True)
626
+ try:
627
+ page = browser.new_page(
628
+ user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
629
+ extra_http_headers={'x-ig-app-id': '936619743392459', 'Accept-Language': 'en-US,en;q=0.9'},
630
+ )
631
+ if cookies:
632
+ page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
633
+ response = page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
634
+ LOGGER.debug('Instagram Playwright posts URL=%s status=%s', reference['url'], response.status if response else None)
635
+ if response is None or response.status >= 400:
636
+ return []
637
+ browser_document = page.content()
638
+ for document in _embedded_json_documents(browser_document):
639
+ for node in _walk_json(document):
640
+ post = _post_from_node(node)
641
+ if not post or post['id'] in seen:
642
+ continue
643
+ seen.add(post['id'])
644
+ posts.append(post)
645
+ if len(posts) >= max(1, int(limit)):
646
+ return posts
647
+ finally:
648
+ browser.close()
649
+ except Exception as exc:
650
+ LOGGER.warning('Falha no Playwright dos posts Instagram: %s', exc, exc_info=True)
651
+ _streamlit_message('error', f'Playwright não conseguiu consultar os posts Instagram: {exc}', 'posts_playwright_error')
652
+ return posts
653
+
654
+
607
655
  def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationResult:
608
656
  LOGGER.debug('fetch_public_instagram_posts source=%s limit=%s', source, limit)
609
657
  _ensure_windows_playwright()
@@ -611,6 +659,10 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
611
659
  reference = normalize_instagram_reference(source)
612
660
  except ValueError as exc:
613
661
  return IntegrationResult(False, str(exc), {})
662
+ if platform.system() == 'Windows':
663
+ LOGGER.debug('Windows detectado: posts Instagram exclusivamente via Playwright.')
664
+ posts = _fetch_posts_with_playwright(reference, limit, _instagram_cookies())
665
+ 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))]})
614
666
  api_user = _fetch_web_profile_user(reference['username'])
615
667
  if api_user:
616
668
  api_posts: list[dict[str, Any]] = []
@@ -647,36 +699,7 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
647
699
  if len(posts) >= max(1, int(limit)):
648
700
  break
649
701
  if not posts:
650
- try:
651
- from playwright.sync_api import sync_playwright
652
- with sync_playwright() as playwright:
653
- browser = playwright.chromium.launch(headless=True)
654
- try:
655
- page = browser.new_page(
656
- user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
657
- extra_http_headers={'x-ig-app-id': '936619743392459', 'Accept-Language': 'en-US,en;q=0.9'},
658
- )
659
- cookies = _instagram_cookies()
660
- if cookies:
661
- page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
662
- page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
663
- browser_document = page.content()
664
- for document in _embedded_json_documents(browser_document):
665
- for node in _walk_json(document):
666
- post = _post_from_node(node)
667
- if not post or post['id'] in seen:
668
- continue
669
- seen.add(post['id'])
670
- posts.append(post)
671
- if len(posts) >= max(1, int(limit)):
672
- break
673
- if len(posts) >= max(1, int(limit)):
674
- break
675
- finally:
676
- browser.close()
677
- except Exception as exc:
678
- LOGGER.warning('Falha no fallback Playwright dos posts Instagram: %s', exc, exc_info=True)
679
- _streamlit_message('error', f'Fallback Playwright dos posts Instagram falhou: {exc}. Execute: python -m playwright install chromium', 'posts_playwright_error')
702
+ posts = _fetch_posts_with_playwright(reference, limit, _instagram_cookies())
680
703
  LOGGER.debug('Instagram posts extracted=%s limit=%s', len(posts), limit)
681
704
  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))]})
682
705
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.48",
3
+ "version": "0.6.49",
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",