@danhachuel/thunderbolt 0.6.31 → 0.6.33

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.
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
+ import re
4
5
  import uuid
5
6
  import zipfile
7
+ import unicodedata
6
8
  from typing import Any, Callable, Mapping
7
9
 
8
10
  import requests
@@ -33,8 +35,9 @@ def _metric(value: Any) -> str:
33
35
  def _profile_metric(profile: Mapping[str, Any], *keys: str) -> Any:
34
36
  for key in keys:
35
37
  value = profile.get(key)
36
- if value not in (None, ""):
37
- return value
38
+ normalized = normalize_instagram_metric(value)
39
+ if normalized is not None:
40
+ return normalized
38
41
  return None
39
42
 
40
43
 
@@ -49,7 +52,48 @@ def _render_instagram_bio(value: Any) -> None:
49
52
 
50
53
 
51
54
  def _profile_bio(profile: Mapping[str, Any]) -> str:
52
- 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()
65
+
66
+
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:
81
+ return ""
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:
87
+ return country
88
+ return COUNTRY_ALIASES.get(key, "")
89
+
90
+
91
+ def _profile_country(profile: Mapping[str, Any]) -> str:
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 ""
53
97
 
54
98
 
55
99
  def _language_index(value: Any) -> int:
@@ -63,7 +107,7 @@ def _country_options() -> tuple[str, ...]:
63
107
 
64
108
  def _country_index(value: Any) -> int:
65
109
  options = _country_options()
66
- value = _clean(value)
110
+ value = _normalise_country(value)
67
111
  return options.index(value) if value in options else 0
68
112
 
69
113
 
@@ -75,6 +119,46 @@ def _instagram_posts_key(profile_id: str) -> str:
75
119
  return f"instagram_posts_{profile_id}"
76
120
 
77
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
+
78
162
  def _instagram_posts_archive(posts: list[dict[str, Any]]) -> bytes:
79
163
  archive = io.BytesIO()
80
164
  with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as bundle:
@@ -100,9 +184,9 @@ def _instagram_posts_archive(posts: list[dict[str, Any]]) -> bytes:
100
184
 
101
185
 
102
186
  def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
103
- profile_id = _clean(profile.get("id"))
187
+ profile_id = _instagram_profile_storage_id(profile)
104
188
  state_key = _instagram_posts_key(profile_id)
105
- posts = st.session_state.get(state_key, [])
189
+ posts = _stored_instagram_posts(profile)
106
190
  with st.expander("Últimos posts do Instagram", expanded=False):
107
191
  action_cols = st.columns(3)
108
192
  with action_cols[0]:
@@ -116,7 +200,7 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
116
200
  posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=target)
117
201
  if posts_ok:
118
202
  posts = loaded_posts
119
- st.session_state[state_key] = posts
203
+ _persist_instagram_posts(profile, posts)
120
204
  st.success(f"{len(posts)} posts carregados.")
121
205
  st.rerun()
122
206
  else:
@@ -139,7 +223,7 @@ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
139
223
  if st.button("Mostrar + 10", key=f"more_instagram_posts_{profile_id}", use_container_width=True):
140
224
  posts_ok, posts_message, loaded_posts = _load_instagram_posts(profile, limit=len(posts) + 10)
141
225
  if posts_ok:
142
- st.session_state[state_key] = loaded_posts
226
+ _persist_instagram_posts(profile, loaded_posts)
143
227
  st.rerun()
144
228
  st.warning(posts_message)
145
229
 
