Spaces:
Sleeping
Sleeping
File size: 14,441 Bytes
6d58347 3bd2b2f ee59f53 3bd2b2f 1022bf0 3bd2b2f 6d58347 3bd2b2f 6d58347 3bd2b2f 1022bf0 3bd2b2f 6d58347 2e07db0 6d58347 2e07db0 6d58347 2e07db0 6d58347 2e07db0 6d58347 2e07db0 6d58347 2e07db0 6d58347 2e07db0 3bd2b2f 6d58347 2e07db0 6d58347 3bd2b2f 6d58347 3bd2b2f 6d58347 3bd2b2f 6d58347 3bd2b2f 6d58347 3bd2b2f 6d58347 3bd2b2f 2e07db0 6d58347 3bd2b2f 1240a6f 3bd2b2f 5caf29a 3bd2b2f 6d58347 3bd2b2f 6d58347 3bd2b2f 1240a6f 6d58347 5caf29a 6d58347 3bd2b2f 6d58347 1240a6f 6d58347 3bd2b2f 1022bf0 6d58347 1022bf0 6d9f8cb 5caf29a 6d58347 3bd2b2f 6d58347 1240a6f 6d58347 2e07db0 1240a6f 6d58347 3bd2b2f 6d58347 2e07db0 6d58347 1240a6f 2e07db0 6d58347 1240a6f 6d58347 1240a6f 6d58347 1240a6f 6d58347 3bd2b2f 6d58347 2e07db0 6d58347 3bd2b2f 2e07db0 6d58347 6d9f8cb 6d58347 3bd2b2f 6d58347 2e07db0 6d58347 1240a6f 6d58347 1022bf0 6d58347 3bd2b2f 6d58347 ee59f53 1240a6f 6d58347 2e07db0 6d58347 1240a6f 6d58347 6d9f8cb 6d58347 1240a6f 6d9f8cb 6d58347 5caf29a 2e07db0 6d58347 1240a6f 6d58347 ee59f53 2e07db0 6d58347 1240a6f 6d58347 1240a6f 6d58347 2e07db0 6d58347 1240a6f 6d58347 ee59f53 6d58347 1240a6f 6d58347 2e07db0 1240a6f 6d58347 1240a6f 6d58347 1240a6f 6d58347 1240a6f 6d58347 1240a6f 6d58347 1240a6f 6d58347 1240a6f ee59f53 6d58347 3bd2b2f 6d58347 3bd2b2f 6d58347 3bd2b2f 6d58347 3bd2b2f 5caf29a 6d58347 1240a6f 6d58347 3bd2b2f 6d58347 b90ddd5 6d58347 b90ddd5 2e07db0 b90ddd5 1240a6f 2e07db0 b90ddd5 6d58347 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
# -*- 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()
|