Compare commits

..

10 Commits

Author SHA1 Message Date
adistya13 d685dc41a4 sentiment service 2026-06-25 20:52:36 +07:00
adistya13 7588f26c1e labeling 2026-06-14 13:37:17 +07:00
adistya13 29a2ae2515 perbaikan labeling 2026-06-14 13:35:27 +07:00
adistya13 9be480d529 flash page 2026-06-13 15:50:29 +07:00
adistya13 dc707605b3 revisi prepo dikit 2026-06-01 06:19:50 +07:00
adistya13 68319b9d23 revisi model 2026-05-29 14:21:45 +07:00
adistya13 dea2bc8a2b Merge branch 'main' of https://github.com/adistya13/sentiment-dashboard2 2026-05-22 23:20:37 +07:00
adistya13 2e1fd22452 slang
push slangword txt dulu
2026-05-22 23:20:19 +07:00
adistya13 2170dca02a
Update README.md 2026-05-22 20:51:31 +07:00
adistya13 f58d1f5c8f
Update README.md 2026-05-22 20:50:05 +07:00
76 changed files with 5090 additions and 612 deletions

View File

@ -1,3 +1,5 @@
dari sqlite udah diubah ke mysql
# Sentiment Dashboard Komdigi
Dashboard analisis sentimen tweet terkait Komdigi dan isu ongkir menggunakan Streamlit. Aplikasi ini mendukung crawling tweet, penyimpanan data ke database lokal, preprocessing teks Bahasa Indonesia, prediksi sentimen dengan model Naive Bayes, serta visualisasi hasil secara interaktif.
@ -30,8 +32,7 @@ Project ini menggunakan beberapa tools dan library berikut:
| scikit-learn | Library machine learning untuk model Naive Bayes dan TF-IDF. |
| Joblib | Memuat file model `.pkl` dan vectorizer `.pkl`. |
| Sastrawi | Stemming kata Bahasa Indonesia. |
| SQLite | Database lokal utama untuk menyimpan data tweet tanpa server tambahan. |
| SQLAlchemy | Engine koneksi database yang digunakan oleh Pandas dan modul aplikasi. |
| MySQL| Database lokal utama untuk menyimpan data tweet tanpa server tambahan. |
| python-dotenv | Membaca konfigurasi rahasia dari file `.env`. |
| OpenPyXL | Membaca dan menulis file Excel `.xlsx`. |
| Playwright | Dependensi pendukung scraping/otomasi browser bila diperlukan. |

11
app.py
View File

@ -3,6 +3,9 @@ from html import escape
import os
import time
# ── Splash screen ─────────────────────────────────────────────────────────
from splash_page import maybe_show_splash
import pandas as pd
import streamlit as st
from streamlit_autorefresh import st_autorefresh
@ -38,6 +41,10 @@ st.set_page_config(
initial_sidebar_state="expanded"
)
# ── Tampilkan splash saat pertama akses ───────────────────────────────────
if maybe_show_splash():
st.stop()
# Initialize timezone selection in session state
if "user_timezone" not in st.session_state:
st.session_state.user_timezone = get_default_timezone()
@ -906,10 +913,10 @@ with st.sidebar:
">📊</div>
<div>
<div style="font-size:1.02rem;font-weight:800;color:#0f172a;line-height:1.1;">
SentimenX
SentiTrack
</div>
<div style="font-size:0.72rem;color:#64748b;font-weight:600;margin-top:2px;">
Twitter Analytics Dashboard
Dashboard Monitoring Sentimen Netizen terhadap Kebijakan Pembatasan Gratis Ongkir
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -755,4 +755,415 @@ wong
yaitu
yakin
yakni
yang
yang
ada
adanya
adalah
adapun
agak
agaknya
agar
akan
akankah
akhirnya
aku
akulah
amat
amatlah
anda
andalah
antar
diantaranya
antara
antaranya
diantara
apa
apaan
mengapa
apabila
apakah
apalagi
apatah
atau
ataukah
ataupun
bagai
bagaikan
sebagai
sebagainya
bagaimana
bagaimanapun
sebagaimana
bagaimanakah
bagi
bahkan
bahwa
bahwasanya
sebaliknya
banyak
sebanyak
beberapa
seberapa
begini
beginian
beginikah
beginilah
sebegini
begitu
begitukah
begitulah
begitupun
sebegitu
belum
belumlah
sebelum
sebelumnya
sebenarnya
berapa
berapakah
berapalah
berapapun
betulkah
sebetulnya
biasa
biasanya
bila
bilakah
bisa
bisakah
sebisanya
boleh
bolehkah
bolehlah
buat
bukan
bukankah
bukanlah
bukannya
cuma
percuma
dahulu
dalam
dan
dapat
dari
daripada
dekat
demi
demikian
demikianlah
sedemikian
dengan
depan
di
dia
dialah
dini
diri
dirinya
terdiri
dong
dulu
enggak
enggaknya
entah
entahlah
terhadap
terhadapnya
hal
hampir
hanya
hanyalah
harus
haruslah
harusnya
seharusnya
hendak
hendaklah
hendaknya
hingga
sehingga
ia
ialah
ibarat
ingin
inginkah
inginkan
ini
inikah
inilah
itu
itukah
itulah
jangan
jangankan
janganlah
jika
jikalau
juga
justru
kala
kalau
kalaulah
kalaupun
kalian
kami
kamilah
kamu
kamulah
kan
kapan
kapankah
kapanpun
dikarenakan
karena
karenanya
ke
kecil
kemudian
kenapa
kepada
kepadanya
ketika
seketika
khususnya
kini
kinilah
kiranya
sekiranya
kita
kitalah
kok
lagi
lagian
selagi
lah
lain
lainnya
melainkan
selaku
lalu
melalui
terlalu
lama
lamanya
selama
selama
selamanya
lebih
terlebih
bermacam
macam
semacam
maka
makanya
makin
malah
malahan
mampu
mampukah
mana
manakala
manalagi
masih
masihkah
semasih
masing
mau
maupun
semaunya
memang
mereka
merekalah
meski
meskipun
semula
mungkin
mungkinkah
nah
namun
nanti
nantinya
nyaris
oleh
olehnya
seorang
seseorang
pada
padanya
padahal
paling
sepanjang
pantas
sepantasnya
sepantasnyalah
para
pasti
pastilah
per
pernah
pula
pun
merupakan
rupanya
serupa
saat
saatnya
sesaat
saja
sajalah
saling
bersama
sama
sesama
sambil
sampai
sana
sangat
sangatlah
saya
sayalah
se
sebab
sebabnya
sebuah
tersebut
tersebutlah
sedang
sedangkan
sedikit
sedikitnya
segala
segalanya
segera
sesegera
sejak
sejenak
sekali
sekalian
sekalipun
sesekali
sekaligus
sekarang
sekarang
sekitar
sekitarnya
sela
selain
selalu
seluruh
seluruhnya
semakin
sementara
sempat
semua
semuanya
sendiri
sendirinya
seolah
seperti
sepertinya
sering
seringnya
serta
siapa
siapakah
siapapun
disini
disinilah
sini
sinilah
sesuatu
sesuatunya
suatu
sesudah
sesudahnya
sudah
sudahkah
sudahlah
supaya
tadi
tadinya
tak
tanpa
setelah
telah
tentang
tentu
tentulah
tentunya
tertentu
seterusnya
tapi
tetapi
setiap
tiap
setidaknya
tidak
tidakkah
tidaklah
toh
waduh
wah
wahai
sewaktu
walau
walaupun
wong
yaitu
yakni
yang
selagi
kebijakan
pemerintah
kementerian
kemendag
aturan
peraturan
regulasi
pembatasan
pengaturan
berlaku
diterapkan
ditetapkan
resmi
pelaksanaan
ongkir
gratis
gratisan
subsidi
bebas
program
promo
promosi
diskon
potongan
layanan
pengiriman
platform
marketplace
ecommerce
belanja
online
shopee
tokopedia
lazada
bukalapak
tiktok
jne
jnt
sicepat
anteraja
ninja
kurir
ekspedisi
paket
kiriman
tarif
biaya
daring
digital
pemberlakuan
implementasi
penerapan

Binary file not shown.

BIN
model_naive_bayes_old.pkl Normal file

Binary file not shown.

BIN
model_naive_bayes_old2.pkl Normal file

Binary file not shown.

BIN
model_naive_bayes_old3.pkl Normal file

Binary file not shown.

BIN
model_naive_bayes_old4.pkl Normal file

Binary file not shown.

BIN
model_naive_bayes_old5.pkl Normal file

Binary file not shown.

View File

@ -1 +1 @@
{"activated": false, "updated_at": "2026-05-22T13:17:14.073811+00:00"}
{"activated": true, "updated_at": "2026-06-25T12:47:56.944759+00:00"}

View File

@ -496,11 +496,7 @@ def _render_page_header():
<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>

View File

