☰
QQ经典农场协议逆向与Node.js自动化脚本实战
2026/10/4 7:03:46 网站建设 项目流程

简介:这是一份面向Node.js开发者与游戏自动化爱好者的QQ经典农场挂机脚本项目,聚焦QQ与微信双平台小程序环境下的全自动农场管理。项目核心在于对WebSocket通信协议的深度逆向分析,涵盖握手过程、帧结构与状态码解析,并借助Node.js非阻塞I/O特性实现与游戏服务器的高效实时交互,模拟用户行为完成数据包收发。资源包共37个文件,约122KB,包含15个js脚本(登录、任务、仓库、好友、网络等模块)、10个proto协议定义文件(对应种植、商店、访问、通知等游戏指令)、6个json配置(作物、道具、等级、种子商店数据)以及说明文档与许可证,结构清晰便于二次开发。目前已有6257人学习下载。读者可获得完整的协议逆向思路、模块化脚本代码与配置数据,理解自动化挂机的实现原理与排错方法,同时需注意遵守游戏服务条款,谨慎使用。

1. 从「手动收菜」到协议级自动化:QQ经典农场挂机脚本到底在做什么

凌晨三点定闹钟爬起来收菜,这种日子我过了整整两周。后来实在扛不住,开始琢磨能不能让程序替我盯着——不是模拟点击那种笨办法,而是直接跟服务器对话。QQ经典农场跑在微信和QQ小程序里,前端跟后端之间靠 WebSocket 长连接传数据,每一次播种、浇水、收获、偷菜,本质上都是一条结构化的二进制消息。只要能把这条链路搞清楚,自动化就是顺理成章的事。

这个方向适合两类人:一是想学 Node.js 做长连接客户端开发的后端新手,农场协议足够简单,是个很好的练手靶子;二是对小程序通信机制好奇、想搞明白「小程序抓包到底能抓到什么」的工程师。核心思路就三步:用 Charles 或类似工具抓包,逆向出 ProtocolB 的消息结构,然后用 Node.js 的 ws 库复刻一个能收发指令的客户端。整条链路不复杂,但坑很密,下面把我踩过的路一条条铺开。

2. 抓包与协议还原:从 Charles 到 ProtocolB 消息结构

2.1 小程序抓包环境的搭建与证书信任

微信小程序的网络请求默认不走系统代理,直接抓会一片空白。常见做法是在 PC 端开 Charles,手机 WiFi 手动设代理指向 PC 的 IP 和 Charles 监听端口(默认 8888),然后在手机浏览器访问chls.pro/ssl下载证书并安装。iOS 还需要在「设置 → 通用 → 关于本机 → 证书信任设置」里手动开启完全信任,这一步漏了的话 Charles 里只能看到 CONNECT 请求,看不到明文。

Android 7.0 以后用户证书不被系统信任,小程序会直接拒绝连接。我一般用两种方案绕过:一是用 Root 后的设备把 Charles 证书塞进系统证书目录;二是直接用一台 Android 7 以下的旧手机,省去折腾。如果手头只有高版本 Android,也可以用模拟器配合 Xposed 模块做证书固定绕过,但那是另一个话题了。

环境通了之后,打开 QQ经典农场小程序,在 Charles 里过滤ws://或wss://开头的连接。你会看到一条长期保持的 WebSocket 会话,消息以二进制帧为主,偶尔夹杂文本帧做心跳。这就是我们要逆向的主战场。

2.2 ProtocolB 二进制帧的字段拆解方法

ProtocolB 不是某个公开标准,而是这套小程序自己定义的一套二进制序列化格式。抓到的帧长这样(十六进制):

00 00 00 1a 00 00 00 03 0a 05 31 30 30 30 31 12 04 ...

前 4 字节是总长度(大端),接着 4 字节是消息类型(cmd id),后面是 Protobuf 编码的 payload。判断依据是 payload 里出现了 Protobuf 典型的0a(field 1, wire type 2)和12(field 2, wire type 2)标签。如果你对 Protobuf 的 wire type 不熟,记住:0a后面跟长度再跟字符串,通常就是第一个 string 字段。

还原字段靠对比法:在游戏里做一个操作,抓一条帧;做另一个操作,再抓一条。把两条帧的 payload 做十六进制 diff,变化的位置就是该操作携带的参数。比如「播种」和「收获」的 cmd id 不同,但 payload 里都有地块编号字段,只是值不一样。反复几次就能把常用操作的 cmd id 和字段布局摸清楚。

