使用 Node.js 生成方便傳播的圖片
- 2019 年 10 月 10 日
- 筆記
本文使用「署名 4.0 國際 (CC BY 4.0)」許可協議,歡迎轉載、或重新修改使用,但需要註明來源。 署名 4.0 國際 (CC BY 4.0)
本文作者: 蘇洋
創建時間: 2019年07月28日 統計字數: 5452字 閱讀時間: 11分鐘閱讀 本文鏈接: https://soulteary.com/2019/07/28/use-nodejs-to-generate-easy-to-spread-images.html
使用 Node.js 生成方便傳播的圖片
日常工作中,總會遇到一些需要和一些和「批量生成圖片」相關的事情,尤其是在需要做內容傳播的場景下:畢竟圖片更直觀、更有衝擊力。
- 手頭有一堆招聘需求,但是平台允許發布的字數有限,沒關係,可以使用九宮格圖片大法,把內容當長圖發出來,但是製作長圖還需要考慮排版,純程式碼實現太過繁瑣。
- 舉辦完一場活動,需要講師分享內容給更多人,讓更多的人知道這個活動,傳播一張稍微有設計感的圖片到朋友圈,這個時候我們需要製作和講師相關的傳播圖片。
- 寫了一篇部落格,但是微博等平台排版全亂,換成長圖傳播才能保留格式等等。
網上常常會推崇使用 node canvas
/ webgl
/ web canvas
來解決問題。在我看來,大可不必,其實使用 Node.js 寫幾十行腳本搭配無頭瀏覽器就能搞定問題。那麼下面就來聊聊,如何編寫簡單可依賴的 Node 腳本。
寫在前面
很多時候,我們會沉迷於使用某一門語言、某一種技術解決所有問題,雖然對於程式維護來說成本很低,但是在執行效率上來看,就得不償失了。
當然,如果是簡單純粹的內容,比如訪客簽名、二維碼生成,就另當別論了,不需要考慮複雜排版、幾乎不需要對內容風格進行訂製,比如我之前提過的:
- 使用 Docker 和 Nginx 打造高性能的二維碼服務
- 使用 Docker 和 Node 快速實現一個在線的 QRCode 解碼服務
讓我們先從最簡單的開始講起,批量生成招聘需求圖片(重視排版)。
批量生成招聘需求圖片
招聘需求類的圖片重在內容排版,特別適合使用 Markdown 書寫,配合 Hugo / Hexo 之類的靜態網站生成工具生成簡潔漂亮的頁面,然後再通過截圖等方式得到我們要的結果。
以 Hugo
為例,將簡歷文案準備好之後,放置在 content/posts
下,目錄結構如下:
. ├── archetypes │ └── default.md ├── config.toml ├── content │ └── posts │ ├── 招聘崗位A.md │ ├── 招聘崗位B.md │ ├── 招聘崗位C.md │ ├── 招聘崗位D.md │ └── 招聘崗位E.md ├── layouts ├── static └── themes
接著執行 hugo server
,你將看到類似下面的日誌輸出:
hugo server | ZH-CN +------------------+-------+ Pages | 18 Paginator pages | 0 Non-page files | 0 Static files | 12 Processed images | 0 Aliases | 1 Sitemaps | 1 Cleaned | 0 Total in 24 ms Watching for changes in /Users/soulteary/work/hugo-jd/jd/{content,data,layouts,static,themes} Watching for config changes in /Users/soulteary/work/hugo-jd/jd/config.toml Environment: "development" Serving pages from memory Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender Web Server is available at http://localhost:1313/ (bind address 127.0.0.1) Press Ctrl+C to stop
使用瀏覽器打開 localhost:1313
,便能看到排版還算過得去的頁面了。
接著稍微寫幾行 CSS
程式碼,做下移動端適配,然後輸出成圖片就大功告成了,但如果你想獲得移動設備(尤其是高分屏)上閱讀體驗還不錯的圖片,光是用系統截圖快捷鍵或是普通截圖軟體「喀嚓」截屏怕是達不到需求,感興趣的同學可以了解下 DPR 。
所以截圖的時候需要模擬高分屏設備進行圖片截取,比如下面這段不到 20 行的 Node.js 腳本所做的一樣:
'use strict'; const puppeteer = require('puppeteer'); const { 'iPhone X': deviceModel } = require('puppeteer/DeviceDescriptors'); const { readFileSync } = require('fs'); const links = readFileSync('./target.txt', 'utf-8').split('n').filter(n => n); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.emulate(deviceModel); for (let i = 0, j = links.length; i < j; i++) { await page.goto(links[i]); await page.screenshot({ path: `./jd-${i}.png`, fullPage: true }); } await browser.close(); })();
這段腳本模擬了高分屏設備 iPhone X 訪問頁面時的狀況,然後通過 puppeteer
所提供的截圖能力,生成我們所需要的圖片。
想使用這段圖片生成腳本,還需要準備一個 target.txt
文件,把需要生成圖片的頁面地址一行一行的寫在文件中:
http://localhost/page/1.html http://localhost/page/2.html http://localhost/page/3.html ...
如果你順利的話,執行 node你的圖片腳本.js
就能得到類似下面的結果啦。

