Spaces:
Sleeping
Sleeping
| # -*- coding: utf-8 -*- | |
| import os | |
| import re | |
| import time | |
| import json | |
| import tempfile | |
| from typing import Optional, Tuple, List, Any | |
| import matplotlib.pyplot as plt | |
| import gradio as gr | |
| # --- 引入 Google API 相關套件 --- | |
| from googleapiclient.discovery import build, Resource | |
| from googleapiclient.http import MediaIoBaseUpload | |
| from googleapiclient.errors import HttpError | |
| from google.oauth2.service_account import Credentials | |
| # ====== CONFIGURATION ====== | |
| DEFAULT_EXTENT = (119.0, 123.5, 21.5, 25.5) # 預設地圖範圍 (台灣) | |
| SCOPES = ("https://www.googleapis.com/auth/drive.file",) # Google Drive API 權限範圍,僅限於本應用程式建立的檔案 | |
| DEFAULT_FOLDER_URL = "https://drive.google.com/drive/folders/1B7D4Z6vV_G30nIrBr8qFsgNA4l2G-dRo" # 預設上傳的資料夾 (可以是你的共用雲端硬碟資料夾) | |
| MIMETYPE_PNG = "image/png" | |
| MIMETYPE_FOLDER = "application/vnd.google-apps.folder" | |
| # ====== UTILITY FUNCTIONS ====== | |
| def clean_folder_id(folder_input: str) -> Optional[str]: | |
| """ | |
| 從 Google Drive 資料夾的 URL 或純 ID 中,提取出乾淨的 folder ID。 | |
| 能自動處理常見的複製貼上錯誤,如多餘的標點符號。 | |
| """ | |
| if not folder_input: | |
| return None | |
| raw = folder_input.strip() | |
| # 優先從 URL 格式中擷取 ID (例如 .../folders/<ID>) | |
| match = re.search(r"/folders/([A-Za-z0-9_-]+)", raw) | |
| if match: | |
| raw = match.group(1) | |
| # 移除 URL 的 query string (例如 ?usp=sharing) | |
| raw = raw.split("?")[0] | |
| # 移除頭尾的引號 | |
| raw = raw.strip().strip('"\'') | |
| # 移除使用者可能不小心複製到的結尾標點符號 | |
| raw = raw.rstrip(" .。;;,,!!??、/\\』」)》)】]}") | |
| # 最終驗證 ID 格式是否合法 (Google Drive ID 通常是長度超過 20 的英數混合字串) | |
| return raw if re.fullmatch(r"[A-Za-z0-9_-]{20,}", raw) else None | |
| def parse_points(csv_text: str) -> List[Tuple[float, float]]: | |
| """從文字輸入中解析出經緯度座標點列表。""" | |
| points: List[Tuple[float, float]] = [] | |
| if not csv_text: | |
| return points | |
| for line in csv_text.strip().splitlines(): | |
| line = line.strip() | |
| # 跳過空行或註解行 | |
| if not line or line.startswith("#"): | |
| continue | |
| # 將 tab 轉換為逗號,並以逗號分割 | |
| parts = [p.strip() for p in line.replace("\t", ",").split(",")] | |
| if len(parts) < 2: | |
| continue | |
| try: | |
| # 嘗試轉換為浮點數 | |
| lon, lat = float(parts[0]), float(parts[1]) | |
| points.append((lon, lat)) | |
| except ValueError: | |
| # 轉換失敗則忽略此行 | |
| continue | |
| return points | |
| def make_figure(points: List[Tuple[float, float]], title: str, | |
| extent: Tuple[float, float, float, float] = DEFAULT_EXTENT, | |
| dpi: int = 150) -> str: | |
| """使用 Matplotlib 繪製地圖並存成暫存圖檔。""" | |
| min_lon, max_lon, min_lat, max_lat = extent | |
| fig, ax = plt.subplots(figsize=(6.5, 6.5), dpi=dpi) | |
| ax.set_title(title or "Map Plot", pad=10) | |
| ax.set_xlabel("Longitude"); ax.set_ylabel("Latitude") | |
| ax.set_xlim(min_lon, max_lon); ax.set_ylim(min_lat, max_lat) | |
| ax.grid(True, linestyle=":", linewidth=0.8) | |
| # 繪製座標點 | |
| if points: | |
| xs = [p[0] for p in points] | |
| ys = [p[1] for p in points] | |
| ax.scatter(xs, ys, s=60, marker="*", label="Input points", zorder=10) | |
| ax.legend(loc="upper right") | |
| # 儲存圖片到系統暫存目錄 | |
| out_path = os.path.join(tempfile.gettempdir(), f"map_{int(time.time())}.png") | |
| fig.tight_layout() | |
| fig.savefig(out_path) | |
| plt.close(fig) # 關閉圖表以釋放記憶體,在 Web App 中很重要 | |
| return out_path | |
| def drive_service_from_secret() -> Tuple[Optional[Resource], Optional[str], Optional[str]]: | |
| """ | |
| 從環境變數讀取服務帳號 JSON,並建立 Google Drive API 服務物件。 | |
| """ | |
| raw_secret = os.environ.get("SERVICE_ACCOUNT_JSON", "").strip() | |
| if not raw_secret: | |
| return None, None, "在 Secrets 中找不到 `SERVICE_ACCOUNT_JSON`。" | |
| try: | |
| info = json.loads(raw_secret) | |
| except json.JSONDecodeError as e: | |
| return None, None, f"`SERVICE_ACCOUNT_JSON` 格式錯誤,不是有效的 JSON: {e}" | |
| client_email = info.get("client_email") | |
| if not client_email: | |
| return None, None, "`SERVICE_ACCOUNT_JSON` 中缺少 `client_email` 欄位。" | |
| try: | |
| creds = Credentials.from_service_account_info(info, scopes=SCOPES) | |
| # cache_discovery=False 在無伺服器或容器環境中是個好習慣,可避免快取問題 | |
| service = build("drive", "v3", credentials=creds, cache_discovery=False) | |
| return service, client_email, None | |
| except Exception as e: | |
| return None, None, f"建立 Drive 服務時發生錯誤: {e}" | |
| def upload_png_to_drive(local_png_path: str, service: Resource, folder_id: Optional[str], make_public: bool) -> Tuple[str, str]: | |
| """上傳本地圖片檔案到指定的 Google Drive 資料夾。""" | |
| file_metadata = {"name": os.path.basename(local_png_path)} | |
| if folder_id: | |
| file_metadata["parents"] = [folder_id] | |
| # 準備上傳的媒體內容 | |
| with open(local_png_path, "rb") as f: | |
| media = MediaIoBaseUpload(f, mimetype=MIMETYPE_PNG) | |
| # 執行上傳請求 | |
| # supportsAllDrives=True 是關鍵,讓服務帳號可以寫入共用雲端硬碟 | |
| created_file = ( | |
| service.files() | |
| .create(body=file_metadata, media_body=media, | |
| fields="id, webViewLink", | |
| supportsAllDrives=True) | |
| .execute() | |
| ) | |
| file_id = created_file.get("id", "") | |
| web_view_link = created_file.get("webViewLink", "") | |
| # 如果使用者勾選了「公開」,則設定檔案權限 | |
| if make_public and file_id: | |
| try: | |
| service.permissions().create( | |
| fileId=file_id, | |
| body={"role": "reader", "type": "anyone"}, | |
| supportsAllDrives=True, | |
| ).execute() | |
| except HttpError as e: | |
| # 即使設定權限失敗,也不中斷流程,僅在連結後附加錯誤訊息 | |
| web_view_link += f"\n(但設定公開連結失敗: {e})" | |
| return file_id, web_view_link | |
| # ====== GRADIO HANDLER FUNCTIONS ====== | |
| def generate_and_upload(points_text, title, min_lon, max_lon, min_lat, max_lat, | |
| folder_input, make_public): | |
| """Gradio 按鈕點擊後的主要處理函式。""" | |
| # 1. 產生本地圖片檔 | |
| try: | |
| pts = parse_points(points_text) | |
| png_path = make_figure(pts, title, extent=(min_lon, max_lon, min_lat, max_lat)) | |
| except Exception as e: | |
| return None, f"❌ 繪圖失敗: {e}" | |
| # 2. 建立 Google Drive 服務 | |
| service, client_email, err_msg = drive_service_from_secret() | |
| if service is None: | |
| return png_path, (f"⚠️ **Google Drive 未設定或設定錯誤**\n" | |
| f"請確認您已在 Hugging Face Space 的 Settings → Secrets 中新增了 `SERVICE_ACCOUNT_JSON`。\n" | |
| f"詳細錯誤: {err_msg}") | |
| # 3. 決定要使用的 Folder ID (優先順序: UI 輸入 > 環境變數 > 程式碼預設值) | |
| env_folder = os.environ.get("DRIVE_FOLDER_ID", "").strip() | |
| raw_folder_input = folder_input or env_folder or DEFAULT_FOLDER_URL | |
| folder_id = clean_folder_id(raw_folder_input) | |
| if not folder_id: | |
| return png_path, (f"❌ **無效的 Google Drive 資料夾網址或 ID**\n" | |
| f"請貼上整個資料夾網址,或是正確的 ID。\n" | |
| f"收到的內容: `{raw_folder_input}`") | |
| # 4. 上傳檔案 | |
| try: | |
| file_id, view_link = upload_png_to_drive(png_path, service, folder_id, make_public) | |
| msg = [ | |
| "✅ **上傳成功!**", | |
| f"• **服務帳號**: `{client_email}`", | |
| f"• **目標資料夾 ID**: `{folder_id}`", | |
| f"• **檔案 ID**: `{file_id}`", | |
| f"• **檔案連結**: {view_link}" | |
| ] | |
| if not make_public: | |
| msg.append("\n> _提示: 未勾選公開,檔案將繼承資料夾的權限設定。_") | |
| return png_path, "\n".join(msg) | |
| except HttpError as e: | |
| return png_path, f"❌ **上傳失敗 (HttpError)**: {e}" | |
| except Exception as e: | |
| return png_path, f"❌ **上傳失敗 (未知錯誤)**: {e}" | |
| def check_config(): | |
| """檢查環境變數與 API 連線狀態。""" | |
| service, client_email, err_msg = drive_service_from_secret() | |
| if service is None: | |
| return f"❌ **服務建立失敗**: {err_msg}" | |
| env_folder = os.environ.get("DRIVE_FOLDER_ID", "").strip() | |
| folder_id = clean_folder_id(env_folder or DEFAULT_FOLDER_URL) | |
| output = [ | |
| f"✅ **服務帳號已載入**: `{client_email}`", | |
| f"➡️ **將使用的資料夾 ID**: `{folder_id}` (來自: {'Secret' if env_folder else '預設值'})" | |
| ] | |
| if folder_id: | |
| try: | |
| # 嘗試取得資料夾名稱來驗證 ID 是否有效以及權限是否足夠 | |
| meta = service.files().get( | |
| fileId=folder_id, fields="id, name", supportsAllDrives=True | |
| ).execute() | |
| output.append(f"✅ **目標資料夾名稱**: **{meta.get('name')}**") | |
| except HttpError as e: | |
| output.append(f"⚠️ **無法讀取資料夾資訊**: {e}\n> _請檢查服務帳號是否有此資料夾的「檢視者」或更高權限。_") | |
| return "\n".join(output) | |
| def list_folder(folder_input): | |
| """列出指定資料夾內的檔案。""" | |
| service, client_email, err_msg = drive_service_from_secret() | |
| if service is None: | |
| return f"❌ **服務建立失敗**: {err_msg}" | |
| env_folder = os.environ.get("DRIVE_FOLDER_ID", "").strip() | |
| raw_folder_input = folder_input or env_folder or DEFAULT_FOLDER_URL | |
| folder_id = clean_folder_id(raw_folder_input) | |
| if not folder_id: | |
| return "❌ **無效的 Google Drive 資料夾網址或 ID。**" | |
| try: | |
| # 驗證目標是否為資料夾 | |
| meta = service.files().get( | |
| fileId=folder_id, fields="name, mimeType", supportsAllDrives=True | |
| ).execute() | |
| if meta.get("mimeType") != MIMETYPE_FOLDER: | |
| return f"⚠️ **目標不是資料夾**: `{meta.get('name')}` (類型: {meta.get('mimeType')})" | |
| except HttpError as e: | |
| return f"❌ **取得資料夾資訊失敗**: {e}\n> _請檢查 ID 是否正確,以及服務帳號是否有權限存取。_" | |
| try: | |
| # 列出資料夾內容 | |
| results = service.files().list( | |
| q=f"'{folder_id}' in parents and trashed=false", | |
| fields="files(id, name, mimeType, webViewLink)", | |
| pageSize=50, | |
| supportsAllDrives=True, | |
| corpora="allDrives" # corpora='allDrives' 可同時搜尋個人、共用雲端硬碟 | |
| ).execute() | |
| files = results.get("files", []) | |
| except HttpError as e: | |
| return f"❌ **列出檔案失敗**: {e}" | |
| lines = [ | |
| f"✅ **服務帳號**: `{client_email}`", | |
| f"✅ **資料夾**: **{meta.get('name')}** (`{folder_id}`)", | |
| "\n--- 內容 (最多 50 筆) ---" | |
| ] | |
| if not files: | |
| lines.append("(此資料夾為空,或服務帳號無權限看見任何檔案)") | |
| else: | |
| for f in files: | |
| lines.append(f"- **{f['name']}** (`{f['mimeType']}`) - [開啟連結]({f['webViewLink']})") | |
| return "\n".join(lines) | |
| # ====== GRADIO UI DEFINITION ====== | |
| with gr.Blocks(title="地圖產生器 → Google Drive", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown(f""" | |
| # 🗺️ 地圖產生器 → Google Drive 上傳工具 | |
| 這個工具會根據輸入的經緯度座標點產生一張地圖圖片,並將其上傳到指定的 Google Drive 資料夾。 | |
| - **預設上傳資料夾**: `{DEFAULT_FOLDER_URL}` | |
| - 可在下方欄位輸入新的資料夾 URL/ID 來覆蓋預設值。 | |
| - 也可在 Space 的 **Settings → Secrets** 中設定 `DRIVE_FOLDER_ID` 來改變預設值。 | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| points_in = gr.Textbox( | |
| label="座標點 (每行一組 經度,緯度)", | |
| value="121.5,25.0\n120.9,24.5\n121.0,23.9\n# 這是註解,會被忽略", | |
| lines=8, | |
| ) | |
| title_in = gr.Textbox(label="地圖標題", value="台灣座標點範例") | |
| with gr.Accordion("進階設定", open=False): | |
| folder_in = gr.Textbox( | |
| label="(可選)覆蓋預設資料夾", | |
| placeholder="貼上新的 Google Drive 資料夾 URL 或 純 ID", | |
| ) | |
| make_public_in = gr.Checkbox( | |
| label="建立公開分享連結 (權限: 擁有連結的任何人皆可檢視)", value=False | |
| ) | |
| with gr.Row(): | |
| min_lon_in = gr.Number(label="最小經度", value=DEFAULT_EXTENT[0]) | |
| max_lon_in = gr.Number(label="最大經度", value=DEFAULT_EXTENT[1]) | |
| min_lat_in = gr.Number(label="最小緯度", value=DEFAULT_EXTENT[2]) | |
| max_lat_in = gr.Number(label="最大緯度", value=DEFAULT_EXTENT[3]) | |
| with gr.Row(): | |
| run_btn = gr.Button("🚀 產生圖片並上傳", variant="primary") | |
| list_btn = gr.Button("📄 列出資料夾內容") | |
| cfg_btn = gr.Button("🧪 檢查設定") | |
| with gr.Column(scale=3): | |
| out_image = gr.Image(label="預覽圖", type="filepath") | |
| out_msg = gr.Markdown(label="執行結果") | |
| with gr.Accordion("除錯與列表資訊", open=False): | |
| cfg_out = gr.Markdown(label="設定檢查結果") | |
| list_out = gr.Markdown(label="資料夾內容列表") | |
| # --- 按鈕事件綁定 --- | |
| run_btn.click( | |
| fn=generate_and_upload, | |
| inputs=[points_in, title_in, min_lon_in, max_lon_in, min_lat_in, max_lat_in, | |
| folder_in, make_public_in], | |
| outputs=[out_image, out_msg], | |
| ) | |
| cfg_btn.click(fn=check_config, inputs=None, outputs=cfg_out) | |
| list_btn.click(fn=list_folder, inputs=folder_in, outputs=list_out) | |
| if __name__ == "__main__": | |
| demo.launch() | |