mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add html file upload mode to html-to-image tool
This commit is contained in:
@@ -125,6 +125,56 @@ export async function capturePage(url: string, options: CaptureOptions): Promise
|
||||
}) as Promise<Buffer>;
|
||||
}
|
||||
|
||||
export async function captureHtml(html: string, options: CaptureOptions): Promise<Buffer> {
|
||||
return queue.add(async () => {
|
||||
let b: Browser;
|
||||
try {
|
||||
b = await getBrowser();
|
||||
} catch (err) {
|
||||
recordCrash();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const page = await b.newPage({
|
||||
viewport: { width: options.viewportWidth, height: options.viewportHeight },
|
||||
isMobile: options.isMobile,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setContent(html, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT });
|
||||
await page.waitForLoadState("networkidle", { timeout: NETWORK_IDLE_GRACE }).catch(() => {});
|
||||
|
||||
if (options.fullPage) {
|
||||
const scrollHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||
const targetHeight = Math.min(scrollHeight, MAX_FULL_PAGE_HEIGHT);
|
||||
await page.setViewportSize({
|
||||
width: options.viewportWidth,
|
||||
height: targetHeight,
|
||||
});
|
||||
}
|
||||
|
||||
const type = options.format === "jpg" ? "jpeg" : options.format;
|
||||
const screenshotOpts: Parameters<typeof page.screenshot>[0] = {
|
||||
type: type as "jpeg" | "png",
|
||||
fullPage: false,
|
||||
};
|
||||
if (options.format !== "png") {
|
||||
screenshotOpts.quality = options.quality;
|
||||
}
|
||||
|
||||
return (await page.screenshot(screenshotOpts)) as Buffer;
|
||||
} catch (err) {
|
||||
if (!b.isConnected()) {
|
||||
recordCrash();
|
||||
browser = null;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
}) as Promise<Buffer>;
|
||||
}
|
||||
|
||||
export async function shutdownBrowser(): Promise<void> {
|
||||
if (browser?.isConnected()) {
|
||||
await browser.close().catch(() => {});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { capturePage, isBrowserAvailable } from "../../lib/browser-service.js";
|
||||
import { captureHtml, capturePage, isBrowserAvailable } from "../../lib/browser-service.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { validateFetchUrl } from "../../lib/ssrf.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
@@ -14,15 +14,25 @@ const DEVICE_PRESETS = {
|
||||
mobile: { width: 375, height: 812, isMobile: true },
|
||||
} as const;
|
||||
|
||||
const settingsSchema = z.object({
|
||||
url: z.string().url(),
|
||||
format: z.enum(["jpg", "png", "webp"]).default("png"),
|
||||
quality: z.number().min(1).max(100).default(90),
|
||||
fullPage: z.boolean().default(false),
|
||||
devicePreset: z.enum(["desktop", "tablet", "mobile", "custom"]).default("desktop"),
|
||||
viewportWidth: z.number().min(320).max(3840).default(1280),
|
||||
viewportHeight: z.number().min(320).max(2160).default(720),
|
||||
});
|
||||
const settingsSchema = z
|
||||
.object({
|
||||
url: z.string().url().optional(),
|
||||
html: z.string().min(1).max(5_000_000).optional(),
|
||||
format: z.enum(["jpg", "png", "webp"]).default("png"),
|
||||
quality: z.number().min(1).max(100).default(90),
|
||||
fullPage: z.boolean().default(false),
|
||||
devicePreset: z.enum(["desktop", "tablet", "mobile", "custom"]).default("desktop"),
|
||||
viewportWidth: z.number().min(320).max(3840).default(1280),
|
||||
viewportHeight: z.number().min(320).max(2160).default(720),
|
||||
})
|
||||
.refine((data) => data.url || data.html, {
|
||||
message: "Either url or html must be provided",
|
||||
path: ["url"],
|
||||
})
|
||||
.refine((data) => !(data.url && data.html), {
|
||||
message: "Provide either url or html, not both",
|
||||
path: ["url"],
|
||||
});
|
||||
|
||||
export function registerHtmlToImage(app: FastifyInstance) {
|
||||
app.post(
|
||||
@@ -50,13 +60,15 @@ export function registerHtmlToImage(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await validateFetchUrl(settings.url);
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "URL is not allowed",
|
||||
details: err instanceof Error ? err.message : "URL validation failed",
|
||||
});
|
||||
if (settings.url) {
|
||||
try {
|
||||
await validateFetchUrl(settings.url);
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "URL is not allowed",
|
||||
details: err instanceof Error ? err.message : "URL validation failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const preset =
|
||||
@@ -69,14 +81,18 @@ export function registerHtmlToImage(app: FastifyInstance) {
|
||||
};
|
||||
|
||||
try {
|
||||
const buffer = await capturePage(settings.url, {
|
||||
const captureOpts = {
|
||||
format: settings.format,
|
||||
quality: settings.quality,
|
||||
fullPage: settings.fullPage,
|
||||
viewportWidth: preset.width,
|
||||
viewportHeight: preset.height,
|
||||
isMobile: preset.isMobile,
|
||||
});
|
||||
};
|
||||
|
||||
const buffer = settings.html
|
||||
? await captureHtml(settings.html, captureOpts)
|
||||
: await capturePage(settings.url!, captureOpts);
|
||||
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
|
||||
Reference in New Issue
Block a user