头图
大家好,我是猫小白,本文带来我最近几天闭关修炼,游走与各大技术论坛最终完成的一个小项目,希望能帮到有相同需求的同学。

我做了个什么东西呢?

其实很简单:通过调用接口把返回的数据转换成表格或者文字定时发送邮件到指定的人!

这个功能干什么用呢?

举个栗子:某位产品负责人很关心上线的app每天有多少注册的新用户,他可能不会经常打开系统后台去查看,只是想每天通过邮件查看,比如在处理其它邮件的时候,顺便看一下,做到心中有数。

又或者,某个管理系统的人,需要在别人发起类似订单的时候及时核对信息并且第一时间审核该订单,那可以通过邮件的方式提醒它。

有的人会想到短信提醒,但是毕竟是收费的。

技术前提:

众所周知、邮件内容是可以加入html字符串的,并且class样式和行内样式都能生效,但是无法运行javascript代码。所以echarts无法动态渲染到邮件中,不过我们可以通过某些手段转换成base64,把它作为图片插入到邮件中。

梳理下整个程序的实现流程

v2-0cadf040e6d945cd39ca4d1407767803_720w_看图王.jpg

在之前新建一个空文件夹,运行npm init把项目搭建起。

主流程如下:

  1. 新建一个html文件,先在本地把样式和布局写好(可以通过live-server等插件本地预览),需要替换的代码片段删除后用变量替代列如:{{body}}{{date}}{{img}}
  2. 通过axios模块获取接口数据,这里是我同事提供的。
  3. 通过接口数据拼接响应的字符串并替换模板中的对应变量,例如:<tr><td>balaba</td><td>lalala</td></tr>替换模板中表格的{{body}}
  4. 通过接口数据渲染出echarts并截图转换为base64,替换img标签的src属性,也就是替换模板中的{{img}}变量。
  5. 配置并发送邮件
  6. 完成定时器函数,监听时间,达到设定的时间才发送。

整个流程看起来比较繁琐,其实在实现程序的时候写着写着后面的步骤就清晰了,我们一步一步看如何实现的。

第一步:新建html,写好样式和布局

这一步没啥好说的,邮件又不需要多花哨,一个表格一些文字就搞定了,大家分分钟就搞定了。

写好html后,把style标签里面的样式代码和body里面的代码赋值到一个js中导出备用。

取名:template.js
大致内容如下(被我删减了一些,看下意思就行):

/**
 * 邮箱模板
 */
const template = `<div>
<style>
    
    p {
        font-size: 13px;
        line-height: 27px;
        padding: 0;
        margin: 0;

        color: #333;
    }

    .content {
        display: flex;
        /* justify-content: center; */
        flex-direction: column;
        align-items: left;
    }

    .img {
        margin-top: 10px;
    }
    table {
        border: 1px solid rgb(206, 206, 206);
        border-collapse: collapse;
        width: 100%;
        max-width: 500px;
        margin-bottom: 16px;
    }
</style>
    <p>各位领导、同事好,</p>
    <p style="text-indent:30px">{{date}}XXXX统计信息如下:</p>
    <div class="content">
        <div>
            <table>
                <thead>
                    <th>表头字段1</th>
                    <th>表头字段2</th>
                    <th>表头字段3</th>
                </thead>
                <tbody>
                    {{tbody1}}
                </tbody>
            </table>
           <img src="{{img1}}" alt="">
        </div>
    </div>
    <p style="text-align: left;margin-top: 10px;">
        统计时间:{{datelong}}
    </p>
`
exports.template = template;

注意其中的关键内容用{{变量名}}的方式替换,方便填充自己的内容; 我这里有表格内容的{{tbody1}}、图片{{img1}}还有统计时间{{datelong}}

第二步:通过axios模块获取接口数据

新建 index.js,安装axios、封装一个函数去拿后端接口的数据,这里老司机都懂,就不再赘述。

第三步:拼接字符串并替换模板中的变量

首先在index.js中引入上面写好的模板。

const {
    template
} = require("./template.js"); //模板

通过循环接口数据,生成<tr>或者<td>字符串,大致代码如下:

