其他
博客 | 用 puppeteer 实现网站自动分页截取的趣事
最近因为工作中的一个需求,需要针对用户数据页面进行分页并截屏并返回 PDF 文件,期间用到了 puppeteer 与 HTML 分页算法,还找到了一个不错的插件,于是来聊些其中遇到的趣事,先附上目录。
一、利用 puppeteer 截取页面
1.1 puppeteer 还是 puppeteer-core
1.2 简单的 cookie 透传
1.3 页面加载状态判断
1.4 截图格式选择 pdf 还是 png
二、DFS 加二分,一个简单的 HTML 分页算法
三、CSS 打印样式
四、插件与其他
4.1 puppeteer-recorder
4.2 参考
开始吧。
一、利用 puppeteer 截取页面
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
// 下文中会多次用到 page 对象,可以先留意下
const page = await browser.newPage();
await page.goto('https://www.google.com');
// other actions...
await browser.close();
})();
1.1 puppeteer 还是 puppeteer-core
puppeteer puppeteer-core
puppeteer-core
在安装时不会下载 Chromiumpuppeteer-core
会忽略所有PUPPETEER_*
环境变量
puppeteer-core
时,需要确保环境中已拥有可执行的 Chromium 文件,比如在调用 puppeteer.launch 方法时,如果你将对应的 Chrome 压缩包解压到了你的 dist 文件夹中,那么便可以通过下面的方式显式指明你的浏览器可执行路径 executablePath
const path = require('path');
import { launch } from 'puppeteer-core';
const getExecutableFilePath = () => {
const extraPath = {
Linux_x64: 'chrome',
Mac: 'Chromium.app/Contents/MacOS/Chromium',
Win: 'chrome.exe',
}[getOSType()]; // 假设通过这个函数可以获得系统类型
return path.join(path.resolve('dist/chrome'), extraPath);
}
const browser = await launch({
executablePath: getExecutableFilePath(),
args: [ '--no-sandbox', ],
headless: true,
});
1.2 简单的 cookie 透传
setCookie
方法,这允许你从请求方发来的 request headers 上获取 cookies,并将内容按键值对解析后批量注入打开的浏览器环境。简单的示例可以长成这样:import { SetCookie } from 'puppeteer-core';
const cookieList: SetCookie[] = [{ ... }];
await page.setCookie(...cookieList);
/**
* URL Schema
*
* - `protocol`: The protocol scheme of the URL (e.g. `http:`).
* - `slashes`: A boolean which indicates whether the `protocol` is followed by two forward slashes (`//`).
* - `auth`: Authentication information portion (e.g. `username:password`).
* - `username`: Username of basic authentication.
* - `password`: Password of basic authentication.
* - `host`: Host name with port number.
* - `hostname`: Host name without port number.
* - `port`: Optional port number.
* - `pathname`: URL path.
* - `query`: Parsed object containing query string, unless parsing is set to false.
* - `hash`: The "fragment" portion of the URL including the pound-sign (`#`).
* - `href`: The full URL.
* - `origin`: The origin of the URL.
*/
const Url = require('url-parse');
let host: string = Url(url).hostname;
1.3 页面加载状态判断
const maxTimeout: number;
const url: string;
const gotoAction = () => {
return page.goto(url, {
waitUntil: waitUntil as 'networkidle0' | 'networkidle2' | 'domcontentloaded' | 'load',
timeout: maxTimeout,
});
};
let pageErrorPromise = new Promise((_res, rej) => {
promiseReject = rej;
});
page.on('pageerror', (pageerr) => {
promiseReject(pageerr);
});
await Promise.race([
gotoAction(),
pageErrorPromise,
]);
await page.waitForFunction('window.allDone', {
timeout: maxTimeout,
});
1.4 截图格式选择 pdf 还是 png
page.screenshot([options])
和 page.pdf([options])
两个 API,分别可以将当前页面截取为图片和 PDF 格式的数据,这个可以具体查看文档使用即可。二、DFS 加二分,一个简单的 HTML 分页算法
// load 事件监听
window.onload = (event) => {
console.log('page is fully loaded');
};
// img 标签加载状态轮询(在 load 事件触发后执行)
const imgs = document.querySelectorAll('img');
imgs.map((img) => {
if (img.complete) {
// ...
} else {
// ...
}
});
// 节点列表
const elementList = [];
// 自定义跳过的节点与 className 列表
const CUSTOM_CLASS_AND_TAGS = ['header', 'footer', 'custom-pagination'];
// 获取节点
const getNode = (id: string): HTMLElement => {
return document.getElementById(id);
}
//
const dfs = (node: HTMLElement): void => {
// 注释节点则跳过
if (node.nodeType === Node.COMMENT_NODE) {
return ;
}
if (node.nodeType === Node.TEXT_NODE) {
elementList.push(node);
return ;
}
// 通过该方法可以获得节点的 className 以及 tag
const nodeClassAndTag = getNodeClassAndTag(node);
const isLeafNode = CUSTOM_CLASS_AND_TAGS.some(leafNodeSelector =>
nodeClassAndTag.includes(leafNodeSelector)
);
if (isLeafNode) {
elementList.push(node);
return ;
}
node?.childNodes?.forEach(item => dfs(item));
}
getBoundingClientRect()
可以得到一个 DOMRect 对象,该对象将范围中的内容包围起来,你可以理解成这是一个边界矩形,通过他你便可以算出当前内容是否超过一页。// 产生 Range
const range = document.createRange();
range.selectNodeContents(getNode('id'));
// 二分查找
const startIndex = 0;
const endIndex = elementList.length - 1;
while (startNodeIndex < endNodeIndex) {
const midNodeIndex = Math.floor((startNodeIndex + endNodeIndex) / 2);
const node = elementList[midNodeIndex];
const includeInNewPage = includeInNewPage(node);
if (includeInNewPage) {
startNodeIndex = midNodeIndex + 1;
} else {
endNodeIndex = midNodeIndex;
}
}
// 判断包含节点的 Range 是否越界
const includeInNewPage = (el: HTMLElement) => {
const innerRange = range.cloneRange();
innerRange.setEndAfter(el);
const rect = innerRange.getBoundingClientRect();
// 判断逻辑
// rect.height ...
}
const newPagedContentRange: Range;
const remainedContentRange: Range;
const paging = (boundaryNodeIndex: number) => {
const boundaryNode = elementList[boundaryNodeIndex];
if (boundaryNode.nodeType === Node.TEXT_NODE) {
const boundaryCharIndex = binarySearchBoundaryCharIndex(boundaryNode);
newPagedContentRange.setEnd(boundaryNode, boundaryCharIndex);
remainedContentRange.setStart(boundaryNode, boundaryCharIndex);
return;
}
newPagedContentRange.setEndBefore(boundaryNode);
remainedContentRange.setStartBefore(boundaryNode);
// 判断 newPagedContentRange 是不是真的有内容,避免无限循环
const rect = newPagedContentRange.getBoundingClientRect();
if (!rect.height) {
newPagedContentRange.setEndAfter(boundaryNode);
remainedContentRange.setStartAfter(boundaryNode);
}
}
binarySearchBoundaryCharIndex
,它的作用是采用二分对当前文本节点进行切分,并返回越界的文本位置下标,这里在判断时依然是采用复制 Range 然后判断边界矩形的思路,只不过 Range.setEndAfter() API 要换成 Range.setEnd(),因为这里你要对一个节点中的文本依次遍历。完成判断之后,便可以拿到 newPagedContentRange 对当前页内容进行操作/复原。三、CSS 打印样式
/** 比如,你可以把你要定制的打印样式写入如下花括号中 */
@media print {
...
}
/** 再比如,你可以这样设置页面边距 */
@page { margin: 2cm }
@page :left {
margin: 1cm;
}
@page :right {
margin: 1cm;
}
page-break-before 是否在此元素前面设置一个分页符; page-break-after 设置元素后面的分页符; page-break-inside 可以在元素内部产生一个截断,比如图片;
/* 以下是可选的设置项 */
page-break-after : auto | always | avoid | left | right
page-break-before : auto | always | avoid | left | right
page-break-inside : auto | avoid
四、插件与其他
4.1 puppeteer-recorder
点击 record 开始录制 按照预期在页面上交互 点击停止,复制插件上生成的如下代码
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch()
const page = await browser.newPage()
const navigationPromise = page.waitForNavigation()
await page.goto('https://hijiangtao.github.io/')
await page.setViewport({ width: 2560, height: 1280 })
await page.waitForSelector('.masthead > .masthead__inner-wrap > .masthead__menu > #site-nav > .site-title')
await page.click('.masthead > .masthead__inner-wrap > .masthead__menu > #site-nav > .site-title')
await navigationPromise
await page.waitForSelector('.archive > .pagination > ul > li:nth-child(3) > a')
await page.click('.archive > .pagination > ul > li:nth-child(3) > a')
await navigationPromise
await page.waitForSelector('.archive > .list__item:nth-child(6) > .archive__item > .archive__item-title > a')
await page.click('.archive > .list__item:nth-child(6) > .archive__item > .archive__item-title > a')
await navigationPromise
await page.waitForSelector('footer > .page__footer-follow > .social-icons > li:nth-child(3) > a')
await page.click('footer > .page__footer-follow > .social-icons > li:nth-child(3) > a')
await navigationPromise
await browser.close()
})()
4.2 参考
puppeteer https://github.com/puppeteer/puppeteer
puppeteer-recorder https://github.com/checkly/puppeteer-recorder