批量生成朋友圈傳播圖

刷朋友圈的時候,常常能看到有一些朋友發來稍微有些設計感的活動宣傳圖片。這類圖片其實也可以批量生成,但和上面的例子有些不同,所以要採取不同的策略。
這類傳播圖片首先文案不多,不需要相對複雜又統一的風格排版;圖片和圖片之間文案差異相對較小,幾乎只有「名字」、「頭像」、「傳播短文案」、「配色」有些許不同;需要生成的圖片數量很多,如果還是採取預先編寫一堆 md 文檔,怕不是會敲鍵盤敲到手麻。
圖片中涉及到的人,我們可以使用某些結構語法進行描述,會省事的多,比如下面這樣:(當然你也可以一行一位,找個和內容不撞車的分隔符進行內容分割)
[ { name: '小明', title: '講師' }, { name: '小剛', title: '嘉賓' } ]
有了可以讓程式操作的結構化的人員數據,我們接著將圖片使用前端技術「畫出來」(傳說中的切圖)。上文提過,這類圖片只有少量資訊不同,比如這裡只有名字和身份有區別,所以我們可以像下面這樣描述「圖片」結構。(這裡偷個懶,用偽程式碼代替,不實現樣式啦。)
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> </head> <body> <h1>我是040期沙龍$TITLE $NAME</h1> <p>我來自美團技術團隊,2018美團技術沙龍資源合輯奉上。</p> </body> </html>
結構中的 $TITLE
, $NAME
就是我們想動態替換的內容,如果我們直接使用瀏覽器打開模版,會看到下面的結果。

如何能讓模版內容如我們所願「動態變化」起來呢?這裡需要藉助 http
這個模組,在用戶獲取模版的時候進行動態內容替換。為了簡單,我這裡以 express
為例,只需要 20~30 行就能搞定問題。
const express = require('express'); const app = express(); const port = 3000; app.get('/', (req, res) => res.redirect('/0')); const template = `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> </head> <body> <h1>我是040期沙龍$TITLE $NAME</h1> <p>我來自美團技術團隊,2018美團技術沙龍資源合輯奉上。</p> </body> </html>`; const personsData = [ { name: '小明', title: '講師' }, { name: '小剛', title: '嘉賓' } ]; app.get('/:id', (req, res) => { const { id } = req.params; const { name, title } = personsData[id]; return res.send(template.replace('$NAME', name).replace('$TITLE', title)); }) app.listen(port, () => console.log(`App listening on port ${port}!`));
將程式碼保存為 web.js
,然後執行 node web.js
,打開瀏覽器,訪問 localhost:3000
,或者 localhost:3000/0
/ localhost:3000/1
模版的資訊就動態化起來啦。

最後適當調整 CSS ,以及參考上文中批量生成圖片的腳本,就能得到本小節開頭的那種圖片啦。
生成部落格文章圖片

你或許會好奇,生成部落格圖片和文章第一節中的圖片有什麼不同么?
不同主要有兩點:
- 實際截取內容的時候,有一些元素需要被隱藏或者「跳過」,避免最終成圖效果不佳。
- 部落格文章一般長度都很長,所以生成的圖片尺寸普遍比較大,某些平台限制圖片單張尺寸、並且
puppeteer
在生成超長圖片時,會「花屏」。
如何避免截取到不必要的元素

