first commit

This commit is contained in:
2026-09-02 11:44:52 +08:00
commit 0c8fa2653e
309 changed files with 57278 additions and 0 deletions
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
通过 MCP 导出任务(export_data)导出 AI 表格,并可自动下载文件。
与普通命令的区别:
- 自动处理 taskId 轮询(直到拿到 downloadUrl 或达到轮询上限)。
- 自动保存导出文件到本地(可选 --output)。
用法:
python scripts/aitable_export_via_task.py <baseId> --scope all
python scripts/aitable_export_via_task.py <baseId> --scope table --table-id <tableId>
python scripts/aitable_export_via_task.py <baseId> --scope view --table-id <tableId> --view-id <viewId>
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
ALLOWED_FORMATS = {"excel", "attachment", "excel_and_attachment", "excel_with_inline_images"}
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]:
cmd = [dws_bin] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return 124, "", f"dws command timeout after {timeout_sec}s"
except FileNotFoundError:
return 127, "", f"dws binary not found: {dws_bin}"
def parse_json_output(raw: str) -> Optional[Dict[str, Any]]:
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
return None
def normalize_download_url(url: str) -> str:
if url.startswith("http://") or url.startswith("https://"):
return url
return f"https://{url}"
def download_file(url: str, output_path: Path) -> Tuple[bool, str]:
req = Request(url, method="GET")
try:
with urlopen(req, timeout=180) as resp:
if resp.status != 200:
return False, f"download http status: {resp.status}"
output_path.write_bytes(resp.read())
return True, ""
except HTTPError as e:
body = e.read().decode("utf-8", "ignore")
return False, f"HTTP {e.code}: {body[:300]}"
except URLError as e:
return False, f"URL error: {e.reason}"
def fail(msg: str, code: int = 1) -> None:
print(f"错误:{msg}", file=sys.stderr)
sys.exit(code)
def build_start_args(args: argparse.Namespace) -> list[str]:
cmd = [
"aitable",
"export",
"data",
"--base-id",
args.base_id,
"--scope",
args.scope,
"--format",
args.export_format,
"--timeout-ms",
str(args.timeout_ms),
]
if args.table_id:
cmd.extend(["--table-id", args.table_id])
if args.view_id:
cmd.extend(["--view-id", args.view_id])
return cmd
def main() -> None:
parser = argparse.ArgumentParser(description="通过 MCP 导出任务导出 AI 表格")
parser.add_argument("base_id", help="目标 AI 表格 baseId")
parser.add_argument("--scope", choices=["all", "table", "view"], required=True, help="导出范围")
parser.add_argument("--table-id", help="scope=table/view 时必填")
parser.add_argument("--view-id", help="scope=view 时必填")
parser.add_argument("--export-format", default="excel", choices=sorted(ALLOWED_FORMATS), help="导出格式")
parser.add_argument("--timeout-ms", type=int, default=1000, help="单次等待毫秒数,默认 1000")
parser.add_argument("--poll-timeout-ms", type=int, default=3000, help="轮询等待毫秒数,默认 3000")
parser.add_argument("--max-polls", type=int, default=10, help="最大轮询次数,默认 10")
parser.add_argument("--output", help="本地保存路径(不传则按 fileName 保存到当前目录)")
parser.add_argument("--dws", default="dws", help="dws 可执行文件路径,默认 dws")
parser.add_argument("--no-download", action="store_true", help="仅返回 downloadUrl,不下载文件")
args = parser.parse_args()
if not validate_resource_id(args.base_id):
fail("无效的 baseId 格式")
if args.scope in ("table", "view") and not args.table_id:
fail("scope=table/view 时必须传 --table-id")
if args.scope == "view" and not args.view_id:
fail("scope=view 时必须传 --view-id")
print("[1/2] start export task", file=sys.stderr)
rc, out, err = run_dws(args.dws, build_start_args(args), timeout_sec=120)
if rc != 0:
fail(f"export_data 启动失败: {err or out}", rc)
obj = parse_json_output(out)
if not obj:
fail(f"export_data 返回非 JSON: {out[:300]}")
data = obj.get("data", {}) or {}
status = obj.get("status")
if status == "error":
fail(f"export_data 返回失败: {json.dumps(obj, ensure_ascii=False)}")
download_url = data.get("downloadUrl")
task_id = data.get("taskId")
file_name = data.get("fileName") or "export_result.bin"
polls = 0
while not download_url and task_id and polls < args.max_polls:
polls += 1
print(f"[2/2] polling task ({polls}/{args.max_polls})", file=sys.stderr)
rc2, out2, err2 = run_dws(
args.dws,
[
"aitable",
"export",
"data",
"--base-id",
args.base_id,
"--task-id",
task_id,
"--timeout-ms",
str(args.poll_timeout_ms),
],
timeout_sec=max(120, int(args.poll_timeout_ms / 1000) + 60),
)
if rc2 != 0:
fail(f"export_data 轮询失败: {err2 or out2}", rc2)
obj2 = parse_json_output(out2)
if not obj2:
fail(f"export_data 轮询返回非 JSON: {out2[:300]}")
if obj2.get("status") == "error":
fail(f"export_data 轮询返回失败: {json.dumps(obj2, ensure_ascii=False)}")
d2 = obj2.get("data", {}) or {}
download_url = d2.get("downloadUrl") or download_url
file_name = d2.get("fileName") or file_name
task_id = d2.get("taskId") or task_id
if not download_url:
time.sleep(0.2)
result: Dict[str, Any] = {
"baseId": args.base_id,
"scope": args.scope,
"exportFormat": args.export_format,
"taskId": task_id,
"fileName": file_name,
"downloadUrl": download_url,
"polledTimes": polls,
}
if not download_url:
result["status"] = "pending"
result["summary"] = "导出任务仍在处理中,请继续用 taskId 轮询。"
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(3)
if args.no_download:
result["status"] = "success"
result["summary"] = "导出完成(未下载文件)。"
print(json.dumps(result, ensure_ascii=False, indent=2))
return
norm_url = normalize_download_url(download_url)
output_path = Path(args.output).expanduser().resolve() if args.output else Path.cwd() / file_name
ok, dl_err = download_file(norm_url, output_path)
if not ok:
fail(f"downloadUrl 下载失败: {dl_err}")
result["status"] = "success"
result["summary"] = "导出完成并已下载。"
result["savedPath"] = str(output_path)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
通过 MCP 文件导入任务(prepare_import_upload -> PUT -> import_data)导入 AI 表格。
与 import_records.py 的区别:
- 本脚本:走“文件导入任务”链路,通常会新建导入数据表。
- import_records.py:走 create_records,写入已有 table。
用法:
python scripts/aitable_import_via_task.py <baseId> <filePath>
python scripts/aitable_import_via_task.py <baseId> <filePath> --timeout 30
python scripts/aitable_import_via_task.py <baseId> <filePath> --dws /tmp/dws
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
ALLOWED_EXTENSIONS = {".csv", ".xlsx", ".xls"}
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]:
cmd = [dws_bin] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return 124, "", f"dws command timeout after {timeout_sec}s"
except FileNotFoundError:
return 127, "", f"dws binary not found: {dws_bin}"
def parse_json_output(raw: str) -> Optional[Dict[str, Any]]:
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
return None
def put_file(upload_url: str, file_path: Path) -> Tuple[bool, str]:
payload = file_path.read_bytes()
req = Request(upload_url, data=payload, method="PUT")
# 关键:清空 Content-Type,避免 SignatureDoesNotMatch。
req.add_header("Content-Type", "")
try:
with urlopen(req, timeout=180) as resp:
if resp.status == 200:
return True, ""
return False, f"unexpected HTTP status: {resp.status}"
except HTTPError as e:
body = e.read().decode("utf-8", "ignore")
return False, f"HTTP {e.code}: {body[:300]}"
except URLError as e:
return False, f"URL error: {e.reason}"
def fail(msg: str, exit_code: int = 1) -> None:
print(f"错误:{msg}", file=sys.stderr)
sys.exit(exit_code)
def main() -> None:
parser = argparse.ArgumentParser(description="通过文件导入任务导入 AI 表格")
parser.add_argument("base_id", help="目标 AI 表格 baseId")
parser.add_argument("file_path", help="待导入文件路径(.csv/.xlsx/.xls")
parser.add_argument("--timeout", type=int, default=30, help="import_data 等待秒数,默认 30")
parser.add_argument("--dws", default="dws", help="dws 可执行文件路径,默认 dws")
args = parser.parse_args()
base_id = args.base_id.strip()
file_path = Path(args.file_path).expanduser().resolve()
if not validate_resource_id(base_id):
fail("无效的 baseId 格式")
if not file_path.exists() or not file_path.is_file():
fail(f"文件不存在或不可读: {file_path}")
if file_path.suffix.lower() not in ALLOWED_EXTENSIONS:
fail(f"仅支持 {sorted(ALLOWED_EXTENSIONS)},当前文件: {file_path.name}")
file_size = file_path.stat().st_size
if file_size <= 0:
fail("文件为空")
print(f"[1/3] prepare import upload: {file_path.name} ({file_size} bytes)", file=sys.stderr)
rc, out, err = run_dws(
args.dws,
[
"aitable",
"import",
"upload",
"--base-id",
base_id,
"--file-name",
file_path.name,
"--file-size",
str(file_size),
"--format",
"json",
],
)
if rc != 0:
fail(f"prepare_import_upload 失败: {err or out}", rc)
prepare_obj = parse_json_output(out)
if not prepare_obj:
fail(f"prepare_import_upload 返回非 JSON: {out[:300]}")
if prepare_obj.get("status") != "success":
fail(f"prepare_import_upload 返回失败: {json.dumps(prepare_obj, ensure_ascii=False)}")
pdata = prepare_obj.get("data") or {}
upload_url = pdata.get("uploadUrl")
import_id = pdata.get("importId")
if not upload_url or not import_id:
fail(f"prepare_import_upload 缺少 uploadUrl/importId: {json.dumps(pdata, ensure_ascii=False)}")
print("[2/3] upload file bytes via PUT", file=sys.stderr)
ok, put_err = put_file(upload_url, file_path)
if not ok:
fail(f"PUT 上传失败: {put_err}")
print("[3/3] trigger import_data", file=sys.stderr)
rc2, out2, err2 = run_dws(
args.dws,
[
"aitable",
"import",
"data",
"--import-id",
import_id,
"--timeout",
str(args.timeout),
"--format",
"json",
],
timeout_sec=max(120, args.timeout + 30),
)
if rc2 != 0:
fail(f"import_data 调用失败: {err2 or out2}", rc2)
import_obj = parse_json_output(out2)
if not import_obj:
fail(f"import_data 返回非 JSON: {out2[:300]}")
result = {
"baseId": base_id,
"fileName": file_path.name,
"fileSize": file_size,
"importId": import_id,
"status": import_obj.get("status"),
"summary": import_obj.get("summary"),
"data": import_obj.get("data", {}),
"error": import_obj.get("error", {}),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
if import_obj.get("status") != "success":
sys.exit(2)
if __name__ == "__main__":
main()
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""
批量添加字段到钉钉 AI 表格数据表(新版 schema)
用法:
python bulk_add_fields.py <baseId> <tableId> fields.json
fields.json 格式:
[
{"fieldName": "字段 1", "type": "text"},
{"fieldName": "字段 2", "type": "number", "config": {"formatter": "INT"}},
{"fieldName": "字段 3", "type": "singleSelect", "config": {"options": [{"name": ""}]}}
]
兼容写法:
- name 会自动映射为 fieldName
- phone 会自动映射为 telephone
"""
import sys
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]]
MAX_FILE_SIZE = 10 * 1024 * 1024
ALLOWED_FILE_EXTENSIONS = ['.json']
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
ALLOWED_FIELD_TYPES = {
'text', 'number', 'singleSelect', 'multipleSelect', 'date', 'currency',
'user', 'department', 'group', 'progress', 'rating', 'checkbox',
'attachment', 'url', 'richText', 'telephone', 'email', 'idCard',
'barcode', 'geolocation', 'address', 'primaryDoc', 'formula',
'unidirectionalLink', 'bidirectionalLink', 'lookup', 'filterUp',
'creator', 'lastModifier', 'createdTime', 'lastModifiedTime',
}
FIELD_TYPE_ALIASES = {
'phone': 'telephone',
}
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_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 normalize_field_config(field: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(field)
if 'fieldName' not in normalized and 'name' in normalized:
normalized['fieldName'] = normalized.pop('name')
normalized['type'] = FIELD_TYPE_ALIASES.get(
normalized.get('type', 'text'), normalized.get('type', 'text')
)
return normalized
def validate_field_config(field: Dict[str, Any]) -> Tuple[bool, str]:
if not isinstance(field, dict):
return False, '字段配置必须是对象'
field = normalize_field_config(field)
if 'fieldName' not in field:
return False, '缺少必需字段:fieldName'
if not isinstance(field['fieldName'], str) or not field['fieldName'].strip():
return False, 'fieldName 必须是非空字符串'
field_type = field.get('type', 'text')
if field_type not in ALLOWED_FIELD_TYPES:
return False, f"不支持的字段类型:{field_type}"
config = field.get('config')
if config is not None and not isinstance(config, dict):
return False, 'config 必须是对象'
if field_type in {'singleSelect', 'multipleSelect'}:
options = (config or {}).get('options')
if not options or not isinstance(options, list):
return False, (
'singleSelect / multipleSelect 必须提供 config.options 数组'
)
if field_type in {'unidirectionalLink', 'bidirectionalLink'}:
linked_table_id = (config or {}).get('linkedTableId')
if not linked_table_id or not validate_resource_id(linked_table_id):
return False, (
'关联字段必须提供合法的 config.linkedTableId(目标 Table ID'
)
if field_type == 'lookup':
cfg = config or {}
if not cfg.get('associateField'):
return False, 'lookup 必须提供 config.associateField(本表关联字段的 fieldId'
if not cfg.get('valuesField'):
return False, 'lookup 必须提供 config.valuesField(关联目标表中要取值的字段 fieldId)'
if not cfg.get('aggregator'):
return False, 'lookup 必须提供 config.aggregatorSUM/AVERAGE/COUNT/MAX/MIN/CONCATENATE'
if field_type == 'filterUp':
cfg = config or {}
if not cfg.get('targetSheet'):
return False, 'filterUp 必须提供 config.targetSheet(目标 Table ID'
filters = cfg.get('filters')
if not filters or not isinstance(filters, list):
return False, 'filterUp 必须提供 config.filters(至少一条筛选规则)'
if not cfg.get('valuesField'):
return False, 'filterUp 必须提供 config.valuesField(目标表中要取值的字段 fieldId)'
if not cfg.get('aggregator'):
return False, 'filterUp 必须提供 config.aggregatorSUM/AVERAGE/COUNT/MAX/MIN/CONCATENATE'
return True, ''
def build_fields_json(fields: List[Dict[str, Any]]) -> str:
"""构建 --fields 参数的 JSON 字符串。"""
payload_fields = []
for field in fields:
normalized = normalize_field_config(field)
item: Dict[str, Any] = {
'fieldName': normalized['fieldName'].strip(),
'type': normalized.get('type', 'text'),
}
if 'config' in normalized and normalized['config'] is not None:
item['config'] = normalized['config']
payload_fields.append(item)
return json.dumps(payload_fields, ensure_ascii=False)
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=60
)
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('错误:命令执行超时(60 秒)')
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装')
return None
def bulk_add_fields(
base_id: str, table_id: str, fields_file: str
) -> bool:
try:
safe_path = resolve_safe_path(fields_file)
except ValueError as e:
print(f"路径验证失败:{e}")
return False
if not validate_file_extension(fields_file, ALLOWED_FILE_EXTENSIONS):
print(f"错误:只允许 {', '.join(ALLOWED_FILE_EXTENSIONS)} 文件")
return False
if not safe_path.exists():
print(f"错误:文件不存在:{safe_path}")
return False
try:
fields = 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(fields, list) or not fields:
print('错误:fields.json 必须是非空 JSON 数组')
return False
if len(fields) > 15:
print('错误:单次最多创建 15 个字段,请拆分后重试')
return False
for i, field in enumerate(fields):
valid, error = validate_field_config(field)
if not valid:
print(f"错误:字段 #{i+1} 配置无效:{error}")
return False
fields_json = build_fields_json(fields)
result = run_dws([
'aitable', 'field', 'create',
'--base-id', base_id,
'--table-id', table_id,
'--fields', fields_json,
'--format', 'json',
])
if not result:
return False
print(json.dumps(result, ensure_ascii=False, indent=2))
return True
def main():
if len(sys.argv) != 4:
print(__doc__)
print('用法示例:')
print(' python bulk_add_fields.py basexxx tablexxx fields.json')
sys.exit(1)
base_id = sys.argv[1]
table_id = sys.argv[2]
fields_file = sys.argv[3]
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式')
sys.exit(1)
if not validate_resource_id(table_id):
print('错误:无效的 tableId 格式')
sys.exit(1)
success = bulk_add_fields(base_id, table_id, fields_file)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
@@ -0,0 +1,333 @@
#!/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
⚠️ CSV 自动类型转换风险:
CSV 读入的所有 cell 都是 string,本脚本会尝试自动识别 'true'/'false'/数字
并转成对应类型(避免 text 字段塞入纯文本数字)。但当 fieldId 对应的字段是
text / telephone / idCard / barcode 这类"字符串形数字"字段时,自动转 int / float
会让 server 拒绝(字段类型不匹配)。这种情况建议改用 JSON 格式(自己显式控制类型),
或在 CSV 写入前给字段值前缀加引号 / 改为非纯数字。
"""
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]) -> 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()
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
上传附件到钉钉 AI 表格 attachment 字段
完整流程(内部自动执行 3 步):
1. dws aitable attachment upload → 获取 uploadUrl + fileToken
2. HTTP PUT 上传文件到 OSS
3. 返回 fileToken,可直接用于 record create/update
用法:
python upload_attachment.py <baseId> <filePath>
输出 (JSON):
{ "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
然后在 record create/update 中使用:
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
"""
import sys
import json
import subprocess
import os
import mimetypes
import re
from pathlib import Path
from typing import Optional, Dict, Any
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def detect_mime_type(file_path: Path) -> str:
"""根据文件扩展名推断 MIME type。"""
mime_type, _ = mimetypes.guess_type(str(file_path))
return mime_type or 'application/octet-stream'
def run_dws(args: list) -> Optional[Dict[str, Any]]:
"""调用 dws 命令并返回解析后的 JSON 结果。"""
cmd = ['dws'] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
print(f"错误:dws 命令失败: {result.stderr.strip()}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
print(f"错误:无法解析 dws 响应: {result.stdout[:300]}", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print('错误:dws 命令超时(60 秒)', file=sys.stderr)
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装并在 PATH 中', file=sys.stderr)
return None
def upload_to_oss(upload_url: str, file_path: Path, mime_type: str) -> bool:
"""通过 HTTP PUT 上传文件到 OSS。"""
file_data = file_path.read_bytes()
req = Request(upload_url, data=file_data, method='PUT')
req.add_header('Content-Type', mime_type)
try:
with urlopen(req, timeout=120) as resp:
if resp.status == 200:
return True
print(f"错误:OSS 上传失败,HTTP {resp.status}", file=sys.stderr)
return False
except HTTPError as e:
print(f"错误:OSS 上传 HTTP 错误 {e.code}: {e.reason}", file=sys.stderr)
return False
except URLError as e:
print(f"错误:OSS 上传网络错误: {e.reason}", file=sys.stderr)
return False
def upload_attachment(base_id: str, file_path_str: str) -> Optional[Dict[str, Any]]:
"""
执行完整的附件上传流程:
1. prepare_attachment_upload → uploadUrl + fileToken
2. PUT 文件到 OSS
3. 返回 fileToken 信息
"""
# 验证文件
file_path = Path(file_path_str).resolve()
if not file_path.exists():
print(f"错误:文件不存在: {file_path}", file=sys.stderr)
return None
if not file_path.is_file():
print(f"错误:不是文件: {file_path}", file=sys.stderr)
return None
file_size = file_path.stat().st_size
if file_size <= 0:
print("错误:文件为空", file=sys.stderr)
return None
if file_size > MAX_FILE_SIZE:
print(f"错误:文件过大 ({file_size:,} 字节,限制 {MAX_FILE_SIZE:,} 字节)", file=sys.stderr)
return None
file_name = file_path.name
mime_type = detect_mime_type(file_path)
# 步骤 1: prepare_attachment_upload
print(f"步骤 1/3: 准备上传 {file_name} ({file_size:,} 字节, {mime_type})...", file=sys.stderr)
dws_args = [
'aitable', 'attachment', 'upload',
'--base-id', base_id,
'--file-name', file_name,
'--size', str(file_size),
'--mime-type', mime_type,
'--format', 'json',
]
result = run_dws(dws_args)
if not result:
return None
status = result.get('status', '')
if status != 'success':
error = result.get('error', {})
print(f"错误:准备上传失败: {error.get('message', json.dumps(error, ensure_ascii=False))}", file=sys.stderr)
return None
data = result.get('data', {})
upload_url = data.get('uploadUrl', '')
file_token = data.get('fileToken', '')
if not upload_url or not file_token:
print(f"错误:返回数据缺少 uploadUrl 或 fileToken: {json.dumps(data, ensure_ascii=False)}", file=sys.stderr)
return None
# 步骤 2: PUT 文件到 OSS
print(f"步骤 2/3: 上传文件到 OSS...", file=sys.stderr)
if not upload_to_oss(upload_url, file_path, mime_type):
return None
# 步骤 3: 返回 fileToken
print(f"步骤 3/3: 上传完成!", file=sys.stderr)
output = {
"fileToken": file_token,
"fileName": file_name,
"size": file_size,
"mimeType": mime_type,
}
return output
def main():
if len(sys.argv) != 3:
print(__doc__)
print('用法:')
print(' python upload_attachment.py <baseId> <filePath>')
print()
print('示例:')
print(' python upload_attachment.py G1DKw2zgV2bEk6PMSBooNxlEVB5r9YAn ./report.pdf')
print()
print('然后在 record create 中使用返回的 fileToken:')
print(' dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \\')
print(' --records \'[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]\' --format json')
sys.exit(1)
base_id = sys.argv[1]
file_path = sys.argv[2]
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式', file=sys.stderr)
sys.exit(1)
result = upload_attachment(base_id, file_path)
if result is None:
sys.exit(1)
# 正常输出到 stdout(JSON 格式,方便解析)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
if __name__ == '__main__':
main()