@danhachuel/thunderbolt 0.6.56 → 0.6.58

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,12 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
- import logging
5
4
  import re
6
5
  import uuid
7
6
  import zipfile
8
7
  import unicodedata
9
- import hashlib
10
8
  from typing import Any, Callable, Mapping
11
9
 
12
10
  import requests
@@ -17,19 +15,10 @@ from hermes_ui.influencers import InfluencerBackendError, STANDALONE_CONTENT_INF
17
15
  from hermes_ui.storage import read_json, write_json
18
16
  from hermes_ui.countries import COUNTRY_OPTIONS
19
17
  from hermes_ui.languages import LANGUAGE_CODES, language_code, language_label
20
- from integrations.instagram_public import (
21
- extract_public_instagram_country,
22
- fetch_public_instagram_posts,
23
- fetch_public_instagram_profile,
24
- normalize_instagram_bio,
25
- normalize_instagram_metric,
26
- )
18
+ from integrations.instagram_public import fetch_public_instagram_posts, fetch_public_instagram_profile, normalize_instagram_bio, normalize_instagram_metric
27
19
  from integrations.meta_social import test_facebook_pages_api_card, test_instagram_api_card
28
20
 
29
21
 
30
- LOGGER = logging.getLogger(__name__)
31
-
32
-
33
22
  def _clean(value: Any) -> str:
34
23
  return str(value or "").strip()
35
24
 
@@ -78,35 +67,11 @@ def _country_key(value: Any) -> str:
78
67
  COUNTRY_ALIASES = {
79
68
  "brazil": "Brasil",
80
69
  "brasil": "Brasil",
81
- "br": "Brasil",
82
70
  "usa": "Estados Unidos",
83
71
  "us": "Estados Unidos",
84
72
  "united states": "Estados Unidos",
85
73
  "united states of america": "Estados Unidos",
86
- "pt": "Portugal",
87
74
  "portugal": "Portugal",
88
- "uk": "Reino Unido",
89
- "gb": "Reino Unido",
90
- "united kingdom": "Reino Unido",
91
- "england": "Reino Unido",
92
- "germany": "Alemanha",
93
- "de": "Alemanha",
94
- "france": "França",
95
- "fr": "França",
96
- "italy": "Itália",
97
- "it": "Itália",
98
- "spain": "Espanha",
99
- "es": "Espanha",
100
- "canada": "Canadá",
101
- "ca": "Canadá",
102
- "australia": "Austrália",
103
- "au": "Austrália",
104
- "japan": "Japão",
105
- "jp": "Japão",
106
- "mexico": "México",
107
- "mx": "México",
108
- "argentina": "Argentina",
109
- "ar": "Argentina",
110
75
  }
111
76
 
112
77
 
@@ -124,17 +89,11 @@ def _normalise_country(value: Any) -> str:
124
89
 
125
90
 
126
91
  def _profile_country(profile: Mapping[str, Any]) -> str:
127
- for key in ("country", "country_name", "country_of_registration", "account_country", "account_based_in", "country_code"):
128
- raw_value = profile.get(key)
129
- candidates = [raw_value]
130
- if isinstance(raw_value, Mapping):
131
- candidates.extend(raw_value.get(child) for child in ("name", "label", "display_name", "country", "country_name", "code"))
132
- for candidate in candidates:
133
- country = _normalise_country(candidate)
134
- if country:
135
- return country
136
- extracted = _normalise_country(extract_public_instagram_country(profile))
137
- return extracted
92
+ for key in ("country", "country_name", "country_of_registration", "account_country", "account_based_in"):
93
+ country = _normalise_country(profile.get(key))
94
+ if country:
95
+ return country
96
+ return ""
138
97
 
139
98
 
140
99
  def _language_index(value: Any) -> int:
@@ -165,41 +124,10 @@ def _instagram_profile_storage_id(profile: Mapping[str, Any]) -> str:
165
124
  return re.sub(r"[^A-Za-z0-9_.-]+", "_", value) or "unknown"
166
125
 
167
126
 
168
- def _clear_instagram_result_widget_state(profile: Mapping[str, Any]) -> None:
169
- """Clear every previous widget key for this profile before rebuilding the form."""
170
- widget_id = _instagram_profile_storage_id(profile)
171
- for field in ("name", "bio", "country", "language", "posts", "followers", "following", "character"):
172
- prefix = f"social_instagram_result_{field}_{widget_id}"
173
- for key in list(st.session_state.keys()):
174
- if str(key).startswith(prefix):
175
- st.session_state.pop(key, None)
176
-
177
-
178
- def _instagram_result_widget_id(profile: Mapping[str, Any]) -> str:
179
- base_id = _instagram_profile_storage_id(profile)
180
- signature = "|".join(_clean(profile.get(key)) for key in ("bio", "bio_raw", "following_count", "subscriber_count", "post_count", "country"))
181
- digest = hashlib.sha1(signature.encode("utf-8")).hexdigest()[:10]
182
- return f"{base_id}_{digest}"
183
-
184
-
185
127
  def _normalise_instagram_posts(value: Any) -> list[dict[str, Any]]:
186
- if isinstance(value, Mapping):
187
- value = value.get("posts") or value.get("items") or value.get("edges") or []
188
128
  if not isinstance(value, list):
189
129
  return []
190
- normalised: list[dict[str, Any]] = []
191
- seen: set[str] = set()
192
- for item in value:
193
- post = item.get("node") if isinstance(item, Mapping) and isinstance(item.get("node"), Mapping) else item
194
- if not isinstance(post, Mapping):
195
- continue
196
- data = dict(post)
197
- identifier = _clean(data.get("id") or data.get("shortcode") or data.get("url"))
198
- if not identifier or identifier in seen:
199
- continue
200
- seen.add(identifier)
201
- normalised.append(data)
202
- return normalised
130
+ return [dict(post) for post in value if isinstance(post, Mapping) and _clean(post.get("id") or post.get("url"))]
203
131
 
204
132
 
205
133
  def _stored_instagram_posts(profile: Mapping[str, Any]) -> list[dict[str, Any]]:
@@ -248,8 +176,8 @@ def _instagram_posts_archive(posts: list[dict[str, Any]]) -> bytes:
248
176
  elif "webp" in content_type:
249
177
  extension = ".webp"
250
178
  bundle.writestr(f"post-{index:02d}{extension}", response.content)
251
- except requests.RequestException as exc:
252
- LOGGER.warning("Não foi possível descarregar a imagem do post Instagram %s: %s", url, exc)
179
+ except requests.RequestException:
180
+ pass
253
181
  manifest.append(f"{index}. {_clean(post.get('url'))}\n{_clean(post.get('caption'))}\n")
254
182
  bundle.writestr("posts.txt", "\n".join(manifest))
255
183
  return archive.getvalue()
@@ -270,14 +198,13 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
270
198
  if load_clicked or refresh_clicked:
271
199
  target = max(10, len(posts)) if refresh_clicked else 10
272
200
  posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=target)
273
- # DEBUG WINDOWS INSTAGRAM: manter até a validação final do carregamento público.
274
- st.write({"instagram_posts_ok": posts_ok, "instagram_posts_message": posts_message, "instagram_posts_count": len(loaded_posts)})
275
201
  if posts_ok:
