前端

提出正确的问题,往往等于解决了问题的大半。

引言

对于我来说,总有一些网站经常浏览但并不热衷参与社区讨论,并不经常使用但又有等级墙。通常暂时没什么影响,然而偶尔要用时被拦在墙外却也没有什么好法子。为此我在n8n上配置了几个定时请求,做自动签到,成本不高也很稳定;直到最近,不知是因为产能过剩还是被爬虫爬烦了,这些网站陆陆续续上了人机验证或者csrf-token 之类的检测手段,拉高了请求重放的成本,这块虽然也可以花功夫去反调试代码,但这是一个没有尽头的猫鼠游戏,我也只是自己用,于是便想到了 puppeteer。这是一个无头浏览器,简单来说就是通过代码直接和浏览器进行交互模拟用户的访问行为,通常用于诸如UI自动化测试之类的领域,但也可以说是终极的爬虫方案,毕竟再怎么反爬也至少得上浏览器能正常使用吧,虽然作为对策也有诸如加密字体之类的手段,但也无非是增加成本,只要能呈现截屏上OCR也不是不可以。以前其实也研究过,但一直没有什么实际得应用空间,正好趁这次机会用起来。

思路

因为是自用,不需要追求极致的自动化,可以允许适当的人工干预,所以我准备先手动登录过各种一次性人机校验(大多数网站人机校验并不会丧心病狂的每次进去都有),借助保留登录授权信息的用户文件后续自动访问点登录就好了。为了通用不需要去关注有哪些信息,选择直接保留浏览器的整个用户文件。
总结来说有三步

  1. 手动登录并保存登录信息
  2. 寻找目标网站的签到界面,编写模拟点击的脚本
  3. 后续定期执行
    定期执行可以继续在n8n上进行,只要做成一个web服务对外提供接口就好了。

过程

安装配置略过不表,现在 puppeteer 基本可以做到一键安装。我这里为了使用 puppeteer-extra 限制了 puppeteer 的版本。 直接使用最新版的 puppeteer 后续的流程应该也是可以走的,我主要是图省事。

npm install [email protected] [email protected] [email protected]

手动登录并保存信息

这里主要用到两个参数,一个是 headless ,这个决定 puppeteer 是否启动一个有界面的浏览器,另一个是 userDataDir ,这里指定路径后用户信息会保存,后续再启动时读取这里的信息,自然就会带上登录态。

这块我参考的
Puppeteer自动化的性能优化与执行速度提升 · Issue #69 · biaochenxuying/blog · GitHub
但这篇文章写于2020年,有一些内容现在不太一样,最终简化了下就如下所示

import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';

function startPuppeteer({ headless = false } = {}) {
    const browser = await puppeteer.use(StealthPlugin()).launch({
        args: [
            // `--proxy-server=${PROXY_ADDR}` // 代理服务器
            '--no-first-run',
            '--disable-gpu', // GPU硬件加速
            '--disable-dev-shm-usage', // 创建临时文件共享内存
            '--disable-setuid-sandbox', // uid沙盒
            '-–no-zygote',
            '--no-sandbox'    
        ],
        userDataDir: 'd:\\myUserDataDir\\',
        defaultViewport: { width: 1440, height: 900 },
        // dumpio: true, // 浏览器内核信息
        // devtools: true, // 一启动就打开浏览器调试工具 
        headless    // false 启动浏览器界面 
    });

    return browser;
}


async function main(){
    const browser = await startPuppeteer({ headless: true });

}

main()

执行上述代码应该可以看到一个浏览器被启动,然后去对应网站登录即可。退出执行后对应的文件夹下面应该会有用户信息。

签到脚本

首先打开一个标签页,并开启网络调试(非必须)

function createPage(browser) {
    const page = await browser.newPage();
    const client = await page.target().createCDPSession();
    // 删除无头浏览器的一些特征
    await page.evaluateOnNewDocument(() => {
        Object.defineProperty(navigator, 'webdriver', () => {});
        delete navigator.__proto__.webdriver;
    });
    await page.setDefaultTimeout(180000); //timeout: 3mins

    await client.send('Network.enable');
    // 伪装 userAgent
    await page.setUserAgent(
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'
    );

    return { page, client };
}

关于网络调试,这个非常适合用来判读签到是否成功,后面发现其实也可以使用 waitForResponse
这块可以参考
请求拦截 | Puppeteer 中文网 (nodejs.cn)
Chrome DevTools Protocol - Network domain
Page.waitForResponse() method | Puppeteer (pptr.dev)

function createRequestWatch(client) {
    let requestDetails = new Map();
    let callback = [];

    client.removeAllListeners('Network.requestWillBeSent');
    client.removeAllListeners('Network.responseReceived');

    const handleRequestWillBeSent = params => {
        requestDetails.set(params.requestId, {
            ...params.request
        });
    };

    const handleResponseReceived = params => {
        const request = { ...(requestDetails.get(params.requestId) || {}) };
        for (const fun of callback) {
            if (typeof fun === 'function') {
                fun(params, request);
            }
        }
        setTimeout(() => {
            requestDetails.delete(params.requestId);
        }, 0);
    };

    client.on('Network.requestWillBeSent', handleRequestWillBeSent);
    client.on('Network.responseReceived', handleResponseReceived);

    return {
        on: fun => {
            callback.push(fun);
        },
        off: fun => {
            const index = callback.indexOf(fun);
            if (index !== -1) {
                callback.splice(index, 1);
            }
        },
        clear: () => {
            client.removeAllListeners('Network.requestWillBeSent');
            client.removeAllListeners('Network.responseReceived');
            callback = [];
            requestDetails.clear();
        }
    };
}

以掘金签到为例


async function main(){
    const browser = await startPuppeteer({ headless: true });
    const { page, client } = await createPage(browser);
    
    await page.goto('https://juejin.cn/user/center/signin?from=main_page');
    // 签到按钮的选择器
    await page.waitForSelector('.code-calender .btn');
    const requestWatch = createRequestWatch(client);
    const result = {
            message: ''
        };
        
   if (!signin) {
        result.message = '未获取到签到按钮'
        return result;
    } else {
        // 模拟点击
        signin.click();
        // 等待结果
        await new Promise(res => {
            // 超时
            let timer = setTimeout(() => {
                    result.check_in = false;
                    res();
            }, 1000 * 30);
                
            const handle = async (params, request) => {
                const url = new URL(params.response.url);
                if (url.pathname === '/growth_api/v1/check_in') {
                    if (request.method === 'OPTIONS') {
                        return;
                    }
                    // 获得请求的响应
                    const response = await client.send('Network.getResponseBody', {
                        requestId: params.requestId
                    });
                    
                    result.check_in = response;

                    requestWatch.off(handle);
                    clearTimeout(timer);
                    res();
                }
            };
            requestWatch.on(handle);
        });
    }
    
    if (result.check_in === false) {
        result.message = '签到失败';
        
        return result;
    }
    // 接下来还可以做一个点击每日抽奖的流程过程差不多不帖了
    result.message = '完成';

    return result;
}

至此一个流程结束了,外面套层 koa 或者 express 提供个接口定时调用就好了。

结语

整个过程比我想象中要流畅的多,当然也有需求并不复杂的因素在里面,更是得益于看到各种开源库使用中的实践;如果不知道有什么做法,阅读开源库里业务实践的代码总是能带来一些“种子”,而现在阅读以及寻找这些“种子”的难度相对之前是简单了不少,在并不是第一次看这些内容的情况下这种对比尤为明显。

评论

This is just a placeholder img.