# -*- 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("👈 請在左側設定關鍵字與筆數,然後點擊「開始爬取」。")