@danhachuel/thunderbolt 0.6.32 → 0.6.34

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.
@@ -4,6 +4,7 @@ import io
4
4
  import re
5
5
  import uuid
6
6
  import zipfile
7
+ import unicodedata
7
8
  from typing import Any, Callable, Mapping
8
9
 
9
10
  import requests
@@ -34,8 +35,9 @@ def _metric(value: Any) -> str:
34
35
  def _profile_metric(profile: Mapping[str, Any], *keys: str) -> Any:
35
36
  for key in keys:
36
37
  value = profile.get(key)
37
- if value not in (None, ""):
38
- return value
38
+ normalized = normalize_instagram_metric(value)
39
+ if normalized is not None:
40
+ return normalized
39
41
  return None
40
42
 
41
43
 
@@ -50,21 +52,48 @@ def _render_instagram_bio(value: Any) -> None:
50
52
 
51
53
 
52
54
  def _profile_bio(profile: Mapping[str, Any]) -> str:
53
- return normalize_instagram_bio(profile.get("bio")) or normalize_instagram_bio(profile.get("bio_raw"))
55
+ for key in ("bio", "bio_raw", "biography", "description"):
56
+ bio = normalize_instagram_bio(profile.get(key))
57
+ if bio:
58
+ return bio
59
+ return ""
60
+
61
+
62
+ def _country_key(value: Any) -> str:
63
+ normalized = unicodedata.normalize("NFKD", _clean(value))
64
+ return "".join(char for char in normalized if not unicodedata.combining(char)).casefold()
54
65
 
55
66
 
56
- def _infer_country_from_bio(value: Any) -> str:
57
- bio = _clean(value).casefold()
58
- if not bio:
67
+ COUNTRY_ALIASES = {
68
+ "brazil": "Brasil",
69
+ "brasil": "Brasil",
70
+ "usa": "Estados Unidos",
71
+ "us": "Estados Unidos",
72
+ "united states": "Estados Unidos",
73
+ "united states of america": "Estados Unidos",
74
+ "portugal": "Portugal",
75
+ }
76
+
77
+
78
+ def _normalise_country(value: Any) -> str:
79
+ clean_value = _clean(value)
80
+ if not clean_value:
59
81
  return ""
60
- for country in sorted(COUNTRY_OPTIONS, key=len, reverse=True):
61
- if re.search(rf"(?<!\w){re.escape(country.casefold())}(?!\w)", bio):
82
+ if clean_value in COUNTRY_OPTIONS:
83
+ return clean_value
84
+ key = _country_key(clean_value)
85
+ for country in COUNTRY_OPTIONS:
86
+ if _country_key(country) == key:
62
87
  return country
63
- return ""
88
+ return COUNTRY_ALIASES.get(key, "")
64
89
 
65
90
 
66
91
  def _profile_country(profile: Mapping[str, Any]) -> str:
67
- return _clean(profile.get("country")) or _infer_country_from_bio(_profile_bio(profile))
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 ""
68
97
 
69
98
 
70
99
  def _language_index(value: Any) -> int:
@@ -78,7 +107,7 @@ def _country_options() -> tuple[str, ...]:
78
107
 
79
108
  def _country_index(value: Any) -> int:
80
109
  options = _country_options()
81
- value = _clean(value)
110
+ value = _normalise_country(value)
82
111
  return options.index(value) if value in options else 0
83
112
 
84
113
 
@@ -90,6 +119,46 @@ def _instagram_posts_key(profile_id: str) -> str:
90
119
  return f"instagram_posts_{profile_id}"
91
120
 
92
121
 
