基于node和playwright的截图服务

公司有一块业务需要生成pdf,本文将讲述如何基于node和playwright来完成这一部分功能

技术选型

    1. 前端canvas绘制
    1. node+自动化UI工具(playwright、puppeteer以及Selenium)

首先,由于公司本身就存在一套基于网页的截图服务,只不过是基于wkhtmltopdf实现的,这个官方早已经停止维护,以及对css3、es6几乎没有兼容性,所以大大增加了开发需要截图的网页的难度,往往开发在本地调试的时候和截图出来的页面不一致,及其难排查问题。由于上述原因,所以要升级原本wkhtmltopdf截图,以及因为本身业务系统已经存在一套比较完善的任务队列、pdf处理流程,同时也没选择纯用前端canvas来对网页截图,而是只需要开发一个新的截图服务来替换旧的截图服务,其他逻辑不用改动。

至于playwright、puppeteer以及Selenium这三者的选型,都是ui自动化测试工具,都提供了网页截图功能,参考了这篇文章,真实环境下playwright的效率是最高的,以及playwright的功能更加强大,能够进行网络资源劫持以及更完善的等待机制。话不多说,撸起袖子就是酷酷干。

首先需要先安装一下node环境,安装一些依赖playwright、koa(用express也行),这些不是文本的重点,不在此做过多阐述。

我们的业务场景是这样的,以班级为单位,每个学生有10页或者更多档案,需要把这几页档案合成一个pdf。业务系统会以单个学生为单位,向截图服务发送请求,请求包含了这个学生的每页档案的网址,然后等待截图服务回传pdf文件流。因此,截图服务需要完成两个功能,首先并发将这些网页变成图片,然后合并成pdf回传。

截图

利用generic-pool创建一个资源池,以提高资源的利用率,减少频繁创建带来的开销。

因为playwright是利用浏览器的能力来截图的,一个浏览器可以开多个标签,所以在服务启动的时候,先初始化一个浏览器资源池,再给浏览器初始化一个page资源池来并发执行任务,考虑过再结合worker_threads再开启一个线程的资源池来进行多线程处理,这样处理效率应该更高。但是实际情况,我们同时需要处理的url一般只有10几个,在维护一个线程资源池会增加服务器的压力,出现不稳定性,所以就只考虑了资源池。

arduino 复制代码
/**
 * 资源池配置,取决于服务器配置
 */
const POOL_CONFIG = {
  // 浏览器池配置
  browser: {
    min: 2,                    // 最小浏览器实例数
    max: 4,                    // 最大浏览器实例数
    idleTimeoutMillis: 1000*60*10,      // 10分钟不用的资源会被移除
    evictionRunIntervalMillis: 1000*60*60, // 每1小时检查一次(不要太频繁,避免无意义检查)
    numTestsPerEvictionRun: 4,     // 每次检查4个
    testOnBorrow:true //借出时检查有效性(防止拿到失效资源)
  },
  // 页面池配置
  page: {
    min: 2,                    // 每个浏览器最小页面数
    max: 4,                    // 每个浏览器最大页面数
    idleTimeoutMillis: 1000*60*10,      // 10分钟不用的资源会被移除
    evictionRunIntervalMillis: 1000*60*60, // 每1小时检查一次(不要太频繁,避免无意义检查)
    numTestsPerEvictionRun: 4,     // 每次检查4个
    testOnBorrow:true //借出时检查有效性(防止拿到失效资源)

  }
};
/**
 * 初始化全局资源池
 */
 
async function initializeResourcePool(config = {}) {
  if (globalPagePoolManager) {
    console.log('资源池已存在,跳过初始化');
    return globalPagePoolManager;
  }
  console.log('初始化全局资源池...');
  // 创建浏览器池和页面池管理器
  const browserPool = createBrowserPool(config.browser);
  globalPagePoolManager = createPagePoolManager(browserPool, config.page);
  console.log('全局资源池初始化完成');
  
  return globalPagePoolManager;
}
/** 复制代码
 * 创建浏览器实例池
 */
function createBrowserPool(config = {}) {
  const finalConfig = { ...POOL_CONFIG.browser, ...config };
  const factory = {
    create: async () => {
      const browser = await playwright.chromium.launch({
        headless: true,
        args: [
          '--no-sandbox',
          '--disable-setuid-sandbox',
          '--disable-dev-shm-usage',
          '--disable-gpu',
          '--no-first-run',
          '--disable-background-timer-throttling',
          '--disable-backgrounding-occluded-windows',
          '--disable-renderer-backgrounding'
        ]
      });
      return browser;
    },
    
    destroy: async (browser) => {
      await browser.close();
    },
    
  };

  return genericPool.createPool(factory, finalConfig);
}
/** 复制代码
 * 创建页面池管理器
 */
function createPagePoolManager(browserPool, config = {}) {
  // 为每个浏览器实例创建页面池
  const pagePools = new Map();
  const finalConfig = { ...POOL_CONFIG.page, ...config };
  
  return {
    /**
     * 获取页面
     */
    acquirePage: async () => {
      // 先获取浏览器实例
      const browser = await browserPool.acquire();
      // 如果这个浏览器还没有页面池,创建一个
      if (!pagePools.has(browser)) {
        const pagePool = createPagePool(browser, finalConfig);
        pagePools.set(browser, pagePool);
      }
      const pagePool = pagePools.get(browser);
      const page = await pagePool.acquire();
      return { browser, page, pagePool };
    },
    
    /**
     * 释放页面
     */
    releasePage: async ({ browser, page, pagePool }) => {
      await pagePool.release(page);
      browserPool.release(browser);
    },
    
    /**
     * 清理所有资源
     */
    drain: async () => {
      // 清理所有页面池
      for (const [browser, pagePool] of pagePools) {
        await pagePool.drain();
      }
      pagePools.clear();
      
      // 清理浏览器池
      await browserPool.drain();
    },
  };
}

