Nodejs 爬取one和墨迹天气定时发邮件

| .

根据用户配置 爬取 one 和不同地区墨迹天气 每天定时发邮件,支持多人地区个性化定制

效果展示


如何快速使用

1. 拉取代码安装依赖

这里使用yarn作为包管理器

1
2
3
git clone https://github.com/cyea/email-bot.git
cd email-bot
yarn

2. 配置

① 修改发送者邮箱账号密码敏感配置

新建.env文件 格式是跟.env.example 一样的 填入自己的邮箱账号密码及邮件提供商

1
2
3
4
NODE_ENV = production #正式环境精简代码所用
EmianService = outlook #邮件提供商 支持列表:https://nodemailer.com/smtp/well-known/
EamilAuth_user = xxxx@outlook.com #发送者邮箱地址
EamilAuth_pass = xxxxxxxxx # smtp 授权码

② 修改其他不敏感配置

修改config/index.js里的配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const { env } = process;
module.exports = {
ONE: "http://wufazhuce.com/", // ONE的web版网站
MOJI_HOST: "https://tianqi.moji.com/weather/china/", // 中国墨迹天气url,
EmianService: env.EmianService, // 发送者邮箱厂家
EamilAuth: {
// 发送者邮箱账户用户名及密码
user: env.EamilAuth_user,
pass: env.EamilAuth_pass
},
EmailFrom: "yuyehack@outlook.com", // 发送者昵称与邮箱地址
EmailSubject: "一封暖暖的小邮件", // 邮件主题
/**
* @description: 收信人详细
*/
EmailToArr: [
{
TO: "yuyehack@gmail.com", // 接收者邮箱地址
CITY: "jiangsu", // 墨迹天气链接末尾城市代码
LOCATION: "pukou-district" // 墨迹天气链接末尾详细地区代码
},
{
TO: "yuyehack@qq.com",
CITY: "jiangsu",
LOCATION: "kunshan"
}
],
//每日发送时间
SENDDATE: "58 15 8 * * *"
};

③ 运行

1
yarn start

代码详解

具体代码可见 https://github.com/cyea/email-bot.git

先展示下项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
├─config
│ index.js #配置

├─email
│ index.js #发送邮件模块

├─superagent
│ index.js #获取天气及ONE 数据

├─utils
│ index.js #通用工具函数
│ superagent.js #请求发送封装

├─view
| index.js #生成邮件样式模块
| index.njk #邮件样式模板模块
│ .env.example #.env
│ index.js #服务启动模块
│ schedule.js #定时模块
│ test.js #模板样式调试模块
│ yarn.lock
│ .gitignore
│ LICENSE
│ package.json
│ README.md

1. 爬取数据

使用 superagent 和 cheerio 组合来实现爬虫

① superagent 使用

因为多次两次使用的superagent 函数代码结构类似 所以我再把 superagent 封装了一次 Promise 抛出 fetch方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// utils/superagent.js
const superagent = require("superagent");
//请求
function fetch(url, method, params, data, cookies) {
return new Promise(function(resolve, reject) {
superagent(method, url)
.query(params)
.send(data)
.set("Content-Type", "application/x-www-form-urlencoded")
.end(function(err, response) {
if (err) {
reject(err);
}
resolve(response);
});
});
}
module.exports = fetch;

