@danhachuel/thunderbolt 0.6.17 → 0.6.19

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
 
@@ -31,6 +34,79 @@ def _language_index(value: Any) -> int:
31
34
  return list(LANGUAGE_CODES).index(code) if code in LANGUAGE_CODES else list(LANGUAGE_CODES).index("pt")
32
35
 
33
36
 
37
+ def _instagram_posts_key(profile_id: str) -> str:
38
+ return f"instagram_posts_{profile_id}"
39
+
40
+
41
+ def _instagram_posts_archive(posts: list[dict[str, Any]]) -> bytes:
42
+ archive = io.BytesIO()
43
+ with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as bundle:
44
+ manifest: list[str] = []
45
+ for index, post in enumerate(posts, start=1):
46
+ url = _clean(post.get("image_url"))
47
+ if url:
48
+ try:
49
+ response = requests.get(url, timeout=15)
50
+ response.raise_for_status()
51
+ extension = ".jpg"
52
+ content_type = response.headers.get("content-type", "")
53
+ if "png" in content_type:
54
+ extension = ".png"
55
+ elif "webp" in content_type:
56
+ extension = ".webp"
57
+ bundle.writestr(f"post-{index:02d}{extension}", response.content)
58
+ except requests.RequestException:
59
+ pass
60
+ manifest.append(f"{index}. {_clean(post.get('url'))}\n{_clean(post.get('caption'))}\n")
61
+ bundle.writestr("posts.txt", "\n".join(manifest))
62
+ return archive.getvalue()
63
+
64
+
65
+ def _render_instagram_posts(profile: Mapping[str, Any]) -> None:
66
+ profile_id = _clean(profile.get("id"))
67
+ state_key = _instagram_posts_key(profile_id)
68
+ posts = st.session_state.get(state_key, [])
69
+ source = _clean(profile.get("url")) or _clean(profile.get("handle"))
70
+ with st.expander("Últimos posts do Instagram", expanded=False):
71
+ action_cols = st.columns(3)
72
+ with action_cols[0]:
73
+ load_clicked = st.button("Carregar últimos 10", key=f"load_instagram_posts_{profile_id}", use_container_width=True)
74
+ with action_cols[1]:
75
+ refresh_clicked = st.button("Actualizar tudo", key=f"refresh_instagram_posts_{profile_id}", use_container_width=True)
76
+ with action_cols[2]:
77
+ 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)
78
+ if load_clicked or refresh_clicked:
79
+ target = max(10, len(posts)) if refresh_clicked else 10
80
+ result = fetch_public_instagram_posts(source, limit=target)
81
+ if result.ok:
82
+ posts = result.data.get("posts", [])
83
+ st.session_state[state_key] = posts
84
+ st.success(f"{len(posts)} posts carregados.")
85
+ else:
86
+ st.warning(result.message)
87
+ posts = st.session_state.get(state_key, posts)
88
+ if not posts:
89
+ st.info("Clique em Carregar últimos 10 para consultar os posts públicos desta conta.")
90
+ return
91
+ for post in posts:
92
+ with st.container(border=True):
93
+ post_cols = st.columns([1, 3])
94
+ with post_cols[0]:
95
+ if _clean(post.get("image_url")):
96
+ st.image(post["image_url"], use_container_width=True)
97
+ with post_cols[1]:
98
+ if _clean(post.get("url")):
99
+ st.link_button("Abrir post", post["url"])
100
+ if _clean(post.get("caption")):
101
+ st.caption(post["caption"])
102
+ if st.button("Mostrar + 10", key=f"more_instagram_posts_{profile_id}", use_container_width=True):
103
+ result = fetch_public_instagram_posts(source, limit=len(posts) + 10)
104
+ if result.ok:
105
+ st.session_state[state_key] = result.data.get("posts", posts)
106
+ st.rerun()
107
+ st.warning(result.message)
108
+
109
+
34
110
  def _api_card_defaults(kind: str) -> tuple[str, list[str], Callable[[Mapping[str, Any]], dict[str, Any]]]:
35
111
  if kind == "instagram":
36
112
  return "instagram_api_cards", ["id", "label", "account_id", "access_token"], test_instagram_api_card
@@ -214,6 +290,9 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
214
290
  with header_cols[1]:
215
291
  st.write(f"**{_clean(profile.get('name')) or 'Sem nome'}**")
216
292
  st.caption(f"{_clean(profile.get('handle')) or _clean(profile.get('url')) or 'sem handler'}")
293
+ bio = _clean(profile.get("bio"))
294
+ if bio:
295
+ st.caption(f"Bio: {bio}")
217
296
  st.caption(f"{_clean(profile.get('country')) or 'País não definido'} · {_clean(profile.get('language')) or 'Idioma não definido'}")
