惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

H
Help Net Security
J
Java Code Geeks
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
H
Hackread – Cybersecurity News, Data Breaches, AI and More
V
Visual Studio Blog
G
Google Developers Blog
V
V2EX
The Register - Security
The Register - Security
博客园 - 三生石上(FineUI控件)
云风的 BLOG
云风的 BLOG
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
博客园_首页
S
SegmentFault 最新的问题
博客园 - Franky
Martin Fowler
Martin Fowler
Stack Overflow Blog
Stack Overflow Blog
A
About on SuperTechFans
人人都是产品经理
人人都是产品经理
aimingoo的专栏
aimingoo的专栏
罗磊的独立博客
C
Check Point Blog
MyScale Blog
MyScale Blog
T
The Blog of Author Tim Ferriss
MongoDB | Blog
MongoDB | Blog
The GitHub Blog
The GitHub Blog
Last Week in AI
Last Week in AI
Microsoft Azure Blog
Microsoft Azure Blog
IT之家
IT之家
F
Fortinet All Blogs
Jina AI
Jina AI
P
Proofpoint News Feed
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
阮一峰的网络日志
阮一峰的网络日志
B
Blog
L
LangChain Blog
月光博客
月光博客
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
宝玉的分享
宝玉的分享
博客园 - 【当耐特】
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
Microsoft Security Blog
Microsoft Security Blog
WordPress大学
WordPress大学
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
B
Blog RSS Feed
博客园 - 聂微东
Hugging Face - Blog
Hugging Face - Blog
M
MIT News - Artificial intelligence
GbyAI
GbyAI

吖远zzyの博客

解决华为开发者工具 DevEco Studio 登录跳转 localhost:10101 端口被阻止问题 | 吖远zzyの博客 微信小程序个人资质审核血泪史:踩坑,从反复被拒到神奇过审 | 吖远zzyの博客 以后在手机上用 Termux 和 GitHub Actions 更新 Hexo 博客 | 吖远zzyの博客 必备!这款万能小程序能给抖音/豆包视频图集去除水印、还有有趣的测反应、玩成语模拟工具。 自制的一些免费API接口第二弹(获取QQ昵称、头像、抖音/豆包视频去水印等...) 告别原生导航栏:微信小程序自定义导航栏完美适配方案 在安卓手机上运行OpenClaw?当然可以,用Termux折腾OpenClaw安装QQ机器人的踩坑记录... | 吖远zzyの博客 个人开发工具之抖音直播录制工具:一款功能强大的Android直播录制应用 UniApp中Canvas绘图的易错点与踩坑指南 QQ频道机器人与UniApp开发:常见踩坑点与解决方案 UniApp中Canvas绘图不显示的常见问题与解决方案 Hexo博客实现随机文章功能的完整教程 QQ频道机器人Android客户端使用指南 | 吖远zzyの博客 安卓版QQ频道机器人APP客户端插件开发指南 | 吖远zzyの博客 2024年AI编程助手深度评测:哪款最适合你? uni-app地图定位踩坑记:地图功能和定位的那些坑 uni-app文件上传踩坑记:图片处理和上传全攻略 uni-app表单验证踩坑记:这些坑我替你踩过了 uni-app开发踩坑记录:新手必看的常见问题与解决方案 uni-app安全防护指南:构建可靠的跨端应用 uni-app性能优化指南:从加载到渲染的全方位提升 uni-app动画效果实战:从基础到高级的动画实现指南 uni-app组件开发实战:从基础到进阶的最佳实践 uni-app网络请求与缓存策略:构建高效的数据层 uni-app状态管理进阶:Vuex最佳实践与性能优化 uni-app路由与页面跳转:最佳实践与踩坑指南
如何实现公众号自动回复或做一个短视频无水印解析机器人? | 吖远zzyの博客
吖远zzy · 2026-06-06 · via 吖远zzyの博客
  1. 1. 起因
  2. 2. 整体思路
  3. 3. 第一步:准备 Vercel 项目
  4. 4. 第二步:写处理代码
  5. 5. 第三步:配置环境变量
  6. 6. 第四步:配置微信公众号
  7. 7. 第五步:各种坑
    1. 7.0.1. 坑一:收不到消息,日志显示“非文本消息”
    2. 7.0.2. 坑二:解析出来的链接太长,多图回复失败
    3. 7.0.3. 坑三:非抖音消息也会回复提示
    4. 7.0.4. 坑四:自定义菜单不见了
    5. 7.0.5. 坑五:创建菜单接口访问失败,报找不到 node-fetch
  • 8. 最终稳定版代码
  • 9. 总结
  • 如何实现公众号自动回复或做一个短视频无水印解析机器人? 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 解析,也别高估个人号的权限。先把最简单的跑通,再一点点加功能,遇到问题耐心看日志,总能解决的。

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