init commit

This commit is contained in:
shenyifei 2025-11-14 14:18:32 +08:00
commit 534c570866
16 changed files with 3274 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
Dockerfile
.dockerignore
dist

24
.env.example Normal file
View File

@ -0,0 +1,24 @@
# 存储配置
STORAGE_TYPE=local # 可选值: cos(腾讯云), oss(阿里云), local(本地存储)
# COS 配置 (腾讯云对象存储)
COS_SECRET_ID=
COS_SECRET_KEY=
COS_BUCKET=
COS_REGION=
COS_DOMAIN=
# OSS 配置 (阿里云对象存储)
OSS_REGION=
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=
# 本地存储配置
LOCAL_DOMAIN=http://localhost:3000
# 上传路径配置
UPLOAD_PATH=uploads
# 服务端口
PORT=3000

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules
.env
.cache
.idea
uploads/pdfs/*
uploads/posters/*

15
.puppeteerrc.cjs Normal file
View File

@ -0,0 +1,15 @@
const { join } = require('path');
// console.log('puppeteer config: ', process.env);
/**
* @type {import("puppeteer").Configuration}
*/
module.exports = {
cacheDirectory: join(__dirname, '.cache', 'puppeteer'),
chrome: {
downloadBaseUrl: 'https://registry.npmmirror.com/-/binary/chrome-for-testing'
},
"chrome-headless-shell": {
downloadBaseUrl: 'https://registry.npmmirror.com/-/binary/chrome-for-testing'
}
};