接下来等业务系统的请求发送过来,就要开始进行截图合并pdf,可能是由于网络以及playwright自身等一切不可测因素(在真实业务中碰到的,发现会有截图白屏等现场),所以需要增加一些兜底策略和错误处理,譬如图片大小小于50kb进行重试,截图过程发生error进行重试,当然万一真有问题,不能无限重试,服务就死循环了

js 复制代码
/**
 * 带重试机制的截图页面
 */
async function captureScreenshotWithRetry(pagePoolManager, url, filename) {
  let lastError = null;
  let attempt = 0;
  
  while (attempt <= RETRY_CONFIG.maxRetries) {
    try {
      console.log(`[${url}] 尝试截图 (第${attempt + 1}次)`);
      
      const result = await captureScreenshot(pagePoolManager, url, filename);
      
      // 检查图片大小
      const imageSize = await checkImageSize(result);
      if (imageSize < RETRY_CONFIG.minImageSize) {
        throw new Error(`图片大小过小: ${imageSize} bytes (小于 ${RETRY_CONFIG.minImageSize} bytes)`);
      }
      
      console.log(`[${url}] 截图成功 (大小: ${Math.round(imageSize / 1024)}KB)`);
      return result;
      
    } catch (error) {
      lastError = error;
      console.warn(`[${url}] 第${attempt + 1}次尝试失败: ${error.message}`);
      
      // 如果不是最后一次尝试,等待后重试
      if (attempt < RETRY_CONFIG.maxRetries) {
        console.log(`[${url}] 等待 ${RETRY_CONFIG.retryDelay}ms 后重试...`);
        await delay(RETRY_CONFIG.retryDelay);
      }
    }
    
    attempt++;
  }
  
  // 所有重试都失败了
  throw new Error(`截图失败,已重试${RETRY_CONFIG.maxRetries}次: ${lastError.message}`);
}
csharp 复制代码
/**
 * 截图页面
 */
async function captureScreenshot(pagePoolManager, url, filename) {
  const { browser, page, pagePool } = await pagePoolManager.acquirePage();
  
  try {
    const filePath = path.join(__dirname, 'screenshots', filename);
    
    // 确保目录存在
    const dir = path.dirname(filePath);
    try {
      await fs.access(dir);
    } catch {
      await fs.mkdir(dir, { recursive: true });
    }
    
    // 导航到页面
    await page.goto(url, { 
      waitUntil: 'networkidle',
      timeout: RETRY_CONFIG.timeout
    });
    
    // 截图
    await page.screenshot({
      path: filePath,
      fullPage: true,
      type: 'png'
    });
    
    return filePath;
    
  } catch (error) {
    throw error;
  } finally {
    // 释放页面和浏览器
    await pagePoolManager.releasePage({ browser, page, pagePool });
  }
}

最后当所有url截图完成之后,对图片进行合并成pdf

js 复制代码
// 并发处理:使用Promise.all()实现异步并发
    // 每个URL会从页面池获取一个页面进行截图
    const screenshotTasks = urls.map((url, index) => 
      captureScreenshotWithRetry(pagePoolManager, url, `screenshot-${index}.png`)
    );

    const screenshotFiles = await Promise.all(screenshotTasks);
    // 合并为PDF
    await mergePDFs(screenshotFiles, outputPdf);
    // 清理临时文件
    await cleanupTempFiles(screenshotFiles);
js 复制代码
async function mergePDFs(imagePaths, outputPath) {
  if (!imagePaths || imagePaths.length === 0) {
    throw new Error('图片路径数组不能为空');
  }
  
  console.log(`开始合并 ${imagePaths.length} 个图片为PDF`);
  
  // 创建PDF文档
  const pdfDoc = await PDFDocument.create();
  
  // 添加图片到PDF
  for (const imagePath of imagePaths) {
    try {
      const imageBytes = await fs.readFile(imagePath);
      const pngImage = PNG.sync.read(imageBytes);
      
      const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
      const embeddedImage = await pdfDoc.embedPng(imageBytes);
      
      page.drawImage(embeddedImage, {
        x: 0,
        y: 0,
        width: pngImage.width,
        height: pngImage.height
      });
    } catch (error) {
      console.warn(`跳过无效图片: ${imagePath}`, error.message);
    }
  }
  
  // 保存PDF
  const pdfBytes = await pdfDoc.save();
}

目前使用的是无痕模式,没有很有效的利用浏览器的资源缓存来达到更加高效,后续会持续优化

相关推荐
cpp加油站几秒前
打脸来的太快了,又发现一个Trae的宝藏功能--内置浏览器可以指定机型来显示前端界面
前端·ai编程·trae
Web极客码14 分钟前
如何为WordPress启用LiteSpeed缓存
前端·缓存
咕噜分发企业签名APP加固彭于晏30 分钟前
白嫖价值千元的EO
前端·javascript
前端开发爱好者32 分钟前
首个「完整级」WebSocket 调试神器来了!
前端·javascript·vue.js
前端Hardy35 分钟前
HTML&CSS&JS:高颜值登录注册页面—建议收藏
前端·javascript·css
Ali酱37 分钟前
远程这两年,我才真正感受到——工作,原来可以不必吞噬生活。
前端·面试·远程工作
金金金__42 分钟前
优化前端性能必读:浏览器渲染流程原理全揭秘
前端·浏览器
Data_Adventure1 小时前
Vue 3 手机外观组件库
前端·github copilot
泯泷1 小时前
Tiptap 深度教程(二):构建你的第一个编辑器
前端·架构·typescript
屁__啦1 小时前
前端错误-null结构
前端