revisi prepo dikit
This commit is contained in:
parent
68319b9d23
commit
dc707605b3
|
|
@ -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"}
|
||||||
|
|
@ -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 slang→baku 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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
"freeongkir": "gratis ongkos kirim",
|
"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",
|
||||||
"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",
|
||||||
"kominfo": "kementerian komunikasi",
|
"biayakirim": "biaya pengiriman",
|
||||||
"ecommerce": "e commerce", "marketplace": "marketplace",
|
# Kebijakan & lembaga
|
||||||
"seller": "penjual", "buyer": "pembeli", "online": "online",
|
"komdigi": "komdigi",
|
||||||
# Negasi tambahan
|
"kemendag": "kementerian perdagangan",
|
||||||
"gk": "tidak", "ga": "tidak", "gak": "tidak",
|
"kominfo": "kementerian komunikasi",
|
||||||
"nggak": "tidak", "ngga": "tidak", "tdk": "tidak",
|
# E-commerce umum
|
||||||
"tak": "tidak", "enggak": "tidak", "engga": "tidak",
|
"ecommerce": "e commerce",
|
||||||
"kagak": "tidak", "kaga": "tidak", "ndak": "tidak",
|
"marketplace": "marketplace",
|
||||||
"ngak": "tidak",
|
"seller": "penjual",
|
||||||
|
"buyer": "pembeli",
|
||||||
|
"online": "online",
|
||||||
|
# Negasi informal — pastikan ditangkap
|
||||||
|
"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
|
# 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",
|
"kece": "keren",
|
||||||
"bener": "benar", "beneran": "benar",
|
"ancur": "hancur", "parahh": "parah",
|
||||||
"kece": "keren", "sip": "baik",
|
# ── SENGAJA TIDAK DIOVERRIDE (vs versi lama): ────────────────────────
|
||||||
# Negatif informal
|
# "mending" → TIDAK diubah ke "lebih baik"
|
||||||
"ancur": "hancur", "parahh": "parah",
|
# 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.65–0.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.65–0.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 |
|
|
@ -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,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",
|
||||||
|
|
|
||||||
|
Loading…
Reference in New Issue