# -*- 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/) 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()