Spaces:
Sleeping
Sleeping
File size: 18,539 Bytes
cf3160b 01d9916 cf3160b 01d9916 cf3160b 01d9916 |
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 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 |
import google.oauth2.credentials
import googleapiclient.discovery
from google.oauth2 import service_account
import json
from urllib.parse import urlparse, parse_qs
import logging # 建議使用 logging 而非 print
import string # 導入 string 模組用於轉換欄位索引
# 設定基本的 logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
class SheetService:
"""
一個用於與 Google Sheets API 互動的服務類別。
"""
SCOPES = ['https://www.googleapis.com/auth/spreadsheets']
def __init__(self, service_account_key_string: str, api_service_name: str = 'sheets', api_version: str = 'v4'):
"""
初始化 SheetService。
Args:
service_account_key_string (str): 包含 Google 服務帳戶憑證資訊的 JSON 字串。
通常是從 JSON 金鑰檔案讀取的內容。
api_service_name (str): 要使用的 Google API 服務名稱。預設為 'sheets'。
api_version (str): 要使用的 Google API 版本。預設為 'v4'。
"""
try:
credentials_info = json.loads(service_account_key_string)
self.credentials = service_account.Credentials.from_service_account_info(
credentials_info, scopes=self.SCOPES
)
self.service = googleapiclient.discovery.build(
api_service_name, api_version, credentials=self.credentials
)
self.sheet = self.service.spreadsheets()
logging.info("成功連接 Google Sheets API")
except json.JSONDecodeError as e:
logging.error(f"解析憑證 JSON 字串時發生錯誤: {e}")
self.service = None
self.sheet = None
except Exception as e:
logging.error(f"連接 Google Sheets API 時發生錯誤: {e}")
self.service = None
self.sheet = None
def get_sheet_id_by_url(self, sheet_url: str) -> str | None:
"""
從 Google Sheets URL 中提取試算表 ID。
"""
parsed_url = urlparse(sheet_url)
path_parts = parsed_url.path.split('/')
try:
# Google Sheet URL 格式通常是 /spreadsheets/d/SPREADSHEET_ID/edit...
if 'd' in path_parts:
id_index = path_parts.index('d') + 1
if id_index < len(path_parts):
spreadsheet_id = path_parts[id_index]
# 進行一些基本檢查,確保它看起來像一個 ID
if len(spreadsheet_id) > 30: # Google Sheet ID 通常很長
return spreadsheet_id
except ValueError:
pass # 'd' 不在路徑中
logging.warning(f"無法從 URL 中提取有效的 Spreadsheet ID: {sheet_url}")
return None
def get_sheet_gid_by_url(self, sheet_url: str) -> int | None:
"""
從 Google Sheets URL 中提取 gid (工作表分頁 ID)。
返回整數型別的 gid 或 None。
"""
parsed_url = urlparse(sheet_url)
query_params = parse_qs(parsed_url.query)
fragment_params = parse_qs(parsed_url.fragment) # gid 也可能在 # 後面
gid_str = None
if 'gid' in query_params:
gid_str = query_params['gid'][0]
elif 'gid' in fragment_params:
gid_str = fragment_params['gid'][0]
if gid_str:
try:
return int(gid_str)
except ValueError:
logging.warning(f"URL 中的 gid 不是有效的整數: {gid_str}")
return None
else:
# logging.info(f"URL 中未找到 gid 參數,將嘗試使用第一個工作表: {sheet_url}")
# 如果 URL 沒有 gid,通常表示是第一個工作表,其 gid 通常是 0
# 但我們在這裡返回 None,讓後續邏輯決定如何處理
return None
def get_sheet_name_by_gid(self, spreadsheet_id: str, gid: int | None) -> str | None:
"""
使用 spreadsheetId 和 gid 獲取工作表名稱 (title)。
如果 gid 為 None,則返回第一個工作表的名稱。
Args:
spreadsheet_id (str): Google 試算表的 ID。
gid (int | None): 目標工作表分頁的 ID。如果為 None,則獲取第一個工作表。
Returns:
str | None: 工作表的名稱 (title),如果找不到或發生錯誤則返回 None。
"""
if not self.service:
logging.error("Sheet API 服務未成功初始化。")
return None
try:
# 使用 spreadsheets.get 獲取試算表的中繼資料
# fields 參數限制只返回我們需要的 sheets.properties (包含 title 和 sheetId)
sheet_metadata = self.service.spreadsheets().get(
spreadsheetId=spreadsheet_id,
fields='sheets(properties(sheetId,title))'
).execute()
sheets = sheet_metadata.get('sheets', [])
if not sheets:
logging.warning(f"試算表 {spreadsheet_id} 中沒有找到任何工作表。")
return None
if gid is not None:
# 如果提供了 gid,尋找匹配的工作表
for sheet in sheets:
properties = sheet.get('properties', {})
if properties.get('sheetId') == gid:
sheet_title = properties.get('title')
if sheet_title:
logging.info(f"找到 gid={gid} 對應的工作表名稱: '{sheet_title}'")
return sheet_title
else:
logging.warning(f"找到 gid={gid} 但缺少 title 屬性。")
return None
# 如果遍歷完畢沒有找到匹配的 gid
logging.warning(f"在試算表 {spreadsheet_id} 中未找到 gid={gid} 的工作表。")
return None
else:
# 如果 gid 為 None,返回第一個工作表的名稱
first_sheet_properties = sheets[0].get('properties', {})
first_sheet_title = first_sheet_properties.get('title')
first_sheet_gid = first_sheet_properties.get('sheetId', '未知')
if first_sheet_title:
logging.info(f"未提供 gid,使用第一個工作表 (gid={first_sheet_gid}): '{first_sheet_title}'")
return first_sheet_title
else:
logging.warning(f"第一個工作表 (gid={first_sheet_gid}) 缺少 title 屬性。")
return None
except googleapiclient.errors.HttpError as error:
logging.error(f"獲取工作表名稱時發生 API 錯誤: {error}")
return None
except Exception as e:
logging.error(f"獲取工作表名稱時發生未知錯誤: {e}")
return None
def get_sheet_data_by_url(self, sheet_url: str, read_range: str | None = None) -> list | None:
"""
通過 Google Sheets URL 自動獲取 Spreadsheet ID 和工作表名稱,並讀取數據。
如果 URL 中包含 gid,則讀取對應的工作表;否則讀取第一個工作表。
默認讀取整個工作表的數據。
Args:
sheet_url (str): Google 試算表的完整 URL。
read_range (str | None): 可選。指定要讀取的儲存格範圍 (例如 'A1:C10')。
如果提供,則只讀取此範圍;否則讀取整個工作表。
Returns:
list | None: 包含讀取到的資料的列表 (list of lists),如果發生錯誤則返回 None。
"""
spreadsheet_id = self.get_sheet_id_by_url(sheet_url)
if not spreadsheet_id:
logging.error("無法從 URL 獲取 Spreadsheet ID。")
return None
gid = self.get_sheet_gid_by_url(sheet_url)
# 無論 gid 是否為 None,都嘗試獲取工作表名稱
sheet_name = self.get_sheet_name_by_gid(spreadsheet_id, gid)
if not sheet_name:
logging.error(f"無法根據 URL ({sheet_url}) 確定要讀取的工作表名稱。")
return None
# 組合 range_name
if read_range:
# 如果使用者指定了範圍,將其與工作表名稱結合
# 需要確保工作表名稱不包含特殊字符,或者正確引用
# 簡單起見,如果名稱包含空格或特殊符號,用單引號括起來
if ' ' in sheet_name or '!' in sheet_name or ':' in sheet_name:
range_name = f"'{sheet_name}'!{read_range}"
else:
range_name = f"{sheet_name}!{read_range}"
else:
# 如果未指定範圍,則讀取整個工作表
# 只需要提供工作表名稱即可
if ' ' in sheet_name or '!' in sheet_name or ':' in sheet_name:
range_name = f"'{sheet_name}'"
else:
range_name = sheet_name
logging.info(f"準備從試算表 '{spreadsheet_id}' 的 '{range_name}' 範圍讀取數據。")
# 使用現有的 get_sheet_value 方法讀取數據
return self.get_sheet_value(spreadsheet_id, range_name)
def get_sheet_value(self, spreadsheet_id: str, range_name: str) -> list | None:
"""
從指定的試算表和範圍讀取資料。
Args:
spreadsheet_id (str): Google 試算表的 ID。
range_name (str): 要讀取的範圍,例如 'Sheet1!A1:B2' 或僅 'Sheet1' (讀取整個工作表)。
Returns:
list | None: 包含讀取到的資料的列表 (list of lists),如果發生錯誤則返回 None。
"""
if not self.sheet:
logging.error("Sheet API 服務未成功初始化。")
return None
try:
logging.info(f"正在讀取 Spreadsheet ID: {spreadsheet_id}, Range: {range_name}")
result = self.sheet.values().get(
spreadsheetId=spreadsheet_id,
range=range_name
).execute()
values = result.get('values', [])
logging.info(f"成功從 {spreadsheet_id} 的 {range_name} 讀取 {len(values)} 列資料。")
# 如果 values 是 None 或空列表,直接返回
if not values:
logging.warning(f"在 {spreadsheet_id} 的 {range_name} 範圍內未找到任何資料。")
return [] # 返回空列表而不是 None,以便後續處理
return values
except googleapiclient.errors.HttpError as error:
# 更詳細地記錄錯誤信息
error_details = error.resp.get('content', '{}')
try:
error_json = json.loads(error_details)
error_message = error_json.get('error', {}).get('message', str(error))
except json.JSONDecodeError:
error_message = str(error)
logging.error(f"讀取試算表時發生 API 錯誤 (ID: {spreadsheet_id}, Range: {range_name}): {error_message}")
return None
except Exception as e:
logging.error(f"讀取試算表時發生未知錯誤 (ID: {spreadsheet_id}, Range: {range_name}): {e}")
return None
@staticmethod
def flatten_column_data(data: list[list[str]]) -> list[str]:
"""
將從 Google Sheets API 獲取的單欄數據(列表的列表)扁平化為單一列表。
例如,將 [['A'], ['B'], ['C']] 轉換為 ['A', 'B', 'C']。
此方法會跳過空的內部列表,並假設每個非空內部列表只取第一個元素。
Args:
data (list[list[str]]): 從 API 獲取的原始數據,通常是 list of lists。
Returns:
list[str]: 包含所有第一欄元素的單一列表。如果輸入為 None 或空列表,
則返回空列表。
"""
if not data:
return []
# 使用列表推導式,提取每個子列表的第一個元素
# 添加 if sublist and sublist[0] is not None 確保子列表非空且第一個元素存在
# 並將其轉換為字串 str() 以確保類型一致性
flattened = [str(sublist[0]) for sublist in data if sublist and sublist[0] is not None]
return flattened
def update_sheet_data_by_url(self, sheet_url, data):
spreadsheet_id = self.get_sheet_id_by_url(sheet_url)
self.sheet.values().update(
spreadsheetId=spreadsheet_id,
range='A1:Z1000',
valueInputOption='USER_ENTERED',
body={'values': data}
).execute()
return True
@staticmethod
def _col_index_to_letter(col_index: int) -> str:
"""將 0-based 的欄位索引轉換為 A1 表示法的字母。"""
if col_index < 0:
raise ValueError("Column index must be non-negative")
letters = ''
while col_index >= 0:
col_index, remainder = divmod(col_index, 26)
letters = string.ascii_uppercase[remainder] + letters
col_index -= 1 # 因為 divmod 是 0-based,但 A=1, Z=26, AA=27 的轉換需要調整
if col_index < -1: # 修正邊界條件
break
# 修正:上面的邏輯有點複雜且可能有誤,改用更簡單的方式
letters = ''
dividend = col_index + 1 # 轉為 1-based
while dividend > 0:
module = (dividend - 1) % 26
letters = string.ascii_uppercase[module] + letters
dividend = (dividend - module) // 26
return letters if letters else "A" # 確保至少返回 "A"
@staticmethod
def _col_index_to_letter_simple(col_index: int) -> str:
"""將 0-based 的欄位索引轉換為 A1 表示法的字母 (簡化版,適用於 A-ZZ)。"""
if col_index < 0:
raise ValueError("Column index must be non-negative")
letters = ""
while col_index >= 0:
letters = string.ascii_uppercase[col_index % 26] + letters
col_index = col_index // 26 - 1
return letters
# SHEET_SERVICE.update_sheet_cell(sheet_url, target_row_index, qa_col_index, qa_result)
def update_sheet_cell(self, sheet_url: str, target_row_index_in_data: int, qa_col_index: int, qa_result: str) -> bool:
"""
更新指定 URL 的 Google Sheet 中的單一儲存格。
Args:
sheet_url (str): Google 試算表的完整 URL。
target_row_index_in_data (int): 目標列在 get_sheet_data_by_url 返回的列表中的索引
(從 1 開始算,因為 0 是標頭)。
qa_col_index (int): 目標欄位的索引 (從 0 開始算)。
qa_result (str): 要寫入儲存格的值。
Returns:
bool: 更新是否成功。
"""
if not self.sheet:
logging.error("Sheet API 服務未成功初始化。")
return False
spreadsheet_id = self.get_sheet_id_by_url(sheet_url)
if not spreadsheet_id:
logging.error(f"無法從 URL 獲取 Spreadsheet ID: {sheet_url}")
return False
gid = self.get_sheet_gid_by_url(sheet_url)
sheet_name = self.get_sheet_name_by_gid(spreadsheet_id, gid)
if not sheet_name:
logging.error(f"無法根據 URL ({sheet_url}) 確定要更新的工作表名稱。")
return False
# 將 0-based 的欄位索引轉換為字母
try:
col_letter = self._col_index_to_letter_simple(qa_col_index)
except ValueError as e:
logging.error(f"無效的欄位索引 {qa_col_index}: {e}")
return False
# 計算實際的工作表列號
# target_row_index_in_data 是 sheet_data 中的索引 (1-based)
# 實際列號是 target_row_index_in_data + 1 (因為工作表通常從第 1 列開始)
actual_sheet_row = target_row_index_in_data + 1
# 構建 A1 表示法的範圍,例如 '測試'!F3
# 需要正確處理工作表名稱中的特殊字符(例如空格)
if ' ' in sheet_name or '!' in sheet_name or ':' in sheet_name or "'" in sheet_name:
# 修正:使用三引號定義 f-string 以避免引號衝突
safe_sheet_name = f"""'{sheet_name.replace("'", "''")}'""" # 單引號用兩個單引號轉義
else:
safe_sheet_name = sheet_name
range_name = f"{safe_sheet_name}!{col_letter}{actual_sheet_row}"
logging.info(f"準備更新 Spreadsheet ID: {spreadsheet_id}, Range: {range_name}, Value: {qa_result}")
try:
body = {
'values': [[qa_result]] # 更新單一儲存格的值需要是 list of lists
}
result = self.sheet.values().update(
spreadsheetId=spreadsheet_id,
range=range_name,
valueInputOption='USER_ENTERED', # 或者 'RAW' 如果你不需要 Google Sheets 解釋輸入
body=body
).execute()
logging.info(f"成功更新儲存格 {range_name}。 更新了 {result.get('updatedCells')} 個儲存格。")
return True
except googleapiclient.errors.HttpError as error:
# 記錄更詳細的錯誤
error_details = error.resp.get('content', '{}')
try:
error_json = json.loads(error_details)
error_message = error_json.get('error', {}).get('message', str(error))
except json.JSONDecodeError:
error_message = str(error)
logging.error(f"更新儲存格時發生 API 錯誤 (ID: {spreadsheet_id}, Range: {range_name}): {error_message}")
# 檢查是否是權限錯誤
if error.resp.status == 403:
logging.error("錯誤 403:權限不足。請檢查服務帳戶是否有目標工作表的編輯權限,以及 API 金鑰是否啟用了正確的範圍 (scopes)。")
return False
except Exception as e:
logging.error(f"更新儲存格時發生未知錯誤 (ID: {spreadsheet_id}, Range: {range_name}): {e}")
return False |