@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.
- package/app/social_networks_ui.py +23 -123
- package/integrations/instagram_public.py +139 -601
- package/package.json +1 -1
- package/requirements.txt +0 -1
|
@@ -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"
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
252
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
129
|
-
"""
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
"""
|
|
282
|
-
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
except json.JSONDecodeError:
|
|
159
|
+
post = _post_from_node(node)
|
|
160
|
+
if not post or post['id'] in seen:
|
|
412
161
|
continue
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
497
|
-
except
|
|
179
|
+
return int(re.sub(r'[^0-9]', '', found.group(1)))
|
|
180
|
+
except ValueError:
|
|
498
181
|
pass
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
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 =
|
|
593
|
-
if
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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('
|
|
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':
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
|
985
|
-
|
|
986
|
-
if
|
|
987
|
-
|
|
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'},
|
|
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
|
|
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', '
|
|
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
package/requirements.txt
CHANGED