美文网首页程序猿阵线联盟-汇总各类技术干货
截图的诱惑:Docker部署Puppeteer项目

截图的诱惑:Docker部署Puppeteer项目

作者: 淡就加点盐 | 来源:发表于2018-10-09 19:39 被阅读23次

    小伙伴们的语雀频道

    一、Puppeteer介绍及安装

    Puppeteer是一个Node库,它提供了一个高级API来通过DevTools协议控制Chromium
    在谷歌推出这款headless浏览器后,Selenium直接被我抛弃了,因为Puppeteer对于Nodejs开发者来说简直太友好了,(正常情况下)只需要npm i puppeteer,即可完成安装,而不需要安装其他的依赖库(当初太年轻o(╥﹏╥)o,其实并不简单)。

    系统环境的话在工作时使用MacOS,部署到服务器上的是Centos 7.
    MacOS上确实简单,只需要npm i puppeteer就行。安装不了有下列几条解决办法:

    # 1. 设置环境变量跳过下载 Chromium(2018-09-03已失效)
    set PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
    
    # 2. 只下载模块而不build,但chromium需要自行下载(2018-09-03有效)
    npm i --save puppeteer --ignore-scripts
    
    # 3. Puppeteer从v1.7.0开始额外提供一个puppeteer-core的库,它只包含Puppeteer的核心库,默认不下载chromium
    npm i puppeteer-core
    
    # 如果连puppeteer都安装不了,建议使用淘宝镜像
    npm config set registry="https://registry.npm.taobao.org"
    

    如果Chromium是自行下载的,则启动headless浏览器时需增加如下配置项

    this.browser = await puppeteer.launch({
      // MacOS应该在"xxx/Chromium.app/Contents/MacOS/Chromium",Linux应该"/usr/bin/chromium-browser"
      executablePath: "Chromium的安装路径",
      // 去沙盒
      args: ['--no-sandbox', '--disable-dev-shm-usage'],
    });
    

    Chromium下载,Linux下需要安装其他依赖
    点击了解Puppeteer的用例

    二、技巧

    懒加载截图

    滚动截图.gif

    在截图或者爬虫时,常常遇到一些页面采用懒加载的方式展示数据,首屏是不会展示全部的信息给我们。
    针对懒加载,采用滚动到底的方式来破解。
    啥?懒加载没有底,尝试直接调他们的接口吧,或者还有其他高明的方式欢迎指出

    page.evaluate(pageFunction, ...args): 该函数能让我们使用内置的DOM选择器

    这里要特别注意下pageFunction的传参方式为:

    const result = await page.evaluate(param1, param2, param3 => {
      return Promise.resolve(8 + param1 + param2 + param3);
    }, param1, param2, param3);
    
    // 也可以传一个字符串:
    console.log(await page.evaluate('1 + 2')); // 输出 "3"
    const x = 10;
    console.log(await page.evaluate(`1 + ${x}`)); // 输出 "11"
    

    代码:以简书的懒加载为例

    /**
     * 懒加载页面自动滚动 
     */
    const path = require('path');
    const puppeteer = require('puppeteer-core');
    
    const log = console.log;
    (async () => {
      const browser = await puppeteer.launch({
        // executablePath: path.join(__dirname, './chromium/Chromium.app/Contents/MacOS/Chromium'),
        // 关闭headless模式, 会打开浏览器
        headless: false,
        args: ['--no-sandbox', '--disable-dev-shm-usage'],
      });
      const page = await browser.newPage();
      await page.goto('https://www.jianshu.com/u/40909ea33e50');
      await autoScroll(page);
    
      // fullPage截图
      await page.screenshot({
        path: 'auto_scroll.png',
        type: 'png',
        fullPage: true,
      });
      await browser.close();
    })();
    
    async function autoScroll(page) {
      log('[AutoScroll begin]');
      await page.evaluate(async () => {
        await new Promise((resolve, reject) => {
          // 页面的当前高度
          let totalHeight = 0;
          // 每次向下滚动的距离
          let distance = 100;
          // 通过setInterval循环执行
          let timer = setInterval(() => {
            let scrollHeight = document.body.scrollHeight;
    
            // 执行滚动操作
            window.scrollBy(0, distance);
    
            // 如果滚动的距离大于当前元素高度则停止执行
            totalHeight += distance;
            if (totalHeight >= scrollHeight) {
              clearInterval(timer);
              resolve();
            }
          }, 100);
        });
      });
    
      log('[AutoScroll done]');
      // 完成懒加载后可以完整截图或者爬取数据等操作
      // do what you like ...
    }
    

    元素精确截图

    精确截图.gif

    精确截图,顾名思义是将元素在页面上所占据的区域下来。 那么换成Puppeteer的方式来处理,是利用screenshotclip参数,根据元素相对视窗的坐标(x、y)及元素的款宽高(width、height)定位截图。当然了,元素选择器必须要找准,否则再怎么样也无法精确截图

    • page.screenshot参数 clip
    • element.getBoundingClientRect(): 通过这个方法可以获取到元素在视窗内的相对位置(返回对象中包括 left、top、width、height),相关知识点可谷歌了解下
    • $eval: 此方法在页面内执行 document.querySelector ,然后把匹配到的元素作为第一个参数传给 pageFunction
    const path = require('path');
    const puppeteer = require('puppeteer-core');
    
    const log = console.log;
    (async () => {
      const browser = await puppeteer.launch({
        // executablePath: path.join(__dirname, './chromium/Chromium.app/Contents/MacOS/Chromium'),
        // 关闭headless模式, 会打开浏览器
        headless: false,
        args: ['--no-sandbox', '--disable-dev-shm-usage'],
      });
      const page = await browser.newPage();
      await page.goto('https://www.jianshu.com/');
      const pos = await getElementBounding(page, '.board');
    
      // clip截图
      await page.screenshot({
        path: 'element_bounding.png',
        type: 'png',
        clip: {
          x: pos.left,
          y: pos.top,
          width: pos.width,
          height: pos.height
        }
      });
      await browser.close();
    })();
    
    async function getElementBounding(page, element) {
      log('[GetElementBounding]: ', element);
    
      const pos = await page.$eval(element, e => {
        // 相当于在evaluate的pageFunction内执行
        // document.querySelector(element).getBoundingClientRect()
        const {left, top, width, height} = e.getBoundingClientRect();
        return {left, top, width, height};
      });
      log('[Element position]: ', JSON.stringify(pos, undefined, 2));
      return pos;
    }
    

    OK,目前为止我们能可以对大部分的元素截图了,其余的是处于内滚动的元素

    内滚动元素截图

    内滚动截图.gif

    内滚动:相对于传统的window窗体滚动,它的主滚动条是在页面(或者某个元素)的内部,而不是在浏览器窗体上。最常见的是在后台管理界面,左侧栏和右侧的内容区的滚动条是分开的。

    想象一下,打开网易云音乐,首屏会出现两个内滚动条,如果我们想看到更多的歌单,需要将滚动条下滑。
    内滚动截图也是同样的道理,结合页面滚动让目标元素暴露在可视范围内,再通过视窗坐标来达到精确截图。

    网易云音乐内滚动条.png 内滚动元素坐标示例.png

    步骤:

    1. 获取目标元素的坐标,判断其是否在当前可视范围内,如果在视窗内,则无需滚动
    2. 由于是内滚动,目标元素外面必定套了一层有滚动条的父元素,通过滚动该父元素来间接展示目标元素。所以这一步需要确定父元素的选择器
    3. 通过模拟页面滚动父元素(设置 window.scrollBy 或者 scrollLeft scrollTop),使目标对象刚好能完整地出现在视窗内
    4. 因为是内滚动,所以需要重新获取目标元素的坐标(getBoundingClientRect
    5. 利用新坐标截图

    这儿有个小细节,关于如何判断元素是否有滚动条。如果元素无X轴滚动条,那么设置他的scrollLeft是没有效果的,这时只能全局滚动才行。

    // 如果scrollWidth值大于clientWidth值,则可以说明其出现了横向滚动条
    element.scrollHeight > element.clientHeight
    
    // 如果scrollHeight值大于clientHeight值,则可以说明其出现了竖向滚动条
    element.scrollHeight > element.clientHeight
    

    示例代码:以Nodejs官方文档中的内滚动为例,获取左侧栏中TTY的截图

    /**
     * 截取左侧栏中TTY所在的li节点
     */
    const path = require('path');
    const puppeteer = require('puppeteer-core');
    
    const log = console.log;
    (async () => {
      const browser = await puppeteer.launch({
        executablePath: path.join(__dirname, './chromium/Chromium.app/Contents/MacOS/Chromium'),
        // 关闭headless模式, 会打开浏览器
        headless: false,
        args: ['--no-sandbox', '--disable-dev-shm-usage'],
      });
      const page = await browser.newPage();
      await page.setViewport({width: 1920, height: 600});
      const viewport = page.viewport();
    
      // Nodejs官方Api文档站
      await page.goto('https://nodejs.org/dist/latest-v10.x/docs/api/');
    
      // await page.waitFor(1000);
      // 这里强烈建议使用 waitForNavigation,1000这中魔鬼数字会让代码变得不放心
      await page.waitForNavigation({
          // 20秒超时时间
          timeout: 20000,
          // 不再有网络连接时判定页面跳转完成
          waitUntil: [
            'domcontentloaded',
            'networkidle0',
          ],
        });
    
      // step1: 确定内滚动的父元素选择器
      const containerEle = '#column2';
      // step1: 确定目标元素选择器
      const targetEle = '#column2 ul:nth-of-type(2) li:nth-of-type(40)';
    
      // step1: 获取目标元素在当前视窗内的坐标
      let pos = await getElementBounding(page, targetEle);
    
      // 使用内置的DOM选择器
      const ret = await page.evaluate(async (viewport, pos, element) => {
    
        // step1: 判断目标元素是否在当前可视范围内
        const sumX = pos.width + pos.left;
        const sumY = pos.height + pos.top;
    
        // X轴和Y轴各需要移动的距离
        const x = sumX <= viewport.width ? 0 : sumX - viewport.width;
        const y = sumY <= viewport.height ? 0 : sumY - viewport.height;
    
        const el = document.querySelector(element);
    
        // strp3: 将元素滚动进视窗可视范围内
        // 此处需要判断目标元素的x、y是否可滚动,如果元素不能滚动则滚动window
        // 如果scrollWidth值大于clientWidth值,则可以说明其出现了横向滚动条
        if (el.scrollWidth > el.clientWidth) {
          el.scrollLeft += x;
        } else {
          window.scrollBy(x, 0);
        }
        // 如果scrollHeight值大于clientHeight值,则可以说明其出现了竖向滚动条
        if (el.scrollHeight > el.clientHeight) {
          el.scrollTop += y;
        } else {
          window.scrollBy(0, y);
        }
    
        return [el.scrollHeight, el.clientHeight];
      }, viewport, pos, containerEle);
    
      // step4: 由于目标元素在视窗外,且处于内滚动父元素内,所以需要重新获取坐标
      pos = await getElementBounding(page, targetEle);
      
      // await page.waitFor(1000);
      // 这里强烈建议使用 waitForNavigation,1000这中魔鬼数字会让代码变得不放心
      await page.waitForNavigation({
          // 20秒超时时间
          timeout: 20000,
          // 不再有网络连接时判定页面跳转完成
          waitUntil: [
            'domcontentloaded',
            'networkidle0',
          ],
        });
    
      // 5. 截图
      await page.screenshot({
        path: 'scroll_and_bounding.png',
        type: 'png',
        clip: {
          x: pos.left,
          y: pos.top,
          width: pos.width,
          height: pos.height
        }
      });
      await browser.close();
    })();
    

    三、踩过的坑:在 Linux 上安装 Chromium

    事实证明:在Linux环境中安装Chromium的经历会无比难忘。
    安装puppeteer时,会自动下载Chromium,由于众所周知的原因,下载常常以失败告终。换个镜像源后Chromium能下载成功,但启动后
    各种报错,是Linux上缺少部分依赖导致的。安装完需要的依赖,代码顺利运行。但截图却发现浏览器上的中文字体竟全是框框框框。OK,安装字体库,中文字正常显示了!

    踩坑后的最佳实践

    • 采用Chromiumnpm包分开的方式,只安装puppeteer-core,通过executablePath引入自行下载的Chromium,极大加快npm install 的速度。
    • 将Linux的镜像源切换成阿里的镜像源,可以快速下载Chromium
    • 将项目改用Docker部署,避免出现本地开发正常,上线后却出现各种问题的情况
    • 尽量避免使用page.waifFor(1000),1000毫秒数只是毛估估的时间,让程序自己决定效果会更好

    相关解决办法:

    yum install pango.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXtst.x86_64 cups-libs.x86_64 libXScrnSaver.x86_64 libXrandr.x86_64 GConf2.x86_64 alsa-lib.x86_64 atk.x86_64 gtk3.x86_64 ipa-gothic-fonts xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-utils xorg-x11-fonts-cyrillic xorg-x11-fonts-Type1 xorg-x11-fonts-misc -y
    
    # 设置阿里镜像源
    echo "https://mirrors.aliyun.com/alpine/edge/main" > /etc/apk/repositories
    echo "https://mirrors.aliyun.com/alpine/edge/community" >> /etc/apk/repositories
    echo "https://mirrors.aliyun.com/alpine/edge/testing" >> /etc/apk/repositories
    
    # 安装Chromium及依赖,包括中文字体支持
    apk -U --no-cache update
    apk -U --no-cache --allow-untrusted add zlib-dev xorg-server dbus ttf-freefont chromium wqy-zenhei@edge -f
    

    安装完后需要去沙箱才能运行,尽管官方并不推荐。

    Linux沙箱:在计算机安全领域,沙箱(Sandbox)是一种程序的隔离运行机制,其目的是限制不可信进程的权限。沙箱技术经常被用于执行未经测试的或不可信的客户程序。为了避免不可信程序可能破坏其它程序的运行。

    • --no-sandbox: 去沙箱运行
    • --disable-dev-shm-usage: 默认情况下,Docker运行一个/dev/shm共享内存空间为64MB 的容器。这通常对Chrome来说太小,并且会导致Chrome在渲染大页面时崩溃。要修复,必须运行容器 docker run --shm-size=1gb 以增加/dev/shm的容量。从Chrome 65开始,使用--disable-dev-shm-usage标志启动浏览器即可,这将会写入共享内存文件/tmp而不是/dev/shm.
    const browser = await puppeteer.launch({
      args: ['--no-sandbox', '--disable-dev-shm-usage']
    });
    

    四、通过 Docker容器 部署项目

    项目干到最后,发现每次都需要安装Chromium,可能每次都会出现不可预料的问题出现。为了节约时间成本干更多有意义的事情,通过 shell脚本Docker容器化 优化上述的部署流程。

    Docker开发流程

    1. 确定基础镜像
    2. 基于基础镜像编写Dockerfile
    3. 根据Dockerfile构建项目镜像
    4. 将构建的镜像推送到Docker仓库,如果私有化部署直接将镜像导出,再去客户环境导入即可
    5. 在测试/生产机器上拉取项目镜像创建并运行Docker容器
    6. 验证项目是否正常运行

    这里以部署一个基于Puppeteer的服务为例

    确定基础镜像

    # 在Docker Hub或私有仓库上搜索需要的镜像
    docker search node
    

    前往Docker Hub能看到更详细的描述和版本

    # 在这选择 `node:10-alpine` 为基础镜像
    docker pull node:10-alpine
    

    编写Dockerfile (攻略不全,建议网上找更详细的资料)

    FROM: 指定基础镜像,必须是Dockerfile中的第一个非注释指令

    FROM <image name>
    FROM node:10-alpine
    

    MAINTAINER: 设置该镜像的作者

    MAINTAINER <author name> (不推荐使用,推荐使用LABEL来指定镜像作者)
    LABEL MAINTAINER="zhangqiling" (推荐)
    

    RUN: 在shell或者exec的环境下执行的命令。RUN指令会在新创建的镜像上添加新的层面,接下来提交的结果用在Dockerfile的下一条指令中

    RUN <command>
    
    # RUN可以执行任何命令,然后在当前镜像上创建一个新层并提交
    RUN echo "https://mirrors.aliyun.com/alpine/edge/main" > /etc/apk/repositories
    
    # 执行多条命令时,可以通过 \ 换行
    RUN apk -U add \
      zlib-dev \
      xorg-server
    

    RUN指令创建的中间镜像会被缓存,并会在下次构建中使用。如果不想使用这些缓存镜像,可以在构建时指定--no-cache参数,如:docker build --no-cache

    CMD: 提供了容器默认的执行命令。 Dockerfile只允许使用一次CMD指令,如果存在多个CMD,也只有最后一个会生效

    # 有三种形式
    CMD ["executable","param1","param2"]
    CMD ["param1","param2"]
    CMD command param1 param2
    

    COPY: 于复制构建环境中的文件或目录到镜像中

    COPY <src>... <dest>
    COPY ["<src>",... "<dest>"]
    
    # 将项目复制到my_app目录下
    COPY . /workspase/my_app
    

    ADD: 也是复制构建环境中的文件或目录到镜像

    ADD <src>... <dest>
    ADD ["<src>",... "<dest>"]
    

    相比COPY, ADD<src>可以是一个URL。同时如果是压缩文件,Docker会自动解压。

    WORKDIR: 指定RUNCMDENTRYPOINT命令的工作目录

    WORKDIR /workspase/my_app
    

    ENV: 设置环境变量

    # 两种方式
    ENV <key> <value>
    ENV <key>=<value>
    

    VOLUME: 授权访问从容器内到主机上的目录

    VOLUME ["/data"]
    

    EXPOSE: 指定容器在运行时监听的端口

    EXPOSE <port>;
    

    附上测试通过的Dockerfile样例

    几个注意点

    • 使用国内阿里云镜像站加快安装依赖
    • 默认不支持中文显示,必须使用文泉驿的免费中文字体,这个库只有在 https://mirrors.aliyun.com/alpine/edge/testing/能找到
    • 容器内默认市区不是东八区,会影响日志打印,需要重新设置时区
    • Centos机器上的docker容器内 npm install 会报错,设置 npm config set unsafe-perm true后能顺利安装,这是什么原因?(MacOS上的docker没这个问题)
    # 拉取node镜像
    FROM node:10-alpine
    
    # 设置镜像作者
    LABEL MAINTAINER="qiyang.hqy@dtwave-inc.com"
    
    # 设置国内阿里云镜像站、安装chromium 68、文泉驿免费中文字体等依赖库
    RUN echo "https://mirrors.aliyun.com/alpine/v3.8/main/" > /etc/apk/repositories \
        && echo "https://mirrors.aliyun.com/alpine/v3.8/community/" >> /etc/apk/repositories \
        && echo "https://mirrors.aliyun.com/alpine/edge/testing/" >> /etc/apk/repositories \
        && apk -U --no-cache update && apk -U --no-cache --allow-untrusted add \
          zlib-dev \
          xorg-server \
          dbus \
          ttf-freefont \
          chromium \
          wqy-zenhei@edge \
          bash \
          bash-doc \
          bash-completion -f
    
    # 设置时区
    RUN rm -rf /etc/localtime && ln -s /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
    
    # 设置环境变量
    ENV NODE_ENV production
    
    # 创建项目代码的目录
    RUN mkdir -p /workspace
    
    # 指定RUN、CMD与ENTRYPOINT命令的工作目录
    WORKDIR /workspace
    
    # 复制宿主机当前路径下所有文件到docker的工作目录
    COPY . /workspace
    
    # 清除npm缓存文件
    RUN npm cache clean --force && npm cache verify
    # 如果设置为true,则当运行package scripts时禁止UID/GID互相切换
    # RUN npm config set unsafe-perm true
    
    # 安装pm2
    RUN npm i pm2 -g
    
    # 安装依赖
    RUN npm install
    
    # 暴露端口
    EXPOSE 3000
    
    # 运行命令
    ENTRYPOINT pm2-runtime start docker_pm2.json
    

    参考文档,感谢分享

    相关文章

      网友评论

        本文标题:截图的诱惑:Docker部署Puppeteer项目

        本文链接:https://www.haomeiwen.com/subject/ldmyaftx.html