@@ -292,24 +376,55 @@ def _characters(settings: Mapping[str, Any]) -> tuple[list[dict[str, Any]], Any
292
376
  return [], None
293
377
 
294
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
+
295
408
  def _save_public_profile(data: Mapping[str, Any], *, country: str, language: str, character_id: str = "") -> dict[str, Any]:
296
- name = _clean(data.get("name")) or _clean(data.get("username")) or "Conta Instagram"
297
- url = _clean(data.get("url"))
298
- selected_country = _clean(country) if _clean(country) in COUNTRY_OPTIONS else ""
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)
299
414
  metadata = {
300
- **dict(data),
415
+ **canonical,
301
416
  "platform": "instagram",
302
417
  "social_network": "Instagram",
303
418
  "name": name,
304
419
  "url": url,
305
- "handle": _clean(data.get("handle")),
306
- "bio": _profile_bio(data),
307
- "bio_raw": _clean(data.get("bio_raw")),
308
- "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),
309
424
  "language": language.strip(),
310
- "post_count": data.get("post_count"),
311
- "subscriber_count": data.get("subscriber_count"),
312
- "following_count": data.get("following_count"),
425
+ "post_count": metrics["post_count"],
426
+ "subscriber_count": metrics["subscriber_count"],
427
+ "following_count": metrics["following_count"],
313
428
  "character_id": character_id.strip(),
314
429
  "active": True,
315
430
  "metrics_source": "instagram_public_page",
@@ -318,10 +433,10 @@ def _save_public_profile(data: Mapping[str, Any], *, country: str, language: str
318
433
 
319
434
 
320
435
  def _merge_instagram_refresh(existing: Mapping[str, Any], refreshed: Mapping[str, Any]) -> dict[str, Any]:
321
- merged = dict(refreshed)
322
- for field in ("subscriber_count", "following_count", "post_count", "bio", "bio_raw", "avatar_url"):
323
- if merged.get(field) in (None, "") and existing.get(field) not in (None, ""):
324
- merged[field] = existing[field]
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"
325
440
  return merged
326
441
 
327
442
 
@@ -354,30 +469,38 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
354
469
  st.write(f"**{_clean(profile.get('name')) or 'Sem nome'}**")
355
470
  st.caption(f"{_clean(profile.get('handle')) or _clean(profile.get('url')) or 'sem handler'}")
356
471
  _render_instagram_bio(_profile_bio(profile))
357
- st.caption(f"{_clean(profile.get('country')) or 'País não definido'} · {_clean(profile.get('language')) or 'Idioma não definido'}")
472
+ st.caption(f"{_profile_country(profile) or 'País não definido'} · {_clean(profile.get('language')) or 'Idioma não definido'}")
358
473
  with header_cols[2]:
359
- st.metric("posts", _metric(profile.get("post_count")))
474
+ st.metric("posts", _metric(_profile_metric(profile, "post_count", "posts", "media_count")))
360
475
  with header_cols[3]:
361
- 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))
362
479
  with header_cols[4]:
363
- 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))
364
483
  with header_cols[5]:
365
484
  refresh_col, edit_col = st.columns(2)
366
485
  with refresh_col:
367
- if st.button("↻", help="Actualizar posts, seguidores e seguindo", key=f"refresh_instagram_{profile_id}"):
486
+ if st.button("Actualizar posts, seguidores e seguindo", key=f"refresh_instagram_{profile_id}", use_container_width=True):
368
487
  refreshed_ok, refreshed_message, refreshed = _refresh_instagram_profile(profile)
369
488
  if refreshed_ok:
370
- posts_key = _instagram_posts_key(profile_id)
371
- current_posts = st.session_state.get(posts_key, [])
489
+ current_posts = _stored_instagram_posts(profile)
372
490
  posts_ok, _, refreshed_posts = _load_instagram_posts(refreshed, limit=max(10, len(current_posts)))
373
491
  if posts_ok:
374
- st.session_state[posts_key] = refreshed_posts
375
- update_channel(profile_id, {**refreshed, "platform": "instagram", "country": 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
376
497
  st.success("Métricas Instagram actualizadas.")
377
498
  st.rerun()
378
499
  st.warning(refreshed_message)
379
500
  with edit_col:
380
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)
381
504
  st.session_state[edit_key] = True
382
505
  st.rerun()
383
506
  if st.button("Apagar", key=f"delete_instagram_button_{profile_id}", use_container_width=True):
@@ -408,15 +531,15 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
408
531
  name = st.text_input("Nome", value=_clean(profile.get("name")), key=f"instagram_edit_name_{profile_id}")
409
532
  handle = st.text_input("handler", value=_clean(profile.get("handle")), key=f"instagram_edit_handle_{profile_id}")
410
533
  bio = st.text_area("Bio", value=_profile_bio(profile), key=f"instagram_edit_bio_{profile_id}")
411
- country = st.selectbox("País", _country_options(), index=_country_index(profile.get("country")), format_func=_country_label, key=f"instagram_edit_country_{profile_id}")
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}")
412
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}")
413
536
  with edit_cols[1]:
414
- 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}")
415
- 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}")
416
- 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}")
417
540
  save_edit = st.form_submit_button("Guardar alterações", type="primary", use_container_width=True)
418
541
  if save_edit:
419
- 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)})
420
543
  st.session_state.pop(edit_key, None)
421
544
  st.success("Conta Instagram actualizada.")
422
545
  st.rerun()
@@ -427,7 +550,7 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
427
550
  with block_cols[1]:
428
551
  st.markdown(f"**handler**\n\n{_clean(profile.get('handle')) or '—'}")
429
552
  with block_cols[2]:
430
- st.markdown(f"**País**\n\n{_clean(profile.get('country')) or '—'}")
553
+ st.markdown(f"**País**\n\n{_profile_country(profile) or '—'}")
431
554
  with block_cols[3]:
432
555
  st.markdown(f"**Idioma**\n\n{_clean(profile.get('language')) or '—'}")
433
556
 
@@ -459,7 +582,8 @@ def render_social_networks(settings: dict[str, Any]) -> None:
459
582
  st.rerun()
460
583
  if search_clicked:
461
584
  result = fetch_public_instagram_profile(source)
462
- st.session_state["social_instagram_result"] = result.data if isinstance(result.data, dict) else {}
585
+ result_data = dict(result.data) if isinstance(result.data, dict) else {}
586
+ st.session_state["social_instagram_result"] = result_data
463
587
  st.session_state["social_instagram_ok"] = result.ok
464
588
  st.session_state["social_instagram_message"] = result.message
465
589
  if st.session_state.get("social_instagram_message"):
@@ -478,22 +602,22 @@ def render_social_networks(settings: dict[str, Any]) -> None:
478
602
  st.caption(_clean(data.get("handle")))
479
603
  _render_instagram_bio(_profile_bio(data))
480
604
  with preview_cols[2]:
481
- st.metric("posts", _metric(data.get("post_count")))
605
+ st.metric("posts", _metric(_profile_metric(data, "post_count", "posts", "media_count")))
482
606
  with preview_cols[3]:
483
- 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")))
484
608
  with preview_cols[4]:
485
- st.metric("seguindo", _metric(data.get("following_count")))
609
+ st.metric("seguindo", _metric(_profile_metric(data, "following_count", "following", "follows", "followingCount", "edge_follow")))
486
610
  with st.form("social_instagram_save_public_profile"):
487
611
  form_cols = st.columns(2)
488
612
  with form_cols[0]:
489
613
  name = st.text_input("Nome", value=_clean(data.get("name")), key="social_instagram_result_name")
490
614
  bio = st.text_area("Bio", value=_profile_bio(data), key="social_instagram_result_bio")
491
- country = st.selectbox("País", _country_options(), index=_country_index(data.get("country")), format_func=_country_label, key="social_instagram_result_country")
615
+ country = st.selectbox("País", _country_options(), index=_country_index(_profile_country(data)), format_func=_country_label, key="social_instagram_result_country")
492
616
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=_language_index(data.get("language")), format_func=language_label, key="social_instagram_result_language")
493
617
  with form_cols[1]:
494
- posts = st.number_input("posts", min_value=0, value=_metric_input(data.get("post_count")), placeholder="Não encontrado", key="social_instagram_result_posts")
495
- followers = st.number_input("Seguidores", min_value=0, value=_metric_input(data.get("subscriber_count")), placeholder="Não encontrado", key="social_instagram_result_followers")
496
- 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")
497
621
  character_options = [""] + [_clean(item.get("id")) for item in characters if _clean(item.get("id"))]
498
622
  character_labels = {"": "Sem personagem associado"} | {_clean(item.get("id")): _clean(item.get("name")) or _clean(item.get("id")) for item in characters}
499
623
  selected_character = st.selectbox("Personagem", character_options, format_func=lambda value: character_labels.get(value, value), key="social_instagram_result_character")
@@ -501,7 +625,8 @@ def render_social_networks(settings: dict[str, Any]) -> None:
501
625
  if save_profile:
502
626
  profile_data = dict(data)
503
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)})
504
- _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)
505
630
  st.success("Conta Instagram cadastrada em Redes Sociais.")
506
631
  for key in ("social_instagram_result", "social_instagram_ok", "social_instagram_message"):
507
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.31",
3
+ "version": "0.6.33",
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",