2021 年 12 月到 2022 年 6 月期间,我一直在开发维护一个大型的 JavaScript 自动化脚本,在此项目中用到了 puppeteer 来自动操作网页,用 opencv.js 识别特定的页面元素。在此记录从项目中获得的一些经验。
内容可能过长,我将分两篇帖子来介绍:
Puppeteer + opencv.js 自动化脚本实践经验总结(part 1)
Puppeteer + opencv.js 自动化脚本实践经验总结(part 2)
另外,这是我发布在 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 树变更时,我不需要每次都去做适配,只要主题颜色和元素形状不改变,我都能够很准确的获取元素坐标。
由于内容长度过长,之后的内容将会放在下一篇帖子: