当前位置: 首页 > news >正文

基于Chrome扩展的浏览器可信事件与网页离线PDF导出

基于Chrome扩展的浏览器可信事件与网页离线PDF导出

Chrome扩展是一种可以在浏览器中添加新功能和修改浏览器行为的软件程序,我们可以基于Manifest规范的API实现对于浏览器和Web页面在一定程度上的修改,例如广告拦截、代理控制等。Chrome DevTools Protocol则是Chrome浏览器提供的一套与浏览器进行交互的API,我们可以基于DevTools协议控制Chromium内核的浏览器进行各种操作,例如操作页面元素、模拟用户交互等。

描述

前段时间我们需要实现一个比较复杂的需求,经常做需求的同学都知道,很多功能并不是可以按步就班地实现的,在某些情况下例如要跨部门甚至无法联系合作的情况下,单方面跨系统完成一些事情就可能需要动用不同寻常的方法。当然具体需求的内容不是很方便表达,所以在这里我们就替代为其他方面的需求展开文章的叙述,虽然实现的目的不一样,但是最终想要表达的技术方案是类似的。

因此在这里假设我们的背景变成了另一个故事,前段时间语雀进行了商业化,对于用户文章的数量和分享都做了一些限制,那么此时我们可能希望将现在已经写过的文档内容抽离出来,将其放在GitHub或者其他软件中作备份或分享等。那么此时问题来了,熟悉富文本的同学都知道,我们在语雀上存储的文档都是JSON文件而不是MarkDown等,会存在固定的私有格式,因此我们可能需要对其先进行一遍解析,而调用语雀的OpenAPI所需要的Personal Token是需要超级会员的,因此我们可能只能走比较常用的Cookie以及私有格式的解析方案,或者自动化操作Puppeteer模拟导出文档也是可行的。

那么有没有更加通用的方案可以参考,熟悉富文本的同学还知道,由于富文本需要实现DOM与选区MODEL的映射,因此生成的DOM结构通常会比较复杂,而当我们从文档中复制内容到剪贴板时,我们会希望这个结构是更规范化的,以便粘贴到其他平台例如飞书、Word等时会有更好的解析。因此我们便可以借助这一点来获取更加通用的方案,毕竟通过HTML解析成MarkDown等格式社区有很多完善的方法而不需要我们自行解析了,此外由于我们是通过HTML来描述内容,对于文档的内容完整性保持的会更好一些,自行解析的情况下可能会由于复杂的嵌套内容需要不断完善解析程序。

当然在这里只是平替了一下需求,前边我们也提到了背景是假设出来的,而由这个背景则延伸出了我们文章要聊的解决方案,如果真的是针对于语雀的这个迁移问题,在批量处理内容的情况下还是自行解析JSON会更方便一些。那么我们可以继续沿着提取HTML内容的思路处理数据,首先我们需要考虑如何获取这个HTML内容,最简单的方案就是我们通过读取Node.innerHTML属性来获取DOM结构,那么问题来了,在语雀当中有大量的ne开头的标签,以及大量的ne属性值来表达样式,以简单的文本与加粗为例,其HTML内容是这样的,其实语雀还算比较简单的结构,如果是飞书的表达则更加复杂。

<!-- 语雀 -->
<ne-p id="u5aec73be" data-lake-id="u5aec73be"><ne-text id="u1e4a00ce">123</ne-text><ne-text id="ucc026ff4" ne-bold="true">123</ne-text><span class="ne-viewer-b-filler" ne-filler="block"><br></span>
</ne-p><!-- 飞书 --><div class="block docx-text-block" data-block-type="text" data-block-id="2" data-record-id="doxcns7E9SHaX2Xft1XweL0Mqwth"><div class="text-block-wrapper"><div class="text-block"><div class="zone-container text-editor non-empty" data-zone-id="2" data-zone-container="*" data-slate-editor="true" contenteditable="true"><div class="ace-line" data-node="true" dir="auto"><span data-string="true" class=" author-0087753711195911211" data-leaf="true">123</span><span data-string="true" style="font-weight:bold;" class=" author-0087753711195911211" data-leaf="true">123</span><span data-string="true" data-enter="true" data-leaf="true">&ZeroWidthSpace;</span></div></div></div></div>
</div>

可以看出来,我们取得这样的HTML解析起来相对成本还是比较高的,而如果我们以上述的剪贴板思路,也就是富文本通常会对复制的内容作Normalize处理,那么我们可以通过剪贴板事件来获取这个规范化的内容,然后再进行处理HTML,这里的HTML内容就会规范很多,那么同样也会便于我们处理数据。在这里实际上通常还会有私有类型的数据,这里就是我们选中部分取得的渲染Fragment,通常是用来在编辑器内部粘贴处理数据无损化还原使用的,如果对于数据格式非常熟悉的话解析这部分内容也是可以的,只是并没有比较高的通用性。

<!-- 语雀 -->
<div class="lake-content" typography="classic"><p id="u5aec73be" class="ne-p" style="margin: 0; padding: 0; min-height: 24px"><span class="ne-text">123</span><strong><span class="ne-text">123</span></strong></p>
</div><!-- 飞书 -->
<div data-page-id="doxcnTYldMboJldT2Mc2wXfervv6vqc" data-docx-has-block-data="false"><div class="ace-line ace-line old-record-id-doxcnsBUassFNud1XwL1vMgth">123<strong>123</strong></div>
</div>

那么我们就可以继续沿着这个思路,以复制出的的内容为基准解析HTML格式解析内容,而实际上说了这么多我们最需要解决的问题是如何自动化提取内容,由此就引出了我们今天要聊的Chrome拓展与Chrome DevTools Protocol协议,当我们成功解决了内容问题之后,接下来将内容格式转换为其他格式社区就有很多成熟的方案了。文中涉及的相关代码都在https://github.com/WindrunnerMax/webpack-simple-environment/tree/master/packages/chrome-debugger中,在这里为了方便处理演示DEMO,我们的事件触发全部都是DOM0级的事件绑定形式。

