File size: 22,600 Bytes
cf3160b
 
 
 
 
 
01d9916
4efcc29
cf3160b
 
 
 
 
 
 
 
01d9916
cf3160b
2ab288e
cf3160b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4efcc29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ab288e
cf3160b
 
 
4efcc29
 
 
 
 
 
 
 
 
 
 
cf3160b
2ab288e
cf3160b
 
4efcc29
cf3160b
4efcc29
 
 
cf3160b
2ab288e
cf3160b
 
4efcc29
cf3160b
 
 
 
4efcc29
 
 
cf3160b
 
 
 
 
 
 
 
 
 
 
 
 
4efcc29
 
 
 
 
 
 
 
 
 
 
 
 
 
cf3160b
4efcc29
cf3160b
4efcc29
 
 
2ab288e
4efcc29
 
 
 
 
 
cf3160b
4efcc29
 
cf3160b
2ab288e
cf3160b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ab288e
cf3160b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ab288e
cf3160b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01d9916
 
 
 
 
 
 
 
 
 
 
 
 
 
2ab288e
01d9916
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ab288e
01d9916
 
 
 
 
 
 
 
 
 
2ab288e
01d9916
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4efcc29
 
2ab288e
4120bc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
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 模組用於轉換欄位索引
import re # 導入 re 模組

