Files
dataease/scripts/capture_dashboard.py
T
2026-08-09 00:36:02 +00:00

1131 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import argparse
import base64
import hashlib
import hmac
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from difflib import SequenceMatcher
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
ROOT_DIR = Path(__file__).resolve().parent.parent
DEFAULT_ALIAS_FILE = ROOT_DIR / "references" / "resource_aliases.json"
BROWSER_CAPTURE_SCRIPT = ROOT_DIR / "scripts" / "browser_capture.mjs"
RSA_KEY_SEPARATOR = base64.urlsafe_b64encode(b"-pk_separator-").decode("ascii")
class ApiError(Exception):
def __init__(self, message, method, url, status_code=None, body=""):
super().__init__(message)
self.method = method
self.url = url
self.status_code = status_code
self.body = body
def print_json(data, code):
print(json.dumps(data, ensure_ascii=False, indent=2))
sys.exit(code)
def error_to_dict(stage, err, extra=None):
payload = {
"ok": False,
"stage": stage,
"error": str(err),
}
if isinstance(err, ApiError):
payload.update({
"method": err.method,
"url": err.url,
"status_code": err.status_code,
"response_body": err.body,
})
if extra:
payload.update(extra)
return payload
def load_dotenv(path):
if not path.exists():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if not key or key in os.environ:
continue
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
os.environ[key] = value
def normalize(text):
text = (text or "").strip().lower()
text = text.replace("“", '"').replace("”", '"').replace("", "'").replace("", "'")
text = re.sub(r"\s+", "", text)
return re.sub(r"[()()【】\[\]{}·,,。.:!?'\"-_/\\]", "", text)
def load_aliases(path):
alias_file = Path(path)
if not alias_file.exists():
return {}
with alias_file.open("r", encoding="utf-8") as handle:
return json.load(handle)
def flatten_tree(nodes, result=None):
result = result or []
for node in nodes or []:
result.append({
"id": node.get("id"),
"name": node.get("name", ""),
"leaf": bool(node.get("leaf", False)),
"type": node.get("type"),
})
flatten_tree(node.get("children") or [], result)
return result
def flatten_org_tree(nodes, result=None):
result = result or []
for node in nodes or []:
children = node.get("children") or []
result.append({
"id": node.get("id"),
"name": node.get("name", ""),
"create_time": node.get("createTime"),
"read_only": node.get("readOnly"),
"leaf": len(children) == 0,
})
flatten_org_tree(children, result)
return result
def extract_response_data(payload, stage):
if isinstance(payload, dict):
if payload.get("code") not in (None, 0):
raise ValueError(f"{stage}接口返回失败: code={payload.get('code')}, msg={payload.get('msg')}")
return payload.get("data")
return payload
def extract_tree_nodes(payload):
data = extract_response_data(payload, "资源树")
if isinstance(data, list):
return data
raise ValueError("资源树接口返回格式不符合预期,未找到 data 列表")
def base64url(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def sign_jwt(payload, secret_key):
header = {"alg": "HS256", "typ": "JWT"}
header_part = base64url(json.dumps(header, separators=(",", ":")).encode("utf-8"))
payload_part = base64url(json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
signing_input = f"{header_part}.{payload_part}".encode("ascii")
signature = hmac.new(secret_key.encode("utf-8"), signing_input, hashlib.sha256).digest()
return f"{header_part}.{payload_part}.{base64url(signature)}"
def aes_cipher_name(secret_key):
length = len(secret_key.encode("utf-8"))
if length == 16:
return "aes-128-cbc"
if length == 24:
return "aes-192-cbc"
if length == 32:
return "aes-256-cbc"
raise ValueError("Secret Key 长度必须是 16、24 或 32 字节")
def aes_encrypt(plain_text, secret_key, iv):
if shutil.which("openssl") is None:
raise RuntimeError("当前环境缺少 openssl 命令,无法生成鉴权签名")
if len(iv.encode("utf-8")) != 16:
raise ValueError("Access Key 长度必须是 16 字节,才能作为 AES IV")
cmd = [
"openssl",
"enc",
f"-{aes_cipher_name(secret_key)}",
"-base64",
"-A",
"-nosalt",
"-K",
secret_key.encode("utf-8").hex(),
"-iv",
iv.encode("utf-8").hex(),
]
proc = subprocess.run(cmd, input=plain_text.encode("utf-8"), capture_output=True, check=False)
if proc.returncode != 0:
stderr = proc.stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(stderr or "openssl 加密失败")
return proc.stdout.decode("utf-8").strip()
def build_ask_auth(access_key, secret_key):
source = f"{access_key}|{uuid.uuid4()}|{int(time.time() * 1000)}"
signature = aes_encrypt(source, secret_key, access_key)
token = sign_jwt({"accessKey": access_key, "signature": signature}, secret_key)
return {
"access_key": access_key,
"signature": signature,
"x_de_ask_token": token,
}
def build_headers(ask_auth):
return {
"Accept": "application/json;charset=UTF-8",
"Content-Type": "application/json",
"accessKey": ask_auth["access_key"],
"signature": ask_auth["signature"],
"X-DE-ASK-TOKEN": ask_auth["x_de_ask_token"],
}
def build_switch_headers(ask_auth):
return {
"Accept": "application/json;charset=UTF-8",
"Content-Type": "application/json",
"X-DE-ASK-TOKEN": ask_auth["x_de_ask_token"],
}
def build_token_headers(x_de_token):
return {
"Accept": "application/json;charset=UTF-8",
"Content-Type": "application/json",
"X-DE-TOKEN": x_de_token,
}
def get_header(headers, name, default=None):
target = name.lower()
for key, value in headers.items():
if key.lower() == target:
return value
return default
def post_json(url, payload, headers, timeout=60):
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
forwarded_headers = dict(headers)
forwarded_headers.setdefault("X-Forwarded-Uri", urlparse(url).path)
forwarded_headers.setdefault("X-Forwarded-Method", "POST")
request = Request(url, data=data, headers=forwarded_headers, method="POST")
try:
with urlopen(request, timeout=timeout) as response:
return response.status, dict(response.headers), response.read()
except HTTPError as err:
body = err.read().decode("utf-8", errors="replace")
raise ApiError(str(err), "POST", url, err.code, body)
except URLError as err:
raise ApiError(str(err), "POST", url, None, "")
def get_bytes(url, headers=None, timeout=60):
request = Request(url, headers=dict(headers or {}), method="GET")
try:
with urlopen(request, timeout=timeout) as response:
return response.status, dict(response.headers), response.read()
except HTTPError as err:
body = err.read().decode("utf-8", errors="replace")
raise ApiError(str(err), "GET", url, err.code, body)
except URLError as err:
raise ApiError(str(err), "GET", url, None, "")
def request_with_fallback(base_url, method, paths, payload=None, headers=None, timeout=60):
last_error = None
for path in paths:
url = f"{base_url.rstrip('/')}{path}"
try:
if method == "GET":
return get_bytes(url, headers=headers, timeout=timeout)
if method == "POST":
return post_json(url, payload, headers or {}, timeout=timeout)
raise ValueError(f"不支持的请求方法: {method}")
except ApiError as err:
last_error = err
if err.status_code not in (404, None):
raise
if last_error:
raise last_error
raise ValueError("未提供可用的请求路径")
def fetch_dekey(base_url):
_, _, body = request_with_fallback(
base_url,
"GET",
["/de2api/dekey", "/dekey"],
headers={"Accept": "application/json;charset=UTF-8"},
timeout=60,
)
payload = json.loads(body.decode("utf-8"))
data = extract_response_data(payload, "dekey")
if not isinstance(data, str) or not data:
raise ValueError("dekey 接口未返回有效字符串")
return data
def aes_decrypt(cipher_text, secret_key):
if shutil.which("openssl") is None:
raise RuntimeError("当前环境缺少 openssl 命令,无法执行账号密码登录加密")
secret_key_bytes = secret_key.encode("utf-8")
if len(secret_key_bytes) not in (16, 24, 32):
raise ValueError("dekey 中的 AES key 长度不合法")
cmd = [
"openssl",
"enc",
f"-{aes_cipher_name(secret_key)}",
"-d",
"-base64",
"-A",
"-nosalt",
"-K",
secret_key_bytes.hex(),
"-iv",
b"0000000000000000".hex(),
]
proc = subprocess.run(cmd, input=cipher_text.encode("utf-8"), capture_output=True, check=False)
if proc.returncode != 0:
stderr = proc.stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(stderr or "openssl 解密 dekey 失败")
return proc.stdout.decode("utf-8")
def split_dekey(dekey):
for separator in (RSA_KEY_SEPARATOR, RSA_KEY_SEPARATOR.rstrip("=")):
if separator and separator in dekey:
parts = dekey.split(separator, 1)
if len(parts) == 2 and parts[0] and parts[1]:
return parts[0], parts[1]
raise ValueError("dekey 格式不符合预期,无法解析公钥信息")
def format_public_key(public_key):
body = "\n".join(public_key[index:index + 64] for index in range(0, len(public_key), 64))
return f"-----BEGIN PUBLIC KEY-----\n{body}\n-----END PUBLIC KEY-----\n"
def rsa_encrypt(plain_text, public_key):
if shutil.which("openssl") is None:
raise RuntimeError("当前环境缺少 openssl 命令,无法执行账号密码登录加密")
with tempfile.TemporaryDirectory(prefix="dataease-pubkey-") as tmpdir:
key_path = Path(tmpdir) / "public.pem"
key_path.write_text(format_public_key(public_key), encoding="utf-8")
proc = subprocess.run(
["openssl", "pkeyutl", "-encrypt", "-pubin", "-inkey", str(key_path)],
input=plain_text.encode("utf-8"),
capture_output=True,
check=False,
)
if proc.returncode != 0:
stderr = proc.stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(stderr or "openssl RSA 加密失败")
return base64.b64encode(proc.stdout).decode("ascii")
def encrypt_login_field(value, dekey):
encrypted_public_key, aes_key = split_dekey(dekey)
public_key = aes_decrypt(encrypted_public_key, aes_key).strip()
if not public_key:
raise ValueError("dekey 解密后未得到有效公钥")
return rsa_encrypt(value, public_key)
def login_with_password(base_url, username, password, login_origin):
dekey = fetch_dekey(base_url)
payload = {
"name": encrypt_login_field(username, dekey),
"pwd": encrypt_login_field(password, dekey),
"origin": int(login_origin),
}
_, _, body = request_with_fallback(
base_url,
"POST",
["/de2api/login/localLogin", "/login/localLogin"],
payload=payload,
headers={"Accept": "application/json;charset=UTF-8", "Content-Type": "application/json"},
timeout=60,
)
result = json.loads(body.decode("utf-8"))
data = extract_response_data(result, "账号密码登录")
if not isinstance(data, dict):
raise ValueError("账号密码登录接口返回格式不符合预期")
mfa = data.get("mfa") or {}
if isinstance(mfa, dict) and mfa.get("enabled"):
raise ValueError("当前账号开启了 MFAskill 暂不支持 MFA 登录")
if data.get("invalidPwd"):
raise ValueError("当前账号需要修改无效密码后才能继续登录")
token = data.get("token")
if not token:
raise ValueError("账号密码登录成功,但接口未返回 token")
return {
"auth_mode": "password",
"x_de_token": token,
"token_exp": data.get("exp"),
"login_origin": int(login_origin),
}
def exchange_de_token(base_url, ask_auth, target_path, payload=None, target_method="POST"):
url = f"{base_url.rstrip('/')}/de2api/apisix/check"
headers = build_headers(ask_auth)
headers["X-Forwarded-Uri"] = target_path
headers["X-Forwarded-Method"] = target_method
_, response_headers, _ = post_json(url, payload, headers, timeout=60)
x_de_token = get_header(response_headers, "X-DE-TOKEN")
if not x_de_token:
raise ValueError("apisix/check 未返回 X-DE-TOKEN")
return x_de_token
def query_org_tree(base_url, headers, keyword="", desc=True):
url = f"{base_url.rstrip('/')}/de2api/org/page/tree"
_, _, body = post_json(url, {"keyword": keyword, "desc": bool(desc)}, headers, timeout=60)
return json.loads(body.decode("utf-8"))
def switch_organization(base_url, headers, org_id):
url = f"{base_url.rstrip('/')}/de2api/user/switch/{org_id}"
_, _, body = post_json(url, None, headers, timeout=60)
return json.loads(body.decode("utf-8"))
def query_resource_tree(base_url, headers, busi_type, resource_table):
url = f"{base_url.rstrip('/')}/de2api/dataVisualization/tree"
_, _, body = post_json(
url,
{"busiFlag": busi_type, "resourceTable": resource_table},
headers,
timeout=60,
)
return json.loads(body.decode("utf-8"))
def parse_pixel(pixel_text):
parts = [part.strip() for part in (pixel_text or "").split("*", 1)]
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValueError("pixel 格式必须是 宽*高,例如 1920*1080")
try:
width = int(parts[0])
height = int(parts[1])
except ValueError as err:
raise ValueError("pixel 宽高必须是整数") from err
if width <= 0 or height <= 0:
raise ValueError("pixel 宽高必须大于 0")
return width, height
def build_preview_url(base_url, resource_id, busi_type):
url = f"{base_url.rstrip('/')}/#/preview?dvId={resource_id}&dvType={busi_type}"
if (busi_type or "").lower() == "dashboard":
url += "&report=true"
return url
def resolve_capture_token(args, auth_context, target_path, target_payload=None):
request_mode = resolve_request_mode(args)
if getattr(args, "x_de_token", ""):
return args.x_de_token, {
"used_x_de_token": True,
"used_org_id": "",
"token_source": "user_supplied",
"auth_mode": "token",
"request_mode": request_mode,
}
if auth_context.get("auth_mode") == "password":
base_token = auth_context["x_de_token"]
if getattr(args, "org_id", ""):
switch_result = switch_organization(args.base_url, build_token_headers(base_token), args.org_id)
switch_data = extract_response_data(switch_result, "切换组织")
x_de_token = switch_data.get("token") if isinstance(switch_data, dict) else None
if not x_de_token:
raise ValueError("切换组织接口未返回 data.token")
return x_de_token, {
"used_x_de_token": True,
"used_org_id": str(args.org_id),
"token_exp": switch_data.get("exp"),
"token_source": "switched_org",
"auth_mode": "password",
"request_mode": request_mode,
}
return base_token, {
"used_x_de_token": True,
"used_org_id": "",
"token_exp": auth_context.get("token_exp"),
"token_source": "password_login",
"auth_mode": "password",
"request_mode": request_mode,
}
if getattr(args, "org_id", ""):
if request_mode == "gateway":
switch_headers = build_headers(auth_context)
else:
de_token = exchange_de_token(args.base_url, auth_context, f"/de2api/user/switch/{args.org_id}")
switch_headers = build_token_headers(de_token)
switch_result = switch_organization(args.base_url, switch_headers, args.org_id)
switch_data = extract_response_data(switch_result, "切换组织")
x_de_token = switch_data.get("token") if isinstance(switch_data, dict) else None
if not x_de_token:
raise ValueError("切换组织接口未返回 data.token")
return x_de_token, {
"used_x_de_token": True,
"used_org_id": str(args.org_id),
"token_exp": switch_data.get("exp"),
"token_source": "switched_org",
"auth_mode": "ask_token",
"request_mode": request_mode,
}
x_de_token = exchange_de_token(args.base_url, auth_context, target_path, target_payload)
return x_de_token, {
"used_x_de_token": True,
"used_org_id": "",
"token_source": "apisix_check",
"auth_mode": "ask_token",
"request_mode": request_mode,
}
# fixed by xuhuanqing
'''
def run_browser_capture(preview_url, x_de_token, pixel, ext_wait_time, result_format, output_path):
if shutil.which("node") is None:
raise RuntimeError("当前环境缺少 node 命令,无法执行本地浏览器截图")
if not BROWSER_CAPTURE_SCRIPT.exists():
raise RuntimeError(f"未找到浏览器截图脚本: {BROWSER_CAPTURE_SCRIPT}")
width, height = parse_pixel(pixel)
cmd = [
"node",
str(BROWSER_CAPTURE_SCRIPT),
"--url",
preview_url,
"--token",
x_de_token,
"--width",
str(width),
"--height",
str(height),
"--wait-seconds",
str(ext_wait_time),
"--result-format",
str(result_format),
"--output",
str(output_path),
]
proc = subprocess.run(
cmd,
cwd=str(ROOT_DIR),
capture_output=True,
text=True,
check=False,
)
stdout = proc.stdout.strip()
stderr = proc.stderr.strip()
if proc.returncode != 0:
detail = stderr or stdout or "浏览器截图失败"
raise RuntimeError(detail)
if not output_path.exists():
raise RuntimeError("浏览器截图命令执行成功,但未生成输出文件")
if stdout:
try:
return json.loads(stdout)
except json.JSONDecodeError:
return {"raw_output": stdout}
return {}
'''
def run_browser_capture(preview_url, x_de_token, pixel, ext_wait_time, result_format, output_path):
if shutil.which("node") is None:
raise RuntimeError("当前环境缺少 node 命令,无法执行本地浏览器截图")
if not BROWSER_CAPTURE_SCRIPT.exists():
raise RuntimeError(f"未找到浏览器截图脚本: {BROWSER_CAPTURE_SCRIPT}")
width, height = parse_pixel(pixel)
cmd = [
"node",
str(BROWSER_CAPTURE_SCRIPT),
"--url",
preview_url,
"--token",
x_de_token,
"--width",
str(width),
"--height",
str(height),
"--wait-seconds",
str(ext_wait_time),
"--result-format",
str(result_format),
"--output",
str(output_path),
]
# 移除 text=True,原始bytes捕获,规避GBK编码崩溃
proc = subprocess.run(
cmd,
cwd=str(ROOT_DIR),
capture_output=True,
check=False,
)
# 容错解码stdout
try:
stdout = proc.stdout.decode("utf-8", errors="replace").strip()
except Exception:
stdout = ""
# 容错解码stderr
try:
stderr = proc.stderr.decode("utf-8", errors="replace").strip()
except Exception:
stderr = ""
if proc.returncode != 0:
detail = stderr or stdout or "浏览器截图失败"
raise RuntimeError(detail)
if not output_path.exists():
raise RuntimeError("浏览器截图命令执行成功,但未生成输出文件")
if stdout:
try:
return json.loads(stdout)
except json.JSONDecodeError:
return {"raw_output": stdout}
return {}
def score_resource(item, query):
name = item.get("name", "")
name_norm = normalize(name)
query_norm = normalize(query)
score = SequenceMatcher(None, query_norm, name_norm).ratio()
if query_norm == name_norm:
score += 1.0
elif query_norm and query_norm in name_norm:
score += 0.25
if item.get("leaf"):
score += 0.05
return score
def search_resources(resources, query, top_n=20):
scored = []
for item in resources:
if not item.get("name"):
continue
scored.append((score_resource(item, query), item))
scored.sort(key=lambda item: item[0], reverse=True)
return scored[:top_n]
def resolve_resource(resources, query, alias_file, min_score):
aliases = load_aliases(alias_file)
resolved_query = aliases.get(query, query)
leaf_resources = [item for item in resources if item.get("leaf")]
query_norm = normalize(resolved_query)
exact_matches = [
item for item in leaf_resources
if normalize(item.get("name")) == query_norm
]
if len(exact_matches) == 1:
return {
"ok": True,
"resolved_query": resolved_query,
"resource": exact_matches[0],
"candidates": [{"score": 2.05, **exact_matches[0]}],
}, 0
if len(exact_matches) > 1:
return {
"ok": False,
"stage": "match",
"error": "存在多个同名资源,无法唯一确定导出目标",
"query": resolved_query,
"candidates": [{"score": 2.05, **item} for item in exact_matches],
}, 2
candidates = search_resources(leaf_resources, resolved_query, top_n=5)
if not candidates:
return {
"ok": False,
"stage": "match",
"error": "资源树中没有找到任何候选资源",
"query": resolved_query,
}, 2
candidate_payload = [
{
"score": round(score, 4),
"id": item.get("id"),
"name": item.get("name"),
"leaf": item.get("leaf"),
"type": item.get("type"),
}
for score, item in candidates
]
best_score, best_item = candidates[0]
second_score = candidates[1][0] if len(candidates) > 1 else None
if best_score < min_score:
return {
"ok": False,
"stage": "match",
"error": "没有找到足够可信的匹配结果",
"query": resolved_query,
"candidates": candidate_payload,
}, 2
if second_score is not None and abs(best_score - second_score) < 0.08:
return {
"ok": False,
"stage": "match",
"error": "存在多个相似资源,无法安全猜测导出目标",
"query": resolved_query,
"candidates": candidate_payload,
}, 2
return {
"ok": True,
"resolved_query": resolved_query,
"resource": best_item,
"candidates": candidate_payload,
}, 0
def guess_extension(result_format, content_type):
content_type = (content_type or "").lower()
if result_format == 1 or "pdf" in content_type:
return ".pdf"
return ".jpg"
def load_auth(args):
if not args.base_url:
print_json({
"ok": False,
"stage": "config",
"error": "缺少必需配置,请通过命令行参数、系统环境变量或 .env 提供",
"missing": ["DATAEASE_BASE_URL"],
}, 1)
if getattr(args, "x_de_token", ""):
return {"auth_mode": "token"}
has_password_auth = bool(args.username or args.password)
if has_password_auth:
missing = []
if not args.username:
missing.append("DATAEASE_USERNAME")
if not args.password:
missing.append("DATAEASE_PASSWORD")
if missing:
print_json({
"ok": False,
"stage": "config",
"error": "用户名密码登录配置不完整,请同时提供用户名和密码",
"missing": missing,
}, 1)
try:
return login_with_password(args.base_url, args.username.strip(), args.password, args.login_origin)
except Exception as err:
print_json({"ok": False, "stage": "auth", "error": str(err)}, 1)
missing = []
if not args.access_key:
missing.append("DATAEASE_ACCESS_KEY")
if not args.secret_key:
missing.append("DATAEASE_SECRET_KEY")
if missing:
missing.extend(["DATAEASE_USERNAME", "DATAEASE_PASSWORD"])
print_json({
"ok": False,
"stage": "config",
"error": "缺少鉴权配置,请提供 accessKey/secretKey 或 username/password",
"missing": missing,
}, 1)
try:
ask_auth = build_ask_auth(args.access_key, args.secret_key)
ask_auth["auth_mode"] = "ask_token"
return ask_auth
except Exception as err:
print_json({"ok": False, "stage": "auth", "error": str(err)}, 1)
def add_common_auth_args(parser):
parser.add_argument("--base-url", default=os.getenv("DATAEASE_BASE_URL", ""))
parser.add_argument("--access-key", default=os.getenv("DATAEASE_ACCESS_KEY", ""))
parser.add_argument("--secret-key", default=os.getenv("DATAEASE_SECRET_KEY", ""))
parser.add_argument("--username", default=os.getenv("DATAEASE_USERNAME", ""))
parser.add_argument("--password", default=os.getenv("DATAEASE_PASSWORD", ""))
parser.add_argument("--login-origin", type=int, default=int(os.getenv("DATAEASE_LOGIN_ORIGIN", "0")))
parser.add_argument("--request-mode", default=os.getenv("DATAEASE_REQUEST_MODE", "auto"), choices=["auto", "gateway", "backend"])
def add_runtime_args(parser):
parser.add_argument("--org-id", default="")
parser.add_argument("--x-de-token", default="")
def add_resource_tree_args(parser):
add_runtime_args(parser)
parser.add_argument("--busi-type", default="dashboard", choices=["dashboard", "dataV"])
parser.add_argument("--resource-table", default="core")
def build_parser():
parser = argparse.ArgumentParser(description="查询 DataEase 组织、资源并导出截图或 PDF")
subparsers = parser.add_subparsers(dest="command")
list_orgs = subparsers.add_parser("list-orgs", help="查询组织树")
add_common_auth_args(list_orgs)
list_orgs.add_argument("--org-keyword", default="")
switch_org = subparsers.add_parser("switch-org", help="切换组织并返回 x-de-token")
add_common_auth_args(switch_org)
switch_org.add_argument("--org-id", required=True)
list_resources = subparsers.add_parser("list-resources", help="查询组织下的仪表板或大屏列表")
add_common_auth_args(list_resources)
add_resource_tree_args(list_resources)
list_resources.add_argument("--resource-name", default="")
list_resources.add_argument("--limit", type=int, default=100)
list_resources.add_argument("--alias-file", default=str(DEFAULT_ALIAS_FILE))
capture = subparsers.add_parser("capture", help="导出截图或 PDF")
add_common_auth_args(capture)
add_resource_tree_args(capture)
name_or_id = capture.add_mutually_exclusive_group(required=True)
name_or_id.add_argument("--resource-name")
name_or_id.add_argument("--resource-id")
capture.add_argument("--alias-file", default=str(DEFAULT_ALIAS_FILE))
capture.add_argument("--min-score", type=float, default=0.55)
capture.add_argument("--pixel", default="1920*1080")
capture.add_argument("--ext-wait-time", type=int, default=0)
capture.add_argument("--result-format", type=int, default=0, choices=[0, 1])
capture.add_argument("--output-dir", default="outputs")
return parser
def parse_args():
parser = build_parser()
argv = sys.argv[1:]
commands = {"list-orgs", "switch-org", "list-resources", "capture"}
if not argv:
parser.print_help()
parser.exit(0)
if argv[0] not in commands and argv[0] not in {"-h", "--help"}:
argv = ["capture"] + argv
return parser.parse_args(argv)
def infer_request_mode(base_url):
port = urlparse(base_url).port
if port == 8100:
return "backend"
return "gateway"
def resolve_request_mode(args):
if getattr(args, "request_mode", "auto") != "auto":
return args.request_mode
return infer_request_mode(args.base_url)
def resolve_runtime_headers(args, auth_context, target_path, target_payload=None):
request_mode = resolve_request_mode(args)
if getattr(args, "x_de_token", ""):
return build_token_headers(args.x_de_token), {
"used_x_de_token": True,
"used_org_id": "",
"token_source": "user_supplied",
"auth_mode": "token",
"request_mode": request_mode,
}
if auth_context.get("auth_mode") == "password":
base_token = auth_context["x_de_token"]
if getattr(args, "org_id", ""):
switch_result = switch_organization(args.base_url, build_token_headers(base_token), args.org_id)
switch_data = extract_response_data(switch_result, "切换组织")
x_de_token = switch_data.get("token") if isinstance(switch_data, dict) else None
if not x_de_token:
raise ValueError("切换组织接口未返回 data.token")
return build_token_headers(x_de_token), {
"used_x_de_token": True,
"used_org_id": str(args.org_id),
"token_exp": switch_data.get("exp"),
"token_source": "switched_org",
"auth_mode": "password",
"request_mode": request_mode,
}
return build_token_headers(base_token), {
"used_x_de_token": True,
"used_org_id": "",
"token_exp": auth_context.get("token_exp"),
"token_source": "password_login",
"auth_mode": "password",
"request_mode": request_mode,
}
if request_mode == "gateway":
if getattr(args, "org_id", ""):
switch_result = switch_organization(args.base_url, build_headers(auth_context), args.org_id)
switch_data = extract_response_data(switch_result, "切换组织")
x_de_token = switch_data.get("token") if isinstance(switch_data, dict) else None
if not x_de_token:
raise ValueError("切换组织接口未返回 data.token")
return build_token_headers(x_de_token), {
"used_x_de_token": True,
"used_org_id": str(args.org_id),
"token_exp": switch_data.get("exp"),
"token_source": "switched_org",
"auth_mode": "ask_token",
"request_mode": request_mode,
}
return build_headers(auth_context), {
"used_x_de_token": False,
"used_org_id": "",
"token_source": "ask_token",
"auth_mode": "ask_token",
"request_mode": request_mode,
}
if getattr(args, "org_id", ""):
de_token = exchange_de_token(args.base_url, auth_context, f"/de2api/user/switch/{args.org_id}")
switch_result = switch_organization(args.base_url, build_token_headers(de_token), args.org_id)
switch_data = extract_response_data(switch_result, "切换组织")
x_de_token = switch_data.get("token") if isinstance(switch_data, dict) else None
if not x_de_token:
raise ValueError("切换组织接口未返回 data.token")
return build_token_headers(x_de_token), {
"used_x_de_token": True,
"used_org_id": str(args.org_id),
"token_exp": switch_data.get("exp"),
"token_source": "switched_org",
"auth_mode": "ask_token",
"request_mode": request_mode,
}
de_token = exchange_de_token(args.base_url, auth_context, target_path, target_payload)
return build_token_headers(de_token), {
"used_x_de_token": True,
"used_org_id": "",
"token_source": "apisix_check",
"auth_mode": "ask_token",
"request_mode": request_mode,
}
def command_list_orgs(args, auth_context):
try:
request_payload = {"keyword": args.org_keyword, "desc": True}
headers, runtime_info = resolve_runtime_headers(args, auth_context, "/de2api/org/page/tree", request_payload)
org_tree = query_org_tree(args.base_url, headers, args.org_keyword)
org_data = extract_response_data(org_tree, "组织树")
organizations = flatten_org_tree(org_data)
print_json({
"ok": True,
"stage": "org_tree",
"org_keyword": args.org_keyword,
"organizations": organizations,
"total": len(organizations),
**runtime_info,
}, 0)
except Exception as err:
print_json(error_to_dict("org_tree", err, {"base_url": args.base_url}), 1)
def command_switch_org(args, auth_context):
try:
request_mode = resolve_request_mode(args)
if auth_context.get("auth_mode") == "password":
switch_headers = build_token_headers(auth_context["x_de_token"])
auth_mode = "password"
elif request_mode == "gateway":
switch_headers = build_headers(auth_context)
auth_mode = "ask_token"
else:
de_token = exchange_de_token(args.base_url, auth_context, f"/de2api/user/switch/{args.org_id}")
switch_headers = build_token_headers(de_token)
auth_mode = "ask_token"
switch_result = switch_organization(args.base_url, switch_headers, args.org_id)
switch_data = extract_response_data(switch_result, "切换组织")
x_de_token = switch_data.get("token") if isinstance(switch_data, dict) else None
if not x_de_token:
raise ValueError("切换组织接口未返回 data.token")
print_json({
"ok": True,
"stage": "switch_org",
"org_id": str(args.org_id),
"x_de_token": x_de_token,
"token_exp": switch_data.get("exp"),
"token_source": "switched_org",
"auth_mode": auth_mode,
"request_mode": request_mode,
}, 0)
except Exception as err:
print_json(error_to_dict("switch_org", err, {"org_id": args.org_id}), 1)
def command_list_resources(args, auth_context):
try:
request_payload = {"busiFlag": args.busi_type, "resourceTable": args.resource_table}
headers, runtime_info = resolve_runtime_headers(args, auth_context, "/de2api/dataVisualization/tree", request_payload)
resource_tree = query_resource_tree(args.base_url, headers, args.busi_type, args.resource_table)
resources = [item for item in flatten_tree(extract_tree_nodes(resource_tree)) if item.get("leaf")]
if args.resource_name:
aliases = load_aliases(args.alias_file)
resolved_query = aliases.get(args.resource_name, args.resource_name)
candidates = [
{
"score": round(score, 4),
"id": item.get("id"),
"name": item.get("name"),
"leaf": item.get("leaf"),
"type": item.get("type"),
}
for score, item in search_resources(resources, resolved_query, top_n=args.limit)
]
print_json({
"ok": True,
"stage": "resource_list",
"busi_type": args.busi_type,
"resource_name": args.resource_name,
"resolved_query": resolved_query,
"resources": candidates,
"total": len(candidates),
**runtime_info,
}, 0)
resource_list = sorted(resources, key=lambda item: (item.get("name") or "").lower())[:args.limit]
print_json({
"ok": True,
"stage": "resource_list",
"busi_type": args.busi_type,
"resources": resource_list,
"total": len(resource_list),
**runtime_info,
}, 0)
except Exception as err:
print_json(error_to_dict("resource_list", err, {"busi_type": args.busi_type}), 1)
def command_capture(args, auth_context):
try:
request_payload = {"busiFlag": args.busi_type, "resourceTable": args.resource_table}
x_de_token, runtime_info = resolve_capture_token(args, auth_context, "/de2api/dataVisualization/tree", request_payload)
headers = build_token_headers(x_de_token)
resource_tree = query_resource_tree(args.base_url, headers, args.busi_type, args.resource_table)
resources = flatten_tree(extract_tree_nodes(resource_tree))
if args.resource_id:
target = next((item for item in resources if str(item.get("id")) == str(args.resource_id)), None)
if not target:
raise ValueError(f"资源树中未找到 resource_id={args.resource_id}")
if not target.get("leaf"):
raise ValueError(f"resource_id={args.resource_id} 对应的是目录节点,不能直接导出")
resolved_query = target.get("name")
candidates = [{
"score": None,
"id": target.get("id"),
"name": target.get("name"),
"leaf": target.get("leaf"),
"type": target.get("type"),
}]
else:
resolved, code = resolve_resource(resources, args.resource_name, args.alias_file, args.min_score)
if code != 0:
print_json({**resolved, **runtime_info}, code)
target = resolved["resource"]
resolved_query = resolved["resolved_query"]
candidates = resolved["candidates"]
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
safe_name = re.sub(r"[^\w\u4e00-\u9fff-]+", "_", target["name"]).strip("_") or "capture"
ext = guess_extension(args.result_format, "")
output_path = (output_dir / f"{safe_name}_{target['id']}{ext}").resolve()
preview_url = build_preview_url(args.base_url, target["id"], args.busi_type)
browser_result = run_browser_capture(
preview_url,
x_de_token,
args.pixel,
args.ext_wait_time,
args.result_format,
output_path,
)
except Exception as err:
extra = {
"busi_type": args.busi_type,
}
if getattr(args, "resource_name", None):
extra["resource_name"] = args.resource_name
if getattr(args, "resource_id", None):
extra["resource_id"] = args.resource_id
print_json(error_to_dict("capture", err, extra), 1)
print_json({
"ok": True,
"stage": "capture",
"resource_id": target["id"],
"resource_name": target["name"],
"resolved_query": resolved_query,
"busi_type": args.busi_type,
"pixel": args.pixel,
"ext_wait_time": args.ext_wait_time,
"result_format": args.result_format,
"preview_url": preview_url,
"saved_file": str(output_path),
"candidates": candidates,
"capture_engine": "local_playwright",
"capture_meta": browser_result,
**runtime_info,
}, 0)
def main():
load_dotenv(ROOT_DIR / ".env")
args = parse_args()
auth_context = load_auth(args)
if args.command == "list-orgs":
command_list_orgs(args, auth_context)
elif args.command == "switch-org":
command_switch_org(args, auth_context)
elif args.command == "list-resources":
command_list_resources(args, auth_context)
elif args.command == "capture":
command_capture(args, auth_context)
else:
print_json({"ok": False, "stage": "args", "error": f"不支持的命令: {args.command}"}, 1)
if __name__ == "__main__":
main()