@ -3,17 +3,28 @@ preprocessing_page.py
=====================
Halaman Bersihkan Data NLP Pipeline 5 Tahap.
PIPELINE 5 TAHAP (selaras dengan sentiment_service.py):
PIPELINE 5 TAHAP:
1. Case Folding ubah semua huruf jadi lowercase
2. Cleaning hapus URL, mention, hashtag, angka, emoji, tanda baca
3. Normalisasi singkatan/slang kata baku
4. Stopword Removal hapus kata umum; JAGA kata sentimen penting
3. Normalisasi singkatan/slang kata baku (DARI FILE normalisasi)
4. Stopword Removal hapus kata umum (DARI FILE stopword); JAGA kata sentimen
5. Stemming bentuk dasar kata via Sastrawi ECS
Catatan: Tokenizing tidak ditampilkan sebagai tahap tersendiri karena:
Stopword removal & stemming sudah melakukan split() secara internal
TF-IDF melakukan tokenisasi sendiri saat inferensi
Tokenizing adalah proses teknis, bukan tahap utama pipeline
PERBAIKAN DARI VERSI SEBELUMNYA (sync dengan sentiment_service.py):
- Domain override 'mending' 'lebih baik' DIHAPUS.
Alasan: mengubah kata kritis/negatif menjadi sinyal positif di lexicon.
"mending X daripada Y" = kritik; setelah diubah jadi "lebih baik X..."
lexicon menangkap 'baik' sebagai POSITIF hasil sentimen salah.
- Domain override 'mendingan' 'lebih baik' DIHAPUS (alasan sama).
- 'mending', 'mendingan', 'daripada', 'ketimbang', 'ngapain', 'percuma',
'begini' DILINDUNGI dari stopword removal agar pola kontekstual
(POLA_KOMPARATIF, POLA_KRITIK_TERSIRAT) di sentiment_service.py tetap
dapat mendeteksinya saat input lexicon preprocessing.
- KATA_SENTIMEN_PENTING diperluas: tambahkan 'mending', 'malah'.
CATATAN PENTING:
Pipeline ini HARUS IDENTIK dengan sentiment_service.py agar token yang
dihasilkan di sini konsisten dengan token saat training model.
"""
import streamlit as st
@ -33,18 +44,15 @@ from timezone_utils import (
get_timezone_name,
)
# ═══════════════════════════════════════════════════════════
# TIMEZONE HELPERS
# ═══════════════════════════════════════════════════════════
def parse_dt(series):
return parse_dt_with_tz(
series,
st.session_state.get("user_timezone", "WIB (UTC+7)")
)
def parse_crawled_dt(series):
return parse_dt_with_source_tz(
series,
@ -52,7 +60,6 @@ def parse_crawled_dt(series):
os.getenv("APP_TIMEZONE", "Asia/Jakarta")
)
def format_dt(value):
if value is None or pd.isna(value):
return "Belum ada"
@ -96,63 +103,285 @@ def _sync_dynamic_period():
# ═══════════════════════════════════════════════════════════
# PREPROCESSING PIPELINE — 5 TAHAP
# PENTING: Pipeline ini harus IDENTIK dengan sentiment_service.py
# agar token yang dihasilkan konsisten dengan training model.
#
# URUTAN:
# 1. Case Folding → lowercase dulu sebelum cleaning
# 2. Cleaning → hapus noise setelah lowercase
# 3. Normalisasi → singkatan/slang → kata baku
# 4. Stopword Removal → buang kata umum, jaga kata sentimen
# 5. Stemming → bentuk dasar via Sastrawi ECS
# ┌─────────────────────────────────────────────────────┐
# │ PENTING: Pipeline ini HARUS identik dengan │
# │ sentiment_service.py agar token konsisten! │
# │ │
# │ URUTAN WAJIB: │
# │ 1. Case Folding → lowercase dulu │
# │ 2. Cleaning → hapus noise setelah lowercase │
# │ 3. Normalisasi → slang→baku setelah bersih │
# │ 4. Stopword → buang kata umum, jaga sentimen │
# │ 5. Stemming → bentuk dasar via Sastrawi ECS │
# └─────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════
# ───────────────────────────────────────────────────────────
# KATA SENTIMEN PENTING
# Kata-kata ini WAJIB DIJAGA dan tidak boleh dihapus saat
# stopword removal, meskipun ada di file stopword.
#
# PERBAIKAN: Tambahkan 'mending' dan 'malah' agar tidak
# hilang di stopword removal dan bisa dideteksi oleh
# pola kontekstual di sentiment_service.py.
# ───────────────────────────────────────────────────────────
KATA_SENTIMEN_PENTING = {
# Negasi
# ── Negasi (pembalik makna kalimat) ──────────────────
"tidak", "bukan", "jangan", "kurang", "belum", "tanpa",
# Positif
# ── Intensitas (penguat/pelemah sentimen) ─────────────
"sangat", "banget", "sekali", "paling", "amat",
"luar", "biasa",
# ── Positif umum ──────────────────────────────────────
"keren", "bagus", "mantap", "setuju", "dukung", "mendukung",
"andal", "handal", "gercep", "bangga", "senang", "suka",
"baik", "benar", "tepat", "oke",
"baik", "benar", "tepat", "oke", "puas",
"sejahtera", "berkembang", "maju", "inovatif",
"tegas", "sigap", "tanggap", "adil", "bijak", "bermanfaat",
"untung", "berhasil", "sukses", "solusi", "manfaat",
"berguna", "membantu", "bantu", "pro", "lanjut",
"sangat", "banget", "sekali", "paling", "amat", "luar", "biasa",
# Negatif
# ── Positif domain e-commerce / ongkir ────────────────
"gratis", "murah", "hemat", "terjangkau", "cepat",
"aman", "mudah", "praktis", "terpercaya",
# ── Negatif umum ──────────────────────────────────────
"kecewa", "buruk", "jelek", "parah", "gagal", "hancur",
"rusak", "bohong", "tipu", "korupsi",
# Emosi
"marah", "sedih", "khawatir",
# ── Negatif domain e-commerce / ongkir ────────────────
"mahal", "lambat", "lelet", "ribet", "susah", "repot",
"rugi", "boros", "malah", "nyusahin", "susah-susahin",
# ── Emosi ─────────────────────────────────────────────
"marah", "sedih", "khawatir", "kecewa",
# ── DITAMBAHKAN: Penanda pola kontekstual ─────────────
# Kata-kata ini perlu tetap ada agar pola komparatif dan
# pola kritik tersirat bisa terdeteksi di sentiment_service.
"mending", # "mending X daripada Y" = kritik implisit
"mendingan", # variasi mending
"malah", # "malah rugi / malah tambah mahal" = negatif
"percuma", # "percuma aja kebijakan ini" = sia-sia/negatif
"ngapain", # "ngapain buat kebijakan ini" = kritik tersirat
"daripada", # komponen "mending X daripada Y"
"ketimbang", # variasi daripada
"begini", # "kebijakan begini" = kritik tersirat
"gajelas", # "gajelas aja kebijakan ini" = tidak jelas/negatif
"gk jelas", # "gk jelas aja kebijakan ini" = tidak jelas/negatif
"mahal", # "mahal banget kebijakan ini" = negatif
"tai", # "kebijakan ini tai" = sangat buruk
"sok", # "kebijakan ini sok keren" = negatif (sok = pura-pura)
"nyusahin", # "kebijakan ini nyusahin" = ribet/susah-susahin"
"malas", # "malas banget urus kebijakan ini" = negatif
"ga prioritas", "gak prioritas", "nggak prioritas", # "kebijakan ini gak prioritas" = negatif
# TAMBAHAN — kata emosi negatif
'malas', 'males', 'enggan', 'bete', 'jengkel', 'depresi', 'tai', 'guoblok', 'goblok', 'goblog',
'gondok', 'dongkol', 'sebal', 'bosan', 'jenuh', 'heran', 'bingung', 'pusing', 'stress', 'panik',
'kapok', 'muak', 'frustrasi', 'menyesal', 'nyesel', 'mahal', 'sok', 'nyusahin', 'susah-susahin',
}
def _load_stopwords():
# ───────────────────────────────────────────────────────────
# KATA POLA PENTING
# Kata struktural yang diperlukan agar pola kontekstual
# di sentiment_service.py bisa bekerja dengan benar.
# Kata-kata ini HARUS dilindungi dari stopword removal.
# ───────────────────────────────────────────────────────────
KATA_POLA_PENTING = {
"mending", # penanda pola komparatif negatif
"malah", # penanda pola komparatif negatif
"mendingan", # variasi mending
"daripada", # komponen "mending X daripada Y"
"ketimbang", # variasi daripada
"ngapain", # penanda kritik tersirat
"percuma", # penanda sia-sia
"begini", # "kebijakan begini" = kritik tersirat
"gajelas", # "gajelas aja kebijakan ini" = tidak jelas/negatif
"malas", # "malas banget urus kebijakan ini" = negatif
"gk jelas", # "gk jelas aja kebijakan ini" = tidak jelas/negatif
"mahal", # "mahal banget kebijakan ini" = negatif
"nyusahin", # penanda ribet/susah-susahin
"sok", # "kebijakan ini sok keren" = negatif (sok = pura-pura)
"ga prioritas", "gak prioritas", "nggak prioritas", # "kebijakan ini gak prioritas" = negatif
"tai", # "kebijakan ini tai" = sangat buruk
"gajelas", # "gajelas aja kebijakan ini" = tidak jelas/negatif
"gk jelas", # "gk jelas aja kebijakan ini" = tidak jelas/negatif
"mahal", # "mahal banget kebijakan ini" = negatif
}
# ───────────────────────────────────────────────────────────
# LOAD NORMALIZATION DARI FILE
# PERBAIKAN: Hapus override 'mending' → 'lebih baik'
# ───────────────────────────────────────────────────────────
def _load_normalization() -> dict:
"""
Muat kamus normalisasi dari file eksternal.
PERUBAHAN DARI VERSI SEBELUMNYA:
- 'mending' TIDAK lagi dioverride ke 'lebih baik'
- 'mendingan' TIDAK lagi dioverride ke 'lebih baik'
KENAPA?
'mending' dalam tweet biasanya digunakan sebagai kritik:
"mending ngurusin judol daripada ngurusin ongkir"
Jika diubah ke "lebih baik", lexicon scoring mendeteksi 'baik'
sebagai sinyal positif hasil sentimen SALAH (Positif, harusnya Negatif).
Biarkan 'mending' apa adanya agar POLA_KOMPARATIF_NEGATIF
di sentiment_service.py bisa mendeteksinya.
"""
norm_file = "indonesian-normalisasi-slangword-complete.txt"
norm_dict: dict = {}
try:
with open(norm_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split(",", 1)
if len(parts) != 2:
continue
slang = parts[0].strip().strip("'\"").lower()
normal = parts[1].strip().lower()
if slang and normal:
norm_dict[slang] = normal
except FileNotFoundError:
st.warning(
"⚠️ File normalisasi tidak ditemukan: "
f"'{norm_file}'. Hanya entri domain yang aktif."
)
# ── Override khusus domain ───────────────────────────
DOMAIN_OVERRIDES: dict = {
# Nama platform — pertahankan apa adanya
"shopee": "shopee",
"tokopedia": "tokopedia",
"lazada": "lazada",
"tiktok": "tiktok",
"bukalapak": "bukalapak",
"blibli": "blibli",
# Logistik — pertahankan apa adanya
"sicepat": "sicepat",
"jne": "jne",
"jnt": "jnt",
"anteraja": "anteraja",
"ninja": "ninja",
# Ongkir & belanja
"freeongkir": "gratis ongkos kirim",
"gratisongkir": "gratis ongkos kirim",
"ongkir": "ongkos kirim",
"ongkr": "ongkos kirim",
"bykrm": "biaya kirim",
"biayakirim": "biaya pengiriman",
# Kebijakan & lembaga
"komdigi": "komdigi",
"kemendag": "kementerian perdagangan",
"kominfo": "kementerian komunikasi",
"gejeee": "geje",
"gk jelas": "tidak jelas",
# E-commerce umum
"ecommerce": "e commerce",
"marketplace": "marketplace",
"seller": "penjual",
"buyer": "pembeli",
"online": "online",
# Negasi informal
"gk": "tidak", "ga": "tidak", "gak": "tidak",
"nggak": "tidak", "ngga": "tidak", "tdk": "tidak",
"tak": "tidak", "enggak": "tidak", "engga": "tidak",
"kagak": "tidak", "kaga": "tidak", "ndak": "tidak",
"ngak": "tidak",
# Intensitas
"bgt": "banget", "bngt": "banget", "bget": "banget", "bgtt": "banget",
# Positif informal (hanya yang benar-benar positif)
"mantep": "mantap", "mntap": "mantap",
"kece": "keren",
"ancur": "hancur", "parahh": "parah",
# ── SENGAJA TIDAK DIOVERRIDE (vs versi lama): ────────
# "mending" → TIDAK diubah ke "lebih baik"
# "mendingan" → TIDAK diubah ke "lebih baik"
# Alasan: lihat docstring di atas.
#
# "malah" → TIDAK dioverride ke "bahkan"
# Alasan: nuansa kritis 'malah' perlu dipertahankan.
#
# "sip" → TIDAK dioverride ke "baik"
# Alasan: "baik" terlalu kontekstual untuk lexicon positif.
}
norm_dict.update(DOMAIN_OVERRIDES)
return norm_dict
# ───────────────────────────────────────────────────────────
# LOAD STOPWORDS DARI FILE
# PERBAIKAN: Lindungi kata pola penting dari stopword removal
# ───────────────────────────────────────────────────────────
def _load_stopwords() -> set:
"""
Muat daftar stopword dari file eksternal.
PERUBAHAN DARI VERSI SEBELUMNYA:
Selain melindungi KATA_SENTIMEN_PENTING, kini juga melindungi
KATA_POLA_PENTING agar pola kontekstual di sentiment_service.py
bisa bekerja dengan benar:
- 'mending' penanda pola komparatif negatif
- 'daripada' komponen "mending X daripada Y"
- 'ketimbang' variasi daripada
- 'ngapain' penanda kritik tersirat
- 'percuma' penanda sia-sia/negatif
- 'begini' "kebijakan begini" = kritik tersirat
"""
stopword_file = "indonesian-stopwords-complete.txt"
base = set()
base: set = set()
try:
with open(stopword_file, "r", encoding="utf-8") as f:
base = set(f.read().splitlines())
for line in f:
word = line.strip().lower()
if word:
base.add(word)
except FileNotFoundError:
st.warning(
"⚠️ File stopword tidak ditemukan: "
f"'{stopword_file}'. Menggunakan daftar minimal."
)
base = {
"yang", "dan", "di", "ke", "dari", "ini", "itu",
"dengan", "untuk", "pada", "adalah", "oleh", "ada",
"ya", "akan", "atau", "juga", "sama", "karena",
"jika", "sudah", "telah",
"jika", "sudah", "telah", "jadi", "bisa",
}
# ── Langkah 1: Lindungi kata sentimen ────────────────
for kata in KATA_SENTIMEN_PENTING:
base.discard(kata)
base.update({
"rt", "amp", "https", "http", "co", "t",
"wkwk", "wkwkwk", "haha", "hehe", "xixi",
"yg", "dgn", "utk", "dr", "krn", "tp", "jd", "sdh",
"aja", "doang", "nih", "sih", "dong", "deh",
"loh", "lah", "tuh", "kak", "gan",
})
# ── Langkah 2: Lindungi kata pola kontekstual ─────────
# Kata-kata ini diperlukan agar pola analisis sentimen
# bisa bekerja setelah stopword removal.
for kata in KATA_POLA_PENTING:
base.discard(kata)
# ── Langkah 3: Tambah noise Twitter/sosmed ────────────
TWITTER_NOISE: set = {
"rt", "amp",
"https", "http", "co", "pic",
"wkwk", "wkwkwk", "wkwkwkwk",
"haha", "hahaha", "hehe", "hihi", "huhu", "xixi",
"nih", "sih", "dong", "deh", "loh", "lah", "tuh",
"kak", "gan", "bro", "sob", "min",
}
base.update(TWITTER_NOISE)
return base
# ───────────────────────────────────────────────────────────
# LOAD STEMMER
# ───────────────────────────────────────────────────────────
def _load_stemmer():
"""Muat stemmer Sastrawi. Return None jika tidak tersedia."""
try:
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
return StemmerFactory().create_stemmer()
@ -160,88 +389,40 @@ def _load_stemmer():
return None
NORMALISASI = {
# Negasi
"gk": "tidak", "ga": "tidak", "gak": "tidak",
"nggak": "tidak", "ngga": "tidak", "tdk": "tidak",
"tak": "tidak", "enggak": "tidak", "engga": "tidak",
"kagak": "tidak", "kaga": "tidak", "ndak": "tidak",
"gkk": "tidak", "ngak": "tidak",
# Kata ganti
"yg": "yang", "dgn": "dengan", "utk": "untuk",
"org": "orang", "krn": "karena", "dr": "dari",
"sm": "sama", "pd": "pada", "dlm": "dalam",
"bwt": "buat", "trm": "terima",
# Verba
"tp": "tapi", "tpi": "tapi", "jd": "jadi",
"sdh": "sudah", "blm": "belum", "emg": "memang",
"emang": "memang", "gimana": "bagaimana",
"gitu": "begitu", "gini": "begini",
"udah": "sudah", "udh": "sudah",
"mau": "mau",
# Intensitas
"bgt": "banget", "bngt": "banget", "bget": "banget", "bgtt": "banget",
# Positif informal
"bener": "benar", "beneran": "benar",
"mantep": "mantap", "mntap": "mantap",
"kece": "keren", "kece bgt": "keren banget",
"cucok": "cocok", "cucuk": "cocok",
"cakep": "bagus", "oke bgt": "oke banget",
"sip": "baik", "siipp": "baik",
"top": "terbaik", "topbgt": "terbaik banget",
"jos": "bagus", "josss": "bagus",
"goks": "luar biasa",
"setujuu": "setuju", "stuju": "setuju",
"dukung": "dukung",
"proud": "bangga",
"mantul": "mantap betul",
# Negatif informal
"ancur": "hancur", "ancrr": "hancur",
"parahh": "parah", "parahhh": "parah",
"gagall": "gagal",
"ngaco": "tidak benar",
"ngasal": "tidak benar",
"receh": "tidak penting",
"gaje": "tidak jelas",
"asal": "sembarangan",
# Domain
"ongkir": "ongkos kirim",
"freeongkir": "gratis ongkos kirim",
"gratisongkir": "gratis ongkos kirim",
"free": "gratis",
"ecommerce": "e commerce",
"komdigi": "komdigi",
"subsidi": "subsidi",
"marketplace": "marketplace",
"seller": "penjual",
"buyer": "pembeli",
"online": "online",
"shopee": "shopee",
"tokopedia": "tokopedia",
"lazada": "lazada",
"tiktok": "tiktok",
}
# ═══════════════════════════════════════════════════════════
# FUNGSI 5 TAHAP PREPROCESSING
# ═══════════════════════════════════════════════════════════
# ── Tahap 1: Case Folding ───────────────────────────────────────────────────
def step1_case_folding(text: str) -> str:
"""
TAHAP 1 CASE FOLDING
Input : teks asli (campuran huruf besar/kecil)
Output: semua huruf jadi lowercase
"""
return str(text).lower()
# ── Tahap 2: Cleaning ───────────────────────────────────────────────────────
def step2_cleaning(text: str) -> str:
"""
TAHAP 2 CLEANING
Input : teks lowercase
Output: teks bersih dari semua elemen noise
"""
text = re.sub(r"http\S+|www\S+|https\S+", "", text)
text = re.sub(r"@\w+", "", text)
text = re.sub(r"#\w+", "", text)
text = re.sub(r"\d+", "", text)
text = re.sub(
r"[\U00010000-\U0010ffff"
r"["
r"\U00010000-\U0010ffff"
r"\U0001F600-\U0001F64F"
r"\U0001F300-\U0001F5FF"
r"\U0001F680-\U0001F6FF"
r"\U0001F1E0-\U0001F1FF"
r"\u2600-\u26FF\u2700-\u27BF"
r"]+", "", text, flags=re.UNICODE
r"\u2600-\u26FF"
r"\u2700-\u27BF"
r"]+",
"", text, flags=re.UNICODE
)
text = text.translate(str.maketrans("", "", string.punctuation))
text = re.sub(r"[^a-zA-Z\s]", "", text)
@ -249,42 +430,76 @@ def step2_cleaning(text: str) -> str:
return text
# ── Tahap 3: Normalisasi ────────────────────────────────────────────────────
def step3_normalization(text: str) -> str:
return " ".join(NORMALISASI.get(word, word) for word in text.split())
def step3_normalization(text: str, norm_dict: dict) -> str:
"""
TAHAP 3 NORMALISASI
Input : teks bersih + norm_dict dari file
Output: teks dengan slang/singkatan sudah diganti kata baku
PERUBAHAN: 'mending' tidak lagi dinormalisasi ke 'lebih baik'.
Lihat komentar di _load_normalization() untuk penjelasan.
"""
tokens = text.split()
normalized = [norm_dict.get(token, token) for token in tokens]
return " ".join(normalized)
# ── Tahap 4: Stopword Removal ───────────────────────────────────────────────
def step4_stopword_removal(tokens: list, stopwords: set) -> list:
return [
w for w in tokens
if (w not in stopwords or w in KATA_SENTIMEN_PENTING) and len(w) > 2
]
"""
TAHAP 4 STOPWORD REMOVAL
Input : list token + stopwords dari file
Output: list token bersih
PERUBAHAN: Kata pola penting (mending, daripada, dll.) dilindungi
dari pembuangan melalui KATA_POLA_PENTING di _load_stopwords().
"""
result = []
for token in tokens:
if token in KATA_SENTIMEN_PENTING:
result.append(token)
continue
if token in KATA_POLA_PENTING:
result.append(token)
continue
if token in stopwords:
continue
if len(token) <= 2:
continue
result.append(token)
return result
# ── Tahap 5: Stemming ───────────────────────────────────────────────────────
def step5_stemming(tokens: list, stemmer) -> list:
"""
TAHAP 5 STEMMING
Input : list token setelah stopword removal
Output: list token dalam bentuk kata dasar
Algoritma: Enhanced Confix Stripping (ECS) via Sastrawi
"""
if stemmer is None:
return tokens
return [stemmer.stem(w) for w in tokens]
return [stemmer.stem(token) for token in tokens]
def full_preprocessing(text: str, stopwords: set, stemmer):
# ───────────────────────────────────────────────────────────
# FUNGSI UTAMA — JALANKAN SEMUA 5 TAHAP
# ───────────────────────────────────────────────────────────
def full_preprocessing(
text: str,
stopwords: set,
stemmer,
norm_dict: dict,
) -> dict:
"""
Jalankan 5 tahap preprocessing dan kembalikan dict hasil setiap tahap.
URUTAN TAHAP:
1. Case Folding lowercase
2. Cleaning hapus noise
3. Normalisasi normalisasi kata
4. Stopword Removal buang stopword (split() dilakukan internal)
5. Stemming bentuk dasar
Jalankan 5 tahap preprocessing secara berurutan.
Return dict berisi hasil setiap tahap.
"""
s1_fold = step1_case_folding(text)
s2_clean = step2_cleaning(s1_fold)
s3_norm = step3_normalization(s2_clean)
s4_filtered = step4_stopword_removal(s3_norm.split(), stopwords)
s5_stemmed = step5_stemming(s4_filtered, stemmer)
s1_fold = step1_case_folding(text)
s2_clean = step2_cleaning(s1_fold)
s3_norm = step3_normalization(s2_clean, norm_dict)
s4_tokens = s3_norm.split()
s4_filtered = step4_stopword_removal(s4_tokens, stopwords)
s5_stemmed = step5_stemming(s4_filtered, stemmer)
return {
"setelah_casefolding": s1_fold,
@ -443,7 +658,7 @@ def _render_page_header():
# PIPELINE STEPS CARDS
# ═══════════════════════════════════════════════════════════
def _render_pipeline_steps(stemmer_ok):
def _render_pipeline_steps(stemmer_ok: bool, norm_count: int, sw_count: int):
steps = [
{
"num": "01", "anim": "pipe-1",
@ -456,8 +671,8 @@ def _render_pipeline_steps(stemmer_ok):
'"ONGKIR""ongkir"',
'"KEREN""keren"',
"Seluruh karakter → huruf kecil",
"Dilakukan pertama agar cleaning konsisten",
"Basis untuk normalisasi & stopword",
"Dilakukan PERTAMA agar regex & dict konsisten",
"Fondasi seluruh tahap berikutnya",
],
},
{
@ -479,30 +694,30 @@ def _render_pipeline_steps(stemmer_ok):
"num": "03", "anim": "pipe-3",
"icon": "🔄", "color": "#16a34a", "dark": "#14532d",
"bg": "linear-gradient(135deg,#f0fdf4,#dcfce7)", "border": "#86efac",
"title": 'Normalisasi <span class="fix-badge">✦ Diperluas</span>',
"desc": "Mengubah kata tidak baku, singkatan, dan slang menjadi kata baku.",
"title": f'Normalisasi <span class="fix-badge">✦ {norm_count:,} entri</span>',
"desc": "Mengubah singkatan/slang ke kata baku. 'mending' TIDAK diubah ke 'lebih baik' (perbaikan konteks sentimen).",
"items": [
"gk/ga/gak/kagak → tidak",
"gk/ga/gak/kagak/ngga → tidak",
"bgt/bngt/bget → banget",
"ongkir → ongkos kirim",
"mantep → mantap",
"kece → keren",
"jos/josss → bagus",
"free → gratis",
"⚠️ mending → mending (dijaga, bukan 'lebih baik')",
f"Total: {norm_count:,} pasang slang→baku dimuat",
],
},
{
"num": "04", "anim": "pipe-4",
"icon": "🚫", "color": "#ea580c", "dark": "#7c2d12",
"bg": "linear-gradient(135deg,#fff7ed,#ffedd5)", "border": "#fed7aa",
"title": 'Stopword Removal <span class="fix-badge">✦ Diperbaiki</span>',
"desc": "Membuang kata umum; kata sentimen penting DIJAGA.",
"title": f'Stopword Removal <span class="fix-badge">✦ {sw_count:,} kata</span>',
"desc": "Membuang kata umum; kata sentimen & kata pola kontekstual DIJAGA.",
"items": [
"Hapus kata umum (dan, di, ke...)",
"Hapus token < 3 karakter",
"JAGA negasi: tidak, bukan, jangan",
"JAGA positif: keren, bagus, mantap",
"JAGA evaluatif: setuju, dukung, bijak",
"JAGA intensitas: banget, sangat, sekali",
f"{sw_count:,} stopword dimuat dari file",
"JAGA negasi: tidak, bukan, jangan, belum",
"JAGA positif: keren, bagus, mantap, gratis",
"JAGA negatif: kecewa, buruk, gagal, mahal",
"JAGA pola: mending, daripada, percuma, begini",
"Hapus token ≤ 2 karakter (noise)",
],
},
{
@ -522,14 +737,12 @@ def _render_pipeline_steps(stemmer_ok):
},
]
# Baris 1: 3 kartu pertama
row1 = st.columns(3, gap="medium")
for col, step in zip(row1, steps[:3]):
_render_step_card(col, step)
_gap("sm")
# Baris 2: 2 kartu terakhir (tengah agar simetris)
_, col4, col5, _ = st.columns([0.5, 1, 1, 0.5], gap="medium")
_render_step_card(col4, steps[3])
_render_step_card(col5, steps[4])
@ -678,7 +891,6 @@ def _render_live_example(df_c):
"Contoh tweet acak dari dataset — refresh halaman untuk contoh berbeda"
)
# Urutan tampilan sesuai pipeline: CF → Clean → Norm → Stop → Stem
steps_ex = [
("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0"),
("① Setelah Case Folding", "setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe"),
@ -727,7 +939,10 @@ def _render_top_words_chart(df_c):
)
if "_tokens_clean" in df_c.columns:
all_words = [w for tokens in df_c["_tokens_clean"] for w in (tokens if isinstance(tokens, list) else [])]
all_words = [
w for tokens in df_c["_tokens_clean"]
for w in (tokens if isinstance(tokens, list) else [])
]
else:
all_words = " ".join(df_c["clean_text"].fillna("")).split()
@ -821,19 +1036,24 @@ def show():
unsafe_allow_html=True
)
# ── Pipeline Overview ─────────────────────────────────────
stemmer = _load_stemmer()
stopwords = _load_stopwords()
norm_dict = _load_normalization()
_section_header(
"🔬 Alur NLP Pipeline — 5 Tahap Preprocessing",
"Setiap tweet diproses berurutan melalui 5 tahap sebelum siap dianalisis sentimennya"
)
stemmer_tmp = _load_stemmer()
_render_pipeline_steps(stemmer_tmp is not None)
_render_pipeline_steps(
stemmer_ok=stemmer is not None,
norm_count=len(norm_dict),
sw_count=len(stopwords),
)
_gap("sm")
_render_flow_arrow()
_gap("md")
# ── Load data ─────────────────────────────────────────────
try:
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
if df_all.empty:
@ -848,13 +1068,14 @@ def show():
s_dt = pd.Timestamp(start_date)
e_dt = pd.Timestamp(end_date)
df = df_all[(df_all["created_at"] >= s_dt) & (df_all["created_at"] <= e_dt)].copy()
df = df_all[
(df_all["created_at"] >= s_dt) & (df_all["created_at"] <= e_dt)
].copy()
if df.empty:
st.warning(f"⚠️ Tidak ada tweet dengan tanggal asli dalam periode {filter_label}.")
return
# ── Cache preprocessing ───────────────────────────────────
total_tweets_in_db = get_tweet_count()
latest_crawl_marker = get_latest_crawl_time() or "no-crawl"
data_marker = (total_tweets_in_db, latest_crawl_marker)
@ -871,13 +1092,15 @@ def show():
force_refresh = data_marker != st.session_state.get("_pp_last_data_marker")
if cache_key not in st.session_state or force_refresh:
stemmer = _load_stemmer()
stopwords = _load_stopwords()
with st.spinner("🧹 Menjalankan 5 tahap preprocessing…"):
results = []
for _, row in df.iterrows():
r = full_preprocessing(row["text"], stopwords, stemmer)
r = full_preprocessing(
text = row["text"],
stopwords = stopwords,
stemmer = stemmer,
norm_dict = norm_dict,
)
r["tweet_id"] = row.get("tweet_id", "")
r["text_asli"] = row["text"]
r["created_at"] = row["created_at"]
@ -886,15 +1109,15 @@ def show():
df_c = pd.DataFrame(results)
df_c = df_c[df_c["clean_text"].str.strip().str.len() > 0].copy()
df_c = df_c.reset_index(drop=True)
st.session_state[cache_key] = df_c
st.session_state[cache_key + "_stemmer_ok"] = stemmer is not None
st.session_state["_pp_last_data_marker"] = data_marker
st.session_state[cache_key] = df_c
st.session_state[cache_key + "_sw_ok"] = stemmer is not None
st.session_state["_pp_last_data_marker"] = data_marker
df_c = st.session_state[cache_key]
stemmer_ok = st.session_state.get(cache_key + "_stemmer_ok", False)
stemmer_ok = st.session_state.get(cache_key + "_sw_ok", False)
# ── Statistik ─────────────────────────────────────────────
removed = len(df) - len(df_c)
_section_header(
@ -908,7 +1131,6 @@ def show():
_render_live_example(df_c)
_gap("lg")
# ── Tabel ─────────────────────────────────────────────────
_section_header(
"📋 Tabel Perbandingan Teks per Tahap",
f"{len(df_c):,} tweet · {filter_label} — scroll horizontal untuk lihat semua kolom"
@ -918,7 +1140,6 @@ def show():
df_c = df_c.copy()
df_c["crawled_at"] = pd.NaT
# Kolom ditampilkan sesuai urutan pipeline: CF → Clean → Norm → Stop → Stem
disp = df_c[[
"tweet_id", "created_at", "crawled_at",
"text_asli",
@ -968,10 +1189,8 @@ def show():
_render_top_words_chart(df_c)
_gap("lg")
# ── Simpan ke session state ───────────────────────────────
st.session_state["preprocessed_df"] = df_c
# ── Download ──────────────────────────────────────────────
st.markdown("""
<div style="background:#ffffff;border:1.5px solid #e2e8f0;border-radius:14px;
padding:1.1rem 1.2rem;box-shadow:0 2px 6px rgba(15,23,42,0.05);

