如何实现公众号自动回复或做一个短视频无水印解析机器人? 0 次阅读

起因

前几天一个朋友问我,能不能让公众号自动回复用户发的抖音链接,把视频或者图集解析出来。我看了看微信后台的自动回复,只能匹配固定的关键词,搞不定这种动态链接api接口的方式。想了想,干脆自己动手写一个。

考虑到成本,我不想买服务器。Vercel 的免费额度对于这种小场景足够用了,而且域名不用备案,只要自己有域名解析过去就行。整个过程折腾了差不多两天,踩了不少坑,记录下来当个笔记。

整体思路

微信公众号收到用户消息后,会向配置的服务器地址推送一条 XML 格式的请求。我们要做的就是:

  1. 验证消息确实来自微信(验证签名)
  2. 解析用户消息内容,提取其中的抖音分享链接
  3. 调用一个现成的解析接口,拿到视频或图片信息
  4. 按照微信要求的 XML 格式回复给用户

听起来不复杂,但细节很多。

第一步:准备 Vercel 项目

注册 Vercel 并登录,用 GitHub 账号授权。新建一个项目,关联一个仓库。我直接在项目根目录下建了 api 文件夹,里面放一个 wx.js 文件。

Vercel 的约定:api/xxx.js 会自动映射成 /api/xxx 路由。后面微信后台填的 URL 就是 https://你的域名/api/wx

第二步:写处理代码

