Compare commits
10 Commits
8830accff5
...
d685dc41a4
| Author | SHA1 | Date |
|---|---|---|
|
|
d685dc41a4 | |
|
|
7588f26c1e | |
|
|
29a2ae2515 | |
|
|
9be480d529 | |
|
|
dc707605b3 | |
|
|
68319b9d23 | |
|
|
dea2bc8a2b | |
|
|
2e1fd22452 | |
|
|
2170dca02a | |
|
|
f58d1f5c8f |
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -756,3 +756,414 @@ yaitu
|
|||
yakin
|
||||
yakni
|
||||
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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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;">🔍 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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
1080
sentiment_service.py
|
|
@ -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: 4–8 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: 4–8 │
|
||||
_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 & 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: 650–750 │
|
||||
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]
|
||||
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
|
@ -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,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",
|
||||
|
||||
|
|
|
|||
|
|
|
@ -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,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",
|
||||
|
|
|
|||
|