View File

@ -1,29 +1,8 @@
"""
sentiment_page.py
=================
Halaman Analisis Sentimen diperbaiki untuk mengatasi semua prediksi negatif.
PERBAIKAN vs VERSI LAMA:
1. MENGGUNAKAN sentiment_service.py (HYBRID CLASSIFIER)
Tidak lagi memanggil model secara langsung dengan predict_batch sederhana.
Sebaliknya menggunakan sentiment_service yang sudah mengimplementasikan:
Lexicon-based override untuk kelas POSITIF (yang tidak ada di model)
Negation handling (tidak bagus bagus)
Fallback ke model NB untuk negatif vs netral
2. PREPROCESSING SELARAS
Memanggil preprocess_for_model() dari sentiment_service agar preprocessing
yang digunakan untuk prediksi persis sama dengan yang dipakai saat training.
3. CONFIDENCE SCORE AKURAT
Menggunakan confidence gabungan dari lexicon score + model probability,
bukan hanya max probability dari model yang bias ke negatif.
4. LABEL MAPPING KONSISTEN
Label dikembalikan selalu dalam format: 'Positif', 'Netral', 'Negatif'
(kapital huruf pertama) agar konsisten di seluruh tampilan.
PERBAIKAN: Hapus parameter key= dari semua st.container() karena tidak
didukung di Streamlit versi lama. CSS styling tetap berjalan via class HTML.
"""
import streamlit as st
@ -44,7 +23,6 @@ from timezone_utils import (
)
import plotly.graph_objects as go
# ── Import sentiment_service (hybrid classifier) ──────────────────────────
from sentiment_service import (
preprocess_for_model,
preprocess_untuk_lexicon,
@ -118,40 +96,27 @@ def _sync_dynamic_period():
# ─────────────────────────────────────────────────────────────
# Core prediction function (DIPERBAIKI)
# Core prediction
# ─────────────────────────────────────────────────────────────
def predict_batch_hybrid(texts: list[str]) -> list[tuple[str, float]]:
"""
Prediksi sentimen menggunakan Hybrid Classifier dari sentiment_service.
Pipeline per tweet:
1. preprocess_for_model() teks untuk TF-IDF + NB
2. preprocess_untuk_lexicon() teks untuk pengecekan lexicon
3. _hitung_skor_lexicon() hitung sinyal positif/negatif kuat
4. _klasifikasi_hybrid() putuskan label + confidence
Return: list of (label, confidence)
"""
def predict_batch_hybrid(texts):
results = []
for text in texts:
teks_model = preprocess_for_model(text)
teks_lexicon = preprocess_untuk_lexicon(text)
teks_lower = str(text).lower()
skor = _hitung_skor_lexicon(teks_lexicon)
label, conf = _klasifikasi_hybrid(teks_model, skor, teks_lower)
skor = _hitung_skor_lexicon(teks_lexicon)
label, conf = _klasifikasi_hybrid(teks_model, skor, teks_lower)
results.append((label, conf))
return results
def preprocess_single(text: str) -> str:
"""Preprocess satu teks untuk disimpan ke kolom clean_text."""
def preprocess_single(text):
return preprocess_for_model(text)
# ─────────────────────────────────────────────────────────────
# Shared UI helpers
# UI helpers
# ─────────────────────────────────────────────────────────────
def _section_header(title, subtitle=""):
@ -173,6 +138,20 @@ def _section_gap(size="md"):
st.markdown(f'<div style="height:{heights.get(size,"1.45rem")};"></div>', unsafe_allow_html=True)
def _card_open(extra_style=""):
"""Buka div card pengganti st.container(border=True)."""
st.markdown(
f'<div style="background:#ffffff;border:1.5px solid #e2e8f0;border-radius:12px;'
f'box-shadow:0 2px 6px rgba(15,23,42,0.06);padding:1.1rem 1.2rem 1rem;'
f'margin-bottom:0.5rem;{extra_style}">',
unsafe_allow_html=True,
)
def _card_close():
st.markdown('</div>', unsafe_allow_html=True)
def _render_sentiment_styles():
st.markdown("""
<style>
@ -185,31 +164,6 @@ section[data-testid="stMain"] * {
[data-testid="stMainBlockContainer"] { padding-top: 1rem !important; }
header[data-testid="stHeader"] { height: 0 !important; min-height: 0 !important; }
.st-key-sentiment_keyword_panel,
.st-key-sentiment_table_controls,
.st-key-sentiment_download_panel,
.st-key-sentiment_chart_panel,
.st-key-sentiment_trend_panel,
.st-key-sentiment_wordfreq_panel {
background: #ffffff !important;
border: 1.5px solid #e2e8f0 !important;
border-radius: 12px !important;
box-shadow: 0 2px 6px rgba(15,23,42,0.06) !important;
padding: 1.1rem 1.2rem 1rem !important;
}
.st-key-sentiment_keyword_panel [data-testid="stTextInput"] label p,
.st-key-sentiment_table_controls [data-testid="stTextInput"] label p,
.st-key-sentiment_table_controls [data-testid="stSelectbox"] label p {
color: #334155 !important;
font-size: 0.72rem !important;
font-weight: 800 !important;
letter-spacing: 0.04em !important;
line-height: 1.2 !important;
margin-bottom: 0.22rem !important;
text-transform: uppercase !important;
}
.stButton > button {
border-radius: 12px !important;
font-weight: 700 !important;
@ -419,7 +373,7 @@ def _render_donut_chart(pos_n, neu_n, neg_n, total, filter_label):
legend=dict(orientation="h", y=-0.1, x=0.5, xanchor="center", font=dict(size=11)),
paper_bgcolor="rgba(0,0,0,0)",
)
st.plotly_chart(fig, width="stretch", config={"displayModeBar": False})
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
return dominant
@ -446,7 +400,7 @@ def _render_bar_chart(pos_n, neu_n, neg_n, total):
yaxis=dict(showgrid=True, gridcolor="rgba(226,232,240,0.8)", griddash="dot",
tickfont=dict(size=10, color="#94a3b8")),
)
st.plotly_chart(fig, width="stretch", config={"displayModeBar": False})
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
# ─────────────────────────────────────────────────────────────
@ -496,49 +450,9 @@ def _render_trend_chart(fdf, filter_label, dt_start, dt_end):
tickfont=dict(size=10, color="#94a3b8")),
hovermode="x unified",
)
with st.container(border=True, key="sentiment_trend_panel"):
st.plotly_chart(fig, width="stretch", config={"displayModeBar": False})
# ─────────────────────────────────────────────────────────────
# Confidence histogram
# ─────────────────────────────────────────────────────────────
# def _render_confidence_chart(fdf):
# _section_header(
# "🎯 Distribusi Keyakinan Model",
# "Seberapa yakin classifier dalam mengklasifikasikan setiap tweet"
# )
# fig = go.Figure()
# sent_cfg = [
# ("Positif", "#16a34a", "rgba(22,163,74,0.7)"),
# ("Netral", "#94a3b8", "rgba(148,163,184,0.7)"),
# ("Negatif", "#ef4444", "rgba(239,68,68,0.7)"),
# ]
# for sent, color, fill_color in sent_cfg:
# sub = fdf[fdf["sentiment"] == sent]["confidence"]
# if sub.empty:
# continue
# fig.add_trace(go.Histogram(
# x=sub, name=sent, nbinsx=20,
# marker=dict(color=fill_color, line=dict(color=color, width=1)),
# opacity=0.85,
# hovertemplate=f"<b>{sent}</b><br>Keyakinan: %{{x:.0%}}<br>Jumlah: %{{y}}<extra></extra>",
# ))
# fig.update_layout(
# height=260, margin=dict(l=0, r=0, t=10, b=0), barmode="overlay",
# paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
# legend=dict(orientation="h", y=1.1, x=0, font=dict(size=11)),
# xaxis=dict(tickformat=".0%", title="Tingkat Keyakinan",
# tickfont=dict(size=10, color="#94a3b8"),
# showgrid=True, gridcolor="rgba(226,232,240,0.6)"),
# yaxis=dict(title="Jumlah Tweet",
# tickfont=dict(size=10, color="#94a3b8"),
# showgrid=True, gridcolor="rgba(226,232,240,0.6)", griddash="dot"),
# )
# with st.container(border=True, key="sentiment_conf_panel"):
# st.plotly_chart(fig, width="stretch", config={"displayModeBar": False})
# ─── PERBAIKAN: hapus key= dari st.container() ───
with st.container():
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
# ─────────────────────────────────────────────────────────────
@ -609,7 +523,7 @@ def _render_word_freq_per_sentiment(fdf):
tickfont=dict(size=8, color="#94a3b8"), fixedrange=True),
yaxis=dict(showgrid=False, tickfont=dict(size=9, color="#334155"), fixedrange=True),
)
st.plotly_chart(fig, width="stretch", config={"displayModeBar": False})
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
st.markdown('</div>', unsafe_allow_html=True)
@ -671,25 +585,25 @@ def _render_tweet_table(fdf, filter_label):
f"Total {len(fdf):,} tweet · {filter_label}"
)
with st.container(border=True, key="sentiment_table_controls"):
tf1, tf2, tf3 = st.columns([3, 1.6, 1.7], gap="medium", vertical_alignment="bottom")
with tf1:
search = st.text_input(
"Search tweet", placeholder="Ketik kata kunci di isi tweet...",
key="sentiment_table_search",
)
with tf2:
sf = st.selectbox(
"Filter sentimen",
["Semua", "Positif 😊", "Netral 😐", "Negatif 😞"],
key="sentiment_table_filter",
)
with tf3:
sort_by = st.selectbox(
"Urutkan",
["Terbaru dulu", "Terlama dulu", "Keyakinan tertinggi"],
key="sentiment_table_sort",
)
# ─── PERBAIKAN: ganti st.container(key=) dengan st.columns biasa ───
tf1, tf2, tf3 = st.columns([3, 1.6, 1.7], gap="medium")
with tf1:
search = st.text_input(
"🔍 Cari tweet", placeholder="Ketik kata kunci di isi tweet...",
key="sentiment_table_search",
)
with tf2:
sf = st.selectbox(
"Filter sentimen",
["Semua", "Positif 😊", "Netral 😐", "Negatif 😞"],
key="sentiment_table_filter",
)
with tf3:
sort_by = st.selectbox(
"Urutkan",
["Terbaru dulu", "Terlama dulu"],
key="sentiment_table_sort",
)
_section_gap("sm")
@ -706,31 +620,26 @@ def _render_tweet_table(fdf, filter_label):
if sort_by == "Terbaru dulu":
tdf = tdf.sort_values("created_at", ascending=False)
elif sort_by == "Terlama dulu":
tdf = tdf.sort_values("created_at", ascending=True)
else:
tdf = tdf.sort_values("confidence", ascending=False)
tdf = tdf.sort_values("created_at", ascending=True)
# out = tdf[["created_at", "crawled_at", "text", "clean_text", "sentiment", "confidence"]].copy()
out = tdf[["created_at", "crawled_at", "text", "clean_text", "sentiment"]].copy()
out["created_at"] = out["created_at"].apply(format_dt)
out["crawled_at"] = out["crawled_at"].apply(format_dt)
# out["confidence"] = out["confidence"].apply(lambda x: f"{x:.0%}")
out["sentiment"] = out["sentiment"].map({
"Positif": "😊 Positif", "Netral": "😐 Netral", "Negatif": "😞 Negatif",
}).fillna(out["sentiment"])
# out.columns = ["Tanggal Tweet", "Masuk Database", "Tweet Asli", "Tweet Bersih", "Sentimen", "Keyakinan"]
out.columns = ["Tanggal Tweet", "Masuk Database", "Tweet Asli", "Tweet Bersih", "Sentimen"]
render_standard_table(
out, height=400, min_width=1220,
badge_columns=["Sentimen"],
nowrap=["Tanggal Tweet", "Masuk Database", "Sentimen", "Keyakinan"],
nowrap=["Tanggal Tweet", "Masuk Database", "Sentimen"],
wide_columns=["Tweet Asli", "Tweet Bersih"],
column_widths={
"Tanggal Tweet": "170px", "Masuk Database": "170px",
"Tweet Asli": "360px", "Tweet Bersih": "360px",
"Sentimen": "130px", "Keyakinan": "110px",
"Sentimen": "130px",
},
)
st.caption(f"Menampilkan {len(tdf):,} tweet")
@ -927,11 +836,9 @@ def show():
if force_refresh:
st.info("🔄 Menyegarkan prediksi sentimen dengan data terbaru...")
with st.spinner("🔍 Preprocessing & prediksi sentimen (Hybrid Classifier)..."):
# Preprocess semua tweet
df["clean_text"] = df["text"].apply(preprocess_single)
dfc = df[df["clean_text"].str.strip().str.len() > 0].copy()
# Prediksi menggunakan hybrid classifier
results = predict_batch_hybrid(dfc["text"].tolist())
if results:
sentiments, confidences = zip(*results)
@ -949,7 +856,8 @@ def show():
# ── Keyword filter ────────────────────────────────────────
_section_header("🎯 Filter Kata Kunci", "Kosongkan untuk melihat semua tweet")
with st.container(border=True, key="sentiment_keyword_panel"):
# ─── PERBAIKAN: hapus key= dari st.container() ───
with st.container():
kw = st.text_input(
"Kata kunci", placeholder="Contoh: ongkir, kurir, komdigi...",
key="sentiment_keyword_search",
@ -1000,11 +908,12 @@ def show():
col_left, col_right = st.columns(2, gap="medium")
with col_left:
_section_header("🔵 Sebaran Sentimen", f"Periode {filter_label}")
with st.container(border=True, key="sentiment_chart_panel"):
# ─── PERBAIKAN: hapus key= dari st.container() ───
with st.container():
dominant = _render_donut_chart(pos_n, neu_n, neg_n, total, filter_label)
with col_right:
_section_header("📊 Perbandingan Jumlah per Sentimen", f"Periode {filter_label}")
with st.container(border=True, key="sentiment_bar_panel"):
with st.container():
_render_bar_chart(pos_n, neu_n, neg_n, total)
_section_gap("lg")
@ -1012,10 +921,6 @@ def show():
_render_trend_chart(fdf, filter_label, start_date, end_date)
_section_gap("lg")
# ── Confidence ────────────────────────────────────────────
# _render_confidence_chart(fdf)
# _section_gap("lg")
# ── Word freq ─────────────────────────────────────────────
_render_word_freq_per_sentiment(fdf)
_section_gap("lg")
@ -1035,15 +940,17 @@ def show():
# ── Download ──────────────────────────────────────────────
_section_header("📥 Unduh Hasil Analisis")
with st.container(border=True, key="sentiment_download_panel"):
d1, d2, d3 = st.columns(3, gap="medium", vertical_alignment="bottom")
# ─── PERBAIKAN: hapus key= dari st.container() ───
with st.container():
d1, d2, d3 = st.columns(3, gap="medium")
with d1:
st.download_button(
"📥 Semua Hasil Prediksi",
fdf.to_csv(index=False).encode("utf-8"),
f"hasil_prediksi_{datetime.now().strftime('%Y%m%d_%H%M')}.csv",
"text/csv", width="stretch",
"text/csv",
use_container_width=True,
)
with d2:
summary = pd.DataFrame({
@ -1055,12 +962,14 @@ def show():
"📈 Ringkasan Sentimen",
summary.to_csv(index=False).encode("utf-8"),
f"ringkasan_{datetime.now().strftime('%Y%m%d_%H%M')}.csv",
"text/csv", width="stretch",
"text/csv",
use_container_width=True,
)
with d3:
st.download_button(
"🎯 Rekomendasi Tindakan",
df_rek.to_csv(index=False).encode("utf-8"),
f"rekomendasi_{datetime.now().strftime('%Y%m%d_%H%M')}.csv",
"text/csv", width="stretch",
"text/csv",
use_container_width=True,
)

File diff suppressed because it is too large Load Diff

433
splash_page.py Normal file
View File

@ -0,0 +1,433 @@
"""
splash_page.py
Splash screen modern, full-viewport centered.
Hanya tampil SEKALI saat pertama kali sistem diakses (browser baru/tab baru).
Jika halaman di-refresh (F5), splash TIDAK akan muncul lagi karena status
"sudah pernah splash" disimpan di URL query parameter, bukan di session_state.
KENAPA TIDAK PAKAI session_state SAJA?
session_state Streamlit terikat ke session koneksi, bukan ke browser/tab.
Pada beberapa kondisi (reconnect websocket, refresh di beberapa environment
hosting), session_state bisa ter-reset sehingga splash muncul lagi padahal
user hanya refresh. query_params (di URL) tidak hilang saat refresh biasa,
sehingga lebih andal untuk menandai "splash sudah pernah tampil".
🕐 PENGATURAN DURASI:
_SPLASH_DURATION = 10 ganti angka ini (satuan: detik)
Rekomendasi: 48 detik
📐 PENGATURAN TINGGI IFRAME:
components.html(..., height=700 ...) sesuaikan jika konten terpotong
"""
import time
import streamlit as st
import streamlit.components.v1 as components
_APP_NAME = "SentiTrack"
# ┌─────────────────────────────────────────┐
# │ ⏱ GANTI ANGKA INI UNTUK UBAH DURASI │
# │ Satuan: detik | Rekomendasi: 48 │
_SPLASH_DURATION = 10
# └─────────────────────────────────────────┘
# Nama parameter URL yang dipakai sebagai "penanda" splash sudah tampil.
# Tidak perlu diubah, kecuali bentrok dengan query param lain di app kamu.
_SPLASH_FLAG_KEY = "splashed"
def _build_splash_html(duration: int) -> str:
return f"""<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<style>
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
/* Full viewport centering bekerja di semua resolusi */
html, body {{
width: 100%; height: 100%;
overflow: hidden;
}}
body {{
font-family: 'Plus Jakarta Sans', sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}}
/* Background gradient */
.bg {{
position: fixed; inset: 0; z-index: 0;
background: linear-gradient(160deg, #eef4ff 0%, #f8faff 40%, #f0f7ff 70%, #eef4ff 100%);
background-size: 300% 300%;
animation: gradMove {duration * 3}s ease infinite;
}}
@keyframes gradMove {{
0% {{ background-position: 0% 50%; }}
50% {{ background-position: 100% 50%; }}
100% {{ background-position: 0% 50%; }}
}}
/* Pola titik */
.dots {{
position: fixed; inset: 0; z-index: 1; pointer-events: none;
background-image: radial-gradient(rgba(59,130,246,0.07) 1.5px, transparent 1.5px);
background-size: 26px 26px;
}}
/* Orb cahaya latar */
.orb {{
position: fixed; border-radius: 50%; z-index: 1; pointer-events: none;
will-change: transform;
}}
.orb-1 {{
width: 480px; height: 480px;
top: -180px; left: -120px;
background: radial-gradient(circle, rgba(99,102,241,0.09) 0%, transparent 70%);
animation: floatA 9s ease-in-out infinite;
}}
.orb-2 {{
width: 380px; height: 380px;
bottom: -120px; right: -80px;
background: radial-gradient(circle, rgba(59,130,246,0.08) 0%, transparent 70%);
animation: floatB 11s ease-in-out infinite;
}}
@keyframes floatA {{ 0%,100% {{ transform: translate(0,0); }} 50% {{ transform: translate(24px,-32px); }} }}
@keyframes floatB {{ 0%,100% {{ transform: translate(0,0); }} 50% {{ transform: translate(-18px,26px); }} }}
/* Card utama */
.card {{
position: relative; z-index: 10;
background: rgba(255,255,255,0.9);
backdrop-filter: blur(18px) saturate(1.4);
-webkit-backdrop-filter: blur(18px) saturate(1.4);
border: 1.5px solid rgba(226,232,240,0.85);
border-radius: 24px;
padding: 2.5rem 2.75rem 2rem;
width: calc(100% - 2.5rem);
max-width: 520px;
box-shadow:
0 0 0 1px rgba(255,255,255,0.5) inset,
0 8px 40px rgba(59,130,246,0.09),
0 2px 12px rgba(15,23,42,0.06);
animation: fadeUp 0.65s cubic-bezier(0.22,1,0.36,1) forwards;
opacity: 0;
}}
@keyframes fadeUp {{
from {{ opacity: 0; transform: translateY(22px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
/* Header: logo + nama app */
.header {{
display: flex; align-items: center; gap: 1rem;
margin-bottom: 1.5rem;
}}
.logo {{
width: 54px; height: 54px; flex-shrink: 0;
background: linear-gradient(135deg, #3b6cf7 0%, #6366f1 100%);
border-radius: 16px;
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem;
box-shadow: 0 8px 24px rgba(59,108,247,0.28);
animation: pulse 2.5s ease-in-out infinite;
}}
@keyframes pulse {{
0%,100% {{ box-shadow: 0 8px 24px rgba(59,108,247,0.28), 0 0 0 0 rgba(59,108,247,0.18); }}
50% {{ box-shadow: 0 10px 30px rgba(59,108,247,0.38), 0 0 0 10px rgba(59,108,247,0); }}
}}
.title {{
font-size: 1.75rem; font-weight: 800; letter-spacing: -0.03em;
background: linear-gradient(135deg, #1e3a8a 20%, #3b6cf7 60%, #6366f1 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text; line-height: 1.1;
}}
.subtitle {{
font-size: 0.65rem; color: #64748b; font-weight: 600;
letter-spacing: 0.01em; margin-top: 4px; line-height: 1.45;
max-width: 320px;
}}
/* Divider */
.divider {{
height: 1px;
background: linear-gradient(90deg, transparent, #e2e8f0 30%, #e2e8f0 70%, transparent);
margin: 1.25rem 0;
}}
/* Topik penelitian */
.topic {{
background: linear-gradient(135deg, #eff6ff, #eef2ff);
border: 1.5px solid #c7d2fe;
border-radius: 14px;
padding: 0.9rem 1.1rem;
margin-bottom: 1.25rem;
}}
.topic-label {{
font-size: 0.6rem; font-weight: 800; color: #4f46e5;
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.3rem;
}}
.topic-value {{
font-size: 0.88rem; font-weight: 700; color: #1e3a8a; line-height: 1.5;
}}
.topic-sub {{
font-size: 0.68rem; color: #6366f1; font-weight: 600; margin-top: 0.25rem;
}}
/* Info grid 2×2 */
.info-grid {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
margin-bottom: 1.25rem;
}}
.info-item {{
background: #f8fafc;
border: 1px solid #e8edf5;
border-radius: 12px;
padding: 0.65rem 0.8rem;
display: flex; align-items: flex-start; gap: 0.6rem;
transition: border-color 0.2s;
}}
.info-item:hover {{ border-color: #c7d2fe; }}
.info-icon {{ font-size: 1rem; flex-shrink: 0; margin-top: 1px; }}
.info-text-label {{
font-size: 0.58rem; font-weight: 700; color: #94a3b8;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 2px;
}}
.info-text-value {{
font-size: 0.76rem; font-weight: 700; color: #0f172a; line-height: 1.35;
}}
/* Identitas mahasiswa */
.identity {{
display: flex; align-items: center; justify-content: space-between;
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: 12px;
padding: 0.65rem 0.9rem;
margin-bottom: 1.25rem;
}}
.identity-left {{
font-size: 0.72rem; color: #15803d; font-weight: 600; line-height: 1.6;
}}
.identity-right {{
font-size: 0.66rem; color: #16a34a; font-weight: 700;
background: white; border: 1px solid #86efac; border-radius: 8px;
padding: 0.2rem 0.6rem; white-space: nowrap;
}}
/* Progress bar */
.progress-wrap {{
height: 4px; background: #e2e8f0; border-radius: 99px; overflow: hidden;
margin-bottom: 0.6rem;
}}
.progress-bar {{
height: 100%;
background: linear-gradient(90deg, #3b6cf7, #6366f1, #818cf8);
border-radius: 99px;
animation: progress {duration}s linear forwards;
width: 0%;
}}
@keyframes progress {{ from {{ width: 0%; }} to {{ width: 100%; }} }}
.progress-footer {{
display: flex; justify-content: space-between; align-items: center;
}}
.progress-label {{
font-size: 0.68rem; color: #94a3b8; font-weight: 500; letter-spacing: 0.03em;
}}
.progress-source {{
font-size: 0.63rem; color: #cbd5e1; font-weight: 500;
}}
/* Shimmer dots loading indicator */
.dots-loader {{
display: flex; align-items: center; gap: 4px; margin-top: 0.2rem;
}}
.dots-loader span {{
width: 5px; height: 5px; border-radius: 50%; background: #c7d2fe;
animation: bounce 1.4s ease-in-out infinite;
}}
.dots-loader span:nth-child(2) {{ animation-delay: 0.18s; }}
.dots-loader span:nth-child(3) {{ animation-delay: 0.36s; }}
@keyframes bounce {{
0%, 80%, 100% {{ transform: scale(0.75); opacity: 0.5; }}
40% {{ transform: scale(1.1); opacity: 1; }}
}}
</style>
</head>
<body>
<div class="bg"></div>
<div class="dots"></div>
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="card">
<!-- Header -->
<div class="header">
<div class="logo">📊</div>
<div>
<div class="title">{_APP_NAME}</div>
<div class="subtitle">Dashboard Monitoring Sentimen Netizen terhadap Kebijakan Pembatasan Gratis Ongkir</div>
</div>
</div>
<div class="divider"></div>
<!-- Topik penelitian -->
<div class="topic">
<div class="topic-label">📌 Topik Penelitian</div>
<div class="topic-value">Perancangan dan Implementasi Dashboard Sentimen Netizen X terhadap Kebijakan Pembatasan Gratis Ongkir dengan Naive Bayes</div>
<div class="topic-sub">Komdigi · Analisis Opini Publik di Platform X (Twitter)</div>
</div>
<!-- Info grid -->
<div class="info-grid">
<div class="info-item">
<div class="info-icon">🤖</div>
<div>
<div class="info-text-label">Model</div>
<div class="info-text-value">Naive Bayes</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">📡</div>
<div>
<div class="info-text-label">Sumber Data</div>
<div class="info-text-value">X Data Historis via Tweet Harvest &amp; Realtime</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">🔤</div>
<div>
<div class="info-text-label">Preprocessing</div>
<div class="info-text-value">Case Folding · Cleaning · Normalisasi · Stopword · Stemming</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">📊</div>
<div>
<div class="info-text-label">Output</div>
<div class="info-text-value">Dashboard Analisis Sentimen</div>
</div>
</div>
</div>
<!-- Identitas mahasiswa -->
<div class="identity">
<div class="identity-left">
<strong style="color:#0f172a;">Nurizzati Adistya Putri</strong> · E31232465<br/>
Politeknik Negeri Jember · Manajemen Informatika
</div>
<div class="identity-right">Tugas Akhir 2026</div>
</div>
<!-- Progress loading -->
<div class="progress-wrap">
<div class="progress-bar"></div>
</div>
<div class="progress-footer">
<div>
<div class="progress-label">Memuat dashboard</div>
<div class="dots-loader">
<span></span><span></span><span></span>
</div>
</div>
<div class="progress-source">Data: X (Twitter)</div>
</div>
</div>
</body>
</html>"""
def maybe_show_splash() -> bool:
"""
Tampilkan splash HANYA jika belum pernah tampil di browser ini.
Mekanisme:
- Saat splash ditampilkan untuk pertama kali, kita menambahkan
query parameter ?splashed=1 ke URL via st.query_params.
- Karena ini tersimpan di URL (bukan di session_state server),
query parameter ini TETAP ADA saat halaman di-refresh (F5).
- Jadi: refresh biasa -> splash TIDAK muncul lagi.
buka tab/browser baru tanpa parameter itu -> splash muncul.
klik tombol "reset splash" (lihat reset_splash()) -> splash
akan muncul lagi di reload berikutnya.
Return:
True -> splash baru saja ditampilkan (halaman akan rerun setelahnya)
False -> splash dilewati (sudah pernah tampil sebelumnya)
"""
# Cek apakah flag sudah ada di URL
already_splashed = st.query_params.get(_SPLASH_FLAG_KEY) == "1"
if already_splashed:
return False
# Sembunyikan semua elemen Streamlit selama splash
st.markdown("""
<style>
#MainMenu, footer, header,
[data-testid="stHeader"],
[data-testid="stSidebar"],
[data-testid="stToolbar"],
[data-testid="collapsedControl"],
[data-testid="stStatusWidget"],
[data-testid="stDecoration"],
.stDeployButton {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
}
.main > div { padding: 0 !important; margin: 0 !important; }
[data-testid="stMain"],
[data-testid="stAppViewContainer"],
.stApp, html, body {
background: #f8faff !important;
overflow: hidden !important;
}
iframe { border: none !important; display: block !important; }
</style>
""", unsafe_allow_html=True)
# ┌───────────────────────────────────────────────────────┐
# │ 📐 TINGGI IFRAME (px) │
# │ Naikkan jika card terpotong di layar kecil. │
# │ Rekomendasi: 650750 │
components.html(_build_splash_html(_SPLASH_DURATION), height=700, scrolling=False)
# └───────────────────────────────────────────────────────┘
# Tunggu sesuai durasi splash sebelum lanjut ke halaman utama
time.sleep(_SPLASH_DURATION + 0.3)
# Tandai di URL bahwa splash sudah pernah tampil.
# Penanda ini bertahan walau halaman di-refresh (F5).
st.query_params[_SPLASH_FLAG_KEY] = "1"
st.rerun()
return True
def reset_splash():
"""
Opsional: panggil fungsi ini (misalnya dari tombol di sidebar)
jika ingin memaksa splash muncul lagi di reload berikutnya.
Contoh penggunaan:
if st.sidebar.button("Tampilkan ulang splash"):
reset_splash()
st.rerun()
"""
if _SPLASH_FLAG_KEY in st.query_params:
del st.query_params[_SPLASH_FLAG_KEY]

Binary file not shown.

BIN
tfidf_vectorizer_old.pkl Normal file

Binary file not shown.

BIN
tfidf_vectorizer_old2.pkl Normal file

Binary file not shown.

BIN
tfidf_vectorizer_old3.pkl Normal file

Binary file not shown.

BIN
tfidf_vectorizer_old4.pkl Normal file

Binary file not shown.

BIN
tfidf_vectorizer_old5.pkl Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

View File

@ -1,2 +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"
"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
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

View File

@ -1,2 +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"
"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
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

View File

@ -0,0 +1,5 @@
"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"
"2070029328407286237","Thu Jun 25 06:20:25 +0000 2026","0","Kebijakan Komdigi mengenai pembatasan gratis ongkir ini ribet merepotkan dan membuat biaya belanja menjadi mahal.","2070029328407286237","","","in","","0","0","0","https://x.com/undefined/status/2070029328407286237","1814143201718247424",
"2070029891974869391","Thu Jun 25 06:22:40 +0000 2026","0","Mending komdigi ngurus judol daripada sibuk membatasi gratis ongkir marketplace.","2070029891974869391","","","in","","0","0","0","https://x.com/undefined/status/2070029891974869391","1814143201718247424",
"2069684663447368156","Wed Jun 24 07:30:51 +0000 2026","0","aturan pembatasan gratis ongkir komdigi tidak bermanfaat bikin marah padahal jualan lagi sukses","2069684663447368156","","","in","","0","0","0","https://x.com/undefined/status/2069684663447368156","1814143201718247424",
"2069684828677767277","Wed Jun 24 07:31:30 +0000 2026","0","awalnya kesal dengar isu pembatasan gratis ongkir komdigi ternyata tujuannya baik melindungi kurir sangat setuju","2069684828677767277","","","in","","0","0","0","https://x.com/undefined/status/2069684828677767277","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 2070029328407286237 Thu Jun 25 06:20:25 +0000 2026 0 Kebijakan Komdigi mengenai pembatasan gratis ongkir ini ribet merepotkan dan membuat biaya belanja menjadi mahal. 2070029328407286237 in 0 0 0 https://x.com/undefined/status/2070029328407286237 1814143201718247424
3 2070029891974869391 Thu Jun 25 06:22:40 +0000 2026 0 Mending komdigi ngurus judol daripada sibuk membatasi gratis ongkir marketplace. 2070029891974869391 in 0 0 0 https://x.com/undefined/status/2070029891974869391 1814143201718247424
4 2069684663447368156 Wed Jun 24 07:30:51 +0000 2026 0 aturan pembatasan gratis ongkir komdigi tidak bermanfaat bikin marah padahal jualan lagi sukses 2069684663447368156 in 0 0 0 https://x.com/undefined/status/2069684663447368156 1814143201718247424
5 2069684828677767277 Wed Jun 24 07:31:30 +0000 2026 0 awalnya kesal dengar isu pembatasan gratis ongkir komdigi ternyata tujuannya baik melindungi kurir sangat setuju 2069684828677767277 in 0 0 0 https://x.com/undefined/status/2069684828677767277 1814143201718247424

View File

@ -1,14 +1,5 @@
"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",
"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",
"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",
"2070029328407286237","Thu Jun 25 06:20:25 +0000 2026","0","Kebijakan Komdigi mengenai pembatasan gratis ongkir ini ribet merepotkan dan membuat biaya belanja menjadi mahal.","2070029328407286237","","","in","","0","0","0","https://x.com/undefined/status/2070029328407286237","1814143201718247424",
"2070029891974869391","Thu Jun 25 06:22:40 +0000 2026","0","Mending komdigi ngurus judol daripada sibuk membatasi gratis ongkir marketplace.","2070029891974869391","","","in","","0","0","0","https://x.com/undefined/status/2070029891974869391","1814143201718247424",
"2069684663447368156","Wed Jun 24 07:30:51 +0000 2026","0","aturan pembatasan gratis ongkir komdigi tidak bermanfaat bikin marah padahal jualan lagi sukses","2069684663447368156","","","in","","0","0","0","https://x.com/undefined/status/2069684663447368156","1814143201718247424",
"2069684828677767277","Wed Jun 24 07:31:30 +0000 2026","0","awalnya kesal dengar isu pembatasan gratis ongkir komdigi ternyata tujuannya baik melindungi kurir sangat setuju","2069684828677767277","","","in","","0","0","0","https://x.com/undefined/status/2069684828677767277","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 2070029328407286237 Fri May 22 11:50:47 +0000 2026 Thu Jun 25 06:20:25 +0000 2026 0 komdigi mengeluarkan kebijakan gratis ongkir @komdigi ??? Kebijakan Komdigi mengenai pembatasan gratis ongkir ini ribet merepotkan dan membuat biaya belanja menjadi mahal. 2057791279896080430 2070029328407286237 in 0 0 0 https://x.com/undefined/status/2057791279896080430 https://x.com/undefined/status/2070029328407286237 1814143201718247424
3 2057660616815571144 2070029891974869391 Fri May 22 03:11:35 +0000 2026 Thu Jun 25 06:22:40 +0000 2026 0 komdigi pembatasan gratis ongkir Mending komdigi ngurus judol daripada sibuk membatasi gratis ongkir marketplace. 2057660616815571144 2070029891974869391 in 0 0 0 https://x.com/undefined/status/2057660616815571144 https://x.com/undefined/status/2070029891974869391 1814143201718247424
4 2056538953340314095 2069684663447368156 Wed May 20 16:01:04 +0000 2026 Wed Jun 24 07:30:51 +0000 2026 4 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 aturan pembatasan gratis ongkir komdigi tidak bermanfaat bikin marah padahal jualan lagi sukses 2057129489994510620 2069684663447368156 https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg killingmaster27 in 0 2 0 0 https://x.com/undefined/status/2057129489994510620 https://x.com/undefined/status/2069684663447368156 1410466956134608899 1814143201718247424
5 2056635322419777630 2069684828677767277 Tue May 19 07:17:25 +0000 2026 Wed Jun 24 07:31:30 +0000 2026 0 komdigi ongkir gk jelas awalnya kesal dengar isu pembatasan gratis ongkir komdigi ternyata tujuannya baik melindungi kurir sangat setuju 2056635322419777630 2069684828677767277 in 0 0 0 https://x.com/undefined/status/2056635322419777630 https://x.com/undefined/status/2069684828677767277 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
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