CoolFace
Apppublic

cormort/procurement-crawler-py

sourceHugging Faceapache-2.0updated 3d agoView on Hugging Face
0likes
app.py534 linesDownload Raw Back to root
1"""🕷️ 政府採購網 - 雲端多功能工作站(Streamlit 前端)2 3這一版的三個重點(實測依據見 README「為什麼這樣改」):4  1. **一次請求取代 N 次**:搜尋端點支援 `columns[]`,實測一次回傳整頁(99 筆)+指定欄位,5     舊版是「勾幾筆就打幾次 `/api/tender`」。6  2. **尊重 API 速率限制**:後端 10 秒 >10 次或 60 秒 >60 次就 429;本版自我限速在 8/10s、50/60s,7     並支援 Bearer Token(官方文件說帶 token 可解除流量限制)。8  3. **快取**:Streamlit 每次互動都會重跑整份腳本,舊版因此在每次點擊都重打一次搜尋 API;9     現在搜尋/查詢結果有 TTL 快取,同一組條件不會重複打。10 11UI 只負責畫面與流程;網路、限速、欄位攤平等邏輯都在 pcc_core.py(可單獨測試)。12"""13 14import concurrent.futures15import io16import json17import os18import time19from datetime import date, datetime, timedelta20 21import pandas as pd22import requests23import streamlit as st24from bs4 import BeautifulSoup25 26from pcc_core import (27    LIST_COLUMNS,28    PccApi,29    RATE_MAX_LONG,30    RATE_MAX_SHORT,31    RATE_WINDOW_LONG,32    RATE_WINDOW_SHORT,33    browser_headers,34    build_query_url,35    flatten_detail,36    human_like_delay,37    listing_rows,38    new_browser_session,39    official_request,40    parse_html_core,41)42 43st.set_page_config(page_title="115年政府採購網工作站", page_icon="🕷️", layout="wide")44 45HISTORY_FILE = "crawled_history.txt"46CACHE_TTL = 900          # 搜尋結果快取 15 分鐘47 48 49# --- 歷史案號(去重資料庫)---50# ⚠️ Hugging Face Space 的容器檔案系統是**暫時性**的:重新建置/重啟就清空。51# 所以除了存檔,另外提供下載/上傳,讓使用者能自己保存這份清單。52def load_history() -> set:53    if os.path.exists(HISTORY_FILE):54        try:55            with open(HISTORY_FILE, "r", encoding="utf-8") as handle:56                return {line.strip() for line in handle if line.strip()}57        except OSError as exc:58            st.error(f"讀取歷史檔案失敗: {exc}")59    return set()60 61 62def save_history(case_ids) -> None:63    try:64        with open(HISTORY_FILE, "w", encoding="utf-8") as handle:65            for case_id in sorted(case_ids):66                handle.write(f"{case_id}\n")67    except OSError as exc:68        st.warning(f"存檔失敗(容器檔案系統可能不可寫): {exc}")69 70 71def history_csv(case_ids) -> bytes:72    return ("案號\n" + "\n".join(sorted(case_ids))).encode("utf-8-sig")73 74 75for key, default in (76    ("crawled_ids", None), ("api_cache", {}), ("detail_cache", {}),77    ("search_list", []), ("api_page", 1), ("api_total_pages", 1), ("temp_results", []),78):79    if key not in st.session_state:80        st.session_state[key] = load_history() if key == "crawled_ids" else default81 82 83# --- API 用戶端(同一顆 token 共用一個連線池;Streamlit 每次互動都會重跑腳本)---84@st.cache_resource(show_spinner=False)85def get_api(token: str | None, enabled_limit: bool) -> PccApi:86    api = PccApi(token=token)87    api.limiter.enabled = enabled_limit and not token88    return api89 90 91@st.cache_data(ttl=CACHE_TTL, show_spinner=False)92def cached_search(query: str, page: int, token: str | None) -> dict:93    """搜尋(含 columns[])並快取;同一組條件在 TTL 內不再打 API。"""94    api = get_api(token, True)95    return api.search_by_title(query, page, LIST_COLUMNS)96 97 98@st.cache_data(ttl=CACHE_TTL, show_spinner=False)99def cached_list_by_date(date_str: str, token: str | None) -> dict:100    api = get_api(token, True)101    return api.list_by_date(date_str, LIST_COLUMNS)102 103 104@st.cache_data(ttl=CACHE_TTL, show_spinner=False)105def cached_tender(unit_id: str, job_number: str, token: str | None) -> dict:106    api = get_api(token, True)107    return api.tender(unit_id, job_number)108 109 110def collect_records(payload: dict) -> list:111    """API 回傳 → records 清單(順手擋掉非預期格式)。"""112    if isinstance(payload, dict) and isinstance(payload.get("records"), list):113        return payload["records"]114    return []115 116 117def flatten_record(record: dict) -> dict:118    """把一筆 /api/tender 的紀錄攤平成 Excel 用的一列(保留全部 86 個欄位)。"""119    detail = record.get("detail") or {}120    row = flatten_detail(detail)121    brief = record.get("brief") or {}122    for key, value in (123        ("案號", record.get("job_number")), ("機關代碼", record.get("unit_id")),124        ("機關名稱(列表)", record.get("unit_name")), ("公告日期(列表)", record.get("date")),125        ("公告類型(列表)", brief.get("type")), ("標案名稱(列表)", brief.get("title")),126    ):127        row.setdefault(key, value if value is not None else "N/A")128    return row129 130 131def excel_bytes(frame: pd.DataFrame, sheet_name: str = "tenders", widths: bool = True) -> bytes:132    """DataFrame → xlsx 位元組(欄寬自動,中文欄位比較好讀)。"""133    output = io.BytesIO()134    with pd.ExcelWriter(output, engine="openpyxl") as writer:135        frame.to_excel(writer, index=False, sheet_name=sheet_name)136        if widths:137            sheet = writer.sheets[sheet_name]138            for idx, column in enumerate(frame.columns, start=1):139                longest = max([len(str(column))] + [len(str(v)) for v in frame[column].head(200)])140                sheet.column_dimensions[sheet.cell(row=1, column=idx).column_letter].width = min(48, max(10, longest + 2))141    return output.getvalue()142 143 144# ==================== 側邊欄 ====================145with st.sidebar:146    st.header("⚙️ 控制面板")147    mode = st.radio("模式選擇", [148        "📡 開放資料 API",149        "🔍 關鍵字搜尋 (官網)",150        "🔗 網址直爬",151        "📂 異步解析",152    ])153 154    st.divider()155    st.subheader("🔑 API Token(選填)")156    token_input = st.text_input(157        "data.openfun.tw 的 Bearer Token",158        value=os.environ.get("PCC_API_TOKEN", ""),159        type="password",160        help="帶 token 可解除 g0v API 的流量限制(官方文件明載)。留空也能用,但會自我限速。",161    )162    token = token_input.strip() or None163    api = get_api(token, True)164    if token:165        st.success("已啟用 Token:不受速率限制")166    else:167        st.caption(168            f"未帶 Token:自我限速 **{RATE_MAX_SHORT} 次/{int(RATE_WINDOW_SHORT)} 秒、"169            f"{RATE_MAX_LONG} 次/{int(RATE_WINDOW_LONG)} 秒**(後端門檻是 10 次/10 秒、60 次/60 秒)。"170        )171    st.caption(f"本次累計請求:**{api.requests_made}** 次|限速等待:**{api.waited:.1f}** 秒")172 173    st.divider()174    st.subheader("💾 案號去重資料庫")175    st.caption("⚠️ 容器檔案系統是暫時性的:重新建置就會清空,請用下方按鈕自行備份。")176    history_str = st.text_area("歷史案號清單 (支援手動貼入)", value="\n".join(sorted(st.session_state["crawled_ids"])), height=140)177    col_h1, col_h2 = st.columns(2)178    with col_h1:179        if st.button("💾 儲存變更", use_container_width=True):180            new_ids = {i.strip() for i in history_str.split("\n") if i.strip()}181            st.session_state["crawled_ids"] = new_ids182            save_history(new_ids)183            st.toast("✅ 歷史記錄已同步")184    with col_h2:185        st.download_button("⬇️ 匯出清單", history_csv(st.session_state["crawled_ids"]),186                           "crawled_history.csv", "text/csv", use_container_width=True)187    history_upload = st.file_uploader("⬆️ 匯入清單(CSV/TXT)", type=["csv", "txt"], label_visibility="collapsed")188    if history_upload is not None:189        text = history_upload.getvalue().decode("utf-8-sig", errors="ignore")190        ids = {line.strip().split(",")[0] for line in text.splitlines() if line.strip()}191        ids.discard("案號")192        if ids:193            st.session_state["crawled_ids"] = ids194            save_history(ids)195            st.success(f"已從檔案匯入 {len(ids)} 筆案號(記得按「儲存變更」確認)")196 197    st.divider()198    st.markdown(199        """200        ### 📂 data.gov.tw 數據指引201        本工作站已整合開源社群維護的 **g0v pcc-api**。202 203        若您需要全量、商業級的穩定採購大數據,請參考官方管道:204        1. **政府資料開放平臺 (data.gov.tw)**:搜尋 `政府電子採購網` 取得官方 XML/CSV 資料集。205        2. **政府電子採購網 M2M 申請**:依「政府電子採購網資訊取得及使用要點」申請專屬 API 授權。206        3. **公共工程雲端服務網**:需要公共工程類即時 API 可前往 [PCIC 平台](https://pcic.pcc.gov.tw/)。207        """208    )209 210    search_limit = 20211    min_d, max_d, max_per_batch = 5, 25, 15212    if mode in ["🔍 關鍵字搜尋 (官網)", "🔗 網址直爬"]:213        st.divider()214        st.subheader("🕷️ 爬蟲參數設定")215        search_limit = st.selectbox("搜尋結果每頁筆數", [10, 20, 50, 100], index=1)216        min_d = st.slider("爬取最小延遲 (秒)", 3, 15, 5)217        max_d = st.slider("爬取最大延遲 (秒)", 16, 60, 25)218        max_per_batch = st.slider("單次最大爬取筆數", 5, 50, 15)219        # 舊版只寫「建議延遲 ≥5 秒」,沒有揭露 15% 機率再加 8~25 秒的尾巴220        st.caption(221            f"⚠️ 單筆最壞延遲 = {max_d} 秒 + 8~25 秒(15% 機率)≈ **{max_d + 25} 秒**;"222            f"{max_per_batch} 筆最壞 ≈ **{(max_d + 25) * max_per_batch / 60:.0f} 分鐘**。"223        )224 225 226st.title("🕷️ 政府採購網 - 雲端多功能工作站")227 228 229# ==================== 模式 1:開放資料 API ====================230if mode == "📡 開放資料 API":231    st.info(232        "💡 使用 g0v 維護的 **pcc-api.openfun.app**。"233        "本版用 `columns[]` **一次請求**就取回整頁含預算金額/截止投標等欄位,"234        "不必再逐筆打 `/api/tender`(那會很快撞到 API 的速率限制)。"235    )236 237    api_mode = st.radio("查詢方式", ["🔑 關鍵字搜尋", "📅 日期查詢"], horizontal=True)238    show_list = []239 240    if api_mode == "🔑 關鍵字搜尋":241        col_input, col_btn = st.columns([4, 1])242        with col_input:243            keyword = st.text_input("搜尋關鍵字", value="農田水利署", key="api_kw")244        with col_btn:245            st.markdown("<div style='height: 28px;'></div>", unsafe_allow_html=True)246            search_clicked = st.button("🔎 API 搜尋", key="api_search")247        if search_clicked:248            st.session_state["api_search_kw"] = keyword.strip()249            st.session_state["api_page"] = 1250            st.session_state["detail_cache"] = {}251        elif keyword.strip() != st.session_state.get("api_search_kw", ""):252            st.session_state["api_search_kw"] = keyword.strip()253            st.session_state["api_page"] = 1254 255        query = st.session_state.get("api_search_kw", "").strip()256        if query:257            try:258                with st.spinner(f"正在搜尋「{query}」第 {st.session_state['api_page']} 頁(含詳細欄位)…"):259                    payload = cached_search(query, st.session_state["api_page"], token)260                records = collect_records(payload)261                st.session_state["api_total_pages"] = int(payload.get("total_pages") or 1)262                st.session_state["api_results"] = records263            except Exception as exc:      # noqa: BLE001 — 使用者要看得到原因,不讓頁面掛掉264                st.error(f"❌ 搜尋失敗:{exc}")265                st.session_state["api_results"] = []266            if st.session_state["api_results"]:267                st.caption(268                    f"符合條件共 {payload.get('total_records', '?')} 筆、"269                    f"{st.session_state['api_total_pages']} 頁(本頁 {len(records)} 筆,"270                    f"已在同一次請求帶回 {len(LIST_COLUMNS)} 個詳細欄位)"271                )272                show_list = listing_rows(st.session_state["api_results"], "API", st.session_state["crawled_ids"])273                col_prev, col_next, col_gap = st.columns([1, 1, 4])274                with col_prev:275                    if st.button("⬅️ 上一頁", disabled=st.session_state["api_page"] <= 1):276                        st.session_state["api_page"] -= 1277                        st.rerun()278                with col_next:279                    if st.button("➡️ 下一頁", disabled=st.session_state["api_page"] >= st.session_state["api_total_pages"]):280                        st.session_state["api_page"] += 1281                        st.rerun()282 283    else:284        query_date = st.date_input("查詢日期", value=date.today() - timedelta(days=1))285        if st.button("🔎 依日期查詢", key="api_date_search"):286            try:287                with st.spinner(f"查詢 {query_date:%Y-%m-%d} 的公告(含詳細欄位)…"):288                    payload = cached_list_by_date(query_date.strftime("%Y%m%d"), token)289                records = collect_records(payload)290                if not records:291                    st.warning("無公告資料(API 可能尚未同步)")292                    st.session_state["api_date_rows"] = []293                else:294                    st.session_state["api_date_rows"] = listing_rows(records, "API", st.session_state["crawled_ids"])295                    st.caption(f"當日共 {len(records)} 筆,已在同一次請求帶回 {len(LIST_COLUMNS)} 個詳細欄位")296            except Exception as exc:      # noqa: BLE001297                st.error(f"❌ 查詢失敗:{exc}")298                st.session_state["api_date_rows"] = []299        show_list = st.session_state.get("api_date_rows", [])300 301    if show_list:302        frame = pd.DataFrame(show_list)303        edited = st.data_editor(frame, use_container_width=True, hide_index=True, key="api_editor")304        selected = edited[edited["勾選"] == True]      # noqa: E712 — pandas 逐列比較305        st.caption(f"已勾選 {len(selected)} 筆")306 307        col_full, col_hist, col_list = st.columns(3)308        with col_full:309            estimate = len(selected) / max(1, RATE_MAX_SHORT) * RATE_WINDOW_SHORT if not token else len(selected) * 0.2310            if st.button(f"⚡ 取得完整欄位(86 欄,{len(selected)} 筆 ≈ {estimate:.0f} 秒)", disabled=selected.empty):311                detail_rows, failures = [], []312                progress = st.progress(0.0)313                status = st.empty()314                cached_hits = 0315                for index, (_, row) in enumerate(selected.iterrows(), start=1):316                    key = (row["機關代碼"], row["案號"])317                    if key in st.session_state["detail_cache"]:318                        detail_rows.append(st.session_state["detail_cache"][key])319                        cached_hits += 1320                    else:321                        try:322                            payload = cached_tender(row["機關代碼"], row["案號"], token)323                            records = collect_records(payload)324                            if not records:325                                failures.append({"案號": row["案號"], "原因": "API 沒有回傳資料"})326                            else:327                                flat = flatten_record(records[0])328                                st.session_state["detail_cache"][key] = flat329                                detail_rows.append(flat)330                        except Exception as exc:      # noqa: BLE001331                            failures.append({"案號": row["案號"], "原因": str(exc)})332                    progress.progress(index / len(selected))333                    status.text(f"已完成 {index}/{len(selected)}(快取命中 {cached_hits})")334                status.empty()335                st.session_state["api_detailed_results"] = detail_rows336                st.session_state["api_detail_failures"] = failures337                st.session_state["crawled_ids"].update(row["案號"] for _, row in selected.iterrows())338                save_history(st.session_state["crawled_ids"])339                st.success(f"✅ 取得 {len(detail_rows)} 筆完整資料(快取命中 {cached_hits}、失敗 {len(failures)})")340                st.rerun()341 342        with col_hist:343            if st.button("💾 將勾選案號加入歷史"):344                ids = set(selected["案號"].tolist())345                st.session_state["crawled_ids"].update(ids)346                save_history(st.session_state["crawled_ids"])347                st.toast(f"✅ 已新增 {len(ids)} 筆至歷史記錄")348                st.rerun()349 350        with col_list:351            st.download_button(352                "📥 下載此頁列表 Excel",353                excel_bytes(frame),354                f"API_List_Tenders_{datetime.now():%m%d_%H%M}.xlsx",355                use_container_width=True,356            )357 358        detail_rows = st.session_state.get("api_detailed_results") or []359        failures = st.session_state.get("api_detail_failures") or []360        if detail_rows:361            st.divider()362            detail_frame = pd.DataFrame(detail_rows)363            st.success(364                f"🎉 已取得 {len(detail_rows)} 筆完整欄位(每列 {len(detail_frame.columns)} 欄)"365                + (f",{len(failures)} 筆失敗" if failures else "")366            )367            st.download_button(368                "📥 下載完整欄位 Excel(86 欄)",369                excel_bytes(detail_frame, "detail"),370                f"API_Detailed_Tenders_{datetime.now():%m%d_%H%M}.xlsx",371                type="primary",372                key="dl_detailed_btn",373            )374            with st.expander("🔍 展開檢視完整資料", expanded=False):375                st.dataframe(detail_frame, use_container_width=True)376        if failures:377            with st.expander(f"⚠️ {len(failures)} 筆失敗原因", expanded=False):378                st.dataframe(pd.DataFrame(failures), use_container_width=True)379 380 381# ==================== 模式 2:官網爬蟲 ====================382elif mode == "🔍 關鍵字搜尋 (官網)":383    st.warning("⚠️ 直接爬官網有觸發驗證碼的風險,建議優先使用「📡 開放資料 API」模式。")384 385    col_kw, col_start, col_end = st.columns([2, 1, 1])386    with col_kw:387        kw = st.text_input("搜尋關鍵字", value="農田水利署")388    with col_start:389        start_d = st.date_input("公告開始日期", value=date(2026, 1, 1))390    with col_end:391        end_d = st.date_input("公告結束日期", value=date(2026, 3, 12))392    status_sel = st.multiselect("標案狀態 (可複選)", ["招標", "決標", "公開閱覽及公開徵求", "更正"], default=["招標"])393 394    if st.button("🔎 搜尋並過濾重複項"):395        if not status_sel:396            st.warning("請至少選擇一種標案狀態")397        else:398            with st.spinner("建立安全連線中..."):399                session = new_browser_session()400                try:401                    session.get("https://web.pcc.gov.tw/prkms/", timeout=15)402                except requests.exceptions.RequestException:403                    pass       # 暖機失敗不影響後續404                params = {405                    "querySentence": kw,406                    "tenderStatusType": status_sel,407                    "sortCol": "TENDER_NOTICE_DATE",408                    "timeRange": f"{start_d.year - 1911}",409                    "issuanceStartDate": f"{start_d.year - 1911}{start_d:%m%d}",410                    "issuanceEndDate": f"{end_d.year - 1911}{end_d:%m%d}",411                    "pageSize": search_limit,412                }413                search_url = "https://web.pcc.gov.tw/prkms/tender/common/bulletion/readBulletion"414                # 這裡只打**一次**:舊版先呼叫 safe_crawl_with_backoff 拿 status,再打第二次拿 HTML415                html, status = official_request(416                    session, search_url, params=params, referer="https://web.pcc.gov.tw/prkms/",417                    log=st.warning,418                )419                if status == "CAPTCHA":420                    st.error("🛑 搜尋階段即觸發驗證碼,建議改用「📡 開放資料 API」模式")421                elif html is None:422                    st.error(f"❌ 連線失敗: {status}")423                else:424                    soup = BeautifulSoup(html, "html.parser")425                    table = soup.find("table", id="bulletion")426                    if not table:427                        st.warning("在此日期區間內找不到相關標案,請調整日期或關鍵字。")428                        st.session_state["search_list"] = []429                    else:430                        rows = []431                        for row in table.select("tbody tr"):432                            cols = row.find_all("td")433                            if len(cols) <= 3:434                                continue435                            node = cols[3].find("a")436                            if not node:437                                continue438                            full_title = node.get_text(strip=True)439                            if "]" in full_title:440                                case_id = full_title.split("]")[0].replace("[", "").strip()441                                case_name = full_title.split("]")[1].strip()442                            else:443                                case_id, case_name = full_title, "(點擊爬取查看詳情)"444                            crawled = case_id in st.session_state["crawled_ids"]445                            rows.append({446                                "勾選": not crawled,447                                "狀態": "🚫 已爬過" if crawled else "⭐ 新標案",448                                "案號": case_id,449                                "標案名稱": case_name,450                                "機關": cols[2].get_text(strip=True),451                                "網址": "https://web.pcc.gov.tw" + node.get("href", ""),452                            })453                        st.session_state["search_list"] = rows454                        st.success(f"成功找到 {len(rows)} 筆標案!")455 456    if st.session_state["search_list"]:457        frame = pd.DataFrame(st.session_state["search_list"])458        edited = st.data_editor(frame, use_container_width=True, hide_index=True, key="official_editor")459        selected = edited[edited["勾選"] == True]      # noqa: E712460        st.caption(461            f"已勾選 {len(selected)} 筆|預估最壞等待 "462            f"**{min(len(selected), max_per_batch) * (max_d + 25) / 60:.1f} 分鐘**"463            f"(每筆最壞 {max_d + 25} 秒)"464        )465        if st.button(f"🕷️ 開始爬取所選(上限 {max_per_batch} 筆)"):466            targets = selected.head(max_per_batch)467            session = new_browser_session()468            results, progress = [], st.progress(0.0)469            status_box = st.empty()470            for index, (_, row) in enumerate(targets.iterrows(), start=1):471                delay = human_like_delay(min_d, max_d)472                status_box.text(f"({index}/{len(targets)}) 等待 {delay:.1f} 秒後爬取 {row['案號']}…")473                time.sleep(delay)474                html, status = official_request(session, row["網址"], referer="https://web.pcc.gov.tw/prkms/", log=st.warning)475                if html is None:476                    results.append({"網址": row["網址"], "結果": f"失敗 ({status})"})477                else:478                    parsed = parse_html_core(html)479                    parsed["狀態"] = "✅ 成功"480                    results.append(parsed)481                    st.session_state["crawled_ids"].add(row["案號"])482                progress.progress(index / len(targets))483            save_history(st.session_state["crawled_ids"])484            st.session_state["temp_results"] = results485            status_box.empty()486            st.dataframe(pd.DataFrame(results), use_container_width=True)487 488 489# ==================== 模式 3:網址直爬 ====================490elif mode == "🔗 網址直爬":491    urls_text = st.text_area("標案網址(一行一個)", height=160, placeholder="https://web.pcc.gov.tw/prkms/tender/common/bulletion/readBulletion?...")492    if st.button("開始爬取"):493        urls = [u.strip() for u in urls_text.split("\n") if u.strip()]494        if not urls:495            st.warning("請至少輸入一個網址")496        else:497            session = new_browser_session()498            results = []499            progress = st.progress(0.0)500            for index, url in enumerate(urls, start=1):501                delay = human_like_delay(min_d, max_d)502                time.sleep(delay)503                html, status = official_request(session, url, referer="https://web.pcc.gov.tw/prkms/", log=st.warning)504                if html is None:505                    results.append({"網址": url, "結果": f"失敗 ({status})"})506                else:507                    parsed = parse_html_core(html)508                    parsed["狀態"] = "✅ 成功"509                    results.append(parsed)510                progress.progress(index / len(urls))511            st.session_state["temp_results"] = results512            frame = pd.DataFrame(results)513            st.dataframe(frame, use_container_width=True)514            st.download_button("📥 下載結果 Excel", excel_bytes(frame), f"Direct_Crawl_{datetime.now():%m%d_%H%M}.xlsx")515 516 517# ==================== 模式 4:本地 HTML 解析 ====================518else:519    files = st.file_uploader("請上傳標案詳情 HTML", accept_multiple_files=True)520    if files and st.button("開始極速解析"):521        with concurrent.futures.ThreadPoolExecutor() as executor:522            results = list(executor.map(lambda f: parse_html_core(f.getvalue().decode("utf-8", errors="ignore")), files))523        frame = pd.DataFrame(results)524        st.dataframe(frame, use_container_width=True)525        st.download_button("📥 下載解析結果 Excel", excel_bytes(frame), f"Parsed_{datetime.now():%m%d_%H%M}.xlsx")526 527 528st.divider()529st.markdown(530    f"**系統狀態**:歷史案號數 `{len(st.session_state['crawled_ids'])}` "531    f"|本次 API 請求 `{api.requests_made}` 次、限速等待 `{api.waited:.1f}` 秒 "532    f"|資料來源:[pcc-api.openfun.app](https://pcc-api.openfun.app) / [web.pcc.gov.tw](https://web.pcc.gov.tw)"533)534