@danhachuel/thunderbolt 0.6.53 → 0.6.55
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.
- package/app/social_networks_ui.py +67 -13
- package/integrations/instagram_public.py +189 -61
- package/package.json +1 -1
|
@@ -17,7 +17,13 @@ from hermes_ui.influencers import InfluencerBackendError, STANDALONE_CONTENT_INF
|
|
|
17
17
|
from hermes_ui.storage import read_json, write_json
|
|
18
18
|
from hermes_ui.countries import COUNTRY_OPTIONS
|
|
19
19
|
from hermes_ui.languages import LANGUAGE_CODES, language_code, language_label
|
|
20
|
-
from integrations.instagram_public import
|
|
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
|
+
)
|
|
21
27
|
from integrations.meta_social import test_facebook_pages_api_card, test_instagram_api_card
|
|
22
28
|
|
|
23
29
|
|
|
@@ -72,11 +78,35 @@ def _country_key(value: Any) -> str:
|
|
|
72
78
|
COUNTRY_ALIASES = {
|
|
73
79
|
"brazil": "Brasil",
|
|
74
80
|
"brasil": "Brasil",
|
|
81
|
+
"br": "Brasil",
|
|
75
82
|
"usa": "Estados Unidos",
|
|
76
83
|
"us": "Estados Unidos",
|
|
77
84
|
"united states": "Estados Unidos",
|
|
78
85
|
"united states of america": "Estados Unidos",
|
|
86
|
+
"pt": "Portugal",
|
|
79
87
|
"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",
|
|
80
110
|
}
|
|
81
111
|
|
|
82
112
|
|
|
@@ -94,11 +124,17 @@ def _normalise_country(value: Any) -> str:
|
|
|
94
124
|
|
|
95
125
|
|
|
96
126
|
def _profile_country(profile: Mapping[str, Any]) -> str:
|
|
97
|
-
for key in ("country", "country_name", "country_of_registration", "account_country", "account_based_in"):
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
102
138
|
|
|
103
139
|
|
|
104
140
|
def _language_index(value: Any) -> int:
|
|
@@ -130,9 +166,13 @@ def _instagram_profile_storage_id(profile: Mapping[str, Any]) -> str:
|
|
|
130
166
|
|
|
131
167
|
|
|
132
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."""
|
|
133
170
|
widget_id = _instagram_profile_storage_id(profile)
|
|
134
171
|
for field in ("name", "bio", "country", "language", "posts", "followers", "following", "character"):
|
|
135
|
-
|
|
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)
|
|
136
176
|
|
|
137
177
|
|
|
138
178
|
def _instagram_result_widget_id(profile: Mapping[str, Any]) -> str:
|
|
@@ -143,9 +183,23 @@ def _instagram_result_widget_id(profile: Mapping[str, Any]) -> str:
|
|
|
143
183
|
|
|
144
184
|
|
|
145
185
|
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 []
|
|
146
188
|
if not isinstance(value, list):
|
|
147
189
|
return []
|
|
148
|
-
|
|
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
|
|
149
203
|
|
|
150
204
|
|
|
151
205
|
def _stored_instagram_posts(profile: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
@@ -219,10 +273,9 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
|
|
|
219
273
|
# DEBUG WINDOWS INSTAGRAM: manter até a validação final do carregamento público.
|
|
220
274
|
st.write({"instagram_posts_ok": posts_ok, "instagram_posts_message": posts_message, "instagram_posts_count": len(loaded_posts)})
|
|
221
275
|
if posts_ok:
|
|
222
|
-
posts = loaded_posts
|
|
276
|
+
posts = _normalise_instagram_posts(loaded_posts)
|
|
223
277
|
_persist_instagram_posts(profile, posts)
|
|
224
278
|
st.success(f"{len(posts)} posts carregados.")
|
|
225
|
-
st.rerun()
|
|
226
279
|
else:
|
|
227
280
|
st.warning(posts_message)
|
|
228
281
|
posts = st.session_state.get(state_key, posts)
|
|
@@ -243,9 +296,10 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
|
|
|
243
296
|
if st.button("Mostrar + 10", key=f"more_instagram_posts_{profile_id}", use_container_width=True):
|
|
244
297
|
posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=len(posts) + 10)
|
|
245
298
|
if posts_ok:
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
299
|
+
posts = _normalise_instagram_posts(loaded_posts)
|
|
300
|
+
_persist_instagram_posts(profile, posts)
|
|
301
|
+
else:
|
|
302
|
+
st.warning(posts_message)
|
|
249
303
|
|
|
250
304
|
|
|
251
305
|
def _api_card_defaults(kind: str) -> tuple[str, list[str], Callable[[Mapping[str, Any]], dict[str, Any]]]:
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
@@ -164,14 +191,23 @@ def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
|
|
|
164
191
|
)
|
|
165
192
|
profile = instaloader.Profile.from_username(loader.context, username)
|
|
166
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
|
+
)
|
|
167
202
|
return {
|
|
168
203
|
'id': str(getattr(profile, 'userid', '') or ''),
|
|
169
204
|
'username': str(getattr(profile, 'username', '') or username),
|
|
170
205
|
'full_name': str(getattr(profile, 'full_name', '') or ''),
|
|
171
206
|
'biography': str(getattr(profile, 'biography', '') or ''),
|
|
172
|
-
'follower_count': getattr(profile, 'followers', None),
|
|
173
|
-
'following_count': getattr(profile, 'followees', None),
|
|
207
|
+
'follower_count': getattr(profile, 'follower_count', getattr(profile, 'followers', None)),
|
|
208
|
+
'following_count': getattr(profile, 'following_count', getattr(profile, 'followees', None)),
|
|
174
209
|
'edge_owner_to_timeline_media': {'count': getattr(profile, 'mediacount', None)},
|
|
210
|
+
'country': country,
|
|
175
211
|
'is_private': bool(getattr(profile, 'is_private', False)),
|
|
176
212
|
'_instagram_source': 'instaloader',
|
|
177
213
|
}
|
|
@@ -180,7 +216,7 @@ def fetch_profile_instaloader(username: str) -> dict[str, Any] | None:
|
|
|
180
216
|
return None
|
|
181
217
|
|
|
182
218
|
|
|
183
|
-
def
|
|
219
|
+
def _fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, Any]]:
|
|
184
220
|
"""Fetch public posts anonymously through Instaloader when Playwright fails."""
|
|
185
221
|
instaloader = _instagram_instaloader()
|
|
186
222
|
if instaloader is None:
|
|
@@ -189,6 +225,8 @@ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, An
|
|
|
189
225
|
try:
|
|
190
226
|
loader = instaloader.Instaloader(
|
|
191
227
|
quiet=True,
|
|
228
|
+
request_timeout=30,
|
|
229
|
+
max_connection_attempts=1,
|
|
192
230
|
download_pictures=False,
|
|
193
231
|
download_videos=False,
|
|
194
232
|
download_video_thumbnails=False,
|
|
@@ -199,13 +237,21 @@ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, An
|
|
|
199
237
|
shortcode = str(getattr(post, 'shortcode', '') or '')
|
|
200
238
|
if not shortcode:
|
|
201
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)
|
|
202
246
|
posts.append({
|
|
203
247
|
'id': str(getattr(post, 'mediaid', '') or shortcode),
|
|
204
248
|
'shortcode': shortcode,
|
|
205
249
|
'url': f'https://www.instagram.com/p/{shortcode}/',
|
|
206
|
-
'image_url':
|
|
250
|
+
'image_url': image_url,
|
|
251
|
+
'display_url': image_url,
|
|
207
252
|
'caption': str(getattr(post, 'caption', '') or '').strip(),
|
|
208
|
-
'published_at':
|
|
253
|
+
'published_at': timestamp,
|
|
254
|
+
'timestamp': timestamp,
|
|
209
255
|
'is_video': bool(getattr(post, 'is_video', False)),
|
|
210
256
|
})
|
|
211
257
|
if len(posts) >= max(1, int(limit)):
|
|
@@ -216,6 +262,14 @@ def fetch_posts_instaloader(username: str, limit: int = 10) -> list[dict[str, An
|
|
|
216
262
|
return posts
|
|
217
263
|
|
|
218
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)
|
|
271
|
+
|
|
272
|
+
|
|
219
273
|
@dataclass
|
|
220
274
|
class IntegrationResult:
|
|
221
275
|
ok: bool
|
|
@@ -326,8 +380,17 @@ def _extract_profile_user(payload: Any) -> Mapping[str, Any] | None:
|
|
|
326
380
|
(payload.get('data') or {}).get('user') if isinstance(payload.get('data'), Mapping) else None,
|
|
327
381
|
(payload.get('graphql') or {}).get('user') if isinstance(payload.get('graphql'), Mapping) else None,
|
|
328
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,
|
|
329
384
|
]
|
|
330
|
-
|
|
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
|
|
331
394
|
|
|
332
395
|
|
|
333
396
|
def _shared_data_documents(document: str) -> list[Any]:
|
|
@@ -376,7 +439,7 @@ def _profile_users_from_document(document: str) -> list[Mapping[str, Any]]:
|
|
|
376
439
|
|
|
377
440
|
|
|
378
441
|
def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | None = None, cookies: Mapping[str, str] | None = None) -> dict[str, Any] | None:
|
|
379
|
-
"""Fetch
|
|
442
|
+
"""Fetch a public profile using browser state, API responses and embedded HTML data."""
|
|
380
443
|
headers = headers or _instagram_headers()
|
|
381
444
|
cookies = cookies or _instagram_cookies()
|
|
382
445
|
captured_user: dict[str, Any] | None = None
|
|
@@ -385,47 +448,72 @@ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | N
|
|
|
385
448
|
with sync_playwright() as playwright:
|
|
386
449
|
browser = playwright.chromium.launch(headless=_instagram_playwright_headless())
|
|
387
450
|
try:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
)
|
|
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)
|
|
392
457
|
if cookies:
|
|
393
|
-
|
|
458
|
+
context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
|
|
459
|
+
page = context.new_page()
|
|
394
460
|
|
|
395
461
|
def capture_profile_response(response: Any) -> None:
|
|
396
462
|
nonlocal captured_user
|
|
397
|
-
|
|
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')):
|
|
398
465
|
return
|
|
466
|
+
status = getattr(response, 'status', None)
|
|
467
|
+
LOGGER.info('Instagram Playwright API response URL=%s status=%s', response_url, status)
|
|
399
468
|
try:
|
|
400
469
|
payload = response.json()
|
|
401
|
-
user = (
|
|
470
|
+
user = _extract_profile_user(payload)
|
|
402
471
|
if isinstance(user, Mapping):
|
|
403
472
|
captured_user = _merge_profile(captured_user, user)
|
|
404
|
-
LOGGER.
|
|
473
|
+
LOGGER.info('Instagram Playwright perfil capturado URL=%s status=%s quality=%s', response_url, status, _profile_quality(captured_user))
|
|
405
474
|
except (ValueError, TypeError, AttributeError) as exc:
|
|
406
|
-
LOGGER.
|
|
475
|
+
LOGGER.warning('Não foi possível interpretar resposta Instagram URL=%s status=%s: %s', response_url, status, exc)
|
|
476
|
+
|
|
407
477
|
|
|
408
478
|
page.on('response', capture_profile_response)
|
|
409
479
|
try:
|
|
410
|
-
page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=
|
|
411
|
-
|
|
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)
|
|
412
484
|
except Exception as exc:
|
|
413
485
|
LOGGER.debug('Instagram Playwright não carregou a página inicial: %s', exc)
|
|
414
486
|
url = f'https://www.instagram.com/{username}/'
|
|
415
|
-
response =
|
|
416
|
-
|
|
417
|
-
if response is None or response.status >= 400:
|
|
418
|
-
LOGGER.warning('Instagram Playwright perfil HTTP status=%s URL=%s', response.status if response else None, url)
|
|
419
|
-
return captured_user
|
|
487
|
+
response = None
|
|
488
|
+
status = None
|
|
420
489
|
try:
|
|
421
|
-
|
|
490
|
+
response = page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
|
491
|
+
status = response.status if response else None
|
|
422
492
|
except Exception as exc:
|
|
423
|
-
LOGGER.
|
|
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)
|
|
495
|
+
try:
|
|
496
|
+
page.wait_for_timeout(5000)
|
|
497
|
+
except Exception:
|
|
498
|
+
pass
|
|
424
499
|
try:
|
|
425
500
|
page.wait_for_load_state('networkidle', timeout=10000)
|
|
426
501
|
except (PlaywrightTimeoutError, TimeoutError):
|
|
427
|
-
LOGGER.debug('Instagram Playwright networkidle excedido; usando
|
|
428
|
-
|
|
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)
|
|
429
517
|
finally:
|
|
430
518
|
browser.close()
|
|
431
519
|
except Exception as exc:
|
|
@@ -445,7 +533,12 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
|
445
533
|
if playwright_user:
|
|
446
534
|
return playwright_user
|
|
447
535
|
LOGGER.warning('Fallback para instaloader activado no perfil @%s.', username)
|
|
448
|
-
|
|
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
|
|
449
542
|
|
|
450
543
|
endpoints = (
|
|
451
544
|
f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
|
|
@@ -579,6 +672,30 @@ def _country_from_bio_fallback(bio: str) -> str:
|
|
|
579
672
|
return ''
|
|
580
673
|
|
|
581
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
|
+
|
|
582
699
|
def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
|
|
583
700
|
followers = _profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
|
|
584
701
|
following_node = user.get('edge_follow')
|
|
@@ -586,10 +703,12 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
|
|
|
586
703
|
following = following if following is not None else _profile_metric(user, 'follows', 'following', 'following_count', 'followingCount')
|
|
587
704
|
followers = followers if followers is not None else _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
|
|
588
705
|
following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
|
|
589
|
-
|
|
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)
|
|
590
708
|
media = media if media is not None else _profile_metric(user, 'posts', 'post_count', 'media_count')
|
|
591
709
|
biography = str(user.get('biography') or user.get('bio') or user.get('description') or '').strip()
|
|
592
|
-
|
|
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'))
|
|
593
712
|
country = country or _country_from_bio_fallback(biography)
|
|
594
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 '—')
|
|
595
714
|
return {
|
|
@@ -607,7 +726,7 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
|
|
|
607
726
|
'public_lookup': True,
|
|
608
727
|
'metrics_source': 'instagram_web_profile_info',
|
|
609
728
|
'last_public_lookup_at': datetime.now(timezone.utc).isoformat(),
|
|
610
|
-
'_api_posts':
|
|
729
|
+
'_api_posts': [dict(node) for node in _profile_post_nodes(user)],
|
|
611
730
|
}
|
|
612
731
|
|
|
613
732
|
|
|
@@ -622,10 +741,14 @@ def normalize_instagram_metric(value: Any) -> int | None:
|
|
|
622
741
|
|
|
623
742
|
|
|
624
743
|
PUBLIC_COUNTRY_KEYS = {
|
|
625
|
-
'about_this_account_country', 'account_based_in', 'account_country',
|
|
626
|
-
'
|
|
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',
|
|
627
751
|
}
|
|
628
|
-
PUBLIC_TRANSPARENCY_KEYS = {'about', 'about_account', 'account_transparency', 'transparency', 'account_info'}
|
|
629
752
|
|
|
630
753
|
|
|
631
754
|
def _country_value(value: Any) -> str:
|
|
@@ -785,26 +908,26 @@ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cooki
|
|
|
785
908
|
with sync_playwright() as playwright:
|
|
786
909
|
browser = playwright.chromium.launch(headless=_instagram_playwright_headless())
|
|
787
910
|
try:
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
)
|
|
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)
|
|
792
917
|
if cookies:
|
|
793
|
-
|
|
918
|
+
context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
|
|
919
|
+
page = context.new_page()
|
|
794
920
|
|
|
795
921
|
def capture_profile_posts(response: Any) -> None:
|
|
796
|
-
|
|
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')):
|
|
797
924
|
return
|
|
925
|
+
LOGGER.info('Instagram Playwright posts response URL=%s status=%s', response_url, getattr(response, 'status', None))
|
|
798
926
|
try:
|
|
799
927
|
payload = response.json()
|
|
800
|
-
user = (
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
for edge in edges or []:
|
|
804
|
-
node = edge.get('node') if isinstance(edge, Mapping) else None
|
|
805
|
-
if not isinstance(node, dict):
|
|
806
|
-
continue
|
|
807
|
-
post = _post_from_node(node)
|
|
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))
|
|
808
931
|
if post and post['id'] not in seen:
|
|
809
932
|
seen.add(post['id'])
|
|
810
933
|
posts.append(post)
|
|
@@ -813,13 +936,16 @@ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cooki
|
|
|
813
936
|
|
|
814
937
|
page.on('response', capture_profile_posts)
|
|
815
938
|
try:
|
|
816
|
-
page.goto('https://www.instagram.com/', wait_until='domcontentloaded', timeout=
|
|
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))
|
|
817
942
|
except Exception as exc:
|
|
818
943
|
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=
|
|
944
|
+
response = page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
|
|
820
945
|
LOGGER.debug('Instagram Playwright posts URL=%s status=%s', reference['url'], response.status if response else None)
|
|
821
946
|
if response is None or response.status >= 400:
|
|
822
947
|
return []
|
|
948
|
+
page.wait_for_timeout(5000)
|
|
823
949
|
try:
|
|
824
950
|
page.wait_for_load_state('networkidle', timeout=10000)
|
|
825
951
|
except (PlaywrightTimeoutError, TimeoutError):
|
|
@@ -847,16 +973,18 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
|
|
|
847
973
|
if not posts:
|
|
848
974
|
LOGGER.warning('Fallback para instaloader activado nos posts de @%s.', reference['username'])
|
|
849
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')
|
|
850
980
|
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
981
|
api_user = _fetch_web_profile_user(reference['username'])
|
|
852
982
|
if api_user:
|
|
853
983
|
api_posts: list[dict[str, Any]] = []
|
|
854
|
-
for
|
|
855
|
-
|
|
856
|
-
if
|
|
857
|
-
post
|
|
858
|
-
if post:
|
|
859
|
-
api_posts.append(post)
|
|
984
|
+
for node in _profile_post_nodes(api_user):
|
|
985
|
+
post = _post_from_node(dict(node))
|
|
986
|
+
if post:
|
|
987
|
+
api_posts.append(post)
|
|
860
988
|
if api_posts:
|
|
861
989
|
LOGGER.debug('Instagram API posts extracted=%s response=JSON', len(api_posts))
|
|
862
990
|
return IntegrationResult(True, 'Posts públicos encontrados.', reference | {'posts': api_posts[:max(1, int(limit))]})
|
package/package.json
CHANGED