|
|
@@ -0,0 +1,152 @@
|
|
|
+import fs from "fs";
|
|
|
+import puppeteer from "puppeteer";
|
|
|
+
|
|
|
+/**
|
|
|
+ * 图表截图仅使用 Chromium/Chrome(Puppeteer)。
|
|
|
+ * - CHART_CHROME_SOURCE=bundled 仅使用 npm / Puppeteer 下载的 Chromium
|
|
|
+ * - CHART_CHROME_SOURCE=system 仅用系统或 CHROME_PATH
|
|
|
+ * - CHART_CHROME_SOURCE=auto 先 CHROME_PATH / 候选路径,找不到再用 bundled(默认)
|
|
|
+ *
|
|
|
+ * 部署后执行:npm run puppeteer:install
|
|
|
+ */
|
|
|
+
|
|
|
+const LINUX_CHROME_CANDIDATES = [
|
|
|
+ "/data/nodejs/chromium_138.0.7204.162_1-arm64_linux/chrome",
|
|
|
+ "/usr/bin/chromium-browser",
|
|
|
+ "/usr/bin/chromium",
|
|
|
+ "/usr/bin/google-chrome-stable",
|
|
|
+ "/usr/bin/google-chrome",
|
|
|
+];
|
|
|
+
|
|
|
+export function optionalEnvPath(key) {
|
|
|
+ const v = process.env[key];
|
|
|
+ if (v == null) return undefined;
|
|
|
+ const s = String(v).trim();
|
|
|
+ return s ? s : undefined;
|
|
|
+}
|
|
|
+
|
|
|
+function isExecutable(p) {
|
|
|
+ if (!p) return false;
|
|
|
+ try {
|
|
|
+ fs.accessSync(p, fs.constants.X_OK);
|
|
|
+ return fs.statSync(p).isFile();
|
|
|
+ } catch {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export function resolveChromeSourceMode() {
|
|
|
+ const v = String(process.env.CHART_CHROME_SOURCE || "auto")
|
|
|
+ .trim()
|
|
|
+ .toLowerCase();
|
|
|
+ if (v === "bundled" || v === "system" || v === "auto") return v;
|
|
|
+ return "auto";
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @returns {string|undefined} undefined = Puppeteer 自带 Chromium
|
|
|
+ */
|
|
|
+export function resolveChromeExecutablePath() {
|
|
|
+ const mode = resolveChromeSourceMode();
|
|
|
+
|
|
|
+ if (mode === "bundled") {
|
|
|
+ console.log(
|
|
|
+ "📦 使用 Puppeteer/npm 自带的 Chromium(需已执行 npm run puppeteer:install)",
|
|
|
+ );
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ const fromEnv = optionalEnvPath("CHROME_PATH");
|
|
|
+ if (fromEnv && isExecutable(fromEnv)) return fromEnv;
|
|
|
+ if (fromEnv) {
|
|
|
+ console.warn(`⚠️ CHROME_PATH 无效 "${fromEnv}",尝试系统候选路径`);
|
|
|
+ }
|
|
|
+ if (process.platform === "linux") {
|
|
|
+ for (const c of LINUX_CHROME_CANDIDATES) {
|
|
|
+ if (isExecutable(c)) {
|
|
|
+ console.warn(`↪️ 使用系统 Chromium: ${c}`);
|
|
|
+ return c;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mode === "system") {
|
|
|
+ throw new Error(
|
|
|
+ "CHART_CHROME_SOURCE=system 但未找到浏览器。请设置 CHROME_PATH 或改用 CHART_CHROME_SOURCE=bundled 并运行 npm run puppeteer:install",
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ console.warn(
|
|
|
+ "📦 未找到系统 Chrome,改用 Puppeteer 自带的 Chromium(npm run puppeteer:install)",
|
|
|
+ );
|
|
|
+ return undefined;
|
|
|
+}
|
|
|
+
|
|
|
+export function getProtocolTimeoutMs(fallbackMs) {
|
|
|
+ const parsed = Number.parseInt(process.env.CHART_RENDER_TIMEOUT_MS || "", 10);
|
|
|
+ return Number.isFinite(parsed) ? parsed : fallbackMs;
|
|
|
+}
|
|
|
+
|
|
|
+export function getBrowserLaunchWaitMs() {
|
|
|
+ const parsed = Number.parseInt(
|
|
|
+ process.env.PUPPETEER_BROWSER_LAUNCH_TIMEOUT_MS || "",
|
|
|
+ 10,
|
|
|
+ );
|
|
|
+ return Number.isFinite(parsed) ? parsed : 120000;
|
|
|
+}
|
|
|
+
|
|
|
+function buildChromeOptions(protocolTimeoutMs) {
|
|
|
+ const executablePath = resolveChromeExecutablePath();
|
|
|
+ const args = [
|
|
|
+ "--no-sandbox",
|
|
|
+ "--disable-setuid-sandbox",
|
|
|
+ "--disable-dev-shm-usage",
|
|
|
+ "--disable-gpu",
|
|
|
+ "--disable-extensions",
|
|
|
+ ];
|
|
|
+ const oz = String(process.env.CHART_CHROME_OZONE_HEADLESS || "")
|
|
|
+ .trim()
|
|
|
+ .toLowerCase();
|
|
|
+ if (oz === "1" || oz === "true" || oz === "yes") {
|
|
|
+ args.push("--use-gl=angle", "--ozone-platform=headless");
|
|
|
+ }
|
|
|
+ const extra = optionalEnvPath("CHROME_EXTRA_ARGS");
|
|
|
+ if (extra) {
|
|
|
+ for (const p of extra.split(/\s+/)) {
|
|
|
+ const t = p.trim();
|
|
|
+ if (t) args.push(t);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const opts = {
|
|
|
+ headless: true,
|
|
|
+ args,
|
|
|
+ protocolTimeout: protocolTimeoutMs,
|
|
|
+ timeout: getBrowserLaunchWaitMs(),
|
|
|
+ dumpio: false,
|
|
|
+ };
|
|
|
+ if (executablePath) opts.executablePath = executablePath;
|
|
|
+ return opts;
|
|
|
+}
|
|
|
+
|
|
|
+export async function launchChromeBrowser(protocolTimeoutMs) {
|
|
|
+ return puppeteer.launch(buildChromeOptions(protocolTimeoutMs));
|
|
|
+}
|
|
|
+
|
|
|
+export async function launchBrowserForChartService(protocolTimeoutMs) {
|
|
|
+ const browser = await launchChromeBrowser(protocolTimeoutMs);
|
|
|
+ return { browser, product: "chrome" };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 每次新开 Chromium(雷达图 / 3D 图等)
|
|
|
+ * @param {(browser: import('puppeteer').Browser) => Promise<unknown>} workFn
|
|
|
+ */
|
|
|
+export async function renderWithStandaloneChrome(workFn) {
|
|
|
+ const t = getProtocolTimeoutMs(500000);
|
|
|
+ const browser = await launchChromeBrowser(t);
|
|
|
+ try {
|
|
|
+ return await workFn(browser);
|
|
|
+ } finally {
|
|
|
+ await browser.close().catch(() => {});
|
|
|
+ }
|
|
|
+}
|