② 数据爬取

  • 爬取 ONE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const getOne = async () => {
// 获取每日一句
let res = await fetch(config.ONE, "GET");
let $ = cheerio.load(res.text); //转化成类似jquery结构
let todayOneList = $("#carousel-one .carousel-inner .item");
// 通过查看DOM获取今日句子
let info = $(todayOneList[0])
.find(".fp-one-cita")
.text()
.replace(/(^\s*)|(\s*$)/g, "");
let imgSrc = $(todayOneList[0])
.find(".fp-one-imagen")
.attr("src");

return {
// 抛出 one 对象
one: {
info,
imgSrc
}
};
};
  • 爬取天气
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
const getWeather = async (city, location) => {
//获取墨迹天气
let url = config.MOJI_HOST + city + "/" + location; // 根据配置得到天气url
let res = await fetch(url, "GET");
let $ = cheerio.load(res.text);

//获取墨迹天气地址
let addressText = $(".search_default")
.text()
.trim()
.split(", ")
.reverse()
.join("-");

//获取墨迹天气提示
let weatherTip = $(".wea_tips em").text();

// 获取现在的天气数据
const now = $(".wea_weather.clearfix");

let nowInfo = {
Temp: now.find("em").text(),
WeatherText: now.find("b").text(),
FreshText: now.find(".info_uptime").text()
};

// 循环获取未来三天数据
let threeDaysData = [];
$(".forecast .days").each(function(i, elem) {
// 循环获取未来几天天气数据
const SingleDay = $(elem).find("li");
threeDaysData.push({
Day: $(SingleDay[0])
.text()
.replace(/(^\s*)|(\s*$)/g, ""),
WeatherImgUrl: $(SingleDay[1])
.find("img")
.attr("src"),
WeatherText: $(SingleDay[1])
.text()
.replace(/(^\s*)|(\s*$)/g, ""),
Temperature: $(SingleDay[2])
.text()
.replace(/(^\s*)|(\s*$)/g, ""),
WindDirection: $(SingleDay[3])
.find("em")
.text()
.replace(/(^\s*)|(\s*$)/g, ""),
WindLevel: $(SingleDay[3])
.find("b")
.text()
.replace(/(^\s*)|(\s*$)/g, ""),
Pollution: $(SingleDay[4])
.text()
.replace(/(^\s*)|(\s*$)/g, ""),
PollutionLevel: $(SingleDay[4])
.find("strong")
.attr("class")
});
});

return {
moji: {
addressText,
weatherTip,
nowInfo,
threeDaysData
}
};
};

③ 数据合并

异步获取两个数据

1
2
3
4
5
6
7
const getAllData = async (city, location) => {
let oneData = await getOne();
let weatherData = await getWeather(city, location);
const allData = { today: formatDate(), ...oneData, ...weatherData };
return allData;
};
module.exports = getAllData;

2. 模版引擎生成 HTML

① 模板编写

ejs 这种模板已经年老 更新不及时,所以换了更清晰更新的 nunjucks 因为邮件不支持外链 css 所以使用内联 css 虽然比较麻烦

使用刚获取到数据 模板渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<!-- index.njk -->
<div style="padding: 0;max-width: 600px;margin: 0 auto;">
<div style="width:100%; margin: 40px auto;font-size:20px; color:#5f5e5e;text-align:center">
<span>今天是{{today}}</span>
</div>
<div style="width:100%; margin: 20px auto;font-size:16px;color:#2bbc8a;text-align: center;">
<span><span style="font-family: Arial;font-size: 100px;line-height: 1;">{{moji.nowInfo.Temp}}</span><span style="vertical-align: top;"></span></span>
<span style="font-size: 30px;padding-top: 50px;">{{moji.nowInfo.WeatherText}}</span>
</div>
<div style="text-align:center;font-size:12px;color:#d480aa">{{moji.addressText}}</dev>
<div style="width:100%; margin: 0 auto;color:#5f5e5e;text-align:center">
<span style="display:block;color:#676767;font-size:20px">{{moji.weatherTip}}</span>
<span style="display:block;margin-top:15px;color:#676767;font-size:15px">近期天气预报</span>

{% for item in moji.threeDaysData %}
<div style="display: flex;margin-top:5px;height: 30px;line-height: 30px;justify-content: space-around;align-items: center;">
<span style="width:15%; text-align:center;">{{ item.Day }}</span>
<div style="width:25%; text-align:center;">
<img style="height:26px;vertical-align:middle;" src='{{ item.WeatherImgUrl }}' alt="">
<span style="display:inline-block">{{ item.WeatherText }}</span>
</div>
<span style="width:25%; text-align:center;">{{ item.Temperature }}</span>


{% if (item.PollutionLevel==='level_1') %}
<div style="width:35%; ">
<span style="display:inline-block;padding:0 8px;line-height:25px;color:#8fc31f; border-radius:15px; text-align:center;">{{ item.Pollution }}</span>
</div>

{% elif (item.PollutionLevel==='level_2') %}
<div style="width:35%; ">
<span style="display:inline-block;padding:0 8px;line-height:25px;color:#d7af0e; border-radius:15px; text-align:center;">{{ item.Pollution }}</span>
</div>

