@danhachuel/thunderbolt 0.6.48 → 0.6.50

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)
@@ -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
@@ -131,18 +132,8 @@ class IntegrationResult:
131
132
 
132
133
 
133
134
  def normalize_instagram_bio(value: Any) -> str:
134
- """Keep the profile biography, never Instagram's metrics summary."""
135
+ """Keep the complete public biography exactly as returned by Instagram."""
135
136
  bio = str(value or '').strip()
136
- if not bio:
137
- return ''
138
- metric_words = r'followers|following|seguidores|seguindo|posts|publicações|publications'
139
- metric_hits = re.findall(
140
- rf'(?:\d[\d.,]*\s*(?:{metric_words})\b|\b(?:{metric_words})\s*\d[\d.,]*)',
141
- bio,
142
- flags=re.IGNORECASE,
143
- )
144
- if len(metric_hits) >= 2:
145
- return ''
146
137
  return bio
147
138
 
148
139
 
@@ -213,48 +204,92 @@ def _structured_metric_from_json(document: str, *keys: str) -> int | None:
213
204
  return None
214
205
 
215
206
 
207
+ def _profile_quality(user: Mapping[str, Any]) -> int:
208
+ groups = (
209
+ ('biography', 'bio', 'description'),
210
+ ('edge_follow', 'following', 'following_count', 'followingCount', 'follows'),
211
+ ('edge_followed_by', 'followers', 'follower_count', 'followers_count', 'followerCount'),
212
+ ('edge_owner_to_timeline_media', 'posts', 'post_count', 'media_count'),
213
+ ('username', 'user_name', 'handle'),
214
+ )
215
+ return sum(any(user.get(key) not in (None, '', {}, []) for key in group) for group in groups)
216
+
217
+
218
+ def _merge_profile(base: dict[str, Any] | None, candidate: Mapping[str, Any]) -> dict[str, Any]:
219
+ merged = dict(base or {})
220
+ for key, value in candidate.items():
221
+ if value not in (None, '', [], {}):
222
+ if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
223
+ merged[key] = {**merged[key], **value}
224
+ else:
225
+ merged[key] = value
226
+ return merged
227
+
228
+
229
+ def _extract_profile_user(payload: Any) -> Mapping[str, Any] | None:
230
+ if not isinstance(payload, Mapping):
231
+ return None
232
+ candidates = [
233
+ payload.get('user'),
234
+ (payload.get('data') or {}).get('user') if isinstance(payload.get('data'), Mapping) else None,
235
+ (payload.get('graphql') or {}).get('user') if isinstance(payload.get('graphql'), Mapping) else None,
236
+ (payload.get('data') or {}).get('profile') if isinstance(payload.get('data'), Mapping) else None,
237
+ ]
238
+ return next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
239
+
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}',
246
+ )
247
+ best_user: dict[str, Any] | None = None
248
+ try:
249
+ from playwright.sync_api import sync_playwright
250
+ with sync_playwright() as playwright:
251
+ browser = playwright.chromium.launch(headless=True)
252
+ try:
253
+ page = browser.new_page(
254
+ user_agent=headers['User-Agent'],
255
+ extra_http_headers={'x-ig-app-id': headers['x-ig-app-id'], 'Accept-Language': 'en-US,en;q=0.9'},
256
+ )
257
+ if cookies:
258
+ page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
259
+ for endpoint in endpoints:
260
+ 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)
273
+ finally:
274
+ browser.close()
275
+ except Exception as exc:
276
+ LOGGER.warning('Falha no Playwright do perfil Instagram: %s', exc, exc_info=True)
277
+ _streamlit_message('error', f'Playwright não conseguiu consultar o perfil Instagram: {exc}', 'profile_playwright_error')
278
+ return best_user
279
+
280
+
216
281
  def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
217
282
  _ensure_windows_playwright()
218
283
  cookies = _instagram_cookies()
219
284
  headers = _instagram_headers()
220
285
  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
286
  endpoints = (
225
287
  f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
226
288
  f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
227
289
  )
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)
290
+ if platform.system() == 'Windows':
291
+ LOGGER.debug('Windows detectado: perfil Instagram exclusivamente via Playwright.')
292
+ return _fetch_profile_with_playwright(username, headers, cookies)
258
293
 
259
294
  request_headers = [
260
295
  headers,
@@ -276,10 +311,10 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
276
311
  if best_user:
277
312
  return best_user
278
313
  continue
279
- user = extract_user(payload)
314
+ user = _extract_profile_user(payload)
280
315
  if isinstance(user, dict):
281
- best_user = merge_profile(best_user, user)
282
- if profile_quality(best_user) >= 4:
316
+ best_user = _merge_profile(best_user, user)
317
+ if _profile_quality(best_user) >= 4:
283
318
  return best_user
284
319
 
285
320
  curl = shutil.which('curl')
@@ -298,10 +333,10 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
298
333
  )
