订阅号开发(服务号需要企业认证,不适合个人开发)

1 注册微信公众号

1.注册网站

官网:https://mp.weixin.qq.com/

2.注册流程

1)打开官网,点击右上角的立即注册
2)选择订阅号注册
3)依次输入要求的信息,勾上我同意,点击注册
4)选择中国内地,点击确定
5)选择订阅号确定
6)主体类型选择个人,填写好信息点击继续
7)填写好信息点击完成

关键词回复:

  • 半匹配:只要包含关键字即可
  • 全匹配:跟名字一样

开发者自行开发

接口测试号

位置: 微信官方文档->开始开发->接口测试号申请

打开WebStorm创建一个简单的服务器:

在工程文件夹下导包:
npm install express

1
2
3
4
5
6
7
8
9
10
//引入express模块
const express = require('express')
//创建app应用对象
const app = express();
//验证服务器的有效性
app.use((req,res,next)=>{

})
//监听端口号
app.listen(3000,()=>console.log('服务器启动成功'));

此时得到的是一个本地网址:http://localhost:3000,要将其转换为外部可访问网址才行
工具:将内网映射成外网 ngrok

指令: ngrok http 3000
观察Forwarding后面即为外网网址

将网址加上/html输入在浏览器(例如: http://xxx.ngrok.io/html
(注意:有的浏览器可能无法访问,换个浏览器就好)

有Gitub账号可直接登录,若没有账号可以先注册再登录

重新打开ngrok输入ngrok http 3000 --authtoken 加上上图authtoken后面的内容

(例如: ngrok http 3000 --authtoken 23RthpB7QpLdHrBTivoitz4CzZ8_4a9AcRX3ZuKKBXWfoPSYe)

将最终得到的网址填入URL栏,Token随便填写即可(尽量复杂)

填好之后在服务器上进行测试:

导入sha1库:npm install sha1

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
71
72
73
//引入express模块
const express = require('express');
//引入sha1模块
const sha1 = require('sha1');
//创建app应用对象
const app = express();
//验证服务器的有效性
/*
1.微信服务器直到开发者服务器是哪个
- 测试号管理器上填写url开发者服务器地址
-使用ngrok 内网穿透 将本地端口号开启的服务映射为外网可以访问的网址
- ngrok http 3000
- Token
- 参与微信签名加密的一个参数
2.开发者服务器 -验证消息是否来自微信服务器
目的:计算得出signature微信加密签名,和微信传递过来的signature进项对比,如果一样说明消息来自于微信服务器,如果不一样说明表示不是微信服务器发送的消息
1.将参与微信加密签名的三个参数(timestamp,nonce,token)组合在一起,按照字典序排序(0-9,a-z)形成一个数组
2.将数组里所有参数拼接成一个字符串,进行sha1加密
3.加密完成就生成了一个signature,和微信传递过来的signature进项对比,
如果一样说明消息来自于微信服务器,返回echostr给微信服务器
如果不一样说明表示不是微信服务器发送的消息,返回error
*/
//定义配置对象
const config = {
token: '',
appID: '',
appsecret: ''
}
//接收处理所有参数
app.use((req, res, next) => {
//微信服务器提交的参数
//console.log(req.query);
/*
{
signature: '', //微信的加密签名
echostr: '', //微信的随机字符串
timestamp: '', //微信发送请求的时间戳
nonce: '' //微信的随机数字
}
*/

const {signature, echostr, timestamp, nonce} = req.query;
const {token} = config;

//1.将参与微信加密签名的三个参数(timestamp,nonce,token)组合在一起,按照字典序排序(0-9,a-z)形成一个数组

const arr = [timestamp, nonce, token];
const arrSort = arr.sort();
console.log(arrSort)

//2.将数组里所有参数拼接成一个字符串,进行sha1加密

const str = arr.join('');
console.log(str);
const sha1Str = sha1(str);
console.log(signature);
console.log(sha1Str);


//3.加密完成就生成了一个signature,和微信传递过来的signature进项对比,

if (sha1Str === signature) {
//如果一样说明消息来自于微信服务器,返回echostr给微信服务器
res.send(echostr);
} else {
//如果不一样说明表示不是微信服务器发送的消息,返回error
res.end('error');
}
})


//监听端口号
app.listen(3000, () => console.log('服务器启动成功'));

模块化代码:

新建config文件夹,在该文件夹下新建index.js文件

index.js

1
2
3
4
5
6
//配置对象模块
module.exports = {
token: '',
appID: '',
appsecret: ''
}

新建wechat文件夹,在该文件下新建auth.js文件

auth.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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//引入config模块
const config = require("../config");
//引入sha1模块
const sha1 = require("sha1");
/*
验证服务器有效性的模块
*/
module.exports = () => {
return (req, res, next) => {
//微信服务器提交的参数
//console.log(req.query);
/*
{
signature: '', //微信的加密签名
echostr: '', //微信的随机字符串
timestamp: '', //微信发送请求的时间戳
nonce: '' //微信的随机数字
}
*/

const {signature, echostr, timestamp, nonce} = req.query;
const {token} = config;

//1.将参与微信加密签名的三个参数(timestamp,nonce,token)组合在一起,按照字典序排序(0-9,a-z)形成一个数组

const arr = [timestamp, nonce, token];
const arrSort = arr.sort();
console.log(arrSort)

//2.将数组里所有参数拼接成一个字符串,进行sha1加密

const str = arr.join('');
console.log(str);
const sha1Str = sha1(str);
console.log(signature);
console.log(sha1Str);

//3.加密完成就生成了一个signature,和微信传递过来的signature进项对比,

if (sha1Str === signature) {
//如果一样说明消息来自于微信服务器,返回echostr给微信服务器
res.send(echostr);
} else {
//如果不一样说明表示不是微信服务器发送的消息,返回error
res.end('error');
}
}
}

主文件

1
2
3
4
5
6
7
8
9
10
//引入express模块
const express = require('express');
//引入auto模块
const auth = require('./wechat/auth')
//创建app应用对象
const app = express();
//接收处理所有参数
app.use(auth())
//监听端口号
app.listen(3000, () => console.log('服务器启动成功'));

获取access_token

位置: 微信官方文档->开始开发->获取access_token

access_token是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用access_token。开发者需要进行妥善保存。access_token的存储至少要保留512个字符空间。access_token的有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的access_token失效。

公众平台的API调用所需的access_token的使用及生成方式说明:

1、建议公众号开发者使用中控服务器统一获取和刷新access_token,其他业务逻辑服务器所使用的access_token均来自于该中控服务器,不应该各自去刷新,否则容易造成冲突,导致access_token覆盖而影响业务;

2、目前access_token的有效期通过返回的expire_in来传达,目前是7200秒之内的值。中控服务器需要根据这个有效时间提前去刷新新access_token。在刷新过程中,中控服务器可对外继续输出的老access_token,此时公众平台后台会保证在5分钟内,新老access_token都可用,这保证了第三方业务的平滑过渡;

3、access_token的有效时间可能会在未来有调整,所以中控服务器不仅需要内部定时主动刷新,还需要提供被动刷新access_token的接口,这样便于业务服务器在API调用获知access_token已超时的情况下,可以触发access_token的刷新流程。

4、对于可能存在风险的调用,在开发者进行获取 access_token调用时进入风险调用确认流程,需要用户管理员确认后才可以成功获取。具体流程为:

开发者通过某IP发起调用->平台返回错误码[89503]并同时下发模板消息给公众号管理员->公众号管理员确认该IP可以调用->开发者使用该IP再次发起调用->调用成功。

如公众号管理员第一次拒绝该IP调用,用户在1个小时内将无法使用该IP再次发起调用,如公众号管理员多次拒绝该IP调用,该IP将可能长期无法发起调用。平台建议开发者在发起调用前主动与管理员沟通确认调用需求,或请求管理员开启IP白名单功能并将该IP加入IP白名单列表。

公众号和小程序均可以使用AppID和AppSecret调用本接口来获取access_token。AppID和AppSecret可在“微信公众平台-开发-基本配置”页中获得(需要已经成为开发者,且帐号没有异常状态)。**调用接口时,请登录“微信公众平台-开发-基本配置”提前将服务器IP地址添加到IP白名单中,点击查看设置方法,否则将无法调用成功。**小程序无需配置IP白名单。

接口调用请求说明

1
https请求方式: GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET

参数说明

参数是否必须说明
grant_type获取access_token填写client_credential
appid第三方用户唯一凭证
secret第三方用户唯一凭证密钥,即appsecret

返回说明

正常情况下,微信会返回下述JSON数据包给公众号:

{“access_token”:“ACCESS_TOKEN”,“expires_in”:7200}

参数说明

参数说明
access_token获取到的凭证
expires_in凭证有效时间,单位:秒

错误时微信会返回错误码等信息,JSON数据包示例如下(该示例为AppID无效错误):

{“errcode”:40013,“errmsg”:“invalid appid”}

返回码说明

返回码说明
-1系统繁忙,此时请开发者稍候再试
0请求成功
40001AppSecret错误或者AppSecret不属于这个公众号,请开发者确认AppSecret的正确性
40002请确保grant_type字段值为client_credential
40164调用接口的IP地址不在白名单中,请在接口IP白名单中进行设置。
89503此IP调用需要管理员确认,请联系管理员
89501此IP正在等待管理员确认,请联系管理员
8950624小时内该IP被管理员拒绝调用两次,24小时内不可再使用该IP调用
895071小时内该IP被管理员拒绝调用一次,1小时内不可再使用该IP调用

在wechat文件夹下新建accessToken.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
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
/*
access_token:微信调用接口全局唯一凭证
特点:
1.唯一
2.有效期为2小时,提前5分钟重新请求
3.接口权限 每天2000次
https请求方式: GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET

设计思路:
1.首次本地没有,发送请求获取access_token,保存下来(本地文件)
2.第二次或以后:
- 先去本地读取文件,判断它是否过期
- 过期了
- 重新请求,保存下来,覆盖之前的文件(保证文件是唯一的)
- 没过期
- 直接使用

整理思路:
读取本地文件(readAccessToken):
- 本地有文件
- 判断它是否过期(isValidAccessToken)
- 过期了
- 重新请求(getAccessToken),保存下来,覆盖之前的文件(保证文件是唯一的)(saveAccessToken)
- 没过期
- 直接使用
- 本地没有文件
- 发送请求获取access_token(getAccessToken),保存下来(本地文件)(saveAccessToken)
*/
//只需要引入request-promise-native库
const rp = require('request-promise-native');

//引入fs模块
const {writeFile, readFile} = require('fs');

//引入config文件
const {appID, appsecret} = require('../config');

//定义一个类,获取access_token
class Wechat {
constructor() {
}

/*
用来获取access_token
*/
getAccessToken() {
//定义请求的地址
const url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=' + appID + '&secret=' + appsecret;
//发送请求
/*
需要下载俩个库
request
request-promise-native 返回值是一个promise对象
*/
return new Promise((resolve, reject) => {
rp({method: 'GET', url, json: true})
.then(res => {
console.log(res);
/*
{
access_token: '53_ASJAH4U8r3yuEZFT4NCdzgLcBZN-W0rPy-0sBU0bizFlnxrXJ8rl8VxCZqllW8A_MZTTYr3eNnQN8GX-TUv4vuB-YuHde5BLlNN38fK-pb0ZujB7yE-5XwkX5NBX7bEA-yY7v2V2Wu8W-G
SASVHcAFAKNZ',
expires_in: 7200
}
*/
//设置access_token的过期时间 单位毫秒
res.expires_in = Date.now() + (res.expires_in - 300) * 1000;
//将promise的对象的状态改为成功的状态
resolve(res);
})
.catch(err => {
console.log(err);
reject('getAccessToken方法出了问题:' + err);
})
})
}

/**
* 用来保存access_token的方法
* @param accessToken 要保存的凭据
*/
saveAccessToken(accessToken) {
//将对象转化为json字符串
accessToken = JSON.stringify(accessToken);
return new Promise((resolve, reject) => {
writeFile('./accessToken.txt', accessToken, err => {
if (!err) {
console.log('文件保存成功');
resolve();
} else {
reject('saveAccessToken方法出了问题:' + err);
}
})
})
}

/**
* 用来读取access_token的方法
*/
readAccessToken() {
//读取本地文件中的access_taken
return new Promise((resolve, reject) => {
readFile('./accessToken.txt', (err, data) => {
if (!err) {
console.log('文件读取成功');
//将json字符串转化成js对象
data = JSON.parse(data);
resolve(data);
} else {
reject('readAccessToken方法出了问题:' + err);
}
})
})
}

/**
* 用来检查access_token是否有效
* @param data
*/
isValidAccessToken(data) {
//检查传入的参数是否有效
if (!data && !data.access_token && !data.expires_in) {
//代表access_token无效
return false;
}

//检查传入的参数是否在有效期内
// if (data.expires_in<Date.now()){
// //过期了
// return false;
// }else {
// //没有过期
// return true;
// }

return data.expires_in > Date.now();
}

/**
* 用来获取没有过期的access_token
* @returns {Promise<unknown>} access_token
*/
fetchAccessToken() {
//优化
if (this.access_token && this.expires_in && this.isValidAccessToken(this)) {
//说明之前保存过access_token,并且access_token有效,直接使用
return Promise.resolve({
access_token: this.access_token,
expires_in: this.expires_in
})
}
return this.readAccessToken()
.then(async res => {
//本地有文件
//判断它是否过期(isValidAccessToken)
if (this.isValidAccessToken(res)) {
//有效的
return Promise.resolve(res);
//resolve(res);
} else {
//过期了
//发送请求获取access_token(getAccessToken)
const res = await this.getAccessToken();
//保存下来(本地文件)(saveAccessToken)
await this.saveAccessToken(res);
//将请求回来的access_token返回出去
return Promise.resolve(res);
// resolve(res);
}
})
.catch(async err => {
//本地没有文件
//发送请求获取access_token(getAccessToken)
const res = await this.getAccessToken();
//保存下来(本地文件)(saveAccessToken)
await this.saveAccessToken(res);
//将请求回来的access_token返回出去
return Promise.resolve(res);
// resolve(res);
})
.then(res => {
//将access_token挂载到this上
this.access_token = res.access_token;
this.expires_in = res.expires_in;
//返回res包装了一层promise对象(此对象为成功的对象)
//是this.readAccessToken()最终返回值
return Promise.resolve(res);
})
}
}

//模拟测试
const w = new Wechat();
/*
读取本地文件(readAccessToken):
- 本地有文件
- 判断它是否过期(isValidAccessToken)
- 过期了
- 重新请求(getAccessToken),保存下来,覆盖之前的文件(保证文件是唯一的)(saveAccessToken)
- 没过期
- 直接使用
- 本地没有文件
- 发送请求获取access_token(getAccessToken),保存下来(本地文件)(saveAccessToken)
*/
w.fetchAccessToken();

获取用户发送的消息

扫描测试号二维码关注测试公众号,发送一条消息进行测试

auto.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
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
71
//引入config模块
const config = require("../config");
//引入sha1模块
const sha1 = require("sha1");
/*
验证服务器有效性的模块
*/
module.exports = () => {
return (req, res, next) => {
//微信服务器提交的参数
//console.log(req.query);

const {signature, echostr, timestamp, nonce} = req.query;
const {token} = config;
/*
//1.将参与微信加密签名的三个参数(timestamp,nonce,token)组合在一起,按照字典序排序(0-9,a-z)形成一个数组

const arr = [timestamp, nonce, token];
const arrSort = arr.sort();
console.log(arrSort)

//2.将数组里所有参数拼接成一个字符串,进行sha1加密

const str = arr.join('');
console.log(str);
const sha1Str = sha1(str);
console.log(sha1Str);
*/

//简写
const sha1str = sha1([timestamp, nonce, token].sort().join(""));

/*
微信服务器会发送俩种类型的消息给开发者
1.GET请求
- 验证服务器的有效性
2.POST请求
- 微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
*/
if (req.method === 'GET') {
//3.加密完成就生成了一个signature,和微信传递过来的signature进项对比,

if (sha1Str === signature) {
//如果一样说明消息来自于微信服务器,返回echostr给微信服务器
res.send(echostr);
} else {
//如果不一样说明表示不是微信服务器发送的消息,返回error
res.end('error');
}
} else if (req.method === 'POST') {
//微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
//验证消息来自于微信服务器
if (sha1str !== signature) {
//说明消息不是微信服务器的
res.end('error');
}
console.log(req.query);
/*

{
signature: '',
timestamp: '',
nonce: '',
openid: '' //用户的微信ID
}
*/
} else {
res.end('error');
}
}
}

运行app.js得到如下信息:

1
2
3
4
5
6
{
signature: '',
timestamp: '',
nonce: '',
openid: '' //用户的微信ID
}

如果开发者服务器没有返回响应给微信服务器,微信服务器会发送三次请求过来
在if中最后加上res.end('');即可

在项目下新建utils工具包文件夹,在里面新建一个文件tool.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
工具函数包
*/

module.exports = {
getUserDataAsync(req) {
return new Promise((resolve, reject) => {
let xmlData = '';
req
.on('data', data => {
//当流式数据传递过来时会触发当前事件,会将数据注入到回调函数中
console.log(data);
//读取的数据是buffer,需要将其转化为字符串
xmlData += data.toString();
})
.on('end', () => {
//当数据接收完毕时,会触发当前
resolve(xmlData);
})
})
}
}

auth.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
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
//引入config模块
const config = require("../config");
//引入sha1模块
const sha1 = require("sha1");
//引入tool模块
const {getUserDataAsync} = require('../utils/tool');
/*
验证服务器有效性的模块
*/
module.exports = () => {
return async (req, res, next) => {
//微信服务器提交的参数
//console.log(req.query);

const {signature, echostr, timestamp, nonce} = req.query;
const {token} = config;
const sha1str = sha1([timestamp, nonce, token].sort().join(""));

/*
微信服务器会发送俩种类型的消息给开发者
1.GET请求
- 验证服务器的有效性
2.POST请求
- 微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
*/
if (req.method === 'GET') {
//3.加密完成就生成了一个signature,和微信传递过来的signature进项对比,

if (sha1Str === signature) {
//如果一样说明消息来自于微信服务器,返回echostr给微信服务器
res.send(echostr);
} else {
//如果不一样说明表示不是微信服务器发送的消息,返回error
res.end('error');
}
} else if (req.method === 'POST') {
//微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
//验证消息来自于微信服务器
if (sha1str !== signature) {
//说明消息不是微信服务器的
res.end('error');
}
// console.log(req.query);
//接收请求体中的数据,流式数据

const xmlData = await getUserDataAsync(req);

console.log(xmlData);

//如果开发者服务器没有返回响应给微信服务器,微信服务器会发送三次请求过来
res.end('');
} else {
res.end('error');
}
}
}

运行app.js得到如下信息:

1
2
3
4
5
6
7
<xml><ToUserName><![CDATA[gh_3b6b3cd4d8e8]]></ToUserName> //开发中id           
<FromUserName><![CDATA[okzHZ6Xb1dMfGx1fti6VV4en0aTU]]></FromUserName>//用户 openid
<CreateTime>1643090286</CreateTime>//发送的时间戳
<MsgType><![CDATA[text]]></MsgType>//发送消息类型
<Content><![CDATA[123]]></Content>//内容
<MsgId>23523394398679914</MsgId>//消息id 微信服务器会默认保存3天用户发送的数据,通过次id三天内就可以找到此消息数据
</xml>

接下来我们要得到xml中的数据

tool.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
31
32
33
34
35
36
37
/*
工具函数包
*/
//npm install xml2js 导包
//引入xml2js,将xml数据转化为js对象
const {parseString} = require('xml2js');

module.exports = {
getUserDataAsync(req) {
return new Promise((resolve, reject) => {
let xmlData = '';
req
.on('data', data => {
//当流式数据传递过来时会触发当前事件,会将数据注入到回调函数中
console.log(data);
//读取的数据是buffer,需要将其转化为字符串
xmlData += data.toString();
})
.on('end', () => {
//当数据接收完毕时,会触发当前
resolve(xmlData);
})
})
},

parseXMLAsync(xmlData){
return new Promise((resolve, reject) => {
parseString(xmlData,{trim:true},(err,data)=>{
if (!err){
resolve(data);
}else {
reject('parseXMLAsync方法出了问题:'+err);
}
})
})
}
}

auth.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
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
//引入config模块
const config = require("../config");
//引入sha1模块
const sha1 = require("sha1");
//引入tool模块
const {getUserDataAsync,parseXMLAsync} = require('../utils/tool');
/*
验证服务器有效性的模块
*/
module.exports = () => {
return async (req, res, next) => {
//微信服务器提交的参数
//console.log(req.query);

const {signature, echostr, timestamp, nonce} = req.query;
const {token} = config;
const sha1str = sha1([timestamp, nonce, token].sort().join(""));

/*
微信服务器会发送俩种类型的消息给开发者
1.GET请求
- 验证服务器的有效性
2.POST请求
- 微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
*/
if (req.method === 'GET') {
//3.加密完成就生成了一个signature,和微信传递过来的signature进项对比,

if (sha1Str === signature) {
//如果一样说明消息来自于微信服务器,返回echostr给微信服务器
res.send(echostr);
} else {
//如果不一样说明表示不是微信服务器发送的消息,返回error
res.end('error');
}
} else if (req.method === 'POST') {
//微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
//验证消息来自于微信服务器
if (sha1str !== signature) {
//说明消息不是微信服务器的
res.end('error');
}
// console.log(req.query);
//接收请求体中的数据,流式数据

const xmlData = await getUserDataAsync(req);

console.log(xmlData);
//将xml数据解析为js对象
const jsData = await parseXMLAsync(xmlData);
console.log(jsData);

//如果开发者服务器没有返回响应给微信服务器,微信服务器会发送三次请求过来
res.end('');
} else {
res.end('error');
}
}
}

运行app.js得到如下信息:

1
2
3
4
5
6
7
8
9
10
{
xml: {
ToUserName: [ 'gh_3b6b3cd4d8e8' ],
FromUserName: [ 'okzHZ6Xb1dMfGx1fti6VV4en0aTU' ],
CreateTime: [ '1643091294' ],
MsgType: [ 'text' ],
Content: [ '111' ],
MsgId: [ '23523410572447438' ]
}
}

格式化得到的数据:

tool.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
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
/*
工具函数包
*/
//npm install xml2js 导包
//引入xml2js,将xml数据转化为js对象
const {parseString} = require('xml2js');

module.exports = {
getUserDataAsync(req) {
return new Promise((resolve, reject) => {
let xmlData = '';
req
.on('data', data => {
//当流式数据传递过来时会触发当前事件,会将数据注入到回调函数中
console.log(data);
//读取的数据是buffer,需要将其转化为字符串
xmlData += data.toString();
})
.on('end', () => {
//当数据接收完毕时,会触发当前
resolve(xmlData);
})
})
},

parseXMLAsync(xmlData) {
return new Promise((resolve, reject) => {
parseString(xmlData, {trim: true}, (err, data) => {
if (!err) {
resolve(data);
} else {
reject('parseXMLAsync方法出了问题:' + err);
}
})
})
},

formatMessage(jsData) {
let message = {};
//获取xml对象
jsData = jsData.xml;
//判断数据是否为一个对象
if (typeof jsData === 'object') {
//遍历对象
for (let key in jsData) {
//获取属性值
let value = jsData[key];
//过滤空数据
if (Array.isArray(value) && value.length > 0) {
//将合法数据赋值到message对象上
message[key] = value[0];
}
}
}

return message;
}
}

auth.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
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
//引入config模块
const config = require("../config");
//引入sha1模块
const sha1 = require("sha1");
//引入tool模块
const {getUserDataAsync,parseXMLAsync,formatMessage} = require('../utils/tool');
/*
验证服务器有效性的模块
*/
module.exports = () => {
return async (req, res, next) => {
//微信服务器提交的参数
//console.log(req.query);

const {signature, echostr, timestamp, nonce} = req.query;
const {token} = config;
const sha1str = sha1([timestamp, nonce, token].sort().join(""));

/*
微信服务器会发送俩种类型的消息给开发者
1.GET请求
- 验证服务器的有效性
2.POST请求
- 微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
*/
if (req.method === 'GET') {
//3.加密完成就生成了一个signature,和微信传递过来的signature进项对比,

if (sha1Str === signature) {
//如果一样说明消息来自于微信服务器,返回echostr给微信服务器
res.send(echostr);
} else {
//如果不一样说明表示不是微信服务器发送的消息,返回error
res.end('error');
}
} else if (req.method === 'POST') {
//微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
//验证消息来自于微信服务器
if (sha1str !== signature) {
//说明消息不是微信服务器的
res.end('error');
}
// console.log(req.query);
//接收请求体中的数据,流式数据

const xmlData = await getUserDataAsync(req);

console.log(xmlData);
//将xml数据解析为js对象
const jsData = await parseXMLAsync(xmlData);
console.log(jsData);

//格式化数据
const message = formatMessage(jsData);
console.log(message);
//如果开发者服务器没有返回响应给微信服务器,微信服务器会发送三次请求过来
res.end('');
} else {
res.end('error');
}
}
}

运行app.js得到如下信息:

1
{ CreateTime: '1643092243', MsgId: '23523424720943421' }

简单的自动回复

微信官方文档->基础消息能力->被动回复用户消息

当用户发送消息给公众号时(或某些特定的用户操作引发的事件推送时),会产生一个POST请求,开发者可以在响应包(Get)中返回特定XML结构,来对该消息进行响应(现支持回复文本、图片、图文、语音、视频、音乐)。严格来说,发送被动响应消息其实并不是一种接口,而是对微信服务器发过来消息的一次回复。

微信服务器在将用户的消息发给公众号的开发者服务器地址(开发者中心处配置)后,微信服务器在五秒内收不到响应会断掉连接,并且重新发起请求,总共重试三次,如果在调试中,发现用户无法收到响应的消息,可以检查是否消息处理超时。关于重试的消息排重,有msgid的消息推荐使用msgid排重。事件类型消息推荐使用FromUserName + CreateTime 排重。

如果开发者希望增强安全性,可以在开发者中心处开启消息加密,这样,用户发给公众号的消息以及公众号被动回复用户消息都会继续加密,详见被动回复消息加解密说明。

假如服务器无法保证在五秒内处理并回复,必须做出下述回复,这样微信服务器才不会对此作任何处理,并且不会发起重试(这种情况下,可以使用客服消息接口进行异步回复),否则,将出现严重的错误提示。详见下面说明:

1、直接回复success(推荐方式) 2、直接回复空串(指字节长度为0的空字符串,而不是XML结构体中content字段的内容为空)

一旦遇到以下情况,微信都会在公众号会话中,向用户下发系统提示“该公众号暂时无法提供服务,请稍后再试”:

1、开发者在5秒内未回复任何内容 2、开发者回复了异常数据,比如JSON数据等

另外,请注意,回复图片(不支持gif动图)等多媒体消息时需要预先通过素材管理接口上传临时素材到微信服务器,可以使用素材管理中的临时素材,也可以使用永久素材。

各消息类型需要的XML数据包结构如下:

回复文本消息
1
2
3
4
5
6
7
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[你好]]></Content>
</xml>
参数是否必须描述
ToUserName接收方帐号(收到的OpenID)
FromUserName开发者微信号
CreateTime消息创建时间 (整型)
MsgType消息类型,文本为text
Content回复的消息内容(换行:在content中能够换行,微信客户端就支持换行显示)
回复图片消息
1
2
3
4
5
6
7
8
9
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[image]]></MsgType>
<Image>
<MediaId><![CDATA[media_id]]></MediaId>
</Image>
</xml>
参数是否必须说明
ToUserName接收方帐号(收到的OpenID)
FromUserName开发者微信号
CreateTime消息创建时间 (整型)
MsgType消息类型,图片为image
MediaId通过素材管理中的接口上传多媒体文件,得到的id。
回复语音消息
1
2
3
4
5
6
7
8
9
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[voice]]></MsgType>
<Voice>
<MediaId><![CDATA[media_id]]></MediaId>
</Voice>
</xml>
参数是否必须说明
ToUserName接收方帐号(收到的OpenID)
FromUserName开发者微信号
CreateTime消息创建时间戳 (整型)
MsgType消息类型,语音为voice
MediaId通过素材管理中的接口上传多媒体文件,得到的id
回复视频消息
1
2
3
4
5
6
7
8
9
10
11
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[video]]></MsgType>
<Video>
<MediaId><![CDATA[media_id]]></MediaId>
<Title><![CDATA[title]]></Title>
<Description><![CDATA[description]]></Description>
</Video>
</xml>
参数是否必须说明
ToUserName接收方帐号(收到的OpenID)
FromUserName开发者微信号
CreateTime消息创建时间 (整型)
MsgType消息类型,视频为video
MediaId通过素材管理中的接口上传多媒体文件,得到的id
Title视频消息的标题
Description视频消息的描述
回复音乐消息
1
2
3
4
5
6
7
8
9
10
11
12
13
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[music]]></MsgType>
<Music>
<Title><![CDATA[TITLE]]></Title>
<Description><![CDATA[DESCRIPTION]]></Description>
<MusicUrl><![CDATA[MUSIC_Url]]></MusicUrl>
<HQMusicUrl><![CDATA[HQ_MUSIC_Url]]></HQMusicUrl>
<ThumbMediaId><![CDATA[media_id]]></ThumbMediaId>
</Music>
</xml>
参数是否必须说明
ToUserName接收方帐号(收到的OpenID)
FromUserName开发者微信号
CreateTime消息创建时间 (整型)
MsgType消息类型,音乐为music
Title音乐标题
Description音乐描述
MusicURL音乐链接
HQMusicUrl高质量音乐链接,WIFI环境优先使用该链接播放音乐
ThumbMediaId缩略图的媒体id,通过素材管理中的接口上传多媒体文件,得到的id
回复图文消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[news]]></MsgType>
<ArticleCount>1</ArticleCount>
<Articles>
<item>
<Title><![CDATA[title1]]></Title>
<Description><![CDATA[description1]]></Description>
<PicUrl><![CDATA[picurl]]></PicUrl>
<Url><![CDATA[url]]></Url>
</item>
</Articles>
</xml>
参数是否必须说明
ToUserName接收方帐号(收到的OpenID)
FromUserName开发者微信号
CreateTime消息创建时间 (整型)
MsgType消息类型,图文为news
ArticleCount图文消息个数;当用户发送文本、图片、语音、视频、图文、地理位置这六种消息时,开发者只能回复1条图文消息;其余场景最多可回复8条图文消息
Articles图文消息信息,注意,如果图文数超过限制,则将只发限制内的条数
Title图文消息标题
Description图文消息描述
PicUrl图片链接,支持JPG、PNG格式,较好的效果为大图360 * 200,小图200 * 200
Url点击图文消息跳转链接

