penambahan button crawling
penambahan button crawling dan pebaikan status crawling service
This commit is contained in:
parent
cc8342c6cd
commit
8830accff5
204
app.py
204
app.py
|
|
@ -26,14 +26,10 @@ except Exception:
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
# ── Auto-start background crawler scheduler ──────────────────────────────────
|
# ── Auto-start background crawler scheduler (disabled) ───────────────────
|
||||||
# Dipanggil sekali saat app pertama dijalankan.
|
# Scheduler NRT hanya boleh berjalan setelah user menekan tombol aktivasi
|
||||||
# Scheduler berjalan sebagai daemon thread — tidak perlu terminal terpisah.
|
# pada mode "Hari Ini" (captured) agar sesuai kebutuhan (NRT dimulai manual).
|
||||||
try:
|
|
||||||
from crawler import ensure_scheduler_running
|
|
||||||
ensure_scheduler_running()
|
|
||||||
except Exception as _e:
|
|
||||||
print(f"[app] Gagal memulai scheduler: {_e}")
|
|
||||||
|
|
||||||
st.set_page_config(
|
st.set_page_config(
|
||||||
page_title="Dashboard Sentimen Twitter",
|
page_title="Dashboard Sentimen Twitter",
|
||||||
|
|
@ -164,8 +160,15 @@ def _check_crawler_alive():
|
||||||
and now - heartbeat_at <= threshold
|
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:
|
try:
|
||||||
from crawler import NRT_INTERVAL_MINUTES
|
from crawler import NRT_INTERVAL_MINUTES
|
||||||
|
|
@ -190,8 +193,7 @@ st.markdown("""
|
||||||
--bg: #f8fafc;
|
--bg: #f8fafc;
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
}
|
}
|
||||||
|
html, body, [class*="css"], .stApp {
|
||||||
* {
|
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +228,7 @@ html, body, .stApp, [data-testid="stAppViewContainer"] {
|
||||||
}
|
}
|
||||||
|
|
||||||
.main > div {
|
.main > div {
|
||||||
padding: 1.5rem 2rem !important;
|
padding: 4.3rem 2rem 1.5rem 2rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-testid="stSidebar"] {
|
[data-testid="stSidebar"] {
|
||||||
|
|
@ -683,27 +685,192 @@ span:not([style]) {
|
||||||
box-shadow: 0 2px 6px rgba(15,23,42,0.05);
|
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 {
|
[data-testid="stDownloadButton"] button {
|
||||||
|
|
||||||
background: var(--card) !important;
|
background: var(--card) !important;
|
||||||
|
|
||||||
color: var(--blue-primary) !important;
|
color: var(--blue-primary) !important;
|
||||||
|
|
||||||
border: 1.5px solid var(--border) !important;
|
border: 1.5px solid var(--border) !important;
|
||||||
|
|
||||||
border-radius: 10px !important;
|
border-radius: 10px !important;
|
||||||
|
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
|
|
||||||
transition: all 0.2s ease !important;
|
transition: all 0.2s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-testid="stDownloadButton"] button:hover {
|
[data-testid="stDownloadButton"] button:hover {
|
||||||
|
|
||||||
background: var(--blue-light) !important;
|
background: var(--blue-light) !important;
|
||||||
|
|
||||||
border-color: var(--blue-primary) !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>
|
</style>
|
||||||
""", unsafe_allow_html=True)
|
""", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
|
||||||
if "page" not in st.session_state:
|
if "page" not in st.session_state:
|
||||||
st.session_state.page = "crawling"
|
st.session_state.page = "crawling"
|
||||||
|
|
||||||
|
|
@ -750,12 +917,15 @@ with st.sidebar:
|
||||||
|
|
||||||
# ── STATUS CRAWLER ──────────────────────────────────────────
|
# ── STATUS CRAWLER ──────────────────────────────────────────
|
||||||
sched_ok = st.session_state.get("_scheduler_started", False)
|
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_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 = (
|
sched_label = (
|
||||||
f"Aktif — diperbarui tiap {sched_interval} menit"
|
f"Aktif — diperbarui tiap {sched_interval} menit"
|
||||||
if sched_ok
|
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"""
|
st.markdown(f"""
|
||||||
|
|
|
||||||
132
crawler.py
132
crawler.py
|
|
@ -12,13 +12,22 @@ NRT_INTERVAL_MINUTES = 5
|
||||||
LOG_FILE = "auto_crawl_log.json"
|
LOG_FILE = "auto_crawl_log.json"
|
||||||
STATE_FILE = "crawler_state.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()
|
_crawl_lock = threading.Lock()
|
||||||
|
|
||||||
# ── Flag untuk scheduler background thread ──
|
# ── Flag scheduler ────────────────────────────────────────────────
|
||||||
_scheduler_started = False
|
_scheduler_started = False
|
||||||
_scheduler_lock = threading.Lock()
|
_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
|
# STATE & LOG HELPERS
|
||||||
|
|
@ -86,6 +95,31 @@ def set_crawler_state(is_running=False, service_active=None):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[crawler] Gagal menulis state: {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
|
# CORE JOB
|
||||||
|
|
@ -95,7 +129,6 @@ def auto_crawl_job():
|
||||||
"""
|
"""
|
||||||
Jalankan satu siklus crawling.
|
Jalankan satu siklus crawling.
|
||||||
Thread-safe: hanya satu crawl yang boleh berjalan pada satu waktu.
|
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):
|
if not _crawl_lock.acquire(blocking=False):
|
||||||
print("[crawler] Crawl sedang berjalan, skip.")
|
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():
|
def _scheduler_loop():
|
||||||
"""
|
"""
|
||||||
Loop yang berjalan di daemon thread.
|
Loop daemon thread. Crawl pertama langsung, lalu tiap NRT_INTERVAL_MINUTES.
|
||||||
Crawling pertama langsung dijalankan, lalu setiap NRT_INTERVAL_MINUTES menit.
|
Berhenti bersih saat _scheduler_stop_event di-set.
|
||||||
"""
|
"""
|
||||||
print(f"[crawler] Scheduler dimulai — interval {NRT_INTERVAL_MINUTES} menit.")
|
print(f"[crawler] Scheduler dimulai — interval {NRT_INTERVAL_MINUTES} menit.")
|
||||||
set_crawler_state(False, service_active=True)
|
set_crawler_state(False, service_active=True)
|
||||||
|
|
||||||
while True:
|
while not _scheduler_stop_event.is_set():
|
||||||
auto_crawl_job()
|
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)
|
wait_until = time.time() + (NRT_INTERVAL_MINUTES * 60)
|
||||||
while time.time() < wait_until:
|
while time.time() < wait_until and not _scheduler_stop_event.is_set():
|
||||||
# Heartbeat agar is_crawler_service_active() tetap True
|
|
||||||
_write_heartbeat()
|
_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():
|
def _write_heartbeat():
|
||||||
|
|
@ -165,31 +202,92 @@ def _write_heartbeat():
|
||||||
pass
|
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():
|
def ensure_scheduler_running():
|
||||||
"""
|
"""
|
||||||
Pastikan background scheduler sudah berjalan.
|
Pastikan background scheduler sudah berjalan.
|
||||||
|
HANYA berjalan jika _nrt_enabled = True (sudah diaktifkan user).
|
||||||
Aman dipanggil berkali-kali (idempotent).
|
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
|
global _scheduler_started
|
||||||
|
|
||||||
|
if not _nrt_enabled:
|
||||||
|
# Belum diaktifkan user — jangan mulai thread apapun
|
||||||
|
return
|
||||||
|
|
||||||
with _scheduler_lock:
|
with _scheduler_lock:
|
||||||
if _scheduler_started:
|
if _scheduler_started:
|
||||||
return
|
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()
|
t.start()
|
||||||
_scheduler_started = True
|
_scheduler_started = True
|
||||||
print("[crawler] Background scheduler thread dimulai.")
|
print("[crawler] Background scheduler thread dimulai.")
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
# ENTRY POINT (tetap bisa dipakai via terminal jika mau)
|
# ENTRY POINT (via terminal, opsional)
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Jalankan scheduler via terminal (opsional, tidak wajib)."""
|
"""Jalankan scheduler via terminal (run_scraper_loop.py)."""
|
||||||
print("Crawler realtime aktif.")
|
global _nrt_enabled
|
||||||
|
_nrt_enabled = True # mode terminal: langsung aktif
|
||||||
|
|
||||||
|
print("Crawler realtime aktif (mode terminal).")
|
||||||
print(f"Interval: {NRT_INTERVAL_MINUTES} menit")
|
print(f"Interval: {NRT_INTERVAL_MINUTES} menit")
|
||||||
set_crawler_state(False, service_active=True)
|
set_crawler_state(False, service_active=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"activated": false, "updated_at": "2026-05-22T13:17:14.073811+00:00"}
|
||||||
|
|
@ -21,16 +21,13 @@ from crawler import (
|
||||||
AUTO_CRAWL_INTERVAL_HOURS,
|
AUTO_CRAWL_INTERVAL_HOURS,
|
||||||
NRT_INTERVAL_MINUTES,
|
NRT_INTERVAL_MINUTES,
|
||||||
auto_crawl_job,
|
auto_crawl_job,
|
||||||
|
activate_nrt_scheduler,
|
||||||
|
deactivate_nrt_scheduler,
|
||||||
|
is_nrt_enabled,
|
||||||
ensure_scheduler_running,
|
ensure_scheduler_running,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════
|
|
||||||
# AUTO-START SCHEDULER
|
|
||||||
# ═══════════════════════════════════════════════════════════
|
|
||||||
ensure_scheduler_running()
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
# TIMEZONE & DATETIME HELPERS
|
# TIMEZONE & DATETIME HELPERS
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
@ -181,39 +178,53 @@ def _render_crawling_styles():
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
|
@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; }
|
[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"] * {
|
section[data-testid="stMain"] * {
|
||||||
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Cards ── */
|
|
||||||
.crawl-card {
|
.crawl-card {
|
||||||
background: #ffffff;
|
background: rgba(255,255,255,0.92);
|
||||||
border: 1px solid #e8edf5;
|
backdrop-filter: blur(10px);
|
||||||
border-radius: 16px;
|
border: 1px solid rgba(226,232,240,0.7);
|
||||||
padding: 1.25rem 1.4rem;
|
border-radius: 20px;
|
||||||
box-shadow: 0 2px 12px rgba(15,23,42,0.05);
|
padding: 1.35rem 1.45rem;
|
||||||
margin-bottom: 1.1rem;
|
box-shadow: 0 4px 20px rgba(15,23,42,0.05), 0 1px 3px rgba(15,23,42,0.04);
|
||||||
transition: box-shadow 0.2s ease;
|
margin-bottom: 1.15rem;
|
||||||
|
transition: all 0.22s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crawl-card:hover {
|
.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 {
|
.mode-card {
|
||||||
transition: transform 0.18s cubic-bezier(.34,1.56,.64,1), box-shadow 0.18s ease;
|
transition: all 0.22s ease;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
}
|
backdrop-filter: blur(8px);
|
||||||
.mode-card:hover {
|
}
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 10px 28px rgba(15,23,42,0.10) !important;
|
.mode-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 24px rgba(15,23,42,0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Metric pills ── */
|
|
||||||
.metric-pill {
|
.metric-pill {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 1rem 0.9rem;
|
padding: 1rem 0.9rem;
|
||||||
|
|
@ -223,7 +234,6 @@ section[data-testid="stMain"] * {
|
||||||
}
|
}
|
||||||
.metric-pill:hover { transform: translateY(-2px); }
|
.metric-pill:hover { transform: translateY(-2px); }
|
||||||
|
|
||||||
/* ── Buttons ── */
|
|
||||||
.stButton > button {
|
.stButton > button {
|
||||||
border-radius: 12px !important;
|
border-radius: 12px !important;
|
||||||
font-weight: 700 !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;
|
box-shadow: 0 6px 18px rgba(59,108,247,0.15) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Page header animation ── */
|
|
||||||
.page-header-card { animation: fadeSlideIn 0.45s ease both; }
|
.page-header-card { animation: fadeSlideIn 0.45s ease both; }
|
||||||
@keyframes fadeSlideIn {
|
@keyframes fadeSlideIn {
|
||||||
from { opacity: 0; transform: translateY(-10px); }
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Status dot pulse ── */
|
|
||||||
@keyframes pulse-green {
|
@keyframes pulse-green {
|
||||||
0%,100% { box-shadow: 0 0 0 0 rgba(22,163,74,0.45); }
|
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); }
|
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); }
|
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); }
|
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-green { animation: pulse-green 2s ease infinite; }
|
||||||
.pulse-dot-blue { animation: pulse-blue 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 {
|
.monitor-row {
|
||||||
display: flex; justify-content: space-between;
|
display: flex; justify-content: space-between;
|
||||||
align-items: center; gap: 0.75rem;
|
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-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; }
|
.monitor-value { font-size: 0.8rem; font-weight: 700; text-align: right; flex-shrink: 0; }
|
||||||
|
|
||||||
/* ── Stat number animation ── */
|
|
||||||
@keyframes countUp {
|
@keyframes countUp {
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
.stat-num { animation: countUp 0.6s ease both; }
|
.stat-num { animation: countUp 0.6s ease both; }
|
||||||
|
|
||||||
/* ── Custom date panel ── */
|
|
||||||
.st-key-custom_date_panel {
|
.st-key-custom_date_panel {
|
||||||
background: #ffffff !important;
|
background: #ffffff !important;
|
||||||
border: 1px solid #e8edf5 !important;
|
border: 1px solid #e8edf5 !important;
|
||||||
|
|
@ -305,12 +315,115 @@ section[data-testid="stMain"] * {
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Section divider ── */
|
|
||||||
.section-divider {
|
.section-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(90deg, transparent, #e2e8f0 30%, #e2e8f0 70%, transparent);
|
background: linear-gradient(90deg, transparent, #e2e8f0 30%, #e2e8f0 70%, transparent);
|
||||||
margin: 1.5rem 0;
|
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>
|
</style>
|
||||||
""", unsafe_allow_html=True)
|
""", 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)
|
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=""):
|
def _section_header(title, subtitle=""):
|
||||||
sub_html = (
|
sub_html = (
|
||||||
f'<p style="font-size:0.74rem;color:#64748b;margin:3px 0 0;line-height:1.5;">{subtitle}</p>'
|
f'<p style="font-size:0.74rem;color:#64748b;margin:3px 0 0;line-height:1.5;">{subtitle}</p>'
|
||||||
if subtitle else ""
|
if subtitle else ""
|
||||||
)
|
)
|
||||||
st.markdown(f"""
|
st.markdown(f"""
|
||||||
<div style="background:#ffffff;border:1px solid #e8edf5;border-radius:12px;
|
<div style="background:rgba(255,255,255,0.85);backdrop-filter:blur(10px);
|
||||||
padding:0.72rem 1.2rem;margin-bottom:0.9rem;
|
border:1px solid #e8edf5;border-radius:16px;padding:0.9rem 1.2rem;
|
||||||
box-shadow:0 1px 6px rgba(15,23,42,0.04);">
|
margin-bottom:1rem;box-shadow:0 2px 10px rgba(15,23,42,0.04);">
|
||||||
<p style="font-size:0.88rem;font-weight:700;color:#0f172a;margin:0;">{title}</p>
|
<p style="font-size:0.95rem;font-weight:800;color:#0f172a;margin:0;">{title}</p>
|
||||||
{sub_html}
|
{sub_html}
|
||||||
</div>
|
</div>
|
||||||
""", unsafe_allow_html=True)
|
""", 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">🐦</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 — Komdigi & 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;">📡 Auto Crawling</span>
|
||||||
|
<span class="ph-tag" style="background:#f0fdf4;color:#15803d;border-color:#bbf7d0;">🤖 Naive Bayes</span>
|
||||||
|
<span class="ph-tag" style="background:#fff7ed;color:#c2410c;border-color:#fdba74;">⚡ Near Realtime</span>
|
||||||
|
<span class="ph-tag" style="background:#f5f3ff;color:#7c3aed;border-color:#ddd6fe;">📊 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">🔄</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">📅</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">🗃</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">🤖</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;">🔍 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;">⚙ 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;">🐦 Crawling tweet</span>
|
||||||
|
<span style="color:#cbd5e1;font-size:0.8rem;">→</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;">🗃 Simpan ke database</span>
|
||||||
|
<span style="color:#cbd5e1;font-size:0.8rem;">→</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;">✁ Preprocessing teks</span>
|
||||||
|
<span style="color:#cbd5e1;font-size:0.8rem;">→</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;">🤖 Prediksi sentimen</span>
|
||||||
|
<span style="color:#cbd5e1;font-size:0.8rem;">→</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;">📊 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;">
|
||||||
|
— 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
|
# NRT STATUS BANNER
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
@ -369,7 +650,7 @@ def _render_nrt_status_banner(sched_ok):
|
||||||
title = "Crawler Siap Dijalankan"
|
title = "Crawler Siap Dijalankan"
|
||||||
desc = (
|
desc = (
|
||||||
f"Scheduler otomatis aktif setiap <strong>{NRT_INTERVAL_MINUTES} menit</strong> "
|
f"Scheduler otomatis aktif setiap <strong>{NRT_INTERVAL_MINUTES} menit</strong> "
|
||||||
f"sejak aplikasi pertama dibuka — tidak perlu terminal terpisah."
|
f"sejak diaktifkan — tidak perlu terminal terpisah."
|
||||||
)
|
)
|
||||||
badge = "⚬ STANDBY"
|
badge = "⚬ STANDBY"
|
||||||
|
|
||||||
|
|
@ -378,7 +659,8 @@ def _render_nrt_status_banner(sched_ok):
|
||||||
padding:1rem 1.3rem;margin-bottom:1rem;
|
padding:1rem 1.3rem;margin-bottom:1rem;
|
||||||
display:flex;align-items:center;gap:1rem;
|
display:flex;align-items:center;gap:1rem;
|
||||||
box-shadow:0 2px 8px rgba(15,23,42,0.04);">
|
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>
|
border-radius:50%;flex-shrink:0;"></div>
|
||||||
<div style="flex:1;">
|
<div style="flex:1;">
|
||||||
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.3rem;">
|
<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")
|
col_a, col_b = st.columns(2, gap="medium")
|
||||||
|
|
||||||
# ── Left card: sistem crawling ──
|
|
||||||
left_rows = [
|
left_rows = [
|
||||||
("Interval crawling", f"{NRT_INTERVAL_MINUTES} menit", "#3b6cf7"),
|
("Interval crawling", f"{NRT_INTERVAL_MINUTES} menit", "#3b6cf7"),
|
||||||
("Jangkauan data", "7 hari terakhir", "#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,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Right card: ringkasan dataset ──
|
|
||||||
right_rows = [
|
right_rows = [
|
||||||
("Update terakhir", last_crawl_str, "#0f172a"),
|
("Update terakhir", last_crawl_str, "#0f172a"),
|
||||||
("Countdown berikutnya", countdown_str, "#16a34a"),
|
("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)
|
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):
|
for col, (icon, bg, color, dark, border_c, label, val, sub) in zip([c1, c2, c3, c4], pills):
|
||||||
with col:
|
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(
|
st.markdown(
|
||||||
f'<div class="metric-pill" style="background:{bg};border:1px solid {border_c};'
|
f'<div class="metric-pill" style="background:{bg};border:1px solid {border_c};'
|
||||||
f'box-shadow:0 2px 10px {color}18;">'
|
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})
|
st.plotly_chart(fig_bar, use_container_width=True, config={"displayModeBar": False})
|
||||||
|
|
||||||
# ── Legend ──
|
|
||||||
legend_items = [("#1d4ed8", "Tertinggi"), ("#3b6cf7", "Normal")]
|
legend_items = [("#1d4ed8", "Tertinggi"), ("#3b6cf7", "Normal")]
|
||||||
if min_cnt > 0 and min_cnt != max_cnt:
|
if min_cnt > 0 and min_cnt != max_cnt:
|
||||||
legend_items.insert(1, ("#a78bfa", "Terendah"))
|
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
|
unsafe_allow_html=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Insight cards ──
|
|
||||||
pc1, pc2, pc3 = st.columns(3, gap="small")
|
pc1, pc2, pc3 = st.columns(3, gap="small")
|
||||||
|
|
||||||
def _date_pills(days_list, color, max_show=4):
|
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 = [
|
|
||||||
("🔄", "Interval crawling", f"Setiap {NRT_INTERVAL_MINUTES} menit"),
|
|
||||||
("📅", "Jangkauan data", "7 hari terakhir"),
|
|
||||||
("🗃", "Penyimpanan", "Database lokal"),
|
|
||||||
("🤖", "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 = [
|
|
||||||
("🐦", "Crawling tweet"),
|
|
||||||
("🗃", "Simpan ke database"),
|
|
||||||
("🧹", "Preprocessing teks"),
|
|
||||||
("🤖", "Prediksi sentimen"),
|
|
||||||
("📊", "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} {label}</span>'
|
|
||||||
+ (
|
|
||||||
'<span style="color:#cbd5e1;font-size:0.85rem;margin:0 2px;">→</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);">🐦</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 — Komdigi & 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> '
|
|
||||||
'— 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;">🔍 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;">"gratis ongkir"</span> OR '
|
|
||||||
'<span style="color:#3b6cf7;font-weight:700;">"free ongkir"</span>) OR '
|
|
||||||
'<span style="color:#3b6cf7;font-weight:700;">"pembatasan gratis ongkir"</span> OR '
|
|
||||||
'<span style="color:#3b6cf7;font-weight:700;">"gratis ongkir dibatasi"</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>'
|
|
||||||
' — 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;">'
|
|
||||||
'⚙ Alur kerja</span>'
|
|
||||||
f'{pipeline_steps_html}'
|
|
||||||
'</div>'
|
|
||||||
|
|
||||||
'</div>' # end outer card
|
|
||||||
)
|
|
||||||
|
|
||||||
st.markdown(html, unsafe_allow_html=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
# MAIN PAGE
|
# MAIN PAGE
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
@ -998,7 +1136,33 @@ def show():
|
||||||
if "filter_end_date" not in st.session_state:
|
if "filter_end_date" not in st.session_state:
|
||||||
st.session_state.filter_end_date = today
|
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()
|
_render_page_header()
|
||||||
|
|
||||||
# ── Mode Selector ─────────────────────────────────────────────
|
# ── Mode Selector ─────────────────────────────────────────────
|
||||||
|
|
@ -1080,7 +1244,7 @@ def show():
|
||||||
|
|
||||||
_gap("md")
|
_gap("md")
|
||||||
|
|
||||||
# ── Load data ─────────────────────────────────────────────────
|
# ── Load data ──────────────────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
|
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1094,7 +1258,7 @@ def show():
|
||||||
df_all["created_at"] = parse_dt(df_all["created_at"])
|
df_all["created_at"] = parse_dt(df_all["created_at"])
|
||||||
df_all["crawled_at"] = parse_crawled_dt(df_all["crawled_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":
|
if st.session_state.analysis_mode == "custom":
|
||||||
df_check = df_all.dropna(subset=["created_at"]).copy()
|
df_check = df_all.dropna(subset=["created_at"]).copy()
|
||||||
if not df_check.empty:
|
if not df_check.empty:
|
||||||
|
|
@ -1151,9 +1315,22 @@ def show():
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
# MODE: TWEET HARI INI (captured)
|
# MODE: HARI INI (captured)
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
if st.session_state.analysis_mode == "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")
|
st_autorefresh(interval=1000, key="crawler_monitor_refresh")
|
||||||
|
|
||||||
logs = get_auto_crawl_logs(5)
|
logs = get_auto_crawl_logs(5)
|
||||||
|
|
@ -1161,12 +1338,18 @@ def show():
|
||||||
sched_ok = is_crawler_service_active(logs)
|
sched_ok = is_crawler_service_active(logs)
|
||||||
last_dt = parse_log_time(last_log.get("timestamp")) if last_log else None
|
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)
|
_render_nrt_status_banner(sched_ok)
|
||||||
_gap("sm")
|
_gap("sm")
|
||||||
|
|
||||||
|
# Panel monitoring
|
||||||
_render_monitoring_panel(last_log, last_dt, sched_ok)
|
_render_monitoring_panel(last_log, last_dt, sched_ok)
|
||||||
_gap("md")
|
_gap("md")
|
||||||
|
|
||||||
# ── Crawl History ──────────────────────────────────────
|
# Riwayat crawl
|
||||||
visible_logs = get_visible_crawl_logs(logs)
|
visible_logs = get_visible_crawl_logs(logs)
|
||||||
if visible_logs:
|
if visible_logs:
|
||||||
rows = []
|
rows = []
|
||||||
|
|
@ -1225,7 +1408,7 @@ def show():
|
||||||
'Menunggu Crawl Pertama</div>'
|
'Menunggu Crawl Pertama</div>'
|
||||||
f'<div style="font-size:0.77rem;color:#64748b;">'
|
f'<div style="font-size:0.77rem;color:#64748b;">'
|
||||||
f'Riwayat akan muncul setelah crawl otomatis pertama selesai '
|
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>'
|
f'</div>'
|
||||||
'</div>',
|
'</div>',
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
|
|
@ -1233,7 +1416,7 @@ def show():
|
||||||
|
|
||||||
_gap("lg")
|
_gap("lg")
|
||||||
|
|
||||||
# ── Tweet yang dicrawl bot hari ini ───────────────────
|
# Tweet yang diambil bot hari ini
|
||||||
today_start = datetime.combine(today, datetime.min.time())
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
today_end = datetime.combine(today, datetime.max.time().replace(microsecond=0))
|
today_end = datetime.combine(today, datetime.max.time().replace(microsecond=0))
|
||||||
|
|
||||||
|
|
@ -1308,7 +1491,6 @@ def show():
|
||||||
|
|
||||||
_divider()
|
_divider()
|
||||||
|
|
||||||
# ── Active Filter Banner ──────────────────────────────────
|
|
||||||
total = len(df)
|
total = len(df)
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f'<div style="display:flex;align-items:center;gap:0.9rem;'
|
f'<div style="display:flex;align-items:center;gap:0.9rem;'
|
||||||
|
|
@ -1359,7 +1541,6 @@ def show():
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── Chart ─────────────────────────────────────────────────
|
|
||||||
st.markdown(
|
st.markdown(
|
||||||
f'<div class="crawl-card" style="padding-bottom:0.4rem;">'
|
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;">'
|
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)
|
_render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end)
|
||||||
|
|
||||||
# ── Tabel ─────────────────────────────────────────────────
|
|
||||||
display_limit = 100
|
display_limit = 100
|
||||||
_section_header(
|
_section_header(
|
||||||
"📋 Daftar Tweet yang Terkumpul",
|
"📋 Daftar Tweet yang Terkumpul",
|
||||||
|
|
@ -1405,7 +1585,6 @@ def show():
|
||||||
st.caption(f"Menampilkan {len(df_disp):,} dari {total:,} tweet")
|
st.caption(f"Menampilkan {len(df_disp):,} dari {total:,} tweet")
|
||||||
_gap("sm")
|
_gap("sm")
|
||||||
|
|
||||||
# ── Actions ───────────────────────────────────────────────
|
|
||||||
c_dl, c_nav = st.columns(2)
|
c_dl, c_nav = st.columns(2)
|
||||||
with c_dl:
|
with c_dl:
|
||||||
st.download_button(
|
st.download_button(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
"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,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"
|
"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,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,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"
|
"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",
|
"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",
|
"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",
|
"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",
|
||||||
"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",
|
"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",
|
||||||
|
|
|
||||||
|
Loading…
Reference in New Issue