vaitor2 / sheet_service.py
youngtsai's picture
def refresh_video_LLM_all_content_by_sheet(sheet_url, sheet_qa_column, video_ids): # 移除 sheet_video_column
01d9916
raw
history blame
18.5 kB
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