我一般会建一张对照表,左边是操作名,右边是 cmd id 和 payload 结构。这张表就是后面写脚本的「字典」。

操作cmd id (hex)payload 关键字段字段类型
登录/握手0x00000001token, platformstring, int32
查询农场0x00000003uidint64
播种0x00000010plot_id, seed_idint32, int32
浇水0x00000011plot_idint32
收获0x00000012plot_idint32
偷菜0x00000020target_uid, plot_idint64, int32
心跳0x0000007ftimestampint64

注意:cmd id 和字段编号会随小程序版本更新变化,这张表只是我抓到的某个版本的快照,你复现时必须以自己抓到的为准。

2.3 用 Node.js 解析二进制帧的最小代码

抓包只是第一步,真正要自动化,得让 Node.js 能读懂这些帧。下面是一段解析帧头和 Protobuf payload 的最小实现:

const protobuf = require('protobufjs'); // 假设已经从抓包中还原出 .proto 定义并加载 const root = await protobuf.load('farm.proto'); const FarmMessage = root.lookupType('farm.FarmMessage'); /** * 解析一条完整的 ProtocolB 帧 * @param {Buffer} frame - 从 WebSocket 收到的原始二进制数据 * @returns {{cmdId: number, payload: object}} */ function parseFrame(frame) { // 前 4 字节:总长度(大端),用于校验 const totalLen = frame.readUInt32BE(0); if (totalLen !== frame.length) { throw new Error(`帧长度不匹配: 头部声明 ${totalLen}, 实际 ${frame.length}`); } // 第 5-8 字节:cmd id const cmdId = frame.readUInt32BE(4); // 第 9 字节开始:Protobuf payload const payloadBuf = frame.slice(8); const message = FarmMessage.decode(payloadBuf); return { cmdId, payload: FarmMessage.toObject(message) }; } // 使用示例 const rawFrame = Buffer.from('0000001a000000030a0531303030311204...', 'hex'); const { cmdId, payload } = parseFrame(rawFrame); console.log('cmd:', cmdId.toString(16), 'payload:', payload);

这段代码的逻辑很直白:先读长度做完整性校验,再读 cmd id 决定这条消息是什么操作,最后把剩余字节交给 Protobuf 解码器。readUInt32BE的BE表示大端序,如果你抓到的帧是小端,换成readUInt32LE即可。FarmMessage是我根据抓包结果反推的 Protobuf message 名,你需要根据自己的.proto文件调整。

参数方面,frame.slice(8)的偏移量 8 来自「4 字节长度 + 4 字节 cmd id」的固定头。如果后续发现还有额外的校验位或版本号,偏移量要相应调整。解码失败时先检查.proto定义是否和当前小程序版本匹配,最常见的原因是字段编号对不上。

3. WebSocket 长连接客户端:心跳、重连与消息队列

3.1 用 ws 库建立连接并完成握手

Node.js 里操作 WebSocket 最顺手的库是ws,没有之一。安装就一行:

npm install ws

连接代码本身不复杂,但小程序的 WebSocket 服务端通常要求先发一条登录/握手消息,验证通过后才允许后续操作。下面是我常用的连接模板:

const WebSocket = require('ws'); const WS_URL = 'wss://farm.example.com/ws'; // 替换为你抓到的实际地址 const TOKEN = 'your_token_here'; // 从抓包中提取的登录凭证 const ws = new WebSocket(WS_URL, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)', 'Origin': 'https://servicewechat.com' } }); ws.on('open', () => { console.log('连接已建立,发送握手消息'); // 构造握手帧:cmd id = 0x01,payload 含 token 和平台标识 const handshake = buildFrame(0x01, { token: TOKEN, platform: 2 }); ws.send(handshake); }); ws.on('message', (data) => { const { cmdId, payload } = parseFrame(data); console.log('收到消息 cmd:', cmdId.toString(16), payload); // 根据 cmdId 分发到不同的处理函数 handleMessage(cmdId, payload); }); ws.on('error', (err) => { console.error('连接出错:', err.message); }); ws.on('close', (code, reason) => { console.warn(`连接关闭 code=${code} reason=${reason}`); // 触发重连逻辑 scheduleReconnect(); });

