技术标签: python java vue js javascript
最近因为工作中的一个需求,需要针对用户数据页面进行分页并截屏并返回 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,即 Headless Chrome Node.js API 实现,被广泛用于自动化测试和爬虫方向的工作,一个最基本用法如下:
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();
})();
如上示例打开了 Chromium 浏览器,并打开了一个新页面跳转至 Google 首页,然后将浏览器关闭,关于更多详细的用法可以直接翻阅 API 文档查看。我们来看其他几个点。
从 1.7.0 版本往后,puppeteer 会同时发布两个版本:
说到两者的区别,主要在于:
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,
});
但更多时候,在国内使用 puppeteer-core 还有一个特殊考虑,那就是 puppeteer 需要下载 Chrome,正常情况下速度很慢。关于两者详细的区别可以看 https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#puppeteer-vs-puppeteer-core
假设你的 puppeteer 服务提供给他人调用,而请求方想截取的是一张需要登陆状态的页面,该怎么办呢?简单来看,透传 cookie 貌似就能解决这个问题。
puppeteer 在 Page 类上提供有 setCookie
方法,这允许你从请求方发来的 request headers 上获取 cookies,并将内容按键值对解析后批量注入打开的浏览器环境。简单的示例可以长成这样:
import { SetCookie } from 'puppeteer-core';
const cookieList: SetCookie[] = [{ ... }];
await page.setCookie(...cookieList);
调用 setCookie 时你可以传入一个对象,也可以传入展开的对象数组,每个传入对象中 name 和 value 属性是必须的,当然也可以指定 domain 字段细化 cookie 的应用范围。由于涉及到对 url 的处理,推荐一个三方库 url-parse,可以节省不少时间,用法如下:
/**
* 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;
如此一来你就可以在打开对应页面时,带上和请求方一致的 cookie 信息了。
我们首先要先把页面加载出来,然后才能做截图处理。但是加载页面,如何判断页面加载完毕呢?puppeteer 在打开页面时有提供 waitUntil 选项以及 timeout 时长,这里取值可以是下列四个选项之一:
const maxTimeout: number;
const url: string;
const gotoAction = () => {
return page.goto(url, {
waitUntil: waitUntil as 'networkidle0' | 'networkidle2' | 'domcontentloaded' | 'load',
timeout: maxTimeout,
});
};
通过一个 Promise 简单包装下打开页面时可能出现的报错,我们的代码可以写成下面这样:
let pageErrorPromise = new Promise((_res, rej) => {
promiseReject = rej;
});
page.on('pageerror', (pageerr) => {
promiseReject(pageerr);
});
await Promise.race([
gotoAction(),
pageErrorPromise,
]);
当然,即便到这一步,你依旧可能没法确认页面已经加载完毕,假设页面就是存在一段特殊逻辑需要在5s后进行一个公式渲染。
好在 puppeteer 提供有监听页面变量/方法变动的 API,于是我们可以这么做:
await page.waitForFunction('window.allDone', {
timeout: maxTimeout,
});
如此一来,只需要目标页面在完成所有内容渲染后触发 window.allDone “通知“我们即可。
puppeteer 的 page 类提供有 page.screenshot([options])
和 page.pdf([options])
两个 API,分别可以将当前页面截取为图片和 PDF 格式的数据,这个可以具体查看文档使用即可。
但在实际使用时,我有遇到一些问题,比如截取 PDF 时指定的宽高不完全准确、页面在内容布局超长时指定的宽度会失效等等,这里面一部分可以通过设置 viewport 解决,另一部分搜了下像是这个 issue 提到的问题,即puppeteer 在截图时会针对相似的内置规格进行“四舍五入”。
注:还有一个需要注意的是,pdf API 到现在还只支持在 headless 模式下操作。
对于分页操作,我们往往不能直接截图生成 PDF 就完事,一方面是尺寸可能和我们需要的效果存在差异,另一方面则是默认打印的效果无法保证页面中 DOM 不被分页分割成两半。
因此,我们需要设计一个简单的分页算法,对 DOM 进行重排,以适合我们的打印布局。这里简单介绍下我们在研发中采用的方法。
分页算法我们分成以下几个部分。
**第一步,等待所有 DOM 节点加载完成。**由于页面中大多数资源除了文本便是图片资源,而图片资源还可能会通过动态逻辑生成,为了准确计算各个节点的尺寸大小与重排,我们需要等待节点加载并完成渲染后再做进一步操作,这里采用监听 load 事件(load 事件已经包含样式文件以及图片资源的加载判断,但针对 JavaScript 逻辑中动态生成的图片标签可能捕获不准)加额外一次 img 标签 complete 状态轮询的方式确定内容渲染完成。
// load 事件监听
window.onload = (event) => {
console.log('page is fully loaded');
};
// img 标签加载状态轮询(在 load 事件触发后执行)
const imgs = document.querySelectorAll('img');
imgs.map((img) => {
if (img.complete) {
// ...
} else {
// ...
}
});
**第二步,打平 DOM 树。**因为要确保分页的准确,所以我们肯定需要对整个 DOM 进行递归性遍历,挨个枚举判断其在文档流中的位置与尺寸,所以我们先将目标节点全部打平。这里相对比较简单,用个 DFS 将 DOM 元素递归打平即可,可能有时我们需要跳过一些指定元素的递归,那么额外维护一个列表即可。
// 节点列表
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));
}
**第三步,找到跨越边界的节点。**这一步的解法也很直观,借助上一步产出的节点列表,我们可以利用二分来快速定位边界节点,但如何确定节点是否越界呢?这里需要借助 Web API 中的 Range 对象,Range 对象是表示一个包含节点与文本节点的一部分的文档片段。
利用 Document.createRange() 可以产生一个 Range,在这个对象上调用 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 ...
}
**第四步,将内容切分为当前页与剩余内容。**虽然我们找到了越界的节点,但是我们并不清楚它是从哪开始越界的。
打个比方,当前节点是一段三行的文本,可能到第二行末尾都是放的下的,只有到第三行才越界,那么我们在切分时就该准确的找到这个位置,并把原来的 DOM 切成两半。再比如,如果我们所遇到的节点是非文本节点,那么这个节点便可以认为是不可再分的单元,只能被唯一分到其中一半中去,所以这块的伪代码应该长成这样:
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 对当前页内容进行操作/复原。
另外,对 remainedContentRange 循环如上第三步和第四步,直至剩余内容不再超过一页。
你也可以通过 CSS 指定打印样式,这允许我们针对页面在打印操作时进行样式的定制化操作。
/** 比如,你可以把你要定制的打印样式写入如下花括号中 */
@media print {
...
}
/** 再比如,你可以这样设置页面边距 */
@page { margin: 2cm }
@page :left {
margin: 1cm;
}
@page :right {
margin: 1cm;
}
再比如,CSS 中还有三个属性,分别是 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
当然,除了上面这些,我们如果需要在现有样式上支持打印样式,还需要做很多其他处理,比如对超链接高亮、隐藏音视频标签等等,这里就不详细展开了。
有一个不错的插件叫 puppeteer-recorder 推荐一下,它能够记录你在 Chrome 上的操作并据此生成一段 Puppeteer script 代码,比如我现在需要在打开页面后通过一系列点击操作跳转到另一个页面状态,如果按照传统方法我们应该是理清思路然后把这段流程用代码写出来,通过这个插件,三步即可达到相同效果:
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()
})()
祝大家在 Web 里玩的愉快。
文章浏览阅读90次。【代码】js-选项卡原理。_选项卡js原理
文章浏览阅读67次。原型模式是一种对象创建型模式,它采用复制原型对象的方法来创建对象的实例。它创建的实例,具有与原型一样的数据结构和值分为深度克隆和浅度克隆。浅度克隆:克隆对象的值类型(基本数据类型),克隆引用类型的地址;深度克隆:克隆对象的值类型,引用类型的对象也复制一份副本。UML图:具体代码:浅度复制:import java.util.List;/*..._prototype 设计模式
文章浏览阅读59次。入选国内首批云计算服务创新发展试点城市的北京、上海、深圳、杭州和无锡起到了很好的示范作用,不仅促进了当地产业的升级换代,而且为国内其他城市发展云计算产业提供了很好的借鉴。据了解,目前国内至少有20个城市确定将云计算作为重点发展的产业。这势必会形成新一轮的云计算基础设施建设的**。由于云计算基础设施建设具有投资规模大,运维成本高,投资回收周期长,地域辐射性强等诸多特点,各地在建...
文章浏览阅读9.4k次,点赞2次,收藏20次。一、功能及目的 在每个STM32的芯片上都有两个管脚BOOT0和BOOT1,这两个管脚在芯片复位时的电平状态决定了芯片复位后从哪个区域开始执行程序。BOOT1=x BOOT0=0 // 从用户闪存启动,这是正常的工作模式。BOOT1=0 BOOT0=1 // 从系统存储器启动,这种模式启动的程序_stm32boot0和boot1作用
文章浏览阅读3.4k次,点赞2次,收藏22次。C语言函数递归调用_c语言函数递归调用
文章浏览阅读410次。明日方舟bilibili服是一款天灾驾到战斗热血的创新二次元废土风塔防手游,精妙的二次元纸片人设计,为宅友们源源不断更新超多的纸片人老婆老公们,玩家将扮演废土正义一方“罗德岛”中的指挥官,与你身边的感染者们并肩作战。与同类塔防手游与众不同的几点,首先你可以在这抽卡轻松获得稀有,同时也可以在战斗体系和敌军走位机制看到不同。明日方舟bilibili服设定:1、起因不明并四处肆虐的天灾,席卷过的土地上出..._明日方舟抽卡模拟器
文章浏览阅读437次。Maven上传Jar到私服报错:ReasonPhrase: Repository version policy: SNAPSHOT does not allow version: xxx_repository version policy snapshot does not all
文章浏览阅读1.2k次。斐波那契数列(Fibonacci Sequence)是由如下形式的一系列数字组成的:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, …上述数字序列中反映出来的规律,就是下一个数字是该数字前面两个紧邻数字的和,具体如下所示:示例:比如上述斐波那契数列中的最后两个数,可以推导出34后面的数为21+34=55下面是一个更长一些的斐波那契数列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584,_斐波那契日
文章浏览阅读363次。PHP必会面试题1. 基础篇1. 用 PHP 打印出前一天的时间格式是 2017-12-28 22:21:21? //>>1.当前时间减去一天的时间,然后再格式化echo date('Y-m-d H:i:s',time()-3600*24);//>>2.使用strtotime,可以将任何字符串时间转换成时间戳,仅针对英文echo date('Y-m-d H:i:s',str..._//该层循环用来控制每轮 冒出一个数 需要比较的次数
文章浏览阅读1.3k次,点赞26次,收藏26次。windows下用mingw编译opencv貌似不支持cuda,选cuda会报错,我无法解决,所以没选cuda,下面两种编译方式支持。打开cmake gui程序,在下面两个框中分别输入opencv的源文件和编译目录,build-mingw为你创建的目录,可自定义命名。1、如果已经安装Qt,则Qt自带mingw编译器,从Qt安装目录找到编译器所在目录即可。1、如果已经安装Qt,则Qt自带cmake,从Qt安装目录找到cmake所在目录即可。2、若未安装Qt,则安装Mingw即可,参考我的另外一篇文章。_opencv mingw contrib
文章浏览阅读10w+次,点赞42次,收藏309次。今天给大家推荐5个好用且免费的简历模板网站,简洁美观,非常值得收藏!1、菜鸟图库https://www.sucai999.com/search/word/0_242_0.html?v=NTYxMjky网站主要以设计类素材为主,办公类素材也很多,简历模板大部个偏简约风,各种版式都有,而且经常会更新。最重要的是全部都能免费下载。2、个人简历网https://www.gerenjianli.com/moban/这是一个专门提供简历模板的网站,里面有超多模板个类,找起来非常方便,风格也很多样,无须注册就能免费下载,_hoso模板官网
文章浏览阅读142次。你听说过吗?该计划可让您以推广您的产品并在成功销售时支付佣金。它提供了新的营销渠道,使您的产品呈现在更广泛的受众面前并提高品牌知名度。此外,TikTok Shop联盟可以是一种经济高效的产品或服务营销方式。您只需在有人购买时付费,因此不存在在无效广告上浪费金钱的风险。这些诱人的好处是否足以让您想要开始您的TikTok Shop联盟活动?如果是这样,本指南适合您。_tiktok联盟