276
- posts = _normalise_instagram_posts(loaded_posts)
202
+ posts = loaded_posts
277
203
  _persist_instagram_posts(profile, posts)
278
204
  st.success(f"{len(posts)} posts carregados.")
205
+ st.rerun()
279
206
  else:
280
- st.error(posts_message)
207
+ st.warning(posts_message)
281
208
  posts = st.session_state.get(state_key, posts)
282
209
  if not posts:
283
210
  st.info("Clique em Carregar últimos 10 para consultar os posts públicos desta conta.")
@@ -296,10 +223,9 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
296
223
  if st.button("Mostrar + 10", key=f"more_instagram_posts_{profile_id}", use_container_width=True):
297
224
  posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=len(posts) + 10)
298
225
  if posts_ok:
299
- posts = _normalise_instagram_posts(loaded_posts)
300
- _persist_instagram_posts(profile, posts)
301
- else:
302
- st.error(posts_message)
226
+ _persist_instagram_posts(profile, loaded_posts)
227
+ st.rerun()
228
+ st.warning(posts_message)
303
229
 
304
230
 
305
231
  def _api_card_defaults(kind: str) -> tuple[str, list[str], Callable[[Mapping[str, Any]], dict[str, Any]]]:
@@ -446,8 +372,7 @@ def _characters(settings: Mapping[str, Any]) -> tuple[list[dict[str, Any]], Any
446
372
  repository = get_repository(settings)
447
373
  items = [item for item in repository.list_influencers() if _clean(item.get("id")) != STANDALONE_CONTENT_INFLUENCER_ID]
448
374
  return items, repository
449
- except Exception as exc:
450
- LOGGER.warning("Não foi possível carregar as personagens: %s", exc, exc_info=True)
375
+ except (InfluencerBackendError, Exception):
451
376
  return [], None
452
377
 
453
378
 
@@ -559,39 +484,19 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
559
484
  refresh_col, edit_col = st.columns(2)
560
485
  with refresh_col:
561
486
  if st.button("↻", help="Actualizar posts, seguidores e seguindo", key=f"refresh_instagram_{profile_id}"):
562
- try:
563
- refreshed_ok, refreshed_message, refreshed = _refresh_instagram_profile(profile)
564
- except Exception as exc:
565
- st.error(f"Falha ao actualizar o perfil Instagram: {exc}")
566
- refreshed_ok, refreshed_message, refreshed = False, str(exc), {}
487
+ refreshed_ok, refreshed_message, refreshed = _refresh_instagram_profile(profile)
567
488
  if refreshed_ok:
568
489
  current_posts = _stored_instagram_posts(profile)
569
- try:
570
- posts_ok, posts_message, refreshed_posts = _load_instagram_posts(refreshed, limit=max(10, len(current_posts)))
571
- except Exception as exc:
572
- posts_ok, posts_message, refreshed_posts = False, str(exc), []
490
+ posts_ok, _, refreshed_posts = _load_instagram_posts(refreshed, limit=max(10, len(current_posts)))
573
491
  if posts_ok:
574
492
  _persist_instagram_posts(profile, refreshed_posts)
575
- else:
576
- st.warning(f"Perfil actualizado, mas os posts não foram actualizados: {posts_message}")
577
- # Debug temporário removido após validação; manter para reactivar se necessário:
578
- # st.write({"profile_ok": refreshed_ok, "profile_message": refreshed_message, "profile_data": refreshed})
579
- # st.write({"posts_ok": posts_ok, "posts_message": posts_message, "posts_count": len(refreshed_posts)})
580
- try:
581
- 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", "")})
582
- except (OSError, PermissionError) as exc:
583
- st.error(f"Não foi possível guardar os dados no Windows: {exc}")
584
- return
585
- except Exception as exc:
586
- st.error(f"Falha ao guardar os dados da conta Instagram: {exc}")
587
- return
493
+ 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", "")})
588
494
  if updated is None:
589
- st.error("A conta Instagram já não existe no armazenamento local ou não foi possível escrever o ficheiro.")
495
+ st.error("A conta Instagram já não existe no armazenamento local.")
590
496
  return
591
497
  st.success("Métricas Instagram actualizadas.")
592
498
  st.rerun()
593
- else:
594
- st.error(f"Não foi possível actualizar o perfil Instagram: {refreshed_message}")
499
+ st.warning(refreshed_message)
595
500
  with edit_col:
596
501
  if st.button("Editar", key=f"edit_instagram_button_{profile_id}", use_container_width=True):
597
502
  for field in ("name", "handle", "bio", "country", "language", "posts", "followers", "following"):
@@ -678,20 +583,15 @@ def render_social_networks(settings: dict[str, Any]) -> None:
678
583
  if search_clicked:
679
584
  result = fetch_public_instagram_profile(source)
680
585
  result_data = dict(result.data) if isinstance(result.data, dict) else {}
681
- if result_data:
682
- _clear_instagram_result_widget_state(result_data)
683
586
  st.session_state["social_instagram_result"] = result_data
684
587
  st.session_state["social_instagram_ok"] = result.ok
685
588
  st.session_state["social_instagram_message"] = result.message
686
589
  if st.session_state.get("social_instagram_message"):
687
- if st.session_state.get("social_instagram_ok"):
688
- st.success(st.session_state["social_instagram_message"])
689
- else:
690
- st.error(st.session_state["social_instagram_message"])
590
+ (st.success if st.session_state.get("social_instagram_ok") else st.warning)(st.session_state["social_instagram_message"])
691
591
  data = st.session_state.get("social_instagram_result", {}) if st.session_state.get("social_instagram_ok") else {}
692
592
  if data:
693
593
  characters, _ = _characters(settings)
694
- result_widget_id = _instagram_result_widget_id(data)
594
+ result_widget_id = _instagram_profile_storage_id(data)
695
595
  with st.container(border=True):
696
596
  st.subheader("Conta Instagram encontrada")
697
597
  preview_cols = st.columns([0.8, 2.2, 1.2, 1.2, 1.2])
@@ -2,108 +2,23 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
  import json
5
- import logging
6
5
  import os
7
6
  import platform
8
- import sys
9
7
  import shutil
10
8
  import subprocess
11
9
  from dataclasses import dataclass
12
10
  from datetime import datetime, timezone
13
11
  from html import unescape
14
- from pathlib import Path
15
12
  from typing import Any, Mapping
16
13
  from urllib.parse import urlparse
17
14
 
18
15
  import requests
19
16
 
20
- try:
21
- import streamlit as st
22
- except ImportError: # pragma: no cover - Streamlit is a runtime dependency
23
- st = None
24
-
25
-
26
- LOGGER = logging.getLogger(__name__)
27
- _WINDOWS_PLAYWRIGHT_CHECKED = False
28
- _INSTALOADER_INSTALL_ATTEMPTED = False
29
-
30
17
 
31
18
  ABOUT_ACCOUNT_URL = 'https://i.instagram.com/api/v1/bloks/apps/com.instagram.interactions.about_this_account/'
32
19
  ABOUT_ACCOUNT_BLOKS_VERSION = '8ca96ca267e30c02cf90888d91eeff09627f0e3fd2bd9df472278c9a6c022cbb'
33
20
 
34
21
 
35
- def _load_dotenv_compat() -> None:
36
- """Load .env when python-dotenv exists, otherwise parse simple KEY=VALUE lines."""
37
- try:
38
- from dotenv import load_dotenv
39
- load_dotenv()
40
- return
41
- except ImportError:
42
- LOGGER.debug('python-dotenv não está instalado; será usado o parser .env compatível.')
43
- env_path = Path(__file__).resolve().parents[1] / '.env'
44
- if not env_path.is_file():
45
- return
46
- try:
47
- for raw_line in env_path.read_text(encoding='utf-8').splitlines():
48
- line = raw_line.strip()
49
- if not line or line.startswith('#') or '=' not in line:
50
- continue
51
- key, value = line.split('=', 1)
52
- value = value.strip().strip('"').strip("'")
53
- os.environ.setdefault(key.strip(), value)
54
- except OSError as exc:
55
- LOGGER.warning('Não foi possível ler %s: %s', env_path, exc)
56
-
57
-
58
- def _streamlit_message(level: str, message: str, key: str) -> None:
59
- """Show a runtime diagnostic once when Streamlit is available."""
60
- if st is None:
61
- return
62
- try:
63
- shown_key = f'_instagram_debug_{key}'
64
- if st.session_state.get(shown_key):
65
- return
66
- st.session_state[shown_key] = True
67
- getattr(st, level)(message)
68
- except Exception as exc: # Streamlit may be imported outside an active app context.
69
- LOGGER.debug('Não foi possível mostrar diagnóstico Streamlit: %s', exc)
70
-
71
-
72
- def _ensure_windows_playwright() -> None:
73
- """Ensure Chromium exists on Windows before using the Playwright fallback."""
74
- global _WINDOWS_PLAYWRIGHT_CHECKED
75
- if os.name != 'nt' or _WINDOWS_PLAYWRIGHT_CHECKED:
76
- return
77
- _WINDOWS_PLAYWRIGHT_CHECKED = True
78
- _load_dotenv_compat()
79
- try:
80
- completed = subprocess.run(
81
- [sys.executable, '-m', 'playwright', 'install', 'chromium'],
82
- capture_output=True,
83
- text=True,
84
- encoding='utf-8',
85
- errors='replace',
86
- timeout=180,
87
- check=False,
88
- )
89
- if completed.returncode != 0:
90
- detail = (completed.stderr or completed.stdout or 'sem detalhes').strip()[-1000:]
91
- message = f'Não foi possível instalar o Chromium do Playwright no Windows: {detail}'
92
- LOGGER.warning(message)
93
- _streamlit_message('error', message + ' Execute: python -m playwright install chromium', 'playwright_install_error')
94
- else:
95
- LOGGER.info('Chromium do Playwright verificado/instalado no Windows.')
96
- except (OSError, subprocess.SubprocessError) as exc:
97
- message = f'Falha ao executar a instalação do Chromium no Windows: {exc}'
98
- LOGGER.warning(message)
99
- _streamlit_message('error', message + ' Execute: python -m playwright install chromium', 'playwright_install_exception')
100
-
101
-
102
- _load_dotenv_compat()
103
- if os.name == 'nt':
104
- _ensure_windows_playwright()
105
-
106
-
107
22
  def _instagram_cookies() -> dict[str, str]:
108
23
  cookies = {}
109
24
  sessionid = os.getenv('INSTAGRAM_SESSIONID') or os.getenv('IG_SESSIONID')
@@ -125,149 +40,34 @@ def _instagram_headers() -> dict[str, str]:
125
40
  }
126
41
 
127
42
 
128
- def _instagram_playwright_headless() -> bool:
129
- """Use headed mode for Windows diagnostics unless explicitly overridden."""
130
- value = os.getenv('INSTAGRAM_PLAYWRIGHT_HEADLESS', 'false').strip().casefold()
131
- return value not in {'0', 'false', 'no', 'off'}
132
-
133
-
134
- def _save_instagram_debug_html(username: str, document: str) -> None:
135
- storage_dir = Path(__file__).resolve().parents[1] / 'storage'
136
- path = Path(os.getenv('INSTAGRAM_DEBUG_PROFILE_HTML', str(storage_dir / 'debug_instagram.html')))
137
- try:
138
- path.parent.mkdir(parents=True, exist_ok=True)
139
- path.write_text(document, encoding='utf-8')
140
- LOGGER.info('HTML salvo em %s', path)
141
- except OSError as exc:
142
- LOGGER.warning('Não foi possível salvar o HTML de diagnóstico Instagram: %s', exc)
143
-
144
-
145
- def _instagram_instaloader():
146
- """Load the anonymous fallback, installing it once when the requirement is missing."""
147
- global _INSTALOADER_INSTALL_ATTEMPTED
43
+ def _fetch_instagram_html_with_playwright(url: str, username: str) -> str:
44
+ """Windows-only browser fallback; the existing Linux HTML parser remains authoritative."""
148
45
  try:
149
- import instaloader
150
- return instaloader
151
- except ImportError:
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()
46
+ from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, sync_playwright
47
+ with sync_playwright() as playwright:
48
+ browser = playwright.chromium.launch(headless=True)
49
+ try:
50
+ context = browser.new_context(
51
+ user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
52
+ extra_http_headers={'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7'},
53
+ )
54
+ page = context.new_page()
55
+ response = page.goto(url, wait_until='domcontentloaded', timeout=30000)
56
+ status = response.status if response else None
57
+ print(f'Playwright usado no Windows para @{username}; status HTML={status}')
58
+ page.wait_for_timeout(5000)
165
59
  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')