28
Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM node:20
WORKDIR /app
RUN set -ex \
&& sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \
&& apt update \
&& apt install -y --no-install-recommends libasound2 libgbm1 libxrandr2 libxfixes3 libxdamage1 libxcomposite1 libxkbcommon0 libcups2 libatk-bridge2.0-0 libdbus-1-3 libnss3 libnspr4 libcairo2 libpango-1.0-0 fonts-noto-cjk fonts-wqy-zenhei fonts-liberation \
&& rm -rf /var/lib/apt/lists/* \
&& apt clean
RUN npm config set registry https://registry.npmmirror.com \
&& npm install -g pnpm
COPY ./.puppeteerrc.cjs ./
COPY ./package.json ./
RUN pnpm install \
&& pnpm approve-builds
RUN ./node_modules/.bin/puppeteer browsers install chrome --base-url=https://registry.npmmirror.com/-/binary/chrome-for-testing
COPY ./lib ./lib
COPY ./server.mjs ./
EXPOSE 3000
CMD ["node", "server.mjs"]

144
README.md Normal file
View File

@ -0,0 +1,144 @@
# ERPTurbo Poster
基于 Puppeteer 的海报生成服务,支持多种云存储后端。
## 目录结构
```
.
├── lib
│ ├── browser.js # 浏览器管理模块
│ ├── storage.js # 存储服务模块
│ ├── params.js # 参数处理模块
│ ├── utils.js # 工具类模块
│ ├── routes.js # 路由处理模块
│ └── constants.js # 常量定义模块
├── .env # 环境变量配置文件
├── server.mjs # 主应用入口
└── package.json # 项目依赖配置
```
## 环境变量配置
项目使用 `.env` 文件配置环境变量,请根据需要修改 `.env` 文件中的配置项:
```env
# 存储配置
STORAGE_TYPE=local # 可选值: cos(腾讯云), oss(阿里云), local(本地存储)
# COS 配置 (腾讯云对象存储)
COS_SECRET_ID=AKIDCGvLmwUmQr3RxKACF1XKrGb1FFA1I2D8
COS_SECRET_KEY=1TYMagMxIxRFMNQEYcQxEhvyuiEY4mIa
COS_BUCKET=qimai-1251581441
COS_REGION=ap-shanghai
COS_DOMAIN=https://qimai-1251581441.cos.ap-shanghai.myqcloud.com/
# OSS 配置 (阿里云对象存储)
OSS_REGION=oss-cn-hangzhou
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=
# 本地存储配置
LOCAL_DOMAIN=http://localhost:3000
# 上传路径配置
UPLOAD_PATH=uploads/posters
```
## 安装依赖
```bash
npm install
```
## 启动服务
```bash
npm run dev
```
## 接口说明
### 1. 健康检查接口
- URL: `/status`
- Method: GET
- Description: 检查服务运行状态
### 2. 海报生成接口
- URL: `/api/v1/poster`
- Method: POST
- Description: 生成海报并上传到云存储
- Request Body:
```json
{
"webpage": "需要截图的网页URL",
"device": "设备缩放因子",
"width": "截图宽度",
"height": "截图高度",
"type": "图片类型(png/jpeg)",
"encoding": "编码方式(binary/base64)"
}
```
### 3. PDF下载接口
- URL: `/api/v1/pdf`
- Method: POST
- Description: 将网页保存为PDF并上传到云存储
- Request Body:
```json
{
"webpage": "需要生成PDF的网页URL",
"device": "设备缩放因子",
"width": "页面宽度",
"height": "页面高度",
"encoding": "编码方式(binary/base64)"
}
```
## 模块说明
### browser.js
浏览器管理模块,负责:
- 浏览器实例的创建和销毁
- 页面资源的管理和复用
- 浏览器连接的维护
### storage.js
存储服务模块,支持:
- 腾讯云COS对象存储
- 阿里云OSS对象存储
- 本地存储
- 统一的上传接口
- 自动根据配置选择存储类型
### params.js
参数处理模块,负责:
- 处理截图参数
- 处理上传参数
### utils.js
工具类模块,包含:
- 随机字符串生成函数
### routes.js
路由处理模块,负责:
- 处理健康检查接口
- 处理海报生成接口
- 处理PDF下载接口
### constants.js
常量定义模块,包含:
- 默认配置常量
- 样式定义常量
- HTTP状态码常量
- 字符串常量

25
docker-compose.yml Normal file
View File

@ -0,0 +1,25 @@
version: '3.8'
services:
app:
build: .
ports:
- "${PORT:-3000}:${PORT:-3000}"
environment:
- PORT=${PORT:-3000}
- STORAGE_TYPE=${STORAGE_TYPE:-local}
- COS_SECRET_ID=${COS_SECRET_ID}
- COS_SECRET_KEY=${COS_SECRET_KEY}
- COS_BUCKET=${COS_BUCKET}
- COS_REGION=${COS_REGION}
- COS_DOMAIN=${COS_DOMAIN}
- OSS_REGION=${OSS_REGION}
- OSS_ACCESS_KEY_ID=${OSS_ACCESS_KEY_ID}
- OSS_ACCESS_KEY_SECRET=${OSS_ACCESS_KEY_SECRET}
- OSS_BUCKET=${OSS_BUCKET}
- LOCAL_DOMAIN=${LOCAL_DOMAIN:-http://localhost:3000}
- UPLOAD_PATH=${UPLOAD_PATH:-uploads}
volumes:
- ./uploads:/app/uploads
- ./lib:/app/lib
restart: unless-stopped

214
lib/browser.js Normal file
View File

@ -0,0 +1,214 @@
import puppeteer from 'puppeteer';
import {existsSync} from 'fs';
const browserArgs = [
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-first-run',
'--no-sandbox',
'--no-zygote',
// '--single-process',
'--disable-background-networking',
'--enable-features=NetworkService,NetworkServiceInProcess',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-breakpad',
'--disable-client-side-phishing-detection',
'--disable-component-extensions-with-background-pages',
'--disable-default-apps',
'--disable-extensions',
'--disable-features=TranslateUI',
'--disable-hang-monitor',
'--disable-ipc-flooding-protection',
'--disable-popup-blocking',
'--disable-prompt-on-repost',
'--disable-renderer-backgrounding',
'--disable-sync',
'--force-color-profile=srgb',
'--metrics-recording-only',
'--no-first-run',
'--enable-automation',
'--password-store=basic',
'--use-mock-keychain',
];
// 尝试查找系统中已安装的 Chrome 或 Chromium 可执行文件路径
function getChromeExecutablePath() {
// 检查环境变量中是否指定了 Chromium 路径
if (process.env.PUPPETEER_EXECUTABLE_PATH) {
return process.env.PUPPETEER_EXECUTABLE_PATH;
}
const paths = [
// Windows
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
// macOS
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
// Linux
'/usr/bin/google-chrome',
'/usr/bin/chromium-browser',
'/usr/bin/chromium'
];
for (const path of paths) {
if (existsSync(path)) {
return path;
}
}
return null;
}
class BrowserManager {
constructor() {
this.browserList = [];
this.unusedBrowser = [];
this.pages = [];
}
async createBrowser(max_wse) {
// 尝试使用系统已安装的 Chrome/Chromium
const executablePath = getChromeExecutablePath();
const launchOptions = {
headless: false, // 默认使用无头模式
args: browserArgs
};
// 如果找到了系统 Chrome则使用它
if (executablePath) {
console.log('Using system Chrome:', executablePath);
launchOptions.executablePath = executablePath;
} else {
console.log('Using bundled Chromium');
}
const browser = await puppeteer.launch(launchOptions);
let pageCount = 0;
// 监听到打开了一个新窗口
browser.on("targetcreated", (data) => {
pageCount++;
});
browser.on("targetdestroyed", () => {
pageCount--;
if (pageCount === 0 && this.unusedBrowser.includes(browser.wsEndpoint())) {
console.log("命中unusedBrowser");
this.delBrowser(browser.wsEndpoint());
}
});
// max_wse
await browser.newPage();
await browser.newPage();
await browser.newPage();
const pages = await browser.pages();
for (const page of pages) {
this.pages.push(page);
}
this.browserList.push(browser.wsEndpoint());
return browser;
}
async initBrowser(max_wse) {
try {
await this.createBrowser(max_wse).then(() => {
console.log("初始化浏览器成功");
});
} catch (error) {
console.error("初始化浏览器失败:", error);
throw error;
}
// 每隔24小时去创建一个新的Browser实例并销毁第一个实例
setInterval(() => {
console.log(this.browserList.join(','));
console.log(this.unusedBrowser.join(','));
this.createBrowser().then(() => {
// 把第一个浏览器置为不可用
this.getFirstBrowser().then(browser => {
browser.pages().then((pages) => {
console.log(`第一个浏览器实例打开的页面数为${pages.length}`);
// 如果当前没有打开的页面创建Browser实例时会默认初始化一个页面所以这里判断的是<=1
if (pages.length <= 1) {
this.delBrowser(browser.wsEndpoint()).then(() => console.log("关闭浏览器成功"));
} else {
this.unusedBrowser.push(browser.wsEndpoint());
}
});
});
});
}, 24 * 60 * 60 * 1000);
}
/**
* 获取页面
*/
getTargetPage() {
while (true) {
const page = this.pages.pop();
if (page !== undefined) {
// 检查页面是否仍然有效
if (!page.isClosed()) {
return page;
}
// 如果页面已关闭,则丢弃并继续获取下一个
console.log('发现已关闭的页面,已丢弃');
} else {
// 如果没有可用页面,则创建新页面
console.log('没有可用页面,需要创建新页面');
// 这里应该触发创建新浏览器实例的逻辑
return null;
}
}
}
/**
* 归还页面
*/
returnTargetPage(target) {
// 只有当页面未关闭时才归还
if (target && !target.isClosed()) {
this.pages.push(target);
}
}
/**
* 从browserList和unusedBrowser中删除
* @param browser
*/
async delBrowser(browser) {
this.browserList = this.browserList.filter((item) => item !== browser);
this.unusedBrowser = this.unusedBrowser.filter((item) => item !== browser);
const browserWSEndpoint = browser;
const browserInstance = await puppeteer.connect({browserWSEndpoint});
await browserInstance.close();
console.log("删除浏览器 %o %o", this.browserList.length, this.unusedBrowser.length);
}
/**
* 始终返回最后一个浏览器实例
*/
async getBrowser() {
const browserWSEndpoint = this.browserList[this.browserList.length - 1];
return puppeteer.connect({browserWSEndpoint});
}
/**
* 始终返回第一个浏览器实例
*/
async getFirstBrowser() {
const browserWSEndpoint = this.browserList[0];
return puppeteer.connect({browserWSEndpoint});
}
}
export default BrowserManager;

