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

33
app.py
View File

@ -857,20 +857,43 @@ with st.sidebar:
</div> </div>
""", unsafe_allow_html=True) """, unsafe_allow_html=True)
# ── QUERY ────────────────────────────────────────────────────
# ── QUERY ──────────────────────────────────────────────────── # ── QUERY ────────────────────────────────────────────────────
crawler_query = os.getenv( crawler_query = os.getenv(
"QUERY", "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"'
)
# 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,
) )
query_html = escape(crawler_query)
st.markdown(f""" st.markdown(f"""
<div class="sidebar-card"> <div class="sidebar-card">
<div style="font-size:0.72rem;font-weight:800;color:#0f172a;margin-bottom:0.55rem;"> <div style="font-size:0.72rem;font-weight:800;color:#0f172a;margin-bottom:0.5rem;">
Query Aktif 🔍 Query Aktif
</div>
<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 style="font-size:0.74rem;color:#475569;line-height:1.8;word-break:break-word;">
{query_html}
</div> </div>
</div> </div>
""", unsafe_allow_html=True) """, 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"<strong>{NRT_INTERVAL_MINUTES} menit</strong>. "
f"Data mencakup <strong>7 hari terakhir</strong> berdasarkan tanggal asli tweet." f"Data mencakup <strong>7 hari terakhir</strong> berdasarkan tanggal asli tweet."
) )
badge = " LIVE" badge = "&#9679; LIVE"
else: else:
bg = "linear-gradient(135deg,#eff6ff,#dbeafe)" bg = "linear-gradient(135deg,#eff6ff,#dbeafe)"
bdr = "#93c5fd" bdr = "#93c5fd"
@ -369,9 +369,9 @@ def _render_nrt_status_banner(sched_ok):
title = "Crawler Siap Dijalankan" title = "Crawler Siap Dijalankan"
desc = ( desc = (
f"Scheduler otomatis aktif setiap <strong>{NRT_INTERVAL_MINUTES} menit</strong> " 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""" st.markdown(f"""
<div style="background:{bg};border:1px solid {bdr};border-radius:14px; <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: if remaining.total_seconds() > 0:
countdown_str = format_countdown(remaining) countdown_str = format_countdown(remaining)
elif is_crawler_running(): elif is_crawler_running():
countdown_str = "Sedang crawling..." countdown_str = "Sedang crawling..."
else: else:
countdown_str = "🔄 Segera diperbarui..." countdown_str = "Segera diperbarui..."
status_text = "Aktif Otomatis" if sched_ok else "Standby" status_text = "Aktif Otomatis" if sched_ok else "Standby"
status_color = "#16a34a" if sched_ok else "#3b6cf7" status_color = "#16a34a" if sched_ok else "#3b6cf7"
status_bg = "#dcfce7" if sched_ok else "#dbeafe" 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_bg = "#f0fdf4" if tweet_today > 0 else "#f8fafc"
note_border = "#bbf7d0" if tweet_today > 0 else "#e8edf5" note_border = "#bbf7d0" if tweet_today > 0 else "#e8edf5"
note_color = "#166534" if tweet_today > 0 else "#64748b" 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") col_a, col_b = st.columns(2, gap="medium")
@ -467,26 +467,28 @@ def _render_monitoring_panel(last_log, last_dt, sched_ok):
rows_html_a = "" rows_html_a = ""
for i, (label, value, vc) in enumerate(left_rows): for i, (label, value, vc) in enumerate(left_rows):
border = "none" if i == len(left_rows) - 1 else "1px solid #f1f5f9" border = "none" if i == len(left_rows) - 1 else "1px solid #f1f5f9"
rows_html_a += f""" rows_html_a += (
<div style="display:flex;justify-content:space-between;align-items:center; f'<div style="display:flex;justify-content:space-between;align-items:center;'
padding:0.6rem 0;border-bottom:{border};"> f'padding:0.6rem 0;border-bottom:{border};">'
<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span> f'<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span>'
<span style="font-size:0.78rem;font-weight:700;color:{vc}; f'<span style="font-size:0.78rem;font-weight:700;color:{vc};'
text-align:right;flex-shrink:0;max-width:58%;">{value}</span> f'text-align:right;flex-shrink:0;max-width:58%;">{value}</span>'
</div>""" f'</div>'
)
with col_a: with col_a:
st.markdown(f""" st.markdown(
<div class="crawl-card"> f'<div class="crawl-card">'
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.9rem;"> f'<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; f'<span style="font-size:0.71rem;font-weight:800;color:#94a3b8;'
letter-spacing:0.07em;text-transform:uppercase;">⚙️ Sistem Crawling</span> f'letter-spacing:0.07em;text-transform:uppercase;">Sistem Crawling</span>'
<span style="background:{status_bg};color:{status_color};font-size:0.68rem; f'<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> f'font-weight:700;padding:0.22rem 0.7rem;border-radius:999px;">{status_text}</span>'
</div> f'</div>'
{rows_html_a} f'{rows_html_a}'
</div> f'</div>',
""", unsafe_allow_html=True) unsafe_allow_html=True,
)
# ── Right card: ringkasan dataset ── # ── Right card: ringkasan dataset ──
right_rows = [ right_rows = [
@ -499,27 +501,28 @@ def _render_monitoring_panel(last_log, last_dt, sched_ok):
rows_html_b = "" rows_html_b = ""
for i, (label, value, vc) in enumerate(right_rows): for i, (label, value, vc) in enumerate(right_rows):
border = "none" if i == len(right_rows) - 1 else "1px solid #f1f5f9" border = "none" if i == len(right_rows) - 1 else "1px solid #f1f5f9"
rows_html_b += f""" rows_html_b += (
<div style="display:flex;justify-content:space-between;align-items:center; f'<div style="display:flex;justify-content:space-between;align-items:center;'
padding:0.6rem 0;border-bottom:{border};"> f'padding:0.6rem 0;border-bottom:{border};">'
<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span> f'<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span>'
<span style="font-size:0.78rem;font-weight:700;color:{vc}; f'<span style="font-size:0.78rem;font-weight:700;color:{vc};'
text-align:right;flex-shrink:0;max-width:58%;">{value}</span> f'text-align:right;flex-shrink:0;max-width:58%;">{value}</span>'
</div>""" f'</div>'
)
with col_b: with col_b:
st.markdown(f""" st.markdown(
<div class="crawl-card"> f'<div class="crawl-card">'
<div style="font-size:0.71rem;font-weight:800;color:#94a3b8; f'<div style="font-size:0.71rem;font-weight:800;color:#94a3b8;'
letter-spacing:0.07em;text-transform:uppercase;margin-bottom:0.9rem;"> f'letter-spacing:0.07em;text-transform:uppercase;margin-bottom:0.9rem;">'
📊 Ringkasan Dataset f'Ringkasan Dataset</div>'
</div> f'{rows_html_b}'
{rows_html_b} f'<div style="margin-top:0.9rem;background:{note_bg};border:1px solid {note_border};'
<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;'
border-radius:10px;padding:0.7rem 0.85rem;font-size:0.76rem; f'font-weight:700;color:{note_color};">{note_text}</div>'
font-weight:700;color:{note_color};">{note_text}</div> f'</div>',
</div> unsafe_allow_html=True,
""", 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): def _render_metrics(total, days, earliest, latest, filter_label, basis_label):
st.markdown(f""" st.markdown(
<div style="display:flex;align-items:center;gap:0.5rem;margin:0.2rem 0 1.1rem;"> 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; f'<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> f'width:20px;height:20px;background:#eef2ff;border-radius:5px;font-size:0.75rem;">&#128260;</span>'
<span style="font-size:0.78rem;color:#64748b;"> f'<span style="font-size:0.78rem;color:#64748b;">'
Data aktif: <strong style="color:#334155;">{filter_label}</strong> f'Data aktif: <strong style="color:#334155;">{filter_label}</strong>'
&nbsp;·&nbsp; {basis_label} f'&nbsp;&middot;&nbsp; {basis_label}'
</span> f'</span></div>',
</div> unsafe_allow_html=True,
""", unsafe_allow_html=True) )
pills = [ 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"), "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), "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"), "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"), "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): for col, (icon, bg, color, dark, border_c, label, val, sub) in zip([c1, c2, c3, c4], pills):
with col: with col:
fs = "1.1rem" if len(str(val)) > 10 else "1.55rem" fs = "1.1rem" if len(str(val)) > 10 else "1.55rem"
st.markdown(f""" st.markdown(
<div class="metric-pill" style="background:{bg};border:1px solid {border_c}; f'<div class="metric-pill" style="background:{bg};border:1px solid {border_c};'
box-shadow:0 2px 10px {color}18;"> f'box-shadow:0 2px 10px {color}18;">'
<div style="width:36px;height:36px;background:{color};border-radius:10px; f'<div style="width:36px;height:36px;background:{color};border-radius:10px;'
display:flex;align-items:center;justify-content:center; f'display:flex;align-items:center;justify-content:center;'
font-size:0.95rem;margin:0 auto 0.65rem; f'font-size:0.95rem;margin:0 auto 0.65rem;'
box-shadow:0 4px 10px {color}44;">{icon}</div> f'box-shadow:0 4px 10px {color}44;">{icon}</div>'
<div style="font-size:0.62rem;font-weight:800;color:{color}; f'<div style="font-size:0.62rem;font-weight:800;color:{color};'
text-transform:uppercase;letter-spacing:0.07em;margin-bottom:0.3rem;">{label}</div> f'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}; f'<div class="stat-num" style="font-size:{fs};font-weight:800;color:{dark};'
line-height:1.15;margin-bottom:0.25rem;">{val}</div> f'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> f'<div style="font-size:0.66rem;color:{color};font-weight:600;opacity:0.85;">{sub}</div>'
</div> f'</div>',
""", unsafe_allow_html=True) 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")) legend_items.insert(1, ("#a78bfa", "Terendah"))
if any(c == 0 for c in counts_day): if any(c == 0 for c in counts_day):
legend_items.append(("#cbd5e1", "Tidak ada data")) legend_items.append(("#cbd5e1", "Tidak ada data"))
legend_html = "".join([ legend_html = "".join([
f'<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;">' 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>' 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): def _date_pills(days_list, color, max_show=4):
if not days_list: 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 = "" pills = ""
for d in days_list[:max_show]: for d in days_list[:max_show]:
pills += ( 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>' f'font-weight:700;margin:2px 2px 0 0;">{d}</span>'
) )
if len(days_list) > max_show: 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 return pills
if not busiest_list: 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: elif len(busiest_list) == 1:
busy_main, busy_count, busy_pills = busiest_list[0], f"{max_cnt:,} tweet", "" busy_main, busy_count, busy_pills = busiest_list[0], f"{max_cnt:,} tweet", ""
else: 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") busy_pills = _date_pills(busiest_list, "#ea580c")
if not quietest_list: 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: elif len(quietest_list) == 1:
quiet_main, quiet_count, quiet_pills = quietest_list[0], f"{min_cnt:,} tweet", "" quiet_main, quiet_count, quiet_pills = quietest_list[0], f"{min_cnt:,} tweet", ""
else: 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", "" aktif_main, aktif_count, aktif_pills = "0 Hari Aktif", f"Dari {total_days} hari, belum ada data", ""
elif active_days == total_days: elif active_days == total_days:
aktif_main = f"{active_days} / {total_days} Hari" 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 = "" aktif_pills = ""
else: else:
aktif_main = f"{active_days} / {total_days} Hari" 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 = "" aktif_pills = ""
def _insight_card(col, icon, bg, color, dark, border_c, title, main, count_txt, pills_html): 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>' f'<div style="margin-top:0.4rem;line-height:1.8;">{pills_html}</div>'
if pills_html else "" if pills_html else ""
) )
st.markdown(f""" st.markdown(
<div class="mode-card" style="background:{bg};border:1px solid {border_c}; f'<div class="mode-card" style="background:{bg};border:1px solid {border_c};'
border-radius:14px;padding:0.9rem 1rem 0.95rem; f'border-radius:14px;padding:0.9rem 1rem 0.95rem;'
box-shadow:0 3px 12px {color}12;min-height:106px;"> f'box-shadow:0 3px 12px {color}12;min-height:106px;">'
<div style="display:flex;align-items:center;gap:0.55rem;margin-bottom:0.55rem;"> f'<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; f'<div style="width:30px;height:30px;background:{color};border-radius:8px;'
display:flex;align-items:center;justify-content:center; f'display:flex;align-items:center;justify-content:center;'
font-size:0.85rem;flex-shrink:0;box-shadow:0 3px 7px {color}38;">{icon}</div> f'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}; f'<span style="font-size:0.58rem;font-weight:800;color:{color};'
text-transform:uppercase;letter-spacing:0.08em;">{title}</span> f'text-transform:uppercase;letter-spacing:0.08em;">{title}</span>'
</div> f'</div>'
<div style="font-size:0.95rem;font-weight:800;color:{dark}; f'<div style="font-size:0.95rem;font-weight:800;color:{dark};'
line-height:1.25;margin-bottom:0.15rem;">{main}</div> f'line-height:1.25;margin-bottom:0.15rem;">{main}</div>'
<div style="font-size:0.67rem;color:{color};font-weight:500; f'<div style="font-size:0.67rem;color:{color};font-weight:500;'
opacity:0.9;line-height:1.45;">{count_txt}</div> f'opacity:0.9;line-height:1.45;">{count_txt}</div>'
{pills_block} f'{pills_block}'
</div> f'</div>',
""", unsafe_allow_html=True) unsafe_allow_html=True,
)
_insight_card(pc1, "🔥", "linear-gradient(135deg,#fff7ed,#ffedd5)", _insight_card(pc1, "&#128293;", "linear-gradient(135deg,#fff7ed,#ffedd5)",
"#ea580c", "#7c2d12", "#fed7aa", "#ea580c", "#7c2d12", "#fed7aa",
"Hari Paling Ramai", busy_main, busy_count, busy_pills) "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", "#7c3aed", "#3b0764", "#ddd6fe",
"Hari Paling Sepi", quiet_main, quiet_count, quiet_pills) "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", "#16a34a", "#14532d", "#bbf7d0",
"Hari Aktif", aktif_main, aktif_count, aktif_pills) "Hari Aktif", aktif_main, aktif_count, aktif_pills)
@ -771,9 +780,9 @@ def get_filtered_data(df_all):
mode = st.session_state.analysis_mode mode = st.session_state.analysis_mode
if mode == "realtime": if mode == "realtime":
mode_display = "Tweet Terkini 7 Hari Terakhir" mode_display = "Tweet Terkini &mdash; 7 Hari Terakhir"
mode_color = "#16a34a" mode_color = "#16a34a"
mode_icon = "📡" mode_icon = "&#128225;"
date_col = "created_at" date_col = "created_at"
basis_label = "Berdasarkan tanggal asli tweet" basis_label = "Berdasarkan tanggal asli tweet"
chart_label = "tanggal asli tweet" chart_label = "tanggal asli tweet"
@ -783,7 +792,7 @@ def get_filtered_data(df_all):
elif mode == "30days": elif mode == "30days":
mode_display = "30 Hari Terakhir" mode_display = "30 Hari Terakhir"
mode_color = "#3b6cf7" mode_color = "#3b6cf7"
mode_icon = "📅" mode_icon = "&#128197;"
date_col = "created_at" date_col = "created_at"
basis_label = "Berdasarkan tanggal asli tweet" basis_label = "Berdasarkan tanggal asli tweet"
chart_label = "tanggal asli tweet" chart_label = "tanggal asli tweet"
@ -793,7 +802,7 @@ def get_filtered_data(df_all):
elif mode == "captured": elif mode == "captured":
mode_display = "Tweet Hari Ini" mode_display = "Tweet Hari Ini"
mode_color = "#0284c7" mode_color = "#0284c7"
mode_icon = "📆" mode_icon = "&#128198;"
date_col = "created_at" date_col = "created_at"
basis_label = "Berdasarkan tanggal asli tweet" basis_label = "Berdasarkan tanggal asli tweet"
chart_label = "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) end_date = st.session_state.get("custom_end_date", today)
mode_display = "Periode Historis Pilihan" mode_display = "Periode Historis Pilihan"
mode_color = "#d97706" mode_color = "#d97706"
mode_icon = "🔍" mode_icon = "&#128269;"
date_col = "created_at" date_col = "created_at"
basis_label = "Berdasarkan tanggal asli tweet" basis_label = "Berdasarkan tanggal asli tweet"
chart_label = "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 # MAIN PAGE
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
@ -850,51 +999,7 @@ def show():
st.session_state.filter_end_date = today st.session_state.filter_end_date = today
# ── Page Header ────────────────────────────────────────────── # ── Page Header ──────────────────────────────────────────────
st.markdown(f""" _render_page_header()
<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)
# ── Mode Selector ───────────────────────────────────────────── # ── Mode Selector ─────────────────────────────────────────────
if "analysis_mode" not in st.session_state: if "analysis_mode" not in st.session_state:
@ -904,29 +1009,29 @@ def show():
MODE_CFG = { MODE_CFG = {
"captured": { "captured": {
"col": col1, "key": "btn_captured", "label": "📆 Hari Ini", "col": col1, "key": "btn_captured", "label": "Hari Ini",
"title": "Tweet Hari Ini", "icon": "📆", "title": "Tweet Hari Ini", "icon": "&#128198;",
"desc": "Tanggal asli <strong>hari ini</strong>", "desc": "Tanggal asli <strong>hari ini</strong>",
"gradient": "linear-gradient(135deg,#eff6ff,#dbeafe)", "gradient": "linear-gradient(135deg,#eff6ff,#dbeafe)",
"border": "#bfdbfe", "color": "#1d4ed8", "dark": "#1e3a8a", "border": "#bfdbfe", "color": "#1d4ed8", "dark": "#1e3a8a",
}, },
"realtime": { "realtime": {
"col": col2, "key": "btn_realtime", "label": "📡 7 Hari", "col": col2, "key": "btn_realtime", "label": "7 Hari",
"title": "Terkini (7 Hari)", "icon": "📡", "title": "Terkini (7 Hari)", "icon": "&#128225;",
"desc": "<strong>7 hari terakhir</strong>", "desc": "<strong>7 hari terakhir</strong>",
"gradient": "linear-gradient(135deg,#f0fdf4,#dcfce7)", "gradient": "linear-gradient(135deg,#f0fdf4,#dcfce7)",
"border": "#86efac", "color": "#16a34a", "dark": "#14532d", "border": "#86efac", "color": "#16a34a", "dark": "#14532d",
}, },
"30days": { "30days": {
"col": col3, "key": "btn_30days", "label": "📅 30 Hari", "col": col3, "key": "btn_30days", "label": "30 Hari",
"title": "30 Hari Terakhir", "icon": "📅", "title": "30 Hari Terakhir", "icon": "&#128197;",
"desc": "<strong>30 hari terakhir</strong>", "desc": "<strong>30 hari terakhir</strong>",
"gradient": "linear-gradient(135deg,#eef2ff,#e0e7ff)", "gradient": "linear-gradient(135deg,#eef2ff,#e0e7ff)",
"border": "#a5b4fc", "color": "#3b6cf7", "dark": "#1e3a8a", "border": "#a5b4fc", "color": "#3b6cf7", "dark": "#1e3a8a",
}, },
"custom": { "custom": {
"col": col4, "key": "btn_custom", "label": "🔍 Pilih Tanggal", "col": col4, "key": "btn_custom", "label": "Pilih Tanggal",
"title": "Pilih Tanggal", "icon": "🔍", "title": "Pilih Tanggal", "icon": "&#128269;",
"desc": "Historis <strong>custom</strong>", "desc": "Historis <strong>custom</strong>",
"gradient": "linear-gradient(135deg,#fefce8,#fef9c3)", "gradient": "linear-gradient(135deg,#fefce8,#fef9c3)",
"border": "#fde68a", "color": "#d97706", "dark": "#713f12", "border": "#fde68a", "color": "#d97706", "dark": "#713f12",
@ -957,20 +1062,21 @@ def show():
if active else "" if active else ""
) )
st.markdown(f""" st.markdown(
<div class="mode-card" style="background:{bg};border:{border_style}; f'<div class="mode-card" style="background:{bg};border:{border_style};'
border-radius:14px;padding:1rem 0.85rem;margin-top:0.3rem; f'border-radius:14px;padding:1rem 0.85rem;margin-top:0.3rem;'
text-align:center;box-shadow:{shadow};"> f'text-align:center;box-shadow:{shadow};">'
<div style="width:38px;height:38px;background:{icon_bg};border-radius:10px; f'<div style="width:38px;height:38px;background:{icon_bg};border-radius:10px;'
display:flex;align-items:center;justify-content:center; f'display:flex;align-items:center;justify-content:center;'
font-size:1.1rem;margin:0 auto 0.55rem; f'font-size:1.1rem;margin:0 auto 0.55rem;'
box-shadow:{icon_shadow};">{cfg['icon']}</div> f'box-shadow:{icon_shadow};">{cfg["icon"]}</div>'
<div style="font-size:0.8rem;font-weight:700;color:{dark};margin-bottom:0.22rem;"> f'<div style="font-size:0.8rem;font-weight:700;color:{dark};margin-bottom:0.22rem;">'
{cfg['title']}</div> f'{cfg["title"]}</div>'
<div style="font-size:0.68rem;color:{color};line-height:1.45;">{cfg['desc']}</div> f'<div style="font-size:0.68rem;color:{color};line-height:1.45;">{cfg["desc"]}</div>'
{underline} f'{underline}'
</div> f'</div>',
""", unsafe_allow_html=True) unsafe_allow_html=True,
)
_gap("md") _gap("md")
@ -978,7 +1084,7 @@ def show():
try: try:
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine) df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
except Exception as e: except Exception as e:
st.error(f"Gagal membaca database: {str(e)}") st.error(f"Gagal membaca database: {str(e)}")
return return
if len(df_all) == 0: if len(df_all) == 0:
@ -995,18 +1101,19 @@ def show():
min_db = df_check["created_at"].min().date() min_db = df_check["created_at"].min().date()
max_db = df_check["created_at"].max().date() max_db = df_check["created_at"].max().date()
st.markdown(f""" st.markdown(
<div class="crawl-card" style="margin-bottom:1rem;"> '<div class="crawl-card" style="margin-bottom:1rem;">'
<p style="font-size:0.875rem;font-weight:700;color:#0f172a;margin:0 0 0.25rem;"> '<p style="font-size:0.875rem;font-weight:700;color:#0f172a;margin:0 0 0.25rem;">'
📅 Pilih Rentang Tanggal Tweet Historis</p> '&#128197; Pilih Rentang Tanggal Tweet Historis</p>'
<p style="font-size:0.775rem;color:#64748b;margin:0;line-height:1.6;"> f'<p style="font-size:0.775rem;color:#64748b;margin:0;line-height:1.6;">'
Data tersedia dari f'Data tersedia dari '
<strong style="color:#0f172a;">{min_db.strftime('%d/%m/%Y')}</strong> f'<strong style="color:#0f172a;">{min_db.strftime("%d/%m/%Y")}</strong>'
hingga f' hingga '
<strong style="color:#0f172a;">{max_db.strftime('%d/%m/%Y')}</strong> f'<strong style="color:#0f172a;">{max_db.strftime("%d/%m/%Y")}</strong>'
</p> f'</p>'
</div> '</div>',
""", unsafe_allow_html=True) unsafe_allow_html=True,
)
with st.container(): with st.container():
c1, c2, c3 = st.columns([2, 2, 1], gap="medium") c1, c2, c3 = st.columns([2, 2, 1], gap="medium")
@ -1038,13 +1145,13 @@ def show():
key="custom_end_input") key="custom_end_input")
st.session_state.custom_end_date = ce st.session_state.custom_end_date = ce
with c3: with c3:
if st.button("Terapkan", type="primary", use_container_width=True): if st.button("Terapkan", type="primary", use_container_width=True):
st.success("Periode diterapkan!") st.success("Periode diterapkan!")
time.sleep(0.8) time.sleep(0.8)
st.rerun() st.rerun()
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# MODE: TWEET HARI INI (captured) — monitoring + log table # MODE: TWEET HARI INI (captured)
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
if st.session_state.analysis_mode == "captured": if st.session_state.analysis_mode == "captured":
st_autorefresh(interval=1000, key="crawler_monitor_refresh") st_autorefresh(interval=1000, key="crawler_monitor_refresh")
@ -1075,14 +1182,14 @@ def show():
crawl_time = log.get("timestamp", "-") or "-" crawl_time = log.get("timestamp", "-") or "-"
if status == "success": if status == "success":
status_label = "Berhasil" status_label = "Berhasil"
note = ( note = (
f"{total_saved:,} tweet baru tersimpan" f"{total_saved:,} tweet baru tersimpan"
if total_saved > 0 if total_saved > 0
else "Crawling berhasil, tidak ada tweet baru" else "Crawling berhasil, tidak ada tweet baru"
) )
else: else:
status_label = "Gagal" status_label = "Gagal"
note = error_msg or "Terjadi kesalahan saat crawling" note = error_msg or "Terjadi kesalahan saat crawling"
rows.append({ rows.append({
@ -1094,8 +1201,8 @@ def show():
}) })
_section_header( _section_header(
"🕒 Riwayat Crawling Otomatis", "&#128336; Riwayat Crawling Otomatis",
f"{len(rows)} aktivitas terbaru · interval {NRT_INTERVAL_MINUTES} menit" f"{len(rows)} aktivitas terbaru &middot; interval {NRT_INTERVAL_MINUTES} menit"
) )
_gap("xs") _gap("xs")
render_standard_table( render_standard_table(
@ -1111,17 +1218,18 @@ def show():
}, },
) )
else: else:
st.markdown(f""" st.markdown(
<div class="crawl-card" style="text-align:center;padding:2rem;"> '<div class="crawl-card" style="text-align:center;padding:2rem;">'
<div style="font-size:2rem;margin-bottom:0.75rem;"></div> '<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;"> '<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">'
Menunggu Crawl Pertama</div> 'Menunggu Crawl Pertama</div>'
<div style="font-size:0.77rem;color:#64748b;"> f'<div style="font-size:0.77rem;color:#64748b;">'
Riwayat akan muncul setelah crawl otomatis pertama selesai f'Riwayat akan muncul setelah crawl otomatis pertama selesai '
(maks. {NRT_INTERVAL_MINUTES} menit sejak aplikasi dibuka) f'(maks. {NRT_INTERVAL_MINUTES} menit sejak aplikasi dibuka)'
</div> f'</div>'
</div> '</div>',
""", unsafe_allow_html=True) unsafe_allow_html=True,
)
_gap("lg") _gap("lg")
@ -1140,22 +1248,23 @@ def show():
].sort_values("crawled_at", ascending=False) ].sort_values("crawled_at", ascending=False)
_section_header( _section_header(
"🤖 Tweet yang Diambil Bot Hari Ini", "&#129302; Tweet yang Diambil Bot Hari Ini",
"Semua tweet yang berhasil disimpan crawler hari ini" "Semua tweet yang berhasil disimpan crawler hari ini"
) )
_gap("xs") _gap("xs")
if bot_df.empty: if bot_df.empty:
st.markdown(f""" st.markdown(
<div class="crawl-card" style="text-align:center;padding:2rem;"> '<div class="crawl-card" style="text-align:center;padding:2rem;">'
<div style="font-size:2rem;margin-bottom:0.75rem;">🐦</div> '<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;"> '<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">'
Belum Ada Tweet Hari Ini</div> 'Belum Ada Tweet Hari Ini</div>'
<div style="font-size:0.77rem;color:#64748b;"> '<div style="font-size:0.77rem;color:#64748b;">'
Tweet baru akan muncul setelah crawl berikutnya selesai 'Tweet baru akan muncul setelah crawl berikutnya selesai'
</div> '</div>'
</div> '</div>',
""", unsafe_allow_html=True) unsafe_allow_html=True,
)
else: else:
bot_disp = bot_df.head(100).copy() bot_disp = bot_df.head(100).copy()
bot_disp["Tanggal Tweet"] = bot_disp["created_at"].apply(format_dt) bot_disp["Tanggal Tweet"] = bot_disp["created_at"].apply(format_dt)
@ -1201,32 +1310,33 @@ def show():
# ── Active Filter Banner ────────────────────────────────── # ── Active Filter Banner ──────────────────────────────────
total = len(df) total = len(df)
st.markdown(f""" st.markdown(
<div style="display:flex;align-items:center;gap:0.9rem; f'<div style="display:flex;align-items:center;gap:0.9rem;'
background:#ffffff; f'background:#ffffff;'
border-left:4px solid {mode_color}; f'border-left:4px solid {mode_color};'
border-top:1px solid #e8edf5; f'border-top:1px solid #e8edf5;'
border-right:1px solid #e8edf5; f'border-right:1px solid #e8edf5;'
border-bottom:1px solid #e8edf5; f'border-bottom:1px solid #e8edf5;'
border-radius:0 14px 14px 0; f'border-radius:0 14px 14px 0;'
padding:0.75rem 1.3rem;margin-bottom:1rem; f'padding:0.75rem 1.3rem;margin-bottom:1rem;'
box-shadow:0 2px 8px rgba(15,23,42,0.04);"> f'box-shadow:0 2px 8px rgba(15,23,42,0.04);">'
<span style="font-size:1.45rem;">{mode_icon}</span> f'<span style="font-size:1.45rem;">{mode_icon}</span>'
<div style="flex:1;"> f'<div style="flex:1;">'
<p style="font-size:0.88rem;font-weight:700;color:#0f172a;margin:0 0 2px;">{mode_display}</p> f'<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;"> f'<p style="font-size:0.74rem;color:#64748b;margin:0;">'
Periode aktif: <strong style="color:{mode_color};">{filter_label}</strong> f'Periode aktif: <strong style="color:{mode_color};">{filter_label}</strong>'
&nbsp;·&nbsp; {basis_label} f'&nbsp;&middot;&nbsp; {basis_label}'
</p> f'</p>'
</div> f'</div>'
<div style="margin-left:auto;background:{mode_color}14;color:{mode_color}; f'<div style="margin-left:auto;background:{mode_color}14;color:{mode_color};'
border:1px solid {mode_color}33; f'border:1px solid {mode_color}33;'
font-size:0.7rem;font-weight:800;padding:0.3rem 0.8rem; f'font-size:0.7rem;font-weight:800;padding:0.3rem 0.8rem;'
border-radius:999px;letter-spacing:0.04em;white-space:nowrap;"> f'border-radius:999px;letter-spacing:0.04em;white-space:nowrap;">'
{total:,} tweet f'{total:,} tweet'
</div> f'</div>'
</div> f'</div>',
""", unsafe_allow_html=True) unsafe_allow_html=True,
)
days = (pd.Timestamp(dt_end).date() - pd.Timestamp(dt_start).date()).days + 1 days = (pd.Timestamp(dt_end).date() - pd.Timestamp(dt_start).date()).days + 1
earliest = pd.Timestamp(dt_start).strftime("%d/%m/%Y") 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) _render_metrics(total, days, earliest, latest, filter_label, basis_label)
if total == 0: if total == 0:
_section_header("📋 Daftar Tweet yang Terkumpul", f"0 tweet · {filter_label}") _section_header("&#128203; Daftar Tweet yang Terkumpul", f"0 tweet &middot; {filter_label}")
st.markdown(f""" st.markdown(
<div class="crawl-card" style="text-align:center;padding:2rem;"> '<div class="crawl-card" style="text-align:center;padding:2rem;">'
<div style="font-size:2rem;margin-bottom:0.75rem;">🔍</div> '<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;"> '<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">'
Tidak Ada Data</div> 'Tidak Ada Data</div>'
<div style="font-size:0.77rem;color:#64748b;"> '<div style="font-size:0.77rem;color:#64748b;">'
Belum ada tweet dengan tanggal asli pada periode ini 'Belum ada tweet dengan tanggal asli pada periode ini'
</div> '</div>'
</div> '</div>',
""", unsafe_allow_html=True) unsafe_allow_html=True,
)
return return
# ── Chart ───────────────────────────────────────────────── # ── Chart ─────────────────────────────────────────────────
st.markdown(f""" st.markdown(
<div class="crawl-card" style="padding-bottom:0.4rem;"> 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;"> f'<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;"> f'<p style="font-size:0.87rem;font-weight:700;color:#0f172a;margin:0;">'
📊 Distribusi Tweet Per Hari</p> f'&#128202; Distribusi Tweet Per Hari</p>'
<span style="font-size:0.67rem;font-weight:600;color:#94a3b8; f'<span style="font-size:0.67rem;font-weight:600;color:#94a3b8;'
background:#f8fafc;border:1px solid #e8edf5; f'background:#f8fafc;border:1px solid #e8edf5;'
border-radius:6px;padding:0.18rem 0.55rem;">{filter_label}</span> f'border-radius:6px;padding:0.18rem 0.55rem;">{filter_label}</span>'
</div> f'</div>'
<p style="font-size:0.69rem;color:#94a3b8;margin:2px 0 0;"> f'<p style="font-size:0.69rem;color:#94a3b8;margin:2px 0 0;">'
Berdasarkan {chart_label} f'Berdasarkan {chart_label}'
</p> f'</p>'
</div> f'</div>',
""", unsafe_allow_html=True) unsafe_allow_html=True,
)
_render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end) _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end)
# ── Tabel ───────────────────────────────────────────────── # ── Tabel ─────────────────────────────────────────────────
display_limit = 100 display_limit = 100
_section_header( _section_header(
"📋 Daftar Tweet yang Terkumpul", "&#128203; Daftar Tweet yang Terkumpul",
f"Menampilkan hingga {display_limit} tweet terbaru · {filter_label}" f"Menampilkan hingga {display_limit} tweet terbaru &middot; {filter_label}"
) )
_gap("xs") _gap("xs")
@ -1297,7 +1409,7 @@ def show():
c_dl, c_nav = st.columns(2) c_dl, c_nav = st.columns(2)
with c_dl: with c_dl:
st.download_button( st.download_button(
f"📥 Unduh Semua Data ({total:,} tweet)", f"Unduh Semua Data ({total:,} tweet)",
df.to_csv(index=False).encode("utf-8"), df.to_csv(index=False).encode("utf-8"),
f"data_twitter_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", f"data_twitter_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
"text/csv", "text/csv",
@ -1305,7 +1417,7 @@ def show():
) )
with c_nav: with c_nav:
if st.button( if st.button(
"🧹 Lanjut ke Bersihkan Data", "Lanjut ke Bersihkan Data",
type="primary", type="primary",
use_container_width=True, use_container_width=True,
key="btn_go_preprocessing" key="btn_go_preprocessing"

View File

@ -1,15 +1,19 @@
""" """
preprocessing_page.py 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 1. Case Folding ubah semua huruf jadi lowercase
2. Cleaning hapus URL, mention, hashtag, angka, emoji, tanda baca 2. Cleaning hapus URL, mention, hashtag, angka, emoji, tanda baca
3. Normalisasi singkatan/slang kata baku 3. Normalisasi singkatan/slang kata baku
4. Tokenizing pecah kalimat list token 4. Stopword Removal hapus kata umum; JAGA kata sentimen penting
5. Stopword Removal hapus kata umum; JAGA kata sentimen penting 5. Stemming bentuk dasar kata via Sastrawi ECS
6. 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 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 # PENTING: Pipeline ini harus IDENTIK dengan sentiment_service.py
# agar token yang dihasilkan konsisten dengan training model. # agar token yang dihasilkan konsisten dengan training model.
# #
@ -99,9 +103,8 @@ def _sync_dynamic_period():
# 1. Case Folding → lowercase dulu sebelum cleaning # 1. Case Folding → lowercase dulu sebelum cleaning
# 2. Cleaning → hapus noise setelah lowercase # 2. Cleaning → hapus noise setelah lowercase
# 3. Normalisasi → singkatan/slang → kata baku # 3. Normalisasi → singkatan/slang → kata baku
# 4. Tokenizing → split menjadi list token # 4. Stopword Removal → buang kata umum, jaga kata sentimen
# 5. Stopword Removal → buang kata umum, jaga kata sentimen # 5. Stemming → bentuk dasar via Sastrawi ECS
# 6. Stemming → bentuk dasar via Sastrawi ECS
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
KATA_SENTIMEN_PENTING = { KATA_SENTIMEN_PENTING = {
@ -251,21 +254,16 @@ def step3_normalization(text: str) -> str:
return " ".join(NORMALISASI.get(word, word) for word in text.split()) return " ".join(NORMALISASI.get(word, word) for word in text.split())
# ── Tahap 4: Tokenizing ───────────────────────────────────────────────────── # ── Tahap 4: Stopword Removal ───────────────────────────────────────────────
def step4_tokenizing(text: str) -> list: def step4_stopword_removal(tokens: list, stopwords: set) -> list:
return text.split()
# ── Tahap 5: Stopword Removal ───────────────────────────────────────────────
def step5_stopword_removal(tokens: list, stopwords: set) -> list:
return [ return [
w for w in tokens w for w in tokens
if (w not in stopwords or w in KATA_SENTIMEN_PENTING) and len(w) > 2 if (w not in stopwords or w in KATA_SENTIMEN_PENTING) and len(w) > 2
] ]
# ── Tahap 6: Stemming ─────────────────────────────────────────────────────── # ── Tahap 5: Stemming ───────────────────────────────────────────────────────
def step6_stemming(tokens: list, stemmer) -> list: def step5_stemming(tokens: list, stemmer) -> list:
if stemmer is None: if stemmer is None:
return tokens return tokens
return [stemmer.stem(w) for w in 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): 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: URUTAN TAHAP:
1. Case Folding lowercase 1. Case Folding lowercase
2. Cleaning hapus noise 2. Cleaning hapus noise
3. Normalisasi normalisasi kata 3. Normalisasi normalisasi kata
4. Tokenizing split token 4. Stopword Removal buang stopword (split() dilakukan internal)
5. Stopword Removal buang stopword 5. Stemming bentuk dasar
6. Stemming bentuk dasar
""" """
s1_fold = step1_case_folding(text) s1_fold = step1_case_folding(text)
s2_clean = step2_cleaning(s1_fold) s2_clean = step2_cleaning(s1_fold)
s3_norm = step3_normalization(s2_clean) s3_norm = step3_normalization(s2_clean)
s4_tokens = step4_tokenizing(s3_norm) s4_filtered = step4_stopword_removal(s3_norm.split(), stopwords)
s5_filtered = step5_stopword_removal(s4_tokens, stopwords) s5_stemmed = step5_stemming(s4_filtered, stemmer)
s6_stemmed = step6_stemming(s5_filtered, stemmer)
return { return {
"setelah_casefolding": s1_fold, "setelah_casefolding": s1_fold,
"setelah_cleaning": s2_clean, "setelah_cleaning": s2_clean,
"setelah_normalisasi": s3_norm, "setelah_normalisasi": s3_norm,
"setelah_tokenizing": " | ".join(s4_tokens), "setelah_stopword": " ".join(s4_filtered),
"setelah_stopword": " ".join(s5_filtered), "clean_text": " ".join(s5_stemmed),
"clean_text": " ".join(s6_stemmed), "_tokens_clean": s5_stemmed,
"_tokens_raw": s4_tokens,
"_tokens_clean": s6_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-3 { animation: fadeRight 0.35s 0.19s ease both; }
.pipe-4 { animation: fadeRight 0.35s 0.26s ease both; } .pipe-4 { animation: fadeRight 0.35s 0.26s ease both; }
.pipe-5 { animation: fadeRight 0.35s 0.33s 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 { transition: all 0.18s ease; }
.example-box:hover { .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; 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 { .fix-badge {
display: inline-block; display: inline-block;
background: #fef9c3; background: #fef9c3;
@ -442,8 +422,8 @@ def _render_page_header():
margin:0 0 4px;letter-spacing:-0.01em;line-height:1.2;"> margin:0 0 4px;letter-spacing:-0.01em;line-height:1.2;">
Bersihkan Data</h2> Bersihkan Data</h2>
<p style="font-size:0.8rem;color:#64748b;margin:0;line-height:1.5;"> <p style="font-size:0.8rem;color:#64748b;margin:0;line-height:1.5;">
Preprocessing teks 6 tahap otomatis: Preprocessing teks 5 tahap otomatis:
<strong style="color:#059669;">Case Folding Cleaning Normalisasi Tokenizing Stopword Removal Stemming</strong> <strong style="color:#059669;">Case Folding Cleaning Normalisasi Stopword Removal Stemming</strong>
</p> </p>
</div> </div>
<div style=" <div style="
@ -512,21 +492,6 @@ def _render_pipeline_steps(stemmer_ok):
}, },
{ {
"num": "04", "anim": "pipe-4", "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", "icon": "🚫", "color": "#ea580c", "dark": "#7c2d12",
"bg": "linear-gradient(135deg,#fff7ed,#ffedd5)", "border": "#fed7aa", "bg": "linear-gradient(135deg,#fff7ed,#ffedd5)", "border": "#fed7aa",
"title": 'Stopword Removal <span class="fix-badge">✦ Diperbaiki</span>', "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", "icon": "🌱", "color": "#ca8a04", "dark": "#713f12",
"bg": "linear-gradient(135deg,#fefce8,#fef9c3)", "border": "#fde68a", "bg": "linear-gradient(135deg,#fefce8,#fef9c3)", "border": "#fde68a",
"title": "Stemming", "title": "Stemming",
@ -557,15 +522,17 @@ def _render_pipeline_steps(stemmer_ok):
}, },
] ]
# Baris 1: 3 kartu pertama
row1 = st.columns(3, gap="medium") row1 = st.columns(3, gap="medium")
for col, step in zip(row1, steps[:3]): for col, step in zip(row1, steps[:3]):
_render_step_card(col, step) _render_step_card(col, step)
_gap("sm") _gap("sm")
row2 = st.columns(3, gap="medium") # Baris 2: 2 kartu terakhir (tengah agar simetris)
for col, step in zip(row2, steps[3:]): _, col4, col5, _ = st.columns([0.5, 1, 1, 0.5], gap="medium")
_render_step_card(col, step) _render_step_card(col4, steps[3])
_render_step_card(col5, steps[4])
def _render_step_card(col, step): def _render_step_card(col, step):
@ -622,9 +589,8 @@ def _render_flow_arrow():
("① Case Folding", "#0284c7", "#eff6ff", "#bfdbfe"), ("① Case Folding", "#0284c7", "#eff6ff", "#bfdbfe"),
("② Cleaning", "#3b6cf7", "#eef2ff", "#c7d2fe"), ("② Cleaning", "#3b6cf7", "#eef2ff", "#c7d2fe"),
("③ Normalisasi", "#16a34a", "#f0fdf4", "#86efac"), ("③ Normalisasi", "#16a34a", "#f0fdf4", "#86efac"),
("④ Tokenizing", "#7c3aed", "#f5f3ff", "#c4b5fd"), ("④ Stopword", "#ea580c", "#fff7ed", "#fed7aa"),
("⑤ Stopword", "#ea580c", "#fff7ed", "#fed7aa"), ("⑤ Stemming", "#ca8a04", "#fefce8", "#fde68a"),
("⑥ Stemming", "#ca8a04", "#fefce8", "#fde68a"),
("✅ Teks Bersih", "#0f172a", "#0f172a", "#334155"), ("✅ Teks Bersih", "#0f172a", "#0f172a", "#334155"),
] ]
@ -660,14 +626,14 @@ def _render_flow_arrow():
# STAT PILLS # STAT PILLS
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
def _render_stat_pills(total_raw, total_clean, avg_tokens_before, avg_tokens_after, removed): def _render_stat_pills(total_raw, total_clean, removed):
c1, c2, c3, c4 = st.columns(4, gap="medium") c1, c2, c3 = st.columns(3, gap="medium")
cards = [ cards = [
(c1, "📥", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#c7d2fe", (c1, "📥", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#c7d2fe",
"Tweet Diproses", f"{total_raw:,}", "Total tweet periode ini"), "Tweet Diproses", f"{total_raw:,}", "Total tweet periode ini"),
(c2, "", "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#16a34a", "#14532d", "#86efac", (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, (c3,
"🗑️" if removed > 0 else "", "🗑️" if removed > 0 else "",
("linear-gradient(135deg,#fff7ed,#ffedd5)" if removed > 0 else "linear-gradient(135deg,#f0fdf4,#dcfce7)"), ("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"), ("#fed7aa" if removed > 0 else "#86efac"),
"Tweet Dibuang", f"{removed:,}", "Tweet Dibuang", f"{removed:,}",
("Teks kosong setelah preprocessing" if removed > 0 else "Semua tweet lolos")), ("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: 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" "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 = [ steps_ex = [
("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0", False), ("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0"),
("① Setelah Case Folding", "setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe", False), ("① Setelah Case Folding", "setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe"),
("② Setelah Cleaning", "setelah_cleaning", "#1e3a8a", "#eef2ff", "#c7d2fe", False), ("② Setelah Cleaning", "setelah_cleaning", "#1e3a8a", "#eef2ff", "#c7d2fe"),
("③ Setelah Normalisasi", "setelah_normalisasi", "#14532d", "#f0fdf4", "#86efac", False), ("③ Setelah Normalisasi", "setelah_normalisasi", "#14532d", "#f0fdf4", "#86efac"),
("④ Setelah Tokenizing", "setelah_tokenizing", "#3b0764", "#f5f3ff", "#c4b5fd", True), ("④ Setelah Stopword", "setelah_stopword", "#7c2d12", "#fff7ed", "#fed7aa"),
("⑤ Setelah Stopword", "setelah_stopword", "#7c2d12", "#fff7ed", "#fed7aa", False), ("⑤ Hasil Akhir (Stem)", "clean_text", "#713f12", "#fefce8", "#fde68a"),
("⑥ Hasil Akhir (Stem)", "clean_text", "#713f12", "#fefce8", "#fde68a", False),
] ]
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, "-") raw = sample.get(col_key, "-")
text_display = str(raw) if raw and str(raw).strip() else "" text_display = str(raw) if raw and str(raw).strip() else ""
if is_token and text_display != "": word_count = len(text_display.split()) if text_display != "" else 0
words = text_display.split(" | ") char_count = len(text_display) if text_display != "" else 0
chips = "".join( char_info = f"{word_count} kata · {char_count} karakter"
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 = ( content_html = (
f'<div style="font-size:0.82rem;color:{text_color};' f'<div style="font-size:0.82rem;color:{text_color};'
f'line-height:1.65;word-break:break-word;">{text_display}</div>' 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"
st.markdown( st.markdown(
f'<div class="example-box" style="background:{bg};border:1.5px solid {border};' 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 # TOP WORDS CHART
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
@ -830,7 +723,7 @@ def _render_length_comparison_chart(df, df_c):
def _render_top_words_chart(df_c): def _render_top_words_chart(df_c):
_section_header( _section_header(
"📊 Kata-Kata Paling Sering Muncul", "📊 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: if "_tokens_clean" in df_c.columns:
@ -930,8 +823,8 @@ def show():
# ── Pipeline Overview ───────────────────────────────────── # ── Pipeline Overview ─────────────────────────────────────
_section_header( _section_header(
"🔬 Alur NLP Pipeline — 6 Tahap Preprocessing", "🔬 Alur NLP Pipeline — 5 Tahap Preprocessing",
"Setiap tweet diproses berurutan melalui 6 tahap sebelum siap dianalisis sentimennya" "Setiap tweet diproses berurutan melalui 5 tahap sebelum siap dianalisis sentimennya"
) )
stemmer_tmp = _load_stemmer() stemmer_tmp = _load_stemmer()
@ -967,12 +860,12 @@ def show():
data_marker = (total_tweets_in_db, latest_crawl_marker) data_marker = (total_tweets_in_db, latest_crawl_marker)
cache_key = ( 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}" f"{start_date}_{end_date}_{total_tweets_in_db}_{latest_crawl_marker}"
) )
for old_key in list(st.session_state.keys()): 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] del st.session_state[old_key]
force_refresh = data_marker != st.session_state.get("_pp_last_data_marker") force_refresh = data_marker != st.session_state.get("_pp_last_data_marker")
@ -981,7 +874,7 @@ def show():
stemmer = _load_stemmer() stemmer = _load_stemmer()
stopwords = _load_stopwords() stopwords = _load_stopwords()
with st.spinner("🧹 Menjalankan 6 tahap preprocessing…"): with st.spinner("🧹 Menjalankan 5 tahap preprocessing…"):
results = [] results = []
for _, row in df.iterrows(): for _, row in df.iterrows():
r = full_preprocessing(row["text"], stopwords, stemmer) r = full_preprocessing(row["text"], stopwords, stemmer)
@ -1002,12 +895,6 @@ def show():
stemmer_ok = st.session_state.get(cache_key + "_stemmer_ok", False) stemmer_ok = st.session_state.get(cache_key + "_stemmer_ok", False)
# ── Statistik ───────────────────────────────────────────── # ── 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) removed = len(df) - len(df_c)
_section_header( _section_header(
@ -1015,7 +902,7 @@ def show():
f"Berdasarkan tanggal asli tweet · {filter_label}" f"Berdasarkan tanggal asli tweet · {filter_label}"
) )
_gap("xs") _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") _gap("lg")
_render_live_example(df_c) _render_live_example(df_c)
@ -1031,14 +918,13 @@ def show():
df_c = df_c.copy() df_c = df_c.copy()
df_c["crawled_at"] = pd.NaT df_c["crawled_at"] = pd.NaT
# Kolom ditampilkan sesuai urutan pipeline: CF → Clean → Norm → Token → Stop → Stem # Kolom ditampilkan sesuai urutan pipeline: CF → Clean → Norm → Stop → Stem
disp = df_c[[ disp = df_c[[
"tweet_id", "created_at", "crawled_at", "tweet_id", "created_at", "crawled_at",
"text_asli", "text_asli",
"setelah_casefolding", "setelah_casefolding",
"setelah_cleaning", "setelah_cleaning",
"setelah_normalisasi", "setelah_normalisasi",
"setelah_tokenizing",
"setelah_stopword", "setelah_stopword",
"clean_text", "clean_text",
]].copy() ]].copy()
@ -1049,9 +935,8 @@ def show():
"① Case Folding", "① Case Folding",
"② Cleaning", "② Cleaning",
"③ Normalisasi", "③ Normalisasi",
"④ Tokenizing", "④ Stopword",
"⑤ Stopword", "⑤ Hasil Akhir",
"⑥ Hasil Akhir",
] ]
disp["Tanggal Tweet"] = disp["Tanggal Tweet"].apply(format_dt) disp["Tanggal Tweet"] = disp["Tanggal Tweet"].apply(format_dt)
@ -1060,30 +945,26 @@ def show():
render_standard_table( render_standard_table(
disp, disp,
height=360, height=360,
min_width=2200, min_width=1980,
nowrap=["ID Tweet", "Tanggal Tweet", "Masuk Database"], nowrap=["ID Tweet", "Tanggal Tweet", "Masuk Database"],
wide_columns=[ wide_columns=[
"Teks Asli", "① Case Folding", "② Cleaning", "Teks Asli", "① Case Folding", "② Cleaning",
"③ Normalisasi", "Tokenizing", "Stopword", " Hasil Akhir", "③ Normalisasi", "Stopword", " Hasil Akhir",
], ],
column_widths={ column_widths={
"ID Tweet": "155px", "ID Tweet": "155px",
"Tanggal Tweet": "165px", "Tanggal Tweet": "165px",
"Masuk Database": "165px", "Masuk Database": "165px",
"Teks Asli": "280px", "Teks Asli": "280px",
"① Case Folding": "220px", "① Case Folding": "240px",
"② Cleaning": "240px", "② Cleaning": "240px",
"③ Normalisasi": "220px", "③ Normalisasi": "240px",
"④ Tokenizing": "240px", "④ Stopword": "230px",
"⑤ Stopword": "220px", "⑤ Hasil Akhir": "230px",
"⑥ Hasil Akhir": "220px",
}, },
) )
_gap("lg") _gap("lg")
_render_length_comparison_chart(df, df_c)
_gap("lg")
_render_top_words_chart(df_c) _render_top_words_chart(df_c)
_gap("lg") _gap("lg")
@ -1102,7 +983,7 @@ def show():
with d1: with d1:
st.download_button( st.download_button(
"📥 Unduh Hasil Preprocessing Lengkap (semua kolom)", "📥 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"), ).to_csv(index=False).encode("utf-8"),
f"preprocessing_lengkap_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", f"preprocessing_lengkap_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
"text/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 re
import string import string
import joblib 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( text = re.sub(
r"http\S+|www\S+|@\w+|#|\d+", r"[\U00010000-\U0010ffff"
"", r"\U0001F600-\U0001F64F"
text 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( text = re.sub(r"[^a-zA-Z\s]", "", text)
str.maketrans("", "", string.punctuation)
)
text = re.sub(r"\s+", " ", text).strip() text = re.sub(r"\s+", " ", text).strip()
return text 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) def _remove_stopwords(tokens: list) -> list:
for text in list_text """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 6092%
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 6090%
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" "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", "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",
"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", "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",
"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", "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",
"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", "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",
"2054861268750901484","Thu May 14 09:47:58 +0000 2026","0","komdigi ongkir?","2054861268750901484","","","in","","0","0","0","https://x.com/undefined/status/2054861268750901484","1814143201718247424", "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",
"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",

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