@danhachuel/thunderbolt 0.6.43 → 0.6.44

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.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
+ import logging
4
5
  import re
5
6
  import uuid
6
7
  import zipfile
@@ -20,6 +21,9 @@ from integrations.instagram_public import fetch_public_instagram_posts, fetch_pu
20
21
  from integrations.meta_social import test_facebook_pages_api_card, test_instagram_api_card
21
22
 
22
23
 
24
+ LOGGER = logging.getLogger(__name__)
25
+
26
+
23
27
  def _clean(value: Any) -> str:
24
28
  return str(value or "").strip()
25
29
 
@@ -190,8 +194,8 @@ def _instagram_posts_archive(posts: list[dict[str, Any]]) -> bytes:
190
194
  elif "webp" in content_type:
191
195
  extension = ".webp"
192
196
  bundle.writestr(f"post-{index:02d}{extension}", response.content)
193
- except requests.RequestException:
194
- pass
197
+ except requests.RequestException as exc:
198
+ LOGGER.warning("Não foi possível descarregar a imagem do post Instagram %s: %s", url, exc)
195
199
  manifest.append(f"{index}. {_clean(post.get('url'))}\n{_clean(post.get('caption'))}\n")
196
200
  bundle.writestr("posts.txt", "\n".join(manifest))
197
201
  return archive.getvalue()
@@ -212,6 +216,8 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
212
216
  if load_clicked or refresh_clicked:
213
217
  target = max(10, len(posts)) if refresh_clicked else 10
214
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)})
215
221
  if posts_ok:
216
222
  posts = loaded_posts
217
223
  _persist_instagram_posts(profile, posts)
