357 lines
11 KiB
JavaScript
357 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { mkdir, writeFile } from "node:fs/promises";
|
||
import { existsSync } from "node:fs";
|
||
import { spawn } from "node:child_process";
|
||
import path from "node:path";
|
||
|
||
function writeJson(payload) {
|
||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||
}
|
||
|
||
async function readStdin() {
|
||
const chunks = [];
|
||
for await (const chunk of process.stdin) {
|
||
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
||
}
|
||
return chunks.join("").trim();
|
||
}
|
||
|
||
function normalizePayload(raw) {
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
return {
|
||
ok: false,
|
||
error: "INVALID_BROWSER_CONTROL_PAYLOAD: expected object",
|
||
};
|
||
}
|
||
return {
|
||
ok: true,
|
||
payload: parsed,
|
||
};
|
||
} catch {
|
||
return {
|
||
ok: false,
|
||
error: "INVALID_BROWSER_CONTROL_PAYLOAD: invalid json",
|
||
};
|
||
}
|
||
}
|
||
|
||
function extractTargetUrl(objective) {
|
||
const text = String(objective || "");
|
||
const fullUrl = text.match(/https?:\/\/[^\s,。;、))]+/i)?.[0];
|
||
if (fullUrl) {
|
||
return fullUrl;
|
||
}
|
||
|
||
const bareDomain = text.match(
|
||
/(?:访问|打开|进入|看一下|visit|open)?\s*((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s,。;、))]*)?)/i,
|
||
)?.[1];
|
||
return bareDomain ? `https://${bareDomain}` : undefined;
|
||
}
|
||
|
||
function cleanupSearchQuery(value) {
|
||
return String(value || "")
|
||
.replace(/打开\s*(?:youtube|油管)/gi, " ")
|
||
.replace(/(?:youtube|油管)/gi, " ")
|
||
.replace(/用浏览器打开/gi, " ")
|
||
.replace(/打开浏览器/gi, " ")
|
||
.replace(/找一个|找一下|搜索|搜一下|搜|播放/gi, " ")
|
||
.replace(/的\s*mv/gi, " MV")
|
||
.replace(/mv/gi, " MV")
|
||
.replace(/[,。;;、]/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
function deriveYouTubeSearchUrl(objective) {
|
||
const text = String(objective || "").trim();
|
||
if (!/(youtube|油管)/i.test(text)) {
|
||
return undefined;
|
||
}
|
||
|
||
const queryPatterns = [
|
||
/(?:找一个|找一下|搜索|搜一下|搜|播放)\s*([^,。;;]+)/i,
|
||
/(?:youtube|油管)\s*([^,。;;]+)/i,
|
||
];
|
||
for (const pattern of queryPatterns) {
|
||
const query = cleanupSearchQuery(text.match(pattern)?.[1]);
|
||
if (query) {
|
||
return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
|
||
}
|
||
}
|
||
|
||
const fallbackQuery = cleanupSearchQuery(text);
|
||
return fallbackQuery
|
||
? `https://www.youtube.com/results?search_query=${encodeURIComponent(fallbackQuery)}`
|
||
: "https://www.youtube.com";
|
||
}
|
||
|
||
async function writeArtifact(payload) {
|
||
const artifactDir = String(process.env.BOSS_CONTROL_ARTIFACT_DIR || "").trim();
|
||
if (!artifactDir) {
|
||
return [];
|
||
}
|
||
|
||
await mkdir(artifactDir, { recursive: true });
|
||
const requestId =
|
||
typeof payload.requestId === "string" && payload.requestId.trim()
|
||
? payload.requestId.trim()
|
||
: `browser-${Date.now()}`;
|
||
const artifactPath = path.join(artifactDir, `${requestId}.json`);
|
||
await writeFile(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||
return [
|
||
{
|
||
kind: "json",
|
||
path: artifactPath,
|
||
},
|
||
];
|
||
}
|
||
|
||
function parseArgs(value) {
|
||
return String(value || "")
|
||
.trim()
|
||
.split(/\s+/)
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function parseArgsJson(value) {
|
||
const raw = String(value || "").trim();
|
||
if (!raw) {
|
||
return undefined;
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
return Array.isArray(parsed) ? parsed.map((item) => String(item)).filter(Boolean) : undefined;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
function detectRequestedBrowserApp(objective) {
|
||
const text = String(objective || "").toLowerCase();
|
||
if (text.includes("chrome") || text.includes("谷歌浏览器")) {
|
||
return "Google Chrome";
|
||
}
|
||
if (text.includes("safari")) {
|
||
return "Safari";
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function resolveBrowserOpenArgs(command, objective) {
|
||
const configured =
|
||
parseArgsJson(process.env.BOSS_BROWSER_OPEN_ARGS_JSON) ??
|
||
parseArgs(process.env.BOSS_BROWSER_OPEN_ARGS);
|
||
if (configured.length > 0) {
|
||
return configured;
|
||
}
|
||
|
||
const requestedApp = detectRequestedBrowserApp(objective);
|
||
const commandName = path.basename(command || "").toLowerCase();
|
||
return requestedApp && commandName === "open" ? ["-a", requestedApp] : [];
|
||
}
|
||
|
||
function escapeAppleScriptString(value) {
|
||
return String(value || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||
}
|
||
|
||
async function openTargetUrl(targetUrl, objective) {
|
||
const commandOverride = String(process.env.BOSS_BROWSER_OPEN_COMMAND || "").trim();
|
||
const command = commandOverride || "open";
|
||
const requestedApp = detectRequestedBrowserApp(objective);
|
||
|
||
if (!commandOverride && process.platform === "darwin" && requestedApp) {
|
||
const app = escapeAppleScriptString(requestedApp);
|
||
const url = escapeAppleScriptString(targetUrl);
|
||
const script = [
|
||
`tell application "${app}" to activate`,
|
||
`tell application "${app}" to open location "${url}"`,
|
||
].join("\n");
|
||
await runCommand("osascript", ["-e", script]);
|
||
return "osascript_open_url_executed";
|
||
}
|
||
|
||
const prefixArgs = resolveBrowserOpenArgs(command, objective);
|
||
await runCommand(command, [...prefixArgs, targetUrl]);
|
||
return "open_url_executed";
|
||
}
|
||
|
||
function getBrowserAutomationMode() {
|
||
const raw = String(process.env.BOSS_BROWSER_AUTOMATION_MODE || "").trim().toLowerCase();
|
||
if (raw === "off" || raw === "fetch" || raw === "playwright" || raw === "auto") {
|
||
return raw;
|
||
}
|
||
return "auto";
|
||
}
|
||
|
||
function shouldOpenVisibleBrowserAfterAutomation() {
|
||
const raw = String(process.env.BOSS_BROWSER_VISIBLE_OPEN_AFTER_AUTOMATION || "").trim().toLowerCase();
|
||
return !["0", "false", "off", "no"].includes(raw);
|
||
}
|
||
|
||
function resolveCodexHome() {
|
||
return String(process.env.CODEX_HOME || "").trim() || path.join(process.env.HOME || "", ".codex");
|
||
}
|
||
|
||
function resolveBundledPlaywrightCommand() {
|
||
const wrapper = path.join(resolveCodexHome(), "skills", "playwright", "scripts", "playwright_cli.sh");
|
||
return existsSync(wrapper) ? wrapper : undefined;
|
||
}
|
||
|
||
function resolveBrowserAutomationArgs(command, commandArgs, targetUrl, requestId) {
|
||
const args = [...commandArgs];
|
||
const session =
|
||
String(process.env.BOSS_BROWSER_AUTOMATION_SESSION || "").trim() ||
|
||
String(requestId || "").trim();
|
||
if (session) {
|
||
args.push("--session", session);
|
||
}
|
||
args.push(command, targetUrl);
|
||
return args;
|
||
}
|
||
|
||
async function runCommand(command, args) {
|
||
return new Promise((resolve, reject) => {
|
||
const child = spawn(command, args, {
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
});
|
||
|
||
let stdout = "";
|
||
let stderr = "";
|
||
child.stdout.setEncoding("utf8");
|
||
child.stderr.setEncoding("utf8");
|
||
child.stdout.on("data", (chunk) => {
|
||
stdout += chunk;
|
||
});
|
||
child.stderr.on("data", (chunk) => {
|
||
stderr += chunk;
|
||
});
|
||
child.on("error", reject);
|
||
child.on("close", (code) => {
|
||
if (code !== 0) {
|
||
reject(new Error(stderr.trim() || `browser open exit code ${code}`));
|
||
return;
|
||
}
|
||
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
||
});
|
||
});
|
||
}
|
||
|
||
async function runBrowserAutomation(targetUrl, requestId) {
|
||
const command =
|
||
String(process.env.BOSS_BROWSER_AUTOMATION_COMMAND || "").trim() || resolveBundledPlaywrightCommand();
|
||
if (!command || !targetUrl) {
|
||
return undefined;
|
||
}
|
||
const prefixArgs =
|
||
parseArgsJson(process.env.BOSS_BROWSER_AUTOMATION_ARGS_JSON) ??
|
||
parseArgs(process.env.BOSS_BROWSER_AUTOMATION_ARGS);
|
||
await runCommand(command, resolveBrowserAutomationArgs("open", prefixArgs, targetUrl, requestId));
|
||
const title = await runCommand(
|
||
command,
|
||
resolveBrowserAutomationArgs("eval", prefixArgs, "document.title", requestId),
|
||
);
|
||
return title.stdout || undefined;
|
||
}
|
||
|
||
async function inspectPageTitle(targetUrl) {
|
||
if (!targetUrl) {
|
||
return undefined;
|
||
}
|
||
try {
|
||
const response = await fetch(targetUrl, {
|
||
redirect: "follow",
|
||
signal: AbortSignal.timeout(8000),
|
||
});
|
||
const contentType = response.headers.get("content-type") || "";
|
||
if (!contentType.includes("text/html")) {
|
||
return undefined;
|
||
}
|
||
const html = await response.text();
|
||
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim();
|
||
return title || undefined;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
const raw = await readStdin();
|
||
const normalized = normalizePayload(raw);
|
||
|
||
if (!normalized.ok) {
|
||
writeJson({
|
||
status: "failed",
|
||
error: normalized.error,
|
||
});
|
||
process.exit(0);
|
||
}
|
||
|
||
const payload = normalized.payload;
|
||
const currentRequestId = typeof payload.requestId === "string" ? payload.requestId.trim() : "";
|
||
const objective =
|
||
typeof payload.objective === "string" && payload.objective.trim()
|
||
? payload.objective.trim()
|
||
: "浏览器控制 smoke 链路正常";
|
||
const targetUrl = extractTargetUrl(objective) || deriveYouTubeSearchUrl(objective);
|
||
const riskLevel =
|
||
typeof payload.context?.riskLevel === "string" && payload.context.riskLevel.trim()
|
||
? payload.context.riskLevel.trim()
|
||
: "unknown";
|
||
const dryRun = payload.context?.dryRun === true;
|
||
let action = targetUrl ? "open_url" : "browser_smoke";
|
||
const configuredAutomationMode = getBrowserAutomationMode();
|
||
const automationMode =
|
||
configuredAutomationMode === "auto"
|
||
? String(process.env.BOSS_BROWSER_AUTOMATION_COMMAND || "").trim() || resolveBundledPlaywrightCommand()
|
||
? "playwright"
|
||
: "fetch"
|
||
: configuredAutomationMode;
|
||
let automationError;
|
||
let automatedTitle;
|
||
if (targetUrl && !dryRun && automationMode === "playwright") {
|
||
try {
|
||
automatedTitle = await runBrowserAutomation(targetUrl, currentRequestId);
|
||
} catch (error) {
|
||
automationError = error instanceof Error ? error.message : String(error);
|
||
}
|
||
}
|
||
const pageTitle =
|
||
automatedTitle ||
|
||
(automationMode !== "off" ? await inspectPageTitle(targetUrl) : undefined);
|
||
if (targetUrl && !dryRun) {
|
||
if (automationMode === "playwright" && automatedTitle) {
|
||
action = "browser_automation_executed";
|
||
if (shouldOpenVisibleBrowserAfterAutomation()) {
|
||
const visibleAction = await openTargetUrl(targetUrl, objective);
|
||
action = `${action}+${visibleAction}`;
|
||
}
|
||
} else {
|
||
action = await openTargetUrl(targetUrl, objective);
|
||
}
|
||
}
|
||
const artifacts = await writeArtifact({
|
||
requestKind: payload.requestKind,
|
||
requestId: payload.requestId,
|
||
action,
|
||
objective,
|
||
targetUrl,
|
||
dryRun,
|
||
riskLevel,
|
||
capturedAt: new Date().toISOString(),
|
||
});
|
||
|
||
writeJson({
|
||
status: "completed",
|
||
requestId: typeof payload.requestId === "string" ? payload.requestId : undefined,
|
||
replyBody: pageTitle
|
||
? `浏览器控制已完成:${objective}。页面标题:${pageTitle}`
|
||
: `浏览器控制已完成:${objective}`,
|
||
executionSummary: pageTitle
|
||
? `${action} completed (risk=${riskLevel}, title=${pageTitle}${automationError ? ", automationFallback=true" : ""})`
|
||
: `${action} completed (risk=${riskLevel}${automationError ? ", automationFallback=true" : ""})`,
|
||
targetUrl,
|
||
artifacts,
|
||
});
|