将渲染逻辑独立,支持扩展渲染器

This commit is contained in:
Kokomi 2023-04-18 02:37:38 +08:00
parent 9e20c1ccc9
commit 4efd53f59b
9 changed files with 187 additions and 210 deletions

View File

@ -1,3 +1,9 @@
# 3.0.2
* 3.6卡池以及图像武器别名等数据更新 **@cvs**
* 将渲染逻辑独立,支持扩展渲染器 **@ikuaki**
* icqq等依赖版本升级
# 3.0.1 # 3.0.1
* 添加`#版本`命令,用于查看更新记录 * 添加`#版本`命令,用于查看更新记录

View File

@ -1,60 +1,28 @@
import fs from 'node:fs' import Renderer from '../renderer/Renderer.js'
import path from 'path'
import { fileURLToPath } from 'url'
import cfg from '../config/config.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) /**
* 暂时保留对手工引用puppeteer.js的兼容
const rendererDir = path.join(__dirname, "../../renderers/"); * 后期会逐步废弃
* 只提供截图及分片截图功能
let rendererBackends = {}; */
export default {
const rendererProxyHandler = { // 截图
get(target, prop, receiver) { async screenshot (name, data = {}) {
if (!(prop in receiver)) { let renderer = Renderer.getRenderer()
logger.fatal("在类 " + target.constructor.name + " 上访问了未实现的方法或属性 " + prop + ", 请报告错误!"); let img = await renderer.render(name, data)
return undefined; return img ? segment.image(img) : img
}
return Reflect.get(...arguments);
}, },
}
async function registerRendererBackends() { // 分片截图
const subFolders = fs.readdirSync(rendererDir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()); async screenshots (name, data = {}) {
for (let subFolder of subFolders) { let renderer = Renderer.getRenderer()
const newRendererBackends = (await import(path.join(rendererDir, subFolder.name, 'index.js'))).default; data.multiPage = true
for (let rendererBackendName in newRendererBackends) { let imgs = await renderer.render(name, data) || []
rendererBackends[rendererBackendName] = newRendererBackends[rendererBackendName]; let ret = []
logger.mark("[渲染后端加载]: 导入 " + rendererBackendName); for (let img of imgs) {
ret.push(img ? segment.image(img) : img)
} }
return ret.length > 0 ? ret : false
} }
} }
function selectRendererBackend() {
let rendererBackendName = cfg.renderer?.name;
// 未指定,回退到 puppeteer
if (!rendererBackendName) rendererBackendName = "puppeteer";
let rendererBackendConstructor = rendererBackends[rendererBackendName];
if (!rendererBackendConstructor) {
logger.warn("未知的渲染后端 " + rendererBackendName + ", 回退到 puppeteer");
rendererBackendName = "puppeteer";
rendererBackendConstructor = rendererBackends[rendererBackendName];
}
if (!rendererBackendConstructor.isImportable()) {
logger.warn("渲染后端 " + rendererBackendName + " 不可用 (导入失败), 回退到 puppeteer");
rendererBackendName = "puppeteer";
rendererBackendConstructor = rendererBackends[rendererBackendName];
}
logger.mark("当前渲染后端:" + rendererBackendName);
return rendererBackendConstructor;
}
async function newRenderer() {
// 自动扫描,并注册渲染后端
await registerRendererBackends();
// 选择渲染后端
let rendererBackendConstructor = selectRendererBackend();
return new rendererBackendConstructor();
}
export default new Proxy(await newRenderer(), rendererProxyHandler);

40
lib/renderer/Renderer.js Normal file
View File

@ -0,0 +1,40 @@
import fs from 'node:fs'
import yaml from 'yaml'
import lodash from 'lodash'
import cfg from '../config/config.js'
import { Data } from '#miao'
let rendererBackends = {}
async function registerRendererBackends () {
const subFolders = fs.readdirSync(`${process.cwd()}/renderers`, { withFileTypes: true }).filter((dirent) => dirent.isDirectory())
for (let subFolder of subFolders) {
let name = subFolder.name
const rendererFn = await Data.importDefault(`/renderers/${name}/index.js`)
let configFile = `./renderers/${name}/config.yaml`
let rendererCfg = {}
if (fs.existsSync(configFile)) {
try {
rendererCfg = yaml.parse(fs.readFileSync(configFile, 'utf8'))
} catch (e) {
rendererCfg = {}
}
}
let renderer = rendererFn(rendererCfg)
if (!renderer.id || !renderer.type || !renderer.render || !lodash.isFunction(renderer.render)) {
logger.warn('渲染后端 ' + (renderer.id || subFolder.name) + ' 不可用')
}
rendererBackends[renderer.id] = renderer
logger.mark('[渲染后端加载]: 导入 ' + renderer.id)
}
}
await registerRendererBackends()
export default {
getRenderer () {
// TODO 渲染器降级
return rendererBackends[cfg.renderer?.name || 'puppeteer']
}
}

