tablemaster 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tablemaster/__init__.py +28 -0
- tablemaster/__main__.py +3 -0
- tablemaster/cli.py +97 -0
- tablemaster/config.py +107 -0
- tablemaster/database.py +286 -0
- tablemaster/feishu.py +502 -0
- tablemaster/gspread.py +130 -0
- tablemaster/local.py +90 -0
- tablemaster/sync.py +139 -0
- tablemaster/utils.py +19 -0
- tablemaster-2.0.0.dist-info/METADATA +243 -0
- tablemaster-2.0.0.dist-info/RECORD +16 -0
- tablemaster-2.0.0.dist-info/WHEEL +5 -0
- tablemaster-2.0.0.dist-info/entry_points.txt +2 -0
- tablemaster-2.0.0.dist-info/licenses/LICENSE +201 -0
- tablemaster-2.0.0.dist-info/top_level.txt +1 -0
tablemaster/feishu.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
_TOKEN_CACHE = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _request_with_retry(method, url, headers=None, params=None, json_data=None, data=None, timeout=30):
|
|
14
|
+
max_retries = 3
|
|
15
|
+
for attempt in range(max_retries):
|
|
16
|
+
response = requests.request(
|
|
17
|
+
method=method,
|
|
18
|
+
url=url,
|
|
19
|
+
headers=headers,
|
|
20
|
+
params=params,
|
|
21
|
+
json=json_data,
|
|
22
|
+
data=data,
|
|
23
|
+
timeout=timeout,
|
|
24
|
+
)
|
|
25
|
+
if response.status_code != 429:
|
|
26
|
+
return response
|
|
27
|
+
if attempt == max_retries - 1:
|
|
28
|
+
break
|
|
29
|
+
sleep_seconds = 2 ** attempt
|
|
30
|
+
logger.warning('feishu api rate limited (429), retry in %ss', sleep_seconds)
|
|
31
|
+
time.sleep(sleep_seconds)
|
|
32
|
+
raise requests.HTTPError(f'Feishu API rate limited after {max_retries} retries', response=response)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_tenant_access_token(feishu_cfg):
|
|
36
|
+
cache_key = (feishu_cfg.feishu_app_id, feishu_cfg.feishu_app_secret)
|
|
37
|
+
cached = _TOKEN_CACHE.get(cache_key)
|
|
38
|
+
now = datetime.utcnow()
|
|
39
|
+
if cached and now < cached['expire_at'] - timedelta(minutes=5):
|
|
40
|
+
return cached['token']
|
|
41
|
+
|
|
42
|
+
feishu_url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
|
|
43
|
+
post_data = {
|
|
44
|
+
"app_id": feishu_cfg.feishu_app_id,
|
|
45
|
+
"app_secret": feishu_cfg.feishu_app_secret
|
|
46
|
+
}
|
|
47
|
+
r = _request_with_retry("post", feishu_url, json_data=post_data)
|
|
48
|
+
body = r.json()
|
|
49
|
+
token = body["tenant_access_token"]
|
|
50
|
+
expire_seconds = int(body.get('expire', 7200))
|
|
51
|
+
_TOKEN_CACHE[cache_key] = {
|
|
52
|
+
'token': token,
|
|
53
|
+
'expire_at': now + timedelta(seconds=expire_seconds),
|
|
54
|
+
}
|
|
55
|
+
return token
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _col_num_to_letter(n):
|
|
59
|
+
"""
|
|
60
|
+
将列号(从1开始)转换为Excel风格的列字母
|
|
61
|
+
例如: 1 -> A, 26 -> Z, 27 -> AA, 28 -> AB
|
|
62
|
+
"""
|
|
63
|
+
result = ""
|
|
64
|
+
while n > 0:
|
|
65
|
+
n, remainder = divmod(n - 1, 26)
|
|
66
|
+
result = chr(65 + remainder) + result
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fs_read_df(sheet_address, feishu_cfg):
|
|
71
|
+
"""
|
|
72
|
+
从飞书电子表格读取数据并返回 DataFrame
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
sheet_address: [spreadsheet_token, sheet_id]
|
|
76
|
+
- spreadsheet_token: 表格的唯一标识(URL中sh开头的部分)
|
|
77
|
+
- sheet_id: 工作表的唯一标识(URL中sheet=后的部分)
|
|
78
|
+
feishu_cfg: 配置对象,包含 feishu_app_id 和 feishu_app_secret
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
pd.DataFrame: 读取的数据
|
|
82
|
+
"""
|
|
83
|
+
tat = _get_tenant_access_token(feishu_cfg)
|
|
84
|
+
header = {
|
|
85
|
+
"content-type": "application/json",
|
|
86
|
+
"Authorization": "Bearer " + str(tat)
|
|
87
|
+
}
|
|
88
|
+
url = (
|
|
89
|
+
"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/"
|
|
90
|
+
+ sheet_address[0] + "/values/" + sheet_address[1]
|
|
91
|
+
+ "?valueRenderOption=ToString&dateTimeRenderOption=FormattedString"
|
|
92
|
+
)
|
|
93
|
+
r = _request_with_retry("get", url, headers=header)
|
|
94
|
+
pull_data = r.json()['data']['valueRange']['values']
|
|
95
|
+
return pd.DataFrame(pull_data[1:], columns=pull_data[0])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fs_read_base(sheet_address, feishu_cfg):
|
|
99
|
+
"""
|
|
100
|
+
从飞书多维表格(Bitable)读取数据并返回 DataFrame
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
sheet_address: [app_token, table_id]
|
|
104
|
+
- app_token: 多维表格的唯一标识
|
|
105
|
+
- table_id: 数据表的唯一标识
|
|
106
|
+
feishu_cfg: 配置对象,包含 feishu_app_id 和 feishu_app_secret
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
pd.DataFrame: 读取的数据
|
|
110
|
+
"""
|
|
111
|
+
tat = _get_tenant_access_token(feishu_cfg)
|
|
112
|
+
header = {
|
|
113
|
+
"content-type": "application/json",
|
|
114
|
+
"Authorization": "Bearer " + str(tat)
|
|
115
|
+
}
|
|
116
|
+
base_url = (
|
|
117
|
+
"https://open.feishu.cn/open-apis/bitable/v1/apps/"
|
|
118
|
+
+ sheet_address[0] + "/tables/" + sheet_address[1] + '/records'
|
|
119
|
+
)
|
|
120
|
+
pull_data = []
|
|
121
|
+
page_token = None
|
|
122
|
+
has_more = True
|
|
123
|
+
|
|
124
|
+
while has_more:
|
|
125
|
+
query_params = "?valueRenderOption=ToString&dateTimeRenderOption=FormattedString&page_size=500"
|
|
126
|
+
if page_token:
|
|
127
|
+
query_params += f"&page_token={page_token}"
|
|
128
|
+
|
|
129
|
+
r = _request_with_retry("get", base_url + query_params, headers=header)
|
|
130
|
+
data = r.json().get('data', {})
|
|
131
|
+
pull_data.extend(data.get('items', []))
|
|
132
|
+
has_more = data.get('has_more', False)
|
|
133
|
+
page_token = data.get('page_token')
|
|
134
|
+
|
|
135
|
+
pull_data_parse = [x['fields'] for x in pull_data]
|
|
136
|
+
return pd.DataFrame(pull_data_parse)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fs_write_df(sheet_address, df, feishu_cfg, loc='A1', clear_sheet=True):
|
|
140
|
+
"""
|
|
141
|
+
将 DataFrame 写入飞书电子表格
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
sheet_address: [spreadsheet_token, sheet_id]
|
|
145
|
+
- spreadsheet_token: 表格的唯一标识(URL中sh开头的部分)
|
|
146
|
+
- sheet_id: 工作表的唯一标识(URL中sheet=后的部分)
|
|
147
|
+
df: 要写入的 pandas DataFrame
|
|
148
|
+
feishu_cfg: 配置对象,包含 feishu_app_id 和 feishu_app_secret
|
|
149
|
+
loc: 写入起始位置,默认 'A1'
|
|
150
|
+
clear_sheet: 是否在写入前清空工作表,默认 True
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
dict: API 响应结果
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
>>> sheet_address = ['shtcnxxxxxx', 'sheet_id_xxx']
|
|
157
|
+
>>> fs_write_df(sheet_address, df, feishu_cfg)
|
|
158
|
+
"""
|
|
159
|
+
logger.info('writing feishu sheets')
|
|
160
|
+
|
|
161
|
+
# 获取 access token
|
|
162
|
+
tat = _get_tenant_access_token(feishu_cfg)
|
|
163
|
+
header = {
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
"Authorization": "Bearer " + str(tat)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
spreadsheet_token = sheet_address[0]
|
|
169
|
+
sheet_id = sheet_address[1]
|
|
170
|
+
|
|
171
|
+
# 清空工作表(如果需要)
|
|
172
|
+
if clear_sheet:
|
|
173
|
+
logger.info('clearing sheet by values_batch_clear api')
|
|
174
|
+
try:
|
|
175
|
+
clear_url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{spreadsheet_token}/values_batch_clear"
|
|
176
|
+
clear_data = {"ranges": [f"{sheet_id}!A1:XFD1048576"]}
|
|
177
|
+
clear_resp = _request_with_retry("post", clear_url, headers=header, json_data=clear_data)
|
|
178
|
+
if clear_resp.json().get('code') == 0:
|
|
179
|
+
logger.info('sheet cleared')
|
|
180
|
+
else:
|
|
181
|
+
logger.warning("failed to clear sheet: %s", clear_resp.json().get('msg'))
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.warning('failed to clear sheet: %s', e)
|
|
184
|
+
|
|
185
|
+
# 处理 DataFrame 数据类型
|
|
186
|
+
df_copy = df.copy()
|
|
187
|
+
|
|
188
|
+
# 将非数值类型转换为字符串
|
|
189
|
+
non_float_int_columns = df_copy.select_dtypes(exclude=['float64', 'int64', 'float32', 'int32']).columns
|
|
190
|
+
for col in non_float_int_columns:
|
|
191
|
+
df_copy[col] = df_copy[col].astype(str)
|
|
192
|
+
|
|
193
|
+
# 处理 NaN 值,转换为空字符串
|
|
194
|
+
df_copy = df_copy.fillna('')
|
|
195
|
+
|
|
196
|
+
# 替换 'nan' 字符串为空字符串
|
|
197
|
+
df_copy = df_copy.replace('nan', '')
|
|
198
|
+
df_copy = df_copy.replace('NaT', '')
|
|
199
|
+
|
|
200
|
+
# 准备写入数据:表头 + 数据
|
|
201
|
+
values = [df_copy.columns.values.tolist()] + df_copy.values.tolist()
|
|
202
|
+
|
|
203
|
+
# 计算写入范围
|
|
204
|
+
num_rows = len(values)
|
|
205
|
+
num_cols = len(values[0]) if values else 0
|
|
206
|
+
|
|
207
|
+
# 解析起始位置
|
|
208
|
+
import re
|
|
209
|
+
loc_match = re.match(r'([A-Z]+)(\d+)', loc.upper())
|
|
210
|
+
if loc_match:
|
|
211
|
+
start_col = loc_match.group(1)
|
|
212
|
+
start_row = int(loc_match.group(2))
|
|
213
|
+
else:
|
|
214
|
+
start_col = 'A'
|
|
215
|
+
start_row = 1
|
|
216
|
+
|
|
217
|
+
# 计算结束列
|
|
218
|
+
start_col_num = sum((ord(c) - ord('A') + 1) * (26 ** i)
|
|
219
|
+
for i, c in enumerate(reversed(start_col)))
|
|
220
|
+
end_col_num = start_col_num + num_cols - 1
|
|
221
|
+
end_col = _col_num_to_letter(end_col_num)
|
|
222
|
+
end_row = start_row + num_rows - 1
|
|
223
|
+
|
|
224
|
+
# 构建写入范围
|
|
225
|
+
write_range = f"{sheet_id}!{start_col}{start_row}:{end_col}{end_row}"
|
|
226
|
+
|
|
227
|
+
logger.info('writing to range: %s', write_range)
|
|
228
|
+
|
|
229
|
+
# 构建请求数据
|
|
230
|
+
post_data = {
|
|
231
|
+
"valueRange": {
|
|
232
|
+
"range": write_range,
|
|
233
|
+
"values": values
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# 发送 PUT 请求写入数据
|
|
238
|
+
url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{spreadsheet_token}/values"
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
r = _request_with_retry("put", url, headers=header, json_data=post_data)
|
|
242
|
+
response = r.json()
|
|
243
|
+
|
|
244
|
+
if response.get('code') == 0:
|
|
245
|
+
logger.info('data is written')
|
|
246
|
+
else:
|
|
247
|
+
logger.error('failed to write data: %s', response.get('msg', 'Unknown error'))
|
|
248
|
+
logger.error('error code: %s', response.get('code'))
|
|
249
|
+
|
|
250
|
+
return response
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.exception('failed to write data: %s', e)
|
|
254
|
+
raise
|
|
255
|
+
|
|
256
|
+
def _get_bitable_fields(app_token, table_id, header):
|
|
257
|
+
"""
|
|
258
|
+
获取多维表格的所有字段名
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
set: 字段名集合
|
|
262
|
+
"""
|
|
263
|
+
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
|
|
264
|
+
r = _request_with_retry("get", url, headers=header)
|
|
265
|
+
|
|
266
|
+
if r.status_code == 200 and r.json().get('code') == 0:
|
|
267
|
+
items = r.json().get('data', {}).get('items', [])
|
|
268
|
+
return {item['field_name'] for item in items}
|
|
269
|
+
else:
|
|
270
|
+
logger.warning("failed to get fields: %s", r.json().get('msg', 'Unknown error'))
|
|
271
|
+
return set()
|
|
272
|
+
|
|
273
|
+
def fs_write_base(sheet_address, df, feishu_cfg, clear_table=False):
|
|
274
|
+
"""
|
|
275
|
+
将 DataFrame 写入飞书多维表格(Bitable)
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
sheet_address: [app_token, table_id]
|
|
279
|
+
- app_token: 多维表格的唯一标识
|
|
280
|
+
- table_id: 数据表的唯一标识
|
|
281
|
+
df: 要写入的 pandas DataFrame (列名需与多维表格字段名匹配)
|
|
282
|
+
feishu_cfg: 配置对象,包含 feishu_app_id 和 feishu_app_secret
|
|
283
|
+
clear_table: 是否在写入前清空数据表,默认 False
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
dict: API 响应结果
|
|
287
|
+
|
|
288
|
+
Note:
|
|
289
|
+
- 多维表格的写入是基于字段名称的,DataFrame的列名需要与表格中的字段名完全匹配
|
|
290
|
+
- 不存在的字段会被自动跳过并打印警告信息
|
|
291
|
+
"""
|
|
292
|
+
logger.info('writing feishu bitable')
|
|
293
|
+
|
|
294
|
+
tat = _get_tenant_access_token(feishu_cfg)
|
|
295
|
+
header = {
|
|
296
|
+
"Content-Type": "application/json",
|
|
297
|
+
"Authorization": "Bearer " + str(tat)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
app_token = sheet_address[0]
|
|
301
|
+
table_id = sheet_address[1]
|
|
302
|
+
|
|
303
|
+
# 获取多维表格中的字段名
|
|
304
|
+
logger.info('fetching bitable fields')
|
|
305
|
+
existing_fields = _get_bitable_fields(app_token, table_id, header)
|
|
306
|
+
|
|
307
|
+
if not existing_fields:
|
|
308
|
+
logger.error('could not fetch table fields or table has no fields')
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
logger.info('table has %s fields', len(existing_fields))
|
|
312
|
+
|
|
313
|
+
# 检查 DataFrame 列名与表格字段的匹配情况
|
|
314
|
+
df_columns = set(df.columns.tolist())
|
|
315
|
+
|
|
316
|
+
# 找出不存在的字段
|
|
317
|
+
missing_fields = df_columns - existing_fields
|
|
318
|
+
valid_fields = df_columns & existing_fields
|
|
319
|
+
|
|
320
|
+
if missing_fields:
|
|
321
|
+
logger.warning('the following columns do not exist in bitable and will be skipped')
|
|
322
|
+
for field in sorted(missing_fields):
|
|
323
|
+
logger.warning('skip column: %s', field)
|
|
324
|
+
|
|
325
|
+
if not valid_fields:
|
|
326
|
+
logger.error('no valid fields to write, all dataframe columns are missing in bitable')
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
logger.info('will write %s valid fields', len(valid_fields))
|
|
330
|
+
|
|
331
|
+
# 清空数据表(如果需要)
|
|
332
|
+
if clear_table:
|
|
333
|
+
logger.info('clearing bitable records')
|
|
334
|
+
try:
|
|
335
|
+
list_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
|
|
336
|
+
record_ids = []
|
|
337
|
+
page_token = None
|
|
338
|
+
has_more = True
|
|
339
|
+
|
|
340
|
+
while has_more:
|
|
341
|
+
query_params = "?page_size=500"
|
|
342
|
+
if page_token:
|
|
343
|
+
query_params += f"&page_token={page_token}"
|
|
344
|
+
list_resp = _request_with_retry("get", list_url + query_params, headers=header)
|
|
345
|
+
|
|
346
|
+
if list_resp.status_code != 200:
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
data = list_resp.json().get('data', {})
|
|
350
|
+
items = data.get('items', [])
|
|
351
|
+
record_ids.extend([item['record_id'] for item in items])
|
|
352
|
+
has_more = data.get('has_more', False)
|
|
353
|
+
page_token = data.get('page_token')
|
|
354
|
+
|
|
355
|
+
if record_ids:
|
|
356
|
+
delete_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_delete"
|
|
357
|
+
for i in range(0, len(record_ids), 500):
|
|
358
|
+
batch_ids = record_ids[i:i + 500]
|
|
359
|
+
delete_data = {"records": batch_ids}
|
|
360
|
+
_request_with_retry("post", delete_url, headers=header, json_data=delete_data)
|
|
361
|
+
logger.info('deleted %s records', len(record_ids))
|
|
362
|
+
|
|
363
|
+
except Exception as e:
|
|
364
|
+
logger.warning('failed to clear table: %s', e)
|
|
365
|
+
|
|
366
|
+
# 处理 DataFrame - 只保留有效字段
|
|
367
|
+
df_copy = df[list(valid_fields)].copy()
|
|
368
|
+
df_copy = df_copy.fillna('')
|
|
369
|
+
df_copy = df_copy.replace('nan', '')
|
|
370
|
+
|
|
371
|
+
# 将 DataFrame 转换为 records 格式
|
|
372
|
+
records = []
|
|
373
|
+
skipped_cols = set() # 记录因数据类型问题跳过的列
|
|
374
|
+
|
|
375
|
+
for idx, row in df_copy.iterrows():
|
|
376
|
+
fields = {}
|
|
377
|
+
for col in df_copy.columns:
|
|
378
|
+
value = row[col]
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
# 检查是否为空值
|
|
382
|
+
is_na = False
|
|
383
|
+
try:
|
|
384
|
+
is_na = pd.isna(value)
|
|
385
|
+
# 如果是数组类型,pd.isna 返回数组,需要用 all() 判断
|
|
386
|
+
if hasattr(is_na, '__iter__') and not isinstance(is_na, str):
|
|
387
|
+
is_na = all(is_na) if len(is_na) > 0 else True
|
|
388
|
+
except (ValueError, TypeError):
|
|
389
|
+
is_na = value is None
|
|
390
|
+
|
|
391
|
+
if is_na:
|
|
392
|
+
continue # 跳过空值
|
|
393
|
+
elif value == '' or value == 'nan' or value == 'None':
|
|
394
|
+
continue # 跳过空字符串
|
|
395
|
+
elif isinstance(value, bool):
|
|
396
|
+
# 布尔值直接写入
|
|
397
|
+
fields[col] = value
|
|
398
|
+
elif isinstance(value, (int, float)):
|
|
399
|
+
# 数字直接写入
|
|
400
|
+
fields[col] = value
|
|
401
|
+
elif isinstance(value, str):
|
|
402
|
+
# 字符串直接写入
|
|
403
|
+
fields[col] = value
|
|
404
|
+
elif isinstance(value, (list, tuple)):
|
|
405
|
+
# 检查列表内容
|
|
406
|
+
if len(value) == 0:
|
|
407
|
+
continue
|
|
408
|
+
first_item = value[0]
|
|
409
|
+
# 如果是字典列表(如附件、人员字段),提取文本或跳过
|
|
410
|
+
if isinstance(first_item, dict):
|
|
411
|
+
# 尝试提取文本内容
|
|
412
|
+
texts = []
|
|
413
|
+
for item in value:
|
|
414
|
+
if isinstance(item, dict):
|
|
415
|
+
# 尝试获取 text、name、title 等常见文本字段
|
|
416
|
+
text = item.get('text') or item.get('name') or item.get('title') or item.get('value')
|
|
417
|
+
if text:
|
|
418
|
+
texts.append(str(text))
|
|
419
|
+
if texts:
|
|
420
|
+
fields[col] = ', '.join(texts)
|
|
421
|
+
else:
|
|
422
|
+
# 无法提取有效文本,跳过该字段
|
|
423
|
+
if col not in skipped_cols:
|
|
424
|
+
skipped_cols.add(col)
|
|
425
|
+
continue
|
|
426
|
+
else:
|
|
427
|
+
# 简单列表(如多选字段的字符串列表)
|
|
428
|
+
fields[col] = [str(item) for item in value]
|
|
429
|
+
elif isinstance(value, dict):
|
|
430
|
+
# 字典类型,尝试提取文本或转为字符串
|
|
431
|
+
text = value.get('text') or value.get('name') or value.get('title') or value.get('value')
|
|
432
|
+
if text:
|
|
433
|
+
fields[col] = str(text)
|
|
434
|
+
else:
|
|
435
|
+
# 转为 JSON 字符串
|
|
436
|
+
fields[col] = json.dumps(value, ensure_ascii=False)
|
|
437
|
+
else:
|
|
438
|
+
# 其他类型转为字符串
|
|
439
|
+
fields[col] = str(value)
|
|
440
|
+
|
|
441
|
+
except Exception as e:
|
|
442
|
+
# 如果处理出错,尝试转为字符串
|
|
443
|
+
try:
|
|
444
|
+
str_val = str(value)
|
|
445
|
+
if str_val and str_val != 'None' and str_val != 'nan':
|
|
446
|
+
fields[col] = str_val
|
|
447
|
+
except:
|
|
448
|
+
if col not in skipped_cols:
|
|
449
|
+
skipped_cols.add(col)
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
records.append({"fields": fields})
|
|
453
|
+
|
|
454
|
+
if skipped_cols:
|
|
455
|
+
logger.warning('skipped columns due to unsupported data types: %s', skipped_cols)
|
|
456
|
+
|
|
457
|
+
# 批量写入(每次最多500条)
|
|
458
|
+
batch_size = 500
|
|
459
|
+
all_responses = []
|
|
460
|
+
|
|
461
|
+
for i in range(0, len(records), batch_size):
|
|
462
|
+
batch = records[i:i + batch_size]
|
|
463
|
+
|
|
464
|
+
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
|
|
465
|
+
post_data = {"records": batch}
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
r = _request_with_retry("post", url, headers=header, json_data=post_data)
|
|
469
|
+
response = r.json()
|
|
470
|
+
all_responses.append(response)
|
|
471
|
+
|
|
472
|
+
if response.get('code') == 0:
|
|
473
|
+
logger.info('batch %s wrote %s records', i // batch_size + 1, len(batch))
|
|
474
|
+
else:
|
|
475
|
+
logger.error('failed to write batch: %s', response.get('msg', 'Unknown error'))
|
|
476
|
+
|
|
477
|
+
except Exception as e:
|
|
478
|
+
logger.exception('failed to write batch: %s', e)
|
|
479
|
+
|
|
480
|
+
logger.info('write summary total records: %s', len(records))
|
|
481
|
+
logger.info('write summary fields written: %s', len(valid_fields))
|
|
482
|
+
if missing_fields:
|
|
483
|
+
logger.info('write summary fields skipped: %s', len(missing_fields))
|
|
484
|
+
for field in sorted(missing_fields):
|
|
485
|
+
logger.info('skip field: %s', field)
|
|
486
|
+
logger.info('data is written')
|
|
487
|
+
|
|
488
|
+
return all_responses
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# 为了向后兼容,保留原有函数签名的包装
|
|
492
|
+
def fs_write_df_simple(map, df, feishu_cfg, loc='A1'):
|
|
493
|
+
"""
|
|
494
|
+
简化版写入函数,参数顺序与 gs_write_df 保持一致
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
map: [spreadsheet_token, sheet_id]
|
|
498
|
+
df: pandas DataFrame
|
|
499
|
+
feishu_cfg: 配置对象
|
|
500
|
+
loc: 起始位置,默认 'A1'
|
|
501
|
+
"""
|
|
502
|
+
return fs_write_df(map, df, feishu_cfg, loc=loc)
|
tablemaster/gspread.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import gspread
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import re
|
|
4
|
+
import warnings
|
|
5
|
+
import logging
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
def _is_google_sheet_id(s):
|
|
11
|
+
return len(s) > 40 and ' ' not in s
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_cell_loc(value):
|
|
15
|
+
return isinstance(value, str) and re.match(r'^[A-Za-z]+[1-9]\d*$', value.strip()) is not None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _warn_deprecated(message):
|
|
19
|
+
warnings.warn(f'{message} This usage will be removed in a future release.', FutureWarning, stacklevel=3)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_service_account_path(cfg, service_account_path):
|
|
23
|
+
if service_account_path:
|
|
24
|
+
_warn_deprecated('service_account_path argument is deprecated; pass a cfg object instead.')
|
|
25
|
+
return service_account_path
|
|
26
|
+
|
|
27
|
+
if cfg is None:
|
|
28
|
+
_warn_deprecated('No cfg argument provided; use gs_read_df(address, cfg).')
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
if isinstance(cfg, str):
|
|
32
|
+
_warn_deprecated('Passing a string path as the second argument is deprecated; pass a cfg object instead.')
|
|
33
|
+
return cfg
|
|
34
|
+
|
|
35
|
+
path = getattr(cfg, 'service_account_path', None)
|
|
36
|
+
if not path:
|
|
37
|
+
raise ValueError('Google config is missing service_account_path; please check cfg.')
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@lru_cache(maxsize=8)
|
|
42
|
+
def _get_gspread_client(service_account_path=None):
|
|
43
|
+
if service_account_path:
|
|
44
|
+
return gspread.service_account(service_account_path)
|
|
45
|
+
return gspread.service_account()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def gs_read_df(address, cfg=None, service_account_path=None):
|
|
49
|
+
logger.info('reading google sheets')
|
|
50
|
+
sa_path = _resolve_service_account_path(cfg, service_account_path)
|
|
51
|
+
gc = _get_gspread_client(sa_path)
|
|
52
|
+
|
|
53
|
+
spreadsheet_identifier = address[0]
|
|
54
|
+
worksheet_name = address[1]
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
if _is_google_sheet_id(spreadsheet_identifier):
|
|
58
|
+
logger.info('opening sheet by ID: %s', spreadsheet_identifier)
|
|
59
|
+
sh = gc.open_by_key(spreadsheet_identifier)
|
|
60
|
+
else:
|
|
61
|
+
logger.info('opening sheet by name: %s', spreadsheet_identifier)
|
|
62
|
+
sh = gc.open(spreadsheet_identifier)
|
|
63
|
+
|
|
64
|
+
wks = sh.worksheet(worksheet_name)
|
|
65
|
+
df = pd.DataFrame(wks.get_all_records())
|
|
66
|
+
logger.info('google sheets read success')
|
|
67
|
+
logger.debug('google sheets preview: %s', df.head())
|
|
68
|
+
return df
|
|
69
|
+
|
|
70
|
+
except gspread.exceptions.SpreadsheetNotFound:
|
|
71
|
+
logger.error("spreadsheet '%s' not found", spreadsheet_identifier)
|
|
72
|
+
return None
|
|
73
|
+
except gspread.exceptions.WorksheetNotFound:
|
|
74
|
+
logger.error("worksheet '%s' not found in spreadsheet", worksheet_name)
|
|
75
|
+
return None
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.exception('an unexpected error occurred: %s', e)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def gs_write_df(address, df, cfg=None, loc='A1', service_account_path=None):
|
|
82
|
+
if isinstance(cfg, str) and _is_cell_loc(cfg):
|
|
83
|
+
_warn_deprecated('Passing loc as the third positional argument is deprecated; use keyword loc=...')
|
|
84
|
+
if isinstance(loc, str) and not _is_cell_loc(loc) and service_account_path is None:
|
|
85
|
+
service_account_path = loc
|
|
86
|
+
loc = cfg
|
|
87
|
+
cfg = None
|
|
88
|
+
|
|
89
|
+
logger.info('writing google sheets')
|
|
90
|
+
sa_path = _resolve_service_account_path(cfg, service_account_path)
|
|
91
|
+
gc = _get_gspread_client(sa_path)
|
|
92
|
+
|
|
93
|
+
spreadsheet_identifier = address[0]
|
|
94
|
+
worksheet_name = address[1]
|
|
95
|
+
|
|
96
|
+
is_id = _is_google_sheet_id(spreadsheet_identifier)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
if is_id:
|
|
100
|
+
logger.info('opening sheet by ID: %s', spreadsheet_identifier)
|
|
101
|
+
sh = gc.open_by_key(spreadsheet_identifier)
|
|
102
|
+
else:
|
|
103
|
+
logger.info('opening sheet by name: %s', spreadsheet_identifier)
|
|
104
|
+
sh = gc.open(spreadsheet_identifier)
|
|
105
|
+
|
|
106
|
+
except gspread.exceptions.SpreadsheetNotFound:
|
|
107
|
+
if is_id:
|
|
108
|
+
logger.error("spreadsheet ID '%s' not found, cannot create with specific ID", spreadsheet_identifier)
|
|
109
|
+
return
|
|
110
|
+
else:
|
|
111
|
+
logger.info("spreadsheet '%s' not found, creating one", spreadsheet_identifier)
|
|
112
|
+
sh = gc.create(spreadsheet_identifier)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
wks = sh.worksheet(worksheet_name)
|
|
116
|
+
except gspread.exceptions.WorksheetNotFound:
|
|
117
|
+
logger.info('worksheet "%s" not found, creating one', worksheet_name)
|
|
118
|
+
wks = sh.add_worksheet(title=worksheet_name, rows="100", cols="20")
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
wks.clear()
|
|
122
|
+
df_copy = df.copy()
|
|
123
|
+
non_float_int_columns = df_copy.select_dtypes(exclude=['float64', 'int64']).columns
|
|
124
|
+
for col in non_float_int_columns:
|
|
125
|
+
df_copy[col] = df_copy[col].astype(str)
|
|
126
|
+
wks.update(loc, ([df_copy.columns.values.tolist()] + df_copy.values.tolist()))
|
|
127
|
+
|
|
128
|
+
logger.info('data is written')
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.exception('failed to update worksheet: %s', e)
|