Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +303 -152
src/streamlit_app.py
CHANGED
@@ -1,161 +1,312 @@
|
|
1 |
import streamlit as st
|
2 |
import pandas as pd
|
3 |
-
import
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
#
|
26 |
-
|
27 |
-
def
|
28 |
-
"""
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
37 |
else:
|
38 |
-
|
|
|
39 |
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
st.
|
51 |
-
st.
|
52 |
-
st.markdown("不僅計算 S 因子,更透過地圖、情境模擬,讓您『看見』並『感受』場址效應的威力!")
|
53 |
-
|
54 |
-
|
55 |
-
# 2. 側邊欄
|
56 |
-
st.sidebar.header("Step 1: 載入資料")
|
57 |
-
data_source = st.sidebar.radio("請選擇資料來源:", ("使用內建範例資料", "上傳自己的 CSV 檔案"))
|
58 |
-
|
59 |
-
input_df = None
|
60 |
-
if data_source == "使用內建範例資料":
|
61 |
-
st.sidebar.info("範例資料已加入經緯度(lat, lon),以用於地圖視覺化。")
|
62 |
-
data = {
|
63 |
-
'station_id': ['TPE', 'KHH', 'HUA', 'TPE', 'KHH', 'HUA', 'TPE', 'KHH', 'HUA', 'TPE', 'KHH', 'HUA'],
|
64 |
-
'lat': [25.0330, 22.6273, 23.9739, 25.0330, 22.6273, 23.9739, 25.0330, 22.6273, 23.9739, 25.0330, 22.6273, 23.9739],
|
65 |
-
'lon': [121.5654, 120.3014, 121.6059, 121.5654, 120.3014, 121.6059, 121.5654, 120.3014, 121.6059, 121.5654, 120.3014, 121.6059],
|
66 |
-
'earthquake_id': ['EQ1', 'EQ1', 'EQ1', 'EQ2', 'EQ2', 'EQ2', 'EQ3', 'EQ3', 'EQ3', 'EQ4', 'EQ4', 'EQ4'],
|
67 |
-
'magnitude': [6.2, 6.2, 6.2, 5.5, 5.5, 5.5, 7.0, 7.0, 7.0, 6.5, 6.5, 6.5],
|
68 |
-
'distance_km': [50, 200, 30, 80, 150, 60, 120, 250, 40, 40, 180, 25],
|
69 |
-
# KHH(高雄)的地質條件較軟,預期會有較大的放大效應
|
70 |
-
'observed_pga': [80, 40, 150, 30, 25, 60, 60, 35, 180, 150, 55, 280]
|
71 |
-
}
|
72 |
-
input_df = pd.DataFrame(data)
|
73 |
-
else:
|
74 |
-
uploaded_file = st.sidebar.file_uploader("上傳 CSV (需含 lat, lon 欄位)", type=["csv"])
|
75 |
-
if uploaded_file:
|
76 |
-
input_df = pd.read_csv(uploaded_file)
|
77 |
-
|
78 |
-
# 3. 主畫面
|
79 |
-
if input_df is not None:
|
80 |
-
st.sidebar.header("Step 2: 執行計算")
|
81 |
-
if st.sidebar.button("🚀 點我開始分析!"):
|
82 |
-
with st.spinner('科學計算中,請稍候...'):
|
83 |
-
required_cols = ['station_id', 'lat', 'lon', 'magnitude', 'distance_km', 'observed_pga']
|
84 |
-
if not all(col in input_df.columns for col in required_cols):
|
85 |
-
st.error(f"資料格式錯誤!請確保您的 CSV 包含以下欄位: {required_cols}")
|
86 |
else:
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
|
|
|
|
93 |
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
final_df = st.session_state.final_results
|
99 |
-
|
100 |
-
with tab1:
|
101 |
-
st.header("📊 各測站平均場址放大因子 (S)")
|
102 |
-
col1, col2 = st.columns([0.5, 0.5])
|
103 |
-
with col1:
|
104 |
-
st.dataframe(final_df[['station_id', 'site_amplification_factor_S']].style.format({'site_amplification_factor_S': "{:.2f}"}))
|
105 |
-
st.bar_chart(final_df.set_index('station_id')['site_amplification_factor_S'])
|
106 |
-
|
107 |
-
with col2:
|
108 |
-
st.subheader("💬 結果白話文解說")
|
109 |
-
for index, row in final_df.iterrows():
|
110 |
-
st.markdown(f"**{row['station_id']} 測站:**")
|
111 |
-
st.markdown(get_s_factor_interpretation(row['site_amplification_factor_S']))
|
112 |
-
st.markdown("---")
|
113 |
-
|
114 |
-
with tab2:
|
115 |
-
st.header("🗺️ 場址放大效應地理分佈")
|
116 |
-
st.markdown("地圖上的點越大,代表該地的場址放大效應越強烈。")
|
117 |
-
|
118 |
-
# 為了讓地圖上的點大小差異更明顯,進行正規化
|
119 |
-
min_s = final_df['site_amplification_factor_S'].min()
|
120 |
-
max_s = final_df['site_amplification_factor_S'].max()
|
121 |
-
# 避免 max_s == min_s 的情況
|
122 |
-
if (max_s - min_s) > 0:
|
123 |
-
final_df['size'] = 50 + ((final_df['site_amplification_factor_S'] - min_s) / (max_s - min_s)) * 500
|
124 |
-
else:
|
125 |
-
final_df['size'] = 100
|
126 |
|
127 |
-
|
128 |
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
sim_col1, sim_col2 = st.columns(2)
|
134 |
-
with sim_col1:
|
135 |
-
sim_mag = st.slider("設定假想地震規模 (M)", min_value=4.0, max_value=8.0, value=6.5, step=0.1)
|
136 |
-
with sim_col2:
|
137 |
-
sim_dist = st.number_input("設定您與震央的距離 (公里)", min_value=10, max_value=300, value=50)
|
138 |
-
|
139 |
-
st.markdown(f"#### 模擬結果:規模 **{sim_mag}**、距離 **{sim_dist}** 公里的地震")
|
140 |
-
|
141 |
-
sim_results = []
|
142 |
-
for index, row in final_df.iterrows():
|
143 |
-
predicted_pga = predict_pga_with_s(sim_mag, sim_dist, row['site_amplification_factor_S'])
|
144 |
-
sim_results.append({
|
145 |
-
"測站": row['station_id'],
|
146 |
-
"場址放大因子 (S)": f"{row['site_amplification_factor_S']:.2f}",
|
147 |
-
"預估搖晃程度 (PGA)": f"{predicted_pga:.2f}"
|
148 |
-
})
|
149 |
-
|
150 |
-
st.table(pd.DataFrame(sim_results))
|
151 |
-
st.info("💡 注意:PGA 越高,代表地表加速度越大,感受到的搖晃越劇烈。")
|
152 |
-
|
153 |
-
with tab4:
|
154 |
-
st.header("📄 詳細資料與計算過程")
|
155 |
-
with st.expander("點此查看原始觀測資料"):
|
156 |
-
st.dataframe(input_df)
|
157 |
-
with st.expander("點此查看詳細計算過程 (含單次放大效應)"):
|
158 |
-
st.dataframe(st.session_state.intermediate_results)
|
159 |
-
else:
|
160 |
-
st.info("請在左方側邊欄選擇資料來源,然後點擊按鈕開始分析。")
|
161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import streamlit as st
|
2 |
import pandas as pd
|
3 |
+
import requests
|
4 |
+
from bs4 import BeautifulSoup
|
5 |
+
from transformers import pipeline
|
6 |
+
import plotly.express as px
|
7 |
+
from wordcloud import WordCloud
|
8 |
+
import matplotlib.pyplot as plt
|
9 |
+
from collections import Counter
|
10 |
+
import re
|
11 |
+
|
12 |
+
# --- 網頁設定與標題 ---
|
13 |
+
st.set_page_config(
|
14 |
+
page_title="地震輿情分析系統",
|
15 |
+
page_icon="📊",
|
16 |
+
layout="wide"
|
17 |
+
)
|
18 |
+
|
19 |
+
st.title("📊 地震輿情分析與洞察報告系統")
|
20 |
+
st.markdown("依據[規格文件](https.example.com)開發,旨在提供即時的地震相關公眾輿情分析。") # 您可以將連結替換為規格文件的實際網址
|
21 |
+
|
22 |
+
# --- 核心功能函式 ---
|
23 |
+
|
24 |
+
# FR1: 關鍵字搜尋與資料獲取 (使用 requests + BeautifulSoup 模擬)
|
25 |
+
# 注意:直接、大量的 Google 搜尋可能會被阻擋。在正式專案中,應使用 Google Search API。
|
26 |
+
# 這裡我們用 DuckDuckGo 作為一個較不易被阻擋的替代方案來示範。
|
27 |
+
def search_and_scrape(keyword, num_results):
|
28 |
+
"""
|
29 |
+
根據關鍵字搜尋並抓取網頁內容。
|
30 |
+
"""
|
31 |
+
st.info(f"🔍 正在使用 DuckDuckGo 搜尋關鍵字:'{keyword}'...")
|
32 |
+
search_url = "https://html.duckduckgo.com/html/"
|
33 |
+
params = {"q": keyword}
|
34 |
+
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"}
|
35 |
+
|
36 |
+
try:
|
37 |
+
response = requests.get(search_url, params=params, headers=headers, timeout=10)
|
38 |
+
response.raise_for_status()
|
39 |
+
soup = BeautifulSoup(response.text, 'html.parser')
|
40 |
+
links = [a['href'] for a in soup.find_all('a', class_='result__a')]
|
41 |
+
except requests.RequestException as e:
|
42 |
+
st.error(f"搜尋失敗:{e}")
|
43 |
+
return []
|
44 |
+
|
45 |
+
scraped_data = []
|
46 |
+
if not links:
|
47 |
+
st.warning("找不到相關的搜尋結果。")
|
48 |
+
return []
|
49 |
+
|
50 |
+
st.info(f"📈 找到 {len(links)} 個結果,將分析前 {num_results} 個...")
|
51 |
+
|
52 |
+
progress_bar = st.progress(0)
|
53 |
+
for i, url in enumerate(links[:num_results]):
|
54 |
+
try:
|
55 |
+
# 修正相對路徑的 URL
|
56 |
+
if url.startswith("//"):
|
57 |
+
url = "https:" + url
|
58 |
+
|
59 |
+
page_response = requests.get(url, headers=headers, timeout=10)
|
60 |
+
page_response.raise_for_status()
|
61 |
+
page_soup = BeautifulSoup(page_response.content, 'html.parser', from_encoding='utf-8')
|
62 |
+
|
63 |
+
# 提取純文字,移除 script 和 style 標籤
|
64 |
+
for script_or_style in page_soup(['script', 'style']):
|
65 |
+
script_or_style.decompose()
|
66 |
+
|
67 |
+
text = ' '.join(p.get_text() for p in page_soup.find_all('p'))
|
68 |
+
text = re.sub(r'\s+', ' ', text).strip() # 清理多餘空白
|
69 |
+
|
70 |
+
if len(text) > 100: # 只分析有足夠內容的頁面
|
71 |
+
scraped_data.append({"url": url, "content": text})
|
72 |
+
except Exception as e:
|
73 |
+
st.warning(f"無法抓取或解析 URL: {url} ({e})")
|
74 |
+
|
75 |
+
progress_bar.progress((i + 1) / num_results)
|
76 |
+
|
77 |
+
st.success(f"✅ 成功抓取並解析 {len(scraped_data)} 篇文章。")
|
78 |
+
return scraped_data
|
79 |
+
|
80 |
+
# FR2 & FR3: Hugging Face 模型分析 (情緒分析)
|
81 |
+
@st.cache_resource
|
82 |
+
def get_sentiment_pipeline(token):
|
83 |
+
"""
|
84 |
+
載入情緒分析模型。使用快取避免重複載入。
|
85 |
+
"""
|
86 |
+
try:
|
87 |
+
# 使用一個支援多語言(含中文)的模型
|
88 |
+
sentiment_pipeline = pipeline(
|
89 |
+
"sentiment-analysis",
|
90 |
+
model="lxyuan/distilbert-base-multilingual-cased-sent-analysis-finance",
|
91 |
+
use_auth_token=token
|
92 |
+
)
|
93 |
+
return sentiment_pipeline
|
94 |
+
except Exception as e:
|
95 |
+
st.error(f"無法載入情緒分析模型,請確認您的 Token 是否正確且有權限存取此模型。錯誤:{e}")
|
96 |
+
return None
|
97 |
+
|
98 |
+
# FR4: 熱議焦點分析 (命名實體辨識)
|
99 |
+
@st.cache_resource
|
100 |
+
def get_ner_pipeline(token):
|
101 |
+
"""
|
102 |
+
載入命名實體辨識模型。
|
103 |
+
"""
|
104 |
+
try:
|
105 |
+
# 使用 ckiplab 的模型,對繁體中文效果很好
|
106 |
+
ner_pipeline = pipeline(
|
107 |
+
"ner",
|
108 |
+
model="ckiplab/bert-base-chinese-ner",
|
109 |
+
use_auth_token=token
|
110 |
+
)
|
111 |
+
return ner_pipeline
|
112 |
+
except Exception as e:
|
113 |
+
st.error(f"無法載入命名實體辨識模型。錯誤:{e}")
|
114 |
+
return None
|
115 |
+
|
116 |
+
def analyze_content(data, sentiment_pipeline, ner_pipeline):
|
117 |
+
"""
|
118 |
+
對抓取到的內容進行全面的 NLP 分析。
|
119 |
+
"""
|
120 |
+
sentiments = []
|
121 |
+
all_entities = []
|
122 |
+
|
123 |
+
st.info("🧠 正在進行 NLP 分析...")
|
124 |
+
progress_bar = st.progress(0)
|
125 |
+
|
126 |
+
for i, item in enumerate(data):
|
127 |
+
# 情緒分析
|
128 |
+
try:
|
129 |
+
# Hugging Face 模型通常有最大長度限制,這裡截斷處理
|
130 |
+
sentiment_result = sentiment_pipeline(item['content'][:512])
|
131 |
+
sentiments.append(sentiment_result[0]['label'])
|
132 |
+
except Exception:
|
133 |
+
sentiments.append('neutral') # 分析失敗則視為中性
|
134 |
+
|
135 |
+
# 命名實體辨識
|
136 |
+
try:
|
137 |
+
ner_results = ner_pipeline(item['content'])
|
138 |
+
# 只提取我們關心的實體,且長度大於1
|
139 |
+
entities = [entity['word'] for entity in ner_results if entity['entity'] in ['GPE', 'ORG', 'PERSON', 'LOC'] and len(entity['word']) > 1]
|
140 |
+
all_entities.extend(entities)
|
141 |
+
except Exception:
|
142 |
+
pass # 分析失敗則忽略
|
143 |
+
|
144 |
+
progress_bar.progress((i + 1) / len(data))
|
145 |
+
|
146 |
+
st.success("🤖 NLP 分析完成!")
|
147 |
+
return sentiments, all_entities
|
148 |
+
|
149 |
+
# FR5: 結果視覺化
|
150 |
+
def create_sentiment_chart(sentiments):
|
151 |
+
"""
|
152 |
+
建立情緒分佈圖。
|
153 |
+
"""
|
154 |
+
df = pd.DataFrame(sentiments, columns=['sentiment'])
|
155 |
+
sentiment_counts = df['sentiment'].value_counts().reset_index()
|
156 |
+
sentiment_counts.columns = ['sentiment', 'count']
|
157 |
+
|
158 |
+
fig = px.pie(sentiment_counts, names='sentiment', values='count',
|
159 |
+
title='整體情緒分佈',
|
160 |
+
color_discrete_map={'positive':'green', 'negative':'red', 'neutral':'blue'})
|
161 |
+
return fig
|
162 |
+
|
163 |
+
def create_word_cloud(entities):
|
164 |
+
"""
|
165 |
+
建立熱議焦點文字雲。
|
166 |
+
"""
|
167 |
+
# 確保字體檔案路徑正確
|
168 |
+
font_path = 'NotoSansTC-Regular.otf'
|
169 |
+
text = ' '.join(entities)
|
170 |
+
|
171 |
+
if not text:
|
172 |
+
return None
|
173 |
+
|
174 |
+
try:
|
175 |
+
wordcloud = WordCloud(
|
176 |
+
width=800,
|
177 |
+
height=400,
|
178 |
+
background_color='white',
|
179 |
+
font_path=font_path
|
180 |
+
).generate(text)
|
181 |
+
|
182 |
+
fig, ax = plt.subplots(figsize=(10, 5))
|
183 |
+
ax.imshow(wordcloud, interpolation='bilinear')
|
184 |
+
ax.axis('off')
|
185 |
+
return fig
|
186 |
+
except Exception as e:
|
187 |
+
st.error(f"無法生成文字雲,請確認字體檔案 'NotoSansTC-Regular.otf' 已上傳。錯誤:{e}")
|
188 |
+
return None
|
189 |
+
|
190 |
+
# FR6: 報告自動產出
|
191 |
+
def generate_report(sentiment_counts, top_entities, keyword):
|
192 |
+
"""
|
193 |
+
生成最終的分析報告。
|
194 |
+
"""
|
195 |
+
total = sentiment_counts['count'].sum()
|
196 |
+
positive_pct = (sentiment_counts[sentiment_counts['sentiment'] == 'positive']['count'].sum() / total) * 100
|
197 |
+
negative_pct = (sentiment_counts[sentiment_counts['sentiment'] == 'negative']['count'].sum() / total) * 100
|
198 |
+
neutral_pct = 100 - positive_pct - negative_pct
|
199 |
+
|
200 |
+
# 罐頭建議
|
201 |
+
recommendation = "初步建議:輿情整體平穩,可持續觀察。"
|
202 |
+
if negative_pct > 40:
|
203 |
+
recommendation = f"初步建議:負面聲量較高,建議針對 '{', '.join(dict(top_entities).keys())}' 等熱議議題發布說明,以緩解公眾疑慮。"
|
204 |
+
elif positive_pct > 40:
|
205 |
+
recommendation = "初步建議:正面聲量佔優,可加強相關正面訊息的傳播。"
|
206 |
+
|
207 |
+
report = f"""
|
208 |
+
# 地震輿情分析報告
|
209 |
+
|
210 |
+
## 1. 總覽
|
211 |
+
- **分析關鍵字**: {keyword}
|
212 |
+
- **分析摘要**: 針對網路輿情進行分析,目前情緒分佈以 **{sentiment_counts['sentiment'].iloc[0]}** 為主。
|
213 |
+
|
214 |
+
## 2. 情緒儀表板
|
215 |
+
- **正面情緒**: {positive_pct:.2f}%
|
216 |
+
- **負面情緒**: {negative_pct:.2f}%
|
217 |
+
- **中性情緒**: {neutral_pct:.2f}%
|
218 |
+
|
219 |
+
## 3. 熱議焦點 (Top 5)
|
220 |
+
"""
|
221 |
+
for entity, count in top_entities:
|
222 |
+
report += f"- {entity} (提及 {count} 次)\n"
|
223 |
+
|
224 |
+
report += f"""
|
225 |
+
## 4. 初步建議
|
226 |
+
{recommendation}
|
227 |
+
"""
|
228 |
+
return report
|
229 |
+
|
230 |
+
# --- UI 介面 ---
|
231 |
+
# FR7: 使用者介面 (側邊欄)
|
232 |
+
st.sidebar.header("⚙️ 控制面板")
|
233 |
+
|
234 |
+
keyword = st.sidebar.text_input("1. 輸入搜尋關鍵字", "花蓮地震")
|
235 |
+
hf_token = st.sidebar.text_input("2. 輸入您的 Hugging Face Token", type="password")
|
236 |
+
num_results = st.sidebar.slider("3. 設定分析文章數量", min_value=5, max_value=20, value=10, step=1)
|
237 |
+
|
238 |
+
analyze_button = st.sidebar.button("🚀 開始分析", type="primary")
|
239 |
+
|
240 |
+
|
241 |
+
# --- 主流程 ---
|
242 |
+
if analyze_button:
|
243 |
+
if not keyword:
|
244 |
+
st.sidebar.error("請輸入關鍵字!")
|
245 |
+
elif not hf_token:
|
246 |
+
st.sidebar.error("請輸入 Hugging Face Token!")
|
247 |
else:
|
248 |
+
# 執行分析
|
249 |
+
scraped_data = search_and_scrape(keyword, num_results)
|
250 |
|
251 |
+
if scraped_data:
|
252 |
+
sentiment_pipeline = get_sentiment_pipeline(hf_token)
|
253 |
+
ner_pipeline = get_ner_pipeline(hf_token)
|
254 |
+
|
255 |
+
if sentiment_pipeline and ner_pipeline:
|
256 |
+
sentiments, all_entities = analyze_content(scraped_data, sentiment_pipeline, ner_pipeline)
|
257 |
+
|
258 |
+
# 儲存結果以供後續使用
|
259 |
+
st.session_state['analysis_complete'] = True
|
260 |
+
st.session_state['sentiments'] = sentiments
|
261 |
+
st.session_state['all_entities'] = all_entities
|
262 |
+
st.session_state['keyword'] = keyword
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
263 |
else:
|
264 |
+
st.session_state['analysis_complete'] = False
|
265 |
+
else:
|
266 |
+
st.error("未能獲取任何可分析的資料。")
|
267 |
+
st.session_state['analysis_complete'] = False
|
268 |
+
|
269 |
+
# 在分析完成後顯示結果
|
270 |
+
if 'analysis_complete' in st.session_state and st.session_state['analysis_complete']:
|
271 |
+
st.header("📈 輿情儀表板")
|
272 |
|
273 |
+
sentiments = st.session_state['sentiments']
|
274 |
+
all_entities = st.session_state['all_entities']
|
275 |
+
keyword = st.session_state['keyword']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
276 |
|
277 |
+
col1, col2 = st.columns(2)
|
278 |
|
279 |
+
with col1:
|
280 |
+
# 情緒分析圖
|
281 |
+
sentiment_fig = create_sentiment_chart(sentiments)
|
282 |
+
st.plotly_chart(sentiment_fig, use_container_width=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
283 |
|
284 |
+
with col2:
|
285 |
+
# 熱議焦點文字雲
|
286 |
+
st.subheader("熱議焦點")
|
287 |
+
wordcloud_fig = create_word_cloud(all_entities)
|
288 |
+
if wordcloud_fig:
|
289 |
+
st.pyplot(wordcloud_fig)
|
290 |
+
else:
|
291 |
+
st.info("沒有足夠的關鍵詞來生成文字雲。")
|
292 |
+
|
293 |
+
st.header("📄 分析報告產出")
|
294 |
+
|
295 |
+
# 計算報告所需數據
|
296 |
+
sentiment_df = pd.DataFrame(sentiments, columns=['sentiment'])
|
297 |
+
sentiment_counts = sentiment_df['sentiment'].value_counts().reset_index()
|
298 |
+
sentiment_counts.columns = ['sentiment', 'count']
|
299 |
+
|
300 |
+
entity_counts = Counter(all_entities).most_common(5)
|
301 |
+
|
302 |
+
# 生成並顯示報告
|
303 |
+
report_text = generate_report(sentiment_counts, entity_counts, keyword)
|
304 |
+
st.markdown(report_text)
|
305 |
+
|
306 |
+
# 下載按鈕
|
307 |
+
st.download_button(
|
308 |
+
label="📥 下載完整報告 (.md)",
|
309 |
+
data=report_text,
|
310 |
+
file_name=f"輿情分析報告_{keyword}.md",
|
311 |
+
mime="text/markdown",
|
312 |
+
)
|