@@ -386,7 +392,8 @@ def _characters(settings: Mapping[str, Any]) -> tuple[list[dict[str, Any]], Any
386
392
  repository = get_repository(settings)
387
393
  items = [item for item in repository.list_influencers() if _clean(item.get("id")) != STANDALONE_CONTENT_INFLUENCER_ID]
388
394
  return items, repository
389
- except (InfluencerBackendError, Exception):
395
+ except Exception as exc:
396
+ LOGGER.warning("Não foi possível carregar as personagens: %s", exc, exc_info=True)
390
397
  return [], None
391
398
 
392
399
 
@@ -498,19 +505,39 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
498
505
  refresh_col, edit_col = st.columns(2)
499
506
  with refresh_col:
500
507
  if st.button("↻", help="Actualizar posts, seguidores e seguindo", key=f"refresh_instagram_{profile_id}"):
501
- refreshed_ok, refreshed_message, refreshed = _refresh_instagram_profile(profile)
508
+ try:
509
+ refreshed_ok, refreshed_message, refreshed = _refresh_instagram_profile(profile)
510
+ except Exception as exc:
511
+ st.error(f"Falha ao actualizar o perfil Instagram: {exc}")
512
+ refreshed_ok, refreshed_message, refreshed = False, str(exc), {}
502
513
  if refreshed_ok:
503
514
  current_posts = _stored_instagram_posts(profile)
504
- posts_ok, _, refreshed_posts = _load_instagram_posts(refreshed, limit=max(10, len(current_posts)))
515
+ try:
516
+ posts_ok, posts_message, refreshed_posts = _load_instagram_posts(refreshed, limit=max(10, len(current_posts)))
517
+ except Exception as exc:
518
+ posts_ok, posts_message, refreshed_posts = False, str(exc), []
505
519
  if posts_ok:
506
520
  _persist_instagram_posts(profile, refreshed_posts)
507
- updated = update_channel(profile_id, {**refreshed, "id": profile_id, "country": _profile_country(refreshed) or _profile_country(profile), "language": profile.get("language", ""), "character_id": profile.get("character_id", "")})
521
+ else:
522
+ st.warning(f"Perfil actualizado, mas os posts não foram actualizados: {posts_message}")
523
+ # Debug temporário removido após validação; manter para reactivar se necessário:
524
+ # st.write({"profile_ok": refreshed_ok, "profile_message": refreshed_message, "profile_data": refreshed})
525
+ # st.write({"posts_ok": posts_ok, "posts_message": posts_message, "posts_count": len(refreshed_posts)})
526
+ try:
527
+ updated = update_channel(profile_id, {**refreshed, "id": profile_id, "country": _profile_country(refreshed) or _profile_country(profile), "language": profile.get("language", ""), "character_id": profile.get("character_id", "")})
528
+ except (OSError, PermissionError) as exc:
529
+ st.error(f"Não foi possível guardar os dados no Windows: {exc}")
530
+ return
531
+ except Exception as exc:
532
+ st.error(f"Falha ao guardar os dados da conta Instagram: {exc}")
533
+ return
508
534
  if updated is None:
509
- st.error("A conta Instagram já não existe no armazenamento local.")
535
+ st.error("A conta Instagram já não existe no armazenamento local ou não foi possível escrever o ficheiro.")
510
536
  return
511
537
  st.success("Métricas Instagram actualizadas.")
512
538
  st.rerun()
513
- st.warning(refreshed_message)
539
+ else:
540
+ st.error(f"Não foi possível actualizar o perfil Instagram: {refreshed_message}")
514
541
  with edit_col:
515
542
  if st.button("Editar", key=f"edit_instagram_button_{profile_id}", use_container_width=True):
516
543
  for field in ("name", "handle", "bio", "country", "language", "posts", "followers", "following"):
@@ -2,22 +2,115 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
  import json
5
+ import logging
5
6
  import os
7
+ import sys
6
8
  import shutil
7
9
  import subprocess
8
10
  from dataclasses import dataclass
9
11
  from datetime import datetime, timezone
10
12
  from html import unescape
13
+ from pathlib import Path
11
14
  from typing import Any, Mapping
12
15
  from urllib.parse import urlparse
13
16
 
14
17
  import requests
15
18
 
19
+ try:
20
+ import streamlit as st
21
+ except ImportError: # pragma: no cover - Streamlit is a runtime dependency
22
+ st = None
23
+
24
+
25
+ LOGGER = logging.getLogger(__name__)
26
+ _WINDOWS_PLAYWRIGHT_CHECKED = False
27
+ _WINDOWS_COOKIE_WARNING_SHOWN = False
28
+
16
29
 
17
30
  ABOUT_ACCOUNT_URL = 'https://i.instagram.com/api/v1/bloks/apps/com.instagram.interactions.about_this_account/'
18
31
  ABOUT_ACCOUNT_BLOKS_VERSION = '8ca96ca267e30c02cf90888d91eeff09627f0e3fd2bd9df472278c9a6c022cbb'
19
32
 
20
33
 
34
+ def _load_dotenv_compat() -> None:
35
+ """Load .env when python-dotenv exists, otherwise parse simple KEY=VALUE lines."""
36
+ try:
37
+ from dotenv import load_dotenv
38
+ load_dotenv()
39
+ return
40
+ except ImportError:
41
+ LOGGER.debug('python-dotenv não está instalado; será usado o parser .env compatível.')
42
+ env_path = Path(__file__).resolve().parents[1] / '.env'
43
+ if not env_path.is_file():
44
+ return
45
+ try:
46
+ for raw_line in env_path.read_text(encoding='utf-8').splitlines():
47
+ line = raw_line.strip()
48
+ if not line or line.startswith('#') or '=' not in line:
49
+ continue
50
+ key, value = line.split('=', 1)
51
+ value = value.strip().strip('"').strip("'")
52
+ os.environ.setdefault(key.strip(), value)
53
+ except OSError as exc:
54
+ LOGGER.warning('Não foi possível ler %s: %s', env_path, exc)
55
+
56
+
57
+ def _streamlit_message(level: str, message: str, key: str) -> None:
58
+ """Show a runtime diagnostic once when Streamlit is available."""
59
+ if st is None:
60
+ return
61
+ try:
62
+ shown_key = f'_instagram_debug_{key}'
63
+ if st.session_state.get(shown_key):
64
+ return
65
+ st.session_state[shown_key] = True
66
+ getattr(st, level)(message)
67
+ except Exception as exc: # Streamlit may be imported outside an active app context.
68
+ LOGGER.debug('Não foi possível mostrar diagnóstico Streamlit: %s', exc)
69
+
70
+
71
+ def _ensure_windows_playwright() -> None:
72
+ """Ensure Chromium exists on Windows before using the Playwright fallback."""
73
+ global _WINDOWS_PLAYWRIGHT_CHECKED, _WINDOWS_COOKIE_WARNING_SHOWN
74
+ if os.name != 'nt' or _WINDOWS_PLAYWRIGHT_CHECKED:
75
+ return
76
+ _WINDOWS_PLAYWRIGHT_CHECKED = True
77
+ _load_dotenv_compat()
78
+ cookie_names = ('INSTAGRAM_SESSIONID', 'IG_SESSIONID')
79
+ if not any(os.getenv(name, '').strip() for name in cookie_names) and not _WINDOWS_COOKIE_WARNING_SHOWN:
80
+ _WINDOWS_COOKIE_WARNING_SHOWN = True
81
+ message = ('Windows: cookies Instagram não configurados. O país da conta pode ficar indisponível; '
82
+ 'configure INSTAGRAM_SESSIONID (e, se necessário, INSTAGRAM_CSRFTOKEN, '
83
+ 'INSTAGRAM_DS_USER_ID, INSTAGRAM_MID e INSTAGRAM_IG_DID) no ficheiro .env.')
84
+ LOGGER.warning(message)
85
+ _streamlit_message('warning', message, 'windows_cookie_warning')
86
+ try:
87
+ completed = subprocess.run(
88
+ [sys.executable, '-m', 'playwright', 'install', 'chromium'],
89
+ capture_output=True,
90
+ text=True,
91
+ encoding='utf-8',
92
+ errors='replace',
93
+ timeout=180,
94
+ check=False,
95
+ )
96
+ if completed.returncode != 0:
97
+ detail = (completed.stderr or completed.stdout or 'sem detalhes').strip()[-1000:]
98
+ message = f'Não foi possível instalar o Chromium do Playwright no Windows: {detail}'
99
+ LOGGER.warning(message)
100
+ _streamlit_message('error', message + ' Execute: python -m playwright install chromium', 'playwright_install_error')
101
+ else:
102
+ LOGGER.info('Chromium do Playwright verificado/instalado no Windows.')
103
+ except (OSError, subprocess.SubprocessError) as exc:
104
+ message = f'Falha ao executar a instalação do Chromium no Windows: {exc}'
105
+ LOGGER.warning(message)
106
+ _streamlit_message('error', message + ' Execute: python -m playwright install chromium', 'playwright_install_exception')
107
+
108
+
109
+ _load_dotenv_compat()
110
+ if os.name == 'nt':
111
+ _ensure_windows_playwright()
112
+
113
+
21
114
  def _instagram_cookies() -> dict[str, str]:
22
115
  cookies = {}
23
116
  sessionid = os.getenv('INSTAGRAM_SESSIONID') or os.getenv('IG_SESSIONID')
@@ -130,6 +223,7 @@ def _structured_metric_from_json(document: str, *keys: str) -> int | None:
130
223
 
131
224
 
132
225
  def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
226
+ _ensure_windows_playwright()
133
227
  headers = _instagram_headers()
134
228
  headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36'
135
229
  # The www host is the current public web endpoint. The legacy i host is
@@ -180,10 +274,13 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
180
274
  for endpoint in endpoints:
181
275
  try:
182
276
  response = requests.get(endpoint, headers=request_header, timeout=15)
277
+ LOGGER.debug('Instagram profile URL=%s status=%s content_type=%s', endpoint, response.status_code, response.headers.get('content-type', ''))
183
278
  if response.status_code >= 400:
184
279
  continue
185
280
  payload = response.json()
281
+ LOGGER.debug('Instagram profile URL=%s response=JSON', endpoint)
186
282
  except (requests.RequestException, ValueError, AttributeError, StopIteration):
283
+ LOGGER.warning('Falha na requisição JSON do perfil Instagram URL=%s', endpoint, exc_info=True)
187
284
  if best_user:
188
285
  return best_user
189
286
  continue
@@ -195,6 +292,7 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
195
292
 
196
293
  curl = shutil.which('curl')
197
294
  if curl:
295
+ LOGGER.debug('Fallback curl disponível em %s', curl)
198
296
  for endpoint in endpoints:
199
297
  try:
200
298
  completed = subprocess.run(
@@ -207,13 +305,18 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
207
305
  check=False,
208
306
  )
209
307
  payload = json.loads(completed.stdout) if completed.returncode == 0 and completed.stdout else None
308
+ 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:])
210
309
  user = extract_user(payload)