299
334
  payload = json.loads(completed.stdout) if completed.returncode == 0 and completed.stdout else None
300
335
  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)
336
+ user = _extract_profile_user(payload)
302
337
  if isinstance(user, dict):
303
- best_user = merge_profile(best_user, user)
304
- if profile_quality(best_user) >= 4:
338
+ best_user = _merge_profile(best_user, user)
339
+ if _profile_quality(best_user) >= 4:
305
340
  return best_user
306
341
  except (OSError, subprocess.SubprocessError, ValueError):
307
342
  LOGGER.warning('Falha no fallback curl do perfil Instagram URL=%s', endpoint, exc_info=True)
@@ -310,39 +345,7 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
310
345
  LOGGER.warning('curl não está disponível no PATH; a tentar Playwright.')
311
346
  _streamlit_message('warning', 'curl não foi encontrado no PATH do Windows; será tentado o fallback Playwright.', 'curl_missing')
312
347
 
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
348
+ return _fetch_profile_with_playwright(username, headers, cookies) or best_user
346
349
 
347
350
 
348
351
  def _country_from_bloks(value: Any) -> str:
@@ -417,7 +420,9 @@ def _country_from_bio_fallback(bio: str) -> str:
417
420
 
418
421
  def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
419
422
  followers = _profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
420
- following = _profile_metric(user, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
423
+ following_node = user.get('edge_follow')
424
+ following = normalize_instagram_metric(following_node) if isinstance(following_node, Mapping) else None
425
+ following = following if following is not None else _profile_metric(user, 'follows', 'following', 'following_count', 'followingCount')
421
426
  followers = followers if followers is not None else _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
422
427
  following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
423
428
  media = ((user.get('edge_owner_to_timeline_media') or {}).get('count') if isinstance(user.get('edge_owner_to_timeline_media'), dict) else None)
@@ -425,7 +430,7 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
425
430
  biography = str(user.get('biography') or user.get('bio') or user.get('description') or '').strip()
426
431
  country = extract_public_instagram_country(user) or _fetch_instagram_about_country(user.get('id') or user.get('pk'))
427
432
  country = country or _country_from_bio_fallback(biography)
428
- LOGGER.debug('Instagram profile extracted bio=%s followers=%s following=%s posts=%s country=%s', bool(biography), followers, following, media, country or '—')
433
+ 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 '—')
429
434
  return {
430
435
  'id': f"instagram_{reference['username']}",
431
436
  **reference,
@@ -604,6 +609,45 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
604
609
  }
605
610
 
606
611
 
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.
614
+ posts: list[dict[str, Any]] = []
615
+ seen: set[str] = set()
616
+ try:
617
+ from playwright.sync_api import sync_playwright
618
+ with sync_playwright() as playwright:
619
+ browser = playwright.chromium.launch(headless=True)
620
+ try:
621
+ page = browser.new_page(
622
+ user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
623
+ extra_http_headers={'x-ig-app-id': '936619743392459', 'Accept-Language': 'en-US,en;q=0.9'},
624
+ )
625
+ if cookies:
626
+ page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
627
+ response = page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
628
+ LOGGER.debug('Instagram Playwright posts URL=%s status=%s', reference['url'], response.status if response else None)
629
+ if response is None or response.status >= 400:
630
+ 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
643
+ finally:
644
+ browser.close()
645
+ except Exception as exc:
646
+ LOGGER.warning('Falha no Playwright dos posts Instagram: %s', exc, exc_info=True)
647
+ _streamlit_message('error', f'Playwright não conseguiu consultar os posts Instagram: {exc}', 'posts_playwright_error')
648
+ return posts
649
+
650
+
607
651
  def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationResult:
608
652
  LOGGER.debug('fetch_public_instagram_posts source=%s limit=%s', source, limit)
609
653
  _ensure_windows_playwright()
@@ -611,6 +655,10 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
611
655
  reference = normalize_instagram_reference(source)
612
656
  except ValueError as exc:
613
657
  return IntegrationResult(False, str(exc), {})
658
+ if platform.system() == 'Windows':
659
+ LOGGER.debug('Windows detectado: posts Instagram exclusivamente via Playwright.')
660
+ posts = _fetch_posts_with_playwright(reference, limit, _instagram_cookies())
661
+ 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
662
  api_user = _fetch_web_profile_user(reference['username'])
615
663
  if api_user:
616
664
  api_posts: list[dict[str, Any]] = []
@@ -647,36 +695,7 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
647
695
  if len(posts) >= max(1, int(limit)):
648
696
  break
649
697
  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')
698
+ posts = _fetch_posts_with_playwright(reference, limit, _instagram_cookies())
680
699
  LOGGER.debug('Instagram posts extracted=%s limit=%s', len(posts), limit)
681
700
  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
701
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.48",
3
+ "version": "0.6.50",
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",