@danhachuel/thunderbolt 0.6.53 → 0.6.54

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.
@@ -25,6 +25,7 @@ except ImportError: # pragma: no cover - Streamlit is a runtime dependency
25
25
 
26
26
  LOGGER = logging.getLogger(__name__)
27
27
  _WINDOWS_PLAYWRIGHT_CHECKED = False
28
+ _INSTALOADER_INSTALL_ATTEMPTED = False
28
29
 
29
30
 
30
31
  ABOUT_ACCOUNT_URL = 'https://i.instagram.com/api/v1/bloks/apps/com.instagram.interactions.about_this_account/'
@@ -131,8 +132,10 @@ def _instagram_playwright_headless() -> bool:
131
132
 
132
133
 
133
134
  def _save_instagram_debug_html(username: str, document: str) -> None:
134
- path = Path(os.getenv('INSTAGRAM_DEBUG_PROFILE_HTML', str(Path(__file__).resolve().parents[1] / 'debug_profile.html')))
135
+ storage_dir = Path(__file__).resolve().parents[1] / 'storage'
136
+ path = Path(os.getenv('INSTAGRAM_DEBUG_PROFILE_HTML', str(storage_dir / 'debug_instagram.html')))
135
137
  try:
138
+ path.parent.mkdir(parents=True, exist_ok=True)
136
139
  path.write_text(document, encoding='utf-8')
137
140
  LOGGER.info('HTML salvo em %s', path)
138
141
  except OSError as exc:
@@ -140,16 +143,38 @@ def _save_instagram_debug_html(username: str, document: str) -> None:
140
143
 
141
144
 
142
145
  def _instagram_instaloader():
143
- """Load the optional anonymous fallback without mutating the user's environment."""
146
+ """Load the anonymous fallback, installing it once when the requirement is missing."""
147
+ global _INSTALOADER_INSTALL_ATTEMPTED
144
148
  try:
145
149
  import instaloader
150
+ return instaloader
146
151
  except ImportError:
147
- LOGGER.warning('Fallback instaloader indisponível; instale com: python -m pip install instaloader')
152
+ if _INSTALOADER_INSTALL_ATTEMPTED:
153
+ LOGGER.warning('Fallback instaloader indisponível; instale com: python -m pip install instaloader')
154
+ return None
155
+ _INSTALOADER_INSTALL_ATTEMPTED = True
156
+ try:
157
+ LOGGER.info('Instaloader ausente; a tentar instalar a dependência automaticamente.')
158
+ completed = subprocess.run(
159
+ [sys.executable, '-m', 'pip', 'install', 'instaloader>=4.14,<5'],
160
+ capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=180, check=False,
161
+ )
162
+ if completed.returncode == 0:
163
+ import importlib
164
+ importlib.invalidate_caches()
165
+ try:
166
+ import instaloader
167
+ return instaloader
168
+ except ImportError:
169
+ LOGGER.warning('Instaloader foi instalado, mas não ficou disponível neste interpretador.')
170
+ LOGGER.warning('Instalação automática do instaloader falhou: %s', (completed.stderr or completed.stdout)[-1000:])
171
+ except (OSError, subprocess.SubprocessError) as exc:
172
+ LOGGER.warning('Não foi possível instalar instaloader automaticamente: %s', exc)
173
+ _streamlit_message('error', 'O fallback instaloader não está disponível. Instale com: python -m pip install instaloader', 'instaloader_missing')
148
174
  return None
149
- return instaloader
150
175
 
151
176
 
152
- def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
177
+ def _fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
153
178
  """Fetch a public profile anonymously through Instaloader when Playwright fails."""
154
179
  instaloader = _instagram_instaloader()
155
180
  if instaloader is None:
@@ -157,6 +182,8 @@ def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
157
182
  try:
158
183
  loader = instaloader.Instaloader(
159
184
  quiet=True,
185
+ request_timeout=30,
186
+ max_connection_attempts=1,
160
187
  download_pictures=False,
161
188
  download_videos=False,
162
189
  download_video_thumbnails=False,
@@ -169,8 +196,8 @@ def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
169
196
  'username': str(getattr(profile, 'username', '') or username),
170
197
  'full_name': str(getattr(profile, 'full_name', '') or ''),
171
198
  'biography': str(getattr(profile, 'biography', '') or ''),
172
- 'follower_count': getattr(profile, 'followers', None),
173
- 'following_count': getattr(profile, 'followees', None),
199
+ 'follower_count': getattr(profile, 'follower_count', getattr(profile, 'followers', None)),
200
+ 'following_count': getattr(profile, 'following_count', getattr(profile, 'followees', None)),
174
201
  'edge_owner_to_timeline_media': {'count': getattr(profile, 'mediacount', None)},
175
202
  'is_private': bool(getattr(profile, 'is_private', False)),
176
203
  '_instagram_source': 'instaloader',
@@ -180,7 +207,7 @@ def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
180
207
  return None
181
208
 
182
209
 
183
- def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, Any]]:
210
+ def _fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, Any]]:
184
211
  """Fetch public posts anonymously through Instaloader when Playwright fails."""