我从开源项目 aiwechat-vercel 得到启发,但根据自己的需求大幅修改了。核心逻辑是:

  • 接收微信 POST 来的 XML
  • 提取 Content 字段
  • 用正则匹配抖音链接(v.douyin.comiesdouyin.com
  • 请求 http://api.hzv5.cn/dysp.php?url=... 解析
  • 把解析结果拼成文本,包装成 XML 回复

踩的第一个坑:Vercel 的 req.body 默认是 Buffer,不是字符串,需要手动转。而且微信的 XML 里用了 CDATA,解析时要注意换行和空格。我一开始用正则死活匹配不到内容,后来写了一个简单的 extractTag 函数,同时支持普通标签和 CDATA。

1
2
3
4
5
6
7
8
function extractTag(xml, tag) {
const cdataRegex = new RegExp(`<${tag}><\\!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${tag}>`);
let match = xml.match(cdataRegex);
if (match) return match[1];
const textRegex = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`);
match = xml.match(textRegex);
return match ? match[1].trim() : '';
}

第三步:配置环境变量

微信验证需要一个 Token,自己随便设一个字符串。在 Vercel 项目里,进入 Settings -> Environment Variables,添加 WX_TOKEN,值就是你自己定的那个。

注意:不要被那个“Create Pre-production Environment”误导了,那东西不是用来添加环境变量的。我一开始也在那上面浪费了不少时间。

添加完变量后必须 Redeploy,否则不生效。

第四步:配置微信公众号

登录公众平台,进入“设置与开发” -> “基本配置” -> “服务器配置”。

· URL: https://你的域名/api/wx
· Token: 和上面 WX_TOKEN 保持一致
· 消息加解密方式: 选“明文模式”(省事)

提交后如果 Token 匹配且代码没有问题,就会验证通过。

第五步:各种坑

坑一:收不到消息,日志显示“非文本消息”

我发送了好几条 test,Vercel 日志里却显示“非文本消息,忽略”。排查了很久,发现是 extractTag 没有拿到 MsgType,导致代码认为不是文本消息。

后来在代码里加了很多 console.log,把原始 XML 打印出来,才发现微信发的 XML 里 Content 标签前后有换行,正则没考虑到。改了正则就好了。

坑二:解析出来的链接太长,多图回复失败

抖音图文集的图片链接非常长,一个链接就有两百多个字符。如果图集有七八张图,加上作者、标题、点赞数,总长度很容易超过微信的限制(大概是 2048 字节)。结果是用户那边收不到任何回复,或者看到乱七八糟的“查看图片.0”之类的东西。

解决方案:生成回复前计算字节长度,如果超过限制就动态减少图片数量,直到满足要求,并在末尾提示“仅显示前 N 张,共 M 张”。同时把标题截断到 15 个字符,作者、标题、点赞合并成一行,图片显示为“图1”“图2”这种简短的链接文字,点击即可跳转。这样既节省篇幅又不影响使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function buildFullReply(data) {
// 构建头部
const header = `${author} | ${shortenTitle(title)} | ❤️${like}`;
// 尝试加入所有图片,超长则逐步减少
while (currentUrls.length > 0) {
const testText = lines.concat(currentUrls.map((url, idx) => `<a href="${url}">图${idx+1}</a>`)).join('\n');
if (Buffer.byteLength(testText, 'utf8') <= MAX_BYTES) {
replyText = testText;
break;
}
currentUrls.pop();
}
// 如果图片被截断,追加提示
if (currentUrls.length < totalNum) {
replyText += `\n(仅显示前${currentUrls.length}张,共${totalNum}张)`;
}
return replyText;
}

坑三:非抖音消息也会回复提示

一开始的逻辑是:如果用户发的消息不包含抖音链接,就回复“请发送抖音分享链接”。后来觉得这样挺烦的,用户随便说句话都要被怼一下。于是改成:没有抖音链接就直接返回 success,不回复任何内容。静默忽略,体验好多了。

坑四:自定义菜单不见了

启用服务器配置之后,公众号后台的自定义菜单和自动回复会被自动停用。这是微信的设计:开发者模式和后端配置互斥。

本来想通过 API 重新创建菜单,但发现个人未认证的订阅号根本没有调用菜单接口的权限。调用就返回 48001 错误。

折腾了一圈发现没戏。最后我选择不搞菜单了,反正核心的解析功能还能用。如果实在想要菜单,要么暂停服务器配置(但解析功能就没了),要么去微信认证(个人号认证门槛不低,而且花钱)。我选择了接受现实。

坑五:创建菜单接口访问失败,报找不到 node-fetch

后来想单独写一个创建菜单的接口放在 Vercel,访问时 500 错误,日志显示缺少 node-fetch 模块。其实 Vercel 的 Node.js 运行时版本是 18+,已经原生支持 fetch,根本不需要这个依赖。删掉 require(‘node-fetch’) 就好了。

再后来调通之后又遇到 IP 白名单问题,加了白名单又遇到 48001…… 最终还是因为权限问题放弃。个人号就是个人号,认了。

最终稳定版代码

我把最终能用的 api/wx.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
const crypto = require('crypto');

const TOKEN = process.env.WX_TOKEN;
const API_URL = 'http://api.hzv5.cn/dysp.php';
const MAX_BYTES = 2000;

function getRawBodyFromReq(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', chunk => { data += chunk; });
req.on('end', () => { resolve(data); });
req.on('error', reject);
});
}

function checkSignature(signature, timestamp, nonce) {
const arr = [TOKEN, timestamp, nonce].sort();
const sha1 = crypto.createHash('sha1').update(arr.join('')).digest('hex');
return sha1 === signature;
}

function extractTag(xml, tag) {
const cdataRegex = new RegExp(`<${tag}><\\!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${tag}>`);
let match = xml.match(cdataRegex);
if (match) return match[1];
const textRegex = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`);
match = xml.match(textRegex);
return match ? match[1].trim() : '';
}

function extractDouyinLink(text) {
if (!text) return null;
const regex = /https?:\/\/(v\.douyin\.com|iesdouyin\.com)\/[a-zA-Z0-9_-]+\/?/;
const match = text.match(regex);
return match ? match[0] : null;
}

async function parseDouyin(shareUrl) {
const url = `${API_URL}?url=${encodeURIComponent(shareUrl)}`;
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.code !== 200) throw new Error(data.msg || '解析失败');
return data.data;
}

function extractImageUrls(urlField) {
if (!urlField) return [];
if (Array.isArray(urlField)) return urlField;
if (typeof urlField === 'object') return Object.values(urlField);
return [urlField];
}

