koishi-plugin-shotkit
把 HTML、本地文件或 URL 渲染成 PNG / WebP 的 Koishi 渲染服务,内核是 ShotKit(裁切到极限的 WebKit):单进程、无浏览器、无 node-gyp、无安装后下载。
服务名是 shotkit(ctx.shotkit),不是 puppeteer。调用方式和 ctx.puppeteer 一样,名字不一样,所以两者可以同时装、不会抢服务名。需要让写死 ctx.puppeteer 的插件也能用上时,打开配置里的 providePuppeteer(默认关闭,原因见下文)。
页面 JavaScript 永不执行。这不是缺陷而是内核的边界:它换来了 40 MB 左右的常驻内存和 40 毫秒级的单张渲染。
安装
npm install koishi-plugin-shotkit
引擎 @pixel.js/shotkit 是本插件的依赖,会自动装上,并按平台拉取六个平台包中的一个(Windows / Linux / macOS,x64 / arm64)。没有 npm 的时代价是 16 MB 左右的原生包,装完即用,不需要下载浏览器。
在 Koishi 控制台里启用 shotkit 即可。
如果这个环境跑不了 npm(锁死的镜像、坏掉的工具链、离线仓库镜像),可以绕过包管理器直接从 registry 取平台包:
node scripts/vendor-engine.mjs # 装到本插件的 node_modules
node scripts/vendor-engine.mjs --registry https://registry.npmmirror.com
node scripts/vendor-engine.mjs 0.4.1 # 指定版本
快速开始
import { Context, h } from 'koishi'
export const inject = ['shotkit']
export function apply(ctx: Context) {
ctx.command('card').action(async () => {
// 返回消息元素字符串,和 ctx.puppeteer.render 的返回值完全一致
return await ctx.shotkit.render('<div style="padding:24px;font-family:微软雅黑">你好,世界</div>')
})
}
只想拿 Buffer 自己发图:
const { image, stats } = await ctx.shotkit.screenshot({
html: '<div id="card">卡片</div>',
selector: '#card',
type: 'webp',
quality: 90,
scale: 2,
})
await ctx.bots[0].sendMessage(channelId, h.image(image, 'image/webp'))
ctx.logger.info('渲染耗时 %d ms,%d 字节', Math.round(stats.timing.render), stats.bytes)
API
ctx.shotkit.render(content, callback?)
与 ctx.puppeteer.render 同形。
| 形式 | 行为 |
|---|---|
render(html) |
渲染 HTML,截取 body 元素,返回 <img src="data:image/png;base64,..."/> 字符串 |
render(html, async (page, next) => ...) |
把页面交给回调,回调里调用 next() 截整屏、next(handle) 截元素 |
// 只截 #card 这个元素
const message = await ctx.shotkit.render(html, async (page, next) => {
const handle = await page.$('#card')
return await next(handle)
})
// 整屏
const full = await ctx.shotkit.render(html, async (page, next) => await next())
选择器没命中时不会报错,会退回整屏截图,与 puppeteer 里「page.$ 返回 null 就截整屏」的行为一致。
ctx.shotkit.screenshot(options)
拿到原始结果:{ image: Buffer | null, stats: { bytes, timing: { render, total } } }。传了 path 时 image 为 null。
| 选项 | 类型 | 说明 |
|---|---|---|
html |
string | 要渲染的 HTML 字符串。与 file 二选一 |
file |
string | http(s) URL、file: URL 或本地路径。本地文件由 Node 读取,旁边的相对子资源照常解析 |
type |
png / webp / webp-lossless |
输出格式,默认 png。没有 jpeg |
quality |
number | 1 到 100,仅 webp 有效;png 传了会报错 |
scale |
number | 设备像素比,0.01 到 8,默认取配置里的 deviceScaleFactor |
viewport |
{ width, height } |
视口尺寸,默认取配置里的 viewport |
selector |
string | 只截第一个命中的元素;与 fullPage 互斥 |
fullPage |
boolean | 拉到文档高度;与 selector 互斥 |
omitBackground |
boolean | 保留透明像素,不铺白底 |
path |
string | 把图片写到该路径,结果里的 image 变成 null |
pageGotoParams.timeout |
number | 导航超时毫秒,默认取配置里的 timeout |
pageGotoParams.waitUntil |
load / networkidle |
内核总是等网络静默后再画,两者等价;puppeteer 的 networkidle0 / networkidle2 / domcontentloaded 也接受 |
allowFileAccess |
boolean | 允许页面加载 file: 子资源 |
baseURL |
string | html 的相对子资源基地址 |
mimeType |
string | html 或本地文件的 MIME 类型,按扩展名推断;不要带 charset 参数,写了内核就不再按 HTML 解析 |
userAgent |
string | 本次截图的 UA,覆盖配置里的 |
deviceScaleFactor |
number | scale 的别名 |
format |
string | type 的别名。传 jpeg 会抛错 |
timeout |
number | pageGotoParams.timeout 的别名 |
别名是为了让 koishi-plugin-kkkshot 那种写法({ selector, timeout, deviceScaleFactor, format, quality })直接可用。
未知选项一律 TypeError,不会被悄悄忽略:写错名字会立刻知道,而不是拿到一张不对的图。
ctx.shotkit.renderFile(file, options?)
返回 Buffer,用于「本地模板文件 + 元素裁切」这条最常见的卡片路径:
const buffer = await ctx.shotkit.renderFile('/path/to/card.html', {
selector: '#container',
deviceScaleFactor: 2,
format: 'png',
timeout: 15000,
})
ctx.shotkit.page()
返回一个页面对象。ShotKit 一次调用一张图,没有活的 DOM,所以「页面」记录的是要渲染什么:
| 方法 | 状态 |
|---|---|
setContent(html) |
可用,记录 HTML |
goto(url, options?) |
可用,记录 http(s)、file: 或本地路径 |
setViewport({ width, height, deviceScaleFactor }) |
可用,deviceScaleFactor 映射到内核的 scale |
setUserAgent(ua) |
可用 |
screenshot(options?) |
可用,返回 Buffer;支持 selector / fullPage / type / quality / omitBackground / path |
$(selector) |
可用,返回带 selector 的句柄,配合 next(handle) 截该元素 |
close() |
可用,标记页面关闭 |
isClosed() / captures |
可用 |
waitForNetworkIdle(options?) |
立即返回:内核本来就会等网络静默 |
waitForTimeout(ms) |
真的等这么多毫秒 |
evaluate / $eval / $$eval / waitForFunction |
抛 NotSupportedError,页面脚本永不执行 |
$$ |
抛 NotSupportedError,只有第一个匹配可寻址 |
clip 选项 |
抛 NotSupportedError,改用 selector |
boundingBox() |
抛 NotSupportedError,内核不暴露元素坐标 |
content / title / pdf / setCookie / addScriptTag / addStyleTag / click / type / hover / waitForSelector |
抛 NotSupportedError |
抛错信息里带着替代做法,例如 screenshot({ clip }) 会告诉你去用 screenshot({ selector })。
ctx.shotkit.browser
newPage()、pages()、close()、isConnected()、version()。close() 只关掉它发出去的页面;browser.process() 抛错,因为引擎跑在 Koishi 进程里。
ctx.shotkit.svg(options?)
与 ctx.puppeteer.svg 同一个 API(child / attr / data / line / circle / rect / text / g / fill),渲染方式换成内核截取根 svg 元素:
const element = await ctx.shotkit.svg({ size: 200 })
.circle(100, 100, 80, { style: 'fill: #3f7fd6' })
.render(ctx)
生命周期与状态
| 成员 | 说明 |
|---|---|
ctx.shotkit.start() |
加载原生插件并开始初始化内核;Koishi 启动时会自动调用 |
ctx.shotkit.stop() |
等在途截图结束并标记停止,下一次截图会自动重新启动 |
ctx.shotkit.status() |
{ running, enginePath, cacheDir: null, cacheActive: false } |
ctx.shotkit.executable |
已加载的 shot.node 路径 |
ctx.shotkit.captures |
本进程累计截图次数 |
配置
| 字段 | 默认 | 说明 |
|---|---|---|
viewport.width |
1280 |
默认视口宽度 |
viewport.height |
720 |
默认视口高度 |
deviceScaleFactor |
1 |
设备像素比,0.01 到 8;卡片类内容常用 2 |
userAgent |
空 | 每次截图的 User-Agent,留空用内核默认值 |
timeout |
30000 |
导航超时毫秒 |
injectCharset |
true |
给没有声明编码的 HTML 补 <meta charset="utf-8">,详见下文 |
enginePath |
空 | 自编译内核对 shot.node 的完整路径,等价于 SHOTKIT_NATIVE_PATH |
providePuppeteer |
false |
额外注册名为 puppeteer 的服务 |
htmlComponent |
false |
注册 <html> 消息元素 |
那个开关:providePuppeteer
打开后本插件会额外注册一个名为 puppeteer 的服务,实现就是 ctx.shotkit 的那个类。它存在的唯一理由是有些插件写死了 ctx.puppeteer,改不了源码。
关于「API 不完整会不会有兼容性问题」,这里的设计是:
- 兼容服务与
ctx.shotkit是同一个类、同一套选项处理,不存在「兼容版少一截 API」的情况。凡是ctx.shotkit做不到的(页面脚本、clip、jpeg、$$等等),在puppeteer名下同样做不到,而且抛的是同一条带替代方案的错误,不会静默给出另一张图。 - 所以风险不在「API 不完整」,而在内核边界:原本依赖
page.evaluate量高度、或依赖clip裁切的代码,在 shotkit 上会明确报错。这类代码通常是 puppeteer 的「自己开页面量尺寸」路径,见下文的排查表。 - 默认关闭是因为它会和
koishi-plugin-puppeteer抢同一个服务名。两者同时启用时后加载的生效,另一个静默失效,这不是受支持的组合,请只开一个。
htmlComponent 同理默认关闭:koishi-plugin-puppeteer 也注册 <html> 元素。打开后的用法一致:
h('html', { style: { padding: '12px' } }, [h('div', '内容')])
h('html', { src: 'https://example.com' })
从 ctx.puppeteer 迁移过来
| puppeteer 写法 | 在 shotkit 上 |
|---|---|
await ctx.puppeteer.render(html) |
await ctx.shotkit.render(html),一样 |
await ctx.puppeteer.render(html, cb) |
一样,next(handle) 走内核的元素截取 |
page.setContent / page.goto / page.setViewport / page.screenshot |
一样 |
page.screenshot({ clip }) |
改成 page.screenshot({ selector }) |
page.screenshot({ type: 'jpeg' }) |
改成 webp(配 quality)或 png |
const clip = await (await page.$(sel)).boundingBox() |
删掉,直接 page.screenshot({ selector: sel }) |
await page.evaluate(...) 量高度、改样式 |
无法照搬:内核不执行脚本。高度交给 viewport 与 fullPage,底色直接写进模板 |
page.waitForNetworkIdle() |
保留即可,立即返回 |
args / executablePath / headless |
没有浏览器,这些配置不存在 |
在 koishi-plugin-kkk 里使用
kkk 的渲染入口(src/karin/module/utils/Render/index.ts)优先使用本服务:装了 koishi-plugin-shotkit 就走内核,没装才退回 ctx.puppeteer。调用形状与原来的 kkkshot 快路径一致:
const buffer = await ctx.shotkit.renderFile(htmlPath, {
selector: '#container',
timeout,
deviceScaleFactor,
type: 'png',
})
配置上启用 shotkit 插件即可,providePuppeteer 不需要打开(kkk 走的是服务名 shotkit,不是 puppeteer)。
渲染耗时受模板影响极大。同一张 B 站视频卡片实测:
| 模板情况 | 内核耗时 |
|---|---|
| 原样(内联 3MB 未 purge 的 Tailwind v4 CSS) | 5.5 到 10 秒 |
去掉含 color-mix() / oklab 的规则 |
1.4 秒 |
去掉整段 <style> |
0.67 秒 |
也就是说,卡片模板把 CSS 收干净带来的收益,比换渲染器大得多。
中文、字体与编码
这两条是实测踩出来的,卡片渲染成乱码或字体不对时先看这里。
编码。 内核拿到的是一串字节加一个 MIME 类型。MIME 是纯 text/html 时,解析器会退回单字节旧编码,于是「中文」渲染成 䏿–‡。所以:
html选项:injectCharset打开时(默认)插件会自动补<meta charset="utf-8">,放在<head>里、<html>后面或片段开头,位置都是解析器认得的。file选项:模板文件自己要写<meta charset="utf-8">,插件不会去改你的文件。mimeType不要写text/html; charset=utf-8:内核把整串当类型用,带上参数就不再按 HTML 解析,选择器会直接命中不到元素。
字体。 实测(Windows x64,@pixel.js/shotkit 0.4.1):sans-serif、serif、monospace、system-ui 这几个泛型族全部落到同一个默认字体(看起来是衬线体),写具体字体名才有效。可用的例如 "Microsoft YaHei" / 微软雅黑、Arial / Helvetica、Segoe UI、SimSun / 宋体。所以卡片模板里请写具体字体名,不要指望 sans-serif:
body { font-family: "Microsoft YaHei", sans-serif; }
性能
本机实测(Windows x64,@pixel.js/shotkit 0.4.1,520x180 的卡片,scale: 2 输出 1136x228 PNG):
| 指标 | 数值 |
|---|---|
| 首次渲染(含内核初始化) | 354 ms |
| 之后单张 | 39 到 42 ms |
| 8 张并发提交 | 合计 329 ms(内核单线程 FIFO,串行执行) |
| 进程 RSS | 10 张之后约 99 MB(引擎自身约 22 MB) |
内核在一个进程里只有一条渲染线程和一个 FIFO 队列:并发提交不会更快,只是排队,Node 主事件循环不会被阻塞。原生崩溃会带走整个 Koishi 进程,这是进程内绑定的固有权衡;需要故障隔离就得用子进程或 CLI 的 --serve。
排错
| 现象 | 原因与处理 |
|---|---|
启动日志出现 the shotkit service needs the native engine package |
@pixel.js/shotkit 没装上,或当前平台没有预编译包。按日志里的命令安装,或用 enginePath 指向自编译的 shot.node |
中文渲染成 䏿–‡ |
HTML 没有编码声明且 injectCharset 被关了;本地模板文件请自己写 <meta charset="utf-8"> |
selector matched no element |
选择器没命中。ctx.shotkit.render 会自动退回整屏,直接调 screenshot 时会抛出来 |
| 卡片右下留白或底部多一条白边 | 用 selector 截元素盒子,别用 fullPage;需要底色时把底色写进模板,或 omitBackground: false |
is not available on the shotkit service |
用到了内核做不到的 API,错误信息里有替代写法,见上文迁移表 |
同时装了 koishi-plugin-puppeteer 又开了 providePuppeteer |
服务名冲突,关掉其中一个 |
开发
node scripts/build.mjs # src/ 编译到 lib/
node scripts/build.mjs --check # 只做类型检查
node scripts/smoke.mjs # 起一个真 Koishi 上下文跑完整接口,图片输出到 .smoke/
node scripts/vendor-engine.mjs # 不用包管理器,直接从 registry 取原生引擎
node scripts/link.mjs # 把本目录链接进 app 的 node_modules(koishi.yml 按包名加载时需要)
本地开发时,Koishi 是按包名 koishi-plugin-shotkit 去 app 的 node_modules 里找插件的。
yarn install(workspaces 里有 plugins/*)会建好这条链接;跑不了包管理器时用 node scripts/link.mjs,
它建的是目录链接而不是副本,改完代码重新 build 即可生效。
许可
MIT。引擎 ShotKit 由其仓库的许可条款约束。