Files
2026-09-02 11:44:52 +08:00

211 lines
7.6 KiB
Python

#!/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()