perbaikan revisi per halaman dan informatif
penyempurnaan per halaman dan tambahan tampilah lebih informatif
This commit is contained in:
parent
8288fd99bd
commit
c886a9891c
33
app.py
33
app.py
|
|
@ -857,20 +857,43 @@ with st.sidebar:
|
|||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# ── QUERY ────────────────────────────────────────────────────
|
||||
# ── QUERY ────────────────────────────────────────────────────
|
||||
crawler_query = os.getenv(
|
||||
"QUERY",
|
||||
'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"""
|
||||
<div class="sidebar-card">
|
||||
<div style="font-size:0.72rem;font-weight:800;color:#0f172a;margin-bottom:0.55rem;">
|
||||
Query Aktif
|
||||
<div style="font-size:0.72rem;font-weight:800;color:#0f172a;margin-bottom:0.5rem;">
|
||||
🔍 Query Aktif
|
||||
</div>
|
||||
<div style="font-size:0.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 & Komdigi<br/>
|
||||
Sumber: X (Twitter) via tweet-harvest
|
||||
</div>
|
||||
<div style="font-size:0.74rem;color:#475569;line-height:1.8;word-break:break-word;">
|
||||
{query_html}
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
|
|
|||
|
|
@ -358,7 +358,7 @@ def _render_nrt_status_banner(sched_ok):
|
|||
f"<strong>{NRT_INTERVAL_MINUTES} menit</strong>. "
|
||||
f"Data mencakup <strong>7 hari terakhir</strong> berdasarkan tanggal asli tweet."
|
||||
)
|
||||
badge = "● LIVE"
|
||||
badge = "● LIVE"
|
||||
else:
|
||||
bg = "linear-gradient(135deg,#eff6ff,#dbeafe)"
|
||||
bdr = "#93c5fd"
|
||||
|
|
@ -369,9 +369,9 @@ def _render_nrt_status_banner(sched_ok):
|
|||
title = "Crawler Siap Dijalankan"
|
||||
desc = (
|
||||
f"Scheduler otomatis aktif setiap <strong>{NRT_INTERVAL_MINUTES} menit</strong> "
|
||||
f"sejak aplikasi pertama dibuka — tidak perlu terminal terpisah."
|
||||
f"sejak aplikasi pertama dibuka — tidak perlu terminal terpisah."
|
||||
)
|
||||
badge = "◎ STANDBY"
|
||||
badge = "⚬ STANDBY"
|
||||
|
||||
st.markdown(f"""
|
||||
<div style="background:{bg};border:1px solid {bdr};border-radius:14px;
|
||||
|
|
@ -437,20 +437,20 @@ def _render_monitoring_panel(last_log, last_dt, sched_ok):
|
|||
if remaining.total_seconds() > 0:
|
||||
countdown_str = format_countdown(remaining)
|
||||
elif is_crawler_running():
|
||||
countdown_str = "⏳ Sedang crawling..."
|
||||
countdown_str = "Sedang crawling..."
|
||||
else:
|
||||
countdown_str = "🔄 Segera diperbarui..."
|
||||
countdown_str = "Segera diperbarui..."
|
||||
|
||||
status_text = "Aktif Otomatis" if sched_ok else "Standby"
|
||||
status_color = "#16a34a" if sched_ok else "#3b6cf7"
|
||||
status_bg = "#dcfce7" if sched_ok else "#dbeafe"
|
||||
|
||||
note_text = f"✅ {tweet_today:,} tweet masuk hari ini" if tweet_today > 0 else "ℹ️ Belum ada tweet baru hari ini"
|
||||
note_text = f"{tweet_today:,} tweet masuk hari ini" if tweet_today > 0 else "Belum ada tweet baru hari ini"
|
||||
note_bg = "#f0fdf4" if tweet_today > 0 else "#f8fafc"
|
||||
note_border = "#bbf7d0" if tweet_today > 0 else "#e8edf5"
|
||||
note_color = "#166534" if tweet_today > 0 else "#64748b"
|
||||
|
||||
_section_header("📡 Status Monitoring Sistem", "Pembaruan data near-realtime · interval otomatis")
|
||||
_section_header("📡 Status Monitoring Sistem", "Pembaruan data near-realtime · interval otomatis")
|
||||
|
||||
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 = ""
|
||||
for i, (label, value, vc) in enumerate(left_rows):
|
||||
border = "none" if i == len(left_rows) - 1 else "1px solid #f1f5f9"
|
||||
rows_html_a += f"""
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;
|
||||
padding:0.6rem 0;border-bottom:{border};">
|
||||
<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span>
|
||||
<span style="font-size:0.78rem;font-weight:700;color:{vc};
|
||||
text-align:right;flex-shrink:0;max-width:58%;">{value}</span>
|
||||
</div>"""
|
||||
rows_html_a += (
|
||||
f'<div style="display:flex;justify-content:space-between;align-items:center;'
|
||||
f'padding:0.6rem 0;border-bottom:{border};">'
|
||||
f'<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span>'
|
||||
f'<span style="font-size:0.78rem;font-weight:700;color:{vc};'
|
||||
f'text-align:right;flex-shrink:0;max-width:58%;">{value}</span>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
with col_a:
|
||||
st.markdown(f"""
|
||||
<div class="crawl-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.9rem;">
|
||||
<span style="font-size:0.71rem;font-weight:800;color:#94a3b8;
|
||||
letter-spacing:0.07em;text-transform:uppercase;">⚙️ Sistem Crawling</span>
|
||||
<span style="background:{status_bg};color:{status_color};font-size:0.68rem;
|
||||
font-weight:700;padding:0.22rem 0.7rem;border-radius:999px;">{status_text}</span>
|
||||
</div>
|
||||
{rows_html_a}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
f'<div class="crawl-card">'
|
||||
f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.9rem;">'
|
||||
f'<span style="font-size:0.71rem;font-weight:800;color:#94a3b8;'
|
||||
f'letter-spacing:0.07em;text-transform:uppercase;">Sistem Crawling</span>'
|
||||
f'<span style="background:{status_bg};color:{status_color};font-size:0.68rem;'
|
||||
f'font-weight:700;padding:0.22rem 0.7rem;border-radius:999px;">{status_text}</span>'
|
||||
f'</div>'
|
||||
f'{rows_html_a}'
|
||||
f'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# ── Right card: ringkasan dataset ──
|
||||
right_rows = [
|
||||
|
|
@ -499,27 +501,28 @@ def _render_monitoring_panel(last_log, last_dt, sched_ok):
|
|||
rows_html_b = ""
|
||||
for i, (label, value, vc) in enumerate(right_rows):
|
||||
border = "none" if i == len(right_rows) - 1 else "1px solid #f1f5f9"
|
||||
rows_html_b += f"""
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;
|
||||
padding:0.6rem 0;border-bottom:{border};">
|
||||
<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span>
|
||||
<span style="font-size:0.78rem;font-weight:700;color:{vc};
|
||||
text-align:right;flex-shrink:0;max-width:58%;">{value}</span>
|
||||
</div>"""
|
||||
rows_html_b += (
|
||||
f'<div style="display:flex;justify-content:space-between;align-items:center;'
|
||||
f'padding:0.6rem 0;border-bottom:{border};">'
|
||||
f'<span style="font-size:0.77rem;color:#64748b;font-weight:500;">{label}</span>'
|
||||
f'<span style="font-size:0.78rem;font-weight:700;color:{vc};'
|
||||
f'text-align:right;flex-shrink:0;max-width:58%;">{value}</span>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
with col_b:
|
||||
st.markdown(f"""
|
||||
<div class="crawl-card">
|
||||
<div style="font-size:0.71rem;font-weight:800;color:#94a3b8;
|
||||
letter-spacing:0.07em;text-transform:uppercase;margin-bottom:0.9rem;">
|
||||
📊 Ringkasan Dataset
|
||||
</div>
|
||||
{rows_html_b}
|
||||
<div style="margin-top:0.9rem;background:{note_bg};border:1px solid {note_border};
|
||||
border-radius:10px;padding:0.7rem 0.85rem;font-size:0.76rem;
|
||||
font-weight:700;color:{note_color};">{note_text}</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
f'<div class="crawl-card">'
|
||||
f'<div style="font-size:0.71rem;font-weight:800;color:#94a3b8;'
|
||||
f'letter-spacing:0.07em;text-transform:uppercase;margin-bottom:0.9rem;">'
|
||||
f'Ringkasan Dataset</div>'
|
||||
f'{rows_html_b}'
|
||||
f'<div style="margin-top:0.9rem;background:{note_bg};border:1px solid {note_border};'
|
||||
f'border-radius:10px;padding:0.7rem 0.85rem;font-size:0.76rem;'
|
||||
f'font-weight:700;color:{note_color};">{note_text}</div>'
|
||||
f'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
|
@ -527,25 +530,25 @@ def _render_monitoring_panel(last_log, last_dt, sched_ok):
|
|||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _render_metrics(total, days, earliest, latest, filter_label, basis_label):
|
||||
st.markdown(f"""
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin:0.2rem 0 1.1rem;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;
|
||||
width:20px;height:20px;background:#eef2ff;border-radius:5px;font-size:0.75rem;">🔄</span>
|
||||
<span style="font-size:0.78rem;color:#64748b;">
|
||||
Data aktif: <strong style="color:#334155;">{filter_label}</strong>
|
||||
· {basis_label}
|
||||
</span>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
f'<div style="display:flex;align-items:center;gap:0.5rem;margin:0.2rem 0 1.1rem;">'
|
||||
f'<span style="display:inline-flex;align-items:center;justify-content:center;'
|
||||
f'width:20px;height:20px;background:#eef2ff;border-radius:5px;font-size:0.75rem;">🔄</span>'
|
||||
f'<span style="font-size:0.78rem;color:#64748b;">'
|
||||
f'Data aktif: <strong style="color:#334155;">{filter_label}</strong>'
|
||||
f' · {basis_label}'
|
||||
f'</span></div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
pills = [
|
||||
("📊", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#dbeafe",
|
||||
("📊", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#dbeafe",
|
||||
"Total Tweet", f"{total:,}", "Terkumpul periode ini"),
|
||||
("📅", "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#16a34a", "#14532d", "#bbf7d0",
|
||||
("📅", "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#16a34a", "#14532d", "#bbf7d0",
|
||||
"Rentang Waktu", f"{days} Hari", basis_label),
|
||||
("🗓️", "linear-gradient(135deg,#fff7ed,#ffedd5)", "#ea580c", "#7c2d12", "#fed7aa",
|
||||
("📇", "linear-gradient(135deg,#fff7ed,#ffedd5)", "#ea580c", "#7c2d12", "#fed7aa",
|
||||
"Mulai Dari", earliest, "Tanggal awal"),
|
||||
("📆", "linear-gradient(135deg,#fefce8,#fef9c3)", "#ca8a04", "#713f12", "#fde68a",
|
||||
("📆", "linear-gradient(135deg,#fefce8,#fef9c3)", "#ca8a04", "#713f12", "#fde68a",
|
||||
"Sampai Dengan", latest, "Tanggal akhir"),
|
||||
]
|
||||
|
||||
|
|
@ -553,20 +556,21 @@ def _render_metrics(total, days, earliest, latest, filter_label, basis_label):
|
|||
for col, (icon, bg, color, dark, border_c, label, val, sub) in zip([c1, c2, c3, c4], pills):
|
||||
with col:
|
||||
fs = "1.1rem" if len(str(val)) > 10 else "1.55rem"
|
||||
st.markdown(f"""
|
||||
<div class="metric-pill" style="background:{bg};border:1px solid {border_c};
|
||||
box-shadow:0 2px 10px {color}18;">
|
||||
<div style="width:36px;height:36px;background:{color};border-radius:10px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:0.95rem;margin:0 auto 0.65rem;
|
||||
box-shadow:0 4px 10px {color}44;">{icon}</div>
|
||||
<div style="font-size:0.62rem;font-weight:800;color:{color};
|
||||
text-transform:uppercase;letter-spacing:0.07em;margin-bottom:0.3rem;">{label}</div>
|
||||
<div class="stat-num" style="font-size:{fs};font-weight:800;color:{dark};
|
||||
line-height:1.15;margin-bottom:0.25rem;">{val}</div>
|
||||
<div style="font-size:0.66rem;color:{color};font-weight:600;opacity:0.85;">{sub}</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
f'<div class="metric-pill" style="background:{bg};border:1px solid {border_c};'
|
||||
f'box-shadow:0 2px 10px {color}18;">'
|
||||
f'<div style="width:36px;height:36px;background:{color};border-radius:10px;'
|
||||
f'display:flex;align-items:center;justify-content:center;'
|
||||
f'font-size:0.95rem;margin:0 auto 0.65rem;'
|
||||
f'box-shadow:0 4px 10px {color}44;">{icon}</div>'
|
||||
f'<div style="font-size:0.62rem;font-weight:800;color:{color};'
|
||||
f'text-transform:uppercase;letter-spacing:0.07em;margin-bottom:0.3rem;">{label}</div>'
|
||||
f'<div class="stat-num" style="font-size:{fs};font-weight:800;color:{dark};'
|
||||
f'line-height:1.15;margin-bottom:0.25rem;">{val}</div>'
|
||||
f'<div style="font-size:0.66rem;color:{color};font-weight:600;opacity:0.85;">{sub}</div>'
|
||||
f'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
|
@ -667,6 +671,7 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
|
|||
legend_items.insert(1, ("#a78bfa", "Terendah"))
|
||||
if any(c == 0 for c in counts_day):
|
||||
legend_items.append(("#cbd5e1", "Tidak ada data"))
|
||||
|
||||
legend_html = "".join([
|
||||
f'<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;">'
|
||||
f'<span style="width:10px;height:10px;border-radius:3px;background:{clr};display:inline-block;"></span>'
|
||||
|
|
@ -683,7 +688,7 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
|
|||
|
||||
def _date_pills(days_list, color, max_show=4):
|
||||
if not days_list:
|
||||
return '<span style="font-size:0.72rem;color:#94a3b8;">—</span>'
|
||||
return '<span style="font-size:0.72rem;color:#94a3b8;">—</span>'
|
||||
pills = ""
|
||||
for d in days_list[:max_show]:
|
||||
pills += (
|
||||
|
|
@ -692,11 +697,14 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
|
|||
f'font-weight:700;margin:2px 2px 0 0;">{d}</span>'
|
||||
)
|
||||
if len(days_list) > max_show:
|
||||
pills += f'<span style="font-size:0.65rem;color:#94a3b8;margin-left:2px;">+{len(days_list)-max_show} lainnya</span>'
|
||||
pills += (
|
||||
f'<span style="font-size:0.65rem;color:#94a3b8;margin-left:2px;">'
|
||||
f'+{len(days_list)-max_show} lainnya</span>'
|
||||
)
|
||||
return pills
|
||||
|
||||
if not busiest_list:
|
||||
busy_main, busy_count, busy_pills = "—", "Belum ada data", ""
|
||||
busy_main, busy_count, busy_pills = "—", "Belum ada data", ""
|
||||
elif len(busiest_list) == 1:
|
||||
busy_main, busy_count, busy_pills = busiest_list[0], f"{max_cnt:,} tweet", ""
|
||||
else:
|
||||
|
|
@ -705,7 +713,7 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
|
|||
busy_pills = _date_pills(busiest_list, "#ea580c")
|
||||
|
||||
if not quietest_list:
|
||||
quiet_main, quiet_count, quiet_pills = "—", "Belum ada data aktif", ""
|
||||
quiet_main, quiet_count, quiet_pills = "—", "Belum ada data aktif", ""
|
||||
elif len(quietest_list) == 1:
|
||||
quiet_main, quiet_count, quiet_pills = quietest_list[0], f"{min_cnt:,} tweet", ""
|
||||
else:
|
||||
|
|
@ -718,11 +726,11 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
|
|||
aktif_main, aktif_count, aktif_pills = "0 Hari Aktif", f"Dari {total_days} hari, belum ada data", ""
|
||||
elif active_days == total_days:
|
||||
aktif_main = f"{active_days} / {total_days} Hari"
|
||||
aktif_count = f"100% hari ada tweet · total {total_tweets:,} tweet"
|
||||
aktif_count = f"100% hari ada tweet · total {total_tweets:,} tweet"
|
||||
aktif_pills = ""
|
||||
else:
|
||||
aktif_main = f"{active_days} / {total_days} Hari"
|
||||
aktif_count = f"{pct_active}% hari aktif · {inactive_days} hari kosong"
|
||||
aktif_count = f"{pct_active}% hari aktif · {inactive_days} hari kosong"
|
||||
aktif_pills = ""
|
||||
|
||||
def _insight_card(col, icon, bg, color, dark, border_c, title, main, count_txt, pills_html):
|
||||
|
|
@ -731,32 +739,33 @@ def _render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end):
|
|||
f'<div style="margin-top:0.4rem;line-height:1.8;">{pills_html}</div>'
|
||||
if pills_html else ""
|
||||
)
|
||||
st.markdown(f"""
|
||||
<div class="mode-card" style="background:{bg};border:1px solid {border_c};
|
||||
border-radius:14px;padding:0.9rem 1rem 0.95rem;
|
||||
box-shadow:0 3px 12px {color}12;min-height:106px;">
|
||||
<div style="display:flex;align-items:center;gap:0.55rem;margin-bottom:0.55rem;">
|
||||
<div style="width:30px;height:30px;background:{color};border-radius:8px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:0.85rem;flex-shrink:0;box-shadow:0 3px 7px {color}38;">{icon}</div>
|
||||
<span style="font-size:0.58rem;font-weight:800;color:{color};
|
||||
text-transform:uppercase;letter-spacing:0.08em;">{title}</span>
|
||||
</div>
|
||||
<div style="font-size:0.95rem;font-weight:800;color:{dark};
|
||||
line-height:1.25;margin-bottom:0.15rem;">{main}</div>
|
||||
<div style="font-size:0.67rem;color:{color};font-weight:500;
|
||||
opacity:0.9;line-height:1.45;">{count_txt}</div>
|
||||
{pills_block}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
f'<div class="mode-card" style="background:{bg};border:1px solid {border_c};'
|
||||
f'border-radius:14px;padding:0.9rem 1rem 0.95rem;'
|
||||
f'box-shadow:0 3px 12px {color}12;min-height:106px;">'
|
||||
f'<div style="display:flex;align-items:center;gap:0.55rem;margin-bottom:0.55rem;">'
|
||||
f'<div style="width:30px;height:30px;background:{color};border-radius:8px;'
|
||||
f'display:flex;align-items:center;justify-content:center;'
|
||||
f'font-size:0.85rem;flex-shrink:0;box-shadow:0 3px 7px {color}38;">{icon}</div>'
|
||||
f'<span style="font-size:0.58rem;font-weight:800;color:{color};'
|
||||
f'text-transform:uppercase;letter-spacing:0.08em;">{title}</span>'
|
||||
f'</div>'
|
||||
f'<div style="font-size:0.95rem;font-weight:800;color:{dark};'
|
||||
f'line-height:1.25;margin-bottom:0.15rem;">{main}</div>'
|
||||
f'<div style="font-size:0.67rem;color:{color};font-weight:500;'
|
||||
f'opacity:0.9;line-height:1.45;">{count_txt}</div>'
|
||||
f'{pills_block}'
|
||||
f'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
_insight_card(pc1, "🔥", "linear-gradient(135deg,#fff7ed,#ffedd5)",
|
||||
_insight_card(pc1, "🔥", "linear-gradient(135deg,#fff7ed,#ffedd5)",
|
||||
"#ea580c", "#7c2d12", "#fed7aa",
|
||||
"Hari Paling Ramai", busy_main, busy_count, busy_pills)
|
||||
_insight_card(pc2, "🌙", "linear-gradient(135deg,#f5f3ff,#ede9fe)",
|
||||
_insight_card(pc2, "🌙", "linear-gradient(135deg,#f5f3ff,#ede9fe)",
|
||||
"#7c3aed", "#3b0764", "#ddd6fe",
|
||||
"Hari Paling Sepi", quiet_main, quiet_count, quiet_pills)
|
||||
_insight_card(pc3, "📆", "linear-gradient(135deg,#f0fdf4,#dcfce7)",
|
||||
_insight_card(pc3, "📆", "linear-gradient(135deg,#f0fdf4,#dcfce7)",
|
||||
"#16a34a", "#14532d", "#bbf7d0",
|
||||
"Hari Aktif", aktif_main, aktif_count, aktif_pills)
|
||||
|
||||
|
|
@ -771,9 +780,9 @@ def get_filtered_data(df_all):
|
|||
mode = st.session_state.analysis_mode
|
||||
|
||||
if mode == "realtime":
|
||||
mode_display = "Tweet Terkini — 7 Hari Terakhir"
|
||||
mode_display = "Tweet Terkini — 7 Hari Terakhir"
|
||||
mode_color = "#16a34a"
|
||||
mode_icon = "📡"
|
||||
mode_icon = "📡"
|
||||
date_col = "created_at"
|
||||
basis_label = "Berdasarkan tanggal asli tweet"
|
||||
chart_label = "tanggal asli tweet"
|
||||
|
|
@ -783,7 +792,7 @@ def get_filtered_data(df_all):
|
|||
elif mode == "30days":
|
||||
mode_display = "30 Hari Terakhir"
|
||||
mode_color = "#3b6cf7"
|
||||
mode_icon = "📅"
|
||||
mode_icon = "📅"
|
||||
date_col = "created_at"
|
||||
basis_label = "Berdasarkan tanggal asli tweet"
|
||||
chart_label = "tanggal asli tweet"
|
||||
|
|
@ -793,7 +802,7 @@ def get_filtered_data(df_all):
|
|||
elif mode == "captured":
|
||||
mode_display = "Tweet Hari Ini"
|
||||
mode_color = "#0284c7"
|
||||
mode_icon = "📆"
|
||||
mode_icon = "📆"
|
||||
date_col = "created_at"
|
||||
basis_label = "Berdasarkan tanggal asli tweet"
|
||||
chart_label = "tanggal asli tweet"
|
||||
|
|
@ -805,7 +814,7 @@ def get_filtered_data(df_all):
|
|||
end_date = st.session_state.get("custom_end_date", today)
|
||||
mode_display = "Periode Historis Pilihan"
|
||||
mode_color = "#d97706"
|
||||
mode_icon = "🔍"
|
||||
mode_icon = "🔍"
|
||||
date_col = "created_at"
|
||||
basis_label = "Berdasarkan tanggal asli tweet"
|
||||
chart_label = "tanggal asli tweet"
|
||||
|
|
@ -834,6 +843,146 @@ def get_filtered_data(df_all):
|
|||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# PAGE HEADER (dipecah jadi beberapa st.markdown() kecil)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _render_page_header():
|
||||
# Bangun semua bagian dalam Python dulu, baru render SEKALI
|
||||
chip_data = [
|
||||
("🔄", "Interval crawling", f"Setiap {NRT_INTERVAL_MINUTES} menit"),
|
||||
("📅", "Jangkauan data", "7 hari terakhir"),
|
||||
("🗃", "Penyimpanan", "Database lokal"),
|
||||
("🤖", "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 = [
|
||||
("🐦", "Crawling tweet"),
|
||||
("🗃", "Simpan ke database"),
|
||||
("🧹", "Preprocessing teks"),
|
||||
("🤖", "Prediksi sentimen"),
|
||||
("📊", "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} {label}</span>'
|
||||
+ (
|
||||
'<span style="color:#cbd5e1;font-size:0.85rem;margin:0 2px;">→</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);">🐦</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 — Komdigi & 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> '
|
||||
'— 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;">🔍 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;">"gratis ongkir"</span> OR '
|
||||
'<span style="color:#3b6cf7;font-weight:700;">"free ongkir"</span>) OR '
|
||||
'<span style="color:#3b6cf7;font-weight:700;">"pembatasan gratis ongkir"</span> OR '
|
||||
'<span style="color:#3b6cf7;font-weight:700;">"gratis ongkir dibatasi"</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>'
|
||||
' — 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;">'
|
||||
'⚙ Alur kerja</span>'
|
||||
f'{pipeline_steps_html}'
|
||||
'</div>'
|
||||
|
||||
'</div>' # end outer card
|
||||
)
|
||||
|
||||
st.markdown(html, unsafe_allow_html=True)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# MAIN PAGE
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
|
@ -850,51 +999,7 @@ def show():
|
|||
st.session_state.filter_end_date = today
|
||||
|
||||
# ── Page Header ──────────────────────────────────────────────
|
||||
st.markdown(f"""
|
||||
<div class="page-header-card" style="
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0f5ff 50%, #eef2ff 100%);
|
||||
border: 1px solid #e0e7ff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 4px 20px rgba(59,108,247,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.1rem;
|
||||
">
|
||||
<div style="
|
||||
width: 52px; height: 52px;
|
||||
background: linear-gradient(135deg, #3b6cf7, #6366f1);
|
||||
border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 6px 16px rgba(59,108,247,0.35);
|
||||
flex-shrink: 0;
|
||||
">🐦</div>
|
||||
<div>
|
||||
<h2 style="
|
||||
font-size: 1.25rem; font-weight: 800; color: #0f172a;
|
||||
margin: 0 0 4px; letter-spacing: -0.01em; line-height: 1.2;
|
||||
">Crawling Data Twitter</h2>
|
||||
<p style="font-size: 0.8rem; color: #64748b; margin: 0; line-height: 1.5;">
|
||||
Sistem crawling berjalan otomatis setiap
|
||||
<strong style="color:#3b6cf7;">{NRT_INTERVAL_MINUTES} menit</strong>
|
||||
— mengambil data
|
||||
<strong style="color:#3b6cf7;">7 hari terakhir</strong>
|
||||
secara near-realtime
|
||||
</p>
|
||||
</div>
|
||||
<div style="
|
||||
margin-left: auto;
|
||||
background: linear-gradient(135deg,#eef2ff,#e0e7ff);
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 10px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.72rem; font-weight: 700; color: #3b6cf7;
|
||||
white-space: nowrap; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
">🔄 Live Monitor</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
_render_page_header()
|
||||
|
||||
# ── Mode Selector ─────────────────────────────────────────────
|
||||
if "analysis_mode" not in st.session_state:
|
||||
|
|
@ -904,29 +1009,29 @@ def show():
|
|||
|
||||
MODE_CFG = {
|
||||
"captured": {
|
||||
"col": col1, "key": "btn_captured", "label": "📆 Hari Ini",
|
||||
"title": "Tweet Hari Ini", "icon": "📆",
|
||||
"col": col1, "key": "btn_captured", "label": "Hari Ini",
|
||||
"title": "Tweet Hari Ini", "icon": "📆",
|
||||
"desc": "Tanggal asli <strong>hari ini</strong>",
|
||||
"gradient": "linear-gradient(135deg,#eff6ff,#dbeafe)",
|
||||
"border": "#bfdbfe", "color": "#1d4ed8", "dark": "#1e3a8a",
|
||||
},
|
||||
"realtime": {
|
||||
"col": col2, "key": "btn_realtime", "label": "📡 7 Hari",
|
||||
"title": "Terkini (7 Hari)", "icon": "📡",
|
||||
"col": col2, "key": "btn_realtime", "label": "7 Hari",
|
||||
"title": "Terkini (7 Hari)", "icon": "📡",
|
||||
"desc": "<strong>7 hari terakhir</strong>",
|
||||
"gradient": "linear-gradient(135deg,#f0fdf4,#dcfce7)",
|
||||
"border": "#86efac", "color": "#16a34a", "dark": "#14532d",
|
||||
},
|
||||
"30days": {
|
||||
"col": col3, "key": "btn_30days", "label": "📅 30 Hari",
|
||||
"title": "30 Hari Terakhir", "icon": "📅",
|
||||
"col": col3, "key": "btn_30days", "label": "30 Hari",
|
||||
"title": "30 Hari Terakhir", "icon": "📅",
|
||||
"desc": "<strong>30 hari terakhir</strong>",
|
||||
"gradient": "linear-gradient(135deg,#eef2ff,#e0e7ff)",
|
||||
"border": "#a5b4fc", "color": "#3b6cf7", "dark": "#1e3a8a",
|
||||
},
|
||||
"custom": {
|
||||
"col": col4, "key": "btn_custom", "label": "🔍 Pilih Tanggal",
|
||||
"title": "Pilih Tanggal", "icon": "🔍",
|
||||
"col": col4, "key": "btn_custom", "label": "Pilih Tanggal",
|
||||
"title": "Pilih Tanggal", "icon": "🔍",
|
||||
"desc": "Historis <strong>custom</strong>",
|
||||
"gradient": "linear-gradient(135deg,#fefce8,#fef9c3)",
|
||||
"border": "#fde68a", "color": "#d97706", "dark": "#713f12",
|
||||
|
|
@ -957,20 +1062,21 @@ def show():
|
|||
if active else ""
|
||||
)
|
||||
|
||||
st.markdown(f"""
|
||||
<div class="mode-card" style="background:{bg};border:{border_style};
|
||||
border-radius:14px;padding:1rem 0.85rem;margin-top:0.3rem;
|
||||
text-align:center;box-shadow:{shadow};">
|
||||
<div style="width:38px;height:38px;background:{icon_bg};border-radius:10px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:1.1rem;margin:0 auto 0.55rem;
|
||||
box-shadow:{icon_shadow};">{cfg['icon']}</div>
|
||||
<div style="font-size:0.8rem;font-weight:700;color:{dark};margin-bottom:0.22rem;">
|
||||
{cfg['title']}</div>
|
||||
<div style="font-size:0.68rem;color:{color};line-height:1.45;">{cfg['desc']}</div>
|
||||
{underline}
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
f'<div class="mode-card" style="background:{bg};border:{border_style};'
|
||||
f'border-radius:14px;padding:1rem 0.85rem;margin-top:0.3rem;'
|
||||
f'text-align:center;box-shadow:{shadow};">'
|
||||
f'<div style="width:38px;height:38px;background:{icon_bg};border-radius:10px;'
|
||||
f'display:flex;align-items:center;justify-content:center;'
|
||||
f'font-size:1.1rem;margin:0 auto 0.55rem;'
|
||||
f'box-shadow:{icon_shadow};">{cfg["icon"]}</div>'
|
||||
f'<div style="font-size:0.8rem;font-weight:700;color:{dark};margin-bottom:0.22rem;">'
|
||||
f'{cfg["title"]}</div>'
|
||||
f'<div style="font-size:0.68rem;color:{color};line-height:1.45;">{cfg["desc"]}</div>'
|
||||
f'{underline}'
|
||||
f'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
_gap("md")
|
||||
|
||||
|
|
@ -978,7 +1084,7 @@ def show():
|
|||
try:
|
||||
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
|
||||
except Exception as e:
|
||||
st.error(f"❌ Gagal membaca database: {str(e)}")
|
||||
st.error(f"Gagal membaca database: {str(e)}")
|
||||
return
|
||||
|
||||
if len(df_all) == 0:
|
||||
|
|
@ -995,18 +1101,19 @@ def show():
|
|||
min_db = df_check["created_at"].min().date()
|
||||
max_db = df_check["created_at"].max().date()
|
||||
|
||||
st.markdown(f"""
|
||||
<div class="crawl-card" style="margin-bottom:1rem;">
|
||||
<p style="font-size:0.875rem;font-weight:700;color:#0f172a;margin:0 0 0.25rem;">
|
||||
📅 Pilih Rentang Tanggal Tweet Historis</p>
|
||||
<p style="font-size:0.775rem;color:#64748b;margin:0;line-height:1.6;">
|
||||
Data tersedia dari
|
||||
<strong style="color:#0f172a;">{min_db.strftime('%d/%m/%Y')}</strong>
|
||||
hingga
|
||||
<strong style="color:#0f172a;">{max_db.strftime('%d/%m/%Y')}</strong>
|
||||
</p>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
'<div class="crawl-card" style="margin-bottom:1rem;">'
|
||||
'<p style="font-size:0.875rem;font-weight:700;color:#0f172a;margin:0 0 0.25rem;">'
|
||||
'📅 Pilih Rentang Tanggal Tweet Historis</p>'
|
||||
f'<p style="font-size:0.775rem;color:#64748b;margin:0;line-height:1.6;">'
|
||||
f'Data tersedia dari '
|
||||
f'<strong style="color:#0f172a;">{min_db.strftime("%d/%m/%Y")}</strong>'
|
||||
f' hingga '
|
||||
f'<strong style="color:#0f172a;">{max_db.strftime("%d/%m/%Y")}</strong>'
|
||||
f'</p>'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
with st.container():
|
||||
c1, c2, c3 = st.columns([2, 2, 1], gap="medium")
|
||||
|
|
@ -1038,13 +1145,13 @@ def show():
|
|||
key="custom_end_input")
|
||||
st.session_state.custom_end_date = ce
|
||||
with c3:
|
||||
if st.button("✅ Terapkan", type="primary", use_container_width=True):
|
||||
st.success("✅ Periode diterapkan!")
|
||||
if st.button("Terapkan", type="primary", use_container_width=True):
|
||||
st.success("Periode diterapkan!")
|
||||
time.sleep(0.8)
|
||||
st.rerun()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# MODE: TWEET HARI INI (captured) — monitoring + log table
|
||||
# MODE: TWEET HARI INI (captured)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
if st.session_state.analysis_mode == "captured":
|
||||
st_autorefresh(interval=1000, key="crawler_monitor_refresh")
|
||||
|
|
@ -1075,14 +1182,14 @@ def show():
|
|||
crawl_time = log.get("timestamp", "-") or "-"
|
||||
|
||||
if status == "success":
|
||||
status_label = "✅ Berhasil"
|
||||
status_label = "Berhasil"
|
||||
note = (
|
||||
f"{total_saved:,} tweet baru tersimpan"
|
||||
if total_saved > 0
|
||||
else "Crawling berhasil, tidak ada tweet baru"
|
||||
)
|
||||
else:
|
||||
status_label = "❌ Gagal"
|
||||
status_label = "Gagal"
|
||||
note = error_msg or "Terjadi kesalahan saat crawling"
|
||||
|
||||
rows.append({
|
||||
|
|
@ -1094,8 +1201,8 @@ def show():
|
|||
})
|
||||
|
||||
_section_header(
|
||||
"🕒 Riwayat Crawling Otomatis",
|
||||
f"{len(rows)} aktivitas terbaru · interval {NRT_INTERVAL_MINUTES} menit"
|
||||
"🕐 Riwayat Crawling Otomatis",
|
||||
f"{len(rows)} aktivitas terbaru · interval {NRT_INTERVAL_MINUTES} menit"
|
||||
)
|
||||
_gap("xs")
|
||||
render_standard_table(
|
||||
|
|
@ -1111,17 +1218,18 @@ def show():
|
|||
},
|
||||
)
|
||||
else:
|
||||
st.markdown(f"""
|
||||
<div class="crawl-card" style="text-align:center;padding:2rem;">
|
||||
<div style="font-size:2rem;margin-bottom:0.75rem;">⏳</div>
|
||||
<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">
|
||||
Menunggu Crawl Pertama</div>
|
||||
<div style="font-size:0.77rem;color:#64748b;">
|
||||
Riwayat akan muncul setelah crawl otomatis pertama selesai
|
||||
(maks. {NRT_INTERVAL_MINUTES} menit sejak aplikasi dibuka)
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
'<div class="crawl-card" style="text-align:center;padding:2rem;">'
|
||||
'<div style="font-size:2rem;margin-bottom:0.75rem;">⏳</div>'
|
||||
'<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">'
|
||||
'Menunggu Crawl Pertama</div>'
|
||||
f'<div style="font-size:0.77rem;color:#64748b;">'
|
||||
f'Riwayat akan muncul setelah crawl otomatis pertama selesai '
|
||||
f'(maks. {NRT_INTERVAL_MINUTES} menit sejak aplikasi dibuka)'
|
||||
f'</div>'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
_gap("lg")
|
||||
|
||||
|
|
@ -1140,22 +1248,23 @@ def show():
|
|||
].sort_values("crawled_at", ascending=False)
|
||||
|
||||
_section_header(
|
||||
"🤖 Tweet yang Diambil Bot Hari Ini",
|
||||
"🤖 Tweet yang Diambil Bot Hari Ini",
|
||||
"Semua tweet yang berhasil disimpan crawler hari ini"
|
||||
)
|
||||
_gap("xs")
|
||||
|
||||
if bot_df.empty:
|
||||
st.markdown(f"""
|
||||
<div class="crawl-card" style="text-align:center;padding:2rem;">
|
||||
<div style="font-size:2rem;margin-bottom:0.75rem;">🐦</div>
|
||||
<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">
|
||||
Belum Ada Tweet Hari Ini</div>
|
||||
<div style="font-size:0.77rem;color:#64748b;">
|
||||
Tweet baru akan muncul setelah crawl berikutnya selesai
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
'<div class="crawl-card" style="text-align:center;padding:2rem;">'
|
||||
'<div style="font-size:2rem;margin-bottom:0.75rem;">🐦</div>'
|
||||
'<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">'
|
||||
'Belum Ada Tweet Hari Ini</div>'
|
||||
'<div style="font-size:0.77rem;color:#64748b;">'
|
||||
'Tweet baru akan muncul setelah crawl berikutnya selesai'
|
||||
'</div>'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
else:
|
||||
bot_disp = bot_df.head(100).copy()
|
||||
bot_disp["Tanggal Tweet"] = bot_disp["created_at"].apply(format_dt)
|
||||
|
|
@ -1201,32 +1310,33 @@ def show():
|
|||
|
||||
# ── Active Filter Banner ──────────────────────────────────
|
||||
total = len(df)
|
||||
st.markdown(f"""
|
||||
<div style="display:flex;align-items:center;gap:0.9rem;
|
||||
background:#ffffff;
|
||||
border-left:4px solid {mode_color};
|
||||
border-top:1px solid #e8edf5;
|
||||
border-right:1px solid #e8edf5;
|
||||
border-bottom:1px solid #e8edf5;
|
||||
border-radius:0 14px 14px 0;
|
||||
padding:0.75rem 1.3rem;margin-bottom:1rem;
|
||||
box-shadow:0 2px 8px rgba(15,23,42,0.04);">
|
||||
<span style="font-size:1.45rem;">{mode_icon}</span>
|
||||
<div style="flex:1;">
|
||||
<p style="font-size:0.88rem;font-weight:700;color:#0f172a;margin:0 0 2px;">{mode_display}</p>
|
||||
<p style="font-size:0.74rem;color:#64748b;margin:0;">
|
||||
Periode aktif: <strong style="color:{mode_color};">{filter_label}</strong>
|
||||
· {basis_label}
|
||||
</p>
|
||||
</div>
|
||||
<div style="margin-left:auto;background:{mode_color}14;color:{mode_color};
|
||||
border:1px solid {mode_color}33;
|
||||
font-size:0.7rem;font-weight:800;padding:0.3rem 0.8rem;
|
||||
border-radius:999px;letter-spacing:0.04em;white-space:nowrap;">
|
||||
{total:,} tweet
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
f'<div style="display:flex;align-items:center;gap:0.9rem;'
|
||||
f'background:#ffffff;'
|
||||
f'border-left:4px solid {mode_color};'
|
||||
f'border-top:1px solid #e8edf5;'
|
||||
f'border-right:1px solid #e8edf5;'
|
||||
f'border-bottom:1px solid #e8edf5;'
|
||||
f'border-radius:0 14px 14px 0;'
|
||||
f'padding:0.75rem 1.3rem;margin-bottom:1rem;'
|
||||
f'box-shadow:0 2px 8px rgba(15,23,42,0.04);">'
|
||||
f'<span style="font-size:1.45rem;">{mode_icon}</span>'
|
||||
f'<div style="flex:1;">'
|
||||
f'<p style="font-size:0.88rem;font-weight:700;color:#0f172a;margin:0 0 2px;">{mode_display}</p>'
|
||||
f'<p style="font-size:0.74rem;color:#64748b;margin:0;">'
|
||||
f'Periode aktif: <strong style="color:{mode_color};">{filter_label}</strong>'
|
||||
f' · {basis_label}'
|
||||
f'</p>'
|
||||
f'</div>'
|
||||
f'<div style="margin-left:auto;background:{mode_color}14;color:{mode_color};'
|
||||
f'border:1px solid {mode_color}33;'
|
||||
f'font-size:0.7rem;font-weight:800;padding:0.3rem 0.8rem;'
|
||||
f'border-radius:999px;letter-spacing:0.04em;white-space:nowrap;">'
|
||||
f'{total:,} tweet'
|
||||
f'</div>'
|
||||
f'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
days = (pd.Timestamp(dt_end).date() - pd.Timestamp(dt_start).date()).days + 1
|
||||
earliest = pd.Timestamp(dt_start).strftime("%d/%m/%Y")
|
||||
|
|
@ -1235,42 +1345,44 @@ def show():
|
|||
_render_metrics(total, days, earliest, latest, filter_label, basis_label)
|
||||
|
||||
if total == 0:
|
||||
_section_header("📋 Daftar Tweet yang Terkumpul", f"0 tweet · {filter_label}")
|
||||
st.markdown(f"""
|
||||
<div class="crawl-card" style="text-align:center;padding:2rem;">
|
||||
<div style="font-size:2rem;margin-bottom:0.75rem;">🔍</div>
|
||||
<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">
|
||||
Tidak Ada Data</div>
|
||||
<div style="font-size:0.77rem;color:#64748b;">
|
||||
Belum ada tweet dengan tanggal asli pada periode ini
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
_section_header("📋 Daftar Tweet yang Terkumpul", f"0 tweet · {filter_label}")
|
||||
st.markdown(
|
||||
'<div class="crawl-card" style="text-align:center;padding:2rem;">'
|
||||
'<div style="font-size:2rem;margin-bottom:0.75rem;">🔍</div>'
|
||||
'<div style="font-size:0.88rem;font-weight:700;color:#334155;margin-bottom:0.3rem;">'
|
||||
'Tidak Ada Data</div>'
|
||||
'<div style="font-size:0.77rem;color:#64748b;">'
|
||||
'Belum ada tweet dengan tanggal asli pada periode ini'
|
||||
'</div>'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
return
|
||||
|
||||
# ── Chart ─────────────────────────────────────────────────
|
||||
st.markdown(f"""
|
||||
<div class="crawl-card" style="padding-bottom:0.4rem;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.1rem;">
|
||||
<p style="font-size:0.87rem;font-weight:700;color:#0f172a;margin:0;">
|
||||
📊 Distribusi Tweet Per Hari</p>
|
||||
<span style="font-size:0.67rem;font-weight:600;color:#94a3b8;
|
||||
background:#f8fafc;border:1px solid #e8edf5;
|
||||
border-radius:6px;padding:0.18rem 0.55rem;">{filter_label}</span>
|
||||
</div>
|
||||
<p style="font-size:0.69rem;color:#94a3b8;margin:2px 0 0;">
|
||||
Berdasarkan {chart_label}
|
||||
</p>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(
|
||||
f'<div class="crawl-card" style="padding-bottom:0.4rem;">'
|
||||
f'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.1rem;">'
|
||||
f'<p style="font-size:0.87rem;font-weight:700;color:#0f172a;margin:0;">'
|
||||
f'📊 Distribusi Tweet Per Hari</p>'
|
||||
f'<span style="font-size:0.67rem;font-weight:600;color:#94a3b8;'
|
||||
f'background:#f8fafc;border:1px solid #e8edf5;'
|
||||
f'border-radius:6px;padding:0.18rem 0.55rem;">{filter_label}</span>'
|
||||
f'</div>'
|
||||
f'<p style="font-size:0.69rem;color:#94a3b8;margin:2px 0 0;">'
|
||||
f'Berdasarkan {chart_label}'
|
||||
f'</p>'
|
||||
f'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
_render_charts(df, filter_label, date_col, chart_label, dt_start, dt_end)
|
||||
|
||||
# ── Tabel ─────────────────────────────────────────────────
|
||||
display_limit = 100
|
||||
_section_header(
|
||||
"📋 Daftar Tweet yang Terkumpul",
|
||||
f"Menampilkan hingga {display_limit} tweet terbaru · {filter_label}"
|
||||
"📋 Daftar Tweet yang Terkumpul",
|
||||
f"Menampilkan hingga {display_limit} tweet terbaru · {filter_label}"
|
||||
)
|
||||
_gap("xs")
|
||||
|
||||
|
|
@ -1297,7 +1409,7 @@ def show():
|
|||
c_dl, c_nav = st.columns(2)
|
||||
with c_dl:
|
||||
st.download_button(
|
||||
f"📥 Unduh Semua Data ({total:,} tweet)",
|
||||
f"Unduh Semua Data ({total:,} tweet)",
|
||||
df.to_csv(index=False).encode("utf-8"),
|
||||
f"data_twitter_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
||||
"text/csv",
|
||||
|
|
@ -1305,7 +1417,7 @@ def show():
|
|||
)
|
||||
with c_nav:
|
||||
if st.button(
|
||||
"🧹 Lanjut ke Bersihkan Data →",
|
||||
"Lanjut ke Bersihkan Data",
|
||||
type="primary",
|
||||
use_container_width=True,
|
||||
key="btn_go_preprocessing"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
"""
|
||||
preprocessing_page.py
|
||||
=====================
|
||||
Halaman Bersihkan Data — NLP Pipeline 6 Tahap.
|
||||
Halaman Bersihkan Data — NLP Pipeline 5 Tahap.
|
||||
|
||||
PIPELINE 6 TAHAP (selaras dengan sentiment_service.py):
|
||||
PIPELINE 5 TAHAP (selaras dengan sentiment_service.py):
|
||||
1. Case Folding — ubah semua huruf jadi lowercase
|
||||
2. Cleaning — hapus URL, mention, hashtag, angka, emoji, tanda baca
|
||||
3. Normalisasi — singkatan/slang → kata baku
|
||||
4. Tokenizing — pecah kalimat → list token
|
||||
5. Stopword Removal — hapus kata umum; JAGA kata sentimen penting
|
||||
6. Stemming — bentuk dasar kata via Sastrawi ECS
|
||||
4. Stopword Removal — hapus kata umum; JAGA kata sentimen penting
|
||||
5. Stemming — bentuk dasar kata via Sastrawi ECS
|
||||
|
||||
Catatan: Tokenizing tidak ditampilkan sebagai tahap tersendiri karena:
|
||||
• Stopword removal & stemming sudah melakukan split() secara internal
|
||||
• TF-IDF melakukan tokenisasi sendiri saat inferensi
|
||||
→ Tokenizing adalah proses teknis, bukan tahap utama pipeline
|
||||
"""
|
||||
|
||||
import streamlit as st
|
||||
|
|
@ -91,7 +95,7 @@ def _sync_dynamic_period():
|
|||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# PREPROCESSING PIPELINE — 6 TAHAP
|
||||
# PREPROCESSING PIPELINE — 5 TAHAP
|
||||
# PENTING: Pipeline ini harus IDENTIK dengan sentiment_service.py
|
||||
# agar token yang dihasilkan konsisten dengan training model.
|
||||
#
|
||||
|
|
@ -99,9 +103,8 @@ def _sync_dynamic_period():
|
|||
# 1. Case Folding → lowercase dulu sebelum cleaning
|
||||
# 2. Cleaning → hapus noise setelah lowercase
|
||||
# 3. Normalisasi → singkatan/slang → kata baku
|
||||
# 4. Tokenizing → split menjadi list token
|
||||
# 5. Stopword Removal → buang kata umum, jaga kata sentimen
|
||||
# 6. Stemming → bentuk dasar via Sastrawi ECS
|
||||
# 4. Stopword Removal → buang kata umum, jaga kata sentimen
|
||||
# 5. Stemming → bentuk dasar via Sastrawi ECS
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
KATA_SENTIMEN_PENTING = {
|
||||
|
|
@ -251,21 +254,16 @@ def step3_normalization(text: str) -> str:
|
|||
return " ".join(NORMALISASI.get(word, word) for word in text.split())
|
||||
|
||||
|
||||
# ── Tahap 4: Tokenizing ─────────────────────────────────────────────────────
|
||||
def step4_tokenizing(text: str) -> list:
|
||||
return text.split()
|
||||
|
||||
|
||||
# ── Tahap 5: Stopword Removal ───────────────────────────────────────────────
|
||||
def step5_stopword_removal(tokens: list, stopwords: set) -> list:
|
||||
# ── Tahap 4: Stopword Removal ───────────────────────────────────────────────
|
||||
def step4_stopword_removal(tokens: list, stopwords: set) -> list:
|
||||
return [
|
||||
w for w in tokens
|
||||
if (w not in stopwords or w in KATA_SENTIMEN_PENTING) and len(w) > 2
|
||||
]
|
||||
|
||||
|
||||
# ── Tahap 6: Stemming ───────────────────────────────────────────────────────
|
||||
def step6_stemming(tokens: list, stemmer) -> list:
|
||||
# ── Tahap 5: Stemming ───────────────────────────────────────────────────────
|
||||
def step5_stemming(tokens: list, stemmer) -> list:
|
||||
if stemmer is None:
|
||||
return tokens
|
||||
return [stemmer.stem(w) for w in tokens]
|
||||
|
|
@ -273,32 +271,28 @@ def step6_stemming(tokens: list, stemmer) -> list:
|
|||
|
||||
def full_preprocessing(text: str, stopwords: set, stemmer):
|
||||
"""
|
||||
Jalankan 6 tahap preprocessing dan kembalikan dict hasil setiap tahap.
|
||||
Jalankan 5 tahap preprocessing dan kembalikan dict hasil setiap tahap.
|
||||
|
||||
URUTAN TAHAP:
|
||||
1. Case Folding → lowercase
|
||||
2. Cleaning → hapus noise
|
||||
3. Normalisasi → normalisasi kata
|
||||
4. Tokenizing → split token
|
||||
5. Stopword Removal → buang stopword
|
||||
6. Stemming → bentuk dasar
|
||||
4. Stopword Removal → buang stopword (split() dilakukan internal)
|
||||
5. Stemming → bentuk dasar
|
||||
"""
|
||||
s1_fold = step1_case_folding(text)
|
||||
s2_clean = step2_cleaning(s1_fold)
|
||||
s3_norm = step3_normalization(s2_clean)
|
||||
s4_tokens = step4_tokenizing(s3_norm)
|
||||
s5_filtered = step5_stopword_removal(s4_tokens, stopwords)
|
||||
s6_stemmed = step6_stemming(s5_filtered, stemmer)
|
||||
s4_filtered = step4_stopword_removal(s3_norm.split(), stopwords)
|
||||
s5_stemmed = step5_stemming(s4_filtered, stemmer)
|
||||
|
||||
return {
|
||||
"setelah_casefolding": s1_fold,
|
||||
"setelah_cleaning": s2_clean,
|
||||
"setelah_normalisasi": s3_norm,
|
||||
"setelah_tokenizing": " | ".join(s4_tokens),
|
||||
"setelah_stopword": " ".join(s5_filtered),
|
||||
"clean_text": " ".join(s6_stemmed),
|
||||
"_tokens_raw": s4_tokens,
|
||||
"_tokens_clean": s6_stemmed,
|
||||
"setelah_stopword": " ".join(s4_filtered),
|
||||
"clean_text": " ".join(s5_stemmed),
|
||||
"_tokens_clean": s5_stemmed,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -376,7 +370,6 @@ header[data-testid="stHeader"] { height: 0 !important; min-height: 0 !important;
|
|||
.pipe-3 { animation: fadeRight 0.35s 0.19s ease both; }
|
||||
.pipe-4 { animation: fadeRight 0.35s 0.26s ease both; }
|
||||
.pipe-5 { animation: fadeRight 0.35s 0.33s ease both; }
|
||||
.pipe-6 { animation: fadeRight 0.35s 0.40s ease both; }
|
||||
|
||||
.example-box { transition: all 0.18s ease; }
|
||||
.example-box:hover {
|
||||
|
|
@ -384,19 +377,6 @@ header[data-testid="stHeader"] { height: 0 !important; min-height: 0 !important;
|
|||
box-shadow: 0 4px 14px rgba(59,108,247,0.10) !important;
|
||||
}
|
||||
|
||||
.token-chip {
|
||||
display: inline-block;
|
||||
background: #eef2ff;
|
||||
color: #3b6cf7;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 5px;
|
||||
padding: 1px 7px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
margin: 2px 2px 0 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.fix-badge {
|
||||
display: inline-block;
|
||||
background: #fef9c3;
|
||||
|
|
@ -442,8 +422,8 @@ def _render_page_header():
|
|||
margin:0 0 4px;letter-spacing:-0.01em;line-height:1.2;">
|
||||
Bersihkan Data</h2>
|
||||
<p style="font-size:0.8rem;color:#64748b;margin:0;line-height:1.5;">
|
||||
Preprocessing teks 6 tahap otomatis:
|
||||
<strong style="color:#059669;">Case Folding → Cleaning → Normalisasi → Tokenizing → Stopword Removal → Stemming</strong>
|
||||
Preprocessing teks 5 tahap otomatis:
|
||||
<strong style="color:#059669;">Case Folding → Cleaning → Normalisasi → Stopword Removal → Stemming</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div style="
|
||||
|
|
@ -512,21 +492,6 @@ def _render_pipeline_steps(stemmer_ok):
|
|||
},
|
||||
{
|
||||
"num": "04", "anim": "pipe-4",
|
||||
"icon": "✂️", "color": "#7c3aed", "dark": "#3b0764",
|
||||
"bg": "linear-gradient(135deg,#f5f3ff,#ede9fe)", "border": "#c4b5fd",
|
||||
"title": "Tokenizing",
|
||||
"desc": "Memecah kalimat menjadi unit kata (token) individual.",
|
||||
"items": [
|
||||
"Pisahkan berdasarkan spasi",
|
||||
'"keren banget" → [keren, banget]',
|
||||
"Setiap token diproses mandiri",
|
||||
"Hasil: daftar kata terpisah",
|
||||
"Input untuk stopword removal",
|
||||
"Ditampilkan sebagai chip per token",
|
||||
],
|
||||
},
|
||||
{
|
||||
"num": "05", "anim": "pipe-5",
|
||||
"icon": "🚫", "color": "#ea580c", "dark": "#7c2d12",
|
||||
"bg": "linear-gradient(135deg,#fff7ed,#ffedd5)", "border": "#fed7aa",
|
||||
"title": 'Stopword Removal <span class="fix-badge">✦ Diperbaiki</span>',
|
||||
|
|
@ -541,7 +506,7 @@ def _render_pipeline_steps(stemmer_ok):
|
|||
],
|
||||
},
|
||||
{
|
||||
"num": "06", "anim": "pipe-6",
|
||||
"num": "05", "anim": "pipe-5",
|
||||
"icon": "🌱", "color": "#ca8a04", "dark": "#713f12",
|
||||
"bg": "linear-gradient(135deg,#fefce8,#fef9c3)", "border": "#fde68a",
|
||||
"title": "Stemming",
|
||||
|
|
@ -557,15 +522,17 @@ def _render_pipeline_steps(stemmer_ok):
|
|||
},
|
||||
]
|
||||
|
||||
# Baris 1: 3 kartu pertama
|
||||
row1 = st.columns(3, gap="medium")
|
||||
for col, step in zip(row1, steps[:3]):
|
||||
_render_step_card(col, step)
|
||||
|
||||
_gap("sm")
|
||||
|
||||
row2 = st.columns(3, gap="medium")
|
||||
for col, step in zip(row2, steps[3:]):
|
||||
_render_step_card(col, step)
|
||||
# Baris 2: 2 kartu terakhir (tengah agar simetris)
|
||||
_, col4, col5, _ = st.columns([0.5, 1, 1, 0.5], gap="medium")
|
||||
_render_step_card(col4, steps[3])
|
||||
_render_step_card(col5, steps[4])
|
||||
|
||||
|
||||
def _render_step_card(col, step):
|
||||
|
|
@ -622,9 +589,8 @@ def _render_flow_arrow():
|
|||
("① Case Folding", "#0284c7", "#eff6ff", "#bfdbfe"),
|
||||
("② Cleaning", "#3b6cf7", "#eef2ff", "#c7d2fe"),
|
||||
("③ Normalisasi", "#16a34a", "#f0fdf4", "#86efac"),
|
||||
("④ Tokenizing", "#7c3aed", "#f5f3ff", "#c4b5fd"),
|
||||
("⑤ Stopword", "#ea580c", "#fff7ed", "#fed7aa"),
|
||||
("⑥ Stemming", "#ca8a04", "#fefce8", "#fde68a"),
|
||||
("④ Stopword", "#ea580c", "#fff7ed", "#fed7aa"),
|
||||
("⑤ Stemming", "#ca8a04", "#fefce8", "#fde68a"),
|
||||
("✅ Teks Bersih", "#0f172a", "#0f172a", "#334155"),
|
||||
]
|
||||
|
||||
|
|
@ -660,14 +626,14 @@ def _render_flow_arrow():
|
|||
# STAT PILLS
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _render_stat_pills(total_raw, total_clean, avg_tokens_before, avg_tokens_after, removed):
|
||||
c1, c2, c3, c4 = st.columns(4, gap="medium")
|
||||
def _render_stat_pills(total_raw, total_clean, removed):
|
||||
c1, c2, c3 = st.columns(3, gap="medium")
|
||||
|
||||
cards = [
|
||||
(c1, "📥", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#c7d2fe",
|
||||
"Tweet Diproses", f"{total_raw:,}", "Total tweet periode ini"),
|
||||
(c2, "✅", "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#16a34a", "#14532d", "#86efac",
|
||||
"Tweet Siap Analisis", f"{total_clean:,}", "Lulus semua 6 tahap"),
|
||||
"Tweet Siap Analisis", f"{total_clean:,}", "Lulus semua 5 tahap"),
|
||||
(c3,
|
||||
"🗑️" if removed > 0 else "✅",
|
||||
("linear-gradient(135deg,#fff7ed,#ffedd5)" if removed > 0 else "linear-gradient(135deg,#f0fdf4,#dcfce7)"),
|
||||
|
|
@ -676,10 +642,6 @@ def _render_stat_pills(total_raw, total_clean, avg_tokens_before, avg_tokens_aft
|
|||
("#fed7aa" if removed > 0 else "#86efac"),
|
||||
"Tweet Dibuang", f"{removed:,}",
|
||||
("Teks kosong setelah preprocessing" if removed > 0 else "Semua tweet lolos")),
|
||||
(c4, "🔤", "linear-gradient(135deg,#f5f3ff,#ede9fe)", "#7c3aed", "#3b0764", "#c4b5fd",
|
||||
"Rata-rata Token",
|
||||
f"{avg_tokens_before:.0f} → {avg_tokens_after:.0f}",
|
||||
"Sebelum → Sesudah stopword"),
|
||||
]
|
||||
|
||||
for col, icon, bg, color, dark, border, label, val, sub in cards:
|
||||
|
|
@ -716,37 +678,28 @@ def _render_live_example(df_c):
|
|||
"Contoh tweet acak dari dataset — refresh halaman untuk contoh berbeda"
|
||||
)
|
||||
|
||||
# Urutan tampilan sesuai pipeline: CF → Clean → Norm → Token → Stop → Stem
|
||||
# Urutan tampilan sesuai pipeline: CF → Clean → Norm → Stop → Stem
|
||||
steps_ex = [
|
||||
("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0", False),
|
||||
("① Setelah Case Folding", "setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe", False),
|
||||
("② Setelah Cleaning", "setelah_cleaning", "#1e3a8a", "#eef2ff", "#c7d2fe", False),
|
||||
("③ Setelah Normalisasi", "setelah_normalisasi", "#14532d", "#f0fdf4", "#86efac", False),
|
||||
("④ Setelah Tokenizing", "setelah_tokenizing", "#3b0764", "#f5f3ff", "#c4b5fd", True),
|
||||
("⑤ Setelah Stopword", "setelah_stopword", "#7c2d12", "#fff7ed", "#fed7aa", False),
|
||||
("⑥ Hasil Akhir (Stem)", "clean_text", "#713f12", "#fefce8", "#fde68a", False),
|
||||
("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0"),
|
||||
("① Setelah Case Folding", "setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe"),
|
||||
("② Setelah Cleaning", "setelah_cleaning", "#1e3a8a", "#eef2ff", "#c7d2fe"),
|
||||
("③ Setelah Normalisasi", "setelah_normalisasi", "#14532d", "#f0fdf4", "#86efac"),
|
||||
("④ Setelah Stopword", "setelah_stopword", "#7c2d12", "#fff7ed", "#fed7aa"),
|
||||
("⑤ Hasil Akhir (Stem)", "clean_text", "#713f12", "#fefce8", "#fde68a"),
|
||||
]
|
||||
|
||||
for label, col_key, text_color, bg, border, is_token in steps_ex:
|
||||
for label, col_key, text_color, bg, border in steps_ex:
|
||||
raw = sample.get(col_key, "-")
|
||||
text_display = str(raw) if raw and str(raw).strip() else "—"
|
||||
|
||||
if is_token and text_display != "—":
|
||||
words = text_display.split(" | ")
|
||||
chips = "".join(
|
||||
f'<span class="token-chip">{w}</span>'
|
||||
for w in words if w.strip()
|
||||
)
|
||||
content_html = f'<div style="line-height:2;">{chips}</div>'
|
||||
char_info = f"{len(words)} token"
|
||||
else:
|
||||
word_count = len(text_display.split()) if text_display != "—" else 0
|
||||
char_count = len(text_display) if text_display != "—" else 0
|
||||
char_info = f"{word_count} kata · {char_count} karakter"
|
||||
|
||||
content_html = (
|
||||
f'<div style="font-size:0.82rem;color:{text_color};'
|
||||
f'line-height:1.65;word-break:break-word;">{text_display}</div>'
|
||||
)
|
||||
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(
|
||||
f'<div class="example-box" style="background:{bg};border:1.5px solid {border};'
|
||||
|
|
@ -763,66 +716,6 @@ def _render_live_example(df_c):
|
|||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# LENGTH COMPARISON CHART
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _render_length_comparison_chart(df, df_c):
|
||||
_section_header(
|
||||
"📐 Perbandingan Panjang Teks per Tahap",
|
||||
"Rata-rata karakter per tweet di tiap tahap · Naik di normalisasi adalah normal (ekspansi singkatan)"
|
||||
)
|
||||
|
||||
stages = [
|
||||
"Teks Asli", "① Case Folding", "② Cleaning",
|
||||
"③ Normalisasi", "④ Tokenizing", "⑤ Stopword", "⑥ Stemming"
|
||||
]
|
||||
avgs = [
|
||||
df["text"].astype(str).str.len().mean(),
|
||||
df_c["setelah_casefolding"].astype(str).str.len().mean(),
|
||||
df_c["setelah_cleaning"].astype(str).str.len().mean(),
|
||||
df_c["setelah_normalisasi"].astype(str).str.len().mean(),
|
||||
df_c["setelah_tokenizing"].apply(
|
||||
lambda x: len(" ".join(str(x).split(" | ")))
|
||||
).mean(),
|
||||
df_c["setelah_stopword"].astype(str).str.len().mean(),
|
||||
df_c["clean_text"].astype(str).str.len().mean(),
|
||||
]
|
||||
avgs = [round(a, 1) for a in avgs]
|
||||
colors = ["#94a3b8", "#0284c7", "#3b6cf7", "#16a34a", "#7c3aed", "#ea580c", "#ca8a04"]
|
||||
|
||||
fig = go.Figure(data=[
|
||||
go.Bar(
|
||||
x=stages, y=avgs,
|
||||
marker=dict(color=colors, line=dict(width=0), opacity=0.9, cornerradius=8),
|
||||
text=[f"{v}" for v in avgs],
|
||||
textposition="outside",
|
||||
textfont=dict(size=10, color="#475569"),
|
||||
width=0.55,
|
||||
hovertemplate="<b>%{x}</b><br>Rata-rata: %{y} karakter<extra></extra>",
|
||||
)
|
||||
])
|
||||
fig.update_layout(
|
||||
height=300,
|
||||
margin=dict(l=0, r=10, t=24, b=10),
|
||||
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
||||
xaxis=dict(tickfont=dict(size=10, color="#64748b"),
|
||||
showgrid=False, zeroline=False, showline=False, fixedrange=True),
|
||||
yaxis=dict(tickfont=dict(size=9, color="#94a3b8"),
|
||||
showgrid=True, gridcolor="rgba(226,232,240,0.7)",
|
||||
griddash="dot", gridwidth=1,
|
||||
zeroline=False, showline=False, fixedrange=True),
|
||||
showlegend=False, hovermode="x unified",
|
||||
)
|
||||
|
||||
st.markdown("""
|
||||
<div style="background:#ffffff;border:1.5px solid #e2e8f0;border-radius:14px;
|
||||
padding:1rem 1.2rem 0.5rem;box-shadow:0 2px 6px rgba(15,23,42,0.05);">
|
||||
""", unsafe_allow_html=True)
|
||||
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
|
||||
st.markdown("</div>", unsafe_allow_html=True)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# TOP WORDS CHART
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
|
@ -830,7 +723,7 @@ def _render_length_comparison_chart(df, df_c):
|
|||
def _render_top_words_chart(df_c):
|
||||
_section_header(
|
||||
"📊 Kata-Kata Paling Sering Muncul",
|
||||
f"Dari {len(df_c):,} tweet yang sudah bersih (hasil akhir tahap 6) — Top 20 kata"
|
||||
f"Dari {len(df_c):,} tweet yang sudah bersih (hasil akhir tahap 5) — Top 20 kata"
|
||||
)
|
||||
|
||||
if "_tokens_clean" in df_c.columns:
|
||||
|
|
@ -930,8 +823,8 @@ def show():
|
|||
|
||||
# ── Pipeline Overview ─────────────────────────────────────
|
||||
_section_header(
|
||||
"🔬 Alur NLP Pipeline — 6 Tahap Preprocessing",
|
||||
"Setiap tweet diproses berurutan melalui 6 tahap sebelum siap dianalisis sentimennya"
|
||||
"🔬 Alur NLP Pipeline — 5 Tahap Preprocessing",
|
||||
"Setiap tweet diproses berurutan melalui 5 tahap sebelum siap dianalisis sentimennya"
|
||||
)
|
||||
|
||||
stemmer_tmp = _load_stemmer()
|
||||
|
|
@ -967,12 +860,12 @@ def show():
|
|||
data_marker = (total_tweets_in_db, latest_crawl_marker)
|
||||
|
||||
cache_key = (
|
||||
f"pp6_{st.session_state.analysis_mode}_"
|
||||
f"pp5_{st.session_state.analysis_mode}_"
|
||||
f"{start_date}_{end_date}_{total_tweets_in_db}_{latest_crawl_marker}"
|
||||
)
|
||||
|
||||
for old_key in list(st.session_state.keys()):
|
||||
if old_key.startswith("pp6_") and old_key != cache_key:
|
||||
if old_key.startswith("pp5_") and old_key != cache_key:
|
||||
del st.session_state[old_key]
|
||||
|
||||
force_refresh = data_marker != st.session_state.get("_pp_last_data_marker")
|
||||
|
|
@ -981,7 +874,7 @@ def show():
|
|||
stemmer = _load_stemmer()
|
||||
stopwords = _load_stopwords()
|
||||
|
||||
with st.spinner("🧹 Menjalankan 6 tahap preprocessing…"):
|
||||
with st.spinner("🧹 Menjalankan 5 tahap preprocessing…"):
|
||||
results = []
|
||||
for _, row in df.iterrows():
|
||||
r = full_preprocessing(row["text"], stopwords, stemmer)
|
||||
|
|
@ -1002,12 +895,6 @@ def show():
|
|||
stemmer_ok = st.session_state.get(cache_key + "_stemmer_ok", False)
|
||||
|
||||
# ── Statistik ─────────────────────────────────────────────
|
||||
avg_tok_before = df_c["setelah_tokenizing"].apply(
|
||||
lambda x: len(str(x).split(" | ")) if str(x).strip() else 0
|
||||
).mean()
|
||||
avg_tok_after = df_c["clean_text"].apply(
|
||||
lambda x: len(str(x).split()) if str(x).strip() else 0
|
||||
).mean()
|
||||
removed = len(df) - len(df_c)
|
||||
|
||||
_section_header(
|
||||
|
|
@ -1015,7 +902,7 @@ def show():
|
|||
f"Berdasarkan tanggal asli tweet · {filter_label}"
|
||||
)
|
||||
_gap("xs")
|
||||
_render_stat_pills(len(df), len(df_c), avg_tok_before, avg_tok_after, removed)
|
||||
_render_stat_pills(len(df), len(df_c), removed)
|
||||
_gap("lg")
|
||||
|
||||
_render_live_example(df_c)
|
||||
|
|
@ -1031,14 +918,13 @@ def show():
|
|||
df_c = df_c.copy()
|
||||
df_c["crawled_at"] = pd.NaT
|
||||
|
||||
# Kolom ditampilkan sesuai urutan pipeline: CF → Clean → Norm → Token → Stop → Stem
|
||||
# Kolom ditampilkan sesuai urutan pipeline: CF → Clean → Norm → Stop → Stem
|
||||
disp = df_c[[
|
||||
"tweet_id", "created_at", "crawled_at",
|
||||
"text_asli",
|
||||
"setelah_casefolding",
|
||||
"setelah_cleaning",
|
||||
"setelah_normalisasi",
|
||||
"setelah_tokenizing",
|
||||
"setelah_stopword",
|
||||
"clean_text",
|
||||
]].copy()
|
||||
|
|
@ -1049,9 +935,8 @@ def show():
|
|||
"① Case Folding",
|
||||
"② Cleaning",
|
||||
"③ Normalisasi",
|
||||
"④ Tokenizing",
|
||||
"⑤ Stopword",
|
||||
"⑥ Hasil Akhir",
|
||||
"④ Stopword",
|
||||
"⑤ Hasil Akhir",
|
||||
]
|
||||
|
||||
disp["Tanggal Tweet"] = disp["Tanggal Tweet"].apply(format_dt)
|
||||
|
|
@ -1060,30 +945,26 @@ def show():
|
|||
render_standard_table(
|
||||
disp,
|
||||
height=360,
|
||||
min_width=2200,
|
||||
min_width=1980,
|
||||
nowrap=["ID Tweet", "Tanggal Tweet", "Masuk Database"],
|
||||
wide_columns=[
|
||||
"Teks Asli", "① Case Folding", "② Cleaning",
|
||||
"③ Normalisasi", "④ Tokenizing", "⑤ Stopword", "⑥ Hasil Akhir",
|
||||
"③ Normalisasi", "④ Stopword", "⑤ Hasil Akhir",
|
||||
],
|
||||
column_widths={
|
||||
"ID Tweet": "155px",
|
||||
"Tanggal Tweet": "165px",
|
||||
"Masuk Database": "165px",
|
||||
"Teks Asli": "280px",
|
||||
"① Case Folding": "220px",
|
||||
"① Case Folding": "240px",
|
||||
"② Cleaning": "240px",
|
||||
"③ Normalisasi": "220px",
|
||||
"④ Tokenizing": "240px",
|
||||
"⑤ Stopword": "220px",
|
||||
"⑥ Hasil Akhir": "220px",
|
||||
"③ Normalisasi": "240px",
|
||||
"④ Stopword": "230px",
|
||||
"⑤ Hasil Akhir": "230px",
|
||||
},
|
||||
)
|
||||
_gap("lg")
|
||||
|
||||
_render_length_comparison_chart(df, df_c)
|
||||
_gap("lg")
|
||||
|
||||
_render_top_words_chart(df_c)
|
||||
_gap("lg")
|
||||
|
||||
|
|
@ -1102,7 +983,7 @@ def show():
|
|||
with d1:
|
||||
st.download_button(
|
||||
"📥 Unduh Hasil Preprocessing Lengkap (semua kolom)",
|
||||
df_c.drop(columns=["_tokens_raw", "_tokens_clean"], errors="ignore"
|
||||
df_c.drop(columns=["_tokens_clean"], errors="ignore"
|
||||
).to_csv(index=False).encode("utf-8"),
|
||||
f"preprocessing_lengkap_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
||||
"text/csv",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,39 +1,509 @@
|
|||
"""
|
||||
sentiment_service.py
|
||||
====================
|
||||
Hybrid Classifier untuk analisis sentimen tweet Bahasa Indonesia.
|
||||
|
||||
PIPELINE PREPROCESSING — 5 TAHAP (selaras dengan preprocessing_page.py):
|
||||
1. Case Folding → lowercase dulu sebelum cleaning
|
||||
2. Cleaning → hapus URL, mention, hashtag, angka, emoji, tanda baca
|
||||
3. Normalisasi → singkatan/slang → kata baku
|
||||
4. Stopword Removal → buang kata umum, jaga kata sentimen penting
|
||||
5. Stemming → bentuk dasar via Sastrawi ECS
|
||||
|
||||
Catatan: Tokenizing tidak menjadi tahap tersendiri karena:
|
||||
• Stopword removal & stemming sudah melakukan split() secara internal
|
||||
• TF-IDF melakukan tokenisasi sendiri saat inferensi
|
||||
|
||||
ARSITEKTUR HYBRID:
|
||||
Teks Asli
|
||||
├── preprocess_untuk_lexicon() ──→ _hitung_skor_lexicon() ──┐
|
||||
└── preprocess_for_model() ──→ TF-IDF → NB Model ───────┘
|
||||
↓
|
||||
_klasifikasi_hybrid() → Label + Confidence
|
||||
"""
|
||||
|
||||
import re
|
||||
import string
|
||||
import joblib
|
||||
|
||||
model = joblib.load("model_naive_bayes.pkl")
|
||||
tfidf = joblib.load("tfidf_vectorizer.pkl")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# KATA SENTIMEN PENTING
|
||||
# Tidak boleh dihapus di tahap stopword removal
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
KATA_SENTIMEN_PENTING = {
|
||||
# ── Negasi ──────────────────────────────────────────────
|
||||
"tidak", "bukan", "jangan", "kurang", "belum", "tanpa",
|
||||
|
||||
# ── Sentimen POSITIF ────────────────────────────────────
|
||||
"keren", "bagus", "mantap", "setuju", "dukung", "mendukung",
|
||||
"andal", "handal", "gercep", "bangga", "senang", "suka",
|
||||
"baik", "benar", "tepat", "oke",
|
||||
"sejahtera", "berkembang", "maju", "inovatif",
|
||||
"tegas", "sigap", "tanggap", "adil", "bijak", "bermanfaat",
|
||||
"untung", "berhasil", "sukses", "solusi", "manfaat",
|
||||
"berguna", "membantu", "bantu", "pro", "lanjut",
|
||||
"sangat", "banget", "sekali", "paling", "amat", "luar", "biasa",
|
||||
|
||||
# ── Sentimen NEGATIF evaluatif ──────────────────────────
|
||||
"kecewa", "buruk", "jelek", "parah", "gagal", "hancur",
|
||||
"rusak", "bohong", "tipu", "korupsi",
|
||||
|
||||
# ── Emosi ───────────────────────────────────────────────
|
||||
"marah", "sedih", "khawatir",
|
||||
}
|
||||
|
||||
|
||||
def bersihkan_teks(text):
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# NORMALISASI (selaras dengan preprocessing_page.py)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
text = str(text).lower()
|
||||
NORMALISASI = {
|
||||
# Negasi
|
||||
"gk": "tidak", "ga": "tidak", "gak": "tidak",
|
||||
"nggak": "tidak", "ngga": "tidak", "tdk": "tidak",
|
||||
"tak": "tidak", "enggak": "tidak", "engga": "tidak",
|
||||
"kagak": "tidak", "kaga": "tidak", "ndak": "tidak",
|
||||
"gkk": "tidak", "ngak": "tidak",
|
||||
# Kata ganti
|
||||
"yg": "yang", "dgn": "dengan", "utk": "untuk",
|
||||
"org": "orang", "krn": "karena", "dr": "dari",
|
||||
"sm": "sama", "pd": "pada", "dlm": "dalam",
|
||||
"bwt": "buat", "trm": "terima",
|
||||
# Verba
|
||||
"tp": "tapi", "tpi": "tapi", "jd": "jadi",
|
||||
"sdh": "sudah", "blm": "belum", "emg": "memang",
|
||||
"emang": "memang", "gimana": "bagaimana",
|
||||
"gitu": "begitu", "gini": "begini",
|
||||
"udah": "sudah", "udh": "sudah",
|
||||
# Intensitas
|
||||
"bgt": "banget", "bngt": "banget", "bget": "banget", "bgtt": "banget",
|
||||
# Positif informal
|
||||
"bener": "benar", "beneran": "benar",
|
||||
"mantep": "mantap", "mntap": "mantap",
|
||||
"kece": "keren",
|
||||
"cucok": "cocok", "cucuk": "cocok",
|
||||
"cakep": "bagus",
|
||||
"sip": "baik", "siipp": "baik",
|
||||
"top": "terbaik",
|
||||
"jos": "bagus", "josss": "bagus",
|
||||
"goks": "luar biasa",
|
||||
"setujuu": "setuju", "stuju": "setuju",
|
||||
"proud": "bangga",
|
||||
"mantul": "mantap betul",
|
||||
# Negatif informal
|
||||
"ancur": "hancur", "ancrr": "hancur",
|
||||
"parahh": "parah", "parahhh": "parah",
|
||||
"gagall": "gagal",
|
||||
"ngaco": "tidak benar",
|
||||
"ngasal": "tidak benar",
|
||||
"gaje": "tidak jelas",
|
||||
# Domain
|
||||
"ongkir": "ongkos kirim",
|
||||
"freeongkir": "gratis ongkos kirim",
|
||||
"gratisongkir": "gratis ongkos kirim",
|
||||
"free": "gratis",
|
||||
"ecommerce": "e commerce",
|
||||
"seller": "penjual",
|
||||
"buyer": "pembeli",
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# LEXICON SENTIMEN
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
LEXICON_POSITIF = {
|
||||
"bagus", "baik", "keren", "mantap", "mantep", "hebat",
|
||||
"oke", "sip", "top", "jos", "goks", "kece", "mantul",
|
||||
"setuju", "dukung", "mendukung", "pro", "lanjut", "sepakat",
|
||||
"bangga", "senang", "suka", "puas", "gembira", "bahagia",
|
||||
"berhasil", "sukses", "berjaya", "prestasi", "pencapaian",
|
||||
"andal", "handal", "gercep", "sigap", "tanggap", "tegas",
|
||||
"adil", "bijak", "bermanfaat", "berguna", "membantu",
|
||||
"inovatif", "maju", "berkembang", "sejahtera",
|
||||
"benar", "tepat", "jelas", "transparan", "amanah", "terpercaya",
|
||||
"untung", "gratis", "murah", "hemat", "terjangkau",
|
||||
"terbaik", "luar biasa",
|
||||
"cakep", "cucok", "gaskeun", "kuy",
|
||||
"dukung", "bantu", "solusi", "manfaat",
|
||||
"memuaskan", "membanggakan", "mengagumkan",
|
||||
}
|
||||
|
||||
LEXICON_NEGATIF = {
|
||||
"buruk", "jelek", "parah", "rusak", "hancur", "ancur",
|
||||
"gagal", "gagall", "ambruk", "terpuruk", "bangkrut",
|
||||
"bohong", "tipu", "curang", "manipulasi", "korupsi", "penipuan",
|
||||
"kebohongan",
|
||||
"kecewa", "marah", "sedih", "khawatir", "takut", "benci",
|
||||
"jijik", "muak", "kesal", "frustrasi",
|
||||
"mahal", "rugi", "merugikan",
|
||||
"lambat", "lemot", "ribet", "susah", "sulit", "bermasalah",
|
||||
"ngaco", "ngasal", "gaje", "receh",
|
||||
"tidak benar", "tidak jelas", "tidak adil", "tidak berguna",
|
||||
"mengecewakan", "menyebalkan", "menyusahkan",
|
||||
}
|
||||
|
||||
KATA_NEGASI = {
|
||||
"tidak", "bukan", "jangan", "belum", "tanpa", "kurang",
|
||||
"anti", "non",
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STOPWORDS
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _load_stopwords() -> set:
|
||||
"""Load stopword dengan penjagaan kata sentimen penting."""
|
||||
stopword_file = "indonesian-stopwords-complete.txt"
|
||||
base = set()
|
||||
try:
|
||||
with open(stopword_file, "r", encoding="utf-8") as f:
|
||||
base = set(f.read().splitlines())
|
||||
except FileNotFoundError:
|
||||
base = {
|
||||
"yang", "dan", "di", "ke", "dari", "ini", "itu",
|
||||
"dengan", "untuk", "pada", "adalah", "oleh", "ada",
|
||||
"ya", "akan", "atau", "juga", "sama", "karena",
|
||||
"jika", "sudah", "telah", "saat", "agar", "maka",
|
||||
"lagi", "bila", "bisa", "pun", "nya",
|
||||
}
|
||||
|
||||
# Jangan hapus kata sentimen penting
|
||||
for kata in KATA_SENTIMEN_PENTING:
|
||||
base.discard(kata)
|
||||
|
||||
# Tambahan stopword domain-spesifik
|
||||
base.update({
|
||||
"rt", "amp", "https", "http", "co", "t",
|
||||
"wkwk", "wkwkwk", "haha", "hehe", "xixi", "hahaha",
|
||||
"yg", "dgn", "utk", "dr", "krn", "tp", "jd", "sdh",
|
||||
"aja", "doang", "nih", "sih", "dong", "deh",
|
||||
"loh", "lah", "tuh", "kak", "gan", "bro", "sis",
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEMMER
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _load_stemmer():
|
||||
"""Load Sastrawi stemmer. Return None jika tidak terinstall."""
|
||||
try:
|
||||
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
|
||||
return StemmerFactory().create_stemmer()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ── Inisialisasi global ─────────────────────────────────────────────────────
|
||||
_STOPWORDS = _load_stopwords()
|
||||
_STEMMER = _load_stemmer()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# MODEL LOADING (lazy)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
_model = None
|
||||
_tfidf = None
|
||||
|
||||
|
||||
def _load_model():
|
||||
"""
|
||||
Lazy load NB model + TF-IDF vectorizer.
|
||||
Return: (model, tfidf) — keduanya bisa None.
|
||||
"""
|
||||
global _model, _tfidf
|
||||
if _model is None:
|
||||
try:
|
||||
_model = joblib.load("model_naive_bayes.pkl")
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Gagal load model_naive_bayes.pkl: {e}")
|
||||
if _tfidf is None:
|
||||
try:
|
||||
_tfidf = joblib.load("tfidf_vectorizer.pkl")
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Gagal load tfidf_vectorizer.pkl: {e}")
|
||||
return _model, _tfidf
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# PREPROCESSING PIPELINE — 5 TAHAP
|
||||
#
|
||||
# URUTAN (selaras dengan preprocessing_page.py):
|
||||
# 1. Case Folding → lowercase
|
||||
# 2. Cleaning → hapus noise
|
||||
# 3. Normalisasi → normalisasi kata
|
||||
# 4. Stopword Removal → buang stopword (split() dilakukan internal)
|
||||
# 5. Stemming → bentuk dasar
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _case_folding(text: str) -> str:
|
||||
"""Tahap 1: Lowercase seluruh teks."""
|
||||
return str(text).lower()
|
||||
|
||||
|
||||
def _cleaning(text: str) -> str:
|
||||
"""Tahap 2: Hapus noise (URL, mention, hashtag, angka, emoji, tanda baca)."""
|
||||
text = re.sub(r"http\S+|www\S+|https\S+", "", text)
|
||||
text = re.sub(r"@\w+", "", text)
|
||||
text = re.sub(r"#\w+", "", text)
|
||||
text = re.sub(r"\d+", "", text)
|
||||
text = re.sub(
|
||||
r"http\S+|www\S+|@\w+|#|\d+",
|
||||
"",
|
||||
text
|
||||
r"[\U00010000-\U0010ffff"
|
||||
r"\U0001F600-\U0001F64F"
|
||||
r"\U0001F300-\U0001F5FF"
|
||||
r"\U0001F680-\U0001F6FF"
|
||||
r"\U0001F1E0-\U0001F1FF"
|
||||
r"\u2600-\u26FF\u2700-\u27BF"
|
||||
r"]+", "", text, flags=re.UNICODE,
|
||||
)
|
||||
|
||||
text = text.translate(
|
||||
str.maketrans("", "", string.punctuation)
|
||||
)
|
||||
|
||||
text = text.translate(str.maketrans("", "", string.punctuation))
|
||||
text = re.sub(r"[^a-zA-Z\s]", "", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def prediksi_sentimen(list_text):
|
||||
def _normalisasi(text: str) -> str:
|
||||
"""Tahap 3: Normalisasi singkatan dan kata tidak baku."""
|
||||
return " ".join(NORMALISASI.get(word, word) for word in text.split())
|
||||
|
||||
clean_texts = [
|
||||
bersihkan_teks(text)
|
||||
for text in list_text
|
||||
|
||||
def _remove_stopwords(tokens: list) -> list:
|
||||
"""Tahap 4: Stopword removal dengan penjagaan kata sentimen."""
|
||||
return [
|
||||
w for w in tokens
|
||||
if (w not in _STOPWORDS or w in KATA_SENTIMEN_PENTING) and len(w) > 2
|
||||
]
|
||||
|
||||
vectors = tfidf.transform(clean_texts)
|
||||
|
||||
predictions = model.predict(vectors)
|
||||
def _stemming(tokens: list) -> list:
|
||||
"""Tahap 5: Stemming ke bentuk dasar via Sastrawi ECS."""
|
||||
if _STEMMER is None:
|
||||
return tokens
|
||||
return [_STEMMER.stem(w) for w in tokens]
|
||||
|
||||
return clean_texts, predictions
|
||||
|
||||
def preprocess_for_model(text: str) -> str:
|
||||
"""
|
||||
Full 5-tahap preprocessing → string teks bersih siap TF-IDF.
|
||||
|
||||
URUTAN: Case Folding → Cleaning → Normalisasi
|
||||
→ Stopword Removal → Stemming
|
||||
|
||||
Split kata dilakukan secara internal di tahap Stopword Removal.
|
||||
Pipeline HARUS sama persis dengan yang dipakai saat training model.
|
||||
"""
|
||||
s1 = _case_folding(text) # Tahap 1
|
||||
s2 = _cleaning(s1) # Tahap 2
|
||||
s3 = _normalisasi(s2) # Tahap 3
|
||||
s4 = _remove_stopwords(s3.split()) # Tahap 4 (split inline)
|
||||
s5 = _stemming(s4) # Tahap 5
|
||||
return " ".join(s5)
|
||||
|
||||
|
||||
def preprocess_untuk_lexicon(text: str) -> str:
|
||||
"""
|
||||
Preprocessing RINGAN untuk lexicon matching.
|
||||
Tidak di-stem → kata asli bisa dicocokkan dengan lexicon.
|
||||
Pipeline: Case Folding → Cleaning → Normalisasi saja.
|
||||
"""
|
||||
s1 = _case_folding(text)
|
||||
s2 = _cleaning(s1)
|
||||
s3 = _normalisasi(s2)
|
||||
return s3
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# LEXICON SCORER
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _hitung_skor_lexicon(teks_lexicon: str) -> dict:
|
||||
"""
|
||||
Hitung skor positif dan negatif dari teks via lexicon.
|
||||
|
||||
NEGATION HANDLING:
|
||||
Kata negasi dalam window 2 kata sebelum kata sentimen → polaritas dibalik.
|
||||
Contoh: "tidak bagus" → ada "tidak" sebelum "bagus" (POSITIF)
|
||||
→ skor_neg += 1 (bukan skor_pos)
|
||||
|
||||
Return: {"positif": int, "negatif": int, "net": int}
|
||||
"""
|
||||
tokens = teks_lexicon.split()
|
||||
skor_pos = 0
|
||||
skor_neg = 0
|
||||
|
||||
for i, token in enumerate(tokens):
|
||||
ada_negasi = any(
|
||||
tokens[i - j] in KATA_NEGASI
|
||||
for j in range(1, 3)
|
||||
if i - j >= 0
|
||||
)
|
||||
|
||||
if token in LEXICON_POSITIF:
|
||||
if ada_negasi:
|
||||
skor_neg += 1
|
||||
else:
|
||||
skor_pos += 1
|
||||
elif token in LEXICON_NEGATIF:
|
||||
if ada_negasi:
|
||||
skor_pos += 1
|
||||
else:
|
||||
skor_neg += 1
|
||||
|
||||
return {
|
||||
"positif": skor_pos,
|
||||
"negatif": skor_neg,
|
||||
"net": skor_pos - skor_neg,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# HYBRID CLASSIFIER
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
# Normalisasi label dari berbagai format yang mungkin dipakai model
|
||||
_LABEL_MAP = {
|
||||
"positif": "Positif", "Positif": "Positif", "positive": "Positif", "pos": "Positif",
|
||||
"negatif": "Negatif", "Negatif": "Negatif", "negative": "Negatif", "neg": "Negatif",
|
||||
"netral": "Netral", "Netral": "Netral", "neutral": "Netral", "net": "Netral",
|
||||
}
|
||||
|
||||
|
||||
def _prediksi_model(teks_model: str):
|
||||
"""
|
||||
Dapatkan prediksi dari NB model.
|
||||
Return: (label_norm, confidence, proba_dict) atau None.
|
||||
"""
|
||||
model, tfidf = _load_model()
|
||||
if model is None or tfidf is None or not teks_model.strip():
|
||||
return None
|
||||
try:
|
||||
vec = tfidf.transform([teks_model])
|
||||
pred_raw = model.predict(vec)[0]
|
||||
proba = model.predict_proba(vec)[0]
|
||||
classes = list(model.classes_)
|
||||
|
||||
label_norm = _LABEL_MAP.get(str(pred_raw), "Netral")
|
||||
pred_idx = classes.index(pred_raw) if pred_raw in classes else 0
|
||||
confidence = float(proba[pred_idx])
|
||||
|
||||
proba_dict = {
|
||||
_LABEL_MAP.get(str(cls), str(cls)): float(p)
|
||||
for cls, p in zip(classes, proba)
|
||||
}
|
||||
return label_norm, confidence, proba_dict
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Prediksi model gagal: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _klasifikasi_hybrid(
|
||||
teks_model: str,
|
||||
skor: dict,
|
||||
teks_lower: str,
|
||||
) -> tuple:
|
||||
"""
|
||||
Klasifikasi hybrid: sinyal lexicon + model NB.
|
||||
|
||||
LOGIKA KEPUTUSAN (berurutan):
|
||||
|
||||
Layer 1 — Override Positif KUAT (net >= 2):
|
||||
→ Positif, confidence 60–92%
|
||||
|
||||
Layer 2 — Override Positif LEMAH (net == 1):
|
||||
→ Cek model; jika model < 65% yakin Negatif → Positif
|
||||
→ Jika model sangat yakin Negatif → tetap Positif (confidence rendah)
|
||||
|
||||
Layer 3 — Override Negatif KUAT (net <= -2):
|
||||
→ Negatif, confidence 60–90%
|
||||
|
||||
Layer 4 — Fallback Model NB:
|
||||
→ Prediksi model dipakai, TAPI:
|
||||
• Jika model = Negatif AND net >= 0 AND confidence < 75%
|
||||
→ downgrade ke Netral (koreksi bias model)
|
||||
• Kasus lainnya → percaya model
|
||||
|
||||
Layer 5 — Ultimate Fallback (model tidak tersedia):
|
||||
→ Gunakan skor lexicon saja
|
||||
|
||||
Return: (label: str, confidence: float)
|
||||
"""
|
||||
net = skor["net"]
|
||||
pos = skor["positif"]
|
||||
|
||||
# ── Layer 1: Positif KUAT ─────────────────────────────────────────────────
|
||||
if net >= 2:
|
||||
conf = min(0.60 + (net * 0.07), 0.92)
|
||||
return ("Positif", round(conf, 3))
|
||||
|
||||
# ── Layer 2: Positif LEMAH ────────────────────────────────────────────────
|
||||
if net == 1 and pos >= 1:
|
||||
model_result = _prediksi_model(teks_model)
|
||||
if model_result is not None:
|
||||
_, _, proba_dict = model_result
|
||||
neg_prob = proba_dict.get("Negatif", 0.0)
|
||||
if neg_prob < 0.65:
|
||||
conf = round(0.55 + (0.65 - neg_prob) * 0.3, 3)
|
||||
return ("Positif", min(conf, 0.80))
|
||||
else:
|
||||
return ("Positif", 0.55)
|
||||
else:
|
||||
return ("Positif", 0.58)
|
||||
|
||||
# ── Layer 3: Negatif KUAT ─────────────────────────────────────────────────
|
||||
if net <= -2:
|
||||
conf = min(0.60 + (abs(net) * 0.06), 0.90)
|
||||
return ("Negatif", round(conf, 3))
|
||||
|
||||
# ── Layer 4: Fallback Model NB ────────────────────────────────────────────
|
||||
model_result = _prediksi_model(teks_model)
|
||||
if model_result is not None:
|
||||
label_norm, confidence, proba_dict = model_result
|
||||
|
||||
# Anti-bias correction:
|
||||
# Model prediksi Negatif tapi tidak ada sinyal negatif dari lexicon
|
||||
# dan confidence < 75% → kemungkinan bias → turunkan ke Netral
|
||||
if label_norm == "Negatif" and net >= 0 and confidence < 0.75:
|
||||
corrected_conf = round(0.50 + max(0, confidence - 0.50) * 0.2, 3)
|
||||
return ("Netral", corrected_conf)
|
||||
|
||||
return (label_norm, round(confidence, 3))
|
||||
|
||||
# ── Layer 5: Ultimate Fallback ────────────────────────────────────────────
|
||||
if net > 0:
|
||||
return ("Positif", 0.55)
|
||||
elif net < 0:
|
||||
return ("Negatif", 0.55)
|
||||
else:
|
||||
return ("Netral", 0.50)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# BACKWARD COMPATIBILITY
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def bersihkan_teks(text: str) -> str:
|
||||
"""[LEGACY] Gunakan preprocess_for_model() untuk pipeline lengkap."""
|
||||
return preprocess_for_model(text)
|
||||
|
||||
|
||||
def prediksi_sentimen(list_text: list):
|
||||
"""
|
||||
[LEGACY] Prediksi batch dengan hybrid classifier.
|
||||
Return: (list clean_texts, list labels)
|
||||
"""
|
||||
clean_texts = [preprocess_for_model(t) for t in list_text]
|
||||
labels = []
|
||||
for text in list_text:
|
||||
teks_model = preprocess_for_model(text)
|
||||
teks_lexicon = preprocess_untuk_lexicon(text)
|
||||
teks_lower = str(text).lower()
|
||||
skor = _hitung_skor_lexicon(teks_lexicon)
|
||||
label, _ = _klasifikasi_hybrid(teks_model, skor, teks_lower)
|
||||
labels.append(label)
|
||||
return clean_texts, labels
|
||||
|
|
@ -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 +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,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,21 +1,6 @@
|
|||
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
|
||||
"2054502269576728775","Wed May 13 10:01:26 +0000 2026","0","Kebijakan komdigi soal ongkir sangat merugikan konsumen.","2054502269576728775","","","in","","0","0","0","https://x.com/undefined/status/2054502269576728775","1814143201718247424",
|
||||
"2054823109291852155","Thu May 14 07:16:20 +0000 2026","0","komdigi bikin aturan ongkir baru = langkah yang cukup bijak","2054823109291852155","","","in","","0","0","0","https://x.com/undefined/status/2054823109291852155","1814143201718247424",
|
||||
"2054502892468662645","Wed May 13 10:03:55 +0000 2026","0","Komdigi dan gratis ongkir sedang ramai dibahas.","2054502892468662645","","","in","","0","0","0","https://x.com/undefined/status/2054502892468662645","1814143201718247424",
|
||||
"2054501918895202348","Wed May 13 10:00:02 +0000 2026","0","Mantap sih @komdigi akhirnya free ongkir diatur juga ","2054501918895202348","","","in","","0","0","0","https://x.com/undefined/status/2054501918895202348","1814143201718247424",
|
||||
"2054861268750901484","Thu May 14 09:47:58 +0000 2026","0","komdigi ongkir?","2054861268750901484","","","in","","0","0","0","https://x.com/undefined/status/2054861268750901484","1814143201718247424",
|
||||
"2054502131672236531","Wed May 13 10:00:53 +0000 2026","0","kebijakan komdigi tentang ongkir patut diapresiasi.","2054502131672236531","","","in","","0","0","0","https://x.com/undefined/status/2054502131672236531","1814143201718247424",
|
||||
"2054478880933855563","Wed May 13 08:28:30 +0000 2026","0","KOMDIGI KNP NGURUSIN ONGKIR SIH??","2054478880933855563","","","in","","0","0","0","https://x.com/undefined/status/2054478880933855563","1814143201718247424",
|
||||
"2054502618484134392","Wed May 13 10:02:49 +0000 2026","0","@komdigi kenapa sih harus ngurusin ongkir segala???","2054502618484134392","","","in","","0","0","0","https://x.com/undefined/status/2054502618484134392","1814143201718247424",
|
||||
"2054816992662790146","Thu May 14 06:52:02 +0000 2026","0","komdigi??? ongkir??? marketplace???","2054816992662790146","","","in","","0","0","0","https://x.com/undefined/status/2054816992662790146","1814143201718247424",
|
||||
"2054502958315020394","Wed May 13 10:04:10 +0000 2026","0","Komdigi disebut dalam diskusi mengenai free ongkir.","2054502958315020394","","","in","","0","0","0","https://x.com/undefined/status/2054502958315020394","1814143201718247424",
|
||||
"2054501981369352445","Wed May 13 10:00:17 +0000 2026","0","Gratis ongkir dibatasi malah bikin marketplace lebih fair.","2054501981369352445","","","in","","0","0","0","https://x.com/undefined/status/2054501981369352445","1814143201718247424",
|
||||
"2054502181446078852","Wed May 13 10:01:05 +0000 2026","0","Menurut saya pembatasan gratis ongkir adalah keputusan tepat","2054502181446078852","","","in","","0","0","0","https://x.com/undefined/status/2054502181446078852","1814143201718247424",
|
||||
"2054816880318427497","Thu May 14 06:51:35 +0000 2026","0","Ada diskusi publik mengenai kebijakan ongkir dari Komdigi.","2054816880318427497","","","in","","0","0","0","https://x.com/undefined/status/2054816880318427497","1814143201718247424",
|
||||
"2054816818741862819","Thu May 14 06:51:20 +0000 2026","0","Saya melihat berita tentang komdigi dan ongkir tadi pagi.","2054816818741862819","","","in","","0","0","0","https://x.com/undefined/status/2054816818741862819","1814143201718247424",
|
||||
"2054501862066524547","Wed May 13 09:59:49 +0000 2026","0","Saya setuju kebijakan Komdigi soal gratis ongkir dibatasi biar persaingan lebih sehat.","2054501862066524547","","","in","","0","0","0","https://x.com/undefined/status/2054501862066524547","1814143201718247424",
|
||||
"2054502430956769299","Wed May 13 10:02:05 +0000 2026","0","Pembatasan gratis ongkir sangat mengecewakan.","2054502430956769299","","","in","","0","0","0","https://x.com/undefined/status/2054502430956769299","1814143201718247424",
|
||||
"2054502817910706448","Wed May 13 10:03:37 +0000 2026","0","Saya membaca berita tentang gratis ongkir dibatasi.","2054502817910706448","","","in","","0","0","0","https://x.com/undefined/status/2054502817910706448","1814143201718247424",
|
||||
"2054791169775964388","Thu May 14 05:09:25 +0000 2026","0","kebijakan komdigi gratis ongkir kerenn","2054791169775964388","","","in","","0","0","0","https://x.com/undefined/status/2054791169775964388","1814143201718247424",
|
||||
"2054502657201733861","Wed May 13 10:02:58 +0000 2026","0","Gratis ongkir dibatasi? keputusan paling aneh tahun ini.","2054502657201733861","","","in","","0","0","0","https://x.com/undefined/status/2054502657201733861","1814143201718247424",
|
||||
"2054502713959014566","Wed May 13 10:03:12 +0000 2026","0","Gratis ongkir dibatasi = belanja online makin mahal ","2054502713959014566","","","in","","0","0","0","https://x.com/undefined/status/2054502713959014566","1814143201718247424",
|
||||
"2056635322419777630","Tue May 19 07:17:25 +0000 2026","0","komdigi ongkir gk jelas","2056635322419777630","","","in","","0","0","0","https://x.com/undefined/status/2056635322419777630","1814143201718247424",
|
||||
"2056892677149581606","Wed May 20 00:20:04 +0000 2026","0","komdigi keren sih mengadakan kebijakan ongkir kaya gini","2056892677149581606","","","in","","0","0","0","https://x.com/undefined/status/2056892677149581606","1814143201718247424",
|
||||
"2056949159845101648","Wed May 20 04:04:30 +0000 2026","0","komdigi kereenn dan bijak membuat keputusan ongkir seperti inii","2056949159845101648","","","in","","0","0","0","https://x.com/undefined/status/2056949159845101648","1814143201718247424",
|
||||
"2056538953340314095","Wed May 20 16:01:04 +0000 2026","5","@killingmaster27 @evelyndewi7 @katrinrienks @HumorJonTampan @wiyantokdr54 @EviDrajat @RickyKardjono @Micheladam69432 @ebonXboy @AntonRebor79105 @gangsterrandom @Pieter_Sun01 @Rahmah0845 @RSjahfidi @prabowo @SmileyNDeLight @Jediimar @Yens0906 @tokaidirecycle @PembuatSistem @bonapasogit24 @Jenniestale @Maskudin126003 @a8sar6alih @JowoNgalam @KING_RAJA_PWS_ @apaajadeh447870 @50ngoku @Submill @EmmaHusain0845 @BebySoSweet @Yulia_4ja @bungalotus25 @bahlillahadalia wahhh... jadi menteri komdigi dong menteri free ongkir https://t.co/1cfvoqIcNl","2057129489994510620","https://pbs.twimg.com/media/HIxkWwDaMAAXH9U.jpg","killingmaster27","in","","0","2","0","https://x.com/undefined/status/2057129489994510620","1410466956134608899",
|
||||
"2055269612334555468","Fri May 15 12:50:35 +0000 2026","0","komdigi + ongkir = chaos wkwkwk","2055269612334555468","","","in","","0","0","0","https://x.com/undefined/status/2055269612334555468","2043979880086339584",
|
||||
|
|
|
|||
|
Loading…
Reference in New Issue