使用Puppeteer制作微信消息通知机器人

微信机器人是一个比较难搞的东西,因为微信登录比较麻烦,而且官方不仅不提供API,还积极封杀机器人和“非法登录”的途径,导致研发风险较大。目前比较成熟的两种方式是模拟微信网页版以及程序控制微信PC版应用。

本文采用Puppeteer框架,借助真实的浏览器访问微信网页版,模拟浏览器的正常操作,以降低被封杀的风险。

大致思路

  • 使用Express框架提供对外接口,让其他程序能够调用。
  • 使用Puppeteer来访问微信网页版。由于微信网页版需要扫码登录,需要设计接口来暴露二维码(这里直接提供网页截图)。另外二维码会过期,因此需要刷新机制(本文程序的话,重启就行,不需要再另行实现)。
  • 通过操作DOM来判断页面的状态。
  • 通过模拟按键来寻找好友或群组、输入消息和发送消息。
  • 通过捕获浏览器AJAX请求来获取新消息内容(本文未实现)。
  • 实现敏感词和敏感言论过滤机制,以防无意或有意的攻击。

代码

详细参见https://github.com/infnan/SimpleNotifyBot

开启浏览器:

// 开启无头浏览器
const browser = await puppeteer.launch({
    headless: true,
});

const page = (await browser.pages())[0];                 // 取浏览器第一个Tab页
await page.setViewport({ width: 1366, height: 768 });    // 浏览器窗口大小

// 使用简体中文界面
await page.goto('https://wx.qq.com/?lang=zh_CN');

发送消息的过程:

const sendMessage = async (target, message) => {
    // 判断是否登录
    const unloginTest = await page.$('body.unlogin');
    if (unloginTest) {
        throw new MessageError('Not login', 'NOLOGIN');
    }

    if (!target) {
        throw new MessageError('Target not found', 'NOTARGET');
    }

    // 如果当前聊天就是目标,那么不用搜了,直接蹦到聊天框
    const testEle1 = await page.$('#chatArea a.title_name');
    const test1 = await (await testEle1.getProperty('textContent')).jsonValue();
    
    if (test1 !== target) {
        const searchEle = await page.$('#search_bar > input');

        // 清空搜索框和搜索结果
        await page.$eval('#search_bar input', node => node.value = '');
        await searchEle.focus();
        await searchEle.type(' ');
        await searchEle.press('Backspace');
        // 延时,使页面上原有的搜索结果消失
        for (let timeout = 40; timeout >= 0; timeout--) {
            const testEle2 = await page.$('#search_bar div.mmpop');
            if (!testEle2) {
                break;
            }
            await sleep(50);
        }

        // 输入目标群组名称
        await searchEle.type(target);

        // 等待出现搜索结果,最长等待5秒
        let ok = false;
        for (let timeout = 100; timeout >= 0; timeout--) {
            const testEle3 = await page.$('#search_bar div.mmpop h4.contact_title');
            if (testEle3) {
                const test3 = await (await testEle3.getProperty('textContent')).jsonValue();
                if (test3 === '找不到匹配的结果') {
                    throw new MessageError('Target not found', 'NOTARGET');
                } else {
                    ok = true;
                    break;
                }
            }
            await sleep(50);
        }
        if (!ok) {
            throw new MessageError('WeChat not responding', 'NORESPONSE');
        }

        // 遍历搜索结果
        // 由于overflow数字不大,且翻页需要消耗操作和等待网络请求的时间,建议目标名称独一无二,免得不好找。
        const pop = await page.$('#search_bar div.mmpop');
        let lastname = '';
        ok = false;
        for (let overflow = 100; overflow >= 0; overflow--) {
            const nowEle = await pop.$('div.contact_item.on');
            // 说明正在loading
            if (!nowEle) {
                await sleep(50);
                continue;
            }
            let currname = await (await (await nowEle.$('h4')).getProperty('textContent')).jsonValue();
            if (lastname === currname) {
                // 未找到目标,结束
                ok = false;
                break;
            }
            lastname = currname;

            // 如果没找到而且能往下翻那么就继续往下翻
            // 找到的话按一下回车键,进入聊天界面
            if (currname === target) {
                ok = true;
                await searchEle.press('Enter');
                break;
            } else {
                await searchEle.press('ArrowDown');

                // 等待微信响应
                for (let timeout = 10; timeout >= 0; timeout--) {
                    const nowEle2 = await pop.$('div.contact_item.on');
                    if (nowEle2) {
                        let currname2 = await (await (await nowEle2.$('h4')).getProperty('textContent')).jsonValue();
                        if (currname !== currname2) {
                            break;
                        }
                        await sleep(20);
                    } else {
                        // 暂时到底了,需要loading
                        await sleep(200);
                    }
                }
            }
        }
        if (!ok) {
            throw new MessageError('Target not found', 'NOTARGET');
        }

        // 等待进入聊天界面
        for (let timeout = 50; timeout >= 0; timeout--) {
            const titleEle = await page.$('#chatArea a.title_name');
            const title = await (await titleEle.getProperty('textContent')).jsonValue();
            if (title === target) {
                break;
            }
            await sleep(20);
        }
    }

    const testEle4 = await page.$('#chatArea a.title_name');
    const test4 = await (await testEle4.getProperty('textContent')).jsonValue();
    if (test4 === target) {
        // 输入消息
        await page.$eval('#editArea', node => node.textContent = '');

        const editEle = await page.$('#editArea');
        await editEle.focus();
        for (const [i, line] of message.split('\n').entries()) {
            if (i > 0) {
                // 发送多行消息时需要用 Ctrl+Enter 换行
                await page.keyboard.down('Control');
                await page.keyboard.press('Enter');
                await page.keyboard.up('Control');
            }
            await editEle.type(line);
        }

        // 按下发送按钮
        await page.keyboard.press('Enter');
    } else {
        throw new MessageError('Target not confirmed', 'NORESPONSE');
    }
};

保号注意事项

为确保安全,使用机器人时需要多加注意,以免封号甚至招致牢狱之灾。以下皆为网友经验,仅供参考。

注册

  • 使用真实手机注册,避免用模拟器或双开软件。
    • 使用模拟器的话需要先用xprivacy控制好微信的权限,否则会无法登录或被微信封禁。
    • 使用双开之前先调查靠不靠谱,例如有些双开会被微信识别,导致账号被封,而小米手机的双开就比较安全。
  • 使用真实手机号注册,并进行实名认证,然后绑定一张银行卡,再往微信钱包里头存一块钱。
  • 手机和手机号尽量专用,一个设备或手机号不要拿着注册很多微信号,也不要拿着频繁登录注销。
  • 新注册的账号要在真实的手机上挂15至30天,然后再进行其他操作,以免让系统“大数据”识别。
  • 不要忘记设置昵称、地区和头像。
  • 至少保持三个真实好友。
  • 一天之内不要加太多好友。
  • 好友不要超过5000。

发送消息

  • 注意消息发送频率不要太高。几秒钟就发一大堆消息(例如像脸滚键盘那样),这样很容易被封号。
  • 不定期往“filehelper”或专用群发送keepalive消息,以防掉线。
  • 注意设计监控和报警机制,掉线之后能及时去恢复连接。
  • 要特别注意控制消息发送内容!尤其是接受用户输入的程序,一定要做好言论控制,以免他人无意或有意触发政治敏感话题,导致你的账号被封,甚至让你被警方请去喝茶。