@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
|
-
|
|
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