564 lines
14 KiB
JavaScript

const CDP = require("chrome-remote-interface");
const { exec } = require("child_process");
const path = require("path");
const os = require("os");
const fs = require("fs");
const net = require("net");
let currentClientPort = 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 socket = new net.Socket();
const onError = () => {
socket.destroy();
resolve(true);
};
socket.setTimeout(100);
socket.once("error", onError);
socket.once("timeout", onError);
socket.connect(port, "127.0.0.1", () => {
socket.destroy();
resolve(false);
});
});
};
const waitForPort = async (port, timeout = 30000) => {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
try {
await CDP.Version({ port });
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,
proxy = null,
browserPath = getBrowserPath(browserType),
windowSize = null,
incognito = false,
headless = false,
disableExtensions = false,
} = options;
if (!browserPath) {
throw new Error("未找到浏览器,或未指定浏览器路径");
}
const port = await findAvailablePort(9222);
currentClientPort = port;
const automationArgs = [
`--remote-debugging-port=${port}`,
"--disable-infobars",
"--disable-notifications",
"--disable-popup-blocking",
"--disable-save-password-bubble",
"--disable-translate",
"--no-first-run",
"--no-default-browser-check",
"--user-data-start-with-quickcomposer",
];
const incognitoArg = {
chrome: "--incognito",
msedge: "--inprivate",
};
const optionArgs = [
windowSize ? `--window-size=${windowSize}` : "--start-maximized",
proxy ? `--proxy-server=${proxy}` : "",
incognito ? incognitoArg[browserType] : "",
headless ? "--headless" : "",
disableExtensions ? "--disable-extensions" : "",
useSingleUserDataDir
? `--user-data-dir=${path.join(
os.tmpdir(),
`${browserType}-debug-${port}`
)}`
: "",
].filter(Boolean);
const args = [...automationArgs, ...optionArgs];
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 getClientPorts = async (returnFirstPort = false) => {
try {
// 创建所有端口检查的 Promise 数组
const portChecks = [];
for (let port = 9222; port < 9322; port++) {
portChecks.push(
CDP.List({ port })
.then(() => port)
.catch(() => null)
);
}
if (returnFirstPort) {
// 如果需要返回第一个可用端口,使用 Promise.race
const firstPort = await Promise.race(portChecks);
if (firstPort) {
return firstPort;
} else {
return null;
}
}
// 如果不需要返回第一个端口或没有找到可用端口,并行执行所有检查
const results = await Promise.all(portChecks);
// 过滤出可用的端口
return results.filter((port) => port !== null);
} catch (error) {
throw new Error(`获取客户端列表失败: ${error.message}`);
}
};
const getCurrentClientPort = async () => {
if (currentClientPort === null) {
const port = await getClientPorts(true);
if (port === null) {
throw new Error("未找到可用的浏览器实例,请先从实例管理里面启动新的实例");
}
currentClientPort = port;
}
return currentClientPort;
};
const getTargets = async () => {
const port = await getCurrentClientPort();
return await CDP.List({ port });
};
const initCDP = async (targetId) => {
try {
const port = await getCurrentClientPort();
const client = await CDP({
target: targetId,
port,
});
const { Page, Runtime, Target, Network, Emulation, DOM } = client;
await Promise.all([Page.enable(), Runtime.enable(), DOM.enable()]);
return {
client,
Page,
Runtime,
Target,
Network,
Emulation,
DOM,
};
} catch (err) {
console.log(err);
throw new Error(`连接到浏览器失败: ${err.message}`);
}
};
const cleanupCDP = async (targetId) => {
try {
// 直接关闭传入的 client
if (targetId?.client) {
await targetId.client.close();
}
} catch (error) {
console.log("关闭CDP连接失败:", error);
}
};
const getTabs = async () => {
const targets = await getTargets();
return targets
.filter((target) => target.type === "page")
.map((target) => ({
url: target.url,
title: target.title,
id: target.id,
}));
};
const getCurrentTab = async () => {
const targets = await getTargets();
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 getTargets();
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);
const port = await getCurrentClientPort();
await CDP.Activate({ id: target.id, port });
};
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);
const port = await getCurrentClientPort();
await cleanupCDP(target.id);
await CDP.Close({ id: target.id, port });
};
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 = `
(async 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 {
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, savePath, selector = null } = options;
try {
const { Page, DOM } = await initCDP(target.id);
await DOM.enable();
let clip = null;
if (selector) {
// 获取DOM节点
const { root } = await DOM.getDocument();
const { nodeId } = await DOM.querySelector({
nodeId: root.nodeId,
selector: selector,
});
if (!nodeId) {
throw new Error(`未找到元素: ${selector}`);
}
// 获取元素的精确四边形坐标
const { quads } = await DOM.getContentQuads({ nodeId });
if (!quads || quads.length === 0) {
throw new Error("无法获取元素位置信息");
}
// 获取布局指标
const { visualViewport } = await Page.getLayoutMetrics();
const { pageX, pageY } = visualViewport;
// 计算边界框
const quad = quads[0];
const x = Math.min(quad[0], quad[2], quad[4], quad[6]);
const y = Math.min(quad[1], quad[3], quad[5], quad[7]);
const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x;
const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y;
clip = {
x: Math.round(x - pageX),
y: Math.round(y - pageY),
width: Math.round(width),
height: Math.round(height),
scale: 1,
};
// 确保尺寸不为0
if (clip.width === 0) clip.width = 1;
if (clip.height === 0) clip.height = 1;
}
const screenshotParams = {
format,
quality: format === "jpeg" ? quality : undefined,
fromSurface: true,
captureBeyondViewport: !!selector,
};
if (clip) {
screenshotParams.clip = clip;
}
const { data } = await Page.captureScreenshot(screenshotParams);
await DOM.disable();
if (savePath) {
fs.writeFileSync(savePath, data, "base64");
}
return data;
} finally {
await cleanupCDP(target.id);
}
};
const destroyClientByPort = async (port) => {
try {
const client = await CDP({ port });
await client.Browser.close();
if (port === currentClientPort) {
currentClientPort = null;
}
} catch (error) {
throw new Error(`销毁客户端失败,请手动关闭`);
}
};
const switchClientByPort = async (port) => {
try {
const versionInfo = await CDP.Version({ port });
if (!versionInfo) {
throw new Error(`端口 ${port} 未找到活动的浏览器实例`);
}
currentClientPort = port;
} catch (error) {
throw new Error(`切换客户端失败: ${error.message}`);
}
};
module.exports = {
launchBrowser,
killRunningBrowser,
getTabs,
getCurrentTab,
activateTab,
createNewTab,
closeTab,
getUrl,
setUrl,
executeScript,
setCookie,
getCookie,
captureScreenshot,
getClientPorts,
destroyClientByPort,
switchClientByPort,
getCurrentClientPort,
getTargets,
};