JavaScript事件

既然我们的目标是自动操作浏览器执行复制操作,那么可供自动化操作的选择有很多例如SeleniumPuppeteer,都是可以考虑的方案。在这里我们考虑比较轻量的解决方案,不需要安装WebDriver等依赖环境,并且可以直接安装在用户本身的浏览器中开箱即用,基于这些考虑则使用Chrome扩展来帮我们实现目标是比较好的选择。并且Chrome扩展程序可以帮我们在Web页面中直接注入脚本,实现相关功能也会更加方便,关于使用扩展程序实现复杂的功能注入可以参考之前的文章,在这里就不重复叙述了。

那么接下来我们就需要考虑一下如何触发页面的OnCopy事件,试想一下此时我们的目的有两个,首先是让编辑器本身提取内容并规范化,其次是让转换后的内容写入剪贴板,那么实现的方式就很明确了,我们只需要主动在页面上触发SelectAllCopy命令即可,那么接下来我们就可以在控制台中测试这两个命令的使用。

document.execCommand("selectAll");
const res = document.execCommand("copy");
console.log(res); // true

当我们手动在控制台执行命令的时候,可以发现页面上的内容已经被选中并且复制到了剪贴板中,那么接下来我们就可以将这两个命令封装到一个函数中,然后通过Content Script注入到页面中,这样我们就可以在页面上直接调用这个函数就可以了。然而当我们真正借助Chrome扩展实现这个功能的时候,会发现页面能够正常全部选中,但是剪贴板的内容却是上次的内容,也就是本次复制并没有真正执行成功。

这实际上是由于浏览器的安全策略导致的,由于浏览器为了加强安全性,限制了一些可能会影响用户隐私的API,只有在用户的直接操作下才能运行,也就是相当于执行Copy命令只有在用户主动激活上下文中才可以正常触发,与之类似的就是当我们在Js中主动执行点击事件例如Node.click()时,其对于浏览器来说是不可信的,在事件触发时会携带isTrusted属性,只有用户主动触发的事件才会为true。因此我们在控制台中执行的命令被认为是浏览器的可信命令,是用户主动触发的事件,而在扩展中执行的不是用户主动触发的事件,进而命令执行失败。

那么为什么我们在控制台的命令就可以正常执行呢,实际上这是因为我们在执行控制台的命令时,会需要点击回车键来执行代码,注意这个回车键是我们主动触发的,因此浏览器会将我们执行的Js代码认为是可信的,所以我们可以正常执行Copy命令。而如果我们在执行代码时将其加入延时,例如我们延时5s再执行命令,此时我们就可以发现即使是同样的代码同样在控制台执行就无法写入剪贴板,document.execCommand("copy")的返回值就是命令是否执行成功,在5s的延时下我们得到的返回值就是false,我们可以同样在控制台中执行代码来获取命令执行状态,在这里也可以不断调整延时的时间来观察执行结果,例如将其设置为2s就可以获得true的返回值。

setTimeout(() => {document.execCommand("selectAll");const res = document.execCommand("copy");console.log(res); // false
}, 5000);

我们暂且先放开需要用户主动激活的可信事件问题不谈,到后边再继续聊这个问题的解决方案。那么我们除了需要测试OnCopy事件之外,同样需要测试一下OnPaste的事件,不要忘记当我们执行了OnCopy提取内容之后,这部分内容实际上还是存在于剪贴板之中的,我们还需要将其提取出来。那么在执行下面的代码之后,我们可以发现OnPasteOnCopy的策略还是不一样,即使是在用户的主动操作下,并且我们此时并没有延时执行,但是其结果依然是false,并且document绑定的事件也没有触发。

document.onpaste = console.log;
const res = document.execCommand("paste");
console.log(res); // false

那么会不会是因为我们没有在input或者textarea中执行paste命令的原因,我们同样可以测试下这个问题。我们可以通过创建一个input元素,然后将其插入到body中,然后将焦点移动到这个input元素上,然后执行paste命令,然而我们仍然无法成功执行命令,而且我们执行focus的时候会发现并没有光标的出现,

const input = document.createElement("input");
input.setAttribute("style", "position:fixed; top:0; right: 0");
document.body.appendChild(input);
input.focus();
const res = document.execCommand("paste");
console.log(res); // false

那么是不是还有其他原因会造成这个问题呢,在前边我们经过OnCopy部分的测试,可以得知在用户主动触发可信事件之后一段时间内的事件都是可信的,但是浏览器的安全策略中还有焦点方面的考量。在某些操作中焦点必须要在document上,否则操作不会正常执行,与之对应的异常就是DOMException: Document is not focused.,而此时我们的焦点是在控制台Console面板上的,这里同样可能存在不可控的问题。因此我们需要在这2s的执行延时中将焦点转移到document上,也就是需要点击body中任意元素,当然直接点击input也是可行的,然而即使这样我们也没有办法执行paste

const input = document.createElement("input");
input.setAttribute("style", "position:fixed; top:0; right: 0");
document.body.appendChild(input);
setTimeout(() => {input.focus();const res = document.execCommand("paste");console.log(res); // false
}, 2000);

实际上在经过查阅文档可以知道document.execCommand("paste")Web Content中实际上已经是被禁用的,然而这个命令还是可以执行的,我们后边会继续聊到。在现代浏览器中我们还有navigator.clipboard API来操作剪贴板,navigator.clipboard.read可以实现有限的剪贴板内容读取,调用这个API时会出现明确的调用授权提示,主动授权对于用户隐私是没有问题的,只是在自动化场景下可能需要多出一步授权操作。