headers里的Origin很关键。小程序的 WebSocket 服务端会校验来源,如果 Origin 不对,服务端可能在握手阶段就直接拒绝。我一般直接从 Charles 抓到的请求头里复制,不要自己编。

buildFrame是parseFrame的逆操作:先算 payload 的 Protobuf 编码长度,拼上 cmd id 和总长度头。实现如下:

function buildFrame(cmdId, payloadObj) { const errMsg = FarmMessage.verify(payloadObj); if (errMsg) throw new Error(`payload 校验失败: ${errMsg}`); const payloadBuf = FarmMessage.encode(FarmMessage.create(payloadObj)).finish(); const header = Buffer.alloc(8); header.writeUInt32BE(8 + payloadBuf.length, 0); // 总长度 header.writeUInt32BE(cmdId, 4); // cmd id return Buffer.concat([header, payloadBuf]); }

3.2 心跳机制:为什么 30 秒是常见阈值

WebSocket 连接闲置太久会被中间层(负载均衡、Nginx、服务端自身)断开。小程序的 WebSocket 服务端一般要求客户端每隔一段时间发一次心跳,超时未收到就主动断连。我抓到的几个版本里,心跳间隔普遍在 25 到 30 秒之间,服务端超时阈值大约是心跳间隔的 2 倍。

心跳实现有两种方式:一是用setInterval定时发;二是用setTimeout递归,每次收到服务端心跳响应后再安排下一次。我更推荐第二种,因为setInterval在事件循环阻塞时可能堆积,而递归方式能保证「上一次完成后再安排下一次」。

let heartbeatTimer = null; const HEARTBEAT_INTERVAL = 25000; // 25 秒,留 5 秒余量 function startHeartbeat() { stopHeartbeat(); heartbeatTimer = setTimeout(() => { if (ws.readyState === WebSocket.OPEN) { const hb = buildFrame(0x7f, { timestamp: Date.now() }); ws.send(hb); console.log('心跳已发送'); } startHeartbeat(); // 递归安排下一次 }, HEARTBEAT_INTERVAL); } function stopHeartbeat() { if (heartbeatTimer) { clearTimeout(heartbeatTimer); heartbeatTimer = null; } }

在ws.on('open')里调用startHeartbeat(),在ws.on('close')里调用stopHeartbeat()。如果服务端有心跳响应消息(通常是同一个 cmd id 返回),可以在handleMessage里重置一个「最后收到消息时间」的变量,超过 60 秒没收到任何消息就主动重连,这样比单纯依赖心跳发送更可靠。

3.3 断线重连与消息队列的配合

网络抖动、服务端重启、token 过期都会导致断连。重连逻辑要解决两个问题:一是重连频率不能太高,否则可能被服务端封 IP;二是重连期间产生的操作请求不能丢。

我一般用指数退避做重连间隔:第一次 1 秒,第二次 2 秒,第三次 4 秒,上限 30 秒。同时维护一个发送队列,连接断开时新请求入队,连接恢复后按顺序补发。

let reconnectDelay = 1000; const MAX_DELAY = 30000; const sendQueue = []; function scheduleReconnect() { setTimeout(() => { console.log(`尝试重连,延迟 ${reconnectDelay}ms`); connect(); // 重新执行连接逻辑 reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY); }, reconnectDelay); } function safeSend(frame) { if (ws.readyState === WebSocket.OPEN) { ws.send(frame); } else { console.warn('连接未就绪,消息入队'); sendQueue.push(frame); } } // 在 ws.on('open') 里补发队列 function flushQueue() { while (sendQueue.length > 0 && ws.readyState === WebSocket.OPEN) { const frame = sendQueue.shift(); ws.send(frame); } }

重连成功后记得重置reconnectDelay = 1000,否则下次断连会直接从 30 秒开始等。队列里的消息要注意时效性——比如「收获」操作如果延迟太久,作物可能已经被别人偷了,所以入队时最好带一个过期时间,超过 10 秒的直接丢弃并记录日志。

4. 自动化调度与操作序列编排

4.1 农场状态轮询:什么时候查、查什么

自动化的前提是知道当前农场状态:哪些地块可以收获、哪些缺水、哪些有杂草。常见做法是每隔一段时间发一条「查询农场」消息(cmd id 0x03),拿到所有地块的状态列表,然后根据状态决定下一步操作。

轮询间隔不能太短,否则请求量太大容易被风控;也不能太长,否则作物成熟了没及时收。我实测下来 15 到 30 秒比较合适,具体取决于你种什么作物。如果种的是短周期作物(比如萝卜),间隔要短一些;种的是长周期作物(比如人参果),间隔可以放到 60 秒。

查询返回的数据结构大致是这样:

// 假设 handleMessage 里收到 cmd 0x03 的响应 function handleFarmState(payload) { const plots = payload.plots || []; for (const plot of plots) { if (plot.state === 'ripe') { enqueueAction('harvest', { plot_id: plot.id }); } else if (plot.state === 'dry') { enqueueAction('water', { plot_id: plot.id }); } else if (plot.state === 'weed') { enqueueAction('weed', { plot_id: plot.id }); } } }

plot.state的取值是我从抓包中归纳的,实际可能是数字枚举(比如 1=空地, 2=生长中, 3=成熟, 4=缺水, 5=有草)。你需要根据自己抓到的数据做映射。

4.2 操作序列的编排与节流

收获、播种、浇水这些操作不能一瞬间全发出去,服务端有频率限制。我一般用令牌桶做节流:每秒最多发 3 条操作消息,桶容量 5。超出的请求排队等待。

class TokenBucket { constructor(rate, capacity) { this.rate = rate; // 每秒补充的令牌数 this.capacity = capacity; // 桶容量 this.tokens = capacity; this.lastRefill = Date.now(); } tryConsume() { this.refill(); if (this.tokens >= 1) { this.tokens -= 1; return true; } return false; } refill() { const now = Date.now(); const elapsed = (now - this.lastRefill) / 1000; this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.rate); this.lastRefill = now; } } const bucket = new TokenBucket(3, 5); async function enqueueAction(type, params) { while (!bucket.tryConsume()) { await sleep(200); // 等待令牌补充 } const cmdMap = { harvest: 0x12, water: 0x11, weed: 0x13, plant: 0x10 }; const frame = buildFrame(cmdMap[type], params); safeSend(frame); }

rate=3表示每秒最多 3 条操作,capacity=5允许短时间突发 5 条。这两个参数要根据服务端的实际限制调整——如果你发现操作经常失败并返回「频率过高」的错误码,就把 rate 降到 2 或 1。

4.3 偷菜与防偷:定时任务与优先级

偷菜是农场游戏的核心乐趣之一,也是自动化脚本最能体现价值的地方。偷菜的前提是知道好友列表和每个好友的农场状态。常见做法是:先查好友列表(cmd id 0x21),再逐个查好友农场(cmd id 0x03 带 target_uid),发现有成熟作物就发偷菜请求(cmd id 0x20)。

好友数量多的时候,逐个查询会很慢。我一般用并发控制,同时查 5 个好友,查完一批再查下一批。偷菜请求的优先级要高于自己农场的浇水除草,因为偷菜有时效性,晚一秒可能就被别人偷光了。

const pLimit = require('p-limit'); const limit = pLimit(5); // 最多 5 个并发 async function stealFromFriends(friendList) { const tasks = friendList.map(friend => limit(async () => { const state = await queryFarm(friend.uid); const ripePlots = state.plots.filter(p => p.state === 'ripe'); for (const plot of ripePlots) { await enqueueAction('steal', { target_uid: friend.uid, plot_id: plot.id }); } }) ); await Promise.all(tasks); }

p-limit是个轻量并发控制库,npm install p-limit即可。并发数设 5 是我试出来的平衡点——再高容易触发风控,再低效率跟不上。

5. 避坑与排查:那些让我熬夜的翻车现场

5.1 抓包抓不到 WebSocket 帧

现象:Charles 里能看到 HTTPS 请求,但 WebSocket 连接显示为CONNECT后就没有下文,或者只有一条101 Switching Protocols然后空白。

原因:小程序的 WebSocket 走了独立通道,Charles 默认不解码wss://的二进制帧。另外 iOS 上如果证书没完全信任,TLS 握手会失败,自然看不到帧内容。

解决:在 Charles 的Proxy → SSL Proxying Settings里添加*:443的通配规则,确保所有 HTTPS 流量都被解密。如果还是不行,换用mitmproxy或Fiddler,它们对 WebSocket 帧的展示更友好。Android 用户记得把证书装到系统目录。

5.2 Protobuf 解码报「invalid wire type」

现象:FarmMessage.decode抛出Error: invalid wire type 7 at offset 3之类的错误。

原因:要么是.proto定义和实际协议不匹配(字段编号或类型写错了),要么是帧头偏移量算错了(比如把 cmd id 当成了 payload 的一部分)。

解决:先用console.log(frame.slice(0, 16).toString('hex'))把原始帧的前 16 字节打出来,对照抓包工具里的十六进制视图逐字节核对。确认前 4 字节是不是长度、第 5-8 字节是不是 cmd id。如果帧头结构对了,再检查.proto里的字段编号——Protobuf 的字段编号一旦写错,解码就会乱套。

5.3 心跳发了但连接还是断

现象:日志显示心跳按时发送,但每隔几分钟ws.on('close')还是触发,close code 是 1006。

原因:1006 表示连接异常关闭,通常不是服务端主动断的,而是网络层出了问题。可能是心跳消息的 cmd id 或 payload 格式不对,服务端根本没认;也可能是心跳间隔太长,服务端在收到心跳之前就已经超时断连了。

解决:先把心跳间隔从 25 秒降到 15 秒试试。如果还断,检查心跳消息的 cmd id 是不是 0x7f——有些版本用的是 0x00 或 0x7e。最可靠的办法是抓一条服务端主动发来的心跳请求,照着它的格式回。

5.4 操作返回「频率过高」或「非法请求」

现象:收获、播种等操作偶尔成功,大部分返回错误码,提示频率过高或请求非法。

原因:一是发送速度太快,触发了服务端限流;二是请求里缺少某些校验字段(比如时间戳、签名),服务端认为是伪造请求。

解决:先把令牌桶的 rate 降到 1,确认是不是限流问题。如果降速后仍然报非法请求,说明协议里还有你没还原的字段。回到抓包数据,对比成功和失败的请求帧,找出差异字段。常见的是timestamp和sign,前者是毫秒时间戳,后者可能是 token + 参数的 MD5。

5.5 npm 脚本在 PowerShell 里被禁止运行

现象:Windows 上执行npm install报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。

原因:PowerShell 的默认执行策略是Restricted,不允许运行.ps1脚本。

解决:以管理员身份打开 PowerShell,执行Set-ExecutionPolicy RemoteSigned,然后输入Y确认。或者改用 CMD 而不是 PowerShell 来跑 npm 命令。这个坑跟农场脚本本身无关,但十个人里有八个会撞上。

6. 进阶技巧:用日志回放定位协议变更

小程序每次更新都可能改协议——cmd id 变了、字段编号变了、甚至帧头结构都变了。如果每次都要重新抓包逆向,效率太低。我的做法是:在脚本里把所有收发的原始帧按十六进制写入日志文件,格式是「时间戳 + 方向 + hex」。一旦发现操作失败,先用日志回放工具把最近的帧重新解析一遍,对比新旧协议的差异。

const fs = require('fs'); const logStream = fs.createWriteStream('frames.log', { flags: 'a' }); function logFrame(direction, frame) { const ts = new Date().toISOString(); logStream.write(`${ts} ${direction} ${frame.toString('hex')}\n`); } // 在 ws.on('message') 里调用 logFrame('IN', data) // 在 ws.send 前调用 logFrame('OUT', frame)

回放脚本读日志文件,逐行解析,遇到解码失败的帧就单独拎出来做十六进制 diff。我一般会对比「最后一次成功操作」和「第一次失败操作」之间的帧,差异点往往就是协议变更的位置。

另一个技巧是用protobufjs的Root.fromJSON动态加载.proto定义,这样改协议时只需要更新 JSON 文件,不用改代码。把.proto编译成 JSON 的命令是:

npx pbjs -t json farm.proto > farm.json

然后在代码里:

const root = protobuf.Root.fromJSON(require('./farm.json'));

这样协议变更时,重新生成farm.json即可,主逻辑一行不用动。

最后说个血泪教训:不要在生产环境直接跑新还原的协议。我一般先用一个「只读」模式跑 24 小时——只查询状态、不执行任何写操作,确认解析无误后再开启写操作。这个习惯帮我省了至少三次封号。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询