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