此外,我们提到了navigator.clipboard是有限的剪贴板内容读取,那么这个有限是指什么呢,实际上这个有限是指只能读取特定的类型,例如text/plaintext/htmlimage/png等常见的类型,而对于私有类型的数据则是无法读取的,例如我们在语雀中复制的text/ne-inode Fragment数据,这部分数据是无法通过navigator.clipboard.read来读取的,通过执行下面的代码并授权之后可以发现并没有任何输出。

setTimeout(() => {navigator.clipboard.read().then(res => {for (const item of res) {item.getType("text/ne-inode").then(console.log).catch(() => null)}});
}, 2000);

我们实际上也可以通过遍历navigator.clipboard的内容来获得剪贴板的内容,同样的我们也只能获取text/plaintext/htmlimage/png等常见的规范MIME-Type类型。而这2s的耗时则是之前提到过的另一个限制,我们必须要在执行下面的代码之后将焦点移动到document上,否则控制台则会抛出DOMException: Document is not focused.异常,同样也不会出现授权弹窗。

setTimeout(() => {navigator.clipboard.read().then(res => {for (const item of res) {const types = item.types;for (const type of types) {item.getType(type).then(data => {const reader = new FileReader();reader.readAsText(data, "utf-8");reader.onload = () => {console.info(type, reader.result);};});}}});
}, 2000);

那么我们可以设想一个问题,富文本编辑器中如果只是写数据的时候写入了自定义的MIME-Type类型,那么我们在剪贴板中应该如何读取呢。实际上这还是得回归到我们的OnPaste事件上,我们借助于navigator.clipboard API是无法读取这部分自定义key值的,虽然我们可以将其写入到复制出的HTML的某个节点作为attributes然后再读取,这样是可以但是没必要,我们可以直接在OnPaste事件中通过clipboardData获取更加完整的相关数据,我们可以获取比较完整的类型了,这个方法同样也可以用于在浏览器中方便地调试剪贴板的内容。

const input = document.createElement("input");
input.style.position = "fixed";
input.style.top = "100px";
input.style.right = "10px";
input.style.zIndex = "999999";
input.style.width = "200px";
input.placeholder = "Read Clipboard On Paste";
input.addEventListener("paste", event => {const clipboardData = event.clipboardData || window.clipboardData;for (const item of clipboardData.items) {console.log("%c" + item.type, "background-color: #165DFF; color: #fff; padding: 3px 5px;");console.log(item.kind === "file" ? item.getAsFile() : clipboardData.getData(item.type));}
});
document.body.appendChild(input);

DevToolsProtocol

在前边我们抛出了需要用户主动激活触发的可信事件问题,那么在部分我们就需要解决这个问题。首先我们需要解决的问题是如何将代码注入到页面中,当然这个问题我们已经说过多次了,就是借助于Chrome扩展将脚本注入即可。那么即使我们能够注入脚本,执行的代码仍然不是用户主动激活的事件,无法突破浏览器的安全限制,那么这时候就需要请出我们的Chrome DevTools Protocol协议了。

熟悉E2E的同学都知道,DevToolsProtocol协议是Chrome浏览器提供的一套与浏览器进行交互的API,无论是SeleniumPuppeteerPlaywright都是基于这个协议来实现的。我们甚至可以基于这个协议主动实现F12的调试面板,也就是说当前在F12开发者工具能够实现的功能我们都可以基于这个协议实现,而且其API也不仅仅只有调试面板的功能实现,并且诸如chrome://inspect等调试程序也可以通过这个协议来完成。

那么在这里就有新的问题了,如果我们采用SeleniumPuppeteer等方案就需要用户安装WebDriver或者Node等依赖项,不能做到让用户开箱即用,那么在这个时候我们就需要将目光转向chrome.debugger了。Chrome.debugger API可以作为Chrome的远程调试协议的另一种传输方式,使用chrome.debugger可以连接到一个或多个标签页来监控网络交互、调试JavaScript、修改DOMCSS等等,对我们来说最重要的是这个API是可以在Chrome扩展中调用的,这样我们就可以做到开箱即用的应用程序。

那么接下来我们就来处理OnCopy的事件,因为chrome.debugger必须要在worker中进行,而我们的控制启动的按钮则是定义在Popup中的,所以我们就需要进行Popup -> Worker的事件通信,关于Chrome扩展的通信方案可以在之前的文章中找到,也可以在前边提到的仓库中找到,在这里就不过多叙述了。那么此时我们就需要在扩展中查询当前活跃的标签页,然后需要过滤下当前活跃标签的协议,例如chrome://协议的连接我们不会进行处理,然后在符合条件的情况下我们将tabId传递下去。

cross.tabs.query({ active: true, currentWindow: true }).then(tabs => {const tab = tabs[0];const tabId = tab && tab.id;const tabURL = tab && tab.url;if (tabURL && !URL_MATCH.some(match => new RegExp(match).test(tabURL))) {return void 0;}return tabId;})

那么接下来我们就需要将协议控制持续挂载到当前活跃的Tab页上,当我们将扩展挂载debugger之后,会在用户的界面上提示我们的扩展已经开始调试此浏览器,这其实也是浏览器的一种安全策略,因为debugger的权限实在是太高了,给予用户可取消的操作还是非常有必要的。那么当挂载之后,我们就可以通过chrome.debugger.sendCommand来发送命令,例如我们可以通过Input.dispatchKeyEvent来模拟按键事件,在这里我们就需要借助按键的事件来发送selectAll命令,实际上发送命令这一环节是可以通过任何按键的发送来实现的,只不过为了符合实际操作我们选择了Ctrl+A的组合键。

chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", {type: "keyDown",modifiers: 4,keyCode: 65,key: "a",code: "KeyA",windowsVirtualKeyCode: 65,nativeVirtualKeyCode: 65,isSystemKey: true,commands: ["selectAll"],
});

