新增分类:浏览器控制,基于CDP对edge和chrome进行自动化操作

This commit is contained in:
fofolee 2025-01-19 13:43:10 +08:00
parent 3b49adcd59
commit 1a1ec4b45f
6 changed files with 1199 additions and 336 deletions

View File

@ -0,0 +1,400 @@
const CDP = require("chrome-remote-interface");
const { exec } = require("child_process");
const path = require("path");
const os = require("os");
const http = require("http");
const axios = require("axios");
const fs = require("fs");
let client = null;
let Page = null;
let Runtime = null;
let DOM = null;
const getBrowserPath = (browser = "msedge") => {
const platform = os.platform();
let paths = null;
if (platform === "win32") {
paths = {
chrome: [
path.join(
process.env["ProgramFiles"],
"Google/Chrome/Application/chrome.exe"
),
path.join(
process.env["ProgramFiles(x86)"],
"Google/Chrome/Application/chrome.exe"
),
path.join(
process.env["LocalAppData"],
"Google/Chrome/Application/chrome.exe"
),
],
msedge: [
path.join(
process.env["ProgramFiles"],
"Microsoft/Edge/Application/msedge.exe"
),
path.join(
process.env["ProgramFiles(x86)"],
"Microsoft/Edge/Application/msedge.exe"
),
path.join(
process.env["LocalAppData"],
"Microsoft/Edge/Application/msedge.exe"
),
],
};
} else if (platform === "darwin") {
paths = {
chrome: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
msedge: [
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
],
};
} else if (platform === "linux") {
paths = {
chrome: [
"/opt/google/chrome/chrome",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
],
msedge: [
"/opt/microsoft/msedge/msedge",
"/usr/bin/microsoft-edge",
"/usr/bin/microsoft-edge-stable",
],
};
} else {
throw new Error("不支持的操作系统");
}
return paths[browser].find((p) => fs.existsSync(p));
};
const isPortAvailable = (port) => {
return new Promise((resolve) => {
const server = http.createServer();
server.listen(port, () => {
server.close(() => resolve(true));
});
server.on("error", () => resolve(false));
});
};
const waitForPort = async (port, timeout = 30000) => {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
try {
const response = await axios.get(`http://localhost:${port}/json/version`);
if (response.status === 200) return true;
} catch (e) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
return false;
};
const findAvailablePort = async (startPort) => {
let port = startPort;
while (port < startPort + 100) {
const available = await isPortAvailable(port);
if (available) {
return port;
}
port++;
}
throw new Error("无法找到可用的调试端口");
};
const launchBrowser = async (options) => {
const {
browserType = "msedge",
useSingleUserDataDir = true,
headless = false,
proxy = null,
browserPath = getBrowserPath(browserType),
windowSize = null,
} = options;
if (!browserPath) {
throw new Error("未找到浏览器,或未指定浏览器路径");
}
// 查找可用端口
const port = await findAvailablePort(9222);
const args = [
`--remote-debugging-port=${port}`,
"--no-first-run",
"--no-default-browser-check",
"--start-maximized",
headless ? "--headless" : "",
windowSize ? `--window-size=${windowSize}` : "",
proxy ? `--proxy-server=${proxy}` : "",
useSingleUserDataDir
? `--user-data-dir=${path.join(
os.tmpdir(),
`${browserType}-debug-${port}`
)}`
: "",
].filter(Boolean);
return new Promise(async (resolve, reject) => {
// 如果使用独立用户数据目录,则需要先杀死已有的浏览器进程
if (!useSingleUserDataDir) {
try {
await killRunningBrowser(browserType);
} catch (e) {
reject(e);
return;
}
}
const child = exec(
`"${browserPath}" ${args.join(" ")}`,
{
windowsHide: true,
},
async (error) => {
if (error) {
reject(error);
return;
}
}
);
// 等待端口可用
waitForPort(port).then((success) => {
if (success) {
resolve({
pid: child.pid,
port,
}); // 返回使用的端口号
} else {
reject(new Error("浏览器启动超时,请检查是否有权限问题或防火墙限制"));
}
});
});
};
const killRunningBrowser = (browserType = "msedge") => {
return new Promise((resolve, reject) => {
if (os.platform() === "win32") {
exec(`taskkill /F /IM ${browserType}.exe`, (error) => {
if (error) reject(error);
else resolve();
});
} else {
exec(`kill -9 $(pgrep ${browserType})`, (error) => {
if (error) reject(error);
else resolve();
});
}
});
};
const initCDP = async (port) => {
if (!client) {
try {
client = await CDP({ port });
({ Page, Runtime, DOM } = client);
await Promise.all([Page.enable(), Runtime.enable(), DOM.enable()]);
} catch (err) {
console.log(err);
throw new Error(
`请确保浏览器已启动,且开启了远程调试端口(--remote-debugging-port=${port})`
);
}
}
return { Page, Runtime, DOM };
};
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 = `
(function(${argNames.join(", ")}) {
${script}
})(${argValues.join(", ")})
`;
const { result } = await Runtime.evaluate({
expression: wrappedScript,
returnByValue: true,
awaitPromise: true,
});
return result.value;
};
const getTabs = async () => {
const targets = await CDP.List();
return targets
.filter((target) => target.type === "page")
.map((target) => ({
url: target.url,
title: target.title,
}));
};
const activateTab = async (index) => {
const targets = await CDP.List();
const pages = targets.filter((target) => target.type === "page");
if (index > 0 && index <= pages.length) {
const targetId = pages[index - 1].id;
await CDP.Activate({ id: targetId });
}
};
const clickElement = async (selector) => {
return await executeScript(`document.querySelector('${selector}').click()`);
};
const inputText = async (selector, text) => {
return await executeScript(
`
const el = document.querySelector('${selector}');
el.value = '${text}';
el.dispatchEvent(new Event('input'));
el.dispatchEvent(new Event('change'));
`
);
};
const getText = async (selector) => {
return await executeScript(
`document.querySelector('${selector}')?.textContent || ''`
);
};
const getHtml = async (selector) => {
return await executeScript(
`const element = document.querySelector('${selector}');
return element ? element.innerHTML : '';`
);
};
const hideElement = async (selector) => {
return await executeScript(
`document.querySelector('${selector}').style.display = 'none'`
);
};
const showElement = async (selector) => {
return await executeScript(
`document.querySelector('${selector}').style.display = ''`
);
};
const injectCSS = async (css) => {
return await executeScript(
`
const style = document.createElement('style');
style.textContent = \`${css}\`;
document.head.appendChild(style);
`
);
};
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 cookies = await Network.getCookies();
return cookies.find((cookie) => cookie.name === name);
};
const scrollTo = async (x, y) => {
return await executeScript(`window.scrollTo(${x}, ${y})`);
};
const scrollToElement = async (selector) => {
return await executeScript(
`document.querySelector('${selector}').scrollIntoView()`
);
};
const getScrollPosition = async () => {
return await executeScript(`
return JSON.stringify({
x: window.pageXOffset || document.documentElement.scrollLeft,
y: window.pageYOffset || document.documentElement.scrollTop
});
`);
};
const getPageSize = async () => {
return await executeScript(`
return JSON.stringify({
width: Math.max(
document.documentElement.scrollWidth,
document.documentElement.clientWidth
),
height: Math.max(
document.documentElement.scrollHeight,
document.documentElement.clientHeight
)
});
`);
};
const waitForElement = async (selector, timeout = 5000) => {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const result = await executeScript(
`!!document.querySelector('${selector}')`
);
if (result) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`等待元素 ${selector} 超时`);
};
module.exports = {
launchBrowser,
getUrl,
setUrl,
executeScript,
getTabs,
activateTab,
clickElement,
inputText,
getText,
getHtml,
hideElement,
showElement,
injectCSS,
setCookie,
getCookie,
scrollTo,
scrollToElement,
getScrollPosition,
getPageSize,
waitForElement,
};