185
212
  instaloader = _instagram_instaloader()
186
213
  if instaloader is None:
@@ -189,6 +216,8 @@ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, An
189
216
  try:
190
217
  loader = instaloader.Instaloader(
191
218
  quiet=True,
219
+ request_timeout=30,
220
+ max_connection_attempts=1,
192
221
  download_pictures=False,
193
222
  download_videos=False,
194
223
  download_video_thumbnails=False,
@@ -199,13 +228,17 @@ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, An
199
228
  shortcode = str(getattr(post, 'shortcode', '') or '')
200
229
  if not shortcode:
201
230
  continue
231
+ image_url = str(getattr(post, 'url', '') or '')
232
+ timestamp = getattr(post, 'date_utc', '') or ''
202
233
  posts.append({
203
234
  'id': str(getattr(post, 'mediaid', '') or shortcode),
204
235
  'shortcode': shortcode,
205
236
  'url': f'https://www.instagram.com/p/{shortcode}/',
206
- 'image_url': str(getattr(post, 'url', '') or ''),
237
+ 'image_url': image_url,
238
+ 'display_url': image_url,
207
239
  'caption': str(getattr(post, 'caption', '') or '').strip(),
208
- 'published_at': getattr(post, 'date_utc', '') or '',
240
+ 'published_at': timestamp,
241
+ 'timestamp': timestamp,
209
242
  'is_video': bool(getattr(post, 'is_video', False)),
210
243
  })
211
244
  if len(posts) >= max(1, int(limit)):
@@ -216,6 +249,14 @@ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, An
216
249
  return posts
217
250
 
218
251
 
252
+ def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
253
+ return _fetch_profile_instaloader(username)
254
+
255
+
256
+ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, Any]]:
257
+ return _fetch_posts_instaloader(username, limit)
258
+
259
+
219
260
  @dataclass
220
261
  class IntegrationResult:
221
262
  ok: bool
@@ -385,12 +426,15 @@ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | N
385
426
  with sync_playwright() as playwright:
386
427
  browser = playwright.chromium.launch(headless=_instagram_playwright_headless())
387
428
  try:
388
- page = browser.new_page(
389
- user_agent=headers['User-Agent'],
390
- extra_http_headers={'x-ig-app-id': headers['x-ig-app-id'], 'Accept-Language': 'en-US,en;q=0.9', 'Accept': 'application/json, text/plain, */*', 'Origin': 'https://www.instagram.com', 'Referer': 'https://www.instagram.com/'},
391
- )
429
+ headers_for_context = {'x-ig-app-id': headers['x-ig-app-id'], 'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7', 'Accept': 'application/json, text/plain, */*', 'Origin': 'https://www.instagram.com', 'Referer': 'https://www.instagram.com/'}
430
+ state_path = Path(__file__).resolve().parents[1] / 'storage' / 'instagram_storage_state.json'
431
+ context_kwargs = {'user_agent': headers['User-Agent'], 'extra_http_headers': headers_for_context}
432
+ if state_path.is_file():
433
+ context_kwargs['storage_state'] = str(state_path)
434
+ context = browser.new_context(**context_kwargs)
392
435
  if cookies:
393
- page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
436
+ context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
437
+ page = context.new_page()
394
438
 
395
439
  def capture_profile_response(response: Any) -> None:
396
440
  nonlocal captured_user
@@ -407,12 +451,14 @@ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | N
407
451
 
408
452
  page.on('response', capture_profile_response)
409
453
  try:
410
- page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=10000)
411
- LOGGER.debug('Instagram Playwright página inicial carregada para cookies.')
454
+ page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=30000)
455
+ state_path.parent.mkdir(parents=True, exist_ok=True)
456
+ context.storage_state(path=str(state_path))
457
+ LOGGER.debug('Instagram Playwright página inicial carregada; storage_state salvo em %s.', state_path)
412
458
  except Exception as exc:
413
459
  LOGGER.debug('Instagram Playwright não carregou a página inicial: %s', exc)
414
460
  url = f'https://www.instagram.com/{username}/'
415
- response = page.goto(url, wait_until='domcontentloaded', timeout=10000)
461
+ response = page.goto(url, wait_until='domcontentloaded', timeout=30000)
416
462
  LOGGER.debug('Instagram Playwright profile URL=%s status=%s', url, response.status if response else None)