需要注意的是经过前边的按键事件发送之后,我们此时执行的事件就会是可信的,通过DevToolsProtocol的模拟按键事件对于浏览器来说是完全可信的,等同于用户主动触发的事件。那么接下来就可以直接通过Eval执行document.execCommand("copy")命令了,这里我们可以通过Runtime.evaluate来执行Js代码,当执行完毕后,我们就需要将debugger卸载出当前活跃的标签页。在我们提供的DEMO中,为了对齐之前直接用Js执行的操作,我们同样也会延时5s再执行操作,此时可以发现我们的代码是可以正常将内容写到剪贴板里的,也就是我们成功执行了Copy命令。

chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {expression: "const res = document.execCommand('copy'); console.log(res);",
})
.then(() => {chrome.debugger.detach({ tabId });
});

那么同样的接下来我们就研究在DevToolsProtocol中的OnPaste事件,那么首先我们并不在权限清单中声明clipboardRead权限,这是在Chrome扩展程序权限清单中的读剪贴板权限,紧接着我们延续之前的代码在debugger中执行document.execCommand("paste"),可以发现执行的结果是false,这表示即使在可信的条件下,执行paste仍然是无法取得结果的。那么如果我们在permissions中声明了clipboardRead,会可以发现仍然是false,这说明在用户脚本Inject Script下执行document.execCommand("paste")是无法取得效果的。

chrome.debugger.attach({ tabId }, "1.2").then(() =>chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", {type: "keyDown",// ...})).then(() => {return chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {expression:"document.onpaste = console.log; const res = document.execCommand('paste'); console.log(res);",});}).finally(() => {chrome.debugger.detach({ tabId });});

那么我们继续保持不在清单中声明clipboardRead权限,尝试用DevToolsProtocol的方式执行document.execCommand("paste"),也就是在模拟按键时将命令发送出去。此时我们可以发现是可以正常触发事件的,这里实际上就同样表明了通过DevToolsProtocol协议直接执行事件是完全以用户主动触发的形式来进行的,其本身就是可信的事件源。

chrome.debugger.sendCommand({ tabId }, "Input.dispatchKeyEvent", {type: "keyDown",modifiers: 4,keyCode: 86,key: "v",code: "KeyV",windowsVirtualKeyCode: 86,nativeVirtualKeyCode: 86,isSystemKey: true,commands: ["paste"],
});

紧接着我们简单更改一下先前在用户态执行的Js事件操作,将执行的copy命令改为paste命令,也就是在Content Script部分执行document.execCommand("paste"),此时仍然是会返回false,说明我们的命令执行并没有成功。那么别忘了此时我们还没有声明清单中的clipboardRead权限,而当我们在清单中声明权限之后,再次执行document.execCommand("paste"),发现此时的结果是true并且可以正常触发事件。

document.onpaste = console.log;
case PCBridge.REQUEST.COPY_ALL: {const res = document.execCommand("paste");console.log(res);break;
}

而如果我们更进一步,继续保持清单中的clipboardRead权限声明,将事件传递到Inject Script中执行,可以发现即使是在声明了权限的情况下,document.execCommand("paste")返回的结果仍然是false,并且无法触发我们绑定的事件,这也印证了之前我们说的在Inject Script下执行paste命令是无法正常触发的,进而我们可以明确clipboardRead权限是需要我们在Content Script中使用的。而对于navigator.clipboard API即使在权限清单中声明权限的情况下 仍然还需要主动授权。

// Content Script
case PCBridge.REQUEST.COPY_ALL: {document.dispatchEvent(new CustomEvent("custom-event"));break;
}// Inject Script
document.onpaste = console.log;
document.addEventListener("custom-event", () => {const res = document.execCommand("paste");console.log(res);
});

网页离线PDF导出

在前段时间刷社区的时候发现有不少用户希望能够将网页保存为PDF文件,方便作为快照保存以供离线阅读,因此在这里也顺便聊一下相关实现方案,而实际上在这里也属于Web页面内容的提取,与我们上文聊的剪贴板操作本质上是类似的功能。那么在浏览器中我们当然可以通过Ctrl + PPDF打印出来,然而通过打印的方式或者生成图片的方式导出的PDF文件就存在一些问题:

  • 导出的PDF必须指定纸张大小,不能随意设定纸张大小,例如当想将页面导出为单页PDF的情况下就难以实现。
  • 导出PDF时必须要弹出选择对话框,不能够静默导出并自动下载,这对于想要同时导出多个Tab页的批量场景不够友好。
  • 导出的PDF不会自动携带Outline,也就是PDF的目录书签大纲,需要后续主动使用pdf-lib等工具来生成。
  • 导出时必须要全页面打印,页面本身可能没有定义@media print样式预设,希望实现局部打印时会有些困难。
  • 如果想在打印PDF前批量自定义样式,则需要为每个页面单独注入样式,这样的操作显然不适用于批量场景。
  • 如果通过类似于HTML2Canvas的方式将页面转换为图片再转换为PDF,则会导致图片体积过大且文本不能选中的问题。

那么在这里我们可以借助Chrome DevTools Protocol协议来实现这个功能,实际上DevTools Protocol协议中有一个Page.printToPDF方法,这也是常用的Node服务端将HTML转换为PDF的常用方法,当然借助PDFKit等工具直接绘制生成PDF也是可行的,只不过成本很高。Page.printToPDF方法可以将当前页面导出为PDF文件,并且可以实现静默导出并自动下载,也可以实现自定义纸张大小,同时也可以实现Outline的生成,这个方法的使用也是非常简单的,只需要传递一个PDF的配置对象即可。

那么在调用方法之前,我们同样需要查询当前活跃的活动窗口,当然直接选择当前Window下的所有窗口也是可行的,此时需要注意权限清单中的tabsactiveTab权限的声明,同样的在这里我们仍然需要过滤chrome://等协议,只处理http://https://file://协议的内容。

cross.tabs.query({ active: true, currentWindow: true }).then(tabs => {const tab = tabs[0];const tabId = tab && tab.id;const tabURL = tab && tab.url;if (tabURL && !URL_MATCH.some(match => new RegExp(match).test(tabURL))) {return void 0;}return tabId;})

接下来我们就可以根据TabId挂载debugger,前边提到了我们是希望将页面导出为单页PDF的,因此我们就需要将页面的高度和宽度取得,此时我们可以通过Page.getLayoutMetrics方法来获取页面的布局信息,这个方法会返回一个LayoutMetrics对象,其中包含了页面的宽度、高度、滚动高度等信息。然而当然我们也可以通过通信的方式将消息传递到Content Script中得到页面的宽高信息,在这里我们采用更加简单的方式,通过执行Runtime.evaluate的方式,获取得到的返回值,这样我们可以灵活地取得更多的数据,当然也可以灵活地控制页面内容,例如在滚动容器不是window的情况下就需要我们注入代码获取宽高以及控制打印范围。

chrome.debugger.attach({ tabId }, "1.3").then(() => {return chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {expression:"JSON.stringify({width: document.body.clientWidth, height: document.body.scrollHeight})",});})

