@danhachuel/thunderbolt 0.6.18 → 0.6.20

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,15 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import io
3
4
  import uuid
5
+ import zipfile
4
6
  from typing import Any, Callable, Mapping
5
7
 
8
+ import requests
6
9
  import streamlit as st
7
10
 
8
11
  from hermes_ui.domain import create_channel, update_channel
9
12
  from hermes_ui.influencers import InfluencerBackendError, STANDALONE_CONTENT_INFLUENCER_ID, get_repository
10
13
  from hermes_ui.storage import read_json, write_json
11
14
  from hermes_ui.languages import LANGUAGE_CODES, language_code, language_label
12
- from integrations.instagram_public import fetch_public_instagram_profile
15
+ from integrations.instagram_public import fetch_public_instagram_posts, fetch_public_instagram_profile
13
16
  from integrations.meta_social import test_facebook_pages_api_card, test_instagram_api_card
14
17
 
15
18
 
@@ -26,11 +29,92 @@ def _metric(value: Any) -> str:
26
29
  return _clean(value) or "—"
27
30
 
28
31
 
32
+ def _profile_metric(profile: Mapping[str, Any], *keys: str) -> Any:
33
+ for key in keys:
34
+ value = profile.get(key)
35
+ if value not in (None, ""):
36
+ return value
37
+ return None
38
+
39
+
29
40
  def _language_index(value: Any) -> int:
30
41
  code = language_code(value)
31
42
  return list(LANGUAGE_CODES).index(code) if code in LANGUAGE_CODES else list(LANGUAGE_CODES).index("pt")
32
43
 
33
44
 
45
+ def _instagram_posts_key(profile_id: str) -> str:
46
+ return f"instagram_posts_{profile_id}"
47
+
48
+
49
+ def _instagram_posts_archive(posts: list[dict[str, Any]]) -> bytes:
50
+ archive = io.BytesIO()
51
+ with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as bundle:
52
+ manifest: list[str] = []
53
+ for index, post in enumerate(posts, start=1):
54
+ url = _clean(post.get("image_url"))
55
+ if url:
56
+ try:
57
+ response = requests.get(url, timeout=15)
58
+ response.raise_for_status()
59
+ extension = ".jpg"
60
+ content_type = response.headers.get("content-type", "")
61
+ if "png" in content_type:
62
+ extension = ".png"
63
+ elif "webp" in content_type:
64
+ extension = ".webp"
65
+ bundle.writestr(f"post-{index:02d}{extension}", response.content)
66
+ except requests.RequestException:
67
+ pass
68
+ manifest.append(f"{index}. {_clean(post.get('url'))}\n{_clean(post.get('caption'))}\n")
69
+ bundle.writestr("posts.txt", "\n".join(manifest))
70
+ return archive.getvalue()
71
+
72
+
73
+ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
74
+ profile_id = _clean(profile.get("id"))
75
+ state_key = _instagram_posts_key(profile_id)
76
+ posts = st.session_state.get(state_key, [])
77
+ source = _clean(profile.get("url")) or _clean(profile.get("handle"))
78
+ with st.expander("Últimos posts do Instagram", expanded=False):
79
+ action_cols = st.columns(3)
80
+ with action_cols[0]:
81
+ load_clicked = st.button("Carregar últimos 10", key=f"load_instagram_posts_{profile_id}", use_container_width=True)
82
+ with action_cols[1]:
83
+ refresh_clicked = st.button("Actualizar tudo", key=f"refresh_instagram_posts_{profile_id}", use_container_width=True)
84
+ with action_cols[2]:
85
+ download_clicked = bool(posts) and st.download_button("Baixar todos", data=_instagram_posts_archive(posts), file_name=f"instagram-{profile_id}-posts.zip", mime="application/zip", key=f"download_instagram_posts_{profile_id}", use_container_width=True)
86
+ if load_clicked or refresh_clicked:
87
+ target = max(10, len(posts)) if refresh_clicked else 10
88
+ result = fetch_public_instagram_posts(source, limit=target)
89
+ if result.ok:
90
+ posts = result.data.get("posts", [])
91
+ st.session_state[state_key] = posts
92
+ st.success(f"{len(posts)} posts carregados.")
93
+ else:
94
+ st.warning(result.message)
95
+ posts = st.session_state.get(state_key, posts)
96
+ if not posts:
97
+ st.info("Clique em Carregar últimos 10 para consultar os posts públicos desta conta.")
98
+ return
99
+ for post in posts:
100
+ with st.container(border=True):
101
+ post_cols = st.columns([1, 3])
102
+ with post_cols[0]:
103
+ if _clean(post.get("image_url")):
104
+ st.image(post["image_url"], use_container_width=True)
105
+ with post_cols[1]:
106
+ if _clean(post.get("url")):
107
+ st.link_button("Abrir post", post["url"])
108
+ if _clean(post.get("caption")):
109
+ st.caption(post["caption"])
110
+ if st.button("Mostrar + 10", key=f"more_instagram_posts_{profile_id}", use_container_width=True):
111
+ result = fetch_public_instagram_posts(source, limit=len(posts) + 10)
112
+ if result.ok:
113
+ st.session_state[state_key] = result.data.get("posts", posts)
114
+ st.rerun()
115
+ st.warning(result.message)
116
+
117
+
34
118
  def _api_card_defaults(kind: str) -> tuple[str, list[str], Callable[[Mapping[str, Any]], dict[str, Any]]]:
