基於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">​</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事件
既然我們的目標是自動操作瀏覽器執行復制操作,那麼可供自動化操作的選擇有很多例如Selenium
、Puppeteer
,都是可以考慮的方案。在這裡我們考慮比較輕量的解決方案,不需要安裝WebDriver
等依賴環境,並且可以直接安裝在使用者本身的瀏覽器中開箱即用,基於這些考慮則使用Chrome
擴充套件來幫我們實現目標是比較好的選擇。並且Chrome
擴充套件程式可以幫我們在Web
頁面中直接注入指令碼,實現相關功能也會更加方便,關於使用擴充套件程式實現複雜的功能注入可以參考之前的文章,在這裡就不重複敘述了。
那麼接下來我們就需要考慮一下如何觸發頁面的OnCopy
事件,試想一下此時我們的目的有兩個,首先是讓編輯器本身提取內容並規範化,其次是讓轉換後的內容寫入剪貼簿,那麼實現的方式就很明確了,我們只需要主動在頁面上觸發SelectAll
與Copy
命令即可,那麼接下來我們就可以在控制檯中測試這兩個命令的使用。
document.execCommand("selectAll");
const res = document.execCommand("copy");
console.log(res); // true
當我們手動在控制檯執行命令的時候,可以發現頁面上的內容已經被選中並且複製到了剪貼簿中,那麼接下來我們就可以將這兩個命令封裝到一個函式中,然後透過Content Script
注入到頁面中,這樣我們就可以在頁面上直接呼叫這個函式就可以了。然而當我們真正藉助Chrome
擴充套件實現這個功能的時候,會發現頁面能夠正常全部選中,但是剪貼簿的內容卻是上次的內容,也就是本次複製並沒有真正執行成功。
這實際上是由於瀏覽器的安全策略導致的,由於瀏覽器為了加強安全性,限制了一些可能會影響使用者隱私的API
,只有在使用者的直接操作下才能執行,也就是相當於執行Copy
命令只有在使用者主動啟用上下文中才可以正常觸發,與之類似的就是當我們在Js
中主動執行點選事件例如Node.click()
時,其對於瀏覽器來說是不可信的,在事件觸發時會攜帶isTrusted
屬性,只有使用者主動觸發的事件才會為true
。因此我們在控制檯中執行的命令被認為是瀏覽器的可信命令,是使用者主動觸發的事件,而在擴充套件中執行的不是使用者主動觸發的事件,進而命令執行失敗。
那麼為什麼我們在控制檯的命令就可以正常執行呢,實際上這是因為我們在執行控制檯的命令時,會需要點選Enter鍵來執行程式碼,注意這個Enter鍵是我們主動觸發的,因此瀏覽器會將我們執行的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
提取內容之後,這部分內容實際上還是存在於剪貼簿之中的,我們還需要將其提取出來。那麼在執行下面的程式碼之後,我們可以發現OnPaste
和OnCopy
的策略還是不一樣,即使是在使用者的主動操作下,並且我們此時並沒有延時執行,但是其結果依然是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/plain
、text/html
、image/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/plain
、text/html
、image/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
,無論是Selenium
、Puppeteer
、Playwright
都是基於這個協議來實現的。我們甚至可以基於這個協議主動實現F12
的除錯皮膚,也就是說當前在F12
開發者工具能夠實現的功能我們都可以基於這個協議實現,而且其API
也不僅僅只有除錯皮膚的功能實現,並且諸如chrome://inspect
等除錯程式也可以透過這個協議來完成。
那麼在這裡就有新的問題了,如果我們採用Selenium
、Puppeteer
等方案就需要使用者安裝WebDriver
或者Node
等依賴項,不能做到讓使用者開箱即用,那麼在這個時候我們就需要將目光轉向chrome.debugger
了。Chrome.debugger API
可以作為Chrome
的遠端除錯協議的另一種傳輸方式,使用chrome.debugger
可以連線到一個或多個標籤頁來監控網路互動、除錯JavaScript
、修改DOM
和CSS
等等,對我們來說最重要的是這個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 + P
將PDF
列印出來,然而透過列印的方式或者生成圖片的方式匯出的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
下的所有視窗也是可行的,此時需要注意許可權清單中的tabs
與activeTab
許可權的宣告,同樣的在這裡我們仍然需要過濾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
中的paperWidth
和paperHeight
是以inch
為單位的,因此我們需要將其轉換為inch
單位,根據CSS
規範1px = 1/96th of 1 inch
,我們通常可以認為1px = 1/96 inch
而不受裝置物理畫素的影響。此外,我們可以指定一些配置,當前我們輸出的PDF
只會包含第一頁的內容,同時會包含背景顏色、生成文件大綱的配置,並且還有Header
、Footer
等配置選項,我們可以根據實際需求來設定輸出格式,需要注意的是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