{% elif (item.PollutionLevel==='level_3') %}
<div style="width:35%; ">
<span style="display:inline-block;padding:0 8px;line-height:25px;color:#f39800; border-radius:15px; text-align:center;">{{ item.Pollution }}</span>
</div>
{% elif (item.PollutionLevel==='level_4') %}
<div style="width:35%; ">
<span style="display:inline-block;padding:0 8px;line-height:25px;color:#e2361a; border-radius:15px; text-align:center;">{{ item.Pollution }}</span>
</div>
{% elif (item.PollutionLevel==='level_5') %}
<div style="width:35%; ">
<span style="display:inline-block;padding:0 8px;line-height:25px;color:#5f52a0; border-radius:15px; text-align:center;">{{ item.Pollution }}</span>
</div>
{% elif (item.PollutionLevel==='level_6') %}
<div style="width:35%; ">
<span style="display:inline-block;padding:0 8px;line-height:25px;color:#631541; border-radius:15px; text-align:center;">{{ item.Pollution }}</span>
</div>
{% else %}
<div style="width:35%; ">
<span style="display:inline-block;padding:0 8px;line-height:25px;color:#631541; border-radius:15px; text-align:center;">none</span>
</div>
{% endif %}
</div>

{% endfor %}
</div>
<div style="text-align:center;margin:35px 0;">
<span style="display:block;margin-top:55px;color:#676767;font-size:15px">ONE · 一个</span>
<img src="{{ one.imgSrc }}" style="max-width:100%;margin:10px auto;" alt="">
<span style="color:#b0b0b0;font-size:13px;">摄影</span>
<div style="margin:10px auto;width:85%;color:#5f5e5e;" >{{one.info}}</div>
</div>
</div>

② 模板渲染

node fs 模块 读取本地模板文件 抛出 渲染好的 html 结构数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const nunjucks = require("nunjucks");
const fs = require("fs");
const path = require("path");

const getHtmlData = njkData => {
return new Promise((resolve, reject) => {
try {
const njkString = fs.readFileSync(
path.resolve(__dirname, "index.njk"),
"utf8"
);
const htmlData = nunjucks.renderString(njkString, njkData);
resolve(htmlData);
} catch (error) {
reject(error);
}
});
};
module.exports = getHtmlData;

3. 使用 Node 发送邮件

这里使用 nodemailer

注意的是邮箱密码不是你登录邮箱的密码,而是 smtp 授权码,什么是 smtp 授权码呢?就是你的邮箱账号可以使用这个 smtp 授权码在别的地方发邮件,一般 smtp 授权码在邮箱官网的设置中可以看的到.不知道的话可以使用邮箱账号及密码试试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const config = require("./../config");
const sendMail = (transporter, To, HtmlData) => {
return new Promise((resolve, reject) => {
let mailOptions = {
from: config.EmailFrom, // 发送者邮箱
to: To, // 接收邮箱
subject: config.EmailSubject, // // 邮件主题
html: HtmlData //模板数据
};
transporter.sendMail(mailOptions, (error, info = {}) => {
if (error) {
console.error("邮件发送成功" + error);
reject(error);
} else {
console.log("邮件发送成功", info.messageId);
console.log("静等下一次发送");
resolve();
}
});
});
};
module.exports = sendMail;

4. 整合运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let transporter = nodemailer.createTransport({
service: EmianService,
port: 465,
secureConnection: true,
auth: EamilAuth,
pool: true
});
const getAllDataAndSendMail = async () => {
for (let i = 0, len = EmailToArr.length; i < len; i++) {
try {
let item = EmailToArr[i];
let apiData = await getAllData(item.CITY, item.LOCATION);
let htmlData = await getHtmlData(apiData);
await sendMail(transporter, item.TO, htmlData);
} catch (error) {
console.error(error);
}
}
};
getAllDataAndSendMail();

5. 定时

这里用到了 node-schedule 来定时执行任务,它跟 corn 很类似 之不是基于Node
具体用法可见 node-schedule文档
这里我使用了 每天早上的 08:15:58 定时发送 尽量不取整点

1
2
3
4
5
6
7
8
9
10
11
const schedule = require("node-schedule");
const config = require("./config");
const scheduleRun = fn => {
console.log("NodeMail: 开始等待目标时刻...");
let j = schedule.scheduleJob(config.SENDDATE, function() {
// SENDDATE: "58 15 8 * * *"
console.log("开始执行任务......");
fn();
});
};
module.exports = scheduleRun;

所以只要 引入scheduleRun方法

1
scheduleRun(getAllDataAndSendMail);

6. 配置详情

因为像邮件 smtp 授权码 是敏感信息 建议放进环境变量 env2 是个不错的工具 ,具体使用可以看env2文档

具体配置详见这里

问题

1. 邮箱登陆失败

一般是在服务器上运行时,邮箱提供商安全机制 会阻止异地登陆 ,只要去邮箱提供商允许就可以了

2. 发送失败

因为多人定制因为邮件内容不一样,所以不是同一封邮件,会额外开辟一个线程发送,可能会超过邮件提供商允许线程

留下足迹