基於Chrome擴充套件的瀏覽器可信事件與網頁離線PDF匯出

WindrunnerMax發表於2024-07-02

基於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。因此我們在控制檯中執行的命令被認為是瀏覽器的可信命令,是使用者主動觸發的事件,而在擴充套件中執行的不是使用者主動觸發的事件,進而命令執行失敗。

那麼為什麼我們在控制檯的命令就可以正常執行呢,實際上這是因為我們在執行控制檯的命令時,會需要點選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提取內容之後,這部分內容實際上還是存在於剪貼簿之中的,我們還需要將其提取出來。那麼在執行下面的程式碼之後,我們可以發現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

相關文章