let html = '';//储存str
data.forEach((item, index) => {
    html += ` <tr>
        <td>${index+1}</td>
        <td>${item['开始时间']} 至 ${item['结束时间']}</td>
        <td>${item['合规率']}</td>
    </tr>`
});

拼接好后我们替换模板中的变量,通过字符串替换函数replace()

代码如下:

let content = template.replace('{{tbody1}}', html)
     .replace('{{tbody2}}', html2)
     .replace('{{datelong}}', parseTime(new Date()))
     .replace('{{date}}', timeFormat(new Date(), '-'));

第四步:通过接口渲染出echarts并截图转换为base64,并替换{{img}}变量。

这一步比起其他步骤来说,要困难很多(对之前来说),开始我想不就是截个图吗,网上应该有很多比较成熟的框架吧,于是我就轻易的搜到了一个叫做node-echarts的库。这个名字和功能很贴切是不是~ github链接

心想,这么简单?但是一看下载量周几十和上次维护时间:Published 3 years ago

2.png

使用也比较简单:


var node_echarts = require('node-echarts');
var config = {
    width: 500, // Image width, type is number.
    height: 500, // Image height, type is number.
    option: {}, // Echarts configuration, type is Object.
    //If the path  is not set, return the Buffer of image.
    path:  '', // Path is filepath of the image which will be created.
    enableAutoDispose: true  //Enable auto-dispose echarts after the image is created.
}
node_echarts(config)

但是我并没有立即开始写代码,而是看下它的依赖,发现核心是使用的node-canvas的一个库。说白了这个库可以生成一个canvas对象,就像在浏览器端的canvas一样。我们有这个canvas对象就可以使用echarts的库了。

使用代码如下:

const {
    createCanvas
} = require('canvas')
const echarts = require('echarts')
// 生成图片 base64
function generateImage(options, width = 800, height = 600, theme = 'chalk') {
    const canvas = createCanvas(width, height)
    const ctx = canvas.getContext('2d')
    ctx.font = '12px'
    echarts.setCanvasCreator(() => canvas)
    const chart = echarts.init(canvas, theme)
    options.animation = false
    chart.setOption(options);
    //返回base64字符串
    return `data:image/png;base64,` + chart.getDom().toBuffer().toString('base64');
}

是不是也挺简单的,几行就已经生成了我们想要的截图base64编码,加入到imgsrc属性中,测试在浏览器中可以正常显示出图片。

等我已经完成了程序,准备上线了,我本地是用windows系统开发测试的。但是公司服务器是linux系统的,于是开始了我在linux系统部署的旅程,结果翻车了~
原因是node-canvas运行在windows系统和linux所需的依赖是不同的。不仅仅是npm install就完事了。

插件官方里面有说到,在不同系统中需要先安装不同的依赖:

微信截图_20220328212332.png

按照文档安装后依赖后,还是报错:

微信图片_20220330123817.png

可能是个人能力不足,网上找了许多文章,整了一天,最终还是没解决~。

搞了一下午之后,我想到换一种思路,使用puppeteer无头浏览器去加载我本地html文件,然后截图即可。

Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过DevTools 协议控制 Chrome 或 Chromium 。Puppeteer 默认运行[无头],但可以配置为运行完整(非无头)Chrome 或 Chromium。

可以做什么:

  • 生成页面的屏幕截图和 PDF。
  • 抓取 SPA(单页应用程序)并生成预渲染内容(即“SSR”(服务器端渲染))。
  • 自动化表单提交、UI 测试、键盘输入等。
  • 创建最新的自动化测试环境。使用最新的 JavaScript 和浏览器功能直接在最新版本的 Chrome 中运行测试。
  • 捕获您网站的时间线轨迹以帮助诊断性能问题。
  • 测试 Chrome 扩展程序。
我们的需求就是把echartshtml中绘制好,等绘制完成后用这个插件截图,导出为base64字符即可。

话不多说,那我们开始吧

先看下html代码

自己在项目中随便建一个html文件:

<body style="height: 1300px; margin: 0">
  <div id="container1" style="width: 800px;height: 600px;"></div>
  <div id="container2" style="width: 800px;height: 600px;margin-top: 50px;"></div>

  <script type="text/javascript" src="./js/echarts.js"></script>
  <script type="text/javascript" src="./js/chalk.js"></script>

  <script type="text/javascript">
    var dom1 = document.getElementById("container1");
    var dom2 = document.getElementById("container2");
    var myChart1 = echarts.init(dom1, 'chalk');//第二个参数是样式主题名称。可以不要
    var myChart2 = echarts.init(dom2, 'chalk');//第二个参数是样式主题名称。可以不要
    //注册全局方法,后面会调用到
    window.loadEcharts = function (option) {
      myChart1.setOption(option[0]);
      myChart2.setOption(option[1]);
    }
  </script>
</body>

因为我的需求是要生成2张图篇,所以创建了2个div容器。

echarts.js复制到本地,加载速度快一些。chalk.jsecharts的主题插件,非必须。叫做 echarts-theme.jsnpm上面可搜到,使用方法也简单。

先忽略 window.loadEcharts方法。我们下面会介绍为什么要写这个全局方法。

其次在screenShot.js中实现截图的核心代码:

/**
 * 通过puppeteer无头浏览器,打开本地html,调用方法传入option参数 加载echarts图形并截图为base64
 * @param {Object} opt1  图形1的option参数
 * @param {Object} opt2  图形2的option参数
 * @returns 
 */
async function getScreenshot(opt1, opt2) {
    const browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox']})
    // const browser = await puppeteer.launch();
    const page = await browser.newPage();
    await page.goto('http://127.0.0.1:3001/html/index.html');
    // await page.goto(path.resolve(__dirname, './html/line-simple.html'));
    await page.evaluate((arr) => { //调用html全局方法loadEcharts 把参数传过去
        loadEcharts(arr)//接受参数 执行html中的windows全局方法
    }, [opt1, opt2]);//参数必须在这里传入
    console.log('等待1s'); //等待echarts图形渲染完成
    await page.evaluate(async () => {
        await new Promise(function (resolve) {
            setTimeout(resolve, 1000)
        });
    });
    //分开获取2个echarts截图
    const el1 = await page.$('#container1');
    const el2 = await page.$('#container2');
    //截图成base64
    let img1 = `data:image/png;base64,` + await el1.screenshot({
        encoding: 'base64'
    });
    let img2 = `data:image/png;base64,` + await el2.screenshot({
        encoding: 'base64'
    });
    console.log('截图成功');
    await browser.close();
    return [img1, img2]
}
//图1的配置
let option_rate = {
    xAxis: [{
        type: 'category',
        boundaryGap: false,
        data: []
    }],
    yAxis: [{
        type: 'value'
    }],
    series: [{
        type: 'line',
        data: []
    }]
};
//图2的配置
let option_type = {
    legend: {
        top: 'bottom'
    },
    series: [{
        type: 'pie',
        radius: ['40%', '70%'],
        avoidLabelOverlap: false,
        data: [],
    }]
};
//对外暴露接口 根据接口数据 拼接echarts的option配置,调用截图方法getScreenshot
module.exports.getImgs = async function (results1, results2) {
    let xAxisArr = [];
    let seriesArr = [];
    results1.forEach(result => {
        xAxisArr.push(result.name)
        seriesArr.push(result.value)
    });
    option_rate.xAxis[0].data = xAxisArr;
    option_rate.series[0].data = seriesArr;

    option_type.series[0].data = results2;
    return await getScreenshot(option_rate, option_type);
}