35
lib/constants.js Normal file
View File

@ -0,0 +1,35 @@
/**
* 应用常量定义
*/
// 样式定义
const FONT_STYLE = '@font-face {\n' +
' font-family: pingfang;\n' +
' font-display: swap;\n' +
' src: url(\'//at.alicdn.com/t/webfont_iteuzxytem.eot\'); /* IE9*/\n' +
' src: url(\'//at.alicdn.com/t/webfont_iteuzxytem.eot?#iefix\') format(\'embedded-opentype\'), /* IE6-IE8 */\n' +
' url(\'//at.alicdn.com/t/webfont_iteuzxytem.woff2\') format(\'woff2\'),\n' +
' url(\'//at.alicdn.com/t/webfont_iteuzxytem.woff\') format(\'woff\'), /* chrome、firefox */\n' +
' url(\'//at.alicdn.com/t/webfont_iteuzxytem.ttf\') format(\'truetype\'), /* chrome、firefox、opera、Safari, Android, iOS 4.2+*/\n' +
' url(\'//at.alicdn.com/t/webfont_iteuzxytem.svg#NotoSansHans-DemiLight\') format(\'svg\'); /* iOS 4.1- */\n' +
'}';
// HTTP状态码
const HTTP_STATUS_OK = 200;
const HTTP_STATUS_ERROR = 500;
// 字符串常量
const RANDOM_STRING_CHARS = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
const RANDOM_STRING_DEFAULT_LENGTH = 32;
export {
// 样式定义
FONT_STYLE,
// HTTP状态码
HTTP_STATUS_OK,
HTTP_STATUS_ERROR,
// 字符串常量
RANDOM_STRING_CHARS,
RANDOM_STRING_DEFAULT_LENGTH
};

