perbaikan revisi per halaman dan informatif

penyempurnaan per halaman dan tambahan tampilah lebih informatif
This commit is contained in:
adistya13 2026-05-21 21:53:09 +07:00
parent 8288fd99bd
commit c886a9891c
9 changed files with 2028 additions and 1013 deletions

35
app.py
View File

@ -858,19 +858,42 @@ with st.sidebar:
""", unsafe_allow_html=True)
# ── QUERY ────────────────────────────────────────────────────
# ── QUERY ────────────────────────────────────────────────────
crawler_query = os.getenv(
"QUERY",
'komdigi (ongkir OR "gratis ongkir" OR "free ongkir")'
'komdigi (ongkir OR "gratis ongkir" OR "free ongkir") '
'OR "pembatasan gratis ongkir" OR "gratis ongkir dibatasi"'
)
query_html = escape(crawler_query)
# Highlight keyword utama dengan warna biru
_QUERY_TERMS = [
"komdigi", "ongkir", "gratis ongkir", "free ongkir",
"pembatasan gratis ongkir", "gratis ongkir dibatasi",
]
query_highlighted = escape(crawler_query)
for term in _QUERY_TERMS:
escaped_term = escape(term)
query_highlighted = query_highlighted.replace(
escaped_term,
f'<span style="color:#3b6cf7;font-weight:700;">{escaped_term}</span>',
1,
)
st.markdown(f"""
<div class="sidebar-card">
<div style="font-size:0.72rem;font-weight:800;color:#0f172a;margin-bottom:0.55rem;">
Query Aktif
<div style="font-size:0.72rem;font-weight:800;color:#0f172a;margin-bottom:0.5rem;">
🔍 Query Aktif
</div>
<div style="font-size:0.74rem;color:#475569;line-height:1.8;word-break:break-word;">
{query_html}
<div style="font-size:0.71rem;color:#475569;line-height:1.9;
word-break:break-word;font-family:monospace;">
{query_highlighted}
</div>
<div style="margin-top:0.6rem;padding-top:0.55rem;
border-top:1px solid #f1f5f9;">
<div style="font-size:0.66rem;color:#94a3b8;line-height:1.6;">
Topik: kebijakan ongkir gratis &amp; Komdigi<br/>
Sumber: X (Twitter) via tweet-harvest
</div>
</div>
</div>
""", unsafe_allow_html=True)

View File

@ -358,7 +358,7 @@ def _render_nrt_status_banner(sched_ok):
f"<strong>{NRT_INTERVAL_MINUTES} menit</strong>. "
f"Data mencakup <strong>7 hari terakhir</strong> berdasarkan tanggal asli tweet."
)
badge = "● LIVE"
badge = "&#9679; LIVE"
else:
bg = "linear-gradient(135deg,#eff6ff,#dbeafe)"
bdr = "#93c5fd"
@ -369,9 +369,9 @@ def _render_nrt_status_banner(sched_ok):
title = "Crawler Siap Dijalankan"
desc = (
f"Scheduler otomatis aktif setiap <strong>{NRT_INTERVAL_MINUTES} menit</strong> "
f"sejak aplikasi pertama dibuka — tidak perlu terminal terpisah."
f"sejak aplikasi pertama dibuka &mdash; tidak perlu terminal terpisah."
)
badge = "◎ STANDBY"
badge = "&#9900; STANDBY"
st.markdown(f"""
<div style="background:{bg};border:1px solid {bdr};border-radius:14px;
@ -437,20 +437,20 @@ def _render_monitoring_panel(last_log, last_dt, sched_ok):
if remaining.total_seconds() > 0:
countdown_str = format_countdown(remaining)
elif is_crawler_running():
countdown_str = "⏳ Sedang crawling..."
countdown_str = "Sedang crawling..."
else:
countdown_str = "🔄 Segera diperbarui..."
countdown_str = "Segera diperbarui..."
status_text = "Aktif Otomatis" if sched_ok else "Standby"
status_color = "#16a34a" if sched_ok else "#3b6cf7"
status_bg = "#dcfce7" if sched_ok else "#dbeafe"
note_text = f"✅ {tweet_today:,} tweet masuk hari ini" if tweet_today > 0 else "ℹ️ Belum ada tweet baru hari ini"
note_text = f"{tweet_today:,} tweet masuk hari ini" if tweet_today > 0 else "Belum ada tweet baru hari ini"
note_bg = "#f0fdf4" if tweet_today > 0 else "#f8fafc"
note_border = "#bbf7d0" if tweet_today > 0 else "#e8edf5"
note_color = "#166534" if tweet_today > 0 else "#64748b"
_section_header("📡 Status Monitoring Sistem", "Pembaruan data near-realtime · interval otomatis")
_section_header("&#128225; Status Monitoring Sistem", "Pembaruan data near-realtime &middot; interval otomatis")
col_a, col_b = st.columns(2, gap="medium")
@ -467,59 +467,62 @@ def _render_monitoring_panel(last_log, last_dt, sched_ok):
rows_html_a = ""
for i, (label, value, vc) in enumerate(left_rows):
border = "none" if i == len(left_rows) - 1 else "1px solid #f1f5f9"
rows_html_a += f"""
<div style="display:flex;justify-content:space-between;align-items:center;
padding:0.6rem 0;border-bottom:{border};">
<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span>
<span style="font-size:0.78rem;font-weight:700;color:{vc};
text-align:right;flex-shrink:0;max-width:58%;">{value}</span>
</div>"""
rows_html_a += (
f'<div style="display:flex;justify-content:space-between;align-items:center;'
f'padding:0.6rem 0;border-bottom:{border};">'
f'<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span>'
f'<span style="font-size:0.78rem;font-weight:700;color:{vc};'
f'text-align:right;flex-shrink:0;max-width:58%;">{value}</span>'
f'</div>'
)
with col_a:
st.markdown(f"""
<div class="crawl-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.9rem;">
<span style="font-size:0.71rem;font-weight:800;color:#94a3b8;
letter-spacing:0.07em;text-transform:uppercase;">⚙️ Sistem Crawling</span>
<span style="background:{status_bg};color:{status_color};font-size:0.68rem;
font-weight:700;padding:0.22rem 0.7rem;border-radius:999px;">{status_text}</span>
</div>
{rows_html_a}
</div>
""", unsafe_allow_html=True)
st.markdown(
f'<div class="crawl-card">'
f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.9rem;">'
f'<span style="font-size:0.71rem;font-weight:800;color:#94a3b8;'
f'letter-spacing:0.07em;text-transform:uppercase;">Sistem Crawling</span>'
f'<span style="background:{status_bg};color:{status_color};font-size:0.68rem;'
f'font-weight:700;padding:0.22rem 0.7rem;border-radius:999px;">{status_text}</span>'
f'</div>'
f'{rows_html_a}'
f'</div>',
unsafe_allow_html=True,
)
# ── Right card: ringkasan dataset ──
right_rows = [
("Update terakhir", last_crawl_str, "#0f172a"),
("Countdown berikutnya", countdown_str, "#16a34a"),
("Tweet masuk hari ini", f"{tweet_today:,} tweet", "#3b6cf7"),
("Total dataset", f"{total_db:,} tweet", "#0f172a"),
("Update terakhir", last_crawl_str, "#0f172a"),
("Countdown berikutnya", countdown_str, "#16a34a"),
("Tweet masuk hari ini", f"{tweet_today:,} tweet", "#3b6cf7"),
("Total dataset", f"{total_db:,} tweet", "#0f172a"),
]
rows_html_b = ""
for i, (label, value, vc) in enumerate(right_rows):
border = "none" if i == len(right_rows) - 1 else "1px solid #f1f5f9"
rows_html_b += f"""
<div style="display:flex;justify-content:space-between;align-items:center;
padding:0.6rem 0;border-bottom:{border};">
<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span>
<span style="font-size:0.78rem;font-weight:700;color:{vc};
text-align:right;flex-shrink:0;max-width:58%;">{value}</span>
</div>"""
rows_html_b += (
f'<div style="display:flex;justify-content:space-between;align-items:center;'
f'padding:0.6rem 0;border-bottom:{border};">'
f'<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span>'
f'<span style="font-size:0.78rem;font-weight:700;color:{vc};'
f'text-align:right;flex-shrink:0;max-width:58%;">{value}</span>'
f'</div>'
)
with col_b:
st.markdown(f"""
<div class="crawl-card">
<div style="font-size:0.71rem;font-weight:800;color:#94a3b8;
letter-spacing:0.07em;text-transform:uppercase;margin-bottom:0.9rem;">
📊 Ringkasan Dataset
</div>
{rows_html_b}
<div style="margin-top:0.9rem;background:{note_bg};border:1px solid {note_border};
border-radius:10px;padding:0.7rem 0.85rem;font-size:0.76rem;
font-weight:700;color:{note_color};">{note_text}</div>
</div>
""", unsafe_allow_html=True)
st.markdown(
f'<div class="crawl-card">'
f'<div style="font-size:0.71rem;font-weight:800;color:#94a3b8;'
f'letter-spacing:0.07em;text-transform:uppercase;margin-bottom:0.9rem;">'
f'Ringkasan Dataset</div>'
f'{rows_html_b}'
f'<div style="margin-top:0.9rem;background:{note_bg};border:1px solid {note_border};'
f'border-radius:10px;padding:0.7rem 0.85rem;font-size:0.76rem;'
f'font-weight:700;color:{note_color};">{note_text}</div>'
f'</div>',
unsafe_allow_html=True,
)
# ═══════════════════════════════════════════════════════════
@ -527,25 +530,25 @@ def _render_monitoring_panel(last_log, last_dt, sched_ok):
# ═══════════════════════════════════════════════════════════
def _render_metrics(total, days, earliest, latest, filter_label, basis_label):
st.markdown(f"""
<div style="display:flex;align-items:center;gap:0.5rem;margin:0.2rem 0 1.1rem;">
<span style="display:inline-flex;align-items:center;justify-content:center;
width:20px;height:20px;background:#eef2ff;border-radius:5px;font-size:0.75rem;">🔄</span>
<span style="font-size:0.78rem;color:#64748b;">
Data aktif: <strong style="color:#334155;">{filter_label}</strong>
&nbsp;·&nbsp; {basis_label}
</span>
</div>
""", unsafe_allow_html=True)
st.markdown(
f'<div style="display:flex;align-items:center;gap:0.5rem;margin:0.2rem 0 1.1rem;">'
f'<span style="display:inline-flex;align-items:center;justify-content:center;'
f'width:20px;height:20px;background:#eef2ff;border-radius:5px;font-size:0.75rem;">&#128260;</span>'
f'<span style="font-size:0.78rem;color:#64748b;">'
f'Data aktif: <strong style="color:#334155;">{filter_label}</strong>'
f'&nbsp;&middot;&nbsp; {basis_label}'
f'</span></div>',
unsafe_allow_html=True,
)
pills = [
("📊", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#dbeafe",
("&#128202;", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#dbeafe",
"Total Tweet", f"{total:,}", "Terkumpul periode ini"),
("📅", "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#16a34a", "#14532d", "#bbf7d0",
("&#128197;", "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#16a34a", "#14532d", "#bbf7d0",
"Rentang Waktu", f"{days} Hari", basis_label),
("🗓️", "linear-gradient(135deg,#fff7ed,#ffedd5)", "#ea580c", "#7c2d12", "#fed7aa",
("&#128199;", "linear-gradient(135deg,#fff7ed,#ffedd5)", "#ea580c", "#7c2d12", "#fed7aa",
"Mulai Dari", earliest, "Tanggal awal"),
("📆", "linear-gradient(135deg,#fefce8,#fef9c3)", "#ca8a04", "#713f12", "#fde68a",
("&#128198;", "linear-gradient(135deg,#fefce8,#fef9c3)", "#ca8a04", "#713f12", "#fde68a",
"Sampai Dengan", latest, "Tanggal akhir"),
]
@ -553,20 +556,21 @@ def _render_metrics(total, days, earliest, latest, filter_label, basis_label):
for col, (icon, bg, color, dark, border_c, label, val, sub) in zip([c1, c2, c3, c4], pills):
with col:
fs = "1.1rem" if len(str(val)) > 10 else "1.55rem"
st.markdown(f"""
<div class="metric-pill" style="background:{bg};border:1px solid {border_c};
box-shadow:0 2px 10px {color}18;">
<div style="width:36px;height:36px;background:{color};border-radius:10px;
display:flex;align-items:center;justify-content:center;
font-size:0.95rem;margin:0 auto 0.65rem;
box-shadow:0 4px 10px {color}44;">{icon}</div>
<div style="font-size:0.62rem;font-weight:800;color:{color};
text-transform:uppercase;letter-spacing:0.07em;margin-bottom:0.3rem;">{label}</div>
<div class="stat-num" style="font-size:{fs};font-weight:800;color:{dark};
line-height:1.15;margin-bottom:0.25rem;">{val}</div>
<div style="font-size:0.66rem;color:{color};font-weight:600;opacity:0.85;">{sub}</div>
</div>
""", unsafe_allow_html=True)
st.markdown(
f'<div class="metric-pill" style="background:{bg};border:1px solid {border_c};'
f'box-shadow:0 2px 10px {color}18;">'
f'<div style="width:36px;height:36px;background:{color};border-radius:10px;'
f'display:flex;align-items:center;justify-content:center;'
f'font-size:0.95rem;margin:0 auto 0.65rem;'
f'box-shadow:0 4px 10px {color}44;">{icon}</div>'
f'<div style="font-size:0.62rem;font-weight:800;color:{color};'
f'text-transform:uppercase;letter-spacing:0.07em;margin-bottom:0.3rem;">{label}</div>'
f'<div class="stat-num" style="font-size:{fs};font-weight:800;color:{dark};'
f'line-height:1.15;margin-bottom:0.25rem;">{val}</div>'
f'<div style="font-size:0.66rem;color:{color};font-weight:600;opacity:0.85;">{sub}</div>'
f'</div>',
unsafe_allow_html=True,
)
# ═══════════════════════════════════════════════════════════
@ -667,6 +671,7 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
legend_items.insert(1, ("#a78bfa", "Terendah"))
if any(c == 0 for c in counts_day):
legend_items.append(("#cbd5e1", "Tidak ada data"))
legend_html = "".join([
f'<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;">'
f'<span style="width:10px;height:10px;border-radius:3px;background:{clr};display:inline-block;"></span>'
@ -683,7 +688,7 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
def _date_pills(days_list, color, max_show=4):
if not days_list:
return '<span style="font-size:0.72rem;color:#94a3b8;">—</span>'
return '<span style="font-size:0.72rem;color:#94a3b8;">&#8212;</span>'
pills = ""
for d in days_list[:max_show]:
pills += (
@ -692,11 +697,14 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
f'font-weight:700;margin:2px 2px 0 0;">{d}</span>'
)
if len(days_list) > max_show:
pills += f'<span style="font-size:0.65rem;color:#94a3b8;margin-left:2px;">+{len(days_list)-max_show} lainnya</span>'
pills += (
f'<span style="font-size:0.65rem;color:#94a3b8;margin-left:2px;">'
f'+{len(days_list)-max_show} lainnya</span>'
)
return pills
if not busiest_list:
busy_main, busy_count, busy_pills = "—", "Belum ada data", ""
busy_main, busy_count, busy_pills = "&#8212;", "Belum ada data", ""
elif len(busiest_list) == 1:
busy_main, busy_count, busy_pills = busiest_list[0], f"{max_cnt:,} tweet", ""
else:
@ -705,7 +713,7 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
busy_pills = _date_pills(busiest_list, "#ea580c")
if not quietest_list:
quiet_main, quiet_count, quiet_pills = "—", "Belum ada data aktif", ""
quiet_main, quiet_count, quiet_pills = "&#8212;", "Belum ada data aktif", ""
elif len(quietest_list) == 1:
quiet_main, quiet_count, quiet_pills = quietest_list[0], f"{min_cnt:,} tweet", ""
else:
@ -718,11 +726,11 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
aktif_main, aktif_count, aktif_pills = "0 Hari Aktif", f"Dari {total_days} hari, belum ada data", ""
elif active_days == total_days:
aktif_main = f"{active_days} / {total_days} Hari"
aktif_count = f"100% hari ada tweet · total {total_tweets:,} tweet"
aktif_count = f"100% hari ada tweet &middot; total {total_tweets:,} tweet"
aktif_pills = ""
else:
aktif_main = f"{active_days} / {total_days} Hari"
aktif_count = f"{pct_active}% hari aktif · {inactive_days} hari kosong"
aktif_count = f"{pct_active}% hari aktif &middot; {inactive_days} hari kosong"
aktif_pills = ""
def _insight_card(col, icon, bg, color, dark, border_c, title, main, count_txt, pills_html):
@ -731,32 +739,33 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
f'<div style="margin-top:0.4rem;line-height:1.8;">{pills_html}</div>'
if pills_html else ""
)
st.markdown(f"""
<div class="mode-card" style="background:{bg};border:1px solid {border_c};
border-radius:14px;padding:0.9rem 1rem 0.95rem;
box-shadow:0 3px 12px {color}12;min-height:106px;">
<div style="display:flex;align-items:center;gap:0.55rem;margin-bottom:0.55rem;">
<div style="width:30px;height:30px;background:{color};border-radius:8px;
display:flex;align-items:center;justify-content:center;
font-size:0.85rem;flex-shrink:0;box-shadow:0 3px 7px {color}38;">{icon}</div>
<span style="font-size:0.58rem;font-weight:800;color:{color};
text-transform:uppercase;letter-spacing:0.08em;">{title}</span>
</div>
<div style="font-size:0.95rem;font-weight:800;color:{dark};
line-height:1.25;margin-bottom:0.15rem;">{main}</div>
<div style="font-size:0.67rem;color:{color};font-weight:500;
opacity:0.9;line-height:1.45;">{count_txt}</div>
{pills_block}
</div>
""", unsafe_allow_html=True)
st.markdown(
f'<div class="mode-card" style="background:{bg};border:1px solid {border_c};'
f'border-radius:14px;padding:0.9rem 1rem 0.95rem;'
f'box-shadow:0 3px 12px {color}12;min-height:106px;">'
f'<div style="display:flex;align-items:center;gap:0.55rem;margin-bottom:0.55rem;">'
f'<div style="width:30px;height:30px;background:{color};border-radius:8px;'
f'display:flex;align-items:center;justify-content:center;'
f'font-size:0.85rem;flex-shrink:0;box-shadow:0 3px 7px {color}38;">{icon}</div>'
f'<span style="font-size:0.58rem;font-weight:800;color:{color};'
f'text-transform:uppercase;letter-spacing:0.08em;">{title}</span>'
f'</div>'
f'<div style="font-size:0.95rem;font-weight:800;color:{dark};'
f'line-height:1.25;margin-bottom:0.15rem;">{main}</div>'
f'<div style="font-size:0.67rem;color:{color};font-weight:500;'
f'opacity:0.9;line-height:1.45;">{count_txt}</div>'
f'{pills_block}'
f'</div>',
unsafe_allow_html=True,
)
_insight_card(pc1, "🔥", "linear-gradient(135deg,#fff7ed,#ffedd5)",
_insight_card(pc1, "&#128293;", "linear-gradient(135deg,#fff7ed,#ffedd5)",
"#ea580c", "#7c2d12", "#fed7aa",
"Hari Paling Ramai", busy_main, busy_count, busy_pills)
_insight_card(pc2, "🌙", "linear-gradient(135deg,#f5f3ff,#ede9fe)",
_insight_card(pc2, "&#127769;", "linear-gradient(135deg,#f5f3ff,#ede9fe)",
"#7c3aed", "#3b0764", "#ddd6fe",
"Hari Paling Sepi", quiet_main, quiet_count, quiet_pills)
_insight_card(pc3, "📆", "linear-gradient(135deg,#f0fdf4,#dcfce7)",
_insight_card(pc3, "&#128198;", "linear-gradient(135deg,#f0fdf4,#dcfce7)",
"#16a34a", "#14532d", "#bbf7d0",
"Hari Aktif", aktif_main, aktif_count, aktif_pills)
@ -771,9 +780,9 @@ def get_filtered_data(df_all):
mode = st.session_state.analysis_mode
if mode == "realtime":
mode_display = "Tweet Terkini — 7 Hari Terakhir"
mode_display = "Tweet Terkini &mdash; 7 Hari Terakhir"
mode_color = "#16a34a"
mode_icon = "📡"
mode_icon = "&#128225;"
date_col = "created_at"
basis_label = "Berdasarkan tanggal asli tweet"
chart_label = "tanggal asli tweet"
@ -783,7 +792,7 @@ def get_filtered_data(df_all):
elif mode == "30days":
mode_display = "30 Hari Terakhir"
mode_color = "#3b6cf7"
mode_icon = "📅"
mode_icon = "&#128197;"
date_col = "created_at"
basis_label = "Berdasarkan tanggal asli tweet"
chart_label = "tanggal asli tweet"
@ -793,7 +802,7 @@ def get_filtered_data(df_all):
elif mode == "captured":
mode_display = "Tweet Hari Ini"
mode_color = "#0284c7"
mode_icon = "📆"
mode_icon = "&#128198;"
date_col = "created_at"
basis_label = "Berdasarkan tanggal asli tweet"
chart_label = "tanggal asli tweet"
@ -805,7 +814,7 @@ def get_filtered_data(df_all):
end_date = st.session_state.get("custom_end_date", today)
mode_display = "Periode Historis Pilihan"
mode_color = "#d97706"
mode_icon = "🔍"
mode_icon = "&#128269;"
date_col = "created_at"
basis_label = "Berdasarkan tanggal asli tweet"
chart_label = "tanggal asli tweet"
@ -834,6 +843,146 @@ def get_filtered_data(df_all):
}
# ═══════════════════════════════════════════════════════════
# PAGE HEADER (dipecah jadi beberapa st.markdown() kecil)
# ═══════════════════════════════════════════════════════════
def _render_page_header():
# Bangun semua bagian dalam Python dulu, baru render SEKALI
chip_data = [
("&#128260;", "Interval crawling", f"Setiap {NRT_INTERVAL_MINUTES} menit"),
("&#128197;", "Jangkauan data", "7 hari terakhir"),
("&#128451;", "Penyimpanan", "Database lokal"),
("&#129302;", "Model prediksi", "Naive Bayes"),
]
chips_html = "".join(
'<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;'
'padding:0.75rem 1rem;display:flex;align-items:center;gap:10px;">'
f'<span style="font-size:1.1rem;flex-shrink:0;">{ico}</span>'
'<div>'
f'<div style="font-size:0.63rem;color:#94a3b8;font-weight:700;'
f'text-transform:uppercase;letter-spacing:0.06em;margin-bottom:3px;">{lbl}</div>'
f'<div style="font-size:0.84rem;font-weight:700;color:#0f172a;">{val}</div>'
'</div></div>'
for ico, lbl, val in chip_data
)
PIPELINE_STEPS = [
("&#128038;", "Crawling tweet"),
("&#128451;", "Simpan ke database"),
("&#129529;", "Preprocessing teks"),
("&#129302;", "Prediksi sentimen"),
("&#128202;", "Visualisasi hasil"),
]
pipeline_steps_html = " ".join(
(
f'<span style="display:inline-flex;align-items:center;gap:5px;'
f'background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;'
f'padding:5px 13px;font-size:0.73rem;color:#475569;white-space:nowrap;">'
f'{icon}&nbsp;{label}</span>'
+ (
'<span style="color:#cbd5e1;font-size:0.85rem;margin:0 2px;">&#8594;</span>'
if i < len(PIPELINE_STEPS) - 1 else ""
)
)
for i, (icon, label) in enumerate(PIPELINE_STEPS)
)
html = (
# ── outer card ──
'<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:20px;'
'padding:1.6rem 1.8rem 1.4rem;margin-bottom:1.5rem;'
'box-shadow:0 4px 24px rgba(15,23,42,0.07);">'
# ── row 1: icon + title + description ──
'<div style="display:flex;align-items:flex-start;gap:1.1rem;margin-bottom:1.4rem;">'
# icon
'<div style="width:54px;height:54px;flex-shrink:0;'
'background:linear-gradient(135deg,#3b6cf7,#6366f1);border-radius:15px;'
'display:flex;align-items:center;justify-content:center;font-size:1.55rem;'
'box-shadow:0 6px 18px rgba(59,108,247,0.28);">&#128038;</div>'
# title block
'<div style="flex:1;min-width:0;padding-top:3px;">'
'<div style="display:flex;align-items:center;gap:0.65rem;margin-bottom:6px;flex-wrap:wrap;">'
'<h2 style="font-size:1.15rem;font-weight:800;color:#0f172a;'
'margin:0;letter-spacing:-0.02em;line-height:1.25;">'
'Crawling Data Twitter &mdash; Komdigi &amp; Isu Ongkir'
'</h2>'
'<span style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:999px;'
'padding:3px 11px;font-size:0.68rem;font-weight:700;color:#16a34a;'
'display:inline-flex;align-items:center;gap:5px;white-space:nowrap;">'
'<span class="pulse-dot-green" style="width:6px;height:6px;border-radius:50%;'
'background:#16a34a;display:inline-block;"></span>'
'Live Monitor'
'</span>'
'</div>'
'<p style="font-size:0.815rem;color:#64748b;margin:0;line-height:1.75;">'
'Dashboard analisis sentimen tweet seputar '
'<strong style="color:#334155;">kebijakan ongkos kirim gratis</strong> dan '
'<strong style="color:#334155;">Komdigi</strong>. '
'Sistem mengumpulkan tweet otomatis, menyimpan ke database lokal, '
'membersihkan teks Bahasa Indonesia, lalu memprediksi sentimen '
'(<em>positif / negatif / netral</em>) via model '
'<strong style="color:#334155;">Naive Bayes</strong> '
'&mdash; near-realtime tanpa intervensi manual.'
'</p>'
'</div>'
'</div>' # end row 1
# ── divider ──
'<div style="height:1px;background:#f1f5f9;margin-bottom:1.2rem;"></div>'
# ── row 2: chips ──
f'<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:1.2rem;">'
f'{chips_html}'
'</div>'
# ── divider ──
'<div style="height:1px;background:#f1f5f9;margin-bottom:1.2rem;"></div>'
# ── row 3: query box ──
'<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;'
'padding:0.9rem 1.1rem;margin-bottom:1.2rem;">'
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">'
'<span style="font-size:0.65rem;font-weight:800;color:#94a3b8;'
'text-transform:uppercase;letter-spacing:0.08em;">&#128269; Query pencarian aktif</span>'
'<span style="background:#eef2ff;color:#3b6cf7;border-radius:999px;'
'font-size:0.65rem;font-weight:700;padding:2px 9px;border:1px solid #c7d7fd;">'
'Twitter / X Search'
'</span>'
'</div>'
'<div style="font-family:\'Fira Mono\',\'Courier New\',monospace;font-size:0.8rem;'
'color:#334155;line-height:2;word-break:break-word;">'
'<span style="color:#3b6cf7;font-weight:700;">komdigi</span> '
'(<span style="color:#3b6cf7;font-weight:700;">ongkir</span> OR '
'<span style="color:#3b6cf7;font-weight:700;">&quot;gratis ongkir&quot;</span> OR '
'<span style="color:#3b6cf7;font-weight:700;">&quot;free ongkir&quot;</span>) OR '
'<span style="color:#3b6cf7;font-weight:700;">&quot;pembatasan gratis ongkir&quot;</span> OR '
'<span style="color:#3b6cf7;font-weight:700;">&quot;gratis ongkir dibatasi&quot;</span>'
'</div>'
'<div style="margin-top:9px;padding-top:9px;border-top:1px solid #e8edf5;'
'font-size:0.7rem;color:#94a3b8;line-height:1.6;">'
'Mencakup variasi: <em>ongkir, gratis ongkir, free ongkir</em>'
' &mdash; termasuk konteks pembatasan subsidi ongkir oleh Komdigi.'
'</div>'
'</div>' # end query box
# ── row 4: pipeline ──
'<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap;">'
'<span style="font-size:0.63rem;font-weight:800;color:#94a3b8;'
'text-transform:uppercase;letter-spacing:0.08em;margin-right:8px;white-space:nowrap;">'
'&#9881; Alur kerja</span>'
f'{pipeline_steps_html}'
'</div>'
'</div>' # end outer card
)
st.markdown(html, unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# MAIN PAGE
# ═══════════════════════════════════════════════════════════
@ -850,51 +999,7 @@ def show():
st.session_state.filter_end_date = today
# ── Page Header ──────────────────────────────────────────────
st.markdown(f"""
<div class="page-header-card" style="
background: linear-gradient(135deg, #ffffff 0%, #f0f5ff 50%, #eef2ff 100%);
border: 1px solid #e0e7ff;
border-radius: 20px;
padding: 1.5rem 1.75rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 20px rgba(59,108,247,0.08);
display: flex;
align-items: center;
gap: 1.1rem;
">
<div style="
width: 52px; height: 52px;
background: linear-gradient(135deg, #3b6cf7, #6366f1);
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem;
box-shadow: 0 6px 16px rgba(59,108,247,0.35);
flex-shrink: 0;
">🐦</div>
<div>
<h2 style="
font-size: 1.25rem; font-weight: 800; color: #0f172a;
margin: 0 0 4px; letter-spacing: -0.01em; line-height: 1.2;
">Crawling Data Twitter</h2>
<p style="font-size: 0.8rem; color: #64748b; margin: 0; line-height: 1.5;">
Sistem crawling berjalan otomatis setiap
<strong style="color:#3b6cf7;">{NRT_INTERVAL_MINUTES} menit</strong>
— mengambil data
<strong style="color:#3b6cf7;">7 hari terakhir</strong>
secara near-realtime
</p>
</div>
<div style="
margin-left: auto;
background: linear-gradient(135deg,#eef2ff,#e0e7ff);
border: 1px solid #c7d2fe;
border-radius: 10px;
padding: 0.45rem 0.9rem;
font-size: 0.72rem; font-weight: 700; color: #3b6cf7;
white-space: nowrap; letter-spacing: 0.04em; text-transform: uppercase;
">🔄 Live Monitor</div>
</div>
""", unsafe_allow_html=True)
_render_page_header()
# ── Mode Selector ─────────────────────────────────────────────
if "analysis_mode" not in st.session_state:
@ -904,29 +1009,29 @@ def show():
MODE_CFG = {
"captured": {
"col": col1, "key": "btn_captured", "label": "📆 Hari Ini",
"title": "Tweet Hari Ini", "icon": "📆",
"col": col1, "key": "btn_captured", "label": "Hari Ini",
"title": "Tweet Hari Ini", "icon": "&#128198;",
"desc": "Tanggal asli <strong>hari ini</strong>",
"gradient": "linear-gradient(135deg,#eff6ff,#dbeafe)",
"border": "#bfdbfe", "color": "#1d4ed8", "dark": "#1e3a8a",
},
"realtime": {
"col": col2, "key": "btn_realtime", "label": "📡 7 Hari",
"title": "Terkini (7 Hari)", "icon": "📡",
"col": col2, "key": "btn_realtime", "label": "7 Hari",
"title": "Terkini (7 Hari)", "icon": "&#128225;",
"desc": "<strong>7 hari terakhir</strong>",
"gradient": "linear-gradient(135deg,#f0fdf4,#dcfce7)",
"border": "#86efac", "color": "#16a34a", "dark": "#14532d",
},
"30days": {
"col": col3, "key": "btn_30days", "label": "📅 30 Hari",
"title": "30 Hari Terakhir", "icon": "📅",
"col": col3, "key": "btn_30days", "label": "30 Hari",
"title": "30 Hari Terakhir", "icon": "&#128197;",
"desc": "<strong>30 hari terakhir</strong>",
"gradient": "linear-gradient(135deg,#eef2ff,#e0e7ff)",
"border": "#a5b4fc", "color": "#3b6cf7", "dark": "#1e3a8a",
},
"custom": {
"col": col4, "key": "btn_custom", "label": "🔍 Pilih Tanggal",
"title": "Pilih Tanggal", "icon": "🔍",
"col": col4, "key": "btn_custom", "label": "Pilih Tanggal",
"title": "Pilih Tanggal", "icon": "&#128269;",
"desc": "Historis <strong>custom</strong>",
"gradient": "linear-gradient(135deg,#fefce8,#fef9c3)",
"border": "#fde68a", "color": "#d97706", "dark": "#713f12",
@ -957,20 +1062,21 @@ def show():
if active else ""
)
st.markdown(f"""
<div class="mode-card" style="background:{bg};border:{border_style};
border-radius:14px;padding:1rem 0.85rem;margin-top:0.3rem;
text-align:center;box-shadow:{shadow};">
<div style="width:38px;height:38px;background:{icon_bg};border-radius:10px;
display:flex;align-items:center;justify-content:center;
font-size:1.1rem;margin:0 auto 0.55rem;
box-shadow:{icon_shadow};">{cfg['icon']}</div>
<div style="font-size:0.8rem;font-weight:700;color:{dark};margin-bottom:0.22rem;">
{cfg['title']}</div>
<div style="font-size:0.68rem;color:{color};line-height:1.45;">{cfg['desc']}</div>
{underline}
</div>
""", unsafe_allow_html=True)
st.markdown(
f'<div class="mode-card" style="background:{bg};border:{border_style};'
f'border-radius:14px;padding:1rem 0.85rem;margin-top:0.3rem;'
f'text-align:center;box-shadow:{shadow};">'
f'<div style="width:38px;height:38px;background:{icon_bg};border-radius:10px;'
f'display:flex;align-items:center;justify-content:center;'
f'font-size:1.1rem;margin:0 auto 0.55rem;'
f'box-shadow:{icon_shadow};">{cfg["icon"]}</div>'
f'<div style="font-size:0.8rem;font-weight:700;color:{dark};margin-bottom:0.22rem;">'
f'{cfg["title"]}</div>'
f'<div style="font-size:0.68rem;color:{color};line-height:1.45;">{cfg["desc"]}</div>'
f'{underline}'
f'</div>',
unsafe_allow_html=True,
)
_gap("md")
@ -978,7 +1084,7 @@ def show():
try:
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
except Exception as e:
st.error(f"❌ Gagal membaca database: {str(e)}")
st.error(f"Gagal membaca database: {str(e)}")
return
if len(df_all) == 0:
@ -995,18 +1101,19 @@ def show():
min_db = df_check["created_at"].min().date()
max_db = df_check["created_at"].max().date()
st.markdown(f"""
<div class="crawl-card" style="margin-bottom:1rem;">
<p style="font-size:0.875rem;font-weight:700;color:#0f172a;margin:0 0 0.25rem;">
📅 Pilih Rentang Tanggal Tweet Historis</p>
<p style="font-size:0.775rem;color:#64748b;margin:0;line-height:1.6;">
Data tersedia dari
<strong style="color:#0f172a;">{min_db.strftime('%d/%m/%Y')}</strong>
hingga
<strong style="color:#0f172a;">{max_db.strftime('%d/%m/%Y')}</strong>
</p>
</div>
""", unsafe_allow_html=True)
st.markdown(
'<div class="crawl-card" style="margin-bottom:1rem;">'
'<p style="font-size:0.875rem;font-weight:700;color:#0f172a;margin:0 0 0.25rem;">'
'&#128197; Pilih Rentang Tanggal Tweet Historis</p>'
f'<p style="font-size:0.775rem;color:#64748b;margin:0;line-height:1.6;">'
f'Data tersedia dari '
f'<strong style="color:#0f172a;">{min_db.strftime("%d/%m/%Y")}</strong>'
f' hingga '
f'<strong style="color:#0f172a;">{max_db.strftime("%d/%m/%Y")}</strong>'
f'</p>'
'</div>',
unsafe_allow_html=True,
)
with st.container():
c1, c2, c3 = st.columns([2, 2, 1], gap="medium")
@ -1038,13 +1145,13 @@ def show():
key="custom_end_input")
st.session_state.custom_end_date = ce
with c3:
if st.button("✅ Terapkan", type="primary", use_container_width=True):
st.success("✅ Periode diterapkan!")
if st.button("Terapkan", type="primary", use_container_width=True):
st.success("Periode diterapkan!")
time.sleep(0.8)
st.rerun()
# ═══════════════════════════════════════════════════════════
# MODE: TWEET HARI INI (captured) — monitoring + log table
# MODE: TWEET HARI INI (captured)
# ═══════════════════════════════════════════════════════════
if st.session_state.analysis_mode == "captured":
st_autorefresh(interval=1000, key="crawler_monitor_refresh")
@ -1075,14 +1182,14 @@ def show():
crawl_time = log.get("timestamp", "-") or "-"
if status == "success":
status_label = "✅ Berhasil"
status_label = "Berhasil"
note = (
f"{total_saved:,} tweet baru tersimpan"
if total_saved > 0
else "Crawling berhasil, tidak ada tweet baru"
)
else:
status_label = "❌ Gagal"
status_label = "Gagal"
note = error_msg or "Terjadi kesalahan saat crawling"
rows.append({
@ -1094,8 +1201,8 @@ def show():
})
_section_header(
"🕒 Riwayat Crawling Otomatis",
f"{len(rows)} aktivitas terbaru · interval {NRT_INTERVAL_MINUTES} menit"
"&#128336; Riwayat Crawling Otomatis",
f"{len(rows)} aktivitas terbaru &middot; interval {NRT_INTERVAL_MINUTES} menit"
)
_gap("xs")
render_standard_table(
@ -1111,17 +1218,18 @@ def show():
},
)
else:
st.markdown(f"""
<div class="crawl-card" style="text-align:center;padding:2rem;">
<div style="font-size:2rem;margin-bottom:0.75rem;">⏳</div>
<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">
Menunggu Crawl Pertama</div>
<div style="font-size:0.77rem;color:#64748b;">
Riwayat akan muncul setelah crawl otomatis pertama selesai
(maks. {NRT_INTERVAL_MINUTES} menit sejak aplikasi dibuka)
</div>
</div>
""", unsafe_allow_html=True)
st.markdown(
'<div class="crawl-card" style="text-align:center;padding:2rem;">'
'<div style="font-size:2rem;margin-bottom:0.75rem;">&#9203;</div>'
'<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">'
'Menunggu Crawl Pertama</div>'
f'<div style="font-size:0.77rem;color:#64748b;">'
f'Riwayat akan muncul setelah crawl otomatis pertama selesai '
f'(maks. {NRT_INTERVAL_MINUTES} menit sejak aplikasi dibuka)'
f'</div>'
'</div>',
unsafe_allow_html=True,
)
_gap("lg")
@ -1140,22 +1248,23 @@ def show():
].sort_values("crawled_at", ascending=False)
_section_header(
"🤖 Tweet yang Diambil Bot Hari Ini",
"&#129302; Tweet yang Diambil Bot Hari Ini",
"Semua tweet yang berhasil disimpan crawler hari ini"
)
_gap("xs")
if bot_df.empty:
st.markdown(f"""
<div class="crawl-card" style="text-align:center;padding:2rem;">
<div style="font-size:2rem;margin-bottom:0.75rem;">🐦</div>
<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">
Belum Ada Tweet Hari Ini</div>
<div style="font-size:0.77rem;color:#64748b;">
Tweet baru akan muncul setelah crawl berikutnya selesai
</div>
</div>
""", unsafe_allow_html=True)
st.markdown(
'<div class="crawl-card" style="text-align:center;padding:2rem;">'
'<div style="font-size:2rem;margin-bottom:0.75rem;">&#128038;</div>'
'<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">'
'Belum Ada Tweet Hari Ini</div>'
'<div style="font-size:0.77rem;color:#64748b;">'
'Tweet baru akan muncul setelah crawl berikutnya selesai'
'</div>'
'</div>',
unsafe_allow_html=True,
)
else:
bot_disp = bot_df.head(100).copy()
bot_disp["Tanggal Tweet"] = bot_disp["created_at"].apply(format_dt)
@ -1201,32 +1310,33 @@ def show():
# ── Active Filter Banner ──────────────────────────────────
total = len(df)
st.markdown(f"""
<div style="display:flex;align-items:center;gap:0.9rem;
background:#ffffff;
border-left:4px solid {mode_color};
border-top:1px solid #e8edf5;
border-right:1px solid #e8edf5;
border-bottom:1px solid #e8edf5;
border-radius:0 14px 14px 0;
padding:0.75rem 1.3rem;margin-bottom:1rem;
box-shadow:0 2px 8px rgba(15,23,42,0.04);">
<span style="font-size:1.45rem;">{mode_icon}</span>
<div style="flex:1;">
<p style="font-size:0.88rem;font-weight:700;color:#0f172a;margin:0 0 2px;">{mode_display}</p>
<p style="font-size:0.74rem;color:#64748b;margin:0;">
Periode aktif: <strong style="color:{mode_color};">{filter_label}</strong>
&nbsp;·&nbsp; {basis_label}
</p>
</div>
<div style="margin-left:auto;background:{mode_color}14;color:{mode_color};
border:1px solid {mode_color}33;
font-size:0.7rem;font-weight:800;padding:0.3rem 0.8rem;
border-radius:999px;letter-spacing:0.04em;white-space:nowrap;">
{total:,} tweet
</div>
</div>
""", unsafe_allow_html=True)
st.markdown(
f'<div style="display:flex;align-items:center;gap:0.9rem;'
f'background:#ffffff;'
f'border-left:4px solid {mode_color};'
f'border-top:1px solid #e8edf5;'
f'border-right:1px solid #e8edf5;'
f'border-bottom:1px solid #e8edf5;'
f'border-radius:0 14px 14px 0;'
f'padding:0.75rem 1.3rem;margin-bottom:1rem;'
f'box-shadow:0 2px 8px rgba(15,23,42,0.04);">'
f'<span style="font-size:1.45rem;">{mode_icon}</span>'
f'<div style="flex:1;">'
f'<p style="font-size:0.88rem;font-weight:700;color:#0f172a;margin:0 0 2px;">{mode_display}</p>'
f'<p style="font-size:0.74rem;color:#64748b;margin:0;">'
f'Periode aktif: <strong style="color:{mode_color};">{filter_label}</strong>'
f'&nbsp;&middot;&nbsp; {basis_label}'
f'</p>'
f'</div>'
f'<div style="margin-left:auto;background:{mode_color}14;color:{mode_color};'
f'border:1px solid {mode_color}33;'
f'font-size:0.7rem;font-weight:800;padding:0.3rem 0.8rem;'
f'border-radius:999px;letter-spacing:0.04em;white-space:nowrap;">'
f'{total:,} tweet'
f'</div>'
f'</div>',
unsafe_allow_html=True,
)
days = (pd.Timestamp(dt_end).date() - pd.Timestamp(dt_start).date()).days + 1
earliest = pd.Timestamp(dt_start).strftime("%d/%m/%Y")
@ -1235,42 +1345,44 @@ def show():
_render_metrics(total, days, earliest, latest, filter_label, basis_label)
if total == 0:
_section_header("📋 Daftar Tweet yang Terkumpul", f"0 tweet · {filter_label}")
st.markdown(f"""
<div class="crawl-card" style="text-align:center;padding:2rem;">
<div style="font-size:2rem;margin-bottom:0.75rem;">🔍</div>
<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">
Tidak Ada Data</div>
<div style="font-size:0.77rem;color:#64748b;">
Belum ada tweet dengan tanggal asli pada periode ini
</div>
</div>
""", unsafe_allow_html=True)
_section_header("&#128203; Daftar Tweet yang Terkumpul", f"0 tweet &middot; {filter_label}")
st.markdown(
'<div class="crawl-card" style="text-align:center;padding:2rem;">'
'<div style="font-size:2rem;margin-bottom:0.75rem;">&#128269;</div>'
'<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">'
'Tidak Ada Data</div>'
'<div style="font-size:0.77rem;color:#64748b;">'
'Belum ada tweet dengan tanggal asli pada periode ini'
'</div>'
'</div>',
unsafe_allow_html=True,
)
return
# ── Chart ─────────────────────────────────────────────────
st.markdown(f"""
<div class="crawl-card" style="padding-bottom:0.4rem;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.1rem;">
<p style="font-size:0.87rem;font-weight:700;color:#0f172a;margin:0;">
📊 Distribusi Tweet Per Hari</p>
<span style="font-size:0.67rem;font-weight:600;color:#94a3b8;
background:#f8fafc;border:1px solid #e8edf5;
border-radius:6px;padding:0.18rem 0.55rem;">{filter_label}</span>
</div>
<p style="font-size:0.69rem;color:#94a3b8;margin:2px 0 0;">
Berdasarkan {chart_label}
</p>
</div>
""", unsafe_allow_html=True)
st.markdown(
f'<div class="crawl-card" style="padding-bottom:0.4rem;">'
f'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.1rem;">'
f'<p style="font-size:0.87rem;font-weight:700;color:#0f172a;margin:0;">'
f'&#128202; Distribusi Tweet Per Hari</p>'
f'<span style="font-size:0.67rem;font-weight:600;color:#94a3b8;'
f'background:#f8fafc;border:1px solid #e8edf5;'
f'border-radius:6px;padding:0.18rem 0.55rem;">{filter_label}</span>'
f'</div>'
f'<p style="font-size:0.69rem;color:#94a3b8;margin:2px 0 0;">'
f'Berdasarkan {chart_label}'
f'</p>'
f'</div>',
unsafe_allow_html=True,
)
_render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end)
# ── Tabel ─────────────────────────────────────────────────
display_limit = 100
_section_header(
"📋 Daftar Tweet yang Terkumpul",
f"Menampilkan hingga {display_limit} tweet terbaru · {filter_label}"
"&#128203; Daftar Tweet yang Terkumpul",
f"Menampilkan hingga {display_limit} tweet terbaru &middot; {filter_label}"
)
_gap("xs")
@ -1297,7 +1409,7 @@ def show():
c_dl, c_nav = st.columns(2)
with c_dl:
st.download_button(
f"📥 Unduh Semua Data ({total:,} tweet)",
f"Unduh Semua Data ({total:,} tweet)",
df.to_csv(index=False).encode("utf-8"),
f"data_twitter_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
"text/csv",
@ -1305,7 +1417,7 @@ def show():
)
with c_nav:
if st.button(
"🧹 Lanjut ke Bersihkan Data →",
"Lanjut ke Bersihkan Data",
type="primary",
use_container_width=True,
key="btn_go_preprocessing"

View File

@ -1,15 +1,19 @@
"""
preprocessing_page.py
=====================
Halaman Bersihkan Data — NLP Pipeline 6 Tahap.
Halaman Bersihkan Data — NLP Pipeline 5 Tahap.
PIPELINE 6 TAHAP (selaras dengan sentiment_service.py):
PIPELINE 5 TAHAP (selaras dengan sentiment_service.py):
1. Case Folding — ubah semua huruf jadi lowercase
2. Cleaning — hapus URL, mention, hashtag, angka, emoji, tanda baca
3. Normalisasi — singkatan/slang → kata baku
4. Tokenizing — pecah kalimat → list token
5. Stopword Removal — hapus kata umum; JAGA kata sentimen penting
6. Stemming — bentuk dasar kata via Sastrawi ECS
4. Stopword Removal — hapus kata umum; JAGA kata sentimen penting
5. Stemming — bentuk dasar kata via Sastrawi ECS
Catatan: Tokenizing tidak ditampilkan sebagai tahap tersendiri karena:
• Stopword removal & stemming sudah melakukan split() secara internal
• TF-IDF melakukan tokenisasi sendiri saat inferensi
→ Tokenizing adalah proses teknis, bukan tahap utama pipeline
"""
import streamlit as st
@ -91,7 +95,7 @@ def _sync_dynamic_period():
# ═══════════════════════════════════════════════════════════
# PREPROCESSING PIPELINE — 6 TAHAP
# PREPROCESSING PIPELINE — 5 TAHAP
# PENTING: Pipeline ini harus IDENTIK dengan sentiment_service.py
# agar token yang dihasilkan konsisten dengan training model.
#
@ -99,9 +103,8 @@ def _sync_dynamic_period():
# 1. Case Folding → lowercase dulu sebelum cleaning
# 2. Cleaning → hapus noise setelah lowercase
# 3. Normalisasi → singkatan/slang → kata baku
# 4. Tokenizing → split menjadi list token
# 5. Stopword Removal → buang kata umum, jaga kata sentimen
# 6. Stemming → bentuk dasar via Sastrawi ECS
# 4. Stopword Removal → buang kata umum, jaga kata sentimen
# 5. Stemming → bentuk dasar via Sastrawi ECS
# ═══════════════════════════════════════════════════════════
KATA_SENTIMEN_PENTING = {
@ -251,21 +254,16 @@ def step3_normalization(text: str) -> str:
return " ".join(NORMALISASI.get(word, word) for word in text.split())
# ── Tahap 4: Tokenizing ─────────────────────────────────────────────────────
def step4_tokenizing(text: str) -> list:
return text.split()
# ── Tahap 5: Stopword Removal ───────────────────────────────────────────────
def step5_stopword_removal(tokens: list, stopwords: set) -> list:
# ── Tahap 4: Stopword Removal ───────────────────────────────────────────────
def step4_stopword_removal(tokens: list, stopwords: set) -> list:
return [
w for w in tokens
if (w not in stopwords or w in KATA_SENTIMEN_PENTING) and len(w) > 2
]
# ── Tahap 6: Stemming ───────────────────────────────────────────────────────
def step6_stemming(tokens: list, stemmer) -> list:
# ── Tahap 5: Stemming ───────────────────────────────────────────────────────
def step5_stemming(tokens: list, stemmer) -> list:
if stemmer is None:
return tokens
return [stemmer.stem(w) for w in tokens]
@ -273,32 +271,28 @@ def step6_stemming(tokens: list, stemmer) -> list:
def full_preprocessing(text: str, stopwords: set, stemmer):
"""
Jalankan 6 tahap preprocessing dan kembalikan dict hasil setiap tahap.
Jalankan 5 tahap preprocessing dan kembalikan dict hasil setiap tahap.
URUTAN TAHAP:
1. Case Folding → lowercase
2. Cleaning → hapus noise
3. Normalisasi → normalisasi kata
4. Tokenizing → split token
5. Stopword Removal → buang stopword
6. Stemming → bentuk dasar
4. Stopword Removal → buang stopword (split() dilakukan internal)
5. Stemming → bentuk dasar
"""
s1_fold = step1_case_folding(text)
s2_clean = step2_cleaning(s1_fold)
s3_norm = step3_normalization(s2_clean)
s4_tokens = step4_tokenizing(s3_norm)
s5_filtered = step5_stopword_removal(s4_tokens, stopwords)
s6_stemmed = step6_stemming(s5_filtered, stemmer)
s4_filtered = step4_stopword_removal(s3_norm.split(), stopwords)
s5_stemmed = step5_stemming(s4_filtered, stemmer)
return {
"setelah_casefolding": s1_fold,
"setelah_cleaning": s2_clean,
"setelah_normalisasi": s3_norm,
"setelah_tokenizing": " | ".join(s4_tokens),
"setelah_stopword": " ".join(s5_filtered),
"clean_text": " ".join(s6_stemmed),
"_tokens_raw": s4_tokens,
"_tokens_clean": s6_stemmed,
"setelah_stopword": " ".join(s4_filtered),
"clean_text": " ".join(s5_stemmed),
"_tokens_clean": s5_stemmed,
}
@ -376,7 +370,6 @@ header[data-testid="stHeader"] { height: 0 !important; min-height: 0 !important;
.pipe-3 { animation: fadeRight 0.35s 0.19s ease both; }
.pipe-4 { animation: fadeRight 0.35s 0.26s ease both; }
.pipe-5 { animation: fadeRight 0.35s 0.33s ease both; }
.pipe-6 { animation: fadeRight 0.35s 0.40s ease both; }
.example-box { transition: all 0.18s ease; }
.example-box:hover {
@ -384,19 +377,6 @@ header[data-testid="stHeader"] { height: 0 !important; min-height: 0 !important;
box-shadow: 0 4px 14px rgba(59,108,247,0.10) !important;
}
.token-chip {
display: inline-block;
background: #eef2ff;
color: #3b6cf7;
border: 1px solid #c7d2fe;
border-radius: 5px;
padding: 1px 7px;
font-size: 0.7rem;
font-weight: 600;
margin: 2px 2px 0 0;
line-height: 1.6;
}
.fix-badge {
display: inline-block;
background: #fef9c3;
@ -442,8 +422,8 @@ def _render_page_header():
margin:0 0 4px;letter-spacing:-0.01em;line-height:1.2;">
Bersihkan Data</h2>
<p style="font-size:0.8rem;color:#64748b;margin:0;line-height:1.5;">
Preprocessing teks 6 tahap otomatis:
<strong style="color:#059669;">Case Folding → Cleaning → Normalisasi → Tokenizing → Stopword Removal → Stemming</strong>
Preprocessing teks 5 tahap otomatis:
<strong style="color:#059669;">Case Folding → Cleaning → Normalisasi → Stopword Removal → Stemming</strong>
</p>
</div>
<div style="
@ -512,21 +492,6 @@ def _render_pipeline_steps(stemmer_ok):
},
{
"num": "04", "anim": "pipe-4",
"icon": "✂️", "color": "#7c3aed", "dark": "#3b0764",
"bg": "linear-gradient(135deg,#f5f3ff,#ede9fe)", "border": "#c4b5fd",
"title": "Tokenizing",
"desc": "Memecah kalimat menjadi unit kata (token) individual.",
"items": [
"Pisahkan berdasarkan spasi",
'"keren banget" → [keren, banget]',
"Setiap token diproses mandiri",
"Hasil: daftar kata terpisah",
"Input untuk stopword removal",
"Ditampilkan sebagai chip per token",
],
},
{
"num": "05", "anim": "pipe-5",
"icon": "🚫", "color": "#ea580c", "dark": "#7c2d12",
"bg": "linear-gradient(135deg,#fff7ed,#ffedd5)", "border": "#fed7aa",
"title": 'Stopword Removal <span class="fix-badge">✦ Diperbaiki</span>',
@ -541,7 +506,7 @@ def _render_pipeline_steps(stemmer_ok):
],
},
{
"num": "06", "anim": "pipe-6",
"num": "05", "anim": "pipe-5",
"icon": "🌱", "color": "#ca8a04", "dark": "#713f12",
"bg": "linear-gradient(135deg,#fefce8,#fef9c3)", "border": "#fde68a",
"title": "Stemming",
@ -557,15 +522,17 @@ def _render_pipeline_steps(stemmer_ok):
},
]
# Baris 1: 3 kartu pertama
row1 = st.columns(3, gap="medium")
for col, step in zip(row1, steps[:3]):
_render_step_card(col, step)
_gap("sm")
row2 = st.columns(3, gap="medium")
for col, step in zip(row2, steps[3:]):
_render_step_card(col, step)
# Baris 2: 2 kartu terakhir (tengah agar simetris)
_, col4, col5, _ = st.columns([0.5, 1, 1, 0.5], gap="medium")
_render_step_card(col4, steps[3])
_render_step_card(col5, steps[4])
def _render_step_card(col, step):
@ -618,14 +585,13 @@ def _render_step_card(col, step):
def _render_flow_arrow():
nodes = [
("📄 Teks Asli", "#94a3b8", "#f8fafc", "#e2e8f0"),
("① Case Folding", "#0284c7", "#eff6ff", "#bfdbfe"),
("② Cleaning", "#3b6cf7", "#eef2ff", "#c7d2fe"),
("③ Normalisasi", "#16a34a", "#f0fdf4", "#86efac"),
("④ Tokenizing", "#7c3aed", "#f5f3ff", "#c4b5fd"),
("⑤ Stopword", "#ea580c", "#fff7ed", "#fed7aa"),
("⑥ Stemming", "#ca8a04", "#fefce8", "#fde68a"),
("✅ Teks Bersih", "#0f172a", "#0f172a", "#334155"),
("📄 Teks Asli", "#94a3b8", "#f8fafc", "#e2e8f0"),
("① Case Folding", "#0284c7", "#eff6ff", "#bfdbfe"),
("② Cleaning", "#3b6cf7", "#eef2ff", "#c7d2fe"),
("③ Normalisasi", "#16a34a", "#f0fdf4", "#86efac"),
("④ Stopword", "#ea580c", "#fff7ed", "#fed7aa"),
("⑤ Stemming", "#ca8a04", "#fefce8", "#fde68a"),
("✅ Teks Bersih", "#0f172a", "#0f172a", "#334155"),
]
parts = ""
@ -660,14 +626,14 @@ def _render_flow_arrow():
# STAT PILLS
# ═══════════════════════════════════════════════════════════
def _render_stat_pills(total_raw, total_clean, avg_tokens_before, avg_tokens_after, removed):
c1, c2, c3, c4 = st.columns(4, gap="medium")
def _render_stat_pills(total_raw, total_clean, removed):
c1, c2, c3 = st.columns(3, gap="medium")
cards = [
(c1, "📥", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#c7d2fe",
"Tweet Diproses", f"{total_raw:,}", "Total tweet periode ini"),
(c2, "✅", "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#16a34a", "#14532d", "#86efac",
"Tweet Siap Analisis", f"{total_clean:,}", "Lulus semua 6 tahap"),
"Tweet Siap Analisis", f"{total_clean:,}", "Lulus semua 5 tahap"),
(c3,
"🗑️" if removed > 0 else "✅",
("linear-gradient(135deg,#fff7ed,#ffedd5)" if removed > 0 else "linear-gradient(135deg,#f0fdf4,#dcfce7)"),
@ -676,10 +642,6 @@ def _render_stat_pills(total_raw, total_clean, avg_tokens_before, avg_tokens_aft
("#fed7aa" if removed > 0 else "#86efac"),
"Tweet Dibuang", f"{removed:,}",
("Teks kosong setelah preprocessing" if removed > 0 else "Semua tweet lolos")),
(c4, "🔤", "linear-gradient(135deg,#f5f3ff,#ede9fe)", "#7c3aed", "#3b0764", "#c4b5fd",
"Rata-rata Token",
f"{avg_tokens_before:.0f} → {avg_tokens_after:.0f}",
"Sebelum → Sesudah stopword"),
]
for col, icon, bg, color, dark, border, label, val, sub in cards:
@ -716,37 +678,28 @@ def _render_live_example(df_c):
"Contoh tweet acak dari dataset — refresh halaman untuk contoh berbeda"
)
# Urutan tampilan sesuai pipeline: CF → Clean → Norm → Token → Stop → Stem
# Urutan tampilan sesuai pipeline: CF → Clean → Norm → Stop → Stem
steps_ex = [
("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0", False),
("① Setelah Case Folding", "setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe", False),
("② Setelah Cleaning", "setelah_cleaning", "#1e3a8a", "#eef2ff", "#c7d2fe", False),
("③ Setelah Normalisasi", "setelah_normalisasi", "#14532d", "#f0fdf4", "#86efac", False),
("④ Setelah Tokenizing", "setelah_tokenizing", "#3b0764", "#f5f3ff", "#c4b5fd", True),
("⑤ Setelah Stopword", "setelah_stopword", "#7c2d12", "#fff7ed", "#fed7aa", False),
("⑥ Hasil Akhir (Stem)", "clean_text", "#713f12", "#fefce8", "#fde68a", False),
("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0"),
("① Setelah Case Folding", "setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe"),
("② Setelah Cleaning", "setelah_cleaning", "#1e3a8a", "#eef2ff", "#c7d2fe"),
("③ Setelah Normalisasi", "setelah_normalisasi", "#14532d", "#f0fdf4", "#86efac"),
("④ Setelah Stopword", "setelah_stopword", "#7c2d12", "#fff7ed", "#fed7aa"),
("⑤ Hasil Akhir (Stem)", "clean_text", "#713f12", "#fefce8", "#fde68a"),
]
for label, col_key, text_color, bg, border, is_token in steps_ex:
for label, col_key, text_color, bg, border in steps_ex:
raw = sample.get(col_key, "-")
text_display = str(raw) if raw and str(raw).strip() else "—"
if is_token and text_display != "—":
words = text_display.split(" | ")
chips = "".join(
f'<span class="token-chip">{w}</span>'
for w in words if w.strip()
)
content_html = f'<div style="line-height:2;">{chips}</div>'
char_info = f"{len(words)} token"
else:
content_html = (
f'<div style="font-size:0.82rem;color:{text_color};'
f'line-height:1.65;word-break:break-word;">{text_display}</div>'
)
word_count = len(text_display.split()) if text_display != "—" else 0
char_count = len(text_display) if text_display != "—" else 0
char_info = f"{word_count} kata · {char_count} karakter"
word_count = len(text_display.split()) if text_display != "—" else 0
char_count = len(text_display) if text_display != "—" else 0
char_info = f"{word_count} kata · {char_count} karakter"
content_html = (
f'<div style="font-size:0.82rem;color:{text_color};'
f'line-height:1.65;word-break:break-word;">{text_display}</div>'
)
st.markdown(
f'<div class="example-box" style="background:{bg};border:1.5px solid {border};'
@ -763,66 +716,6 @@ def _render_live_example(df_c):
)
# ═══════════════════════════════════════════════════════════
# LENGTH COMPARISON CHART
# ═══════════════════════════════════════════════════════════
def _render_length_comparison_chart(df, df_c):
_section_header(
"📐 Perbandingan Panjang Teks per Tahap",
"Rata-rata karakter per tweet di tiap tahap · Naik di normalisasi adalah normal (ekspansi singkatan)"
)
stages = [
"Teks Asli", "① Case Folding", "② Cleaning",
"③ Normalisasi", "④ Tokenizing", "⑤ Stopword", "⑥ Stemming"
]
avgs = [
df["text"].astype(str).str.len().mean(),
df_c["setelah_casefolding"].astype(str).str.len().mean(),
df_c["setelah_cleaning"].astype(str).str.len().mean(),
df_c["setelah_normalisasi"].astype(str).str.len().mean(),
df_c["setelah_tokenizing"].apply(
lambda x: len(" ".join(str(x).split(" | ")))
).mean(),
df_c["setelah_stopword"].astype(str).str.len().mean(),
df_c["clean_text"].astype(str).str.len().mean(),
]
avgs = [round(a, 1) for a in avgs]
colors = ["#94a3b8", "#0284c7", "#3b6cf7", "#16a34a", "#7c3aed", "#ea580c", "#ca8a04"]
fig = go.Figure(data=[
go.Bar(
x=stages, y=avgs,
marker=dict(color=colors, line=dict(width=0), opacity=0.9, cornerradius=8),
text=[f"{v}" for v in avgs],
textposition="outside",
textfont=dict(size=10, color="#475569"),
width=0.55,
hovertemplate="<b>%{x}</b><br>Rata-rata: %{y} karakter<extra></extra>",
)
])
fig.update_layout(
height=300,
margin=dict(l=0, r=10, t=24, b=10),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
xaxis=dict(tickfont=dict(size=10, color="#64748b"),
showgrid=False, zeroline=False, showline=False, fixedrange=True),
yaxis=dict(tickfont=dict(size=9, color="#94a3b8"),
showgrid=True, gridcolor="rgba(226,232,240,0.7)",
griddash="dot", gridwidth=1,
zeroline=False, showline=False, fixedrange=True),
showlegend=False, hovermode="x unified",
)
st.markdown("""
<div style="background:#ffffff;border:1.5px solid #e2e8f0;border-radius:14px;
padding:1rem 1.2rem 0.5rem;box-shadow:0 2px 6px rgba(15,23,42,0.05);">
""", unsafe_allow_html=True)
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
st.markdown("</div>", unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# TOP WORDS CHART
# ═══════════════════════════════════════════════════════════
@ -830,7 +723,7 @@ def _render_length_comparison_chart(df, df_c):
def _render_top_words_chart(df_c):
_section_header(
"📊 Kata-Kata Paling Sering Muncul",
f"Dari {len(df_c):,} tweet yang sudah bersih (hasil akhir tahap 6) — Top 20 kata"
f"Dari {len(df_c):,} tweet yang sudah bersih (hasil akhir tahap 5) — Top 20 kata"
)
if "_tokens_clean" in df_c.columns:
@ -930,8 +823,8 @@ def show():
# ── Pipeline Overview ─────────────────────────────────────
_section_header(
"🔬 Alur NLP Pipeline — 6 Tahap Preprocessing",
"Setiap tweet diproses berurutan melalui 6 tahap sebelum siap dianalisis sentimennya"
"🔬 Alur NLP Pipeline — 5 Tahap Preprocessing",
"Setiap tweet diproses berurutan melalui 5 tahap sebelum siap dianalisis sentimennya"
)
stemmer_tmp = _load_stemmer()
@ -967,12 +860,12 @@ def show():
data_marker = (total_tweets_in_db, latest_crawl_marker)
cache_key = (
f"pp6_{st.session_state.analysis_mode}_"
f"pp5_{st.session_state.analysis_mode}_"
f"{start_date}_{end_date}_{total_tweets_in_db}_{latest_crawl_marker}"
)
for old_key in list(st.session_state.keys()):
if old_key.startswith("pp6_") and old_key != cache_key:
if old_key.startswith("pp5_") and old_key != cache_key:
del st.session_state[old_key]
force_refresh = data_marker != st.session_state.get("_pp_last_data_marker")
@ -981,7 +874,7 @@ def show():
stemmer = _load_stemmer()
stopwords = _load_stopwords()
with st.spinner("🧹 Menjalankan 6 tahap preprocessing…"):
with st.spinner("🧹 Menjalankan 5 tahap preprocessing…"):
results = []
for _, row in df.iterrows():
r = full_preprocessing(row["text"], stopwords, stemmer)
@ -1002,12 +895,6 @@ def show():
stemmer_ok = st.session_state.get(cache_key + "_stemmer_ok", False)
# ── Statistik ─────────────────────────────────────────────
avg_tok_before = df_c["setelah_tokenizing"].apply(
lambda x: len(str(x).split(" | ")) if str(x).strip() else 0
).mean()
avg_tok_after = df_c["clean_text"].apply(
lambda x: len(str(x).split()) if str(x).strip() else 0
).mean()
removed = len(df) - len(df_c)
_section_header(
@ -1015,7 +902,7 @@ def show():
f"Berdasarkan tanggal asli tweet · {filter_label}"
)
_gap("xs")
_render_stat_pills(len(df), len(df_c), avg_tok_before, avg_tok_after, removed)
_render_stat_pills(len(df), len(df_c), removed)
_gap("lg")
_render_live_example(df_c)
@ -1031,14 +918,13 @@ def show():
df_c = df_c.copy()
df_c["crawled_at"] = pd.NaT
# Kolom ditampilkan sesuai urutan pipeline: CF → Clean → Norm → Token → Stop → Stem
# Kolom ditampilkan sesuai urutan pipeline: CF → Clean → Norm → Stop → Stem
disp = df_c[[
"tweet_id", "created_at", "crawled_at",
"text_asli",
"setelah_casefolding",
"setelah_cleaning",
"setelah_normalisasi",
"setelah_tokenizing",
"setelah_stopword",
"clean_text",
]].copy()
@ -1049,9 +935,8 @@ def show():
"① Case Folding",
"② Cleaning",
"③ Normalisasi",
"④ Tokenizing",
"⑤ Stopword",
"⑥ Hasil Akhir",
"④ Stopword",
"⑤ Hasil Akhir",
]
disp["Tanggal Tweet"] = disp["Tanggal Tweet"].apply(format_dt)
@ -1060,30 +945,26 @@ def show():
render_standard_table(
disp,
height=360,
min_width=2200,
min_width=1980,
nowrap=["ID Tweet", "Tanggal Tweet", "Masuk Database"],
wide_columns=[
"Teks Asli", "① Case Folding", "② Cleaning",
"③ Normalisasi", "④ Tokenizing", "⑤ Stopword", "⑥ Hasil Akhir",
"③ Normalisasi", "④ Stopword", "⑤ Hasil Akhir",
],
column_widths={
"ID Tweet": "155px",
"Tanggal Tweet": "165px",
"Masuk Database": "165px",
"Teks Asli": "280px",
"① Case Folding": "220px",
"① Case Folding": "240px",
"② Cleaning": "240px",
"③ Normalisasi": "220px",
"④ Tokenizing": "240px",
"⑤ Stopword": "220px",
"⑥ Hasil Akhir": "220px",
"③ Normalisasi": "240px",
"④ Stopword": "230px",
"⑤ Hasil Akhir": "230px",
},
)
_gap("lg")
_render_length_comparison_chart(df, df_c)
_gap("lg")
_render_top_words_chart(df_c)
_gap("lg")
@ -1102,7 +983,7 @@ def show():
with d1:
st.download_button(
"📥 Unduh Hasil Preprocessing Lengkap (semua kolom)",
df_c.drop(columns=["_tokens_raw", "_tokens_clean"], errors="ignore"
df_c.drop(columns=["_tokens_clean"], errors="ignore"
).to_csv(index=False).encode("utf-8"),
f"preprocessing_lengkap_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
"text/csv",

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +1,509 @@
"""
sentiment_service.py
====================
Hybrid Classifier untuk analisis sentimen tweet Bahasa Indonesia.
PIPELINE PREPROCESSING — 5 TAHAP (selaras dengan preprocessing_page.py):
1. Case Folding → lowercase dulu sebelum cleaning
2. Cleaning → hapus URL, mention, hashtag, angka, emoji, tanda baca
3. Normalisasi → singkatan/slang → kata baku
4. Stopword Removal → buang kata umum, jaga kata sentimen penting
5. Stemming → bentuk dasar via Sastrawi ECS
Catatan: Tokenizing tidak menjadi tahap tersendiri karena:
• Stopword removal & stemming sudah melakukan split() secara internal
• TF-IDF melakukan tokenisasi sendiri saat inferensi
ARSITEKTUR HYBRID:
Teks Asli
├── preprocess_untuk_lexicon() ──→ _hitung_skor_lexicon() ──┐
└── preprocess_for_model() ──→ TF-IDF → NB Model ───────┘
↓
_klasifikasi_hybrid() → Label + Confidence
"""
import re
import string
import joblib
model = joblib.load("model_naive_bayes.pkl")
tfidf = joblib.load("tfidf_vectorizer.pkl")
# ═══════════════════════════════════════════════════════════
# KATA SENTIMEN PENTING
# Tidak boleh dihapus di tahap stopword removal
# ═══════════════════════════════════════════════════════════
KATA_SENTIMEN_PENTING = {
# ── Negasi ──────────────────────────────────────────────
"tidak", "bukan", "jangan", "kurang", "belum", "tanpa",
# ── Sentimen POSITIF ────────────────────────────────────
"keren", "bagus", "mantap", "setuju", "dukung", "mendukung",
"andal", "handal", "gercep", "bangga", "senang", "suka",
"baik", "benar", "tepat", "oke",
"sejahtera", "berkembang", "maju", "inovatif",
"tegas", "sigap", "tanggap", "adil", "bijak", "bermanfaat",
"untung", "berhasil", "sukses", "solusi", "manfaat",
"berguna", "membantu", "bantu", "pro", "lanjut",
"sangat", "banget", "sekali", "paling", "amat", "luar", "biasa",
# ── Sentimen NEGATIF evaluatif ──────────────────────────
"kecewa", "buruk", "jelek", "parah", "gagal", "hancur",
"rusak", "bohong", "tipu", "korupsi",
# ── Emosi ───────────────────────────────────────────────
"marah", "sedih", "khawatir",
}
def bersihkan_teks(text):
# ═══════════════════════════════════════════════════════════
# NORMALISASI (selaras dengan preprocessing_page.py)
# ═══════════════════════════════════════════════════════════
text = str(text).lower()
NORMALISASI = {
# Negasi
"gk": "tidak", "ga": "tidak", "gak": "tidak",
"nggak": "tidak", "ngga": "tidak", "tdk": "tidak",
"tak": "tidak", "enggak": "tidak", "engga": "tidak",
"kagak": "tidak", "kaga": "tidak", "ndak": "tidak",
"gkk": "tidak", "ngak": "tidak",
# Kata ganti
"yg": "yang", "dgn": "dengan", "utk": "untuk",
"org": "orang", "krn": "karena", "dr": "dari",
"sm": "sama", "pd": "pada", "dlm": "dalam",
"bwt": "buat", "trm": "terima",
# Verba
"tp": "tapi", "tpi": "tapi", "jd": "jadi",
"sdh": "sudah", "blm": "belum", "emg": "memang",
"emang": "memang", "gimana": "bagaimana",
"gitu": "begitu", "gini": "begini",
"udah": "sudah", "udh": "sudah",
# Intensitas
"bgt": "banget", "bngt": "banget", "bget": "banget", "bgtt": "banget",
# Positif informal
"bener": "benar", "beneran": "benar",
"mantep": "mantap", "mntap": "mantap",
"kece": "keren",
"cucok": "cocok", "cucuk": "cocok",
"cakep": "bagus",
"sip": "baik", "siipp": "baik",
"top": "terbaik",
"jos": "bagus", "josss": "bagus",
"goks": "luar biasa",
"setujuu": "setuju", "stuju": "setuju",
"proud": "bangga",
"mantul": "mantap betul",
# Negatif informal
"ancur": "hancur", "ancrr": "hancur",
"parahh": "parah", "parahhh": "parah",
"gagall": "gagal",
"ngaco": "tidak benar",
"ngasal": "tidak benar",
"gaje": "tidak jelas",
# Domain
"ongkir": "ongkos kirim",
"freeongkir": "gratis ongkos kirim",
"gratisongkir": "gratis ongkos kirim",
"free": "gratis",
"ecommerce": "e commerce",
"seller": "penjual",
"buyer": "pembeli",
}
# ═══════════════════════════════════════════════════════════
# LEXICON SENTIMEN
# ═══════════════════════════════════════════════════════════
LEXICON_POSITIF = {
"bagus", "baik", "keren", "mantap", "mantep", "hebat",
"oke", "sip", "top", "jos", "goks", "kece", "mantul",
"setuju", "dukung", "mendukung", "pro", "lanjut", "sepakat",
"bangga", "senang", "suka", "puas", "gembira", "bahagia",
"berhasil", "sukses", "berjaya", "prestasi", "pencapaian",
"andal", "handal", "gercep", "sigap", "tanggap", "tegas",
"adil", "bijak", "bermanfaat", "berguna", "membantu",
"inovatif", "maju", "berkembang", "sejahtera",
"benar", "tepat", "jelas", "transparan", "amanah", "terpercaya",
"untung", "gratis", "murah", "hemat", "terjangkau",
"terbaik", "luar biasa",
"cakep", "cucok", "gaskeun", "kuy",
"dukung", "bantu", "solusi", "manfaat",
"memuaskan", "membanggakan", "mengagumkan",
}
LEXICON_NEGATIF = {
"buruk", "jelek", "parah", "rusak", "hancur", "ancur",
"gagal", "gagall", "ambruk", "terpuruk", "bangkrut",
"bohong", "tipu", "curang", "manipulasi", "korupsi", "penipuan",
"kebohongan",
"kecewa", "marah", "sedih", "khawatir", "takut", "benci",
"jijik", "muak", "kesal", "frustrasi",
"mahal", "rugi", "merugikan",
"lambat", "lemot", "ribet", "susah", "sulit", "bermasalah",
"ngaco", "ngasal", "gaje", "receh",
"tidak benar", "tidak jelas", "tidak adil", "tidak berguna",
"mengecewakan", "menyebalkan", "menyusahkan",
}
KATA_NEGASI = {
"tidak", "bukan", "jangan", "belum", "tanpa", "kurang",
"anti", "non",
}
# ═══════════════════════════════════════════════════════════
# STOPWORDS
# ═══════════════════════════════════════════════════════════
def _load_stopwords() -> set:
"""Load stopword dengan penjagaan kata sentimen penting."""
stopword_file = "indonesian-stopwords-complete.txt"
base = set()
try:
with open(stopword_file, "r", encoding="utf-8") as f:
base = set(f.read().splitlines())
except FileNotFoundError:
base = {
"yang", "dan", "di", "ke", "dari", "ini", "itu",
"dengan", "untuk", "pada", "adalah", "oleh", "ada",
"ya", "akan", "atau", "juga", "sama", "karena",
"jika", "sudah", "telah", "saat", "agar", "maka",
"lagi", "bila", "bisa", "pun", "nya",
}
# Jangan hapus kata sentimen penting
for kata in KATA_SENTIMEN_PENTING:
base.discard(kata)
# Tambahan stopword domain-spesifik
base.update({
"rt", "amp", "https", "http", "co", "t",
"wkwk", "wkwkwk", "haha", "hehe", "xixi", "hahaha",
"yg", "dgn", "utk", "dr", "krn", "tp", "jd", "sdh",
"aja", "doang", "nih", "sih", "dong", "deh",
"loh", "lah", "tuh", "kak", "gan", "bro", "sis",
})
return base
# ═══════════════════════════════════════════════════════════
# STEMMER
# ═══════════════════════════════════════════════════════════
def _load_stemmer():
"""Load Sastrawi stemmer. Return None jika tidak terinstall."""
try:
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
return StemmerFactory().create_stemmer()
except Exception:
return None
# ── Inisialisasi global ─────────────────────────────────────────────────────
_STOPWORDS = _load_stopwords()
_STEMMER = _load_stemmer()
# ═══════════════════════════════════════════════════════════
# MODEL LOADING (lazy)
# ═══════════════════════════════════════════════════════════
_model = None
_tfidf = None
def _load_model():
"""
Lazy load NB model + TF-IDF vectorizer.
Return: (model, tfidf) — keduanya bisa None.
"""
global _model, _tfidf
if _model is None:
try:
_model = joblib.load("model_naive_bayes.pkl")
except Exception as e:
print(f"[WARNING] Gagal load model_naive_bayes.pkl: {e}")
if _tfidf is None:
try:
_tfidf = joblib.load("tfidf_vectorizer.pkl")
except Exception as e:
print(f"[WARNING] Gagal load tfidf_vectorizer.pkl: {e}")
return _model, _tfidf
# ═══════════════════════════════════════════════════════════
# PREPROCESSING PIPELINE — 5 TAHAP
#
# URUTAN (selaras dengan preprocessing_page.py):
# 1. Case Folding → lowercase
# 2. Cleaning → hapus noise
# 3. Normalisasi → normalisasi kata
# 4. Stopword Removal → buang stopword (split() dilakukan internal)
# 5. Stemming → bentuk dasar
# ═══════════════════════════════════════════════════════════
def _case_folding(text: str) -> str:
"""Tahap 1: Lowercase seluruh teks."""
return str(text).lower()
def _cleaning(text: str) -> str:
"""Tahap 2: Hapus noise (URL, mention, hashtag, angka, emoji, tanda baca)."""
text = re.sub(r"http\S+|www\S+|https\S+", "", text)
text = re.sub(r"@\w+", "", text)
text = re.sub(r"#\w+", "", text)
text = re.sub(r"\d+", "", text)
text = re.sub(
r"http\S+|www\S+|@\w+|#|\d+",
"",
text
r"[\U00010000-\U0010ffff"
r"\U0001F600-\U0001F64F"
r"\U0001F300-\U0001F5FF"
r"\U0001F680-\U0001F6FF"
r"\U0001F1E0-\U0001F1FF"
r"\u2600-\u26FF\u2700-\u27BF"
r"]+", "", text, flags=re.UNICODE,
)
text = text.translate(
str.maketrans("", "", string.punctuation)
)
text = text.translate(str.maketrans("", "", string.punctuation))
text = re.sub(r"[^a-zA-Z\s]", "", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def prediksi_sentimen(list_text):
def _normalisasi(text: str) -> str:
"""Tahap 3: Normalisasi singkatan dan kata tidak baku."""
return " ".join(NORMALISASI.get(word, word) for word in text.split())
clean_texts = [
bersihkan_teks(text)
for text in list_text
def _remove_stopwords(tokens: list) -> list:
"""Tahap 4: Stopword removal dengan penjagaan kata sentimen."""
return [
w for w in tokens
if (w not in _STOPWORDS or w in KATA_SENTIMEN_PENTING) and len(w) > 2
]
vectors = tfidf.transform(clean_texts)
predictions = model.predict(vectors)
def _stemming(tokens: list) -> list:
"""Tahap 5: Stemming ke bentuk dasar via Sastrawi ECS."""
if _STEMMER is None:
return tokens
return [_STEMMER.stem(w) for w in tokens]
return clean_texts, predictions
def preprocess_for_model(text: str) -> str:
"""
Full 5-tahap preprocessing → string teks bersih siap TF-IDF.
URUTAN: Case Folding → Cleaning → Normalisasi
→ Stopword Removal → Stemming
Split kata dilakukan secara internal di tahap Stopword Removal.
Pipeline HARUS sama persis dengan yang dipakai saat training model.
"""
s1 = _case_folding(text) # Tahap 1
s2 = _cleaning(s1) # Tahap 2
s3 = _normalisasi(s2) # Tahap 3
s4 = _remove_stopwords(s3.split()) # Tahap 4 (split inline)
s5 = _stemming(s4) # Tahap 5
return " ".join(s5)
def preprocess_untuk_lexicon(text: str) -> str:
"""
Preprocessing RINGAN untuk lexicon matching.
Tidak di-stem → kata asli bisa dicocokkan dengan lexicon.
Pipeline: Case Folding → Cleaning → Normalisasi saja.
"""
s1 = _case_folding(text)
s2 = _cleaning(s1)
s3 = _normalisasi(s2)
return s3
# ═══════════════════════════════════════════════════════════
# LEXICON SCORER
# ═══════════════════════════════════════════════════════════
def _hitung_skor_lexicon(teks_lexicon: str) -> dict:
"""
Hitung skor positif dan negatif dari teks via lexicon.
NEGATION HANDLING:
Kata negasi dalam window 2 kata sebelum kata sentimen → polaritas dibalik.
Contoh: "tidak bagus" → ada "tidak" sebelum "bagus" (POSITIF)
→ skor_neg += 1 (bukan skor_pos)
Return: {"positif": int, "negatif": int, "net": int}
"""
tokens = teks_lexicon.split()
skor_pos = 0
skor_neg = 0
for i, token in enumerate(tokens):
ada_negasi = any(
tokens[i - j] in KATA_NEGASI
for j in range(1, 3)
if i - j >= 0
)
if token in LEXICON_POSITIF:
if ada_negasi:
skor_neg += 1
else:
skor_pos += 1
elif token in LEXICON_NEGATIF:
if ada_negasi:
skor_pos += 1
else:
skor_neg += 1
return {
"positif": skor_pos,
"negatif": skor_neg,
"net": skor_pos - skor_neg,
}
# ═══════════════════════════════════════════════════════════
# HYBRID CLASSIFIER
# ═══════════════════════════════════════════════════════════
# Normalisasi label dari berbagai format yang mungkin dipakai model
_LABEL_MAP = {
"positif": "Positif", "Positif": "Positif", "positive": "Positif", "pos": "Positif",
"negatif": "Negatif", "Negatif": "Negatif", "negative": "Negatif", "neg": "Negatif",
"netral": "Netral", "Netral": "Netral", "neutral": "Netral", "net": "Netral",
}
def _prediksi_model(teks_model: str):
"""
Dapatkan prediksi dari NB model.
Return: (label_norm, confidence, proba_dict) atau None.
"""
model, tfidf = _load_model()
if model is None or tfidf is None or not teks_model.strip():
return None
try:
vec = tfidf.transform([teks_model])
pred_raw = model.predict(vec)[0]
proba = model.predict_proba(vec)[0]
classes = list(model.classes_)
label_norm = _LABEL_MAP.get(str(pred_raw), "Netral")
pred_idx = classes.index(pred_raw) if pred_raw in classes else 0
confidence = float(proba[pred_idx])
proba_dict = {
_LABEL_MAP.get(str(cls), str(cls)): float(p)
for cls, p in zip(classes, proba)
}
return label_norm, confidence, proba_dict
except Exception as e:
print(f"[WARNING] Prediksi model gagal: {e}")
return None
def _klasifikasi_hybrid(
teks_model: str,
skor: dict,
teks_lower: str,
) -> tuple:
"""
Klasifikasi hybrid: sinyal lexicon + model NB.
LOGIKA KEPUTUSAN (berurutan):
Layer 1 — Override Positif KUAT (net >= 2):
→ Positif, confidence 60–92%
Layer 2 — Override Positif LEMAH (net == 1):
→ Cek model; jika model < 65% yakin Negatif → Positif
→ Jika model sangat yakin Negatif → tetap Positif (confidence rendah)
Layer 3 — Override Negatif KUAT (net <= -2):
→ Negatif, confidence 60–90%
Layer 4 — Fallback Model NB:
→ Prediksi model dipakai, TAPI:
• Jika model = Negatif AND net >= 0 AND confidence < 75%
→ downgrade ke Netral (koreksi bias model)
• Kasus lainnya → percaya model
Layer 5 — Ultimate Fallback (model tidak tersedia):
→ Gunakan skor lexicon saja
Return: (label: str, confidence: float)
"""
net = skor["net"]
pos = skor["positif"]
# ── Layer 1: Positif KUAT ─────────────────────────────────────────────────
if net >= 2:
conf = min(0.60 + (net * 0.07), 0.92)
return ("Positif", round(conf, 3))
# ── Layer 2: Positif LEMAH ────────────────────────────────────────────────
if net == 1 and pos >= 1:
model_result = _prediksi_model(teks_model)
if model_result is not None:
_, _, proba_dict = model_result
neg_prob = proba_dict.get("Negatif", 0.0)
if neg_prob < 0.65:
conf = round(0.55 + (0.65 - neg_prob) * 0.3, 3)
return ("Positif", min(conf, 0.80))
else:
return ("Positif", 0.55)
else:
return ("Positif", 0.58)
# ── Layer 3: Negatif KUAT ─────────────────────────────────────────────────
if net <= -2:
conf = min(0.60 + (abs(net) * 0.06), 0.90)
return ("Negatif", round(conf, 3))
# ── Layer 4: Fallback Model NB ────────────────────────────────────────────
model_result = _prediksi_model(teks_model)
if model_result is not None:
label_norm, confidence, proba_dict = model_result
# Anti-bias correction:
# Model prediksi Negatif tapi tidak ada sinyal negatif dari lexicon
# dan confidence < 75% → kemungkinan bias → turunkan ke Netral
if label_norm == "Negatif" and net >= 0 and confidence < 0.75:
corrected_conf = round(0.50 + max(0, confidence - 0.50) * 0.2, 3)
return ("Netral", corrected_conf)
return (label_norm, round(confidence, 3))
# ── Layer 5: Ultimate Fallback ────────────────────────────────────────────
if net > 0:
return ("Positif", 0.55)
elif net < 0:
return ("Negatif", 0.55)
else:
return ("Netral", 0.50)
# ═══════════════════════════════════════════════════════════
# BACKWARD COMPATIBILITY
# ═══════════════════════════════════════════════════════════
def bersihkan_teks(text: str) -> str:
"""[LEGACY] Gunakan preprocess_for_model() untuk pipeline lengkap."""
return preprocess_for_model(text)
def prediksi_sentimen(list_text: list):
"""
[LEGACY] Prediksi batch dengan hybrid classifier.
Return: (list clean_texts, list labels)
"""
clean_texts = [preprocess_for_model(t) for t in list_text]
labels = []
for text in list_text:
teks_model = preprocess_for_model(text)
teks_lexicon = preprocess_untuk_lexicon(text)
teks_lower = str(text).lower()
skor = _hitung_skor_lexicon(teks_lexicon)
label, _ = _klasifikasi_hybrid(teks_model, skor, teks_lower)
labels.append(label)
return clean_texts, labels

View File

@ -1 +1,2 @@
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","5","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",

1 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 2056538953340314095 Wed May 20 16:01:04 +0000 2026 5 @killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl 2057129489994510620 https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg killingmaster27 in 0 2 0 https://x.com/undefined/status/2057129489994510620 1410466956134608899

View File

@ -1 +1,2 @@
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","5","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",

1 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 2056538953340314095 Wed May 20 16:01:04 +0000 2026 5 @killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl 2057129489994510620 https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg killingmaster27 in 0 2 0 https://x.com/undefined/status/2057129489994510620 1410466956134608899

View File

@ -1,12 +0,0 @@
"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"
"2054502530504364380","Wed May 13 10:02:28 +0000 2026","0","GRATIS ONGKIR DIBATASI??? PARAH ","2054502530504364380","","","in","","0","0","0","https://x.com/undefined/status/2054502530504364380","1814143201718247424",
"2054816635131994490","Thu May 14 06:50:37 +0000 2026","0","keren sih ada kebijakan dari komdigi ongkir jadi berasa lebih merataa","2054816635131994490","","","in","","0","0","0","https://x.com/undefined/status/2054816635131994490","1814143201718247424",
"2054839912529350807","Thu May 14 08:23:06 +0000 2026","0","komdigi mending ngurusin lainnya deh dsripada ngurusin ongkir giniii","2054839912529350807","","","in","","0","0","0","https://x.com/undefined/status/2054839912529350807","1814143201718247424",
"2054502757869199625","Wed May 13 10:03:22 +0000 2026","0","Komdigi mengeluarkan kebijakan terkait gratis ongkir.","2054502757869199625","","","in","","0","0","0","https://x.com/undefined/status/2054502757869199625","1814143201718247424",
"2054996678391091685","Thu May 14 18:46:02 +0000 2026","0","komdigi ongkir mending sampingkan dulu","2054996678391091685","","","in","","0","0","0","https://x.com/undefined/status/2054996678391091685","1814143201718247424",
"2055002136027767200","Thu May 14 19:07:43 +0000 2026","0","komdigi ongkir apa gunanyaa??222","2055002136027767200","","","in","","0","0","0","https://x.com/undefined/status/2055002136027767200","2043979880086339584",
"2054998005359751518","Thu May 14 18:51:19 +0000 2026","0","komdigi ongkir apa gunanyaa??","2054998005359751518","","","in","","0","0","0","https://x.com/undefined/status/2054998005359751518","2043979880086339584",
"2055002941288001599","Thu May 14 19:10:55 +0000 2026","0","yakin komdigi bikin kebijakan pembatasan gratis ongkir ini bakal bisa ngimbangin pemerataan umkm??","2055002941288001599","","","in","","0","0","0","https://x.com/undefined/status/2055002941288001599","2043979880086339584",
"2055269612334555468","Fri May 15 12:50:35 +0000 2026","0","komdigi + ongkir = chaos wkwkwk","2055269612334555468","","","in","","0","0","0","https://x.com/undefined/status/2055269612334555468","2043979880086339584",
"2055001478004367487","Thu May 14 19:05:07 +0000 2026","0","maksud dari komdigi bikin kebijakan gratis ongkir ini apa yaaa?","2055001478004367487","","","in","","0","0","0","https://x.com/undefined/status/2055001478004367487","2043979880086339584",
"2055013912555475243","Thu May 14 19:54:31 +0000 2026","0","komdigi haloo?? masa iya ngurusin ongkir dulu dibanding permasalahan sebelah??","2055013912555475243","","","in","","0","0","0","https://x.com/undefined/status/2055013912555475243","2043979880086339584",
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 2054502530504364380 Wed May 13 10:02:28 +0000 2026 0 GRATIS ONGKIR DIBATASI??? PARAH 2054502530504364380 in 0 0 0 https://x.com/undefined/status/2054502530504364380 1814143201718247424
3 2054816635131994490 Thu May 14 06:50:37 +0000 2026 0 keren sih ada kebijakan dari komdigi ongkir jadi berasa lebih merataa 2054816635131994490 in 0 0 0 https://x.com/undefined/status/2054816635131994490 1814143201718247424
4 2054839912529350807 Thu May 14 08:23:06 +0000 2026 0 komdigi mending ngurusin lainnya deh dsripada ngurusin ongkir giniii 2054839912529350807 in 0 0 0 https://x.com/undefined/status/2054839912529350807 1814143201718247424
5 2054502757869199625 Wed May 13 10:03:22 +0000 2026 0 Komdigi mengeluarkan kebijakan terkait gratis ongkir. 2054502757869199625 in 0 0 0 https://x.com/undefined/status/2054502757869199625 1814143201718247424
6 2054996678391091685 Thu May 14 18:46:02 +0000 2026 0 komdigi ongkir mending sampingkan dulu 2054996678391091685 in 0 0 0 https://x.com/undefined/status/2054996678391091685 1814143201718247424
7 2055002136027767200 Thu May 14 19:07:43 +0000 2026 0 komdigi ongkir apa gunanyaa??222 2055002136027767200 in 0 0 0 https://x.com/undefined/status/2055002136027767200 2043979880086339584
8 2054998005359751518 Thu May 14 18:51:19 +0000 2026 0 komdigi ongkir apa gunanyaa?? 2054998005359751518 in 0 0 0 https://x.com/undefined/status/2054998005359751518 2043979880086339584
9 2055002941288001599 Thu May 14 19:10:55 +0000 2026 0 yakin komdigi bikin kebijakan pembatasan gratis ongkir ini bakal bisa ngimbangin pemerataan umkm?? 2055002941288001599 in 0 0 0 https://x.com/undefined/status/2055002941288001599 2043979880086339584
10 2055269612334555468 Fri May 15 12:50:35 +0000 2026 0 komdigi + ongkir = chaos wkwkwk 2055269612334555468 in 0 0 0 https://x.com/undefined/status/2055269612334555468 2043979880086339584
11 2055001478004367487 Thu May 14 19:05:07 +0000 2026 0 maksud dari komdigi bikin kebijakan gratis ongkir ini apa yaaa? 2055001478004367487 in 0 0 0 https://x.com/undefined/status/2055001478004367487 2043979880086339584
12 2055013912555475243 Thu May 14 19:54:31 +0000 2026 0 komdigi haloo?? masa iya ngurusin ongkir dulu dibanding permasalahan sebelah?? 2055013912555475243 in 0 0 0 https://x.com/undefined/status/2055013912555475243 2043979880086339584

View File

@ -1,21 +1,6 @@
"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"
"2054502269576728775","Wed May 13 10:01:26 +0000 2026","0","Kebijakan komdigi soal ongkir sangat merugikan konsumen.","2054502269576728775","","","in","","0","0","0","https://x.com/undefined/status/2054502269576728775","1814143201718247424",
"2054823109291852155","Thu May 14 07:16:20 +0000 2026","0","komdigi bikin aturan ongkir baru = langkah yang cukup bijak","2054823109291852155","","","in","","0","0","0","https://x.com/undefined/status/2054823109291852155","1814143201718247424",
"2054502892468662645","Wed May 13 10:03:55 +0000 2026","0","Komdigi dan gratis ongkir sedang ramai dibahas.","2054502892468662645","","","in","","0","0","0","https://x.com/undefined/status/2054502892468662645","1814143201718247424",
"2054501918895202348","Wed May 13 10:00:02 +0000 2026","0","Mantap sih @komdigi akhirnya free ongkir diatur juga ","2054501918895202348","","","in","","0","0","0","https://x.com/undefined/status/2054501918895202348","1814143201718247424",
"2054861268750901484","Thu May 14 09:47:58 +0000 2026","0","komdigi ongkir?","2054861268750901484","","","in","","0","0","0","https://x.com/undefined/status/2054861268750901484","1814143201718247424",
"2054502131672236531","Wed May 13 10:00:53 +0000 2026","0","kebijakan komdigi tentang ongkir patut diapresiasi.","2054502131672236531","","","in","","0","0","0","https://x.com/undefined/status/2054502131672236531","1814143201718247424",
"2054478880933855563","Wed May 13 08:28:30 +0000 2026","0","KOMDIGI KNP NGURUSIN ONGKIR SIH??","2054478880933855563","","","in","","0","0","0","https://x.com/undefined/status/2054478880933855563","1814143201718247424",
"2054502618484134392","Wed May 13 10:02:49 +0000 2026","0","@komdigi kenapa sih harus ngurusin ongkir segala???","2054502618484134392","","","in","","0","0","0","https://x.com/undefined/status/2054502618484134392","1814143201718247424",
"2054816992662790146","Thu May 14 06:52:02 +0000 2026","0","komdigi??? ongkir??? marketplace???","2054816992662790146","","","in","","0","0","0","https://x.com/undefined/status/2054816992662790146","1814143201718247424",
"2054502958315020394","Wed May 13 10:04:10 +0000 2026","0","Komdigi disebut dalam diskusi mengenai free ongkir.","2054502958315020394","","","in","","0","0","0","https://x.com/undefined/status/2054502958315020394","1814143201718247424",
"2054501981369352445","Wed May 13 10:00:17 +0000 2026","0","Gratis ongkir dibatasi malah bikin marketplace lebih fair.","2054501981369352445","","","in","","0","0","0","https://x.com/undefined/status/2054501981369352445","1814143201718247424",
"2054502181446078852","Wed May 13 10:01:05 +0000 2026","0","Menurut saya pembatasan gratis ongkir adalah keputusan tepat","2054502181446078852","","","in","","0","0","0","https://x.com/undefined/status/2054502181446078852","1814143201718247424",
"2054816880318427497","Thu May 14 06:51:35 +0000 2026","0","Ada diskusi publik mengenai kebijakan ongkir dari Komdigi.","2054816880318427497","","","in","","0","0","0","https://x.com/undefined/status/2054816880318427497","1814143201718247424",
"2054816818741862819","Thu May 14 06:51:20 +0000 2026","0","Saya melihat berita tentang komdigi dan ongkir tadi pagi.","2054816818741862819","","","in","","0","0","0","https://x.com/undefined/status/2054816818741862819","1814143201718247424",
"2054501862066524547","Wed May 13 09:59:49 +0000 2026","0","Saya setuju kebijakan Komdigi soal gratis ongkir dibatasi biar persaingan lebih sehat.","2054501862066524547","","","in","","0","0","0","https://x.com/undefined/status/2054501862066524547","1814143201718247424",
"2054502430956769299","Wed May 13 10:02:05 +0000 2026","0","Pembatasan gratis ongkir sangat mengecewakan.","2054502430956769299","","","in","","0","0","0","https://x.com/undefined/status/2054502430956769299","1814143201718247424",
"2054502817910706448","Wed May 13 10:03:37 +0000 2026","0","Saya membaca berita tentang gratis ongkir dibatasi.","2054502817910706448","","","in","","0","0","0","https://x.com/undefined/status/2054502817910706448","1814143201718247424",
"2054791169775964388","Thu May 14 05:09:25 +0000 2026","0","kebijakan komdigi gratis ongkir kerenn","2054791169775964388","","","in","","0","0","0","https://x.com/undefined/status/2054791169775964388","1814143201718247424",
"2054502657201733861","Wed May 13 10:02:58 +0000 2026","0","Gratis ongkir dibatasi? keputusan paling aneh tahun ini.","2054502657201733861","","","in","","0","0","0","https://x.com/undefined/status/2054502657201733861","1814143201718247424",
"2054502713959014566","Wed May 13 10:03:12 +0000 2026","0","Gratis ongkir dibatasi = belanja online makin mahal ","2054502713959014566","","","in","","0","0","0","https://x.com/undefined/status/2054502713959014566","1814143201718247424",
"2056635322419777630","Tue May 19 07:17:25 +0000 2026","0","komdigi ongkir gk jelas","2056635322419777630","","","in","","0","0","0","https://x.com/undefined/status/2056635322419777630","1814143201718247424",
"2056892677149581606","Wed May 20 00:20:04 +0000 2026","0","komdigi keren sih mengadakan kebijakan ongkir kaya gini","2056892677149581606","","","in","","0","0","0","https://x.com/undefined/status/2056892677149581606","1814143201718247424",
"2056949159845101648","Wed May 20 04:04:30 +0000 2026","0","komdigi kereenn dan bijak membuat keputusan ongkir seperti inii","2056949159845101648","","","in","","0","0","0","https://x.com/undefined/status/2056949159845101648","1814143201718247424",
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","5","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",
"2055269612334555468","Fri May 15 12:50:35 +0000 2026","0","komdigi + ongkir = chaos wkwkwk","2055269612334555468","","","in","","0","0","0","https://x.com/undefined/status/2055269612334555468","2043979880086339584",

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 2054502269576728775 2056635322419777630 Wed May 13 10:01:26 +0000 2026 Tue May 19 07:17:25 +0000 2026 0 Kebijakan komdigi soal ongkir sangat merugikan konsumen. komdigi ongkir gk jelas 2054502269576728775 2056635322419777630 in 0 0 0 https://x.com/undefined/status/2054502269576728775 https://x.com/undefined/status/2056635322419777630 1814143201718247424
3 2054823109291852155 2056892677149581606 Thu May 14 07:16:20 +0000 2026 Wed May 20 00:20:04 +0000 2026 0 komdigi bikin aturan ongkir baru = langkah yang cukup bijak komdigi keren sih mengadakan kebijakan ongkir kaya gini 2054823109291852155 2056892677149581606 in 0 0 0 https://x.com/undefined/status/2054823109291852155 https://x.com/undefined/status/2056892677149581606 1814143201718247424
4 2054502892468662645 2056949159845101648 Wed May 13 10:03:55 +0000 2026 Wed May 20 04:04:30 +0000 2026 0 Komdigi dan gratis ongkir sedang ramai dibahas. komdigi kereenn dan bijak membuat keputusan ongkir seperti inii 2054502892468662645 2056949159845101648 in 0 0 0 https://x.com/undefined/status/2054502892468662645 https://x.com/undefined/status/2056949159845101648 1814143201718247424
5 2054501918895202348 2056538953340314095 Wed May 13 10:00:02 +0000 2026 Wed May 20 16:01:04 +0000 2026 0 5 Mantap sih @komdigi akhirnya free ongkir diatur juga @killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl 2054501918895202348 2057129489994510620 https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg killingmaster27 in 0 0 2 0 https://x.com/undefined/status/2054501918895202348 https://x.com/undefined/status/2057129489994510620 1814143201718247424 1410466956134608899
6 2054861268750901484 2055269612334555468 Thu May 14 09:47:58 +0000 2026 Fri May 15 12:50:35 +0000 2026 0 komdigi ongkir? komdigi + ongkir = chaos wkwkwk 2054861268750901484 2055269612334555468 in 0 0 0 https://x.com/undefined/status/2054861268750901484 https://x.com/undefined/status/2055269612334555468 1814143201718247424 2043979880086339584
2054502131672236531 Wed May 13 10:00:53 +0000 2026 0 kebijakan komdigi tentang ongkir patut diapresiasi. 2054502131672236531 in 0 0 0 https://x.com/undefined/status/2054502131672236531 1814143201718247424
2054478880933855563 Wed May 13 08:28:30 +0000 2026 0 KOMDIGI KNP NGURUSIN ONGKIR SIH?? 2054478880933855563 in 0 0 0 https://x.com/undefined/status/2054478880933855563 1814143201718247424
2054502618484134392 Wed May 13 10:02:49 +0000 2026 0 @komdigi kenapa sih harus ngurusin ongkir segala??? 2054502618484134392 in 0 0 0 https://x.com/undefined/status/2054502618484134392 1814143201718247424
2054816992662790146 Thu May 14 06:52:02 +0000 2026 0 komdigi??? ongkir??? marketplace??? 2054816992662790146 in 0 0 0 https://x.com/undefined/status/2054816992662790146 1814143201718247424
2054502958315020394 Wed May 13 10:04:10 +0000 2026 0 Komdigi disebut dalam diskusi mengenai free ongkir. 2054502958315020394 in 0 0 0 https://x.com/undefined/status/2054502958315020394 1814143201718247424
2054501981369352445 Wed May 13 10:00:17 +0000 2026 0 Gratis ongkir dibatasi malah bikin marketplace lebih fair. 2054501981369352445 in 0 0 0 https://x.com/undefined/status/2054501981369352445 1814143201718247424
2054502181446078852 Wed May 13 10:01:05 +0000 2026 0 Menurut saya pembatasan gratis ongkir adalah keputusan tepat 2054502181446078852 in 0 0 0 https://x.com/undefined/status/2054502181446078852 1814143201718247424
2054816880318427497 Thu May 14 06:51:35 +0000 2026 0 Ada diskusi publik mengenai kebijakan ongkir dari Komdigi. 2054816880318427497 in 0 0 0 https://x.com/undefined/status/2054816880318427497 1814143201718247424
2054816818741862819 Thu May 14 06:51:20 +0000 2026 0 Saya melihat berita tentang komdigi dan ongkir tadi pagi. 2054816818741862819 in 0 0 0 https://x.com/undefined/status/2054816818741862819 1814143201718247424
2054501862066524547 Wed May 13 09:59:49 +0000 2026 0 Saya setuju kebijakan Komdigi soal gratis ongkir dibatasi biar persaingan lebih sehat. 2054501862066524547 in 0 0 0 https://x.com/undefined/status/2054501862066524547 1814143201718247424
2054502430956769299 Wed May 13 10:02:05 +0000 2026 0 Pembatasan gratis ongkir sangat mengecewakan. 2054502430956769299 in 0 0 0 https://x.com/undefined/status/2054502430956769299 1814143201718247424
2054502817910706448 Wed May 13 10:03:37 +0000 2026 0 Saya membaca berita tentang gratis ongkir dibatasi. 2054502817910706448 in 0 0 0 https://x.com/undefined/status/2054502817910706448 1814143201718247424
2054791169775964388 Thu May 14 05:09:25 +0000 2026 0 kebijakan komdigi gratis ongkir kerenn 2054791169775964388 in 0 0 0 https://x.com/undefined/status/2054791169775964388 1814143201718247424
2054502657201733861 Wed May 13 10:02:58 +0000 2026 0 Gratis ongkir dibatasi? keputusan paling aneh tahun ini. 2054502657201733861 in 0 0 0 https://x.com/undefined/status/2054502657201733861 1814143201718247424
2054502713959014566 Wed May 13 10:03:12 +0000 2026 0 Gratis ongkir dibatasi = belanja online makin mahal 2054502713959014566 in 0 0 0 https://x.com/undefined/status/2054502713959014566 1814143201718247424