417
463
  if response is None or response.status >= 400:
418
464
  LOGGER.warning('Instagram Playwright perfil HTTP status=%s URL=%s', response.status if response else None, url)
@@ -421,6 +467,7 @@ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | N
421
467
  _save_instagram_debug_html(username, page.content())
422
468
  except Exception as exc:
423
469
  LOGGER.debug('Não foi possível guardar HTML de diagnóstico: %s', exc)
470
+ page.wait_for_timeout(5000)
424
471
  try:
425
472
  page.wait_for_load_state('networkidle', timeout=10000)
426
473
  except (PlaywrightTimeoutError, TimeoutError):
@@ -445,7 +492,12 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
445
492
  if playwright_user:
446
493
  return playwright_user
447
494
  LOGGER.warning('Fallback para instaloader activado no perfil @%s.', username)
448
- return fetch_profile_instaloader(username)
495
+ fallback_user = fetch_profile_instaloader(username)
496
+ if fallback_user:
497
+ _streamlit_message('info', 'Dados obtidos via fallback (instaloader). O país pode não estar disponível.', 'profile_instaloader_fallback')
498
+ else:
499
+ _streamlit_message('error', 'Não foi possível obter os dados do Instagram. Verifique sua conexão ou tente novamente mais tarde.', 'profile_instagram_failed')
500
+ return fallback_user
449
501
 
450
502
  endpoints = (
451
503
  f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
@@ -785,12 +837,15 @@ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cooki
785
837
  with sync_playwright() as playwright:
786
838
  browser = playwright.chromium.launch(headless=_instagram_playwright_headless())
787
839
  try:
788
- page = browser.new_page(
789
- user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
790
- extra_http_headers={'x-ig-app-id': '936619743392459', 'Accept-Language': 'en-US,en;q=0.9', 'Accept': 'application/json, text/plain, */*', 'Origin': 'https://www.instagram.com', 'Referer': 'https://www.instagram.com/'},
791
- )
840
+ state_path = Path(__file__).resolve().parents[1] / 'storage' / 'instagram_storage_state.json'
841
+ headers_for_context = {'x-ig-app-id': '936619743392459', 'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7', 'Accept': 'application/json, text/plain, */*', 'Origin': 'https://www.instagram.com', 'Referer': 'https://www.instagram.com/'}
842
+ context_kwargs = {'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36', 'extra_http_headers': headers_for_context}
843
+ if state_path.is_file():
844
+ context_kwargs['storage_state'] = str(state_path)
845
+ context = browser.new_context(**context_kwargs)
792
846
  if cookies:
793
- page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
847
+ context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
848
+ page = context.new_page()
794
849
 
795
850
  def capture_profile_posts(response: Any) -> None:
796
851
  if '/api/v1/users/web_profile_info/' not in response.url:
@@ -813,13 +868,16 @@ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cooki
813
868
 
814
869
  page.on('response', capture_profile_posts)
815
870
  try:
816
- page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=10000)
871
+ page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=30000)
872
+ state_path.parent.mkdir(parents=True, exist_ok=True)
873
+ context.storage_state(path=str(state_path))
817
874
  except Exception as exc:
818
875
  LOGGER.debug('Instagram Playwright não carregou a página inicial para posts: %s', exc)
819
- response = page.goto(reference['url'], wait_until='domcontentloaded', timeout=10000)
876
+ response = page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
820
877
  LOGGER.debug('Instagram Playwright posts URL=%s status=%s', reference['url'], response.status if response else None)
821
878
  if response is None or response.status >= 400:
822
879
  return []
880
+ page.wait_for_timeout(5000)
823
881
  try:
824
882
  page.wait_for_load_state('networkidle', timeout=10000)
825
883
  except (PlaywrightTimeoutError, TimeoutError):
@@ -847,6 +905,10 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
847
905
  if not posts:
848
906
  LOGGER.warning('Fallback para instaloader activado nos posts de @%s.', reference['username'])
849
907
  posts = fetch_posts_instaloader(reference['username'], limit)
908
+ if posts:
909
+ _streamlit_message('info', 'Dados obtidos via fallback (instaloader). O país pode não estar disponível.', 'posts_instaloader_fallback')
910
+ else:
911
+ _streamlit_message('error', 'Não foi possível obter os dados do Instagram. Verifique sua conexão ou tente novamente mais tarde.', 'posts_instagram_failed')
850
912
  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))]})
851
913
  api_user = _fetch_web_profile_user(reference['username'])
852
914
  if api_user:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.53",
3
+ "version": "0.6.54",
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",