Puppeteer + opencv.js 自动化脚本实践经验总结(part 1)

2021 年 12 月到 2022 年 6 月期间,我一直在开发维护一个大型的 JavaScript 自动化脚本,在此项目中用到了 puppeteer 来自动操作网页,用 opencv.js 识别特定的页面元素。在此记录从项目中获得的一些经验。

内容可能过长,我将分两篇帖子来介绍:

Puppeteer + opencv.js 自动化脚本实践经验总结(part 1)

Puppeteer + opencv.js 自动化脚本实践经验总结(part 2)

Puppeteer 常用连接配置

另外,这是我发布在 bilibili 上的相关视频手把手带您实操 puppeteer,如果您是 puppeteer 的新手,这个系列视频将很适合您。


使用 puppeteer 遇到的问题

  • 浏览器会弹出"是否允许通知"的弹出框,这将阻断我的页面操作。
  • 如何调节浏览器窗口大小。
  • 如何调节页面视口大小。
  • 本地调试时,开启的浏览器窗口遮住了我的代码编辑器。
  • 设置 UA。
  • 如何禁止图片加载,节省流量和带宽开销。
  • 查找元素。
  • 元素滚动到视口内。
  • 点击元素。
  • 元素截图。
  • 如何防止键盘输入内容时触发了快捷键。
  • 如何判定我的脚本执行成功了。我的脚本是否准确完成了点击某个按钮,是否准确的提交了某条评论,是否真的删除了一条内容?
  • 网站给页面元素做了复杂的变种,让我无法使用 css 选择器定位元素。

使用 opencv 识别页面元素遇到的问题

  • 如何将 opencv 引入到 node 项目。
  • 如何识别页面元素。
  • 如何获取目标元素的坐标。
  • 如何提高识别准确率,选用那种识别算法。

我的程序被浏览器通知提示阻断了!

浏览器弹出框

假设您正在使用 puppeteer 操作页面滚动,此时浏览器弹出通知提示框,那么您的滚动操作大概率将会被阻断,这时就需要禁止浏览器通知功能。puppeteer 提供了接口来关闭通知功能,下面是示例代码:


puppeteer.launch({
	args: [
		`--disable-notifications=true`,
    	],
})

args 还可传入更多其他选项来设置您的浏览器,您可以 在这 找到 Chromium 标志列表。

调节浏览器窗口尺寸

注意这里所说的是窗口大小,不是网页视口大小,以下是对于窗口和视口的解释:

以下是窗口大小设置相关示例代码:


puppeteer.launch({
	args: [
		`--window-size=1920,1080`,
    	],
})

调节视口尺寸

在调节窗口尺寸章节中对视口概念有解释,以下是调节视口尺寸示例代码:


puppeteer.launch({
	defaultViewport: { width: 1440, height: 780 },
	args: [
		`--window-size=1920,1080`,
    	],
})

本地调试时,浏览器窗口遮住了我的代码编辑器

使用双屏开发时,puppeteer 开启的浏览器实例将会展示在你聚焦的屏幕,如果此时您正在编辑代码,开起来的浏览器实例会获得聚焦,这会打断手头的工作。您可以设置浏览器打开时的位置,让它默认显示在第二块屏幕内,以下是示例代码:


puppeteer.launch({
	defaultViewport: { width: 1440, height: 780 },
	args: [
		"--window-position=1921,0",
    	],
})

另外,我暂时还未解决浏览器实例占据焦点的问题!

设置 UA

User-Agent 首部包含了一个特征字符串,用来让网络协议的对端来识别发起请求的用户代理软件的应用类型、操作系统、软件开发商以及版本号。如果您需要使用 puppeteer 对某网站执行大量自动化操作,使用固定 UA 很有可能会被识别为机器人。另外,有一些网站会根据不同 UA 返回不同布局的页面内容,您需要留意您的脚本是否能够适配不同布局的页面。以下是设置 user-agent 的示例代码:


puppeteer.launch({
	defaultViewport: { width: 1440, height: 780 },
	args: [
		`--user-agent=${your user agent}`
    	],
})

