penambahan button crawling

penambahan button crawling dan pebaikan status crawling service
This commit is contained in:
adistya13 2026-05-22 20:42:18 +07:00
parent cc8342c6cd
commit 8830accff5
8 changed files with 843 additions and 393 deletions

204
app.py
View File

@ -26,14 +26,10 @@ except Exception:
init_db()
# ── Auto-start background crawler scheduler ──────────────────────────────────
# Dipanggil sekali saat app pertama dijalankan.
# Scheduler berjalan sebagai daemon thread — tidak perlu terminal terpisah.
try:
from crawler import ensure_scheduler_running
ensure_scheduler_running()
except Exception as _e:
print(f"[app] Gagal memulai scheduler: {_e}")
# ── Auto-start background crawler scheduler (disabled) ───────────────────
# Scheduler NRT hanya boleh berjalan setelah user menekan tombol aktivasi
# pada mode "Hari Ini" (captured) agar sesuai kebutuhan (NRT dimulai manual).
st.set_page_config(
page_title="Dashboard Sentimen Twitter",
@ -164,8 +160,15 @@ def _check_crawler_alive():
and now - heartbeat_at <= threshold
)
# Hanya update _scheduler_started dari database jika user TIDAK sedang nonaktifkan eksplisit
_raw_alive = _check_crawler_alive()
explicitly_off = st.session_state.get("nrt_explicitly_deactivated", False)
st.session_state._scheduler_started = _check_crawler_alive()
if explicitly_off:
# User sudah klik nonaktifkan — override database state, paksa False
st.session_state._scheduler_started = False
else:
st.session_state._scheduler_started = _raw_alive
try:
from crawler import NRT_INTERVAL_MINUTES
@ -190,8 +193,7 @@ st.markdown("""
--bg: #f8fafc;
--card: #ffffff;
}
* {
html, body, [class*="css"], .stApp {
font-family: 'Plus Jakarta Sans', sans-serif !important;
box-sizing: border-box;
}
@ -226,7 +228,7 @@ html, body, .stApp, [data-testid="stAppViewContainer"] {
}
.main > div {
padding: 1.5rem 2rem !important;
padding: 4.3rem 2rem 1.5rem 2rem !important;
}
[data-testid="stSidebar"] {
@ -683,27 +685,192 @@ span:not([style]) {
box-shadow: 0 2px 6px rgba(15,23,42,0.05);
}
#MainMenu, footer, header { visibility: hidden !important; }
/* =====================================================
CLEAN STREAMLIT HEADER + SIDEBAR
===================================================== */
[data-testid="stHeader"] { background: transparent !important; }
/* Hide bawaan Streamlit */
#MainMenu {
visibility: hidden !important;
}
footer {
visibility: hidden !important;
}
/* =====================================================
HEADER
===================================================== */
[data-testid="stHeader"] {
background: rgba(248,250,252,0.96) !important;
border-bottom: 1px solid #e2e8f0 !important;
backdrop-filter: blur(8px) !important;
}
/* =====================================================
SIDEBAR
===================================================== */
[data-testid="stSidebar"] {
background: linear-gradient(
180deg,
#ffffff 0%,
#f8fafc 100%
) !important;
border-right: 1px solid #e2e8f0 !important;
}
/* =====================================================
MAIN CONTENT
===================================================== */
.main > div {
padding-top: 4.4rem !important;
}
/* =====================================================
SAFE SIDEBAR TOGGLE
===================================================== */
/* =====================================================
SAFE SIDEBAR TOGGLE
===================================================== */
[data-testid="stHeader"],
header[data-testid="stHeader"],
header[data-testid="stHeader"] > div {
overflow: visible !important;
}
[data-testid="collapsedControl"] {
position: fixed !important;
top: 10px !important;
left: 10px !important;
z-index: 2147483647 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: all !important;
width: 44px !important;
height: 44px !important;
overflow: visible !important;
clip: unset !important;
clip-path: none !important;
}
[data-testid="collapsedControl"] button {
width: 44px !important;
height: 44px !important;
border-radius: 50% !important;
background: linear-gradient(135deg, #3b6cf7, #5b7cfa) !important;
border: 2px solid #2d59d1 !important;
box-shadow: 0 4px 16px rgba(59,108,247,0.5) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
padding: 0 !important;
overflow: visible !important;
}
[data-testid="collapsedControl"] button svg {
width: 20px !important;
height: 20px !important;
fill: #ffffff !important;
color: #ffffff !important;
stroke: #ffffff !important;
flex-shrink: 0 !important;
}
[data-testid="collapsedControl"] button svg * {
fill: #ffffff !important;
stroke: #ffffff !important;
}
[data-testid="collapsedControl"] button:hover {
background: #2d59d1 !important;
transform: scale(1.1) !important;
box-shadow: 0 6px 22px rgba(59,108,247,0.6) !important;
}
/* Tombol COLLAPSE di dalam sidebar */
section[data-testid="stSidebar"] button[kind="header"] {
background: #ffffff !important;
border: 1px solid #e2e8f0 !important;
color: #0f172a !important;
border-radius: 10px !important;
box-shadow: 0 2px 6px rgba(15,23,42,0.08) !important;
}
section[data-testid="stSidebar"] button[kind="header"]:hover {
background: #eef2ff !important;
border-color: #3b6cf7 !important;
}
/* =====================================================
DOWNLOAD BUTTON
===================================================== */
[data-testid="stDownloadButton"] button {
background: var(--card) !important;
color: var(--blue-primary) !important;
border: 1.5px solid var(--border) !important;
border-radius: 10px !important;
font-weight: 600 !important;
transition: all 0.2s ease !important;
}
[data-testid="stDownloadButton"] button:hover {
background: var(--blue-light) !important;
border-color: var(--blue-primary) !important;
}
/* =====================================================
SIDEBAR SAFE FIX
===================================================== */
[data-testid="stSidebar"] {
background: linear-gradient(
180deg,
#ffffff 0%,
#f8fafc 100%
) !important;
border-right: 1px solid #e2e8f0 !important;
z-index: 999999 !important;
}
button[kind="header"] {
background: white !important;
border-radius: 10px !important;
border: 1px solid #dbe2ea !important;
box-shadow: 0 2px 6px rgba(15,23,42,0.08) !important;
}
button[kind="header"]:hover {
background: #eef2ff !important;
border-color: #3b6cf7 !important;
}
</style>
""", unsafe_allow_html=True)
if "page" not in st.session_state:
st.session_state.page = "crawling"
@ -750,12 +917,15 @@ with st.sidebar:
# ── STATUS CRAWLER ──────────────────────────────────────────
sched_ok = st.session_state.get("_scheduler_started", False)
explicitly_off = st.session_state.get("nrt_explicitly_deactivated", False)
sched_interval = st.session_state.get("_scheduler_interval", 0)
sched_color = "#16a34a" if sched_ok else "#3b6cf7"
sched_color = "#16a34a" if sched_ok else "#64748b"
sched_label = (
f"Aktif — diperbarui tiap {sched_interval} menit"
if sched_ok
else "Standby — akan crawl otomatis setiap 5 menit"
else "Nonaktif — crawling dihentikan oleh pengguna"
if explicitly_off
else "Standby — aktifkan melalui menu Hari Ini"
)
st.markdown(f"""

View File

@ -12,13 +12,22 @@ NRT_INTERVAL_MINUTES = 5
LOG_FILE = "auto_crawl_log.json"
STATE_FILE = "crawler_state.json"
# ── Internal lock agar tidak ada dua crawl berjalan bersamaan ──
# ── Lock agar tidak ada dua crawl berjalan bersamaan ──────────────
_crawl_lock = threading.Lock()
# ── Flag untuk scheduler background thread ──
# ── Flag scheduler ────────────────────────────────────────────────
_scheduler_started = False
_scheduler_lock = threading.Lock()
# ── Flag aktivasi NRT — harus True sebelum scheduler boleh jalan ──
# PENTING: Nilai awal selalu False. Tidak ada kode module-level
# yang boleh mengubah ini menjadi True secara otomatis.
_nrt_enabled = False
_nrt_enable_lock = threading.Lock()
# ── Event untuk menghentikan scheduler secara bersih ──────────────
_scheduler_stop_event = threading.Event()
# ═══════════════════════════════════════════════════════════
# STATE & LOG HELPERS
@ -86,6 +95,31 @@ def set_crawler_state(is_running=False, service_active=None):
except Exception as e:
print(f"[crawler] Gagal menulis state: {e}")
# ═══════════════════════════════════════════════════════════
# NRT ACTIVATION PERSISTENCE
# ═══════════════════════════════════════════════════════════
NRT_ACTIVATION_FILE = "nrt_activation.json"
def save_nrt_activation(activated: bool):
"""Simpan status aktivasi NRT ke file agar persist setelah refresh."""
try:
with open(NRT_ACTIVATION_FILE, "w") as f:
json.dump({"activated": activated, "updated_at": datetime.now(timezone.utc).isoformat()}, f)
except Exception as e:
print(f"[crawler] Gagal menyimpan NRT activation: {e}")
def load_nrt_activation() -> bool:
"""Baca status aktivasi NRT dari file."""
if not os.path.exists(NRT_ACTIVATION_FILE):
return False
try:
with open(NRT_ACTIVATION_FILE, "r") as f:
data = json.load(f)
return bool(data.get("activated", False))
except Exception:
return False
# ═══════════════════════════════════════════════════════════
# CORE JOB
@ -95,7 +129,6 @@ def auto_crawl_job():
"""
Jalankan satu siklus crawling.
Thread-safe: hanya satu crawl yang boleh berjalan pada satu waktu.
Bisa dipanggil dari tombol UI maupun scheduler otomatis.
"""
if not _crawl_lock.acquire(blocking=False):
print("[crawler] Crawl sedang berjalan, skip.")
@ -129,28 +162,32 @@ def auto_crawl_job():
# ═══════════════════════════════════════════════════════════
# BACKGROUND SCHEDULER (dipakai Streamlit, tanpa terminal)
# BACKGROUND SCHEDULER
# ═══════════════════════════════════════════════════════════
def _scheduler_loop():
"""
Loop yang berjalan di daemon thread.
Crawling pertama langsung dijalankan, lalu setiap NRT_INTERVAL_MINUTES menit.
Loop daemon thread. Crawl pertama langsung, lalu tiap NRT_INTERVAL_MINUTES.
Berhenti bersih saat _scheduler_stop_event di-set.
"""
print(f"[crawler] Scheduler dimulai — interval {NRT_INTERVAL_MINUTES} menit.")
set_crawler_state(False, service_active=True)
while True:
while not _scheduler_stop_event.is_set():
auto_crawl_job()
# Tulis heartbeat setiap 30 detik selagi menunggu interval berikutnya
# Tunggu interval berikutnya sambil tulis heartbeat tiap 10 detik
wait_until = time.time() + (NRT_INTERVAL_MINUTES * 60)
while time.time() < wait_until:
# Heartbeat agar is_crawler_service_active() tetap True
while time.time() < wait_until and not _scheduler_stop_event.is_set():
_write_heartbeat()
time.sleep(min(30, max(1, wait_until - time.time())))
time.sleep(min(10, max(1, wait_until - time.time())))
print(f"[crawler] Interval berikutnya dalam {NRT_INTERVAL_MINUTES} menit...")
if not _scheduler_stop_event.is_set():
print(f"[crawler] Mulai crawl berikutnya...")
# Bersihkan state saat loop selesai
set_crawler_state(False, service_active=False)
print("[crawler] Scheduler dihentikan.")
def _write_heartbeat():
@ -165,31 +202,92 @@ def _write_heartbeat():
pass
# ═══════════════════════════════════════════════════════════
# ACTIVATION API ← dipakai oleh crawling_page.py
# ═══════════════════════════════════════════════════════════
def is_nrt_enabled() -> bool:
"""Kembalikan True jika NRT sudah diaktifkan user."""
return _nrt_enabled
def activate_nrt_scheduler():
"""
Aktifkan NRT scheduler.
Harus dipanggil HANYA dari tombol UI tidak dari module-level manapun.
Idempotent: aman dipanggil berkali-kali.
"""
global _nrt_enabled
with _nrt_enable_lock:
_nrt_enabled = True
save_nrt_activation(True) # ← TAMBAH INI
_scheduler_stop_event.clear() # pastikan event tidak dalam kondisi set
ensure_scheduler_running()
print("[crawler] NRT diaktifkan oleh user.")
def deactivate_nrt_scheduler():
"""
Hentikan NRT scheduler.
Thread yang berjalan akan berhenti bersih pada iterasi berikutnya.
"""
global _nrt_enabled, _scheduler_started
with _nrt_enable_lock:
_nrt_enabled = False
save_nrt_activation(False)
_scheduler_stop_event.set() # sinyal ke thread agar berhenti
with _scheduler_lock:
_scheduler_started = False # izinkan start ulang di masa depan
set_crawler_state(False, service_active=False)
print("[crawler] NRT dinonaktifkan oleh user.")
def ensure_scheduler_running():
"""
Pastikan background scheduler sudah berjalan.
HANYA berjalan jika _nrt_enabled = True (sudah diaktifkan user).
Aman dipanggil berkali-kali (idempotent).
Dipanggil dari crawling_page.py saat halaman dirender.
JANGAN panggil fungsi ini dari module-level atau saat app startup.
Panggil hanya setelah activate_nrt_scheduler() dipicu dari UI.
"""
global _scheduler_started
if not _nrt_enabled:
# Belum diaktifkan user — jangan mulai thread apapun
return
with _scheduler_lock:
if _scheduler_started:
return
t = threading.Thread(target=_scheduler_loop, name="CrawlerScheduler", daemon=True)
_scheduler_stop_event.clear()
t = threading.Thread(
target=_scheduler_loop,
name="CrawlerScheduler",
daemon=True,
)
t.start()
_scheduler_started = True
print("[crawler] Background scheduler thread dimulai.")
# ═══════════════════════════════════════════════════════════
# ENTRY POINT (tetap bisa dipakai via terminal jika mau)
# ENTRY POINT (via terminal, opsional)
# ═══════════════════════════════════════════════════════════
def main():
"""Jalankan scheduler via terminal (opsional, tidak wajib)."""
print("Crawler realtime aktif.")
"""Jalankan scheduler via terminal (run_scraper_loop.py)."""
global _nrt_enabled
_nrt_enabled = True # mode terminal: langsung aktif
print("Crawler realtime aktif (mode terminal).")
print(f"Interval: {NRT_INTERVAL_MINUTES} menit")
set_crawler_state(False, service_active=True)

1
nrt_activation.json Normal file
View File

@ -0,0 +1 @@
{"activated": false, "updated_at": "2026-05-22T13:17:14.073811+00:00"}

View File

@ -21,16 +21,13 @@ from crawler import (
AUTO_CRAWL_INTERVAL_HOURS,
NRT_INTERVAL_MINUTES,
auto_crawl_job,
activate_nrt_scheduler,
deactivate_nrt_scheduler,
is_nrt_enabled,
ensure_scheduler_running,
)
# ═══════════════════════════════════════════════════════════
# AUTO-START SCHEDULER
# ═══════════════════════════════════════════════════════════
ensure_scheduler_running()
# ═══════════════════════════════════════════════════════════
# TIMEZONE & DATETIME HELPERS
# ═══════════════════════════════════════════════════════════
@ -181,39 +178,53 @@ def _render_crawling_styles():
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
.block-container { padding-top: 1rem !important; }
.block-container {
padding-top: 1rem !important;
padding-left: 1.5rem !important;
padding-right: 1.5rem !important;
}
.stApp {
background:
radial-gradient(circle at top left, #eef4ff 0%, transparent 25%),
radial-gradient(circle at top right, #f0fdf4 0%, transparent 20%),
#f8fafc;
}
[data-testid="stMainBlockContainer"] { padding-top: 1rem !important; }
header[data-testid="stHeader"] { height: 0 !important; min-height: 0 !important; }
header[data-testid="stHeader"] { height: 0 !important; min-height: 0 !important; overflow: visible !important; }
section[data-testid="stMain"] * {
font-family: 'Plus Jakarta Sans', sans-serif !important;
}
/* Cards */
.crawl-card {
background: #ffffff;
border: 1px solid #e8edf5;
border-radius: 16px;
padding: 1.25rem 1.4rem;
box-shadow: 0 2px 12px rgba(15,23,42,0.05);
margin-bottom: 1.1rem;
transition: box-shadow 0.2s ease;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(10px);
border: 1px solid rgba(226,232,240,0.7);
border-radius: 20px;
padding: 1.35rem 1.45rem;
box-shadow: 0 4px 20px rgba(15,23,42,0.05), 0 1px 3px rgba(15,23,42,0.04);
margin-bottom: 1.15rem;
transition: all 0.22s ease;
}
.crawl-card:hover {
box-shadow: 0 6px 24px rgba(15,23,42,0.09);
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(15,23,42,0.08), 0 2px 8px rgba(15,23,42,0.05);
}
/* Mode cards */
.mode-card {
transition: transform 0.18s cubic-bezier(.34,1.56,.64,1), box-shadow 0.18s ease;
cursor: default;
}
.mode-card:hover {
transform: translateY(-3px);
box-shadow: 0 10px 28px rgba(15,23,42,0.10) !important;
transition: all 0.22s ease;
cursor: pointer;
backdrop-filter: blur(8px);
}
.mode-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(15,23,42,0.08) !important;
}
/* Metric pills */
.metric-pill {
border-radius: 14px;
padding: 1rem 0.9rem;
@ -223,7 +234,6 @@ section[data-testid="stMain"] * {
}
.metric-pill:hover { transform: translateY(-2px); }
/* Buttons */
.stButton > button {
border-radius: 12px !important;
font-weight: 700 !important;
@ -237,14 +247,12 @@ section[data-testid="stMain"] * {
box-shadow: 0 6px 18px rgba(59,108,247,0.15) !important;
}
/* Page header animation */
.page-header-card { animation: fadeSlideIn 0.45s ease both; }
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Status dot pulse */
@keyframes pulse-green {
0%,100% { box-shadow: 0 0 0 0 rgba(22,163,74,0.45); }
50% { box-shadow: 0 0 0 7px rgba(22,163,74,0); }
@ -253,10 +261,14 @@ section[data-testid="stMain"] * {
0%,100% { box-shadow: 0 0 0 0 rgba(59,108,247,0.45); }
50% { box-shadow: 0 0 0 7px rgba(59,108,247,0); }
}
@keyframes pulse-orange {
0%,100% { box-shadow: 0 0 0 0 rgba(234,88,12,0.45); }
50% { box-shadow: 0 0 0 7px rgba(234,88,12,0); }
}
.pulse-dot-green { animation: pulse-green 2s ease infinite; }
.pulse-dot-blue { animation: pulse-blue 2s ease infinite; }
.pulse-dot-orange { animation: pulse-orange 2s ease infinite; }
/* Monitor table rows */
.monitor-row {
display: flex; justify-content: space-between;
align-items: center; gap: 0.75rem;
@ -266,14 +278,12 @@ section[data-testid="stMain"] * {
.monitor-label { font-size: 0.78rem; color: #64748b; font-weight: 500; }
.monitor-value { font-size: 0.8rem; font-weight: 700; text-align: right; flex-shrink: 0; }
/* Stat number animation */
@keyframes countUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.stat-num { animation: countUp 0.6s ease both; }
/* Custom date panel */
.st-key-custom_date_panel {
background: #ffffff !important;
border: 1px solid #e8edf5 !important;
@ -305,12 +315,115 @@ section[data-testid="stMain"] * {
margin-bottom: 0 !important;
}
/* Section divider */
.section-divider {
height: 1px;
background: linear-gradient(90deg, transparent, #e2e8f0 30%, #e2e8f0 70%, transparent);
margin: 1.5rem 0;
}
/* Page Header ph-* classes */
.ph-card {
background: rgba(255,255,255,0.95);
border: 1px solid #e8edf5;
border-radius: 20px;
padding: 1.6rem 1.8rem 1.4rem;
margin-bottom: 1.2rem;
box-shadow: 0 2px 12px rgba(15,23,42,0.05);
}
.ph-top {
display: flex;
align-items: flex-start;
gap: 1.1rem;
margin-bottom: 1.2rem;
}
.ph-icon {
width: 50px; height: 50px; flex-shrink: 0;
background: linear-gradient(135deg,#3b6cf7,#6366f1);
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem;
box-shadow: 0 4px 14px rgba(59,108,247,0.25);
}
.ph-title {
font-size: 1.25rem;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
line-height: 1.3;
}
.ph-desc {
font-size: 0.82rem;
color: #64748b;
line-height: 1.75;
max-width: 860px;
margin: 0.4rem 0 0;
}
.ph-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 0.75rem;
}
.ph-tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 999px;
font-size: 0.71rem;
font-weight: 700;
border: 1px solid;
}
.ph-divider {
height: 1px;
background: #f1f5f9;
margin: 1.1rem 0;
}
.ph-chips {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 1.1rem;
}
.ph-chip {
display: flex;
align-items: center;
gap: 10px;
background: #f8fafc;
border: 1px solid #e8edf5;
border-radius: 12px;
padding: 10px 12px;
}
.ph-chip-icon { font-size: 1.1rem; flex-shrink: 0; }
.ph-chip-label {
font-size: 0.6rem;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 2px;
}
.ph-chip-value { font-size: 0.8rem; font-weight: 700; color: #1e293b; }
/* Activation Gate */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.activation-panel { animation: fadeIn 0.4s ease both; }
/* Active Strip */
.active-strip {
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
border: 1px solid #86efac;
border-radius: 14px;
padding: 0.85rem 1.15rem;
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
box-shadow: 0 2px 8px rgba(22,163,74,0.08);
}
</style>
""", unsafe_allow_html=True)
@ -321,25 +434,193 @@ def _gap(size="md"):
st.markdown(f'<div style="height:{h}px;"></div>', unsafe_allow_html=True)
def _divider():
st.markdown('<div class="section-divider"></div>', unsafe_allow_html=True)
def _section_header(title, subtitle=""):
sub_html = (
f'<p style="font-size:0.74rem;color:#64748b;margin:3px 0 0;line-height:1.5;">{subtitle}</p>'
if subtitle else ""
)
st.markdown(f"""
<div style="background:#ffffff;border:1px solid #e8edf5;border-radius:12px;
padding:0.72rem 1.2rem;margin-bottom:0.9rem;
box-shadow:0 1px 6px rgba(15,23,42,0.04);">
<p style="font-size:0.88rem;font-weight:700;color:#0f172a;margin:0;">{title}</p>
<div style="background:rgba(255,255,255,0.85);backdrop-filter:blur(10px);
border:1px solid #e8edf5;border-radius:16px;padding:0.9rem 1.2rem;
margin-bottom:1rem;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
<p style="font-size:0.95rem;font-weight:800;color:#0f172a;margin:0;">{title}</p>
{sub_html}
</div>
""", unsafe_allow_html=True)
def _divider():
st.markdown('<div class="section-divider"></div>', unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# PAGE HEADER
# ═══════════════════════════════════════════════════════════
def _render_page_header():
nrt = str(NRT_INTERVAL_MINUTES)
st.markdown(f"""
<div class="ph-card">
<div class="ph-top">
<div class="ph-icon">&#128038;</div>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.45rem;">
<span class="ph-title">Crawling Data Twitter &mdash; Komdigi &amp; Isu Ongkir</span>
<span style="background:#dcfce7;border:1px solid #86efac;border-radius:999px;padding:4px 12px;font-size:0.68rem;font-weight:700;color:#15803d;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><span class="pulse-dot-green" style="width:7px;height:7px;border-radius:50%;background:#16a34a;display:inline-block;"></span>Live Monitor</span>
</div>
<p class="ph-desc">Sistem ini memantau opini masyarakat Twitter/X mengenai <strong style="color:#334155;">kebijakan pembatasan gratis ongkir</strong> oleh <strong style="color:#334155;">Komdigi</strong>. Tweet dikumpulkan otomatis, dibersihkan melalui preprocessing Bahasa Indonesia, lalu dianalisis menggunakan model <strong style="color:#334155;">Naive Bayes</strong> untuk menentukan sentimen (<em>positif, negatif, atau netral</em>) secara near-realtime.</p>
<div class="ph-tags" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:0.75rem;">
<span class="ph-tag" style="background:#eef2ff;color:#3b6cf7;border-color:#c7d2fe;">&#128225; Auto Crawling</span>
<span class="ph-tag" style="background:#f0fdf4;color:#15803d;border-color:#bbf7d0;">&#129302; Naive Bayes</span>
<span class="ph-tag" style="background:#fff7ed;color:#c2410c;border-color:#fdba74;">&#9889; Near Realtime</span>
<span class="ph-tag" style="background:#f5f3ff;color:#7c3aed;border-color:#ddd6fe;">&#128202; Dashboard Monitoring</span>
</div>
</div>
</div>
</div>
""", unsafe_allow_html=True)
st.markdown(f"""
<div class="ph-card" style="margin-top:-0.8rem;">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;">
<div class="ph-chip"><span class="ph-chip-icon">&#128260;</span><div><div class="ph-chip-label">Interval Crawling</div><div class="ph-chip-value">Setiap {nrt} menit</div></div></div>
<div class="ph-chip"><span class="ph-chip-icon">&#128197;</span><div><div class="ph-chip-label">Jangkauan Data</div><div class="ph-chip-value">7 hari terakhir</div></div></div>
<div class="ph-chip"><span class="ph-chip-icon">&#128451;</span><div><div class="ph-chip-label">Penyimpanan</div><div class="ph-chip-value">Database lokal</div></div></div>
<div class="ph-chip"><span class="ph-chip-icon">&#129302;</span><div><div class="ph-chip-label">Model Prediksi</div><div class="ph-chip-value">Naive Bayes</div></div></div>
</div>
</div>
""", unsafe_allow_html=True)
st.markdown("""
<div class="ph-card" style="margin-top:-0.8rem;">
<div style="display:flex;flex-direction:column;align-items:center;gap:10px;">
<div style="display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap;">
<span style="font-size:0.62rem;font-weight:800;color:#94a3b8;text-transform:uppercase;letter-spacing:0.07em;margin-right:4px;">&#128269; Query</span>
<span style="background:#eef2ff;color:#3b6cf7;border:1px solid #c7d2fe;border-radius:999px;padding:4px 13px;font-size:0.72rem;font-weight:700;">komdigi</span>
<span style="background:#dcfce7;color:#15803d;border:1px solid #bbf7d0;border-radius:999px;padding:4px 13px;font-size:0.72rem;font-weight:700;">gratis ongkir</span>
<span style="background:#fef3c7;color:#b45309;border:1px solid #fde68a;border-radius:999px;padding:4px 13px;font-size:0.72rem;font-weight:700;">free ongkir</span>
<span style="background:#fee2e2;color:#b91c1c;border:1px solid #fecaca;border-radius:999px;padding:4px 13px;font-size:0.72rem;font-weight:700;">pembatasan ongkir</span>
</div>
<div style="display:flex;align-items:center;justify-content:center;gap:4px;flex-wrap:wrap;">
<span style="font-size:0.62rem;font-weight:800;color:#94a3b8;text-transform:uppercase;letter-spacing:0.07em;margin-right:4px;">&#9881; Alur kerja</span>
<span style="display:inline-flex;align-items:center;gap:4px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;padding:5px 12px;font-size:0.71rem;color:#475569;font-weight:600;white-space:nowrap;">&#128038; Crawling tweet</span>
<span style="color:#cbd5e1;font-size:0.8rem;">&#8594;</span>
<span style="display:inline-flex;align-items:center;gap:4px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;padding:5px 12px;font-size:0.71rem;color:#475569;font-weight:600;white-space:nowrap;">&#128451; Simpan ke database</span>
<span style="color:#cbd5e1;font-size:0.8rem;">&#8594;</span>
<span style="display:inline-flex;align-items:center;gap:4px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;padding:5px 12px;font-size:0.71rem;color:#475569;font-weight:600;white-space:nowrap;">&#9985; Preprocessing teks</span>
<span style="color:#cbd5e1;font-size:0.8rem;">&#8594;</span>
<span style="display:inline-flex;align-items:center;gap:4px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;padding:5px 12px;font-size:0.71rem;color:#475569;font-weight:600;white-space:nowrap;">&#129302; Prediksi sentimen</span>
<span style="color:#cbd5e1;font-size:0.8rem;">&#8594;</span>
<span style="display:inline-flex;align-items:center;gap:4px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;padding:5px 12px;font-size:0.71rem;color:#475569;font-weight:600;white-space:nowrap;">&#128202; Visualisasi hasil</span>
</div>
</div>
</div>
""", unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# NRT ACTIVATION GATE
# ═══════════════════════════════════════════════════════════
def _render_activation_gate():
items = [
f'Scheduler berjalan setiap <strong>{NRT_INTERVAL_MINUTES} menit</strong> di background',
'Mencakup tweet dalam <strong>7 hari terakhir</strong>',
'Tweet baru otomatis tersimpan ke database lokal',
'Crawl pertama dimulai <strong>segera</strong> setelah tombol diklik',
]
grid_rows = ""
for item in items:
grid_rows += (
'<div style="display:flex;align-items:flex-start;gap:0.5rem;">'
'<span style="color:#16a34a;font-weight:700;flex-shrink:0;margin-top:1px;">\u2713</span>'
f'<span style="font-size:0.74rem;color:#44403c;line-height:1.55;">{item}</span>'
'</div>'
)
html = (
'<div style="background:linear-gradient(135deg,#fffbeb,#fef3c7);border:1.5px solid #fde68a;border-radius:20px;padding:2rem 2rem 1.6rem;margin-bottom:1rem;box-shadow:0 4px 24px rgba(234,179,8,0.1);">'
'<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.4rem;">'
'<div style="width:48px;height:48px;background:linear-gradient(135deg,#f59e0b,#d97706);border-radius:14px;display:flex;align-items:center;justify-content:center;font-size:1.4rem;flex-shrink:0;box-shadow:0 4px 14px rgba(245,158,11,0.3);">\u26a1</div>'
'<div>'
'<div style="font-size:1rem;font-weight:800;color:#78350f;letter-spacing:-0.01em;">Pengambilan Data Otomatis Belum Aktif</div>'
f'<div style="font-size:0.75rem;color:#92400e;font-weight:500;margin-top:3px;line-height:1.5;">Aktifkan untuk mulai crawling tweet secara near-realtime setiap <strong>{NRT_INTERVAL_MINUTES} menit</strong>.</div>'
'</div>'
'</div>'
'<div style="background:rgba(255,255,255,0.65);border:1px solid #fde68a;border-radius:12px;padding:1rem 1.15rem;margin-bottom:1.25rem;">'
'<div style="font-size:0.67rem;font-weight:800;color:#b45309;text-transform:uppercase;letter-spacing:0.07em;margin-bottom:0.75rem;">Yang akan terjadi setelah aktivasi</div>'
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.55rem 1.5rem;">'
+ grid_rows +
'</div>'
'</div>'
'<div style="background:rgba(255,255,255,0.45);border:1px dashed #fbbf24;border-radius:10px;padding:0.65rem 1rem;">'
'<span style="font-size:0.7rem;color:#78350f;font-weight:500;line-height:1.65;">'
'\U0001f4a1 <strong>Catatan:</strong> Crawling aktif selama sesi aplikasi berjalan. '
'Data historis tetap tersimpan dan bisa dilihat di mode <em>7 Hari</em> atau <em>30 Hari</em> tanpa perlu mengaktifkan crawling.'
'</span>'
'</div>'
'</div>'
)
st.markdown(html, unsafe_allow_html=True)
col_l, col_btn, col_r = st.columns([1, 2, 1])
with col_btn:
if st.button(
"\u26a1 Aktifkan Pengambilan Data Secara Otomatis",
type="primary",
use_container_width=True,
key="btn_activate_nrt",
):
st.session_state.nrt_activated = True
st.session_state.nrt_explicitly_deactivated = False # ← RESET FLAG
st.session_state.crawl_history_started_at = datetime.now(tz=timezone.utc)
activate_nrt_scheduler()
st.success("\u2705 Crawling otomatis berhasil diaktifkan! Crawl pertama akan segera dimulai.")
time.sleep(1.0)
st.rerun()
_gap("md")
# ═══════════════════════════════════════════════════════════
# ACTIVE STRIP (setelah diaktifkan)
# ═══════════════════════════════════════════════════════════
def _render_active_strip():
col_info, col_btn = st.columns([3, 1], gap="medium")
with col_info:
st.markdown(f"""
<div class="active-strip">
<div class="pulse-dot-green"
style="width:10px;height:10px;background:#16a34a;border-radius:50%;flex-shrink:0;"></div>
<div>
<span style="font-size:0.8rem;font-weight:700;color:#15803d;">
Crawling Otomatis Aktif
</span>
<span style="font-size:0.7rem;color:#166534;margin-left:0.5rem;">
&mdash; berjalan setiap <strong>{NRT_INTERVAL_MINUTES} menit</strong> di background
</span>
</div>
</div>
""", unsafe_allow_html=True)
with col_btn:
_gap("xs")
if st.button(
"⏹ Nonaktifkan",
type="secondary",
use_container_width=True,
key="btn_deactivate_nrt",
):
# Set semua flag nonaktif
st.session_state.nrt_activated = False
st.session_state.nrt_explicitly_deactivated = True # ← FLAG BARU
st.session_state.pop("crawl_history_started_at", None)
deactivate_nrt_scheduler()
st.warning("⏹ Crawling otomatis dihentikan untuk sesi ini.")
time.sleep(0.9)
st.rerun()
# ═══════════════════════════════════════════════════════════
# NRT STATUS BANNER
# ═══════════════════════════════════════════════════════════
@ -369,7 +650,7 @@ def _render_nrt_status_banner(sched_ok):
title = "Crawler Siap Dijalankan"
desc = (
f"Scheduler otomatis aktif setiap <strong>{NRT_INTERVAL_MINUTES} menit</strong> "
f"sejak aplikasi pertama dibuka &mdash; tidak perlu terminal terpisah."
f"sejak diaktifkan &mdash; tidak perlu terminal terpisah."
)
badge = "&#9900; STANDBY"
@ -378,7 +659,8 @@ def _render_nrt_status_banner(sched_ok):
padding:1rem 1.3rem;margin-bottom:1rem;
display:flex;align-items:center;gap:1rem;
box-shadow:0 2px 8px rgba(15,23,42,0.04);">
<div class="{dot_cls}" style="width:12px;height:12px;background:{dot_color};
<div class="{dot_cls}"
style="width:12px;height:12px;background:{dot_color};
border-radius:50%;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.3rem;">
@ -454,7 +736,6 @@ def _render_monitoring_panel(last_log, last_dt, sched_ok):
col_a, col_b = st.columns(2, gap="medium")
# ── Left card: sistem crawling ──
left_rows = [
("Interval crawling", f"{NRT_INTERVAL_MINUTES} menit", "#3b6cf7"),
("Jangkauan data", "7 hari terakhir", "#3b6cf7"),
@ -490,7 +771,6 @@ def _render_monitoring_panel(last_log, last_dt, sched_ok):
unsafe_allow_html=True,
)
# ── Right card: ringkasan dataset ──
right_rows = [
("Update terakhir", last_crawl_str, "#0f172a"),
("Countdown berikutnya", countdown_str, "#16a34a"),
@ -555,7 +835,7 @@ def _render_metrics(total, days, earliest, latest, filter_label, basis_label):
c1, c2, c3, c4 = st.columns(4)
for col, (icon, bg, color, dark, border_c, label, val, sub) in zip([c1, c2, c3, c4], pills):
with col:
fs = "1.1rem" if len(str(val)) > 10 else "1.55rem"
fs = "1.45rem" if len(str(val)) > 10 else "2rem"
st.markdown(
f'<div class="metric-pill" style="background:{bg};border:1px solid {border_c};'
f'box-shadow:0 2px 10px {color}18;">'
@ -665,7 +945,6 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
)
st.plotly_chart(fig_bar, use_container_width=True, config={"displayModeBar": False})
# ── Legend ──
legend_items = [("#1d4ed8", "Tertinggi"), ("#3b6cf7", "Normal")]
if min_cnt > 0 and min_cnt != max_cnt:
legend_items.insert(1, ("#a78bfa", "Terendah"))
@ -683,7 +962,6 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
unsafe_allow_html=True
)
# ── Insight cards ──
pc1, pc2, pc3 = st.columns(3, gap="small")
def _date_pills(days_list, color, max_show=4):
@ -843,146 +1121,6 @@ def get_filtered_data(df_all):
}
# ═══════════════════════════════════════════════════════════
# PAGE HEADER (dipecah jadi beberapa st.markdown() kecil)
# ═══════════════════════════════════════════════════════════
def _render_page_header():
# Bangun semua bagian dalam Python dulu, baru render SEKALI
chip_data = [
("&#128260;", "Interval crawling", f"Setiap {NRT_INTERVAL_MINUTES} menit"),
("&#128197;", "Jangkauan data", "7 hari terakhir"),
("&#128451;", "Penyimpanan", "Database lokal"),
("&#129302;", "Model prediksi", "Naive Bayes"),
]
chips_html = "".join(
'<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;'
'padding:0.75rem 1rem;display:flex;align-items:center;gap:10px;">'
f'<span style="font-size:1.1rem;flex-shrink:0;">{ico}</span>'
'<div>'
f'<div style="font-size:0.63rem;color:#94a3b8;font-weight:700;'
f'text-transform:uppercase;letter-spacing:0.06em;margin-bottom:3px;">{lbl}</div>'
f'<div style="font-size:0.84rem;font-weight:700;color:#0f172a;">{val}</div>'
'</div></div>'
for ico, lbl, val in chip_data
)
PIPELINE_STEPS = [
("&#128038;", "Crawling tweet"),
("&#128451;", "Simpan ke database"),
("&#129529;", "Preprocessing teks"),
("&#129302;", "Prediksi sentimen"),
("&#128202;", "Visualisasi hasil"),
]
pipeline_steps_html = " ".join(
(
f'<span style="display:inline-flex;align-items:center;gap:5px;'
f'background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;'
f'padding:5px 13px;font-size:0.73rem;color:#475569;white-space:nowrap;">'
f'{icon}&nbsp;{label}</span>'
+ (
'<span style="color:#cbd5e1;font-size:0.85rem;margin:0 2px;">&#8594;</span>'
if i < len(PIPELINE_STEPS) - 1 else ""
)
)
for i, (icon, label) in enumerate(PIPELINE_STEPS)
)
html = (
# ── outer card ──
'<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:20px;'
'padding:1.6rem 1.8rem 1.4rem;margin-bottom:1.5rem;'
'box-shadow:0 4px 24px rgba(15,23,42,0.07);">'
# ── row 1: icon + title + description ──
'<div style="display:flex;align-items:flex-start;gap:1.1rem;margin-bottom:1.4rem;">'
# icon
'<div style="width:54px;height:54px;flex-shrink:0;'
'background:linear-gradient(135deg,#3b6cf7,#6366f1);border-radius:15px;'
'display:flex;align-items:center;justify-content:center;font-size:1.55rem;'
'box-shadow:0 6px 18px rgba(59,108,247,0.28);">&#128038;</div>'
# title block
'<div style="flex:1;min-width:0;padding-top:3px;">'
'<div style="display:flex;align-items:center;gap:0.65rem;margin-bottom:6px;flex-wrap:wrap;">'
'<h2 style="font-size:1.15rem;font-weight:800;color:#0f172a;'
'margin:0;letter-spacing:-0.02em;line-height:1.25;">'
'Crawling Data Twitter &mdash; Komdigi &amp; Isu Ongkir'
'</h2>'
'<span style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:999px;'
'padding:3px 11px;font-size:0.68rem;font-weight:700;color:#16a34a;'
'display:inline-flex;align-items:center;gap:5px;white-space:nowrap;">'
'<span class="pulse-dot-green" style="width:6px;height:6px;border-radius:50%;'
'background:#16a34a;display:inline-block;"></span>'
'Live Monitor'
'</span>'
'</div>'
'<p style="font-size:0.815rem;color:#64748b;margin:0;line-height:1.75;">'
'Dashboard analisis sentimen tweet seputar '
'<strong style="color:#334155;">kebijakan ongkos kirim gratis</strong> dan '
'<strong style="color:#334155;">Komdigi</strong>. '
'Sistem mengumpulkan tweet otomatis, menyimpan ke database lokal, '
'membersihkan teks Bahasa Indonesia, lalu memprediksi sentimen '
'(<em>positif / negatif / netral</em>) via model '
'<strong style="color:#334155;">Naive Bayes</strong> '
'&mdash; near-realtime tanpa intervensi manual.'
'</p>'
'</div>'
'</div>' # end row 1
# ── divider ──
'<div style="height:1px;background:#f1f5f9;margin-bottom:1.2rem;"></div>'
# ── row 2: chips ──
f'<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:1.2rem;">'
f'{chips_html}'
'</div>'
# ── divider ──
'<div style="height:1px;background:#f1f5f9;margin-bottom:1.2rem;"></div>'
# ── row 3: query box ──
'<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;'
'padding:0.9rem 1.1rem;margin-bottom:1.2rem;">'
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">'
'<span style="font-size:0.65rem;font-weight:800;color:#94a3b8;'
'text-transform:uppercase;letter-spacing:0.08em;">&#128269; Query pencarian aktif</span>'
'<span style="background:#eef2ff;color:#3b6cf7;border-radius:999px;'
'font-size:0.65rem;font-weight:700;padding:2px 9px;border:1px solid #c7d7fd;">'
'Twitter / X Search'
'</span>'
'</div>'
'<div style="font-family:\'Fira Mono\',\'Courier New\',monospace;font-size:0.8rem;'
'color:#334155;line-height:2;word-break:break-word;">'
'<span style="color:#3b6cf7;font-weight:700;">komdigi</span> '
'(<span style="color:#3b6cf7;font-weight:700;">ongkir</span> OR '
'<span style="color:#3b6cf7;font-weight:700;">&quot;gratis ongkir&quot;</span> OR '
'<span style="color:#3b6cf7;font-weight:700;">&quot;free ongkir&quot;</span>) OR '
'<span style="color:#3b6cf7;font-weight:700;">&quot;pembatasan gratis ongkir&quot;</span> OR '
'<span style="color:#3b6cf7;font-weight:700;">&quot;gratis ongkir dibatasi&quot;</span>'
'</div>'
'<div style="margin-top:9px;padding-top:9px;border-top:1px solid #e8edf5;'
'font-size:0.7rem;color:#94a3b8;line-height:1.6;">'
'Mencakup variasi: <em>ongkir, gratis ongkir, free ongkir</em>'
' &mdash; termasuk konteks pembatasan subsidi ongkir oleh Komdigi.'
'</div>'
'</div>' # end query box
# ── row 4: pipeline ──
'<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap;">'
'<span style="font-size:0.63rem;font-weight:800;color:#94a3b8;'
'text-transform:uppercase;letter-spacing:0.08em;margin-right:8px;white-space:nowrap;">'
'&#9881; Alur kerja</span>'
f'{pipeline_steps_html}'
'</div>'
'</div>' # end outer card
)
st.markdown(html, unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# MAIN PAGE
# ═══════════════════════════════════════════════════════════
@ -998,7 +1136,33 @@ def show():
if "filter_end_date" not in st.session_state:
st.session_state.filter_end_date = today
# ── Page Header ──────────────────────────────────────────────
# ── State aktivasi NRT ─────────────────────────────────────────────────
from crawler import load_nrt_activation, ensure_scheduler_running as _ensure_sched
# Inisialisasi pertama kali sesi dimulai
if "nrt_activated" not in st.session_state:
was_activated = load_nrt_activation()
# Jika file persist bilang aktif tapi user sudah eksplisit nonaktifkan → ikut flag
explicitly_off_init = st.session_state.get("nrt_explicitly_deactivated", False)
if was_activated and not explicitly_off_init:
st.session_state.nrt_activated = True
from crawler import activate_nrt_scheduler as _activate
_activate()
else:
st.session_state.nrt_activated = False
# Sinkronisasi: _scheduler_started sudah dijaga oleh app.py (False jika explicitly_off)
# Cukup mirror nilainya — tidak perlu logika tambahan
explicitly_off = st.session_state.get("nrt_explicitly_deactivated", False)
if explicitly_off:
st.session_state.nrt_activated = False
elif st.session_state.get("_scheduler_started", False):
if not st.session_state.get("nrt_activated", False):
st.session_state.nrt_activated = True
if "crawl_history_started_at" not in st.session_state:
st.session_state.crawl_history_started_at = datetime.now(tz=timezone.utc)
# ── Page Header ───────────────────────────────────────────────
_render_page_header()
# ── Mode Selector ─────────────────────────────────────────────
@ -1080,7 +1244,7 @@ def show():
_gap("md")
# ── Load data ─────────────────────────────────────────────────
# ── Load data ─────────────────────────────────────────────────
try:
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
except Exception as e:
@ -1094,7 +1258,7 @@ def show():
df_all["created_at"] = parse_dt(df_all["created_at"])
df_all["crawled_at"] = parse_crawled_dt(df_all["crawled_at"])
# ── Custom Date Picker ────────────────────────────────────────
# ── Custom Date Picker ────────────────────────────────────────
if st.session_state.analysis_mode == "custom":
df_check = df_all.dropna(subset=["created_at"]).copy()
if not df_check.empty:
@ -1151,9 +1315,22 @@ def show():
st.rerun()
# ═══════════════════════════════════════════════════════════
# MODE: TWEET HARI INI (captured)
# MODE: HARI INI (captured)
# ═══════════════════════════════════════════════════════════
if st.session_state.analysis_mode == "captured":
nrt_activated = st.session_state.get("nrt_activated", False)
if not nrt_activated:
# ── Belum diaktifkan: tampilkan activation gate ───────
_render_activation_gate()
else:
# ── Sudah diaktifkan: pastikan thread scheduler hidup ─
if is_nrt_enabled():
ensure_scheduler_running()
# Auto-refresh setiap 1 detik untuk countdown live
st_autorefresh(interval=1000, key="crawler_monitor_refresh")
logs = get_auto_crawl_logs(5)
@ -1161,12 +1338,18 @@ def show():
sched_ok = is_crawler_service_active(logs)
last_dt = parse_log_time(last_log.get("timestamp")) if last_log else None
# Strip status aktif + tombol nonaktifkan
_render_active_strip()
# Banner status crawler
_render_nrt_status_banner(sched_ok)
_gap("sm")
# Panel monitoring
_render_monitoring_panel(last_log, last_dt, sched_ok)
_gap("md")
# ── Crawl History ──────────────────────────────────────
# Riwayat crawl
visible_logs = get_visible_crawl_logs(logs)
if visible_logs:
rows = []
@ -1225,7 +1408,7 @@ def show():
'Menunggu Crawl Pertama</div>'
f'<div style="font-size:0.77rem;color:#64748b;">'
f'Riwayat akan muncul setelah crawl otomatis pertama selesai '
f'(maks. {NRT_INTERVAL_MINUTES} menit sejak aplikasi dibuka)'
f'(maks. {NRT_INTERVAL_MINUTES} menit sejak aktivasi)'
f'</div>'
'</div>',
unsafe_allow_html=True,
@ -1233,7 +1416,7 @@ def show():
_gap("lg")
# ── Tweet yang dicrawl bot hari ini ───────────────────
# Tweet yang diambil bot hari ini
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time().replace(microsecond=0))
@ -1308,7 +1491,6 @@ def show():
_divider()
# ── Active Filter Banner ──────────────────────────────────
total = len(df)
st.markdown(
f'<div style="display:flex;align-items:center;gap:0.9rem;'
@ -1359,7 +1541,6 @@ def show():
)
return
# ── Chart ─────────────────────────────────────────────────
st.markdown(
f'<div class="crawl-card" style="padding-bottom:0.4rem;">'
f'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.1rem;">'
@ -1378,7 +1559,6 @@ def show():
_render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end)
# ── Tabel ─────────────────────────────────────────────────
display_limit = 100
_section_header(
"&#128203; Daftar Tweet yang Terkumpul",
@ -1405,7 +1585,6 @@ def show():
st.caption(f"Menampilkan {len(df_disp):,} dari {total:,} tweet")
_gap("sm")
# ── Actions ───────────────────────────────────────────────
c_dl, c_nav = st.columns(2)
with c_dl:
st.download_button(

View File

@ -1,2 +1,2 @@
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","5","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","4","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",

1 conversation_id_str created_at favorite_count full_text id_str image_url in_reply_to_screen_name lang location quote_count reply_count retweet_count tweet_url user_id_str username
2 2056538953340314095 Wed May 20 16:01:04 +0000 2026 5 4 @killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl 2057129489994510620 https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg killingmaster27 in 0 2 0 https://x.com/undefined/status/2057129489994510620 1410466956134608899

View File

@ -1,2 +1,2 @@
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","5","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","4","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",

1 conversation_id_str created_at favorite_count full_text id_str image_url in_reply_to_screen_name lang location quote_count reply_count retweet_count tweet_url user_id_str username
2 2056538953340314095 Wed May 20 16:01:04 +0000 2026 5 4 @killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl 2057129489994510620 https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg killingmaster27 in 0 2 0 https://x.com/undefined/status/2057129489994510620 1410466956134608899

View File

@ -1,6 +0,0 @@
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2056892677149581606","Wed May 20 00:20:04 +0000 2026","0","komdigi keren sih mengadakan kebijakan ongkir kaya gini","2056892677149581606","","","in","","0","0","0","https://x.com/undefined/status/2056892677149581606","1814143201718247424",
"2056949159845101648","Wed May 20 04:04:30 +0000 2026","0","komdigi kereenn dan bijak membuat keputusan ongkir seperti inii","2056949159845101648","","","in","","0","0","0","https://x.com/undefined/status/2056949159845101648","1814143201718247424",
"2056635322419777630","Tue May 19 07:17:25 +0000 2026","0","komdigi ongkir gk jelas","2056635322419777630","","","in","","0","0","0","https://x.com/undefined/status/2056635322419777630","1814143201718247424",
"2055269612334555468","Fri May 15 12:50:35 +0000 2026","0","komdigi + ongkir = chaos wkwkwk","2055269612334555468","","","in","","0","0","0","https://x.com/undefined/status/2055269612334555468","2043979880086339584",
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","5","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",
1 conversation_id_str created_at favorite_count full_text id_str image_url in_reply_to_screen_name lang location quote_count reply_count retweet_count tweet_url user_id_str username
2 2056892677149581606 Wed May 20 00:20:04 +0000 2026 0 komdigi keren sih mengadakan kebijakan ongkir kaya gini 2056892677149581606 in 0 0 0 https://x.com/undefined/status/2056892677149581606 1814143201718247424
3 2056949159845101648 Wed May 20 04:04:30 +0000 2026 0 komdigi kereenn dan bijak membuat keputusan ongkir seperti inii 2056949159845101648 in 0 0 0 https://x.com/undefined/status/2056949159845101648 1814143201718247424
4 2056635322419777630 Tue May 19 07:17:25 +0000 2026 0 komdigi ongkir gk jelas 2056635322419777630 in 0 0 0 https://x.com/undefined/status/2056635322419777630 1814143201718247424
5 2055269612334555468 Fri May 15 12:50:35 +0000 2026 0 komdigi + ongkir = chaos wkwkwk 2055269612334555468 in 0 0 0 https://x.com/undefined/status/2055269612334555468 2043979880086339584
6 2056538953340314095 Wed May 20 16:01:04 +0000 2026 5 @killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl 2057129489994510620 https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg killingmaster27 in 0 2 0 https://x.com/undefined/status/2057129489994510620 1410466956134608899

View File

@ -1,6 +1,14 @@
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2057791279896080430","Fri May 22 11:50:47 +0000 2026","0","komdigi mengeluarkan kebijakan gratis ongkir @komdigi ???","2057791279896080430","","","in","","0","0","0","https://x.com/undefined/status/2057791279896080430","1814143201718247424",
"2057660616815571144","Fri May 22 03:11:35 +0000 2026","0","komdigi pembatasan gratis ongkir","2057660616815571144","","","in","","0","0","0","https://x.com/undefined/status/2057660616815571144","1814143201718247424",
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","4","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",
"2056635322419777630","Tue May 19 07:17:25 +0000 2026","0","komdigi ongkir gk jelas","2056635322419777630","","","in","","0","0","0","https://x.com/undefined/status/2056635322419777630","1814143201718247424",
"2056892677149581606","Wed May 20 00:20:04 +0000 2026","0","komdigi keren sih mengadakan kebijakan ongkir kaya gini","2056892677149581606","","","in","","0","0","0","https://x.com/undefined/status/2056892677149581606","1814143201718247424",
"2056949159845101648","Wed May 20 04:04:30 +0000 2026","0","komdigi kereenn dan bijak membuat keputusan ongkir seperti inii","2056949159845101648","","","in","","0","0","0","https://x.com/undefined/status/2056949159845101648","1814143201718247424",
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","5","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",
"2055269612334555468","Fri May 15 12:50:35 +0000 2026","0","komdigi + ongkir = chaos wkwkwk","2055269612334555468","","","in","","0","0","0","https://x.com/undefined/status/2055269612334555468","2043979880086339584",
"2056892677149581606","Wed May 20 00:20:04 +0000 2026","0","komdigi keren sih mengadakan kebijakan ongkir kaya gini","2056892677149581606","","","in","","0","0","0","https://x.com/undefined/status/2056892677149581606","1814143201718247424",
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2057791279896080430","Fri May 22 11:50:47 +0000 2026","0","komdigi mengeluarkan kebijakan gratis ongkir @komdigi ???","2057791279896080430","","","in","","0","0","0","https://x.com/undefined/status/2057791279896080430","1814143201718247424",
"2057660616815571144","Fri May 22 03:11:35 +0000 2026","0","komdigi pembatasan gratis ongkir","2057660616815571144","","","in","","0","0","0","https://x.com/undefined/status/2057660616815571144","1814143201718247424",
"2056635322419777630","Tue May 19 07:17:25 +0000 2026","0","komdigi ongkir gk jelas","2056635322419777630","","","in","","0","0","0","https://x.com/undefined/status/2056635322419777630","1814143201718247424",
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","4","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",
"2056949159845101648","Wed May 20 04:04:30 +0000 2026","0","komdigi kereenn dan bijak membuat keputusan ongkir seperti inii","2056949159845101648","","","in","","0","0","0","https://x.com/undefined/status/2056949159845101648","1814143201718247424",
"2056892677149581606","Wed May 20 00:20:04 +0000 2026","0","komdigi keren sih mengadakan kebijakan ongkir kaya gini","2056892677149581606","","","in","","0","0","0","https://x.com/undefined/status/2056892677149581606","1814143201718247424",

1 conversation_id_str created_at favorite_count full_text id_str image_url in_reply_to_screen_name lang location quote_count reply_count retweet_count tweet_url user_id_str username
2 2057791279896080430 Fri May 22 11:50:47 +0000 2026 0 komdigi mengeluarkan kebijakan gratis ongkir @komdigi ??? 2057791279896080430 in 0 0 0 https://x.com/undefined/status/2057791279896080430 1814143201718247424
3 2057660616815571144 Fri May 22 03:11:35 +0000 2026 0 komdigi pembatasan gratis ongkir 2057660616815571144 in 0 0 0 https://x.com/undefined/status/2057660616815571144 1814143201718247424
4 2056538953340314095 Wed May 20 16:01:04 +0000 2026 4 @killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl 2057129489994510620 https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg killingmaster27 in 0 2 0 https://x.com/undefined/status/2057129489994510620 1410466956134608899
5 2056635322419777630 Tue May 19 07:17:25 +0000 2026 0 komdigi ongkir gk jelas 2056635322419777630 in 0 0 0 https://x.com/undefined/status/2056635322419777630 1814143201718247424
2056892677149581606 Wed May 20 00:20:04 +0000 2026 0 komdigi keren sih mengadakan kebijakan ongkir kaya gini 2056892677149581606 in 0 0 0 https://x.com/undefined/status/2056892677149581606 1814143201718247424
6 2056949159845101648 Wed May 20 04:04:30 +0000 2026 0 komdigi kereenn dan bijak membuat keputusan ongkir seperti inii 2056949159845101648 in 0 0 0 https://x.com/undefined/status/2056949159845101648 1814143201718247424
7 2056538953340314095 2056892677149581606 Wed May 20 16:01:04 +0000 2026 Wed May 20 00:20:04 +0000 2026 5 0 @killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl komdigi keren sih mengadakan kebijakan ongkir kaya gini 2057129489994510620 2056892677149581606 https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg killingmaster27 in 0 2 0 0 https://x.com/undefined/status/2057129489994510620 https://x.com/undefined/status/2056892677149581606 1410466956134608899 1814143201718247424
8 2055269612334555468 conversation_id_str Fri May 15 12:50:35 +0000 2026 created_at 0 favorite_count komdigi + ongkir = chaos wkwkwk full_text 2055269612334555468 id_str image_url in_reply_to_screen_name in lang location 0 quote_count 0 reply_count 0 retweet_count https://x.com/undefined/status/2055269612334555468 tweet_url 2043979880086339584 user_id_str username
9 2057791279896080430 Fri May 22 11:50:47 +0000 2026 0 komdigi mengeluarkan kebijakan gratis ongkir @komdigi ??? 2057791279896080430 in 0 0 0 https://x.com/undefined/status/2057791279896080430 1814143201718247424
10 2057660616815571144 Fri May 22 03:11:35 +0000 2026 0 komdigi pembatasan gratis ongkir 2057660616815571144 in 0 0 0 https://x.com/undefined/status/2057660616815571144 1814143201718247424
11 2056635322419777630 Tue May 19 07:17:25 +0000 2026 0 komdigi ongkir gk jelas 2056635322419777630 in 0 0 0 https://x.com/undefined/status/2056635322419777630 1814143201718247424
12 2056538953340314095 Wed May 20 16:01:04 +0000 2026 4 @killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl 2057129489994510620 https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg killingmaster27 in 0 2 0 https://x.com/undefined/status/2057129489994510620 1410466956134608899
13 2056949159845101648 Wed May 20 04:04:30 +0000 2026 0 komdigi kereenn dan bijak membuat keputusan ongkir seperti inii 2056949159845101648 in 0 0 0 https://x.com/undefined/status/2056949159845101648 1814143201718247424
14 2056892677149581606 Wed May 20 00:20:04 +0000 2026 0 komdigi keren sih mengadakan kebijakan ongkir kaya gini 2056892677149581606 in 0 0 0 https://x.com/undefined/status/2056892677149581606 1814143201718247424