说明:

  1. opt1opt2分别是echartsoption配置项参数。
  2. {args: ['--no-sandbox', '--disable-setuid-sandbox']}参数不能省略,不然在linux中会报错。
  3. page.goto('http://127.0.0.1:3001/html/index.html') 本地html需要用http服务访问,可以用nginx或者express等搭建一个,我采用的express搭建(3001端口)。
  4. page.evaluate(()=>{},option)执行页面方法,里面可以拿到html页面的windows全局方法也就是上面提到loadEcharts,注意第二个参数option需要传入`。
  5. page.evaluate()方法让程序等待1s,等待echarts渲染完成后再截图。
  6. const el1 = await page.$('#container1');是获取特定的dom完成截图,所以我这里分开截了2次。
  7. 转换后base64字符串需要加上前缀:data:image/png;base64,才能作为imgsrc属性使用。

htmlscreenShot.js都准备好了,还差一个邮件发送模块 nodemailer.js

这里选择用nodemailer模块,使用起来其实很简单。可以点这篇文章了解详情

nodemailer.js代码如下:

//引入模块 nodemailer
const nodemailer = require('nodemailer')

const {
    timeFormat
} = require("./tools.js"); //工具函数
const config = {
    host: 'smtp.exmail.qq.com',
    //端口
    port: 465,
    secureConnection: true,
    auth: {
        // 发件人邮箱账号
        user: 'xxxxxxxxx@qq.com',
        //发件人邮箱的授权码
        pass: 'xxxx'
    }
}
const mail = {
    // 发件人 邮箱  '昵称<发件人邮箱>'
    from: '自动检测程序<xxxxxxxxx@qq.com>',
    // 主题
    subject: 'XXXXXX每日统计_',
    // 收件人 的邮箱 可以是其他邮箱 不一定是qq邮箱
    to: 'xxxxxxxx@163.com',
    html: '',
}

exports.sendEmail = function (content) {
    const transporter = nodemailer.createTransport(config);
    if (content) mail.html = content;
    return new Promise((res, rej) => {
        transporter.sendMail({
            ...mail,
            subject: mail.subject + timeFormat(new Date()) + '(自动发送)'
        }, function (error, info) {
            if (error) {
                rej('发送失败' + error);
                return console.log(error);
            }
            transporter.close()
            console.log('mail sent:', info.response)
            res();
        })
    })
}

邮件模块配置好了,我们在入口文件中引入整个模块就好。

const {
    sendEmail
} = require("./nodemailer.js"); //发送邮件模块

好了,该有的模块和工具我们都写好了,但是还有一个小需求:邮件不是执行立即发送的,而是定时发送
我的需求是每天9点整发送

我的定时函数是这样:

let SENDWeekDAY = -1; // 负数每天  正数1-7 周一到周日
let SENDHOUR = 9; // 9点发送
let SENDMINUTES = 00; // 分钟
let sendList = {};

/**
 * 判断是否达到发送时间,保证每天发送一条数据
 * @returns 
 */
function isSendTime() {
    const now = new Date();
    let sendDateStr = timeFormat(now);
    if ((now.getDay() == SENDWeekDAY || SENDWeekDAY == -1) && now.getHours() == SENDHOUR && now.getMinutes() == SENDMINUTES) {
        if (!sendList[sendDateStr]) {
            sendList[sendDateStr] = true;
            return true;
        }
    }
}
SENDWeekDAY、SENDHOUR、SENDMINUTES、可以简单配置发送的时机。你可以自己实现一个定时函数。

好了,我们所有需要的功能都已经有了,下面贴出入口文件的核心代码:

在入口文件index.js中:

const {
    GET,
    timeFormat,
    parseTime,
} = require("./tools.js"); //工具函数 可自己封装

const {
    sendEmail
} = require("./nodemailer.js"); //发送邮件模块

const {
    template
} = require("./template.js"); //模板引入
const {
    getImgs,
} = require("./screenShot.js")//截图模块引入

let SENDWeekDAY = -1; // 负数每天  正数1-7 周一到周日
let SENDHOUR = 9; // 9点发送
let SENDMINUTES = 00; // 分钟
let sendList = {};

const express = require('express');//加载express 
const path = require('path');
//开启一个本地静态服务
const app = express();
app.use('/html', express.static(path.join(__dirname, 'html')));
app.listen(3001);

//主函数
(function () {
   //test();//测试使用
   setInterval(() => {
        if (isSendTime()) {//检测是否发送
            test();//发送
        }
    }, 45 * 1000)
})()

/**
 * 判断是否达到发送时间,保证每天发送一条数据
 * @returns 
 */
function isSendTime() {
    const now = new Date();
    let sendDateStr = timeFormat(now);
    if ((now.getDay() == SENDWeekDAY || SENDWeekDAY == -1) && now.getHours() == SENDHOUR && now.getMinutes() == SENDMINUTES) {
        if (!sendList[sendDateStr]) {
            sendList[sendDateStr] = true;
            return true;
        }
    }
}

/**
 * 请求数据组装模板
 */
function test() {
    //GET是封装的请求函数基于axios
    Promise.all([GET(url_rate), GET(url_type_calc)]).then(([rateArr, typesArr]) => {
        let html1 = ''; //表格1字符串
        let html2 = ''; //表格2字符串
        let option1_series = [];//图表1的series数据
        let option2_series = [];//图表2的series数据
       
        
        //....省略数据处理和html代码拼接
        
        //用字符串replace方法替换变量
        let content = template.replace方法替换变量('{{tbody1}}', html1)
            .replace('{{tbody2}}', html2)
            .replace('{{datelong}}', parseTime(new Date()))
            .replace('{{date}}', timeFormat(new Date(), '-'));
        //获取图表Base64 img
        return getImgs(option1_series, option2_series).then(([img1, img2]) => {
             //替换图片
            content = content.replace('{{img1}}', img1).replace('{{img2}}', img2);
            return content;
        });
    }).then(res => {
        send(res); // 发送邮件
    }).catch(err => {
        console.log('服务器出错', err);
    })
}

//发送邮件
function send(content) {
    content && sendEmail(content).then(() => {
        console.log('---' + timeFormat(new Date()) + '报告发送成功');
    })
}
上面代码有删减,无法直接运行,需要你理清主要思路自己实现。

linux安装Puppeteer再次遇到问题

自己按照官方文档安装,死活运行不起,报错,在issue里面也有人有相同的问题,但都没有很好的解决,难点在于每个人的系统不同、版本不同,报的错误往往是不同的。

后来我找到一个大佬的博客 点这里

我的服务器环境是centos7

简单来说就是npm install puppeteer,以后还需要安装一些列依赖才能正常运行。

#依赖库
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 -y

再者初始化的时候需要加上参数 {args: ['--no-sandbox', '--disable-setuid-sandbox']}

const browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox']});
这个不一定能解决你的问题,因为我们系统可能不同,可以到issue里面找~

我想这下截图看到没问题了吧,确实截图没问题了,不过没那么简单~

字体又出问题了

linux上默认是不支持中文字体的,所以截的echarts图里面中文会显示乱码。这问题可谓是一波未平一波又起啊~
看图:

3.png

既然来都来了,那就整到底,总体来说不难,这里就不展开说了。但有一点要注意,复制本机字体的时候,如果发现一个字体安装好了还是不行,那就换一个字体例如:微软雅黑、宋体、简体,最好不要选英文名称的字体。点这里学习linux安装中文字体

安装好字体后终于大功告成,虽然感觉字体还是有点奇怪,不过现在这样已经可以满足需求了。

4.jpg

关闭执行窗口后,如何保持执行?

众所周知,windows中我们关闭cmd窗口后,node执行的服务就自动关闭。那在linux上如何保持node服务一直执行呢?
点击这篇文章,学习pm2的安装

pm2就是可以让node程序一直运行,关闭窗口也不会关闭。除非使用它的命令执行关闭操作。

安装:

npm install pm2 -g

启动node服务:

首先通过cd 命令进入自己项目的根目录。

3.png

pm2 start index.js //运行入口文件 index.js或者自己的app.js
//或者--name 加名称方便区别
pm2 start index.js --name send2.0

查看运行的程序列表:

pm2 list

微信截图_20220331090301.png

查看node日志:

pm2 log id(程序id或者name)

微信截图_20220331090334.png

删除指定程序:

pm2 delete id(程序id或者name)

重启程序:

pm2 restart id(程序id或者name)

以上。

感觉每一个问题都可以出一篇文章了,由于这是一个小众需求,所以网上相关文章并不是很多,遇到问题只有自己多翻文档,或者换个思路说不定就顺畅了。

这篇文文章希望能帮到你~

微信图片_20220306173458.jpg

肯请各位大佬,不要忘了给我点赞评论收藏

往期精彩:

1. 【混淆系列】一问:module.exports、exports、export都是导出,有何区别?

2.【包真】我的第一次webpack优化,首屏渲染从9s到1s


华仔
18 声望1 粉丝

前端爱好者,爱好分享,一起进步