重构浏览器自动化功能:执行脚本,获取地址等操作,需指定标签页,新增标签页截图功能

This commit is contained in:
fofolee 2025-01-23 00:30:09 +08:00
parent 26360e9643
commit e942259c2e
6 changed files with 366 additions and 211 deletions

View File

@ -6,10 +6,7 @@ const http = require("http");
const axios = require("axios"); const axios = require("axios");
const fs = require("fs"); const fs = require("fs");
let client = null; let clients = new Map(); // 存储每个标签页的CDP客户端
let Page = null;
let Runtime = null;
let DOM = null;
const getBrowserPath = (browser = "msedge") => { const getBrowserPath = (browser = "msedge") => {
const platform = os.platform(); const platform = os.platform();
@ -120,7 +117,6 @@ const launchBrowser = async (options) => {
throw new Error("未找到浏览器,或未指定浏览器路径"); throw new Error("未找到浏览器,或未指定浏览器路径");
} }
// 查找可用端口
const port = await findAvailablePort(9222); const port = await findAvailablePort(9222);
const args = [ const args = [
@ -140,7 +136,6 @@ const launchBrowser = async (options) => {
].filter(Boolean); ].filter(Boolean);
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
// 如果使用独立用户数据目录,则需要先杀死已有的浏览器进程
if (!useSingleUserDataDir) { if (!useSingleUserDataDir) {
try { try {
await killRunningBrowser(browserType); await killRunningBrowser(browserType);
@ -151,9 +146,7 @@ const launchBrowser = async (options) => {
} }
const child = exec( const child = exec(
`"${browserPath}" ${args.join(" ")}`, `"${browserPath}" ${args.join(" ")}`,
{ { windowsHide: true },
windowsHide: true,
},
async (error) => { async (error) => {
if (error) { if (error) {
reject(error); reject(error);
@ -162,13 +155,9 @@ const launchBrowser = async (options) => {
} }
); );
// 等待端口可用
waitForPort(port).then((success) => { waitForPort(port).then((success) => {
if (success) { if (success) {
resolve({ resolve({ pid: child.pid, port });
pid: child.pid,
port,
}); // 返回使用的端口号
} else { } else {
reject(new Error("浏览器启动超时,请检查是否有权限问题或防火墙限制")); reject(new Error("浏览器启动超时,请检查是否有权限问题或防火墙限制"));
} }
@ -192,78 +181,247 @@ const killRunningBrowser = (browserType = "msedge") => {
}); });
}; };
const initCDP = async (port) => { const initCDP = async (targetId) => {
if (!client) { if (!clients.has(targetId)) {
try { try {
client = await CDP({ port }); const client = await CDP({ target: targetId });
({ Page, Runtime, DOM } = client); const { Page, Runtime, Target, Network, Emulation } = client;
await Promise.all([Page.enable(), Runtime.enable(), DOM.enable()]); await Promise.all([Page.enable(), Runtime.enable()]);
clients.set(targetId, {
client,
Page,
Runtime,
Target,
Network,
Emulation,
});
} catch (err) { } catch (err) {
console.log(err); console.log(err);
throw new Error(`请先通过浏览器控制中的"启动浏览器"打开浏览器`); throw new Error(`请先通过浏览器控制中的"启动浏览器"打开浏览器`);
} }
} }
return { Page, Runtime, DOM }; return clients.get(targetId);
}; };
const getUrl = async () => { const cleanupCDP = async (targetId) => {
const { Page } = await initCDP(); const client = clients.get(targetId);
const { frameTree } = await Page.getFrameTree(); if (client) {
return frameTree.frame.url; await client.client.close();
}; clients.delete(targetId);
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 getCookie = async (name) => { // 获取所有标签页
const { Network } = await initCDP(); const getTabs = async () => {
const cookies = await Network.getCookies(); 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); 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 = { module.exports = {
launchBrowser, launchBrowser,
killRunningBrowser,
getTabs,
getCurrentTab,
activateTab,
createNewTab,
closeTab,
getUrl, getUrl,
setUrl, setUrl,
executeScript, executeScript,
setCookie, setCookie,
getCookie, getCookie,
captureScreenshot,
}; };

View File

@ -1,11 +1,15 @@
const { executeScript } = require("./browser"); const { executeScript } = require("./browser");
const clickElement = async (selector) => { const clickElement = async (tab, selector) => {
return await executeScript(`document.querySelector('${selector}').click()`); return await executeScript(
tab,
`document.querySelector('${selector}').click()`
);
}; };
const inputText = async (selector, text) => { const inputText = async (tab, selector, text) => {
return await executeScript( return await executeScript(
tab,
` `
const el = document.querySelector('${selector}'); const el = document.querySelector('${selector}');
el.value = '${text}'; el.value = '${text}';
@ -15,52 +19,62 @@ const inputText = async (selector, text) => {
); );
}; };
const getText = async (selector) => { const getText = async (tab, selector) => {
return await executeScript( return await executeScript(
tab,
`document.querySelector('${selector}')?.textContent || ''` `document.querySelector('${selector}')?.textContent || ''`
); );
}; };
const getHtml = async (selector) => { const getHtml = async (tab, selector) => {
return await executeScript( return await executeScript(
tab,
`const element = document.querySelector('${selector}'); `const element = document.querySelector('${selector}');
return element ? element.innerHTML : '';` return element ? element.innerHTML : '';`
); );
}; };
const hideElement = async (selector) => { const hideElement = async (tab, selector) => {
return await executeScript( return await executeScript(
tab,
`document.querySelector('${selector}').style.display = 'none'` `document.querySelector('${selector}').style.display = 'none'`
); );
}; };
const showElement = async (selector) => { const showElement = async (tab, selector) => {
return await executeScript( return await executeScript(
tab,
`document.querySelector('${selector}').style.display = ''` `document.querySelector('${selector}').style.display = ''`
); );
}; };
const scrollTo = async (x, y) => { const scrollTo = async (tab, x, y) => {
return await executeScript(`window.scrollTo(${x}, ${y})`); return await executeScript(tab, `window.scrollTo(${x}, ${y})`);
}; };
const scrollToElement = async (selector) => { const scrollToElement = async (tab, selector) => {
return await executeScript( return await executeScript(
tab,
`document.querySelector('${selector}').scrollIntoView()` `document.querySelector('${selector}').scrollIntoView()`
); );
}; };
const getScrollPosition = async () => { const getScrollPosition = async (tab) => {
return await executeScript(` return await executeScript(
tab,
`
return JSON.stringify({ return JSON.stringify({
x: window.pageXOffset || document.documentElement.scrollLeft, x: window.pageXOffset || document.documentElement.scrollLeft,
y: window.pageYOffset || document.documentElement.scrollTop y: window.pageYOffset || document.documentElement.scrollTop
}); });
`); `
);
}; };
const getPageSize = async () => { const getPageSize = async (tab) => {
return await executeScript(` return await executeScript(
tab,
`
return JSON.stringify({ return JSON.stringify({
width: Math.max( width: Math.max(
document.documentElement.scrollWidth, document.documentElement.scrollWidth,
@ -71,13 +85,15 @@ const getPageSize = async () => {
document.documentElement.clientHeight document.documentElement.clientHeight
) )
}); });
`); `
);
}; };
const waitForElement = async (selector, timeout = 5000) => { const waitForElement = async (tab, selector, timeout = 5000) => {
const startTime = Date.now(); const startTime = Date.now();
while (Date.now() - startTime < timeout) { while (Date.now() - startTime < timeout) {
const result = await executeScript( const result = await executeScript(
tab,
`!!document.querySelector('${selector}')` `!!document.querySelector('${selector}')`
); );
if (result) return; if (result) return;
@ -86,8 +102,9 @@ const waitForElement = async (selector, timeout = 5000) => {
throw new Error(`等待元素 ${selector} 超时`); throw new Error(`等待元素 ${selector} 超时`);
}; };
const injectCSS = async (css) => { const injectCSS = async (tab, css) => {
return await executeScript( return await executeScript(
tab,
` `
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = \`${css}\`; style.textContent = \`${css}\`;

View File

@ -116,8 +116,10 @@ const getOptimalSelector = () => {
`; `;
}; };
const getSelector = async () => { const getSelector = async (tab) => {
return await executeScript(` return await executeScript(
tab,
`
return new Promise((resolve) => { return new Promise((resolve) => {
// 创建高亮元素 // 创建高亮元素
const highlight = document.createElement('div'); const highlight = document.createElement('div');
@ -172,7 +174,8 @@ const getSelector = async () => {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('click', handleClick, true); document.addEventListener('click', handleClick, true);
}); });
`); `
);
}; };
module.exports = { module.exports = {

View File

@ -1,11 +1,9 @@
const browser = require("./browser"); const browser = require("./browser");
const tabs = require("./tabs");
const getSelector = require("./getSelector"); const getSelector = require("./getSelector");
const script = require("./script"); const execScript = require("./execScript");
module.exports = { module.exports = {
...browser, ...browser,
...getSelector, ...getSelector,
...tabs, ...execScript,
...script,
}; };

View File

@ -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,
};

View File

@ -1,5 +1,32 @@
import { newVarInputVal } from "js/composer/varInputValManager"; 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 = { export const browserCommands = {
label: "浏览器控制", label: "浏览器控制",
icon: "web", icon: "web",
@ -79,6 +106,7 @@ export const browserCommands = {
label: "获取/设置网址", label: "获取/设置网址",
icon: "link", icon: "link",
isAsync: true, isAsync: true,
config: [tabConfig],
subCommands: [ subCommands: [
{ {
value: "quickcomposer.browser.getUrl", value: "quickcomposer.browser.getUrl",
@ -103,7 +131,7 @@ export const browserCommands = {
}, },
{ {
value: "quickcomposer.browser.getTabs", value: "quickcomposer.browser.getTabs",
label: "获取/切换标签", label: "标签操作",
icon: "tab", icon: "tab",
isAsync: true, isAsync: true,
subCommands: [ subCommands: [
@ -116,26 +144,12 @@ export const browserCommands = {
value: "quickcomposer.browser.activateTab", value: "quickcomposer.browser.activateTab",
label: "切换标签", label: "切换标签",
icon: "tab_unselected", icon: "tab_unselected",
config: [ config: [tabConfig],
{ },
component: "QSelect", {
icon: "tab", value: "quickcomposer.browser.getCurrentTab",
width: 3, label: "获取当前标签页",
options: [ icon: "tab",
{ label: "通过URL", value: "url" },
{ label: "通过标题", value: "title" },
{ label: "通过ID", value: "id" },
],
defaultValue: "url",
},
{
label: "URL/标题/ID",
component: "VariableInput",
icon: "tab",
width: 9,
placeholder: "支持模糊匹配",
},
],
}, },
{ {
value: "quickcomposer.browser.createNewTab", value: "quickcomposer.browser.createNewTab",
@ -155,26 +169,69 @@ export const browserCommands = {
value: "quickcomposer.browser.closeTab", value: "quickcomposer.browser.closeTab",
label: "关闭标签页", label: "关闭标签页",
icon: "tab", 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", component: "QSelect",
icon: "tab", icon: "format",
width: 3, width: 4,
options: [ options: [
{ label: "通过URL", value: "url" }, { label: "PNG", value: "png" },
{ label: "通过标题", value: "title" }, { label: "JPEG", value: "jpeg" },
{ label: "通过ID", value: "id" }, { label: "WebP", value: "webp" },
], ],
defaultValue: "url",
}, },
{ quality: {
label: "搜索条件", label: "质量",
component: "NumberInput",
icon: "quality",
width: 4,
min: 0,
max: 100,
},
fullPage: {
label: "全屏截图",
component: "CheckButton",
icon: "fullscreen",
width: 4,
},
savePath: {
label: "保存路径",
component: "VariableInput", component: "VariableInput",
icon: "tab", icon: "folder",
width: 9, placeholder: "留空则不保存",
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", icon: "code",
isAsync: true, isAsync: true,
config: [ config: [
tabConfig,
{ {
label: "脚本内容", label: "脚本内容",
component: "CodeEditor", component: "CodeEditor",
@ -204,6 +262,7 @@ export const browserCommands = {
label: "Cookie操作", label: "Cookie操作",
icon: "cookie", icon: "cookie",
isAsync: true, isAsync: true,
config: [tabConfig],
subCommands: [ subCommands: [
{ {
value: "quickcomposer.browser.setCookie", value: "quickcomposer.browser.setCookie",
@ -282,7 +341,7 @@ export const browserCommands = {
component: "VariableInput", component: "VariableInput",
icon: "label", icon: "label",
width: 12, width: 12,
placeholder: "输入Cookie名称", placeholder: "输入Cookie名称,留空则获取所有",
}, },
], ],
}, },
@ -294,6 +353,7 @@ export const browserCommands = {
icon: "style", icon: "style",
isAsync: true, isAsync: true,
config: [ config: [
tabConfig,
{ {
label: "CSS内容", label: "CSS内容",
component: "CodeEditor", component: "CodeEditor",
@ -303,19 +363,13 @@ export const browserCommands = {
}, },
], ],
}, },
{
value: "quickcomposer.browser.getSelector",
label: "手动选择元素",
icon: "mouse",
isAsync: true,
config: [],
},
{ {
value: "quickcomposer.browser.clickElement", value: "quickcomposer.browser.clickElement",
label: "元素操作", label: "元素操作",
icon: "web", icon: "web",
isAsync: true, isAsync: true,
config: [ config: [
tabConfig,
{ {
label: "选择器", label: "选择器",
component: "VariableInput", component: "VariableInput",
@ -395,6 +449,7 @@ export const browserCommands = {
label: "滚动及页面尺寸", label: "滚动及页面尺寸",
icon: "open_in_full", icon: "open_in_full",
isAsync: true, isAsync: true,
config: [tabConfig],
subCommands: [ subCommands: [
{ {
value: "quickcomposer.browser.scrollTo", value: "quickcomposer.browser.scrollTo",