174
- return None
175
-
176
-
177
- def _fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
178
- """Fetch a public profile anonymously through Instaloader when Playwright fails."""
179
- instaloader = _instagram_instaloader()
180
- if instaloader is None:
181
- return None
182
- try:
183
- loader = instaloader.Instaloader(
184
- quiet=True,
185
- request_timeout=30,
186
- max_connection_attempts=1,
187
- download_pictures=False,
188
- download_videos=False,
189
- download_video_thumbnails=False,
190
- save_metadata=False,
191
- )
192
- profile = instaloader.Profile.from_username(loader.context, username)
193
- LOGGER.info('Fallback instaloader activado para o perfil @%s', username)
194
- country = next(
195
- (
196
- str(getattr(profile, attribute, '') or '').strip()
197
- for attribute in ('country', 'country_name', 'country_of_registration', 'account_country', 'account_based_in')
198
- if str(getattr(profile, attribute, '') or '').strip()
199
- ),
200
- '',
201
- )
202
- return {
203
- 'id': str(getattr(profile, 'userid', '') or ''),
204
- 'username': str(getattr(profile, 'username', '') or username),
205
- 'full_name': str(getattr(profile, 'full_name', '') or ''),
206
- 'biography': str(getattr(profile, 'biography', '') or ''),
207
- 'follower_count': getattr(profile, 'follower_count', getattr(profile, 'followers', None)),
208
- 'following_count': getattr(profile, 'following_count', getattr(profile, 'followees', None)),
209
- 'edge_owner_to_timeline_media': {'count': getattr(profile, 'mediacount', None)},
210
- 'country': country,
211
- 'is_private': bool(getattr(profile, 'is_private', False)),
212
- '_instagram_source': 'instaloader',
213
- }
214
- except Exception as exc:
215
- LOGGER.warning('Fallback instaloader falhou para @%s: %s', username, exc, exc_info=True)
216
- return None
217
-
218
-
219
- def _fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, Any]]:
220
- """Fetch public posts anonymously through Instaloader when Playwright fails."""
221
- instaloader = _instagram_instaloader()
222
- if instaloader is None:
223
- return []
224
- posts: list[dict[str, Any]] = []
225
- try:
226
- loader = instaloader.Instaloader(
227
- quiet=True,
228
- request_timeout=30,
229
- max_connection_attempts=1,
230
- download_pictures=False,
231
- download_videos=False,
232
- download_video_thumbnails=False,
233
- save_metadata=False,
234
- )
235
- profile = instaloader.Profile.from_username(loader.context, username)
236
- for post in profile.get_posts():
237
- shortcode = str(getattr(post, 'shortcode', '') or '')
238
- if not shortcode:
239
- continue
240
- image_url = str(getattr(post, 'url', '') or '')
241
- timestamp = getattr(post, 'date_utc', '') or ''
242
- if hasattr(timestamp, 'isoformat'):
243
- timestamp = timestamp.isoformat()
244
- else:
245
- timestamp = str(timestamp)
246
- posts.append({
247
- 'id': str(getattr(post, 'mediaid', '') or shortcode),
248
- 'shortcode': shortcode,
249
- 'url': f'https://www.instagram.com/p/{shortcode}/',
250
- 'image_url': image_url,
251
- 'display_url': image_url,
252
- 'caption': str(getattr(post, 'caption', '') or '').strip(),
253
- 'published_at': timestamp,
254
- 'timestamp': timestamp,
255
- 'is_video': bool(getattr(post, 'is_video', False)),
256
- })
257
- if len(posts) >= max(1, int(limit)):
258
- break
259
- LOGGER.info('Fallback instaloader carregou %s posts para @%s', len(posts), username)
60
+ page.wait_for_load_state('networkidle', timeout=10000)
61
+ except (PlaywrightTimeoutError, TimeoutError):
62
+ pass
63
+ html_content = page.content()
64
+ print(f'HTML Instagram recebido no Windows para @{username}: {len(html_content)} bytes')
65
+ return html_content
66
+ finally:
67
+ browser.close()
260
68
  except Exception as exc:
261
- LOGGER.warning('Fallback instaloader falhou nos posts de @%s: %s', username, exc, exc_info=True)
262
- return posts
263
-
264
-
265
- def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
266
- return _fetch_profile_instaloader(username)
267
-
268
-
269
- def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, Any]]:
270
- return _fetch_posts_instaloader(username, limit)
69
+ print(f'Playwright falhou no Windows para @{username}: {exc}')
70
+ return ''
271
71
 
272
72
 
273
73
  @dataclass
@@ -278,9 +78,8 @@ class IntegrationResult:
278
78
 
279
79
 
280
80
  def normalize_instagram_bio(value: Any) -> str:
281
- """Keep the complete public biography exactly as returned by Instagram."""
282
- bio = str(value or '').strip()
283
- return bio
81
+ """Preserve the complete biography returned by Instagram."""
82
+ return str(value or '').strip()
284
83
 
285
84
 
286
85
  def normalize_instagram_reference(source: str) -> dict[str, str]:
@@ -350,230 +149,101 @@ def _structured_metric_from_json(document: str, *keys: str) -> int | None:
350
149
  return None
351
150
 
352
151
 
353
- def _profile_quality(user: Mapping[str, Any]) -> int:
354
- groups = (
355
- ('biography', 'bio', 'description'),
356
- ('edge_follow', 'following', 'following_count', 'followingCount', 'follows'),
357
- ('edge_followed_by', 'followers', 'follower_count', 'followers_count', 'followerCount'),
358
- ('edge_owner_to_timeline_media', 'posts', 'post_count', 'media_count'),
359
- ('username', 'user_name', 'handle'),
360
- )
361
- return sum(any(user.get(key) not in (None, '', {}, []) for key in group) for group in groups)
362
-
363
-
364
- def _merge_profile(base: dict[str, Any] | None, candidate: Mapping[str, Any]) -> dict[str, Any]:
365
- merged = dict(base or {})
366
- for key, value in candidate.items():
367
- if value not in (None, '', [], {}):
368
- if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
369
- merged[key] = {**merged[key], **value}
370
- else:
371
- merged[key] = value
372
- return merged
373
-
374
-
375
- def _extract_profile_user(payload: Any) -> Mapping[str, Any] | None:
376
- if not isinstance(payload, Mapping):
377
- return None
378
- candidates = [
379
- payload.get('user'),
380
- (payload.get('data') or {}).get('user') if isinstance(payload.get('data'), Mapping) else None,
381
- (payload.get('graphql') or {}).get('user') if isinstance(payload.get('graphql'), Mapping) else None,
382
- (payload.get('data') or {}).get('profile') if isinstance(payload.get('data'), Mapping) else None,
383
- (payload.get('xdt_api__v1__users__web_profile_info') or {}).get('user') if isinstance(payload.get('xdt_api__v1__users__web_profile_info'), Mapping) else None,
384
- ]
385
- direct = next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
386
- if direct is not None:
387
- return direct
388
- for node in _walk_json(payload):
389
- if not isinstance(node, Mapping) or not node.get('username'):
390
- continue
391
- if any(key in node for key in ('biography', 'edge_follow', 'edge_followed_by', 'follower_count', 'following_count', 'full_name')):
392
- return node
393
- return None
394
-
395
-
396
- def _shared_data_documents(document: str) -> list[Any]:
397
- """Extract JSON assigned to window._sharedData from the rendered profile HTML."""
398
- decoder = json.JSONDecoder()
399
- documents: list[Any] = []
400
- patterns = (
401
- r'window\s*\.\s*_sharedData\s*=\s*',
402
- r'window\s*\[\s*["\']_sharedData["\']\s*\]\s*=\s*',
403
- )
404
- for pattern in patterns:
405
- for match in re.finditer(pattern, document, flags=re.IGNORECASE):
406
- start = document.find('{', match.end())
407
- if start < 0:
152
+ def _extract_posts_from_html(document: str, limit: int = 10) -> list[dict[str, Any]]:
153
+ posts: list[dict[str, Any]] = []
154
+ seen: set[str] = set()
155
+ for payload in _embedded_json_documents(document):
156
+ for node in _walk_json(payload):
157
+ if not isinstance(node, dict):
408
158
  continue
409
- try:
410
- parsed, _ = decoder.raw_decode(document[start:])
411
- except json.JSONDecodeError:
159
+ post = _post_from_node(node)
160
+ if not post or post['id'] in seen:
412
161
  continue
413
- documents.append(parsed)
414
- return documents
162
+ seen.add(post['id'])
163
+ posts.append(post)
164
+ if len(posts) >= max(1, int(limit)):
165
+ return posts
166
+ return posts
415
167
 
416
168
 