76
lib/params.js Normal file
View File

@ -0,0 +1,76 @@
/**
* 获取截图参数
* @param req 请求对象
* @param filename 文件名
* @param upload_path 上传路径
* @returns 截图参数
*/
function getParam(req, filename, upload_path) {
const clip = req.body.clip;
const quality = Math.min(Math.max(req.body.quality || 100, 0), 100); // 限制质量在0-100之间
const omit_background = req.body.omit_background || true;
const encoding = req.body.encoding || 'binary';
const type = req.body.type || 'png';
// 验证type参数
if (!['png', 'jpeg'].includes(type)) {
throw new Error('Invalid image type. Supported types: png, jpeg');
}
// 验证encoding参数
if (!['binary', 'base64'].includes(encoding)) {
throw new Error('Invalid encoding. Supported encodings: binary, base64');
}
let params = {
type: type,
clip: clip,
omitBackground: omit_background,
encoding: encoding,
};
if (type === 'jpeg') {
params = Object.assign(params, {quality: quality});
}
if (encoding === 'binary') {
params = Object.assign(params, {path: `${upload_path}/${filename}`});
}
console.log("params", new Date().getMilliseconds(), JSON.stringify(params));
return params;
}
/**
* 上传文件
* @param res 响应对象
* @param encoding 编码方式
* @param base64 文件base64数据
* @param type 文件类型
* @param filename 文件名
* @param storageManager 存储管理器
* @param basePath 应用根路径
* @param upload_path 上传路径
* @returns 上传结果
*/
async function upload(res, encoding, base64, type, filename, storageManager, basePath, upload_path) {
if (encoding === 'base64' && typeof base64 === 'string') {
return res.status(200).json({
name: filename,
path: base64,
});
}
try {
const result = await storageManager.upload(basePath, upload_path, filename);
console.log("response", new Date().getMilliseconds(), JSON.stringify(result));
return res.status(200).json(result);
} catch (err) {
throw err;
}
}
export {
getParam,
upload
};

165
lib/routes.js Normal file
View File

