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