博客 / 詳情

返回

基於Eggjs+puppeteer實現頁面截圖的服務,並上傳華為雲obs

主要功能

通過 puppeteer 打開無頭瀏覽器,對目標頁面進行截圖存儲本地制定目錄,通過把本地文件轉為文件流的方式上傳華為雲,上傳成功過後返回預覽文件的 key;
上傳華為雲通過 ObsClient、putObject 方式實現 [華為雲 OBS Nodejs SDK](https://support.huaweicloud.com/sdk-nodejs-devg-obs/obs_29_0403.html)

依賴包版本介紹

  • Nodejs:v16.3.0
  • Eggjs:^3
  • puppeteer:^21.2.1
  • esdk-obs-nodejs:^3.24.3

核心代碼&邏輯説明

截圖核心代碼
  • 截圖的圖片高度根據頁面給定的dom元素的高度來計算(在page.setViewport中height體現);我這裏默認給了個“content”的class,接口入參支持傳element的class;
  • 把截圖的頁面圖片存儲到本地的指定目錄,
const puppeteer = require('puppeteer');
const { getParamsValueFromUrl } = require('../utils/tools');
/**
 * @description 執行截圖邏輯 --> 生成截圖 --> 存到根目錄 --> 關閉瀏覽器
 * @param {*} page
 * @param {*} baseDir
 * @param {*} element 要獲取的目標元素,默認'.element'
 * @return {*}
 */
const asyncGeneratePng = (page, baseDir, element) => {
  return new Promise((resolve, reject) => {
    page.once('load', async () => {
      try {
        // 獲取元素的高度,如果元素不存在則返回0
        const elementHeight = await page.evaluate((element) => {
          const targetElement = document.querySelector(`.${element}`);
          return targetElement ? targetElement.clientHeight : 0;
        }, element);
        console.log('Element height:', elementHeight);
        if (elementHeight > 0) {
          await page.setViewport({
            width: 1920,
            height: elementHeight,
          });
          await page.waitForTimeout(2000); // 等待2秒
          const screenshotOptions = {
            path: `${baseDir}/screen-shot.png`,
            fullPage: true,
            type: 'png',
            waitUntil: 'domcontentloaded',
          };
          // 獲取當前頁面URL中screen的值
          const currentUrl = page.url();
          const pageType = getParamsValueFromUrl(currentUrl);
          if (pageType === 'pdf') {
            const tableDataRendered = await page.evaluate(() => {
              return document.querySelectorAll('table tr').length > 0;
            });

            if (tableDataRendered) {
              await page.screenshot(screenshotOptions);
            }
          } else {
            await page.screenshot(screenshotOptions);
          }
        } else {
          console.log('未獲取到元素高度,截圖失敗');
        }
        resolve();
      } catch (error) {
        reject(error);
      }
    });
  });
};

/**
 * 截屏的核心代碼
 * @param {*} url 要截圖的網站地址
 * @param {*} name 截圖之後保存的文件名
 * @param baseDir
 * @param element
 */
const snapShot = async (url, baseDir, element) => {
  const conf = {
    headless: true, // 設置為無頭模式
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--ignore-certificate-errors',
      // '--proxy-server=${newProxyUrl}'
    ],
    dumpio: true,
    slowMo: 100, // 設置瀏覽器每一步之間的時間間隔,單位毫秒
  };
  // 啓動無頭瀏覽器
  const browser = await puppeteer.launch(conf);
  // 創建新頁面
  const page = await browser.newPage();
  try {
    // 跳轉目標頁面
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
    // 執行截圖邏輯
    await asyncGeneratePng(page, baseDir, element);
  } catch (error) {
    console.error(error);
  } finally {
    await browser.close(); // 關閉瀏覽器
  }
};

module.exports = snapShot;
截圖Controller代碼
'use strict';
const BaseController = require('./base');
// const getObsData = require('./obs');
const uploadFile = require('./upload');
/**
 * @description 生成頁面截圖的文件流 --> 調用upload Controller
 * @class ShotController
 * @augments {Controller}
 */
class ShotController extends BaseController {
  constructor(that) {
    super(that);
    // this.getObsDataController = new getObsData(that);
    this.uploadFileController = new uploadFile(that);
  }
  /**
   * 調用截圖Controller --> 調用上傳OBSController --> 刪除本地生成的文件
   *
   * @return 上傳至obs的文件key
   */
  async generateSnapShotBufferUpload() {
    const { url, appname, element } = this.ctx.request.body;
    try {
      await this.service.screenShot.screenShot({
        url, // 目標頁面url
        appname, // 存儲到obs的目錄
        element: element || 'content', // 要截取頁面的目標元素, 默認是content
      });
      // 調用上傳文件Controller
      const uploadResponse = await this.uploadFileController.uploadFileToOBS(appname);
      if (uploadResponse) {
        const response = {
          fileKey: uploadResponse,
        };
        this.success(response);
        // 成功返回後,刪除本地生成的截圖文件
        this.deleteLocalFile(this);
      }
    } catch (error) {
      this.faild(error);
    }
  }
}
module.exports = ShotController;
使用到的工具函數
getParamsValueFromUrl: (url, value = 'screenshot') => {
    const urlParams = new URLSearchParams(url);
    return urlParams.get(value);
}