auth.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
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
71
72
73
74
75
76
77
78
79
80
81
//引入config模块
const config = require("../config");
//引入sha1模块
const sha1 = require("sha1");
//引入tool模块
const {getUserDataAsync, parseXMLAsync, formatMessage} = require('../utils/tool');
/*
验证服务器有效性的模块
*/
module.exports = () => {
return async (req, res, next) => {
//微信服务器提交的参数
//console.log(req.query);

const {signature, echostr, timestamp, nonce} = req.query;
const {token} = config;
const sha1str = sha1([timestamp, nonce, token].sort().join(""));

/*
微信服务器会发送俩种类型的消息给开发者
1.GET请求
- 验证服务器的有效性
2.POST请求
- 微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
*/
if (req.method === 'GET') {
//3.加密完成就生成了一个signature,和微信传递过来的signature进项对比,

if (sha1Str === signature) {
//如果一样说明消息来自于微信服务器,返回echostr给微信服务器
res.send(echostr);
} else {
//如果不一样说明表示不是微信服务器发送的消息,返回error
res.end('error');
}
} else if (req.method === 'POST') {
//微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
//验证消息来自于微信服务器
if (sha1str !== signature) {
//说明消息不是微信服务器的
res.end('error');
}
// console.log(req.query);
//接收请求体中的数据,流式数据

const xmlData = await getUserDataAsync(req);

// console.log(xmlData);
//将xml数据解析为js对象
const jsData = await parseXMLAsync(xmlData);
// console.log(jsData);
//格式化数据
const message = formatMessage(jsData);
// console.log(message);
// console.log(message.Content)
let content = '你在说什么?';
//判断用户发送的消息是不是文本消息
if (message.MsgType==='text'){
//判断用户发送的内容具体是什么
if (message.Content==='1'){//全匹配
content='你好!!';
}else if (message.Content==='2'){
content='hello~~';
}else if (message.Content.match('爱')){//半匹配
content='我爱你';
}
}

//最终回复用户的消息
let replyMessage = '<xml><ToUserName><![CDATA['+message.FromUserName+']]></ToUserName><FromUserName><![CDATA['+message.ToUserName+']]></FromUserName><CreateTime>'+Date.now()+'</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA['+content+']]></Content></xml>';

//返回响应给微信服务器
res.send(replyMessage);

//如果开发者服务器没有返回响应给微信服务器,微信服务器会发送三次请求过来
// res.end('');
} else {
res.end('error');
}
}
}

