Publish dataease via gitea-publish skill

This commit is contained in:
qwenpaw-skills
2026-08-09 00:36:02 +00:00
commit ac82f67e09
24 changed files with 7145 additions and 0 deletions
+503
View File
@@ -0,0 +1,503 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
const MAX_DEBUG_LOGS = 20;
const RENDER_SETTLE_POLL_MS = 500;
const RENDER_SETTLE_IDLE_MS = 2000;
const MAX_CAPTURE_VIEWPORT_HEIGHT = 12000;
function printHelp() {
console.log(`Usage: node scripts/browser_capture.mjs --url <url> --token <x-de-token> --width 1920 --height 1080 --wait-seconds 0 --result-format 0 --output /abs/path/file.jpg
Options:
--url DataEase preview URL
--token X-DE-TOKEN to inject into localStorage as user.token
--width Browser viewport width in pixels
--height Browser viewport height in pixels
--wait-seconds Extra wait time after canvas is visible
--result-format 0=jpeg, 1=pdf
--output Absolute or relative output path
-h, --help Show this help message
`);
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index];
if (current === '-h' || current === '--help') {
args.help = true;
continue;
}
if (!current.startsWith('--')) {
throw new Error(`Unexpected argument: ${current}`);
}
const key = current.slice(2);
const value = argv[index + 1];
if (value == null || value.startsWith('--')) {
throw new Error(`Missing value for --${key}`);
}
args[key] = value;
index += 1;
}
return args;
}
function parsePositiveInteger(value, name) {
const number = Number.parseInt(value, 10);
if (!Number.isFinite(number) || number <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return number;
}
function parseWaitSeconds(value) {
const number = Number.parseInt(value ?? '0', 10);
if (!Number.isFinite(number) || number < 0) {
throw new Error('wait-seconds must be a non-negative integer');
}
return number;
}
async function waitForCanvasReady(page, selector, timeout) {
const locator = page.locator(selector).first();
await locator.waitFor({ state: 'visible', timeout });
await page.waitForFunction(
targetSelector => {
const element = document.querySelector(targetSelector);
if (!element) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
},
selector,
{ timeout }
);
await page.waitForTimeout(500);
return locator;
}
function buildWsCacheItem(value, now = Date.now(), expiresAt = 253402300799000) {
return JSON.stringify({
c: now,
e: expiresAt,
v: JSON.stringify(value)
});
}
async function collectDiagnostics(page, debugLogs) {
const state = await page.evaluate(() => {
const app = document.querySelector('#app');
const visible = element => {
if (!element) {
return false;
}
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || '1') > 0 && rect.width > 0 && rect.height > 0;
};
return {
href: location.href,
title: document.title,
hasCanvas: !!document.querySelector('.canvas-container'),
hasContent: !!document.querySelector('.content'),
hasEmpty: !!document.querySelector('.empty-background, .el-empty'),
visibleLoadingMasks: Array.from(document.querySelectorAll('.el-loading-mask,.ed-loading-mask,.v-loading-mask')).filter(visible).length,
unfinishedReportLoads: document.querySelectorAll('.report-load:not(.report-load-finish)').length,
bodyText: (document.body?.innerText || '').slice(0, 400),
appHtml: (app?.innerHTML || '').slice(0, 1200)
};
});
return {
...state,
logs: debugLogs.slice(-MAX_DEBUG_LOGS)
};
}
async function getRenderState(page, selector) {
return page.evaluate(targetSelector => {
const visible = element => {
if (!element) {
return false;
}
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || '1') > 0 && rect.width > 0 && rect.height > 0;
};
const canvas = document.querySelector(targetSelector);
const loadingSelectors = '.el-loading-mask,.ed-loading-mask,.v-loading-mask,[class*="loading-mask"]';
const visibleLoadingMasks = Array.from(document.querySelectorAll(loadingSelectors)).filter(visible).length;
const unfinishedReportLoads = document.querySelectorAll('.report-load:not(.report-load-finish)').length;
const box = canvas?.getBoundingClientRect();
return {
hasCanvas: !!canvas,
visibleLoadingMasks,
unfinishedReportLoads,
width: Math.round(box?.width || 0),
height: Math.round(box?.height || 0)
};
}, selector);
}
async function waitForRenderSettled(page, selector, timeout, debugLogs, getPendingRequests) {
const startedAt = Date.now();
let stableSince = 0;
let lastSnapshot = '';
while (Date.now() - startedAt < timeout) {
const state = await getRenderState(page, selector);
const pendingRequests = getPendingRequests();
const snapshot = JSON.stringify({ ...state, pendingRequests });
if (snapshot !== lastSnapshot) {
debugLogs.push(`render-state ${snapshot}`);
lastSnapshot = snapshot;
}
const settled =
state.hasCanvas &&
state.width > 0 &&
state.height > 0 &&
state.visibleLoadingMasks === 0 &&
state.unfinishedReportLoads === 0 &&
pendingRequests === 0;
if (settled) {
if (!stableSince) {
stableSince = Date.now();
}
if (Date.now() - stableSince >= RENDER_SETTLE_IDLE_MS) {
return state;
}
} else {
stableSince = 0;
}
await page.waitForTimeout(RENDER_SETTLE_POLL_MS);
}
const state = await getRenderState(page, selector);
throw new Error(
`render settle timeout: ${JSON.stringify({
...state,
pendingRequests: getPendingRequests()
})}`
);
}
async function expandScrollableCapture(page, selector, viewportWidth, viewportHeight) {
return page.evaluate(
({ targetSelector, maxViewportHeight, minViewportHeight, viewportWidth }) => {
const canvas = document.querySelector(targetSelector);
if (!canvas) {
return null;
}
const isScrollable = element => {
if (!element) {
return false;
}
const style = window.getComputedStyle(element);
const overflowY = style.overflowY;
return ['auto', 'scroll', 'overlay'].includes(overflowY) && element.scrollHeight - element.clientHeight > 2;
};
const applyStyle = (element, key, value) => {
const styleKey = key;
element.style[styleKey] = value;
};
let scrollableAncestor = null;
let current = canvas.parentElement;
while (current && current !== document.body) {
if (isScrollable(current)) {
scrollableAncestor = current;
break;
}
current = current.parentElement;
}
const scrollingElement = document.scrollingElement || document.documentElement;
const initialRect = canvas.getBoundingClientRect();
if (scrollableAncestor) {
scrollableAncestor.scrollTop = 0;
applyStyle(scrollableAncestor, 'overflowY', 'visible');
applyStyle(scrollableAncestor, 'height', `${scrollableAncestor.scrollHeight}px`);
applyStyle(scrollableAncestor, 'maxHeight', 'none');
}
if (scrollingElement) {
scrollingElement.scrollTop = 0;
}
window.scrollTo(0, 0);
const rect = canvas.getBoundingClientRect();
const fullHeight = Math.max(
Math.ceil(rect.height),
Math.ceil(canvas.scrollHeight || 0),
Math.ceil(scrollableAncestor?.scrollHeight || 0),
Math.ceil(document.documentElement.scrollHeight || 0)
);
return {
hadScrollableAncestor: Boolean(scrollableAncestor),
initialHeight: Math.ceil(initialRect.height),
finalHeight: Math.ceil(rect.height),
captureHeight: fullHeight,
viewportHeight: Math.min(Math.max(fullHeight, minViewportHeight), maxViewportHeight),
viewportWidth: viewportWidth
};
},
{
targetSelector: selector,
maxViewportHeight: MAX_CAPTURE_VIEWPORT_HEIGHT,
minViewportHeight: viewportHeight,
viewportWidth: viewportWidth
}
);
}
async function toPdfBuffer(pngBytes, PDFDocument) {
const pdfDoc = await PDFDocument.create();
const image = await pdfDoc.embedPng(pngBytes);
const page = pdfDoc.addPage([image.width, image.height]);
page.drawImage(image, {
x: 0,
y: 0,
width: image.width,
height: image.height
});
const pdfBytes = await pdfDoc.save();
return Buffer.from(pdfBytes);
}
async function main() {
let browser;
let page;
const debugLogs = [];
try {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp();
return;
}
const url = args.url;
const token = args.token;
const output = args.output;
if (!url || !token || !output) {
throw new Error('url, token and output are required');
}
const width = parsePositiveInteger(args.width ?? '1920', 'width');
const height = parsePositiveInteger(args.height ?? '1080', 'height');
const waitSeconds = parseWaitSeconds(args['wait-seconds']);
const resultFormat = Number.parseInt(args['result-format'] ?? '0', 10);
if (![0, 1].includes(resultFormat)) {
throw new Error('result-format must be 0 or 1');
}
const [{ chromium }, { PDFDocument }] = await Promise.all([
import('playwright'),
import('pdf-lib')
]);
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined
});
const context = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: 1,
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
'X-DE-TOKEN': token
}
});
await context.route('**/de2api/**', async route => {
const request = route.request();
const headers = {
...request.headers(),
'X-DE-TOKEN': token
};
const url = request.url();
if (url.includes('/de2api/outerParams/getOuterParamsInfo/')) {
try {
const response = await route.fetch({ headers });
if (response.status() < 500) {
await route.fulfill({ response });
return;
}
debugLogs.push(`outerParams fallback: ${response.status()} ${url}`);
} catch (error) {
debugLogs.push(`outerParams fallback error: ${error?.message || String(error)}`);
}
await route.fulfill({
status: 200,
contentType: 'application/json;charset=UTF-8',
body: JSON.stringify({
code: 0,
msg: 'success',
data: {
outerParamsInfoMap: {},
outerParamsInfoBaseMap: {}
}
})
});
return;
}
await route.continue({ headers });
});
page = await context.newPage();
const selector = '.canvas-container';
const timeout = 120000;
const pendingRequests = new Set();
const trackRequest = request => {
const resourceType = request.resourceType();
if (!['fetch', 'xhr'].includes(resourceType)) {
return false;
}
return request.url().includes('/de2api/');
};
page.on('request', request => {
if (trackRequest(request)) {
pendingRequests.add(request);
}
});
page.on('requestfinished', request => {
pendingRequests.delete(request);
});
page.on('requestfailed', request => {
pendingRequests.delete(request);
});
page.on('response', response => {
if (response.status() >= 400 && response.url().includes('/de2api/')) {
debugLogs.push(`response ${response.status()} ${response.url()}`);
}
});
page.on('pageerror', error => {
debugLogs.push(`pageerror ${error.message}`);
});
page.on('console', message => {
if (message.type() === 'error') {
debugLogs.push(`console ${message.text()}`);
}
});
const now = Date.now();
await page.addInitScript(
({ injectedToken, cacheToken, cacheExp, cacheTime }) => {
localStorage.setItem('user.token', cacheToken);
localStorage.setItem('user.exp', cacheExp);
localStorage.setItem('user.time', cacheTime);
localStorage.setItem('__de_raw_token__', injectedToken);
},
{
injectedToken: token,
cacheToken: buildWsCacheItem(token, now),
cacheExp: buildWsCacheItem(now + 3600 * 1000, now),
cacheTime: buildWsCacheItem(now, now)
}
);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
try {
await page.waitForLoadState('networkidle', { timeout: 10000 });
} catch {
// Preview pages may keep network connections alive; selector checks below are stricter.
}
const locator = await waitForCanvasReady(page, selector, timeout);
const renderState = await waitForRenderSettled(
page,
selector,
timeout,
debugLogs,
() => pendingRequests.size
);
const expandedCapture = await expandScrollableCapture(page, selector, width, height);
if (expandedCapture?.captureHeight > height) {
await page.setViewportSize({
width,
height: expandedCapture.viewportHeight
});
await page.waitForTimeout(500);
await waitForRenderSettled(
page,
selector,
timeout,
debugLogs,
() => pendingRequests.size
);
}
if (waitSeconds > 0) {
await page.waitForTimeout(waitSeconds * 1000);
}
const box = await locator.boundingBox();
if (!box || box.width < 1 || box.height < 1) {
throw new Error('canvas container is empty');
}
if (resultFormat === 1) {
const pngBytes = await locator.screenshot({
type: 'png',
animations: 'disabled'
});
const pdfBytes = await toPdfBuffer(pngBytes, PDFDocument);
await fs.writeFile(output, pdfBytes);
console.log(JSON.stringify({
ok: true,
format: 'pdf',
width: Math.round(box.width),
height: Math.round(box.height),
renderState,
expandedCapture,
output
}));
return;
}
await locator.screenshot({
path: output,
type: 'jpeg',
quality: 90,
animations: 'disabled'
});
console.log(JSON.stringify({
ok: true,
format: 'jpeg',
width: Math.round(box.width),
height: Math.round(box.height),
renderState,
expandedCapture,
output
}));
} catch (error) {
if (error && error.code === 'ERR_MODULE_NOT_FOUND') {
console.error('Missing runtime dependency. Please run `npm install` and `npx playwright install chromium` first.');
process.exitCode = 1;
return;
}
try {
const diagnostics = page
? await collectDiagnostics(page, debugLogs)
: null;
if (diagnostics) {
console.error(`${error && error.stack ? error.stack : String(error)}\nDiagnostics: ${JSON.stringify(diagnostics)}`);
} else {
console.error(error && error.stack ? error.stack : String(error));
}
} catch {
console.error(error && error.stack ? error.stack : String(error));
}
process.exitCode = 1;
} finally {
if (browser) {
await browser.close();
}
}
}
main();
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
import json
import time
import uuid
import base64
import os
import requests
import jwt
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.backends import default_backend
import urllib3
urllib3.disable_warnings()
class DataEaseClient:
def __init__(self, base_url, access_key, secret_key):
# Separate base URL and API prefix
self.base_url = base_url.rstrip('/')
self.api_prefix = os.environ.get('DATAEASE_API_PREFIX', '/de2api')
self.access_key = access_key
self.secret_key = secret_key
def _get_signature(self, uid, timestamp):
src_str = f"{self.access_key}|{uid}|{timestamp}"
padder = padding.PKCS7(128).padder()
padded_data = padder.update(src_str.encode('utf-8')) + padder.finalize()
cipher = Cipher(
algorithms.AES(self.secret_key.encode('utf-8')),
modes.CBC(self.access_key.encode('utf-8')),
backend=default_backend()
)
encryptor = cipher.encryptor()
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
return base64.b64encode(ciphertext).decode('utf-8')
def _get_headers(self):
timestamp = str(int(time.time() * 1000))
uid = str(uuid.uuid4())
signature = self._get_signature(uid, timestamp)
claims = {
"accessKey": self.access_key,
"signature": signature
}
token = jwt.encode(claims, self.secret_key, algorithm="HS256")
if isinstance(token, bytes):
token = token.decode('utf-8')
return {
"accessKey": self.access_key,
"signature": signature,
"x-de-ask-token": token,
"timestamp": timestamp,
"nonce": uid,
"Content-Type": "application/json"
}
def get(self, path, params=None):
url = f"{self.base_url}{self.api_prefix}{path}"
response = requests.get(url, headers=self._get_headers(), params=params, verify=False)
return response
def post(self, path, payload=None):
url = f"{self.base_url}{self.api_prefix}{path}"
response = requests.post(url, headers=self._get_headers(), json=payload, verify=False)
return response
def get_dataset_fields(self, dataset_id):
return self.post(f"/datasetField/listByDatasetGroup/{dataset_id}").json()
def update_publish_status(self, dashboard_id, name, status=1, type='dashboard', mobile_layout=False, active_view_ids=None):
url = f"{self.base_url}{self.api_prefix}/dataVisualization/updatePublishStatus"
payload = {
"id": dashboard_id,
"name": name,
"mobileLayout": mobile_layout,
"activeViewIds": active_view_ids or [],
"status": status,
"type": type
}
response = requests.post(url, headers=self._get_headers(), json=payload, verify=False)
response.raise_for_status()
return response.json()
+158
View File
@@ -0,0 +1,158 @@
import sys
import os
import json
import subprocess
# Add local path for engine import
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from engine import DataEaseChartEngine
# --- Configuration from Environment ---
def load_dotenv():
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
if os.path.exists(env_path):
with open(env_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
os.environ[key.strip()] = value.strip().strip('"').strip("'")
load_dotenv()
ACCESS_KEY = os.environ.get("DATAEASE_ACCESS_KEY")
SECRET_KEY = os.environ.get("DATAEASE_SECRET_KEY")
BASE_URL = os.environ.get("DATAEASE_BASE_URL")
USERNAME = os.environ.get("DATAEASE_USERNAME")
PASSWORD = os.environ.get("DATAEASE_PASSWORD")
LOGIN_ORIGIN = os.environ.get("DATAEASE_LOGIN_ORIGIN", "0")
# Screenshot configuration - auto-detect OpenClaw workspace for MEDIA: display
def _default_output_dir():
"""优先使用 OpenClaw workspace 目录,否则用 skill 本地 output 目录"""
# 1. 环境变量显式指定
env_dir = os.environ.get("DATAEASE_OUTPUT_DIR")
if env_dir:
return env_dir
# 2. 自动检测 OpenClaw workspace
home = os.path.expanduser("~")
workspace_dir = os.path.join(home, ".openclaw", "workspace", "dataease-output")
if os.path.isdir(os.path.join(home, ".openclaw", "workspace")):
os.makedirs(workspace_dir, exist_ok=True)
return workspace_dir
# 3. 回退到 skill 本地 output
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output")
OUTPUT_DIR = _default_output_dir()
DEFAULT_PIXEL = "2560*1440" # 更高分辨率,图片更清晰
def capture_dashboard(dashboard_id, output_format="jpeg"):
"""调用 capture_dashboard.py 进行截图"""
scripts_dir = os.path.dirname(os.path.abspath(__file__))
capture_script = os.path.join(scripts_dir, "capture_dashboard.py")
if not os.path.exists(capture_script):
return None, "Screenshot script not found"
# 确保输出目录存在
os.makedirs(OUTPUT_DIR, exist_ok=True)
# capture_dashboard.py 使用相同的 BASE_URL(不含 /de2api 后缀)
capture_base_url = BASE_URL.rstrip("/")
# 调用截图脚本
result_format = "0" if output_format == "jpeg" else "1" # 0=jpeg, 1=pdf
cmd = [
"python3", capture_script,
"capture",
"--resource-id", str(dashboard_id),
"--busi-type", "dashboard",
"--output-dir", OUTPUT_DIR,
"--result-format", result_format,
"--base-url", capture_base_url,
"--pixel", DEFAULT_PIXEL
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0:
output = json.loads(result.stdout)
if output.get("ok"):
return output.get("saved_file"), None
else:
return None, output.get("error", "Unknown capture error")
else:
return None, result.stderr or "Capture failed"
except subprocess.TimeoutExpired:
return None, "Capture timeout"
except Exception as e:
return None, str(e)
def main():
if not BASE_URL:
print("Error: Missing required environment variable DATAEASE_BASE_URL.")
sys.exit(1)
if not ACCESS_KEY and not USERNAME:
print("Error: Missing authentication. Please set either:")
print(" DATAEASE_ACCESS_KEY + DATAEASE_SECRET_KEY")
print(" or DATAEASE_USERNAME + DATAEASE_PASSWORD")
sys.exit(1)
if len(sys.argv) < 5:
print("Usage: python3 deploy.py <type> <title> <dataset_name_or_id> <x_fields> <y_fields> [--no-screenshot]")
print("Example: python3 deploy.py line 'Skill Test' '电商用户购买行为' '访问平台' '访问次数'")
sys.exit(1)
chart_type = sys.argv[1]
title = sys.argv[2]
dataset_id = sys.argv[3]
x_axis = [f.strip() for f in sys.argv[4].split(',') if f.strip()]
y_axis = [f.strip() for f in sys.argv[5].split(',') if f.strip()]
no_screenshot = "--no-screenshot" in sys.argv
if ACCESS_KEY and SECRET_KEY:
engine = DataEaseChartEngine(BASE_URL, ACCESS_KEY, SECRET_KEY)
else:
engine = DataEaseChartEngine(BASE_URL, USERNAME, PASSWORD, auth_mode="password")
try:
did, url = engine.deploy(chart_type, title, dataset_id, x_axis, y_axis)
print(f"\n✅ Successfully deployed!")
print(f"Dashboard ID: {did}")
print(f"URL: {url}")
# 自动截图
if not no_screenshot:
print("\n📸 Capturing screenshot...")
screenshot_path, error = capture_dashboard(did)
if screenshot_path:
print(f"Screenshot saved: {screenshot_path}")
# 输出 JSON 结果供 Agent 解析
result = {
"ok": True,
"dashboard_id": str(did),
"url": url,
"screenshot": screenshot_path,
"title": title
}
print(f"\n__RESULT_JSON__\n{json.dumps(result, ensure_ascii=False)}\n__END_JSON__")
else:
print(f"⚠️ Screenshot failed: {error}")
result = {
"ok": True,
"dashboard_id": str(did),
"url": url,
"screenshot": None,
"screenshot_error": error,
"title": title
}
print(f"\n__RESULT_JSON__\n{json.dumps(result, ensure_ascii=False)}\n__END_JSON__")
except Exception as e:
print(f"\n❌ Deployment failed: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()
+326
View File
@@ -0,0 +1,326 @@
import json, re, time, random, os, sys, requests
import urllib3
urllib3.disable_warnings()
# Local SDK client
from client import DataEaseClient
class DataEaseChartEngine:
def __init__(self, base_url, ak_or_user, sk_or_password, auth_mode=None):
self.base_url = base_url.rstrip('/')
self.api_prefix = os.environ.get('DATAEASE_API_PREFIX', '/de2api')
self.auth_mode = auth_mode or 'aksk'
if self.auth_mode == 'password':
import importlib.util
spec = importlib.util.spec_from_file_location("inspect_data", os.path.join(os.path.dirname(__file__), "inspect_data.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
x_de_token = mod.login_with_password(base_url, self.api_prefix, ak_or_user, sk_or_password)
self.headers = mod.get_headers_token(x_de_token)
self.client = None
else:
self.client = DataEaseClient(base_url, ak_or_user, sk_or_password)
self.headers = None
def _get_headers(self):
if self.headers:
return self.headers
return self._get_headers()
def _post(self, path, payload=None):
url = f"{self.base_url}{self.api_prefix}{path}"
return requests.post(url, headers=self._get_headers(), json=payload, verify=False, timeout=30)
def _get(self, path, params=None):
url = f"{self.base_url}{self.api_prefix}{path}"
return requests.get(url, headers=self._get_headers(), params=params, verify=False, timeout=30)
def resolve_dataset_id(self, name_or_id):
"""Resolve dataset name to ID using /datasetTree/tree if needed"""
if str(name_or_id).isdigit() and len(str(name_or_id)) > 10:
return name_or_id
url = f"{self.base_url}{self.api_prefix}/datasetTree/tree"
headers = self._get_headers()
resp = requests.post(url, headers=headers, json={"busiFlag": "dataset"}, verify=False, timeout=30)
if resp.status_code != 200:
raise Exception(f"Failed to fetch dataset tree: {resp.text}")
nodes = resp.json().get('data', [])
def find_in_tree(items, target_name):
for item in items:
if item.get('name') == target_name:
return item.get('id')
children = item.get('children', [])
if children:
found = find_in_tree(children, target_name)
if found: return found
return None
dataset_id = find_in_tree(nodes, name_or_id)
if not dataset_id:
raise ValueError(f"Dataset '{name_or_id}' not found in DataEase")
return dataset_id
def get_dataset_ctx(self, dataset_name_or_id, x_names, y_names):
"""Fetch dataset metadata and build rendering context"""
dataset_id = self.resolve_dataset_id(dataset_name_or_id)
# DataEase v2 often uses datasetGroup for the tree structure
url = f"{self.base_url}{self.api_prefix}/datasetTree/details/{dataset_id}"
headers = self._get_headers()
resp = requests.get(url, headers=headers, verify=False, timeout=30)
if resp.status_code != 200:
# Fallback to the original method if the specific tree detail endpoint fails
resp = self._post(f"/datasetField/listByDatasetGroup/{dataset_id}")
fields = resp.json().get('data', [])
else:
data = resp.json().get('data', {})
fields = data.get('allFields', [])
if not fields:
# Try to get from datasetField/listByDatasetGroup if tree details is empty
resp = self._post(f"/datasetField/listByDatasetGroup/{dataset_id}")
fields = resp.json().get('data', [])
if not fields:
raise ValueError(f"No fields found for dataset {dataset_id}")
f_map = {f['name']: f for f in fields}
# Get table/datasource IDs from the first field (they should be common)
datasource_id = fields[0].get('datasourceId', "")
table_id = fields[0].get('datasetTableId', "")
ctx = {
"DATASET_GROUP_ID": dataset_id,
"DATASOURCE_ID": datasource_id,
"DATASET_TABLE_ID": table_id,
}
# Map axis fields (supports multi-measure if needed)
for i, name in enumerate(x_names):
suffix = "" if i == 0 else str(i+1)
f = f_map.get(name)
if not f: raise ValueError(f"X-Axis Field '{name}' not found")
ctx[f"XAXIS{suffix}_FIELD_ID"] = f['id']
ctx[f"XAXIS{suffix}_DE_NAME"] = f['dataeaseName']
for i, name in enumerate(y_names):
suffix = "" if i == 0 else str(i+1)
f = f_map.get(name)
if not f: raise ValueError(f"Y-Axis Field '{name}' not found")
ctx[f"YAXIS{suffix}_FIELD_ID"] = f['id']
ctx[f"YAXIS{suffix}_DE_NAME"] = f['dataeaseName']
ctx[f"YAXIS{suffix}_SERIES_ID"] = f"{f['id']}-yAxis"
# Additional keys for deep parameterization
if i == 0:
ctx["YAXIS_FIELD_ID"] = f['id']
ctx["YAXIS_DE_NAME"] = f['dataeaseName']
ctx["YAXIS_SERIES_ID"] = f"{f['id']}-yAxis"
elif i == 1:
ctx["YAXIS2_FIELD_ID"] = f['id']
ctx["YAXIS2_DE_NAME"] = f['dataeaseName']
ctx["YAXIS2_SERIES_ID"] = f"{f['id']}-yAxis"
return ctx
def deploy(self, chart_type, title, dataset_id, x_names, y_names, layout=None):
# 1. Directory standardization
# Use relative path from this script (scripts/engine.py) to templates/
base_dir = os.path.dirname(os.path.abspath(__file__))
tpl_dir = os.path.join(base_dir, "..", "templates", f"chart_{chart_type}")
if not os.path.exists(tpl_dir):
raise FileNotFoundError(f"Template directory {tpl_dir} not found")
with open(os.path.join(tpl_dir, "template.j2")) as f: template_str = f.read()
with open(os.path.join(tpl_dir, "params.json")) as f:
raw_params = json.load(f)
# 2. Build Context (Flatten params + dynamic IDs)
ctx = {}
# Flatten nested params.json
def flatten_dict(d):
items = {}
for k, v in d.items():
if isinstance(v, dict):
items.update(flatten_dict(v))
elif not k.startswith("_"):
items[k] = v
return items
ctx.update(flatten_dict(raw_params))
ctx.update(self.get_dataset_ctx(dataset_id, x_names, y_names))
# 3. Runtime Randomization
view_id, content_id = self.rand_id(), self.rand_id()
ctx.update({"VIEW_ID": view_id, "CONTENT_ID": content_id, "SCENE_ID": "0"})
# 4. Thorough Parameterization (Global substitution)
def rep(m):
k = m.group(1).strip()
if k in ctx:
return str(ctx[k])
return m.group(0) # Keep placeholder if not found
rendered = re.sub(r"\{\{\s*(\w+)\s*\}\}", rep, template_str)
payload = json.loads(rendered)
# 5. Type Closure & Payload Finalization
board_name = f"{title}_{int(time.time())}"
# Override title in canvasViewInfo
if "canvasViewInfo" in payload and view_id in payload["canvasViewInfo"]:
view_info = payload["canvasViewInfo"][view_id]
view_info["title"] = title
# Deep update all field names (xAxis, yAxis, labels, tooltips, etc.)
def deep_update_field_names(obj, target_id, new_name):
if isinstance(obj, dict):
if str(obj.get("id")) == str(target_id):
obj["name"] = new_name
if "description" in obj: obj["description"] = new_name
if "originName" in obj: obj["originName"] = new_name
# 核心修复:DataEase V2 在某些场景下会回退到 dbFieldName,强制设为 null 或同步
if "dbFieldName" in obj: obj["dbFieldName"] = None
# 特殊修复:处理 DataEase 特有的标签和提示框拼接字段 (如 "访问平台(求和)")
# 我们无法预知原始模板里的名字,所以这里尝试匹配常见的拼接模式
if "optionLabel" in obj:
obj["optionLabel"] = new_name + (obj["optionLabel"].partition("(")[1] + obj["optionLabel"].partition("(")[2] if "(" in obj["optionLabel"] else "")
if "optionShowName" in obj:
obj["optionShowName"] = new_name + (obj["optionShowName"].partition("(")[1] + obj["optionShowName"].partition("(")[2] if "(" in obj["optionShowName"] else "")
for v in obj.values():
deep_update_field_names(v, target_id, new_name)
elif isinstance(obj, list):
for item in obj:
deep_update_field_names(item, target_id, new_name)
# Apply updates for X and Y axis fields
# We use the field IDs obtained from dataset metadata to find and replace
dataset_ctx = self.get_dataset_ctx(dataset_id, x_names, y_names)
# Pre-scan templates/params.json for the old names to ensure replacement works
# or use common default names from templates
old_names_to_clear = ["访问次数", "访问平台", "浏览量"]
for i, x_name in enumerate(x_names):
f_id = dataset_ctx.get(f"XAXIS{'' if i==0 else i+1}_FIELD_ID")
if f_id:
deep_update_field_names(view_info, f_id, x_name)
for i, y_name in enumerate(y_names):
f_id = dataset_ctx.get(f"YAXIS{'' if i==0 else i+1}_FIELD_ID")
if f_id:
deep_update_field_names(view_info, f_id, y_name)
# 最终保底:如果还有残留的“访问次数”或“访问平台”,强行全局替换
def brute_force_replace(obj, search_list, replace_list):
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, str):
for s, r in zip(search_list, replace_list):
if s in v:
obj[k] = v.replace(s, r)
else:
brute_force_replace(v, search_list, replace_list)
elif isinstance(obj, list):
for item in obj:
brute_force_replace(item, search_list, replace_list)
# Use primary X/Y names for brute force fallback
if x_names and y_names:
brute_force_replace(view_info, ["访问平台", "访问次数", "浏览量"], [x_names[0], y_names[0], y_names[0]])
# If layout is provided, override it in componentData
if "componentData" in payload:
components = json.loads(payload["componentData"])
if components:
target = components[0]
# Update component name to title
target["name"] = title
target["label"] = title
# 单图表模式:充满整个画布
if layout is None and len(components) == 1:
# DataEase 单图表网格 72x36,让图表充满整个画布
target["x"] = 1
target["y"] = 1
target["sizeX"] = 72
target["sizeY"] = 36
if "style" not in target:
target["style"] = {}
target["style"]["width"] = 1920
target["style"]["height"] = 1080
target["style"]["left"] = 0
target["style"]["top"] = 0
elif layout:
if "x" in layout: target["x"] = layout["x"]
if "y" in layout: target["y"] = layout["y"]
if "sizeX" in layout: target["sizeX"] = layout["sizeX"]
if "sizeY" in layout: target["sizeY"] = layout["sizeY"]
if "style" not in target: target["style"] = {}
if "width" in layout: target["style"]["width"] = layout["width"]
if "height" in layout: target["style"]["height"] = layout["height"]
if "left" in layout: target["style"]["left"] = layout["left"]
if "top" in layout: target["style"]["top"] = layout["top"]
payload["componentData"] = json.dumps(components, separators=(',', ':'), ensure_ascii=False)
# Ensure canvasStyleData is also compact
if "canvasStyleData" in payload:
style = json.loads(payload["canvasStyleData"])
payload["canvasStyleData"] = json.dumps(style, separators=(',', ':'), ensure_ascii=False)
payload.update({
"id": None,
"name": board_name,
"type": "dashboard",
"status": 0,
"dataState": "ready",
"selfWatermarkStatus": True,
"checkVersion": "2.10.20",
"pid": "0",
"mobileLayout": False
})
headers = self._get_headers()
print(f"Deploying chart '{title}' (Type: {chart_type})...")
save_r = requests.post(
f"{self.base_url}{self.api_prefix}/dataVisualization/saveCanvas",
headers=headers, json=payload, verify=False, timeout=30
)
if save_r.status_code != 200:
raise Exception(f"saveCanvas failed: {save_r.text}")
dashboard_id = str(save_r.json()["data"])
# 6. Publish
requests.post(
f"{self.base_url}{self.api_prefix}/dataVisualization/updatePublishStatus",
headers=headers, verify=False, timeout=30,
json={
"id": dashboard_id,
"name": board_name,
"activeViewIds": [view_id],
"status": 1,
"type": "dashboard",
"mobileLayout": False
}
)
# Generate preview URL
return dashboard_id, f"{self.base_url}/#/preview?dvId={dashboard_id}&dvType=dashboard&ignoreParams=true"
@staticmethod
def rand_id():
return str(int(time.time() * 1000) + random.randint(1000, 9999))
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""
DataEase 数据集探索脚本
支持 AK/SK 和密码登录两种认证方式
"""
import os
import json
import sys
import argparse
import subprocess
import base64
import hashlib
import hmac
import time
import uuid
import shutil
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
# Add scripts to path for engine import
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
def load_dotenv():
"""加载 .env 文件"""
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
if os.path.exists(env_path):
with open(env_path, encoding="utf8") as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
os.environ[key.strip()] = value.strip().strip('"').strip("'")
def base64url(raw):
"""Base64 URL-safe 编码"""
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def sign_jwt(payload, secret_key):
"""使用 HMAC-SHA256 签名 JWT"""
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):
"""根据密钥长度返回 AES 加密算法名称"""
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):
"""AES 加密"""
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):
"""构建 ASK 认证信息"""
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 get_headers_ask(ask_auth):
"""构建 ASK 认证请求头"""
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 fetch_dekey(base_url, api_prefix):
"""获取 dekey 用于密码登录"""
url = f"{base_url.rstrip('/')}{api_prefix}/dekey"
request = Request(url, headers={"Accept": "application/json;charset=UTF-8"}, method="GET")
try:
with urlopen(request, timeout=30) as response:
payload = json.loads(response.read().decode("utf-8"))
data = payload.get("data")
if not isinstance(data, str) or not data:
raise ValueError("dekey 接口未返回有效字符串")
return data
except HTTPError as err:
raise RuntimeError(f"获取 dekey 失败: {err.code}")
def split_dekey(dekey):
"""拆分 dekey"""
separator = base64.urlsafe_b64encode(b"-pk_separator-").decode("ascii")
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"
# fixed by xuhuanqing
'''
def rsa_encrypt(plain_text, public_key):
"""RSA 加密"""
import tempfile
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:
raise RuntimeError(proc.stderr.decode("utf-8", errors="replace").strip() or "RSA 加密失败")
return base64.b64encode(proc.stdout).decode("ascii")
'''
def rsa_encrypt(plain_text, public_key):
"""RSA加密,兼容OpenSSL pkeyutl参数规范,修复no private key报错"""
import tempfile
import subprocess
import base64
from shutil import which
from pathlib import Path
if which("openssl") is None:
raise RuntimeError("当前环境缺少 openssl 命令")
# 格式化标准PEM公钥
def format_public_key(pub_raw):
body = "\n".join(pub_raw[index:index + 64] for index in range(0, len(pub_raw), 64))
return f"-----BEGIN PUBLIC KEY-----\n{body}\n-----END PUBLIC KEY-----\n"
pub_pem = format_public_key(public_key)
# 创建临时公钥文件
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False, encoding="utf-8") as tmp_key:
tmp_key.write(pub_pem)
tmp_key_path = tmp_key.name
try:
# 标准规范参数:-inkey 指定公钥文件、-pubin 标识文件类型为公钥
proc = subprocess.run(
[
"openssl", "pkeyutl",
"-encrypt",
"-pubin",
"-inkey", tmp_key_path
],
input=plain_text.encode("utf-8"),
capture_output=True,
check=False
)
if proc.returncode != 0:
err_msg = proc.stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(f"RSA加密失败: {err_msg}")
return base64.b64encode(proc.stdout).decode("ascii")
finally:
# 执行完毕强制删除临时密钥文件
Path(tmp_key_path).unlink(missing_ok=True)
def login_with_password(base_url, api_prefix, username, password):
"""使用密码登录获取 x-de-token"""
dekey = fetch_dekey(base_url, api_prefix)
encrypted_pk, aes_key_str = split_dekey(dekey)
# 解密获取公钥
cmd = [
"openssl", "enc", f"-{aes_cipher_name(aes_key_str)}", "-d",
"-base64", "-A", "-nosalt",
"-K", aes_key_str.encode("utf-8").hex(),
"-iv", b"0000000000000000".hex(),
]
proc = subprocess.run(cmd, input=encrypted_pk.encode("utf-8"), capture_output=True, check=False)
if proc.returncode != 0:
raise RuntimeError("解密 dekey 失败")
public_key = proc.stdout.decode("utf-8").strip()
# RSA 加密用户名和密码
# fixed by xuhuanqing
# encrypted_name = rsa_encrypt(username, public_key)
# encrypted_pwd = rsa_encrypt(password, public_key)
encrypted_name = rsa_encrypt(username, public_key)
encrypted_pwd = rsa_encrypt(password, public_key)
login_origin = int(os.environ.get("DATAEASE_LOGIN_ORIGIN", "0"))
payload = {
"name": encrypted_name,
"pwd": encrypted_pwd,
"origin": login_origin,
}
url = f"{base_url.rstrip('/')}{api_prefix}/login/localLogin"
request = Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode("utf-8"))
if result.get("code") not in (None, 0):
raise RuntimeError(f"登录失败: {result.get('msg', '未知错误')}")
# fixed by xuhuanqing
# return result.get("data")
return result.get("data")["token"]
except HTTPError as err:
body = err.read().decode("utf-8", errors="replace")
raise RuntimeError(f"登录请求失败: {err.code} - {body}")
def get_headers_token(x_de_token):
"""构建 Token 认证请求头"""
return {
"Accept": "application/json;charset=UTF-8",
"Content-Type": "application/json",
"X-DE-TOKEN": x_de_token,
}
def make_request(url, headers, payload=None, method="POST"):
"""发送 HTTP 请求"""
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
request = Request(url, data=data, headers=headers, method=method)
try:
with urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as err:
body = err.read().decode("utf-8", errors="replace")
raise RuntimeError(f"请求失败: {err.code} - {body}")
def list_datasets(base_url, api_prefix, headers):
"""列出所有数据集"""
url = f"{base_url.rstrip('/')}{api_prefix}/datasetTree/tree"
payload = {"busiFlag": "dataset"}
result = make_request(url, headers, payload)
nodes = result.get('data', [])
datasets = []
def collect_leaf(items):
for item in items:
if item.get('leaf'):
datasets.append({"name": item.get('name'), "id": item.get('id')})
children = item.get('children', [])
if children:
collect_leaf(children)
collect_leaf(nodes)
return datasets
def get_dataset_fields(base_url, api_prefix, headers, dataset_id):
"""获取数据集字段"""
# 先尝试详情接口
url = f"{base_url.rstrip('/')}{api_prefix}/datasetTree/details/{dataset_id}"
request = Request(url, headers=headers, method="GET")
try:
with urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode("utf-8"))
fields = result.get('data', {}).get('allFields', [])
if fields:
return fields
except Exception:
pass
# 回退到字段列表接口
url = f"{base_url.rstrip('/')}{api_prefix}/datasetField/listByDatasetGroup/{dataset_id}"
result = make_request(url, headers, method="POST")
return result.get('data', [])
def resolve_dataset_id(base_url, api_prefix, headers, name_or_id):
"""解析数据集名称为 ID"""
if str(name_or_id).isdigit() and len(str(name_or_id)) > 10:
return name_or_id
datasets = list_datasets(base_url, api_prefix, headers)
for ds in datasets:
if ds['name'] == name_or_id:
return ds['id']
raise ValueError(f"未找到数据集: {name_or_id}")
def main():
load_dotenv()
base_url = os.environ.get("DATAEASE_BASE_URL", "")
api_prefix = os.environ.get("DATAEASE_API_PREFIX", "/de2api")
access_key = os.environ.get("DATAEASE_ACCESS_KEY", "")
secret_key = os.environ.get("DATAEASE_SECRET_KEY", "")
username = os.environ.get("DATAEASE_USERNAME", "")
password = os.environ.get("DATAEASE_PASSWORD", "")
if not base_url:
print("Error: 请设置 DATAEASE_BASE_URL")
sys.exit(1)
parser = argparse.ArgumentParser(description="探索 DataEase 数据集和字段")
parser.add_argument("--list-datasets", action="store_true", help="列出所有数据集")
parser.add_argument("--dataset", type=str, help="查看指定数据集的字段")
args = parser.parse_args()
# 认证:优先 AK/SK,否则用密码登录
try:
if access_key and secret_key:
ask_auth = build_ask_auth(access_key, secret_key)
headers = get_headers_ask(ask_auth)
auth_mode = "AK/SK"
elif username and password:
x_de_token = login_with_password(base_url, api_prefix, username, password)
headers = get_headers_token(x_de_token)
auth_mode = "密码登录"
else:
print("Error: 请配置 AK/SK (DATAEASE_ACCESS_KEY + DATAEASE_SECRET_KEY) 或用户名密码 (DATAEASE_USERNAME + DATAEASE_PASSWORD)")
sys.exit(1)
except Exception as e:
print(f"认证失败: {e}")
sys.exit(1)
if args.list_datasets:
try:
datasets = list_datasets(base_url, api_prefix, headers)
print(json.dumps(datasets, ensure_ascii=False, indent=2))
except Exception as e:
print(f"获取数据集列表失败: {e}")
sys.exit(1)
elif args.dataset:
try:
dataset_id = resolve_dataset_id(base_url, api_prefix, headers, args.dataset)
fields = get_dataset_fields(base_url, api_prefix, headers, dataset_id)
result = [
{"name": f['name'], "id": f['id'], "type": f.get('deType'), "dataeaseName": f.get('dataeaseName')}
for f in fields
]
print(json.dumps(result, ensure_ascii=False, indent=2))
except Exception as e:
print(f"获取数据集字段失败: {e}")
sys.exit(1)
else:
parser.print_help()
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
import sys
import os
import json
import subprocess
# Add local path for engine import
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from multi_engine import MultiDataEaseChartEngine
# --- Configuration from Environment ---
def load_dotenv():
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
if os.path.exists(env_path):
with open(env_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
os.environ[key.strip()] = value.strip().strip('"').strip("'")
load_dotenv()
ACCESS_KEY = os.environ.get("DATAEASE_ACCESS_KEY")
SECRET_KEY = os.environ.get("DATAEASE_SECRET_KEY")
BASE_URL = os.environ.get("DATAEASE_BASE_URL")
# Screenshot configuration - auto-detect OpenClaw workspace for MEDIA: display
def _default_output_dir():
"""优先使用 OpenClaw workspace 目录,否则用 skill 本地 output 目录"""
env_dir = os.environ.get("DATAEASE_OUTPUT_DIR")
if env_dir:
return env_dir
home = os.path.expanduser("~")
workspace_dir = os.path.join(home, ".openclaw", "workspace", "dataease-output")
if os.path.isdir(os.path.join(home, ".openclaw", "workspace")):
os.makedirs(workspace_dir, exist_ok=True)
return workspace_dir
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output")
OUTPUT_DIR = _default_output_dir()
DEFAULT_PIXEL = "2560*1440" # 更高分辨率,图片更清晰
def capture_dashboard(dashboard_id, output_format="jpeg"):
"""调用 capture_dashboard.py 进行截图"""
scripts_dir = os.path.dirname(os.path.abspath(__file__))
capture_script = os.path.join(scripts_dir, "capture_dashboard.py")
if not os.path.exists(capture_script):
return None, "Screenshot script not found"
# 确保输出目录存在
os.makedirs(OUTPUT_DIR, exist_ok=True)
# capture_dashboard.py 使用相同的 BASE_URL(不含 /de2api 后缀)
capture_base_url = BASE_URL.rstrip("/")
# 调用截图脚本
result_format = "0" if output_format == "jpeg" else "1" # 0=jpeg, 1=pdf
cmd = [
"python3", capture_script,
"capture",
"--resource-id", str(dashboard_id),
"--busi-type", "dashboard",
"--output-dir", OUTPUT_DIR,
"--result-format", result_format,
"--base-url", capture_base_url,
"--pixel", DEFAULT_PIXEL
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0:
output = json.loads(result.stdout)
if output.get("ok"):
return output.get("saved_file"), None
else:
return None, output.get("error", "Unknown capture error")
else:
return None, result.stderr or "Capture failed"
except subprocess.TimeoutExpired:
return None, "Capture timeout"
except Exception as e:
return None, str(e)
def main():
if not all([ACCESS_KEY, SECRET_KEY, BASE_URL]):
print("Error: Missing required environment variables.")
sys.exit(1)
if len(sys.argv) < 3:
print("Usage: python3 multi_deploy.py <dashboard_title> <charts_json_config> [--no-screenshot]")
print("Example: python3 multi_deploy.py '综合分析' '[{\"type\":\"bar\",\"dataset_name\":\"ds1\",\"x_axis\":[\"f1\"],\"y_axis\":[\"f2\"]}]'")
sys.exit(1)
title = sys.argv[1]
try:
charts_config = json.loads(sys.argv[2])
except Exception as e:
print(f"Error parsing JSON config: {e}")
sys.exit(1)
# 检查是否禁用截图
no_screenshot = "--no-screenshot" in sys.argv
engine = MultiDataEaseChartEngine(BASE_URL, ACCESS_KEY, SECRET_KEY)
try:
did, url = engine.deploy_multi(title, charts_config)
print(f"\n✅ Successfully deployed multi-chart dashboard!")
print(f"Dashboard ID: {did}")
print(f"URL: {url}")
# 自动截图
if not no_screenshot:
print("\n📸 Capturing screenshot...")
screenshot_path, error = capture_dashboard(did)
if screenshot_path:
print(f"Screenshot saved: {screenshot_path}")
# 输出 JSON 结果供 Agent 解析
result = {
"ok": True,
"dashboard_id": str(did),
"url": url,
"screenshot": screenshot_path,
"title": title,
"charts_count": len(charts_config)
}
print(f"\n__RESULT_JSON__\n{json.dumps(result, ensure_ascii=False)}\n__END_JSON__")
else:
print(f"⚠️ Screenshot failed: {error}")
result = {
"ok": True,
"dashboard_id": str(did),
"url": url,
"screenshot": None,
"screenshot_error": error,
"title": title,
"charts_count": len(charts_config)
}
print(f"\n__RESULT_JSON__\n{json.dumps(result, ensure_ascii=False)}\n__END_JSON__")
except Exception as e:
print(f"\n❌ Deployment failed: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()
+247
View File
@@ -0,0 +1,247 @@
import json, re, time, random, os, sys, requests
from typing import List, Dict, Any
# Local SDK client
from client import DataEaseClient
from engine import DataEaseChartEngine
class MultiDataEaseChartEngine(DataEaseChartEngine):
def __init__(self, base_url, ak, sk):
super().__init__(base_url, ak, sk)
def extract_chart_payload(self, chart_type: str, dataset_id: str, x_names: list, y_names: list, view_id: str, layout: dict, title: str = None):
"""
Reads the single chart template, applies parameters, and extracts the componentData & canvasViewInfo.
"""
base_dir = os.path.dirname(os.path.abspath(__file__))
tpl_dir = os.path.join(base_dir, "..", "templates", f"chart_{chart_type}")
if not os.path.exists(tpl_dir):
raise FileNotFoundError(f"Template directory {tpl_dir} not found")
with open(os.path.join(tpl_dir, "template.j2")) as f:
template_str = f.read()
with open(os.path.join(tpl_dir, "params.json")) as f:
raw_params = json.load(f)
# Build Context (Flatten params + dynamic IDs)
ctx = {}
def flatten_dict(d):
items = {}
for k, v in d.items():
if isinstance(v, dict):
items.update(flatten_dict(v))
elif not k.startswith("_"):
items[k] = v
return items
ctx.update(flatten_dict(raw_params))
ctx.update(self.get_dataset_ctx(dataset_id, x_names, y_names))
# Runtime Randomization
# SCENE_ID is usually "0" for base
ctx.update({"VIEW_ID": view_id, "SCENE_ID": "0"})
# Sub placeholder
def rep(m):
k = m.group(1).strip()
if k in ctx:
return str(ctx[k])
return m.group(0)
rendered = re.sub(r"\{\{\s*(\w+)\s*\}\}", rep, template_str)
payload = json.loads(rendered)
# Extract canvasViewInfo
canvas_view_info = payload.get("canvasViewInfo", {})
target_view_info = canvas_view_info.get(view_id)
if not target_view_info:
raise ValueError(f"View ID {view_id} not found in the rendered template!")
# Override Chart Title in view_info if provided
if title:
target_view_info["title"] = title
# --- Fix: Robust field name replacement ---
def deep_update_field_names(obj, target_id, new_name):
if isinstance(obj, dict):
if str(obj.get("id")) == str(target_id):
obj["name"] = new_name
if "description" in obj: obj["description"] = new_name
if "originName" in obj: obj["originName"] = new_name
if "dbFieldName" in obj: obj["dbFieldName"] = None
if "optionLabel" in obj:
obj["optionLabel"] = new_name + (obj["optionLabel"].partition("(")[1] + obj["optionLabel"].partition("(")[2] if "(" in obj["optionLabel"] else "")
if "optionShowName" in obj:
obj["optionShowName"] = new_name + (obj["optionShowName"].partition("(")[1] + obj["optionShowName"].partition("(")[2] if "(" in obj["optionShowName"] else "")
for v in obj.values():
deep_update_field_names(v, target_id, new_name)
elif isinstance(obj, list):
for item in obj:
deep_update_field_names(item, target_id, new_name)
dataset_ctx = self.get_dataset_ctx(dataset_id, x_names, y_names)
for i, x_name in enumerate(x_names):
f_id = dataset_ctx.get(f"XAXIS{'' if i==0 else i+1}_FIELD_ID")
if f_id: deep_update_field_names(target_view_info, f_id, x_name)
for i, y_name in enumerate(y_names):
f_id = dataset_ctx.get(f"YAXIS{'' if i==0 else i+1}_FIELD_ID")
if f_id: deep_update_field_names(target_view_info, f_id, y_name)
def brute_force_replace(obj, search_list, replace_list):
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, str):
for s, r in zip(search_list, replace_list):
if s in v: obj[k] = v.replace(s, r)
else: brute_force_replace(v, search_list, replace_list)
elif isinstance(obj, list):
for item in obj: brute_force_replace(item, search_list, replace_list)
if x_names and y_names:
brute_force_replace(target_view_info, ["访问平台", "访问次数", "浏览量"], [x_names[0], y_names[0], y_names[0]])
# --- End Fix ---
# Extract componentData
component_data_str = payload.get("componentData", "[]")
components = json.loads(component_data_str)
if not components:
raise ValueError(f"No component data found in the rendered template")
# We only take the first component as it was a single chart template
target_component = components[0]
# Override component name/label if title provided
if title:
target_component["name"] = title
target_component["label"] = title
# Override layout
if "layout" in layout:
layout = layout["layout"] # in case it's nested
if "style" not in target_component:
target_component["style"] = {}
if "x" in layout: target_component["x"] = layout["x"]
if "y" in layout: target_component["y"] = layout["y"]
if "sizeX" in layout: target_component["sizeX"] = layout["sizeX"]
if "sizeY" in layout: target_component["sizeY"] = layout["sizeY"]
if "width" in layout: target_component["style"]["width"] = layout["width"]
if "height" in layout: target_component["style"]["height"] = layout["height"]
if "left" in layout: target_component["style"]["left"] = layout["left"]
if "top" in layout: target_component["style"]["top"] = layout["top"]
return target_component, target_view_info
def deploy_multi(self, title: str, charts_config: List[Dict[str, Any]]):
"""
Deploy multiple charts into a single dashboard.
charts_config supports optional 'layout'. If missing, it will auto-layout.
"""
content_id = self.rand_id()
board_name = f"{title}_{int(time.time())}"
# Load base dashboard style from template
base_dir = os.path.dirname(os.path.abspath(__file__))
dashboard_tpl_path = os.path.join(base_dir, "..", "templates", "dashboard", "base.json")
if os.path.exists(dashboard_tpl_path):
with open(dashboard_tpl_path) as f:
base_tpl = json.load(f)
canvas_style_data = json.loads(base_tpl.get("canvasStyleData", "{}"))
else:
canvas_style_data = {
"width": 1920, "height": 1080, "screenAdaptor": "widthFirst",
"dashboard": {"gap": "yes", "gapSize": 5, "matrixBase": 4}
}
final_component_data = []
final_canvas_view_info = {}
active_view_ids = []
# Auto-layout calculation constants
GRID_COLS = 72
CHART_WIDTH = 36
CHART_HEIGHT = 14
# Process each chart sequentially
for idx, c_conf in enumerate(charts_config):
view_id = self.rand_id()
active_view_ids.append(view_id)
# Calculate auto-layout if not provided
layout = c_conf.get("layout", {})
if not layout:
row = idx // 2
col = idx % 2
layout = {
"x": col * CHART_WIDTH + 1,
"y": row * CHART_HEIGHT + 1,
"sizeX": CHART_WIDTH,
"sizeY": CHART_HEIGHT,
"width": 519, "height": 65, # Standard relative sizes
"left": col * 519, "top": row * 65
}
comp_data, view_info = self.extract_chart_payload(
chart_type=c_conf["type"],
dataset_id=c_conf["dataset_name"],
x_names=c_conf.get("x_axis", []),
y_names=c_conf.get("y_axis", []),
view_id=view_id,
layout=layout,
title=c_conf.get("title")
)
comp_data["_dragId"] = idx
final_component_data.append(comp_data)
final_canvas_view_info[view_id] = view_info
dashboard_payload = {
"id": None,
"name": board_name,
"type": "dashboard",
"status": 0,
"dataState": "ready",
"selfWatermarkStatus": True,
"checkVersion": "2.10.20",
"pid": "0",
"mobileLayout": False,
"canvasStyleData": json.dumps(canvas_style_data, separators=(',', ':'), ensure_ascii=False),
"componentData": json.dumps(final_component_data, separators=(',', ':'), ensure_ascii=False),
"canvasViewInfo": final_canvas_view_info,
"contentId": content_id
}
headers = self.client._get_headers()
print(f"Deploying multi-chart dashboard '{title}' with {len(charts_config)} charts...")
save_r = requests.post(
f"{self.base_url}{self.api_prefix}/dataVisualization/saveCanvas",
headers=headers, json=dashboard_payload, verify=False, timeout=30
)
if save_r.status_code != 200:
raise Exception(f"saveCanvas failed: {save_r.text}")
dashboard_id = str(save_r.json()["data"])
# Publish
requests.post(
f"{self.base_url}{self.api_prefix}/dataVisualization/updatePublishStatus",
headers=headers, verify=False, timeout=30,
json={
"id": dashboard_id,
"name": board_name,
"activeViewIds": active_view_ids,
"status": 1,
"type": "dashboard",
"mobileLayout": False
}
)
return dashboard_id, f"{self.base_url}/#/preview?dvId={dashboard_id}&dvType=dashboard&ignoreParams=true"
return dashboard_id, f"{self.base_url}/#/preview?dvId={dashboard_id}&dvType=dashboard&ignoreParams=true"