上傳華為雲核心代碼
  • 使用strem的形式通過obs.putObject的方法進行上傳;通過fs.createReadStream將本地的png文件轉為obs可識別可讀流
const BaseController = require('./base');
const ObsClient = require('esdk-obs-nodejs');
const fs = require('fs');
const sendToWormhole = require('stream-wormhole');
const { randomString } = require('../utils/tools');
/**
 * @description 文件上傳 --> 上傳華為雲obs
 * @class UploadFileController
 * @augments {BaseController}
 */
class UploadFileController extends BaseController {
  async uploadFileToOBS(appname) {
    const { ctx } = this;
    const filePath = `${ctx.app.config.baseDir}/screen-shot.png`;
    // 檢查本地文件是否生成
    const result = await this.waitForFile(filePath);
    // 防止本地文件生成失敗,服務死循環
    if (result) {
      // 創建可讀流
      const stream = await fs.createReadStream(filePath);
      const fileName = `${randomString(30)}.png`;
      try {
        console.log('this.app.config,.,.,.,.,.,.', this.app.config);
        const { obsData } = this.app.config;
        // 獲取obs配置
        const obs = new ObsClient({
          access_key_id: obsData.ak,
          secret_access_key: obsData.sk,
          server: obsData.endPoint,
        });
        // 調用OBS插件提供的方法將圖片上傳到OBS
        const fileKey = `${fileName}`;
        const result = await obs.putObject({
          Bucket: obsData.bucketName,
          Key: fileKey,
          Body: stream,
          ACL: 'public-read', // 設置上傳對象的ACL權限為公共讀
        });
        console.log('result,.,.,.,.', result);
        if (result.CommonMsg.Status !== 200) {
          this.faild(result);
          return;
        }
        // 返回圖片的在obs的存儲key值
        return fileKey;
      } catch (error) {
        // 必須將上傳的文件流消費掉,要不然瀏覽器響應會卡死
        await sendToWormhole(stream);
        this.faild(error);
      }
    } else {
      this.faild('文件不存在');
    }
  }
}

module.exports = UploadFileController;
使用到的工具函數和baseController
// 生成隨機數用於文件名
randomString: (len) => {
  len = len || 32;
  const chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
  const maxPos = chars.length;
  let pwd = '';
  for (let i = 0; i < len; i++) {
    pwd += chars.charAt(Math.floor(Math.random() * maxPos));
  }
  return pwd;
}

const Controller = require('egg').Controller;
const fs = require('fs');
const path = require('path');
/**
 * @description  公共controller,定義返回結構
 * @class BaseController
 * @augments {Controller}
 */
class BaseController extends Controller {
  constructor(ctx) {
    super(ctx);
    this.count = 0;
  }
  success(data = null, message = 'success', code = 200) {
    const { ctx } = this;
    ctx.status = 200;
    ctx.body = {
      code,
      message,
      data,
    };
  }
  faild(data = null, message = 'faild', code = 400) {
    const { ctx } = this;
    ctx.status = 400;
    ctx.body = {
      code,
      message,
      data,
    };
  }
  /**
   * 等待文件存在
   * @param filePath 文件路徑
   * @return Promise<void>
   */
  async waitForFile(filePath) {
    let result = false;
    try {
      // 檢查文件是否存在
      await new Promise((resolve, reject) => {
        // eslint-disable-next-line node/prefer-promises/fs
        fs.stat(filePath, (err, stats) => {
          if (err) {
            reject(err);
          } else {
            resolve(stats);
            result = true;
          }
        });
      });
    } catch (error) {
      if (error.code === 'ENOENT') {
        this.count++;
        // 文件不存在, 等待 100ms 後重試
        await new Promise((resolve) => setTimeout(resolve, 100));
        // 防止本地文件生成失敗,服務死循環
        if (this.count > 50) {
          return false;
        }
        return await this.waitForFile(filePath);
      }
      throw error;
    }
    return result;
  }
  /**
   * 異步刪除文件
   * @return 刪除結果
   */
  async deleteLocalFile() {
    const { ctx } = this;
    const filePath = path.join(ctx.app.config.baseDir, '/', 'screen-shot.png');
    // eslint-disable-next-line node/prefer-promises/fs
    fs.unlink(filePath, (err) => {
      if (err) {
        this.faild('刪除文件失敗');
      } else {
        this.success('刪除文件成功');
      }
    });
  }
}
module.exports = BaseController;

接口返回實例

{
    "code": 200,
    "message": "success",
    "data": {
        "fileKey": "5pfdrt7wyRQDapwndJ4k7WieJZNnj3.png" // fileKey:obs中的文件名
    }
}
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.