截圖的誘惑:Docker部署Puppeteer項目

小夥伴們的語雀頻道html

1、Puppeteer介紹及安裝

Puppeteer是一個Node庫,它提供了一個高級API來經過DevTools協議控制Chromium。 在谷歌推出這款headless瀏覽器後,Selenium直接被我拋棄了,由於Puppeteer對於Nodejs開發者來講簡直太友好了,(正常狀況下)只須要npm i puppeteer,便可完成安裝,而不須要安裝其餘的依賴庫(當初太年輕o(╥﹏╥)o,其實並不簡單)。node

系統環境的話在工做時使用MacOS,部署到服務器上的是Centos 7. 在MacOS上確實簡單,只須要npm i puppeteer就行。安裝不了有下列幾條解決辦法:linux

# 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瀏覽器時需增長以下配置項git

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的用例github

2、技巧

懶加載截圖

滾動截圖.gif

在截圖或者爬蟲時,經常遇到一些頁面採用懶加載的方式展現數據,首屏是不會展現所有的信息給咱們。 針對懶加載,採用滾動到底的方式來破解。 啥?懶加載沒有底,嘗試直接調他們的接口吧,或者還有其餘高明的方式歡迎指出web

page.evaluate(pageFunction, ...args): 該函數能讓咱們使用內置的DOM選擇器docker

這裏要特別注意下pageFunction的傳參方式爲:shell

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"
複製代碼

代碼:以簡書的懶加載爲例npm

/** * 懶加載頁面自動滾動 */
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)定位截圖。固然了,元素選擇器必需要找準,不然再怎麼樣也沒法精確截圖json

  • 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();
})();
複製代碼

3、踩過的坑:在 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']
});
複製代碼

4、經過 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 6八、文泉驛免費中文字體等依賴庫
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 複製代碼

參考文檔,感謝分享

相關文章
相關標籤/搜索