417
- def _profile_users_from_document(document: str) -> list[Mapping[str, Any]]:
418
- """Return users found in legacy _sharedData and current embedded profile payloads."""
419
- users: list[Mapping[str, Any]] = []
420
- documents = _shared_data_documents(document) + _embedded_json_documents(document)
421
- for payload in documents:
422
- direct = _extract_profile_user(payload)
423
- if isinstance(direct, Mapping):
424
- users.append(direct)
425
- for node in _walk_json(payload):
426
- if not isinstance(node, Mapping):
427
- continue
428
- candidate = node.get('user')
429
- if isinstance(candidate, Mapping) and any(key in candidate for key in ('biography', 'edge_follow', 'edge_followed_by', 'username')):
430
- users.append(candidate)
431
- unique: list[Mapping[str, Any]] = []
432
- seen: set[int] = set()
433
- for user in users:
434
- marker = id(user)
435
- if marker not in seen:
436
- seen.add(marker)
437
- unique.append(user)
438
- return unique
439
-
440
-
441
- def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | None = None, cookies: Mapping[str, str] | None = None) -> dict[str, Any] | None:
442
- """Fetch a public profile using browser state, API responses and embedded HTML data."""
443
- headers = headers or _instagram_headers()
444
- cookies = cookies or _instagram_cookies()
445
- captured_user: dict[str, Any] | None = None
446
- try:
447
- from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, sync_playwright
448
- with sync_playwright() as playwright:
449
- browser = playwright.chromium.launch(headless=_instagram_playwright_headless())
450
- try:
451
- 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/'}
452
- state_path = Path(__file__).resolve().parents[1] / 'storage' / 'instagram_storage_state.json'
453
- context_kwargs = {'user_agent': headers['User-Agent'], 'extra_http_headers': headers_for_context}
454
- if state_path.is_file():
455
- context_kwargs['storage_state'] = str(state_path)
456
- context = browser.new_context(**context_kwargs)
457
- if cookies:
458
- context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
459
- page = context.new_page()
169
+ def _extract_profile_user_from_html(document: str, username: str) -> dict[str, Any]:
170
+ description = _meta(document, 'og:description')
171
+ title = _meta(document, 'og:title') or username
172
+ avatar_url = _meta(document, 'og:image')
460
173
 
461
- def capture_profile_response(response: Any) -> None:
462
- nonlocal captured_user
463
- response_url = str(getattr(response, 'url', '') or '')
464
- if not any(marker in response_url for marker in ('/api/v1/users/web_profile_info/', '/graphql/query')):
465
- return
466
- status = getattr(response, 'status', None)
467
- LOGGER.info('Instagram Playwright API response URL=%s status=%s', response_url, status)
468
- try:
469
- payload = response.json()
470
- user = _extract_profile_user(payload)
471
- if isinstance(user, Mapping):
472
- captured_user = _merge_profile(captured_user, user)
473
- LOGGER.info('Instagram Playwright perfil capturado URL=%s status=%s quality=%s', response_url, status, _profile_quality(captured_user))
474
- except (ValueError, TypeError, AttributeError) as exc:
475
- LOGGER.warning('Não foi possível interpretar resposta Instagram URL=%s status=%s: %s', response_url, status, exc)
476
-
477
-
478
- page.on('response', capture_profile_response)
479
- try:
480
- page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=30000)
481
- state_path.parent.mkdir(parents=True, exist_ok=True)
482
- context.storage_state(path=str(state_path))
483
- LOGGER.debug('Instagram Playwright página inicial carregada; storage_state salvo em %s.', state_path)
484
- except Exception as exc:
485
- LOGGER.debug('Instagram Playwright não carregou a página inicial: %s', exc)
486
- url = f'https://www.instagram.com/{username}/'
487
- response = None
488
- status = None
489
- try:
490
- response = page.goto(url, wait_until='domcontentloaded', timeout=30000)
491
- status = response.status if response else None
492
- except Exception as exc:
493
- LOGGER.warning('Instagram Playwright navegação falhou URL=%s: %s', url, exc)
494
- LOGGER.info('Instagram Playwright profile URL=%s status=%s', url, status)
174
+ def metric(patterns: tuple[str, ...]) -> int | None:
175
+ for pattern in patterns:
176
+ found = re.search(pattern, description, flags=re.IGNORECASE)
177
+ if found:
495
178
  try:
496
- page.wait_for_timeout(5000)
497
- except Exception:
179
+ return int(re.sub(r'[^0-9]', '', found.group(1)))
180
+ except ValueError:
498
181
  pass
499
- try:
500
- page.wait_for_load_state('networkidle', timeout=10000)
501
- except (PlaywrightTimeoutError, TimeoutError):
502
- LOGGER.debug('Instagram Playwright networkidle excedido; usando dados já capturados.')
503
- try:
504
- document = page.content()
505
- _save_instagram_debug_html(username, document)
506
- for user in _profile_users_from_document(document):
507
- if isinstance(user, Mapping):
508
- candidate = _merge_profile(captured_user, user)
509
- if _profile_quality(candidate) >= _profile_quality(captured_user or {}):
510
- captured_user = candidate
511
- except Exception as exc:
512
- LOGGER.warning('Não foi possível guardar/analisar HTML de diagnóstico URL=%s: %s', url, exc)
513
- if response is None or status is None or status >= 400:
514
- LOGGER.warning('Instagram Playwright perfil HTTP status=%s URL=%s', status, url)
515
- return captured_user
516
- LOGGER.info('Instagram Playwright perfil final captured=%s quality=%s URL=%s', bool(captured_user), _profile_quality(captured_user or {}), url)
517
- finally:
518
- browser.close()
519
- except Exception as exc:
520
- LOGGER.warning('Falha no Playwright do perfil Instagram: %s', exc, exc_info=True)
521
- _streamlit_message('error', f'Playwright não conseguiu consultar o perfil Instagram: {exc}', 'profile_playwright_error')
522
- return captured_user
182
+ return None
183
+
184
+ followers = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:followers|seguidores)',)) or _structured_metric(document, 'edge_followed_by', 'followers', 'follower_count') or _structured_metric_from_json(document, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
185
+ following = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:following|seguindo)',)) or _structured_metric(document, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount') or _structured_metric_from_json(document, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
186
+ post_count = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:posts|publicações|publications)',)) or _structured_metric(document, 'edge_owner_to_timeline_media', 'posts', 'post_count')
187
+ country = ''
188
+ for payload in _embedded_json_documents(document):
189
+ country = extract_public_instagram_country(payload)
190
+ if country:
191
+ break
192
+ posts = _extract_posts_from_html(document, 10)
193
+ user: dict[str, Any] = {
194
+ 'username': username,
195
+ 'full_name': title.split('(')[0].strip() or username,
196
+ 'biography': description,
197
+ 'edge_followed_by': {'count': followers} if followers is not None else {},
198
+ 'edge_follow': {'count': following} if following is not None else {},
199
+ 'edge_owner_to_timeline_media': {'count': post_count, 'edges': [{'node': post} for post in posts]},
200
+ 'country': country,
201
+ 'profile_pic_url': avatar_url,
202
+ '_html_posts': posts,
203
+ }
204
+ return user if any((description, followers, following, post_count, country, posts)) else {}
523
205
 
524
206
 
525
207
  def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
526
- _ensure_windows_playwright()
527
- cookies = _instagram_cookies()
528
208
  headers = _instagram_headers()
529
209
  headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36'
