140 lines
5.9 KiB
TypeScript
140 lines
5.9 KiB
TypeScript
import { isIP } from "node:net";
|
|
import { chromium } from "playwright-core";
|
|
import { JSDOM } from "jsdom";
|
|
import { Defuddle } from "defuddle/node";
|
|
import { assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
|
|
import { buildCleanHtml } from "@/lib/clean-pdf/template";
|
|
|
|
export class EmptyContentError extends Error {}
|
|
export class RenderError extends Error {}
|
|
|
|
/**
|
|
* Оборачивает промис в app-level таймаут: если `p` не завершится за `ms`,
|
|
* гонка завершится отказом с RenderError, и внешний `finally` (browser.close())
|
|
* освободит слот browserless вместо того, чтобы держать его до зависшего вызова.
|
|
*/
|
|
function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
const guard = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new RenderError(message)), ms);
|
|
timer.unref?.();
|
|
});
|
|
return Promise.race([p, guard]).finally(() => clearTimeout(timer)) as Promise<T>;
|
|
}
|
|
|
|
const GOTO_TIMEOUT_MS = 30_000;
|
|
const SETTLE_TIMEOUT_MS = 10_000;
|
|
const PDF_TIMEOUT_MS = 30_000;
|
|
const UA =
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
|
|
|
function browserWsUrl(): string {
|
|
const base = process.env.BROWSER_WS_URL;
|
|
if (!base) throw new RenderError("BROWSER_WS_URL не задан");
|
|
const token = process.env.BROWSERLESS_TOKEN;
|
|
return token ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : base;
|
|
}
|
|
|
|
export async function generateCleanPdf(opts: {
|
|
url: string;
|
|
format: "A4" | "Letter";
|
|
theme: "light" | "dark";
|
|
}): Promise<{ pdf: Buffer; title: string }> {
|
|
const target = await assertPublicUrl(opts.url);
|
|
|
|
const browser = await chromium.connectOverCDP(browserWsUrl()).catch((e) => {
|
|
throw new RenderError(`Браузер недоступен: ${e.message}`);
|
|
});
|
|
|
|
try {
|
|
const context = await browser.newContext({ userAgent: UA, viewport: { width: 1280, height: 800 } });
|
|
// Вторая линия SSRF-обороны: страница не может дёргать приватные адреса
|
|
// (литеральные IP и localhost; hostnames уже проверены до goto).
|
|
await context.route("**/*", (route) => {
|
|
try {
|
|
const u = new URL(route.request().url());
|
|
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
if (
|
|
(u.protocol !== "http:" && u.protocol !== "https:") ||
|
|
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
|
(isIP(host) !== 0 && isPrivateAddress(host))
|
|
) {
|
|
return route.abort();
|
|
}
|
|
} catch {
|
|
return route.abort();
|
|
}
|
|
return route.continue();
|
|
});
|
|
|
|
// context.route НЕ перехватывает WebSocket — отдельная защита от SSRF через ws://
|
|
await context.routeWebSocket("**/*", (ws) => {
|
|
try {
|
|
const u = new URL(ws.url());
|
|
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
if (
|
|
(u.protocol !== "ws:" && u.protocol !== "wss:") ||
|
|
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
|
(isIP(host) !== 0 && isPrivateAddress(host))
|
|
) {
|
|
ws.close();
|
|
return;
|
|
}
|
|
} catch {
|
|
ws.close();
|
|
return;
|
|
}
|
|
ws.connectToServer();
|
|
});
|
|
|
|
const page = await context.newPage();
|
|
await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => {
|
|
throw new RenderError(`Страница не открылась: ${e.message}`);
|
|
});
|
|
await page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
|
|
const rawHtml = await page.content();
|
|
const pageTitle = await page.title().catch(() => "");
|
|
|
|
const dom = new JSDOM(rawHtml, { url: target.href });
|
|
const article = await Defuddle(dom.window.document, target.href);
|
|
// DefuddleResponse (node_modules/defuddle/dist/types.d.ts) типизирует
|
|
// content/title/author/site/domain/wordCount как обязательные (не optional) поля —
|
|
// сам объект тоже не nullable (Defuddle() не возвращает null/undefined).
|
|
// Отсутствие контента выражается пустой строкой/нулевым wordCount, не отсутствием поля.
|
|
if (!article.content || article.wordCount < 10) {
|
|
throw new EmptyContentError("Не удалось выделить содержимое страницы");
|
|
}
|
|
|
|
const title = article.title || pageTitle || target.hostname;
|
|
const cleanHtml = buildCleanHtml({
|
|
title,
|
|
author: article.author || undefined,
|
|
site: article.site || article.domain || undefined,
|
|
url: target.href,
|
|
contentHtml: article.content,
|
|
theme: opts.theme,
|
|
});
|
|
|
|
const pdfPage = await context.newPage();
|
|
await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
|
|
// page.pdf() в playwright-core не принимает опцию timeout (проверено по
|
|
// node_modules/playwright-core/types/types.d.ts) и не имеет implicit-бага —
|
|
// оборачиваем в app-level withTimeout, чтобы зависание всё же дошло до
|
|
// finally (browser.close()) и не держало CONCURRENT-слот browserless.
|
|
const pdf = await withTimeout(
|
|
pdfPage.pdf({
|
|
format: opts.format,
|
|
printBackground: true,
|
|
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
|
|
}),
|
|
PDF_TIMEOUT_MS,
|
|
"Печать PDF не завершилась вовремя",
|
|
);
|
|
|
|
return { pdf, title };
|
|
} finally {
|
|
await browser.close().catch(() => {});
|
|
}
|
|
}
|