运行app.js即可测试公众号

定义回复用户消息的模块文件

接收普通消息

当普通微信用户向公众账号发消息时,微信服务器将POST消息的XML数据包到开发者填写的URL上。

请注意:

关于重试的消息排重,推荐使用msgid排重。
微信服务器在五秒内收不到响应会断掉连接,并且重新发起请求,总共重试三次。假如服务器无法保证在五秒内处理并回复,可以直接回复空串,微信服务器不会对此作任何处理,并且不会发起重试。详情请见“发送消息-被动回复消息”。
如果开发者需要对用户消息在5秒内立即做出回应,即使用“发送消息-被动回复消息”接口向用户被动回复消息时,可以在

公众平台官网的开发者中心处设置消息加密。开启加密后,用户发来的消息和开发者回复的消息都会被加密(但开发者通过客服接口等API调用形式向用户发送消息,则不受影响)。关于消息加解密的详细说明,请见“发送消息-被动回复消息加解密说明”。 各消息类型的推送XML数据包结构如下:

文本消息
1
2
3
4
5
6
7
8
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1348831860</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[this is a test]]></Content>
<MsgId>1234567890123456</MsgId>
</xml>
参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType消息类型,文本为text
Content文本消息内容
MsgId消息id,64位整型
图片消息
1
2
3
4
5
6
7
8
9
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1348831860</CreateTime>
<MsgType><![CDATA[image]]></MsgType>
<PicUrl><![CDATA[this is a url]]></PicUrl>
<MediaId><![CDATA[media_id]]></MediaId>
<MsgId>1234567890123456</MsgId>
</xml>
参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType消息类型,图片为image
PicUrl图片链接(由系统生成)
MediaId图片消息媒体id,可以调用获取临时素材接口拉取数据。
MsgId消息id,64位整型
语音消息
1
2
3
4
5
6
7
8
9
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1357290913</CreateTime>
<MsgType><![CDATA[voice]]></MsgType>
<MediaId><![CDATA[media_id]]></MediaId>
<Format><![CDATA[Format]]></Format>
<MsgId>1234567890123456</MsgId>
</xml>
参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType语音为voice
MediaId语音消息媒体id,可以调用获取临时素材接口拉取数据。
Format语音格式,如amr,speex等
MsgId消息id,64位整型

请注意,开通语音识别后,用户每次发送语音给公众号时,微信会在推送的语音消息XML数据包中,增加一个Recognition字段(注:由于客户端缓存,开发者开启或者关闭语音识别功能,对新关注者立刻生效,对已关注用户需要24小时生效。开发者可以重新关注此帐号进行测试)。开启语音识别后的语音XML数据包如下:

1
2
3
4
5
6
7
8
9
10
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1357290913</CreateTime>
<MsgType><![CDATA[voice]]></MsgType>
<MediaId><![CDATA[media_id]]></MediaId>
<Format><![CDATA[Format]]></Format>
<Recognition><![CDATA[腾讯微信团队]]></Recognition>
<MsgId>1234567890123456</MsgId>
</xml>

参数说明:

参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType语音为voice
MediaId语音消息媒体id,可以调用获取临时素材接口拉取该媒体
Format语音格式:amr
Recognition语音识别结果,UTF8编码
MsgId消息id,64位整型
视频消息
1
2
3
4
5
6
7
8
9
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1357290913</CreateTime>
<MsgType><![CDATA[video]]></MsgType>
<MediaId><![CDATA[media_id]]></MediaId>
<ThumbMediaId><![CDATA[thumb_media_id]]></ThumbMediaId>
<MsgId>1234567890123456</MsgId>
</xml>
参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType视频为video
MediaId视频消息媒体id,可以调用获取临时素材接口拉取数据。
ThumbMediaId视频消息缩略图的媒体id,可以调用多媒体文件下载接口拉取数据。
MsgId消息id,64位整型
小视频消息
1
2
3
4
5
6
7
8
9
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1357290913</CreateTime>
<MsgType><![CDATA[shortvideo]]></MsgType>
<MediaId><![CDATA[media_id]]></MediaId>
<ThumbMediaId><![CDATA[thumb_media_id]]></ThumbMediaId>
<MsgId>1234567890123456</MsgId>
</xml>
参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType小视频为shortvideo
MediaId视频消息媒体id,可以调用获取临时素材接口拉取数据。
ThumbMediaId视频消息缩略图的媒体id,可以调用获取临时素材接口拉取数据。
MsgId消息id,64位整型
地理位置消息
1
2
3
4
5
6
7
8
9
10
11
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1351776360</CreateTime>
<MsgType><![CDATA[location]]></MsgType>
<Location_X>23.134521</Location_X>
<Location_Y>113.358803</Location_Y>
<Scale>20</Scale>
<Label><![CDATA[位置信息]]></Label>
<MsgId>1234567890123456</MsgId>
</xml>
参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType消息类型,地理位置为location
Location_X地理位置纬度
Location_Y地理位置经度
Scale地图缩放大小
Label地理位置信息
MsgId消息id,64位整型
链接消息
1
2
3
4
5
6
7
8
9
10
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1351776360</CreateTime>
<MsgType><![CDATA[link]]></MsgType>
<Title><![CDATA[公众平台官网链接]]></Title>
<Description><![CDATA[公众平台官网链接]]></Description>
<Url><![CDATA[url]]></Url>
<MsgId>1234567890123456</MsgId>
</xml>
参数描述
ToUserName接收方微信号
FromUserName发送方微信号,若为普通用户,则是一个OpenID
CreateTime消息创建时间
MsgType消息类型,链接为link
Title消息标题
Description消息描述
Url消息链接
MsgId消息id,64位整型

接收事件推送

在微信用户和公众号产生交互的过程中,用户的某些操作会使得微信服务器通过事件推送的形式通知到开发者在开发者中心处设置的服务器地址,从而开发者可以获取到该信息。其中,某些事件推送在发生后,是允许开发者回复用户的,某些则不允许,详细内容如下:

关注/取消关注事件

用户在关注与取消关注公众号时,微信会把这个事件推送到开发者填写的URL。方便开发者给用户下发欢迎消息或者做帐号的解绑。为保护用户数据隐私,开发者收到用户取消关注事件时需要删除该用户的所有信息。

微信服务器在五秒内收不到响应会断掉连接,并且重新发起请求,总共重试三次。

关于重试的消息排重,推荐使用FromUserName + CreateTime 排重。

假如服务器无法保证在五秒内处理并回复,可以直接回复空串,微信服务器不会对此作任何处理,并且不会发起重试。

推送XML数据包示例:

1
2
3
4
5
6
7
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[FromUser]]></FromUserName>
<CreateTime>123456789</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[subscribe]]></Event>
</xml>

参数说明:

参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType消息类型,event
Event事件类型,subscribe(订阅)、unsubscribe(取消订阅)
扫描带参数二维码事件

用户扫描带场景值二维码时,可能推送以下两种事件:

如果用户还未关注公众号,则用户可以关注公众号,关注后微信会将带场景值关注事件推送给开发者。
如果用户已经关注公众号,则微信会将带场景值扫描事件推送给开发者。

  1. 用户未关注时,进行关注后的事件推送

推送XML数据包示例:

1
2
3
4
5
6
7
8
9
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[FromUser]]></FromUserName>
<CreateTime>123456789</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[subscribe]]></Event>
<EventKey><![CDATA[qrscene_123123]]></EventKey>
<Ticket><![CDATA[TICKET]]></Ticket>
</xml>

参数说明:

参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType消息类型,event
Event事件类型,subscribe
EventKey事件KEY值,qrscene_为前缀,后面为二维码的参数值
Ticket二维码的ticket,可用来换取二维码图片
  1. 用户已关注时的事件推送

推送XML数据包示例:

1
2
3
4
5
6
7
8
9
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[FromUser]]></FromUserName>
<CreateTime>123456789</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[SCAN]]></Event>
<EventKey><![CDATA[SCENE_VALUE]]></EventKey>
<Ticket><![CDATA[TICKET]]></Ticket>
</xml>

参数说明:

参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType消息类型,event
Event事件类型,SCAN
EventKey事件KEY值,是一个32位无符号整数,即创建二维码时的二维码scene_id
Ticket二维码的ticket,可用来换取二维码图片
上报地理位置事件

用户同意上报地理位置后,每次进入公众号会话时,都会在进入时上报地理位置,或在进入会话后每5秒上报一次地理位置,公众号可以在公众平台网站中修改以上设置。上报地理位置时,微信会将上报地理位置事件推送到开发者填写的URL。

推送XML数据包示例:

1
2
3
4
5
6
7
8
9
10
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>123456789</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[LOCATION]]></Event>
<Latitude>23.137466</Latitude>
<Longitude>113.352425</Longitude>
<Precision>119.385040</Precision>
</xml>

参数说明:

参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType消息类型,event
Event事件类型,LOCATION
Latitude地理位置纬度
Longitude地理位置经度
Precision地理位置精度
自定义菜单事件

用户点击自定义菜单后,微信会把点击事件推送给开发者,请注意,点击菜单弹出子菜单,不会产生上报。

点击菜单拉取消息时的事件推送

推送XML数据包示例:

1
2
3
4
5
6
7
8
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[FromUser]]></FromUserName>
<CreateTime>123456789</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[CLICK]]></Event>
<EventKey><![CDATA[EVENTKEY]]></EventKey>
</xml>

参数说明:

参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType消息类型,event
Event事件类型,CLICK
EventKey事件KEY值,与自定义菜单接口中KEY值对应

点击菜单跳转链接时的事件推送

推送XML数据包示例:

1
2
3
4
5
6
7
8
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[FromUser]]></FromUserName>
<CreateTime>123456789</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[VIEW]]></Event>
<EventKey><![CDATA[www.qq.com]]></EventKey>
</xml>

参数说明:

参数描述
ToUserName开发者微信号
FromUserName发送方帐号(一个OpenID)
CreateTime消息创建时间 (整型)
MsgType消息类型,event
Event事件类型,VIEW
EventKey事件KEY值,设置的跳转URL

在wechat文件夹下新建template.js,reply.js文件

