cwadayi's picture
Update app.py
6d58347 verified
# -*- 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()