122
+ def _instagram_profile_storage_id(profile: Mapping[str, Any]) -> str:
123
+ value = _clean(profile.get("id")) or _clean(profile.get("username")) or _clean(profile.get("handle")).lstrip("@") or _clean(profile.get("url"))
124
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", value) or "unknown"
125
+
126
+
127
+ def _normalise_instagram_posts(value: Any) -> list[dict[str, Any]]:
128
+ if not isinstance(value, list):
129
+ return []
130
+ return [dict(post) for post in value if isinstance(post, Mapping) and _clean(post.get("id") or post.get("url"))]
131
+
132
+
133
+ def _stored_instagram_posts(profile: Mapping[str, Any]) -> list[dict[str, Any]]:
134
+ profile_id = _instagram_profile_storage_id(profile)
135
+ state_key = _instagram_posts_key(profile_id)
136
+ posts = _normalise_instagram_posts(st.session_state.get(state_key, []))
137
+ if posts:
138
+ return posts
139
+ for key in ("instagram_posts", "recent_posts", "posts"):
140
+ posts = _normalise_instagram_posts(profile.get(key))
141
+ if posts:
142
+ st.session_state[state_key] = posts
143
+ return posts
144
+ stored = read_json("instagram_posts.json", {})
145
+ if isinstance(stored, dict):
146
+ posts = _normalise_instagram_posts(stored.get(profile_id))
147
+ if posts:
148
+ st.session_state[state_key] = posts
149
+ return posts
150
+
151
+
152
+ def _persist_instagram_posts(profile: Mapping[str, Any], posts: list[dict[str, Any]]) -> None:
153
+ profile_id = _instagram_profile_storage_id(profile)
154
+ stored = read_json("instagram_posts.json", {})
155
+ if not isinstance(stored, dict):
156
+ stored = {}
157
+ stored[profile_id] = _normalise_instagram_posts(posts)
158
+ write_json("instagram_posts.json", stored)
159
+ st.session_state[_instagram_posts_key(profile_id)] = stored[profile_id]
160
+
161
+
93
162
  def _instagram_posts_archive(posts: list[dict[str, Any]]) -> bytes:
94
163
  archive = io.BytesIO()
95
164
  with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as bundle:
@@ -115,9 +184,9 @@ def _instagram_posts_archive(posts: list[dict[str, Any]]) -> bytes:
115
184
 
116
185
 
117
186
  def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
118
- profile_id = _clean(profile.get("id"))
187
+ profile_id = _instagram_profile_storage_id(profile)
119
188
  state_key = _instagram_posts_key(profile_id)
120
- posts = st.session_state.get(state_key, [])
189
+ posts = _stored_instagram_posts(profile)
121
190
  with st.expander("Últimos posts do Instagram", expanded=False):
122
191
  action_cols = st.columns(3)
123
192
  with action_cols[0]:
@@ -131,7 +200,7 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
131
200
  posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=target)
132
201
  if posts_ok:
133
202
  posts = loaded_posts
134
- st.session_state[state_key] = posts
203
+ _persist_instagram_posts(profile, posts)
135
204
  st.success(f"{len(posts)} posts carregados.")
136
205
  st.rerun()
137
206
  else:
@@ -154,7 +223,7 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
154
223
  if st.button("Mostrar + 10", key=f"more_instagram_posts_{profile_id}", use_container_width=True):
155
224
  posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=len(posts) + 10)
156
225
  if posts_ok:
157
- st.session_state[state_key] = loaded_posts
226
+ _persist_instagram_posts(profile, loaded_posts)
158
227
  st.rerun()
159
228
  st.warning(posts_message)
160
229
 