template.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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/*
加工处理最终回复用户消息的模板(xml数据)
mediaID:上传素材得到
*/
module.exports = options => {
let replayMessage = '<xml>' +
'<ToUserName><![CDATA[' + options.toUserName + ']]></ToUserName>' +
'<FromUserName><![CDATA[' + message.fromUserName + ']]></FromUserName>' +
'<CreateTime>' + options.createTime + '</CreateTime>' +
'<MsgType><![CDATA[' + options.msgType + ']]></MsgType>';
if (options.msgType === 'text') {
replyMessage += '<Content><![CDATA[' + options.content + ']]></Content>';
} else if (options.msgType === 'image') {
replyMessage += '<Image><MediaId><![CDATA[' + options.mediaID + ']]></MediaId></Image>';
} else if (options.msgType === 'voice') {
replyMessage += '<Voice><MediaId><![CDATA[' + options.mediaID + ']]></MediaId></Voice>';
} else if (options.msgType === 'video') {
replyMessage += '<Video>' +
'<MediaId><![CDATA[' + options.mediaID + ']]></MediaId>' +
'<Title><![CDATA[' + options.title + ']]></Title>' +
'<Description><![CDATA[' + options.description + ']]></Description>' +
'</Video>';
} else if (options.msgType === 'music') {
replyMessage += '<Music>' +
'<Title><![CDATA[' + options.title + ']]></Title>' +
'<Description><![CDATA[' + options.description + ']]></Description>' +
'<MusicUrl><![CDATA[' + options.musicUrl + ']]></MusicUrl>' +
'<HQMusicUrl><![CDATA[' + options.hpMusicUrl + ']]></HQMusicUrl>' +
'<ThumbMediaId><![CDATA[' + options.mediaID + ']]></ThumbMediaId>' +
'</Music>';
} else if (options.msgType === 'news') {
replyMessage += '<ArticleCount>' + options.content.length + '</ArticleCount>' +
'<Articles>';

options.content.forEach(item => {
replyMessage += '<item>' +
'<Title><![CDATA[' + item.title + ']]></Title>' +
'<Description><![CDATA[' + item.description + ']]></Description>' +
'<PicUrl><![CDATA[' + item.picUrl + ']]></PicUrl>' +
'<Url><![CDATA[' + item.url + ']]></Url>' +
'</item>';
})
replayMessage += '</Articles>';
}
replayMessage += '</xml>';
return replayMessage;
}

reply.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
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
/*
处理用户发送的消息的类型和内容,决定返回不同的内容给用户
*/

module.exports = message => {
let options = {
toUserName: message.FromUserName,
fromUserName: message.ToUserName,
createTime: Date.now(),
magType: 'text'
}

let content = '你在说什么?';
//判断用户发送的消息是不是文本消息
if (message.MsgType === 'text') {
//判断用户发送的内容具体是什么
if (message.Content === '1') {//全匹配
content = '你好!!';
} else if (message.Content === '2') {
content = 'hello~~';
} else if (message.Content.match('爱')) {//半匹配
content = '我爱你';
}
} else if (message.MsgType === 'image') {
options.msgType = 'image';
options.mediaID = message.MediaId;
console.log(message.PicUrl);
} else if (message.MsgType === 'voice') {
options.msgType = 'voice';
options.mediaID = message.MediaId;
console.log(message.Recognition);
} else if (message.MsgType === 'location') {
content = '维度:' + message.Location_X + ' 经度:' + message.Location_Y + ' 缩放大小:' + message.Scale
+ ' 位置信息:' + message.Label;
console.log(message.PicUrl);
} else if (message.MsgType === 'event') {
if (message.Event === 'subscribe') {
//用户订阅事件
content = "感谢您的订阅";
if (message.EventKey) {
content = '用户扫描了带参数的二维码关注事件';
}
} else if (message.Event === 'unsubscribe') {
console.log('无情取关');
} else if (message.Event === 'SCAN') {
content = '用户已经关注,再次扫描了带参数的二维码关注事件';
} else if (message.Event === 'LOCATION') {
content = '维度:' + message.Latitude + ' 经度:' + message.Longitude + ' 精度:' + message.Precision;
} else if (message.Event === 'CLICK') {
content = '你点击了按钮:' + message.EventKey;
}
}

options.content = content;

return options;
}

auth.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
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
71
//引入config模块
const config = require("../config");
//引入template模块
const template = require("./template");
//引入reply模块
const reply = require("./reply");
//引入sha1模块
const sha1 = require("sha1");
//引入tool模块
const {getUserDataAsync, parseXMLAsync, formatMessage} = require('../utils/tool');
/*
验证服务器有效性的模块
*/
module.exports = () => {
return async (req, res, next) => {
//微信服务器提交的参数
//console.log(req.query);

const {signature, echostr, timestamp, nonce} = req.query;
const {token} = config;
const sha1str = sha1([timestamp, nonce, token].sort().join(""));

/*
微信服务器会发送俩种类型的消息给开发者
1.GET请求
- 验证服务器的有效性
2.POST请求
- 微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
*/
if (req.method === 'GET') {
//3.加密完成就生成了一个signature,和微信传递过来的signature进项对比,

if (sha1Str === signature) {
//如果一样说明消息来自于微信服务器,返回echostr给微信服务器
res.send(echostr);
} else {
//如果不一样说明表示不是微信服务器发送的消息,返回error
res.end('error');
}
} else if (req.method === 'POST') {
//微信服务器会将用户发送的数据以POST请求的方式转发到开发者服务器
//验证消息来自于微信服务器
if (sha1str !== signature) {
//说明消息不是微信服务器的
res.end('error');
}
// console.log(req.query);
//接收请求体中的数据,流式数据

const xmlData = await getUserDataAsync(req);

// console.log(xmlData);
//将xml数据解析为js对象
const jsData = await parseXMLAsync(xmlData);
// console.log(jsData);
//格式化数据
const message = formatMessage(jsData);

const options=reply(message);
//最终回复用户的消息
const replyMessage = template(options);
//返回响应给微信服务器
res.send(replyMessage);

//如果开发者服务器没有返回响应给微信服务器,微信服务器会发送三次请求过来
// res.end('');
} else {
res.end('error');
}
}
}

注意:接收语音识别结果、获取用户地理位置需在测试号管理界面开启

实现自定义菜单

创建接口

请注意:

自定义菜单最多包括3个一级菜单,每个一级菜单最多包含5个二级菜单。
一级菜单最多4个汉字,二级菜单最多8个汉字,多出来的部分将会以“…”代替。
创建自定义菜单后,菜单的刷新策略是,在用户进入公众号会话页或公众号profile页时,如果发现上一次拉取菜单的请求在5分钟以前,就会拉取一下菜单,如果菜单有更新,就会刷新客户端的菜单。测试时可以尝试取消关注公众账号后再次关注,则可以看到创建后的效果。

自定义菜单接口可实现多种类型按钮,如下:

click:点击推事件用户点击click类型按钮后,微信服务器会通过消息接口推送消息类型为event的结构给开发者(参考消息接口指南),并且带上按钮中开发者填写的key值,开发者可以通过自定义的key值与用户进行交互;
view:跳转URL用户点击view类型按钮后,微信客户端将会打开开发者在按钮中填写的网页URL,可与网页授权获取用户基本信息接口结合,获得用户基本信息。
scancode_push:扫码推事件用户点击按钮后,微信客户端将调起扫一扫工具,完成扫码操作后显示扫描结果(如果是URL,将进入URL),且会将扫码的结果传给开发者,开发者可以下发消息。
scancode_waitmsg:扫码推事件且弹出“消息接收中”提示框用户点击按钮后,微信客户端将调起扫一扫工具,完成扫码操作后,将扫码的结果传给开发者,同时收起扫一扫工具,然后弹出“消息接收中”提示框,随后可能会收到开发者下发的消息。
pic_sysphoto:弹出系统拍照发图用户点击按钮后,微信客户端将调起系统相机,完成拍照操作后,会将拍摄的相片发送给开发者,并推送事件给开发者,同时收起系统相机,随后可能会收到开发者下发的消息。
pic_photo_or_album:弹出拍照或者相册发图用户点击按钮后,微信客户端将弹出选择器供用户选择“拍照”或者“从手机相册选择”。用户选择后即走其他两种流程。
pic_weixin:弹出微信相册发图器用户点击按钮后,微信客户端将调起微信相册,完成选择操作后,将选择的相片发送给开发者的服务器,并推送事件给开发者,同时收起相册,随后可能会收到开发者下发的消息。
location_select:弹出地理位置选择器用户点击按钮后,微信客户端将调起地理位置选择工具,完成选择操作后,将选择的地理位置发送给开发者的服务器,同时收起位置选择工具,随后可能会收到开发者下发的消息。
media_id:下发消息(除文本消息)用户点击media_id类型按钮后,微信服务器会将开发者填写的永久素材id对应的素材下发给用户,永久素材类型可以是图片、音频、视频 、图文消息。请注意:永久素材id必须是在“素材管理/新增永久素材”接口上传后获得的合法id。
view_limited:跳转图文消息URL用户点击view_limited类型按钮后,微信客户端将打开开发者在按钮中填写的永久素材id对应的图文消息URL,永久素材类型只支持图文消息。请注意:永久素材id必须是在“素材管理/新增永久素材”接口上传后获得的合法id。
article_id:用户点击 article_id 类型按钮后,微信客户端将会以卡片形式,下发开发者在按钮中填写的图文消息
article_view_limited:类似 view_limited,但不使用 media_id 而使用 article_id

注意: 草稿接口灰度完成后,将不再支持图文信息类型的 media_id 和 view_limited,有需要的,请使用 article_id 和 article_view_limited 代替

请注意,3到8的所有事件,仅支持微信iPhone5.4.1以上版本,和Android5.4以上版本的微信用户,旧版本微信用户点击后将没有回应,开发者也不能正常接收到事件推送。9~12,是专门给第三方平台旗下未微信认证(具体而言,是资质认证未通过)的订阅号准备的事件类型,它们是没有事件推送的,能力相对受限,其他类型的公众号不必使用。

接口调用请求说明

http请求方式:POST(请使用https协议) https://api.weixin.qq.com/cgi-bin/menu/create?access_token=ACCESS_TOKEN

click和view的请求示例

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
{
"button":[
{
"type":"click",
"name":"今日歌曲",
"key":"V1001_TODAY_MUSIC"
},
{
"name":"菜单",
"sub_button":[
{
"type":"view",
"name":"搜索",
"url":"http://www.soso.com/"
},
{
"type":"miniprogram",
"name":"wxa",
"url":"http://mp.weixin.qq.com",
"appid":"wx286b93c14bbf93aa",
"pagepath":"pages/lunar/index"
},
{
"type":"click",
"name":"赞一下我们",
"key":"V1001_GOOD"
}]
}]
}

其他新增按钮类型的请求示例

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
{
"button": [
{
"name": "扫码",
"sub_button": [
{
"type": "scancode_waitmsg",
"name": "扫码带提示",
"key": "rselfmenu_0_0",
"sub_button": [ ]
},
{
"type": "scancode_push",
"name": "扫码推事件",
"key": "rselfmenu_0_1",
"sub_button": [ ]
}
]
},
{
"name": "发图",
"sub_button": [
{
"type": "pic_sysphoto",
"name": "系统拍照发图",
"key": "rselfmenu_1_0",
"sub_button": [ ]
},
{
"type": "pic_photo_or_album",
"name": "拍照或者相册发图",
"key": "rselfmenu_1_1",
"sub_button": [ ]
},
{
"type": "pic_weixin",
"name": "微信相册发图",
"key": "rselfmenu_1_2",
"sub_button": [ ]
}
]
},
{
"name": "发送位置",
"type": "location_select",
"key": "rselfmenu_2_0"
},
{
"type": "media_id",
"name": "图片",
"media_id": "MEDIA_ID1"
},
{
"type": "view_limited",
"name": "图文消息",
"media_id": "MEDIA_ID2"
},
{
"type": "article_id",
"name": "发布后的图文消息",
"article_id": "ARTICLE_ID1"
},
{
"type": "article_view_limited",
"name": "发布后的图文消息",
"article_id": "ARTICLE_ID2"
}
]
}

参数说明

参数是否必须说明
button一级菜单数组,个数应为1~3个
sub_button二级菜单数组,个数应为1~5个
type菜单的响应动作类型,view表示网页类型,click表示点击类型,miniprogram表示小程序类型
name菜单标题,不超过16个字节,子菜单不超过60个字节
keyclick等点击类型必须菜单KEY值,用于消息接口推送,不超过128字节
urlview、miniprogram类型必须网页 链接,用户点击菜单可打开链接,不超过1024字节。 type为miniprogram时,不支持小程序的老版本客户端将打开本url。
media_idmedia_id类型和view_limited类型必须调用新增永久素材接口返回的合法media_id
appidminiprogram类型必须小程序的appid(仅认证公众号可配置)
pagepathminiprogram类型必须小程序的页面路径
article_idarticle_id类型和article_view_limited类型必须发布后获得的合法 article_id

返回结果

正确时的返回JSON数据包如下:

{“errcode”:0,“errmsg”:“ok”}

错误时的返回JSON数据包如下(示例为无效菜单名长度):

{“errcode”:40018,“errmsg”:“invalid button name size”}

删除接口

使用接口创建自定义菜单后,开发者还可使用接口删除当前使用的自定义菜单。另请注意,在个性化菜单时,调用此接口会删除默认菜单及全部个性化菜单。

请求说明

http请求方式:GET https://api.weixin.qq.com/cgi-bin/menu/delete?access_token=ACCESS_TOKEN

返回说明

对应创建接口,正确的Json返回结果:

{“errcode”:0,“errmsg”:“ok”}

代码:

在wechat文件夹下新建menu.js文件用来存储发送回去的数据:

menu.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
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
/*
自定义菜单
*/
module.exports = {
"button": [
{
"type": "click",
"name": "戳我呀~",
"key": "CLICK"
},
{
"name": "菜单二",
"sub_button": [
{
"type": "view",
"name": "百度搜索",
"url": "http://www.baidu.com/"
},
{
"type": "scancode_waitmsg",
"name": "扫码带提示",
"key": "扫码带提示"
},
// {
// "type": "scancode_push",
// "name": "扫码推事件",
// "key": "扫码推事件"
// },
// {
// "type": "media_id",
// "name": "点击按钮发送图片",
// "media_id": "MEDIA_ID1"
// },
// {
// "type": "view_limited",
// "name": "图文消息",
// "media_id": "MEDIA_ID2"
// }
]
},
{
"name": "发图",
"sub_button": [
{
"type": "pic_sysphoto",
"name": "系统拍照发图",
"key": "系统拍照发图"
},
{
"type": "pic_photo_or_album",
"name": "拍照或者相册发图",
"key": "拍照或者相册发图"
},
{
"type": "pic_weixin",
"name": "微信相册发图",
"key": "微信相册发图"
},
{
"name": "发送位置",
"type": "location_select",
"key": "rselfmenu_2_0"
}
]
}
]
}

