File size: 12,177 Bytes
495475c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
import requests
from bs4 import BeautifulSoup
import pandas as pd
from typing import List, Tuple
import time
from pandas.io.formats.style import Styler
import streamlit as st
import os
from datetime import datetime
import io

BASE_URL = "https://cgc.twse.com.tw/front/chPage"

def fetch_page(offset: int, max_per: int = 30, fmt: str = "") -> str:
    params = {"offset": offset, "max": max_per, "format": fmt}
    resp = requests.get(BASE_URL, params=params, timeout=10)
    resp.raise_for_status()
    return resp.text

def parse_companies(html: str) -> List[Tuple[str, str, str]]:
    soup = BeautifulSoup(html, "html.parser")
    results = []
    for tr in soup.select("table tr"):
        tds = tr.find_all("td")
        if len(tds) >= 3:
            code = tds[1].get_text(strip=True)
            name = tds[2].get_text(strip=True)
            link_tag = tds[2].find("a")
            url = link_tag["href"].strip() if link_tag and "href" in link_tag.attrs else ""
            if code.isdigit():
                results.append((code, name, url))
    return results

def collect_all(start_offset: int = 0, max_per: int = 30, max_pages: int = 100, progress_bar=None, status_text=None) -> pd.DataFrame:
    all_rows = []
    offset = start_offset

    for i in range(max_pages):
        try:
            # 更新進度條和狀態
            if progress_bar:
                progress_bar.progress((i + 1) / max_pages)
            if status_text:
                status_text.text(f"正在爬取第 {i + 1} 頁,偏移量: {offset}")

            html = fetch_page(offset, max_per)
            rows = parse_companies(html)
            if not rows:
                if status_text:
                    status_text.text(f"已完成爬取,共處理 {i + 1} 頁")
                break
            all_rows.extend(rows)
            offset += max_per
            time.sleep(0.5)
        except Exception as e:
            if status_text:
                status_text.text(f"錯誤發生於偏移量 {offset}: {e}")
            break

    # 加入編號欄位
    df = pd.DataFrame(all_rows, columns=["公司代碼", "公司名稱", "公司網址"])
    df.insert(0, "編號", range(1, len(df) + 1))
    return df

def style_dataframe(df: pd.DataFrame) -> Styler:
    """
    設定DataFrame的樣式:
    - 編號、公司代碼、公司名稱欄位標題為藍色背景
    - 每個欄位的值交替黃色背景
    """
    def header_style(s):
        """設定標題樣式"""
        styles = []
        for col in s.index:
            if col in ["編號", "公司代碼", "公司名稱"]:
                styles.append('background-color: #4472C4; color: white; font-weight: bold')
            else:
                styles.append('background-color: #D9D9D9; color: black; font-weight: bold')
        return styles

    def alternating_rows(s):
        """設定交替行顏色"""
        styles = []
        for i, col in enumerate(s.index):
            if col in ["編號", "公司代碼", "公司名稱"]:
                if s.name % 2 == 0:  # 偶數行
                    styles.append('background-color: #FFF2CC')  # 淺黃色
                else:  # 奇數行
                    styles.append('background-color: #FFFFFF')  # 白色
            else:
                styles.append('background-color: #F8F8F8')  # 淺灰色
        return styles

    # 應用樣式
    styled = df.style.apply(alternating_rows, axis=1).apply(header_style, axis=0)

    # 設定表格整體樣式
    styled = styled.set_table_styles([
        {'selector': 'th', 'props': [('text-align', 'center'), ('padding', '8px')]},
        {'selector': 'td', 'props': [('text-align', 'center'), ('padding', '6px')]},
        {'selector': 'table', 'props': [('border-collapse', 'collapse'), ('margin', 'auto')]},
        {'selector': 'th, td', 'props': [('border', '1px solid #CCCCCC')]}
    ])

    return styled

def save_to_excel(df: pd.DataFrame) -> bytes:
    """儲存為Excel格式並應用樣式,返回bytes"""
    output = io.BytesIO()

    # 建立樣式化的DataFrame
    with pd.ExcelWriter(output, engine='openpyxl') as writer:
        # 先寫入基本資料
        df.to_excel(writer, sheet_name='公司資料', index=False)

        # 取得工作表以進行格式設定
        worksheet = writer.sheets['公司資料']

        # 設定欄寬
        worksheet.column_dimensions['A'].width = 8   # 編號
        worksheet.column_dimensions['B'].width = 12  # 公司代碼
        worksheet.column_dimensions['C'].width = 25  # 公司名稱
        worksheet.column_dimensions['D'].width = 40  # 公司網址

        # 使用openpyxl進行進階格式設定
        from openpyxl.styles import PatternFill, Font, Alignment, Border, Side

        # 定義顏色
        blue_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
        yellow_fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
        white_fill = PatternFill(start_color="FFFFFF", end_color="FFFFFF", fill_type="solid")
        gray_fill = PatternFill(start_color="D9D9D9", end_color="D9D9D9", fill_type="solid")

        # 定義字體
        header_font = Font(bold=True, color="FFFFFF")
        normal_font = Font(color="000000")

        # 定義對齊
        center_alignment = Alignment(horizontal="center", vertical="center")

        # 定義邊框
        thin_border = Border(
            left=Side(style='thin'),
            right=Side(style='thin'),
            top=Side(style='thin'),
            bottom=Side(style='thin')
        )

        # 設定標題行格式
        for col_num, col_name in enumerate(['編號', '公司代碼', '公司名稱', '公司網址'], 1):
            cell = worksheet.cell(row=1, column=col_num)
            cell.font = header_font
            cell.alignment = center_alignment
            cell.border = thin_border

            if col_name in ['編號', '公司代碼', '公司名稱']:
                cell.fill = blue_fill
            else:
                cell.fill = gray_fill

        # 設定資料行格式
        for row_num in range(2, len(df) + 2):
            for col_num in range(1, 5):
                cell = worksheet.cell(row=row_num, column=col_num)
                cell.font = normal_font
                cell.alignment = center_alignment
                cell.border = thin_border

                # 針對編號、公司代碼、公司名稱欄位設定交替顏色
                if col_num <= 3:  # 編號、公司代碼、公司名稱
                    if (row_num - 2) % 2 == 0:  # 偶數行
                        cell.fill = yellow_fill
                    else:  # 奇數行
                        cell.fill = white_fill

    output.seek(0)
    return output.getvalue()