@@ -307,24 +376,55 @@ def _characters(settings: Mapping[str, Any]) -> tuple[list[dict[str, Any]], Any
307
376
  return [], None
308
377
 
309
378
 
379
+ def _canonical_instagram_metrics(profile: Mapping[str, Any]) -> dict[str, int | None]:
380
+ return {
381
+ "post_count": _profile_metric(profile, "post_count", "posts", "media_count"),
382
+ "subscriber_count": _profile_metric(profile, "subscriber_count", "followers_count", "follower_count", "followers", "edge_followed_by"),
383
+ "following_count": _profile_metric(profile, "following_count", "following", "follows", "followingCount", "edge_follow"),
384
+ }
385
+
386
+
387
+ def _canonical_instagram_profile(data: Mapping[str, Any], existing: Mapping[str, Any] | None = None) -> dict[str, Any]:
388
+ current = dict(existing or {})
389
+ incoming = dict(data)
390
+ merged = {**current, **incoming}
391
+ for field, value in _canonical_instagram_metrics(incoming).items():
392
+ if value is not None:
393
+ merged[field] = value
394
+ elif field not in merged or merged.get(field) in (None, ""):
395
+ merged[field] = _canonical_instagram_metrics(current).get(field)
396
+ bio = _profile_bio(incoming) or _profile_bio(current)
397
+ if bio:
398
+ merged["bio"] = bio
399
+ raw_bio = _clean(incoming.get("bio_raw")) or _clean(incoming.get("biography")) or _clean(current.get("bio_raw"))
400
+ if raw_bio:
401
+ merged["bio_raw"] = raw_bio
402
+ country = _profile_country(incoming) or _profile_country(current)
403
+ if country:
404
+ merged["country"] = country
405
+ return merged
406
+
407
+
310
408
  def _save_public_profile(data: Mapping[str, Any], *, country: str, language: str, character_id: str = "") -> dict[str, Any]:
311
- name = _clean(data.get("name")) or _clean(data.get("username")) or "Conta Instagram"
312
- url = _clean(data.get("url"))
313
- selected_country = _clean(country) if _clean(country) in COUNTRY_OPTIONS else _infer_country_from_bio(_profile_bio(data))
409
+ canonical = _canonical_instagram_profile(data)
410
+ name = _clean(canonical.get("name")) or _clean(canonical.get("username")) or "Conta Instagram"
411
+ url = _clean(canonical.get("url"))
412
+ metrics = _canonical_instagram_metrics(canonical)
413
+ bio = _profile_bio(canonical)
314
414
  metadata = {
315
- **dict(data),
415
+ **canonical,
316
416
  "platform": "instagram",
317
417
  "social_network": "Instagram",
318
418
  "name": name,
319
419
  "url": url,
320
- "handle": _clean(data.get("handle")),
321
- "bio": _profile_bio(data),
322
- "bio_raw": _clean(data.get("bio_raw")),
323
- "country": selected_country,
420
+ "handle": _clean(canonical.get("handle")) or _clean(canonical.get("username")),
421
+ "bio": bio,
422
+ "bio_raw": _clean(data.get("bio_raw")) or _clean(canonical.get("bio_raw")) or bio,
423
+ "country": _normalise_country(country) or _profile_country(canonical),
324
424
  "language": language.strip(),
325
- "post_count": data.get("post_count"),
326
- "subscriber_count": data.get("subscriber_count"),
327
- "following_count": data.get("following_count"),
425
+ "post_count": metrics["post_count"],
426
+ "subscriber_count": metrics["subscriber_count"],
427
+ "following_count": metrics["following_count"],
328
428
  "character_id": character_id.strip(),
329
429
  "active": True,
330
430
  "metrics_source": "instagram_public_page",
@@ -333,12 +433,10 @@ def _save_public_profile(data: Mapping[str, Any], *, country: str, language: str
333
433
 
334
434
 
335
435
  def _merge_instagram_refresh(existing: Mapping[str, Any], refreshed: Mapping[str, Any]) -> dict[str, Any]:
336
- merged = dict(refreshed)
337
- for field in ("subscriber_count", "following_count", "post_count", "bio", "bio_raw", "avatar_url"):
338
- if merged.get(field) in (None, "") and existing.get(field) not in (None, ""):
339
- merged[field] = existing[field]
340
- if not _clean(merged.get("country")):
341
- merged["country"] = _infer_country_from_bio(_profile_bio(merged))
436
+ merged = _canonical_instagram_profile(refreshed, existing)
437
+ merged["id"] = existing.get("id") or refreshed.get("id")
438
+ merged["platform"] = "instagram"
439
+ merged["social_network"] = "Instagram"
342
440
  return merged
343
441
 
344
442
 
@@ -373,28 +471,36 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
373
471
  _render_instagram_bio(_profile_bio(profile))
374
472
  st.caption(f"{_profile_country(profile) or 'País não definido'} · {_clean(profile.get('language')) or 'Idioma não definido'}")
375
473
  with header_cols[2]:
376
- st.metric("posts", _metric(profile.get("post_count")))
474
+ st.metric("posts", _metric(_profile_metric(profile, "post_count", "posts", "media_count")))
377
475
  with header_cols[3]:
378
- st.metric("Seguidores", _metric(_profile_metric(profile, "subscriber_count", "followers_count", "follower_count")))
476
+ followers = _profile_metric(profile, "subscriber_count", "followers_count", "follower_count")
477
+ followers = followers if followers is not None else _profile_metric(profile, "followers", "edge_followed_by")
478
+ st.metric("Seguidores", _metric(followers))
379
479
  with header_cols[4]:
380
- st.metric("seguindo", _metric(_profile_metric(profile, "following_count", "following", "follows")))
480
+ following = _profile_metric(profile, "following_count", "following", "follows")
481
+ following = following if following is not None else _profile_metric(profile, "followingCount", "edge_follow")
482
+ st.metric("seguindo", _metric(following))
381
483
  with header_cols[5]:
382
484
  refresh_col, edit_col = st.columns(2)
383
485
  with refresh_col:
384
486
  if st.button("↻", help="Actualizar posts, seguidores e seguindo", key=f"refresh_instagram_{profile_id}"):
385
487
  refreshed_ok, refreshed_message, refreshed = _refresh_instagram_profile(profile)
386
488
  if refreshed_ok:
387
- posts_key = _instagram_posts_key(profile_id)
388
- current_posts = st.session_state.get(posts_key, [])
489
+ current_posts = _stored_instagram_posts(profile)
389
490
  posts_ok, _, refreshed_posts = _load_instagram_posts(refreshed, limit=max(10, len(current_posts)))
390
491
  if posts_ok:
391
- st.session_state[posts_key] = refreshed_posts
392
- update_channel(profile_id, {**refreshed, "platform": "instagram", "country": refreshed.get("country") or profile.get("country", ""), "language": profile.get("language", ""), "character_id": profile.get("character_id", "")})
492
+ _persist_instagram_posts(profile, refreshed_posts)
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", "")})
494
+ if updated is None:
495
+ st.error("A conta Instagram já não existe no armazenamento local.")
496
+ return
393
497
  st.success("Métricas Instagram actualizadas.")
394
498
  st.rerun()
395
499
  st.warning(refreshed_message)
396
500
  with edit_col:
397
501
  if st.button("Editar", key=f"edit_instagram_button_{profile_id}", use_container_width=True):
502
+ for field in ("name", "handle", "bio", "country", "language", "posts", "followers", "following"):
503
+ st.session_state.pop(f"instagram_edit_{field}_{profile_id}", None)
398
504
  st.session_state[edit_key] = True
399
505
  st.rerun()
400
506
  if st.button("Apagar", key=f"delete_instagram_button_{profile_id}", use_container_width=True):
@@ -428,12 +534,12 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
428
534
  country = st.selectbox("País", _country_options(), index=_country_index(_profile_country(profile)), format_func=_country_label, key=f"instagram_edit_country_{profile_id}")
429
535
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=_language_index(profile.get("language")), format_func=language_label, key=f"instagram_edit_language_{profile_id}")
430
536
  with edit_cols[1]:
431
- posts = st.number_input("posts", min_value=0, value=_metric_input(profile.get("post_count")), placeholder="Não encontrado", key=f"instagram_edit_posts_{profile_id}")
432
- followers = st.number_input("Seguidores", min_value=0, value=_metric_input(profile.get("subscriber_count")), placeholder="Não encontrado", key=f"instagram_edit_followers_{profile_id}")
433
- following = st.number_input("seguindo", min_value=0, value=_metric_input(profile.get("following_count")), placeholder="Não encontrado", key=f"instagram_edit_following_{profile_id}")
537
+ posts = st.number_input("posts", min_value=0, value=_metric_input(_profile_metric(profile, "post_count", "posts", "media_count")), placeholder="Não encontrado", key=f"instagram_edit_posts_{profile_id}")
538
+ followers = st.number_input("Seguidores", min_value=0, value=_metric_input(_profile_metric(profile, "subscriber_count", "followers_count", "follower_count", "followers", "edge_followed_by")), placeholder="Não encontrado", key=f"instagram_edit_followers_{profile_id}")
539
+ following = st.number_input("seguindo", min_value=0, value=_metric_input(_profile_metric(profile, "following_count", "following", "follows", "followingCount", "edge_follow")), placeholder="Não encontrado", key=f"instagram_edit_following_{profile_id}")
434
540
  save_edit = st.form_submit_button("Guardar alterações", type="primary", use_container_width=True)
435
541
  if save_edit:
436
- update_channel(profile_id, {"name": name.strip(), "handle": handle.strip(), "bio": normalize_instagram_bio(bio), "bio_raw": bio, "country": country.strip(), "language": language, "post_count": _metric_input(posts), "subscriber_count": _metric_input(followers), "following_count": _metric_input(following)})
542
+ update_channel(profile_id, {"name": name.strip(), "handle": handle.strip(), "bio": normalize_instagram_bio(bio), "bio_raw": bio, "country": _normalise_country(country), "language": language, "post_count": _metric_input(posts), "subscriber_count": _metric_input(followers), "following_count": _metric_input(following)})
437
543
  st.session_state.pop(edit_key, None)
438
544
  st.success("Conta Instagram actualizada.")
439
545
  st.rerun()
@@ -477,8 +583,6 @@ def render_social_networks(settings: dict[str, Any]) -> None:
477
583
  if search_clicked:
478
584
  result = fetch_public_instagram_profile(source)
479
585
  result_data = dict(result.data) if isinstance(result.data, dict) else {}
480
- if result_data and not _clean(result_data.get("country")):
481
- result_data["country"] = _infer_country_from_bio(_profile_bio(result_data))
482
586
  st.session_state["social_instagram_result"] = result_data
483
587
  st.session_state["social_instagram_ok"] = result.ok
484
588
  st.session_state["social_instagram_message"] = result.message
@@ -498,11 +602,11 @@ def render_social_networks(settings: dict[str, Any]) -> None:
498
602
  st.caption(_clean(data.get("handle")))
499
603
  _render_instagram_bio(_profile_bio(data))
500
604
  with preview_cols[2]:
501
- st.metric("posts", _metric(data.get("post_count")))
605
+ st.metric("posts", _metric(_profile_metric(data, "post_count", "posts", "media_count")))
502
606
  with preview_cols[3]:
503
- st.metric("Seguidores", _metric(data.get("subscriber_count")))
607
+ st.metric("Seguidores", _metric(_profile_metric(data, "subscriber_count", "followers_count", "follower_count", "followers", "edge_followed_by")))
504
608
  with preview_cols[4]:
505
- st.metric("seguindo", _metric(data.get("following_count")))
609
+ st.metric("seguindo", _metric(_profile_metric(data, "following_count", "following", "follows", "followingCount", "edge_follow")))
506
610
  with st.form("social_instagram_save_public_profile"):
507
611
  form_cols = st.columns(2)
508
612
  with form_cols[0]:
@@ -511,9 +615,9 @@ def render_social_networks(settings: dict[str, Any]) -> None:
511
615
  country = st.selectbox("País", _country_options(), index=_country_index(_profile_country(data)), format_func=_country_label, key="social_instagram_result_country")
512
616
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=_language_index(data.get("language")), format_func=language_label, key="social_instagram_result_language")
513
617
  with form_cols[1]:
514
- posts = st.number_input("posts", min_value=0, value=_metric_input(data.get("post_count")), placeholder="Não encontrado", key="social_instagram_result_posts")
515
- followers = st.number_input("Seguidores", min_value=0, value=_metric_input(data.get("subscriber_count")), placeholder="Não encontrado", key="social_instagram_result_followers")
516
- following = st.number_input("seguindo", min_value=0, value=_metric_input(data.get("following_count")), placeholder="Não encontrado", key="social_instagram_result_following")
618
+ posts = st.number_input("posts", min_value=0, value=_metric_input(_profile_metric(data, "post_count", "posts", "media_count")), placeholder="Não encontrado", key="social_instagram_result_posts")
619
+ followers = st.number_input("Seguidores", min_value=0, value=_metric_input(_profile_metric(data, "subscriber_count", "followers_count", "follower_count", "followers", "edge_followed_by")), placeholder="Não encontrado", key="social_instagram_result_followers")
620
+ following = st.number_input("seguindo", min_value=0, value=_metric_input(_profile_metric(data, "following_count", "following", "follows", "followingCount", "edge_follow")), placeholder="Não encontrado", key="social_instagram_result_following")
517
621
  character_options = [""] + [_clean(item.get("id")) for item in characters if _clean(item.get("id"))]
518
622
  character_labels = {"": "Sem personagem associado"} | {_clean(item.get("id")): _clean(item.get("name")) or _clean(item.get("id")) for item in characters}
519
623
  selected_character = st.selectbox("Personagem", character_options, format_func=lambda value: character_labels.get(value, value), key="social_instagram_result_character")
@@ -521,7 +625,8 @@ def render_social_networks(settings: dict[str, Any]) -> None:
521
625
  if save_profile:
522
626
  profile_data = dict(data)
523
627
  profile_data.update({"name": name, "bio": normalize_instagram_bio(bio), "bio_raw": bio, "post_count": _metric_input(posts), "subscriber_count": _metric_input(followers), "following_count": _metric_input(following)})
524
- _save_public_profile(profile_data, country=country, language=language, character_id=selected_character)
628
+ profile_data["country"] = _normalise_country(country)
629
+ _save_public_profile(profile_data, country=profile_data["country"], language=language, character_id=selected_character)
525
630
  st.success("Conta Instagram cadastrada em Redes Sociais.")
526
631
  for key in ("social_instagram_result", "social_instagram_ok", "social_instagram_message"):
527
632
  st.session_state.pop(key, None)
@@ -87,7 +87,7 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
87
87
  "updated_at": now(),
88
88
  }