211
310
  if isinstance(user, dict):
212
311
  best_user = merge_profile(best_user, user)
213
312
  if profile_quality(best_user) >= 4:
214
313
  return best_user
215
314
  except (OSError, subprocess.SubprocessError, ValueError):
315
+ LOGGER.warning('Falha no fallback curl do perfil Instagram URL=%s', endpoint, exc_info=True)
216
316
  continue
317
+ else:
318
+ LOGGER.warning('curl não está disponível no PATH; a tentar Playwright.')
319
+ _streamlit_message('warning', 'curl não foi encontrado no PATH do Windows; será tentado o fallback Playwright.', 'curl_missing')
217
320
 
218
321
  # Em algumas instalações Windows o Instagram devolve a página HTML aos
219
322
  # clientes HTTP, embora entregue o JSON completo a um navegador Chromium.
@@ -242,8 +345,9 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
242
345
  continue
243
346
  finally:
244
347
  browser.close()
245
- except (ImportError, OSError, RuntimeError):
246
- pass
348
+ except Exception as exc:
349
+ LOGGER.warning('Falha no fallback Playwright do perfil Instagram: %s', exc, exc_info=True)
350
+ _streamlit_message('error', f'Fallback Playwright do perfil Instagram falhou: {exc}. Execute: python -m playwright install chromium', 'profile_playwright_error')
247
351
  return best_user
