@danhachuel/thunderbolt 0.7.14 → 0.7.16
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.
- package/app/main.py +45 -10
- package/hermes_ui/storage.py +25 -3
- package/integrations/tiktok_public.py +1 -1
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -2419,7 +2419,7 @@ def render_tiktok_channels():
|
|
|
2419
2419
|
for channel in visible_tiktok_channels:
|
|
2420
2420
|
channel_id = str(channel.get("id") or "")
|
|
2421
2421
|
with st.container(border=True):
|
|
2422
|
-
card_cols = st.columns([0.7,
|
|
2422
|
+
card_cols = st.columns([0.7, 2.55, 1.15, 1.05, 1.05, 1.25, 1.35], gap="small")
|
|
2423
2423
|
with card_cols[0]:
|
|
2424
2424
|
avatar_url = _tiktok_avatar_url(channel)
|
|
2425
2425
|
if avatar_url:
|
|
@@ -2432,12 +2432,16 @@ def render_tiktok_channels():
|
|
|
2432
2432
|
st.caption(channel.get("description") or "Perfil carregado da página pública do TikTok.")
|
|
2433
2433
|
st.caption(f"Nicho: {channel.get('niche') or 'não configurado'}")
|
|
2434
2434
|
with card_cols[2]:
|
|
2435
|
-
|
|
2435
|
+
tiktok_url = _tiktok_channel_url(channel)
|
|
2436
|
+
if tiktok_url:
|
|
2437
|
+
st.link_button("Abrir canal", tiktok_url, type="primary", width="content")
|
|
2436
2438
|
with card_cols[3]:
|
|
2437
|
-
st.metric("
|
|
2439
|
+
st.metric("Seguidores", format_metric_number(channel.get("subscriber_count")))
|
|
2438
2440
|
with card_cols[4]:
|
|
2439
|
-
st.metric("
|
|
2441
|
+
st.metric("Curtidas", format_metric_number(channel.get("likes_count")))
|
|
2440
2442
|
with card_cols[5]:
|
|
2443
|
+
st.metric("Vídeos", format_metric_number(channel.get("video_count")))
|
|
2444
|
+
with card_cols[6]:
|
|
2441
2445
|
if st.button("↻", key=f"refresh_tiktok_metrics_{channel_id}", help="Actualizar Seguidores, Vídeos e Curtidas", width="stretch"):
|
|
2442
2446
|
with st.spinner("A actualizar métricas TikTok…"):
|
|
2443
2447
|
refreshed, message = _refresh_tiktok_channel_metrics(channel)
|
|
@@ -2651,20 +2655,37 @@ def _refresh_youtube_channel_metrics(channel: dict[str, Any], youtube: YouTubeAd
|
|
|
2651
2655
|
|
|
2652
2656
|
|
|
2653
2657
|
def _refresh_tiktok_channel_metrics(channel: dict[str, Any]) -> tuple[bool, str]:
|
|
2654
|
-
|
|
2658
|
+
channel_ref = str(channel.get("url") or channel.get("handle") or "").strip()
|
|
2659
|
+
if not channel_ref:
|
|
2660
|
+
return False, "Este canal não tem URL ou handle TikTok para actualizar as métricas."
|
|
2661
|
+
try:
|
|
2662
|
+
result = fetch_public_tiktok_profile(channel_ref)
|
|
2663
|
+
except Exception as exc:
|
|
2664
|
+
return False, f"Não foi possível actualizar as métricas TikTok ({type(exc).__name__}). Tente novamente."
|
|
2655
2665
|
if not result.ok or not isinstance(result.data, dict):
|
|
2656
2666
|
return False, result.message
|
|
2657
2667
|
data = result.data
|
|
2658
|
-
|
|
2659
|
-
"subscriber_count": data.get("subscriber_count"),
|
|
2660
|
-
"likes_count": data.get("likes_count"),
|
|
2661
|
-
"video_count": data.get("video_count"),
|
|
2668
|
+
updates = {
|
|
2662
2669
|
"metrics_source": data.get("metrics_source", "tiktok_public_page"),
|
|
2663
2670
|
"last_public_lookup_at": data.get("last_public_lookup_at", now()),
|
|
2664
|
-
}
|
|
2671
|
+
}
|
|
2672
|
+
for field in ("subscriber_count", "likes_count", "video_count", "view_count", "name", "handle", "url", "avatar_url", "thumbnail_url"):
|
|
2673
|
+
if data.get(field) is not None and data.get(field) != "":
|
|
2674
|
+
updates[field] = data[field]
|
|
2675
|
+
update_channel(str(channel["id"]), updates)
|
|
2665
2676
|
return True, "Métricas TikTok actualizadas."
|
|
2666
2677
|
|
|
2667
2678
|
|
|
2679
|
+
def _tiktok_channel_url(channel: dict[str, Any]) -> str:
|
|
2680
|
+
source = str(channel.get("url") or channel.get("handle") or channel.get("username") or "").strip()
|
|
2681
|
+
if not source:
|
|
2682
|
+
return ""
|
|
2683
|
+
try:
|
|
2684
|
+
return str(normalize_tiktok_reference(source).get("url") or "").strip()
|
|
2685
|
+
except ValueError:
|
|
2686
|
+
return source if source.startswith(("http://", "https://")) else ""
|
|
2687
|
+
|
|
2688
|
+
|
|
2668
2689
|
def render_channels():
|
|
2669
2690
|
st.title("Canais Youtube")
|
|
2670
2691
|
st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
|
|
@@ -5590,6 +5611,11 @@ def _render_tiktok_automation_cards():
|
|
|
5590
5611
|
st.subheader("Vídeos cadastrados TikTok")
|
|
5591
5612
|
st.caption("Esta fila mostra exclusivamente tarefas associadas a canais TikTok.")
|
|
5592
5613
|
tiktok_tasks = load_automation_tasks_for_platform("tiktok")
|
|
5614
|
+
tiktok_channels_by_id = {
|
|
5615
|
+
str(channel.get("id")): channel
|
|
5616
|
+
for channel in read_json("channels.json", [])
|
|
5617
|
+
if isinstance(channel, dict) and classify_channel_platform(channel) == "tiktok"
|
|
5618
|
+
}
|
|
5593
5619
|
if not tiktok_tasks:
|
|
5594
5620
|
st.info("Ainda não existem vídeos TikTok cadastrados.")
|
|
5595
5621
|
for task in tiktok_tasks:
|
|
@@ -5608,6 +5634,15 @@ def _render_tiktok_automation_cards():
|
|
|
5608
5634
|
st.caption("Thumbnail ainda não pronta")
|
|
5609
5635
|
st.write(f"**{task.get('title') or task.get('topic') or 'Vídeo TikTok'}**")
|
|
5610
5636
|
st.caption(f"{task.get('channel_name') or 'Canal TikTok'} · {task_id}")
|
|
5637
|
+
task_channel = tiktok_channels_by_id.get(str(task.get("channel_id") or ""))
|
|
5638
|
+
if task_channel is None:
|
|
5639
|
+
task_channel = next(
|
|
5640
|
+
(channel for channel in tiktok_channels_by_id.values() if str(channel.get("name") or "").strip() == str(task.get("channel_name") or "").strip()),
|
|
5641
|
+
None,
|
|
5642
|
+
)
|
|
5643
|
+
task_channel_url = _tiktok_channel_url(task_channel or {})
|
|
5644
|
+
if task_channel_url:
|
|
5645
|
+
st.link_button("Abrir canal", task_channel_url, type="primary", width="content")
|
|
5611
5646
|
thumbnail_download_col, prompt_download_col = st.columns(2, gap="small")
|
|
5612
5647
|
with thumbnail_download_col:
|
|
5613
5648
|
st.download_button(
|
package/hermes_ui/storage.py
CHANGED
|
@@ -450,6 +450,7 @@ _LOCK_TIMEOUT_SECONDS = 30.0
|
|
|
450
450
|
_LOCK_POLL_SECONDS = 0.05
|
|
451
451
|
_LOCK_STALE_SECONDS = 120
|
|
452
452
|
_PROTECTED_STATE_FILES = {"channels.json", "tasks.json", "batches.json", "queues.json", "uploads.json"}
|
|
453
|
+
_REPLACE_RETRY_DELAYS_SECONDS = (0.05, 0.1, 0.2, 0.4, 0.8, 1.0, 1.0, 1.0)
|
|
453
454
|
|
|
454
455
|
|
|
455
456
|
class StorageIntegrityError(RuntimeError):
|
|
@@ -533,10 +534,31 @@ def _atomic_write_unlocked(path: Path, data: Any) -> None:
|
|
|
533
534
|
handle.write("\n")
|
|
534
535
|
handle.flush()
|
|
535
536
|
os.fsync(handle.fileno())
|
|
536
|
-
|
|
537
|
+
replace_error: PermissionError | None = None
|
|
538
|
+
for delay in (0.0, *_REPLACE_RETRY_DELAYS_SECONDS):
|
|
539
|
+
if delay:
|
|
540
|
+
time.sleep(delay)
|
|
541
|
+
try:
|
|
542
|
+
os.replace(temp_name, path)
|
|
543
|
+
replace_error = None
|
|
544
|
+
break
|
|
545
|
+
except PermissionError as exc:
|
|
546
|
+
# Windows can briefly keep the destination open while Streamlit,
|
|
547
|
+
# an antivirus scanner, or the previous reader releases it.
|
|
548
|
+
# Keep the atomic replace, but give that transient handle time
|
|
549
|
+
# to disappear instead of failing the pipeline at its next
|
|
550
|
+
# progress checkpoint.
|
|
551
|
+
replace_error = exc
|
|
552
|
+
if replace_error is not None:
|
|
553
|
+
raise replace_error
|
|
537
554
|
finally:
|
|
538
|
-
|
|
539
|
-
os.
|
|
555
|
+
try:
|
|
556
|
+
if os.path.exists(temp_name):
|
|
557
|
+
os.unlink(temp_name)
|
|
558
|
+
except OSError:
|
|
559
|
+
# A scanner may also briefly hold the temporary file. The next
|
|
560
|
+
# write can clean it up; never hide the replace error with cleanup.
|
|
561
|
+
pass
|
|
540
562
|
|
|
541
563
|
|
|
542
564
|
def atomic_write(path: Path, data: Any) -> None:
|
|
@@ -212,7 +212,7 @@ def fetch_public_tiktok_profile(source: str) -> IntegrationResult:
|
|
|
212
212
|
"Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
|
|
213
213
|
}
|
|
214
214
|
try:
|
|
215
|
-
response = requests.get(reference["url"], headers=headers, timeout=
|
|
215
|
+
response = requests.get(reference["url"], headers=headers, timeout=8, allow_redirects=True)
|
|
216
216
|
except requests.RequestException as exc:
|
|
217
217
|
return IntegrationResult(False, f"Não foi possível consultar o perfil público do TikTok: {exc}", reference)
|
|
218
218
|
if response.status_code in {401, 403, 429}:
|
package/package.json
CHANGED