因为创建菜单需要用到access_token值,所以我们在accessToken.js文件下增加俩个函数:创建菜单和删除菜单,并改名为wechat.js

wechat.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
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
/*
access_token:微信调用接口全局唯一凭证
特点:
1.唯一
2.有效期为2小时,提前5分钟重新请求
3.接口权限 每天2000次
https请求方式: GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET

设计思路:
1.首次本地没有,发送请求获取access_token,保存下来(本地文件)
2.第二次或以后:
- 先去本地读取文件,判断它是否过期
- 过期了
- 重新请求,保存下来,覆盖之前的文件(保证文件是唯一的)
- 没过期
- 直接使用

整理思路:
读取本地文件(readAccessToken):
- 本地有文件
- 判断它是否过期(isValidAccessToken)
- 过期了
- 重新请求(getAccessToken),保存下来,覆盖之前的文件(保证文件是唯一的)(saveAccessToken)
- 没过期
- 直接使用
- 本地没有文件
- 发送请求获取access_token(getAccessToken),保存下来(本地文件)(saveAccessToken)
*/

//引入menu模块
const menu=require('./menu');
//只需要引入request-promise-native库
const rp = require('request-promise-native');

//引入fs模块
const {writeFile, readFile} = require('fs');

//引入config文件
const {appID, appsecret} = require('../config');

//定义一个类,获取access_token
class Wechat {
constructor() {
}

/*
用来获取access_token
*/
getAccessToken() {
//定义请求的地址
const url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=' + appID + '&secret=' + appsecret;
//发送请求
/*
需要下载俩个库
request
request-promise-native 返回值是一个promise对象
*/
return new Promise((resolve, reject) => {
rp({method: 'GET', url, json: true})
.then(res => {
console.log(res);
/*
{
access_token: '',
expires_in:
}
*/
//设置access_token的过期时间 单位毫秒
res.expires_in = Date.now() + (res.expires_in - 300) * 1000;
//将promise的对象的状态改为成功的状态
resolve(res);
})
.catch(err => {
console.log(err);
reject('getAccessToken方法出了问题:' + err);
})
})
}

/**
* 用来保存access_token的方法
* @param accessToken 要保存的凭据
*/
saveAccessToken(accessToken) {
//将对象转化为json字符串
accessToken = JSON.stringify(accessToken);
return new Promise((resolve, reject) => {
writeFile('./accessToken.txt', accessToken, err => {
if (!err) {
console.log('文件保存成功');
resolve();
} else {
reject('saveAccessToken方法出了问题:' + err);
}
})
})
}

/**
* 用来读取access_token的方法
*/
readAccessToken() {
//读取本地文件中的access_taken
return new Promise((resolve, reject) => {
readFile('./accessToken.txt', (err, data) => {
if (!err) {
console.log('文件读取成功');
//将json字符串转化成js对象
data = JSON.parse(data);
resolve(data);
} else {
reject('readAccessToken方法出了问题:' + err);
}
})
})
}

/**
* 用来检查access_token是否有效
* @param data
*/
isValidAccessToken(data) {
//检查传入的参数是否有效
if (!data && !data.access_token && !data.expires_in) {
//代表access_token无效
return false;
}

//检查传入的参数是否在有效期内
// if (data.expires_in<Date.now()){
// //过期了
// return false;
// }else {
// //没有过期
// return true;
// }

return data.expires_in > Date.now();
}

/**
* 用来获取没有过期的access_token
* @returns {Promise<unknown>} access_token
*/
fetchAccessToken() {
//优化
if (this.access_token && this.expires_in && this.isValidAccessToken(this)) {
//说明之前保存过access_token,并且access_token有效,直接使用
return Promise.resolve({
access_token: this.access_token,
expires_in: this.expires_in
})
}
return this.readAccessToken()
.then(async res => {
//本地有文件
//判断它是否过期(isValidAccessToken)
if (this.isValidAccessToken(res)) {
//有效的
return Promise.resolve(res);
//resolve(res);
} else {
//过期了
//发送请求获取access_token(getAccessToken)
const res = await this.getAccessToken();
//保存下来(本地文件)(saveAccessToken)
await this.saveAccessToken(res);
//将请求回来的access_token返回出去
return Promise.resolve(res);
// resolve(res);
}
})
.catch(async err => {
//本地没有文件
//发送请求获取access_token(getAccessToken)
const res = await this.getAccessToken();
//保存下来(本地文件)(saveAccessToken)
await this.saveAccessToken(res);
//将请求回来的access_token返回出去
return Promise.resolve(res);
// resolve(res);
})
.then(res => {
//将access_token挂载到this上
this.access_token = res.access_token;
this.expires_in = res.expires_in;
//返回res包装了一层promise对象(此对象为成功的对象)
//是this.readAccessToken()最终返回值
return Promise.resolve(res);
})
}

/**
* 用来创建自定义菜单
* @param menu 菜单的对象
* @returns {Promise<unknown>}
*/
createMenu(menu) {
return new Promise(async (resolve, reject) => {
try {
//获取access_token
const data = await this.fetchAccessToken();
//定义请求地址
const url = `https://api.weixin.qq.com/cgi-bin/menu/create?access_token=${data.access_token}`;
//发送请求
const result = await rp({method: 'POST', url, json: true, body: menu});
resolve(result);
} catch (e) {
reject('createMenu方法出了问题:' + e);
}
})
}

/**
* 用来删除自定义菜单
* @returns {Promise<unknown>}
*/
deleteMenu() {
return new Promise(async (resolve, reject) => {
try {
const data = await this.fetchAccessToken();
//定义请求地址
const url = `GET https://api.weixin.qq.com/cgi-bin/menu/delete?access_token=${data.access_token}`;
//发送请求
const result = await rp({method: 'GET', url, json: true});
resolve(result);
} catch
(e) {
reject('deleteMenu方法出了问题:' + e);
}
})
}
}

(async ()=>{
const w=new Wechat();
//删除之前定义的菜单
let result = await w.deleteMenu();
console.log(result);
//创建新的菜单
result = await w.createMenu(menu);
console.log(result);
})()

运行wechat.js得到如下结果:

{ errcode: 0, errmsg: ‘ok’ }
{ errcode: 0, errmsg: ‘ok’ }

网页开发

获取ticket

自己写页面:
在项目下新建一个views文件夹,在里面新建search.ejs文件

search.ejs

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>search</title>
</head>
<body>
<h1>这是一个搜索页面</h1>
</body>
</html>

然后配置模板资源目录、配置模板引擎、渲染页面,将渲染好的页面通过render方法返回给用户
app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//引入express模块
const express = require('express');
//引入auto模块
const auth = require('./wechat/auth');
//创建app应用对象
const app = express();
//配置模板资源目录
app.set('views','./views');
//配置模板引擎
app.set('view engine','ejs');
//页面路由
app.get('/search',(req,res)=>{
//渲染页面,将渲染好的页面返回给用户
res.render('search');
})
//接收处理所有参数
app.use(auth())
//监听端口号
app.listen(3000, () => console.log('服务器启动成功'));

运行app.js,输入ngrok网址+/search得到如下结果:

接下来我们使用微信给我们提供的工具:JS-SDK

官方文档

首先我们需要获得ticket(获取方式与access_token大同小异)

在utils文件夹下新建api.js用来存放api接口:

api.js

1
2
3
4
5
6
7
8
9
10
11
12
//地址前缀

const prefix = 'https://api.weixin.qq.com/cgi-bin/';

module.exports = {
accessToken: `${prefix}token?grant_type=client_credential`,
ticket: `${prefix}ticket/getticket?type=jsapi`,
menu: {
create: `${prefix}menu/create?`,
delete: `${prefix}menu/delete?`
}
}

tool.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
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/*
工具函数包
*/
//npm install xml2js 导包
//引入xml2js,将xml数据转化为js对象
const {parseString} = require('xml2js');
//引入fs模块
const {writeFile, readFile} = require('fs');
//引入path模块
const {resolve}=require('path');

module.exports = {
getUserDataAsync(req) {
return new Promise((resolve, reject) => {
let xmlData = '';
req
.on('data', data => {
//当流式数据传递过来时会触发当前事件,会将数据注入到回调函数中
// console.log(data);
//读取的数据是buffer,需要将其转化为字符串
xmlData += data.toString();
})
.on('end', () => {
//当数据接收完毕时,会触发当前
resolve(xmlData);
})
})
},

parseXMLAsync(xmlData) {
return new Promise((resolve, reject) => {
parseString(xmlData, {trim: true}, (err, data) => {
if (!err) {
resolve(data);
} else {
reject('parseXMLAsync方法出了问题:' + err);
}
})
})
},

formatMessage(jsData) {
let message = {};
//获取xml对象
jsData = jsData.xml;
//判断数据是否为一个对象
if (typeof jsData === 'object') {
//遍历对象
for (let key in jsData) {
//获取属性值
let value = jsData[key];
//过滤空数据
if (Array.isArray(value) && value.length > 0) {
//将合法数据赋值到message对象上
message[key] = value[0];
}
}
}
return message;
},

writeFileAsync(data, fileName) {
//将对象转化为json字符串
data = JSON.stringify(data);
//使用绝对路径
const filePath = resolve(__dirname,fileName);
return new Promise((resolve, reject) => {
writeFile(filePath, data, err => {
if (!err) {
console.log('文件保存成功');
resolve();
} else {
reject('writeFileAsync方法出了问题:' + err);
}
})
})
},

readFileAsync(fileName){
const filePath = resolve(__dirname,fileName);
return new Promise((resolve, reject) => {
readFile(filePath, (err, data) => {
if (!err) {
console.log('文件读取成功');
//将json字符串转化成js对象
data = JSON.parse(data);
resolve(data);
} else {
reject('readFileAsync方法出了问题:' + err);
}
})
})
}
}

wechat.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
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
//引入menu模块
const menu=require('./menu');
//只需要引入request-promise-native库
const rp = require('request-promise-native');
//引入api模块
const api=require('../utils/api')
//引入工具函数
const {writeFileAsync,readFileAsync}=require('../utils/tool');
//引入config文件
const {appID, appsecret} = require('../config');

//定义一个类,获取access_token
class Wechat {
constructor() {
}

/*
用来获取access_token
*/
getAccessToken() {
//定义请求的地址
const url = `${api.accessToken}&appid=${appID}&secret=${appsecret}`;
//发送请求
/*
需要下载俩个库
request
request-promise-native 返回值是一个promise对象
*/
return new Promise((resolve, reject) => {
rp({method: 'GET', url, json: true})
.then(res => {
console.log(res);
//设置access_token的过期时间 单位毫秒
res.expires_in = Date.now() + (res.expires_in - 300) * 1000;
//将promise的对象的状态改为成功的状态
resolve(res);
})
.catch(err => {
console.log(err);
reject('getAccessToken方法出了问题:' + err);
})
})
}

/**
* 用来保存access_token的方法
* @param accessToken 要保存的凭据
*/
saveAccessToken(accessToken) {
return writeFileAsync(accessToken,'access_token.txt');
}

/**
* 用来读取access_token的方法
*/
readAccessToken() {
return readFileAsync('access_token.txt')
}

/**
* 用来检查access_token是否有效
* @param data
*/
isValidAccessToken(data) {
//检查传入的参数是否有效
if (!data && !data.access_token && !data.expires_in) {
//代表access_token无效
return false;
}
return data.expires_in > Date.now();
}

/**
* 用来获取没有过期的access_token
* @returns {Promise<unknown>} access_token
*/
fetchAccessToken() {
//优化
if (this.access_token && this.expires_in && this.isValidAccessToken(this)) {
//说明之前保存过access_token,并且access_token有效,直接使用
return Promise.resolve({
access_token: this.access_token,
expires_in: this.expires_in
})
}
return this.readAccessToken()
.then(async res => {
//本地有文件
//判断它是否过期(isValidAccessToken)
if (this.isValidAccessToken(res)) {
//有效的
return Promise.resolve(res);
//resolve(res);
} else {
//过期了
//发送请求获取access_token(getAccessToken)
const res = await this.getAccessToken();
//保存下来(本地文件)(saveAccessToken)
await this.saveAccessToken(res);
//将请求回来的access_token返回出去
return Promise.resolve(res);
// resolve(res);
}
})
.catch(async err => {
//本地没有文件
//发送请求获取access_token(getAccessToken)
const res = await this.getAccessToken();
//保存下来(本地文件)(saveAccessToken)
await this.saveAccessToken(res);
//将请求回来的access_token返回出去
return Promise.resolve(res);
// resolve(res);
})
.then(res => {
//将access_token挂载到this上
this.access_token = res.access_token;
this.expires_in = res.expires_in;
//返回res包装了一层promise对象(此对象为成功的对象)
//是this.readAccessToken()最终返回值
return Promise.resolve(res);
})
}

/*
用来获取jsapi_ticket
*/
getTicket() {
//发送请求
return new Promise(async (resolve, reject) => {
//获取access_token
const data = await this.fetchAccessToken();
//定义请求的地址
const url = `${api.ticket}&access_token=${data.access_token}`;
rp({method: 'GET', url, json: true})
.then(res => {
//将promise的对象的状态改为成功的状态
resolve({
ticket:res.ticket,
expires_in:Date.now() + (res.expires_in - 300) * 1000
});
})
.catch(err => {
console.log(err);
reject('getTicket方法出了问题:' + err);
})
})
}

/**
* 用来保存jsapi_ticket的方法
* @param ticket 要保存的票据
*/
saveTicket(ticket) {
return writeFileAsync(ticket,'ticket.txt');
}

/**
* 用来读取ticket的方法
*/
readTicket() {
return readFileAsync('ticket.txt')
}

/**
* 用来检查ticket是否有效
* @param data
*/
isValidTicket(data) {
//检查传入的参数是否有效
if (!data && !data.ticket && !data.expires_in) {
//代表ticket无效
return false;
}
return data.expires_in > Date.now();
}

/**
* 用来获取没有过期的ticket
* @returns {Promise<unknown>} ticket
*/
fetchTicket() {
//优化
if (this.ticket && this.ticket_expires_in && this.isValidTicket(this)) {
//说明之前保存过ticket,并且ticket有效,直接使用
return Promise.resolve({
ticket: this.ticket,
expires_in: this.expires_in
})
}
return this.readTicket()
.then(async res => {
//本地有文件
//判断它是否过期(isValidTicket)
if (this.isValidTicket(res)) {
//有效的
return Promise.resolve(res);
} else {
//过期了
const res = await this.getTicket();
//保存下来(本地文件)(saveTicket)
await this.saveTicket(res);
//将请求回来的access_token返回出去
return Promise.resolve(res);
}
})
.catch(async err => {
//本地没有文件
const res = await this.getTicket();
await this.saveTicket(res);
return Promise.resolve(res);
})
.then(res => {
//将ticket挂载到this上
this.ticket = res.ticket;
this.ticket_expires_in = res.expires_in;
//返回res包装了一层promise对象(此对象为成功的对象)
return Promise.resolve(res);
})
}

/**
* 用来创建自定义菜单
* @param menu 菜单的对象
* @returns {Promise<unknown>}
*/
createMenu(menu) {
return new Promise(async (resolve, reject) => {
try {
//获取access_token
const data = await this.fetchAccessToken();
//定义请求地址
const url = `${api.menu.create}access_token=${data.access_token}`;
//发送请求
const result = await rp({method: 'POST', url, json: true, body: menu});
resolve(result);
} catch (e) {
reject('createMenu方法出了问题:' + e);
}
})
}

/**
* 用来删除自定义菜单
* @returns {Promise<unknown>}
*/
deleteMenu() {
return new Promise(async (resolve, reject) => {
try {
const data = await this.fetchAccessToken();
//定义请求地址
const url = `${api.menu.delete}access_token=${data.access_token}`;
//发送请求
const result = await rp({method: 'GET', url, json: true});
resolve(result);
} catch(e) {
reject('deleteMenu方法出了问题:' + e);
}
})
}
}