248
352
 
249
353
 
@@ -294,13 +398,29 @@ def _fetch_instagram_about_country(user_id: Any) -> str:
294
398
  }
295
399
  try:
296
400
  response = requests.post(ABOUT_ACCOUNT_URL, data=payload, headers=_instagram_headers(), cookies=cookies, timeout=20)
401
+ LOGGER.debug('Instagram about-account URL=%s status=%s content_type=%s', ABOUT_ACCOUNT_URL, response.status_code, response.headers.get('content-type', ''))
297
402
  if response.status_code >= 400:
298
403
  return ''
299
- return _country_from_bloks(response.json())
300
- except (requests.RequestException, ValueError, TypeError):
404
+ country = _country_from_bloks(response.json())
405
+ LOGGER.debug('Instagram about-account country_found=%s', bool(country))
406
+ return country
407
+ except (requests.RequestException, ValueError, TypeError) as exc:
408
+ LOGGER.warning('Falha ao consultar país público do Instagram: %s', exc, exc_info=True)
301
409
  return ''
302
410
 
303
411
 
412
+ def _country_from_bio_fallback(bio: str) -> str:
413
+ patterns = (
414
+ r'\b(?:from|based in|located in|living in)\s+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .-]{1,60})',
415
+ r'\b(?:de|baseado em|morando em|localizado em)\s+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .-]{1,60})',
416
+ )
417
+ for pattern in patterns:
418
+ match = re.search(pattern, bio or '', flags=re.IGNORECASE)
419
+ if match:
420
+ return match.group(1).strip(' .,!;:')
421
+ return ''
422
+
423
+
304
424
  def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
305
425
  followers = _profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