# 設定基本的 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, api_service_name='sheets', api_version='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 _extract_ids_from_url(self, sheet_url):
        """
        (私有方法) 從 Google Sheets URL 中提取 spreadsheetId 和 gid。
        """
        spreadsheet_id = None
        gid = None
        # 嘗試從 URL 中提取 spreadsheetId (通常在 /d/ 和 /edit 之間)
        match_id = re.search(r'/d/([a-zA-Z0-9-_]+)', sheet_url)
        if match_id:
            spreadsheet_id = match_id.group(1)
        # 嘗試從 URL 中提取 gid (通常在 gid= 後面)
        match_gid = re.search(r'[#&]gid=(\d+)', sheet_url)
        if match_gid:
            gid = int(match_gid.group(1)) # gid 是數字
        else:
            # 如果 URL 沒有 gid,通常指向第一個工作表,gid 為 0
            # logging.info(f"URL 中未找到 gid,假設為第一個工作表 (gid=0): {sheet_url}")
            gid = 0
        return spreadsheet_id, gid

    def get_sheet_id_by_url(self, sheet_url):
        """
        從 Google Sheets URL 中提取試算表 ID。
        """
        spreadsheet_id, _ = self._extract_ids_from_url(sheet_url)
        if spreadsheet_id:
             # 可以添加一些基本檢查,確保它看起來像一個 ID
             if len(spreadsheet_id) > 30: # Google Sheet ID 通常很長
                 return spreadsheet_id
             else:
                 logging.warning(f"從 URL 提取的 ID '{spreadsheet_id}' 看起來太短,可能無效: {sheet_url}")
                 return None
        else:
            logging.warning(f"無法從 URL 中提取有效的 Spreadsheet ID: {sheet_url}")
            return None

    def get_sheet_gid_by_url(self, sheet_url):
        """
        從 Google Sheets URL 中提取 gid (工作表分頁 ID)。
        返回整數型別的 gid 或 None (如果未指定則返回 0)。
        """
        _, gid = self._extract_ids_from_url(sheet_url)
        # 即使 gid 是 0 (預設),也將其視為有效的 gid
        return gid

    def get_sheet_name_by_gid(self, spreadsheet_id, gid):
        """
        使用 spreadsheetId 和 gid 獲取工作表名稱 (title)。
        如果 gid 為 None,則返回第一個工作表的名稱 (gid=0)。
        """
        if not self.service:
            logging.error("Sheet API 服務未成功初始化。")
            return None
        # 如果傳入的 gid 是 None,我們將其視為 0
        target_gid = gid if gid is not None else 0

        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

            for sheet in sheets:
                properties = sheet.get('properties', {})
                if properties.get('sheetId') == target_gid:
                    sheet_title = properties.get('title')
                    logging.info(f"找到 gid={target_gid} 對應的工作表名稱: '{sheet_title}'")
                    return sheet_title

            # 如果循環結束還沒找到
            logging.warning(f"在試算表 {spreadsheet_id} 中未找到 gid={target_gid} 對應的工作表。")
            # 如果 gid 是 0 但找不到,可能試算表是空的或有問題,返回第一個工作表的名稱作為備選
            if target_gid == 0 and sheets:
                 first_sheet_title = sheets[0].get('properties', {}).get('title')
                 logging.warning(f"返回第一個工作表的名稱 '{first_sheet_title}' 作為 gid=0 的備選。")
                 return first_sheet_title
            return None

        except Exception as e:
            logging.error(f"獲取工作表名稱時發生 API 錯誤 (ID: {spreadsheet_id}, GID: {target_gid}): {e}")
            return None

    def get_sheet_name_by_url(self, sheet_url):
        """
        從 Google Sheets URL 中提取 gid 並獲取對應的工作表名稱。
        """
        spreadsheet_id, gid = self._extract_ids_from_url(sheet_url)
        if not spreadsheet_id:
            logging.error(f"無法從 URL {sheet_url} 中提取 Spreadsheet ID。")
            return None
        # 直接調用已有的 get_sheet_name_by_gid 方法
        return self.get_sheet_name_by_gid(spreadsheet_id, gid)

    def get_sheet_data_by_url(self, sheet_url, read_range=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, range_name):
        """
        從指定的試算表和範圍讀取資料。

        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):
        """
        將從 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):
        """將 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):
        """將 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, target_row_index_in_data, qa_col_index, qa_result):
        """
        更新指定 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

    def batch_update_cells(self, sheet_url, update_data):
        """
        批次更新 Google Sheet 中的多個儲存格。

        Args:
            sheet_url (str): Google Sheet 的 URL。
            update_data (list[dict]): 一個字典列表,每個字典應包含:
                'range' (str): 要更新的範圍 (例如 '工作表名!A1')
                'values' (list[list[str]]): 要寫入的值 (二維列表, 例如 [['value1']])

        Returns:
            dict | None: Google API 的返回結果,如果成功。否則返回 None。
        """
        spreadsheet_id, _ = self._extract_ids_from_url(sheet_url)
        if not spreadsheet_id:
            logging.error(f"錯誤:無法從 URL {sheet_url} 中提取 Spreadsheet ID 進行批次更新。")
            return None
        if not self.service:
            logging.error("Sheet API 服務未成功初始化。")
            return None
        if not update_data:
            logging.warning("沒有需要批次更新的資料。")
            return None

        body = {
            'valueInputOption': 'USER_ENTERED', # 或者 'RAW'
            'data': []
        }
        valid_updates_count = 0 # 計算實際加入請求的數量
        for item in update_data:
        #  修正 1: 檢查 'values' 而不是 'value',並檢查 'values' 是否為列表
            if 'range' not in item or 'values' not in item or not isinstance(item['values'], list):
                logging.warning(f"跳過格式錯誤的更新項目 (缺少 'range' 或 'values' 非列表): {item}")
                continue
            # 修正 2: 稍微改進範圍檢查,確保 '!' 後面有內容
            range_parts = item['range'].split('!', 1) # 最多分割一次
            if len(range_parts) != 2 or not range_parts[0] or not range_parts[1]:
                logging.warning(f"跳過範圍格式錯誤的更新項目 (格式應為 'SheetName!A1'): {item['range']}")
                continue

            # 修正 3: 直接使用 item['values'],它本身就應該是二維列表
            body['data'].append({
                'range': item['range'],
                'values': item['values'] # 直接使用傳入的二維列表
            })
            valid_updates_count += 1

        # 修正 4: 根據實際加入請求的數量判斷是否繼續
        if not body['data']: # 或者 if valid_updates_count == 0:
            logging.warning("經過驗證後,沒有有效的資料可以進行批次更新。")
            # 即使沒有資料,也返回一個表示「沒有執行」的狀態,而不是 None,避免呼叫方誤判
            # 或者可以返回一個特定的物件或空字典來表示未執行
            return {"totalUpdatedCells": 0, "replies": [], "message": "No valid data to update after validation."} # 返回模擬的回應

        try:
            # logging.info(f"準備批次更新 Spreadsheet ID: {spreadsheet_id},共 {len(body['data'])} 個儲存格...")
            # 使用 valid_updates_count 進行日誌記錄更準確
            logging.info(f"準備批次更新 Spreadsheet ID: {spreadsheet_id},共 {valid_updates_count} 個有效儲存格...")
            result = self.service.spreadsheets().values().batchUpdate(
                spreadsheetId=spreadsheet_id,
                body=body
            ).execute()
            total_updated = result.get('totalUpdatedCells', 0)
            logging.info(f"批次更新完成。API 回報共更新了 {total_updated} 個儲存格。")
            # 可以在這裡比較 total_updated 和 valid_updates_count
            if total_updated != valid_updates_count:
                logging.warning(f"預期更新 {valid_updates_count} 個儲存格,但 API 回報更新了 {total_updated} 個。")
            return result # 返回實際的 API 回應
        except googleapiclient.errors.HttpError as error: # 更具體地捕獲 HttpError
            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}): {error_message}")
            logging.error(f"錯誤詳情: {error.content.decode()}") # 打印詳細錯誤內容
            return None # API 錯誤返回 None
        except Exception as e:
            logging.error(f"批次更新儲存格時發生未知錯誤: {e}")
            import traceback
            traceback.print_exc()
            return None # 其他錯誤返回 None