89
89
  if metadata:
90
- channel.update({k: v for k, v in metadata.items() if k in channel})
90
+ channel.update({k: v for k, v in metadata.items() if k in channel and k != "id"})
91
91
  channels = read_json("channels.json", [])
92
92
  channels.append(channel)
93
93
  write_json("channels.json", channels)
@@ -157,12 +157,14 @@ def _profile_data_from_api(user: Mapping[str, Any], reference: Mapping[str, str]
157
157
  following = following if following is not None else _structured_metric_from_json(json.dumps(user), 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
158
158
  media = ((user.get('edge_owner_to_timeline_media') or {}).get('count') if isinstance(user.get('edge_owner_to_timeline_media'), dict) else None)
159
159
  biography = str(user.get('biography') or '').strip()
160
+ country = extract_public_instagram_country(user)
160
161
  return {
161
162
  'id': f"instagram_{reference['username']}",
162
163
  **reference,
163
164
  'name': str(user.get('full_name') or user.get('username') or reference['username']).strip(),
164
165
  'bio': normalize_instagram_bio(biography),
165
166
  'bio_raw': biography,
167
+ 'country': country,
166
168
  'avatar_url': str(user.get('profile_pic_url_hd') or user.get('profile_pic_url') or '').strip(),
167
169
  'subscriber_count': followers,
168
170
  'following_count': following,
@@ -185,6 +187,45 @@ def normalize_instagram_metric(value: Any) -> int | None:
185
187
  return int(digits) if digits else None
186
188
 
187
189
 
190
+ PUBLIC_COUNTRY_KEYS = {
191
+ 'account_based_in', 'account_country', 'country_name',
192
+ 'country_of_origin', 'country_of_registration', 'location_country', 'transparency_country',
193
+ }
194
+ PUBLIC_TRANSPARENCY_KEYS = {'about', 'about_account', 'account_transparency', 'transparency', 'account_info'}
195
+
196
+
197
+ def _country_value(value: Any) -> str:
198
+ if isinstance(value, Mapping):
199
+ for key in ('name', 'label', 'display_name', 'country', 'country_name', 'code'):
200
+ candidate = str(value.get(key) or '').strip()
201
+ if candidate and not candidate.isdigit() and len(candidate) <= 80:
202
+ return candidate
203
+ return ''
204
+ candidate = str(value or '').strip()
205
+ return candidate if candidate and not candidate.isdigit() and len(candidate) <= 80 else ''
206
+
207
+
208
+ def extract_public_instagram_country(value: Any) -> str:
209
+ """Read only the account-country field exposed by Instagram transparency data."""
210
+ if isinstance(value, Mapping):
211
+ for key, candidate in value.items():
212
+ if str(key).casefold() in PUBLIC_COUNTRY_KEYS:
213
+ country = _country_value(candidate)
214
+ if country:
215
+ return country
216
+ for key, candidate in value.items():
217
+ if str(key).casefold() in PUBLIC_TRANSPARENCY_KEYS:
218
+ country = extract_public_instagram_country(candidate)
219
+ if country:
220
+ return country
221
+ elif isinstance(value, list):
222
+ for candidate in value:
223
+ country = extract_public_instagram_country(candidate)
224
+ if country:
225
+ return country
226
+ return ''
227
+
228
+
188
229
  def _profile_metric(user: Mapping[str, Any], *keys: str) -> int | None:
189
230
  for key in keys:
190
231
  value = normalize_instagram_metric(user.get(key))
@@ -224,7 +265,12 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
224
265
  followers = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:followers|seguidores)',)) or _structured_metric(response.text, 'edge_followed_by', 'followers', 'follower_count') or _structured_metric_from_json(response.text, 'edge_followed_by', 'followers', 'follower_count', 'followerCount')
225
266
  following = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:following|seguindo)',)) or _structured_metric(response.text, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount') or _structured_metric_from_json(response.text, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
226
267
  posts = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:posts|publicações|publications)',)) or _structured_metric(response.text, 'edge_owner_to_timeline_media', 'posts', 'post_count')
227
- data = {'id': f"instagram_{reference['username']}", **reference, 'name': title.split('(')[0].strip() or reference['username'], 'bio': normalize_instagram_bio(description), 'bio_raw': description, '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()}
268
+ embedded_country = ''
269
+ for document in _embedded_json_documents(response.text):
270
+ embedded_country = extract_public_instagram_country(document)
271
+ if embedded_country:
272
+ break
273
+ 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()}
228
274
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
229
275
 
230
276
 
@@ -325,4 +371,4 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
325
371
  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))]})
326
372
 
327
373
 
328
- __all__ = ['IntegrationResult', 'fetch_public_instagram_posts', 'fetch_public_instagram_profile', 'normalize_instagram_bio', 'normalize_instagram_metric', 'normalize_instagram_reference']
374
+ __all__ = ['IntegrationResult', 'extract_public_instagram_country', 'fetch_public_instagram_posts', 'fetch_public_instagram_profile', 'normalize_instagram_bio', 'normalize_instagram_metric', 'normalize_instagram_reference']
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.32",
3
+ "version": "0.6.34",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",