35
119
  if kind == "instagram":
36
120
  return "instagram_api_cards", ["id", "label", "account_id", "access_token"], test_instagram_api_card
@@ -221,16 +305,20 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
221
305
  with header_cols[2]:
222
306
  st.metric("posts", _metric(profile.get("post_count")))
223
307
  with header_cols[3]:
224
- st.metric("Seguidores", _metric(profile.get("subscriber_count")))
308
+ st.metric("Seguidores", _metric(_profile_metric(profile, "subscriber_count", "followers_count", "follower_count")))
225
309
  with header_cols[4]:
226
- st.metric("seguindo", _metric(profile.get("following_count")))
310
+ st.metric("seguindo", _metric(_profile_metric(profile, "following_count", "following", "follows")))
227
311
  with header_cols[5]:
228
312
  refresh_col, edit_col = st.columns(2)
229
313
  with refresh_col:
230
314
  if st.button("↻", help="Actualizar posts, seguidores e seguindo", key=f"refresh_instagram_{profile_id}"):
231
315
  result = fetch_public_instagram_profile(_clean(profile.get("url")) or _clean(profile.get("handle")))
232
316
  if result.ok:
233
- update_channel(profile_id, {**result.data, "platform": "instagram", "country": profile.get("country", ""), "language": profile.get("language", ""), "character_id": profile.get("character_id", "")})
317
+ refreshed = dict(result.data)
318
+ for field in ("subscriber_count", "following_count", "post_count", "bio", "avatar_url"):
319
+ if refreshed.get(field) in (None, "") and profile.get(field) not in (None, ""):
320
+ refreshed[field] = profile[field]
321
+ update_channel(profile_id, {**refreshed, "platform": "instagram", "country": profile.get("country", ""), "language": profile.get("language", ""), "character_id": profile.get("character_id", "")})
234
322
  st.success("Métricas Instagram actualizadas.")
235
323
  st.rerun()
236
324
  st.warning(result.message)
@@ -279,6 +367,7 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
279
367
  update_channel(profile_id, {"character_id": selected_character})
280
368
  st.success("Personagem associado à conta Instagram.")
281
369
  st.rerun()
370
+ _render_instagram_posts(profile)
282
371
 
283
372
 
284
373
  def render_social_networks(settings: dict[str, Any]) -> None:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
+ import json
4
5
  from dataclasses import dataclass
5
6
  from datetime import datetime, timezone
6
7
  from html import unescape
@@ -66,6 +67,24 @@ def _structured_metric(document: str, *keys: str) -> int | None:
66
67
  return None
67
68
 
68
69
 
70
+ def _structured_metric_from_json(document: str, *keys: str) -> int | None:
71
+ wanted = {key.casefold() for key in keys}
72
+ for parsed in _embedded_json_documents(document):
73
+ for node in _walk_json(parsed):
74
+ if not isinstance(node, dict):
75
+ continue
76
+ for key, value in node.items():
77
+ if str(key).casefold() not in wanted:
78
+ continue
79
+ if isinstance(value, dict):
80
+ value = value.get('count') or value.get('value')
81
+ try:
82
+ return int(value)
83
+ except (TypeError, ValueError):
84
+ continue
85
+ return None
86
+
87
+
69
88
  def fetch_public_instagram_profile(source: str) -> IntegrationResult:
70
89
  try:
71
90
  reference = normalize_instagram_reference(source)
@@ -91,11 +110,95 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
91
110
  continue
92
111
  return None
93
112
 