如何禁止图片加载,节省流量和带宽开销

我的这个项目每小时都有上万次的页面请求,并且每个页面上都有大量的图片,这些图片对我的主要业务是无关紧要的,加载它们甚至会严重占用带宽,导致我的正常业务无法运行。好在 puppeteer 提供接口可设置禁止图片加载,但这并不是万无一失的!通过观察线上截取回来的图片来看,有时页面仍然能够加载出来图片,浏览器设置禁用图片不一定全都生效,因此您可能还需要在代码里添加禁用图片的操作,这里列出两种方案的示例代码:

  • 浏览器层面禁用

puppeteer.launch({
	defaultViewport: { width: 1440, height: 780 },
	args: [
		'--blink-settings=imagesEnabled=false'
    	],
})
  • 代码层面禁用

await page.setRequestInterception(true);
page.on('request', (request) => {
    if (request.resourceType() === 'image') request.abort();
    else request.continue();
});

查找元素

puppeteer 提供多种获取元素的 API,但您可能需要留意它们之间的区别!在不同场景使用更适合的查找元素方式。

  • page.$(selector)

page.$(selector) 没有匹配到元素时返回 null,如果您的页面还没有完全渲染好,使用此种方式查找元素将得不到任何结果!您可能会想到:延时等待页面加载一段时间后,再使用 page.$(selector) 查找元素。我并不推荐这种做法,网络延迟是不可控因素,我们无法得知页面什么时候渲染完成。如果能够断定此时页面已经完全渲染好,您可大胆这种方式查找元素。


await delay(ms("5s")); // 延时等待 5 秒

const element = await page.$("[id='my_target']")

  • page.waitForSelector(selector)

page.waitForSelector(selector) 在规定时间内没有匹配到元素会抛错,注意做好错误处理,否则可能阻塞您之后的代码。通常,我会在对页面执行某种操作之前,先等待必须的元素,如果没能找到必须的元素则断定此次任务失败,终止执行。下面是示例代码:

await page.goto("https://google.com", {
	timeout: ms('60s')
});

// 等待关键元素,如果超过 60 秒还没找到,断定此次任务失败,终止执行
try {
	await page.waitForSelector(".target", {
    	timeout: ms('60s')
    })
} catch(err){
	logger.error("意外终止,未找到页面关键元素", {err});
    	throw err;
}

将元素滚动到视口内

这是一个很常见的需求,点击某个元素时您需要先将它移到到视口内,对元素截图也同样如此。 page.hover(selector) 具有将元素滚动到可视区的能力,但它不稳定,有时执行它后毫无反应,您需要尝试使用另外一种方式:

let css = ".target";

page.evaluate((selector)=> {
	const target = document.querySelector(selector);
    
    if(target){
    	target.scrollIntoView()
    }
}, css);

如果还是无法将目标元素滚动到视口内,您可能需要将 css 选择器指向目标元素的某个父元素!

点击元素

page.click(selector)page.mouse.click()page.evaluate() 都能够实现点击操作。

page.click(selector) 最方便,你只需要传入目标元素的 css 选择器,但 page.click(selector) 并不稳定,有时点击没有反应,另外还经常报错说元素不可点击或出现以下错误:

Error: Node is either not visible or not an HTMLElement

使用 page.mouse.click() 需要您提前获得目标元素的坐标。您需要先将目标元素滚动到可视区内,等待滚动稳定后,最后获取目标元素的坐标,以下是示例代码:

// 获取目标元素坐标
const css = ".target";

const position = await page.evaluate((selector)=> {
	const target = document.querySelector(selector);
    
    if(!target){
    	return null;
    }
    
    const recet = target.getBoundingClientRect()
    
    return {
    	x: recet.x,
        y: recet.y,
    }
}, css)

await page.mouse.click(position.x, position.y);

当然您也可以直接用原生 Dom 点击操作,但最好用拟人化的方式点击,以免被识别出来是机器人。

const css = ".target";

