# -*- coding: utf-8 -*- import streamlit as st import requests import json import pandas as pd import time import plotly.express as px import plotly.graph_objects as go import matplotlib.pyplot as plt import matplotlib.font_manager as fm import matplotlib as mpl import os import tempfile # ──────────────────────────────────────────────── # 頁面設定 # ──────────────────────────────────────────────── st.set_page_config( page_title="PChome 商品價格分析", page_icon="🛒", layout="wide", ) st.title("🛒 PChome 電商商品價格分析") st.markdown("輸入關鍵字與筆數,自動爬取 PChome 商品資料並視覺化分析。") # ──────────────────────────────────────────────── # 側邊欄:使用者輸入 # ──────────────────────────────────────────────── with st.sidebar: st.header("⚙️ 搜尋設定") keyword = st.text_input("搜尋關鍵字", value="耳機", placeholder="例:耳機、筆電、滑鼠") max_items = st.slider("最多筆數", min_value=20, max_value=200, value=60, step=20) sort_option = st.selectbox( "排序方式", options=["sale/dc", "price/ac", "price/dc", "new/dc"], format_func=lambda x: { "sale/dc": "銷量高→低", "price/ac": "價格低→高", "price/dc": "價格高→低", "new/dc": "最新上架", }[x], ) sleep_sec = st.slider("每頁請求間隔(秒)", min_value=1, max_value=10, value=2) run_btn = st.button("🚀 開始爬取", use_container_width=True) # ──────────────────────────────────────────────── # 爬蟲函式 # ──────────────────────────────────────────────── ITEMS_PER_PAGE = 20 # PChome 每頁固定 20 筆 def fetch_pchome(keyword: str, max_items: int, sort: str, sleep_sec: int) -> pd.DataFrame: total_pages = -(-max_items // ITEMS_PER_PAGE) # 無條件進位除法 all_data = pd.DataFrame() progress = st.progress(0, text="爬取中…") for i in range(1, total_pages + 1): url = ( f"https://ecshweb.pchome.com.tw/search/v3.3/all/results" f"?q={keyword}&page={i}&sort={sort}" ) try: resp = requests.get(url, timeout=15) resp.raise_for_status() data = json.loads(resp.content) if "prods" not in data or not data["prods"]: break df_page = pd.DataFrame(data["prods"]) all_data = pd.concat([all_data, df_page], ignore_index=True) except Exception as e: st.warning(f"第 {i} 頁爬取失敗:{e}") break progress.progress(i / total_pages, text=f"已爬取第 {i}/{total_pages} 頁…") if i < total_pages: time.sleep(sleep_sec) progress.empty() # 截斷到使用者要求的筆數 return all_data.head(max_items) # ──────────────────────────────────────────────── # 主流程 # ──────────────────────────────────────────────── if run_btn: if not keyword.strip(): st.error("請輸入搜尋關鍵字!") st.stop() with st.spinner("正在爬取資料,請稍候…"): raw_df = fetch_pchome(keyword, max_items, sort_option, sleep_sec) if raw_df.empty: st.error("未取得任何資料,請確認關鍵字或稍後再試。") st.stop() # 只保留需要的欄位(有些商品可能缺欄) cols_needed = [c for c in ["name", "price", "brand", "storeId", "picS"] if c in raw_df.columns] df = raw_df[cols_needed].copy() df["price"] = pd.to_numeric(df["price"], errors="coerce") df = df.dropna(subset=["price"]) df = df.reset_index(drop=True) df.index += 1 # 從 1 開始 avg_price = df["price"].mean() max_price = df["price"].max() min_price = df["price"].min() # ── 統計指標 ────────────────────────────── st.subheader("📊 價格統計") c1, c2, c3, c4 = st.columns(4) c1.metric("筆數", f"{len(df)} 筆") c2.metric("平均價格", f"NT$ {avg_price:,.0f}") c3.metric("最高價格", f"NT$ {max_price:,.0f}") c4.metric("最低價格", f"NT$ {min_price:,.0f}") # ── 下載 CSV ───────────────────────────── import datetime today = datetime.date.today().strftime("%Y%m%d") csv_bytes = df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig") st.download_button( label="📥 下載 CSV", data=csv_bytes, file_name=f"{today}_PCHOME_{keyword}.csv", mime="text/csv", ) # ── 資料表 ─────────────────────────────── with st.expander("🔍 查看原始資料", expanded=False): st.dataframe(df, use_container_width=True) # ════════════════════════════════════════ # 圖表區 # ════════════════════════════════════════ st.subheader("📈 視覺化分析") tab1, tab2, tab3 = st.tabs(["折線圖", "圓餅圖", "旭日圖"]) # ── Tab1:折線圖 ───────────────────────── with tab1: fig_line = go.Figure() fig_line.add_trace( go.Scatter( x=df.index, y=df["price"], mode="lines+markers", name="售價", line=dict(color="#4C9BE8", width=2), marker=dict(size=5), hovertext=df["name"] if "name" in df.columns else None, hovertemplate="%{hovertext}
售價:NT$ %{y:,.0f}", ) ) fig_line.add_hline( y=avg_price, line_dash="dash", line_color="red", annotation_text=f"平均 NT$ {avg_price:,.0f}", annotation_position="top left", ) fig_line.update_layout( title=f"{today} PChome 電商「{keyword}」售價走勢", xaxis_title="商品編號", yaxis_title="價格(NT$)", hovermode="x unified", height=500, ) st.plotly_chart(fig_line, use_container_width=True) # ── Tab2:圓餅圖(依價格區間) ──────────── with tab2: bins = [0, 500, 1000, 3000, 5000, 10000, float("inf")] labels = ["≤500", "501~1000", "1001~3000", "3001~5000", "5001~10000", ">10000"] df["price_range"] = pd.cut(df["price"], bins=bins, labels=labels, right=True) pie_data = df["price_range"].value_counts().reset_index() pie_data.columns = ["price_range", "count"] pie_data = pie_data.sort_values("price_range") fig_pie = px.pie( pie_data, names="price_range", values="count", title=f"「{keyword}」各價格區間商品占比", hole=0.35, color_discrete_sequence=px.colors.qualitative.Set3, ) fig_pie.update_traces(textposition="inside", textinfo="percent+label") fig_pie.update_layout(height=500) st.plotly_chart(fig_pie, use_container_width=True) # ── Tab3:旭日圖(品牌 → 價格區間) ────── with tab3: if "brand" in df.columns and df["brand"].notna().any(): sun_df = df[["brand", "price_range"]].dropna() sun_df["brand"] = sun_df["brand"].fillna("未知品牌").replace("", "未知品牌") sun_df["count"] = 1 sun_df = sun_df.groupby(["brand", "price_range"], as_index=False)["count"].sum() fig_sun = px.sunburst( sun_df, path=["brand", "price_range"], values="count", title=f"「{keyword}」品牌 × 價格區間旭日圖", color="count", color_continuous_scale="Blues", ) fig_sun.update_layout(height=600) st.plotly_chart(fig_sun, use_container_width=True) else: # brand 欄不存在時,改用商品名稱前綴 × 價格區間 df["name_short"] = df["name"].str[:6] + "…" if "name" in df.columns else "商品" sun_df = df[["name_short", "price_range"]].dropna() sun_df["count"] = 1 sun_df = sun_df.groupby(["name_short", "price_range"], as_index=False)["count"].sum() fig_sun = px.sunburst( sun_df, path=["name_short", "price_range"], values="count", title=f"「{keyword}」商品 × 價格區間旭日圖", color="count", color_continuous_scale="Blues", ) fig_sun.update_layout(height=600) st.plotly_chart(fig_sun, use_container_width=True) else: st.info("👈 請在左側設定關鍵字與筆數,然後點擊「開始爬取」。")