94
- followers = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:followers|seguidores)',)) or _structured_metric(response.text, 'edge_followed_by', 'followers', 'follower_count')
95
- following = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:following|seguindo)',)) or _structured_metric(response.text, 'edge_follow', 'follows', 'following', 'following_count', 'followingCount')
113
+ 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')
114
+ 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')
96
115
  posts = metric((r'([\d,.]+)\s*(?:mil\s+)?(?:posts|publicações|publications)',)) or _structured_metric(response.text, 'edge_owner_to_timeline_media', 'posts', 'post_count')
97
116
  data = {'id': f"instagram_{reference['username']}", **reference, 'name': title.split('(')[0].strip() or reference['username'], 'bio': 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()}
98
117
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
99
118
 
100
119
 
101
- __all__ = ['IntegrationResult', 'fetch_public_instagram_profile', 'normalize_instagram_reference']
120
+ def _walk_json(value: Any):
121
+ if isinstance(value, dict):
122
+ yield value
123
+ for child in value.values():
124
+ yield from _walk_json(child)
125
+ elif isinstance(value, list):
126
+ for child in value:
127
+ yield from _walk_json(child)
128
+
129
+
130
+ def _embedded_json_documents(document: str) -> list[Any]:
131
+ decoder = json.JSONDecoder()
132
+ documents: list[Any] = []
133
+ for script in re.findall(r'<script[^>]*>(.*?)</script>', document, flags=re.IGNORECASE | re.DOTALL):
134
+ candidate = script.strip()
135
+ if not candidate:
136
+ continue
137
+ for start in [0] + [match.start() for match in re.finditer(r'[\[{]', candidate)][:20]:
138
+ try:
139
+ parsed, _ = decoder.raw_decode(candidate[start:])
140
+ except (json.JSONDecodeError, TypeError):
141
+ continue
142
+ documents.append(parsed)
143
+ break
144
+ return documents
145
+
146
+
147
+ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
148
+ shortcode = str(node.get('shortcode') or node.get('code') or '').strip()
149
+ media_id = str(node.get('id') or '').strip()
150
+ image_url = str(node.get('display_url') or node.get('thumbnail_src') or node.get('image') or '').strip()
151
+ if not shortcode and not media_id:
152
+ return None
153
+ if not image_url and not node.get('is_video'):
154
+ return None
155
+ caption_value = node.get('caption') or node.get('title') or ''
156
+ if isinstance(caption_value, dict):
157
+ caption_value = caption_value.get('text') or ''
158
+ edges = node.get('edge_media_to_caption')
159
+ if isinstance(edges, dict):
160
+ edge_list = edges.get('edges') or []
161
+ if edge_list and isinstance(edge_list[0], dict):
162
+ caption_value = ((edge_list[0].get('node') or {}).get('text') or caption_value)
163
+ url = str(node.get('permalink') or node.get('url') or '').strip()
164
+ if not url and shortcode:
165
+ url = f'https://www.instagram.com/p/{shortcode}/'
166
+ return {
167
+ 'id': media_id or shortcode,
168
+ 'shortcode': shortcode,
169
+ 'url': url,
170
+ 'image_url': image_url,
171
+ 'caption': str(caption_value or '').strip(),
172
+ 'published_at': node.get('taken_at_timestamp') or node.get('taken_at') or '',
173
+ 'is_video': bool(node.get('is_video') or node.get('video_url')),
174
+ }
175
+
176
+
177
+ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationResult:
178
+ try:
179
+ reference = normalize_instagram_reference(source)
180
+ except ValueError as exc:
181
+ return IntegrationResult(False, str(exc), {})
182
+ try:
183
+ 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)
184
+ except requests.RequestException as exc:
185
+ return IntegrationResult(False, f'Não foi possível consultar os posts públicos do Instagram: {exc}', reference)
186
+ if response.status_code >= 400:
187
+ return IntegrationResult(False, f'O Instagram devolveu HTTP {response.status_code} ao consultar os posts.', reference | {'status_code': response.status_code})
188
+ posts: list[dict[str, Any]] = []
189
+ seen: set[str] = set()
190
+ for document in _embedded_json_documents(response.text):
191
+ for node in _walk_json(document):
192
+ post = _post_from_node(node)
193
+ if not post or post['id'] in seen:
194
+ continue
195
+ seen.add(post['id'])
196
+ posts.append(post)
197
+ if len(posts) >= max(1, int(limit)):
198
+ break
199
+ if len(posts) >= max(1, int(limit)):
200
+ break
201
+ 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))]})
202
+
203
+
204
+ __all__ = ['IntegrationResult', 'fetch_public_instagram_posts', 'fetch_public_instagram_profile', 'normalize_instagram_reference']
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
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",