""" preprocessing_page.py ===================== Halaman Bersihkan Data — NLP Pipeline 5 Tahap. 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 (DARI FILE normalisasi) 4. Stopword Removal — hapus kata umum (DARI FILE stopword); JAGA kata sentimen 5. Stemming — bentuk dasar kata via Sastrawi ECS 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 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, ) # ═══════════════════════════════════════════════════════════ # 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, st.session_state.get("user_timezone", "WIB (UTC+7)"), os.getenv("APP_TIMEZONE", "Asia/Jakarta") ) 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" # ═══════════════════════════════════════════════════════════ # PREPROCESSING PIPELINE — 5 TAHAP # # ┌─────────────────────────────────────────────────────┐ # │ 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 (pembalik makna kalimat) ────────────────── "tidak", "bukan", "jangan", "kurang", "belum", "tanpa", # ── 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", "puas", "sejahtera", "berkembang", "maju", "inovatif", "tegas", "sigap", "tanggap", "adil", "bijak", "bermanfaat", "untung", "berhasil", "sukses", "solusi", "manfaat", "berguna", "membantu", "bantu", "pro", "lanjut", # ── 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", # ── Negatif domain e-commerce / ongkir ──────────────── "mahal", "lambat", "lelet", "ribet", "susah", "repot", "rugi", "boros", # ── 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 "nyusahin", # "kebijakan ini nyusahin" = ribet/susah-susahin" "malas", # "malas banget urus kebijakan ini" = negatif # TAMBAHAN — kata emosi negatif 'malas', 'males', 'enggan', 'bete', 'jengkel', 'depresi', 'gondok', 'dongkol', 'sebal', 'bosan', 'jenuh', 'heran', 'bingung', 'pusing', 'stress', 'panik', 'kapok', 'muak', 'frustrasi', 'menyesal', 'nyesel', 'mahal', } # ─────────────────────────────────────────────────────────── # 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 "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 "mahal", # "mahal banget kebijakan ini" = negatif "nyusahin", # penanda ribet/susah-susahin } # ─────────────────────────────────────────────────────────── # 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", # 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 = set() try: with open(stopword_file, "r", encoding="utf-8") as f: 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", "jadi", "bisa", } # ── Langkah 1: Lindungi kata sentimen ──────────────── for kata in KATA_SENTIMEN_PENTING: base.discard(kata) # ── 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() except Exception: return None # ═══════════════════════════════════════════════════════════ # FUNGSI 5 TAHAP PREPROCESSING # ═══════════════════════════════════════════════════════════ 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() 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"[" r"\U00010000-\U0010ffff" r"\U0001F600-\U0001F64F" r"\U0001F300-\U0001F5FF" r"\U0001F680-\U0001F6FF" r"\U0001F1E0-\U0001F1FF" 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) text = re.sub(r"\s+", " ", text).strip() return text 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) def step4_stopword_removal(tokens: list, stopwords: set) -> list: """ 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 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(token) for token in tokens] # ─────────────────────────────────────────────────────────── # FUNGSI UTAMA — JALANKAN SEMUA 5 TAHAP # ─────────────────────────────────────────────────────────── def full_preprocessing( text: str, stopwords: set, stemmer, norm_dict: dict, ) -> dict: """ 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, 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, "setelah_cleaning": s2_clean, "setelah_normalisasi": s3_norm, "setelah_stopword": " ".join(s4_filtered), "clean_text": " ".join(s5_stemmed), "_tokens_clean": s5_stemmed, } # ═══════════════════════════════════════════════════════════ # UI HELPERS # ═══════════════════════════════════════════════════════════ def _section_header(title, subtitle=""): sub_html = ( f'
Preprocessing teks 5 tahap otomatis: Case Folding → Cleaning → Normalisasi → Stopword Removal → Stemming