截圖的誘惑:Docker部署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'], }); 複製程式碼
二、技巧
懶載入截圖

在截圖或者爬蟲時,常常遇到一些頁面採用懶載入的方式展示資料,首屏是不會展示全部的資訊給我們。 針對懶載入,採用滾動到底的方式來破解。 啥?懶載入沒有底,嘗試直接調他們的介面吧,或者還有其他高明的方式歡迎指出
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 ... } 複製程式碼
元素精確截圖

精確截圖,顧名思義是將元素在頁面上所佔據的區域 摳
下來。 那麼換成 Puppeteer
的方式來處理,是利用 screenshot
的 clip
引數,根據元素相對視窗的座標( 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,目前為止我們能可以對大部分的元素截圖了,其餘的是處於內滾動的元素
內滾動元素截圖

內滾動:相對於傳統的window窗體滾動,它的主滾動條是在頁面(或者某個元素)的內部,而不是在瀏覽器窗體上。最常見的是在後臺管理介面,左側欄和右側的內容區的滾動條是分開的。
想象一下,開啟網易雲音樂,首屏會出現兩個內滾動條,如果我們想看到更多的歌單,需要將滾動條下滑。 內滾動截圖也是同樣的道理,結合頁面滾動讓目標元素暴露在可視範圍內,再通過視窗座標來達到精確截圖。


步驟:
- 獲取目標元素的座標,判斷其是否在當前可視範圍內,如果在視窗內,則無需滾動
- 由於是內滾動,目標元素外面必定套了一層有滾動條的父元素,通過滾動該父元素來間接展示目標元素。所以這一步需要確定父元素的選擇器
- 通過模擬頁面滾動父元素(設定
window.scrollBy
或者scrollLeft scrollTop
),使目標物件剛好能完整地出現在視窗內 - 因為是內滾動,所以需要重新獲取目標元素的座標(
getBoundingClientRect
) - 利用新座標截圖
這兒有個小細節,關於如何判斷元素是否有滾動條。如果元素無 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,安裝字型庫,中文字正常顯示了!
踩坑後的最佳實踐
- 採用
Chromium
和npm包
分開的方式,只安裝puppeteer-core
,通過executablePath
引入自行下載的Chromium
,極大加快npm install
的速度。 - 將Linux的映象源切換成阿里的映象源,可以快速下載
Chromium
- 將專案改用
Docker
部署,避免出現本地開發正常,上線後卻出現各種問題的情況 - 儘量避免使用
page.waifFor(1000)
,1000毫秒數只是毛估估的時間,讓程式自己決定效果會更好
相關解決辦法:
-
Chrome%2Fpuppeteer%2Fblob%2Fmaster%2Fdocs%2Ftroubleshooting.md" rel="nofollow,noindex">官方整理的錯誤集錦
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開發流程
Dockerfile Dockerfile Docker倉庫 Docker容器
這裡以部署一個基於 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
: 指定 RUN
、 CMD
與 ENTRYPOINT
命令的工作目錄
WORKDIR /workspase/my_app 複製程式碼
ENV
: 設定環境變數
# 兩種方式 ENV <key> <value> ENV <key>=<value> 複製程式碼
VOLUME
: 授權訪問從容器內到主機上的目錄
VOLUME ["/data"] 複製程式碼
EXPOSE
: 指定容器在執行時監聽的埠
EXPOSE <port>; 複製程式碼