瀏覽器插件功能實現-抓取dom/抓取瀏覽器響應/處理流式響應
一、需求背景
實現一個類似于monica的智能體,要求在注入頁面內,當檢測到用戶點擊制定區域后,抓取dom內容,詢問智能體,流式返回內容
二、實現方案
1.抓取dom元素
最開始的構想是直接通過click事件,返回點擊的dom,這里碰到了第一個問題點:用戶點擊的區域是一個iframe,對于iframe,需要考慮是否同源,如果是同源則可以通過contentWindow?.document來獲取。
直接上代碼
1 useEffect(() => { 2 const observer = new MutationObserver(() => { 3 const iframe = document.querySelector( 4 "iframe#amzv-web" 5 ) as HTMLIFrameElement | null; 6 7 if (iframe) { 8 // 等待 iframe 加載完成 9 iframe.onload = () => { 10 const iframeDoc = iframe.contentWindow?.document; 11 12 if (iframeDoc) { 13 // 綁定事件監聽器只在第一次 14 if (!(iframe as CustomHTMLIFrameElement)._clickEventBound) { 15 iframeDoc.addEventListener( 16 "click", 17 (event) => { 18 const target = event.target as HTMLElement; 19 console.log("Target:", target); 20 21 const elements = iframeDoc.querySelectorAll( 22 ".right-box>.item>.label" 23 ) as NodeListOf<HTMLElement>; 24 25 console.log("Elements:", elements); 26 27 const matchedElement = Array.from(elements).find( 28 (el) => el.textContent?.trim() === "工單編號" 29 ); 30 31 if (matchedElement) { 32 const nextSibling = 33 matchedElement.nextElementSibling as HTMLElement | null; 34 const value = 35 nextSibling 36 ?.querySelector(".gb-text-ellipsis") 37 ?.textContent?.trim() || ""; 38 console.log("Work Order Value:", value); 39 } 40 }, 41 true 42 ); 43 44 // 標記事件已綁定 45 (iframe as CustomHTMLIFrameElement)._clickEventBound = true; 46 } 47 } 48 }; 49 } 50 }); 51 52 observer.observe(document.body, { childList: true, subtree: true }); 53 54 return () => observer.disconnect(); // Clean up observer on unmount 55 }, []);
這里目前有個問題,第一次獲取dom后的點擊沒法獲取數據,這里通過定時器延時獲取,我當前dom結構的查詢。
export function getValueByLabelFromIframe( iframeDoc: Document, labelText: string, timeout = 1500 ): Promise<string | null> { return new Promise((resolve) => { const tryFind = () => { const labelElements = iframeDoc.querySelectorAll(".right-box > .item > .label"); const matched = Array.from(labelElements).find( (el) => el.textContent?.trim() === labelText ); if (matched) { const value = matched.nextElementSibling ?.querySelector(".gb-text-ellipsis") ?.textContent?.trim() || null; resolve(value); return true; } return false; }; if (tryFind()) return; const interval = setInterval(() => { if (tryFind()) clearInterval(interval); }, 100); setTimeout(() => { clearInterval(interval); resolve(null); }, timeout); }); }
2.通過抓取接口數據
另一種實現思路是通過抓取接口數據,觀察到在某個接口的response內
chrome本身的能力webRequest可以抓取的信息有限,并沒有response的內容。當然如果能滿足你的需求只使用它能解決是最好的。
這里采取了動態腳本注入的方式,world: 'MAIN' 非常關鍵的一個配置項,使用后可以讓注入的content-script和宿主網頁擁有相同的上下文,就可以實現XMR拓展。
代碼如下
//background.ts chrome.action.onClicked.addListener(function (tab) { chrome.scripting.executeScript({ target: { tabId: tab.id as number }, files: ["inject.js"], world: 'MAIN' }); }); // // inject.js 放在public文件目錄下 (function (xhr) { if (XMLHttpRequest.prototype.sayMyName) return; console.log("%c>>>>> replace XMLHttpRequest", "color:yellow;background:red"); var XHR = XMLHttpRequest.prototype; XHR.sayMyName = "aqinogbei"; // 記錄原始的 open 和 send 方法 var open = XHR.open; var send = XHR.send; XHR.open = function (method, url) { this._method = method; // 記錄method和url this._url = url; return open.apply(this, arguments); }; XHR.send = function () { console.log("send", this._method, this._url); this.addEventListener("load", function (event) { console.log('XHR response received:', event.target.responseText); // 捕獲響應文本 }); return send.apply(this, arguments); }; })(XMLHttpRequest); // 捕獲所有的 fetch 請求 (function () { const originalFetch = window.fetch; window.fetch = function (url, options) { console.log("fetch request:", url, options); return originalFetch(url, options) .then(response => { response.clone().text().then(body => { console.log('fetch response:', body); // 打印響應內容 }); return response; }); }; })();
//manifest.json配置
看似解決了,但是還有個問題,這個腳本是被注入到網頁內的,不是iframe,iframe內的請求還是拿不到。
做到這里的時候,回頭又采用了dom抓取的方案,當然順著這個方向繼續排查應該可以有解決方式。
參考:https://segmentfault.com/a/1190000045278358
3.接口請求
接口請求參考https://juejin.cn/post/7396933333493170228
我們的接口請求一定會跨域,所有的fetch行為必須要放在background.ts內處理,然后把響應交給content_script.js
## 4.30更新
后續就是一些常規的流式數據處理,沒什么難點。
需求要求實現點擊切換其他卡片的時候中止當前stream,直接AbortController解決。同時封裝請求,改造成單例。
實現打字特效還是用的每次更新dom,暫時沒想出更好的優化點。
## 5.13更新
關于數據處理,目前把sendmessage方案放棄了,對于sse,使用port更方便且穩定
// portClient.ts let portInstance: chrome.runtime.Port | null = null; export const getPort = (portName = "stream-channel") => { if (!portInstance) { portInstance = chrome.runtime.connect({ name: portName }); } return portInstance; };
通過port發送消息
port.postMessage(message);
sendMessage使用例
(async () => { // 使用 sendMessage 從 Content 發送消息 const response = await chrome.runtime.sendMessage({greeting: "hello"}); console.log(response); // 使用 onMessage.addListener Content 接收消息 chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { console.log(sender.tab ? "from a content script:" + sender.tab.url : "from the extension"); if (request.greeting === "hello") sendResponse({farewell: "goodbye"}); }); // 使用 connect 從 Content 發送和接收消息 var port = chrome.runtime.connect({name: "knockknock"}); port.postMessage({joke: "Knock knock"}); port.onMessage.addListener(function(msg) { if (msg.question === "Who's there?") port.postMessage({answer: "Madame"}); else if (msg.question === "Madame who?") port.postMessage({answer: "Madame... Bovary"}); }); })();
sendResponse的回調非常關鍵,如果不寫會造成報錯關閉channel導致組件重新加載


浙公網安備 33010602011771號