@danhachuel/thunderbolt 0.6.49 → 0.6.51
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.
|
@@ -216,8 +216,8 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
|
|
|
216
216
|
if load_clicked or refresh_clicked:
|
|
217
217
|
target = max(10, len(posts)) if refresh_clicked else 10
|
|
218
218
|
posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=target)
|
|
219
|
-
#
|
|
220
|
-
|
|
219
|
+
# DEBUG WINDOWS INSTAGRAM: manter até a validação final do carregamento público.
|
|
220
|
+
st.write({"instagram_posts_ok": posts_ok, "instagram_posts_message": posts_message, "instagram_posts_count": len(loaded_posts)})
|
|
221
221
|
if posts_ok:
|
|
222
222
|
posts = loaded_posts
|
|
223
223
|
_persist_instagram_posts(profile, posts)
|
|
@@ -132,18 +132,8 @@ class IntegrationResult:
|
|
|
132
132
|
|
|
133
133
|
|
|
134
134
|
def normalize_instagram_bio(value: Any) -> str:
|
|
135
|
-
"""Keep the
|
|
135
|
+
"""Keep the complete public biography exactly as returned by Instagram."""
|
|
136
136
|
bio = str(value or '').strip()
|
|
137
|
-
if not bio:
|
|
138
|
-
return ''
|
|
139
|
-
metric_words = r'followers|following|seguidores|seguindo|posts|publicações|publications'
|
|
140
|
-
metric_hits = re.findall(
|
|
141
|
-
rf'(?:\d[\d.,]*\s*(?:{metric_words})\b|\b(?:{metric_words})\s*\d[\d.,]*)',
|
|
142
|
-
bio,
|
|
143
|
-
flags=re.IGNORECASE,
|
|
144
|
-
)
|
|
145
|
-
if len(metric_hits) >= 2:
|
|
146
|
-
return ''
|
|
147
137
|
return bio
|
|
148
138
|
|
|
149
139
|
|
|
@@ -248,11 +238,55 @@ def _extract_profile_user(payload: Any) -> Mapping[str, Any] | None:
|
|
|
248
238
|
return next((candidate for candidate in candidates if isinstance(candidate, Mapping)), None)
|
|
249
239
|
|
|
250
240
|
|
|
251
|
-
def
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
241
|
+
def _shared_data_documents(document: str) -> list[Any]:
|
|
242
|
+
"""Extract JSON assigned to window._sharedData from the rendered profile HTML."""
|
|
243
|
+
decoder = json.JSONDecoder()
|
|
244
|
+
documents: list[Any] = []
|
|
245
|
+
patterns = (
|
|
246
|
+
r'window\s*\.\s*_sharedData\s*=\s*',
|
|
247
|
+
r'window\s*\[\s*["\']_sharedData["\']\s*\]\s*=\s*',
|
|
255
248
|
)
|
|
249
|
+
for pattern in patterns:
|
|
250
|
+
for match in re.finditer(pattern, document, flags=re.IGNORECASE):
|
|
251
|
+
start = document.find('{', match.end())
|
|
252
|
+
if start < 0:
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
parsed, _ = decoder.raw_decode(document[start:])
|
|
256
|
+
except json.JSONDecodeError:
|
|
257
|
+
continue
|
|
258
|
+
documents.append(parsed)
|
|
259
|
+
return documents
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _profile_users_from_document(document: str) -> list[Mapping[str, Any]]:
|
|
263
|
+
"""Return users found in legacy _sharedData and current embedded profile payloads."""
|
|
264
|
+
users: list[Mapping[str, Any]] = []
|
|
265
|
+
documents = _shared_data_documents(document) + _embedded_json_documents(document)
|
|
266
|
+
for payload in documents:
|
|
267
|
+
direct = _extract_profile_user(payload)
|
|
268
|
+
if isinstance(direct, Mapping):
|
|
269
|
+
users.append(direct)
|
|
270
|
+
for node in _walk_json(payload):
|
|
271
|
+
if not isinstance(node, Mapping):
|
|
272
|
+
continue
|
|
273
|
+
candidate = node.get('user')
|
|
274
|
+
if isinstance(candidate, Mapping) and any(key in candidate for key in ('biography', 'edge_follow', 'edge_followed_by', 'username')):
|
|
275
|
+
users.append(candidate)
|
|
276
|
+
unique: list[Mapping[str, Any]] = []
|
|
277
|
+
seen: set[int] = set()
|
|
278
|
+
for user in users:
|
|
279
|
+
marker = id(user)
|
|
280
|
+
if marker not in seen:
|
|
281
|
+
seen.add(marker)
|
|
282
|
+
unique.append(user)
|
|
283
|
+
return unique
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str] | None = None, cookies: Mapping[str, str] | None = None) -> dict[str, Any] | None:
|
|
287
|
+
"""Fetch the public profile page and read its embedded structured profile data."""
|
|
288
|
+
headers = headers or _instagram_headers()
|
|
289
|
+
cookies = cookies or _instagram_cookies()
|
|
256
290
|
best_user: dict[str, Any] | None = None
|
|
257
291
|
try:
|
|
258
292
|
from playwright.sync_api import sync_playwright
|
|
@@ -265,20 +299,15 @@ def _fetch_profile_with_playwright(username: str, headers: Mapping[str, str], co
|
|
|
265
299
|
)
|
|
266
300
|
if cookies:
|
|
267
301
|
page.context.add_cookies([{'name': name, 'value': value, 'domain': '.instagram.com', 'path': '/'} for name, value in cookies.items()])
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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)
|
|
302
|
+
url = f'https://www.instagram.com/{username}/'
|
|
303
|
+
response = page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
|
304
|
+
LOGGER.debug('Instagram Playwright profile URL=%s status=%s', url, response.status if response else None)
|
|
305
|
+
if response is None or response.status >= 400:
|
|
306
|
+
return None
|
|
307
|
+
document = page.content()
|
|
308
|
+
for user in _profile_users_from_document(document):
|
|
309
|
+
best_user = _merge_profile(best_user, user)
|
|
310
|
+
LOGGER.debug('Instagram Playwright _sharedData profile user=%s quality=%s', bool(best_user), _profile_quality(best_user or {}))
|
|
282
311
|
finally:
|
|
283
312
|
browser.close()
|
|
284
313
|
except Exception as exc:
|
|
@@ -292,13 +321,14 @@ def _fetch_web_profile_user(username: str) -> dict[str, Any] | None:
|
|
|
292
321
|
cookies = _instagram_cookies()
|
|
293
322
|
headers = _instagram_headers()
|
|
294
323
|
headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36'
|
|
324
|
+
if platform.system() == 'Windows':
|
|
325
|
+
LOGGER.debug('Windows detectado: perfil Instagram exclusivamente via Playwright.')
|
|
326
|
+
return _fetch_profile_with_playwright(username, headers, cookies)
|
|
327
|
+
|
|
295
328
|
endpoints = (
|
|
296
329
|
f'https://www.instagram.com/api/v1/users/web_profile_info/?username={username}',
|
|
297
330
|
f'https://i.instagram.com/api/v1/users/web_profile_info/?username={username}',
|
|
298
331
|
)
|
|
299
|
-
if platform.system() == 'Windows':
|
|
300
|
-
LOGGER.debug('Windows detectado: perfil Instagram exclusivamente via Playwright.')
|
|
301
|
-
return _fetch_profile_with_playwright(username, headers, cookies)
|
|
302
332
|
|
|
303
333
|
request_headers = [
|
|
304
334
|
headers,
|
|
@@ -429,7 +459,9 @@ def _country_from_bio_fallback(bio: str) -> str:
|
|
|
429
459
|
|
|
430
460
|
def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]) -> dict[str, Any]:
|
|
431
461
|
followers = _profile_metric(user, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
|
|
432
|
-
|
|
462
|
+
following_node = user.get('edge_follow')
|
|
463
|
+
following = normalize_instagram_metric(following_node) if isinstance(following_node, Mapping) else None
|
|
464
|
+
following = following if following is not None else _profile_metric(user, 'follows', 'following', 'following_count', 'followingCount')
|
|
433
465
|
followers = followers if followers is not None else _structured_metric_from_json(json.dumps(user), 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
|
|
434
466
|
following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
|
|
435
467
|
media = ((user.get('edge_owner_to_timeline_media') or {}).get('count') if isinstance(user.get('edge_owner_to_timeline_media'), dict) else None)
|
|
@@ -437,7 +469,7 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
|
|
|
437
469
|
biography = str(user.get('biography') or user.get('bio') or user.get('description') or '').strip()
|
|
438
470
|
country = extract_public_instagram_country(user) or _fetch_instagram_about_country(user.get('id') or user.get('pk'))
|
|
439
471
|
country = country or _country_from_bio_fallback(biography)
|
|
440
|
-
LOGGER.debug('Instagram profile extracted bio=%s followers=%s following=%s posts=%s country=%s', bool(biography), followers, following, media, country or '—')
|
|
472
|
+
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 '—')
|
|
441
473
|
return {
|
|
442
474
|
'id': f"instagram_{reference['username']}",
|
|
443
475
|
**reference,
|
|
@@ -523,6 +555,8 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
|
|
|
523
555
|
api_user = _fetch_web_profile_user(reference['username'])
|
|
524
556
|
if api_user:
|
|
525
557
|
return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', _profile_data_from_api(api_user, reference))
|
|
558
|
+
if platform.system() == 'Windows':
|
|
559
|
+
return IntegrationResult(False, 'Não foi possível extrair os dados públicos do Instagram com o Playwright.', reference)
|
|
526
560
|
try:
|
|
527
561
|
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)
|
|
528
562
|
except requests.RequestException as exc:
|
|
@@ -616,9 +650,11 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
616
650
|
}
|
|
617
651
|
|
|
618
652
|
|
|
619
|
-
def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cookies: Mapping[str, str]) -> list[dict[str, Any]]:
|
|
653
|
+
def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cookies: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
|
|
654
|
+
# CORREÇÃO WINDOWS: posts são extraídos exclusivamente do JSON/HTML obtido pelo Playwright.
|
|
620
655
|
posts: list[dict[str, Any]] = []
|
|
621
656
|
seen: set[str] = set()
|
|
657
|
+
cookies = cookies or _instagram_cookies()
|
|
622
658
|
try:
|
|
623
659
|
from playwright.sync_api import sync_playwright
|
|
624
660
|
with sync_playwright() as playwright:
|
|
@@ -635,13 +671,16 @@ def _fetch_posts_with_playwright(reference: Mapping[str, str], limit: int, cooki
|
|
|
635
671
|
if response is None or response.status >= 400:
|
|
636
672
|
return []
|
|
637
673
|
browser_document = page.content()
|
|
638
|
-
|
|
674
|
+
LOGGER.debug('Instagram Playwright posts document_bytes=%s', len(browser_document))
|
|
675
|
+
documents = _shared_data_documents(browser_document) + _embedded_json_documents(browser_document)
|
|
676
|
+
for document in documents:
|
|
639
677
|
for node in _walk_json(document):
|
|
640
678
|
post = _post_from_node(node)
|
|
641
679
|
if not post or post['id'] in seen:
|
|
642
680
|
continue
|
|
643
681
|
seen.add(post['id'])
|
|
644
682
|
posts.append(post)
|
|
683
|
+
LOGGER.debug('Instagram Playwright post extracted id=%s', post['id'])
|
|
645
684
|
if len(posts) >= max(1, int(limit)):
|
|
646
685
|
return posts
|
|
647
686
|
finally:
|
package/package.json
CHANGED