View File

@ -0,0 +1,3 @@
const browser = require("./browser");
module.exports = browser;

View File

@ -6,6 +6,7 @@
"": {
"dependencies": {
"axios": "^1.7.9",
"chrome-remote-interface": "^0.33.2",
"crypto-js": "^4.2.0",
"exif-reader": "^2.0.1",
"iconv-lite": "^0.6.3",
@ -39,6 +40,18 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/chrome-remote-interface": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.33.2.tgz",
"integrity": "sha512-wvm9cOeBTrb218EC+6DteGt92iXr2iY0+XJP30f15JVDhqvWvJEVACh9GvUm8b9Yd8bxQivaLSb8k7mgrbyomQ==",
"dependencies": {
"commander": "2.11.x",
"ws": "^7.2.0"
},
"bin": {
"chrome-remote-interface": "bin/client.js"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -51,6 +64,11 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz",
"integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ=="
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
@ -198,6 +216,26 @@
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
},
"dependencies": {
@ -221,6 +259,15 @@
"proxy-from-env": "^1.1.0"
}
},
"chrome-remote-interface": {
"version": "0.33.2",
"resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.33.2.tgz",
"integrity": "sha512-wvm9cOeBTrb218EC+6DteGt92iXr2iY0+XJP30f15JVDhqvWvJEVACh9GvUm8b9Yd8bxQivaLSb8k7mgrbyomQ==",
"requires": {
"commander": "2.11.x",
"ws": "^7.2.0"
}
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -229,6 +276,11 @@
"delayed-stream": "~1.0.0"
}
},
"commander": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz",
"integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ=="
},
"crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
@ -325,6 +377,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="
},
"ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"requires": {}
}
}
}