View File

@ -1,6 +1,6 @@
{ {
"name": "miao-yunzai", "name": "miao-yunzai",
"version": "3.0.1", "version": "3.0.2",
"author": "Yoimiya-Kokomi, Le-niao", "author": "Yoimiya-Kokomi, Le-niao",
"description": "QQ group Bot", "description": "QQ group Bot",
"main": "app.js", "main": "app.js",

View File

@ -1,6 +1,6 @@
* *
!.gitignore !.gitignore
!base_renderer.js
!puppeteer !puppeteer
!puppeteer/** !puppeteer/**
puppeteer/config.yaml

View File

@ -1,28 +0,0 @@
export default class BaseRenderer {
/**
* 截图
*/
async screenshot(name, data = {}) {
this.constructor._WARNING('screenshot(name, data)');
return false;
}
/**
* 分片截图
*/
async screenshots(name, data = {}) {
this.constructor._WARNING('screenshots(name, data)');
return false;
}
/**
* 该渲染后端是否可导入
*/
static isImportable() {
return false;
}
static _WARNING(fName) {
logger.fatal('方法 "' + fName + ' 在类 ' + this.name + ' 中未被实现,请报告错误!');
}
}

View File

@ -0,0 +1,15 @@
# 如需自定义,复制此文件为 config.yaml 进行配置
# 更新配置后需要重启
# chromium 地址
chromiumPath:
# headless
headless: true
# puppeteer启动args
args:
- --disable-gpu
- --disable-setuid-sandbox
- --no-sandbox
- --no-zygote

View File

@ -1,7 +1,22 @@
import PuppeteerRenderer from './puppeteer_renderer.js'; import Puppeteer from './lib/puppeteer.js'
const newRendererBackends = { /**
'puppeteer': PuppeteerRenderer *
}; * @param config 本地config.yaml的配置内容
* @returns renderer 渲染器对象
* @returns renderer.id 渲染器ID对应renderer中选择的id
* @returns renderer.type 渲染类型保留字段暂时支持image
* @returns renderer.render 渲染入口
*/
export default function (config) {
// TODO Puppeteer待简化重构
const PuppeteerRender = new Puppeteer(config)
export default newRendererBackends; return {
id: 'puppeteer',
type: 'image',
async render (name, data) {
return await PuppeteerRender.screenshot(name, data)
}
}
}

View File

@ -3,9 +3,9 @@ import os from 'node:os'
import lodash from 'lodash' import lodash from 'lodash'
import template from 'art-template' import template from 'art-template'
import chokidar from 'chokidar' import chokidar from 'chokidar'
// 暂时保留对原config的兼容
import cfg from '../../lib/config/config.js' import cfg from '../../lib/config/config.js'
import common from '../../lib/common/common.js' import { Data } from '#miao'
import BaseRenderer from '../base_renderer.js'
const _path = process.cwd() const _path = process.cwd()
@ -14,9 +14,8 @@ let puppeteer = {}
// mac地址 // mac地址
let mac = '' let mac = ''
export default class PuppeteerRenderer extends BaseRenderer { export default class PuppeteerRenderer {
constructor() { constructor (config) {
super()
this.browser = false this.browser = false
this.lock = false this.lock = false
this.shoting = [] this.shoting = []
@ -25,18 +24,17 @@ export default class PuppeteerRenderer extends BaseRenderer {
/** 截图次数 */ /** 截图次数 */
this.renderNum = 0 this.renderNum = 0
this.config = { this.config = {
headless: true, headless: Data.def(config.headless, true),
args: [ args: Data.def(config.args, [
'--disable-gpu', '--disable-gpu',
'--disable-setuid-sandbox', '--disable-setuid-sandbox',
'--no-sandbox', '--no-sandbox',
'--no-zygote' '--no-zygote'
] ])
} }
if (cfg?.bot?.chromium_path || config.chromiumPath) {
if (cfg.bot?.chromium_path) {
/** chromium其他路径 */ /** chromium其他路径 */
this.config.executablePath = cfg.bot.chromium_path this.config.executablePath = cfg.bot?.chromium_path || config.chromiumPath
} }
this.html = {} this.html = {}
@ -44,15 +42,13 @@ export default class PuppeteerRenderer extends BaseRenderer {
this.createDir('./temp/html') this.createDir('./temp/html')
} }
async initPupp() { async initPupp () {
if (!lodash.isEmpty(puppeteer)) return puppeteer if (!lodash.isEmpty(puppeteer)) return puppeteer
puppeteer = (await import('puppeteer')).default puppeteer = (await import('puppeteer')).default
return puppeteer return puppeteer
} }
createDir(dir) { createDir (dir) {
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
let dirs = dir.split('/') let dirs = dir.split('/')
for (let idx = 1; idx <= dirs.length; idx++) { for (let idx = 1; idx <= dirs.length; idx++) {
@ -67,7 +63,7 @@ export default class PuppeteerRenderer extends BaseRenderer {
/** /**
* 初始化chromium * 初始化chromium
*/ */
async browserInit() { async browserInit () {
await this.initPupp() await this.initPupp()
if (this.browser) return this.browser if (this.browser) return this.browser
if (this.lock) return false if (this.lock) return false
@ -150,7 +146,7 @@ export default class PuppeteerRenderer extends BaseRenderer {
} }
// 获取Mac地址 // 获取Mac地址
async getMac() { async getMac () {
// 获取Mac地址 // 获取Mac地址
let mac = '00:00:00:00:00:00' let mac = '00:00:00:00:00:00'
try { try {
@ -185,12 +181,16 @@ export default class PuppeteerRenderer extends BaseRenderer {
* @param data.quality screenshot参数图片质量 0-100jpeg是可传默认90 * @param data.quality screenshot参数图片质量 0-100jpeg是可传默认90
* @param data.omitBackground screenshot参数隐藏默认的白色背景背景透明默认不透明 * @param data.omitBackground screenshot参数隐藏默认的白色背景背景透明默认不透明
* @param data.path screenshot参数截图保存路径截图图片类型将从文件扩展名推断出来如果是相对路径则从当前路径解析如果没有指定路径图片将不会保存到硬盘 * @param data.path screenshot参数截图保存路径截图图片类型将从文件扩展名推断出来如果是相对路径则从当前路径解析如果没有指定路径图片将不会保存到硬盘
* @return icqq img * @param data.multiPage 是否分页截图默认false
* @param data.multiPageHeight 分页状态下页面高度默认4000
* @param data.pageGotoParams 页面goto时的参数
* @return img/[]img 不做segment包裹
*/ */
async screenshot(name, data = {}) { async screenshot (name, data = {}) {
if (!await this.browserInit()) { if (!await this.browserInit()) {
return false return false
} }
const pageHeight = data.multiPageHeight || 4000
let savePath = this.dealTpl(name, data) let savePath = this.dealTpl(name, data)
if (!savePath) return false if (!savePath) return false
@ -198,26 +198,80 @@ export default class PuppeteerRenderer extends BaseRenderer {
let buff = '' let buff = ''
let start = Date.now() let start = Date.now()
let ret = []
this.shoting.push(name) this.shoting.push(name)
try { try {
const page = await this.browser.newPage() const page = await this.browser.newPage()
await page.goto(`file://${_path}${lodash.trim(savePath, '.')}`, data.pageGotoParams || {}) let pageGotoParams = lodash.extend({ timeout: 120000 }, data.pageGotoParams || {})
await page.goto(`file://${_path}${lodash.trim(savePath, '.')}`, pageGotoParams)
let body = await page.$('#container') || await page.$('body') let body = await page.$('#container') || await page.$('body')
// 计算页面高度
const boundingBox = await body.boundingBox()
// 分页数
let num = 1
let randData = { let randData = {
// encoding: 'base64',
type: data.imgType || 'jpeg', type: data.imgType || 'jpeg',
omitBackground: data.omitBackground || false, omitBackground: data.omitBackground || false,
quality: data.quality || 90, quality: data.quality || 90,
path: data.path || '' path: data.path || ''
} }
if (data.imgType === 'png') delete randData.quality if (data.multiPage) {
randData.type = 'jpeg'
num = Math.round(boundingBox.height / pageHeight) || 1
}
buff = await body.screenshot(randData) if (data.imgType === 'png') {
delete randData.quality
}
if (!data.multiPage) {
buff = await body.screenshot(randData)
/** 计算图片大小 */
const kb = (buff.length / 1024).toFixed(2) + 'kb'
logger.mark(`[图片生成][${name}][${this.renderNum}次] ${kb} ${logger.green(`${Date.now() - start}ms`)}`)
this.renderNum++
ret.push(buff)
} else {
// 分片截图
if (num > 1) {
await page.setViewport({
width: boundingBox.width,
height: pageHeight + 100
})
}
for (let i = 1; i <= num; i++) {
if (i !== 1 && i === num) {
await page.setViewport({
width: boundingBox.width,
height: parseInt(boundingBox.height) - pageHeight * (num - 1)
})
}
if (i !== 1 && i <= num) {
await page.evaluate(() => window.scrollBy(0, 7000))
}
if (num === 1) {
buff = await body.screenshot(randData)
} else {
buff = await page.screenshot(randData)
}
if (num > 2) await Data.sleep(200)
this.renderNum++
/** 计算图片大小 */
const kb = (buff.length / 1024).toFixed(2) + 'kb'
logger.mark(`[图片生成][${name}][${i}/${num}] ${kb}`)
ret.push(buff)
}
if (num > 1) {
logger.mark(`[图片生成][${name}] 处理完成`)
}
}
page.close().catch((err) => logger.error(err)) page.close().catch((err) => logger.error(err))
} catch (error) { } catch (error) {
logger.error(`图片生成失败:${name}:${error}`) logger.error(`图片生成失败:${name}:${error}`)
/** 关闭浏览器 */ /** 关闭浏览器 */
@ -225,112 +279,24 @@ export default class PuppeteerRenderer extends BaseRenderer {
await this.browser.close().catch((err) => logger.error(err)) await this.browser.close().catch((err) => logger.error(err))
} }
this.browser = false this.browser = false
buff = '' ret = []
return false return false
} }
this.shoting.pop() this.shoting.pop()
if (!buff) { if (ret.length === 0 || !ret[0]) {
logger.error(`图片生成为空:${name}`) logger.error(`图片生成为空:${name}`)
return false return false
} }
this.renderNum++
/** 计算图片大小 */
let kb = (buff.length / 1024).toFixed(2) + 'kb'
logger.mark(`[图片生成][${name}][${this.renderNum}次] ${kb} ${logger.green(`${Date.now() - start}ms`)}`)
this.restart() this.restart()
return segment.image(buff) return data.multiPage ? ret : ret[0]
}
/**
* `chromium` 分片截图
*/
async screenshots(name, data = {}) {
// FIXME: pageHeight 作为参数?
const pageHeight = 7000
await this.browserInit()
if (!this.browser) return false
const savePath = this.dealTpl(name, data)
if (!savePath) return false
const page = await this.browser.newPage()
try {
await page.goto(`file://${_path}${lodash.trim(savePath, '.')}`, { timeout: 120000 })
const body = await page.$('#container') || await page.$('body')
const boundingBox = await body.boundingBox()
const num = Math.round(boundingBox.height / pageHeight) || 1
if (num > 1) {
await page.setViewport({
width: boundingBox.width,
height: pageHeight + 100
})
}
const img = []
for (let i = 1; i <= num; i++) {
const randData = {
type: 'jpeg',
quality: 90
}
if (i !== 1 && i === num) {
await page.setViewport({
width: boundingBox.width,
height: parseInt(boundingBox.height) - pageHeight * (num - 1)
})
}
if (i !== 1 && i <= num) {
await page.evaluate(() => window.scrollBy(0, 7000))
}
let buff
if (num === 1) {
buff = await body.screenshot(randData)
} else {
buff = await page.screenshot(randData)
}
if (num > 2) await common.sleep(200)
this.renderNum++
/** 计算图片大小 */
const kb = (buff.length / 1024).toFixed(2) + 'kb'
logger.mark(`[图片生成][${name}][${this.renderNum}次] ${kb}`)
img.push(segment.image(buff))
}
await page.close().catch((err) => logger.error(err))
if (num > 1) {
logger.mark(`[图片生成][${name}] 处理完成`)
}
return img
} catch (error) {
logger.error(`图片生成失败:${name}:${error}`)
/** 关闭浏览器 */
if (this.browser) {
await this.browser.close().catch((err) => logger.error(err))
}
this.browser = false
}
} }
/** 模板 */ /** 模板 */
dealTpl(name, data) { dealTpl (name, data) {
let { tplFile, saveId = name } = data let { tplFile, saveId = name } = data
let savePath = `./temp/html/${name}/${saveId}.html` let savePath = `./temp/html/${name}/${saveId}.html`
@ -362,7 +328,7 @@ export default class PuppeteerRenderer extends BaseRenderer {
} }
/** 监听配置文件 */ /** 监听配置文件 */
watch(tplFile) { watch (tplFile) {
if (this.watcher[tplFile]) return if (this.watcher[tplFile]) return
const watcher = chokidar.watch(tplFile) const watcher = chokidar.watch(tplFile)
@ -375,7 +341,7 @@ export default class PuppeteerRenderer extends BaseRenderer {
} }
/** 重启 */ /** 重启 */
restart() { restart () {
/** 截图超过重启数时,自动关闭重启浏览器,避免生成速度越来越慢 */ /** 截图超过重启数时,自动关闭重启浏览器,避免生成速度越来越慢 */
if (this.renderNum % this.restartNum === 0) { if (this.renderNum % this.restartNum === 0) {
if (this.shoting.length <= 0) { if (this.shoting.length <= 0) {
@ -389,10 +355,5 @@ export default class PuppeteerRenderer extends BaseRenderer {
} }
} }
} }
static isImportable() {
// XXX: Puppeteer 为默认的,因此总可以被导入
return true;
}
} }