(async ()=>{
const w=new Wechat();
/*//删除之前定义的菜单
let result = await w.deleteMenu();
console.log(result);
//创建新的菜单
result = await w.createMenu(menu);
console.log(result);*/
const data = await w.fetchTicket();
console.log(data);
})()

将Wechat暴露出去:在最后添加module.exports = Wechat;并把上面的测试代码注释掉
即:

1
2
3
4
5
6
7
8
9
10
11
/*(async ()=>{
const w=new Wechat();
/!*!//删除之前定义的菜单
let result = await w.deleteMenu();
console.log(result);
//创建新的菜单
result = await w.createMenu(menu);
console.log(result);*!/
const data = await w.fetchTicket();
console.log(data);
})()*/

在config文件夹下的index.js中添加url: 'ngrok地址'

app.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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
//引入express模块
const express = require('express');
//引入sha1加密
const sha1 = require('sha1');
//引入config文件
const {url} = require('./config');
//引入auto模块
const auth = require('./wechat/auth');
//引入wechat模块
const Wechat = require('./wechat/wechat');
//创建app应用对象
const app = express();
//配置模板资源目录
app.set('views', './views');
//配置模板引擎
app.set('view engine', 'ejs');
//创建实例对象
const wechatApi = new Wechat();
//页面路由
app.get('/search', async (req, res) => {
//生成js-sdk需要使用的签名:
//随机字符串
const noncestr = Math.random().split('.')[1];
//时间戳
const timestamp = Date.now();
//获取票据
const {ticket} = await wechatApi.fetchTicket();
//1.组合参与签名的4个参数:jsapi_ticket(临时票据),noncestr(随机字符串),timestamp(时间戳),url(当前服务器地址)
const arr = [
`jsapi_ticket=${ticket}`,
`noncestr=${noncestr}`,
`timestamp=${timestamp}`,
`url=${url}/search`
]
//2.将其进行字典连续,并以'&'拼接在一起
const str = arr.sort().join('&');
console.log(str);//xxx=xxx&xxx=xxx&xxx=xxx
//3.进行sha1加密,最终生成想要的signature
const signature = sha1(str);
//渲染页面,将渲染好的页面返回给用户
res.render('search', {
signature,
noncestr,
timestamp
});
})
//接收处理所有参数
app.use(auth())
//监听端口号
app.listen(3000, () => console.log('服务器启动成功'));

修改search.ejs

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
<title>search</title>
</head>
<body>
<h1 id="search">语音识别查电影</h1>
<script type="text/javascript" src="http://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/zepto/1.2.0/zepto.min.js"></script>
<script type="text/javascript">
/*
1.绑定域名
- 在接口测试号页面上填写js安全域名接口 不用写域名:"http://"
2.引入js文件
- http://res.wx.qq.com/open/js/jweixin-1.6.0.js
3.通过config接口注入权限验证配置
*/
wx.config({
debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
appId: 'wxd9cdd2f13c019b6d', // 必填,公众号的唯一标识
timestamp: '<%= timestamp %>', // 必填,生成签名的时间戳
nonceStr: '<%= noncestr %>', // 必填,生成签名的随机串
signature: '<%= signature %>',// 必填,签名
jsApiList: [
'onMenuShareQQ',
'onMenuShareQZone',
'startRecord',
'stopRecord',
'translateVoice'
] // 必填,需要使用的JS接口列表
});
//通过ready接口处理成功验证
wx.ready(function () {
// config信息验证后会执行ready方法,所有接口调用都必须在config接口获得结果之后,config是一个客户端的异步操作,所以如果需要在页面加载时就调用相关接口,则须把相关接口放在ready函数中调用来确保正确执行。
// 对于用户触发时才调用的接口,则可以直接调用,不需要放在ready函数中。

//验证接口是否有权限
wx.checkJsApi({
jsApiList: ['onMenuShareQQ',
'onMenuShareQZone',
'startRecord',
'stopRecord',
'translateVoice'], // 需要检测的JS接口列表,所有JS接口列表见附录2,
success: function (res) {
// 以键值对的形式返回,可用的api值true,不可用为false
// 如:{"checkResult":{"chooseImage":true},"errMsg":"checkJsApi:ok"}
console.log(res);
},
fail: function (err) {

}
});

//设置标志位,是否在录音中
var isRecord = false;
//语音识别功能
$('#search').tap(function () {
if (!isRecord) {
//开始录音
wx.startRecord();
isRecord = false;
} else {
//结束录音
wx.stopRecord({
success: function (res) {
//结束录音后会自动上传录音到微信服务器中,微信服务器会返回一个id给开发者使用
var localId = res.localId;
//将录音转化为文字
/*wx.translateVoice({
localId: localId, // 需要识别的音频的本地Id,由录音相关接口获得
isShowProgressTips: 1, // 默认为1,显示进度提示
success: function (res) {
alert(res.translateResult); // 语音识别的结果
}
});*/
isRecord = false;
}
});
}
})
});
//通过error接口处理失败验证
wx.error(function (res) {
// config信息验证失败会执行error函数,如签名过期导致验证失败,具体错误信息可以打开config的debug模式查看,也可以在返回的res参数中查看,对于SPA可以在这里更新签名。
});
</script>
</body>
</html>

进入BootCDN官网,搜索zepto,复制第一个script标签即可

分享接口:

1
2
3
4
5
6
7
8
9
10
11
12
wx.onMenuShareQQ({
title: '', // 分享标题
desc: '', // 分享描述
link: '', // 分享链接
imgUrl: '', // 分享图标
success: function () {
// 用户确认分享后执行的回调函数
},
cancel: function () {
// 用户取消分享后执行的回调函数
}
});

优化项目

写了这么久的项目,是不是觉得项目十分的杂乱,这时候我们就需要对项目就行优化

因为auth,reply,template三个模块的作用都是拿到数据并做出反应给用户,所以我们可以把他们整合到一起

然后menu,wechat我们是用来实现自定义菜单功能的,我们今后可以将所有接口都放到wechat里面

在项目下新建文件夹reply,用来处理用户响应,反应给用户的一些模块,即将auth,reply,template三个模块放进去,将auth改名为index

修改app.js中的引入auth代码,修改为

1
2
//引入reply模块
const reply = require('./reply');

修改app.use(auth())app.use(reply())

提取app.js中的路由模块

在项目下新建router文件夹,新建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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//引入express模块
const express = require('express');
//引入sha1加密
const sha1 = require('sha1');
//引入config文件
const {url} = require("../config");
//引入wechat模块
const Wechat = require("../wechat/wechat");
//引入reply模块
const reply = require("../reply");
//获取Router
const Router = express.Router;
//创建路由器对象
const router = new Router();
//创建实例对象
const wechatApi = new Wechat();
//页面路由
router.get('/search', async (req, res) => {
//生成js-sdk需要使用的签名:
//随机字符串
const noncestr = Math.random().split('.')[1];
//时间戳
const timestamp = Date.now();
//获取票据
const {ticket} = await wechatApi.fetchTicket();
//1.组合参与签名的4个参数:jsapi_ticket(临时票据),noncestr(随机字符串),timestamp(时间戳),url(当前服务器地址)
const arr = [
`jsapi_ticket=${ticket}`,
`noncestr=${noncestr}`,
`timestamp=${timestamp}`,
`url=${url}/search`
]
//2.将其进行字典连续,并以'&'拼接在一起
const str = arr.sort().join('&');
console.log(str);//xxx=xxx&xxx=xxx&xxx=xxx
//3.进行sha1加密,最终生成想要的signature
const signature = sha1(str);
//渲染页面,将渲染好的页面返回给用户
res.render('search', {
signature,
noncestr,
timestamp
});
})
//接收处理所有参数
router.use(reply())
//将路由器对象暴露出去
module.exports = router;

修改app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//引入express模块
const express = require('express');
//引入路由器模块
const router = require('./router');
//创建app应用对象
const app = express();
//配置模板资源目录
app.set('views', './views');
//配置模板引擎
app.set('view engine', 'ejs');
//应用路由器
app.use(router);
//监听端口号
app.listen(3000, () => console.log('服务器启动成功'));

爬取浏览器数据

使用puppeteer

github地址

api文档

下载: npm i puppeteer

puppeteer 在执行安装的过程中需要执行install.js,这里会下载Chromium,翻墙也下载失败,导致安装不成功,解决办法(使用淘宝镜像):

npm config set puppeteer_download_host=https://npm.taobao.org/mirrors
npm i puppeteer

我们只需要爬取8条数据

在项目下新建文件夹server,在里面再新建一个文件夹crawler,在其中新建theaterCrawler.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
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
const puppeteer = require('puppeteer')

//爬取热门电影信息
const url = 'https://movie.douban.com/cinema/nowplaying/chongqing/';

module.exports = async () => {
//1.打开浏览器
const browser = await puppeteer.launch({
// args: ['--no-sandbox'],
headless: true //以无头浏览器形式打开浏览器,没有界面显示,在后台运行的8
});
//2.创建tab标签页
const page = await browser.newPage();
//3.跳转到指定网址
await page.goto(url, {
waitUntil: 'networkidle2' //等待网络空闲时再跳转加载页面
});
//4.等待网址加载完成,开始爬取数据
//开启延时器,延时2秒钟开始爬取数据
await timeout();
let result = await page.evaluate(() => {
//对加载好的页面进行dom操作
//所有爬取的数据数组
let result = [];
//获取所有热门电影的li
const $list = $('#nowplaying>.mod-bd>.lists>.list-item');
//只取8条数据
for (var i = 0; i < 8; i++) {
const liDom = $list[i];
//电影标题
let title = $(liDom).data('title');
//电影评分
let rating = $(liDom).data('score');
//电影片长
let runtime = $(liDom).data('duration');
//导演
let directors = $(liDom).data('director');
//主演
let casts = $(liDom).data('actors');
//电影详情网址
let href = $(liDom).find('.poster>a').attr('href');
//电影海报图
let image = $(liDom).find('.poster>a>img').attr('src');
//id
let doubanId = $(liDom).data('subject');
result.push({
title,
rating,
runtime,
directors,
casts,
href,
image,
doubanId
})
}
//将爬取的数据返回出去
return result;
})
//console.log(result);

//遍历爬取到的8条数据
for (let i = 0; i < result.length; i++) {
//获取每一条信息
let item = result[i];
//获取电影详情网址
let url = result[i].href;
//跳转到电影详情页
await page.goto(url, {
waitUntil: 'networkidle2' //等待网络空闲时再跳转加载页面
});
//爬取其他数据
let itemResult = await page.evaluate(() => {
let genre = [];
//类型
const $genre = $('[property="v:genre"]');
for (let j = 0; j < $genre.length; j++) {
genre.push($genre[j].innerText);
}
//简介
const summary = $('[property="v:summary"]').html().replace(/\s+/g, '');
//上映日期
const releaseDate = $('[property="v:initialReleaseDate"]')[0].innerText;
//给单个对象添加3个属性
return {
genre,
summary,
releaseDate
}
})
//在最后给当前对象添加3个属性
//在evaluate函数中没办法读取到服务器中的变量
item.genre = itemResult.genre;
item.summary = itemResult.summary;
item.releaseDate = itemResult.releaseDate;
}
console.log(result);
//5.关闭浏览器
await browser.close();
}

function timeout() {
return new Promise(resolve => setTimeout(resolve, 2000))
}

在server文件夹下新建index.js文件

1
2
3
4
5
const theatersCrawler = require('./crawler/theatersCrawler');

(async () => {
await theatersCrawler();
})()