那么接下来我们就需要根据页面的宽高信息来设置PDF的配置对象,在这里需要注意的是我们通过document取得的宽高信息是像素大小,而在Page.printToPDF中的paperWidthpaperHeight是以inch为单位的,因此我们需要将其转换为inch单位,根据CSS规范1px = 1/96th of 1 inch,我们通常可以认为1px = 1/96 inch而不受设备物理像素的影响。此外,我们可以指定一些配置,当前我们输出的PDF只会包含第一页的内容,同时会包含背景颜色、生成文档大纲的配置,并且还有HeaderFooter等配置选项,我们可以根据实际需求来设置输出格式,需要注意的是generateDocumentOutline是实验性的配置,在比较新的Chrome版本中才被支持。

const value = res.result.value as string;
const rect = TSON.parse<{ width: number; height: number }>(value);
return chrome.debugger.sendCommand({ tabId }, "Page.printToPDF", {paperHeight: rect ? rect.height / 96 : undefined,paperWidth: rect ? rect.width / 96 : undefined,pageRanges: "1",printBackground: true,generateDocumentOutline: true,
});

那么在生成完毕后,我们接下来就需要将其下载到设备中,触发下载的方法又很多,例如可以将数据传递到页面中通过a标签触发下载。在扩展程序中实际上提供了chrome.downloads.download方法,这个方法可以直接下载文件到设备中,并且虽然传递数据参数名字为url,但是实际上并不会受到链接长度/字符数的限制,通过传递Base64编码的数据可以实现大量数据下载,只要注意在权限清单中声明权限即可。那么在下载完成之后,我们同样就可以将debugger分离当前Tab页,这样就完成了整个PDF导出的过程。

const base64 = res.data as string;
chrome.downloads.download({ url: "data:application/pdf;base64," + base64 });.finally(() => {chrome.debugger.detach({ tabId });});

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://chromedevtools.github.io/devtools-protocol/
https://github.com/microsoft/playwright/issues/29417
https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API
https://developer.chrome.google.cn/docs/extensions/reference/api/debugger?hl=zh-cn
https://stackoverflow.com/questions/71005817/how-does-pixels-relate-to-screen-size-in-css
https://chromewebstore.google.com/detail/just-one-page-pdf/fgbhbfdgdlojklkbhdoilkdlomoilbpl

相关文章:

基于Chrome扩展的浏览器可信事件与网页离线PDF导出

基于Chrome扩展的浏览器可信事件与网页离线PDF导出 Chrome扩展是一种可以在浏览器中添加新功能和修改浏览器行为的软件程序&#xff0c;我们可以基于Manifest规范的API实现对于浏览器和Web页面在一定程度上的修改&#xff0c;例如广告拦截、代理控制等。Chrome DevTools Proto…...

马拉松报名小程序的设计

管理员账户功能包括&#xff1a;系统首页&#xff0c;个人中心&#xff0c;用户管理&#xff0c;赛事信息管理&#xff0c;赛事报名管理&#xff0c;活动商城管理&#xff0c;留言板管理&#xff0c;系统管理 微信端账号功能包括&#xff1a;系统首页&#xff0c;赛事信息&…...

python使用pywebview集成vue3和element-plus开发桌面系统框架

随着web技术越来越成熟&#xff0c;就连QQ的windows客户端都用web技术来开发&#xff0c;所以在未来&#xff0c;web技术来开发windows桌面软件也会越来越多&#xff0c;所以在此发展驱动之下&#xff0c;将最近流程的python与web技术相结合&#xff0c;使用vue3和element-plus…...

C++线程的使用

C11之前&#xff0c;C语言没有对并发编程提供语言级别的支持&#xff0c;这使得我们在编写可移植的并发程序时&#xff0c;存在诸多的不便。现在C11中增加了线程以及线程相关的类&#xff0c;很方便地支持了并发编程&#xff0c;使得编写的多线程程序的可移植性得到了很大的提高…...

算法库应用--寻找最长麦穗

学习贺利坚老师算法库 数据结构例程——串的顺序存储应用_使用顺序串存储身份证号-CSDN博客 本人详细解析博客 串的顺序存储的应用实例二_串的顺序存储应用-CSDN博客 版本更新日志 V1.0: 在原有的基础上, 进行优化名字, 并且有了相应的算法库作为支撑, 我使用了for循环来代替老…...

ython 使用 cx_Freeze 打包,不想要打包文件中能直接看到依赖的代码,如何处理

背景&#xff1a;因为使用 cx_Freeze 打包时&#xff0c;添加需要依赖的文件 cx_Freeze 是一个用于将 Python 程序打包成独立可执行文件的工具&#xff0c;支持多个平台。当你需要打包包含多个 .py 文件的项目时&#xff0c;你可以通过编写一个 setup.py 文件来指定哪些模块应…...

