init commit
This commit is contained in:
commit
534c570866
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
dist
|
||||
24
.env.example
Normal file
24
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.env
|
||||
.cache
|
||||
.idea
|
||||
|
||||
uploads/pdfs/*
|
||||
uploads/posters/*
|
||||
15
.puppeteerrc.cjs
Normal file
15
.puppeteerrc.cjs
Normal 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
28
Dockerfile
Normal 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
144
README.md
Normal 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
25
docker-compose.yml
Normal 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
214
lib/browser.js
Normal 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
35
lib/constants.js
Normal 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
76
lib/params.js
Normal 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
165
lib/routes.js
Normal 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
172
lib/storage.js
Normal 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
118
lib/utils.js
Normal 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
22
package.json
Normal 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
2156
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
64
server.mjs
Normal file
64
server.mjs
Normal 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}!`));
|
||||
Loading…
Reference in New Issue
Block a user