运行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
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
[                                                                          
{
title: '四海',
rating: 5.6,
runtime: '128分钟',
directors: '韩寒',
casts: '刘昊然 / 刘浩存 / 沈腾',
href: 'https://movie.douban.com/subject/35337517/?from=playing_poster',
image: 'https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2867433563.webp',
doubanId: 35337517,
genre: [ '喜剧', '动作', '爱情' ],
summary: '在码头做摩托车特技表演顺便拉客的年轻人吴仁耀(刘昊然饰),他多年不见的浪荡父亲吴仁腾(沈腾饰);梦想大城市生活的餐馆服务员周欢颂和他浮夸真诚的哥哥周
欢歌。一支从没有赢过一场比赛的“不败传说”车队频频出战,一群可笑又可爱的小人物命运交织。阿耀和欢颂都立志活成自己亲人的反面,想彼此取暖,彼此独立,却又总不在一个频道
上。世事无常,他们不得不背井离乡,迎接一场未知旅途,阿耀的一身技能竟会用在一个自己都意想不到的场合,他的亲情友情和爱情最终又将会是如何……',
releaseDate: '2022-02-01(中国大陆)'
},
{
title: '奇迹·笨小孩',
rating: 7.4,
runtime: '106分钟',
directors: '文牧野',
casts: '易烊千玺 / 田雨 / 陈哈琳',
href: 'https://movie.douban.com/subject/35312437/?from=playing_poster',
image: 'https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2842327103.webp',
doubanId: 35312437,
genre: [ '剧情' ],
summary: '二十岁的景浩(易烊千玺饰)独自带着年幼的妹妹来到深圳生活,兄妹俩生活温馨却拮据。为了妹妹高昂的手术费,机缘巧合之下,景浩得到一个机会,本以为美好生活
即将来临,却不料遭遇重创。在时间和金钱的双重压力下,毫无退路的景浩决定孤注一掷,而他陷入困境的平凡人生,又能否燃起希望的火花?<br>电影《奇迹》是中宣部国家电影局20
21年重点电影项目,也是2021年重点建党百年献礼片,描述十八大以后新时代年轻人在深圳创业的影片。',
releaseDate: '2022-02-01(中国大陆)'
},
{
title: '这个杀手不太冷静',
rating: 6.9,
runtime: '109分钟',
directors: '邢文雄',
casts: '马丽 / 魏翔 / 陈明昊',
href: 'https://movie.douban.com/subject/35505100/?from=playing_poster',
image: 'https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2814949620.webp',
doubanId: 35505100,
genre: [ '喜剧' ],
summary: '毕生追求男主梦的魏成功(魏翔饰)终于得到了女明星米兰(马丽饰)的“赏识”,被邀请出演她的男一号“杀手卡尔”,他兴致勃勃诠释角色的同时,却没想到已经落入了
一场危机四伏的阴谋,但他依然借自己“精湛”的演技和绝佳的运气化险为夷,而残酷的真相也离他越来越近……',
releaseDate: '2022-02-01(中国大陆)'
},
{
title: '长津湖之水门桥',
rating: 7.2,
runtime: '149分钟',
directors: '徐克',
casts: '吴京 / 易烊千玺 / 朱亚文',
href: 'https://movie.douban.com/subject/35613853/?from=playing_poster',
image: 'https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2846021991.webp',
doubanId: 35613853,
genre: [ '剧情', '历史', '战争' ],
summary: '电影以抗美援朝战争第二次战役中的长津湖战役为背景,讲述了在结束了新兴里和下碣隅里的战斗之后,七连战士们又接到了更艰巨的任务……',
releaseDate: '2022-02-01(中国大陆)'
},
{
title: '狙击手',
rating: 7.7,
runtime: '96分钟',
directors: '张艺谋 张末',
casts: '陈永胜 / 章宇 / 张译',
href: 'https://movie.douban.com/subject/35215390/?from=playing_poster',
image: 'https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2738601191.webp',
doubanId: 35215390,
genre: [ '剧情', '历史', '战争' ],
summary: '影片根据抗美援朝战争“冷枪冷炮”运动中神枪手群体事迹改编。1952年冬至1953年初,中国人民志愿军与联合国军在朝鲜战场形成僵持,双方发起了低强度的密集狙击战
,史称"冷枪冷炮运动"。在连长(张译饰)带领下的狙击五班战士枪法过人,成为敌军的心头大患,班长刘文武(章宇饰)更成为重点狙击对象。为重创狙击五班,敌方调配精英狙击小
队,配以最先进的武器装备,更迫使狙击五班战士大永(陈永胜饰)等人为救同伴进入其设好的险境之地。但正当敌军打响自己如意算盘之时,他们未料到,被他们当作诱饵的侦察兵亮
亮(刘奕铁饰)身上其实隐藏着更大的秘密......',
releaseDate: '2022-02-01(中国大陆)'
},
{
title: '喜羊羊与灰太狼之筐出未来',
rating: 0,
runtime: '94分钟',
directors: '黄伟明',
casts: '祖晴 / 张琳 / 邓玉婷',
href: 'https://movie.douban.com/subject/35608160/?from=playing_poster',
image: 'https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2797468943.webp',
doubanId: 35608160,
genre: [ '喜剧', '动画', '运动' ],
summary: '喜羊羊、灰太狼与一众小羊组成的守护者队进入篮球顶级赛事决赛,但却意外地败北,团队分崩离析。<br>虽然各散东西,但对篮球的热爱和对冠军的渴望让大家再次组
队,参加新一届大赛,然而这次的对手更强大,他们面临更大的挑战!',
releaseDate: '2022-02-01(中国大陆)'
},
{
title: '熊出没·重返地球',
rating: 0,
runtime: '99分钟',
directors: '林汇达',
casts: '张秉君 / 张伟 / 谭笑',
href: 'https://movie.douban.com/subject/35377026/?from=playing_poster',
image: 'https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2856825681.webp',
doubanId: 35377026,
genre: [ '喜剧', '动画', '儿童' ],
summary: '有点懒又有点馋的熊二虽然总是各种失误犯错,内心却一直梦想成为一位英雄,以此获得大家特别是哥哥熊大的认可。<br>一块外星原核的坠落打破了狗熊岭的平静,熊
二意外的与外星原核合体,成为了拥有外星智慧能量的熊!随之而来的是“外星人”阿布的抢夺。阿布为了夺回原核,故意制造事端,让熊二众叛亲离。就在阿布将要成功的时候,一支神
秘高科技军团的攻击彻底打乱了计划,导致熊强组合甚至整个地球于巨大的危难之中。<br>阿布隐藏的身份以及来到地球的真正目的到底是什么?神秘的高科技军团背后有何故事?自暴
自弃的熊二能否振作起来实现他的英雄梦?他们能否挽救地球的危机?',
releaseDate: '2022-02-01(中国大陆)'
},
{
title: '李茂扮太子',
rating: 4.3,
runtime: '100分钟',
directors: '高可',
casts: '马丽 / 常远 / 艾伦',
href: 'https://movie.douban.com/subject/35444998/?from=playing_poster',
image: 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2812626447.webp',
doubanId: 35444998,
genre: [ '喜剧', '古装' ],
summary: '富家女杨家珍(马丽饰)与小捕快李茂(常远饰)成婚,虽夫妻恩爱,但始终得不到家珍父母的认可。李茂意外发现自己竟与当朝太子相貌相同,一个想进宫获得晋升,
一个想出宫获得自由,二人交换身份,却不知正一步步卷入尚书的阴谋里……',
releaseDate: '2022-01-01(中国大陆)'
}
]

保存数据到数据库

在项目下新建db文件夹,在其中新建index.js文件,需要下载mongoose包:npm install mongoose

安装mongoDB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//引入mongoose
const mongoose = require('mongoose');
module.exports = new Promise((resolve, reject) => {
//连接数据库
mongoose.connect('mongodb://localhost:27017/movie', {useNewUrlParser: true});
//绑定事件监听
mongoose.connection.once('open', err => {
if (!err) {
console.log('数据库连接成功~~');
resolve();
}else {
reject('数据库连接失败:'+err);
}
})
})

在项目下新建model文件夹,在其中新建Theaters.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
//引入mongoose
const mongoose = require('mongoose');
//获取Schema
const Schema = mongoose.Schema;
//创建约束对象
const theatersSchema = new Schema({
title: String,
rating: Number,
runtime: String,
directors: String,
casts: String,
image: String,
doubanId: {
type: Number,
unique: true
},
genre: [String],
summary: String,
releaseDate: String,
posterKey: String,//图片上传到七牛中返回的key值
createTime: {
type: Date,
default: Date.now()
}
})
//创建模型对象
const Theaters = mongoose.model('Theaters',theatersSchema);
//暴露出去
module.exports = Theaters;

在server文件夹下新建save文件夹,并在其中新建saveTheaters.js文件用来保存实现保存操作的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//引入Theaters
const Theaters = require('../../model/Theaters');

module.exports = async data => {
for (var i = 0; i < data.length; i++) {
let item = data[i];
await Theaters.create({
title: item.title,
rating: item.rating,
runtime: item.runtime,
directors: item.directors,
casts: item.casts,
image: item.image,
doubanId: item.doubanId,
genre: item.genre,
summary: item.summary,
releaseDate: item.releaseDate,
})
console.log('数据保存成功');
}
}

在关闭浏览器后将数据返回出去:在theatersCrawler.js中关闭浏览器代码后添加return result;

修改server文件夹下面的index文件

1
2
3
4
5
6
7
8
9
10
11
const db = require('../db');
const theatersCrawler = require('./crawler/theatersCrawler');
const saveTheaters = require('./save/saveTheaters');
(async () => {
//连接数据库
await db;
//爬取数据
const data = await theatersCrawler();
//将爬取的数据保存到数据库中
await saveTheaters(data);
})()

在数据库中执行:db.theaters.find({}) 查看数据保存情况

回复用户热门电影数据

修改reply下的reply.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
//引入Theaters
const Theaters = require('../model/Theaters');
//引入config
const {url} = require('../config');

//需要将该函数改为async函数
if (message.Content === '热门') {//全匹配
//回复用户热门电影数据
const data = await Theaters.find({}, {title: 1, summary: 1, image: 1, doubanId: 1, _id: 0});
//将回复内容改为空数组
content = [];
options.msgType = 'news';
//通过变量将数据添加进去
for (var i = 0; i < data.length; i++) {
let item = data[i];
content.push({
title: item.title,
description: item.summary,
picUrl: item.image,
url: `${url}/detail/${item.doubanId}`
})
}

}

将reply下的index.js文件中的const options = reply(message);改为

1
2
3
//将reply改为了async函数,返回值变为了promise对象
//必须用await关键字才能拿到最终的返回值
const options = await reply(message);

电影详情页面

router的index.js中,在搜索页面路由下添加电影详情页面路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//引入Theaters
const Theaters = require('../model/Theaters');

//详情页面路由
router.get('/detail/:id', async (req, res) => {
//获取占位符id的值
const {id} = req.params;
//判断id值是否存在
if (id) {
//去数据库中找到对应id值的所有数据
const data = await Theaters.findOne({doubanId: id}, {_id: 0, __v: 0, createTime: 0, doubanId: 0});
//渲染到页面上
res.render('detail', {data});
} else {
res.end('error');
}
})

电影详情页

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>movie</title>
</head>
<body>
<div id="app">
<header>
<a class="header_title" href="/movie">电影</a>
<a class="header_search" href="/search">搜索</a>
</header>
<div class="page">
<h1 class="title"><%= data.title %></h1>
<section class="info">
<div class="left">
<p class="rating">
<span>评分:</span>
<strong><%= data.rating %></strong>
<span class="ratingNum">98375人评价</span>
</p>
<p class="meta">
<%= data.runtime %> /
<%= data.genre.forEach()(function(item){ %>
<%= item %> /
<% }) %>
<%= data.directors %>(导演) / <%= data.casts %> / <%= data.releaseDate %>
</p>
</div>
<div class="right">
<a href="javascript:">
<img src="<%= data.image %>" alt="<%= data.title %>">
</a>
</div>
</section>
<section class="intro">
<h2><%= data.title %>的简介</h2>
<div class="bd">
<p><%= data.summary %></p>
</div>
</section>
</div>
</div>
</body>
</html>

文本搜索电影

在匹配的最后加一个else判断,用来搜索用户输入指定电影信息

reply.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
31
32
33
//引入rp
const rp = require('request-promise-native');


else {
//搜索用户输入指定电影信息
//定义请求地址
//这种写法会导致message.Content变为一些%之类的码
//const url = `https://api.douban.com/v2/movie/search?q=${message.Content}&count=8`;
const url = 'https://api.douban.com/v2/movie/search';
//发送请求
const {subjects} = await rp({method: 'GET', url, json: true, qs: {q: message.Content, count: 8}});
//判断subjects是否有值
if (subjects && subjects.length) {
//有数据,返回一个图文消息给用户
//将回复内容改为空数组
content = [];
options.msgType = 'news';
//通过变量将数据添加进去
for (var i = 0; i < subjects.length; i++) {
let item = subjects[i];
content.push({
title: item.title,
description: `电影评分为:${item.rating.average}`,
picUrl: item.image.small,
url: item.alt
})
}
} else {
//无数据
content = "暂时没有电影信息";
}
}

上传图片到七牛

七牛官网

注册并登录

点击控制台->资源管理下的存储空间->新建存储空间(填写信息后确认创建)

我们上传成功之后,只需复制外链使用即可

文档:文档中心->SDK&工具->官方SDK->Node.js 服务器

安装依赖: npm i qiniu -D

在server文件夹下新建qiniu文件夹,并在其中新建upload.js、index.js文件

upload.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
31
32
33
34
//引入qiniu
const qiniu = require('qiniu');
//个人中心->秘钥管理
var accessKey = 'your access key';
var secretKey = 'your secret key'
//定义鉴权对象
var mac = new qiniu.auth.digest.Mac(accessKey, secretKey);
//定义配置对象
var config = new qiniu.conf.Config();
//存储区域 z0 -- 华东 z1 -- 华北 z2 -- 华南
config.zone = qiniu.zone.Zone_z2;
//bucketManager对象上就有所有方法
var bucketManager = new qiniu.rs.BucketManager(mac, config);
const bucket = '';
module.exports = (resUrl, key) => {
/*
resUrl 网络资源地址
bucket 存储空间名称
*/
return new Promise((resolve, reject) => {
bucketManager.fetch(resUrl, bucket, key, function (err, respBody, respInfo) {
if (err) {
console.log(err);
//throw err;
reject('上传图片方法出了问题' + err);
} else {
if (respInfo.statusCode == 200) {
console.log('文件上传成功');
resolve();
}
}
});
})
}

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
31
32
33
34
35
36
37
38
/*
将数据库中的图片上传到七牛
*/
//模型对象
const Theaters = require('../../model/Theaters');
//上传图片到七牛方法
const upload = require('./upload');
//生成唯一的key值
const nanoid = require('nanoid');
module.exports = async () => {
/*
1.获取数据库中的图片链接
2.上传到七牛
3.保存key值到数据库中
*/
//去数据库中所有没有上传图片的文档对象
//const movies = await Theaters.find({posterKey: {$in: ['', null, {$exists: false}]}})
const movies = await Theaters.find({
$or: [
{posterKey: ''},
{posterKey: null},
{posterKey: {$exists: false}}
]
})

//遍历每一条数据
for (let i = 0; i < movies.length; i++) {
//获取每一个文档对象
let movie = movies[i];
//上传到七牛中
let url = movie.image;
let key = `${nanoid(10)}.jpg`;
await upload(url, key);
//保存key值到数据库中
movie.posterKey = key;
await movie.save();
}
}

修改server文件夹下的index.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const db = require('../db');
const theatersCrawler = require('./crawler/theatersCrawler');
const saveTheaters = require('./save/saveTheaters');
const uploadToQiniu = require('./qiniu');
(async () => {
//连接数据库
await db;
//爬取数据
//const data = await theatersCrawler();
//将爬取的数据保存到数据库中
//await saveTheaters(data);
//上传图片到七牛中
await uploadToQiniu();
})()

运行server文件夹下的index.js文件