function shortenTitle(title, maxLen = 15) {
if (!title) return '无标题';
if (title.length <= maxLen) return title;
return title.substring(0, maxLen) + '…';
}

function buildFullReply(data) {
const type = data.type;
const author = data.author || '未知';
const title = shortenTitle(data.title);
const like = data.like || 0;
const header = `${author} | ${title} | ❤️${like}`;
const lines = [header];

if (type === '视频') {
lines.push(`<a href="${data.url}">▶ 观看视频</a>`);
return lines.join('\n');
}

if (type !== '图文') {
lines.push(`未知类型:${JSON.stringify(data).substring(0, 100)}`);
return lines.join('\n');
}

const allUrls = extractImageUrls(data.url);
const totalNum = data.num || allUrls.length;
lines.push(`📷 共${totalNum}张图`);

let currentUrls = [...allUrls];
let replyText = '';

while (currentUrls.length > 0) {
const testLines = [...lines];
currentUrls.forEach((url, idx) => {
testLines.push(`<a href="${url}">图${idx+1}</a>`);
});
const testText = testLines.join('\n');
const byteLength = Buffer.byteLength(testText, 'utf8');
if (byteLength <= MAX_BYTES) {
replyText = testText;
break;
} else {
if (currentUrls.length === 1) {
replyText = testText;
break;
}
currentUrls.pop();
}
}

if (replyText && currentUrls.length < allUrls.length) {
replyText += `\n(仅显示前${currentUrls.length}张,共${totalNum}张)`;
}
return replyText || (lines.join('\n') + '\n(无法生成回复)');
}

function buildReply(toUser, fromUser, content) {
const timestamp = Math.floor(Date.now() / 1000);
return `<xml>
<ToUserName><![CDATA[${toUser}]]></ToUserName>
<FromUserName><![CDATA[${fromUser}]]></FromUserName>
<CreateTime>${timestamp}</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[${content}]]></Content>
</xml>`;
}

module.exports = async (req, res) => {
if (req.method === 'GET') {
const { signature, timestamp, nonce, echostr } = req.query;
if (checkSignature(signature, timestamp, nonce)) {
return res.status(200).send(echostr);
}
return res.status(401).send('Invalid signature');
}

if (req.method === 'POST') {
try {
const rawXml = await getRawBodyFromReq(req);
const fromUser = extractTag(rawXml, 'FromUserName');
const toUser = extractTag(rawXml, 'ToUserName');
const content = extractTag(rawXml, 'Content');

if (!content) return res.status(200).send('success');

const douyinUrl = extractDouyinLink(content);
if (!douyinUrl) return res.status(200).send('success');

const parsed = await parseDouyin(douyinUrl);
const replyText = buildFullReply(parsed);
const replyXml = buildReply(fromUser, toUser, replyText);
res.setHeader('Content-Type', 'application/xml');
return res.status(200).send(replyXml);
} catch (err) {
console.error(err);
return res.status(200).send('success');
}
}

res.status(405).send('Method Not Allowed');
};

总结

用 Vercel 搭公众号机器人,优点是免费、不用折腾服务器、域名免备案。缺点是 Vercel 函数执行时间只有 10 秒,但解析抖音通常一两秒就够了。

另外,个人订阅号的权限限制是个硬伤。如果你只想做一个简单的消息自动回复,那没问题;但如果你想要自定义菜单、客服消息、网页授权等高级功能,最好老老实实搞个认证的服务号,或者用测试号体验。

这次折腾下来,最深的体会是:别小看微信的 XML 解析,也别高估个人号的权限。先把最简单的跑通,再一点点加功能,遇到问题耐心看日志,总能解决的。

希望这篇文章能帮到也想折腾公众号机器人的朋友。如果你也遇到类似的坑,欢迎交流。

下一篇 以后在手机上用 Termux 和 GitHub Actions 更新 Hexo 博客
感谢您的支持!
微信赞赏码 微信赞赏
支付宝赞赏码 支付宝赞赏