View File

@ -1,6 +1,7 @@
{
"dependencies": {
"axios": "^1.7.9",
"chrome-remote-interface": "^0.33.2",
"crypto-js": "^4.2.0",
"exif-reader": "^2.0.1",
"iconv-lite": "^0.6.3",

View File

@ -0,0 +1,399 @@
import { newVarInputVal } from "js/composer/varInputValManager";
export const browserCommands = {
label: "浏览器控制",
icon: "web",
defaultOpened: false,
commands: [
{
value: "quickcomposer.browser.launchBrowser",
label: "启动浏览器",
icon: "launch",
isAsync: true,
config: [
{
component: "OptionEditor",
icon: "settings",
width: 12,
options: {
browserType: {
component: "ButtonGroup",
defaultValue: "msedge",
options: [
{ label: "Edge", value: "msedge" },
{ label: "Chrome", value: "chrome" },
],
width: 12,
},
useSingleUserDataDir: {
label: "使用独立用户数据目录",
component: "CheckButton",
width: 6,
},
headless: {
label: "启用无头模式",
component: "CheckButton",
width: 6,
},
windowSize: {
label: "窗口尺寸",
component: "VariableInput",
icon: "window",
width: 6,
placeholder: "如1920x1080不设置则最大化",
},
proxy: {
label: "代理",
component: "VariableInput",
icon: "vpn_lock",
width: 6,
placeholder: "如 socks5://127.0.0.1:7890",
},
browserPath: {
label: "浏览器路径",
component: "VariableInput",
icon: "folder",
width: 12,
options: {
dialog: {
type: "open",
options: {
title: "选择浏览器",
properties: ["openFile"],
},
},
},
placeholder: "二进制绝对路径,留空则自动查找",
},
},
defaultValue: {
browserType: "msedge",
useSingleUserDataDir: true,
headless: false,
},
},
],
},
{
value: "quickcomposer.browser.getUrl",
label: "浏览器操作",
icon: "web",
isAsync: true,
subCommands: [
{
value: "quickcomposer.browser.getUrl",
label: "获取当前地址",
icon: "link",
},
{
value: "quickcomposer.browser.setUrl",
label: "设置当前地址",
icon: "link",
config: [
{
label: "网址",
component: "VariableInput",
icon: "link",
width: 12,
placeholder: "输入网址",
},
],
},
{
value: "quickcomposer.browser.getTabs",
label: "获取标签列表",
icon: "tab",
},
{
value: "quickcomposer.browser.activateTab",
label: "切换标签",
icon: "tab_unselected",
config: [
{
label: "标签索引",
component: "NumberInput",
icon: "tab",
min: 1,
defaultValue: 1,
width: 12,
},
],
},
{
value: "quickcomposer.browser.executeScript",
label: "执行脚本",
icon: "code",
config: [
{
label: "脚本内容",
component: "CodeEditor",
icon: "code",
width: 12,
placeholder: "输入JavaScript代码",
},
{
topLabel: "要传递的参数",
component: "DictEditor",
icon: "data_array",
width: 12,
},
],
},
{
value: "quickcomposer.browser.clickElement",
label: "点击元素",
icon: "mouse",
config: [
{
label: "选择器",
component: "VariableInput",
icon: "code",
width: 12,
placeholder: "输入CSS选择器",
},
],
},
{
value: "quickcomposer.browser.inputText",
label: "输入文本",
icon: "edit",
config: [
{
label: "选择器",
component: "VariableInput",
icon: "code",
width: 12,
placeholder: "输入CSS选择器",
},
{
label: "文本内容",
component: "VariableInput",
icon: "edit",
width: 12,
placeholder: "输入要填写的文本",
},
],
},
{
value: "quickcomposer.browser.getText",
label: "获取文本",
icon: "text_fields",
config: [
{
label: "选择器",
component: "VariableInput",
icon: "code",
width: 12,
placeholder: "输入CSS选择器",
},
],
},
{
value: "quickcomposer.browser.getHtml",
label: "获取HTML",
icon: "code",
config: [
{
label: "选择器",
component: "VariableInput",
icon: "code",
width: 12,
placeholder: "输入CSS选择器",
},
],
},
{
value: "quickcomposer.browser.hideElement",
label: "隐藏元素",
icon: "visibility_off",
config: [
{
label: "选择器",
component: "VariableInput",
icon: "code",
width: 12,
placeholder: "输入CSS选择器",
},
],
},
{
value: "quickcomposer.browser.showElement",
label: "显示元素",
icon: "visibility",
config: [
{
label: "选择器",
component: "VariableInput",
icon: "code",
width: 12,
placeholder: "输入CSS选择器",
},
],
},
{
value: "quickcomposer.browser.injectCSS",
label: "注入CSS",
icon: "style",
config: [
{
label: "CSS内容",
component: "CodeEditor",
icon: "style",
width: 12,
placeholder: "输入CSS代码",
},
],
},
{
value: "quickcomposer.browser.setCookie",
label: "设置Cookie",
icon: "cookie",
config: [
{
label: "Cookie",
component: "ArrayEditor",
icon: "cookie",
width: 12,
columns: {
name: {
label: "名称",
defaultValue: newVarInputVal("str"),
},
value: {
label: "值",
defaultValue: newVarInputVal("str"),
},
},
},
{
label: "选项",
component: "OptionEditor",
icon: "settings",
width: 12,
options: {
expires: {
label: "过期时间",
component: "QSelect",
icon: "timer",
width: 6,
options: [
{ label: "关闭浏览器失效", value: false },
{ label: "1小时", value: 1 },
{ label: "1天", value: 24 },
{ label: "1年", value: 24 * 365 },
],
},
path: {
label: "路径",
component: "VariableInput",
icon: "folder",
width: 6,
},
domain: {
label: "域名",
component: "VariableInput",
icon: "domain",
width: 6,
},
secure: {
label: "安全",
component: "CheckButton",
icon: "lock",
width: 6,
},
},
defaultValue: {
expires: false,
path: newVarInputVal("str", "/"),
domain: newVarInputVal("str", ""),
secure: false,
},
},
],
},
{
value: "quickcomposer.browser.getCookie",
label: "获取Cookie",
icon: "cookie",
config: [
{
label: "名称",
component: "VariableInput",
icon: "label",
width: 12,
placeholder: "输入Cookie名称",
},
],
},
{
value: "quickcomposer.browser.scrollTo",
label: "滚动到位置",
icon: "open_in_full",
config: [
{
label: "X坐标",
component: "NumberInput",
icon: "arrow_right",
width: 12,
defaultValue: 0,
},
{
label: "Y坐标",
component: "NumberInput",
icon: "arrow_drop_down",
width: 12,
defaultValue: 0,
},
],
},
{
value: "quickcomposer.browser.scrollToElement",
label: "滚动到元素",
icon: "open_in_full",
config: [
{
label: "选择器",
component: "VariableInput",
icon: "code",
width: 12,
placeholder: "输入CSS选择器",
},
],
},
{
value: "quickcomposer.browser.getScrollPosition",
label: "获取滚动位置",
icon: "open_in_full",
},
{
value: "quickcomposer.browser.getPageSize",
label: "获取页面尺寸",
icon: "open_in_full",
},
{
value: "quickcomposer.browser.waitForElement",
label: "等待元素",
icon: "hourglass_empty",
config: [
{
label: "选择器",
component: "VariableInput",
icon: "code",
width: 12,
placeholder: "输入CSS选择器",
},
{
label: "超时时间",
component: "NumberInput",
icon: "timer",
width: 12,
defaultValue: 5000,
min: 1000,
step: 1000,
},
],
},
],
},
],
};

View File

@ -18,6 +18,7 @@ import { windowsCommands } from "./windowsCommands";
import { statusCommands } from "./statusCommands";
import { macosCommands } from "./macosCommands";
import { scriptCommands } from "./scriptCommands";
import { browserCommands } from "./browserCommands";
const platformCommands = {
win32: [windowsCommands],
@ -33,6 +34,7 @@ export const commandCategories = [
imageCommands,
utoolsCommands,
...platformCommands[window.processPlatform],
browserCommands,
dataCommands,
codingCommands,
controlCommands,