基于 puppeteer 的自用通用签到实现思路
提出正确的问题,往往等于解决了问题的大半。
引言
对于我来说,总有一些网站经常浏览但并不热衷参与社区讨论,并不经常使用但又有等级墙。通常暂时没什么影响,然而偶尔要用时被拦在墙外却也没有什么好法子。为此我在n8n上配置了几个定时请求,做自动签到,成本不高也很稳定;直到最近,不知是因为产能过剩还是被爬虫爬烦了,这些网站陆陆续续上了人机验证或者csrf-token 之类的检测手段,拉高了请求重放的成本,这块虽然也可以花功夫去反调试代码,但这是一个没有尽头的猫鼠游戏,我也只是自己用,于是便想到了 puppeteer。这是一个无头浏览器,简单来说就是通过代码直接和浏览器进行交互模拟用户的访问行为,通常用于诸如UI自动化测试之类的领域,但也可以说是终极的爬虫方案,毕竟再怎么反爬也至少得上浏览器能正常使用吧,虽然作为对策也有诸如加密字体之类的手段,但也无非是增加成本,只要能呈现截屏上OCR也不是不可以。以前其实也研究过,但一直没有什么实际得应用空间,正好趁这次机会用起来。
思路
因为是自用,不需要追求极致的自动化,可以允许适当的人工干预,所以我准备先手动登录过各种一次性人机校验(大多数网站人机校验并不会丧心病狂的每次进去都有),借助保留登录授权信息的用户文件后续自动访问点登录就好了。为了通用不需要去关注有哪些信息,选择直接保留浏览器的整个用户文件。
总结来说有三步
- 手动登录并保存登录信息
- 寻找目标网站的签到界面,编写模拟点击的脚本
- 后续定期执行
定期执行可以继续在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 提供个接口定时调用就好了。
结语
整个过程比我想象中要流畅的多,当然也有需求并不复杂的因素在里面,更是得益于看到各种开源库使用中的实践;如果不知道有什么做法,阅读开源库里业务实践的代码总是能带来一些“种子”,而现在阅读以及寻找这些“种子”的难度相对之前是简单了不少,在并不是第一次看这些内容的情况下这种对比尤为明显。