Chatbot截长图技术实现:从原理到生产环境避坑指南
背景痛点:为什么 Chatbot 截图这么难
做客服机器人、社群助手或 AI 伴侣的同学,几乎都收到过用户这样的反馈:
“能不能把刚才的对话打包发我?”
“我要投诉,请把完整记录截给我。”
看似简单的“截长图”,在 Chatbot 场景里却踩坑不断:
- 消息类型杂:文本、Markdown、富媒体卡片、语音转文字、表情包,样式差异大,DOM 结构随时变。
- 动态加载:下拉才渲染历史,或滚动到指定消息才拉取详情,直接截只能拿到“一屏”。
- 无限滚动:对话列表通常采用虚拟滚动,DOM 节点被反复回收,高度随时变化。
- 多端样式:桌面端宽屏、移动端小窗、暗黑模式,截图要保真,否则用户秒投诉“货不对板”。
- 长页面内存:一次截几千条消息,Canvas 尺寸爆炸,浏览器直接崩溃。
一句话:普通网页截长图方案,搬到 Chatbot 里会水土不服。
技术选型:HTML2Canvas、Puppeteer、PhantomJS 谁更适合
HTML2Canvas
优点:纯前端,无需服务端,隐私数据不出域。
缺点:- 只能截“已渲染”区域,虚拟滚动外的消息直接空白。
- 不支持 iframe 内嵌富媒体(很多卡片用 iframe)。
- 大画布在移动端容易 OOM。
适用:消息不多、已全量渲染、对清晰度要求不高的 H5 活动页。
PhantomJS
优点:老项目熟悉,API 简单。
缺点:- 2018 停止维护,内核停留在 Chrome 58,不支持现代 CSS(grid、flex 部分失效)。
- 社区停滞,新安全漏洞无人修。
结论:已入土,不建议新项目使用。
Puppeteer(Headless Chrome)
优点:- 真 Chrome 内核,CSS/ES2022 完全对齐生产环境。
- 支持设备模拟、网络拦截、滚动、懒加载触发。
- 活跃维护,问题一搜就有答案。
缺点: - 需要 Node 服务,占用内存 100~300 MB/实例。
- 要额外做集群隔离、权限管控。
适用:需要“所见即所得”的客服、质检、审计场景,也是本文主推方案。
核心实现:用 Puppeteer 做“分页截图 + 纵向拼接”
整体思路:
“让浏览器自己滚动,把对话一条条滚进视口,每滚一屏截一次,最后像拼火车一样拼成 PNG。”
文字版架构流程:
用户请求 ↓ Node API 接收参数(会话 ID、截图格式) ↓ 启动 Puppeteer 新浏览器实例 ↓ 打开聊天页面 + 注入身份 Cookie ↓ 循环滚动到顶部 → 记录当前视口图片 ↓ 反向滚动到最旧消息,再正向滚动到最新消息 ↓ 合并所有图片 → 上传 OSS → 返回 URL关键代码(Node 18 + Puppeteer 19):
// screenshot-service.js import puppeteer from 'puppeteer'; import { concatImages } from 'concat-images'; // 依赖 sharp,性能优于 Jimp const VIEWPORT = { width: 420, height: 800 }; // 移动端宽,防止元素错位 const SCROLL_STEP = 600; // 每次滚 600px,小于视口高度保证重叠 export async function captureChat(sessionId) { const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] }); const page = await browser.newPage(); await page.setViewport(VIEWPORT); // 1. 登录并定位到对话页 await page.goto(`https://bot.example.com/chat/${sessionId}`, { waitUntil: 'networkidle2' }); await page.evaluate(() => { // 关闭虚拟滚动,强制渲染全部节点(业务需提前提供开关) window.__DISABLE_VIRTUAL = true; }); // 2. 滚动到最顶端,触发历史消息加载 let lastHeight = 0; while (true) { const current = await page.evaluate('document.body.scrollHeight'); if (current === lastHeight) break; lastHeight = current; await page.evaluate(() => window.scrollTo(0, 0)); await page.waitForTimeout(300); // 等接口返回 } // 3. 分页截图 const chunks = []; let scrollY = 0; while (scrollY < lastHeight) { await page.evaluate(y => window.scrollTo(0, y), scrollY); await page.waitForTimeout(150); // 等懒加载图片 const buffer = await page.screenshot({ fullPage: false }); // 只截视口 chunks.push(buffer); scrollY += SCROLL_STEP; } // 4. 拼接 const png = await concatImages(chunks, { direction: 'vertical' }); await png.toFile(`/tmp/${sessionId}.png`); await browser.close(); return `https://static.example.com/${sessionId}.png`; }要点解释:
fullPage: false避免 Puppeteer 自动整页截,控制权回到我们手里。SCROLL_STEP < VIEWPORT.height保证上下两张图有重叠区,concat-images 会自动裁剪重复像素,防止缝隙。- 关闭虚拟滚动是关键,否则高度计算永远不准;如果产品不允许,就要在循环里手动触发“加载更多”按钮。
性能优化:让截图飞起来,而不是把服务器拖垮
分辨率选择
- 客服质检场景 420×800 足够,文件体积减少 60%。
- 若用户要打印,可提供 2x 图开关,但默认 1x。
并发与复用
- 每截图都
launch()会吃光内存,建议使用puppeteer-cluster或自维护浏览器池,最大并发 ≤ CPU 核心数 × 0.8。 - 同一实例多标签页模式比多进程省 30% 内存,但异常隔离差,选哪种看业务容错。
- 每截图都
流式上传
- 拼接后图片可能 10~20 MB,先写本地磁盘流,再用分片上传 OSS,避免 Node 内存暴涨。
内存泄漏防范
- 每次
browser.close()前主动page.removeAllListeners(),防止事件句柄堆积。 - 在 Linux 上打开
--disable-dev-shm-usage,默认/dev/shm只有 64 MB,容易爆。
- 每次
缓存加速
- 对静态资源设置
Cache-Control: max-age=31536000,截图时先page.setCacheEnabled(true),重复访问提速 40%。
- 对静态资源设置
避坑指南:我踩过的 5 个坑,你直接跳过去
CSS 加载不全,截图白块
现象:部分气泡没背景色。
原因:页面用了动态 import CSS,Puppeteer 的networkidle2在 CSS 加载前就会 resolve。
解决:在page.goto后加await page.waitForStyle('[data-chat-bubble]'),或监听requestfinished事件统计 CSS 数量。图片懒加载导致空白
现象:用户头像、表情图裂图。
解决:await page.addScriptRequestInterception(); page.on('request', req => { if (req.resourceType() === 'image') req.continue(); });滚动后统一
await page.waitForLoadEvent('load')并强制scrollIntoView所有<img>。固定定位元素重复出现
现象:顶部标题栏在每一张分页图都出现,拼接后叠影重重。
解决:给固定头加类.screenshot-hidden { visibility: hidden; },截图前注入 CSS:await page.addStyleTag({ content: '.screenshot-hidden { visibility: hidden; }' }); await page.addScriptTag({ content: 'document.querySelector(".header").classList.add("screenshot-hidden");' });表情字体乱码
现象:😊 变成 □□□。
解决:Docker 镜像里装fonts-noto-color-emoji,并挂载到容器。时间戳不一致
现象:截图里消息时间比用户端晚 8 小时。
解决:在page.evaluate(() => { Intl.DateTimeFormat = function() {} })之前先page.emulateTimezone('Asia/Shanghai'),保证时区一致。
安全考量:别让截图变成数据泄露通道
XSS 风险
用户昵称、消息里可能夹带<script>,虽然浏览器不会执行静态截图,但拼接过程在服务端,如果再用 HTML2Canvas 做二次编辑,就可能执行。
解决:- 截图前把用户输入全部转义,或直接用
textContent替代innerHTML渲染。 - 禁止注入任何第三方脚本,只保留同域白名单。
- 截图前把用户输入全部转义,或直接用
敏感信息泄露
日志、截图文件落盘,运维人员都能看。
解决:- 临时目录用
memfs或挂载tmpfs,截图完立即上传 OSS 并删除本地文件。 - OSS 链接带一次性签名,过期时间 ≤ 5 分钟,防止外泄。
- 临时目录用
越权访问
接口只传sessionId没鉴权,攻击者可以遍历。
解决:- 截图服务内再次校验 JWT,拒绝非本用户会话。
- 对同一会话做频率限制,如 10 次/小时,防止刷接口。
小结与开放式问题
本文从 Chatbot 特有的动态加载、虚拟滚动、富媒体渲染等痛点出发,对比了 HTML2Canvas、PhantomJS、Puppeteer 的优劣,给出基于 Puppeteer 的分页截图 + 纵向拼接完整代码,并补充了性能、安全、常见坑的实践经验。
但“更快、更轻、更清晰”的方案永远在路上:
- 有没有办法在前端直接利用
OffscreenCanvas+WebCodecs实现零依赖、不 squeezing 内存的长截图? - 如果对话里包含实时视频流,WebRTC 帧如何无损嵌入 PNG?
- 在浏览器原生支持
scrollTo({ behavior: 'smooth' })的情况下,如何精准计算滚动距离,避免 1px 误差导致拼接缝隙?
欢迎你在自己的项目里尝试、测速,并把更好的思路分享出来。
如果你想亲手把 AI 能力串成一条完整链路,而不仅停留在截图层面,可以看看这个动手实验:从0打造个人豆包实时通话AI。我跟着做了一遍,发现它把语音识别、大模型对话、语音合成全部拆开讲透,本地也能跑通,对理解“耳朵→大脑→嘴巴”的闭环挺有帮助。也许跑完实验,你会想到让 Chatbot 把对话直接读出来,而不再只是发一张长图。