530
- if platform.system() == 'Windows':
531
- LOGGER.debug('Windows detectado: perfil Instagram exclusivamente via Playwright.')
532
- playwright_user = _fetch_profile_with_playwright(username, headers, cookies)
533
- if playwright_user:
534
- return playwright_user
535
- LOGGER.warning('Fallback para instaloader activado no perfil @%s.', username)
536
- fallback_user = fetch_profile_instaloader(username)
537
- if fallback_user:
538
- _streamlit_message('info', 'Dados obtidos via fallback (instaloader). O país pode não estar disponível.', 'profile_instaloader_fallback')
539
- else:
540
- _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')
541
- return fallback_user
542
-
210
+ # The www host is the current public web endpoint. The legacy i host is
211
+ # retained as a fallback because Instagram rate-limits the two hosts
212
+ # independently and their availability can vary by region.
543
213
  endpoints = (
544
214
  f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
545
215
  f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
546
216
  )
217
+ def profile_quality(user: Mapping[str, Any]) -> int:
218
+ return sum(bool(user.get(key)) for key in ('biography', 'edge_follow', 'edge_followed_by', 'edge_owner_to_timeline_media', 'username'))
219
+
220
+ def merge_profile(base: dict[str, Any] | None, candidate: Mapping[str, Any]) -> dict[str, Any]:
221
+ merged = dict(base or {})
222
+ for key, value in candidate.items():
223
+ if value not in (None, '', [], {}):
224
+ if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
225
+ merged[key] = {**merged[key], **value}
226
+ else:
227
+ merged[key] = value
228
+ return merged
547
229
 
548
- request_headers = [
549
- headers,
550
- {**headers, 'User-Agent': 'Instagram 390.0.0.0.50 Android', 'Accept': 'application/json, text/plain, */*'},
551
- {**headers, 'Referer': f'https://www.instagram.com/{username}/', 'Origin': 'https://www.instagram.com', 'X-Requested-With': 'XMLHttpRequest'},
552
- ]
553
230
  best_user: dict[str, Any] | None = None
554
- for request_header in request_headers:
555
- for endpoint in endpoints:
556
- try:
557
- response = requests.get(endpoint, headers=request_header, cookies=cookies or None, timeout=15)
558
- LOGGER.debug('Instagram profile URL=%s status=%s content_type=%s', endpoint, response.status_code, response.headers.get('content-type', ''))
559
- if response.status_code >= 400:
560
- continue
561
- payload = response.json()
562
- LOGGER.debug('Instagram profile URL=%s response=JSON', endpoint)
563
- except (requests.RequestException, ValueError, AttributeError, StopIteration):
564
- LOGGER.warning('Falha na requisição JSON do perfil Instagram URL=%s', endpoint, exc_info=True)
565
- if best_user:
566
- return best_user
231
+ for endpoint in endpoints:
232
+ try:
233
+ response = requests.get(endpoint, headers=headers, timeout=15)
234
+ if response.status_code >= 400:
567
235
  continue
568
- user = _extract_profile_user(payload)
569
- if isinstance(user, dict):
570
- best_user = _merge_profile(best_user, user)
571
- if _profile_quality(best_user) >= 4:
572
- return best_user
236
+ payload = response.json()
237
+ except (requests.RequestException, ValueError, AttributeError):
238
+ continue
239
+ user = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
240
+ if isinstance(user, dict):
241
+ best_user = merge_profile(best_user, user)
242
+ if profile_quality(best_user) >= 4:
243
+ return best_user
573
244
 
574
245
  curl = shutil.which('curl')
575
246
  if curl:
576
- LOGGER.debug('Fallback curl disponível em %s', curl)
577
247
  for endpoint in endpoints:
578
248
  try:
579
249
  completed = subprocess.run(
@@ -586,20 +256,21 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
586
256
  check=False,
587
257
  )
588
258
  payload = json.loads(completed.stdout) if completed.returncode == 0 and completed.stdout else None
589
- 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:])
590
- user = _extract_profile_user(payload)
259
+ user = ((payload.get('data') or {}).get('user') if isinstance(payload, dict) else None)
591
260
  if isinstance(user, dict):
592
- best_user = _merge_profile(best_user, user)
593
- if _profile_quality(best_user) >= 4:
261
+ best_user = merge_profile(best_user, user)
262
+ if profile_quality(best_user) >= 4:
594
263
  return best_user
595
264
  except (OSError, subprocess.SubprocessError, ValueError):
596
- LOGGER.warning('Falha no fallback curl do perfil Instagram URL=%s', endpoint, exc_info=True)
597
265
  continue
598
- else:
599
- LOGGER.warning('curl não está disponível no PATH; a tentar Playwright.')
600
- _streamlit_message('warning', 'curl não foi encontrado no PATH do Windows; será tentado o fallback Playwright.', 'curl_missing')
601
-
602
- return _fetch_profile_with_playwright(username, headers, cookies) or best_user
266
+ if platform.system() == 'Windows':
267
+ html_content = _fetch_instagram_html_with_playwright(f'https://www.instagram.com/{username}/', username)
268
+ if html_content:
269
+ html_user = _extract_profile_user_from_html(html_content, username)
270
+ if html_user:
271
+ print(f'Perfil Instagram extraído do HTML no Windows para @{username}')
272
+ return html_user
273
+ return best_user
603
274
 
604
275
 
605
276
  def _country_from_bloks(value: Any) -> str:
@@ -649,72 +320,25 @@ def _fetch_instagram_about_country(user_id: Any) -> str:
649
320
  }
650
321
  try:
651
322
  response = requests.post(ABOUT_ACCOUNT_URL, data=payload, headers=_instagram_headers(), cookies=cookies, timeout=20)
652
- LOGGER.debug('Instagram about-account URL=%s status=%s content_type=%s', ABOUT_ACCOUNT_URL, response.status_code, response.headers.get('content-type', ''))
653
323
  if response.status_code >= 400:
654
324
  return ''
655
- country = _country_from_bloks(response.json())
656
- LOGGER.debug('Instagram about-account country_found=%s', bool(country))
657
- return country
658
- except (requests.RequestException, ValueError, TypeError) as exc:
659
- LOGGER.warning('Falha ao consultar país público do Instagram: %s', exc, exc_info=True)
325
+ return _country_from_bloks(response.json())
326
+ except (requests.RequestException, ValueError, TypeError):
660
327
  return ''
661
328
 
662
329
 
663
- def _country_from_bio_fallback(bio: str) -> str:
664
- patterns = (
665
- r'\b(?:from|based in|located in|living in)\s+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .-]{1,60})',
666
- r'\b(?:de|baseado em|morando em|localizado em)\s+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .-]{1,60})',
667
- )
668
- for pattern in patterns:
669
- match = re.search(pattern, bio or '', flags=re.IGNORECASE)
670
- if match:
671
- return match.group(1).strip(' .,!;:')
672
- return ''
673
-
674
-
675
- def _profile_post_nodes(user: Mapping[str, Any]) -> list[Mapping[str, Any]]:
676
- """Return post nodes from both legacy GraphQL edges and current items payloads."""
677
- containers: list[Any] = [
678
- user.get('edge_owner_to_timeline_media'),
679
- user.get('timeline_media'),
680
- user.get('media'),
681
- user,
682
- ]
683
- nodes: list[Mapping[str, Any]] = []
684
- for container in containers:
685
- if not isinstance(container, Mapping):
686
- continue
687
- raw_items = container.get('edges') or container.get('items') or container.get('data') or []
688
- if isinstance(raw_items, Mapping):
689
- raw_items = raw_items.get('items') or raw_items.get('edges') or []
690
- if not isinstance(raw_items, list):
691
- continue
692
- for item in raw_items:
693
- node = item.get('node') if isinstance(item, Mapping) and isinstance(item.get('node'), Mapping) else item
694
- if isinstance(node, Mapping):
695
- nodes.append(node)
696
- return nodes
697
-
698
-
699
330
  def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