@ -0,0 +1,165 @@
import {fileURLToPath} from 'url';
import {dirname} from 'path';
import {getParam, upload} from './params.js';
import {randomString, jsonToHtml} from './utils.js';
import {FONT_STYLE} from './constants.js';
// 获取当前模块的目录名
const __filename = fileURLToPath(import.meta.url);
const basePath = dirname(dirname(__filename)); // 项目根目录
// 延迟获取 upload_path确保环境变量已被加载
let upload_path;
/**
* 健康检查接口
* @param {*} req
* @param {*} res
*/
function statusHandler(req, res) {
console.log("健康检查", new Date().getMilliseconds());
return res.status(200).json({});
}
/**
* 海报生成接口
* @param {*} req
* @param {*} res
* @param {*} browserManager
* @param {*} storageManager
*/
async function posterHandler(
req,
res,
browserManager,
storageManager
) {
// 确保在使用时获取最新的环境变量值
upload_path = process.env.UPLOAD_PATH || 'uploads';
upload_path = upload_path + '/posters'
// 参数校验
if (!req.body || !req.body.webpage) {
return res.status(400).json({
error: 'Missing required parameter: webpage'
});
}
const webpage = req.body.webpage;
const device = req.body.device || 1;
const width = req.body.width || 1920;
const height = req.body.height || 1080;
const type = req.body.type || 'png';
const encoding = req.body.encoding || 'binary';
const filename = 'poster_' + randomString(20) + '.' + type;
let param = getParam(req, filename, upload_path);
let base64;
const page = browserManager.getTargetPage();
try {
await page.setViewport({width: width, height: height, deviceScaleFactor: device, isMobile: true})
await page.goto(webpage, {
timeout: 30000,
waitUntil: 'networkidle0'
});
await page.addStyleTag({content: FONT_STYLE});
await page.screenshot(param).then((data) => {
base64 = data;
});
await upload(res, encoding, base64, type, filename, storageManager, basePath, upload_path);
// 状态码在主应用中处理
} catch (err) {
console.error('Poster generation error:', err);
return res.status(500).json({
error: 'Internal server error',
message: err.toString()
});
} finally {
// 确保只归还有效的页面
if (page && !page.isClosed()) {
browserManager.returnTargetPage(page);
}
}
}
/**
* PDF下载接口
* @param {*} req
* @param {*} res
* @param {*} browserManager
* @param {*} storageManager
*/
async function pdfHandler(
req,
res,
browserManager,
storageManager
) {
// 确保在使用时获取最新的环境变量值
upload_path = process.env.UPLOAD_PATH || 'uploads';
upload_path = upload_path + '/pdfs'
// 参数校验
if (!req.body || (!req.body.webpage && !req.body.html)) {
return res.status(400).json({
error: 'Missing required parameter: webpage or html'
});
}
const webpage = req.body.webpage;
let html = req.body.html;
const filename = 'pdf_' + randomString(20) + '.pdf';
let base64;
const page = browserManager.getTargetPage();
try {
// 根据参数选择加载方式
if (html) {
// 检查html是否为JSON格式如果是则转换为HTML字符串
if (typeof html === 'object' && html !== null) {
html = jsonToHtml(html);
}
// 直接使用HTML内容
await page.setContent(html, {
timeout: 30000,
});
} else {
// 导航到网页
await page.goto(webpage, {
timeout: 30000,
waitUntil: 'networkidle0'
});
}
await page.addStyleTag({content: FONT_STYLE});
// 生成PDF
await page.pdf({
format: 'A4',
printBackground: true,
path: `${upload_path}/${filename}`
});
// 复用上传函数处理PDF文件
await upload(res, 'binary', base64, 'pdf', filename, storageManager, basePath, upload_path);
} catch (err) {
console.error('PDF generation error:', err);
return res.status(500).json({
error: 'Internal server error',
message: err.toString()
});
} finally {
// 确保只归还有效的页面
if (page && !page.isClosed()) {
browserManager.returnTargetPage(page);
}
}
}
export {
statusHandler,
posterHandler,
pdfHandler
};

172
lib/storage.js Normal file
View File

