import streamlit as st
import pandas as pd
import os
import re
import string
from collections import Counter
import plotly.graph_objects as go
from datetime import datetime, timedelta
from database import engine, get_tweet_count, get_latest_crawl_time
from page_modules.table_utils import render_standard_table
from timezone_utils import (
parse_dt_with_tz,
parse_dt_with_source_tz,
get_timezone_label,
get_timezone_name,
)
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,
st.session_state.get("user_timezone", "WIB (UTC+7)"),
os.getenv("APP_TIMEZONE", "Asia/Makassar")
)
def format_dt(value):
if value is None or pd.isna(value):
return "Belum ada"
try:
tz_label = get_timezone_label(
st.session_state.get("user_timezone", "WIB (UTC+7)")
)
return f"{value.strftime('%d/%m/%Y %H:%M')} {tz_label}"
except Exception:
return "Belum ada"
def user_today():
timezone_choice = st.session_state.get("user_timezone", "WIB (UTC+7)")
return pd.Timestamp.now(tz=get_timezone_name(timezone_choice)).date()
def _sync_dynamic_period():
mode = st.session_state.get("analysis_mode")
today = user_today()
configs = {
"realtime": (
today - timedelta(days=6),
today,
"Tweet Terkini โ 7 Hari Terakhir",
),
"30days": (
today - timedelta(days=29),
today,
"30 Hari Terakhir",
),
"captured": (
today,
today,
"Tweet Hari Ini",
),
}
if mode not in configs:
return
start_day, end_day, mode_display = configs[mode]
dt_start = datetime.combine(start_day, datetime.min.time())
dt_end = datetime.combine(
end_day,
datetime.max.time().replace(microsecond=0)
)
st.session_state.filter_start_date = dt_start
st.session_state.filter_end_date = dt_end
st.session_state.filter_label = (
f"{dt_start.strftime('%d/%m/%Y')} s/d {dt_end.strftime('%d/%m/%Y')}"
)
st.session_state.mode_display = mode_display
st.session_state.filter_date_column = "created_at"
def _load_stopwords():
stopword_file = "indonesian-stopwords-complete.txt"
base = set()
try:
with open(stopword_file, "r", encoding="utf-8") as f:
base = set(f.read().splitlines())
for kata in ["tidak", "bukan", "jangan", "kurang", "lebih"]:
base.discard(kata)
except FileNotFoundError:
base = {
"yang", "dan", "di", "ke", "dari", "ini", "itu",
"dengan", "untuk", "pada", "adalah", "oleh", "ada",
"ya", "akan", "atau", "juga", "sama", "karena",
"jika", "sudah", "telah"
}
base.update({
"rt", "amp", "https", "http", "co", "t",
"wkwk", "wkwkwk", "haha", "hehe",
"yg", "dgn", "utk", "dr", "krn", "tp", "jd", "sdh",
"aja", "doang", "banget", "bgt", "nih", "sih", "dong", "deh",
})
return base
def _load_stemmer():
try:
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
return StemmerFactory().create_stemmer()
except Exception:
return None
NORMALISASI = {
"gk": "tidak",
"ga": "tidak",
"gak": "tidak",
"nggak": "tidak",
"ngga": "tidak",
"tdk": "tidak",
"tak": "tidak",
"yg": "yang",
"dgn": "dengan",
"utk": "untuk",
"org": "orang",
"krn": "karena",
"dr": "dari",
"tp": "tapi",
"tpi": "tapi",
"sm": "sama",
"jd": "jadi",
"sdh": "sudah",
"blm": "belum",
"emg": "memang",
"emang": "memang",
"gimana": "bagaimana",
"gitu": "begitu",
"gini": "begini",
"bgt": "banget",
"ongkir": "ongkos kirim",
"freeongkir": "gratis ongkir",
"free": "gratis",
"ecommerce": "e commerce",
"komdigi": "komdigi",
}
def step1_cleaning(text):
text = str(text).lower()
text = re.sub(r"http\S+|www\S+|https\S+", "", text)
text = re.sub(r"@\w+", "", text)
text = re.sub(r"#", "", text)
text = re.sub(r"\d+", "", text)
text = text.translate(
str.maketrans("", "", string.punctuation)
)
text = re.sub(r"[^a-zA-Z\s]", "", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def step2_normalization(text):
return " ".join(
NORMALISASI.get(word, word)
for word in text.split()
)
def step3_stopword_removal(text, stopwords):
return " ".join(
word
for word in text.split()
if word not in stopwords and len(word) > 2
)
def step4_stemming(text, stemmer):
if stemmer is None:
return text
return stemmer.stem(text)
def full_preprocessing(text, stopwords, stemmer):
s1 = step1_cleaning(text)
s2 = step2_normalization(s1)
s3 = step3_stopword_removal(s2, stopwords)
s4 = step4_stemming(s3, stemmer)
return {
"setelah_cleaning": s1,
"setelah_normalisasi": s2,
"setelah_stopword": s3,
"clean_text": s4,
}
def _section_header(title, subtitle=""):
"""Render consistent section header card"""
sub_html = (
f'
{subtitle}
'
if subtitle else ""
)
st.markdown(f"""
""", unsafe_allow_html=True)
def _render_preprocessing_styles():
st.markdown("""
""", unsafe_allow_html=True)
def _section_gap(size="md"):
st.markdown(
f'',
unsafe_allow_html=True
)
def _pill(col, icon, bg, color, dark, label, val, sub):
"""Render metric pill card with consistent styling"""
with col:
fs = "1.25rem" if len(str(val)) <= 8 else "1rem"
st.markdown(f"""
{icon}
{label}
{val}
{sub}
""", unsafe_allow_html=True)
def show():
st.markdown("""
""", unsafe_allow_html=True)
_render_preprocessing_styles()
st.markdown("""
๐ Apa yang dilakukan halaman ini?
Tweet dibersihkan melalui 4 tahap preprocessing:
โ Cleaning โ hapus URL, mention, hashtag, angka, tanda baca
โ
โก Normalisasi โ ubah kata tidak baku
โ
โข Stopword Removal โ hapus kata tidak bermakna
โ
โฃ Stemming โ ubah kata ke bentuk dasar.
""", unsafe_allow_html=True)
if "analysis_mode" not in st.session_state:
st.warning("โ ๏ธ Silakan pilih mode tampilan di halaman Ambil Data Twitter terlebih dahulu.")
return
_sync_dynamic_period()
start_date = st.session_state.get("filter_start_date")
end_date = st.session_state.get("filter_end_date")
filter_label = st.session_state.get("filter_label", "-")
mode_display = st.session_state.get("mode_display", "-")
if start_date is None or end_date is None:
st.warning("โ ๏ธ Silakan buka halaman Ambil Data Twitter terlebih dahulu untuk memilih periode.")
return
mode_meta = {
"realtime": ("#16a34a", "๐ก"),
"30days": ("#3b6cf7", "๐
"),
"captured": ("#0284c7", "๐"),
"custom": ("#d97706", "๐"),
}
mode_color, mode_icon = mode_meta.get(
st.session_state.analysis_mode,
("#3b6cf7", "๐")
)
st.markdown(f"""
{mode_icon}
{mode_display}
Periode: {filter_label}
""", unsafe_allow_html=True)
try:
df_all = pd.read_sql(
"SELECT * FROM tweets ORDER BY created_at DESC",
engine
)
if df_all.empty:
st.warning("โ ๏ธ Belum ada data. Kembali ke halaman Ambil Data Twitter.")
return
df_all["created_at"] = parse_dt(df_all["created_at"])
if "crawled_at" in df_all.columns:
df_all["crawled_at"] = parse_crawled_dt(df_all["crawled_at"])
except Exception as e:
st.error(f"โ {e}")
return
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()
if df.empty:
st.warning(
f"โ ๏ธ Tidak ada tweet dengan tanggal asli dalam periode {filter_label}."
)
return
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)
cache_key = (
f"pp_{st.session_state.analysis_mode}_"
f"{start_date}_{end_date}_{total_tweets_in_db}_{latest_crawl_marker}"
)
# Clear old cache entries
for old_key in list(st.session_state.keys()):
if old_key.startswith("pp_") and old_key != cache_key:
del st.session_state[old_key]
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("๐งน Sedang memproses 4 tahap preprocessing..."):
results = []
for _, row in df.iterrows():
r = full_preprocessing(
row["text"],
stopwords,
stemmer
)
r["tweet_id"] = row.get("tweet_id", "")
r["text_asli"] = row["text"]
r["created_at"] = row["created_at"]
r["crawled_at"] = row.get("crawled_at")
results.append(r)
df_c = pd.DataFrame(results)
df_c = df_c[
df_c["clean_text"].str.strip().str.len() > 0
].copy()
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
df_c = st.session_state[cache_key]
stemmer_ok = st.session_state.get(
cache_key + "_stemmer_ok",
False
)
avg_b = df["text"].astype(str).str.len().mean()
avg_a = df_c["clean_text"].astype(str).str.len().mean()
red = ((avg_b - avg_a) / avg_b * 100) if avg_b > 0 else 0
rmv = len(df) - len(df_c)
_section_header(
"๐ Ringkasan Preprocessing",
f"Berdasarkan tanggal asli tweet ยท {filter_label}"
)
_section_gap("sm")
c1, c2, c3, c4 = st.columns(4, gap="medium")
_pill(
c1, "๐", "#eef2ff", "#3b6cf7", "#1e3a8a",
"Tweet Siap Dianalisis", f"{len(df_c):,}",
"Setelah 4 tahap preprocessing"
)
_pill(
c2, "๐", "#f0fdf4", "#16a34a", "#14532d",
"Panjang Sebelum", f"{avg_b:.0f} karakter",
"Rata-rata per tweet"
)
_pill(
c3, "โจ", "#fff7ed", "#ea580c", "#7c2d12",
"Panjang Sesudah", f"{avg_a:.0f} karakter",
"Rata-rata per tweet"
)
_pill(
c4, "๐๏ธ", "#fef2f2", "#ef4444", "#7f1d1d",
"Reduksi Teks", f"{red:.1f}%",
f"{rmv} tweet terlalu pendek dibuang"
)
_section_gap("lg")
steps = [
(
"#eef2ff", "#3b6cf7", "#1e3a8a",
"โ Cleaning",
"Ubah ke huruf kecil ยท Hapus URL, mention, hashtag, angka, tanda baca, karakter non-alfabet"
),
(
"#f0fdf4", "#16a34a", "#14532d",
"โก Normalisasi Kata",
"Ubah singkatan/kata gaul seperti gk โ tidak, ongkir โ ongkos kirim."
),
(
"#fff7ed", "#ea580c", "#7c2d12",
"โข Stopword Removal",
"Hapus kata tidak bermakna, tetapi pertahankan kata negasi seperti tidak dan bukan."
),
(
"#fefce8", "#ca8a04", "#713f12",
"โฃ Stemming",
f"Ubah kata ke bentuk dasar. {'โ
Aktif' if stemmer_ok else 'โ ๏ธ Nonaktif'}"
),
]
cols = st.columns(4, gap="medium")
for col, (bg, color, dark, title, desc) in zip(cols, steps):
with col:
st.markdown(f"""
""", unsafe_allow_html=True)
_section_gap("lg")
_section_header(
"๐ Perbandingan Teks per Tahap Preprocessing",
f"{len(df_c):,} tweet ยท {filter_label}"
)
if "crawled_at" not in df_c.columns:
df_c = df_c.copy()
df_c["crawled_at"] = pd.NaT
disp = df_c[
[
"tweet_id",
"created_at",
"crawled_at",
"text_asli",
"setelah_cleaning",
"setelah_normalisasi",
"setelah_stopword",
"clean_text",
]
].copy()
disp.columns = [
"ID Tweet",
"Tanggal Tweet",
"Masuk Database",
"Teks Asli",
"โ Setelah Cleaning",
"โก Setelah Normalisasi",
"โข Setelah Stopword",
"โฃ Hasil Akhir",
]
disp["Tanggal Tweet"] = disp["Tanggal Tweet"].apply(format_dt)
disp["Masuk Database"] = disp["Masuk Database"].apply(format_dt)
render_standard_table(
disp,
height=350,
min_width=1580,
nowrap=["ID Tweet", "Tanggal Tweet", "Masuk Database"],
wide_columns=[
"Teks Asli",
"โ Setelah Cleaning",
"โก Setelah Normalisasi",
"โข Setelah Stopword",
"โฃ Hasil Akhir",
],
column_widths={
"ID Tweet": "160px",
"Tanggal Tweet": "170px",
"Masuk Database": "170px",
"Teks Asli": "300px",
"โ Setelah Cleaning": "260px",
"โก Setelah Normalisasi": "260px",
"โข Setelah Stopword": "260px",
"โฃ Hasil Akhir": "260px",
},
)
_section_gap("lg")
with st.container(border=True, key="preprocessing_download_panel"):
c1, c2 = st.columns(2, gap="medium", vertical_alignment="bottom")
with c1:
st.download_button(
"๐ฅ Unduh Hasil Preprocessing Lengkap",
df_c.to_csv(index=False).encode("utf-8"),
f"clean_tweet_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
"text/csv",
width="stretch"
)
with c2:
out2 = df_c[
[
"tweet_id",
"text_asli",
"clean_text"
]
].copy()
out2.columns = [
"tweet_id",
"tweet",
"clean_text"
]
st.download_button(
"๐ฅ Unduh Teks Bersih Saja",
out2.to_csv(index=False).encode("utf-8"),
f"teks_bersih_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
"text/csv",
width="stretch"
)
_section_gap("lg")
_section_header(
"๐ Kata-Kata yang Paling Sering Muncul",
f"Dari {len(df_c):,} tweet yang sudah bersih"
)
all_words = " ".join(
df_c["clean_text"].fillna("")
).split()
stop_extra = {
"ongkos",
"kirim",
"gratis",
"komdigi"
}
filtered_words = [
w for w in all_words
if len(w) > 2 and w not in stop_extra
]
word_freq = Counter(
filtered_words
).most_common(20)
if not word_freq:
st.info("โ ๏ธ Kata tidak cukup untuk ditampilkan.")
else:
words = [w[0] for w in word_freq]
counts = [w[1] for w in word_freq]
fig = go.Figure(data=[
go.Bar(
y=words[::-1],
x=counts[::-1],
orientation="h",
marker=dict(color="#3b6cf7"),
text=[str(c) for c in counts[::-1]],
textposition="outside",
hovertemplate="%{y}
Muncul %{x} kali",
)
])
fig.update_layout(
height=500,
margin=dict(l=0, r=60, t=8, b=8),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
xaxis=dict(showgrid=True, gridcolor="rgba(203,213,225,0.8)"),
yaxis=dict(showgrid=False),
)
with st.container(border=True, key="preprocessing_chart_panel"):
st.plotly_chart(
fig,
width="stretch",
config={"displayModeBar": False}
)
_section_gap("lg")
st.session_state["preprocessed_df"] = df_c
if st.button(
"๐ Lanjut ke Analisis Sentimen โ",
type="primary",
width="stretch"
):
st.session_state.current_page = "sentiment"
st.rerun()