first commit
This commit is contained in:
@@ -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.aggregator(SUM/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.aggregator(SUM/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()
|
||||
Reference in New Issue
Block a user