某DingTalk企典 - Token

⚠️前言⚠️ 本文仅用于学术交流。 学习探讨逆向知识&#xff0c;欢迎私信共享学习心得。 如有侵权&#xff0c;联系博主删除。 请勿商用&#xff0c;否则后果自负。 网址 aHR0cHM6Ly9kaW5ndGFsay5jb20vcWlkaWFuLw 浅聊一下 没毛病&#xff0c;就这字段&#xff0c;有效期…...

手写一个类似@RequestParam的注解(用来接收请求体的参数)

一、本文解决的痛点 按照大众认为的开发规范&#xff0c;一般post类型的请求参数应该传在请求body里面。但是我们有些post接口只需要传入一个字段&#xff0c;我们接受这种参数就得像下面这样单独创建一个类&#xff0c;类中再添加要传入的基本类型字段&#xff0c;配合Reques…...

【遇坑笔记】Node.js 开发环境与配置 Visual Studio Code

【遇坑笔记】Node.js 开发环境与配置 Visual Studio Code 前言node.js开发环境配置解决pnpm 不是内部或外部命令的问题&#xff08;pnpm安装教程&#xff09;解决 pnpm : 无法加载文件 C:\Program Files\nodejs\pnpm.ps1&#xff0c;因为在此系统上禁止运行脚本。 vscode 插件开…...

【ajax实战07】文章筛选功能

本文章目标&#xff1a;根据筛选条件&#xff0c;获取匹配数据展示 本章**“查询参数对象”指的是&#xff0c;要“获取文章列表”功能**中服务器接口要求配置的对象 实现步骤如下&#xff1a; 一&#xff1a;设置频道列表数据 二&#xff1a;监听筛选条件改变&#xff0c;…...

promise.all和promise.race的区别

Promise.all和Promise.race是JavaScript中Promise API的两个重要方法&#xff0c;它们在处理多个Promise对象时表现出不同的行为。以下是它们之间的主要区别&#xff1a; 1. 功能和行为 Promise.all&#xff1a; 功能&#xff1a;接收一个包含多个Promise的数组&#x…...

Python爬取豆瓣电影+数据可视化,爬虫教程!

1. 爬取数据 1.1 导入以下模块 import os import re import time import requests from bs4 import BeautifulSoup from fake_useragent import UserAgent from openpyxl import Workbook, load_workbook1.2 获取每页电影链接 def getonepagelist(url,headers):try:r reque…...

初阶数据结构二叉树练习系列(1)

这个系列的文章将带大家一起刷题&#xff0c;并且总结思路 温馨提示&#xff1a;本篇文章里的练习题仅适合刚学完二叉树的小白使用 相同的树 思路 情况分析&#xff1a;第一种情况&#xff1a;两棵树都为空 → 返回true 第二种情况&am…...

【selenium 】操作元素

操作元素 元素操作鼠标操作键盘操作 元素操作 元素操作示例清空输入框clear()deiver.find_element_by_id(“username”).clear()输入文字send_keys()deiver.find_element_by_id(“username”).send_keys(‘zs’)元素点击 click()deiver.find_element_by_id(“login”).click()…...

【MySQL】事务实现原理

目录 事务 如何使用 ACID 原子性(Atomicity) 原子性实现原理 持久性(Durability) 持久性实现原理 隔离性 隔离级别 读未提交 读已提交 可重复读 串行化 隔离级别原理 锁 共享锁&独占锁 意向锁 索引记录锁 间隙锁 临键锁 插入意向锁 自增锁 MVCC 实现…...

面向物联网行业的异常监控追踪技术解决方案:技术革新与运维保障

在现代高度数字化和互联的环境中&#xff0c;物联网技术已经深入到我们生活的方方面面。特别是在家庭和工业环境中&#xff0c;物联网系列通讯作为连接各类设备的关键枢纽&#xff0c;其稳定性和可靠性显得尤为重要。本文将介绍一种创新的监控系统&#xff0c;旨在实时跟踪和分…...

守护厨房空气:全面排查与修复油烟净化器跳闸问题

我最近分析了餐饮市场的油烟净化器等产品报告&#xff0c;解决了餐饮业厨房油腻的难题&#xff0c;更加方便了在餐饮业和商业场所有需求的小伙伴们。 在繁忙的餐饮业厨房中&#xff0c;油烟净化器是确保空气清新和环境卫生的关键设备。然而&#xff0c;油烟净化器在长时间高强…...

【微服务网关——https与http2代理实现】

1.https与http2代理 1.1 重新认识https与http2 https是http安全版本http2是一种传输协议两者并没有本质联系 1.1.1 https与http的区别 HTTP&#xff08;超文本传输协议&#xff09;和 HTTPS&#xff08;安全超文本传输协议&#xff09;是用于在网络上交换数据的两种协议。H…...

mssql查询历史执行过的语句日志

SELECT deqs.creation_time,dest.text AS [SQL Text],deqs.execution_count,deqs.total_elapsed_time,deqs.total_worker_time FROM sys.dm_exec_query_stats AS deqs CROSS APPLY sys.dm_exec_sql_text(deqs.sql_handle) AS dest--where dest.text like %这个是我的条件&#…...

【LeetCode】每日一题:买卖股票的最佳时机 II

给你一个整数数组 prices &#xff0c;其中 prices[i] 表示某支股票第 i 天的价格。 在每一天&#xff0c;你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买&#xff0c;然后在 同一天 出售。 返回 你能获得的 最大 利润 。 AC代码 水…...

【TS】TypeScript 联合类型详解:解锁更灵活的类型系统