218
297
  with header_cols[2]:
219
298
  st.metric("posts", _metric(profile.get("post_count")))
@@ -276,6 +355,7 @@ def _render_instagram_card(profile: dict[str, Any], characters: list[dict[str, A
276
355
  update_channel(profile_id, {"character_id": selected_character})
277
356
  st.success("Personagem associado à conta Instagram.")
278
357
  st.rerun()
358
+ _render_instagram_posts(profile)
279
359
 
280
360
 
281
361
  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
@@ -98,4 +99,88 @@ def fetch_public_instagram_profile(source: str) -> IntegrationResult:
98
99
  return IntegrationResult(True, 'Perfil Instagram encontrado publicamente.', data)
99
100
 
100
101
 
101
- __all__ = ['IntegrationResult', 'fetch_public_instagram_profile', 'normalize_instagram_reference']
102
+ def _walk_json(value: Any):
103
+ if isinstance(value, dict):
104
+ yield value
105
+ for child in value.values():
106
+ yield from _walk_json(child)
107
+ elif isinstance(value, list):
108
+ for child in value:
109
+ yield from _walk_json(child)
110
+
111
+
112
+ def _embedded_json_documents(document: str) -> list[Any]:
113
+ decoder = json.JSONDecoder()
114
+ documents: list[Any] = []
115
+ for script in re.findall(r'<script[^>]*>(.*?)</script>', document, flags=re.IGNORECASE | re.DOTALL):
116
+ candidate = script.strip()
117
+ if not candidate:
118
+ continue
119
+ for start in [0] + [match.start() for match in re.finditer(r'[\[{]', candidate)][:20]:
120
+ try:
121
+ parsed, _ = decoder.raw_decode(candidate[start:])
122
+ except (json.JSONDecodeError, TypeError):
123
+ continue
124
+ documents.append(parsed)
125
+ break
126
+ return documents
127
+
128
+
129
+ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
130
+ shortcode = str(node.get('shortcode') or node.get('code') or '').strip()
131
+ media_id = str(node.get('id') or '').strip()
132
+ image_url = str(node.get('display_url') or node.get('thumbnail_src') or node.get('image') or '').strip()
133
+ if not shortcode and not media_id:
134
+ return None
135
+ if not image_url and not node.get('is_video'):
136
+ return None
137
+ caption_value = node.get('caption') or node.get('title') or ''
138
+ if isinstance(caption_value, dict):
139
+ caption_value = caption_value.get('text') or ''
140
+ edges = node.get('edge_media_to_caption')
141
+ if isinstance(edges, dict):
142
+ edge_list = edges.get('edges') or []
143
+ if edge_list and isinstance(edge_list[0], dict):
144
+ caption_value = ((edge_list[0].get('node') or {}).get('text') or caption_value)
145
+ url = str(node.get('permalink') or node.get('url') or '').strip()
146
+ if not url and shortcode:
147
+ url = f'https://www.instagram.com/p/{shortcode}/'
148
+ return {
149
+ 'id': media_id or shortcode,
150
+ 'shortcode': shortcode,
151
+ 'url': url,
152
+ 'image_url': image_url,
153
+ 'caption': str(caption_value or '').strip(),
154
+ 'published_at': node.get('taken_at_timestamp') or node.get('taken_at') or '',
155
+ 'is_video': bool(node.get('is_video') or node.get('video_url')),
156
+ }
157
+
158
+
159
+ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationResult:
160
+ try:
161
+ reference = normalize_instagram_reference(source)
162
+ except ValueError as exc:
163
+ return IntegrationResult(False, str(exc), {})
164
+ try:
165
+ 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)
166
+ except requests.RequestException as exc:
167
+ return IntegrationResult(False, f'Não foi possível consultar os posts públicos do Instagram: {exc}', reference)
168
+ if response.status_code >= 400:
169
+ return IntegrationResult(False, f'O Instagram devolveu HTTP {response.status_code} ao consultar os posts.', reference | {'status_code': response.status_code})
170
+ posts: list[dict[str, Any]] = []
171
+ seen: set[str] = set()
172
+ for document in _embedded_json_documents(response.text):
173
+ for node in _walk_json(document):
174
+ post = _post_from_node(node)
175
+ if not post or post['id'] in seen:
176
+ continue
177
+ seen.add(post['id'])
178
+ posts.append(post)
179
+ if len(posts) >= max(1, int(limit)):
180
+ break
181
+ if len(posts) >= max(1, int(limit)):
182
+ break
183
+ 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))]})
184
+
185
+
186
+ __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.17",
3
+ "version": "0.6.19",
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",