cormort/procurement-crawler-py
0
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 