&#x1f308;个人主页: 鑫宝Code &#x1f525;热门专栏: 闲话杂谈&#xff5c; 炫酷HTML | JavaScript基础 ​&#x1f4ab;个人格言: "如无必要&#xff0c;勿增实体" 文章目录 TypeScript 联合类型详解&#xff1a;解锁更灵活的类型系统一、联合类型的定义二…...

kali改回官方源后更新失败

官方源&#xff1a; deb http://http.kali.org/kali kali-rolling main non-free contrib deb-src http://http.kali.org/kali kali-rolling main non-free contrib在文件 /etc/cat/sources.list中将官方源修改为&#xff1a; deb http://http.kali.org/kali kali-rolling ma…...

Mysql 左关联(LEFT JOIN)

在左关联&#xff08;LEFT JOIN&#xff09;操作中&#xff0c;关于大表和小表的连接顺序&#xff0c;通常建议将小表放在前面&#xff0c;大表放在后面。这种安排方式有助于提高查询效率&#xff0c;原因如下&#xff1a; 扫描效率&#xff1a;在SQL查询中&#xff0c;尤其是…...

[笔记]小米CyberDog机器狗仿真调试记录

从官方github的所有源码库来看&#xff0c;所有的source命令只有两条&#xff0c;执行它以配置环境变量&#xff1a; source /opt/ros/galactic/setup.bash source /home/cyberdog_ws/install/setup.bash 如果运行脚本之后gazebo正常启动及机器狗模型在悬空状态&#xff0c;问…...

第十四届蓝桥杯省赛C++B组G题【子串简写】题解(AC)

题目大意 给定字符串 s s s&#xff0c;字符 a , b a, b a,b&#xff0c;问字符串 s s s 中有多少个 a a a 开头 b b b 结尾的子串。 解题思路 20pts 使用二重循环枚举左端点和右端点&#xff0c;判断是否为 a a a 开头 b b b 结尾的字符串&#xff0c;是则答案加一…...

实现Java Web应用的高性能负载均衡方案

实现Java Web应用的高性能负载均衡方案 大家好&#xff0c;我是微赚淘客系统3.0的小编&#xff0c;也是冬天不穿秋裤&#xff0c;天冷也要风度的程序猿&#xff01; 在高并发的网络环境中&#xff0c;负载均衡是确保Web应用程序高性能和可靠性的关键策略之一。本文将探讨如何…...

医学预测模型web APP的制作建议

医学预测模型web APP的制作建议 医学预测模型类web APP定义为承载预测模型而便利预测模型临床应用的可视化客户端。 医学预测模型类web APP的功能是衔接预测模型和临床实践&#xff0c;让用户正确地&#xff0c;方便地使用预测模型并恰当地理解预测模型的结果&#xff0c;在此…...

gitlab每日备份以及restore

gitlab服务有非常简洁的每日备份命令&#xff0c; 从production的gitlab的每日备份中restore到backup环境也非常方便。 一、Production gitlab每日备份 1. Production gitlab环境上编写脚本 cat /root/gitlab_bak.shgitlab-rake gitlab:backup:create > /var/opt/gitl…...

2024-07-05 base SAS programming学习笔记9(variables)

1.在数据集增加累加变量值&#xff08;SUM&#xff09; 求和语句(SUM STATEMENT)&#xff1a;variableexpression variable是累积求和的变量名&#xff0c;为数值型&#xff0c;默认初始值为0&#xff1b;该variable值则会保留到一个观测 当expression有缺失值&#xff0c;在求…...

kafka--发布-订阅消息系统

1. Kafka概述 1. kafka是什么 kafka是分布式的、高并发的、基于发布/订阅模式的消息队列软件系统。 kafka中的重要组件 Producer&#xff1a;消息生产者&#xff0c;发布消息到Kafka集群的终端或服务Consume&#xff1a;消费者&#xff0c;从Kafka集群中消费消息的终端或服…...

2024最新软件测试面试题。内附答案+文档

&#x1f345; 视频学习&#xff1a;文末有免费的配套视频可观看 &#x1f345; 点击文末小卡片&#xff0c;免费获取软件测试全套资料&#xff0c;资料在手&#xff0c;涨薪更快 1、你以前工作时的测试流程是什么&#xff1f; 参考答案&#xff1a;&#xff08;灵活回答&…...

新加坡很火的slots游戏代投Facebook广告新流量趋势

新加坡很火的slots游戏代投Facebook广告新流量趋势 在新加坡这片充满活力的土地上&#xff0c;Slots游戏以其独特的魅力和吸引力&#xff0c;迅速成为了许多玩家的心头好。而Facebook&#xff0c;作为全球最大的社交媒体平台之一&#xff0c;为Slots游戏的推广提供了得天独厚的…...

C++ 实现字符串逆序

C 实现字符串逆序 思路&#xff1a; 输入一个字符串。使用双指针法&#xff0c;交换字符串的首尾字符&#xff0c;逐步向中间移动。输出逆序后的字符串。 #include <iostream> #include <string>using namespace std;void reverseString(string &str) {int …...

【项目实践】贪吃蛇

一、游戏效果展示二、博客目标三、使用到的知识四、Win32 API 介绍 4.1 WIn32 API4.2 控制台程序4.3 控制屏幕上的坐标COORD4.4 GetStdHandle4.5 GetConsoleCursorInfo 4.5.1 CONSOLE_CURSOR_INFO 4.6 SetConsoleCursorInfo4.7 SetConsoleCursorPosition4.8 GetAsyncKeyState 五…...

将exe文件添加到注册表中,实现开机时自动运行

目录 一、前言 二、代码 三、使用步骤 1.编译生成exe文件、 2.以管理员身份运行代码 3.打开注册表&#xff0c;验证结果 一、前言 在Windows操作系统中&#xff0c;将exe文件的路径添加到注册表下&#xff0c;主要用于实现程序的开机自动运行功能。 注册表路径为&#xf…...

SQL使用注意事项