def save_to_csv(df: pd.DataFrame) -> str:
    """儲存為CSV格式,返回CSV字串"""
    return df.to_csv(index=False, encoding="utf-8-sig")

def main():
    st.set_page_config(
        page_title="台灣證交所公司資料爬取工具",
        page_icon="🏢",
        layout="wide",
        initial_sidebar_state="expanded"
    )

    st.title("🏢 台灣證交所公司資料爬取工具")
    st.markdown("這個工具可以幫您從台灣證交所網站爬取上市公司資料,並提供CSV或Excel格式下載。")

    # 側邊欄參數設定
    with st.sidebar:
        st.header("⚙️ 參數設定")

        start_offset = st.number_input(
            "起始偏移量",
            min_value=0,
            value=0,
            step=1,
            help="從第幾筆資料開始爬取"
        )

        max_per = st.slider(
            "每頁筆數",
            min_value=1,
            max_value=100,
            value=30,
            step=1,
            help="每次請求爬取的資料筆數"
        )

        max_pages = st.slider(
            "最大頁數",
            min_value=1,
            max_value=100,
            value=50,
            step=1,
            help="最多爬取幾頁資料"
        )

        output_format = st.radio(
            "輸出格式",
            options=["CSV", "Excel", "兩者都要"],
            index=1,
            help="選擇要下載的檔案格式"
        )

        st.markdown("---")

        # 使用說明
        with st.expander("📖 使用說明"):
            st.markdown("""
            ### 參數說明:
            - **起始偏移量**:從第幾筆資料開始爬取,通常設為0
            - **每頁筆數**:每次API請求的資料筆數,建議30-50
            - **最大頁數**:最多爬取幾頁,避免設定太大導致執行時間過長
            - **輸出格式**:
              - CSV:純文字格式,適合後續程式處理
              - Excel:包含樣式格式的Excel檔案
              - 兩者都要:同時產生CSV和Excel檔案

            ### 注意事項:
            - 爬取過程中請勿關閉瀏覽器
            - 建議先用較小的參數測試
            - 檔案會自動加上時間戳記避免重複
            """)

    # 主要內容區域
    col1, col2 = st.columns([2, 1])

    with col2:
        start_scraping = st.button("🚀 開始爬取", type="primary", use_container_width=True)

    # 執行爬取
    if start_scraping:
        # 驗證輸入參數
        if start_offset < 0:
            st.error("起始偏移量不能小於0")
            return
        if max_per <= 0 or max_per > 100:
            st.error("每頁筆數必須在1-100之間")
            return
        if max_pages <= 0 or max_pages > 1000:
            st.error("最大頁數必須在1-1000之間")
            return

        try:
            # 建立進度條和狀態顯示
            progress_bar = st.progress(0)
            status_text = st.empty()

            # 開始爬取資料
            status_text.text("開始爬取資料...")
            df = collect_all(start_offset, max_per, max_pages, progress_bar, status_text)

            if df.empty:
                st.warning("未爬取到任何資料")
                return

            # 產生時間戳記
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

            # 完成狀態
            progress_bar.progress(1.0)
            status_text.text(f"✅ 成功爬取 {len(df)} 筆公司資料!")

            # 顯示資料預覽
            st.subheader("📊 資料預覽(前10筆)")
            st.dataframe(df.head(10), use_container_width=True)

            # 檔案下載區域
            st.subheader("📁 檔案下載")

            download_col1, download_col2 = st.columns(2)

            if output_format in ["CSV", "兩者都要"]:
                csv_data = save_to_csv(df)
                with download_col1:
                    st.download_button(
                        label="⬇️ 下載 CSV 檔案",
                        data=csv_data,
                        file_name=f"companies_{timestamp}.csv",
                        mime="text/csv",
                        use_container_width=True
                    )

            if output_format in ["Excel", "兩者都要"]:
                excel_data = save_to_excel(df)
                with download_col2:
                    st.download_button(
                        label="⬇️ 下載 Excel 檔案",
                        data=excel_data,
                        file_name=f"companies_styled_{timestamp}.xlsx",
                        mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                        use_container_width=True
                    )

            # 顯示統計資訊
            st.subheader("📈 統計資訊")
            stat_col1, stat_col2, stat_col3 = st.columns(3)

            with stat_col1:
                st.metric("總公司數量", len(df))

            with stat_col2:
                st.metric("有網址的公司", len(df[df['公司網址'] != '']))

            with stat_col3:
                st.metric("執行頁數", min(max_pages, (len(df) // max_per) + 1))

        except Exception as e:
            st.error(f"❌ 爬取過程中發生錯誤:{str(e)}")

if __name__ == "__main__":
    main()