使用 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、编程上的一些问题,也会在群里不按期的分享一些技术沙龙的资料。

喜欢折腾的小伙伴欢迎扫码添加好友。(请注明来源和目的,不然不会经过审核)

关于折腾群入群的那些事

相关文章
相关标签/搜索