700
331
  followers = _profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
701
- following_node = user.get('edge_follow')
702
- following = normalize_instagram_metric(following_node) if isinstance(following_node, Mapping) else None
703
- following = following if following is not None else _profile_metric(user, 'follows', 'following', 'following_count', 'followingCount')
332
+ following = _profile_metric(user, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
704
333
  followers = followers if followers is not None else _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
705
334
  following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
706
- media_node = user.get('edge_owner_to_timeline_media') or user.get('media')
707
- media = (media_node.get('count') if isinstance(media_node, Mapping) else None)
708
- media = media if media is not None else _profile_metric(user, 'posts', 'post_count', 'media_count')
709
- biography = str(user.get('biography') or user.get('bio') or user.get('description') or '').strip()
710
- direct_country = _country_value(user.get('country') or user.get('country_code'))
711
- country = direct_country or extract_public_instagram_country(user) or _fetch_instagram_about_country(user.get('id') or user.get('pk'))
712
- country = country or _country_from_bio_fallback(biography)
713
- 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 '—')
335
+ media = ((user.get('edge_owner_to_timeline_media') or {}).get('count') if isinstance(user.get('edge_owner_to_timeline_media'), dict) else None)
336
+ biography = str(user.get('biography') or '').strip()
337
+ country = extract_public_instagram_country(user) or _fetch_instagram_about_country(user.get('id') or user.get('pk'))
714
338
  return {
715
339
  'id': f"instagram_{reference['username']}",
716
340
  **reference,
717
- 'name': str(user.get('full_name') or user.get('name') or user.get('username') or reference['username']).strip(),
341
+ 'name': str(user.get('full_name') or user.get('username') or reference['username']).strip(),
718
342
  'bio': normalize_instagram_bio(biography),
719
343
  'bio_raw': biography,
720
344
  'country': country,
@@ -726,7 +350,7 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
726
350
  'public_lookup': True,
727
351
  'metrics_source': 'instagram_web_profile_info',
728
352
  'last_public_lookup_at': datetime.now(timezone.utc).isoformat(),
729
- '_api_posts': [dict(node) for node in _profile_post_nodes(user)],
353
+ '_api_posts': (((user.get('edge_owner_to_timeline_media') or {}).get('edges') or []) if isinstance(user.get('edge_owner_to_timeline_media'), dict) else []),
730
354
  }
731
355
 
732
356
 
@@ -741,14 +365,10 @@ def normalize_instagram_metric(value: Any) -> int | None:
741
365
 
742
366
 
743
367
  PUBLIC_COUNTRY_KEYS = {
744
- 'about_this_account_country', 'account_based_in', 'account_country',
745
- 'country_name', 'country_of_origin', 'country_of_registration', 'location_country',
746
- 'transparency_country',
747
- }
748
- PUBLIC_TRANSPARENCY_KEYS = {
749
- 'about', 'about_account', 'about_this_account', 'about_this_account_data',
750
- 'account_transparency', 'transparency', 'account_info', 'profile_context',
368
+ 'about_this_account_country', 'account_based_in', 'account_country', 'country_name',
369
+ 'country_of_origin', 'country_of_registration', 'location_country', 'transparency_country',
751
370
  }
371
+ PUBLIC_TRANSPARENCY_KEYS = {'about', 'about_account', 'account_transparency', 'transparency', 'account_info'}
752
372
 
753
373
 
754
374
  def _country_value(value: Any) -> str:
@@ -792,25 +412,17 @@ def _profile_metric(user: Mapping[str, Any], *keys: str) -> int | None:
792
412
 
793
413
 
794
414
  def fetch_public_instagram_profile(source: str) -> IntegrationResult:
795
- LOGGER.debug('fetch_public_instagram_profile source=%s', source)
796
415
  try:
797
416
  reference = normalize_instagram_reference(source)
798
417
  except ValueError as exc:
799
418
  return IntegrationResult(False, str(exc), {})
800
419
  api_user = _fetch_web_profile_user(reference['username'])
801
420
  if api_user:
802
- data = _profile_data_from_api(api_user, reference)
803
- if api_user.get('_instagram_source') == 'instaloader':
804
- return IntegrationResult(True, 'Perfil encontrado via fallback instaloader. O país pode não estar disponível.', data)
805
- return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
806
- if platform.system() == 'Windows':
807
- return IntegrationResult(False, 'Não foi possível obter os dados do Instagram. Verifique sua conexão ou tente novamente mais tarde.', reference)
421
+ return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', _profile_data_from_api(api_user, reference))
808
422
  try:
809
423
  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)
810
424
  except requests.RequestException as exc:
811
- LOGGER.warning('Falha HTTP no perfil Instagram URL=%s: %s', reference['url'], exc, exc_info=True)
812
425
  return IntegrationResult(False, f'Não foi possível consultar o perfil público do Instagram: {exc}', reference)
813
- LOGGER.debug('Instagram HTML profile URL=%s status=%s content_type=%s response=HTML', reference['url'], response.status_code, response.headers.get('content-type', ''))
814
426
  if response.status_code >= 400:
815
427
  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})
816
428
  title = _meta(response.text, 'og:title') or reference['username']
@@ -835,8 +447,6 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
835
447
  embedded_country = extract_public_instagram_country(document)
836
448
  if embedded_country:
837
449
  break
838
- embedded_country = embedded_country or _country_from_bio_fallback(description)
839
- LOGGER.debug('Instagram HTML profile extracted bio=%s followers=%s following=%s posts=%s country=%s', bool(description), followers, following, posts, embedded_country or '—')
840
450
  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()}
841
451
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
842
452
 
@@ -898,104 +508,35 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
898
508
  }
899
509
 
900
510
 
