前段时间有个同事说:你试试 puppeteer,我就去谷歌了下相关的资料。根据官方描述 puppeteer 是一个 node 类库,通过提供 API 控制 Chrome 或者 Chromium 浏览器(有头无头都可以),通信的协议是 DevTools Protocol。维护的团队是本家的 Chrome DevTools team 团队,他们似乎想通过 puppeteer 项目让大家更好的认识 DevTools Protocol 协议。
Puppeteer 源码托管在 github,文档地址是 https://pptr.dev。最近 1 个月 issue 新增 42 个,关闭 29 个,pr 新增 11 个,合并 21 个,看起来还蛮活跃的。
Puppeteer 的 API 设计很符合直觉,文档写的也很详细,根据 README.md(这个文件在 github 那边)的描述我们可以很快写出自己的 demo。
import * as puppeteer from 'puppeteer';
class Index {
async main() {
const browser = await puppeteer.launch({
headless: false,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
await page.goto('https://console.authing.cn/login');
await page.waitForSelector('button[type="button"]', {
timeout: 5000
})
await page.click('div.styles_authing-tabs-inner__KEW7v div');
await page.waitForSelector('#identity', {
timeout: 3000
})
await page.type('#identity', 'zhangsan');
await page.type('#password', 'password');
await page.click('button[type="button"]');
await page.waitForTimeout(10000);
} finally {
await browser.close();
}
}
}
(async () => {
const index = new Index();
await index.main();
})()
在终端运行 npm install
然后再运行 npm run try-try
就可以看到效果了(try-try 对应的命令是:ts-node -r tsconfig-paths/register src/main/index.ts)。首先会打开一个 chromium 浏览器,然后输入地址,切换 tab 输入用户名/密码点击登录(肯定提示用户名密码不对,我瞎填的)等待 10s 后关闭浏览器。
6 ~ 9 启动 chromium 浏览器,参数 headless: false
指定不使用无头浏览器,参数 --no-sandbox
, --disable-setuid-sandbox
生产尽量别用,可能会导致安全问题,关于 sandbox 是啥,后面有篇漫画解释。
11 ~ 15 打开一个新的 Tab 页,输入地址并且等待 button 元素出现,最长等待 5s。
16 ~ 19 点击按钮,等待 id = identity 的元素出现。
20 ~ 22 输入用户名/密码并提交。
26 关闭浏览器。
32 ~ 35 入口函数。
Sandbox 简介
Puppeteer 通过 css selector 来定位元素,本质是 document.querySelector(selector)
方法。比较常用的有 ID selector 比如 #identity,class selector 比如 div.styles_authing-tabs-inner__KEW7v,attribute selector 比如 button[type=”button”],还有 type selector 比如 div。常写前端的同学应该都很熟悉,忘记了也可以查文档。
如果遇到特别刁钻的元素还可以试试 page.$x(expression)
。这个方法通过 xpath 选取元素,比 css selector 更加灵活和强大。这个方法返回的是 ElementHandle 它的用法和 page 很类似,详细用法可以查看文档。
选中元素是为了操作它,常用的点击和输入操作 puppeteer 都有对应的 API 可以很方便的完成。
page.click(selector)
page.type(selector, text)
为了完成自动化登录还需要通过滑动验证码验证,谷歌一番后觉得可能 python 更加适合做一些图像处理,这里借鉴的是这位大佬的博客《让 Python 爬虫也能读得懂「滑动验证码」》,这也是那个同事说的。当然这么做了整体会复杂一点,node 和 python 需要通信,而且为了让项目能跑起来,需要同时安装 node 环境和 python 环境。
通信用了 python-shell 这个 node 类库,使用方法也很简单,只需要指定 python 文件路径和入参即可,然后执行结果就会通过回调函数告诉 node。
PythonShell.run(pythonFile, options, function (err, out) {
});
图像处理用了 opencv 类库,主要就是为了识别那个缺口,然后返回缺口的坐标 (x, y) 和图片的大小 (width, height),用来确定滑块需要滑动多少个像素,python 的代码直接照抄那位大佬的,命名为 edge-detector.py 放到 src/utils 文件夹下。然后用 node 包装一下方便使用,万能的 Promise。
import {PythonShell} from 'python-shell';
class ImageDetector {
async detectEdge(imageUrl: string): Promise<Object> {
return new Promise((resolve, reject) => {
try {
PythonShell.run('src/utils/edge-detector.py', {
args: [imageUrl]
},
function (err, out) {
if (err) throw err;
resolve(out);
});
} catch(e) {
reject(e);
}
});
}
}
export const imageDetector = new ImageDetector();
然后是解锁滑动验证码的代码,单独写了一个模块,包含 3 个辅助方法,命名 slideCaptcha.ts 放到 src/utils 文件夹下
import { imageDetector } from 'utils/imageDetector';
import fetch from 'node-fetch';
import * as fs from 'fs';
class SlideCaptcha {
async unlock(page): Promise<Object> {
const resp:any = {};
try {
const frame = page.frames().find(frame => frame.name() == 'tcaptcha_iframe')
const filepath = await this.saveCaptchaImage(frame);
const offset = await this.getSlideOffset(frame, filepath);
const coord = await this.getStartCoord(frame);
await page.mouse.move(coord.x, coord.y);
await page.mouse.down();
await page.waitForTimeout(500);
for (let i = 1; i <= offset; i++) {
await page.mouse.move(coord.x + i, coord.y);
await page.waitForTimeout(10);
}
await page.waitForTimeout(500);
await page.mouse.up();
await page.waitForTimeout(3000);
resp.filepath = filepath;
} catch(e) {
console.log(e);
resp.error = e;
}
return resp;
}
async saveCaptchaImage(frame): Promise<string> {
const imageUrl = await frame.$eval('#slideBg', el => el.getAttribute('src'));
const filepath = '/tmp/scrapy/login/catpchaBg.jpg';
const response = await fetch(imageUrl);
const dest = fs.createWriteStream(filepath);
await response.body.pipe(dest)
return filepath;
}
async getSlideOffset(frame, filepath): Promise<number> {
const retArray = await imageDetector.detectEdge(filepath);
const ret = JSON.parse(retArray[0]);
const drag_bar = await frame.$('#slide');
const bar_position = await drag_bar.boundingBox();
const width = bar_position.width;
const offset = width * (parseInt(ret['dx']) - 23) / parseInt(ret['width']) - 26;
return offset;
}
async getStartCoord(frame) {
const drag_btn = await frame.$('#tcaptcha_drag_thumb');
const position = await drag_btn.boundingBox();
return {
'x': position.x,
'y': position.y + position.height / 2
};
}
}
export const slideCaptcha = new SlideCaptcha();
15 ~ 25 移动滑块,通过辅助方法得到滑块的起始位置和滑动距离,这里用 page.mouse 控制鼠标来移动滑块。用 waitForTimeout 和 for 循环滑动是为了方便人眼看,实际上一滑到底也是可以的。puppeteer 还有很多 wait 方法用来等待元素加载,按需使用。
page.waitForSelector(selector)
page.waitForXpath(xpath)
page.waitForRequest(urlOrPredicate)
page.waitForResponse(urlOrPredicate)
page.waitForFrame(urlOrPredicate)
33 ~ 40 保存图片的辅助方法,取到 img 元素的 src 值,通过 fetch API 下载图片到本地,然后返回文件的本地路径,记得先把文件夹建起来。这里用了 page.$eval 方法用来获取元素的属性,类似的方法还有好几个,用来取元素或者元素属性
page.$(selector)
page.$$(selector)
page.$x(selector)
page.$$eval(selector, pageFunction)
42 ~ 50 计算缺口的辅助方法,返回需要移动的像素。需要注意的是图片展示时实际上是被压缩的,所以图片像素和 web 页面的像素大小实际上是不一样的,所以需要等比缩小。这里的 23 是滑块图距离边缘有 23 像素,用画图工具测量的,这是图片的像素所以要等比缩一下。这里的 26 是 web 页面滑块距离左边的像素距离,这个可以在 css 样式表那边看到。
52 ~ 59 计算开始坐标,也就是滑动按钮起始位置的坐标。
最后是调用的代码
import * as puppeteer from 'puppeteer';
import { slideCaptcha } from 'utils/slideCaptcha';
class Index {
async main() {
const browser = await puppeteer.launch({
headless: false,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
await page.goto('https://007.qq.com/online.html');
await page.waitForSelector('#code');
await page.waitForTimeout(1000);
await page.hover('#code');
await page.click('#code');
await page.waitForSelector('#tcaptcha_iframe');
await page.waitForTimeout(5000);
await slideCaptcha.unlock(page);
} finally {
await browser.close();
}
}
}
(async () => {
const index = new Index();
await index.main();
})()
和第一个写的 demo 很类似,多用了一个 hover 方法,它的作用是滚动页面展示出这个元素,然后鼠标移动到这个元素的中央。这样一个自动登陆的程序就完成了,完整的代码我放在 github 上。
遇到的坑
Windows 不区分大小写,导致模块找不到。
开始不知道怎么命名 ts 文件,纠结首字母要不要大写,然后因为 windows 不区分大小写导致文件修改后没有提交到 git 最后导致在 Linux 环境下运行时找不到模块(其实就是找不到 ts 文件)。
找不到模块
Node 引入模块有两种方式,一种是相对地址,一般以 ./ 开头,其他的都是非相对地址。相对地址有个蛋疼的问题,如果模块位置放的不太好,会出现 ../../../xxx/yyy 这种情况。查了 node 文档只要在 tsconfig.json 配置 baseUrl 就好了,然而我配置了运行是依然报错,找不到模块。费了老大劲才发现是 ts-node 的问题。我使用 ts-node 运行程序,它可以直接运行 ts 文件不需要先编译成 js 文件,代价是需要加上参数 -r tsconfig-paths/register 才能正常找到非相对地址的模块。
用 page.$eval 给元素赋值
这个问题纯属犯二了,page.$eval 这个方法的确可以改变元素属性,但是和 type 不同,它是直接改值不会触发事件。现在的页面很多都是双向绑定的,纯改视图的值并不会修改对应的 model 的值,正常用 type 方法就行。
nodejs 版本和类库版本不兼容
这个问题发生在 node-fetch 这个类库,没仔细看文档,上来就直接安装了 v3 版本,但是因为我 node 环境是 12.14,v3 最低要求 node 12.20 改用 v2 版本就好了。
参考文档