diff --git a/plugin/lib/quickcomposer/browser/browser.js b/plugin/lib/quickcomposer/browser/browser.js index 22ff72e..7c688ae 100644 --- a/plugin/lib/quickcomposer/browser/browser.js +++ b/plugin/lib/quickcomposer/browser/browser.js @@ -6,10 +6,7 @@ const http = require("http"); const axios = require("axios"); const fs = require("fs"); -let client = null; -let Page = null; -let Runtime = null; -let DOM = null; +let clients = new Map(); // 存储每个标签页的CDP客户端 const getBrowserPath = (browser = "msedge") => { const platform = os.platform(); @@ -120,7 +117,6 @@ const launchBrowser = async (options) => { throw new Error("未找到浏览器,或未指定浏览器路径"); } - // 查找可用端口 const port = await findAvailablePort(9222); const args = [ @@ -140,7 +136,6 @@ const launchBrowser = async (options) => { ].filter(Boolean); return new Promise(async (resolve, reject) => { - // 如果使用独立用户数据目录,则需要先杀死已有的浏览器进程 if (!useSingleUserDataDir) { try { await killRunningBrowser(browserType); @@ -151,9 +146,7 @@ const launchBrowser = async (options) => { } const child = exec( `"${browserPath}" ${args.join(" ")}`, - { - windowsHide: true, - }, + { windowsHide: true }, async (error) => { if (error) { reject(error); @@ -162,13 +155,9 @@ const launchBrowser = async (options) => { } ); - // 等待端口可用 waitForPort(port).then((success) => { if (success) { - resolve({ - pid: child.pid, - port, - }); // 返回使用的端口号 + resolve({ pid: child.pid, port }); } else { reject(new Error("浏览器启动超时,请检查是否有权限问题或防火墙限制")); } @@ -192,78 +181,247 @@ const killRunningBrowser = (browserType = "msedge") => { }); }; -const initCDP = async (port) => { - if (!client) { +const initCDP = async (targetId) => { + if (!clients.has(targetId)) { try { - client = await CDP({ port }); - ({ Page, Runtime, DOM } = client); - await Promise.all([Page.enable(), Runtime.enable(), DOM.enable()]); + const client = await CDP({ target: targetId }); + const { Page, Runtime, Target, Network, Emulation } = client; + await Promise.all([Page.enable(), Runtime.enable()]); + clients.set(targetId, { + client, + Page, + Runtime, + Target, + Network, + Emulation, + }); } catch (err) { console.log(err); throw new Error(`请先通过浏览器控制中的"启动浏览器"打开浏览器`); } } - return { Page, Runtime, DOM }; + return clients.get(targetId); }; -const getUrl = async () => { - const { Page } = await initCDP(); - const { frameTree } = await Page.getFrameTree(); - return frameTree.frame.url; -}; - -const setUrl = async (url) => { - const { Page } = await initCDP(); - await Page.navigate({ url }); - await Page.loadEventFired(); -}; - -const executeScript = async (script, args = {}) => { - const { Runtime } = await initCDP(); - // 构建参数列表 - const argNames = Object.keys(args); - const argValues = Object.values(args).map((v) => JSON.stringify(v)); - - const wrappedScript = ` - (async function(${argNames.join(", ")}) { - ${script} - })(${argValues.join(", ")}) - `; - const { result } = await Runtime.evaluate({ - expression: wrappedScript, - returnByValue: true, - awaitPromise: true, - }); - return result.value; -}; - -const setCookie = async (cookies, options = {}) => { - const { Network } = await initCDP(); - for (const cookie of cookies) { - await Network.setCookie({ - name: cookie.name, - value: cookie.value, - domain: options.domain || undefined, - path: options.path || "/", - secure: options.secure || false, - expires: options.expires - ? Math.floor(Date.now() / 1000) + options.expires * 3600 - : undefined, - }); +const cleanupCDP = async (targetId) => { + const client = clients.get(targetId); + if (client) { + await client.client.close(); + clients.delete(targetId); } }; -const getCookie = async (name) => { - const { Network } = await initCDP(); - const cookies = await Network.getCookies(); +// 获取所有标签页 +const getTabs = async () => { + const targets = await CDP.List(); + return targets + .filter((target) => target.type === "page") + .map((target) => ({ + url: target.url, + title: target.title, + id: target.id, + })); +}; + +// 获取当前活动标签页 +const getCurrentTab = async () => { + const targets = await CDP.List(); + // 一般排第一个的就是活动标签页 + const currentTarget = targets.find((target) => target.type === "page"); + + if (!currentTarget) { + throw new Error("未找到当前活动标签页"); + } + + return { + url: currentTarget.url, + title: currentTarget.title, + id: currentTarget.id, + }; +}; + +// 搜索标签页 +const searchTarget = async (tab) => { + if (!tab || !tab.by || !tab.searchValue || tab.by === "active") { + const currentTab = await getCurrentTab(); + return currentTab; + } + + const targets = await CDP.List(); + const target = targets.find((target) => + target[tab.by].includes(tab.searchValue) + ); + if (!target) { + throw new Error(`未找到目标标签页: ${tab.by} = ${tab.searchValue}`); + } + return target; +}; + +// 激活指定标签页 +const activateTab = async (tab) => { + const target = await searchTarget(tab); + await CDP.Activate({ id: target.id }); +}; + +// 创建新标签页 +const createNewTab = async (url = "about:blank") => { + const currentTab = await getCurrentTab(); + const { Target } = await initCDP(currentTab.id); + const { targetId } = await Target.createTarget({ url }); + const { targetInfo } = await Target.getTargetInfo({ targetId }); + await cleanupCDP(currentTab.id); + return { + url: targetInfo.url, + title: targetInfo.title, + id: targetId, + }; +}; + +// 关闭标签页 +const closeTab = async (tab) => { + const target = await searchTarget(tab); + await cleanupCDP(target.id); + await CDP.Close({ id: target.id }); +}; + +const getUrl = async (tab) => { + const target = await searchTarget(tab); + const { Page } = await initCDP(target.id); + const { frameTree } = await Page.getFrameTree(); + await cleanupCDP(target.id); + return frameTree.frame.url; +}; + +const setUrl = async (tab, url) => { + const target = await searchTarget(tab); + const { Page } = await initCDP(target.id); + await Page.navigate({ url }); + await Page.loadEventFired(); + await cleanupCDP(target.id); +}; + +const executeScript = async (tab, script, args = {}) => { + const target = await searchTarget(tab); + try { + const { Runtime } = await initCDP(target.id); + const argNames = Object.keys(args); + const argValues = Object.values(args).map((v) => JSON.stringify(v)); + + const wrappedScript = ` + (function(${argNames.join(", ")}) { + ${script} + })(${argValues.join(", ")}) + `; + + const { result } = await Runtime.evaluate({ + expression: wrappedScript, + returnByValue: true, + awaitPromise: true, + }); + + await cleanupCDP(target.id); + return result.value; + } catch (e) { + console.log(e); + throw new Error("执行脚本失败"); + } +}; + +const setCookie = async (tab, cookies, options = {}) => { + const target = await searchTarget(tab); + const { Network, Page } = await initCDP(target.id); + try { + // 直接从Page获取URL,避免创建新连接 + const { frameTree } = await Page.getFrameTree(); + const url = frameTree.frame.url; + + for (const cookie of cookies) { + await Network.setCookie({ + name: cookie.name, + value: cookie.value, + domain: options.domain || url.split("/")[2], + path: options.path || "/", + secure: options.secure || false, + expires: options.expires + ? Math.floor(Date.now() / 1000) + options.expires * 3600 + : undefined, + }); + } + } finally { + await cleanupCDP(target.id); + } +}; + +const getCookie = async (tab, name) => { + const target = await searchTarget(tab); + const { Network } = await initCDP(target.id); + const { cookies } = await Network.getCookies(); + await cleanupCDP(target.id); + if (!name) return cookies; return cookies.find((cookie) => cookie.name === name); }; +// 捕获标签页截图 +const captureScreenshot = async (tab, options = {}) => { + const target = await searchTarget(tab); + const { format = "png", quality = 100, fullPage = false, savePath } = options; + + try { + const { Page, Emulation } = await initCDP(target.id); + + if (fullPage) { + const metrics = await Page.getLayoutMetrics(); + const width = Math.max( + metrics.contentSize.width, + metrics.layoutViewport.clientWidth, + metrics.visualViewport.clientWidth + ); + const height = Math.max( + metrics.contentSize.height, + metrics.layoutViewport.clientHeight, + metrics.visualViewport.clientHeight + ); + await Emulation.setDeviceMetricsOverride({ + width, + height, + deviceScaleFactor: 1, + mobile: false, + }); + } + + const { data } = await Page.captureScreenshot({ + format, + quality: format === "jpeg" ? quality : undefined, + fromSurface: true, + captureBeyondViewport: fullPage, + }); + + if (fullPage) { + await Emulation.clearDeviceMetricsOverride(); + } + + if (savePath) { + fs.writeFileSync(savePath, data, "base64"); + } + + return data; + } finally { + await cleanupCDP(target.id); + } +}; + module.exports = { launchBrowser, + killRunningBrowser, + getTabs, + getCurrentTab, + activateTab, + createNewTab, + closeTab, getUrl, setUrl, executeScript, setCookie, getCookie, + captureScreenshot, }; diff --git a/plugin/lib/quickcomposer/browser/script.js b/plugin/lib/quickcomposer/browser/execScript.js similarity index 69% rename from plugin/lib/quickcomposer/browser/script.js rename to plugin/lib/quickcomposer/browser/execScript.js index b388365..4075b18 100644 --- a/plugin/lib/quickcomposer/browser/script.js +++ b/plugin/lib/quickcomposer/browser/execScript.js @@ -1,11 +1,15 @@ const { executeScript } = require("./browser"); -const clickElement = async (selector) => { - return await executeScript(`document.querySelector('${selector}').click()`); +const clickElement = async (tab, selector) => { + return await executeScript( + tab, + `document.querySelector('${selector}').click()` + ); }; -const inputText = async (selector, text) => { +const inputText = async (tab, selector, text) => { return await executeScript( + tab, ` const el = document.querySelector('${selector}'); el.value = '${text}'; @@ -15,52 +19,62 @@ const inputText = async (selector, text) => { ); }; -const getText = async (selector) => { +const getText = async (tab, selector) => { return await executeScript( + tab, `document.querySelector('${selector}')?.textContent || ''` ); }; -const getHtml = async (selector) => { +const getHtml = async (tab, selector) => { return await executeScript( + tab, `const element = document.querySelector('${selector}'); return element ? element.innerHTML : '';` ); }; -const hideElement = async (selector) => { +const hideElement = async (tab, selector) => { return await executeScript( + tab, `document.querySelector('${selector}').style.display = 'none'` ); }; -const showElement = async (selector) => { +const showElement = async (tab, selector) => { return await executeScript( + tab, `document.querySelector('${selector}').style.display = ''` ); }; -const scrollTo = async (x, y) => { - return await executeScript(`window.scrollTo(${x}, ${y})`); +const scrollTo = async (tab, x, y) => { + return await executeScript(tab, `window.scrollTo(${x}, ${y})`); }; -const scrollToElement = async (selector) => { +const scrollToElement = async (tab, selector) => { return await executeScript( + tab, `document.querySelector('${selector}').scrollIntoView()` ); }; -const getScrollPosition = async () => { - return await executeScript(` +const getScrollPosition = async (tab) => { + return await executeScript( + tab, + ` return JSON.stringify({ x: window.pageXOffset || document.documentElement.scrollLeft, y: window.pageYOffset || document.documentElement.scrollTop }); - `); + ` + ); }; -const getPageSize = async () => { - return await executeScript(` +const getPageSize = async (tab) => { + return await executeScript( + tab, + ` return JSON.stringify({ width: Math.max( document.documentElement.scrollWidth, @@ -71,13 +85,15 @@ const getPageSize = async () => { document.documentElement.clientHeight ) }); - `); + ` + ); }; -const waitForElement = async (selector, timeout = 5000) => { +const waitForElement = async (tab, selector, timeout = 5000) => { const startTime = Date.now(); while (Date.now() - startTime < timeout) { const result = await executeScript( + tab, `!!document.querySelector('${selector}')` ); if (result) return; @@ -86,8 +102,9 @@ const waitForElement = async (selector, timeout = 5000) => { throw new Error(`等待元素 ${selector} 超时`); }; -const injectCSS = async (css) => { +const injectCSS = async (tab, css) => { return await executeScript( + tab, ` const style = document.createElement('style'); style.textContent = \`${css}\`; diff --git a/plugin/lib/quickcomposer/browser/getSelector.js b/plugin/lib/quickcomposer/browser/getSelector.js index 849fb5b..6a16eb5 100644 --- a/plugin/lib/quickcomposer/browser/getSelector.js +++ b/plugin/lib/quickcomposer/browser/getSelector.js @@ -116,8 +116,10 @@ const getOptimalSelector = () => { `; }; -const getSelector = async () => { - return await executeScript(` +const getSelector = async (tab) => { + return await executeScript( + tab, + ` return new Promise((resolve) => { // 创建高亮元素 const highlight = document.createElement('div'); @@ -172,7 +174,8 @@ const getSelector = async () => { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('click', handleClick, true); }); - `); + ` + ); }; module.exports = { diff --git a/plugin/lib/quickcomposer/browser/index.js b/plugin/lib/quickcomposer/browser/index.js index ac69262..60b5507 100644 --- a/plugin/lib/quickcomposer/browser/index.js +++ b/plugin/lib/quickcomposer/browser/index.js @@ -1,11 +1,9 @@ const browser = require("./browser"); -const tabs = require("./tabs"); const getSelector = require("./getSelector"); -const script = require("./script"); +const execScript = require("./execScript"); module.exports = { ...browser, ...getSelector, - ...tabs, - ...script, + ...execScript, }; diff --git a/plugin/lib/quickcomposer/browser/tabs.js b/plugin/lib/quickcomposer/browser/tabs.js deleted file mode 100644 index de341cf..0000000 --- a/plugin/lib/quickcomposer/browser/tabs.js +++ /dev/null @@ -1,76 +0,0 @@ -const CDP = require("chrome-remote-interface"); -const { executeScript } = require("./browser"); - -let client = null; -let Page = null; -let Runtime = null; -let Target = null; - -const initCDP = async (port) => { - if (!client) { - try { - client = await CDP({ port }); - ({ Page, Runtime, Target } = client); - await Promise.all([Page.enable(), Runtime.enable()]); - } catch (err) { - console.log(err); - throw new Error(`请先通过浏览器控制中的"启动浏览器"打开浏览器`); - } - } - return { Page, Runtime, Target }; -}; - -// 获取所有标签页 -const getTabs = async () => { - const targets = await CDP.List(); - return targets - .filter((target) => target.type === "page") - .map((target) => ({ - url: target.url, - title: target.title, - id: target.id, - })); -}; - -const searchTarget = async (searchProperty, searchValue) => { - const targets = await CDP.List(); - const target = targets.find((target) => - target[searchProperty].includes(searchValue) - ); - if (!target) { - throw new Error(`未找到目标: ${searchProperty} = ${searchValue}`); - } - return target; -}; - -// 激活指定标签页 -const activateTab = async (searchProperty, searchValue) => { - const target = await searchTarget(searchProperty, searchValue); - await CDP.Activate({ id: target.id }); -}; - -// 创建新标签页 -const createNewTab = async (url = "about:blank") => { - const { Target } = await initCDP(); - const { targetId } = await Target.createTarget({ url }); - const { targetInfo } = await Target.getTargetInfo({ targetId }); - return { - url: targetInfo.url, - title: targetInfo.title, - id: targetId, - }; -}; - -// 关闭标签页 -const closeTab = async (searchProperty, searchValue) => { - const target = await searchTarget(searchProperty, searchValue); - await CDP.Close({ id: target.id }); -}; - - -module.exports = { - getTabs, - activateTab, - createNewTab, - closeTab, -}; diff --git a/src/js/composer/commands/browserCommands.js b/src/js/composer/commands/browserCommands.js index 0a7c6b6..e3e122b 100644 --- a/src/js/composer/commands/browserCommands.js +++ b/src/js/composer/commands/browserCommands.js @@ -1,5 +1,32 @@ import { newVarInputVal } from "js/composer/varInputValManager"; +const tabConfig = { + component: "OptionEditor", + width: 12, + options: { + by: { + component: "QSelect", + label: "标签", + width: 3, + options: [ + { label: "当前标签页", value: "active" }, + { label: "通过URL", value: "url" }, + { label: "通过标题", value: "title" }, + { label: "通过ID", value: "id" }, + ], + }, + searchValue: { + component: "VariableInput", + icon: "tab", + width: 9, + placeholder: "当前标签页留空,其他支持模糊匹配", + }, + }, + defaultValue: { + by: "active", + }, +}; + export const browserCommands = { label: "浏览器控制", icon: "web", @@ -79,6 +106,7 @@ export const browserCommands = { label: "获取/设置网址", icon: "link", isAsync: true, + config: [tabConfig], subCommands: [ { value: "quickcomposer.browser.getUrl", @@ -103,7 +131,7 @@ export const browserCommands = { }, { value: "quickcomposer.browser.getTabs", - label: "获取/切换标签", + label: "标签操作", icon: "tab", isAsync: true, subCommands: [ @@ -116,26 +144,12 @@ export const browserCommands = { value: "quickcomposer.browser.activateTab", label: "切换标签", icon: "tab_unselected", - config: [ - { - component: "QSelect", - icon: "tab", - width: 3, - options: [ - { label: "通过URL", value: "url" }, - { label: "通过标题", value: "title" }, - { label: "通过ID", value: "id" }, - ], - defaultValue: "url", - }, - { - label: "URL/标题/ID", - component: "VariableInput", - icon: "tab", - width: 9, - placeholder: "支持模糊匹配", - }, - ], + config: [tabConfig], + }, + { + value: "quickcomposer.browser.getCurrentTab", + label: "获取当前标签页", + icon: "tab", }, { value: "quickcomposer.browser.createNewTab", @@ -155,26 +169,69 @@ export const browserCommands = { value: "quickcomposer.browser.closeTab", label: "关闭标签页", icon: "tab", - config: [ - { + config: [tabConfig], + }, + ], + }, + { + value: "quickcomposer.browser.captureScreenshot", + label: "捕获截图", + icon: "screenshot", + config: [ + tabConfig, + { + label: "选项", + component: "OptionEditor", + icon: "settings", + width: 12, + options: { + format: { + label: "格式", component: "QSelect", - icon: "tab", - width: 3, + icon: "format", + width: 4, options: [ - { label: "通过URL", value: "url" }, - { label: "通过标题", value: "title" }, - { label: "通过ID", value: "id" }, + { label: "PNG", value: "png" }, + { label: "JPEG", value: "jpeg" }, + { label: "WebP", value: "webp" }, ], - defaultValue: "url", }, - { - label: "搜索条件", + quality: { + label: "质量", + component: "NumberInput", + icon: "quality", + width: 4, + min: 0, + max: 100, + }, + fullPage: { + label: "全屏截图", + component: "CheckButton", + icon: "fullscreen", + width: 4, + }, + savePath: { + label: "保存路径", component: "VariableInput", - icon: "tab", - width: 9, - placeholder: "支持模糊匹配", + icon: "folder", + placeholder: "留空则不保存", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存截图", + properties: ["saveFile"], + }, + }, + }, }, - ], + }, + defaultValue: { + format: "png", + quality: 100, + fullPage: false, + }, }, ], }, @@ -184,6 +241,7 @@ export const browserCommands = { icon: "code", isAsync: true, config: [ + tabConfig, { label: "脚本内容", component: "CodeEditor", @@ -204,6 +262,7 @@ export const browserCommands = { label: "Cookie操作", icon: "cookie", isAsync: true, + config: [tabConfig], subCommands: [ { value: "quickcomposer.browser.setCookie", @@ -282,7 +341,7 @@ export const browserCommands = { component: "VariableInput", icon: "label", width: 12, - placeholder: "输入Cookie名称", + placeholder: "输入Cookie名称,留空则获取所有", }, ], }, @@ -294,6 +353,7 @@ export const browserCommands = { icon: "style", isAsync: true, config: [ + tabConfig, { label: "CSS内容", component: "CodeEditor", @@ -303,19 +363,13 @@ export const browserCommands = { }, ], }, - { - value: "quickcomposer.browser.getSelector", - label: "手动选择元素", - icon: "mouse", - isAsync: true, - config: [], - }, { value: "quickcomposer.browser.clickElement", label: "元素操作", icon: "web", isAsync: true, config: [ + tabConfig, { label: "选择器", component: "VariableInput", @@ -395,6 +449,7 @@ export const browserCommands = { label: "滚动及页面尺寸", icon: "open_in_full", isAsync: true, + config: [tabConfig], subCommands: [ { value: "quickcomposer.browser.scrollTo",