revisi prepo dikit

This commit is contained in:
adistya13 2026-06-01 06:19:50 +07:00
parent 68319b9d23
commit dc707605b3
10 changed files with 484 additions and 316 deletions

View File

@ -1 +1 @@
{"activated": true, "updated_at": "2026-05-29T05:53:53.439233+00:00"} {"activated": true, "updated_at": "2026-05-31T01:21:56.067731+00:00"}

View File

@ -10,13 +10,17 @@ PIPELINE 5 TAHAP:
4. Stopword Removal hapus kata umum (DARI FILE stopword); JAGA kata sentimen 4. Stopword Removal hapus kata umum (DARI FILE stopword); JAGA kata sentimen
5. Stemming bentuk dasar kata via Sastrawi ECS 5. Stemming bentuk dasar kata via Sastrawi ECS
PERUBAHAN DARI VERSI SEBELUMNYA: PERBAIKAN DARI VERSI SEBELUMNYA (sync dengan sentiment_service.py):
- Normalisasi kini dimuat dari 'indonesian-normalisasi-slangword-complete.txt' - Domain override 'mending' 'lebih baik' DIHAPUS.
(1.700+ entri), menggantikan dict hardcoded yang hanya ~60 entri. Alasan: mengubah kata kritis/negatif menjadi sinyal positif di lexicon.
- Stopword kini murni dari 'indonesian-stopwords-complete.txt', ditambah "mending X daripada Y" = kritik; setelah diubah jadi "lebih baik X..."
noise Twitter yang spesifik tidak ada penghapusan manual acak. lexicon menangkap 'baik' sebagai POSITIF hasil sentimen salah.
- KATA_SENTIMEN_PENTING diperluas dengan kata domain e-commerce/ongkir. - Domain override 'mendingan' 'lebih baik' DIHAPUS (alasan sama).
- Semua fungsi preprocessing menerima parameter eksplisit (tidak pakai global). - '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: CATATAN PENTING:
Pipeline ini HARUS IDENTIK dengan sentiment_service.py agar token yang Pipeline ini HARUS IDENTIK dengan sentiment_service.py agar token yang
@ -119,18 +123,16 @@ def _sync_dynamic_period():
# Kata-kata ini WAJIB DIJAGA dan tidak boleh dihapus saat # Kata-kata ini WAJIB DIJAGA dan tidak boleh dihapus saat
# stopword removal, meskipun ada di file stopword. # stopword removal, meskipun ada di file stopword.
# #
# Kenapa perlu? Karena file stopword mengandung kata seperti # PERBAIKAN: Tambahkan 'mending' dan 'malah' agar tidak
# "tidak", "belum", "sangat" yang justru krusial untuk # hilang di stopword removal dan bisa dideteksi oleh
# menentukan sentimen positif/negatif suatu kalimat. # pola kontekstual di sentiment_service.py.
# ─────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────
KATA_SENTIMEN_PENTING = { KATA_SENTIMEN_PENTING = {
# ── Negasi (pembalik makna kalimat) ────────────────── # ── Negasi (pembalik makna kalimat) ──────────────────
# "tidak bagus" ≠ "bagus" → "tidak" wajib ada
"tidak", "bukan", "jangan", "kurang", "belum", "tanpa", "tidak", "bukan", "jangan", "kurang", "belum", "tanpa",
# ── Intensitas (penguat/pelemah sentimen) ───────────── # ── Intensitas (penguat/pelemah sentimen) ─────────────
# "sangat bagus" lebih positif dari "bagus" saja
"sangat", "banget", "sekali", "paling", "amat", "sangat", "banget", "sekali", "paling", "amat",
"luar", "biasa", # ← "luar biasa" = dua token, keduanya dijaga "luar", "biasa",
# ── Positif umum ────────────────────────────────────── # ── Positif umum ──────────────────────────────────────
"keren", "bagus", "mantap", "setuju", "dukung", "mendukung", "keren", "bagus", "mantap", "setuju", "dukung", "mendukung",
"andal", "handal", "gercep", "bangga", "senang", "suka", "andal", "handal", "gercep", "bangga", "senang", "suka",
@ -150,30 +152,52 @@ KATA_SENTIMEN_PENTING = {
"rugi", "boros", "rugi", "boros",
# ── Emosi ───────────────────────────────────────────── # ── Emosi ─────────────────────────────────────────────
"marah", "sedih", "khawatir", "kecewa", "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
}
# ───────────────────────────────────────────────────────────
# 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
} }
# ─────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────
# LOAD NORMALIZATION DARI FILE # LOAD NORMALIZATION DARI FILE
# File: indonesian-normalisasi-slangword-complete.txt # PERBAIKAN: Hapus override 'mending' → 'lebih baik'
# Format per baris: slang,kata_baku
# Contoh: gk,tidak | ongkir,ongkos kirim | free,gratis
# ─────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────
def _load_normalization() -> dict: def _load_normalization() -> dict:
""" """
Muat kamus normalisasi dari file eksternal. Muat kamus normalisasi dari file eksternal.
KENAPA DARI FILE? PERUBAHAN DARI VERSI SEBELUMNYA:
File berisi 1.700+ pasang slangbaku yang jauh lebih lengkap - 'mending' TIDAK lagi dioverride ke 'lebih baik'
dibanding dict hardcoded. Dengan ini, kata seperti: - 'mendingan' TIDAK lagi dioverride ke 'lebih baik'
gk/ga/gak/kagak/ngga semua jadi "tidak"
bgt/bngt/bget semua jadi "sangat"
ongkir/ongkr jadi "ongkos kirim"
...dan ribuan kasus lainnya tertangani otomatis.
Setelah file dimuat, override dengan entri khusus domain KENAPA?
(nama platform, singkatan kebijakan) yang mungkin belum ada 'mending' dalam tweet biasanya digunakan sebagai kritik:
di file generik. "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_file = "indonesian-normalisasi-slangword-complete.txt"
norm_dict: dict = {} norm_dict: dict = {}
@ -184,28 +208,20 @@ def _load_normalization() -> dict:
line = line.strip() line = line.strip()
if not line: if not line:
continue continue
# Split hanya pada koma pertama — nilai bisa mengandung koma
# Contoh: "on the way, sedang di jalan,dijalan" → split jadi 2 bagian
parts = line.split(",", 1) parts = line.split(",", 1)
if len(parts) != 2: if len(parts) != 2:
continue continue
# Bersihkan tanda kutip liar di awal/akhir (ada di beberapa baris file)
slang = parts[0].strip().strip("'\"").lower() slang = parts[0].strip().strip("'\"").lower()
normal = parts[1].strip().lower() normal = parts[1].strip().lower()
if slang and normal: if slang and normal:
norm_dict[slang] = normal norm_dict[slang] = normal
except FileNotFoundError: except FileNotFoundError:
# Jika file tidak ditemukan, lanjut dengan dict kosong.
# Entri domain di bawah tetap akan ditambahkan.
st.warning( st.warning(
"⚠️ File normalisasi tidak ditemukan: " "⚠️ File normalisasi tidak ditemukan: "
f"'{norm_file}'. Hanya entri domain yang aktif." f"'{norm_file}'. Hanya entri domain yang aktif."
) )
# ── Override khusus domain ─────────────────────────── # ── Override khusus domain ───────────────────────────
# Entri ini menimpa file generik karena domain spesifik
# membutuhkan perlakuan khusus (nama platform tidak diubah,
# singkatan kebijakan punya padanan resmi, dll.)
DOMAIN_OVERRIDES: dict = { DOMAIN_OVERRIDES: dict = {
# Nama platform — pertahankan apa adanya # Nama platform — pertahankan apa adanya
"shopee": "shopee", "shopee": "shopee",
@ -237,6 +253,28 @@ def _load_normalization() -> dict:
"seller": "penjual", "seller": "penjual",
"buyer": "pembeli", "buyer": "pembeli",
"online": "online", "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) norm_dict.update(DOMAIN_OVERRIDES)
@ -245,23 +283,22 @@ def _load_normalization() -> dict:
# ─────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────
# LOAD STOPWORDS DARI FILE # LOAD STOPWORDS DARI FILE
# File: indonesian-stopwords-complete.txt # PERBAIKAN: Lindungi kata pola penting dari stopword removal
# Format: satu kata per baris
# ─────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────
def _load_stopwords() -> set: def _load_stopwords() -> set:
""" """
Muat daftar stopword dari file eksternal. Muat daftar stopword dari file eksternal.
PROSES SETELAH MUAT FILE: PERUBAHAN DARI VERSI SEBELUMNYA:
1. Hapus KATA_SENTIMEN_PENTING dari daftar Selain melindungi KATA_SENTIMEN_PENTING, kini juga melindungi
Agar "tidak", "belum", "sangat", dll. tidak ikut dibuang KATA_POLA_PENTING agar pola kontekstual di sentiment_service.py
2. Tambahkan noise Twitter/sosmed yang memang harus dibuang bisa bekerja dengan benar:
"rt", "amp", sisa URL, suara tawa, partikel informal - 'mending' penanda pola komparatif negatif
- 'daripada' komponen "mending X daripada Y"
KENAPA DARI FILE? - 'ketimbang' variasi daripada
File berisi 700+ stopword Indonesia yang lebih lengkap dan - 'ngapain' penanda kritik tersirat
terstandar dibanding daftar manual. Kita tidak perlu menambah/ - 'percuma' penanda sia-sia/negatif
mengurangi secara manual kecuali untuk dua kategori di atas. - 'begini' "kebijakan begini" = kritik tersirat
""" """
stopword_file = "indonesian-stopwords-complete.txt" stopword_file = "indonesian-stopwords-complete.txt"
base: set = set() base: set = set()
@ -273,7 +310,6 @@ def _load_stopwords() -> set:
if word: if word:
base.add(word) base.add(word)
except FileNotFoundError: except FileNotFoundError:
# Fallback minimal — cukup untuk tetap jalan
st.warning( st.warning(
"⚠️ File stopword tidak ditemukan: " "⚠️ File stopword tidak ditemukan: "
f"'{stopword_file}'. Menggunakan daftar minimal." f"'{stopword_file}'. Menggunakan daftar minimal."
@ -286,24 +322,21 @@ def _load_stopwords() -> set:
} }
# ── Langkah 1: Lindungi kata sentimen ──────────────── # ── Langkah 1: Lindungi kata sentimen ────────────────
# Beberapa kata sentimen penting ADA di file stopword
# (misal: "tidak", "belum", "sangat", "paling", "kurang").
# Kita HAPUS dari stopword agar tidak ikut dibuang.
for kata in KATA_SENTIMEN_PENTING: for kata in KATA_SENTIMEN_PENTING:
base.discard(kata) base.discard(kata)
# ── Langkah 2: Tambah noise Twitter/sosmed ──────────── # ── Langkah 2: Lindungi kata pola kontekstual ─────────
# Ini bukan stopword bahasa Indonesia biasa, tapi noise # Kata-kata ini diperlukan agar pola analisis sentimen
# yang sangat sering muncul di tweet dan tidak bermakna. # bisa bekerja setelah stopword removal.
for kata in KATA_POLA_PENTING:
base.discard(kata)
# ── Langkah 3: Tambah noise Twitter/sosmed ────────────
TWITTER_NOISE: set = { TWITTER_NOISE: set = {
# Artefak Twitter
"rt", "amp", "rt", "amp",
# Sisa URL setelah cleaning (kadang lolos)
"https", "http", "co", "pic", "https", "http", "co", "pic",
# Suara tawa (tidak bermakna untuk sentimen)
"wkwk", "wkwkwk", "wkwkwkwk", "wkwk", "wkwkwk", "wkwkwkwk",
"haha", "hahaha", "hehe", "hihi", "huhu", "xixi", "haha", "hahaha", "hehe", "hihi", "huhu", "xixi",
# Partikel informal yang tidak bermakna
"nih", "sih", "dong", "deh", "loh", "lah", "tuh", "nih", "sih", "dong", "deh", "loh", "lah", "tuh",
"kak", "gan", "bro", "sob", "min", "kak", "gan", "bro", "sob", "min",
} }
@ -326,8 +359,6 @@ def _load_stemmer():
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# FUNGSI 5 TAHAP PREPROCESSING # FUNGSI 5 TAHAP PREPROCESSING
# Setiap fungsi bertanggung jawab SATU tahap saja.
# Input & output setiap tahap dijelaskan di docstring.
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
def step1_case_folding(text: str) -> str: def step1_case_folding(text: str) -> str:
@ -335,11 +366,6 @@ def step1_case_folding(text: str) -> str:
TAHAP 1 CASE FOLDING TAHAP 1 CASE FOLDING
Input : teks asli (campuran huruf besar/kecil) Input : teks asli (campuran huruf besar/kecil)
Output: semua huruf jadi lowercase Output: semua huruf jadi lowercase
Kenapa pertama?
Agar tahap berikutnya (cleaning, normalisasi) bekerja
secara konsisten regex dan dict lookup case-sensitive.
Contoh: "Gratis" "gratis", "ONGKIR" "ongkir"
""" """
return str(text).lower() return str(text).lower()
@ -349,43 +375,25 @@ def step2_cleaning(text: str) -> str:
TAHAP 2 CLEANING TAHAP 2 CLEANING
Input : teks lowercase Input : teks lowercase
Output: teks bersih dari semua elemen noise Output: teks bersih dari semua elemen noise
Urutan pembersihan PENTING:
1. URL dulu (sebelum @ dan # agar tidak salah potong)
2. Mention (@username)
3. Hashtag (#topik)
4. Angka
5. Emoji & simbol unicode
6. Tanda baca
7. Karakter non-latin (huruf Arab, Cina, dll.)
8. Spasi berlebih
""" """
# 1. Hapus URL (http, https, www)
text = re.sub(r"http\S+|www\S+|https\S+", "", text) text = re.sub(r"http\S+|www\S+|https\S+", "", text)
# 2. Hapus mention Twitter (@username)
text = re.sub(r"@\w+", "", text) text = re.sub(r"@\w+", "", text)
# 3. Hapus hashtag (#topik)
text = re.sub(r"#\w+", "", text) text = re.sub(r"#\w+", "", text)
# 4. Hapus angka dan digit
text = re.sub(r"\d+", "", text) text = re.sub(r"\d+", "", text)
# 5. Hapus emoji & simbol unicode (berbagai range)
text = re.sub( text = re.sub(
r"[" r"["
r"\U00010000-\U0010ffff" # Suplemen karakter unicode r"\U00010000-\U0010ffff"
r"\U0001F600-\U0001F64F" # Emotikon wajah r"\U0001F600-\U0001F64F"
r"\U0001F300-\U0001F5FF" # Simbol & piktogram r"\U0001F300-\U0001F5FF"
r"\U0001F680-\U0001F6FF" # Transport & peta r"\U0001F680-\U0001F6FF"
r"\U0001F1E0-\U0001F1FF" # Bendera negara r"\U0001F1E0-\U0001F1FF"
r"\u2600-\u26FF" # Simbol campuran r"\u2600-\u26FF"
r"\u2700-\u27BF" # Dingbats r"\u2700-\u27BF"
r"]+", r"]+",
"", text, flags=re.UNICODE "", text, flags=re.UNICODE
) )
# 6. Hapus tanda baca (.,!?;: dll.)
text = text.translate(str.maketrans("", "", string.punctuation)) text = text.translate(str.maketrans("", "", string.punctuation))
# 7. Hapus karakter non-latin (hanya sisakan huruf a-z dan spasi)
text = re.sub(r"[^a-zA-Z\s]", "", text) text = re.sub(r"[^a-zA-Z\s]", "", text)
# 8. Normalisasi spasi berlebih → satu spasi, lalu strip
text = re.sub(r"\s+", " ", text).strip() text = re.sub(r"\s+", " ", text).strip()
return text return text
@ -393,22 +401,11 @@ def step2_cleaning(text: str) -> str:
def step3_normalization(text: str, norm_dict: dict) -> str: def step3_normalization(text: str, norm_dict: dict) -> str:
""" """
TAHAP 3 NORMALISASI TAHAP 3 NORMALISASI
Input : teks bersih (sudah case fold + cleaning) Input : teks bersih + norm_dict dari file
norm_dict : kamus {slang: kata_baku} dari file
Output: teks dengan slang/singkatan sudah diganti kata baku Output: teks dengan slang/singkatan sudah diganti kata baku
Cara kerja: token per token (word by word). PERUBAHAN: 'mending' tidak lagi dinormalisasi ke 'lebih baik'.
Setiap token dicari di norm_dict. Lihat komentar di _load_normalization() untuk penjelasan.
Jika ada ganti. Jika tidak ada biarkan.
Contoh:
"gk bs ongkir" "tidak bisa ongkos kirim"
"mantep bgt" "mantap sangat"
Kenapa setelah Cleaning?
Karena slang di file ditulis dalam bentuk sudah lowercase
dan sudah tanpa tanda baca. Jika normalisasi dilakukan
sebelum cleaning, banyak entri tidak cocok.
""" """
tokens = text.split() tokens = text.split()
normalized = [norm_dict.get(token, token) for token in tokens] normalized = [norm_dict.get(token, token) for token in tokens]
@ -418,36 +415,24 @@ def step3_normalization(text: str, norm_dict: dict) -> str:
def step4_stopword_removal(tokens: list, stopwords: set) -> list: def step4_stopword_removal(tokens: list, stopwords: set) -> list:
""" """
TAHAP 4 STOPWORD REMOVAL TAHAP 4 STOPWORD REMOVAL
Input : list token (hasil split dari teks ternormalisasi) Input : list token + stopwords dari file
stopwords : set kata yang harus dibuang (dari file)
Output: list token bersih Output: list token bersih
ATURAN PENYARINGAN (prioritas urutan): PERUBAHAN: Kata pola penting (mending, daripada, dll.) dilindungi
1. JAGA token yang ada di KATA_SENTIMEN_PENTING dari pembuangan melalui KATA_POLA_PENTING di _load_stopwords().
meskipun juga ada di stopwords, tetap disimpan
2. BUANG token yang ada di stopwords
3. BUANG token dengan panjang 2 karakter
menghilangkan sisa noise seperti "rt", "yg", "di"
PENGECUALIAN: token di KATA_SENTIMEN_PENTING tetap disimpan
walau 2 karakter (contoh: "ok" jika masuk sentimen)
Kenapa setelah Normalisasi?
Agar "gak" yang sudah dinormalisasi jadi "tidak" tidak ikut
dibuang "tidak" dilindungi di KATA_SENTIMEN_PENTING.
""" """
result = [] result = []
for token in tokens: for token in tokens:
# Prioritas 1: selalu simpan jika kata sentimen penting
if token in KATA_SENTIMEN_PENTING: if token in KATA_SENTIMEN_PENTING:
result.append(token) result.append(token)
continue continue
# Prioritas 2: buang jika stopword if token in KATA_POLA_PENTING:
result.append(token)
continue
if token in stopwords: if token in stopwords:
continue continue
# Prioritas 3: buang jika terlalu pendek (noise)
if len(token) <= 2: if len(token) <= 2:
continue continue
# Lolos semua filter → simpan
result.append(token) result.append(token)
return result return result
@ -456,18 +441,8 @@ def step5_stemming(tokens: list, stemmer) -> list:
""" """
TAHAP 5 STEMMING TAHAP 5 STEMMING
Input : list token setelah stopword removal Input : list token setelah stopword removal
stemmer : objek Sastrawi (atau None)
Output: list token dalam bentuk kata dasar Output: list token dalam bentuk kata dasar
Algoritma: Enhanced Confix Stripping (ECS) via Sastrawi Algoritma: Enhanced Confix Stripping (ECS) via Sastrawi
Contoh:
"pengiriman" "kirim"
"pembatasan" "batas"
"berlari" "lari"
"makanan" "makan"
Jika stemmer None (Sastrawi tidak terinstal), token dikembalikan
apa adanya tanpa error.
""" """
if stemmer is None: if stemmer is None:
return tokens return tokens
@ -484,37 +459,14 @@ def full_preprocessing(
norm_dict: dict, norm_dict: dict,
) -> dict: ) -> dict:
""" """
Jalankan 5 tahap preprocessing secara berurutan dan kembalikan Jalankan 5 tahap preprocessing secara berurutan.
hasil setiap tahap sebagai dict (untuk ditampilkan di tabel). Return dict berisi hasil setiap tahap.
Parameter:
text : teks tweet asli
stopwords : set stopword (dari _load_stopwords)
stemmer : objek Sastrawi (dari _load_stemmer)
norm_dict : kamus normalisasi (dari _load_normalization)
Return dict berisi:
setelah_casefolding : hasil Tahap 1
setelah_cleaning : hasil Tahap 2
setelah_normalisasi : hasil Tahap 3
setelah_stopword : hasil Tahap 4 (joined string)
clean_text : hasil akhir Tahap 5 (joined string)
_tokens_clean : hasil Tahap 5 sebagai list (untuk analisis)
""" """
# Tahap 1 — Case Folding
s1_fold = step1_case_folding(text) s1_fold = step1_case_folding(text)
# Tahap 2 — Cleaning
s2_clean = step2_cleaning(s1_fold) s2_clean = step2_cleaning(s1_fold)
# Tahap 3 — Normalisasi (perlu norm_dict)
s3_norm = step3_normalization(s2_clean, norm_dict) s3_norm = step3_normalization(s2_clean, norm_dict)
# Tahap 4 — Stopword Removal (split → filter → simpan sebagai list)
s4_tokens = s3_norm.split() s4_tokens = s3_norm.split()
s4_filtered = step4_stopword_removal(s4_tokens, stopwords) s4_filtered = step4_stopword_removal(s4_tokens, stopwords)
# Tahap 5 — Stemming
s5_stemmed = step5_stemming(s4_filtered, stemmer) s5_stemmed = step5_stemming(s4_filtered, stemmer)
return { return {
@ -523,7 +475,7 @@ def full_preprocessing(
"setelah_normalisasi": s3_norm, "setelah_normalisasi": s3_norm,
"setelah_stopword": " ".join(s4_filtered), "setelah_stopword": " ".join(s4_filtered),
"clean_text": " ".join(s5_stemmed), "clean_text": " ".join(s5_stemmed),
"_tokens_clean": s5_stemmed, # list, untuk Counter frekuensi kata "_tokens_clean": s5_stemmed,
} }
@ -711,13 +663,13 @@ def _render_pipeline_steps(stemmer_ok: bool, norm_count: int, sw_count: int):
"icon": "🔄", "color": "#16a34a", "dark": "#14532d", "icon": "🔄", "color": "#16a34a", "dark": "#14532d",
"bg": "linear-gradient(135deg,#f0fdf4,#dcfce7)", "border": "#86efac", "bg": "linear-gradient(135deg,#f0fdf4,#dcfce7)", "border": "#86efac",
"title": f'Normalisasi <span class="fix-badge">✦ {norm_count:,} entri</span>', "title": f'Normalisasi <span class="fix-badge">✦ {norm_count:,} entri</span>',
"desc": "Mengubah kata tidak baku, singkatan, dan slang menjadi kata baku (dari file).", "desc": "Mengubah singkatan/slang ke kata baku. 'mending' TIDAK diubah ke 'lebih baik' (perbaikan konteks sentimen).",
"items": [ "items": [
"gk/ga/gak/kagak/ngga → tidak", "gk/ga/gak/kagak/ngga → tidak",
"bgt/bngt/bget → sangat", "bgt/bngt/bget → banget",
"ongkir → ongkos kirim", "ongkir → ongkos kirim",
"mantep → mantap", "mantep → mantap",
"free → gratis", "⚠️ mending → mending (dijaga, bukan 'lebih baik')",
f"Total: {norm_count:,} pasang slang→baku dimuat", f"Total: {norm_count:,} pasang slang→baku dimuat",
], ],
}, },
@ -726,13 +678,13 @@ def _render_pipeline_steps(stemmer_ok: bool, norm_count: int, sw_count: int):
"icon": "🚫", "color": "#ea580c", "dark": "#7c2d12", "icon": "🚫", "color": "#ea580c", "dark": "#7c2d12",
"bg": "linear-gradient(135deg,#fff7ed,#ffedd5)", "border": "#fed7aa", "bg": "linear-gradient(135deg,#fff7ed,#ffedd5)", "border": "#fed7aa",
"title": f'Stopword Removal <span class="fix-badge">✦ {sw_count:,} kata</span>', "title": f'Stopword Removal <span class="fix-badge">✦ {sw_count:,} kata</span>',
"desc": "Membuang kata umum dari file; kata sentimen DIJAGA.", "desc": "Membuang kata umum; kata sentimen & kata pola kontekstual DIJAGA.",
"items": [ "items": [
f"{sw_count:,} stopword dimuat dari file", f"{sw_count:,} stopword dimuat dari file",
"JAGA negasi: tidak, bukan, jangan, belum", "JAGA negasi: tidak, bukan, jangan, belum",
"JAGA positif: keren, bagus, mantap, gratis", "JAGA positif: keren, bagus, mantap, gratis",
"JAGA negatif: kecewa, buruk, gagal, mahal, mending, malah", "JAGA negatif: kecewa, buruk, gagal, mahal",
"JAGA intensitas: sangat, banget, sekali", "JAGA pola: mending, daripada, percuma, begini",
"Hapus token ≤ 2 karakter (noise)", "Hapus token ≤ 2 karakter (noise)",
], ],
}, },
@ -753,14 +705,12 @@ def _render_pipeline_steps(stemmer_ok: bool, norm_count: int, sw_count: int):
}, },
] ]
# Baris 1: 3 kartu pertama
row1 = st.columns(3, gap="medium") row1 = st.columns(3, gap="medium")
for col, step in zip(row1, steps[:3]): for col, step in zip(row1, steps[:3]):
_render_step_card(col, step) _render_step_card(col, step)
_gap("sm") _gap("sm")
# Baris 2: 2 kartu terakhir (tengah agar simetris)
_, col4, col5, _ = st.columns([0.5, 1, 1, 0.5], gap="medium") _, col4, col5, _ = st.columns([0.5, 1, 1, 0.5], gap="medium")
_render_step_card(col4, steps[3]) _render_step_card(col4, steps[3])
_render_step_card(col5, steps[4]) _render_step_card(col5, steps[4])
@ -964,7 +914,6 @@ def _render_top_words_chart(df_c):
else: else:
all_words = " ".join(df_c["clean_text"].fillna("")).split() all_words = " ".join(df_c["clean_text"].fillna("")).split()
# Filter minimum 3 karakter (konsisten dengan step4)
filtered_words = [w for w in all_words if len(w) > 2] filtered_words = [w for w in all_words if len(w) > 2]
word_freq = Counter(filtered_words).most_common(20) word_freq = Counter(filtered_words).most_common(20)
@ -1055,14 +1004,10 @@ def show():
unsafe_allow_html=True unsafe_allow_html=True
) )
# ── Muat resource preprocessing (sekali per session) ──────
# Semua tiga resource dimuat di sini, bukan di dalam loop,
# agar tidak memuat ulang setiap tweet.
stemmer = _load_stemmer() stemmer = _load_stemmer()
stopwords = _load_stopwords() stopwords = _load_stopwords()
norm_dict = _load_normalization() norm_dict = _load_normalization()
# ── Pipeline Overview ──────────────────────────────────────
_section_header( _section_header(
"🔬 Alur NLP Pipeline — 5 Tahap Preprocessing", "🔬 Alur NLP Pipeline — 5 Tahap Preprocessing",
"Setiap tweet diproses berurutan melalui 5 tahap sebelum siap dianalisis sentimennya" "Setiap tweet diproses berurutan melalui 5 tahap sebelum siap dianalisis sentimennya"
@ -1077,7 +1022,6 @@ def show():
_render_flow_arrow() _render_flow_arrow()
_gap("md") _gap("md")
# ── Load data dari database ────────────────────────────────
try: try:
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine) df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
if df_all.empty: if df_all.empty:
@ -1100,9 +1044,6 @@ def show():
st.warning(f"⚠️ Tidak ada tweet dengan tanggal asli dalam periode {filter_label}.") st.warning(f"⚠️ Tidak ada tweet dengan tanggal asli dalam periode {filter_label}.")
return return
# ── Cache preprocessing ────────────────────────────────────
# Cache key: kombinasi mode + periode + jumlah tweet di DB
# Jika ada tweet baru → cache otomatis invalid → proses ulang
total_tweets_in_db = get_tweet_count() total_tweets_in_db = get_tweet_count()
latest_crawl_marker = get_latest_crawl_time() or "no-crawl" latest_crawl_marker = get_latest_crawl_time() or "no-crawl"
data_marker = (total_tweets_in_db, latest_crawl_marker) data_marker = (total_tweets_in_db, latest_crawl_marker)
@ -1112,7 +1053,6 @@ def show():
f"{start_date}_{end_date}_{total_tweets_in_db}_{latest_crawl_marker}" f"{start_date}_{end_date}_{total_tweets_in_db}_{latest_crawl_marker}"
) )
# Bersihkan cache lama untuk mode/periode yang sudah tidak aktif
for old_key in list(st.session_state.keys()): for old_key in list(st.session_state.keys()):
if old_key.startswith("pp5_") and old_key != cache_key: if old_key.startswith("pp5_") and old_key != cache_key:
del st.session_state[old_key] del st.session_state[old_key]
@ -1123,7 +1063,6 @@ def show():
with st.spinner("🧹 Menjalankan 5 tahap preprocessing…"): with st.spinner("🧹 Menjalankan 5 tahap preprocessing…"):
results = [] results = []
for _, row in df.iterrows(): for _, row in df.iterrows():
# Jalankan 5 tahap — norm_dict diteruskan sebagai parameter
r = full_preprocessing( r = full_preprocessing(
text = row["text"], text = row["text"],
stopwords = stopwords, stopwords = stopwords,
@ -1137,9 +1076,7 @@ def show():
results.append(r) results.append(r)
df_c = pd.DataFrame(results) df_c = pd.DataFrame(results)
# Buang baris yang clean_text-nya kosong setelah semua 5 tahap
df_c = df_c[df_c["clean_text"].str.strip().str.len() > 0].copy() df_c = df_c[df_c["clean_text"].str.strip().str.len() > 0].copy()
# Reset index agar rapi
df_c = df_c.reset_index(drop=True) df_c = df_c.reset_index(drop=True)
st.session_state[cache_key] = df_c st.session_state[cache_key] = df_c
@ -1149,7 +1086,6 @@ def show():
df_c = st.session_state[cache_key] df_c = st.session_state[cache_key]
stemmer_ok = st.session_state.get(cache_key + "_sw_ok", False) stemmer_ok = st.session_state.get(cache_key + "_sw_ok", False)
# ── Statistik ──────────────────────────────────────────────
removed = len(df) - len(df_c) removed = len(df) - len(df_c)
_section_header( _section_header(
@ -1163,7 +1099,6 @@ def show():
_render_live_example(df_c) _render_live_example(df_c)
_gap("lg") _gap("lg")
# ── Tabel ──────────────────────────────────────────────────
_section_header( _section_header(
"📋 Tabel Perbandingan Teks per Tahap", "📋 Tabel Perbandingan Teks per Tahap",
f"{len(df_c):,} tweet · {filter_label} — scroll horizontal untuk lihat semua kolom" f"{len(df_c):,} tweet · {filter_label} — scroll horizontal untuk lihat semua kolom"
@ -1173,7 +1108,6 @@ def show():
df_c = df_c.copy() df_c = df_c.copy()
df_c["crawled_at"] = pd.NaT df_c["crawled_at"] = pd.NaT
# Kolom ditampilkan sesuai urutan pipeline
disp = df_c[[ disp = df_c[[
"tweet_id", "created_at", "crawled_at", "tweet_id", "created_at", "crawled_at",
"text_asli", "text_asli",
@ -1223,10 +1157,8 @@ def show():
_render_top_words_chart(df_c) _render_top_words_chart(df_c)
_gap("lg") _gap("lg")
# ── Simpan ke session state untuk halaman sentimen ─────────
st.session_state["preprocessed_df"] = df_c st.session_state["preprocessed_df"] = df_c
# ── Download ───────────────────────────────────────────────
st.markdown(""" st.markdown("""
<div style="background:#ffffff;border:1.5px solid #e2e8f0;border-radius:14px; <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); padding:1.1rem 1.2rem;box-shadow:0 2px 6px rgba(15,23,42,0.05);

View File

@ -1,15 +1,51 @@
""" """
sentiment_service.py Hybrid Classifier (versi perbaikan) sentiment_service.py Hybrid Classifier (versi perbaikan v2)
============================================================= ================================================================
Perbaikan utama vs versi sebelumnya: PERBAIKAN UTAMA vs versi sebelumnya:
1. LEXICON DIPERLUAS kata positif & negatif yang tidak ada di vocab TF-IDF
(bagus, mantap, setuju, puas, hemat, berhasil, dll.) kini tetap bisa MASALAH 1 NORMALISASI MERUSAK KONTEKS SENTIMEN
terdeteksi melalui lexicon scoring. 'mending' 'lebih baik' (salah: di domain override)
2. THRESHOLD DISESUAIKAN Layer 1 diperlonggar (net >= 2 Positif), Akibat: "mending ngurusin judol daripada ngurusin ongkir"
Layer anti-bias diperketat agar prediksi lebih proporsional. "lebih baik ngurusin judol daripada..."
3. NEGATION WINDOW DIPERLUAS window 3 kata (sebelumnya 2) agar lexicon tangkap 'baik' = POSITIF (SEHARUSNYA NEGATIF)
"tidak terlalu bagus" tetap terdeteksi negasinya. SOLUSI: Override 'mending' 'mending' (biarkan apa adanya);
4. PREPROCESSING IDENTIK dengan preprocessing_page.py (5 tahap). hapus 'mendingan' 'lebih baik' dari domain override.
MASALAH 2 LEXICON TERLALU LUAS (KATA KONTEKSTUAL)
Kata-kata berikut dihapus dari LEXICON_POSITIF karena terlalu
kontekstual maknanya bergantung penuh pada kalimat sekitar:
"baik" "lebih baik X daripada Y" bukan pujian
"benar" "itu benar" bisa netral
"penting" "lebih penting" bukan pujian
"wajar" "wajar aja" bisa netral
"perlu" "perlu X" bukan pujian
"tepat" bisa kontekstual
"jelas" bisa kontekstual
"fair" bisa kontekstual
"manfaat" bisa kontekstual
SOLUSI: Hapus dari LEXICON_POSITIF, pindahkan ke list terpisah
yang tidak ikut skor lexicon otomatis.
MASALAH 3 POLA KOMPARATIF TIDAK DIKENALI
"mending X daripada Y", "lebih baik X daripada Y" = kritik implisit
SOLUSI: Tambahkan deteksi POLA_KOMPARATIF_NEGATIF yang memberi
skor negatif ketika pola ini ditemukan di teks.
MASALAH 4 POLA KRITIK TERSIRAT TIDAK DIKENALI
"tidak penting", "gak usah", "ngapain", "begini aja" = kritik implisit
SOLUSI: Tambahkan POLA_KRITIK_TERSIRAT yang memberi skor negatif
ketika pola ini ditemukan.
MASALAH 5 "pembatasan gratis" DIHITUNG POSITIF
"pembatasan gratis ongkir" lexicon tangkap 'gratis' = POSITIF
padahal konteksnya adalah keluhan/informasi negatif.
SOLUSI: Jika 'gratis' didahului 'pembatasan' dalam window 3 kata,
tidak dihitung sebagai sinyal positif.
MASALAH 6 TWEET INFORMATIF DIKLASIFIKASI POSITIF/NEGATIF
"saya melihat berita mengenai pembatasan gratis ongkir" NETRAL
SOLUSI: Deteksi KATA_INFORMATIF; jika tweet hanya berisi konteks
informatif tanpa kata sentimen eksplisit dorong ke Netral.
""" """
import re import re
@ -49,60 +85,78 @@ KATA_SENTIMEN_PENTING = {
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# LEXICON SENTIMEN # LEXICON SENTIMEN (DIPERBAIKI — kata kontekstual dihapus)
# Diperluas agar kata yang tidak ada di vocab TF-IDF tetap #
# bisa berkontribusi melalui jalur lexicon scoring. # PRINSIP PEMILIHAN KATA LEXICON:
# Kata masuk lexicon HANYA jika bisa berdiri sendiri sebagai
# sinyal sentimen tanpa bergantung konteks kalimat di sekitarnya.
#
# DIHAPUS dari versi lama karena terlalu kontekstual:
# "baik", "benar", "penting", "tepat", "jelas", "transparan",
# "amanah", "wajar", "fair", "perlu", "manfaat", "kompetitif"
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
LEXICON_POSITIF = { LEXICON_POSITIF = {
# Umum & informal # Apresiasi murni
"bagus", "baik", "keren", "mantap", "mantep", "hebat", "bagus", "keren", "mantap", "mantep", "hebat",
"oke", "sip", "top", "jos", "goks", "kece", "mantul", "oke", "sip", "top", "jos", "goks", "kece", "mantul",
"terbaik", "cakep", "cucok", "gaskeun", "kuy",
# Dukungan / persetujuan
"setuju", "dukung", "mendukung", "pro", "lanjut", "sepakat", "setuju", "dukung", "mendukung", "pro", "lanjut", "sepakat",
# Emosi positif
"bangga", "senang", "suka", "puas", "gembira", "bahagia", "bangga", "senang", "suka", "puas", "gembira", "bahagia",
"berhasil", "sukses", "berjaya", "prestasi", "pencapaian", "syukur", "alhamdulillah",
# Keberhasilan
"berhasil", "sukses", "berjaya",
# Kualitas SDM/institusi
"andal", "handal", "gercep", "sigap", "tanggap", "tegas", "andal", "handal", "gercep", "sigap", "tanggap", "tegas",
"adil", "bijak", "bermanfaat", "berguna", "membantu", "adil", "bijak", "bermanfaat", "berguna", "membantu",
"inovatif", "maju", "berkembang", "sejahtera", "inovatif", "maju", "berkembang", "sejahtera", "terpercaya",
"benar", "tepat", "jelas", "transparan", "amanah", "terpercaya", # Ekonomi / ongkir positif
"untung", "gratis", "murah", "hemat", "terjangkau", "untung", "gratis", "murah", "hemat", "terjangkau",
"terbaik", "luar biasa", "efisien", "mudah", "praktis", "cepat", "aman", "andalan",
"cakep", "cucok", "gaskeun", "kuy", # Solusi
"solusi", "manfaat", "menguntungkan", "solusi", "menguntungkan", "memuaskan", "membanggakan",
"memuaskan", "membanggakan", "mengagumkan", "mengagumkan",
"sehat", "fair", "wajar", # ─── DIHAPUS (terlalu kontekstual): ────────────────
"syukur", "alhamdulillah", # "baik" → "lebih baik X daripada Y" bukan pujian langsung
"saing", "kompetitif", # "benar" → kontekstual ("itu benar" bisa netral)
# Domain ongkir/ecommerce positif # "penting" → "lebih penting" bukan pujian
"terjangkau", "hemat", "efisien", "mudah", "praktis", # "wajar" → netral kontekstual
"cepat", "aman", "terpercaya", "andalan", # "perlu" → netral kontekstual
# Dukungan kebijakan # "tepat" → kontekstual
"dukung", "setuju", "bagus", "tepat", "bijak", # "jelas" → kontekstual
"perlu", "penting", "benar", "wajar", "adil", # "fair" → kontekstual
# "manfaat" → kontekstual
# "kompetitif", "saing" → kontekstual
} }
LEXICON_NEGATIF = { LEXICON_NEGATIF = {
# Umum # Penilaian buruk
"buruk", "jelek", "parah", "rusak", "hancur", "ancur", "buruk", "jelek", "parah", "rusak", "hancur", "ancur",
"gagal", "ambruk", "terpuruk", "bangkrut", "gagal", "ambruk", "terpuruk", "bangkrut",
"bohong", "tipu", "curang", "manipulasi", "korupsi",
"kebohongan", "penipuan",
"kecewa", "mending", "malah", "marah", "sedih",
"khawatir", "takut", "benci", "jijik", "muak",
"kesal", "frustrasi", "geram", "dongkol",
"mahal", "rugi", "merugikan", "rugikan",
"lambat", "lemot", "lelet", "ribet", "susah",
"sulit", "bermasalah",
"ngaco", "ngasal", "gaje", "receh", "ngaco", "ngasal", "gaje", "receh",
"tolol", "bodoh", "idiot", "goblok", "salah", "keliru", "gegabah", "sembarangan", "ngawur",
"mengecewakan", "menyebalkan", "menyusahkan", # Ketidakjujuran
"bohong", "tipu", "curang", "manipulasi", "korupsi",
"kebohongan", "penipuan", "hoax", "kibul",
"monopoli", "licik", "monopoli", "licik",
# Domain ongkir negatif # Emosi negatif
"boros", "memberatkan", "menyulitkan", "kecewa", "marah", "sedih", "khawatir", "takut",
"repot", "ribet", "ngeributin", "ribut", "benci", "jijik", "muak", "kesal", "frustrasi",
# Kritik kebijakan "geram", "dongkol",
"salah", "keliru", "gegabah", "sembarangan", # Ekonomi / ongkir negatif
"tidak jelas", "ngawur", "asal", "mahal", "rugi", "merugikan", "rugikan", "overprice",
"lambat", "lemot", "lelet", "boros",
"memberatkan", "menyulitkan",
# Kesulitan / masalah
"ribet", "susah", "sulit", "bermasalah", "repot",
"ngeributin",
# Sifat mengecewakan
"mengecewakan", "menyebalkan", "menyusahkan",
# Kritik / penolakan
"mengeluh", "protes", "menolak", "tolak", "keberatan",
"diskriminasi", "zalim",
} }
KATA_NEGASI = { KATA_NEGASI = {
@ -112,11 +166,72 @@ KATA_NEGASI = {
} }
# ═══════════════════════════════════════════════════════════
# POLA KONTEKSTUAL (BARU — deteksi kritik implisit)
#
# Pola-pola ini mendeteksi sentimen dari struktur kalimat,
# bukan hanya dari kata individual. Sangat penting untuk
# tweet berbahasa informal Indonesia.
# ═══════════════════════════════════════════════════════════
# Pola komparatif yang menandakan kritik/saran negatif tersirat
# Format: (regex_pattern, skor_negatif_tambahan)
POLA_KOMPARATIF_NEGATIF = [
# "mending X daripada Y" — membandingkan, menyiratkan Y tidak layak
(r"\bmending\b.{1,80}\bdaripada\b", 2),
# "lebih baik X daripada Y" — sama dengan di atas
(r"\blebih baik\b.{1,80}\bdaripada\b", 1),
# "daripada X, mending Y" — variasi urutan
(r"\bdaripada\b.{1,50}\bmending\b", 1),
# "ketimbang urus ongkir, mending urus judol"
(r"\bketimbang\b.{1,60}\b(kebijakan|urus|ngurusin)\b", 1),
]
# Pola kritik tersirat — frasa yang menyiratkan ketidaksetujuan
# meskipun tidak ada kata negatif eksplisit
POLA_KRITIK_TERSIRAT = [
# "tidak penting / gak penting" — dismissif
(r"\b(tidak|tak|gak|ga|ngga|nggak)\b.{0,15}\bpenting\b", 1),
# "ngapain / buat apa / untuk apa" + konteks kebijakan
(r"\b(ngapain|buat apa|untuk apa|ngapain)\b.{1,50}\b(kebijakan|ongkir|aturan|regulasi)\b", 2),
# "gak usah / tidak usah / gak perlu"
(r"\b(gak|ga|tidak|tak|ngga)\b\s*(usah|perlu)\b", 1),
# "percuma / sia-sia / buang-buang"
(r"\b(percuma|sia-sia|buang-buang)\b", 2),
# "mending urusin yang lain / yang lebih penting"
(r"\bmending\b.{1,30}\b(urusin|urus)\b", 1),
# "kebijakan begini / aturan begini" — menyiratkan tidak setuju
(r"\b(kebijakan|aturan|regulasi)\b.{0,20}\bbegini\b", 1),
# "apa gunanya / apa manfaatnya" — retorikal negatif
(r"\bapa\b.{0,10}\b(guna|manfaat|untung)\b.{0,10}(nya|sih|ini)\b", 1),
]
# Kata informatif — menunjukkan tweet berisi pelaporan/informasi netral
KATA_INFORMATIF = {
"berita", "melihat", "membaca", "mendengar", "mengetahui",
"laporan", "informasi", "kabar", "melaporkan", "dikabarkan",
"menurut", "dilaporkan", "diberitakan", "dikutip", "mengutip",
}
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# LOAD NORMALISASI DARI FILE # LOAD NORMALISASI DARI FILE
# PERUBAHAN: Hapus override 'mending' → 'lebih baik' karena
# mengubah kata kritis negatif menjadi sinyal positif.
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
def _load_normalization() -> dict: def _load_normalization() -> dict:
"""
Muat kamus normalisasi dari file eksternal.
PERBAIKAN di versi ini:
- 'mending' dan 'mendingan' TIDAK lagi dioverride ke 'lebih baik'
karena mengakibatkan kata negatif/kritis menjadi sinyal positif
di lexicon scoring. Kata ini dibiarkan apa adanya agar POLA_KOMPARATIF
dan POLA_KRITIK dapat mendeteksinya.
- Override 'malah' dihapus file normalisasi umum mengubah
'malah' 'bahkan' yang bisa mengubah konteks sentimen.
"""
norm_file = "indonesian-normalisasi-slangword-complete.txt" norm_file = "indonesian-normalisasi-slangword-complete.txt"
norm_dict: dict = {} norm_dict: dict = {}
try: try:
@ -135,34 +250,67 @@ def _load_normalization() -> dict:
except FileNotFoundError: except FileNotFoundError:
pass pass
DOMAIN_OVERRIDES = { # ── Override khusus domain ───────────────────────────────────────────────
"shopee": "shopee", "tokopedia": "tokopedia", "lazada": "lazada", # Entri ini menimpa file generik. Perhatikan komentar DIHAPUS di bawah.
"tiktok": "tiktok", "bukalapak": "bukalapak", "blibli": "blibli", DOMAIN_OVERRIDES: dict = {
"sicepat": "sicepat", "jne": "jne", "jnt": "jnt", # Nama platform — pertahankan apa adanya
"anteraja": "anteraja", "ninja": "ninja", "shopee": "shopee",
"tokopedia": "tokopedia",
"lazada": "lazada",
"tiktok": "tiktok",
"bukalapak": "bukalapak",
"blibli": "blibli",
# Logistik
"sicepat": "sicepat",
"jne": "jne",
"jnt": "jnt",
"anteraja": "anteraja",
"ninja": "ninja",
# Ongkir & belanja
"freeongkir": "gratis ongkos kirim", "freeongkir": "gratis ongkos kirim",
"gratisongkir": "gratis ongkos kirim", "gratisongkir": "gratis ongkos kirim",
"ongkir": "ongkos kirim", "ongkr": "ongkos kirim", "ongkir": "ongkos kirim",
"bykrm": "biaya kirim", "biayakirim": "biaya pengiriman", "ongkr": "ongkos kirim",
"komdigi": "komdigi", "kemendag": "kementerian perdagangan", "bykrm": "biaya kirim",
"biayakirim": "biaya pengiriman",
# Kebijakan & lembaga
"komdigi": "komdigi",
"kemendag": "kementerian perdagangan",
"kominfo": "kementerian komunikasi", "kominfo": "kementerian komunikasi",
"ecommerce": "e commerce", "marketplace": "marketplace", # E-commerce umum
"seller": "penjual", "buyer": "pembeli", "online": "online", "ecommerce": "e commerce",
# Negasi tambahan "marketplace": "marketplace",
"seller": "penjual",
"buyer": "pembeli",
"online": "online",
# Negasi informal — pastikan ditangkap
"gk": "tidak", "ga": "tidak", "gak": "tidak", "gk": "tidak", "ga": "tidak", "gak": "tidak",
"nggak": "tidak", "ngga": "tidak", "tdk": "tidak", "nggak": "tidak", "ngga": "tidak", "tdk": "tidak",
"tak": "tidak", "enggak": "tidak", "engga": "tidak", "tak": "tidak", "enggak": "tidak", "engga": "tidak",
"kagak": "tidak", "kaga": "tidak", "ndak": "tidak", "kagak": "tidak", "kaga": "tidak", "ndak": "tidak",
"ngak": "tidak", "ngak": "tidak",
# Intensitas # Intensitas
"bgt": "banget", "bngt": "banget", "bget": "banget", "bgt": "banget", "bngt": "banget", "bget": "banget", "bgtt": "banget",
"bgtt": "banget", # Positif informal — hanya yang benar-benar positif
# Positif informal
"mantep": "mantap", "mntap": "mantap", "mantep": "mantap", "mntap": "mantap",
"bener": "benar", "beneran": "benar", "kece": "keren",
"kece": "keren", "sip": "baik",
# Negatif informal
"ancur": "hancur", "parahh": "parah", "ancur": "hancur", "parahh": "parah",
# ── SENGAJA TIDAK DIOVERRIDE (vs versi lama): ────────────────────────
# "mending" → TIDAK diubah ke "lebih baik"
# Alasan: "mending X daripada Y" adalah ekspresi kritik.
# Mengubah ke "lebih baik" membuat lexicon menangkap 'baik' = POSITIF,
# padahal kalimatnya bermakna negatif/kritik. Biarkan "mending" apa
# adanya agar POLA_KOMPARATIF_NEGATIF bisa mendeteksinya.
#
# "mendingan" → TIDAK diubah ke "lebih baik" (alasan sama)
#
# "malah" → TIDAK dioverride. File normalisasi mengubah 'mlah'→'malah'
# yang sudah benar; 'malah' sendiri tidak perlu diubah ke 'bahkan'
# karena mengubah nuansa kritis.
#
# "sip" → TIDAK dioverride ke "baik". "sip" cukup dikenal dan
# berdiri sendiri sebagai apresiasi. "baik" sudah dihapus dari
# lexicon karena terlalu kontekstual.
} }
norm_dict.update(DOMAIN_OVERRIDES) norm_dict.update(DOMAIN_OVERRIDES)
return norm_dict return norm_dict
@ -173,6 +321,15 @@ def _load_normalization() -> dict:
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
def _load_stopwords() -> set: def _load_stopwords() -> set:
"""
Muat daftar stopword dari file eksternal.
PERBAIKAN di versi ini:
- Pertahankan 'mending' dari stopword removal. Kata ini penting
sebagai penanda POLA_KOMPARATIF_NEGATIF dan POLA_KRITIK_TERSIRAT.
- Pertahankan 'daripada' dari stopword removal. Kata ini adalah
komponen kunci pola "mending X daripada Y".
"""
stopword_file = "indonesian-stopwords-complete.txt" stopword_file = "indonesian-stopwords-complete.txt"
base: set = set() base: set = set()
try: try:
@ -189,11 +346,26 @@ def _load_stopwords() -> set:
"jika", "sudah", "telah", "jadi", "bisa", "jika", "sudah", "telah", "jadi", "bisa",
} }
# Lindungi kata sentimen penting # ── Lindungi kata sentimen penting ──────────────────────────────────────
for kata in KATA_SENTIMEN_PENTING: for kata in KATA_SENTIMEN_PENTING:
base.discard(kata) base.discard(kata)
# Tambah noise Twitter # ── Lindungi kata penanda pola kontekstual ──────────────────────────────
# Kata-kata ini diperlukan agar POLA_KOMPARATIF dan POLA_KRITIK bisa
# mendeteksi struktur kalimat dengan benar di teks yang sudah bersih.
KATA_POLA_PENTING: set = {
"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
}
for kata in KATA_POLA_PENTING:
base.discard(kata)
# ── Tambah noise Twitter/sosmed ─────────────────────────────────────────
base.update({ base.update({
"rt", "amp", "https", "http", "co", "pic", "rt", "amp", "https", "http", "co", "pic",
"wkwk", "wkwkwk", "wkwkwkwk", "wkwk", "wkwkwk", "wkwkwkwk",
@ -216,7 +388,7 @@ def _load_stemmer():
return None return None
# ── Inisialisasi global (dimuat sekali saat modul diimport) # ── Inisialisasi global ─────────────────────────────────────────────────────
_STOPWORDS = _load_stopwords() _STOPWORDS = _load_stopwords()
_STEMMER = _load_stemmer() _STEMMER = _load_stemmer()
_NORM_DICT = _load_normalization() _NORM_DICT = _load_normalization()
@ -247,6 +419,7 @@ def _load_model():
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# 5-TAHAP PREPROCESSING PIPELINE # 5-TAHAP PREPROCESSING PIPELINE
# IDENTIK dengan preprocessing_page.py
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
def _step1_case_folding(text: str) -> str: def _step1_case_folding(text: str) -> str:
@ -312,54 +485,104 @@ def preprocess_for_model(text: str) -> str:
def preprocess_untuk_lexicon(text: str) -> str: def preprocess_untuk_lexicon(text: str) -> str:
"""Preprocessing ringan untuk lexicon matching (tanpa stemming).""" """
Preprocessing untuk lexicon matching.
PENTING: TIDAK distem dan TIDAK dihapus stopword-nya agar
pola kontekstual (mending, daripada, begini, dll.) tetap ada
dan bisa dideteksi oleh POLA_KOMPARATIF dan POLA_KRITIK.
"""
s1 = _step1_case_folding(text) s1 = _step1_case_folding(text)
s2 = _step2_cleaning(s1) s2 = _step2_cleaning(s1)
s3 = _step3_normalization(s2, _NORM_DICT) s3 = _step3_normalization(s2, _NORM_DICT)
return s3 return s3 # Kembalikan setelah normalisasi saja — stopword & stem TIDAK dilakukan
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# LEXICON SCORER (dengan negation window diperluas ke 3) # LEXICON SCORER (DIPERBAIKI — pola kontekstual + pembatasan)
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
def _hitung_skor_lexicon(teks_lexicon: str) -> dict: def _hitung_skor_lexicon(teks_lexicon: str) -> dict:
""" """
Hitung skor sentimen via lexicon + negation handling. Hitung skor sentimen via lexicon + pola kontekstual + negation handling.
Perubahan vs versi lama: PERUBAHAN UTAMA vs versi lama:
- Window negasi diperluas menjadi 3 kata (sebelumnya 2)
- Lexicon positif/negatif lebih luas (kata yang tidak ada di vocab TF-IDF) 1. POLA_KOMPARATIF_NEGATIF: "mending X daripada Y" tambah skor negatif
2. POLA_KRITIK_TERSIRAT: "tidak penting", "gak usah", dll. skor negatif
Return: {"positif": int, "negatif": int, "net": int} 3. POLA 'pembatasan gratis': 'gratis' tidak dihitung positif jika
didahului 'pembatasan' dalam window 3 kata
4. DETEKSI TWEET INFORMATIF: jika teks hanya berisi konteks informasi
tanpa kata sentimen eksplisit, tandai sebagai 'informatif'
5. Window negasi tetap 3 kata (dari versi sebelumnya)
Return: {"positif": int, "negatif": int, "net": int, "informatif": bool}
""" """
tokens = teks_lexicon.split() tokens = teks_lexicon.split()
skor_pos = 0 skor_pos = 0
skor_neg = 0 skor_neg = 0
# ── Tahap 1: Deteksi pola komparatif ──────────────────────────────────
for pola, bobot in POLA_KOMPARATIF_NEGATIF:
if re.search(pola, teks_lexicon):
skor_neg += bobot
# ── Tahap 2: Deteksi pola kritik tersirat ─────────────────────────────
for pola, bobot in POLA_KRITIK_TERSIRAT:
if re.search(pola, teks_lexicon):
skor_neg += bobot
# ── Tahap 3: Lexicon per token ────────────────────────────────────────
for i, token in enumerate(tokens): for i, token in enumerate(tokens):
# Cek negasi dalam window 3 kata sebelumnya # Negation window: 3 kata sebelumnya
ada_negasi = any( ada_negasi = any(
tokens[i - j] in KATA_NEGASI tokens[i - j] in KATA_NEGASI
for j in range(1, 4) for j in range(1, 4)
if i - j >= 0 if i - j >= 0
) )
# Konteks pembatasan: 'gratis' setelah 'pembatasan' tidak = positif
ada_pembatasan = any(
tokens[i - j] == "pembatasan"
for j in range(1, 4)
if i - j >= 0
)
if token in LEXICON_POSITIF: if token in LEXICON_POSITIF:
if ada_negasi: if ada_negasi:
skor_neg += 1 skor_neg += 1
elif ada_pembatasan and token == "gratis":
# "pembatasan gratis ongkir" = konteks negatif/netral,
# bukan pujian terhadap 'gratis'. Lewati.
pass
else: else:
skor_pos += 1 skor_pos += 1
elif token in LEXICON_NEGATIF: elif token in LEXICON_NEGATIF:
if ada_negasi: if ada_negasi:
skor_pos += 1 skor_pos += 1
else: else:
skor_neg += 1 skor_neg += 1
# ── Tahap 4: Deteksi tweet informatif ────────────────────────────────
# Tweet informatif: mengandung kata pelaporan, TIDAK ada kata sentimen
# eksplisit, dan panjang teks tidak terlalu pendek.
token_set = set(tokens)
ada_info_kata = bool(token_set & KATA_INFORMATIF)
ada_sentimen_eksplisit = bool(
(token_set & LEXICON_POSITIF) | (token_set & LEXICON_NEGATIF)
)
# Informatif jika: ada kata informatif + tidak ada sentimen eksplisit
# + skor negatif dari pola kecil (≤1, artinya cuma 'pembatasan')
is_informatif = (
ada_info_kata
and not ada_sentimen_eksplisit
and skor_neg <= 1
)
return { return {
"positif": skor_pos, "positif": skor_pos,
"negatif": skor_neg, "negatif": skor_neg,
"net": skor_pos - skor_neg, "net": skor_pos - skor_neg,
"informatif": is_informatif,
} }
@ -406,7 +629,7 @@ def _prediksi_model(teks_model: str):
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# HYBRID CLASSIFIER (diperkuat) # HYBRID CLASSIFIER (DIPERBAIKI)
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
def _klasifikasi_hybrid( def _klasifikasi_hybrid(
@ -415,66 +638,75 @@ def _klasifikasi_hybrid(
teks_lower: str, teks_lower: str,
) -> tuple: ) -> tuple:
""" """
Klasifikasi hybrid: sinyal lexicon + model NB 3-kelas. Klasifikasi hybrid: sinyal lexicon + pola kontekstual + model NB.
PERUBAHAN vs versi lama: ALUR KEPUTUSAN:
Layer 1 Override Positif KUAT (net >= 2): Layer 0 Override Informatif:
Confidence lebih tinggi karena lexicon lebih kuat. Jika skor['informatif'] = True DAN net mendekati 0 Netral langsung.
Range: 0.650.92 Mencegah tweet berita/informatif terklasifikasi positif/negatif
karena kata domain (mis. 'gratis' dalam "berita mengenai gratis ongkir").
Layer 1a Override Positif KUAT (net >= 2):
Sinyal lexicon + pola sangat kuat Positif langsung.
Layer 1b Override Negatif KUAT (net <= -2): Layer 1b Override Negatif KUAT (net <= -2):
Simetris dengan positif. Sinyal lexicon + pola sangat kuat Negatif langsung.
Range: 0.650.90 CATATAN: Pola komparatif "mending X daripada Y" menambah net -2/-3
sehingga tweet kritik implisit langsung terdeteksi di sini.
Layer 2 Override Positif LEMAH (net == 1): Layer 2a Override Positif LEMAH (net == 1):
Diperlonggar: sekarang berlaku jika model tidak sangat Konsultasi model. Jika model tidak sangat yakin Negatif Positif.
yakin negatif (threshold 0.70, sebelumnya 0.65).
Layer 2b Override Negatif LEMAH (net == -1): Layer 2b Override Negatif LEMAH (net == -1):
Simetris baru. Sebelumnya tidak ada. Konsultasi model. Jika model tidak sangat yakin Positif Negatif.
Layer 3 Fallback Model NB: Layer 3 Fallback Model NB:
Anti-bias diperketat: Anti-bias:
- Model Negatif + lexicon bersih (net >= 0) + conf < 0.65 - Model Negatif + lexicon bersih (net >= 0) + conf < 0.65 Netral
turunkan ke Netral (threshold naik dari 0.70 ke 0.65) - Model Positif + lexicon negatif (net <= -1) + conf < 0.65 Netral
- Model Positif + lexicon negatif kuat (net <= -1) + conf < 0.65
turunkan ke Netral
Layer 4 Ultimate Fallback: Layer 4 Ultimate Fallback (model tidak tersedia):
Lexicon saja jika model tidak tersedia. Gunakan sinyal lexicon saja.
Return: (label: str, confidence: float) Return: (label: str, confidence: float)
""" """
net = skor["net"] net = skor["net"]
pos = skor["positif"] is_info = skor.get("informatif", False)
neg = skor["negatif"]
# ── Layer 1a: Positif KUAT ─────────────────────────────────────────────── # ── Layer 0: Override tweet informatif ───────────────────────────────
# Tweet yang hanya berisi pelaporan/informasi tanpa sentimen eksplisit
# dan net mendekati 0 → langsung Netral agar tidak salah klasifikasi.
if is_info and -1 <= net <= 1:
return ("Netral", 0.52)
# ── Layer 1a: Positif KUAT ────────────────────────────────────────────
if net >= 2: if net >= 2:
conf = min(0.65 + (net * 0.05), 0.92) conf = min(0.65 + (net * 0.05), 0.92)
return ("Positif", round(conf, 3)) return ("Positif", round(conf, 3))
# ── Layer 1b: Negatif KUAT ─────────────────────────────────────────────── # ── Layer 1b: Negatif KUAT ────────────────────────────────────────────
# Pola komparatif ("mending X daripada Y") memberi net -2 hingga -4,
# sehingga tweet kritik implisit langsung tertangkap di layer ini.
if net <= -2: if net <= -2:
conf = min(0.65 + (abs(net) * 0.05), 0.90) conf = min(0.65 + (abs(net) * 0.05), 0.92)
return ("Negatif", round(conf, 3)) return ("Negatif", round(conf, 3))
# ── Layer 2a: Positif LEMAH ────────────────────────────────────────────── # ── Layer 2a: Positif LEMAH ───────────────────────────────────────────
if net == 1 and pos >= 1: if net == 1:
model_result = _prediksi_model(teks_model) model_result = _prediksi_model(teks_model)
if model_result is not None: if model_result is not None:
_, _, proba_dict = model_result _, _, proba_dict = model_result
neg_prob = proba_dict.get("Negatif", 0.0) neg_prob = proba_dict.get("Negatif", 0.0)
if neg_prob < 0.70: # diperlonggar dari 0.65 if neg_prob < 0.70:
conf = round(0.55 + (0.70 - neg_prob) * 0.30, 3) conf = round(0.55 + (0.70 - neg_prob) * 0.30, 3)
return ("Positif", min(conf, 0.82)) return ("Positif", min(conf, 0.82))
else: else:
return ("Positif", 0.55) return ("Positif", 0.55)
return ("Positif", 0.58) return ("Positif", 0.58)
# ── Layer 2b: Negatif LEMAH (baru) ─────────────────────────────────────── # ── Layer 2b: Negatif LEMAH ───────────────────────────────────────────
if net == -1 and neg >= 1: if net == -1:
model_result = _prediksi_model(teks_model) model_result = _prediksi_model(teks_model)
if model_result is not None: if model_result is not None:
_, _, proba_dict = model_result _, _, proba_dict = model_result
@ -486,7 +718,7 @@ def _klasifikasi_hybrid(
return ("Negatif", 0.55) return ("Negatif", 0.55)
return ("Negatif", 0.58) return ("Negatif", 0.58)
# ── Layer 3: Fallback Model NB 3-kelas ─────────────────────────────────── # ── Layer 3: Fallback Model NB ────────────────────────────────────────
model_result = _prediksi_model(teks_model) model_result = _prediksi_model(teks_model)
if model_result is not None: if model_result is not None:
label_norm, confidence, proba_dict = model_result label_norm, confidence, proba_dict = model_result
@ -503,7 +735,7 @@ def _klasifikasi_hybrid(
return (label_norm, round(confidence, 3)) return (label_norm, round(confidence, 3))
# ── Layer 4: Ultimate Fallback ──────────────────────────────────────────── # ── Layer 4: Ultimate Fallback ────────────────────────────────────────
if net > 0: if net > 0:
return ("Positif", 0.55) return ("Positif", 0.55)
elif net < 0: elif net < 0:

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -1,8 +1,10 @@
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username" "conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2058829602865451445","Mon May 25 08:36:43 +0000 2026","0","komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir","2058829602865451445","","","in","","0","0","0","https://x.com/undefined/status/2058829602865451445","2046458899843170304",
"2058950111976386607","Mon May 25 16:35:34 +0000 2026","0","komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju","2058950111976386607","","","in","","0","0","0","https://x.com/undefined/status/2058950111976386607","2046458899843170304",
"2058950559856832819","Mon May 25 16:37:21 +0000 2026","0","setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini","2058950559856832819","","","in","","0","0","0","https://x.com/undefined/status/2058950559856832819","2046458899843170304", "2058950559856832819","Mon May 25 16:37:21 +0000 2026","0","setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini","2058950559856832819","","","in","","0","0","0","https://x.com/undefined/status/2058950559856832819","2046458899843170304",
"2060234400777064942","Fri May 29 05:38:53 +0000 2026","0","tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi","2060234400777064942","","","in","","0","0","0","https://x.com/undefined/status/2060234400777064942","1814143201718247424", "2058829602865451445","Mon May 25 08:36:43 +0000 2026","0","komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir","2058829602865451445","","","in","","0","0","0","https://x.com/undefined/status/2058829602865451445","2046458899843170304",
"2058816188659192179","Mon May 25 07:43:24 +0000 2026","0","komdigi gajelas bikin kebijakan ongkir!","2058816188659192179","","","in","","0","0","0","https://x.com/undefined/status/2058816188659192179","1814143201718247424", "2060580175084204231","Sat May 30 04:32:52 +0000 2026","0","komdigi gratis ongkir sangat banyak yang tidak setuju","2060580175084204231","","","in","","0","0","0","https://x.com/undefined/status/2060580175084204231","1814143201718247424",
"2059619299309011403","Wed May 27 12:54:41 +0000 2026","0","komdigi ngeluarin kebijakan ongkir kaya gini ngapain dah","2059619299309011403","","","in","","0","0","0","https://x.com/undefined/status/2059619299309011403","1814143201718247424", "2059619299309011403","Wed May 27 12:54:41 +0000 2026","0","komdigi ngeluarin kebijakan ongkir kaya gini ngapain dah","2059619299309011403","","","in","","0","0","0","https://x.com/undefined/status/2059619299309011403","1814143201718247424",
"2058950111976386607","Mon May 25 16:35:34 +0000 2026","0","komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju","2058950111976386607","","","in","","0","0","0","https://x.com/undefined/status/2058950111976386607","2046458899843170304",
"2060641816647790635","Sat May 30 08:37:48 +0000 2026","0","komdigi gk penting deh ngeluarin kebijakan ongkir begini mending tu urusin yang lebih penting!!","2060641816647790635","","","in","","0","0","0","https://x.com/undefined/status/2060641816647790635","1814143201718247424",
"2058816188659192179","Mon May 25 07:43:24 +0000 2026","0","komdigi gajelas bikin kebijakan ongkir!","2058816188659192179","","","in","","0","0","0","https://x.com/undefined/status/2058816188659192179","1814143201718247424",
"2058816239112446387","Mon May 25 07:43:37 +0000 2026","0","komdigi gk penting buat kebijakan ongkir kaya gini","2058816239112446387","","","in","","0","0","0","https://x.com/undefined/status/2058816239112446387","1814143201718247424", "2058816239112446387","Mon May 25 07:43:37 +0000 2026","0","komdigi gk penting buat kebijakan ongkir kaya gini","2058816239112446387","","","in","","0","0","0","https://x.com/undefined/status/2058816239112446387","1814143201718247424",
"2060234400777064942","Fri May 29 05:38:53 +0000 2026","0","tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi","2060234400777064942","","","in","","0","0","0","https://x.com/undefined/status/2060234400777064942","1814143201718247424",

1 conversation_id_str created_at favorite_count full_text id_str image_url in_reply_to_screen_name lang location quote_count reply_count retweet_count tweet_url user_id_str username
2058829602865451445 Mon May 25 08:36:43 +0000 2026 0 komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir 2058829602865451445 in 0 0 0 https://x.com/undefined/status/2058829602865451445 2046458899843170304
2058950111976386607 Mon May 25 16:35:34 +0000 2026 0 komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju 2058950111976386607 in 0 0 0 https://x.com/undefined/status/2058950111976386607 2046458899843170304
2 2058950559856832819 Mon May 25 16:37:21 +0000 2026 0 setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini 2058950559856832819 in 0 0 0 https://x.com/undefined/status/2058950559856832819 2046458899843170304
3 2060234400777064942 2058829602865451445 Fri May 29 05:38:53 +0000 2026 Mon May 25 08:36:43 +0000 2026 0 tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir 2060234400777064942 2058829602865451445 in 0 0 0 https://x.com/undefined/status/2060234400777064942 https://x.com/undefined/status/2058829602865451445 1814143201718247424 2046458899843170304
4 2058816188659192179 2060580175084204231 Mon May 25 07:43:24 +0000 2026 Sat May 30 04:32:52 +0000 2026 0 komdigi gajelas bikin kebijakan ongkir! komdigi gratis ongkir sangat banyak yang tidak setuju 2058816188659192179 2060580175084204231 in 0 0 0 https://x.com/undefined/status/2058816188659192179 https://x.com/undefined/status/2060580175084204231 1814143201718247424
5 2059619299309011403 Wed May 27 12:54:41 +0000 2026 0 komdigi ngeluarin kebijakan ongkir kaya gini ngapain dah 2059619299309011403 in 0 0 0 https://x.com/undefined/status/2059619299309011403 1814143201718247424
6 2058950111976386607 Mon May 25 16:35:34 +0000 2026 0 komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju 2058950111976386607 in 0 0 0 https://x.com/undefined/status/2058950111976386607 2046458899843170304
7 2060641816647790635 Sat May 30 08:37:48 +0000 2026 0 komdigi gk penting deh ngeluarin kebijakan ongkir begini mending tu urusin yang lebih penting!! 2060641816647790635 in 0 0 0 https://x.com/undefined/status/2060641816647790635 1814143201718247424
8 2058816188659192179 Mon May 25 07:43:24 +0000 2026 0 komdigi gajelas bikin kebijakan ongkir! 2058816188659192179 in 0 0 0 https://x.com/undefined/status/2058816188659192179 1814143201718247424
9 2058816239112446387 Mon May 25 07:43:37 +0000 2026 0 komdigi gk penting buat kebijakan ongkir kaya gini 2058816239112446387 in 0 0 0 https://x.com/undefined/status/2058816239112446387 1814143201718247424
10 2060234400777064942 Fri May 29 05:38:53 +0000 2026 0 tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi 2060234400777064942 in 0 0 0 https://x.com/undefined/status/2060234400777064942 1814143201718247424

View File

@ -1,8 +1,10 @@
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username" "conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2058950559856832819","Mon May 25 16:37:21 +0000 2026","0","setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini","2058950559856832819","","","in","","0","0","0","https://x.com/undefined/status/2058950559856832819","2046458899843170304",
"2058829602865451445","Mon May 25 08:36:43 +0000 2026","0","komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir","2058829602865451445","","","in","","0","0","0","https://x.com/undefined/status/2058829602865451445","2046458899843170304", "2058829602865451445","Mon May 25 08:36:43 +0000 2026","0","komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir","2058829602865451445","","","in","","0","0","0","https://x.com/undefined/status/2058829602865451445","2046458899843170304",
"2058950111976386607","Mon May 25 16:35:34 +0000 2026","0","komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju","2058950111976386607","","","in","","0","0","0","https://x.com/undefined/status/2058950111976386607","2046458899843170304", "2058950111976386607","Mon May 25 16:35:34 +0000 2026","0","komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju","2058950111976386607","","","in","","0","0","0","https://x.com/undefined/status/2058950111976386607","2046458899843170304",
"2058950559856832819","Mon May 25 16:37:21 +0000 2026","0","setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini","2058950559856832819","","","in","","0","0","0","https://x.com/undefined/status/2058950559856832819","2046458899843170304",
"2060234400777064942","Fri May 29 05:38:53 +0000 2026","0","tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi","2060234400777064942","","","in","","0","0","0","https://x.com/undefined/status/2060234400777064942","1814143201718247424",
"2058816188659192179","Mon May 25 07:43:24 +0000 2026","0","komdigi gajelas bikin kebijakan ongkir!","2058816188659192179","","","in","","0","0","0","https://x.com/undefined/status/2058816188659192179","1814143201718247424",
"2059619299309011403","Wed May 27 12:54:41 +0000 2026","0","komdigi ngeluarin kebijakan ongkir kaya gini ngapain dah","2059619299309011403","","","in","","0","0","0","https://x.com/undefined/status/2059619299309011403","1814143201718247424", "2059619299309011403","Wed May 27 12:54:41 +0000 2026","0","komdigi ngeluarin kebijakan ongkir kaya gini ngapain dah","2059619299309011403","","","in","","0","0","0","https://x.com/undefined/status/2059619299309011403","1814143201718247424",
"2060641816647790635","Sat May 30 08:37:48 +0000 2026","0","komdigi gk penting deh ngeluarin kebijakan ongkir begini mending tu urusin yang lebih penting!!","2060641816647790635","","","in","","0","0","0","https://x.com/undefined/status/2060641816647790635","1814143201718247424",
"2060580175084204231","Sat May 30 04:32:52 +0000 2026","0","komdigi gratis ongkir sangat banyak yang tidak setuju","2060580175084204231","","","in","","0","0","0","https://x.com/undefined/status/2060580175084204231","1814143201718247424",
"2058816188659192179","Mon May 25 07:43:24 +0000 2026","0","komdigi gajelas bikin kebijakan ongkir!","2058816188659192179","","","in","","0","0","0","https://x.com/undefined/status/2058816188659192179","1814143201718247424",
"2058816239112446387","Mon May 25 07:43:37 +0000 2026","0","komdigi gk penting buat kebijakan ongkir kaya gini","2058816239112446387","","","in","","0","0","0","https://x.com/undefined/status/2058816239112446387","1814143201718247424", "2058816239112446387","Mon May 25 07:43:37 +0000 2026","0","komdigi gk penting buat kebijakan ongkir kaya gini","2058816239112446387","","","in","","0","0","0","https://x.com/undefined/status/2058816239112446387","1814143201718247424",
"2060234400777064942","Fri May 29 05:38:53 +0000 2026","0","tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi","2060234400777064942","","","in","","0","0","0","https://x.com/undefined/status/2060234400777064942","1814143201718247424",

1 conversation_id_str created_at favorite_count full_text id_str image_url in_reply_to_screen_name lang location quote_count reply_count retweet_count tweet_url user_id_str username
2 2058950559856832819 Mon May 25 16:37:21 +0000 2026 0 setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini 2058950559856832819 in 0 0 0 https://x.com/undefined/status/2058950559856832819 2046458899843170304
3 2058829602865451445 Mon May 25 08:36:43 +0000 2026 0 komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir 2058829602865451445 in 0 0 0 https://x.com/undefined/status/2058829602865451445 2046458899843170304
4 2058950111976386607 Mon May 25 16:35:34 +0000 2026 0 komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju 2058950111976386607 in 0 0 0 https://x.com/undefined/status/2058950111976386607 2046458899843170304
2058950559856832819 Mon May 25 16:37:21 +0000 2026 0 setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini 2058950559856832819 in 0 0 0 https://x.com/undefined/status/2058950559856832819 2046458899843170304
2060234400777064942 Fri May 29 05:38:53 +0000 2026 0 tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi 2060234400777064942 in 0 0 0 https://x.com/undefined/status/2060234400777064942 1814143201718247424
2058816188659192179 Mon May 25 07:43:24 +0000 2026 0 komdigi gajelas bikin kebijakan ongkir! 2058816188659192179 in 0 0 0 https://x.com/undefined/status/2058816188659192179 1814143201718247424
5 2059619299309011403 Wed May 27 12:54:41 +0000 2026 0 komdigi ngeluarin kebijakan ongkir kaya gini ngapain dah 2059619299309011403 in 0 0 0 https://x.com/undefined/status/2059619299309011403 1814143201718247424
6 2060641816647790635 Sat May 30 08:37:48 +0000 2026 0 komdigi gk penting deh ngeluarin kebijakan ongkir begini mending tu urusin yang lebih penting!! 2060641816647790635 in 0 0 0 https://x.com/undefined/status/2060641816647790635 1814143201718247424
7 2060580175084204231 Sat May 30 04:32:52 +0000 2026 0 komdigi gratis ongkir sangat banyak yang tidak setuju 2060580175084204231 in 0 0 0 https://x.com/undefined/status/2060580175084204231 1814143201718247424
8 2058816188659192179 Mon May 25 07:43:24 +0000 2026 0 komdigi gajelas bikin kebijakan ongkir! 2058816188659192179 in 0 0 0 https://x.com/undefined/status/2058816188659192179 1814143201718247424
9 2058816239112446387 Mon May 25 07:43:37 +0000 2026 0 komdigi gk penting buat kebijakan ongkir kaya gini 2058816239112446387 in 0 0 0 https://x.com/undefined/status/2058816239112446387 1814143201718247424
10 2060234400777064942 Fri May 29 05:38:53 +0000 2026 0 tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi 2060234400777064942 in 0 0 0 https://x.com/undefined/status/2060234400777064942 1814143201718247424