@ -0,0 +1,172 @@
import COS from 'cos-nodejs-sdk-v5';
import OSS from 'ali-oss';
import fs from 'fs';
import path from 'path';
class StorageManager {
constructor(config) {
this.config = config;
this.storageType = (config.STORAGE_TYPE || 'local').toLowerCase();
// 初始化COS客户端
if (this.storageType === 'cos' && config.COS_SECRET_ID && config.COS_SECRET_KEY) {
const cosConfig = {
SecretId: config.COS_SECRET_ID,
SecretKey: config.COS_SECRET_KEY,
};
this.cosClient = new COS(cosConfig);
}
// 初始化OSS客户端
if (this.storageType === 'oss') {
const ossConfig = {
region: config.OSS_REGION,
accessKeyId: config.OSS_ACCESS_KEY_ID,
accessKeySecret: config.OSS_ACCESS_KEY_SECRET,
bucket: config.OSS_BUCKET
};
if (ossConfig.accessKeyId && ossConfig.accessKeySecret && ossConfig.region && ossConfig.bucket) {
this.ossClient = new OSS(ossConfig);
}
}
}
/**
* 上传文件
* @param basePath 应用根路径
* @param upload_path 上传路径
* @param filename 文件名
* @returns {Promise<*>}
*/
async upload(basePath, upload_path, filename) {
switch (this.storageType.toLowerCase()) {
case 'oss':
return this.uploadToOSS(basePath, upload_path, filename);
case 'cos':
return this.uploadToCOS(basePath, upload_path, filename);
case 'local':
return this.uploadToLocal(basePath, upload_path, filename);
default:
return this.uploadToCOS(basePath, upload_path, filename);
}
}
/**
* 上传文件到本地
* @param basePath 应用根路径
* @param upload_path 上传路径
* @param filename 文件名
* @returns {Promise<*>}
*/
async uploadToLocal(basePath, upload_path, filename) {
const date = new Date();
const pathDir = `${upload_path}/${date.getFullYear()}${date.getMonth() + 1}/${date.getDate()}`;
const localFilePath = `${basePath}/${upload_path}/${filename}`;
const targetDir = `${basePath}/${pathDir}`;
// 确保目标目录存在
await fs.promises.mkdir(targetDir, { recursive: true });
// 移动文件到目标位置
const targetPath = `${targetDir}/${filename}`;
await fs.promises.rename(localFilePath, targetPath);
// 构造访问URL
const localDomain = process.env.LOCAL_DOMAIN || 'http://localhost:3000';
const url = `${localDomain}/${pathDir}/${filename}`;
return {
name: filename,
path: url
};
}
/**
* 上传文件到COS
* @param basePath 应用根路径
* @param upload_path 上传路径
* @param filename 文件名
* @returns {Promise<*>}
*/
async uploadToCOS(basePath, upload_path, filename) {
const date = new Date();
const path = `${upload_path}/${date.getFullYear()}${date.getMonth() + 1}/${date.getDate()}`;
const localFilePath = `${basePath}/${upload_path}/${filename}`;
const cosDomain = this.config.COS_DOMAIN; // 保存this.config的引用
return new Promise((resolve, reject) => {
this.cosClient.putObject({
Bucket: this.config.COS_BUCKET,
Region: this.config.COS_REGION,
Key: `${path}/${filename}`,
StorageClass: 'STANDARD',
Body: fs.createReadStream(localFilePath),
}, function (err, data) {
fs.unlink(localFilePath, function (err) {
if (err) {
console.error('Failed to delete local file:', err);
}
});
if (err) {
reject(err);
} else {
const response = {
name: filename,
path: `${cosDomain}/${path}/${filename}`,
data: data
};
resolve(response);
}
});
});
}
/**
* 上传文件到OSS
* @param basePath 应用根路径
* @param upload_path 上传路径
* @param filename 文件名
* @returns {Promise<*>}
*/
async uploadToOSS(basePath, upload_path, filename) {
if (!this.ossClient) {
throw new Error('OSS client not initialized. Please check OSS configuration.');
}
const date = new Date();
const path = `${upload_path}/${date.getFullYear()}${date.getMonth() + 1}/${date.getDate()}`;
const fullPath = `${path}/${filename}`;
const localFilePath = `${basePath}/${upload_path}/${filename}`;
try {
const result = await this.ossClient.put(fullPath, localFilePath);
// 上传完成后删除本地文件
fs.unlink(localFilePath, function (err) {
if (err) {
console.error('Failed to delete local file:', err);
}
});
return {
name: filename,
path: result.url,
data: result
};
} catch (err) {
// 出错时也删除本地文件
fs.unlink(localFilePath, function (err) {
if (err) {
console.error('Failed to delete local file:', err);
}
});
throw err;
}
}
}
export default StorageManager;

118
lib/utils.js Normal file
View File

