使用 Node.js 生成方便傳播的圖片

本文使用「署名 4.0 國際 (CC BY 4.0)」許可協議,歡迎轉載、或從新修改使用,但須要註明來源。 署名 4.0 國際 (CC BY 4.0)html

本文做者: 蘇洋前端

建立時間: 2019年07月28日 統計字數: 5452字 閱讀時間: 11分鐘閱讀 本文連接: soulteary.com/2019/07/28/…node


使用 Node.js 生成方便傳播的圖片

平常工做中,總會遇到一些須要和一些和「批量生成圖片」相關的事情,尤爲是在須要作內容傳播的場景下:畢竟圖片更直觀、更有衝擊力nginx

  • 手頭有一堆招聘需求,可是平臺容許發佈的字數有限,不要緊,可使用九宮格圖片大法,把內容當長圖發出來,可是製做長圖還須要考慮排版,純代碼實現太過繁瑣。
  • 舉辦完一場活動,須要講師分享內容給更多人,讓更多的人知道這個活動,傳播一張稍微有設計感的圖片到朋友圈,這個時候咱們須要製做和講師相關的傳播圖片。
  • 寫了一篇博客,可是微博等平臺排版全亂,換成長圖傳播才能保留格式等等。

網上經常會推崇使用 node canvas / webgl / web canvas 來解決問題。在我看來,大可沒必要,其實使用 Node.js 寫幾十行腳本搭配無頭瀏覽器就能搞定問題。那麼下面就來聊聊,如何編寫簡單可依賴的 Node 腳本。web

寫在前面

不少時候,咱們會沉迷於使用某一門語言、某一種技術解決全部問題,雖然對於程序維護來講成本很低,可是在執行效率上來看,就得不償失了。docker

固然,若是是簡單純粹的內容,好比訪客簽名、二維碼生成,就另當別論了,不須要考慮複雜排版、幾乎不須要對內容風格進行定製,好比我以前提過的:express

讓咱們先從最簡單的開始講起,批量生成招聘需求圖片(重視排版)。編程

批量生成招聘需求圖片

微博之類的社交網站的九宮格長圖

招聘需求類的圖片重在內容排版,特別適合使用 Markdown 書寫,配合 Hugo / Hexo 之類的靜態網站生成工具生成簡潔漂亮的頁面,而後再經過截圖等方式獲得咱們要的結果。canvas

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,便能看到排版還算過得去的頁面了。

使用 Hugo 排版後的頁面

接着稍微寫幾行 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 <pfeldman@chromium.org>
Reviewed-by: Pavel Feldman <pfeldman@chromium.org>
Reviewed-by: Dmitry Gozman <dgozman@chromium.org>
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


我如今有一個小小的折騰羣,裏面彙集了一些喜歡折騰的小夥伴。

在不發廣告的狀況下,咱們在裏面會一塊兒聊聊軟件、HomeLab、編程上的一些問題,也會在羣裏不按期的分享一些技術沙龍的資料。

喜歡折騰的小夥伴歡迎掃碼添加好友。(請註明來源和目的,不然不會經過審覈)

關於折騰羣入羣的那些事

相關文章
相關標籤/搜索