@danhachuel/thunderbolt 0.6.48 → 0.6.49
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/integrations/instagram_public.py +125 -102
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import re
|
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
|
+
import platform
|
|
7
8
|
import sys
|
|
8
9
|
import shutil
|
|
9
10
|
import subprocess
|
|
@@ -213,48 +214,91 @@ def _structured_metric_from_json(document: str, *keys: str) -> int | None:
|
|
|
213
214
|
return None
|
|
214
215
|
|
|
215
216
|
|
|
217
|
+
def _profile_quality(user: Mapping[str, Any]) -> int:
|
|
218
|
+
groups = (
|
|
219
|
+
('biography', 'bio', 'description'),
|
|
220
|
+
('edge_follow', 'following', 'following_count', 'followingCount', 'follows'),
|
|
221
|
+
('edge_followed_by', 'followers', 'follower_count', 'followers_count', 'followerCount'),
|
|
222
|
+
('edge_owner_to_timeline_media', 'posts', 'post_count', 'media_count'),
|
|
223
|
+
('username', 'user_name', 'handle'),
|
|
224
|
+
)
|
|
225
|
+
return sum(any(user.get(key) not in (None, '', {}, []) for key in group) for group in groups)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _merge_profile(base: dict[str, Any] | None, candidate: Mapping[str, Any]) -> dict[str, Any]:
|
|
229
|
+
merged = dict(base or {})
|
|
230
|
+
for key, value in candidate.items():
|
|
231
|
+
if value not in (None, '', [], {}):
|
|
232
|
+
if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
|
|
233
|
+
merged[key] = {**merged[key], **value}
|
|
234
|
+
else:
|
|
235
|
+
merged[key] = value
|
|
236
|
+
return merged
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _extract_profile_user(payload: Any) -> Mapping[str, Any] | None:
|
|
240
|
+
if not isinstance(payload, Mapping):
|
|
241
|
+
return None
|
|
242
|
+
candidates = [
|
|
243
|
+
payload.get('user'),
|
|
244
|
+
(payload.get('data') or {}).get('user') if isinstance(payload.get('data'), Mapping) else None,
|
|
245
|
+
(payload.get('graphql') or {}).get('user') if isinstance(payload.get('graphql'), Mapping) else None,
|
|
246
|
+
(payload.get('data') or {}).get('profile') if isinstance(payload.get('data'), Mapping) else None,
|
|
247
|
+
]
|
|
248
|
+
return next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str], cookies: Mapping[str, str]) -> dict[str, Any] | None:
|
|
252
|
+
endpoints = (
|
|
253
|
+
f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
|
|
254
|
+
f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
|
|
255
|
+
)
|
|
256
|
+
best_user: dict[str, Any] | None = None
|
|
257
|
+
try:
|
|
258
|
+
from playwright.sync_api import sync_playwright
|
|
259
|
+
with sync_playwright() as playwright:
|
|
260
|
+
browser = playwright.chromium.launch(headless=True)
|
|
261
|
+
try:
|
|
262
|
+
page = browser.new_page(
|
|
263
|
+
user_agent=headers['User-Agent'],
|
|
264
|
+
extra_http_headers={'x-ig-app-id': headers['x-ig-app-id'], 'Accept-Language': 'en-US,en;q=0.9'},
|
|
265
|
+
)
|
|
266
|
+
if cookies:
|
|
267
|
+
page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
|
|
268
|
+
for endpoint in endpoints:
|
|
269
|
+
try:
|
|
270
|
+
response = page.goto(endpoint, wait_until='domcontentloaded', timeout=30000)
|
|
271
|
+
LOGGER.debug('Instagram Playwright profile URL=%s status=%s', endpoint, response.status if response else None)
|
|
272
|
+
if response is None or response.status >= 400:
|
|
273
|
+
continue
|
|
274
|
+
payload = json.loads(page.locator('body').inner_text(timeout=5000))
|
|
275
|
+
user = _extract_profile_user(payload)
|
|
276
|
+
if isinstance(user, dict):
|
|
277
|
+
best_user = _merge_profile(best_user, user)
|
|
278
|
+
if _profile_quality(best_user) >= 4:
|
|
279
|
+
return best_user
|
|
280
|
+
except (ValueError, TimeoutError) as exc:
|
|
281
|
+
LOGGER.warning('Falha ao interpretar resposta Playwright do perfil URL=%s: %s', endpoint, exc)
|
|
282
|
+
finally:
|
|
283
|
+
browser.close()
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
LOGGER.warning('Falha no Playwright do perfil Instagram: %s', exc, exc_info=True)
|
|
286
|
+
_streamlit_message('error', f'Playwright não conseguiu consultar o perfil Instagram: {exc}', 'profile_playwright_error')
|
|
287
|
+
return best_user
|
|
288
|
+
|
|
289
|
+
|
|
216
290
|
def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
217
291
|
_ensure_windows_playwright()
|
|
218
292
|
cookies = _instagram_cookies()
|
|
219
293
|
headers = _instagram_headers()
|
|
220
294
|
headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36'
|
|
221
|
-
# The www host is the current public web endpoint. The legacy i host is
|
|
222
|
-
# retained as a fallback because Instagram rate-limits the two hosts
|
|
223
|
-
# independently and their availability can vary by region.
|
|
224
295
|
endpoints = (
|
|
225
296
|
f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
|
|
226
297
|
f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
|
|
227
298
|
)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
('edge_follow', 'following', 'following_count', 'followingCount', 'follows'),
|
|
232
|
-
('edge_followed_by', 'followers', 'follower_count', 'followers_count', 'followerCount'),
|
|
233
|
-
('edge_owner_to_timeline_media', 'posts', 'post_count', 'media_count'),
|
|
234
|
-
('username', 'user_name', 'handle'),
|
|
235
|
-
)
|
|
236
|
-
return sum(any(user.get(key) not in (None, '', {}, []) for key in group) for group in groups)
|
|
237
|
-
|
|
238
|
-
def merge_profile(base: dict[str, Any] | None, candidate: Mapping[str, Any]) -> dict[str, Any]:
|
|
239
|
-
merged = dict(base or {})
|
|
240
|
-
for key, value in candidate.items():
|
|
241
|
-
if value not in (None, '', [], {}):
|
|
242
|
-
if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
|
|
243
|
-
merged[key] = {**merged[key], **value}
|
|
244
|
-
else:
|
|
245
|
-
merged[key] = value
|
|
246
|
-
return merged
|
|
247
|
-
|
|
248
|
-
def extract_user(payload: Any) -> Mapping[str, Any] | None:
|
|
249
|
-
if not isinstance(payload, Mapping):
|
|
250
|
-
return None
|
|
251
|
-
candidates = [
|
|
252
|
-
payload.get('user'),
|
|
253
|
-
(payload.get('data') or {}).get('user') if isinstance(payload.get('data'), Mapping) else None,
|
|
254
|
-
(payload.get('graphql') or {}).get('user') if isinstance(payload.get('graphql'), Mapping) else None,
|
|
255
|
-
(payload.get('data') or {}).get('profile') if isinstance(payload.get('data'), Mapping) else None,
|
|
256
|
-
]
|
|
257
|
-
return next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
|
|
299
|
+
if platform.system() == 'Windows':
|
|
300
|
+
LOGGER.debug('Windows detectado: perfil Instagram exclusivamente via Playwright.')
|
|
301
|
+
return _fetch_profile_with_playwright(username, headers, cookies)
|
|
258
302
|
|
|
259
303
|
request_headers = [
|
|
260
304
|
headers,
|
|
@@ -276,10 +320,10 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
|
276
320
|
if best_user:
|
|
277
321
|
return best_user
|
|
278
322
|
continue
|
|
279
|
-
user =
|
|
323
|
+
user = _extract_profile_user(payload)
|
|
280
324
|
if isinstance(user, dict):
|
|
281
|
-
best_user =
|
|
282
|
-
if
|
|
325
|
+
best_user = _merge_profile(best_user, user)
|
|
326
|
+
if _profile_quality(best_user) >= 4:
|
|
283
327
|
return best_user
|
|
284
328
|
|
|
285
329
|
curl = shutil.which('curl')
|
|
@@ -298,10 +342,10 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
|
298
342
|
)
|
|
299
343
|
payload = json.loads(completed.stdout) if completed.returncode == 0 and completed.stdout else None
|
|
300
344
|
LOGGER.debug('Instagram curl URL=%s returncode=%s stdout_bytes=%s stderr=%s', endpoint, completed.returncode, len(completed.stdout or ''), (completed.stderr or '').strip()[-500:])
|
|
301
|
-
user =
|
|
345
|
+
user = _extract_profile_user(payload)
|
|
302
346
|
if isinstance(user, dict):
|
|
303
|
-
best_user =
|
|
304
|
-
if
|
|
347
|
+
best_user = _merge_profile(best_user, user)
|
|
348
|
+
if _profile_quality(best_user) >= 4:
|
|
305
349
|
return best_user
|
|
306
350
|
except (OSError, subprocess.SubprocessError, ValueError):
|
|
307
351
|
LOGGER.warning('Falha no fallback curl do perfil Instagram URL=%s', endpoint, exc_info=True)
|
|
@@ -310,39 +354,7 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
|
310
354
|
LOGGER.warning('curl não está disponível no PATH; a tentar Playwright.')
|
|
311
355
|
_streamlit_message('warning', 'curl não foi encontrado no PATH do Windows; será tentado o fallback Playwright.', 'curl_missing')
|
|
312
356
|
|
|
313
|
-
|
|
314
|
-
# clientes HTTP, embora entregue o JSON completo a um navegador Chromium.
|
|
315
|
-
# O fallback é opcional: a aplicação continua funcional sem Playwright.
|
|
316
|
-
try:
|
|
317
|
-
from playwright.sync_api import sync_playwright
|
|
318
|
-
with sync_playwright() as playwright:
|
|
319
|
-
browser = playwright.chromium.launch(headless=True)
|
|
320
|
-
try:
|
|
321
|
-
page = browser.new_page(
|
|
322
|
-
user_agent=headers['User-Agent'],
|
|
323
|
-
extra_http_headers={'x-ig-app-id': headers['x-ig-app-id'], 'Accept-Language': 'en-US,en;q=0.9'},
|
|
324
|
-
)
|
|
325
|
-
if cookies:
|
|
326
|
-
page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
|
|
327
|
-
for endpoint in endpoints:
|
|
328
|
-
try:
|
|
329
|
-
response = page.goto(endpoint, wait_until='domcontentloaded', timeout=30000)
|
|
330
|
-
if response is None or response.status >= 400:
|
|
331
|
-
continue
|
|
332
|
-
payload = json.loads(page.locator('body').inner_text(timeout=5000))
|
|
333
|
-
user = extract_user(payload)
|
|
334
|
-
if isinstance(user, dict):
|
|
335
|
-
best_user = merge_profile(best_user, user)
|
|
336
|
-
if profile_quality(best_user) >= 4:
|
|
337
|
-
return best_user
|
|
338
|
-
except (ValueError, TimeoutError):
|
|
339
|
-
continue
|
|
340
|
-
finally:
|
|
341
|
-
browser.close()
|
|
342
|
-
except Exception as exc:
|
|
343
|
-
LOGGER.warning('Falha no fallback Playwright do perfil Instagram: %s', exc, exc_info=True)
|
|
344
|
-
_streamlit_message('error', f'Fallback Playwright do perfil Instagram falhou: {exc}. Execute: python -m playwright install chromium', 'profile_playwright_error')
|
|
345
|
-
return best_user
|
|
357
|
+
return _fetch_profile_with_playwright(username, headers, cookies) or best_user
|
|
346
358
|
|
|
347
359
|
|
|
348
360
|
def _country_from_bloks(value: Any) -> str:
|
|
@@ -604,6 +616,42 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
604
616
|
}
|
|
605
617
|
|
|
606
618
|
|
|
619
|
+
def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cookies: Mapping[str, str]) -> list[dict[str, Any]]:
|
|
620
|
+
posts: list[dict[str, Any]] = []
|
|
621
|
+
seen: set[str] = set()
|
|
622
|
+
try:
|
|
623
|
+
from playwright.sync_api import sync_playwright
|
|
624
|
+
with sync_playwright() as playwright:
|
|
625
|
+
browser = playwright.chromium.launch(headless=True)
|
|
626
|
+
try:
|
|
627
|
+
page = browser.new_page(
|
|
628
|
+
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
|
|
629
|
+
extra_http_headers={'x-ig-app-id': '936619743392459', 'Accept-Language': 'en-US,en;q=0.9'},
|
|
630
|
+
)
|
|
631
|
+
if cookies:
|
|
632
|
+
page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
|
|
633
|
+
response = page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
|
|
634
|
+
LOGGER.debug('Instagram Playwright posts URL=%s status=%s', reference['url'], response.status if response else None)
|
|
635
|
+
if response is None or response.status >= 400:
|
|
636
|
+
return []
|
|
637
|
+
browser_document = page.content()
|
|
638
|
+
for document in _embedded_json_documents(browser_document):
|
|
639
|
+
for node in _walk_json(document):
|
|
640
|
+
post = _post_from_node(node)
|
|
641
|
+
if not post or post['id'] in seen:
|
|
642
|
+
continue
|
|
643
|
+
seen.add(post['id'])
|
|
644
|
+
posts.append(post)
|
|
645
|
+
if len(posts) >= max(1, int(limit)):
|
|
646
|
+
return posts
|
|
647
|
+
finally:
|
|
648
|
+
browser.close()
|
|
649
|
+
except Exception as exc:
|
|
650
|
+
LOGGER.warning('Falha no Playwright dos posts Instagram: %s', exc, exc_info=True)
|
|
651
|
+
_streamlit_message('error', f'Playwright não conseguiu consultar os posts Instagram: {exc}', 'posts_playwright_error')
|
|
652
|
+
return posts
|
|
653
|
+
|
|
654
|
+
|
|
607
655
|
def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationResult:
|
|
608
656
|
LOGGER.debug('fetch_public_instagram_posts source=%s limit=%s', source, limit)
|
|
609
657
|
_ensure_windows_playwright()
|
|
@@ -611,6 +659,10 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
|
|
|
611
659
|
reference = normalize_instagram_reference(source)
|
|
612
660
|
except ValueError as exc:
|
|
613
661
|
return IntegrationResult(False, str(exc), {})
|
|
662
|
+
if platform.system() == 'Windows':
|
|
663
|
+
LOGGER.debug('Windows detectado: posts Instagram exclusivamente via Playwright.')
|
|
664
|
+
posts = _fetch_posts_with_playwright(reference, limit, _instagram_cookies())
|
|
665
|
+
return IntegrationResult(bool(posts), 'Posts públicos encontrados.' if posts else 'Não foi possível encontrar posts públicos nesta página do Instagram.', reference | {'posts': posts[:max(1, int(limit))]})
|
|
614
666
|
api_user = _fetch_web_profile_user(reference['username'])
|
|
615
667
|
if api_user:
|
|
616
668
|
api_posts: list[dict[str, Any]] = []
|
|
@@ -647,36 +699,7 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
|
|
|
647
699
|
if len(posts) >= max(1, int(limit)):
|
|
648
700
|
break
|
|
649
701
|
if not posts:
|
|
650
|
-
|
|
651
|
-
from playwright.sync_api import sync_playwright
|
|
652
|
-
with sync_playwright() as playwright:
|
|
653
|
-
browser = playwright.chromium.launch(headless=True)
|
|
654
|
-
try:
|
|
655
|
-
page = browser.new_page(
|
|
656
|
-
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36',
|
|
657
|
-
extra_http_headers={'x-ig-app-id': '936619743392459', 'Accept-Language': 'en-US,en;q=0.9'},
|
|
658
|
-
)
|
|
659
|
-
cookies = _instagram_cookies()
|
|
660
|
-
if cookies:
|
|
661
|
-
page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
|
|
662
|
-
page.goto(reference['url'], wait_until='domcontentloaded', timeout=30000)
|
|
663
|
-
browser_document = page.content()
|
|
664
|
-
for document in _embedded_json_documents(browser_document):
|
|
665
|
-
for node in _walk_json(document):
|
|
666
|
-
post = _post_from_node(node)
|
|
667
|
-
if not post or post['id'] in seen:
|
|
668
|
-
continue
|
|
669
|
-
seen.add(post['id'])
|
|
670
|
-
posts.append(post)
|
|
671
|
-
if len(posts) >= max(1, int(limit)):
|
|
672
|
-
break
|
|
673
|
-
if len(posts) >= max(1, int(limit)):
|
|
674
|
-
break
|
|
675
|
-
finally:
|
|
676
|
-
browser.close()
|
|
677
|
-
except Exception as exc:
|
|
678
|
-
LOGGER.warning('Falha no fallback Playwright dos posts Instagram: %s', exc, exc_info=True)
|
|
679
|
-
_streamlit_message('error', f'Fallback Playwright dos posts Instagram falhou: {exc}. Execute: python -m playwright install chromium', 'posts_playwright_error')
|
|
702
|
+
posts = _fetch_posts_with_playwright(reference, limit, _instagram_cookies())
|
|
680
703
|
LOGGER.debug('Instagram posts extracted=%s limit=%s', len(posts), limit)
|
|
681
704
|
return IntegrationResult(bool(posts), 'Posts públicos encontrados.' if posts else 'Não foi possível encontrar posts públicos nesta página do Instagram.', reference | {'posts': posts[:max(1, int(limit))]})
|
|
682
705
|
|
package/package.json
CHANGED