Скриншот Temptation: проект развертывания Docker Puppeteer

задняя часть Linux Docker Puppeteer

Маленькие партнеры канала «Птичий язык»

один,PuppeteerВведение и установка

PuppeteerЯвляетсяNodeБиблиотека, предоставляющая высокоуровневый API для управления по протоколу DevTools.Chromium. запусти это в гуглеheadlessПосле браузераSeleniumбыл прямо оставлен мной, потому чтоPuppeteerзаNodejsРазработчики слишком дружелюбны, (при нормальных обстоятельствах) нужно толькоnpm i puppeteer, вы можете завершить установку без установки других зависимых библиотек (Я был слишком молод в начале о(╥﹏╥)о, это непросто).

Для системной среды на работе используется 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

2. Навыки

Ленивая загрузка скриншотов

滚动截图.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"

Код: в качестве примера возьмем ленивую загрузку Jianshu.

/**
 * 懒加载页面自动滚动 
 */
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Способ борьбы с этим заключается в использованииscreenshotизclipпараметр, по координатам элемента относительно области просмотра (x、y), а также ширину и высоту элемента (width、height) скриншот позиционирования. Разумеется, селектор элементов должен быть точным, иначе он не сможет делать точные скриншоты.

  • Клип параметра page.screenshot
  • element.getBoundingClientRect(): с помощью этого метода можно получить относительное положение элемента в окне просмотра (возвращаемый объект включает в себяleft、top、width、height), соответствующие точки знаний можно найти в Google
  • $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;
}

Хорошо, пока мы можем сделать скриншоты большинства элементов, остальные элементы, которые прокручиваются внутри

Скриншот внутреннего элемента прокрутки

内滚动截图.gif

Внутренняя прокрутка: по сравнению с традиционной прокруткой оконной формы, ее основная полоса прокрутки находится внутри страницы (или элемента), а не в форме браузера. Чаще всего в интерфейсе управления фоном полоса прокрутки в левой колонке и область содержимого справа разделены.

Представьте, что, когда открывается музыкальная облачная музыка, появится два внутренних полоса прокрутки на первом экране. Если мы хотим увидеть больше плейлистов, нам нужно сдвинуть полосы прокрутки вниз. То же самое относится и к внутренним скриншонам прокрутки. В сочетании с прокруткой страницы целевой элемент подвергается воздействию в видимом диапазоне, а затем точные скриншоты достигаются через координаты окна.

网易云音乐内滚动条.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所在的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();
})();

3. Яма, на которую наступили: вLinuxустановить наChromium

Оказывается: опыт установки Chromium в среде Linux может быть незабываемым. Установитьpuppeteer, Хром скачивается автоматически, что часто заканчивается сбоем по известным причинам. Chromium может быть успешно загружен после смены источника зеркала, но после запуска Различные ошибки вызваны отсутствием частичных зависимостей от Linux. После установки необходимых зависимостей код работает без сбоев. Однако на скриншоте обнаружил, что китайские шрифты в браузере все рамочные. ОК, установите библиотеку шрифтов, китайские иероглифы отображаются нормально!

Лучшие практики после того, как вы наступили на яму

  • использоватьChromiumиnpm包Отдельный способ, только установкаpuppeteer-core,пройти черезexecutablePathВнедрить самостоятельную загрузкуChromium, сильно ускоряетnpm installскорость.
  • Переключите зеркальный источник Linux на зеркальный источник Ali, который можно быстро загрузить.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: в области компьютерной безопасности песочница — это изолированный рабочий механизм для программ, целью которого является ограничение разрешений ненадежных процессов. Методы песочницы часто используются для выполнения непроверенных или ненадежных клиентских программ. Во избежание недоверенные программы могут нарушить работу других программ.

  • --no-sandbox: перейти в песочницу, чтобы запустить
  • --disable-dev-shm-usage: по умолчанию,Dockerуправлять/dev/shmКонтейнер с общей памятью 64 МБ. Обычно это слишком мало для 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容器化Оптимизировать приведенный выше процесс развертывания.

Процесс разработки докера

  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: Команда, которая будет выполняться в оболочке или среде 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: уточнитьRUN,CMDиENTRYPOINTрабочий каталог команды

WORKDIR /workspase/my_app

ENV: установить переменную среды

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

VOLUME: разрешить доступ к каталогам из контейнера на хост

VOLUME ["/data"]

EXPOSE: указывает порт, который контейнер прослушивает во время выполнения.

EXPOSE <port>;

Прикрепил тест пройденDockerfileОбразец

Несколько заметок

  • Используйте отечественную зеркальную станцию ​​Alibaba Cloud для ускорения установки зависимостей
  • По умолчанию отображение на китайском языке не поддерживается. Необходимо использовать бесплатные китайские шрифты Wenquanyi. Эта библиотека доступна только вhttps://mirrors.aliyun.com/alpine/edge/testing/могу найти
  • Городской район по умолчанию в контейнере не является районом Дунба, что повлияет на печать журнала и потребует сброса часового пояса.
  • Внутри док-контейнера на машине Centosnpm installсообщит об ошибке, установитеnpm config set unsafe-perm trueПосле гладкой установки, в чем причина? (Докер не проблема на MacOS)
# 拉取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

Справочная документация, спасибо, что поделились