nanoid库:生成唯一key值
下载: npm i nanoid -D

修改reply.js回复热门下面的picUrl为:

picUrl: http://r6v3vcdm2.hn-bkt.clouddn.com/${item.posterKey},

爬取预告片电影数据

跟之前爬取热门电影一样,我们先在server文件夹下的crawler文件夹下新建trailersCrawler.js文件用来爬取预告片电影数据

trailersCrawler.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
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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
const puppeteer = require('puppeteer')

//爬取热门电影信息
const url = 'https://movie.douban.com/coming';

module.exports = async () => {
//1.打开浏览器
const browser = await puppeteer.launch({
// args: ['--no-sandbox'],
headless: true //以无头浏览器形式打开浏览器,没有界面显示,在后台运行的8
});
//2.创建tab标签页
const page = await browser.newPage();
//3.跳转到指定网址
await page.goto(url, {
waitUntil: 'networkidle2' //等待网络空闲时再跳转加载页面
});
//4.等待网址加载完成,开始爬取数据
//开启延时器,延时2秒钟开始爬取数据
await timeout();
//一、爬取所有预告片详情页面网址
let result = await page.evaluate(() => {
//对加载好的页面进行dom操作
//所有爬取的数据数组
let result = [];
//获取所有即将上映电影
const $trs = $('.coming_list>tbody>tr');
for (var i = 0; i < $trs.length; i++) {
const trDom = $trs[i];
//获取想看的人数
let num = parseInt($(trDom).find('td').last().html());
//判断num大小
if (num > 1000) {
//想看人数大于1000才叫好
//电影详情页面
let href = $(trDom).find('a').attr('href');
result.push(href);
}
}
//将爬取的数据返回出去
return result;
})
//console.log(result);
//所有电影数据的数组
let moviesData = [];
//二、爬取主要的数据
//遍历爬取到的数据
for (let i = 0; i < result.length; i++) {
//获取电影详情网址
let url = result[i];
//跳转到电影详情页
await page.goto(url, {
waitUntil: 'networkidle2' //等待网络空闲时再跳转加载页面
});
//爬取其他数据
let itemResult = await page.evaluate(() => {
//预告片电影网址
const href = $('.related-pic-video').attr('href');
if (!href) {
return false;
}
//标题
let title = $('[property="v:itemreviewed"]').html();
//导演
let directors = $('[rel="v:directedBy"]').html();
//海报图
let image = $('[rel="v:image"]').attr('src');
//豆瓣id
let doubanId = $('.lnk-sharing').attr('share-id');
//演员
let casts = [];
let $star = $('[rel="v:starring"]');
let length = $star.length > 3 ? 3 : $star.length;
for (var j = 0; j < length; j++) {
casts.push($star[j].innerText);
}
//类型
let genre = [];
const $genre = $('[property="v:genre"]');
for (let j = 0; j < $genre.length; j++) {
genre.push($genre[j].innerText);
}
//简介
const summary = $('[property="v:summary"]').html().replace(/\s+/g, '');
//上映日期
const releaseDate = $('[property="v:initialReleaseDate"]')[0].innerText;
//片长
const runTime = $('[property="v:runtime"]').html();
//封面图片
const cover = $('.related-pic-video').attr('background-image');
//给单个对象添加3个属性
return {
title,
directors,
casts,
genre,
image,
summary,
releaseDate,
doubanId,
runTime,
href,
cover
}
})
if(itemResult)
{
moviesData.push(itemResult);
}
}
//console.log(moviesData);
//三、爬取预告片电影链接
for (let i = 0; i < moviesData.length; i++) {
let item = moviesData[i];
let url = item.href;
//跳转到电影详情页
await page.goto(url, {
waitUntil: 'networkidle2' //等待网络空闲时再跳转加载页面
});
//爬取其他数据
item.link = await page.evaluate(() => {
//电影链接
return $('video>source').attr('src');
})
}
console.log(moviesData);
//5.关闭浏览器
await browser.close();
return moviesData;
}

function timeout() {
return new Promise(resolve => setTimeout(resolve, 2000))
}

修改server文件夹下的index.js文件并运行测试是否爬取到数据

index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const db = require('../db');
const theatersCrawler = require('./crawler/theatersCrawler');
const trailersCrawler = require('./crawler/trailersCrawler');
const saveTheaters = require('./save/saveTheaters');
const uploadToQiniu = require('./qiniu');
(async () => {
//连接数据库
//await db;
//爬取数据
//const data = await theatersCrawler();
const data = await trailersCrawler();
//将爬取的数据保存到数据库中
//await saveTheaters(data);
//上传图片到七牛中
//await uploadToQiniu();
})()

我们同样在server文件夹下的save文件夹下新建saveTrailers.js用来将爬取到的数据保存到数据库

saveTrailers.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//引入Trailers
const Trailers = require('../../model/Trailers');

module.exports = async data => {
for (var i = 0; i < data.length; i++) {
let item = data[i];
await Trailers.create({
title: item.title,
runtime: item.runtime,
directors: item.directors,
casts: item.casts,
image: item.image,
doubanId: item.doubanId,
cover: item.cover,
genre: item.genre,
summary: item.summary,
releaseDate: item.releaseDate,
link: item.link,
})
console.log('数据保存成功');
}
}

Trailers.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
31
32
//引入mongoose
const mongoose = require('mongoose');
//获取Schema
const Schema = mongoose.Schema;
//创建约束对象
const trailersSchema = new Schema({
title: String,
runtime: String,
directors: String,
casts: [String],
image: String,
doubanId: {
type: Number,
unique: true
},
genre: [String],
cover: String,
summary: String,
releaseDate: String,
link:String,
posterKey: String,//图片上传到七牛中返回的key值
coverKey: String,//视频封面图
videoKey: String,//预告片
createTime: {
type: Date,
default: Date.now()
}
})
//创建模型对象
const Trailers = mongoose.model('Trailers', trailersSchema);
//暴露出去
module.exports = Trailers;

修改server文件夹下的index.js文件并运行测试保存数据是否成功

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const db = require('../db');
const theatersCrawler = require('./crawler/theatersCrawler');
const trailersCrawler = require('./crawler/trailersCrawler');
const saveTheaters = require('./save/saveTheaters');
const saveTrailers = require('./save/saveTrailers');
const uploadToQiniu = require('./qiniu');
(async () => {
//连接数据库
await db;
//爬取数据
//const data = await theatersCrawler();
const data = await trailersCrawler();
//将爬取的数据保存到数据库中
//await saveTheaters(data);
await saveTrailers(data);
//上传图片到七牛中
//await uploadToQiniu();
})()

打开数据库即可查看

重新定义上传七牛的方法

修改server文件夹下的qiniu文件夹下的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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*
将数据库中的图片上传到七牛
*/
//上传图片到七牛方法
const upload = require('./upload');
//生成唯一的key值
const {nanoid} = require('nanoid');
module.exports = async (key, Model) => {
/*
1.获取数据库中的图片链接
2.上传到七牛
3.保存key值到数据库中
*/
//去数据库中所有没有上传图片的文档对象
//const movies = await Model.find({posterKey: {$in: ['', null, {$exists: false}]}})
const movies = await Model.find({
$or: [
{[key]: ''},
{[key]: null},
{[key]: {$exists: false}}
]
})

//遍历每一条数据
for (let i = 0; i < movies.length; i++) {
//获取每一个文档对象
let movie = movies[i];
//初始化
let url = movie.image;
let filename = '.jpg';
if (key === 'coverKey') {
url = movie.cover;
} else if (key === 'videoKey') {
url = movie.link;
filename = '.mp4';
}
//文件名
filename = `${nanoid(10)}${filename}`;
//上传到七牛中
await upload(url, filename);
//保存key值到数据库中
movie[key] = filename;
await movie.save();
}
}

修改server文件夹下的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
const db = require('../db');
const theatersCrawler = require('./crawler/theatersCrawler');
const trailersCrawler = require('./crawler/trailersCrawler');
const saveTheaters = require('./save/saveTheaters');
const saveTrailers = require('./save/saveTrailers');
const uploadToQiniu = require('./qiniu');
const Theaters = require('../model/Theaters');
const Trailers = require('../model/Trailers');
(async () => {
//连接数据库
await db;
//爬取数据
//const data = await theatersCrawler();
//const data = await trailersCrawler();
//将爬取的数据保存到数据库中
//await saveTheaters(data);
//await saveTrailers(data);
//上传图片到七牛中
//await uploadToQiniu('posterKey', Theaters);
await uploadToQiniu('coverKey', Trailers);
await uploadToQiniu('posterKey', Trailers);
await uploadToQiniu('videoKey', Trailers);
})()

搭建预告片静态页面

可自行搭建movie.html

搭建预告片ejs页面

在router文件夹的index.js中添加电影预告片路由

1
2
3
4
5
6
7
8
9
const Trailers = require('../model/Trailers');

//预告片页面路由
router.get('/movie', async (req, res) => {
//去数据库中找到所有预告片电影数据
const data = await Trailers.find({}, {_id: 0, __v: 0,cover:0,link:0,image:0});
//渲染到页面上
res.render('movie', {data});
})

修改movie.html为movie.ejs,同之前的search.ejs修改即可

预告片视频功能

可以使用一个叫DPlayer的库,可以去github查看一下
文档

bootcdn地址

获取media-id

我们在项目下新建media文件夹来存放素材

在utils文件夹中的api.js新增接口

1
2
3
4
5
6
7
8
9
10
temporary: {
upload: `${prefix}media/upload?`,
get: `${prefix}media/get?`
},
permanent: {
uploadNews: `${prefix}material/add_news?`,
uploadImg: `${prefix}media/uploadimg?`,
uploadOthers: `${prefix}material/add_material?`,
get: `${prefix}material/get_material?`
}

上传临时素材

官方文档

我们在wechat.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
//fs模块
const {createReadStream}= require('fs');
//path 模块
const {resolve}=require('path');

//上传临时素材
uploadTemporaryMaterial(type, fileName) {

//获取文件绝对路径
const filePath = resolve(__dirname, '../media', fileName);
return new Promise(async (resolve, reject) => {
try {
//获取access_token
const data = await this.fetchAccessToken();
//定义请求地址
const url = `${api.temporary.upload}access_token=${data.access_token}`;
const formData = {
media: createReadStream(filePath)
}
//form表单方式发送请求
const result = await rp({method: 'POST', url, json: true, formData});
//将数据返回给用户
resolve(result);
} catch (e) {
reject('uploadTemporaryMaterial方法出了问题' + e);
}
})
}

获取临时素材

官方文档

wechat.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
31
32
33
//以流的方式接收
const request = require('request');
//fs模块 增加可写流
const {createReadStream,createWriteStream}= require('fs');

//获取临时素材
getTemporaryMaterial(type, mediaId, fileName) {
//获取文件绝对路径
const filePath = resolve(__dirname, '../media', fileName);
return new Promise(async (resolve, reject) => {
try {
//获取access_token
const data = await this.fetchAccessToken();
//定义请求地址
let url = `${api.temporary.get}access_token=${data.access_token}&media_id=${mediaId}`;
//判断是否为视频文件
if (type === 'video') {
//视频文件只支持http协议
url = url.replace('https://', 'http://');
//发送请求
const result = await rp({method: 'GET', url, json: true});
resolve(result);
} else {
//其他类型
request(url)
.pipe(createWriteStream(filePath))
.once('close', resolve) //当文件读取完毕时 可读流自动关闭 一旦关闭触发close事件 从而调用resolve方法通知外部文件读取完毕
}
}catch (e) {
reject('getTemporaryMaterial方法出了问题'+e);
}
})
}

上传永久素材

官方文档

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
//path 模块 添加join方法
const {resolve,join} = require('path');

//上传永久素材
uploadPermanentMaterial(type, material, body) {
return new Promise(async (resolve, reject) => {
try {
//获取access_token
const data = await this.fetchAccessToken();
//请求的配置对象
let options = {
method: 'POST',
json: true
}
//定义请求地址
if (type === 'news') {
//上传图文消息
options.url = `${api.permanent.uploadNews}access_token=${data.access_token}`;
options.body = material;
} else if (type === 'pic') {
//上传图片
options.url = `${api.permanent.uploadImg}access_token=${data.access_token}`;
options.formData = {
media: createReadStream(join(__dirname, '../media', material))
}
} else {
//其他媒体素材
options.url = `${api.permanent.uploadOthers}access_token=${data.access_token}&type=${type}`;
options.formData = {
media: createReadStream(join(__dirname, '../media', material))
}
//视频素材
if (type === 'video') {
options.body = body;
}
}
//发送请求
const result = await rp(options);
//将返回值返回出去
resolve(result)
} catch (e) {
reject('uploadPermanentMaterial方法出了问题' + e);
}
})
}

获取永久素材

官方文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//获取永久素材
getPermanentMaterial(type, mediaId, fileName) {
return new Promise(async (resolve, reject) => {
try {
//获取access_token
const data = await this.fetchAccessToken();
//定义请求地址
const url = `${api.permanent.get}access_token=${data.access_token}`;
const options = {method: 'POST', url, json: true,body:{media_id:mediaId}};
if (type === 'video'||'news') {
//发送请求
const result = await rp(options);
resolve(result);
} else {
request(options)
.pipe(createWriteStream(join(__filename,'../media',fileName)))
.once('close', resolve)
}
}catch (e) {
reject('getPermanentMaterial方法出了问题'+e);
}
})
}

封装上传素材函数

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
//上传素材
uploadMaterial(type, material, body, isPermanent = true) {
return new Promise(async (resolve, reject) => {
try {
//获取access_token
const data = await this.fetchAccessToken();
//请求的配置对象
let options = {
method: 'POST',
json: true,
formData : {
media: createReadStream(join(__dirname, '../media', material))
}
}
if (isPermanent) {
//永久素材
//定义请求地址
if (type === 'news') {
//上传图文消息
options.url = `${api.permanent.uploadNews}access_token=${data.access_token}`;
options.body = material;
options.formData = null;
} else if (type === 'pic') {
//上传图片
options.url = `${api.permanent.uploadImg}access_token=${data.access_token}`;
} else {
//其他媒体素材
options.url = `${api.permanent.uploadOthers}access_token=${data.access_token}&type=${type}`;
//视频素材
if (type === 'video') {
options.body = body;
}
}
}else{
//临时素材
//定义请求地址
options.url = `${api.temporary.upload}access_token=${data.access_token}`;
}
//发送请求
const result = await rp(options);
//将返回值返回出去
resolve(result)
} catch (e) {
reject('uploadPermanentMaterial方法出了问题' + e);
}
})
}