auto backup 2026-07-02 11:00
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 CSV / JSON 批量导入记录到钉钉 AI 表格(新版 schema)
|
||||
|
||||
用法:
|
||||
python import_records.py <baseId> <tableId> data.csv [batch_size]
|
||||
python import_records.py <baseId> <tableId> data.json [batch_size]
|
||||
|
||||
说明:
|
||||
- CSV 表头默认视为 fieldId
|
||||
- JSON 支持两种格式:
|
||||
1. [{"cells": {"fldxxx": "value"}}, ...]
|
||||
2. [{"fldxxx": "value"}, ...] # 会自动包装成 cells
|
||||
"""
|
||||
|
||||
import sys
|
||||
import csv
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union, List, Dict, Any, Optional, Tuple
|
||||
|
||||
JsonData = Union[List[Any], Dict[str, Any]]
|
||||
RecordDict = Dict[str, str]
|
||||
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024
|
||||
ALLOWED_CSV_EXTENSIONS = ['.csv']
|
||||
ALLOWED_JSON_EXTENSIONS = ['.json']
|
||||
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
|
||||
MAX_RECORDS_PER_BATCH = 100
|
||||
DEFAULT_BATCH_SIZE = 50
|
||||
|
||||
|
||||
def resolve_safe_path(
|
||||
path: str, allowed_root: Optional[str] = None
|
||||
) -> Path:
|
||||
if allowed_root is None:
|
||||
allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd())
|
||||
allowed_root = Path(allowed_root).resolve()
|
||||
target_path = (
|
||||
Path(path).resolve()
|
||||
if Path(path).is_absolute()
|
||||
else (Path.cwd() / path).resolve()
|
||||
)
|
||||
try:
|
||||
target_path.relative_to(allowed_root)
|
||||
return target_path
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"路径超出允许范围:{path}\n"
|
||||
f"目标路径:{target_path}\n"
|
||||
f"允许根目录:{allowed_root}\n"
|
||||
f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内"
|
||||
)
|
||||
|
||||
|
||||
def validate_resource_id(resource_id: str) -> bool:
|
||||
return bool(
|
||||
resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip())
|
||||
)
|
||||
|
||||
|
||||
def validate_file_extension(
|
||||
filename: str, allowed_extensions: list
|
||||
) -> bool:
|
||||
return any(filename.lower().endswith(ext) for ext in allowed_extensions)
|
||||
|
||||
|
||||
def safe_csv_load(
|
||||
file_path: Path, max_size: int = MAX_FILE_SIZE
|
||||
) -> List[RecordDict]:
|
||||
file_size = file_path.stat().st_size
|
||||
if file_size > max_size:
|
||||
raise ValueError(
|
||||
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
|
||||
)
|
||||
with open(file_path, 'r', encoding='utf-8', newline='') as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
def safe_json_load(
|
||||
file_path: Path, max_size: int = MAX_FILE_SIZE
|
||||
) -> JsonData:
|
||||
file_size = file_path.stat().st_size
|
||||
if file_size > max_size:
|
||||
raise ValueError(
|
||||
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
|
||||
)
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def sanitize_record_value(
|
||||
value: Any,
|
||||
) -> Optional[Union[str, int, float, bool, list, dict]]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (bool, int, float, list, dict)):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if not value.strip():
|
||||
return None
|
||||
|
||||
value = value.strip()
|
||||
if value.lower() == 'true':
|
||||
return True
|
||||
if value.lower() == 'false':
|
||||
return False
|
||||
|
||||
try:
|
||||
if '.' in value:
|
||||
return float(value)
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def normalize_record(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if 'cells' in record and isinstance(record['cells'], dict):
|
||||
cells = record['cells']
|
||||
else:
|
||||
cells = record
|
||||
normalized = {}
|
||||
for key, value in cells.items():
|
||||
sanitized = sanitize_record_value(value)
|
||||
if sanitized is not None:
|
||||
normalized[key] = sanitized
|
||||
return {'cells': normalized}
|
||||
|
||||
|
||||
def validate_record(
|
||||
record: Dict[str, Any], headers: List[str]
|
||||
) -> Tuple[bool, str]:
|
||||
if not isinstance(record, dict):
|
||||
return False, '记录必须是对象'
|
||||
normalized = normalize_record(record)
|
||||
cells = normalized.get('cells', {})
|
||||
if not cells or not isinstance(cells, dict):
|
||||
return False, '记录必须包含非空 cells 对象'
|
||||
return True, ''
|
||||
|
||||
|
||||
def run_dws(args: List[str]) -> Optional[Dict[str, Any]]:
|
||||
if not args:
|
||||
print('错误:空命令')
|
||||
return None
|
||||
cmd = ['dws'] + args
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}")
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"无法解析响应:{result.stdout[:200]}...")
|
||||
print(f"JSON 解析错误:{e}")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
print('错误:命令执行超时(120 秒)')
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
print('错误:未找到 dws 命令,请确认已安装')
|
||||
return None
|
||||
|
||||
|
||||
def import_from_csv(
|
||||
base_id: str, table_id: str, csv_file: str,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
) -> bool:
|
||||
try:
|
||||
safe_path = resolve_safe_path(csv_file)
|
||||
except ValueError as e:
|
||||
print(f"路径验证失败:{e}")
|
||||
return False
|
||||
|
||||
if not validate_file_extension(csv_file, ALLOWED_CSV_EXTENSIONS):
|
||||
print(f"错误:只允许 {', '.join(ALLOWED_CSV_EXTENSIONS)} 文件")
|
||||
return False
|
||||
if not safe_path.exists():
|
||||
print(f"错误:文件不存在:{safe_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
rows = safe_csv_load(safe_path)
|
||||
except ValueError as e:
|
||||
print(f"错误:{e}")
|
||||
return False
|
||||
except csv.Error as e:
|
||||
print(f"错误:CSV 格式无效:{e}")
|
||||
return False
|
||||
|
||||
if not rows:
|
||||
print('错误:CSV 文件为空或没有有效数据行')
|
||||
return False
|
||||
|
||||
records = [
|
||||
normalize_record(row)
|
||||
for row in rows
|
||||
if normalize_record(row)['cells']
|
||||
]
|
||||
return import_records(base_id, table_id, records, batch_size)
|
||||
|
||||
|
||||
def import_from_json(
|
||||
base_id: str, table_id: str, json_file: str,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
) -> bool:
|
||||
try:
|
||||
safe_path = resolve_safe_path(json_file)
|
||||
except ValueError as e:
|
||||
print(f"路径验证失败:{e}")
|
||||
return False
|
||||
|
||||
if not validate_file_extension(json_file, ALLOWED_JSON_EXTENSIONS):
|
||||
print(f"错误:只允许 {', '.join(ALLOWED_JSON_EXTENSIONS)} 文件")
|
||||
return False
|
||||
if not safe_path.exists():
|
||||
print(f"错误:文件不存在:{safe_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
records = safe_json_load(safe_path)
|
||||
except ValueError as e:
|
||||
print(f"错误:{e}")
|
||||
return False
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"错误:JSON 格式无效:{e}")
|
||||
return False
|
||||
|
||||
if not isinstance(records, list) or not records:
|
||||
print('错误:JSON 文件必须是非空数组')
|
||||
return False
|
||||
|
||||
for i, record in enumerate(records):
|
||||
valid, error = validate_record(record, [])
|
||||
if not valid:
|
||||
print(f"错误:记录 #{i+1} 格式无效:{error}")
|
||||
return False
|
||||
|
||||
return import_records(
|
||||
base_id, table_id,
|
||||
[normalize_record(r) for r in records], batch_size,
|
||||
)
|
||||
|
||||
|
||||
def import_records(
|
||||
base_id: str, table_id: str,
|
||||
records: List[Dict[str, Any]], batch_size: int,
|
||||
) -> bool:
|
||||
if batch_size <= 0:
|
||||
print('错误:batch_size 必须大于 0')
|
||||
return False
|
||||
if batch_size > MAX_RECORDS_PER_BATCH:
|
||||
batch_size = MAX_RECORDS_PER_BATCH
|
||||
|
||||
total_batches = (len(records) + batch_size - 1) // batch_size
|
||||
success = True
|
||||
|
||||
for i in range(0, len(records), batch_size):
|
||||
batch = records[i:i + batch_size]
|
||||
batch_num = (i // batch_size) + 1
|
||||
records_json = json.dumps(batch, ensure_ascii=False)
|
||||
result = run_dws([
|
||||
'aitable', 'record', 'create',
|
||||
'--base-id', base_id,
|
||||
'--table-id', table_id,
|
||||
'--records', records_json,
|
||||
'--format', 'json',
|
||||
])
|
||||
if result:
|
||||
print(
|
||||
f"[{batch_num}/{total_batches}] "
|
||||
f"✓ 已提交 {len(batch)} 条记录"
|
||||
)
|
||||
else:
|
||||
print(f"[{batch_num}/{total_batches}] ✗ 导入失败")
|
||||
success = False
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 4 or len(sys.argv) > 5:
|
||||
print(__doc__)
|
||||
print('用法示例:')
|
||||
print(
|
||||
' python import_records.py basexxx tablexxx data.csv 50'
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
base_id = sys.argv[1]
|
||||
table_id = sys.argv[2]
|
||||
input_file = sys.argv[3]
|
||||
batch_size = (
|
||||
int(sys.argv[4]) if len(sys.argv) == 5
|
||||
else DEFAULT_BATCH_SIZE
|
||||
)
|
||||
|
||||
if not validate_resource_id(base_id):
|
||||
print('错误:无效的 baseId 格式')
|
||||
sys.exit(1)
|
||||
if not validate_resource_id(table_id):
|
||||
print('错误:无效的 tableId 格式')
|
||||
sys.exit(1)
|
||||
|
||||
if input_file.lower().endswith('.csv'):
|
||||
success = import_from_csv(
|
||||
base_id, table_id, input_file, batch_size
|
||||
)
|
||||
elif input_file.lower().endswith('.json'):
|
||||
success = import_from_json(
|
||||
base_id, table_id, input_file, batch_size
|
||||
)
|
||||
else:
|
||||
print('错误:仅支持 .csv 或 .json 文件')
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user