const position = await page.evaluate((selector)=> {
	const target = document.querySelector(selector);
    
    if(!target){
    	return null;
    }
    
    targrt.click();
}, css)

元素截图

本地调试时,通常会关闭无头模式,此模式下对元素截图会出现截图位置错误的情况,开启无头模式将不会出现这种问题。

值得注意的是,如果您要手动微调截图尺寸或位置,您需要传入截图坐标和尺寸,这里获取坐标的方式有点不同,以下是示例代码:

const css = ".target";

const targetElement = await page.$(css);

const clip = await page.evaluate((selector)=> {
	const target = document.querySelector(selector);
    
    if(!target){
    	return null;
    }
    
    const recet = target.getBoundingClientRect()
    
    return {
    	x: recet.x + document.documentElement.scrollLeft,
        y: recet.y + document.documentElement.scrollTop,
        width: recet.width,
        height: recet.height,
    }
}, css)

if(!clip){
	throw new Error("未获取到目标元素坐标");
}

// 元素截图
await targetElement.screenshot({
	path: "./target.png",
    	clip,
});

如何防止键盘输入内容时触发了快捷键

由于我输入的内容是由程序采集的,所以里面包含一些特殊字符,如:\n\r 、  @ 等容易触发快捷键的字符。当输入内容里包含换行符时可能直接触发了提交操作,当内容中包含 @ 符时可能会被转换成提醒某个人或关联某个资源。

您可以提前删除这些殊字符,防止触发一些意料之外的事件发生,另外,您可以尝试使用浏览器复制剪切操作,以下是示例代码:

await browserPage.evaluate(
        (element, content) => {
          (() => {
            const dataTransfer = new DataTransfer();
            dataTransfer.setData("text/plain", content);

            element.dispatchEvent(
              new ClipboardEvent("paste", {
                clipboardData: dataTransfer,
                bubbles: true,
                cancelable: true,
              })
            );

            dataTransfer.clearData();
          })();
        },
        inputElement,
        commentTemplate.content
      );

它能够有效防止因为输入内容中带有回车而触发了提交。

如何判定我的脚本执行成功了

假设您的脚本是给 bilibili 视频点赞,那么只有监听到点赞接口成功返回后,才能够判定您的点赞操作是成功的,以接口返回结果为判定标准是比较可靠的方式。下面是示例代码:

const apiUrl = "https://example.com/api/like"

try {
	await page.waitForResponse(async(response)=>{
    	if(response.url() !== apiUrl){
        	return false;
        }
        
        const statusCode = response.status();
        
        if(statusCode === 200){
        	return true;
        }
        
        return false;
    }, {
    	timeout: ms("60s")
    })
    
    logger.log("点赞成功", { accountId, userId });
} catch(err){
	logger.log("点赞失败", { accountId, userId, err });
}

另外,您可能还需要从接口返回的结果中提取数据。您可以使用 JSON.parse 解析后提取,也可以使用正则表达式截取,但如果返回数据的层级结构太深,或者干脆不能确定数据层级,这时您可以考虑使用 jsonpath-plus 库来提取,您只需要提供大概数据层级即可提取到目标数据。

使用图像识别技术来查找页面元素

某些大平台的网页反扒虫做的很好,他们会想尽各种手段来阻止非正常用户爬取页面数据。他们可能会在页面内加很多没用的且隐藏起来的元素来误导你,还会故意减少使用具有特征的 css ,如:不使用固定的 id 选择器、大量复用的 className、className 是随机码,不具有可读性。我还遇到过两次访问同一个页面, Dom 结构却不同!

这写反扒手段让我难以使用 css 选择器来获取元素 ,由此我不得不使用图像识别技术来获得元素位置。使用图像识别获取元素坐标而不是 css 选择器给我带来很多好处,当网站 css 样式或 Dom 树变更时,我不需要每次都去做适配,只要主题颜色和元素形状不改变,我都能够很准确的获取元素坐标。

由于内容长度过长,之后的内容将会放在下一篇帖子:

Puppeteer + opencv.js 自动化脚本实践经验总结(part 2)

展示评论