901
- def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cookies: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
902
- # CORREÇÃO WINDOWS: posts são extraídos exclusivamente da resposta API capturada pelo Playwright.
903
- posts: list[dict[str, Any]] = []
904
- seen: set[str] = set()
905
- cookies = cookies or _instagram_cookies()
906
- try:
907
- from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, sync_playwright
908
- with sync_playwright() as playwright:
909
- browser = playwright.chromium.launch(headless=_instagram_playwright_headless())
910
- try:
911
- state_path = Path(__file__).resolve().parents[1] / 'storage' / 'instagram_storage_state.json'
912
- 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/'}
913
- 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}
914
- if state_path.is_file():
915
- context_kwargs['storage_state'] = str(state_path)
916
- context = browser.new_context(**context_kwargs)
917
- if cookies:
918
- context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
919
- page = context.new_page()
920
-
921
- def capture_profile_posts(response: Any) -> None:
922
- response_url = str(getattr(response, 'url', '') or '')
923
- if not any(marker in response_url for marker in ('/api/v1/users/web_profile_info/', '/graphql/query')):
924
- return
925
- LOGGER.info('Instagram Playwright posts response URL=%s status=%s', response_url, getattr(response, 'status', None))
926
- try:
927
- payload = response.json()
928
- user = _extract_profile_user(payload)
929
- for node in _profile_post_nodes(user) if isinstance(user, Mapping) else []:
930
- post = _post_from_node(dict(node))
931
- if post and post['id'] not in seen:
932
- seen.add(post['id'])
933
- posts.append(post)
934
- except (ValueError, TypeError, AttributeError) as exc:
935
- LOGGER.debug('Não foi possível interpretar posts web_profile_info: %s', exc)
936
-
937
- page.on('response', capture_profile_posts)
938
- try:
939
- page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=30000)
940
- state_path.parent.mkdir(parents=True, exist_ok=True)
941
- context.storage_state(path=str(state_path))
942
- except Exception as exc:
943
- LOGGER.debug('Instagram Playwright não carregou a página inicial para posts: %s', exc)
944
- response = page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
945
- LOGGER.debug('Instagram Playwright posts URL=%s status=%s', reference['url'], response.status if response else None)
946
- if response is None or response.status >= 400:
947
- return []
948
- page.wait_for_timeout(5000)
949
- try:
950
- page.wait_for_load_state('networkidle', timeout=10000)
951
- except (PlaywrightTimeoutError, TimeoutError):
952
- LOGGER.debug('Instagram Playwright networkidle excedido; usando posts API já capturados.')
953
- LOGGER.debug('Instagram Playwright API posts extracted=%s limit=%s', len(posts), limit)
954
- return posts[:max(1, int(limit))]
955
- finally:
956
- browser.close()
957
- except Exception as exc:
958
- LOGGER.warning('Falha no Playwright dos posts Instagram: %s', exc, exc_info=True)
959
- _streamlit_message('error', f'Playwright não conseguiu consultar os posts Instagram: {exc}', 'posts_playwright_error')
960
- return posts
961
-
962
-
963
511
  def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationResult:
964
- LOGGER.debug('fetch_public_instagram_posts source=%s limit=%s', source, limit)
965
- _ensure_windows_playwright()
966
512
  try:
967
513
  reference = normalize_instagram_reference(source)
968
514
  except ValueError as exc:
969
515
  return IntegrationResult(False, str(exc), {})
970
- if platform.system() == 'Windows':
971
- LOGGER.debug('Windows detectado: posts Instagram exclusivamente via Playwright.')
972
- posts = _fetch_posts_with_playwright(reference, limit, _instagram_cookies())
973
- if not posts:
974
- LOGGER.warning('Fallback para instaloader activado nos posts de @%s.', reference['username'])
975
- posts = fetch_posts_instaloader(reference['username'], limit)
976
- if posts:
977
- _streamlit_message('info', 'Dados obtidos via fallback (instaloader). O país pode não estar disponível.', 'posts_instaloader_fallback')
978
- else:
979
- _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')
980
- return IntegrationResult(bool(posts), 'Posts públicos encontrados.' if posts else 'Não foi possível obter os dados do Instagram. Verifique sua conexão ou tente novamente mais tarde.', reference | {'posts': posts[:max(1, int(limit))]})
981
516
  api_user = _fetch_web_profile_user(reference['username'])
982
517
  if api_user:
983
518
  api_posts: list[dict[str, Any]] = []
984
- for node in _profile_post_nodes(api_user):
985
- post = _post_from_node(dict(node))
986
- if post:
987
- api_posts.append(post)
519
+ for edge in ((api_user.get('edge_owner_to_timeline_media') or {}).get('edges') or []):
520
+ node = edge.get('node') if isinstance(edge, dict) else None
521
+ if isinstance(node, dict):
522
+ post = _post_from_node(node)
523
+ if post:
524
+ api_posts.append(post)
988
525
  if api_posts:
989
- LOGGER.debug('Instagram API posts extracted=%s response=JSON', len(api_posts))
990
526
  return IntegrationResult(True, 'Posts públicos encontrados.', reference | {'posts': api_posts[:max(1, int(limit))]})
991
527
  if api_user.get('is_private'):
992
528
  return IntegrationResult(False, 'Esta conta é privada. O Instagram não disponibiliza posts sem uma sessão autenticada.', reference | {'posts': [], 'is_private': True})
529
+ if platform.system() == 'Windows':
530
+ html_posts = api_user.get('_html_posts') or []
531
+ return IntegrationResult(bool(html_posts), 'Posts públicos encontrados.' if html_posts else 'Não foi possível encontrar posts públicos nesta página do Instagram.', reference | {'posts': html_posts[:max(1, int(limit))]})
532
+ if platform.system() == 'Windows':
533
+ html_content = _fetch_instagram_html_with_playwright(reference['url'], reference['username'])
534
+ posts = _extract_posts_from_html(html_content, limit) if html_content else []
535
+ 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))]})
993
536
  try:
994
- response = requests.get(reference['url'], headers={'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8'}, cookies=_instagram_cookies() or None, timeout=15)
537
+ 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)
995
538
  except requests.RequestException as exc:
996
- LOGGER.warning('Falha HTTP nos posts Instagram URL=%s: %s', reference['url'], exc, exc_info=True)
997
539
  return IntegrationResult(False, f'Não foi possível consultar os posts públicos do Instagram: {exc}', reference)
998
- LOGGER.debug('Instagram HTML posts URL=%s status=%s content_type=%s response=HTML', reference['url'], response.status_code, response.headers.get('content-type', ''))
999
540
  if response.status_code >= 400:
1000
541
  return IntegrationResult(False, f'O Instagram devolveu HTTP {response.status_code} ao consultar os posts.', reference | {'status_code': response.status_code})
1001
542
  posts: list[dict[str, Any]] = []
@@ -1011,10 +552,7 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
1011
552
  break
1012
553
  if len(posts) >= max(1, int(limit)):
1013
554
  break
1014
- if not posts:
1015
- posts = _fetch_posts_with_playwright(reference, limit, _instagram_cookies())
1016
- LOGGER.debug('Instagram posts extracted=%s limit=%s', len(posts), limit)
1017
- return IntegrationResult(bool(posts), 'Posts públicos encontrados.' if posts else 'Não foi possível obter os dados do Instagram. Verifique sua conexão ou tente novamente mais tarde.', reference | {'posts': posts[:max(1, int(limit))]})
555
+ 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))]})
1018
556
 
1019
557
 
1020
- __all__ = ['IntegrationResult', 'extract_public_instagram_country', 'fetch_posts_instaloader', 'fetch_profile_instaloader', 'fetch_public_instagram_posts', 'fetch_public_instagram_profile', 'normalize_instagram_bio', 'normalize_instagram_metric', 'normalize_instagram_reference']
558
+ __all__ = ['IntegrationResult', 'extract_public_instagram_country', 'fetch_public_instagram_posts', 'fetch_public_instagram_profile', 'normalize_instagram_bio', 'normalize_instagram_metric', 'normalize_instagram_reference']
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.56",
3
+ "version": "0.6.58",
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",
package/requirements.txt CHANGED
@@ -26,6 +26,5 @@ kagglehub>=0.3,<1
26
26
  supabase>=2.4,<3
27
27
  bilibili-api-python>=17.4,<18
28
28
  playwright>=1.45,<2
29
- instaloader>=4.14,<5
30
29
  composio>=0.21,<1
31
30
  justokenmax @ https://github.com/Kalmantic/jusTokenMax/archive/refs/heads/main.zip#subdirectory=python