@ -0,0 +1,118 @@
/**
* 将JSON格式的HTML结构转换为HTML字符串
* @param {Object} json - JSON格式的HTML结构
* @returns {string} HTML字符串
*/
function jsonToHtml(json) {
if (!json || typeof json !== 'object') {
return '';
}
function parseElement(obj) {
if (obj === null || obj === undefined) {
return '';
}
if (typeof obj === 'string') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => parseElement(item)).join('');
}
// 检查是否为文本节点 (#text)
if ('#text' in obj) {
let tagName = 'div'; // 默认标签
let attributes = '';
let content = obj['#text'] || '';
// 处理属性和其他设置
for (const key in obj) {
if (key === '#text') continue;
if (key.startsWith('@')) {
// 属性 (@class, @id等)
const attrName = key.substring(1);
attributes += ` ${attrName}="${obj[key]}"`;
} else if (['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'section', 'header', 'footer', 'main', 'article', 'aside'].includes(key)) {
// 子元素标签
tagName = key;
content = parseElement(obj[key]);
}
}
return `<${tagName}${attributes}>${content}</${tagName}>`;
}
// 普通元素节点
let tagName = 'div'; // 默认标签
let attributes = '';
let content = '';
for (const key in obj) {
if (key.startsWith('@')) {
// 属性 (@class, @id等)
const attrName = key.substring(1);
attributes += ` ${attrName}="${obj[key]}"`;
} else if (['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'section', 'header', 'footer', 'main', 'article', 'aside', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th'].includes(key)) {
// 明确指定的标签类型
tagName = key;
content = parseElement(obj[key]);
} else if (key === 'style') {
// style标签特殊处理
content += `<style>${obj[key]}</style>`;
} else if (key === 'title') {
// title标签特殊处理
content += `<title>${obj[key]}</title>`;
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
// 其他嵌套对象
content += parseElement(obj[key]);
} else if (key !== '#text') {
// 其他属性
attributes += ` ${key}="${obj[key]}"`;
}
}
return `<${tagName}${attributes}>${content}</${tagName}>`;
}
// 处理顶层html结构
if (json.html) {
const headContent = json.html.head ? parseElement(json.html.head) : '';
const bodyContent = json.html.body ? parseElement(json.html.body) : '';
const headSection = headContent ? `<head>${headContent}</head>` : '<head></head>';
const bodySection = bodyContent ? `<body>${bodyContent}</body>` : '<body></body>';
return `<!DOCTYPE html><html>${headSection}${bodySection}</html>`;
}
if (json.head || json.body) {
const headContent = json.head ? parseElement(json.head) : '';
const bodyContent = json.body ? parseElement(json.body) : '';
const headSection = headContent ? `<head>${headContent}</head>` : '<head></head>';
const bodySection = bodyContent ? `<body>${bodyContent}</body>` : '<body></body>';
return `<!DOCTYPE html><html>${headSection}${bodySection}</html>`;
}
return parseElement(json);
}
/**
* 生成随机字符串
* @param {number} length - 字符串长度
* @returns {string} 随机字符串
*/
function randomString(length) {
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
export {
jsonToHtml,
randomString
};

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "puppeteer",
"version": "1.0.0",
"description": "",
"main": "server.mjs",
"type": "module",
"bin": "index.mjs",
"dependencies": {
"ali-oss": "^6.17.1",
"body-parser": "^2.2.0",
"cos-nodejs-sdk-v5": "^2.8.3",
"dotenv": "^8.2.0",
"express": "5.0.0",
"node-fetch": "^3.3.2",
"puppeteer": "24.29.1"
},
"scripts": {
"dev": "node server.mjs"
},
"author": "",
"license": "ISC"
}

2156
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

64
server.mjs Normal file
View File

@ -0,0 +1,64 @@
import express from 'express';
import bodyParser from 'body-parser';
import dotenv from 'dotenv';
// 配置 dotenv
dotenv.config();
import BrowserManager from './lib/browser.js';
import StorageManager from './lib/storage.js';
import {pdfHandler, posterHandler, statusHandler} from './lib/routes.js';
// 初始化存储管理器
const storageManager = new StorageManager(process.env);
// 初始化浏览器管理器
const browserManager = new BrowserManager();
await browserManager.initBrowser(2);
const app = express();
app.use(bodyParser.json({limit: '10mb'}));
// 提供上传文件的静态访问
app.use('/uploads', express.static('uploads'));
// 健康检查接口
app.get('/status', statusHandler);
// 添加安全头
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
next();
});
// 海报生成接口
app.post('/api/v1/poster', async function (req, res) {
// 基本的安全检查
if (!req.headers['content-type'] || !req.headers['content-type'].includes('application/json')) {
return res.status(400).json({error: 'Content-Type must be application/json'});
}
await posterHandler(
req,
res,
browserManager,
storageManager
);
});
// PDF生成接口
app.post('/api/v1/pdf', async function (req, res) {
// 基本的安全检查
if (!req.headers['content-type'] || !req.headers['content-type'].includes('application/json')) {
return res.status(400).json({error: 'Content-Type must be application/json'});
}
await pdfHandler(
req,
res,
browserManager,
storageManager
);
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Puppeteer app listening on port ${port}!`));