306
426
  following = _profile_metric(user, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
@@ -310,6 +430,8 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
310
430
  media = media if media is not None else _profile_metric(user, 'posts', 'post_count', 'media_count')
311
431
  biography = str(user.get('biography') or user.get('bio') or user.get('description') or '').strip()
312
432
  country = extract_public_instagram_country(user) or _fetch_instagram_about_country(user.get('id') or user.get('pk'))
433
+ country = country or _country_from_bio_fallback(biography)
434
+ LOGGER.debug('Instagram profile extracted bio=%s followers=%s following=%s posts=%s country=%s', bool(biography), followers, following, media, country or '—')
313
435
  return {
314
436
  'id': f"instagram_{reference['username']}",
315
437
  **reference,
@@ -387,6 +509,7 @@ def _profile_metric(user: Mapping[str, Any], *keys: str) -> int | None:
387
509
 
388
510
 
389
511
  def fetch_public_instagram_profile(source: str) -> IntegrationResult:
512
+ LOGGER.debug('fetch_public_instagram_profile source=%s', source)
390
513
  try:
391
514
  reference = normalize_instagram_reference(source)
392
515
  except ValueError as exc:
@@ -397,7 +520,9 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
397
520
  try:
398
521
  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)
399
522
  except requests.RequestException as exc:
523
+ LOGGER.warning('Falha HTTP no perfil Instagram URL=%s: %s', reference['url'], exc, exc_info=True)
400
524
  return IntegrationResult(False, f'Não foi possível consultar o perfil público do Instagram: {exc}', reference)
525
+ LOGGER.debug('Instagram HTML profile URL=%s status=%s content_type=%s response=HTML', reference['url'], response.status_code, response.headers.get('content-type', ''))
401
526
  if response.status_code >= 400:
402
527
  return IntegrationResult(False, f'O Instagram devolveu HTTP {response.status_code}. Confirme o @handle ou use o cadastro manual.', reference | {'status_code': response.status_code})
403
528
  title = _meta(response.text, 'og:title') or reference['username']
@@ -422,6 +547,8 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
422
547
  embedded_country = extract_public_instagram_country(document)
423
548
  if embedded_country:
424
549
  break
550
+ embedded_country = embedded_country or _country_from_bio_fallback(description)
551
+ LOGGER.debug('Instagram HTML profile extracted bio=%s followers=%s following=%s posts=%s country=%s', bool(description), followers, following, posts, embedded_country or '—')
425
552
  data = {'id': f"instagram_{reference['username']}", **reference, 'name': title.split('(')[0].strip() or reference['username'], 'bio': normalize_instagram_bio(description), 'bio_raw': description, 'country': embedded_country, 'avatar_url': avatar_url, 'subscriber_count': followers, 'following_count': following, 'post_count': posts, 'public_lookup': True, 'metrics_source': 'instagram_public_page', 'last_public_lookup_at': datetime.now(timezone.utc).isoformat()}
426
553
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
427
554
 
@@ -484,6 +611,8 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
484
611
 
485
612
 
486
613
  def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationResult:
614
+ LOGGER.debug('fetch_public_instagram_posts source=%s limit=%s', source, limit)
615
+ _ensure_windows_playwright()
487
616
  try:
488
617
  reference = normalize_instagram_reference(source)
489
618
  except ValueError as exc:
@@ -498,13 +627,16 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
498
627
  if post:
499
628
  api_posts.append(post)
500
629
  if api_posts:
630
+ LOGGER.debug('Instagram API posts extracted=%s response=JSON', len(api_posts))
501
631
  return IntegrationResult(True, 'Posts públicos encontrados.', reference | {'posts': api_posts[:max(1, int(limit))]})
502
632
  if api_user.get('is_private'):
503
633
  return IntegrationResult(False, 'Esta conta é privada. O Instagram não disponibiliza posts sem uma sessão autenticada.', reference | {'posts': [], 'is_private': True})
504
634
  try:
505
635
  response = requests.get(reference['url'], headers={'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8'}, timeout=15)
506
636
  except requests.RequestException as exc:
637
+ LOGGER.warning('Falha HTTP nos posts Instagram URL=%s: %s', reference['url'], exc, exc_info=True)
507
638
  return IntegrationResult(False, f'Não foi possível consultar os posts públicos do Instagram: {exc}', reference)
639
+ LOGGER.debug('Instagram HTML posts URL=%s status=%s content_type=%s response=HTML', reference['url'], response.status_code, response.headers.get('content-type', ''))
508
640
  if response.status_code >= 400:
509
641
  return IntegrationResult(False, f'O Instagram devolveu HTTP {response.status_code} ao consultar os posts.', reference | {'status_code': response.status_code})
510
642
  posts: list[dict[str, Any]] = []
@@ -545,8 +677,10 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
545
677
  break
546
678
  finally:
547
679
  browser.close()
548
- except (ImportError, OSError, RuntimeError, TimeoutError):
549
- pass
680
+ except Exception as exc:
681
+ LOGGER.warning('Falha no fallback Playwright dos posts Instagram: %s', exc, exc_info=True)
682
+ _streamlit_message('error', f'Fallback Playwright dos posts Instagram falhou: {exc}. Execute: python -m playwright install chromium', 'posts_playwright_error')
683
+ LOGGER.debug('Instagram posts extracted=%s limit=%s', len(posts), limit)
550
684
  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))]})
551
685
 
552
686
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.43",
3
+ "version": "0.6.44",
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",