作为开发人员日常最为熟悉的工具sql。但是在实际使用中&#xff0c;有一些坑需要尽量避免&#xff0c;本文是对一些常用注意事项的总结 查询需要的。不要全部都查询。禁止使用存储过程&#xff0c;禁止使用外键。使用sql进行计算&#xff0c;要小心。&#xff08;数据量大的情况…...

uniapp小程序IOS端,uni.createInnerAudioContext()无声音

可能的问题 路径中有中文字符需要使用uni.getBackgroundAudioManager()播放其他问题 解决办法 首先我的路径中没有中文字符&#xff0c;如果有的&#xff0c;可能需要转义一下或者干脆不使用中文字符&#xff0c;第二个也是从其他博客中看到的&#xff0c;我这边分享一下我的…...

第二节-K8s词汇表

关键字词汇表 https://kubernetes.io/zh-cn/docs/reference/glossary/?fundamentaltrue API Group (API 组)Kubernetes API 中的一组相关路径。 API 服务器亦称作:kube-apiserver API 服务器是 Kubernetes 控制平面的组件&#xff0c; 该组件负责公开了 Kubernetes API&…...

命令行运行git reflog(reference log)报错的解决办法

文章目录 1. 检查 Git 是否已安装2. 检查 PATH 环境变量3. 重新安装 Git 在Git中&#xff0c; reflog的英文全称是 “ reference log”。意思是 引用日志&#xff08;参考日志&#xff09;。它记录了本地仓库中HEAD和分支引用所指向的提交的变更历史。这包括了你所有的提交&…...

python3 imwrite 中文路径不成功解决方法

filename 中文路径 #cv2.imwrite(filename, frame) cv2.imencode(.jpg, frame)[1].tofile(filename)...

tapd 与国内外主流的8大项目管理软件大对比

对比Tapd与8大项目管理工具&#xff1a;PingCode、Worktile、Redmine、Teambition、广联达、Jira、禅道、飞书。 Tapd 是腾讯推出的一款敏捷开发管理工具&#xff0c;特别适合那些需要高效协作和快速迭代的敏捷开发团队。它支持多种敏捷方法论&#xff0c;包括Scrum和Kanban&am…...

IP地址配置

1.为虚拟机配置IP地址&#xff0c;网关&#xff0c;DNS 例如&#xff1a;手动给虚拟机配置IP地址为 192.168.5.50/24&#xff1b;网关地址为&#xff1a;192.168.5.2&#xff1b;DNS地址为&#xff1a;192.168.5.2 解题步骤如下&#xff1a; #配置IP地址 [rootlocalhost ~]#…...

【C#】ProgressBar进度条异步编程思想

1.控件介绍 进度条通常用于显示代码的执行进程进度&#xff0c;在一些复杂功能交互体验时告知用户进程还在继续。 在属性栏中&#xff0c;有三个值常用&#xff1a; Value表示当前值&#xff0c;Minimum表示进度条范围下限&#xff0c;Maximum表示进度条范围上限。 2.简单实…...

深入浅出3D感知中的优化与基于学习的技术1(原创系列)

近期几乎看了所有有关NERF技术论文&#xff0c;本身我研究的领域不在深度学习技术方向&#xff0c;是传统的机器人控制和感知。所以总结了下这部分基于学习的感知技术&#xff0c;会写一个新的系列教程讲解这部分三维感知技术的发展到最新的技术细节&#xff0c;并支持自己最近…...

【CentOS 7 上安装 Oracle JDK 8u333】

文章目录 下载 Oracle JDK 8u333&#xff1a;上传 RPM 包到服务器安装 Oracle JDK设置 JAVA_HOME 环境变量验证 下载 Oracle JDK 8u333 访问 https://www.oracle.com/java/technologies/javase/javase8-archive-downloads.html 找到 JDK 8u333 版本&#xff0c;并下载适用于 L…...

Nginx 常用配置与应用

Nginx 常用配置与应用 官网地址&#xff1a;https://nginx.org/en/docs/ 目录 Nginx 常用配置与应用 Nginx总架构 正向代理 反向代理 Nginx 基本配置反向代理案例 负载均衡 Nginx总架构 进程模型 正向代理 反向代理 Nginx 基本配置反向代理案例 负载均衡 Nginx 基本配置…...

基于Springboot的智慧养老中心管理系统

文章目录 项目介绍主要功能截图:部分代码展示设计总结项目获取方式🍅 作者主页:超级无敌暴龙战士塔塔开 🍅 简介:Java领域优质创作者🏆、 简历模板、学习资料、面试题库【关注我,都给你】 🍅文末获取源码联系🍅 项目介绍 基于Springboot的智慧养老中心管理系统,…...

数据结构笔记第3篇:双向链表

1、双向链表的结构 注意&#xff1a;这里的 "带头" 跟前面我们说的 "头结点" 是两个概念&#xff0c;实际前面的在单链表阶段称呼不严谨&#xff0c;但是为了同学们更好的理解就直接称为单链表的头结点。 带头链表里的头结点&#xff0c;实际为 "哨兵…...

详细对比Java SPI、Spring SPI 和 Dubbo SPI

SPI&#xff08;Service Provider Interface&#xff09;概述 定义&#xff1a;SPI是一种动态替换发现机制&#xff0c;用于实现接口与实现的解耦&#xff0c;提高框架的可扩展性。核心思想&#xff1a;解耦和方便扩展。 Java SPI 约定规范&#xff1a; 扩展类文件放在META-…...

CPU的核心数和线程数

CPU的核心数和线程数 一、关系&#xff1a; 1、线程数可以模拟出不同的CPU核心数。 CPU的核心数指的是硬件上存在着几个核心&#xff0c;而线程数可以模拟出多个核心数的功能。线程数越多&#xff0c;越有利于同时运行多个程序&#xff0c;因为线程数等同于在某个瞬间CPU能同…...