社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  docker

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

撸起袖子吃鸡 • 5 年前 • 336 次点击  
阅读 49

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

小伙伴们的语雀频道

一、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>;
复制代码

参考文档,感谢分享


今天看啥 - 高品质阅读平台
本文地址:http://www.jintiankansha.me/t/hXMf03MSnk
Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/24903
 
336 次点击