像上圖中用紅色線框圈出的部分,不太希望在圖片生成的過程中也被「記錄」下來。如果是在瀏覽器中,可以在頁面中執行 JavaScript 程式碼來刪除這些元素,解決問題,比如:
const selector = "#J_footer-container,.page-navigation-container,.page-comments-container"; const elements = document.querySelectorAll(selector); for (let i = 0; i < elements.length; i++) { elements[i].parentNode.removeChild(elements[i]); }
當然,結合 puppeteer
需要一些小小的改造:
'use strict'; const puppeteer = require('puppeteer'); const { 'iPhone X': deviceModel } = require('puppeteer/DeviceDescriptors'); const { readFileSync } = require('fs'); const targetLinks = readFileSync('./target.txt', 'utf-8').split('n').filter(n => n); const elementsRemoved = "#J_footer-container,.page-navigation-container,.page-comments-container"; (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.emulate(deviceModel); for (let i = 0, j = targetLinks.length; i < j; i++) { await page.goto(targetLinks[i]); await page.evaluate((selector) => { const elements = document.querySelectorAll(selector); for (let i = 0; i < elements.length; i++) { elements[i].parentNode.removeChild(elements[i]); } }, elementsRemoved) await page.screenshot({ path: `./blog-${i}.png`, fullPage: true }); } await browser.close(); })();
將程式碼保存為 blog.js
,然後執行 node blog.js
,如果文章不是特別長的話,你就能成功得到本小節開頭的部落格文章長圖了。
將長圖分割避免圖片生成錯誤
但是如果你想生成圖片的文章特別長,會得到下面的結果:一張沒有生成完畢的圖片。

4月份的時候和 @貘大 有請教過,這個截圖的 Bug 其實來自Google 官方的一次提交。
DevTools: capture full page screenshot renders blank page for pages higher than 0x4000px. Bug: 831773 Change-Id: Ia5dfad17af526b495c38d6827292364a1d505dba TBR: dgozman Reviewed-on: https://chromium-review.googlesource.com/1010476 Commit-Queue: Pavel Feldman <[email protected]> Reviewed-by: Pavel Feldman <[email protected]> Reviewed-by: Dmitry Gozman <[email protected]> Cr-Commit-Position: refs/heads/master@{#550264}
如下圖所示,官方出於性能考慮,限制了頁面全螢幕模式下獲取的圖片高度,感興趣的同學可以圍觀程式碼提交地址。

解決方案也很簡單:自己編譯一個 puppeteer
並去掉限制,或者更簡單一些,將圖片切割為若干塊。
程式碼實現並不難,只需要在之前的程式碼基礎上再多寫十行,就能解決問題了。
'use strict'; const puppeteer = require('puppeteer'); const { 'iPhone X': deviceModel } = require('puppeteer/DeviceDescriptors'); const { readFileSync } = require('fs'); const links = readFileSync('./target.txt', 'utf-8').split('n').filter(n => n); const elementsRemoved = "#J_footer-container,.page-navigation-container,.page-comments-container"; (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.emulate(deviceModel); for (let i = 0, j = links.length; i < j; i++) { await page.goto(links[i]); await page.evaluate((selector) => { const elements = document.querySelectorAll(selector); for (let i = 0; i < elements.length; i++) { elements[i].parentNode.removeChild(elements[i]); } }, elementsRemoved); const { width: viewWidth, height: viewHeight } = page.viewport(); const pageHeight = await page.evaluate(_ => document.body.scrollHeight); const dpr = await page.evaluate('window.devicePixelRatio'); const maxHeight = viewHeight * 8; const splitCount = Math.ceil(pageHeight / maxHeight); const lastViewHeight = pageHeight - ((splitCount - 1) * maxHeight) for (let i = 1; i <= splitCount; i++) { await page.screenshot({ clip: { x: 0, y: maxHeight * (i - 1), width: viewWidth, height: i !== splitCount ? maxHeight : lastViewHeight }, path: `./out/split-${i}-@${dpr}x.png` }); } } await browser.close(); })();
將上面的程式碼保存為 split.js
,然後執行 node split.js
就能獲取一張正常的圖片啦。

最後
如果你閱讀過我的其他文章,會發現我一直在嘗試使用簡短程式碼和簡單方案去解決我們日常中遇到的許多看似複雜的需求。
其實很多時候,這些需求並不複雜,只要你願意靜下心來把它進行合理拆分,用簡單可依賴的方案逐步擊破就完事了。
但是做事的人往往陷入自己的固有知識陷阱中,把事情想的太過複雜、實施的太過複雜,以至於後續項目加入成本過高、難以維護。
如果你看到了這裡,希望你在做事的過程中,可以多想想有沒有什麼更簡單的方式解決你當前手頭的問題,而不是一味追求「同構、高大上的方案」。
共勉。
—EOF