diff --git a/.gitignore b/.gitignore index 0e99307f..9b95501c 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,32 @@ tmp-debug-port*/ .env .env.* !.env.example + +# Local sensitive configs / accounts +*.ovpn +clash.yaml +mihomo.yaml +singbox.json +xray.json +proxy-subscription.yaml +proxy-subscriptions.yaml +subscription.yaml +subscriptions.yaml +account.json +accounts.json +accounts.yaml +accounts.yml +*credential*.json +*credentials*.json +*secret*.json +*secrets*.json +*token*.json +*tokens*.json +*cookie*.json +*cookies*.json +*.local.yaml +*.local.yml +*.local.json + +# Local backups +.local-backup/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..570aa8f3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Project Agent Instructions + + + +## Shared Local Skills + +Use the shared local skills below when the task matches their scope: + +- `page-style-linear-flow`: `D:\code\open_source\ant-ready-start\skills\page-style-linear-flow\SKILL.md` +- `ui-ux-pro-max`: `D:\code\open_source\ant-ready-start\skills\ui-ux-pro-max\SKILL.md` +- `frontend-skill`: `D:\code\open_source\ant-ready-start\skills\frontend-skill\SKILL.md` +- `create-plan`: `D:\code\open_source\ant-ready-start\skills\create-plan\SKILL.md` +- `create-plan-doc`: `D:\code\open_source\ant-ready-start\skills\create-plan-doc\SKILL.md` + +Apply it for frontend page design or refactors involving pages, admin panels, forms, tables, dashboards, detail views, wizards, modal/drawer placement, or multi-step flows. + +Apply `ui-ux-pro-max` for broader UI/UX design work involving visual direction, design-system shaping, palette and typography selection, component styling, landing pages, dashboards, and cross-stack interface generation when linear-flow rules alone are not enough. + +Apply `frontend-skill` when the task needs stronger frontend art direction, visual hierarchy, landing-page composition, sparse premium layouts, image-led sections, or restrained motion design. + +Apply `create-plan` when the user explicitly asks for a plan, task breakdown, implementation roadmap, rollout outline, or a step-by-step execution plan before coding. + +Apply `create-plan-doc` when the user explicitly asks for a plan that should also be saved into the repository as a markdown document under `docs/plan`. + +Core expectations: + +- Keep each page focused on one primary responsibility. +- Do not mix operational tables and submit forms on the same screen. +- Use modal/drawer for short low-risk forms; use a dedicated page or wizard for complex flows. +- Remove filler copy, repeated headings, decorative cards, and meaningless whitespace. +- Keep the next action obvious and preserve predictable back/cancel/save behavior. +- For detailed UI checks, selectively read `D:\code\open_source\ant-ready-start\skills\page-style-linear-flow\references\checklist.md`. + +These shared skill instructions supplement project-specific rules in this `AGENTS.md`; keep more specific project rules authoritative for this repository. + + diff --git a/README.md b/README.md index c0cce1cc..8969cf04 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Ant Browser +# Ant Browser > 面向多账号隔离、代理绑定和本地环境管理的桌面浏览器工具(Windows / Linux / macOS unsigned)。 @@ -152,18 +152,58 @@ Ant Browser 适合以下场景: 1. 开发默认使用 `master` 分支;该分支不带测试用户数据,适合作为日常开发基线。 2. 如需带测试库的演示环境,请切换到 `user_data` 分支。 -3. Windows 统一执行 `bat\dev.bat`;默认是稳定模式,如需前端 HMR 联调使用 `bat\dev.bat live`,如需受限内存复现使用 `bat\dev.bat limited`。 +3. Windows 统一执行 `bat\dev.bat`;默认是 `live` 热更新模式,如需静态资源排查使用 `bat\dev.bat stable`,如需受限内存复现使用 `bat\dev.bat limited`。 4. Windows 运行时使用 `bin/xray.exe`、`bin/sing-box.exe`;Linux 运行时使用 `bin/linux-/xray`、`bin/linux-/sing-box`;macOS 运行时使用 `bin/darwin-/xray`、`bin/darwin-/sing-box`。 5. 运行时文件采用“仓库固定 + 哈希校验”,校验清单在 `publish/runtime-manifest.json`,固定来源清单在 `publish/runtime-sources.json`。 6. 如需刷新 Linux / macOS 运行时,执行 `python3 tools/runtime/sync-runtime.py --target `(会按固定来源下载、校验归档并更新 manifest)。 开发模式说明: -- `bat\dev.bat`:默认稳定模式,先构建 `frontend/dist`,再以静态资源模式启动 Wails,不依赖外部 Vite dev server -- `bat\dev.bat live`:显式启动 Vite watcher,并通过 `-frontenddevserverurl` 接入桌面壳 +- `bat\dev.bat`:默认 `live` 模式,启动 Vite watcher,并通过 `-frontenddevserverurl` 接入桌面壳 +- `bat\dev.bat stable`:先构建 `frontend/dist`,再以静态资源模式启动 Wails,不依赖外部 Vite dev server +- `bat\dev.bat live`:显式指定 `live` 模式,效果与默认一致 - `bat\dev.bat limited`:在 `live` 基础上为 watcher 与其子进程附加 Windows Job Object 内存限制 - 如需为依赖下载配置代理,可在启动前设置 `DEV_PROXY_URL`、`DEV_NO_PROXY`、`DEV_GOPROXY` +### 自动化脚本包 + +自动化脚本现在分成两层: + +- 仓库里的可提交 demo 脚本库:`backend/internal/automation/demo-library/` +- 本地运行时 / 用户自定义脚本:`data/automation/scripts/` + +规则是: + +- 只有 demo 脚本库里的脚本会提交到 git +- `data/automation/scripts/` 下的运行时脚本统一忽略,不提交 git +- 默认只同步三个 demo:`dual-instance-runtime-switch`、`news-query-txt`、`web-image-generate-download` + +脚本包采用“一脚本一目录”的可搬运结构: + +```text +/ +├── automation.script.json +├── index.cjs +└── 其他辅助文件 +``` + +其中: + +- `automation.script.json`:脚本元数据和默认参数 +- `index.cjs`:入口脚本,`entryFile` 也可以改成相对路径,例如 `scripts/index.cjs` +- 其他辅助文件:脚本依赖的本地模块、模板、静态资源 + +运行时落盘结构和分发结构不同。应用内部会把脚本写到: + +```text +data/automation/scripts// +├── config +├── index.cjs +└── 其他辅助文件 +``` + +这里的 `config` 是应用内部持久化格式;对外复制、导入、脚本库管理一律使用 `automation.script.json` 包结构。 + ### Linux 发布打包(源码) Linux 发布脚本位于 `publish/linux/`。 @@ -261,7 +301,7 @@ chrome/ - Releases: - Issues: -- 友链: +- 感谢以下社区的支持: ## License diff --git a/backend/app.go b/backend/app.go index cd83d0fa..b8153102 100644 --- a/backend/app.go +++ b/backend/app.go @@ -42,6 +42,8 @@ type App struct { maintenanceMu sync.Mutex bridgeMu sync.Mutex xrayBridgeRefs map[string]string + deferredStartTargetsMu sync.Mutex + deferredStartTargets map[string][]string automationTargetMu sync.Mutex automationTargetCursor map[string]string stopServicesOnce sync.Once @@ -58,6 +60,7 @@ func NewApp(appRoot string, appVersion ...string) *App { appRoot: strings.TrimSpace(appRoot), version: version, xrayBridgeRefs: make(map[string]string), + deferredStartTargets: make(map[string][]string), automationTargetCursor: make(map[string]string), } } diff --git a/backend/app_browser_config_api.go b/backend/app_browser_config_api.go index b7fa933c..21b8400f 100644 --- a/backend/app_browser_config_api.go +++ b/backend/app_browser_config_api.go @@ -13,6 +13,7 @@ func (a *App) GetBrowserSettings() BrowserSettings { DefaultFingerprintArgs: append([]string{}, a.config.Browser.DefaultFingerprintArgs...), DefaultLaunchArgs: append([]string{}, a.config.Browser.DefaultLaunchArgs...), DefaultStartURLs: append([]string{}, a.config.Browser.DefaultStartURLs...), + LightStartEnabled: browserLightStartEnabled(a.config), RestoreLastSession: a.config.Browser.RestoreLastSession, StartReadyTimeoutMs: browserStartReadyTimeoutMillis(a.config), StartStableWindowMs: browserStartStableWindowMillis(a.config), @@ -29,6 +30,8 @@ func (a *App) SaveBrowserSettings(settings BrowserSettings) error { } else if a.config.Browser.DefaultStartURLs == nil { a.config.Browser.DefaultStartURLs = config.DefaultBrowserStartURLs() } + lightStartEnabled := settings.LightStartEnabled + a.config.Browser.LightStartEnabled = &lightStartEnabled a.config.Browser.RestoreLastSession = settings.RestoreLastSession if settings.StartReadyTimeoutMs > 0 { a.config.Browser.StartReadyTimeoutMs = settings.StartReadyTimeoutMs diff --git a/backend/app_browser_profile_api.go b/backend/app_browser_profile_api.go index b702d1d8..a7eeaeec 100644 --- a/backend/app_browser_profile_api.go +++ b/backend/app_browser_profile_api.go @@ -17,6 +17,7 @@ type BrowserCore = browser.Core type BrowserCoreInput = browser.CoreInput type BrowserCoreValidateResult = browser.CoreValidateResult type BrowserCoreExtendedInfo = browser.CoreExtendedInfo +type BrowserProfileCopyOptions = browser.ProfileCopyOptions // BrowserProfileList 获取所有实例列表 func (a *App) BrowserProfileList() []BrowserProfile { return a.browserMgr.List() } @@ -51,6 +52,16 @@ func (a *App) BrowserProfileCopy(profileId string, newName string) (*BrowserProf return a.browserMgr.Copy(profileId, newName) } +// BrowserProfileCopyWithMode 按模式复制实例配置。 +func (a *App) BrowserProfileCopyWithMode(profileId string, newName string, mode string) (*BrowserProfile, error) { + return a.browserMgr.CopyWithMode(profileId, newName, mode) +} + +// BrowserProfileCopyWithOptions 按结构化选项复制实例配置。 +func (a *App) BrowserProfileCopyWithOptions(profileId string, newName string, options BrowserProfileCopyOptions) (*BrowserProfile, error) { + return a.browserMgr.CopyWithOptions(profileId, newName, options) +} + // migrateToSQLite 一次性迁移:若 SQLite 表为空则从旧文件导入数据,或初始化默认数据 // 迁移顺序:cores → proxies → profiles → bookmarks func (a *App) migrateToSQLite() { diff --git a/backend/app_browser_settings_test.go b/backend/app_browser_settings_test.go index bf8a49eb..2edf4bb2 100644 --- a/backend/app_browser_settings_test.go +++ b/backend/app_browser_settings_test.go @@ -31,6 +31,7 @@ func TestSaveBrowserSettingsPreservesExistingStartTimingWhenOmitted(t *testing.T UserDataRoot: app.config.Browser.UserDataRoot, DefaultFingerprintArgs: append([]string{}, app.config.Browser.DefaultFingerprintArgs...), DefaultLaunchArgs: append([]string{}, app.config.Browser.DefaultLaunchArgs...), + LightStartEnabled: browserLightStartEnabled(app.config), }); err != nil { t.Fatalf("SaveBrowserSettings returned error: %v", err) } @@ -44,6 +45,9 @@ func TestSaveBrowserSettingsPreservesExistingStartTimingWhenOmitted(t *testing.T if len(app.config.Browser.DefaultStartURLs) != len(config.DefaultBrowserStartURLs()) { t.Fatalf("expected default start urls to be preserved, got %v", app.config.Browser.DefaultStartURLs) } + if app.config.Browser.LightStartEnabled == nil || !*app.config.Browser.LightStartEnabled { + t.Fatal("expected light start setting to be preserved") + } } func TestSaveBrowserSettingsAppliesExplicitStartTiming(t *testing.T) { @@ -55,6 +59,7 @@ func TestSaveBrowserSettingsAppliesExplicitStartTiming(t *testing.T) { DefaultFingerprintArgs: append([]string{}, app.config.Browser.DefaultFingerprintArgs...), DefaultLaunchArgs: append([]string{}, app.config.Browser.DefaultLaunchArgs...), DefaultStartURLs: []string{}, + LightStartEnabled: false, RestoreLastSession: true, StartReadyTimeoutMs: 18000, StartStableWindowMs: 3000, @@ -71,6 +76,9 @@ func TestSaveBrowserSettingsAppliesExplicitStartTiming(t *testing.T) { if len(app.config.Browser.DefaultStartURLs) != 0 { t.Fatalf("expected default start urls to be cleared, got %v", app.config.Browser.DefaultStartURLs) } + if app.config.Browser.LightStartEnabled == nil || *app.config.Browser.LightStartEnabled { + t.Fatal("expected light start to be disabled") + } if !app.config.Browser.RestoreLastSession { t.Fatal("expected restore last session to be enabled") } diff --git a/backend/app_cookie.go b/backend/app_cookie.go index 286e7abf..c125da67 100644 --- a/backend/app_cookie.go +++ b/backend/app_cookie.go @@ -11,6 +11,12 @@ import ( "github.com/gorilla/websocket" ) +const ( + cdpHTTPTimeout = 3 * time.Second + cdpWebSocketHandshakeTimeout = 3 * time.Second + cdpWebSocketReadTimeout = 5 * time.Second +) + // ============================================================================ // Cookie 管理 API(通过 CDP) // ============================================================================ @@ -53,15 +59,41 @@ type cdpResponse struct { } `json:"error,omitempty"` } +func cdpGetEndpointBody(debugPort int, endpoint string) ([]byte, error) { + client := &http.Client{Timeout: cdpHTTPTimeout} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d%s", debugPort, endpoint)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + return body, nil +} + +func cdpDialWebSocket(wsURL string) (*websocket.Conn, error) { + dialer := *websocket.DefaultDialer + dialer.HandshakeTimeout = cdpWebSocketHandshakeTimeout + conn, _, err := dialer.Dial(wsURL, nil) + if err != nil { + return nil, err + } + return conn, nil +} + // cdpCall 向指定 debugPort 发送单次 CDP 命令并返回 result 字段 func cdpCall(debugPort int, method string, params map[string]any) (map[string]any, error) { // 1. 获取 WebSocket 调试地址 - resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/json", debugPort)) + body, err := cdpGetEndpointBody(debugPort, "/json") if err != nil { return nil, fmt.Errorf("CDP /json 请求失败: %w", err) } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) var targets []cdpTarget if err := json.Unmarshal(body, &targets); err != nil || len(targets) == 0 { @@ -83,12 +115,12 @@ func cdpCall(debugPort int, method string, params map[string]any) (map[string]an } // 2. 建立 WebSocket 连接 - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + conn, err := cdpDialWebSocket(wsURL) if err != nil { return nil, fmt.Errorf("WebSocket 连接失败: %w", err) } defer conn.Close() - conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + conn.SetReadDeadline(time.Now().Add(cdpWebSocketReadTimeout)) // 3. 发送 CDP 命令 msg := cdpMessage{Id: 1, Method: method, Params: params} @@ -108,13 +140,10 @@ func cdpCall(debugPort int, method string, params map[string]any) (map[string]an } func cdpBrowserCall(debugPort int, method string, params map[string]any) error { - resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/json/version", debugPort)) + body, err := cdpGetEndpointBody(debugPort, "/json/version") if err != nil { return fmt.Errorf("CDP /json/version 请求失败: %w", err) } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) var version cdpBrowserVersion if err := json.Unmarshal(body, &version); err != nil { return fmt.Errorf("CDP browser target 解析失败: %w", err) @@ -124,12 +153,12 @@ func cdpBrowserCall(debugPort int, method string, params map[string]any) error { return fmt.Errorf("未找到浏览器级 WebSocket 调试地址") } - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + conn, err := cdpDialWebSocket(wsURL) if err != nil { return fmt.Errorf("浏览器级 WebSocket 连接失败: %w", err) } defer conn.Close() - conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + conn.SetReadDeadline(time.Now().Add(cdpWebSocketReadTimeout)) msg := cdpMessage{Id: 1, Method: method, Params: params} if err := conn.WriteJSON(msg); err != nil { diff --git a/backend/app_dashboard_api.go b/backend/app_dashboard_api.go index 7c53d3fa..ec4d7fac 100644 --- a/backend/app_dashboard_api.go +++ b/backend/app_dashboard_api.go @@ -7,7 +7,10 @@ import ( ) func (a *App) GetDashboardStats() map[string]interface{} { - profiles := a.browserMgr.List() + profiles := []BrowserProfile{} + if a.browserMgr != nil { + profiles = a.browserMgr.List() + } totalInstances := len(profiles) runningInstances := 0 for _, profile := range profiles { @@ -15,8 +18,17 @@ func (a *App) GetDashboardStats() map[string]interface{} { runningInstances++ } } - proxyCount := len(a.config.Browser.Proxies) - coreCount := len(a.config.Browser.Cores) + + proxyCount := 0 + coreCount := 0 + maxProfileLimit := 20 + if a.config != nil { + proxyCount = len(a.config.Browser.Proxies) + coreCount = len(a.config.Browser.Cores) + if a.config.App.MaxProfileLimit > 0 { + maxProfileLimit = a.config.App.MaxProfileLimit + } + } var mem goruntime.MemStats goruntime.ReadMemStats(&mem) @@ -28,6 +40,7 @@ func (a *App) GetDashboardStats() map[string]interface{} { "proxyCount": proxyCount, "coreCount": coreCount, "memUsedMB": int(memUsedMB), + "maxProfileLimit": maxProfileLimit, "appVersion": a.appVersion(), } } diff --git a/backend/app_instance_launch_args.go b/backend/app_instance_launch_args.go index d875ba88..b229e43c 100644 --- a/backend/app_instance_launch_args.go +++ b/backend/app_instance_launch_args.go @@ -12,6 +12,15 @@ import ( "time" ) +var startBrowserWindowProcess = func(chromeBinaryPath string, args []string) (*exec.Cmd, error) { + cmd := exec.Command(chromeBinaryPath, args...) + cmd.Dir = filepath.Dir(chromeBinaryPath) + if err := cmd.Start(); err != nil { + return nil, err + } + return cmd, nil +} + func tryCloseBrowserViaCDP(debugPort int, timeout time.Duration) bool { if debugPort <= 0 || !canConnectDebugPort(debugPort, 250*time.Millisecond) { return false @@ -99,23 +108,8 @@ func browserRestoreLastSession(cfg *config.Config) bool { } func appendLaunchTargets(args []string, startURLs []string, defaultStartURLs []string, skipDefaultStartURLs bool, restoreLastSession bool) []string { - normalizedStartURLs := normalizeNonEmptyStrings(startURLs) - if len(normalizedStartURLs) > 0 { - return browser.BuildLaunchArgs(args, normalizedStartURLs) - } - - if !skipDefaultStartURLs { - normalizedDefaultStartURLs := normalizeNonEmptyStrings(defaultStartURLs) - if len(normalizedDefaultStartURLs) > 0 { - return browser.BuildLaunchArgs(args, normalizedDefaultStartURLs) - } - } - - if !restoreLastSession { - return browser.BuildLaunchArgs(args, []string{"about:blank"}) - } - - return args + launchTargets, _ := buildBrowserLaunchTargets(startURLs, defaultStartURLs, skipDefaultStartURLs, restoreLastSession, false) + return browser.BuildLaunchArgs(args, launchTargets) } func (a *App) markProfileStoppedLocked(profileId string, profile *BrowserProfile) { @@ -129,6 +123,7 @@ func (a *App) markProfileStoppedLocked(profileId string, profile *BrowserProfile profile.RuntimeWarning = "" profile.LastStopAt = time.Now().Format(time.RFC3339) delete(a.browserMgr.BrowserProcesses, profileId) + a.clearDeferredStartTargets(profileId) a.releaseProfileXrayBridge(profileId) if a.launchServer != nil { a.launchServer.ClearActiveProfile(profileId) @@ -158,14 +153,15 @@ func (a *App) openBrowserWindowForRunningProfile(profile *BrowserProfile, extraL args = append(args, "about:blank") } - cmd := exec.Command(chromeBinaryPath, args...) - cmd.Dir = filepath.Dir(chromeBinaryPath) - if err := cmd.Start(); err != nil { + cmd, err := startBrowserWindowProcess(chromeBinaryPath, args) + if err != nil { return fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err)) } - go func() { - _ = cmd.Wait() - }() + if cmd != nil { + go func() { + _ = cmd.Wait() + }() + } return nil } diff --git a/backend/app_instance_monitor.go b/backend/app_instance_monitor.go index 1f46c143..b4c9c71a 100644 --- a/backend/app_instance_monitor.go +++ b/backend/app_instance_monitor.go @@ -27,6 +27,10 @@ func (a *App) waitBrowserProcess(profileId string, monitor *browserProcessMonito if wasRunning && debugPort > 0 { snapshot, changed := a.waitForBrowserDebugReady(profileId, debugPort, browserLauncherDetachGraceWindow) + if warningSnapshot, warningChanged := a.finalizeDeferredStartTargets(profileId, debugPort); warningSnapshot != nil { + snapshot = warningSnapshot + changed = changed || warningChanged + } if snapshot != nil && changed { log.Info("浏览器启动器进程退出后,调试接口延迟就绪", logger.F("profile_id", profileId), diff --git a/backend/app_instance_open_url_test.go b/backend/app_instance_open_url_test.go new file mode 100644 index 00000000..0617491b --- /dev/null +++ b/backend/app_instance_open_url_test.go @@ -0,0 +1,265 @@ +package backend + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + goruntime "runtime" + "strings" + "testing" + "time" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" +) + +func TestBrowserInstanceOpenURLUsesCDPCreateTarget(t *testing.T) { + t.Parallel() + + server := newRecordedCDPServer(t) + defer server.Close() + + app := newBrowserOpenURLTestApp(t) + app.browserMgr.Profiles = map[string]*BrowserProfile{ + "profile-ready": { + ProfileId: "profile-ready", + ProfileName: "Ready Browser", + Running: true, + DebugReady: true, + DebugPort: server.Port(), + Pid: 12345, + }, + } + app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) + + ok, err := app.BrowserInstanceOpenUrl("profile-ready", "https://open.example/") + if err != nil { + t.Fatalf("BrowserInstanceOpenUrl returned error: %v", err) + } + if !ok { + t.Fatal("expected BrowserInstanceOpenUrl to succeed") + } + + want := []recordedCDPCommand{ + {Scope: "browser", Method: "Target.createTarget", URL: "https://open.example/"}, + } + if got := server.Commands(); !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected CDP command sequence:\n got=%v\nwant=%v", got, want) + } +} + +func TestBrowserInstanceOpenURLFallsBackToWindowWhenDebugPending(t *testing.T) { + app, exePath := newBrowserOpenURLTestAppWithCore(t) + + cmd := longLivedCommand(2 * time.Second) + if err := cmd.Start(); err != nil { + t.Fatalf("启动长生命周期测试进程失败: %v", err) + } + defer func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } + }() + + profile := &BrowserProfile{ + ProfileId: "profile-pending", + ProfileName: "Pending Browser", + UserDataDir: "profile-pending", + Running: true, + DebugReady: false, + DebugPort: 0, + Pid: cmd.Process.Pid, + } + app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} + app.browserMgr.BrowserProcesses = map[string]*exec.Cmd{profile.ProfileId: cmd} + + expectedUserDataDir := app.browserMgr.ResolveUserDataDir(profile) + var gotPath string + var gotArgs []string + + originalStart := startBrowserWindowProcess + startBrowserWindowProcess = func(chromeBinaryPath string, args []string) (*exec.Cmd, error) { + gotPath = chromeBinaryPath + gotArgs = append([]string{}, args...) + return nil, nil + } + defer func() { + startBrowserWindowProcess = originalStart + }() + + ok, err := app.BrowserInstanceOpenUrl(profile.ProfileId, "https://pending.example/") + if err != nil { + t.Fatalf("BrowserInstanceOpenUrl returned error: %v", err) + } + if !ok { + t.Fatal("expected BrowserInstanceOpenUrl to succeed") + } + if gotPath != exePath { + t.Fatalf("unexpected browser path: got=%q want=%q", gotPath, exePath) + } + + wantArgs := []string{ + fmt.Sprintf("--user-data-dir=%s", expectedUserDataDir), + "https://pending.example/", + } + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("unexpected browser args:\n got=%v\nwant=%v", gotArgs, wantArgs) + } +} + +func TestBrowserInstanceOpenURLFallsBackToWindowWhenCDPOpenFails(t *testing.T) { + app, exePath := newBrowserOpenURLTestAppWithCore(t) + + cmd := longLivedCommand(2 * time.Second) + if err := cmd.Start(); err != nil { + t.Fatalf("启动长生命周期测试进程失败: %v", err) + } + defer func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } + }() + + profile := &BrowserProfile{ + ProfileId: "profile-ready-fallback", + ProfileName: "Fallback Browser", + UserDataDir: "profile-ready-fallback", + Running: true, + DebugReady: true, + DebugPort: freeLoopbackPort(t), + Pid: cmd.Process.Pid, + } + app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} + app.browserMgr.BrowserProcesses = map[string]*exec.Cmd{profile.ProfileId: cmd} + + expectedUserDataDir := app.browserMgr.ResolveUserDataDir(profile) + var gotPath string + var gotArgs []string + + originalStart := startBrowserWindowProcess + startBrowserWindowProcess = func(chromeBinaryPath string, args []string) (*exec.Cmd, error) { + gotPath = chromeBinaryPath + gotArgs = append([]string{}, args...) + return nil, nil + } + defer func() { + startBrowserWindowProcess = originalStart + }() + + ok, err := app.BrowserInstanceOpenUrl(profile.ProfileId, "https://fallback.example/") + if err != nil { + t.Fatalf("BrowserInstanceOpenUrl returned error: %v", err) + } + if !ok { + t.Fatal("expected BrowserInstanceOpenUrl to succeed") + } + if gotPath != exePath { + t.Fatalf("unexpected browser path: got=%q want=%q", gotPath, exePath) + } + + wantArgs := []string{ + fmt.Sprintf("--user-data-dir=%s", expectedUserDataDir), + "https://fallback.example/", + } + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("unexpected browser args:\n got=%v\nwant=%v", gotArgs, wantArgs) + } +} + +func TestBrowserInstanceOpenURLMarksStaleProfileStopped(t *testing.T) { + t.Parallel() + + app := newBrowserOpenURLTestApp(t) + profile := &BrowserProfile{ + ProfileId: "profile-stale", + ProfileName: "Stale Browser", + Running: true, + DebugReady: false, + } + app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} + app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) + + ok, err := app.BrowserInstanceOpenUrl(profile.ProfileId, "https://stale.example/") + if err == nil { + t.Fatal("expected BrowserInstanceOpenUrl to fail for stale runtime state") + } + if ok { + t.Fatal("expected BrowserInstanceOpenUrl to return false for stale runtime state") + } + if !strings.Contains(err.Error(), "运行状态已失效") { + t.Fatalf("unexpected error: %v", err) + } + if profile.Running { + t.Fatal("expected stale profile to be marked stopped") + } + if profile.DebugReady { + t.Fatal("expected stale profile debug state to be cleared") + } + if profile.DebugPort != 0 || profile.Pid != 0 { + t.Fatalf("expected runtime identifiers to be cleared, got debugPort=%d pid=%d", profile.DebugPort, profile.Pid) + } +} + +func newBrowserOpenURLTestApp(t *testing.T) *App { + t.Helper() + + cfg := config.DefaultConfig() + app := NewApp("") + app.config = cfg + app.browserMgr = browser.NewManager(cfg, t.TempDir()) + app.browserMgr.Profiles = make(map[string]*BrowserProfile) + app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) + return app +} + +func newBrowserOpenURLTestAppWithCore(t *testing.T) (*App, string) { + t.Helper() + + cfg := config.DefaultConfig() + exePath := createFakeBrowserExecutable(t) + cfg.Browser.Cores = []config.BrowserCore{ + { + CoreId: "core-open-url-test", + CoreName: "Open URL Test Core", + CorePath: exePath, + IsDefault: true, + }, + } + + app := NewApp("") + app.config = cfg + app.browserMgr = browser.NewManager(cfg, t.TempDir()) + app.browserMgr.Profiles = make(map[string]*BrowserProfile) + app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) + return app, exePath +} + +func createFakeBrowserExecutable(t *testing.T) string { + t.Helper() + + candidates := browser.CoreExecutableCandidates() + if len(candidates) == 0 { + t.Fatal("no core executable candidates available") + } + + baseDir := t.TempDir() + exePath := filepath.Join(baseDir, filepath.FromSlash(candidates[0])) + if err := os.MkdirAll(filepath.Dir(exePath), 0o755); err != nil { + t.Fatalf("创建测试内核目录失败: %v", err) + } + + mode := os.FileMode(0o644) + content := []byte("test-browser") + if goruntime.GOOS != "windows" { + mode = 0o755 + content = []byte("#!/bin/sh\nexit 0\n") + } + if err := os.WriteFile(exePath, content, mode); err != nil { + t.Fatalf("写入测试内核可执行文件失败: %v", err) + } + return exePath +} diff --git a/backend/app_instance_start.go b/backend/app_instance_start.go index 4e110665..6fec79f6 100644 --- a/backend/app_instance_start.go +++ b/backend/app_instance_start.go @@ -30,6 +30,10 @@ func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []s } plan, err := a.prepareBrowserStartPlan(input, profile) + if err == errBrowserStartHandledByRecoveredRuntime { + a.emitBrowserInstanceStarted(profile, true) + return profile, nil + } if err != nil { return profile, err } diff --git a/backend/app_instance_start_execute.go b/backend/app_instance_start_execute.go index a94b0291..a37164d8 100644 --- a/backend/app_instance_start_execute.go +++ b/backend/app_instance_start_execute.go @@ -11,6 +11,7 @@ import ( func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browserStartPlan) (*BrowserProfile, error) { log := logger.New("Browser") profile := plan.profile + a.clearDeferredStartTargets(input.ProfileID) cmd := exec.Command(plan.chromeBinaryPath, plan.args...) cmd.Dir = filepath.Dir(plan.chromeBinaryPath) @@ -49,6 +50,20 @@ func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browser a.bindProfileXrayBridge(input.ProfileID, plan.acquiredXrayBridgeKey) plan.releaseXrayBridge = false } + if len(plan.deferredStartTargets) > 0 { + if err := openBrowserStartTargets(stableDebugPort, plan.deferredStartTargets); err != nil { + warning := deferredStartTargetsWarning(plan.deferredStartTargets, err) + profile.RuntimeWarning = warning + profile.LastError = "" + log.Warn("浏览器已就绪,但启动页延后打开失败", + logger.F("profile_id", input.ProfileID), + logger.F("debug_port", stableDebugPort), + logger.F("target_count", len(plan.deferredStartTargets)), + logger.F("error", err.Error()), + logger.F("warning", warning), + ) + } + } log.Info("实例启动", logger.F("profile_id", input.ProfileID), @@ -97,6 +112,9 @@ func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browser runtimeWarning := browserDebugPendingWarning(plan.totalReadyTimeout) pendingStartNotice = browserDebugPendingStartNotice(plan.totalReadyTimeout) a.markProfileRunningLocked(input.ProfileID, profile, cmd, cmd.Process.Pid, plan.assignedDebugPort, false, runtimeWarning) + if len(plan.deferredStartTargets) > 0 { + a.storeDeferredStartTargets(input.ProfileID, plan.deferredStartTargets) + } if plan.acquiredXrayBridgeKey != "" { a.bindProfileXrayBridge(input.ProfileID, plan.acquiredXrayBridgeKey) plan.releaseXrayBridge = false @@ -120,10 +138,12 @@ func (a *App) startBrowserProfileWithPlan(input browserStartInput, plan *browser } if lastStartErr != nil { + a.clearDeferredStartTargets(input.ProfileID) profile.LastError = lastStartErr.Error() return profile, lastStartErr } + a.clearDeferredStartTargets(input.ProfileID) startErr := fmt.Errorf("实例启动失败:浏览器在等待窗口内仍未就绪") profile.LastError = startErr.Error() return profile, startErr diff --git a/backend/app_instance_start_prepare.go b/backend/app_instance_start_prepare.go index d9b575fd..96f2a2f7 100644 --- a/backend/app_instance_start_prepare.go +++ b/backend/app_instance_start_prepare.go @@ -26,6 +26,7 @@ type browserStartPlan struct { chromeBinaryPath string userDataDir string args []string + deferredStartTargets []string effectiveProxy string acquiredXrayBridgeKey string releaseXrayBridge bool @@ -36,6 +37,8 @@ type browserStartPlan struct { totalReadyTimeout time.Duration } +var clearBrowserSessionRestoreData = browser.ClearSessionRestoreData + func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool, proxyID string, proxyConfig string) browserStartInput { normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs) if preferVisibleWindow { @@ -76,6 +79,7 @@ func (a *App) resolveBrowserStartProfile(input browserStartInput) (*BrowserProfi log.Error("实例不存在", logger.F("profile_id", input.ProfileID), logger.F("reason", err.Error())) return nil, false, err } + a.ensureProfileLaunchCode(profile) if !profile.Running { return profile, false, nil @@ -113,7 +117,8 @@ func (a *App) resolveBrowserStartProfile(input browserStartInput) (*BrowserProfi } func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserProfile) (*browserStartPlan, error) { - sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, err := a.prepareBrowserLaunchContext(input, profile) + bookmarks := a.BookmarkList() + sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, err := a.prepareBrowserLaunchContext(input, profile, bookmarks) if err != nil { return nil, err } @@ -126,6 +131,15 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP startReadyTimeout, startStableWindow := a.browserStartTimingSettings() maxStartAttempts := browserStartAttemptCount() totalReadyTimeout := time.Duration(maxStartAttempts) * startReadyTimeout + restoreLastSession := browserRestoreLastSession(a.config) + defaultStartURLs := mergeStartURLs(browserDefaultStartURLs(a.config), bookmarkStartURLs(bookmarks)) + launchTargets, deferredStartTargets := buildBrowserLaunchTargets( + input.StartURLs, + defaultStartURLs, + input.SkipDefaultStartURLs, + restoreLastSession, + browserLightStartEnabled(a.config), + ) assignedDebugPort, err := nextAvailablePort() if err != nil { @@ -143,7 +157,8 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP profile: profile, chromeBinaryPath: chromeBinaryPath, userDataDir: userDataDir, - args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, input.StartURLs, a.browserDefaultStartURLs(), input.SkipDefaultStartURLs, browserRestoreLastSession(a.config)), + args: buildBrowserLaunchArgs(profile, userDataDir, assignedDebugPort, effectiveProxy, sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, launchTargets), + deferredStartTargets: deferredStartTargets, effectiveProxy: effectiveProxy, acquiredXrayBridgeKey: acquiredXrayBridgeKey, releaseXrayBridge: releaseXrayBridge, @@ -155,7 +170,7 @@ func (a *App) prepareBrowserStartPlan(input browserStartInput, profile *BrowserP }, nil } -func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *BrowserProfile) ([]string, []string, string, string, error) { +func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *BrowserProfile, bookmarks []BrowserBookmark) ([]string, []string, string, string, error) { log := logger.New("Browser") sanitizedProfileLaunchArgs, managedProfileArgs := sanitizeManagedLaunchArgs(profile.LaunchArgs) @@ -193,12 +208,47 @@ func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *Brow return nil, nil, "", "", startErr } - if err := browser.EnsureDefaultBookmarks(userDataDir, a.BookmarkList()); err != nil { + if err := browser.EnsureDefaultBookmarks(userDataDir, bookmarks); err != nil { log.Error("默认书签写入失败", logger.F("error", err.Error())) } + if detection, ok := detectBrowserRuntimeByActivePort(userDataDir); ok && detection.DebugReady { + a.markProfileRunningLocked(input.ProfileID, profile, nil, detection.PID, detection.DebugPort, true, "") + log.Warn("检测到同一用户数据目录已有浏览器运行,已接管为当前实例状态", + logger.F("profile_id", input.ProfileID), + logger.F("user_data_dir", userDataDir), + logger.F("pid", detection.PID), + logger.F("debug_port", detection.DebugPort), + ) + if input.PreferVisibleWindow { + if err := a.openBrowserWindowForRunningProfile(profile, input.ExtraLaunchArgs, input.StartURLs); err != nil { + startErr := fmt.Errorf("实例已在运行,但窗口唤起失败:%w", err) + profile.LastError = startErr.Error() + return nil, nil, "", "", startErr + } + } + return nil, nil, "", "", errBrowserStartHandledByRecoveredRuntime + } + if !browserRestoreLastSession(a.config) { - if err := browser.ClearSessionRestoreData(userDataDir); err != nil { + if err := clearBrowserSessionRestoreData(userDataDir); err != nil { + if terminated, terminateErr := terminateBrowserProcessesByUserDataDir(userDataDir, 5*time.Second); terminateErr == nil && terminated { + log.Warn("会话缓存被旧浏览器进程占用,已结束占用进程并重试清理", + logger.F("profile_id", input.ProfileID), + logger.F("user_data_dir", userDataDir), + ) + if retryErr := clearBrowserSessionRestoreData(userDataDir); retryErr == nil { + return sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, nil + } else { + err = retryErr + } + } else if terminateErr != nil { + log.Warn("会话缓存清理失败后尝试结束占用进程失败", + logger.F("profile_id", input.ProfileID), + logger.F("user_data_dir", userDataDir), + logger.F("error", terminateErr.Error()), + ) + } sessionDir := filepath.Join(userDataDir, "Default", "Sessions") startErr := fmt.Errorf("实例启动失败:无法清理上次会话缓存 %s。原因:%w。请关闭占用该目录的浏览器进程后重试。", sessionDir, err) log.Error("会话恢复缓存清理失败", @@ -215,7 +265,7 @@ func (a *App) prepareBrowserLaunchContext(input browserStartInput, profile *Brow return sanitizedProfileLaunchArgs, sanitizedExtraLaunchArgs, chromeBinaryPath, userDataDir, nil } -func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPort int, effectiveProxy string, sanitizedProfileLaunchArgs []string, sanitizedExtraLaunchArgs []string, startURLs []string, defaultStartURLs []string, skipDefaultStartURLs bool, restoreLastSession bool) []string { +func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPort int, effectiveProxy string, sanitizedProfileLaunchArgs []string, sanitizedExtraLaunchArgs []string, launchTargets []string) []string { args := []string{ fmt.Sprintf("--user-data-dir=%s", userDataDir), fmt.Sprintf("--remote-debugging-port=%d", debugPort), @@ -241,7 +291,7 @@ func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPo } if effectiveProxy == "direct://" { - args = append(args, "--proxy-server=direct://") + args = append(args, "--no-proxy-server") } else if effectiveProxy != "" { args = append(args, fmt.Sprintf("--proxy-server=%s", effectiveProxy)) } @@ -249,5 +299,5 @@ func buildBrowserLaunchArgs(profile *BrowserProfile, userDataDir string, debugPo args = append(args, profile.FingerprintArgs...) args = append(args, sanitizedProfileLaunchArgs...) args = append(args, sanitizedExtraLaunchArgs...) - return appendLaunchTargets(args, startURLs, defaultStartURLs, skipDefaultStartURLs, restoreLastSession) + return browser.BuildLaunchArgs(args, launchTargets) } diff --git a/backend/app_instance_start_test.go b/backend/app_instance_start_test.go index 2cb72054..1241f2a0 100644 --- a/backend/app_instance_start_test.go +++ b/backend/app_instance_start_test.go @@ -540,6 +540,37 @@ func TestAppendLaunchTargetsPreservesSessionRestoreWhenEnabled(t *testing.T) { } } +func TestBuildBrowserLaunchArgsUsesNoProxyServerForDirectProxy(t *testing.T) { + t.Parallel() + + profile := &BrowserProfile{ + ProfileId: "profile-direct", + } + + got := buildBrowserLaunchArgs( + profile, + `D:\profiles\direct`, + 9222, + "direct://", + nil, + nil, + []string{"about:blank"}, + ) + + hasNoProxyServer := false + for _, arg := range got { + if arg == "--no-proxy-server" { + hasNoProxyServer = true + } + if arg == "--proxy-server=direct://" { + t.Fatalf("expected direct proxy launch args to avoid --proxy-server=direct://, got=%v", got) + } + } + if !hasNoProxyServer { + t.Fatalf("expected direct proxy to use --no-proxy-server, got=%v", got) + } +} + func mustListenLoopback(t *testing.T) net.Listener { t.Helper() diff --git a/backend/app_instance_status.go b/backend/app_instance_status.go index 2f197580..80c3d3ce 100644 --- a/backend/app_instance_status.go +++ b/backend/app_instance_status.go @@ -1,6 +1,13 @@ package backend -import "fmt" +import ( + "fmt" + "strings" + + "ant-chrome/backend/internal/logger" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) func (a *App) BrowserInstanceStatus(profileId string) (*BrowserProfile, error) { a.browserMgr.Mutex.Lock() @@ -9,17 +16,112 @@ func (a *App) BrowserInstanceStatus(profileId string) (*BrowserProfile, error) { if !exists { return nil, fmt.Errorf("profile not found") } + a.ensureProfileLaunchCode(profile) + if !profile.Running { + userDataDir := a.browserMgr.ResolveUserDataDir(profile) + if detection, ok := detectBrowserRuntimeByUserDataDir(userDataDir); ok && detection.DebugReady { + a.markProfileRunningLocked(profileId, profile, nil, detection.PID, detection.DebugPort, true, "") + logger.New("Browser").Warn("状态查询发现同一用户数据目录浏览器已运行,已同步实例状态", + logger.F("profile_id", profileId), + logger.F("user_data_dir", userDataDir), + logger.F("pid", detection.PID), + logger.F("debug_port", detection.DebugPort), + ) + } + } return profile, nil } -func (a *App) BrowserInstanceOpenUrl(profileId string, targetUrl string) bool { +func (a *App) BrowserInstanceOpenUrl(profileId string, targetUrl string) (bool, error) { + normalizedTargetURL := strings.TrimSpace(targetUrl) + if normalizedTargetURL == "" { + return false, fmt.Errorf("打开地址失败:目标地址不能为空") + } + + log := logger.New("Browser") + a.browserMgr.Mutex.Lock() profile, exists := a.browserMgr.Profiles[profileId] - a.browserMgr.Mutex.Unlock() - if !exists || !profile.Running { - return false + if !exists { + a.browserMgr.Mutex.Unlock() + return false, fmt.Errorf("打开地址失败:未找到实例配置(ID=%s)。请刷新列表后重试。", profileId) } - return true + a.ensureProfileLaunchCode(profile) + trackedCmd := a.browserMgr.BrowserProcesses[profileId] + if !profile.Running { + userDataDir := a.browserMgr.ResolveUserDataDir(profile) + if detection, ok := detectBrowserRuntimeByUserDataDir(userDataDir); ok && detection.DebugReady { + a.markProfileRunningLocked(profileId, profile, nil, detection.PID, detection.DebugPort, true, "") + log.Warn("打开地址前发现同一用户数据目录浏览器已运行,已同步实例状态", + logger.F("profile_id", profileId), + logger.F("user_data_dir", userDataDir), + logger.F("pid", detection.PID), + logger.F("debug_port", detection.DebugPort), + ) + } else { + a.browserMgr.Mutex.Unlock() + return false, fmt.Errorf("打开地址失败:实例当前未运行,请先启动实例后再试。") + } + } + if !isBrowserProfileLive(profile, trackedCmd) { + staleDebugPort := profile.DebugPort + stalePID := profile.Pid + a.markProfileStoppedLocked(profileId, profile) + profile.LastError = "打开地址失败:检测到实例运行状态已失效,请先重新启动实例。" + a.browserMgr.Mutex.Unlock() + + log.Warn("检测到实例运行状态已失效,取消复用打开地址", + logger.F("profile_id", profileId), + logger.F("debug_port", staleDebugPort), + logger.F("pid", stalePID), + ) + if a.ctx != nil { + runtime.EventsEmit(a.ctx, "browser:instance:stopped", profileId) + } + return false, fmt.Errorf("%s", profile.LastError) + } + + snapshot := copyBrowserProfileSnapshot(profile) + a.browserMgr.Mutex.Unlock() + + if snapshot.DebugReady && snapshot.DebugPort > 0 { + if err := createBrowserStartTarget(snapshot.DebugPort, normalizedTargetURL); err == nil { + log.Info("复用运行中实例通过 CDP 打开地址", + logger.F("profile_id", profileId), + logger.F("debug_port", snapshot.DebugPort), + logger.F("target_url", normalizedTargetURL), + ) + return true, nil + } else { + log.Warn("运行中实例通过 CDP 打开地址失败,回退到浏览器进程唤起", + logger.F("profile_id", profileId), + logger.F("debug_port", snapshot.DebugPort), + logger.F("target_url", normalizedTargetURL), + logger.F("error", err.Error()), + ) + } + } + + if err := a.openBrowserWindowForRunningProfile(snapshot, nil, []string{normalizedTargetURL}); err != nil { + openErr := fmt.Errorf("打开地址失败:实例运行中,但复用现有会话打开页面失败:%w", err) + log.Error("运行中实例打开地址失败", + logger.F("profile_id", profileId), + logger.F("debug_ready", snapshot.DebugReady), + logger.F("debug_port", snapshot.DebugPort), + logger.F("target_url", normalizedTargetURL), + logger.F("error", err.Error()), + logger.F("reason", openErr.Error()), + ) + return false, openErr + } + + log.Info("复用运行中实例通过浏览器进程打开地址", + logger.F("profile_id", profileId), + logger.F("debug_ready", snapshot.DebugReady), + logger.F("debug_port", snapshot.DebugPort), + logger.F("target_url", normalizedTargetURL), + ) + return true, nil } func (a *App) BrowserInstanceGetTabs(profileId string) []BrowserTab { diff --git a/backend/app_launchcode.go b/backend/app_launchcode.go index 7baa2176..0d932568 100644 --- a/backend/app_launchcode.go +++ b/backend/app_launchcode.go @@ -55,7 +55,11 @@ func (a *App) BrowserProfileGetCode(profileId string) (string, error) { if a.launchCodeSvc == nil { return "", nil } - return a.launchCodeSvc.EnsureCode(profileId) + code, err := a.launchCodeSvc.EnsureCode(profileId) + if err == nil { + a.setManagedProfileLaunchCode(profileId, code) + } + return code, err } // BrowserProfileRegenerateCode 重新生成实例的 LaunchCode(Wails 绑定) @@ -63,7 +67,11 @@ func (a *App) BrowserProfileRegenerateCode(profileId string) (string, error) { if a.launchCodeSvc == nil { return "", nil } - return a.launchCodeSvc.RegenerateCode(profileId) + code, err := a.launchCodeSvc.RegenerateCode(profileId) + if err == nil { + a.setManagedProfileLaunchCode(profileId, code) + } + return code, err } // BrowserProfileSetCode 自定义设置实例 LaunchCode(Wails 绑定) @@ -71,7 +79,11 @@ func (a *App) BrowserProfileSetCode(profileId string, code string) (string, erro if a.launchCodeSvc == nil { return "", nil } - return a.launchCodeSvc.SetCode(profileId, code) + updated, err := a.launchCodeSvc.SetCode(profileId, code) + if err == nil { + a.setManagedProfileLaunchCode(profileId, updated) + } + return updated, err } // BrowserInstanceStartByCode 通过 LaunchCode 启动实例(Wails 绑定) diff --git a/backend/automation_demo_api.go b/backend/automation_demo_api.go index 84c61e22..db54946c 100644 --- a/backend/automation_demo_api.go +++ b/backend/automation_demo_api.go @@ -119,18 +119,26 @@ func (a *App) automationDemoRequest(method string, apiPath string, body any) (in return a.automationDemoRequestWithContext(context.Background(), method, apiPath, body) } -func (a *App) automationDemoRequestWithContext(ctx context.Context, method string, apiPath string, body any) (int, map[string]interface{}, error) { +func automationDemoRequestContext(ctx context.Context) (context.Context, context.CancelFunc) { if ctx == nil { ctx = context.Background() } + if _, hasDeadline := ctx.Deadline(); hasDeadline { + return ctx, func() {} + } + return context.WithTimeout(ctx, automationDemoTimeout) +} + +func (a *App) automationDemoRequestWithContext(ctx context.Context, method string, apiPath string, body any) (int, map[string]interface{}, error) { + reqCtx, cancel := automationDemoRequestContext(ctx) + defer cancel() + baseURL, authHeader, authValue, err := a.automationDemoEndpoint() if err != nil { return 0, nil, err } requestURL := strings.TrimRight(baseURL, "/") + apiPath - ctx, cancel := context.WithTimeout(ctx, automationDemoTimeout) - defer cancel() var reader io.Reader if body != nil { @@ -141,7 +149,7 @@ func (a *App) automationDemoRequestWithContext(ctx context.Context, method strin reader = bytes.NewReader(raw) } - req, err := http.NewRequestWithContext(ctx, method, requestURL, reader) + req, err := http.NewRequestWithContext(reqCtx, method, requestURL, reader) if err != nil { return 0, nil, fmt.Errorf("create demo request failed: %w", err) } @@ -152,9 +160,9 @@ func (a *App) automationDemoRequestWithContext(ctx context.Context, method strin req.Header.Set(authHeader, authValue) } - resp, err := (&http.Client{Timeout: automationDemoTimeout}).Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { + if ctxErr := reqCtx.Err(); ctxErr != nil { return 0, nil, fmt.Errorf("call launch api failed: %w", ctxErr) } return 0, nil, fmt.Errorf("call launch api failed: %w", err) diff --git a/backend/automation_demo_api_test.go b/backend/automation_demo_api_test.go index e79fbc7d..3100280f 100644 --- a/backend/automation_demo_api_test.go +++ b/backend/automation_demo_api_test.go @@ -2,9 +2,11 @@ package backend import ( "bytes" + "context" "net/http" "regexp" "testing" + "time" ) func TestAutomationDemoLaunchCodeFormat(t *testing.T) { @@ -85,3 +87,38 @@ func TestDecodeAutomationDemoBodyFallsBackToRawText(t *testing.T) { t.Fatalf("expected rawBody fallback, got %#v", payload["rawBody"]) } } + +func TestAutomationDemoRequestContextAppliesDefaultTimeoutWithoutDeadline(t *testing.T) { + startedAt := time.Now() + ctx, cancel := automationDemoRequestContext(context.Background()) + defer cancel() + + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("expected request context to contain a deadline") + } + + timeout := deadline.Sub(startedAt) + if timeout < 9*time.Second || timeout > 11*time.Second { + t.Fatalf("expected default timeout near 10s, got %s", timeout) + } +} + +func TestAutomationDemoRequestContextKeepsExistingDeadline(t *testing.T) { + wantDeadline := time.Now().Add(45 * time.Second) + parentCtx, parentCancel := context.WithDeadline(context.Background(), wantDeadline) + defer parentCancel() + + ctx, cancel := automationDemoRequestContext(parentCtx) + defer cancel() + + gotDeadline, ok := ctx.Deadline() + if !ok { + t.Fatal("expected existing deadline to be preserved") + } + + diff := gotDeadline.Sub(wantDeadline) + if diff < -100*time.Millisecond || diff > 100*time.Millisecond { + t.Fatalf("expected preserved deadline %s, got %s", wantDeadline, gotDeadline) + } +} diff --git a/backend/automation_script_api_test.go b/backend/automation_script_api_test.go index 05f18ce1..19f32030 100644 --- a/backend/automation_script_api_test.go +++ b/backend/automation_script_api_test.go @@ -24,8 +24,8 @@ func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) { if err != nil { t.Fatalf("AutomationScriptList returned error: %v", err) } - if len(items) != 2 { - t.Fatalf("expected two default scripts, got %d", len(items)) + if len(items) != 3 { + t.Fatalf("expected three default scripts, got %d", len(items)) } byID := make(map[string]automation.ScriptRecord, len(items)) @@ -36,6 +36,7 @@ func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) { expectedNames := map[string]string{ "dual-instance-runtime-switch": "双实例启动与 Runtime 切换", "news-query-txt": "查询新闻并写 TXT", + "web-image-generate-download": "网页图片生成并下载", } for scriptID, expectedName := range expectedNames { @@ -49,6 +50,9 @@ func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) { if script.EntryFile != "index.cjs" { t.Fatalf("unexpected default entry file for %q: %q", scriptID, script.EntryFile) } + if script.Source.Type != "builtin" { + t.Fatalf("expected builtin source for %q, got %+v", scriptID, script.Source) + } scriptDir := filepath.Join(app.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", "scripts"))), script.ID) if _, err := os.Stat(filepath.Join(scriptDir, "config")); err != nil { @@ -82,6 +86,59 @@ func TestAutomationScriptListSeedsDefaultScriptsOnFreshApp(t *testing.T) { } } +func TestAutomationScriptListAddsMissingBuiltinWhenLegacyMarkerExists(t *testing.T) { + app := NewApp(t.TempDir()) + + if _, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "custom-script", + Name: "自定义脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + }); err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + legacyMarkerPath := app.automationScriptDefaultsMarkerPath("defaults-seeded-v7") + if err := os.MkdirAll(filepath.Dir(legacyMarkerPath), 0o755); err != nil { + t.Fatalf("create legacy marker dir failed: %v", err) + } + if err := os.WriteFile(legacyMarkerPath, []byte("ok\n"), 0o644); err != nil { + t.Fatalf("write legacy marker failed: %v", err) + } + + items, err := app.AutomationScriptList() + if err != nil { + t.Fatalf("AutomationScriptList returned error: %v", err) + } + if len(items) != 4 { + t.Fatalf("expected custom script plus three defaults, got %d items", len(items)) + } + + expectedDefaultIDs := []string{ + automation.DualInstanceRuntimeScriptID, + automation.NewsQueryTXTScriptID, + automation.WebImageGenerateScriptID, + } + for _, scriptID := range expectedDefaultIDs { + found := false + for _, item := range items { + if item.ID == scriptID { + found = true + break + } + } + if !found { + t.Fatalf("expected migrated default script %q to exist", scriptID) + } + } + + if !app.automationScriptDefaultsInitialized() { + t.Fatalf("expected new defaults marker to be written") + } +} + func TestAutomationScriptSaveListAndDelete(t *testing.T) { app := NewApp(t.TempDir()) @@ -276,6 +333,37 @@ func TestAutomationScriptRunWithOptionsAllowsEmptySelectorForDualInstanceRuntime } } +func TestAutomationScriptRunWithOptionsSeedsDefaultScriptsOnFreshApp(t *testing.T) { + app := NewApp(t.TempDir()) + + run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ + ScriptID: automation.DualInstanceRuntimeScriptID, + UseScriptSelector: true, + UseScriptParams: true, + }) + if err != nil { + t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err) + } + if run == nil { + t.Fatalf("AutomationScriptRunWithOptions returned nil result") + } + if run.ScriptID != automation.DualInstanceRuntimeScriptID { + t.Fatalf("unexpected script id: %+v", run) + } + if run.ScriptName != "双实例启动与 Runtime 切换" { + t.Fatalf("expected default script metadata to be hydrated, got %+v", run) + } + if run.ScriptType != "launch-api" { + t.Fatalf("expected default script type launch-api, got %+v", run) + } + if run.Summary == "脚本读取失败" { + t.Fatalf("expected direct run to seed defaults before execution, got %+v", run) + } + if strings.Contains(strings.ToLower(run.Error), "script not found") { + t.Fatalf("expected seeded default script, got %+v", run) + } +} + func TestAutomationScriptRefreshFromLocalFile(t *testing.T) { app := NewApp(t.TempDir()) @@ -330,6 +418,64 @@ func TestAutomationScriptRefreshFromLocalFile(t *testing.T) { } } +func TestAutomationScriptRefreshFromBuiltin(t *testing.T) { + app := NewApp(t.TempDir()) + + savedImportedAt := "2026-01-01T00:00:00Z" + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: automation.NewsQueryTXTScriptID, + Name: "旧新闻脚本", + Type: "launch-api", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "builtin", + URI: "repo://backend/internal/automation/demo-library/news-query-txt", + Ref: "HEAD", + Path: automation.NewsQueryTXTScriptID, + ImportedAt: savedImportedAt, + }, + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Path: "demo/news-refresh", + RequestMode: "params-only", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + refreshed, err := app.AutomationScriptRefresh(saved.ID) + if err != nil { + t.Fatalf("AutomationScriptRefresh returned error: %v", err) + } + if refreshed == nil { + t.Fatalf("AutomationScriptRefresh returned nil result") + } + if refreshed.ID != saved.ID { + t.Fatalf("expected same script id, got %q want %q", refreshed.ID, saved.ID) + } + if refreshed.Status != "ready" { + t.Fatalf("expected status to be preserved, got %q", refreshed.Status) + } + if refreshed.Name != "查询新闻并写 TXT" { + t.Fatalf("expected builtin script name to be restored, got %q", refreshed.Name) + } + if !strings.Contains(refreshed.ScriptText, "acceptedItems") { + t.Fatalf("expected refreshed builtin script text to contain news filtering logic, got %q", refreshed.ScriptText) + } + if refreshed.Source.Type != "builtin" || refreshed.Source.Path != automation.NewsQueryTXTScriptID { + t.Fatalf("unexpected refreshed source: %+v", refreshed.Source) + } + if refreshed.Source.ImportedAt == "" || refreshed.Source.ImportedAt == savedImportedAt { + t.Fatalf("expected builtin refresh to update importedAt, got %+v", refreshed.Source) + } + if refreshed.PublicAPI.Path != "demo/news-refresh" || !refreshed.PublicAPI.Enabled { + t.Fatalf("expected public api config to be preserved on refresh, got %+v", refreshed.PublicAPI) + } +} + func TestAutomationScriptRefreshFromLocalDirectory(t *testing.T) { app := NewApp(t.TempDir()) @@ -388,6 +534,133 @@ func TestAutomationScriptRefreshFromLocalDirectory(t *testing.T) { } } +func TestImportAutomationLocalLibraryImportsAndUpdatesExistingSource(t *testing.T) { + app := NewApp(t.TempDir()) + libraryRoot := filepath.Join(t.TempDir(), "script-library") + + firstScriptDir := filepath.Join(libraryRoot, "first-script") + writeAutomationScriptLibraryPackage(t, firstScriptDir, `{ + "name": "脚本一", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`, "module.exports.run = async () => ({ ok: true, source: 'first-script' })") + + secondScriptDir := filepath.Join(libraryRoot, "second-script") + if err := os.MkdirAll(secondScriptDir, 0o755); err != nil { + t.Fatalf("create second script dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(secondScriptDir, "index.cjs"), []byte("module.exports.run = async () => ({ ok: true, source: 'second-script' })"), 0o644); err != nil { + t.Fatalf("write second script entry failed: %v", err) + } + + existing, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "existing-local-library-script", + Name: "旧脚本一", + Type: "launch-api", + Status: "disabled", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: false })", + Source: automation.ScriptSource{ + Type: "local-dir", + URI: firstScriptDir, + }, + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Path: "library/existing-script", + }, + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + result, err := app.importAutomationLocalLibrary(libraryRoot) + if err != nil { + t.Fatalf("importAutomationLocalLibrary returned error: %v", err) + } + if result == nil { + t.Fatalf("importAutomationLocalLibrary returned nil result") + } + if result.Scanned != 2 { + t.Fatalf("expected scanned count 2, got %d", result.Scanned) + } + if len(result.Imported) != 2 { + t.Fatalf("expected two imported scripts, got %d", len(result.Imported)) + } + if len(result.Failed) != 0 { + t.Fatalf("expected no failed imports, got %+v", result.Failed) + } + + updatedFirst, err := app.AutomationScriptGet(existing.ID) + if err != nil { + t.Fatalf("AutomationScriptGet returned error: %v", err) + } + if updatedFirst.Name != "脚本一" { + t.Fatalf("expected existing script to be refreshed from library, got %q", updatedFirst.Name) + } + if updatedFirst.Status != "disabled" { + t.Fatalf("expected existing status to be preserved, got %q", updatedFirst.Status) + } + if updatedFirst.Source.Type != "local-dir" || updatedFirst.Source.URI != firstScriptDir { + t.Fatalf("unexpected updated source: %+v", updatedFirst.Source) + } + if !strings.Contains(updatedFirst.ScriptText, "first-script") { + t.Fatalf("expected refreshed first script body, got %q", updatedFirst.ScriptText) + } + if updatedFirst.PublicAPI.Path != "library/existing-script" || !updatedFirst.PublicAPI.Enabled { + t.Fatalf("expected existing public api config to be preserved, got %+v", updatedFirst.PublicAPI) + } + + allScripts, err := app.automationScriptStore().List() + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(allScripts) != 2 { + t.Fatalf("expected two stored scripts after upsert, got %d", len(allScripts)) + } +} + +func TestImportAutomationLocalLibraryContinuesOnSinglePackageFailure(t *testing.T) { + app := NewApp(t.TempDir()) + libraryRoot := filepath.Join(t.TempDir(), "script-library") + + goodDir := filepath.Join(libraryRoot, "good-script") + writeAutomationScriptLibraryPackage(t, goodDir, `{ + "name": "好脚本", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`, "module.exports.run = async () => ({ ok: true, source: 'good-script' })") + + badDir := filepath.Join(libraryRoot, "bad-script") + writeAutomationScriptLibraryPackage(t, badDir, `{ + "name": "坏脚本", + "type": "playwright-cdp", + "entryFile": "missing.cjs" +}`, "") + + result, err := app.importAutomationLocalLibrary(libraryRoot) + if err != nil { + t.Fatalf("importAutomationLocalLibrary returned error: %v", err) + } + if result == nil { + t.Fatalf("importAutomationLocalLibrary returned nil result") + } + if result.Scanned != 2 { + t.Fatalf("expected scanned count 2, got %d", result.Scanned) + } + if len(result.Imported) != 1 { + t.Fatalf("expected one imported script, got %d", len(result.Imported)) + } + if len(result.Failed) != 1 { + t.Fatalf("expected one failed script, got %+v", result.Failed) + } + if result.Failed[0].Path != badDir { + t.Fatalf("unexpected failed path: %+v", result.Failed[0]) + } + if !strings.Contains(result.Failed[0].Message, "entry file missing.cjs not found") { + t.Fatalf("unexpected failed message: %+v", result.Failed[0]) + } +} + func TestAutomationScriptRefreshFromRemote(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{ @@ -733,3 +1006,21 @@ func buildAutomationZipBytesForTest(t *testing.T, files map[string]string) []byt } return buf.Bytes() } + +func writeAutomationScriptLibraryPackage(t *testing.T, dir string, manifest string, entry string) { + t.Helper() + + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create script library package dir failed: %v", err) + } + if strings.TrimSpace(manifest) != "" { + if err := os.WriteFile(filepath.Join(dir, "automation.script.json"), []byte(manifest), 0o644); err != nil { + t.Fatalf("write script library manifest failed: %v", err) + } + } + if strings.TrimSpace(entry) != "" { + if err := os.WriteFile(filepath.Join(dir, "index.cjs"), []byte(entry), 0o644); err != nil { + t.Fatalf("write script library entry failed: %v", err) + } + } +} diff --git a/backend/automation_script_defaults.go b/backend/automation_script_defaults.go index 2993285d..e66665d6 100644 --- a/backend/automation_script_defaults.go +++ b/backend/automation_script_defaults.go @@ -8,10 +8,19 @@ import ( ) const ( - automationScriptDefaultsMarkerName = "defaults-seeded-v2" - automationScriptDefaultsLegacyMarkerName = "defaults-seeded-v1" + automationScriptDefaultsMarkerName = "defaults-seeded-v9" ) +var automationScriptDefaultsLegacyMarkerNames = []string{ + "defaults-seeded-v8", + "defaults-seeded-v7", + "defaults-seeded-v6", + "defaults-seeded-v5", + "defaults-seeded-v4", + "defaults-seeded-v3", + "defaults-seeded-v2", +} + func (a *App) automationScriptDefaultsMarkerPath(name string) string { return a.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", name))) } @@ -25,8 +34,13 @@ func (a *App) automationScriptDefaultsInitialized() bool { return a.automationScriptDefaultsInitializedByName(automationScriptDefaultsMarkerName) } -func (a *App) automationScriptDefaultsInitializedLegacy() bool { - return a.automationScriptDefaultsInitializedByName(automationScriptDefaultsLegacyMarkerName) +func (a *App) automationScriptDefaultsInitializedAnyLegacy() bool { + for _, name := range automationScriptDefaultsLegacyMarkerNames { + if a.automationScriptDefaultsInitializedByName(name) { + return true + } + } + return false } func (a *App) markAutomationScriptDefaultsInitialized() error { @@ -38,7 +52,10 @@ func (a *App) markAutomationScriptDefaultsInitialized() error { } func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) error { - defaults := automation.DefaultScripts() + defaults, err := automation.DefaultScriptBundles() + if err != nil { + return err + } items, err := store.List() if err != nil { return err @@ -51,12 +68,12 @@ func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) erro if len(items) == 0 { // Keep legacy behavior for users that had deleted all defaults under v1. - if a.automationScriptDefaultsInitializedLegacy() { + if a.automationScriptDefaultsInitializedAnyLegacy() { return a.markAutomationScriptDefaultsInitialized() } - for _, record := range defaults { - if _, err := store.Save(record); err != nil { + for _, bundle := range defaults { + if _, err := store.ImportBundle(bundle); err != nil { return err } } @@ -64,16 +81,16 @@ func (a *App) ensureAutomationScriptDefaults(store *automation.ScriptStore) erro } // Migration from v1: existing scripts are present, add any missing built-in baselines once. - if a.automationScriptDefaultsInitializedLegacy() { + if a.automationScriptDefaultsInitializedAnyLegacy() { existingIDs := make(map[string]struct{}, len(items)) for _, item := range items { existingIDs[item.ID] = struct{}{} } - for _, record := range defaults { - if _, exists := existingIDs[record.ID]; exists { + for _, bundle := range defaults { + if _, exists := existingIDs[bundle.Record.ID]; exists { continue } - if _, err := store.Save(record); err != nil { + if _, err := store.ImportBundle(bundle); err != nil { return err } } diff --git a/backend/automation_script_http_e2e_test.go b/backend/automation_script_http_e2e_test.go new file mode 100644 index 00000000..022d7172 --- /dev/null +++ b/backend/automation_script_http_e2e_test.go @@ -0,0 +1,588 @@ +package backend + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + goruntime "runtime" + "strings" + "testing" + "time" + + "ant-chrome/backend/internal/automation" + "ant-chrome/backend/internal/config" +) + +type automationHTTPProfileCreateResponse struct { + OK bool `json:"ok"` + Created bool `json:"created"` + Launched bool `json:"launched"` + ProfileID string `json:"profileId"` + LaunchCode string `json:"launchCode"` +} + +type automationHTTPScriptsResponse struct { + OK bool `json:"ok"` + Data struct { + Count int `json:"count"` + Items []struct { + ID string `json:"id"` + } `json:"items"` + } `json:"data"` +} + +type automationHTTPRunResponse struct { + OK bool `json:"ok"` + Data struct { + Run struct { + Status string `json:"status"` + Summary string `json:"summary"` + Error string `json:"error"` + ResultText string `json:"resultText"` + } `json:"run"` + } `json:"data"` +} + +type automationHTTPHookEnvelopeResponse struct { + OK bool `json:"ok"` + Status string `json:"status"` + Summary string `json:"summary"` + Result map[string]interface{} `json:"result"` +} + +type automationHTTPLaunchLogsResponse struct { + OK bool `json:"ok"` + Items []json.RawMessage `json:"items"` +} + +func TestAutomationScriptRunHTTPReturnsSavedMailProbeScript(t *testing.T) { + nodePath := lookupAutomationHTTPProbeNode(t) + chromePath := lookupAutomationHTTPProbeChrome(t) + repoRoot := automationHTTPRepoRoot(t) + + tempRoot := t.TempDir() + cfg := config.DefaultConfig() + cfg.Logging.FileEnabled = false + cfg.LaunchServer.Port = automationHTTPFreePort(t) + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodePath + cfg.Automation.HeadlessDefault = true + if err := cfg.Save(filepath.Join(tempRoot, "config.yaml")); err != nil { + t.Fatalf("save config failed: %v", err) + } + + if err := prepareAutomationHTTPRuntime(tempRoot, repoRoot, cfg.Automation.RuntimeVersion); err != nil { + t.Fatalf("prepare runtime failed: %v", err) + } + + fixtureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, automationHTTPMailFixtureHTML) + })) + defer fixtureServer.Close() + + app := NewApp(tempRoot) + Start(app, nil) + defer Stop(app, nil) + + if err := app.BrowserCoreSave(BrowserCoreInput{ + CoreId: "system-chrome", + CoreName: "System Chrome", + CorePath: chromePath, + IsDefault: true, + }); err != nil { + t.Fatalf("save core failed: %v", err) + } + if err := app.BrowserCoreSetDefault("system-chrome"); err != nil { + t.Fatalf("set default core failed: %v", err) + } + + baseURL, ok := app.GetLaunchServerInfo()["baseUrl"].(string) + if !ok || strings.TrimSpace(baseURL) == "" { + t.Fatalf("launch server baseUrl missing: %+v", app.GetLaunchServerInfo()) + } + + var createResp automationHTTPProfileCreateResponse + if err := automationHTTPRequestJSON(http.MethodPost, baseURL+"/api/profiles", map[string]any{ + "profile": map[string]any{ + "profileName": "mail-probe", + "launchArgs": []string{ + "--headless=new", + "--disable-gpu", + "--no-first-run", + "--no-default-browser-check", + "--window-size=1440,1024", + }, + }, + "launchCode": "MAIL01", + }, &createResp); err != nil { + t.Fatalf("create profile via http failed: %v", err) + } + if !createResp.OK || !createResp.Created || createResp.LaunchCode != "MAIL01" { + t.Fatalf("unexpected create response: %+v", createResp) + } + + savedScript, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "mail-probe-script", + Name: "测试邮件探针", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: automationHTTPMailProbeScriptText, + }) + if err != nil { + t.Fatalf("save mail probe script failed: %v", err) + } + if savedScript == nil { + t.Fatalf("expected mail probe script to be saved") + } + savedScript.PublicAPI = automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "mail/probe-message", + RequestMode: "params-only", + ResponseMode: "envelope", + TimeoutMs: 120000, + } + savedScript.SelectorText = fmt.Sprintf("{\n \"code\": %q\n}", createResp.LaunchCode) + if _, err := app.AutomationScriptSave(*savedScript); err != nil { + t.Fatalf("save mail probe public api config failed: %v", err) + } + + var scriptsResp automationHTTPScriptsResponse + if err := automationHTTPRequestJSON(http.MethodGet, baseURL+"/api/automation/scripts", nil, &scriptsResp); err != nil { + t.Fatalf("list scripts via http failed: %v", err) + } + if !scriptsResp.OK || scriptsResp.Data.Count == 0 { + t.Fatalf("unexpected scripts response: %+v", scriptsResp) + } + if !automationHTTPHasScript(scriptsResp.Data.Items, "mail-probe-script") { + t.Fatalf("saved mail probe script missing: %+v", scriptsResp) + } + + var runResp automationHTTPRunResponse + runErr := automationHTTPRequestJSON(http.MethodPost, baseURL+"/api/automation/scripts/run", map[string]any{ + "scriptId": "mail-probe-script", + "selector": map[string]any{ + "code": createResp.LaunchCode, + }, + "params": map[string]any{ + "inboxUrl": fixtureServer.URL, + "timeoutMs": 45000, + }, + "timeoutMs": 120000, + }, &runResp) + if runErr != nil { + t.Fatalf("run script via http failed: %v", runErr) + } + + parsed := make(map[string]any) + if text := strings.TrimSpace(runResp.Data.Run.ResultText); text != "" { + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + t.Fatalf("parse run result failed: %v; result=%s", err, text) + } + if nested, ok := parsed["result"].(map[string]any); ok && len(nested) > 0 { + parsed = nested + } + } + if runResp.Data.Run.Status != "success" { + var logsResp automationHTTPLaunchLogsResponse + _ = automationHTTPRequestJSON(http.MethodGet, baseURL+"/api/launch/logs?limit=10", nil, &logsResp) + t.Fatalf("unexpected run response: status=%s summary=%s error=%s logs=%s", + runResp.Data.Run.Status, + runResp.Data.Run.Summary, + runResp.Data.Run.Error, + automationHTTPMarshal(t, logsResp), + ) + } + + if got := automationHTTPStringValue(parsed, "mailboxName"); got != "ChatGPT" { + t.Fatalf("unexpected mailboxName: %q parsed=%s", got, automationHTTPMarshal(t, parsed)) + } + if got := automationHTTPStringValue(parsed, "senderEmail"); got != "noreply@tm.openai.com" { + t.Fatalf("unexpected senderEmail: %q parsed=%s", got, automationHTTPMarshal(t, parsed)) + } + if got := automationHTTPStringValue(parsed, "recipientEmail"); got != "target@example.com" { + t.Fatalf("unexpected recipientEmail: %q parsed=%s", got, automationHTTPMarshal(t, parsed)) + } + if got := automationHTTPStringValue(parsed, "verificationCode"); got != "429792" { + t.Fatalf("unexpected verificationCode: %q parsed=%s", got, automationHTTPMarshal(t, parsed)) + } + if got := parsed["permissionApplied"]; got != true { + t.Fatalf("expected permissionApplied=true, got %#v parsed=%s", got, automationHTTPMarshal(t, parsed)) + } + if got := automationHTTPStringValue(parsed, "permissionOrigin"); got != fixtureServer.URL { + t.Fatalf("unexpected permissionOrigin: %q parsed=%s", got, automationHTTPMarshal(t, parsed)) + } + signature := automationHTTPStringValue(parsed, "signature") + if !strings.Contains(signature, "Best regards") || !strings.Contains(signature, "ChatGPT") { + t.Fatalf("unexpected signature: %q parsed=%s", signature, automationHTTPMarshal(t, parsed)) + } + + var hookResp automationHTTPHookEnvelopeResponse + hookErr := automationHTTPRequestJSON(http.MethodPost, baseURL+"/api/automation/hooks/mail/probe-message", map[string]any{ + "params": map[string]any{ + "inboxUrl": fixtureServer.URL, + }, + "timeoutMs": 45000, + }, &hookResp) + if hookErr != nil { + t.Fatalf("run public hook via http failed: %v", hookErr) + } + if !hookResp.OK || hookResp.Status != "success" { + t.Fatalf("unexpected hook response: %+v", hookResp) + } + if got := automationHTTPStringValue(hookResp.Result, "verificationCode"); got != "429792" { + t.Fatalf("unexpected hook verificationCode: %q resp=%s", got, automationHTTPMarshal(t, hookResp)) + } + if got := automationHTTPStringValue(hookResp.Result, "senderEmail"); got != "noreply@tm.openai.com" { + t.Fatalf("unexpected hook senderEmail: %q resp=%s", got, automationHTTPMarshal(t, hookResp)) + } + + t.Logf("automation http result: %s", automationHTTPMarshal(t, map[string]any{ + "profileId": createResp.ProfileID, + "launchCode": createResp.LaunchCode, + "runStatus": runResp.Data.Run.Status, + "runSummary": runResp.Data.Run.Summary, + "hookStatus": hookResp.Status, + "hookSummary": hookResp.Summary, + "mailboxName": automationHTTPStringValue(parsed, "mailboxName"), + "senderEmail": automationHTTPStringValue(parsed, "senderEmail"), + "recipientEmail": automationHTTPStringValue(parsed, "recipientEmail"), + "verificationCode": automationHTTPStringValue(parsed, "verificationCode"), + "signature": signature, + "subject": automationHTTPStringValue(parsed, "subject"), + })) +} + +func lookupAutomationHTTPProbeNode(t *testing.T) string { + t.Helper() + + const preferred = `D:\code\plugin\nodejs\node.exe` + if _, err := os.Stat(preferred); err == nil { + return preferred + } + return lookupAutomationTestNode(t) +} + +func lookupAutomationHTTPProbeChrome(t *testing.T) string { + t.Helper() + + const preferred = `C:\Program Files\Google\Chrome\Application\chrome.exe` + if _, err := os.Stat(preferred); err == nil { + return preferred + } + t.Skip("system chrome is not installed") + return "" +} + +func automationHTTPRepoRoot(t *testing.T) string { + t.Helper() + + _, file, _, ok := goruntime.Caller(0) + if !ok { + t.Fatal("resolve repo root failed") + } + return filepath.Dir(filepath.Dir(file)) +} + +func automationHTTPFreePort(t *testing.T) int { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("allocate port failed: %v", err) + } + defer ln.Close() + return ln.Addr().(*net.TCPAddr).Port +} + +func prepareAutomationHTTPRuntime(appRoot string, repoRoot string, runtimeVersion string) error { + repoRuntimeDir := filepath.Join(repoRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion)) + tempRuntimeDir := filepath.Join(appRoot, "data", "runtime", "automation", strings.TrimSpace(runtimeVersion)) + if _, err := os.Stat(repoRuntimeDir); err != nil { + return fmt.Errorf("repo runtime not found: %w", err) + } + if err := os.MkdirAll(filepath.Join(tempRuntimeDir, "node_modules"), 0o755); err != nil { + return err + } + if err := automationHTTPCopyFile( + filepath.Join(repoRuntimeDir, "runner.cjs"), + filepath.Join(tempRuntimeDir, "runner.cjs"), + ); err != nil { + return err + } + return automationHTTPCopyDir( + filepath.Join(repoRuntimeDir, "node_modules", "playwright-core"), + filepath.Join(tempRuntimeDir, "node_modules", "playwright-core"), + ) +} + +func automationHTTPCopyFile(src string, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return os.WriteFile(dst, data, 0o644) +} + +func automationHTTPCopyDir(src string, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + + relativePath, err := filepath.Rel(src, path) + if err != nil { + return err + } + targetPath := filepath.Join(dst, relativePath) + if info.IsDir() { + return os.MkdirAll(targetPath, 0o755) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + return os.WriteFile(targetPath, data, info.Mode()) + }) +} + +func automationHTTPRequestJSON(method string, url string, payload any, target any) error { + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, url, body) + if err != nil { + return err + } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s %s returned %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(raw))) + } + if target == nil { + return nil + } + if err := json.Unmarshal(raw, target); err != nil { + return fmt.Errorf("decode %s %s failed: %w; body=%s", method, url, err, string(raw)) + } + return nil +} + +func automationHTTPHasScript(items []struct { + ID string `json:"id"` +}, scriptID string) bool { + for _, item := range items { + if strings.TrimSpace(item.ID) == scriptID { + return true + } + } + return false +} + +func automationHTTPStringValue(payload map[string]any, key string) string { + if payload == nil { + return "" + } + value, _ := payload[key].(string) + return strings.TrimSpace(value) +} + +func automationHTTPMarshal(t *testing.T, value any) string { + t.Helper() + + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal debug payload failed: %v", err) + } + return string(data) +} + +const automationHTTPMailFixtureHTML = ` + + + + Mail Fixture + + + + +
+ +
+

Your ChatGPT verification code

+

From: ChatGPT <noreply@tm.openai.com>

+

To: target@example.com

+

Hello,

+

Your verification code is 429792.

+

Please use this code to continue signing in.

+

Best regards

+

ChatGPT

+
+
+ +` + +const automationHTTPMailProbeScriptTextRaw = `module.exports.run = async ({ launch, connect, openPage, selector, params = {} }) => { + const normalizeText = (value) => String(value == null ? '' : value).trim() + const timeoutMs = Number.isFinite(Number(params.timeoutMs)) + ? Math.max(5000, Math.round(Number(params.timeoutMs))) + : 45000 + const inboxUrl = normalizeText(params.inboxUrl) + + if (!inboxUrl) { + throw new Error('inboxUrl is required') + } + + const session = await launch({ + selector, + skipDefaultStartUrls: true, + startUrls: [inboxUrl], + }) + const connection = await connect(session, { timeoutMs }) + const browser = connection.browser + if (!browser) { + throw new Error('browser connection is unavailable') + } + + const context = + connection.context || + browser.contexts()[0] || + (typeof browser.newContext === 'function' ? await browser.newContext() : null) + if (!context) { + throw new Error('browser context is unavailable') + } + + const opened = await openPage(connection, { + url: inboxUrl, + timeoutMs, + permissions: ['notifications'], + }) + const page = opened.page + await page.waitForLoadState('networkidle', { + timeout: Math.min(timeoutMs, 2500), + }).catch(() => {}) + + const result = await page.evaluate(() => { + const normalizeText = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim() + const article = document.querySelector('article') + const lines = Array.from(document.querySelectorAll('article p')) + .map((node) => normalizeText(node.textContent)) + .filter(Boolean) + const subject = normalizeText(document.querySelector('article h1')?.textContent) + const fromLine = lines.find((line) => line.startsWith('From:')) || '' + const toLine = lines.find((line) => line.startsWith('To:')) || '' + const articleText = normalizeText(article?.textContent) + const mailboxMatch = fromLine.match(/^From:\s*([^<]+?)\s* strings.TrimSpace(result.Imported[j].UpdatedAt) + }) + + if len(result.Imported) == 0 { + if len(result.Failed) == 0 { + return nil, fmt.Errorf("未导入任何脚本") + } + firstFailure := result.Failed[0] + if len(result.Failed) == 1 { + return nil, fmt.Errorf("导入失败: %s", firstFailure.Message) + } + return nil, fmt.Errorf("导入失败: %s;另有 %d 个脚本包也失败", firstFailure.Message, len(result.Failed)-1) + } + + return result, nil +} + +func (a *App) automationScriptsBySourceKey() (map[string]automation.ScriptRecord, error) { + items, err := a.automationScriptStore().List() + if err != nil { + return nil, err + } + + result := make(map[string]automation.ScriptRecord, len(items)) + for _, item := range items { + key := automationScriptSourceKey(item.Source) + if key == "" { + continue + } + result[key] = item + } + return result, nil +} + +func automationScriptSourceKey(source automation.ScriptSource) string { + sourceType := strings.TrimSpace(source.Type) + if sourceType == "" { + return "" + } + + normalizeLocalPath := func(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + cleaned := filepath.Clean(trimmed) + if strings.EqualFold(cleaned, ".") { + return "" + } + if runtime.GOOS == "windows" { + return strings.ToLower(cleaned) + } + return cleaned + } + + uri := strings.TrimSpace(source.URI) + ref := strings.TrimSpace(source.Ref) + path := strings.TrimSpace(source.Path) + + switch sourceType { + case "local-file", "local-dir": + uri = normalizeLocalPath(firstNonBlank(uri, path)) + path = "" + case "git": + if path != "" { + path = filepath.ToSlash(filepath.Clean(path)) + } + default: + uri = strings.TrimSpace(uri) + path = strings.TrimSpace(path) + } + + return strings.Join([]string{ + strings.ToLower(sourceType), + uri, + ref, + path, + }, "|") +} + func (a *App) automationScriptImportOptions() automation.ImportOptions { if a.config == nil { return automation.ImportOptions{} diff --git a/backend/automation_script_import_source.go b/backend/automation_script_import_source.go index e66d9710..fc048a4a 100644 --- a/backend/automation_script_import_source.go +++ b/backend/automation_script_import_source.go @@ -21,6 +21,8 @@ const ( func (a *App) loadAutomationBundleFromSource(source automation.ScriptSource) (automation.ImportedBundle, error) { sourceType := strings.TrimSpace(source.Type) switch sourceType { + case "builtin": + return automation.ImportBuiltinBundleFromSource(source) case "local-file": path := firstNonBlank(source.URI, source.Path) if path == "" { diff --git a/backend/automation_script_public_api_invoke.go b/backend/automation_script_public_api_invoke.go new file mode 100644 index 00000000..e991b4d2 --- /dev/null +++ b/backend/automation_script_public_api_invoke.go @@ -0,0 +1,192 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "ant-chrome/backend/internal/launchcode" +) + +const automationScriptPublicAPIInvokeDefaultTimeout = 31 * time.Minute + +type AutomationScriptPublicAPIInvokeInput struct { + URL string `json:"url"` + Method string `json:"method"` + BodyText string `json:"bodyText"` + APIKey string `json:"apiKey"` + AuthHeader string `json:"authHeader"` + TimeoutMs int `json:"timeoutMs"` +} + +type AutomationScriptPublicAPIInvokeResult struct { + OK bool `json:"ok"` + Status int `json:"status"` + StatusText string `json:"statusText"` + BodyText string `json:"bodyText"` + BodyJSON interface{} `json:"bodyJson"` +} + +func (a *App) AutomationScriptInvokePublicAPI(input AutomationScriptPublicAPIInvokeInput) (*AutomationScriptPublicAPIInvokeResult, error) { + requestURL, err := normalizeAutomationScriptInvokeURL(input.URL) + if err != nil { + return nil, err + } + + method := strings.ToUpper(strings.TrimSpace(input.Method)) + if method == "" { + method = http.MethodPost + } + + bodyText := strings.TrimSpace(input.BodyText) + if bodyText == "" { + bodyText = "{}" + } + + timeout := automationScriptPublicAPIInvokeDefaultTimeout + if input.TimeoutMs > 0 { + timeout = time.Duration(input.TimeoutMs) * time.Millisecond + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, + method, + requestURL.String(), + bytes.NewBufferString(bodyText), + ) + if err != nil { + return nil, fmt.Errorf("create invoke request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + authHeader := strings.TrimSpace(input.AuthHeader) + if authHeader == "" { + authHeader = launchcode.DefaultAPIKeyHeader + } + apiKey := strings.TrimSpace(input.APIKey) + if apiKey == "" { + if autoHeader, autoKey := a.resolveAutomationScriptInvokeAuth(requestURL); autoKey != "" { + authHeader = autoHeader + apiKey = autoKey + } + } + if authHeader != "" && apiKey != "" { + req.Header.Set(authHeader, apiKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, fmt.Errorf("invoke public api failed: %w", ctxErr) + } + return nil, fmt.Errorf("invoke public api failed: %w", err) + } + defer resp.Body.Close() + + rawBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, fmt.Errorf("read public api response failed: %w", err) + } + + result := &AutomationScriptPublicAPIInvokeResult{ + OK: resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices, + Status: resp.StatusCode, + StatusText: http.StatusText(resp.StatusCode), + BodyText: string(rawBody), + BodyJSON: nil, + } + + trimmedBody := bytes.TrimSpace(rawBody) + if len(trimmedBody) > 0 { + var decoded interface{} + if err := json.Unmarshal(trimmedBody, &decoded); err == nil { + result.BodyJSON = decoded + } + } + + return result, nil +} + +func normalizeAutomationScriptInvokeURL(rawURL string) (*url.URL, error) { + normalizedURL := strings.TrimSpace(rawURL) + if normalizedURL == "" { + return nil, fmt.Errorf("接口地址不能为空") + } + + parsedURL, err := url.Parse(normalizedURL) + if err != nil { + return nil, fmt.Errorf("接口地址不合法: %w", err) + } + if !parsedURL.IsAbs() { + return nil, fmt.Errorf("接口地址必须是完整 URL") + } + + switch strings.ToLower(parsedURL.Scheme) { + case "http", "https": + default: + return nil, fmt.Errorf("接口地址仅支持 http 或 https") + } + + if strings.TrimSpace(parsedURL.Host) == "" { + return nil, fmt.Errorf("接口地址缺少主机") + } + + return parsedURL, nil +} + +func (a *App) resolveAutomationScriptInvokeAuth(targetURL *url.URL) (string, string) { + if targetURL == nil || a.launchServer == nil || a.config == nil { + return "", "" + } + if !a.launchServer.APIAuthEnabled() { + return "", "" + } + + apiKey := strings.TrimSpace(a.config.LaunchServer.Auth.APIKey) + if apiKey == "" { + return "", "" + } + + launchPort := a.launchServer.Port() + if launchPort <= 0 { + return "", "" + } + + requestPort := targetURL.Port() + if requestPort == "" { + switch strings.ToLower(targetURL.Scheme) { + case "https": + requestPort = "443" + default: + requestPort = "80" + } + } + if requestPort != strconv.Itoa(launchPort) { + return "", "" + } + + host := strings.TrimSpace(strings.ToLower(targetURL.Hostname())) + if host == "" { + return "", "" + } + if parsedIP := net.ParseIP(host); parsedIP != nil { + if !parsedIP.IsLoopback() { + return "", "" + } + } else if host != "localhost" { + return "", "" + } + + return a.launchServer.APIAuthHeader(), apiKey +} diff --git a/backend/automation_script_public_api_invoke_test.go b/backend/automation_script_public_api_invoke_test.go new file mode 100644 index 00000000..2b94309c --- /dev/null +++ b/backend/automation_script_public_api_invoke_test.go @@ -0,0 +1,118 @@ +package backend + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/launchcode" +) + +func TestAutomationScriptInvokePublicAPIReturnsJSON(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read request body failed: %v", err) + } + if strings.TrimSpace(string(body)) != `{"hello":"world"}` { + t.Fatalf("unexpected request body: %s", string(body)) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"verificationCode":"429792"}`)) + })) + defer server.Close() + + app := NewApp(t.TempDir()) + result, err := app.AutomationScriptInvokePublicAPI(AutomationScriptPublicAPIInvokeInput{ + URL: server.URL + "/api/automation/hooks/test", + Method: http.MethodPost, + BodyText: `{"hello":"world"}`, + }) + if err != nil { + t.Fatalf("AutomationScriptInvokePublicAPI returned error: %v", err) + } + if result == nil { + t.Fatal("AutomationScriptInvokePublicAPI returned nil result") + } + if !result.OK { + t.Fatalf("expected ok result, got %+v", result) + } + if result.Status != http.StatusOK { + t.Fatalf("expected status 200, got %d", result.Status) + } + + bodyJSON, ok := result.BodyJSON.(map[string]interface{}) + if !ok { + t.Fatalf("expected bodyJson object, got %#v", result.BodyJSON) + } + if bodyJSON["verificationCode"] != "429792" { + t.Fatalf("expected verificationCode 429792, got %#v", bodyJSON["verificationCode"]) + } +} + +func TestAutomationScriptInvokePublicAPIAutoUsesLaunchServerAuth(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Test-Key"); got != "secret-123" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"ok":false}`)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"mailboxName":"ChatGPT"}`)) + })) + defer server.Close() + + parsedURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse test server url failed: %v", err) + } + port, err := strconv.Atoi(parsedURL.Port()) + if err != nil { + t.Fatalf("parse test server port failed: %v", err) + } + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.LaunchServer.Auth.Enabled = true + app.config.LaunchServer.Auth.APIKey = "secret-123" + app.config.LaunchServer.Auth.Header = "X-Test-Key" + app.launchServer = launchcode.NewLaunchServer(nil, nil, nil, port) + app.launchServer.SetAPIAuthConfig(launchcode.APIAuthConfig{ + Enabled: true, + APIKey: "secret-123", + Header: "X-Test-Key", + }) + + result, err := app.AutomationScriptInvokePublicAPI(AutomationScriptPublicAPIInvokeInput{ + URL: server.URL + "/api/automation/hooks/test", + Method: http.MethodPost, + BodyText: `{}`, + }) + if err != nil { + t.Fatalf("AutomationScriptInvokePublicAPI returned error: %v", err) + } + if result == nil { + t.Fatal("AutomationScriptInvokePublicAPI returned nil result") + } + if !result.OK { + t.Fatalf("expected ok result, got %+v", result) + } + if result.Status != http.StatusOK { + t.Fatalf("expected status 200, got %d", result.Status) + } +} diff --git a/backend/automation_script_run_entry.go b/backend/automation_script_run_entry.go index 3b30de2a..67958425 100644 --- a/backend/automation_script_run_entry.go +++ b/backend/automation_script_run_entry.go @@ -40,7 +40,14 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) StartedAt: startedAt.Format(time.RFC3339), } - script, err := a.automationScriptStore().Get(run.ScriptID) + store := a.automationScriptStore() + if err := a.ensureAutomationScriptDefaults(store); err != nil { + run.Summary = "脚本读取失败" + run.Error = err.Error() + return a.finalizeAutomationScriptRun(run, startedAt) + } + + script, err := store.Get(run.ScriptID) if err != nil { run.Summary = "脚本读取失败" run.Error = err.Error() diff --git a/backend/automation_script_run_integration_test.go b/backend/automation_script_run_integration_test.go index 451125c0..34323673 100644 --- a/backend/automation_script_run_integration_test.go +++ b/backend/automation_script_run_integration_test.go @@ -2,6 +2,7 @@ package backend import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -10,8 +11,10 @@ import ( "path/filepath" "strconv" "strings" + "sync" "sync/atomic" "testing" + "time" "ant-chrome/backend/internal/automation" "ant-chrome/backend/internal/browser" @@ -228,6 +231,212 @@ func TestAutomationScriptRunWithOptionsPrestartsStoredTargetForConnectOnlyScript } } +func TestAutomationScriptRunWithOptionsPrestartsManualCodeTargetForConnectOnlyScript(t *testing.T) { + app, cleanup := newAutomationPlaywrightRunTestApp(t, automationTestConnectProbePlaywrightModule) + defer cleanup() + + var debugHits atomic.Int32 + debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + debugHits.Add(1) + if r.URL.Path != "/json/version" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "Browser": "Chrome/123.0.0.0", + }) + })) + defer debugServer.Close() + + profile := createAutomationRunningProfileWithCode( + t, + app, + "buyer-manual-code", + "BUYER_001", + automationTestServerPort(t, debugServer.URL), + ) + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "playwright-connect-manual-code", + Name: "Playwright Connect Manual Code", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + ScriptText: "module.exports.run = async ({ connect }) => {\n" + + " const { browser } = await connect()\n" + + " return { ok: true, summary: 'connected through manual code', contextCount: browser.contexts().length }\n" + + "}\n", + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + run, err := app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ + ScriptID: saved.ID, + SelectorText: `{"code":"BUYER_001"}`, + UseScriptSelector: false, + UseScriptParams: true, + }) + if err != nil { + t.Fatalf("AutomationScriptRunWithOptions returned error: %v", err) + } + if run == nil { + t.Fatalf("AutomationScriptRunWithOptions returned nil result") + } + if run.Status != "success" { + t.Fatalf("expected success status, got %+v", run) + } + if !strings.Contains(run.Summary, "connected through manual code") { + t.Fatalf("unexpected run summary: %q", run.Summary) + } + if !strings.Contains(run.ResultText, `"contextCount":1`) { + t.Fatalf("expected connect result payload, got %s", run.ResultText) + } + if debugHits.Load() == 0 { + t.Fatalf("expected connect() to hit active debug endpoint through launch server") + } + if app.launchServer == nil { + t.Fatal("expected launch server to be initialized") + } + activeProfileID, _, _ := app.launchServer.ActiveProfile() + if activeProfileID != profile.ProfileId { + t.Fatalf("expected active profile %s, got %s", profile.ProfileId, activeProfileID) + } +} + +func TestAutomationScriptRunWithOptionsAllowsSameScriptOnDifferentProfiles(t *testing.T) { + app, cleanup := newAutomationPlaywrightRunTestApp(t, "module.exports = { chromium: {} }\n") + defer cleanup() + + debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer debugServer.Close() + + debugPort := automationTestServerPort(t, debugServer.URL) + profileA := createAutomationRunningProfileWithCode(t, app, "buyer-a", "BUYER_A", debugPort) + profileB := createAutomationRunningProfileWithCode(t, app, "buyer-b", "BUYER_B", debugPort) + + saved, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "slow-shared-script", + Name: "Slow Shared Script", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 800))\n return { ok: true, summary: 'slow ok' }\n}\n", + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + results := runAutomationScriptsConcurrently(t, 2, func(index int) (*automation.ScriptRunRecord, error) { + profileID := profileA.ProfileId + if index == 1 { + profileID = profileB.ProfileId + } + return app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ + ScriptID: saved.ID, + SelectorText: fmt.Sprintf(`{"profileId":"%s"}`, profileID), + UseScriptSelector: false, + UseScriptParams: true, + TimeoutMs: 5000, + }) + }) + + for _, result := range results { + if result.err != nil { + t.Fatalf("AutomationScriptRunWithOptions returned error: %v", result.err) + } + if result.run == nil { + t.Fatal("AutomationScriptRunWithOptions returned nil result") + } + if result.run.Status != "success" { + t.Fatalf("expected both runs to succeed on different profiles, got %+v", result.run) + } + } +} + +func TestAutomationScriptRunWithOptionsBlocksDifferentScriptsOnSameProfile(t *testing.T) { + app, cleanup := newAutomationPlaywrightRunTestApp(t, "module.exports = { chromium: {} }\n") + defer cleanup() + + debugServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer debugServer.Close() + + profile := createAutomationRunningProfileWithCode( + t, + app, + "buyer-shared", + "BUYER_SHARED", + automationTestServerPort(t, debugServer.URL), + ) + + firstScript, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "slow-script-a", + Name: "Slow Script A", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 800))\n return { ok: true, summary: 'slow ok a' }\n}\n", + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + secondScript, err := app.AutomationScriptSave(automation.ScriptRecord{ + ID: "slow-script-b", + Name: "Slow Script B", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "scripts/index.cjs", + ScriptText: "module.exports.run = async () => {\n await new Promise((resolve) => setTimeout(resolve, 800))\n return { ok: true, summary: 'slow ok b' }\n}\n", + }) + if err != nil { + t.Fatalf("AutomationScriptSave returned error: %v", err) + } + + results := runAutomationScriptsConcurrently(t, 2, func(index int) (*automation.ScriptRunRecord, error) { + scriptID := firstScript.ID + if index == 1 { + scriptID = secondScript.ID + } + return app.AutomationScriptRunWithOptions(automation.ScriptRunRequest{ + ScriptID: scriptID, + SelectorText: fmt.Sprintf(`{"profileId":"%s"}`, profile.ProfileId), + UseScriptSelector: false, + UseScriptParams: true, + TimeoutMs: 5000, + }) + }) + + successCount := 0 + failedCount := 0 + for _, result := range results { + if result.err != nil { + t.Fatalf("AutomationScriptRunWithOptions returned error: %v", result.err) + } + if result.run == nil { + t.Fatal("AutomationScriptRunWithOptions returned nil result") + } + switch result.run.Status { + case "success": + successCount++ + case "failed": + failedCount++ + if !strings.Contains(result.run.Error, "已有自动化任务在运行中") { + t.Fatalf("expected target lock failure, got %+v", result.run) + } + default: + t.Fatalf("unexpected run status: %+v", result.run) + } + } + if successCount != 1 || failedCount != 1 { + t.Fatalf("expected one success and one failure on same profile, got success=%d failed=%d results=%+v", successCount, failedCount, results) + } +} + func lookupAutomationTestNode(t *testing.T) string { t.Helper() @@ -345,3 +554,116 @@ main().catch((error) => { process.exit(1) }) ` + +type automationConcurrentRunResult struct { + run *automation.ScriptRunRecord + err error +} + +func newAutomationPlaywrightRunTestApp(t *testing.T, playwrightModuleSource string) (*App, func()) { + t.Helper() + + nodeExecPath := lookupAutomationTestNode(t) + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.config.Automation.Enabled = true + app.config.Automation.NodeSource = config.AutomationNodeSourceSystem + app.config.Automation.SystemNodePath = nodeExecPath + app.config.Automation.NodeVersion = "test-node" + app.config.Automation.PlaywrightCoreVersion = "1.59.0" + app.config.Automation.RuntimeVersion = "test-runtime" + app.browserMgr = browser.NewManager(app.config, app.appRoot) + app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) + app.browserMgr.CodeProvider = app.launchCodeSvc + app.automationMgr = automation.NewManager(app.appRoot, app.config, nil, automation.Options{}) + + prepareAutomationTestRuntimeWithPlaywrightModule( + t, + app.automationMgr, + app.config.Automation.PlaywrightCoreVersion, + playwrightModuleSource, + ) + + app.launchServer = launchcode.NewLaunchServer( + app.launchCodeSvc, + app, + app.browserMgr, + 0, + ) + if err := app.launchServer.Start(); err != nil { + t.Fatalf("start launch server failed: %v", err) + } + + return app, func() { + _ = app.launchServer.Stop() + } +} + +func automationTestServerPort(t *testing.T, rawURL string) int { + t.Helper() + + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parse server url failed: %v", err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatalf("parse server port failed: %v", err) + } + return port +} + +func createAutomationRunningProfileWithCode(t *testing.T, app *App, name string, code string, debugPort int) *browser.Profile { + t.Helper() + + profile, err := app.browserMgr.Create(browser.ProfileInput{ + ProfileName: name, + }) + if err != nil { + t.Fatalf("create profile failed: %v", err) + } + if profile == nil { + t.Fatal("create profile returned nil") + } + if strings.TrimSpace(code) != "" { + if _, err := app.launchCodeSvc.SetCode(profile.ProfileId, code); err != nil { + t.Fatalf("set code failed: %v", err) + } + } + + app.browserMgr.Profiles[profile.ProfileId].Running = true + app.browserMgr.Profiles[profile.ProfileId].DebugReady = true + app.browserMgr.Profiles[profile.ProfileId].DebugPort = debugPort + app.browserMgr.Profiles[profile.ProfileId].Pid = 12345 + + return profile +} + +func runAutomationScriptsConcurrently(t *testing.T, count int, runner func(index int) (*automation.ScriptRunRecord, error)) []automationConcurrentRunResult { + t.Helper() + + results := make([]automationConcurrentRunResult, count) + start := make(chan struct{}) + var wg sync.WaitGroup + + for index := 0; index < count; index++ { + index := index + wg.Add(1) + go func() { + defer wg.Done() + <-start + run, err := runner(index) + results[index] = automationConcurrentRunResult{ + run: run, + err: err, + } + }() + } + + time.Sleep(50 * time.Millisecond) + close(start) + wg.Wait() + + return results +} diff --git a/backend/automation_script_run_playwright.go b/backend/automation_script_run_playwright.go index 9a17737c..dbd85428 100644 --- a/backend/automation_script_run_playwright.go +++ b/backend/automation_script_run_playwright.go @@ -6,6 +6,7 @@ import ( "strings" "ant-chrome/backend/internal/automation" + "ant-chrome/backend/internal/browser" ) func automationSelectorProfileID(selector map[string]any) string { @@ -17,16 +18,73 @@ func automationSelectorProfileID(selector map[string]any) string { return strings.TrimSpace(profileID) } -func (a *App) ensurePlaywrightTargetReady(selector map[string]any) error { - profileID := automationSelectorProfileID(selector) - if profileID == "" { +func automationSelectorCode(selector map[string]any) string { + if selector == nil { + return "" + } + + code, _ := selector["code"].(string) + return strings.ToUpper(strings.TrimSpace(code)) +} + +func cloneAutomationSelector(selector map[string]any) map[string]any { + if selector == nil { return nil } - if _, err := a.BrowserInstanceStart(profileID); err != nil { - return fmt.Errorf("预启动脚本目标实例失败: %w", err) + cloned := make(map[string]any, len(selector)) + for key, value := range selector { + cloned[key] = value } - return nil + return cloned +} + +func (a *App) ensurePlaywrightTargetReady(selector map[string]any) (map[string]any, string, error) { + normalized := cloneAutomationSelector(selector) + profileID := automationSelectorProfileID(normalized) + code := automationSelectorCode(normalized) + if profileID == "" && code == "" { + return normalized, "", nil + } + + var ( + profile *browser.Profile + err error + ) + switch { + case profileID != "": + profile, err = a.BrowserInstanceStart(profileID) + case code != "": + profile, err = a.BrowserInstanceStartByCode(code) + } + if err != nil { + return nil, "", fmt.Errorf("预启动脚本目标实例失败: %w", err) + } + if profile == nil { + return nil, "", fmt.Errorf("预启动脚本目标实例失败:未返回实例") + } + + resolvedProfileID := strings.TrimSpace(profile.ProfileId) + if resolvedProfileID != "" && normalized != nil { + normalized["profileId"] = resolvedProfileID + } + + resolvedCode := strings.ToUpper(strings.TrimSpace(profile.LaunchCode)) + if resolvedCode == "" { + resolvedCode = code + } + if resolvedCode != "" && normalized != nil { + normalized["code"] = resolvedCode + } + + return normalized, resolvedProfileID, nil +} + +func automationScriptTaskKey(scriptID string, selector map[string]any) string { + if profileID := automationSelectorProfileID(selector); profileID != "" { + return profileID + } + return "script:" + strings.TrimSpace(scriptID) } func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { @@ -60,7 +118,8 @@ func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptR if err != nil { return "", "脚本执行失败", err.Error() } - if err := a.ensurePlaywrightTargetReady(selector); err != nil { + selector, taskProfileID, err := a.ensurePlaywrightTargetReady(selector) + if err != nil { return "", "脚本执行失败", err.Error() } if err := ctx.Err(); err != nil { @@ -86,7 +145,7 @@ func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptR } taskResult, err := a.automationMgr.RunScriptTask(ctx, automation.ScriptTaskRequest{ - TaskKey: "script:" + script.ID, + TaskKey: automationScriptTaskKey(script.ID, selector), ScriptPath: scriptPath, Selector: selector, Params: params, @@ -99,6 +158,9 @@ func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptR if err != nil { return "", "脚本执行失败", err.Error() } + if taskResult.TaskKey == "" && taskProfileID != "" { + taskResult.TaskKey = taskProfileID + } if !taskResult.OK { errorText := strings.TrimSpace(taskResult.Error) if errorText == "" { diff --git a/backend/automation_script_run_test.go b/backend/automation_script_run_test.go index 9147f78d..2d6c1efd 100644 --- a/backend/automation_script_run_test.go +++ b/backend/automation_script_run_test.go @@ -64,6 +64,57 @@ func TestPreparePlaywrightScriptWorkspaceCopiesScriptDirectory(t *testing.T) { if info, err := os.Stat(artifactDir); err != nil || !info.IsDir() { t.Fatalf("expected artifact dir to exist, got err=%v info=%v", err, info) } + if !strings.Contains(filepath.ToSlash(artifactDir), "data/automation/artifacts/workspace-script/") { + t.Fatalf("expected default artifact dir under data/automation/artifacts, got %s", artifactDir) + } +} + +func TestPreparePlaywrightScriptWorkspaceUsesConfiguredArtifactsDir(t *testing.T) { + appRoot := t.TempDir() + app := NewApp(appRoot) + customRoot := filepath.Join(t.TempDir(), "custom-artifacts") + app.config = DefaultConfig() + app.config.Automation.ArtifactsDir = customRoot + + script := automation.ScriptRecord{ + ID: "custom-artifact-script", + Name: "自定义输出脚本", + Type: "playwright-cdp", + Status: "ready", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + } + + runtimeDir := filepath.Join(t.TempDir(), "runtime") + _, artifactDir, cleanup, err := app.preparePlaywrightScriptWorkspace(runtimeDir, script) + if err != nil { + t.Fatalf("preparePlaywrightScriptWorkspace returned error: %v", err) + } + defer cleanup() + + if !strings.HasPrefix(artifactDir, customRoot+string(os.PathSeparator)) { + t.Fatalf("expected artifact dir under custom root, got %s want-prefix %s", artifactDir, customRoot) + } + if info, err := os.Stat(artifactDir); err != nil || !info.IsDir() { + t.Fatalf("expected custom artifact dir to exist, got err=%v info=%v", err, info) + } + if !strings.Contains(filepath.ToSlash(artifactDir), "/custom-artifact-script/") { + t.Fatalf("expected artifact dir to include script id, got %s", artifactDir) + } + + relativeApp := NewApp(appRoot) + relativeApp.config = DefaultConfig() + relativeApp.config.Automation.ArtifactsDir = "exports/automation" + _, relativeArtifactDir, relativeCleanup, err := relativeApp.preparePlaywrightScriptWorkspace(runtimeDir, script) + if err != nil { + t.Fatalf("preparePlaywrightScriptWorkspace with relative dir returned error: %v", err) + } + defer relativeCleanup() + + expectedRelativeRoot := filepath.Join(appRoot, "exports", "automation") + if !strings.HasPrefix(relativeArtifactDir, expectedRelativeRoot+string(os.PathSeparator)) { + t.Fatalf("expected relative artifact dir under app root, got %s want-prefix %s", relativeArtifactDir, expectedRelativeRoot) + } } func TestPreparePlaywrightScriptWorkspaceFallsBackWhenScriptDirMissing(t *testing.T) { diff --git a/backend/automation_script_target_resolver.go b/backend/automation_script_target_resolver.go index d971f745..b28502c9 100644 --- a/backend/automation_script_target_resolver.go +++ b/backend/automation_script_target_resolver.go @@ -15,13 +15,26 @@ const defaultAutomationCreateNameTemplate = "${templateName}-${timestamp}" func (a *App) resolveAutomationEffectiveSelector(script automation.ScriptRecord, input automation.ScriptRunRequest, required bool) (map[string]any, string, error) { overrideSelectorText := strings.TrimSpace(input.SelectorText) - if !input.UseScriptSelector && overrideSelectorText != "" { + if automationScriptTargetMode(script) == "manual" && !input.UseScriptSelector && overrideSelectorText != "" { selector, err := parseAutomationJSONObject(overrideSelectorText, required) return selector, "", err } - if strings.TrimSpace(script.TargetConfig.Mode) != "" && !strings.EqualFold(script.TargetConfig.Mode, "manual") { - return a.resolveAutomationScriptTarget(script) + if automationScriptTargetMode(script) != "manual" { + targetInput := input.TargetInput + if targetInput == nil && !input.UseScriptSelector && overrideSelectorText != "" { + selector, err := parseAutomationJSONObject(overrideSelectorText, false) + if err != nil { + return nil, "", err + } + targetInput = selector + } + input.TargetInput = targetInput + effectiveScript, err := applyAutomationRunTargetInput(script, input.TargetInput) + if err != nil { + return nil, "", err + } + return a.resolveAutomationScriptTarget(effectiveScript) } selectorText := resolveAutomationRunJSONText(input.SelectorText, script.SelectorText, input.UseScriptSelector) @@ -29,6 +42,88 @@ func (a *App) resolveAutomationEffectiveSelector(script automation.ScriptRecord, return selector, "", err } +func automationScriptTargetMode(script automation.ScriptRecord) string { + mode := strings.ToLower(strings.TrimSpace(script.TargetConfig.Mode)) + switch mode { + case "existing", "create", "rotate": + return mode + default: + return "manual" + } +} + +func applyAutomationRunTargetInput(script automation.ScriptRecord, value any) (automation.ScriptRecord, error) { + if value == nil { + return script, nil + } + payload, err := marshalAutomationRunTargetInput(value) + if err != nil { + return script, err + } + if len(payload) == 0 { + return script, nil + } + + switch automationScriptTargetMode(script) { + case "existing": + selector, err := decodeAutomationRunTargetSelector(payload) + if err != nil { + return script, fmt.Errorf("已有实例配置无效: %w", err) + } + script.TargetConfig.Selector = selector + case "rotate": + selector, err := decodeAutomationRunTargetSelector(payload) + if err != nil { + return script, fmt.Errorf("条件轮询配置无效: %w", err) + } + script.TargetConfig.Selector = selector + case "create": + var input struct { + TemplateSelector automation.ScriptTargetSelector `json:"templateSelector"` + Selector automation.ScriptTargetSelector `json:"selector"` + CreateNameTemplate string `json:"createNameTemplate"` + ProfileName string `json:"profileName"` + } + if err := json.Unmarshal(payload, &input); err != nil { + return script, fmt.Errorf("targetInput must be a JSON object") + } + selector := input.TemplateSelector + if automationTargetSelectorEmpty(normalizeAutomationTargetSelector(selector)) { + selector = input.Selector + } + script.TargetConfig.TemplateSelector = selector + if name := strings.TrimSpace(input.CreateNameTemplate); name != "" { + script.TargetConfig.CreateNameTemplate = name + } else if name := strings.TrimSpace(input.ProfileName); name != "" { + script.TargetConfig.CreateNameTemplate = name + } + } + return script, nil +} + +func marshalAutomationRunTargetInput(value any) ([]byte, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("targetInput must be a JSON object") + } + var object map[string]any + if err := json.Unmarshal(data, &object); err != nil || object == nil { + return nil, fmt.Errorf("targetInput must be a JSON object") + } + if len(object) == 0 { + return nil, nil + } + return data, nil +} + +func decodeAutomationRunTargetSelector(payload []byte) (automation.ScriptTargetSelector, error) { + var selector automation.ScriptTargetSelector + if err := json.Unmarshal(payload, &selector); err != nil { + return selector, fmt.Errorf("targetInput must be a JSON object") + } + return selector, nil +} + func (a *App) resolveAutomationScriptTarget(script automation.ScriptRecord) (map[string]any, string, error) { switch strings.ToLower(strings.TrimSpace(script.TargetConfig.Mode)) { case "existing": diff --git a/backend/automation_script_workspace.go b/backend/automation_script_workspace.go index dcd51624..c4e9811c 100644 --- a/backend/automation_script_workspace.go +++ b/backend/automation_script_workspace.go @@ -13,7 +13,7 @@ import ( func (a *App) preparePlaywrightScriptWorkspace(runtimeDir string, script automation.ScriptRecord) (string, string, func(), error) { execRoot := filepath.Join(runtimeDir, "tmp", "script-run", fmt.Sprintf("%s-%d", strings.TrimSpace(script.ID), time.Now().UnixNano())) scriptPath := filepath.Join(execRoot, filepath.FromSlash(script.EntryFile)) - artifactDir := filepath.Join(a.appDataDir(), "automation", "artifacts", strings.TrimSpace(script.ID), time.Now().Format("20060102-150405")) + artifactDir := filepath.Join(a.automationArtifactsRootDir(), strings.TrimSpace(script.ID), time.Now().Format("20060102-150405")) if err := os.MkdirAll(artifactDir, 0o755); err != nil { return "", "", nil, fmt.Errorf("create script artifact dir failed: %w", err) @@ -45,6 +45,17 @@ func (a *App) preparePlaywrightScriptWorkspace(runtimeDir string, script automat return scriptPath, artifactDir, cleanup, nil } +func (a *App) automationArtifactsRootDir() string { + configured := "" + if a.config != nil { + configured = strings.TrimSpace(a.config.Automation.ArtifactsDir) + } + if configured == "" { + configured = filepath.ToSlash(filepath.Join("data", "automation", "artifacts")) + } + return a.resolveAppPath(configured) +} + func copyAutomationScriptDir(srcDir string, dstDir string) error { if err := filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error { if walkErr != nil { diff --git a/backend/automation_state_api.go b/backend/automation_state_api.go index 17f7e3e4..d4056f5a 100644 --- a/backend/automation_state_api.go +++ b/backend/automation_state_api.go @@ -18,6 +18,7 @@ func (a *App) automationStatePayload() map[string]interface{} { "headlessDefault": false, "keepRuntimeOnDisable": true, "allowTypeScriptBuild": false, + "artifactsDir": "data/automation/artifacts", "nodeSource": config.DefaultAutomationNodeSource, "systemNodePath": "", "nodeVersion": config.DefaultAutomationNodeVersion, @@ -46,6 +47,7 @@ func (a *App) automationStatePayload() map[string]interface{} { settings["headlessDefault"] = a.config.Automation.HeadlessDefault settings["keepRuntimeOnDisable"] = a.config.Automation.KeepRuntimeOnDisable settings["allowTypeScriptBuild"] = a.config.Automation.AllowTypeScriptBuild + settings["artifactsDir"] = a.config.Automation.ArtifactsDir settings["nodeSource"] = a.config.Automation.NodeSource settings["systemNodePath"] = a.config.Automation.SystemNodePath settings["nodeVersion"] = a.config.Automation.NodeVersion @@ -102,6 +104,11 @@ func applyAutomationConfigDefaults(auto *config.AutomationConfig) { if !auto.KeepRuntimeOnDisable { auto.KeepRuntimeOnDisable = true } + if strings.TrimSpace(auto.ArtifactsDir) == "" { + auto.ArtifactsDir = "data/automation/artifacts" + } else { + auto.ArtifactsDir = strings.TrimSpace(auto.ArtifactsDir) + } } func normalizeAutomationNodeSourceInput(value string) string { diff --git a/backend/browser_process_discovery.go b/backend/browser_process_discovery.go new file mode 100644 index 00000000..96133ff5 --- /dev/null +++ b/backend/browser_process_discovery.go @@ -0,0 +1,111 @@ +package backend + +import ( + "errors" + "fmt" + "os" + "regexp" + "strconv" + "strings" + "time" +) + +type browserUserDataProcess struct { + PID int `json:"pid"` + DebugPort int `json:"debugPort"` + CommandLine string `json:"commandLine"` +} + +type browserRuntimeDetection struct { + PID int + DebugPort int + DebugReady bool +} + +var errBrowserStartHandledByRecoveredRuntime = errors.New("browser start handled by recovered runtime") + +var findBrowserUserDataProcesses = findBrowserUserDataProcessesOS +var terminateBrowserUserDataProcess = terminateBrowserUserDataProcessOS + +var remoteDebuggingPortPattern = regexp.MustCompile(`(?i)--remote-debugging-port=(\d+)`) + +func parseRemoteDebuggingPort(commandLine string) int { + matches := remoteDebuggingPortPattern.FindStringSubmatch(commandLine) + if len(matches) < 2 { + return 0 + } + port, err := strconv.Atoi(matches[1]) + if err != nil || port <= 0 { + return 0 + } + return port +} + +func detectBrowserRuntimeByUserDataDir(userDataDir string) (browserRuntimeDetection, bool) { + userDataDir = strings.TrimSpace(userDataDir) + if userDataDir == "" { + return browserRuntimeDetection{}, false + } + + if detection, ok := detectBrowserRuntimeByActivePort(userDataDir); ok { + return detection, true + } + + processes, err := findBrowserUserDataProcesses(userDataDir) + if err != nil || len(processes) == 0 { + return browserRuntimeDetection{}, false + } + + for _, process := range processes { + debugPort := process.DebugPort + if debugPort <= 0 { + debugPort = parseRemoteDebuggingPort(process.CommandLine) + } + if debugPort > 0 { + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { + return browserRuntimeDetection{PID: process.PID, DebugPort: debugPort, DebugReady: true}, true + } + } + } + + first := processes[0] + return browserRuntimeDetection{PID: first.PID, DebugPort: first.DebugPort}, true +} + +func detectBrowserRuntimeByActivePort(userDataDir string) (browserRuntimeDetection, bool) { + userDataDir = strings.TrimSpace(userDataDir) + if userDataDir == "" { + return browserRuntimeDetection{}, false + } + + if debugPort, err := readBrowserDebugPortFile(userDataDir); err == nil && debugPort > 0 { + if err := probeBrowserDebugPort(debugPort, browserDebugProbeTimeout); err == nil { + return browserRuntimeDetection{DebugPort: debugPort, DebugReady: true}, true + } + } + + return browserRuntimeDetection{}, false +} + +func terminateBrowserProcessesByUserDataDir(userDataDir string, timeout time.Duration) (bool, error) { + processes, err := findBrowserUserDataProcesses(userDataDir) + if err != nil { + return false, err + } + if len(processes) == 0 { + return false, nil + } + + var errs []error + terminated := false + for _, process := range processes { + if process.PID <= 0 || process.PID == os.Getpid() { + continue + } + terminated = true + if err := terminateBrowserUserDataProcess(process.PID, timeout); err != nil { + errs = append(errs, fmt.Errorf("pid %d: %w", process.PID, err)) + } + } + return terminated, errors.Join(errs...) +} diff --git a/backend/browser_process_discovery_other.go b/backend/browser_process_discovery_other.go new file mode 100644 index 00000000..9f6bc266 --- /dev/null +++ b/backend/browser_process_discovery_other.go @@ -0,0 +1,28 @@ +//go:build !windows +// +build !windows + +package backend + +import ( + "fmt" + "os" + "time" +) + +func findBrowserUserDataProcessesOS(userDataDir string) ([]browserUserDataProcess, error) { + return nil, nil +} + +func terminateBrowserUserDataProcessOS(pid int, timeout time.Duration) error { + if pid <= 0 { + return nil + } + process, err := os.FindProcess(pid) + if err != nil { + return err + } + if err := process.Kill(); err != nil { + return err + } + return fmt.Errorf("process termination fallback does not wait for pid %d", pid) +} diff --git a/backend/browser_process_discovery_test.go b/backend/browser_process_discovery_test.go new file mode 100644 index 00000000..2916a992 --- /dev/null +++ b/backend/browser_process_discovery_test.go @@ -0,0 +1,200 @@ +package backend + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" +) + +func TestParseRemoteDebuggingPort(t *testing.T) { + t.Parallel() + + got := parseRemoteDebuggingPort(`chrome.exe --user-data-dir="D:\data\p1" --remote-debugging-port=49152`) + if got != 49152 { + t.Fatalf("expected port 49152, got %d", got) + } +} + +func TestBrowserInstanceStatusRecoversRunningProfileByUserDataDir(t *testing.T) { + server := newRecordedCDPServer(t) + defer server.Close() + + app := newRuntimeRecoveryTestApp(t) + profile := &BrowserProfile{ + ProfileId: "recover-status", + ProfileName: "Recover Status", + UserDataDir: "recover-status", + Running: false, + } + app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} + userDataDir := app.browserMgr.ResolveUserDataDir(profile) + writeDevToolsActivePort(t, userDataDir, server.Port()) + + snapshot, err := app.BrowserInstanceStatus(profile.ProfileId) + if err != nil { + t.Fatalf("BrowserInstanceStatus returned error: %v", err) + } + if snapshot == nil || !snapshot.Running || !snapshot.DebugReady { + t.Fatalf("expected recovered running profile, got %+v", snapshot) + } + if snapshot.DebugPort != server.Port() { + t.Fatalf("expected debug port %d, got %d", server.Port(), snapshot.DebugPort) + } +} + +func TestBrowserInstanceStartRecoversRunningProfileBeforeSessionCleanup(t *testing.T) { + server := newRecordedCDPServer(t) + defer server.Close() + + app := newRuntimeRecoveryTestApp(t) + profile := &BrowserProfile{ + ProfileId: "recover-start", + ProfileName: "Recover Start", + UserDataDir: "recover-start", + Running: false, + } + app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} + userDataDir := app.browserMgr.ResolveUserDataDir(profile) + writeDevToolsActivePort(t, userDataDir, server.Port()) + + snapshot, err := app.BrowserInstanceStart(profile.ProfileId) + if err != nil { + t.Fatalf("BrowserInstanceStart returned error: %v", err) + } + if snapshot == nil || !snapshot.Running || !snapshot.DebugReady { + t.Fatalf("expected recovered running profile, got %+v", snapshot) + } + if snapshot.DebugPort != server.Port() { + t.Fatalf("expected debug port %d, got %d", server.Port(), snapshot.DebugPort) + } +} + +func TestPrepareBrowserLaunchContextSkipsProcessScanWhenNoActivePort(t *testing.T) { + app := newRuntimeRecoveryTestApp(t) + profile := &BrowserProfile{ + ProfileId: "cold-start", + ProfileName: "Cold Start", + UserDataDir: "cold-start", + } + app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} + + originalFind := findBrowserUserDataProcesses + defer func() { findBrowserUserDataProcesses = originalFind }() + + findCalls := 0 + findBrowserUserDataProcesses = func(string) ([]browserUserDataProcess, error) { + findCalls++ + return nil, nil + } + + _, _, _, _, err := app.prepareBrowserLaunchContext(newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "", ""), profile, nil) + if err != nil { + t.Fatalf("prepareBrowserLaunchContext returned error: %v", err) + } + if findCalls != 0 { + t.Fatalf("cold start should not scan browser processes, got %d calls", findCalls) + } +} +func TestPrepareBrowserLaunchContextTerminatesProcessAndRetriesSessionCleanup(t *testing.T) { + app := newRuntimeRecoveryTestApp(t) + profile := &BrowserProfile{ + ProfileId: "recover-lock", + ProfileName: "Recover Lock", + UserDataDir: "recover-lock", + } + app.browserMgr.Profiles = map[string]*BrowserProfile{profile.ProfileId: profile} + userDataDir := app.browserMgr.ResolveUserDataDir(profile) + sessionsDir := filepath.Join(userDataDir, "Default", "Sessions") + if err := os.MkdirAll(sessionsDir, 0o755); err != nil { + t.Fatalf("create sessions dir failed: %v", err) + } + + originalFind := findBrowserUserDataProcesses + originalTerminate := terminateBrowserUserDataProcess + originalClear := clearBrowserSessionRestoreData + defer func() { + findBrowserUserDataProcesses = originalFind + terminateBrowserUserDataProcess = originalTerminate + clearBrowserSessionRestoreData = originalClear + }() + + findBrowserUserDataProcesses = func(string) ([]browserUserDataProcess, error) { + return []browserUserDataProcess{{PID: 4321}}, nil + } + terminatedPID := 0 + terminateBrowserUserDataProcess = func(pid int, timeout time.Duration) error { + terminatedPID = pid + return nil + } + clearCalls := 0 + clearBrowserSessionRestoreData = func(string) error { + clearCalls++ + if clearCalls == 1 { + return errors.New("remove sessions dir: locked") + } + return nil + } + + _, _, _, _, err := app.prepareBrowserLaunchContext(newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "", ""), profile, nil) + if err != nil { + t.Fatalf("prepareBrowserLaunchContext returned error: %v", err) + } + if terminatedPID != 4321 { + t.Fatalf("expected terminating pid 4321, got %d", terminatedPID) + } + if clearCalls != 2 { + t.Fatalf("expected session cleanup to be retried once, got %d calls", clearCalls) + } +} + +func newRuntimeRecoveryTestApp(t *testing.T) *App { + t.Helper() + + appRoot := t.TempDir() + cfg := config.DefaultConfig() + cfg.Browser.UserDataRoot = "data" + cfg.Browser.RestoreLastSession = false + exePath := createRuntimeRecoveryFakeBrowserExecutable(t, appRoot) + cfg.Browser.Cores = []config.BrowserCore{ + {CoreId: "runtime-recovery-core", CoreName: "Runtime Recovery Core", CorePath: exePath, IsDefault: true}, + } + + app := NewApp(appRoot) + app.config = cfg + app.browserMgr = browser.NewManager(cfg, appRoot) + app.browserMgr.Profiles = make(map[string]*BrowserProfile) + app.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd) + return app +} + +func createRuntimeRecoveryFakeBrowserExecutable(t *testing.T, appRoot string) string { + t.Helper() + + path := filepath.Join(appRoot, "chrome", "chrome.exe") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create fake browser dir failed: %v", err) + } + if err := os.WriteFile(path, []byte("fake"), 0o644); err != nil { + t.Fatalf("write fake browser failed: %v", err) + } + return filepath.Dir(path) +} + +func writeDevToolsActivePort(t *testing.T, userDataDir string, debugPort int) { + t.Helper() + + if err := os.MkdirAll(userDataDir, 0o755); err != nil { + t.Fatalf("create user data dir failed: %v", err) + } + content := fmt.Sprintf("%d\n/devtools/browser/test\n", debugPort) + if err := os.WriteFile(filepath.Join(userDataDir, "DevToolsActivePort"), []byte(content), 0o644); err != nil { + t.Fatalf("write DevToolsActivePort failed: %v", err) + } +} diff --git a/backend/browser_process_discovery_windows.go b/backend/browser_process_discovery_windows.go new file mode 100644 index 00000000..2bb795e7 --- /dev/null +++ b/backend/browser_process_discovery_windows.go @@ -0,0 +1,166 @@ +//go:build windows +// +build windows + +package backend + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +func findBrowserUserDataProcessesOS(userDataDir string) ([]browserUserDataProcess, error) { + userDataDir = strings.TrimSpace(userDataDir) + if userDataDir == "" { + return nil, nil + } + fullUserDataDir, err := filepath.Abs(userDataDir) + if err != nil { + fullUserDataDir = userDataDir + } + + psScript := `param([string]$UserDataDir) +$ErrorActionPreference = 'SilentlyContinue' +$target = [System.IO.Path]::GetFullPath($UserDataDir).TrimEnd('\') +$items = @() +Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -match '--user-data-dir=' } | ForEach-Object { + $cmd = [string]$_.CommandLine + $match = [regex]::Match($cmd, '--user-data-dir=(?:"([^"]+)"|([^\s]+))') + if (-not $match.Success) { return } + $dir = $match.Groups[1].Value + if ([string]::IsNullOrWhiteSpace($dir)) { $dir = $match.Groups[2].Value } + if ([string]::IsNullOrWhiteSpace($dir)) { return } + try { $dir = [System.IO.Path]::GetFullPath($dir).TrimEnd('\') } catch {} + if (-not $dir.Equals($target, [System.StringComparison]::OrdinalIgnoreCase)) { return } + $port = 0 + $portMatch = [regex]::Match($cmd, '--remote-debugging-port=(\d+)') + if ($portMatch.Success) { [void][int]::TryParse($portMatch.Groups[1].Value, [ref]$port) } + $items += [pscustomobject]@{ pid = [int]$_.ProcessId; debugPort = [int]$port; commandLine = $cmd } +} +$items | ConvertTo-Json -Compress +` + + output, err := runPowerShellJSON(psScript, "-UserDataDir", fullUserDataDir) + if err != nil { + return nil, err + } + output = bytes.TrimSpace(output) + if len(output) == 0 || bytes.Equal(output, []byte("null")) { + return nil, nil + } + + var list []browserUserDataProcess + if bytes.HasPrefix(output, []byte("[")) { + if err := json.Unmarshal(output, &list); err != nil { + return nil, err + } + } else { + var item browserUserDataProcess + if err := json.Unmarshal(output, &item); err != nil { + return nil, err + } + list = append(list, item) + } + return list, nil +} + +func terminateBrowserUserDataProcessOS(pid int, timeout time.Duration) error { + if pid <= 0 { + return nil + } + if timeout <= 0 { + timeout = 5 * time.Second + } + if tryCloseBrowserViaCDPPID(pid, timeout) { + return nil + } + + softKillCmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T") + hideWindow(softKillCmd) + _ = softKillCmd.Run() + if waitProcessExitWindows(pid, timeout) { + return nil + } + + forceKillCmd := exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid), "/T") + hideWindow(forceKillCmd) + if err := forceKillCmd.Run(); err != nil { + return err + } + if !waitProcessExitWindows(pid, 2*time.Second) { + return fmt.Errorf("process still running") + } + return nil +} + +func tryCloseBrowserViaCDPPID(pid int, timeout time.Duration) bool { + processes, err := findBrowserProcessByPID(pid) + if err != nil || len(processes) == 0 { + return false + } + debugPort := processes[0].DebugPort + if debugPort <= 0 { + debugPort = parseRemoteDebuggingPort(processes[0].CommandLine) + } + return tryCloseBrowserViaCDP(debugPort, timeout) +} + +func findBrowserProcessByPID(pid int) ([]browserUserDataProcess, error) { + if pid <= 0 { + return nil, nil + } + psScript := `param([int]$PidValue) +$ErrorActionPreference = 'SilentlyContinue' +$p = Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -eq $PidValue } | Select-Object -First 1 +if ($null -eq $p) { exit 0 } +$cmd = [string]$p.CommandLine +$port = 0 +$portMatch = [regex]::Match($cmd, '--remote-debugging-port=(\d+)') +if ($portMatch.Success) { [void][int]::TryParse($portMatch.Groups[1].Value, [ref]$port) } +[pscustomobject]@{ pid = [int]$p.ProcessId; debugPort = [int]$port; commandLine = $cmd } | ConvertTo-Json -Compress +` + output, err := runPowerShellJSON(psScript, "-PidValue", strconv.Itoa(pid)) + if err != nil { + return nil, err + } + output = bytes.TrimSpace(output) + if len(output) == 0 || bytes.Equal(output, []byte("null")) { + return nil, nil + } + var item browserUserDataProcess + if err := json.Unmarshal(output, &item); err != nil { + return nil, err + } + return []browserUserDataProcess{item}, nil +} + +func runPowerShellJSON(script string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + powershellPath := `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` + if _, err := exec.LookPath(powershellPath); err != nil { + if fallbackPath, lookErr := exec.LookPath("powershell.exe"); lookErr == nil { + powershellPath = fallbackPath + } + } + + commandArgs := []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script} + commandArgs = append(commandArgs, args...) + cmd := exec.CommandContext(ctx, powershellPath, commandArgs...) + hideWindow(cmd) + output, err := cmd.Output() + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("powershell query timed out") + } + if err != nil { + return nil, err + } + return output, nil +} diff --git a/backend/browser_profile_launchcode.go b/backend/browser_profile_launchcode.go new file mode 100644 index 00000000..36eda8e0 --- /dev/null +++ b/backend/browser_profile_launchcode.go @@ -0,0 +1,48 @@ +package backend + +import "strings" + +func (a *App) ensureProfileLaunchCode(profile *BrowserProfile) string { + if profile == nil { + return "" + } + + profileID := strings.TrimSpace(profile.ProfileId) + if profileID == "" { + return strings.TrimSpace(profile.LaunchCode) + } + if a == nil || a.launchCodeSvc == nil { + return strings.TrimSpace(profile.LaunchCode) + } + + code, err := a.launchCodeSvc.EnsureCode(profileID) + if err != nil { + return strings.TrimSpace(profile.LaunchCode) + } + + code = strings.TrimSpace(code) + if code != "" { + profile.LaunchCode = code + } + return code +} + +func (a *App) setManagedProfileLaunchCode(profileID string, launchCode string) { + if a == nil || a.browserMgr == nil { + return + } + + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return + } + + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + profile, ok := a.browserMgr.Profiles[profileID] + if !ok || profile == nil { + return + } + profile.LaunchCode = strings.TrimSpace(launchCode) +} diff --git a/backend/browser_profile_launchcode_test.go b/backend/browser_profile_launchcode_test.go new file mode 100644 index 00000000..e29f57cb --- /dev/null +++ b/backend/browser_profile_launchcode_test.go @@ -0,0 +1,81 @@ +package backend + +import ( + "os" + "testing" + + "ant-chrome/backend/internal/browser" + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/launchcode" +) + +func newBrowserLaunchCodeTestApp(t *testing.T) *App { + t.Helper() + + app := NewApp(t.TempDir()) + app.config = config.DefaultConfig() + app.browserMgr = browser.NewManager(app.config, app.appRoot) + app.launchCodeSvc = launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO()) + app.browserMgr.CodeProvider = app.launchCodeSvc + return app +} + +func TestBrowserProfileSetCodeUpdatesManagedProfile(t *testing.T) { + app := newBrowserLaunchCodeTestApp(t) + + profile, err := app.browserMgr.Create(browser.ProfileInput{ProfileName: "mail-profile"}) + if err != nil { + t.Fatalf("create profile failed: %v", err) + } + if profile == nil { + t.Fatal("create profile returned nil") + } + + code, err := app.BrowserProfileSetCode(profile.ProfileId, "mail01") + if err != nil { + t.Fatalf("BrowserProfileSetCode failed: %v", err) + } + if code != "MAIL01" { + t.Fatalf("expected normalized code MAIL01, got %s", code) + } + + if got := app.browserMgr.Profiles[profile.ProfileId].LaunchCode; got != "MAIL01" { + t.Fatalf("expected managed profile launch code MAIL01, got %s", got) + } +} + +func TestEnsurePlaywrightTargetReadyResolvesUpdatedLaunchCode(t *testing.T) { + app := newBrowserLaunchCodeTestApp(t) + + profile, err := app.browserMgr.Create(browser.ProfileInput{ProfileName: "mail-profile"}) + if err != nil { + t.Fatalf("create profile failed: %v", err) + } + if profile == nil { + t.Fatal("create profile returned nil") + } + + if _, err := app.launchCodeSvc.SetCode(profile.ProfileId, "MAIL01"); err != nil { + t.Fatalf("set launch code failed: %v", err) + } + + managed := app.browserMgr.Profiles[profile.ProfileId] + managed.Running = true + managed.Pid = os.Getpid() + + selector, taskProfileID, err := app.ensurePlaywrightTargetReady(map[string]any{ + "profileId": profile.ProfileId, + }) + if err != nil { + t.Fatalf("ensurePlaywrightTargetReady failed: %v", err) + } + if taskProfileID != profile.ProfileId { + t.Fatalf("expected task profile id %s, got %s", profile.ProfileId, taskProfileID) + } + if got, _ := selector["code"].(string); got != "MAIL01" { + t.Fatalf("expected selector code MAIL01, got %v", selector["code"]) + } + if managed.LaunchCode != "MAIL01" { + t.Fatalf("expected managed profile launch code to sync to MAIL01, got %s", managed.LaunchCode) + } +} diff --git a/backend/browser_runtime_state.go b/backend/browser_runtime_state.go index 2f35ab38..0179b388 100644 --- a/backend/browser_runtime_state.go +++ b/backend/browser_runtime_state.go @@ -165,7 +165,15 @@ func (a *App) waitForBrowserDebugReady(profileId string, debugPort int, timeout func (a *App) waitBrowserDebugReadyAsync(profileId string, debugPort int, timeout time.Duration) { snapshot, changed := a.waitForBrowserDebugReady(profileId, debugPort, timeout) - if snapshot == nil || !changed { + if snapshot == nil { + return + } + + if warningSnapshot, warningChanged := a.finalizeDeferredStartTargets(profileId, debugPort); warningSnapshot != nil { + snapshot = warningSnapshot + changed = changed || warningChanged + } + if !changed { return } diff --git a/backend/browser_start_targets.go b/backend/browser_start_targets.go new file mode 100644 index 00000000..e61d8bd7 --- /dev/null +++ b/backend/browser_start_targets.go @@ -0,0 +1,204 @@ +package backend + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "ant-chrome/backend/internal/config" + "ant-chrome/backend/internal/logger" +) + +func browserLightStartEnabled(cfg *config.Config) bool { + if cfg == nil || cfg.Browser.LightStartEnabled == nil { + return true + } + return *cfg.Browser.LightStartEnabled +} + +func resolveConfiguredStartTargets(startURLs []string, defaultStartURLs []string, skipDefaultStartURLs bool) []string { + normalizedStartURLs := normalizeNonEmptyStrings(startURLs) + if len(normalizedStartURLs) > 0 { + return normalizedStartURLs + } + + if skipDefaultStartURLs { + return nil + } + + return normalizeNonEmptyStrings(defaultStartURLs) +} + +func buildBrowserLaunchTargets(startURLs []string, defaultStartURLs []string, skipDefaultStartURLs bool, restoreLastSession bool, lightStartEnabled bool) ([]string, []string) { + configuredTargets := resolveConfiguredStartTargets(startURLs, defaultStartURLs, skipDefaultStartURLs) + if lightStartEnabled && len(configuredTargets) > 0 { + return []string{"about:blank"}, configuredTargets + } + if len(configuredTargets) > 0 { + return configuredTargets, nil + } + if !restoreLastSession { + return []string{"about:blank"}, nil + } + return nil, nil +} + +func deferredStartTargetsWarning(targets []string, err error) string { + normalized := normalizeNonEmptyStrings(targets) + if len(normalized) <= 1 { + return fmt.Sprintf("浏览器已启动,但启动页未能自动打开:%v。可稍后手动打开。", err) + } + return fmt.Sprintf("浏览器已启动,但 %d 个启动页未能自动打开:%v。可稍后手动打开。", len(normalized), err) +} + +func (a *App) storeDeferredStartTargets(profileId string, targets []string) { + if a == nil { + return + } + + normalized := normalizeNonEmptyStrings(targets) + a.deferredStartTargetsMu.Lock() + defer a.deferredStartTargetsMu.Unlock() + + if len(normalized) == 0 { + delete(a.deferredStartTargets, profileId) + return + } + a.deferredStartTargets[profileId] = append([]string{}, normalized...) +} + +func (a *App) consumeDeferredStartTargets(profileId string) []string { + if a == nil { + return nil + } + + a.deferredStartTargetsMu.Lock() + defer a.deferredStartTargetsMu.Unlock() + + targets := append([]string{}, a.deferredStartTargets[profileId]...) + delete(a.deferredStartTargets, profileId) + return targets +} + +func (a *App) clearDeferredStartTargets(profileId string) { + if a == nil { + return + } + + a.deferredStartTargetsMu.Lock() + delete(a.deferredStartTargets, profileId) + a.deferredStartTargetsMu.Unlock() +} + +func cdpBrowserCallResult(debugPort int, method string, params map[string]any) (map[string]any, error) { + body, err := cdpGetEndpointBody(debugPort, "/json/version") + if err != nil { + return nil, fmt.Errorf("CDP /json/version 请求失败: %w", err) + } + var version cdpBrowserVersion + if err := json.Unmarshal(body, &version); err != nil { + return nil, fmt.Errorf("CDP browser target 解析失败: %w", err) + } + wsURL := strings.TrimSpace(version.WebSocketDebuggerUrl) + if wsURL == "" { + return nil, fmt.Errorf("未找到浏览器级 WebSocket 调试地址") + } + + conn, err := cdpDialWebSocket(wsURL) + if err != nil { + return nil, fmt.Errorf("浏览器级 WebSocket 连接失败: %w", err) + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(cdpWebSocketReadTimeout)) + + msg := cdpMessage{Id: 1, Method: method, Params: params} + if err := conn.WriteJSON(msg); err != nil { + return nil, fmt.Errorf("浏览器级 CDP 命令发送失败: %w", err) + } + + var cdpResp cdpResponse + if err := conn.ReadJSON(&cdpResp); err != nil { + return nil, fmt.Errorf("浏览器级 CDP 响应读取失败: %w", err) + } + if cdpResp.Error != nil { + return nil, fmt.Errorf("浏览器级 CDP 错误: %s", cdpResp.Error.Message) + } + return cdpResp.Result, nil +} + +func createBrowserStartTarget(debugPort int, url string) error { + _, err := cdpBrowserCallResult(debugPort, "Target.createTarget", map[string]any{ + "url": url, + }) + return err +} + +func openBrowserStartTargets(debugPort int, targets []string) error { + normalized := normalizeNonEmptyStrings(targets) + if len(normalized) == 0 { + return nil + } + + _, navigateErr := cdpCall(debugPort, "Page.navigate", map[string]any{ + "url": normalized[0], + }) + if navigateErr == nil { + for _, url := range normalized[1:] { + if err := createBrowserStartTarget(debugPort, url); err != nil { + return err + } + } + return nil + } + + for _, url := range normalized { + if err := createBrowserStartTarget(debugPort, url); err != nil { + return fmt.Errorf("首个页面改为新标签回退失败:navigate=%v, createTarget=%w", navigateErr, err) + } + } + return nil +} + +func (a *App) setProfileRuntimeWarning(profileId string, debugPort int, warning string) (*BrowserProfile, bool) { + if a == nil || a.browserMgr == nil { + return nil, false + } + + a.browserMgr.Mutex.Lock() + defer a.browserMgr.Mutex.Unlock() + + profile, exists := a.browserMgr.Profiles[profileId] + if !exists || profile == nil || !profile.Running || profile.DebugPort != debugPort { + return nil, false + } + + if profile.RuntimeWarning == warning && profile.LastError == "" { + return copyBrowserProfileSnapshot(profile), false + } + profile.RuntimeWarning = warning + profile.LastError = "" + return copyBrowserProfileSnapshot(profile), true +} + +func (a *App) finalizeDeferredStartTargets(profileId string, debugPort int) (*BrowserProfile, bool) { + targets := a.consumeDeferredStartTargets(profileId) + if len(targets) == 0 { + return nil, false + } + + if err := openBrowserStartTargets(debugPort, targets); err != nil { + warning := deferredStartTargetsWarning(targets, err) + snapshot, changed := a.setProfileRuntimeWarning(profileId, debugPort, warning) + logger.New("Browser").Warn("浏览器已就绪,但启动页延后打开失败", + logger.F("profile_id", profileId), + logger.F("debug_port", debugPort), + logger.F("target_count", len(targets)), + logger.F("error", err.Error()), + logger.F("warning", warning), + ) + return snapshot, changed + } + + return nil, false +} diff --git a/backend/browser_start_targets_test.go b/backend/browser_start_targets_test.go new file mode 100644 index 00000000..e660b983 --- /dev/null +++ b/backend/browser_start_targets_test.go @@ -0,0 +1,172 @@ +package backend + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strconv" + "strings" + "sync" + "testing" + + "github.com/gorilla/websocket" +) + +func TestBuildBrowserLaunchTargetsDefersConfiguredTargetsWhenLightStartEnabled(t *testing.T) { + t.Parallel() + + launchTargets, deferredTargets := buildBrowserLaunchTargets( + []string{"https://one.example/", "https://two.example/"}, + nil, + false, + false, + true, + ) + if !reflect.DeepEqual(launchTargets, []string{"about:blank"}) { + t.Fatalf("expected blank-page launch target, got %v", launchTargets) + } + if !reflect.DeepEqual(deferredTargets, []string{"https://one.example/", "https://two.example/"}) { + t.Fatalf("expected deferred targets to be preserved, got %v", deferredTargets) + } +} + +func TestBuildBrowserLaunchTargetsPreservesSessionRestoreWhenNoConfiguredTargets(t *testing.T) { + t.Parallel() + + launchTargets, deferredTargets := buildBrowserLaunchTargets(nil, nil, false, true, true) + if len(launchTargets) != 0 { + t.Fatalf("expected no launch targets when restore-last-session is enabled, got %v", launchTargets) + } + if len(deferredTargets) != 0 { + t.Fatalf("expected no deferred targets, got %v", deferredTargets) + } +} + +func TestOpenBrowserStartTargetsNavigatesFirstPageAndCreatesRemainingTargets(t *testing.T) { + t.Parallel() + + server := newRecordedCDPServer(t) + defer server.Close() + + if err := openBrowserStartTargets(server.Port(), []string{"https://one.example/", "https://two.example/"}); err != nil { + t.Fatalf("openBrowserStartTargets returned error: %v", err) + } + + want := []recordedCDPCommand{ + {Scope: "page", Method: "Page.navigate", URL: "https://one.example/"}, + {Scope: "browser", Method: "Target.createTarget", URL: "https://two.example/"}, + } + if got := server.Commands(); !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected CDP command sequence:\n got=%v\nwant=%v", got, want) + } +} + +type recordedCDPCommand struct { + Scope string + Method string + URL string +} + +type recordedCDPServer struct { + server *httptest.Server + upgrader websocket.Upgrader + + mu sync.Mutex + commands []recordedCDPCommand +} + +func newRecordedCDPServer(t *testing.T) *recordedCDPServer { + t.Helper() + + recorder := &recordedCDPServer{ + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + }, + } + + mux := http.NewServeMux() + mux.HandleFunc("/json", func(w http.ResponseWriter, r *http.Request) { + wsURL := recorder.wsURL("/devtools/page/page-1") + payload := []map[string]any{ + { + "type": "page", + "webSocketDebuggerUrl": wsURL, + }, + } + _ = json.NewEncoder(w).Encode(payload) + }) + mux.HandleFunc("/json/version", func(w http.ResponseWriter, r *http.Request) { + payload := map[string]any{ + "Browser": "Chrome/142.0", + "webSocketDebuggerUrl": recorder.wsURL("/devtools/browser/browser-1"), + } + _ = json.NewEncoder(w).Encode(payload) + }) + mux.HandleFunc("/devtools/page/page-1", func(w http.ResponseWriter, r *http.Request) { + recorder.handleWebsocket(w, r, "page") + }) + mux.HandleFunc("/devtools/browser/browser-1", func(w http.ResponseWriter, r *http.Request) { + recorder.handleWebsocket(w, r, "browser") + }) + + recorder.server = httptest.NewServer(mux) + return recorder +} + +func (s *recordedCDPServer) Close() { + if s == nil || s.server == nil { + return + } + s.server.Close() +} + +func (s *recordedCDPServer) Port() int { + parsed, err := url.Parse(s.server.URL) + if err != nil { + return 0 + } + port, _ := strconv.Atoi(parsed.Port()) + return port +} + +func (s *recordedCDPServer) Commands() []recordedCDPCommand { + s.mu.Lock() + defer s.mu.Unlock() + return append([]recordedCDPCommand{}, s.commands...) +} + +func (s *recordedCDPServer) wsURL(path string) string { + return "ws" + strings.TrimPrefix(s.server.URL, "http") + path +} + +func (s *recordedCDPServer) handleWebsocket(w http.ResponseWriter, r *http.Request, scope string) { + conn, err := s.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + var msg cdpMessage + if err := conn.ReadJSON(&msg); err != nil { + return + } + + command := recordedCDPCommand{ + Scope: scope, + Method: msg.Method, + } + if urlValue, _ := msg.Params["url"].(string); urlValue != "" { + command.URL = urlValue + } + + s.mu.Lock() + s.commands = append(s.commands, command) + s.mu.Unlock() + + _ = conn.WriteJSON(cdpResponse{ + Id: msg.Id, + Result: map[string]any{"targetId": "target-1"}, + }) +} diff --git a/backend/internal/automation/assets/runner.cjs b/backend/internal/automation/assets/runner.cjs index cbf9cc5e..dbc0bc4b 100644 --- a/backend/internal/automation/assets/runner.cjs +++ b/backend/internal/automation/assets/runner.cjs @@ -15,6 +15,14 @@ function normalizeTimeout(value, fallback) { return fallback; } +function isPlainObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function hasOwnProperty(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -210,6 +218,278 @@ function toSerializable(value, seen = new WeakSet()) { return inspectValue(value); } +function normalizeOrigin(value) { + const normalized = String(value || '').trim(); + if (!normalized) { + return ''; + } + + try { + const parsed = new URL(normalized); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return ''; + } + return parsed.origin; + } catch { + return ''; + } +} + +function normalizePermissionList(value) { + const source = Array.isArray(value) ? value : value == null ? [] : [value]; + const result = []; + const seen = new Set(); + + for (const item of source) { + const normalized = String(item || '').trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + + return result; +} + +function normalizePageAPIHeaders(value) { + const headers = {}; + if (!value) { + return headers; + } + + if (typeof value.forEach === 'function') { + value.forEach((entryValue, entryKey) => { + const key = String(entryKey || '').trim(); + if (key) { + headers[key] = String(entryValue); + } + }); + return headers; + } + + if (Array.isArray(value)) { + for (const entry of value) { + if (!Array.isArray(entry) || entry.length < 2) { + continue; + } + const key = String(entry[0] || '').trim(); + if (key) { + headers[key] = String(entry[1]); + } + } + return headers; + } + + if (isPlainObject(value)) { + for (const [key, entryValue] of Object.entries(value)) { + const normalizedKey = String(key || '').trim(); + if (normalizedKey && entryValue !== undefined && entryValue !== null) { + headers[normalizedKey] = String(entryValue); + } + } + } + return headers; +} + +function setPageAPIHeaderIfAbsent(headers, key, value) { + const normalizedKey = String(key || '').trim(); + if (!normalizedKey) { + return; + } + const lowerKey = normalizedKey.toLowerCase(); + if (Object.keys(headers).some((existingKey) => existingKey.toLowerCase() === lowerKey)) { + return; + } + headers[normalizedKey] = value; +} + +function appendPageAPIQuery(rawURL, query) { + if (!isPlainObject(query) && !Array.isArray(query)) { + return rawURL; + } + + const searchParams = new URLSearchParams(); + const appendEntry = (key, value) => { + const normalizedKey = String(key || '').trim(); + if (!normalizedKey || value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + for (const item of value) { + appendEntry(normalizedKey, item); + } + return; + } + searchParams.append(normalizedKey, String(value)); + }; + + if (Array.isArray(query)) { + for (const entry of query) { + if (Array.isArray(entry) && entry.length >= 2) { + appendEntry(entry[0], entry[1]); + } + } + } else { + for (const [key, value] of Object.entries(query)) { + appendEntry(key, value); + } + } + + const queryText = searchParams.toString(); + if (!queryText) { + return rawURL; + } + + const hashIndex = rawURL.indexOf('#'); + const baseURL = hashIndex >= 0 ? rawURL.slice(0, hashIndex) : rawURL; + const hash = hashIndex >= 0 ? rawURL.slice(hashIndex) : ''; + const separator = baseURL.includes('?') + ? baseURL.endsWith('?') || baseURL.endsWith('&') + ? '' + : '&' + : '?'; + return `${baseURL}${separator}${queryText}${hash}`; +} + +function normalizePageAPICredentials(value) { + const normalized = String(value || '').trim(); + if (['include', 'same-origin', 'omit'].includes(normalized)) { + return normalized; + } + return 'include'; +} + +function normalizePageAPIBody(source, headers) { + if (hasOwnProperty(source, 'bodyText')) { + return source.bodyText == null ? null : String(source.bodyText); + } + + if (hasOwnProperty(source, 'json')) { + setPageAPIHeaderIfAbsent(headers, 'Content-Type', 'application/json'); + return JSON.stringify(source.json == null ? null : source.json); + } + + if (!hasOwnProperty(source, 'body')) { + return null; + } + + const body = source.body; + if (body == null) { + return null; + } + if (typeof body === 'string') { + return body; + } + setPageAPIHeaderIfAbsent(headers, 'Content-Type', 'application/json'); + return JSON.stringify(body); +} + +function normalizePageAPIRequest(urlOrRequest, options = {}) { + const base = isPlainObject(urlOrRequest) ? urlOrRequest : { url: urlOrRequest }; + const source = { + ...base, + ...(isPlainObject(options) ? options : {}), + }; + const headers = normalizePageAPIHeaders(source.headers); + const bodyText = normalizePageAPIBody(source, headers); + const method = String( + source.method || (bodyText == null ? 'GET' : 'POST') + ) + .trim() + .toUpperCase(); + const url = appendPageAPIQuery(String(source.url || '').trim(), source.query || source.searchParams); + + if (!url) { + throw new Error('page api url is required'); + } + if ((method === 'GET' || method === 'HEAD') && bodyText != null) { + throw new Error(`${method} page api request cannot include a body`); + } + + return { + url, + method, + headers, + credentials: normalizePageAPICredentials(source.credentials), + bodyText, + timeoutMs: normalizeTimeout(source.timeoutMs, 30000), + parseJSON: source.parseJSON !== false, + throwOnError: source.throwOnError === true || source.throwOnHTTPError === true, + }; +} + +async function executePageAPIRequest(request) { + const headers = request && request.headers && typeof request.headers === 'object' + ? request.headers + : {}; + const init = { + method: request.method || 'GET', + headers, + credentials: request.credentials || 'include', + }; + + let timeoutID = null; + if (request.timeoutMs > 0 && typeof AbortController !== 'undefined') { + const controller = new AbortController(); + init.signal = controller.signal; + timeoutID = setTimeout(() => controller.abort(), request.timeoutMs); + } + + if (request.bodyText !== null && request.bodyText !== undefined) { + init.body = request.bodyText; + } + + try { + const response = await fetch(request.url, init); + const responseHeaders = {}; + if (response.headers && typeof response.headers.forEach === 'function') { + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + } + + const bodyText = await response.text(); + let bodyJSON = null; + let hasBodyJSON = false; + if (request.parseJSON !== false && String(bodyText || '').trim()) { + try { + bodyJSON = JSON.parse(bodyText); + hasBodyJSON = true; + } catch {} + } + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + url: response.url, + headers: responseHeaders, + bodyText, + bodyJSON: hasBodyJSON ? bodyJSON : null, + json: hasBodyJSON ? bodyJSON : null, + error: response.ok ? '' : response.statusText || `HTTP ${response.status}`, + }; + } catch (error) { + const message = error && error.message ? error.message : String(error); + return { + ok: false, + status: 0, + statusText: '', + url: request.url, + headers: {}, + bodyText: '', + bodyJSON: null, + json: null, + error: message, + }; + } finally { + if (timeoutID) { + clearTimeout(timeoutID); + } + } +} + function buildLaunchRequestBody(defaultSelector, options) { const launchOptions = options && typeof options === 'object' ? options : {}; const body = {}; @@ -225,6 +505,8 @@ function buildLaunchRequestBody(defaultSelector, options) { 'tags', 'groupId', 'matchMode', + 'proxyId', + 'proxyConfig', 'launchArgs', 'startUrls', 'skipDefaultStartUrls', @@ -358,7 +640,9 @@ async function runScriptTask(payload, chromium) { return response.body; }; - const connect = async (session = {}) => { + const connect = async (session = {}, options = {}) => { + const connectOptions = + options && typeof options === 'object' && !Array.isArray(options) ? options : {}; const endpoints = buildConnectEndpoints(payload, session); if (endpoints.length === 0) { throw new Error( @@ -368,7 +652,8 @@ async function runScriptTask(payload, chromium) { ); } - const deadline = Date.now() + timeout; + const connectTimeout = normalizeTimeout(connectOptions.timeoutMs, timeout); + const deadline = Date.now() + connectTimeout; let lastError = null; while (Date.now() <= deadline) { @@ -380,7 +665,7 @@ async function runScriptTask(payload, chromium) { try { const browser = await chromium.connectOverCDP(endpoint, { - timeout: Math.max(1000, Math.min(remaining, timeout)), + timeout: Math.max(1000, Math.min(remaining, connectTimeout)), }); connectedBrowsers.add(browser); const context = browser.contexts()[0] || null; @@ -409,14 +694,230 @@ async function runScriptTask(payload, chromium) { const lastMessage = lastError && lastError.message ? lastError.message : String(lastError || 'unknown error'); throw new Error( - `cdp endpoint is not ready after ${timeout} ms (endpoints: ${endpoints.join(', ')}): ${lastMessage}` + `cdp endpoint is not ready after ${connectTimeout} ms (endpoints: ${endpoints.join(', ')}): ${lastMessage}` ); }; + const resolveConnectionContext = async (connection) => { + const browser = connection && connection.browser ? connection.browser : null; + if (!browser) { + throw new Error('browser connection is unavailable'); + } + + const context = + connection.context || + browser.contexts()[0] || + (typeof browser.newContext === 'function' ? await browser.newContext() : null); + if (!context) { + throw new Error('browser context is unavailable'); + } + + return { + browser, + context, + }; + }; + + const grantPermissions = async (target, options = {}) => { + const permissionOptions = + options && typeof options === 'object' && !Array.isArray(options) ? options : {}; + const permissions = normalizePermissionList(permissionOptions.permissions); + const origin = normalizeOrigin(permissionOptions.origin); + + let context = null; + if (target && typeof target.grantPermissions === 'function') { + context = target; + } else if (target && typeof target === 'object') { + context = target.context || null; + if (!context && target.browser) { + const resolved = await resolveConnectionContext(target); + context = resolved.context; + } + } + + if (!context) { + return { + applied: false, + permissions, + origin, + reason: 'browser context is unavailable', + }; + } + if (!origin) { + return { + applied: false, + permissions, + origin: '', + reason: 'origin is required', + }; + } + if (permissions.length === 0) { + return { + applied: false, + permissions, + origin, + reason: 'permissions are required', + }; + } + if (typeof context.grantPermissions !== 'function') { + return { + applied: false, + permissions, + origin, + reason: 'grantPermissions is unavailable', + }; + } + + try { + await context.grantPermissions(permissions, { origin }); + return { + applied: true, + permissions, + origin, + strategy: 'grantPermissions', + }; + } catch (error) { + return { + applied: false, + permissions, + origin, + reason: error && error.message ? error.message : String(error), + }; + } + }; + + const openPage = async (connection, options = {}) => { + const openOptions = + options && typeof options === 'object' && !Array.isArray(options) ? options : {}; + const { browser, context } = await resolveConnectionContext(connection); + const shouldReuseCurrentPage = openOptions.reuseCurrentPage === true; + + let page = null; + if ( + shouldReuseCurrentPage && + connection && + connection.page && + typeof connection.page.isClosed === 'function' && + !connection.page.isClosed() + ) { + page = connection.page; + } + if (!page) { + page = await context.newPage(); + } + + if (typeof page.bringToFront === 'function' && openOptions.bringToFront !== false) { + await page.bringToFront().catch(() => {}); + } + + const permissionResult = + openOptions.permissions !== undefined + ? await grantPermissions(context, { + origin: + typeof openOptions.permissionOrigin === 'string' && openOptions.permissionOrigin.trim() + ? openOptions.permissionOrigin + : openOptions.url, + permissions: openOptions.permissions, + }) + : { + applied: false, + permissions: [], + origin: '', + reason: '', + }; + + const targetURL = String(openOptions.url || '').trim(); + if (targetURL) { + const waitUntil = ALLOWED_WAIT_UNTIL.has(String(openOptions.waitUntil || '').trim()) + ? String(openOptions.waitUntil).trim() + : 'domcontentloaded'; + await page.goto(targetURL, { + waitUntil, + timeout: normalizeTimeout(openOptions.timeoutMs, timeout), + }); + } + + return { + browser, + context, + page, + permissionResult, + reusedPage: page === (connection && connection.page ? connection.page : null), + }; + }; + + const resolvePageTarget = (target) => { + if (target && typeof target.evaluate === 'function') { + return target; + } + if (target && target.page && typeof target.page.evaluate === 'function') { + return target.page; + } + throw new Error('page api target must be a Playwright page or an object containing page'); + }; + + const callPageAPI = async (target, urlOrRequest, options = {}) => { + const page = resolvePageTarget(target); + const request = normalizePageAPIRequest(urlOrRequest, options); + const response = await page.evaluate(executePageAPIRequest, request); + + if (request.throwOnError && (!response || response.ok !== true)) { + const status = response && response.status ? response.status : 0; + const message = + (response && typeof response.error === 'string' && response.error.trim()) || + (status ? `page api returned http ${status}` : 'page api request failed'); + throw new Error(message); + } + + return response; + }; + + const browserFetch = callPageAPI; + const pageAPI = callPageAPI; + + const useBrowser = async (options = {}) => { + const runOptions = options && typeof options === 'object' && !Array.isArray(options) ? options : {}; + const launchOptions = + runOptions.launch && typeof runOptions.launch === 'object' && !Array.isArray(runOptions.launch) + ? runOptions.launch + : runOptions; + const connectOptions = + runOptions.connect && typeof runOptions.connect === 'object' && !Array.isArray(runOptions.connect) + ? runOptions.connect + : {}; + const openOptions = + runOptions.open && typeof runOptions.open === 'object' && !Array.isArray(runOptions.open) + ? runOptions.open + : { + url: runOptions.url, + waitUntil: runOptions.waitUntil, + timeoutMs: runOptions.timeoutMs, + permissions: runOptions.permissions, + permissionOrigin: runOptions.permissionOrigin, + reuseCurrentPage: runOptions.reuseCurrentPage, + bringToFront: runOptions.bringToFront, + }; + + const session = await launch(launchOptions); + const connection = await connect(session, connectOptions); + const opened = await openPage(connection, openOptions); + return { + session, + connection, + ...opened, + }; + }; + const api = { chromium, launch, connect, + grantPermissions, + openPage, + useBrowser, + callPageAPI, + pageAPI, + browserFetch, selector, params, log, diff --git a/backend/internal/automation/builtin_script_library.go b/backend/internal/automation/builtin_script_library.go new file mode 100644 index 00000000..09eefef8 --- /dev/null +++ b/backend/internal/automation/builtin_script_library.go @@ -0,0 +1,220 @@ +package automation + +import ( + "embed" + "fmt" + "io/fs" + "path" + "sort" + "strings" + "time" +) + +const ( + DualInstanceRuntimeScriptID = "dual-instance-runtime-switch" + NewsQueryTXTScriptID = "news-query-txt" + WebImageGenerateScriptID = "web-image-generate-download" + builtinScriptLibraryRoot = "demo-library" +) + +var builtinScriptLibraryPackageDirs = map[string]string{ + DualInstanceRuntimeScriptID: builtinScriptLibraryRoot + "/" + DualInstanceRuntimeScriptID, + NewsQueryTXTScriptID: builtinScriptLibraryRoot + "/" + NewsQueryTXTScriptID, + WebImageGenerateScriptID: builtinScriptLibraryRoot + "/" + WebImageGenerateScriptID, +} + +var builtinScriptLibraryDefaultOrder = []string{ + DualInstanceRuntimeScriptID, + NewsQueryTXTScriptID, + WebImageGenerateScriptID, +} + +//go:embed demo-library +var builtinScriptLibraryFS embed.FS + +func DefaultScriptBundles() ([]ImportedBundle, error) { + bundles := make([]ImportedBundle, 0, len(builtinScriptLibraryDefaultOrder)) + for _, scriptID := range builtinScriptLibraryDefaultOrder { + dir, ok := builtinScriptLibraryPackageDirs[scriptID] + if !ok { + return nil, fmt.Errorf("内置脚本 %q 不存在", scriptID) + } + + bundle, err := importBuiltinScriptLibraryBundle(dir) + if err != nil { + return nil, err + } + bundles = append(bundles, bundle) + } + return bundles, nil +} + +func ImportBuiltinBundleFromSource(source ScriptSource) (ImportedBundle, error) { + dir, err := resolveBuiltinScriptLibraryDir(source) + if err != nil { + return ImportedBundle{}, err + } + + bundle, err := importBuiltinScriptLibraryBundle(dir) + if err != nil { + return ImportedBundle{}, err + } + bundle.Record.Source.ImportedAt = time.Now().Format(time.RFC3339) + return bundle, nil +} + +func resolveBuiltinScriptLibraryDir(source ScriptSource) (string, error) { + candidates := []string{ + strings.TrimSpace(source.Path), + path.Base(strings.TrimSpace(strings.TrimPrefix(source.URI, "repo://"))), + path.Base(strings.TrimSpace(source.URI)), + } + + for _, candidate := range candidates { + if candidate == "" { + continue + } + if dir, ok := builtinScriptLibraryPackageDirs[candidate]; ok { + return dir, nil + } + } + + switch { + case strings.TrimSpace(source.Path) != "": + return "", fmt.Errorf("内置脚本 %q 不存在", strings.TrimSpace(source.Path)) + case strings.TrimSpace(source.URI) != "": + return "", fmt.Errorf("内置脚本来源 %q 不存在", strings.TrimSpace(source.URI)) + default: + return "", fmt.Errorf("内置脚本来源缺失") + } +} + +func importBuiltinScriptLibraryBundle(packageDir string) (ImportedBundle, error) { + manifestPath := path.Join(packageDir, scriptPackageManifestName) + manifestData, err := fs.ReadFile(builtinScriptLibraryFS, manifestPath) + if err != nil { + return ImportedBundle{}, fmt.Errorf("read built-in script manifest failed: %w", err) + } + + descriptor, err := parseImportManifest(manifestData) + if err != nil { + return ImportedBundle{}, err + } + + entryFile := normalizeScriptEntryFile(mapStringValueAny(descriptor, "entryFile")) + if entryFile == "" { + return ImportedBundle{}, fmt.Errorf("built-in script manifest is missing entryFile") + } + + entryData, err := fs.ReadFile(builtinScriptLibraryFS, path.Join(packageDir, entryFile)) + if err != nil { + return ImportedBundle{}, fmt.Errorf("read built-in script entry failed: %w", err) + } + + files, err := collectImportedBundleFilesFromFS(builtinScriptLibraryFS, packageDir) + if err != nil { + return ImportedBundle{}, err + } + + record, err := buildImportedRecord(scriptImportEnvelope{ + Format: mapStringValueAny(descriptor, "format"), + PackageFormat: mapStringValueAny(descriptor, "packageFormat"), + ManifestVersion: mapIntValueAny(descriptor, "manifestVersion"), + Name: mapStringValueAny(descriptor, "name"), + Description: mapStringValueAny(descriptor, "description"), + Type: mapStringValueAny(descriptor, "type"), + Status: mapStringValueAny(descriptor, "status"), + EntryFile: entryFile, + Tags: mapStringSliceValue(descriptor, "tags"), + Selector: descriptor["selector"], + SelectorText: descriptor["selectorText"], + Params: descriptor["params"], + ParamsText: descriptor["paramsText"], + ScriptText: string(entryData), + Notes: mapStringValueAny(descriptor, "notes"), + TargetConfig: mapObjectValue(descriptor, "targetConfig"), + PublicAPI: mapObjectValue(descriptor, "publicAPI"), + Source: mapObjectValue(descriptor, "source"), + }, path.Base(packageDir), "") + if err != nil { + return ImportedBundle{}, err + } + if manifestID := strings.TrimSpace(mapStringValueAny(descriptor, "id")); manifestID != "" { + record.ID = manifestID + } + + bundle := ImportedBundle{ + Record: record, + Files: files, + } + if err := validateImportedBundle(bundle.Record, bundle.Files); err != nil { + return ImportedBundle{}, err + } + return bundle, nil +} + +func collectImportedBundleFilesFromFS(fsys fs.FS, root string) ([]ImportedBundleFile, error) { + files := make([]ImportedBundleFile, 0, 8) + totalSize := 0 + + err := fs.WalkDir(fsys, root, func(current string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if current == root { + return nil + } + + relativePath := strings.TrimPrefix(current, root) + relativePath = strings.TrimPrefix(relativePath, "/") + if relativePath == "." { + return nil + } + + if entry.IsDir() { + name := strings.ToLower(strings.TrimSpace(entry.Name())) + if name == ".git" { + return fs.SkipDir + } + if name == "node_modules" { + return fmt.Errorf("script bundle must not include node_modules") + } + return nil + } + + normalizedPath, err := normalizeBundleFilePath(relativePath) + if err != nil { + return err + } + if isImportManifestPath(normalizedPath) { + return nil + } + + content, err := fs.ReadFile(fsys, current) + if err != nil { + return err + } + + totalSize += len(content) + if totalSize > maxImportedBundleBytes { + return fmt.Errorf("script bundle is too large") + } + if len(files) >= maxImportedBundleFiles { + return fmt.Errorf("script bundle contains too many files") + } + + files = append(files, ImportedBundleFile{ + Path: normalizedPath, + Content: content, + }) + return nil + }) + if err != nil { + return nil, fmt.Errorf("collect script bundle files failed: %w", err) + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Path < files[j].Path + }) + return files, nil +} diff --git a/backend/internal/automation/builtin_script_library_test.go b/backend/internal/automation/builtin_script_library_test.go new file mode 100644 index 00000000..de1265f1 --- /dev/null +++ b/backend/internal/automation/builtin_script_library_test.go @@ -0,0 +1,85 @@ +package automation + +import "testing" + +func TestDefaultScriptBundles(t *testing.T) { + bundles, err := DefaultScriptBundles() + if err != nil { + t.Fatalf("DefaultScriptBundles returned error: %v", err) + } + if len(bundles) != 3 { + t.Fatalf("expected three default script bundles, got %d", len(bundles)) + } + + expected := []struct { + id string + name string + uri string + publicAPI string + }{ + { + id: DualInstanceRuntimeScriptID, + name: "双实例启动与 Runtime 切换", + uri: "repo://backend/internal/automation/demo-library/dual-instance-runtime-switch", + publicAPI: "", + }, + { + id: NewsQueryTXTScriptID, + name: "查询新闻并写 TXT", + uri: "repo://backend/internal/automation/demo-library/news-query-txt", + publicAPI: "", + }, + { + id: WebImageGenerateScriptID, + name: "网页图片生成并下载", + uri: "repo://backend/internal/automation/demo-library/web-image-generate-download", + publicAPI: "image/chatgpt-generate-download", + }, + } + + for index, item := range expected { + bundle := bundles[index] + if bundle.Record.ID != item.id { + t.Fatalf("unexpected bundle id at %d: want %q got %q", index, item.id, bundle.Record.ID) + } + if bundle.Record.Name != item.name { + t.Fatalf("unexpected bundle name at %d: want %q got %q", index, item.name, bundle.Record.Name) + } + if bundle.Record.EntryFile != "index.cjs" { + t.Fatalf("unexpected entry file for %q: %q", bundle.Record.ID, bundle.Record.EntryFile) + } + if bundle.Record.Source.Type != "builtin" || bundle.Record.Source.URI != item.uri { + t.Fatalf("unexpected source for %q: %+v", bundle.Record.ID, bundle.Record.Source) + } + if bundle.Record.PublicAPI.Path != item.publicAPI { + t.Fatalf("unexpected public api path for %q: %+v", bundle.Record.ID, bundle.Record.PublicAPI) + } + if item.publicAPI != "" && !bundle.Record.PublicAPI.Enabled { + t.Fatalf("expected public api to be enabled for %q", bundle.Record.ID) + } + if bundle.Record.ID == WebImageGenerateScriptID { + if len(bundle.Record.PublicAPI.Variables) != 1 || bundle.Record.PublicAPI.Variables[0].Name != "prompt" { + t.Fatalf("expected web image script to expose prompt variable, got %+v", bundle.Record.PublicAPI.Variables) + } + } + if len(bundle.Files) == 0 { + t.Fatalf("expected bundled files for %q", bundle.Record.ID) + } + } +} + +func TestImportBuiltinBundleFromSourcePreservesManifestID(t *testing.T) { + bundle, err := ImportBuiltinBundleFromSource(ScriptSource{ + Type: "builtin", + Path: NewsQueryTXTScriptID, + }) + if err != nil { + t.Fatalf("ImportBuiltinBundleFromSource returned error: %v", err) + } + if bundle.Record.ID != NewsQueryTXTScriptID { + t.Fatalf("expected manifest id %q, got %q", NewsQueryTXTScriptID, bundle.Record.ID) + } + if bundle.Record.Source.ImportedAt == "" { + t.Fatalf("expected importedAt to be populated") + } +} diff --git a/backend/internal/automation/demo-library/.gitignore b/backend/internal/automation/demo-library/.gitignore new file mode 100644 index 00000000..b6b67c15 --- /dev/null +++ b/backend/internal/automation/demo-library/.gitignore @@ -0,0 +1,9 @@ +* +!.gitignore +!README.md +!dual-instance-runtime-switch/ +!dual-instance-runtime-switch/** +!news-query-txt/ +!news-query-txt/** +!web-image-generate-download/ +!web-image-generate-download/** diff --git a/backend/internal/automation/demo-library/README.md b/backend/internal/automation/demo-library/README.md new file mode 100644 index 00000000..77999be1 --- /dev/null +++ b/backend/internal/automation/demo-library/README.md @@ -0,0 +1,7 @@ +# Automation Demo Library + +这里只放会提交到 git 的演示脚本包。 + +- 每个脚本一个独立目录,可直接整体复制到别的脚本库里使用 +- 运行时 / 用户自定义脚本不放这里 +- 运行时脚本统一落到 `data/automation/scripts`,并由 `.gitignore` 忽略 diff --git a/backend/internal/automation/demo-library/dual-instance-runtime-switch/automation.script.json b/backend/internal/automation/demo-library/dual-instance-runtime-switch/automation.script.json new file mode 100644 index 00000000..45fd19a0 --- /dev/null +++ b/backend/internal/automation/demo-library/dual-instance-runtime-switch/automation.script.json @@ -0,0 +1,42 @@ +{ + "format": "ant-automation-script", + "packageFormat": "ant-automation-script", + "manifestVersion": 1, + "id": "dual-instance-runtime-switch", + "name": "双实例启动与 Runtime 切换", + "description": "通过 Launch API 分别启动两个实例,切换 Runtime 会话后交给 OpenClaw 执行。", + "type": "launch-api", + "status": "ready", + "entryFile": "index.cjs", + "tags": [ + "Launch API", + "OpenClaw", + "双实例" + ], + "params": { + "browsers": [ + { + "code": "BUYER_001", + "skipDefaultStartUrls": true, + "startUrls": [ + "https://finance.sina.com.cn/" + ] + }, + { + "code": "BUYER_002", + "skipDefaultStartUrls": true, + "startUrls": [ + "https://map.baidu.com/" + ] + } + ], + "timeoutMs": 45000 + }, + "notes": "先通过接口启动两个实例并切换 Runtime 会话;随后把实例信息交给 OpenClaw 执行自动化动作。", + "source": { + "type": "builtin", + "uri": "repo://backend/internal/automation/demo-library/dual-instance-runtime-switch", + "ref": "HEAD", + "path": "dual-instance-runtime-switch" + } +} diff --git a/backend/internal/automation/demo-library/dual-instance-runtime-switch/index.cjs b/backend/internal/automation/demo-library/dual-instance-runtime-switch/index.cjs new file mode 100644 index 00000000..b88f678d --- /dev/null +++ b/backend/internal/automation/demo-library/dual-instance-runtime-switch/index.cjs @@ -0,0 +1,117 @@ +export async function run({ baseUrl, apiKey, params, log }) { + const normalizeCode = (value, fallback) => + String(value || fallback || "").trim().toUpperCase() + const normalizeStringArray = (value) => + Array.isArray(value) + ? value + .map((item) => String(item || "").trim()) + .filter(Boolean) + : [] + const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => { + const raw = value && typeof value === "object" ? value : {} + const code = normalizeCode(raw.code || raw.launchCode, fallbackCode) + if (!code) { + return null + } + const startUrls = normalizeStringArray(raw.startUrls) + const fallbackUrls = normalizeStringArray(fallbackStartUrls) + const launchArgs = normalizeStringArray(raw.launchArgs) + + return { + code, + skipDefaultStartUrls: + raw.skipDefaultStartUrls !== undefined + ? raw.skipDefaultStartUrls !== false + : defaultSkip, + startUrls: startUrls.length > 0 ? startUrls : fallbackUrls, + launchArgs, + } + } + + const timeoutMs = Number.isFinite(Number(params.timeoutMs)) + ? Math.max(1000, Math.round(Number(params.timeoutMs))) + : 45000 + const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false + + let browsers = Array.isArray(params.browsers) + ? params.browsers + .map((item, index) => + normalizeBrowserInput( + item, + ["BUYER_001", "BUYER_002"][index] || "", + ["https://finance.sina.com.cn/", "https://map.baidu.com/"][index] || [], + defaultSkipDefaultStartUrls, + ), + ) + .filter(Boolean) + : [] + + if (browsers.length === 0) { + browsers = [ + normalizeBrowserInput( + { code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, + "BUYER_001", + ["https://finance.sina.com.cn/"], + defaultSkipDefaultStartUrls, + ), + normalizeBrowserInput( + { code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, + "BUYER_002", + ["https://map.baidu.com/"], + defaultSkipDefaultStartUrls, + ), + ].filter(Boolean) + } + + if (browsers.length === 0) { + throw new Error("params.browsers 不能为空") + } + + const headers = { + "Content-Type": "application/json", + ...(apiKey ? { "X-Ant-Api-Key": apiKey } : {}), + } + + const post = async (requestPath, payload) => { + const response = await fetch(`${baseUrl}${requestPath}`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }) + const text = await response.text() + let body = text + try { + body = text ? JSON.parse(text) : null + } catch { + body = text + } + if (!response.ok) { + throw new Error(`${requestPath} failed: ${response.status} ${text}`) + } + return body + } + + const sessions = [] + + for (const browser of browsers) { + const sessionResult = await post("/api/runtime/session", { + selector: { code: browser.code, matchMode: "unique" }, + skipDefaultStartUrls: browser.skipDefaultStartUrls, + ...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}), + ...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}), + timeoutMs, + }) + + sessions.push(sessionResult) + } + + const browserCodes = browsers.map((item) => item.code) + log("browserCodes", browserCodes) + + return { + ok: true, + summary: `${browserCodes.length} 个浏览器已就绪:${browserCodes.join(" / ")}`, + browserCodes, + sessions, + } +} diff --git a/backend/internal/automation/demo-library/news-query-txt/automation.script.json b/backend/internal/automation/demo-library/news-query-txt/automation.script.json new file mode 100644 index 00000000..8c394a38 --- /dev/null +++ b/backend/internal/automation/demo-library/news-query-txt/automation.script.json @@ -0,0 +1,32 @@ +{ + "format": "ant-automation-script", + "packageFormat": "ant-automation-script", + "manifestVersion": 1, + "id": "news-query-txt", + "name": "查询新闻并写 TXT", + "description": "通过 Bing 搜索新闻关键词,提取结果并写入本地 txt 文件。", + "type": "playwright-cdp", + "status": "ready", + "entryFile": "index.cjs", + "tags": [ + "Playwright", + "新闻", + "TXT" + ], + "params": { + "keyword": "OpenAI", + "limit": 10, + "timeRange": "week", + "outputFileName": "openai-news.txt", + "timeoutMs": 30000, + "waitAfterLoadMs": 1500, + "captureScreenshot": false + }, + "notes": "脚本会优先使用 Bing 搜索真实新闻结果,并自动追加时间过滤、排除问答/聚合站点、回退查询词和质量校验;只有达到新闻质量门槛时才会判定成功,并把结果写入本地 txt。执行时可直接点“创建 Demo 并执行”,成功后在结果里的 outputPath 查看文件。", + "source": { + "type": "builtin", + "uri": "repo://backend/internal/automation/demo-library/news-query-txt", + "ref": "HEAD", + "path": "news-query-txt" + } +} diff --git a/backend/internal/automation/default_scripts.go b/backend/internal/automation/demo-library/news-query-txt/index.cjs similarity index 75% rename from backend/internal/automation/default_scripts.go rename to backend/internal/automation/demo-library/news-query-txt/index.cjs index db9a0154..96b755bc 100644 --- a/backend/internal/automation/default_scripts.go +++ b/backend/internal/automation/demo-library/news-query-txt/index.cjs @@ -1,175 +1,4 @@ -package automation - -const DualInstanceRuntimeScriptID = "dual-instance-runtime-switch" - -func DefaultScripts() []ScriptRecord { - return []ScriptRecord{ - { - ID: DualInstanceRuntimeScriptID, - Name: "双实例启动与 Runtime 切换", - Description: "通过 Launch API 分别启动两个实例,切换 Runtime 会话后交给 OpenClaw 执行。", - Type: "launch-api", - Status: "ready", - EntryFile: "index.cjs", - Tags: []string{"Launch API", "OpenClaw", "双实例"}, - ParamsText: `{ - "browsers": [ - { - "code": "BUYER_001", - "skipDefaultStartUrls": true, - "startUrls": ["https://finance.sina.com.cn/"] - }, - { - "code": "BUYER_002", - "skipDefaultStartUrls": true, - "startUrls": ["https://map.baidu.com/"] - } - ], - "timeoutMs": 45000 -}`, - ScriptText: `export async function run({ baseUrl, apiKey, params, log }) { - const normalizeCode = (value, fallback) => - String(value || fallback || '').trim().toUpperCase() - const normalizeStringArray = (value) => - Array.isArray(value) - ? value - .map((item) => String(item || '').trim()) - .filter(Boolean) - : [] - const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => { - const raw = value && typeof value === 'object' ? value : {} - const code = normalizeCode(raw.code || raw.launchCode, fallbackCode) - if (!code) { - return null - } - const startUrls = normalizeStringArray(raw.startUrls) - const fallbackUrls = normalizeStringArray(fallbackStartUrls) - const launchArgs = normalizeStringArray(raw.launchArgs) - - return { - code, - skipDefaultStartUrls: - raw.skipDefaultStartUrls !== undefined - ? raw.skipDefaultStartUrls !== false - : defaultSkip, - startUrls: startUrls.length > 0 ? startUrls : fallbackUrls, - launchArgs, - } - } - - const timeoutMs = Number.isFinite(Number(params.timeoutMs)) - ? Math.max(1000, Math.round(Number(params.timeoutMs))) - : 45000 - const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false - - let browsers = Array.isArray(params.browsers) - ? params.browsers - .map((item, index) => - normalizeBrowserInput( - item, - ['BUYER_001', 'BUYER_002'][index] || '', - ['https://finance.sina.com.cn/', 'https://map.baidu.com/'][index] || [], - defaultSkipDefaultStartUrls, - ), - ) - .filter(Boolean) - : [] - - if (browsers.length === 0) { - browsers = [ - normalizeBrowserInput( - { code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, - 'BUYER_001', - ['https://finance.sina.com.cn/'], - defaultSkipDefaultStartUrls, - ), - normalizeBrowserInput( - { code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, - 'BUYER_002', - ['https://map.baidu.com/'], - defaultSkipDefaultStartUrls, - ), - ].filter(Boolean) - } - - if (browsers.length === 0) { - throw new Error('params.browsers 不能为空') - } - - const headers = { - 'Content-Type': 'application/json', - ...(apiKey ? { 'X-Ant-Api-Key': apiKey } : {}), - } - - const post = async (path, payload) => { - const response = await fetch(baseUrl + path, { - method: 'POST', - headers, - body: JSON.stringify(payload), - }) - const text = await response.text() - let body = text - try { - body = text ? JSON.parse(text) : null - } catch { - body = text - } - if (!response.ok) { - throw new Error(path + ' failed: ' + response.status + ' ' + text) - } - return body - } - - const sessions = [] - - for (const browser of browsers) { - const sessionResult = await post('/api/runtime/session', { - selector: { code: browser.code, matchMode: 'unique' }, - skipDefaultStartUrls: browser.skipDefaultStartUrls, - ...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}), - ...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}), - timeoutMs, - }) - - sessions.push(sessionResult) - } - - const browserCodes = browsers.map((item) => item.code) - log('browserCodes', browserCodes) - - return { - ok: true, - summary: browserCodes.length + ' 个浏览器已就绪:' + browserCodes.join(' / '), - browserCodes, - sessions, - } -}`, - Notes: "先通过接口启动两个实例并切换 Runtime 会话;随后把实例信息交给 OpenClaw 执行自动化动作。", - Source: ScriptSource{ - Type: "builtin", - URI: "repo://backend/internal/automation/default_scripts.go", - Ref: "HEAD", - Path: DualInstanceRuntimeScriptID, - }, - }, - { - ID: "news-query-txt", - Name: "查询新闻并写 TXT", - Description: "通过 Bing 搜索新闻关键词,提取结果并写入本地 txt 文件。", - Type: "playwright-cdp", - Status: "ready", - EntryFile: "index.cjs", - Tags: []string{"Playwright", "新闻", "TXT"}, - ParamsText: `{ - "keyword": "OpenAI", - "limit": 10, - "timeRange": "week", - "outputFileName": "openai-news.txt", - "timeoutMs": 30000, - "waitAfterLoadMs": 1500, - "captureScreenshot": false -}`, - ScriptText: `const fs = require('fs') +const fs = require('fs') const DEFAULT_EXCLUDED_DOMAINS = [ 'zhihu.com', @@ -792,14 +621,4 @@ module.exports.run = async ({ launch, connect, selector, params, log, artifact } scannedPageCount: scannedPageCount, firstTitle: acceptedItems[0] ? acceptedItems[0].title : '', } -}`, - Notes: "脚本会优先使用 Bing 搜索真实新闻结果,并自动追加时间过滤、排除问答/聚合站点、回退查询词和质量校验;只有达到新闻质量门槛时才会判定成功,并把结果写入本地 txt。执行时可直接点“创建 Demo 并执行”,成功后在结果里的 outputPath 查看文件。", - Source: ScriptSource{ - Type: "builtin", - URI: "repo://backend/internal/automation/default_scripts.go", - Ref: "HEAD", - Path: "news-query-txt", - }, - }, - } } diff --git a/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json b/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json new file mode 100644 index 00000000..a01ecf5e --- /dev/null +++ b/backend/internal/automation/demo-library/web-image-generate-download/automation.script.json @@ -0,0 +1,58 @@ +{ + "format": "ant-automation-script", + "packageFormat": "ant-automation-script", + "manifestVersion": 1, + "id": "web-image-generate-download", + "name": "网页图片生成并下载", + "description": "打开指定网页,创建新会话,发送图片生成消息,等待图片生成后下载图片。当前是等待补充页面信息的脚手架。", + "type": "playwright-cdp", + "status": "draft", + "entryFile": "index.cjs", + "tags": [ + "Playwright", + "图片生成", + "下载", + "脚手架" + ], + "params": { + "pageUrl": "https://chatgpt.com/", + "prompt": "A cinematic chrome ant browser mascot, premium product lighting", + "outputFileName": "generated-image.png", + "selectors": { + "newSessionButton": "", + "promptInput": "#prompt-textarea[contenteditable=\"true\"], textarea[name=\"prompt-textarea\"]", + "sendButton": "button[data-testid=\"send-button\"], button[aria-label*=\"发送\"], button.composer-submit-button-color", + "generatedImage": "img[src*=\"/backend-api/estuary/content\"], img[alt*=\"已生成图片\"], img[src*=\"oaiusercontent\"], img[src*=\"oaidalleapiprodscus\"], img[alt*=\"生成\"], img[alt*=\"image\" i]", + "downloadButton": "" + }, + "timeoutMs": 300000, + "waitAfterLoadMs": 1200, + "settleMs": 2500, + "captureScreenshot": false + }, + "notes": "脚本默认打开 ChatGPT,输入图片生成提示词并发送;等待 img[src*=\"/backend-api/estuary/content\"] 或 alt 包含“已生成图片”的结果出现后,使用页面登录态读取图片地址并保存到本地。", + "publicAPI": { + "enabled": true, + "method": "POST", + "path": "image/chatgpt-generate-download", + "requestMode": "standard", + "responseMode": "envelope", + "timeoutMs": 300000, + "requestBodyText": "{\n \"params\": {\n \"prompt\": \"{{prompt}}\"\n }\n}", + "responseBodyText": "{\n \"ok\": true,\n \"outputPath\": \"${artifactsDir}/generated-image.png\",\n \"downloadAddress\": \"${artifactsDir}/generated-image.png\"\n}", + "variables": [ + { + "name": "prompt", + "defaultValue": "A cinematic chrome ant browser mascot, premium product lighting", + "description": "发送到 ChatGPT 的图片生成提示词。", + "required": true + } + ] + }, + "source": { + "type": "builtin", + "uri": "repo://backend/internal/automation/demo-library/web-image-generate-download", + "ref": "HEAD", + "path": "web-image-generate-download" + } +} diff --git a/backend/internal/automation/demo-library/web-image-generate-download/index.cjs b/backend/internal/automation/demo-library/web-image-generate-download/index.cjs new file mode 100644 index 00000000..5f6b2eef --- /dev/null +++ b/backend/internal/automation/demo-library/web-image-generate-download/index.cjs @@ -0,0 +1,321 @@ +const fs = require('fs') +const path = require('path') + +function normalizeText(value) { + return String(value || '').trim() +} + +function normalizeInt(value, fallback, min, max) { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return fallback + } + const rounded = Math.round(parsed) + if (rounded < min) { + return min + } + if (rounded > max) { + return max + } + return rounded +} + +function sanitizeFileName(value, fallback) { + const normalized = normalizeText(value || fallback) + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/\s+/g, ' ') + .trim() + return normalized || fallback +} + +function ensurePngExtension(fileName) { + if (/\.(png|jpg|jpeg|webp)$/i.test(fileName)) { + return fileName + } + return fileName + '.png' +} + +function resolveOutputPath(outputDir, outputFileName) { + const safeName = ensurePngExtension(sanitizeFileName(outputFileName, 'generated-image.png')) + return path.join(outputDir || process.cwd(), safeName) +} + +function buildMissingSetup(selectors, pageUrl) { + const missing = [] + if (!pageUrl) { + missing.push('pageUrl') + } + for (const key of ['promptInput', 'generatedImage']) { + if (!normalizeText(selectors[key])) { + missing.push('selectors.' + key) + } + } + return missing +} + +async function detectLoginRequired(page) { + const url = page.url() + if (/auth\.openai\.com|\/auth\/login|email-verification/i.test(url)) { + return { url, reason: 'auth_page' } + } + const bodyText = await page.locator('body').innerText({ timeout: 2000 }).catch(() => '') + if (/requires you to be logged in|需要登录|登录以获取|登录以|log in|sign in|免费注册/i.test(bodyText)) { + return { url, reason: 'login_required_text' } + } + return null +} + +async function firstVisibleLocator(page, selector, timeoutMs, label) { + const locator = page.locator(selector) + const deadline = Date.now() + timeoutMs + let lastCount = 0 + while (Date.now() < deadline) { + lastCount = await locator.count().catch(() => 0) + for (let index = 0; index < lastCount; index += 1) { + const candidate = locator.nth(index) + if (await candidate.isVisible().catch(() => false)) { + return candidate + } + } + await page.waitForTimeout(250) + } + throw new Error(`${label || 'selector'} 未找到可见元素:${selector},匹配数量 ${lastCount}`) +} + +async function clickWhenReady(page, selector, timeoutMs, label) { + const locator = await firstVisibleLocator(page, selector, timeoutMs, label) + await locator.click({ timeout: timeoutMs }) + return { step: label, selector } +} + +async function fillPrompt(page, selector, prompt, timeoutMs) { + const locator = await firstVisibleLocator(page, selector, timeoutMs, 'promptInput') + const tagName = await locator.evaluate((element) => element.tagName.toLowerCase()) + const isContentEditable = await locator.evaluate((element) => element.isContentEditable) + await locator.click({ timeout: timeoutMs }) + if (tagName === 'textarea' || tagName === 'input') { + await locator.fill(prompt, { timeout: timeoutMs }) + } else if (isContentEditable) { + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A') + await page.keyboard.type(prompt, { delay: 8 }) + } else { + await locator.fill(prompt, { timeout: timeoutMs }) + } + return { step: 'input_prompt', selector, promptLength: prompt.length, inputMode: isContentEditable ? 'contenteditable' : tagName } +} + +async function submitPrompt(page, selector, timeoutMs) { + const normalizedSelector = normalizeText(selector) + if (normalizedSelector) { + const locator = await firstVisibleLocator(page, normalizedSelector, Math.min(timeoutMs, 15000), 'sendButton') + await locator.click({ timeout: Math.min(timeoutMs, 15000) }) + return { step: 'submit_prompt', selector: normalizedSelector, submitMode: 'button' } + } + + await page.keyboard.press('Enter') + return { step: 'submit_prompt', selector: '', submitMode: 'keyboard-enter' } +} + +async function waitForGeneratedImage(page, selector, timeoutMs) { + const locator = page.locator(selector) + const deadline = Date.now() + timeoutMs + const loginRequiredPattern = /requires you to be logged in|需要登录|登录以获取|登录以|log in|sign in/i + while (Date.now() < deadline) { + if ((await locator.count().catch(() => 0)) > 0 && await locator.first().isVisible().catch(() => false)) { + break + } + const bodyText = await page.locator('body').innerText({ timeout: 1000 }).catch(() => '') + if (loginRequiredPattern.test(bodyText)) { + return { + step: 'wait_image', + ok: false, + status: 'needs_login', + summary: 'ChatGPT 当前未登录,页面拒绝生成图片。', + } + } + await page.waitForTimeout(1500) + } + const visibleLocator = await firstVisibleLocator(page, selector, Math.max(1000, deadline - Date.now()), 'generatedImage') + const imageInfo = await visibleLocator.evaluate((element) => { + const tagName = element.tagName.toLowerCase() + const src = tagName === 'img' ? element.currentSrc || element.src || '' : '' + const rect = element.getBoundingClientRect() + return { + tagName, + src, + width: Math.round(rect.width), + height: Math.round(rect.height), + } + }) + return { step: 'wait_image', selector, imageInfo } +} + +async function downloadByButton(page, selector, timeoutMs) { + const downloadPromise = page.waitForEvent('download', { timeout: timeoutMs }) + await page.locator(selector).first().click({ timeout: timeoutMs }) + return downloadPromise +} + +async function downloadByImageURL(page, imageSelector, timeoutMs, outputPath) { + const locator = await firstVisibleLocator(page, imageSelector, timeoutMs, 'generatedImage') + const imageUrl = await locator.evaluate((element) => { + if (element.tagName.toLowerCase() !== 'img') { + return '' + } + return element.currentSrc || element.src || '' + }) + if (!imageUrl) { + throw new Error('已找到生成结果,但没有可下载的图片 URL。请补充 selectors.downloadButton 或确认 generatedImage 指向 img。') + } + + const result = await page.evaluate(async (targetUrl) => { + const response = await fetch(targetUrl, { credentials: 'include' }) + if (!response.ok) { + return { + ok: false, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type') || '', + bytes: [], + } + } + const arrayBuffer = await response.arrayBuffer() + return { + ok: true, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type') || '', + bytes: Array.from(new Uint8Array(arrayBuffer)), + } + }, imageUrl) + if (!result.ok || result.bytes.length === 0) { + throw new Error(`图片 URL 下载失败:HTTP ${result.status || 'unknown'} ${result.statusText || ''}`.trim()) + } + const buffer = Buffer.from(result.bytes) + fs.writeFileSync(outputPath, buffer) + return { source: 'image-url', url: imageUrl, contentType: result.contentType, bytes: buffer.length } +} + +async function captureScreenshotIfNeeded(page, enabled, outputDir, label) { + if (!enabled) { + return '' + } + const screenshotPath = path.join(outputDir || process.cwd(), `web-image-generate-download-${label}.png`) + await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => {}) + return screenshotPath +} + +exports.run = async function run({ useBrowser, params = {}, artifact, artifactsDir, log }) { + const pageUrl = normalizeText(params.pageUrl || params.url) || 'https://chatgpt.com/' + const prompt = normalizeText(params.prompt) || 'A cinematic chrome ant browser mascot, premium product lighting' + const selectors = params.selectors && typeof params.selectors === 'object' ? params.selectors : {} + const timeoutMs = normalizeInt(params.timeoutMs, 300000, 5000, 900000) + const waitAfterLoadMs = normalizeInt(params.waitAfterLoadMs, 1200, 0, 30000) + const settleMs = normalizeInt(params.settleMs, 2500, 0, 60000) + const captureScreenshot = Boolean(params.captureScreenshot) + const outputFileName = ensurePngExtension(sanitizeFileName(params.outputFileName, 'generated-image.png')) + const outputPath = typeof artifact === 'function' + ? artifact(outputFileName) + : resolveOutputPath(artifactsDir, outputFileName) + const steps = [] + + const missing = buildMissingSetup(selectors, pageUrl) + if (missing.length > 0) { + return { + ok: false, + status: 'needs_page_info', + summary: '网页图片生成脚手架已创建,等待补充页面 URL 和选择器。', + missing, + expectedFlow: [ + 'open_page', + 'create_new_session', + 'send_image_prompt', + 'wait_image_generated', + 'download_image', + ], + } + } + + log && log('web-image-generate-download:start', { + pageUrl, + promptLength: prompt.length, + hasDownloadButton: Boolean(normalizeText(selectors.downloadButton)), + }) + + if (typeof useBrowser !== 'function') { + throw new Error('automation runtime does not provide useBrowser') + } + + const { page } = await useBrowser({ + url: pageUrl, + waitUntil: 'domcontentloaded', + timeoutMs, + reuseCurrentPage: true, + bringToFront: true, + }) + if (waitAfterLoadMs > 0) { + await page.waitForTimeout(waitAfterLoadMs) + } + steps.push({ step: 'open_page', url: pageUrl }) + + const loginRequired = await detectLoginRequired(page) + if (loginRequired) { + const screenshotPath = await captureScreenshotIfNeeded(page, true, path.dirname(outputPath), 'needs-login') + return { + ok: false, + status: 'needs_login', + summary: '目标实例未登录 ChatGPT,无法生成图片。', + screenshotPath, + steps: [...steps, { step: 'check_login', ok: false, ...loginRequired }], + } + } + + if (normalizeText(selectors.newSessionButton)) { + steps.push(await clickWhenReady(page, selectors.newSessionButton, timeoutMs, 'create_new_session')) + } else { + steps.push({ step: 'create_new_session', skipped: true, reason: 'selectors.newSessionButton is empty' }) + } + steps.push(await fillPrompt(page, selectors.promptInput, prompt, timeoutMs)) + steps.push(await submitPrompt(page, selectors.sendButton, timeoutMs)) + const generatedImageStep = await waitForGeneratedImage(page, selectors.generatedImage, timeoutMs) + steps.push(generatedImageStep) + if (generatedImageStep && generatedImageStep.ok === false) { + const screenshotPath = await captureScreenshotIfNeeded(page, true, path.dirname(outputPath), 'needs-login') + return { + ok: false, + status: generatedImageStep.status || 'failed', + summary: generatedImageStep.summary || '图片未生成。', + screenshotPath, + steps, + } + } + + if (settleMs > 0) { + await page.waitForTimeout(settleMs) + } + + let downloadInfo + if (normalizeText(selectors.downloadButton)) { + const download = await downloadByButton(page, selectors.downloadButton, timeoutMs) + await download.saveAs(outputPath) + downloadInfo = { + source: 'download-button', + suggestedFilename: download.suggestedFilename(), + } + } else { + downloadInfo = await downloadByImageURL(page, selectors.generatedImage, timeoutMs, outputPath) + } + steps.push({ step: 'download_image', outputPath, ...downloadInfo }) + + const screenshotPath = await captureScreenshotIfNeeded(page, captureScreenshot, path.dirname(outputPath), 'done') + + return { + ok: true, + status: 'completed', + summary: '图片已生成并下载。', + outputPath, + downloadAddress: outputPath, + screenshotPath, + steps, + } +} diff --git a/backend/internal/automation/script_importer.go b/backend/internal/automation/script_importer.go index eaa0240d..2b1e8431 100644 --- a/backend/internal/automation/script_importer.go +++ b/backend/internal/automation/script_importer.go @@ -30,6 +30,7 @@ type scriptImportEnvelope struct { ScriptText string `json:"scriptText"` Notes string `json:"notes"` TargetConfig map[string]any `json:"targetConfig"` + PublicAPI map[string]any `json:"publicAPI"` Source map[string]any `json:"source"` Files []scriptTemplateFile `json:"files"` } diff --git a/backend/internal/automation/script_importer_library.go b/backend/internal/automation/script_importer_library.go new file mode 100644 index 00000000..d1610b86 --- /dev/null +++ b/backend/internal/automation/script_importer_library.go @@ -0,0 +1,143 @@ +package automation + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const maxDiscoveredScriptLibraryPackages = 256 + +var automationLibraryEntryMarkers = [][]byte{ + []byte("module.exports.run"), + []byte("exports.run"), + []byte("export async function run"), + []byte("export function run"), + []byte("export const run"), + []byte("export default"), +} + +func DiscoverImportableScriptDirectoriesWithOptions(rootDir string) ([]string, error) { + baseDir := filepath.Clean(strings.TrimSpace(rootDir)) + if baseDir == "" || baseDir == "." { + return nil, fmt.Errorf("script library directory is required") + } + + info, err := os.Stat(baseDir) + if err != nil { + return nil, fmt.Errorf("stat script library directory failed: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("script library path is not a directory") + } + + discovered := make([]string, 0, 8) + seen := make(map[string]struct{}) + + err = filepath.WalkDir(baseDir, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + return nil + } + + if path != baseDir { + switch strings.ToLower(strings.TrimSpace(entry.Name())) { + case ".git", "node_modules": + return filepath.SkipDir + } + } + + importable, err := isDiscoverableScriptDirectory(path) + if err != nil { + return err + } + if !importable { + return nil + } + + normalizedPath := filepath.Clean(path) + if _, exists := seen[normalizedPath]; !exists { + if len(discovered) >= maxDiscoveredScriptLibraryPackages { + return fmt.Errorf("script library contains too many importable packages") + } + discovered = append(discovered, normalizedPath) + seen[normalizedPath] = struct{}{} + } + return filepath.SkipDir + }) + if err != nil { + return nil, fmt.Errorf("scan script library failed: %w", err) + } + + if len(discovered) == 0 { + return nil, fmt.Errorf("未在所选目录下找到可导入脚本包") + } + + sort.Strings(discovered) + return discovered, nil +} + +func isDiscoverableScriptDirectory(dir string) (bool, error) { + manifestPath, err := resolveImportManifest(dir) + if err != nil { + return false, err + } + if manifestPath != "" { + return true, nil + } + + entryPath, found, err := findDiscoverableScriptEntryFile(dir) + if err != nil { + return false, err + } + if !found { + return false, nil + } + + return looksLikeAutomationEntryFile(entryPath) +} + +func findDiscoverableScriptEntryFile(dir string) (string, bool, error) { + for _, candidate := range []string{ + "index.cjs", + "index.js", + "index.mjs", + "index.ts", + "index.cts", + "index.mts", + } { + entryPath := filepath.Join(dir, candidate) + info, err := os.Stat(entryPath) + if err == nil && !info.IsDir() { + return entryPath, true, nil + } + if err != nil && !os.IsNotExist(err) { + return "", false, fmt.Errorf("stat script entry failed: %w", err) + } + } + return "", false, nil +} + +func looksLikeAutomationEntryFile(path string) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, fmt.Errorf("read script entry failed: %w", err) + } + + compact := bytes.ToLower(bytes.TrimSpace(data)) + if len(compact) == 0 { + return false, nil + } + + for _, marker := range automationLibraryEntryMarkers { + if bytes.Contains(compact, bytes.ToLower(marker)) { + return true, nil + } + } + return false, nil +} diff --git a/backend/internal/automation/script_importer_library_test.go b/backend/internal/automation/script_importer_library_test.go new file mode 100644 index 00000000..e235718a --- /dev/null +++ b/backend/internal/automation/script_importer_library_test.go @@ -0,0 +1,71 @@ +package automation + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestDiscoverImportableScriptDirectoriesWithOptions(t *testing.T) { + rootDir := t.TempDir() + + writeLibraryPackageFile(t, filepath.Join(rootDir, "manifest-script", "automation.script.json"), `{ + "name": "Manifest Script", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`) + writeLibraryPackageFile(t, filepath.Join(rootDir, "manifest-script", "index.cjs"), "module.exports.run = async () => ({ ok: true, source: 'manifest' })") + + writeLibraryPackageFile(t, filepath.Join(rootDir, "entry-script", "index.cjs"), "module.exports.run = async () => ({ ok: true, source: 'entry' })") + writeLibraryPackageFile(t, filepath.Join(rootDir, "entry-no-run", "index.ts"), "export const value = 'not-a-script'") + + writeLibraryPackageFile(t, filepath.Join(rootDir, "nested", "child-script", "automation.script.json"), `{ + "name": "Nested Script", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`) + writeLibraryPackageFile(t, filepath.Join(rootDir, "nested", "child-script", "index.cjs"), "module.exports.run = async () => ({ ok: true, source: 'nested' })") + + writeLibraryPackageFile(t, filepath.Join(rootDir, "parent-script", "automation.script.json"), `{ + "name": "Parent Script", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`) + writeLibraryPackageFile(t, filepath.Join(rootDir, "parent-script", "index.cjs"), "module.exports.run = async () => ({ ok: true, source: 'parent' })") + writeLibraryPackageFile(t, filepath.Join(rootDir, "parent-script", "child-script", "automation.script.json"), `{ + "name": "Child Script", + "type": "playwright-cdp", + "entryFile": "index.cjs" +}`) + writeLibraryPackageFile(t, filepath.Join(rootDir, "parent-script", "child-script", "index.cjs"), "module.exports.run = async () => ({ ok: true, source: 'child' })") + + writeLibraryPackageFile(t, filepath.Join(rootDir, ".git", "ignored-script", "automation.script.json"), `{"name":"Ignored","entryFile":"index.cjs"}`) + writeLibraryPackageFile(t, filepath.Join(rootDir, "node_modules", "ignored-script", "automation.script.json"), `{"name":"Ignored","entryFile":"index.cjs"}`) + + directories, err := DiscoverImportableScriptDirectoriesWithOptions(rootDir) + if err != nil { + t.Fatalf("DiscoverImportableScriptDirectoriesWithOptions returned error: %v", err) + } + + expected := []string{ + filepath.Join(rootDir, "entry-script"), + filepath.Join(rootDir, "manifest-script"), + filepath.Join(rootDir, "nested", "child-script"), + filepath.Join(rootDir, "parent-script"), + } + if !reflect.DeepEqual(directories, expected) { + t.Fatalf("unexpected discovered directories:\nwant: %#v\ngot: %#v", expected, directories) + } +} + +func writeLibraryPackageFile(t *testing.T, path string, content string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create package directory failed: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write package file failed: %v", err) + } +} diff --git a/backend/internal/automation/script_importer_manifest.go b/backend/internal/automation/script_importer_manifest.go index 372ddfe9..6bc777a8 100644 --- a/backend/internal/automation/script_importer_manifest.go +++ b/backend/internal/automation/script_importer_manifest.go @@ -64,6 +64,9 @@ func buildImportedRecord(envelope scriptImportEnvelope, defaultName string, sour if envelope.TargetConfig != nil { descriptor["targetConfig"] = envelope.TargetConfig } + if envelope.PublicAPI != nil { + descriptor["publicAPI"] = envelope.PublicAPI + } if envelope.Selector != nil { descriptor["selector"] = envelope.Selector } @@ -115,6 +118,7 @@ func buildImportedRecord(envelope scriptImportEnvelope, defaultName string, sour ScriptText: scriptText, Notes: mapStringValueAny(descriptor, "notes"), TargetConfig: mapScriptTargetConfigValue(descriptor["targetConfig"]), + PublicAPI: mapScriptPublicAPIValue(descriptor["publicAPI"]), Source: source, CreatedAt: now, UpdatedAt: now, diff --git a/backend/internal/automation/script_importer_mapping.go b/backend/internal/automation/script_importer_mapping.go index beb9e5f0..54410736 100644 --- a/backend/internal/automation/script_importer_mapping.go +++ b/backend/internal/automation/script_importer_mapping.go @@ -179,6 +179,63 @@ func mapScriptTargetConfigValue(value any) ScriptTargetConfig { } } +func mapScriptPublicAPIValue(value any) ScriptPublicAPIConfig { + object, ok := value.(map[string]any) + if !ok || object == nil { + return ScriptPublicAPIConfig{} + } + + return ScriptPublicAPIConfig{ + Enabled: mapBoolValueAny(object, "enabled"), + Method: mapStringValueAny(object, "method"), + Path: firstNonEmpty(mapStringValueAny(object, "path"), mapStringValueAny(object, "route")), + RequestMode: mapStringValueAny(object, "requestMode"), + ResponseMode: mapStringValueAny(object, "responseMode"), + TimeoutMs: mapIntValueAny(object, "timeoutMs"), + RequestBodyText: stringifyImportJSONValue(firstNonNil(object["requestBodyText"], object["requestBody"], object["requestExample"])), + ResponseBodyText: stringifyImportJSONValue(firstNonNil(object["responseBodyText"], object["responseBody"], object["responseExample"])), + Variables: mapScriptPublicAPIVariablesValue(object["variables"]), + } +} + +func mapScriptPublicAPIVariablesValue(value any) []ScriptPublicAPIVariable { + switch typed := value.(type) { + case []ScriptPublicAPIVariable: + return normalizeScriptPublicAPIVariables(typed) + case []any: + variables := make([]ScriptPublicAPIVariable, 0, len(typed)) + for _, item := range typed { + object, ok := item.(map[string]any) + if !ok || object == nil { + continue + } + variables = append(variables, ScriptPublicAPIVariable{ + Name: firstNonEmpty(mapStringValueAny(object, "name"), mapStringValueAny(object, "key")), + DefaultValue: firstNonEmpty(mapStringValueAny(object, "defaultValue"), mapStringValueAny(object, "default"), mapStringValueAny(object, "value")), + Description: firstNonEmpty(mapStringValueAny(object, "description"), mapStringValueAny(object, "label"), mapStringValueAny(object, "note")), + Required: mapBoolValueAny(object, "required"), + }) + } + return normalizeScriptPublicAPIVariables(variables) + case map[string]any: + variables := make([]ScriptPublicAPIVariable, 0, len(typed)) + for key, rawValue := range typed { + variable := ScriptPublicAPIVariable{Name: key} + if object, ok := rawValue.(map[string]any); ok && object != nil { + variable.DefaultValue = firstNonEmpty(mapStringValueAny(object, "defaultValue"), mapStringValueAny(object, "default"), mapStringValueAny(object, "value")) + variable.Description = firstNonEmpty(mapStringValueAny(object, "description"), mapStringValueAny(object, "label"), mapStringValueAny(object, "note")) + variable.Required = mapBoolValueAny(object, "required") + } else if rawValue != nil { + variable.DefaultValue = strings.TrimSpace(fmt.Sprint(rawValue)) + } + variables = append(variables, variable) + } + return normalizeScriptPublicAPIVariables(variables) + default: + return nil + } +} + func mapScriptTargetSelectorValue(value any) ScriptTargetSelector { object, ok := value.(map[string]any) if !ok || object == nil { @@ -195,6 +252,32 @@ func mapScriptTargetSelectorValue(value any) ScriptTargetSelector { } } +func mapBoolValueAny(payload map[string]any, key string) bool { + if payload == nil { + return false + } + value, exists := payload[key] + if !exists || value == nil { + return false + } + switch typed := value.(type) { + case bool: + return typed + case string: + switch strings.ToLower(strings.TrimSpace(typed)) { + case "true", "1", "yes", "enabled", "on": + return true + } + case float64: + return typed != 0 + case int: + return typed != 0 + case int64: + return typed != 0 + } + return false +} + func firstNonNil(values ...any) any { for _, value := range values { if value != nil { diff --git a/backend/internal/automation/script_importer_test.go b/backend/internal/automation/script_importer_test.go index 58d4850c..7a448a90 100644 --- a/backend/internal/automation/script_importer_test.go +++ b/backend/internal/automation/script_importer_test.go @@ -22,6 +22,16 @@ func TestImportBundleFromBytesSupportsInlineJSONPackage(t *testing.T) { "params": { "url": "https://example.com" }, + "publicAPI": { + "enabled": true, + "path": "mail/demo-script", + "requestMode": "params-only", + "responseMode": "result-only", + "variables": [ + {"name": "recipientQuery", "defaultValue": "target@example.com", "description": "收件人", "required": true}, + {"name": "senderEmail", "defaultValue": "otp@tm1.openai.com, noreply@tm.openai.com"} + ] + }, "script": "module.exports.run = async () => ({ ok: true })" }`), "远程地址 https://example.com/demo-script.json") if err != nil { @@ -49,6 +59,12 @@ func TestImportBundleFromBytesSupportsInlineJSONPackage(t *testing.T) { if len(bundle.Files) != 1 || bundle.Files[0].Path != "index.cjs" { t.Fatalf("unexpected bundle files: %+v", bundle.Files) } + if bundle.Record.PublicAPI.Path != "mail/demo-script" || !bundle.Record.PublicAPI.Enabled { + t.Fatalf("unexpected public api import result: %+v", bundle.Record.PublicAPI) + } + if len(bundle.Record.PublicAPI.Variables) != 2 || bundle.Record.PublicAPI.Variables[0].Name != "recipientQuery" || !bundle.Record.PublicAPI.Variables[0].Required { + t.Fatalf("unexpected public api variables: %+v", bundle.Record.PublicAPI.Variables) + } if bundle.Record.Source.Type != "remote-url" { t.Fatalf("expected remote-url source, got %+v", bundle.Record.Source) } diff --git a/backend/internal/automation/script_run_store.go b/backend/internal/automation/script_run_store.go index 571d057d..ae655fdd 100644 --- a/backend/internal/automation/script_run_store.go +++ b/backend/internal/automation/script_run_store.go @@ -29,6 +29,7 @@ type ScriptRunRecord struct { type ScriptRunRequest struct { ScriptID string `json:"scriptId"` SelectorText string `json:"selectorText"` + TargetInput any `json:"targetInput,omitempty"` ParamsText string `json:"paramsText"` UseScriptSelector bool `json:"useScriptSelector"` UseScriptParams bool `json:"useScriptParams"` diff --git a/backend/internal/automation/script_template.go b/backend/internal/automation/script_template.go index 7838f8e4..e2fa1e0a 100644 --- a/backend/internal/automation/script_template.go +++ b/backend/internal/automation/script_template.go @@ -34,6 +34,7 @@ func MarshalScriptPackageManifest(record ScriptRecord) ([]byte, error) { "tags": append([]string{}, normalized.Tags...), "notes": normalized.Notes, "targetConfig": normalized.TargetConfig, + "publicAPI": normalized.PublicAPI, "source": map[string]any{ "type": normalized.Source.Type, "uri": normalized.Source.URI, @@ -82,12 +83,24 @@ func MarshalScriptTemplate(bundle ImportedBundle) ([]byte, error) { "tags": append([]string{}, record.Tags...), "notes": record.Notes, "targetConfig": record.TargetConfig, + "publicAPI": record.PublicAPI, "source": record.Source, "createdAt": record.CreatedAt, "updatedAt": record.UpdatedAt, }, ScriptText: record.ScriptText, Notes: record.Notes, + PublicAPI: map[string]any{ + "enabled": record.PublicAPI.Enabled, + "method": record.PublicAPI.Method, + "path": record.PublicAPI.Path, + "requestMode": record.PublicAPI.RequestMode, + "responseMode": record.PublicAPI.ResponseMode, + "timeoutMs": record.PublicAPI.TimeoutMs, + "requestBodyText": record.PublicAPI.RequestBodyText, + "responseBodyText": record.PublicAPI.ResponseBodyText, + "variables": record.PublicAPI.Variables, + }, Source: map[string]any{ "type": record.Source.Type, "uri": record.Source.URI, diff --git a/backend/internal/automation/script_template_test.go b/backend/internal/automation/script_template_test.go index 8222f1f9..136c6c41 100644 --- a/backend/internal/automation/script_template_test.go +++ b/backend/internal/automation/script_template_test.go @@ -18,6 +18,17 @@ func TestMarshalScriptTemplateRoundTripsAdditionalFiles(t *testing.T) { SelectorText: `{"code":"DEMO_TEMPLATE"}`, ParamsText: `{"url":"https://example.com"}`, ScriptText: "const helper = require('./helpers/helper.cjs')\nmodule.exports.run = async () => helper.run()", + PublicAPI: ScriptPublicAPIConfig{ + Enabled: true, + Path: "mail/template-roundtrip", + RequestMode: "params-only", + ResponseMode: "result-only", + RequestBodyText: "{\n \"recipientQuery\": \"target@example.com\"\n}", + ResponseBodyText: "{\n \"verificationCode\": \"429792\"\n}", + Variables: []ScriptPublicAPIVariable{ + {Name: "recipientQuery", DefaultValue: "target@example.com", Description: "收件人", Required: true}, + }, + }, }, Files: []ImportedBundleFile{ { @@ -62,6 +73,12 @@ func TestMarshalScriptTemplateRoundTripsAdditionalFiles(t *testing.T) { if !hasBundleFile(imported.Files, "assets/raw.bin", []byte{0x00, 0x01, 0x02, 0xff}) { t.Fatalf("expected binary file to round-trip, got %+v", imported.Files) } + if imported.Record.PublicAPI.Path != "mail/template-roundtrip" || !imported.Record.PublicAPI.Enabled { + t.Fatalf("expected public api to round-trip, got %+v", imported.Record.PublicAPI) + } + if len(imported.Record.PublicAPI.Variables) != 1 || imported.Record.PublicAPI.Variables[0].Name != "recipientQuery" { + t.Fatalf("expected public api variables to round-trip, got %+v", imported.Record.PublicAPI.Variables) + } } func TestScriptStoreExportBundleIncludesNestedFiles(t *testing.T) { diff --git a/backend/internal/automation/scripts_store.go b/backend/internal/automation/scripts_store.go index 05b364fa..be74fa30 100644 --- a/backend/internal/automation/scripts_store.go +++ b/backend/internal/automation/scripts_store.go @@ -13,8 +13,10 @@ const ( defaultScriptPackageFormat = "ant-automation-script" defaultScriptManifestVersion = 1 defaultScriptCreateNameTemplate = "${templateName}-${timestamp}" + defaultScriptPublicAPITimeoutMs = 300000 scriptStoreConfigFileName = "config" scriptStoreLegacyConfigName = "manifest.json" + scriptPublicAPIBasePath = "/api/automation/hooks" ) type ScriptSource struct { @@ -41,24 +43,44 @@ type ScriptTargetConfig struct { CreateNameTemplate string `json:"createNameTemplate"` } +type ScriptPublicAPIConfig struct { + Enabled bool `json:"enabled"` + Method string `json:"method"` + Path string `json:"path"` + RequestMode string `json:"requestMode"` + ResponseMode string `json:"responseMode"` + TimeoutMs int `json:"timeoutMs"` + RequestBodyText string `json:"requestBodyText"` + ResponseBodyText string `json:"responseBodyText"` + Variables []ScriptPublicAPIVariable `json:"variables"` +} + +type ScriptPublicAPIVariable struct { + Name string `json:"name"` + DefaultValue string `json:"defaultValue"` + Description string `json:"description"` + Required bool `json:"required"` +} + type ScriptRecord struct { - PackageFormat string `json:"packageFormat"` - ManifestVersion int `json:"manifestVersion"` - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Type string `json:"type"` - Status string `json:"status"` - EntryFile string `json:"entryFile"` - Tags []string `json:"tags"` - SelectorText string `json:"selectorText"` - ParamsText string `json:"paramsText"` - ScriptText string `json:"scriptText"` - Notes string `json:"notes"` - TargetConfig ScriptTargetConfig `json:"targetConfig"` - Source ScriptSource `json:"source"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + PackageFormat string `json:"packageFormat"` + ManifestVersion int `json:"manifestVersion"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Status string `json:"status"` + EntryFile string `json:"entryFile"` + Tags []string `json:"tags"` + SelectorText string `json:"selectorText"` + ParamsText string `json:"paramsText"` + ScriptText string `json:"scriptText"` + Notes string `json:"notes"` + TargetConfig ScriptTargetConfig `json:"targetConfig"` + PublicAPI ScriptPublicAPIConfig `json:"publicAPI"` + Source ScriptSource `json:"source"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } type ImportedBundleFile struct { @@ -72,22 +94,23 @@ type ImportedBundle struct { } type scriptStoreConfig struct { - PackageFormat string `json:"packageFormat"` - ManifestVersion int `json:"manifestVersion"` - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Type string `json:"type"` - Status string `json:"status"` - EntryFile string `json:"entryFile"` - Tags []string `json:"tags"` - SelectorText string `json:"selectorText"` - ParamsText string `json:"paramsText"` - Notes string `json:"notes"` - TargetConfig ScriptTargetConfig `json:"targetConfig"` - Source ScriptSource `json:"source"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + PackageFormat string `json:"packageFormat"` + ManifestVersion int `json:"manifestVersion"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Status string `json:"status"` + EntryFile string `json:"entryFile"` + Tags []string `json:"tags"` + SelectorText string `json:"selectorText"` + ParamsText string `json:"paramsText"` + Notes string `json:"notes"` + TargetConfig ScriptTargetConfig `json:"targetConfig"` + PublicAPI ScriptPublicAPIConfig `json:"publicAPI"` + Source ScriptSource `json:"source"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } type ScriptStore struct { @@ -154,6 +177,9 @@ func (s *ScriptStore) Save(input ScriptRecord) (ScriptRecord, error) { if err != nil { return ScriptRecord{}, err } + if err := s.validateRecord(record); err != nil { + return ScriptRecord{}, err + } return s.writeRecord(dir, record, existing, nil) } @@ -209,6 +235,9 @@ func (s *ScriptStore) ImportBundle(bundle ImportedBundle) (ScriptRecord, error) } existing, _ := s.readScriptDir(dir) + if err := s.validateRecord(record); err != nil { + return ScriptRecord{}, err + } return s.writeRecord(dir, record, existing, bundle.Files) } diff --git a/backend/internal/automation/scripts_store_files.go b/backend/internal/automation/scripts_store_files.go index 494a9327..24d5fba9 100644 --- a/backend/internal/automation/scripts_store_files.go +++ b/backend/internal/automation/scripts_store_files.go @@ -41,6 +41,7 @@ func (s *ScriptStore) readScriptDir(dir string) (ScriptRecord, error) { ParamsText: config.ParamsText, Notes: config.Notes, TargetConfig: config.TargetConfig, + PublicAPI: config.PublicAPI, Source: config.Source, CreatedAt: config.CreatedAt, UpdatedAt: config.UpdatedAt, @@ -122,6 +123,7 @@ func (s *ScriptStore) writeRecord(dir string, record ScriptRecord, existing Scri ParamsText: record.ParamsText, Notes: record.Notes, TargetConfig: record.TargetConfig, + PublicAPI: record.PublicAPI, Source: record.Source, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, diff --git a/backend/internal/automation/scripts_store_normalize.go b/backend/internal/automation/scripts_store_normalize.go index 20aa017a..bc6e5e9a 100644 --- a/backend/internal/automation/scripts_store_normalize.go +++ b/backend/internal/automation/scripts_store_normalize.go @@ -2,6 +2,7 @@ package automation import ( "fmt" + "path" "path/filepath" "strings" "time" @@ -47,6 +48,7 @@ func normalizeScriptRecord(input ScriptRecord, existing ScriptRecord) (ScriptRec ScriptText: normalizeScriptText(input.ScriptText), Notes: strings.TrimSpace(input.Notes), TargetConfig: normalizeScriptTargetConfig(input.TargetConfig), + PublicAPI: normalizeScriptPublicAPIConfig(input.PublicAPI), Source: normalizeScriptSource(input.Source, existing.Source), CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -195,6 +197,149 @@ func normalizeScriptTargetSelector(input ScriptTargetSelector) ScriptTargetSelec } } +func normalizeScriptPublicAPIConfig(input ScriptPublicAPIConfig) ScriptPublicAPIConfig { + return ScriptPublicAPIConfig{ + Enabled: input.Enabled, + Method: normalizeScriptPublicAPIMethod(input.Method), + Path: normalizeScriptPublicAPIPath(input.Path), + RequestMode: normalizeScriptPublicAPIRequestMode(input.RequestMode), + ResponseMode: normalizeScriptPublicAPIResponseMode(input.ResponseMode), + TimeoutMs: normalizeScriptPublicAPITimeout(input.TimeoutMs), + RequestBodyText: normalizeScriptJSONText(input.RequestBodyText), + ResponseBodyText: normalizeScriptJSONText(input.ResponseBodyText), + Variables: normalizeScriptPublicAPIVariables(input.Variables), + } +} + +func normalizeScriptPublicAPIVariables(variables []ScriptPublicAPIVariable) []ScriptPublicAPIVariable { + seen := make(map[string]struct{}, len(variables)) + result := make([]ScriptPublicAPIVariable, 0, len(variables)) + for _, variable := range variables { + name := strings.TrimSpace(variable.Name) + if name == "" { + continue + } + if _, exists := seen[name]; exists { + continue + } + seen[name] = struct{}{} + result = append(result, ScriptPublicAPIVariable{ + Name: name, + DefaultValue: strings.TrimSpace(variable.DefaultValue), + Description: strings.TrimSpace(variable.Description), + Required: variable.Required, + }) + } + return result +} + +func normalizeScriptPublicAPIMethod(value string) string { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "POST": + return "POST" + default: + return "POST" + } +} + +func normalizeScriptPublicAPIRequestMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "params-only": + return "params-only" + default: + return "standard" + } +} + +func normalizeScriptPublicAPIResponseMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "result-only": + return "result-only" + default: + return "envelope" + } +} + +func normalizeScriptPublicAPITimeout(value int) int { + if value <= 0 { + return defaultScriptPublicAPITimeoutMs + } + if value < 1000 { + return 1000 + } + if value > 30*60*1000 { + return 30 * 60 * 1000 + } + return value +} + +func normalizeScriptPublicAPIPath(value string) string { + normalized := strings.TrimSpace(value) + if normalized == "" { + return "" + } + + normalized = strings.ReplaceAll(normalized, "\\", "/") + lower := strings.ToLower(normalized) + if strings.HasPrefix(lower, scriptPublicAPIBasePath+"/") { + normalized = normalized[len(scriptPublicAPIBasePath)+1:] + } else if strings.HasPrefix(lower, strings.TrimPrefix(scriptPublicAPIBasePath, "/")+"/") { + normalized = normalized[len(strings.TrimPrefix(scriptPublicAPIBasePath, "/"))+1:] + } + + cleaned := path.Clean("/" + normalized) + cleaned = strings.Trim(cleaned, "/") + if cleaned == "" || cleaned == "." { + return "" + } + + parts := strings.Split(cleaned, "/") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if normalizedPart := normalizeScriptPublicAPIPathSegment(part); normalizedPart != "" { + result = append(result, normalizedPart) + } + } + return strings.Join(result, "/") +} + +func normalizeScriptPublicAPIPathSegment(value string) string { + var builder strings.Builder + lastDash := false + + for _, ch := range strings.TrimSpace(value) { + switch { + case ch >= 'a' && ch <= 'z': + builder.WriteRune(ch) + lastDash = false + case ch >= 'A' && ch <= 'Z': + builder.WriteRune(ch + 32) + lastDash = false + case ch >= '0' && ch <= '9': + builder.WriteRune(ch) + lastDash = false + case ch == '-', ch == '_', ch == '.': + builder.WriteRune(ch) + lastDash = false + default: + if !lastDash { + builder.WriteByte('-') + lastDash = true + } + } + } + + return strings.Trim(builder.String(), "-") +} + +func scriptPublicAPIRoute(pathValue string) string { + pathValue = normalizeScriptPublicAPIPath(pathValue) + if pathValue == "" { + return scriptPublicAPIBasePath + } + return scriptPublicAPIBasePath + "/" + pathValue +} + func isSafeScriptID(value string) bool { for _, ch := range value { switch { diff --git a/backend/internal/automation/scripts_store_test.go b/backend/internal/automation/scripts_store_test.go index fbe489b6..a571951d 100644 --- a/backend/internal/automation/scripts_store_test.go +++ b/backend/internal/automation/scripts_store_test.go @@ -179,6 +179,95 @@ func TestScriptStorePersistsTargetConfig(t *testing.T) { } } +func TestScriptStorePersistsPublicAPIConfig(t *testing.T) { + store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) + + saved, err := store.Save(ScriptRecord{ + ID: "public-api-script", + Name: "对外接口脚本", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + PublicAPI: ScriptPublicAPIConfig{ + Enabled: true, + Method: "post", + Path: "/API/Automation/Hooks/Mail/Proton First Message", + RequestMode: "params-only", + ResponseMode: "result-only", + TimeoutMs: 999, + RequestBodyText: "{\n \"recipientQuery\": \"target@example.com\"\n}", + ResponseBodyText: "{\n \"verificationCode\": \"429792\"\n}", + Variables: []ScriptPublicAPIVariable{ + {Name: "recipientQuery", DefaultValue: "target@example.com", Description: "收件人", Required: true}, + {Name: "recipientQuery", DefaultValue: "duplicate"}, + {Name: "senderEmail", DefaultValue: "otp@tm1.openai.com, noreply@tm.openai.com"}, + }, + }, + }) + if err != nil { + t.Fatalf("Save returned error: %v", err) + } + + if !saved.PublicAPI.Enabled { + t.Fatalf("expected public api to be enabled") + } + if saved.PublicAPI.Method != "POST" { + t.Fatalf("expected public api method POST, got %+v", saved.PublicAPI) + } + if saved.PublicAPI.Path != "mail/proton-first-message" { + t.Fatalf("expected normalized public api path, got %+v", saved.PublicAPI) + } + if saved.PublicAPI.TimeoutMs != 1000 { + t.Fatalf("expected normalized public api timeout 1000, got %+v", saved.PublicAPI) + } + if len(saved.PublicAPI.Variables) != 2 || saved.PublicAPI.Variables[0].Name != "recipientQuery" || !saved.PublicAPI.Variables[0].Required { + t.Fatalf("expected normalized public api variables, got %+v", saved.PublicAPI.Variables) + } + + loaded, err := store.Get(saved.ID) + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + if loaded.PublicAPI.Path != "mail/proton-first-message" { + t.Fatalf("unexpected persisted public api path: %+v", loaded.PublicAPI) + } + if loaded.PublicAPI.RequestMode != "params-only" || loaded.PublicAPI.ResponseMode != "result-only" { + t.Fatalf("unexpected persisted public api modes: %+v", loaded.PublicAPI) + } + if len(loaded.PublicAPI.Variables) != 2 || loaded.PublicAPI.Variables[1].Name != "senderEmail" { + t.Fatalf("unexpected persisted public api variables: %+v", loaded.PublicAPI.Variables) + } +} + +func TestScriptStoreRejectsDuplicatePublicAPIPath(t *testing.T) { + store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) + + if _, err := store.Save(ScriptRecord{ + ID: "hook-a", + Name: "Hook A", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + PublicAPI: ScriptPublicAPIConfig{ + Enabled: true, + Path: "mail/proton-first-message", + }, + }); err != nil { + t.Fatalf("initial Save returned error: %v", err) + } + + if _, err := store.Save(ScriptRecord{ + ID: "hook-b", + Name: "Hook B", + EntryFile: "index.cjs", + ScriptText: "module.exports.run = async () => ({ ok: true })", + PublicAPI: ScriptPublicAPIConfig{ + Enabled: false, + Path: "/api/automation/hooks/mail/proton-first-message", + }, + }); err == nil { + t.Fatalf("expected duplicate public api path to fail") + } +} + func TestScriptStoreReadsLegacyManifestAndMigratesOnSave(t *testing.T) { store := NewScriptStore(filepath.Join(t.TempDir(), "data", "automation", "scripts")) scriptDir := filepath.Join(store.rootDir, "legacy-script") diff --git a/backend/internal/automation/scripts_store_validate.go b/backend/internal/automation/scripts_store_validate.go new file mode 100644 index 00000000..32b94568 --- /dev/null +++ b/backend/internal/automation/scripts_store_validate.go @@ -0,0 +1,96 @@ +package automation + +import ( + "fmt" + "strings" +) + +func (s *ScriptStore) validateRecord(record ScriptRecord) error { + if err := validateScriptPublicAPIConfig(record.PublicAPI); err != nil { + return err + } + return s.validatePublicAPIUniqueness(record) +} + +func validateScriptPublicAPIConfig(config ScriptPublicAPIConfig) error { + for _, variable := range config.Variables { + if !isScriptPublicAPIVariableName(variable.Name) { + return fmt.Errorf("public api variable name %q is invalid", variable.Name) + } + } + + if !config.Enabled && strings.TrimSpace(config.Path) == "" { + return nil + } + + if method := normalizeScriptPublicAPIMethod(config.Method); method != "POST" { + return fmt.Errorf("public api method %q is not supported", method) + } + if normalizeScriptPublicAPIRequestMode(config.RequestMode) == "" { + return fmt.Errorf("public api request mode is invalid") + } + if normalizeScriptPublicAPIResponseMode(config.ResponseMode) == "" { + return fmt.Errorf("public api response mode is invalid") + } + + pathValue := normalizeScriptPublicAPIPath(config.Path) + if config.Enabled && pathValue == "" { + return fmt.Errorf("public api path is required when enabled") + } + if pathValue == "" { + return nil + } + + for _, part := range strings.Split(pathValue, "/") { + if strings.TrimSpace(part) == "" { + return fmt.Errorf("public api path is invalid") + } + } + return nil +} + +func isScriptPublicAPIVariableName(value string) bool { + name := strings.TrimSpace(value) + if name == "" { + return false + } + for index, ch := range name { + switch { + case ch >= 'a' && ch <= 'z': + case ch >= 'A' && ch <= 'Z': + case ch == '_': + case index > 0 && ch >= '0' && ch <= '9': + default: + return false + } + } + return true +} + +func (s *ScriptStore) validatePublicAPIUniqueness(record ScriptRecord) error { + targetPath := normalizeScriptPublicAPIPath(record.PublicAPI.Path) + if targetPath == "" { + return nil + } + + items, err := s.List() + if err != nil { + return err + } + + for _, item := range items { + if strings.TrimSpace(item.ID) == strings.TrimSpace(record.ID) { + continue + } + if normalizeScriptPublicAPIPath(item.PublicAPI.Path) != targetPath { + continue + } + + targetName := strings.TrimSpace(item.Name) + if targetName == "" { + targetName = strings.TrimSpace(item.ID) + } + return fmt.Errorf("public api path %q is already used by script %s", scriptPublicAPIRoute(targetPath), targetName) + } + return nil +} diff --git a/backend/internal/automation/task_runner_test.go b/backend/internal/automation/task_runner_test.go index d2d26963..2bfd2ccc 100644 --- a/backend/internal/automation/task_runner_test.go +++ b/backend/internal/automation/task_runner_test.go @@ -135,6 +135,230 @@ module.exports.run = async ({ launch, connect, selector, params, log, artifact } } } +func TestRunScriptTaskOpenPageCreatesFreshPageAndGrantsPermissions(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-script", + "debugPort": 9333, + "cdpUrl": "http://127.0.0.1:9333", + }) + })) + defer server.Close() + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-open-page.cjs") + scriptSource := `module.exports.run = async ({ launch, connect, openPage, selector, params }) => { + const session = await launch({ + selector, + startUrls: [params.url], + skipDefaultStartUrls: true, + }) + + const connection = await connect(session) + const opened = await openPage(connection, { + url: params.url, + timeoutMs: params.timeoutMs || 30000, + permissions: ['notifications'], + }) + + return { + ok: true, + summary: 'openPage helper ok', + url: opened.page.url(), + permissionApplied: opened.permissionResult.applied, + permissionOrigin: opened.permissionResult.origin, + permissionStrategy: opened.permissionResult.strategy || '', + reusedPage: opened.reusedPage, + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:open-page", + ScriptPath: scriptPath, + Selector: map[string]any{"code": "BUYER_001"}, + Params: map[string]any{"url": "https://example.com/inbox", "timeoutMs": 30000}, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } + + parsed := map[string]any{} + if err := json.Unmarshal([]byte(result.ResultText), &parsed); err != nil { + t.Fatalf("parse result text failed: %v result=%s", err, result.ResultText) + } + if nested, ok := parsed["result"].(map[string]any); ok && len(nested) > 0 { + parsed = nested + } + if parsed["permissionApplied"] != true { + t.Fatalf("expected permissionApplied to be true, got %+v", parsed) + } + if parsed["permissionOrigin"] != "https://example.com" { + t.Fatalf("unexpected permissionOrigin: %+v", parsed) + } + if parsed["reusedPage"] != false { + t.Fatalf("expected reusedPage to be false, got %+v", parsed) + } + if parsed["url"] != "https://example.com/inbox" { + t.Fatalf("unexpected url: %+v", parsed) + } +} + +func TestRunScriptTaskCallPageAPIUsesBrowserContext(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-page-api", + "debugPort": 9333, + "cdpUrl": "http://127.0.0.1:9333", + }) + })) + defer server.Close() + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-page-api.cjs") + scriptSource := `module.exports.run = async ({ useBrowser, callPageAPI, browserFetch, selector, params }) => { + const runtime = await useBrowser({ + selector, + startUrls: [params.url], + skipDefaultStartUrls: true, + url: params.url, + reuseCurrentPage: true, + timeoutMs: 30000, + }) + + const created = await callPageAPI(runtime, { + url: '/api/order/create', + method: 'POST', + query: { + source: 'automation', + tag: ['a', 'b'], + }, + headers: { + 'X-Test': 'page-api', + }, + json: { + skuId: params.skuId, + count: 2, + }, + }) + const ping = await browserFetch(runtime.page, '/api/ping', { method: 'GET' }) + + return { + ok: true, + summary: 'page api helper ok', + status: created.status, + requestUrl: created.json.url, + method: created.json.method, + credentials: created.json.credentials, + contentType: created.json.headers['Content-Type'], + testHeader: created.json.headers['X-Test'], + requestBody: created.json.body, + pingMethod: ping.json.method, + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:page-api", + ScriptPath: scriptPath, + Selector: map[string]any{"code": "BUYER_001"}, + Params: map[string]any{"url": "https://example.com/app", "skuId": "sku-123"}, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } + + parsed := map[string]any{} + if err := json.Unmarshal([]byte(result.ResultText), &parsed); err != nil { + t.Fatalf("parse result text failed: %v result=%s", err, result.ResultText) + } + if nested, ok := parsed["result"].(map[string]any); ok && len(nested) > 0 { + parsed = nested + } + if parsed["status"] != float64(201) { + t.Fatalf("unexpected status: %+v", parsed) + } + if parsed["method"] != "POST" || parsed["pingMethod"] != "GET" { + t.Fatalf("unexpected methods: %+v", parsed) + } + if parsed["credentials"] != "include" { + t.Fatalf("expected credentials=include, got %+v", parsed) + } + if parsed["contentType"] != "application/json" || parsed["testHeader"] != "page-api" { + t.Fatalf("unexpected headers: %+v", parsed) + } + if !strings.Contains(fmt.Sprint(parsed["requestUrl"]), "/api/order/create?source=automation&tag=a&tag=b") { + t.Fatalf("unexpected requestUrl: %+v", parsed) + } + if !strings.Contains(fmt.Sprint(parsed["requestBody"]), `"skuId":"sku-123"`) { + t.Fatalf("unexpected requestBody: %+v", parsed) + } +} + func TestRunScriptTaskLaunchFiltersNonLaunchParams(t *testing.T) { nodeExecPath := lookupNodeExecutable(t) @@ -167,6 +391,8 @@ func TestRunScriptTaskLaunchFiltersNonLaunchParams(t *testing.T) { Tags []string `json:"tags"` GroupID string `json:"groupId"` MatchMode string `json:"matchMode"` + ProxyID string `json:"proxyId"` + ProxyConfig string `json:"proxyConfig"` Selector map[string]any `json:"selector"` LaunchArgs []string `json:"launchArgs"` StartURLs []string `json:"startUrls"` @@ -244,6 +470,103 @@ func TestRunScriptTaskLaunchFiltersNonLaunchParams(t *testing.T) { if receivedBody.Keyword != "" { t.Fatalf("expected non-launch params to be filtered, got keyword=%q", receivedBody.Keyword) } + if receivedBody.ProxyID != "" || receivedBody.ProxyConfig != "" { + t.Fatalf("expected proxy launch params to be empty, got %+v", receivedBody) + } +} + +func TestRunScriptTaskLaunchPassesTemporaryProxyParams(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + type launchRequestPayload struct { + ProxyID string `json:"proxyId"` + ProxyConfig string `json:"proxyConfig"` + SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"` + } + + receivedBody := launchRequestPayload{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + if r.URL.Path != "/api/launch" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&receivedBody); err != nil { + t.Fatalf("decode launch request body failed: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-script", + "debugPort": 9333, + "cdpUrl": "http://127.0.0.1:9333", + }) + })) + defer server.Close() + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-launch-proxy.cjs") + scriptSource := `module.exports.run = async ({ launch }) => { + await launch({ + proxyId: 'proxy-picked', + proxyConfig: 'socks5://127.0.0.1:1080', + skipDefaultStartUrls: true, + }) + + return { + ok: true, + summary: '脚本执行成功', + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:launch-proxy", + ScriptPath: scriptPath, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } + if receivedBody.ProxyID != "proxy-picked" { + t.Fatalf("expected proxyId to be forwarded, got %+v", receivedBody) + } + if receivedBody.ProxyConfig != "socks5://127.0.0.1:1080" { + t.Fatalf("expected proxyConfig to be forwarded, got %+v", receivedBody) + } + if !receivedBody.SkipDefaultStartURLs { + t.Fatalf("expected skipDefaultStartUrls to stay true, got %+v", receivedBody) + } } func TestRunScriptTaskFallsBackToLaunchBaseURLWhenSessionEndpointIsInvalid(t *testing.T) { @@ -395,6 +718,71 @@ func TestRunScriptTaskClosesBrowserConnections(t *testing.T) { } } +func TestRunScriptTaskConnectHonorsPerCallTimeout(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModuleWithExpectedConnectTimeout(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion, 47000); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "profileId": "profile-timeout", + "debugPort": 9333, + "cdpUrl": "http://127.0.0.1:9333", + }) + })) + defer server.Close() + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-connect-timeout.cjs") + scriptSource := `module.exports.run = async ({ launch, connect, selector }) => { + const session = await launch({ selector }) + const connection = await connect(session, { timeoutMs: 47000 }) + + return { + ok: true, + summary: '脚本执行成功', + connectedEndpoint: connection.session.cdpUrl, + } +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + result, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:connect-timeout", + ScriptPath: scriptPath, + Selector: map[string]any{"code": "DEMO_READY"}, + LaunchBaseURL: server.URL, + }) + if err != nil { + t.Fatalf("RunScriptTask returned error: %v", err) + } + if !result.OK { + t.Fatalf("expected script task to succeed, got %+v", result) + } +} + func TestRunScriptTaskTerminatesHungScriptOnTimeout(t *testing.T) { nodeExecPath := lookupNodeExecutable(t) @@ -488,6 +876,61 @@ func writeMockPlaywrightModuleWithPersistentConnection(runtimeDir, version, expe return writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint, true) } +func writeMockPlaywrightModuleWithExpectedConnectTimeout(runtimeDir, version string, expectedConnectTimeout int) error { + moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + return err + } + + packageJSON := fmt.Sprintf("{\"name\":\"playwright-core\",\"version\":\"%s\",\"main\":\"index.js\"}", version) + if err := os.WriteFile(filepath.Join(moduleDir, "package.json"), []byte(packageJSON), 0o644); err != nil { + return err + } + + indexJS := fmt.Sprintf(`const expectedConnectTimeout = %d; + +const context = { + async grantPermissions() {}, + async newPage() { + return { + async goto() {}, + async bringToFront() {}, + async waitForLoadState() {}, + async waitForTimeout() {}, + async close() {}, + isClosed() { + return false; + }, + async title() { + return 'Mock Page Title'; + }, + url() { + return 'about:blank'; + }, + }; + }, + pages() { + return []; + }, +}; + +exports.chromium = { + async connectOverCDP(endpoint, options = {}) { + if (options.timeout !== expectedConnectTimeout) { + throw new Error('unexpected connect timeout: ' + String(options.timeout)); + } + return { + contexts() { + return [context]; + }, + async close() {}, + }; + }, +}; +`, expectedConnectTimeout) + return os.WriteFile(filepath.Join(moduleDir, "index.js"), []byte(indexJS), 0o644) +} + func writeMockPlaywrightModuleWithOptions(runtimeDir, version, expectedEndpoint string, persistentConnection bool) error { moduleDir := filepath.Join(runtimeDir, "node_modules", "playwright-core") if err := os.MkdirAll(moduleDir, 0o755); err != nil { @@ -519,21 +962,58 @@ function createPage() { async goto(url) { currentURL = url; }, + async bringToFront() {}, + async waitForLoadState() {}, async waitForTimeout() {}, async screenshot(options) { fs.writeFileSync(options.path, 'mock-screenshot'); }, + async evaluate(fn, arg) { + const previousFetch = global.fetch; + global.fetch = async (url, init = {}) => { + return { + ok: String(init.method || 'GET').toUpperCase() !== 'DELETE', + status: String(init.method || 'GET').toUpperCase() === 'POST' ? 201 : 200, + statusText: String(init.method || 'GET').toUpperCase() === 'DELETE' ? 'Forbidden' : 'OK', + url: String(url), + headers: { + forEach(callback) { + callback('application/json', 'content-type'); + }, + }, + async text() { + return JSON.stringify({ + ok: true, + url: String(url), + method: String(init.method || 'GET').toUpperCase(), + credentials: init.credentials || '', + headers: init.headers || {}, + body: init.body || '', + }); + }, + }; + }; + try { + return await fn(arg); + } finally { + global.fetch = previousFetch; + } + }, async title() { return 'Mock Page Title'; }, url() { return currentURL; }, + isClosed() { + return false; + }, async close() {}, }; } const context = { + async grantPermissions() {}, async newPage() { return createPage(); }, diff --git a/backend/internal/browser/profile_copy.go b/backend/internal/browser/profile_copy.go index 4bd7fc37..6256244f 100644 --- a/backend/internal/browser/profile_copy.go +++ b/backend/internal/browser/profile_copy.go @@ -3,14 +3,62 @@ package browser import ( "ant-chrome/backend/internal/logger" "fmt" + "regexp" "strings" "time" "github.com/google/uuid" ) +const ( + copyModeRegular = "regular" + copyModeAutoFingerprint = "auto_fingerprint" + + copyAutomationTargetSeed = "seed" + copyAutomationTargetIdentity = "identity" + copyAutomationTargetLocale = "locale" + copyAutomationTargetScreen = "screen" + copyAutomationTargetHardware = "hardware" + copyAutomationTargetRender = "render" + copyAutomationTargetFonts = "fonts" + copyAutomationTargetNetwork = "network" + copyAutomationTargetDevices = "devices" +) + +var profileCopyNameSuffixPattern = regexp.MustCompile(`[[:space:]]*(?:\([[:space:]]*副本[[:space:]]*\)|(副本))[[:space:]]*(?:[0-9]{12})?$`) + // Copy 复制实例配置(除指纹参数外全部复制,指纹使用默认值生成新种子) func (m *Manager) Copy(profileId string, newName string) (*Profile, error) { + return m.copyProfile(profileId, newName, func(*Profile) []string { + return append([]string{}, m.Config.Browser.DefaultFingerprintArgs...) + }) +} + +// CopyWithMode 按模式复制实例配置。 +// regular: 保留原实例指纹参数。 +// auto_fingerprint: 保留原指纹模板,但移除显式种子,让新实例自动生成新种子。 +func (m *Manager) CopyWithMode(profileId string, newName string, mode string) (*Profile, error) { + return m.CopyWithOptions(profileId, newName, ProfileCopyOptions{Mode: mode}) +} + +// CopyWithOptions 按结构化选项复制实例配置。 +func (m *Manager) CopyWithOptions(profileId string, newName string, options ProfileCopyOptions) (*Profile, error) { + normalizedMode := normalizeCopyMode(options.Mode) + normalizedTargets, err := normalizeCopyAutomationTargets(options.AutomationTargets) + if err != nil { + return nil, err + } + return m.copyProfile(profileId, newName, func(src *Profile) []string { + switch normalizedMode { + case copyModeRegular: + return append([]string{}, src.FingerprintArgs...) + default: + return buildAutoFingerprintArgs(src.FingerprintArgs, m.Config.Browser.DefaultFingerprintArgs, normalizedTargets) + } + }) +} + +func (m *Manager) copyProfile(profileId string, newName string, fingerprintResolver func(*Profile) []string) (*Profile, error) { log := logger.New("Browser") m.InitData() m.Mutex.Lock() @@ -27,12 +75,13 @@ func (m *Manager) Copy(profileId string, newName string) (*Profile, error) { return nil, fmt.Errorf("profile not found") } - now := time.Now().Format(time.RFC3339) + now := time.Now() + nowText := now.Format(time.RFC3339) newId := uuid.NewString() profileName := strings.TrimSpace(newName) if profileName == "" { - profileName = src.ProfileName + " (副本)" + profileName = buildProfileCopyName(src.ProfileName, now) } profile := &Profile{ @@ -40,7 +89,7 @@ func (m *Manager) Copy(profileId string, newName string) (*Profile, error) { ProfileName: profileName, UserDataDir: newId, CoreId: normalizeProfileCoreID(src.CoreId), - FingerprintArgs: append([]string{}, m.Config.Browser.DefaultFingerprintArgs...), + FingerprintArgs: fingerprintResolver(src), ProxyId: src.ProxyId, ProxyConfig: src.ProxyConfig, ProxyBindSourceID: src.ProxyBindSourceID, @@ -55,8 +104,8 @@ func (m *Manager) Copy(profileId string, newName string) (*Profile, error) { DebugPort: 0, Pid: 0, LastError: "", - CreatedAt: now, - UpdatedAt: now, + CreatedAt: nowText, + UpdatedAt: nowText, } m.Profiles[newId] = profile @@ -69,3 +118,243 @@ func (m *Manager) Copy(profileId string, newName string) (*Profile, error) { m.ensureProfileLaunchCode(profile) return profile, nil } + +func buildProfileCopyName(sourceName string, now time.Time) string { + baseName := normalizeProfileCopyBaseName(sourceName) + if baseName == "" { + baseName = "未命名实例" + } + return fmt.Sprintf("%s(副本)%s", baseName, now.Format("060102150405")) +} + +func normalizeProfileCopyBaseName(sourceName string) string { + trimmed := strings.TrimSpace(sourceName) + if trimmed == "" { + return "" + } + + baseName := trimmed + for baseName != "" { + nextName := strings.TrimSpace(profileCopyNameSuffixPattern.ReplaceAllString(baseName, "")) + if nextName == baseName { + break + } + if nextName == "" { + return trimmed + } + baseName = nextName + } + + return baseName +} + +func normalizeCopyMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case copyModeRegular: + return copyModeRegular + default: + return copyModeAutoFingerprint + } +} + +func normalizeCopyAutomationTargets(targets []string) ([]string, error) { + if len(targets) == 0 { + return defaultCopyAutomationTargets(), nil + } + + normalized := make([]string, 0, len(targets)) + seen := make(map[string]struct{}, len(targets)) + unknown := make([]string, 0) + + for _, target := range targets { + value := strings.ToLower(strings.TrimSpace(target)) + if value == "" { + continue + } + if _, ok := copyAutomationTargetArgPrefixes()[value]; !ok { + unknown = append(unknown, value) + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + + if len(unknown) > 0 { + return nil, fmt.Errorf("复制实例失败:包含不支持的自动化指纹项(%s)。", strings.Join(unknown, ", ")) + } + if len(normalized) == 0 { + return nil, fmt.Errorf("复制实例失败:请至少选择一个自动化指纹项。") + } + return normalized, nil +} + +func defaultCopyAutomationTargets() []string { + return []string{copyAutomationTargetSeed} +} + +func buildAutoFingerprintArgs(sourceArgs []string, defaultArgs []string, targets []string) []string { + base := sourceArgs + if len(base) == 0 { + base = defaultArgs + } + if len(targets) == 0 { + targets = defaultCopyAutomationTargets() + } + return applyCopyAutomationTargets(base, defaultArgs, targets) +} + +func applyCopyAutomationTargets(baseArgs []string, defaultArgs []string, targets []string) []string { + if len(baseArgs) == 0 { + return []string{} + } + + targetSet := make(map[string]struct{}, len(targets)) + for _, target := range targets { + targetSet[target] = struct{}{} + } + + defaultArgsByKey := mapArgsByKey(defaultArgs) + outputArgs := make([]string, 0, len(baseArgs)) + outputKeys := make(map[string]struct{}, len(baseArgs)) + + for _, arg := range baseArgs { + trimmed := strings.TrimSpace(arg) + if trimmed == "" { + continue + } + + key, ok := copyFingerprintArgKey(trimmed) + if !ok { + outputArgs = append(outputArgs, trimmed) + continue + } + + target, ok := copyFingerprintArgTarget(key) + if !ok { + outputArgs = append(outputArgs, trimmed) + outputKeys[key] = struct{}{} + continue + } + + if _, targeted := targetSet[target]; !targeted { + outputArgs = append(outputArgs, trimmed) + outputKeys[key] = struct{}{} + continue + } + + if replacement, ok := defaultArgsByKey[key]; ok { + if _, exists := outputKeys[key]; !exists { + outputArgs = append(outputArgs, replacement) + outputKeys[key] = struct{}{} + } + } + } + + for _, arg := range defaultArgs { + trimmed := strings.TrimSpace(arg) + if trimmed == "" { + continue + } + key, ok := copyFingerprintArgKey(trimmed) + if !ok { + continue + } + target, ok := copyFingerprintArgTarget(key) + if !ok { + continue + } + if _, targeted := targetSet[target]; !targeted { + continue + } + if _, exists := outputKeys[key]; exists { + continue + } + outputArgs = append(outputArgs, trimmed) + outputKeys[key] = struct{}{} + } + + return outputArgs +} + +func mapArgsByKey(args []string) map[string]string { + out := make(map[string]string, len(args)) + for _, arg := range args { + trimmed := strings.TrimSpace(arg) + if trimmed == "" { + continue + } + key, ok := copyFingerprintArgKey(trimmed) + if !ok { + continue + } + out[key] = trimmed + } + return out +} + +func copyFingerprintArgKey(arg string) (string, bool) { + trimmed := strings.TrimSpace(arg) + if trimmed == "" || !strings.HasPrefix(trimmed, "--") { + return "", false + } + eqIdx := strings.Index(trimmed, "=") + if eqIdx <= 0 { + return strings.ToLower(trimmed), true + } + return strings.ToLower(trimmed[:eqIdx]), true +} + +func copyFingerprintArgTarget(key string) (string, bool) { + for target, prefixes := range copyAutomationTargetArgPrefixes() { + for _, prefix := range prefixes { + if key == prefix { + return target, true + } + } + } + return "", false +} + +func copyAutomationTargetArgPrefixes() map[string][]string { + return map[string][]string{ + copyAutomationTargetSeed: { + "--fingerprint", + }, + copyAutomationTargetIdentity: { + "--fingerprint-brand", + "--fingerprint-platform", + }, + copyAutomationTargetLocale: { + "--lang", + "--timezone", + }, + copyAutomationTargetScreen: { + "--window-size", + "--fingerprint-color-depth", + }, + copyAutomationTargetHardware: { + "--fingerprint-hardware-concurrency", + "--fingerprint-device-memory", + }, + copyAutomationTargetRender: { + "--fingerprint-canvas-noise", + "--fingerprint-webgl-vendor", + "--fingerprint-webgl-renderer", + "--fingerprint-audio-noise", + }, + copyAutomationTargetFonts: { + "--fingerprint-fonts", + }, + copyAutomationTargetNetwork: { + "--webrtc-ip-handling-policy", + "--fingerprint-do-not-track", + }, + copyAutomationTargetDevices: { + "--fingerprint-media-devices", + "--fingerprint-touch-points", + }, + } +} diff --git a/backend/internal/browser/profile_copy_test.go b/backend/internal/browser/profile_copy_test.go new file mode 100644 index 00000000..d33a3471 --- /dev/null +++ b/backend/internal/browser/profile_copy_test.go @@ -0,0 +1,211 @@ +package browser + +import ( + "ant-chrome/backend/internal/config" + "reflect" + "regexp" + "strings" + "testing" +) + +func TestCopyWithModeRegularKeepsFingerprintArgs(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Browser.DefaultFingerprintArgs = []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=windows"} + mgr := NewManager(cfg, t.TempDir()) + + source := &Profile{ + ProfileId: "src-regular", + ProfileName: "源实例", + UserDataDir: "src-regular", + CoreId: "core-1", + FingerprintArgs: []string{"--fingerprint=12345", "--fingerprint-brand=Edge", "--fingerprint-platform=linux"}, + ProxyId: "proxy-1", + ProxyConfig: "socks5://127.0.0.1:1080", + LaunchArgs: []string{"--disable-sync"}, + Tags: []string{"tag-1"}, + Keywords: []string{"kw-1"}, + } + mgr.Profiles[source.ProfileId] = source + + copied, err := mgr.CopyWithMode(source.ProfileId, "源实例-副本", copyModeRegular) + if err != nil { + t.Fatalf("CopyWithMode regular failed: %v", err) + } + + if !reflect.DeepEqual(copied.FingerprintArgs, source.FingerprintArgs) { + t.Fatalf("expected regular copy to preserve fingerprint args, got=%v want=%v", copied.FingerprintArgs, source.FingerprintArgs) + } +} + +func TestCopyWithModeAutoFingerprintRemovesSeedButKeepsTemplate(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Browser.DefaultFingerprintArgs = []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=windows"} + mgr := NewManager(cfg, t.TempDir()) + + source := &Profile{ + ProfileId: "src-auto", + ProfileName: "源实例", + UserDataDir: "src-auto", + FingerprintArgs: []string{"--fingerprint=67890", "--fingerprint-brand=Edge", "--fingerprint-platform=linux", "--lang=en-US"}, + } + mgr.Profiles[source.ProfileId] = source + + copied, err := mgr.CopyWithMode(source.ProfileId, "源实例-自动指纹", copyModeAutoFingerprint) + if err != nil { + t.Fatalf("CopyWithMode auto_fingerprint failed: %v", err) + } + + if hasFingerprintSeedArg(copied.FingerprintArgs) { + t.Fatalf("expected auto fingerprint copy to remove explicit seed, got=%v", copied.FingerprintArgs) + } + want := []string{"--fingerprint-brand=Edge", "--fingerprint-platform=linux", "--lang=en-US"} + if !reflect.DeepEqual(copied.FingerprintArgs, want) { + t.Fatalf("expected auto fingerprint copy to keep source template, got=%v want=%v", copied.FingerprintArgs, want) + } +} + +func TestCopyWithOptionsAutoFingerprintReplacesSelectedGroups(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Browser.DefaultFingerprintArgs = []string{ + "--fingerprint-brand=Chrome", + "--fingerprint-platform=windows", + "--lang=zh-CN", + "--timezone=Asia/Shanghai", + "--webrtc-ip-handling-policy=disable_non_proxied_udp", + "--fingerprint-do-not-track=false", + "--fingerprint-media-devices=1,1,0", + "--fingerprint-touch-points=0", + } + mgr := NewManager(cfg, t.TempDir()) + + source := &Profile{ + ProfileId: "src-auto-groups", + ProfileName: "源实例", + UserDataDir: "src-auto-groups", + FingerprintArgs: []string{ + "--fingerprint=67890", + "--fingerprint-brand=Edge", + "--fingerprint-platform=linux", + "--lang=en-US", + "--timezone=America/New_York", + "--window-size=1440,900", + "--fingerprint-hardware-concurrency=16", + "--fingerprint-device-memory=16", + "--fingerprint-canvas-noise=true", + "--fingerprint-fonts=Arial,Helvetica", + "--webrtc-ip-handling-policy=default_public_interface_only", + "--fingerprint-do-not-track=true", + "--fingerprint-media-devices=2,1,0", + "--fingerprint-touch-points=5", + }, + } + mgr.Profiles[source.ProfileId] = source + + copied, err := mgr.CopyWithOptions(source.ProfileId, "源实例-自动化指纹", ProfileCopyOptions{ + Mode: copyModeAutoFingerprint, + AutomationTargets: []string{ + copyAutomationTargetSeed, + copyAutomationTargetIdentity, + copyAutomationTargetLocale, + copyAutomationTargetNetwork, + copyAutomationTargetDevices, + }, + }) + if err != nil { + t.Fatalf("CopyWithOptions auto_fingerprint failed: %v", err) + } + + want := []string{ + "--fingerprint-brand=Chrome", + "--fingerprint-platform=windows", + "--lang=zh-CN", + "--timezone=Asia/Shanghai", + "--window-size=1440,900", + "--fingerprint-hardware-concurrency=16", + "--fingerprint-device-memory=16", + "--fingerprint-canvas-noise=true", + "--fingerprint-fonts=Arial,Helvetica", + "--webrtc-ip-handling-policy=disable_non_proxied_udp", + "--fingerprint-do-not-track=false", + "--fingerprint-media-devices=1,1,0", + "--fingerprint-touch-points=0", + } + if !reflect.DeepEqual(copied.FingerprintArgs, want) { + t.Fatalf("expected auto fingerprint copy to replace selected groups, got=%v want=%v", copied.FingerprintArgs, want) + } +} + +func TestCopyKeepsLegacyDefaultFingerprintBehavior(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Browser.DefaultFingerprintArgs = []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=windows"} + mgr := NewManager(cfg, t.TempDir()) + + source := &Profile{ + ProfileId: "src-legacy", + ProfileName: "源实例", + UserDataDir: "src-legacy", + FingerprintArgs: []string{"--fingerprint=99999", "--fingerprint-brand=Edge", "--fingerprint-platform=linux"}, + } + mgr.Profiles[source.ProfileId] = source + + copied, err := mgr.Copy(source.ProfileId, "源实例-旧复制") + if err != nil { + t.Fatalf("Copy failed: %v", err) + } + + if !reflect.DeepEqual(copied.FingerprintArgs, cfg.Browser.DefaultFingerprintArgs) { + t.Fatalf("expected legacy copy to use default fingerprint args, got=%v want=%v", copied.FingerprintArgs, cfg.Browser.DefaultFingerprintArgs) + } +} + +func TestCopyBlankNameUsesTimestampedCopyName(t *testing.T) { + cfg := config.DefaultConfig() + mgr := NewManager(cfg, t.TempDir()) + + source := &Profile{ + ProfileId: "src-copy-name", + ProfileName: "邮箱测试 (副本)", + UserDataDir: "src-copy-name", + } + mgr.Profiles[source.ProfileId] = source + + copied, err := mgr.Copy(source.ProfileId, "") + if err != nil { + t.Fatalf("Copy failed: %v", err) + } + + matched := regexp.MustCompile(`^邮箱测试(副本)\d{12}$`).MatchString(copied.ProfileName) + if !matched { + t.Fatalf("expected timestamped copy name without duplicated suffix, got=%q", copied.ProfileName) + } +} + +func TestCopyWithOptionsRejectsUnknownAutomationTarget(t *testing.T) { + cfg := config.DefaultConfig() + mgr := NewManager(cfg, t.TempDir()) + + source := &Profile{ + ProfileId: "src-invalid-target", + ProfileName: "源实例", + UserDataDir: "src-invalid-target", + FingerprintArgs: []string{"--fingerprint=12345", "--fingerprint-brand=Chrome"}, + } + mgr.Profiles[source.ProfileId] = source + + _, err := mgr.CopyWithOptions(source.ProfileId, "源实例-失败", ProfileCopyOptions{ + Mode: copyModeAutoFingerprint, + AutomationTargets: []string{"unknown_target"}, + }) + if err == nil { + t.Fatal("expected CopyWithOptions to reject unknown automation target") + } +} + +func hasFingerprintSeedArg(args []string) bool { + for _, arg := range args { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(arg)), "--fingerprint=") { + return true + } + } + return false +} diff --git a/backend/internal/browser/session_restore.go b/backend/internal/browser/session_restore.go index 112ff71c..8ba24dfe 100644 --- a/backend/internal/browser/session_restore.go +++ b/backend/internal/browser/session_restore.go @@ -24,15 +24,25 @@ func ClearSessionRestoreData(userDataDir string) error { } profileDir := filepath.Join(rootDir, "Default") + if _, err := os.Stat(profileDir); os.IsNotExist(err) { + return nil + } else if err != nil { + return fmt.Errorf("stat profile dir: %w", err) + } + sessionsDir := filepath.Join(profileDir, "Sessions") var errs []error - if err := os.RemoveAll(sessionsDir); err != nil && !os.IsNotExist(err) { - errs = append(errs, fmt.Errorf("remove sessions dir: %w", err)) - } else if err == nil { - if mkErr := os.MkdirAll(sessionsDir, 0o755); mkErr != nil { - errs = append(errs, fmt.Errorf("recreate sessions dir: %w", mkErr)) + if _, statErr := os.Stat(sessionsDir); statErr == nil { + if err := os.RemoveAll(sessionsDir); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Errorf("remove sessions dir: %w", err)) + } else if err == nil { + if mkErr := os.MkdirAll(sessionsDir, 0o755); mkErr != nil { + errs = append(errs, fmt.Errorf("recreate sessions dir: %w", mkErr)) + } } + } else if statErr != nil && !os.IsNotExist(statErr) { + errs = append(errs, fmt.Errorf("stat sessions dir: %w", statErr)) } for _, name := range sessionRestoreLegacyFiles { diff --git a/backend/internal/browser/session_restore_test.go b/backend/internal/browser/session_restore_test.go index 516f7cad..27b2bcf9 100644 --- a/backend/internal/browser/session_restore_test.go +++ b/backend/internal/browser/session_restore_test.go @@ -52,3 +52,31 @@ func TestClearSessionRestoreDataRemovesSessionArtifactsOnly(t *testing.T) { t.Fatalf("Preferences 不应被删除: %v", err) } } +func TestClearSessionRestoreDataSkipsMissingProfileDir(t *testing.T) { + t.Parallel() + + userDataDir := t.TempDir() + if err := ClearSessionRestoreData(userDataDir); err != nil { + t.Fatalf("ClearSessionRestoreData 返回错误: %v", err) + } + if _, err := os.Stat(filepath.Join(userDataDir, "Default")); !os.IsNotExist(err) { + t.Fatalf("缺少会话数据时不应创建 Default 目录: err=%v", err) + } +} + +func TestClearSessionRestoreDataDoesNotCreateMissingSessionsDir(t *testing.T) { + t.Parallel() + + userDataDir := t.TempDir() + profileDir := filepath.Join(userDataDir, "Default") + if err := os.MkdirAll(profileDir, 0o755); err != nil { + t.Fatalf("创建 profile 目录失败: %v", err) + } + + if err := ClearSessionRestoreData(userDataDir); err != nil { + t.Fatalf("ClearSessionRestoreData 返回错误: %v", err) + } + if _, err := os.Stat(filepath.Join(profileDir, "Sessions")); !os.IsNotExist(err) { + t.Fatalf("缺少会话数据时不应创建 Sessions 目录: err=%v", err) + } +} diff --git a/backend/internal/browser/types.go b/backend/internal/browser/types.go index 14a3b349..b5022b14 100644 --- a/backend/internal/browser/types.go +++ b/backend/internal/browser/types.go @@ -51,6 +51,12 @@ type ProfileInput struct { GroupId string `json:"groupId"` // 所属分组ID } +// ProfileCopyOptions 复制实例时的附加选项。 +type ProfileCopyOptions struct { + Mode string `json:"mode"` + AutomationTargets []string `json:"automationTargets"` +} + // Tab 浏览器标签页 type Tab struct { TabId string `json:"tabId"` @@ -65,6 +71,7 @@ type Settings struct { DefaultFingerprintArgs []string `json:"defaultFingerprintArgs"` DefaultLaunchArgs []string `json:"defaultLaunchArgs"` DefaultStartURLs []string `json:"defaultStartUrls"` + LightStartEnabled bool `json:"lightStartEnabled"` RestoreLastSession bool `json:"restoreLastSession"` StartReadyTimeoutMs int `json:"startReadyTimeoutMs"` StartStableWindowMs int `json:"startStableWindowMs"` diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index af1b5921..95c91820 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -71,6 +71,7 @@ type AutomationConfig struct { HeadlessDefault bool `yaml:"headless_default,omitempty"` KeepRuntimeOnDisable bool `yaml:"keep_runtime_on_disable,omitempty"` AllowTypeScriptBuild bool `yaml:"allow_typescript_build,omitempty"` + ArtifactsDir string `yaml:"artifacts_dir,omitempty"` NodeSource string `yaml:"node_source,omitempty"` SystemNodePath string `yaml:"system_node_path,omitempty"` NodeVersion string `yaml:"node_version,omitempty"` @@ -145,6 +146,7 @@ type BrowserConfig struct { DefaultFingerprintArgs []string `yaml:"default_fingerprint_args"` DefaultLaunchArgs []string `yaml:"default_launch_args"` DefaultStartURLs []string `yaml:"default_start_urls"` + LightStartEnabled *bool `yaml:"light_start_enabled,omitempty"` RestoreLastSession bool `yaml:"restore_last_session"` StartReadyTimeoutMs int `yaml:"start_ready_timeout_ms,omitempty"` StartStableWindowMs int `yaml:"start_stable_window_ms,omitempty"` diff --git a/backend/internal/config/config_defaults.go b/backend/internal/config/config_defaults.go index 83327668..67111b24 100644 --- a/backend/internal/config/config_defaults.go +++ b/backend/internal/config/config_defaults.go @@ -110,6 +110,9 @@ func normalizeConfig(config *Config) { } else if isLegacyVerificationStartURLs(config.Browser.DefaultStartURLs) { config.Browser.DefaultStartURLs = []string{} } + if config.Browser.LightStartEnabled == nil { + config.Browser.LightStartEnabled = defaultConfig.Browser.LightStartEnabled + } if config.Browser.StartReadyTimeoutMs <= 0 { config.Browser.StartReadyTimeoutMs = defaultConfig.Browser.StartReadyTimeoutMs } @@ -154,6 +157,7 @@ func normalizeConfig(config *Config) { !config.Automation.KeepRuntimeOnDisable && strings.TrimSpace(config.Automation.InstallPolicy) == "" && strings.TrimSpace(config.Automation.RuntimeVersion) == "" && + strings.TrimSpace(config.Automation.ArtifactsDir) == "" && strings.TrimSpace(config.Automation.NodeSource) == "" && strings.TrimSpace(config.Automation.SystemNodePath) == "" && strings.TrimSpace(config.Automation.NodeVersion) == "" && @@ -170,6 +174,11 @@ func normalizeConfig(config *Config) { if strings.TrimSpace(config.Automation.PlaywrightCoreVersion) == "" { config.Automation.PlaywrightCoreVersion = defaultConfig.Automation.PlaywrightCoreVersion } + if strings.TrimSpace(config.Automation.ArtifactsDir) == "" { + config.Automation.ArtifactsDir = defaultConfig.Automation.ArtifactsDir + } else { + config.Automation.ArtifactsDir = strings.TrimSpace(config.Automation.ArtifactsDir) + } config.Automation.NodeSource = normalizeAutomationNodeSource(config.Automation.NodeSource) config.Automation.SystemNodePath = strings.TrimSpace(config.Automation.SystemNodePath) if strings.TrimSpace(config.Automation.RuntimeVersion) == "" { @@ -233,6 +242,7 @@ func DefaultConfig() *Config { DefaultFingerprintArgs: defaultFingerprintArgsForOS(goruntime.GOOS), DefaultLaunchArgs: []string{"--disable-sync", "--no-first-run"}, DefaultStartURLs: DefaultBrowserStartURLs(), + LightStartEnabled: boolPtr(true), RestoreLastSession: false, StartReadyTimeoutMs: 3000, StartStableWindowMs: 1200, @@ -280,6 +290,7 @@ func DefaultConfig() *Config { HeadlessDefault: false, KeepRuntimeOnDisable: true, AllowTypeScriptBuild: false, + ArtifactsDir: "data/automation/artifacts", NodeSource: DefaultAutomationNodeSource, SystemNodePath: "", NodeVersion: DefaultAutomationNodeVersion, @@ -312,3 +323,8 @@ func normalizeAutomationNodeSource(value string) string { return AutomationNodeSourceAuto } } + +func boolPtr(value bool) *bool { + v := value + return &v +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 70823124..355ab6f0 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -233,6 +233,7 @@ automation: headless_default: true keep_runtime_on_disable: false allow_typescript_build: true + artifacts_dir: D:/automation-outputs node_source: system system_node_path: C:/tools/node/node.exe node_version: 22.15.1 @@ -304,6 +305,9 @@ automation: if !cfg.Automation.AllowTypeScriptBuild { t.Fatalf("Automation.AllowTypeScriptBuild 显式 true 被覆盖") } + if cfg.Automation.ArtifactsDir != "D:/automation-outputs" { + t.Fatalf("Automation.ArtifactsDir 显式配置被覆盖: got=%q", cfg.Automation.ArtifactsDir) + } } func TestLoadMigratesLegacyRootLogPath(t *testing.T) { diff --git a/backend/internal/launchcode/automation_api.go b/backend/internal/launchcode/automation_api.go index 9aae627c..a8880d21 100644 --- a/backend/internal/launchcode/automation_api.go +++ b/backend/internal/launchcode/automation_api.go @@ -11,9 +11,15 @@ import ( "ant-chrome/backend/internal/automation" ) +const ( + automationMinTimeoutMs = 1000 + automationMaxTimeoutMs = 30 * 60 * 1000 +) + type automationScriptRunAPIRequest struct { ScriptID string `json:"scriptId"` Selector json.RawMessage `json:"selector"` + TargetInput json.RawMessage `json:"targetInput"` Params json.RawMessage `json:"params"` UseScriptSelector *bool `json:"useScriptSelector"` UseScriptParams *bool `json:"useScriptParams"` @@ -21,19 +27,20 @@ type automationScriptRunAPIRequest struct { } type automationScriptSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Type string `json:"type"` - Status string `json:"status"` - EntryFile string `json:"entryFile"` - Tags []string `json:"tags"` - Selector map[string]interface{} `json:"selector"` - Params map[string]interface{} `json:"params"` - Notes string `json:"notes"` - TargetConfig automation.ScriptTargetConfig `json:"targetConfig"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Status string `json:"status"` + EntryFile string `json:"entryFile"` + Tags []string `json:"tags"` + Selector map[string]interface{} `json:"selector"` + Params map[string]interface{} `json:"params"` + Notes string `json:"notes"` + TargetConfig automation.ScriptTargetConfig `json:"targetConfig"` + PublicAPI automation.ScriptPublicAPIConfig `json:"publicAPI"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } type automationScriptDetail struct { @@ -45,28 +52,19 @@ type automationScriptDetail struct { func (s *LaunchServer) handleAutomationScripts(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ - "ok": false, - "error": "method not allowed", - }) + writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "") return } lister, ok := s.starter.(AutomationScriptLister) if !ok { - writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ - "ok": false, - "error": "automation script api is unavailable", - }) + writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "") return } items, err := lister.AutomationScriptList() if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ - "ok": false, - "error": err.Error(), - }) + writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "") return } @@ -75,37 +73,27 @@ func (s *LaunchServer) handleAutomationScripts(w http.ResponseWriter, r *http.Re result = append(result, summarizeAutomationScript(item)) } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, - "count": len(result), - "items": result, + writeAutomationAPISuccess(w, http.StatusOK, "", automationAPIListData[automationScriptSummary]{ + Items: result, + Count: len(result), }) } func (s *LaunchServer) handleAutomationScriptByID(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ - "ok": false, - "error": "method not allowed", - }) + writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "") return } scriptID, ok := parseAutomationScriptPathID(r.URL.Path) if !ok { - writeJSON(w, http.StatusNotFound, map[string]interface{}{ - "ok": false, - "error": "script not found", - }) + writeAutomationAPIError(w, http.StatusNotFound, "not_found", "script not found", "") return } getter, ok := s.starter.(AutomationScriptGetter) if !ok { - writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ - "ok": false, - "error": "automation script api is unavailable", - }) + writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "") return } @@ -113,47 +101,31 @@ func (s *LaunchServer) handleAutomationScriptByID(w http.ResponseWriter, r *http if err != nil { message := strings.TrimSpace(err.Error()) if os.IsNotExist(err) { - writeJSON(w, http.StatusNotFound, map[string]interface{}{ - "ok": false, - "error": "script not found", - }) + writeAutomationAPIError(w, http.StatusNotFound, "not_found", "script not found", "") return } if strings.Contains(strings.ToLower(message), "script id is invalid") || strings.Contains(strings.ToLower(message), "script id is required") { - writeJSON(w, http.StatusBadRequest, map[string]interface{}{ - "ok": false, - "error": message, - }) + writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", message, "scriptId") return } - writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ - "ok": false, - "error": message, - }) + writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", message, "") return } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, - "item": detailAutomationScript(*item), + writeAutomationAPISuccess(w, http.StatusOK, "", automationAPIItemData[automationScriptDetail]{ + Item: detailAutomationScript(*item), }) } func (s *LaunchServer) handleAutomationScriptRun(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ - "ok": false, - "error": "method not allowed", - }) + writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "") return } runner, ok := s.starter.(AutomationScriptRunner) if !ok { - writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ - "ok": false, - "error": "automation script api is unavailable", - }) + writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "") return } @@ -161,52 +133,41 @@ func (s *LaunchServer) handleAutomationScriptRun(w http.ResponseWriter, r *http. dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]interface{}{ - "ok": false, - "error": "invalid request body", - }) + writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body", "") return } input, err := normalizeAutomationRunRequest(req) if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]interface{}{ - "ok": false, - "error": err.Error(), - }) + writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", err.Error(), automationRequestErrorField(err)) return } run, err := runner.AutomationScriptRunWithOptions(input) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ - "ok": false, - "error": err.Error(), - }) + writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "") return } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, - "run": run, - }) + data := automationAPIRunData{ + Run: run, + Summary: run.Summary, + } + if result := decodeAutomationRunResult(run.ResultText); result != nil { + data.Result = result + } + writeAutomationAPISuccess(w, http.StatusOK, run.Summary, data) } func (s *LaunchServer) handleAutomationScriptRuns(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ - "ok": false, - "error": "method not allowed", - }) + writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "") return } lister, ok := s.starter.(AutomationScriptRunLister) if !ok { - writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{ - "ok": false, - "error": "automation script api is unavailable", - }) + writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "") return } @@ -225,18 +186,14 @@ func (s *LaunchServer) handleAutomationScriptRuns(w http.ResponseWriter, r *http items, err := lister.AutomationScriptRunList(limit) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]interface{}{ - "ok": false, - "error": err.Error(), - }) + writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "") return } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, - "count": len(items), - "limit": limit, - "items": items, + writeAutomationAPISuccess(w, http.StatusOK, "", automationAPIListData[automation.ScriptRunRecord]{ + Items: items, + Count: len(items), + Limit: limit, }) } @@ -253,6 +210,7 @@ func summarizeAutomationScript(record automation.ScriptRecord) automationScriptS Params: parseJSONObjectText(record.ParamsText), Notes: strings.TrimSpace(record.Notes), TargetConfig: record.TargetConfig, + PublicAPI: record.PublicAPI, CreatedAt: strings.TrimSpace(record.CreatedAt), UpdatedAt: strings.TrimSpace(record.UpdatedAt), } @@ -287,6 +245,10 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio if err != nil { return automation.ScriptRunRequest{}, err } + targetInput, hasTargetInput, err := decodeJSONObjectRaw(req.TargetInput, "targetInput") + if err != nil { + return automation.ScriptRunRequest{}, err + } params, hasParams, err := decodeJSONObjectRaw(req.Params, "params") if err != nil { return automation.ScriptRunRequest{}, err @@ -300,6 +262,9 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio if err != nil { return automation.ScriptRunRequest{}, err } + if err := validateAutomationTimeoutMs(req.TimeoutMs); err != nil { + return automation.ScriptRunRequest{}, err + } selectorText := "" if !useScriptSelector { @@ -322,13 +287,24 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio return automation.ScriptRunRequest{ ScriptID: scriptID, SelectorText: selectorText, + TargetInput: targetInput, ParamsText: paramsText, - UseScriptSelector: useScriptSelector, + UseScriptSelector: useScriptSelector && !hasTargetInput, UseScriptParams: useScriptParams, TimeoutMs: req.TimeoutMs, }, nil } +func validateAutomationTimeoutMs(timeoutMs int) error { + if timeoutMs == 0 { + return nil + } + if timeoutMs < automationMinTimeoutMs || timeoutMs > automationMaxTimeoutMs { + return badAutomationRequest("timeoutMs must be between 1000 and 1800000") + } + return nil +} + func resolveUseScriptField(name string, explicit *bool, hasObject bool) (bool, error) { if explicit == nil { return !hasObject, nil @@ -360,6 +336,14 @@ func decodeJSONObjectRaw(raw json.RawMessage, fieldName string) (map[string]inte return obj, true, nil } +func decodeAutomationRunResult(raw string) interface{} { + _, result, ok := decodeAutomationRunPayloadValue(raw) + if !ok { + return nil + } + return result +} + func parseJSONObjectText(text string) map[string]interface{} { trimmed := strings.TrimSpace(text) if trimmed == "" { @@ -390,6 +374,16 @@ func badAutomationRequest(message string) error { return automationRequestError(strings.TrimSpace(message)) } +func automationRequestErrorField(err error) string { + message := strings.TrimSpace(err.Error()) + for _, field := range []string{"scriptId", "selector", "targetInput", "params"} { + if strings.Contains(message, field) { + return field + } + } + return "" +} + type automationRequestError string func (e automationRequestError) Error() string { diff --git a/backend/internal/launchcode/automation_public_api.go b/backend/internal/launchcode/automation_public_api.go new file mode 100644 index 00000000..4f9bde06 --- /dev/null +++ b/backend/internal/launchcode/automation_public_api.go @@ -0,0 +1,553 @@ +package launchcode + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path" + "strconv" + "strings" + + "ant-chrome/backend/internal/automation" +) + +const automationPublicHookRoutePrefix = "/api/automation/hooks/" + +func (s *LaunchServer) handleAutomationPublicHook(w http.ResponseWriter, r *http.Request) { + hookPath, ok := parseAutomationPublicHookPath(r.URL.Path) + if !ok { + writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "") + return + } + + record, err := s.findAutomationPublicHookScript(hookPath) + if err != nil { + if err == errAutomationHookServiceUnavailable { + writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "") + return + } + if os.IsNotExist(err) { + writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "") + return + } + writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "") + return + } + + if !record.PublicAPI.Enabled { + writeAutomationAPIError(w, http.StatusNotFound, "not_found", "hook not found", "") + return + } + + if r.Method != record.PublicAPI.Method { + writeAutomationAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed", "") + return + } + + runner, ok := s.starter.(AutomationScriptRunner) + if !ok { + writeAutomationAPIError(w, http.StatusServiceUnavailable, "service_unavailable", "automation script api is unavailable", "") + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body", "") + return + } + + input, err := buildAutomationPublicHookRunRequest(*record, r, body) + if err != nil { + writeAutomationAPIError(w, http.StatusBadRequest, "invalid_request", err.Error(), automationRequestErrorField(err)) + return + } + + run, err := runner.AutomationScriptRunWithOptions(input) + if err != nil { + writeAutomationAPIError(w, http.StatusInternalServerError, "internal_error", err.Error(), "") + return + } + + writeAutomationPublicHookResponse(w, *record, run) +} + +var errAutomationHookServiceUnavailable = automationRequestError("automation hook service unavailable") + +func (s *LaunchServer) findAutomationPublicHookScript(hookPath string) (*automation.ScriptRecord, error) { + lister, ok := s.starter.(AutomationScriptLister) + if !ok { + return nil, errAutomationHookServiceUnavailable + } + + items, err := lister.AutomationScriptList() + if err != nil { + return nil, err + } + + for _, item := range items { + if normalizeAutomationPublicHookPath(item.PublicAPI.Path) != hookPath { + continue + } + record := item + return &record, nil + } + + return nil, os.ErrNotExist +} + +func parseAutomationPublicHookPath(urlPath string) (string, bool) { + trimmed := strings.TrimSpace(urlPath) + if !strings.HasPrefix(trimmed, automationPublicHookRoutePrefix) { + return "", false + } + + trimmed = strings.TrimPrefix(trimmed, automationPublicHookRoutePrefix) + trimmed = normalizeAutomationPublicHookPath(trimmed) + if trimmed == "" { + return "", false + } + return trimmed, true +} + +func normalizeAutomationPublicHookPath(value string) string { + value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") + if value == "" { + return "" + } + + cleaned := strings.Trim(path.Clean("/"+value), "/") + if cleaned == "" || cleaned == "." { + return "" + } + return strings.ToLower(cleaned) +} + +func buildAutomationPublicHookRunRequest(record automation.ScriptRecord, r *http.Request, body []byte) (automation.ScriptRunRequest, error) { + if shouldApplyAutomationPublicHookVariables(record) { + resolvedBody, err := resolveAutomationPublicHookRequestBody(record, body) + if err != nil { + return automation.ScriptRunRequest{}, err + } + body = resolvedBody + } + + input, err := decodeAutomationPublicHookRequestBody(body) + if err != nil { + return automation.ScriptRunRequest{}, err + } + selectorText := "" + useScriptSelector := true + if strings.TrimSpace(input.Code) != "" { + encodedSelectorText, err := encodeAutomationPublicHookJSONObject(map[string]interface{}{"code": strings.TrimSpace(input.Code)}) + if err != nil { + return automation.ScriptRunRequest{}, badAutomationRequest("code is invalid") + } + selectorText = encodedSelectorText + useScriptSelector = false + } + if err := validateAutomationTimeoutMs(input.TimeoutMs); err != nil { + return automation.ScriptRunRequest{}, err + } + params := mergeAutomationPublicHookDefaultParamsObject(record, input.Params) + paramsText, err := encodeAutomationPublicHookJSONObject(params) + if err != nil { + return automation.ScriptRunRequest{}, badAutomationRequest("params must be a JSON object") + } + + return automation.ScriptRunRequest{ + ScriptID: record.ID, + SelectorText: selectorText, + ParamsText: paramsText, + UseScriptSelector: useScriptSelector, + UseScriptParams: false, + TimeoutMs: resolveAutomationPublicHookTimeout(r, input.TimeoutMs, record.PublicAPI.TimeoutMs), + }, nil +} + +type automationPublicHookRequestBody struct { + Code string `json:"code"` + Params map[string]interface{} `json:"params"` + TimeoutMs int `json:"timeoutMs"` +} + +func decodeAutomationPublicHookRequestBody(body []byte) (automationPublicHookRequestBody, error) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return automationPublicHookRequestBody{}, nil + } + + var input automationPublicHookRequestBody + dec := json.NewDecoder(bytes.NewReader(trimmed)) + dec.DisallowUnknownFields() + if err := dec.Decode(&input); err != nil { + return automationPublicHookRequestBody{}, badAutomationRequest("invalid request body") + } + if input.Params == nil { + input.Params = map[string]interface{}{} + } + return input, nil +} + +func encodeAutomationPublicHookJSONObject(obj map[string]interface{}) (string, error) { + encoded, err := json.Marshal(obj) + if err != nil { + return "", err + } + return string(encoded), nil +} + +func shouldApplyAutomationPublicHookVariables(record automation.ScriptRecord) bool { + if strings.TrimSpace(record.PublicAPI.RequestBodyText) == "" { + return false + } + for _, variable := range record.PublicAPI.Variables { + name := strings.TrimSpace(variable.Name) + if name == "" { + continue + } + if strings.Contains(record.PublicAPI.RequestBodyText, "{{"+name+"}}") || strings.Contains(record.PublicAPI.RequestBodyText, "${"+name+"}") { + return true + } + } + return false +} + +func replaceAutomationPublicHookPlaceholderValue(bodyText string, name string, rawValue interface{}) string { + value := strings.TrimSpace(formatAutomationPublicHookVariableValue(rawValue)) + escapedValue := escapeAutomationPublicHookJSONString(value) + for _, placeholder := range []string{"{{" + name + "}}", "${" + name + "}"} { + bodyText = strings.ReplaceAll(bodyText, placeholder, escapedValue) + } + return bodyText +} + +func resolveAutomationPublicHookRequestBody(record automation.ScriptRecord, body []byte) ([]byte, error) { + config := record.PublicAPI + input, err := decodeAutomationPublicHookRequestBody(body) + if err != nil { + return nil, err + } + values := input.Params + + bodyText := replaceAutomationPublicHookPlaceholderValue(config.RequestBodyText, "code", input.Code) + for _, variable := range config.Variables { + name := strings.TrimSpace(variable.Name) + if name == "" { + continue + } + placeholders := []string{"{{" + name + "}}", "${" + name + "}"} + used := false + for _, placeholder := range placeholders { + if strings.Contains(bodyText, placeholder) { + used = true + break + } + } + if !used { + continue + } + + rawValue := interface{}(variable.DefaultValue) + if incomingValue, ok := values[name]; ok { + rawValue = incomingValue + } + value := strings.TrimSpace(formatAutomationPublicHookVariableValue(rawValue)) + if variable.Required && value == "" { + return nil, badAutomationRequest("missing required variable: " + name) + } + escapedValue := escapeAutomationPublicHookJSONString(value) + for _, placeholder := range placeholders { + bodyText = strings.ReplaceAll(bodyText, placeholder, escapedValue) + } + } + + var decoded interface{} + if err := json.Unmarshal([]byte(bodyText), &decoded); err != nil { + return nil, badAutomationRequest("resolved request body must be a JSON object") + } + decodedBody, ok := decoded.(map[string]interface{}) + if !ok { + return nil, badAutomationRequest("resolved request body must be a JSON object") + } + mergedBody := mergeAutomationPublicHookDefaultParams(record, decodedBody) + encoded, err := json.Marshal(mergedBody) + if err != nil { + return nil, badAutomationRequest("resolved request body must be a JSON object") + } + return encoded, nil +} + +func mergeAutomationPublicHookDefaultParams(record automation.ScriptRecord, body map[string]interface{}) map[string]interface{} { + defaultParams, ok := parseAutomationPublicHookJSONObject(record.ParamsText) + if !ok || len(defaultParams) == 0 { + return body + } + + if record.PublicAPI.RequestMode == "params-only" { + return mergeAutomationPublicHookJSONObjects(defaultParams, body) + } + + rawParams, ok := body["params"].(map[string]interface{}) + if !ok { + return body + } + + nextBody := make(map[string]interface{}, len(body)) + for key, value := range body { + nextBody[key] = value + } + nextBody["params"] = mergeAutomationPublicHookJSONObjects(defaultParams, rawParams) + return nextBody +} + +func mergeAutomationPublicHookDefaultParamsObject(record automation.ScriptRecord, param map[string]interface{}) map[string]interface{} { + if param == nil { + param = map[string]interface{}{} + } + defaultParams, ok := parseAutomationPublicHookJSONObject(record.ParamsText) + if !ok || len(defaultParams) == 0 { + return param + } + return mergeAutomationPublicHookJSONObjects(defaultParams, param) +} + +func parseAutomationPublicHookJSONObject(text string) (map[string]interface{}, bool) { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return nil, false + } + var value interface{} + if err := json.Unmarshal([]byte(trimmed), &value); err != nil { + return nil, false + } + object, ok := value.(map[string]interface{}) + return object, ok +} + +func mergeAutomationPublicHookJSONObjects(base map[string]interface{}, patch map[string]interface{}) map[string]interface{} { + merged := make(map[string]interface{}, len(base)+len(patch)) + for key, value := range base { + merged[key] = value + } + for key, value := range patch { + baseObject, baseOK := merged[key].(map[string]interface{}) + patchObject, patchOK := value.(map[string]interface{}) + if baseOK && patchOK { + merged[key] = mergeAutomationPublicHookJSONObjects(baseObject, patchObject) + continue + } + merged[key] = value + } + return merged +} + +func formatAutomationPublicHookVariableValue(value interface{}) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return typed + case float64, bool, int, int64, json.Number: + return strings.TrimSpace(strings.Trim(fmt.Sprint(typed), "\"")) + default: + encoded, err := json.Marshal(typed) + if err != nil { + return fmt.Sprint(typed) + } + return string(encoded) + } +} + +func escapeAutomationPublicHookJSONString(value string) string { + encoded, err := json.Marshal(value) + if err != nil { + return value + } + text := string(encoded) + if len(text) >= 2 { + return text[1 : len(text)-1] + } + return text +} + +func decodeAutomationRunAPIRequestBody(body []byte) (automationScriptRunAPIRequest, error) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return automationScriptRunAPIRequest{}, nil + } + + var req automationScriptRunAPIRequest + dec := json.NewDecoder(bytes.NewReader(trimmed)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + return automationScriptRunAPIRequest{}, badAutomationRequest("invalid request body") + } + return req, nil +} + +func decodeJSONObjectBody(body []byte, fieldName string) (map[string]interface{}, bool, error) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil, false, nil + } + + var value interface{} + if err := json.Unmarshal(trimmed, &value); err != nil { + return nil, false, badAutomationRequest(fieldName + " must be a JSON object") + } + + obj, ok := value.(map[string]interface{}) + if !ok { + return nil, false, badAutomationRequest(fieldName + " must be a JSON object") + } + return obj, true, nil +} + +func resolveAutomationPublicHookTimeout(r *http.Request, requestTimeout int, fallback int) int { + if requestTimeout > 0 { + return requestTimeout + } + + if r != nil { + if raw := strings.TrimSpace(r.URL.Query().Get("timeoutMs")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { + return parsed + } + } + } + + return fallback +} + +func writeAutomationPublicHookResponse(w http.ResponseWriter, record automation.ScriptRecord, run *automation.ScriptRunRecord) { + _ = record + parsedPayload, resultPayload, hasResult := decodeAutomationRunPayloadValue(run.ResultText) + if run.Status != "success" { + writeJSON(w, http.StatusOK, compactAutomationPublicHookFailure(run)) + return + } + + response := map[string]interface{}{ + "ok": true, + "status": run.Status, + "summary": run.Summary, + "message": run.Summary, + "data": map[string]interface{}{}, + "result": map[string]interface{}{}, + } + + if hasResult { + data := compactAutomationPublicHookData(resultPayload, run) + response["data"] = data + response["result"] = data + } else if parsedPayload != nil { + data := compactAutomationPublicHookData(parsedPayload, run) + response["data"] = data + response["result"] = data + } + + writeJSON(w, http.StatusOK, response) +} + +func compactAutomationPublicHookFailure(run *automation.ScriptRunRecord) map[string]interface{} { + response := map[string]interface{}{ + "ok": false, + "status": run.Status, + "summary": run.Summary, + "message": run.Summary, + "data": map[string]interface{}{}, + "result": map[string]interface{}{}, + } + if strings.TrimSpace(run.Error) != "" { + response["error"] = run.Error + } + return response +} + +func compactAutomationPublicHookData(payload interface{}, run *automation.ScriptRunRecord) interface{} { + data := compactAutomationPublicHookResult(payload, run) + delete(data, "ok") + delete(data, "summary") + return data +} + +func compactAutomationPublicHookResult(payload interface{}, run *automation.ScriptRunRecord) map[string]interface{} { + obj, ok := payload.(map[string]interface{}) + if !ok { + result := map[string]interface{}{"ok": true} + if strings.TrimSpace(run.Summary) != "" { + result["summary"] = run.Summary + } + if payload != nil { + result["result"] = payload + } + return result + } + + if !hasAutomationPublicHookDownloadField(obj) { + result := make(map[string]interface{}, len(obj)+1) + result["ok"] = true + for key, value := range obj { + if key != "ok" && value != nil { + result[key] = value + } + } + if _, exists := result["summary"]; !exists && strings.TrimSpace(run.Summary) != "" { + result["summary"] = run.Summary + } + return result + } + + result := map[string]interface{}{"ok": true} + + for _, key := range []string{ + "downloadAddress", + "downloadPath", + "outputPath", + "sourceImageUrl", + "sourceDownloadUrl", + "screenshotPath", + "pageScreenshotPath", + "contentType", + "imageWidth", + "imageHeight", + "status", + "summary", + "error", + } { + if value, exists := obj[key]; exists && value != nil { + result[key] = value + } + } + return result +} + +func hasAutomationPublicHookDownloadField(obj map[string]interface{}) bool { + for _, key := range []string{"downloadAddress", "downloadPath", "outputPath"} { + if value, exists := obj[key]; exists && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { + return true + } + } + return false +} + +func decodeAutomationRunPayloadValue(raw string) (interface{}, interface{}, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, nil, false + } + + var payload interface{} + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return nil, nil, false + } + + if obj, ok := payload.(map[string]interface{}); ok { + result, exists := obj["result"] + return payload, result, exists + } + return payload, nil, false +} diff --git a/backend/internal/launchcode/automation_response.go b/backend/internal/launchcode/automation_response.go new file mode 100644 index 00000000..146dea18 --- /dev/null +++ b/backend/internal/launchcode/automation_response.go @@ -0,0 +1,54 @@ +package launchcode + +import "net/http" + +type automationAPIResponse[T any] struct { + OK bool `json:"ok"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Error *automationAPIError `json:"error,omitempty"` + Data T `json:"data,omitempty"` +} + +type automationAPIError struct { + Code string `json:"code"` + Message string `json:"message"` + Field string `json:"field,omitempty"` +} + +type automationAPIListData[T any] struct { + Items []T `json:"items"` + Count int `json:"count"` + Limit int `json:"limit,omitempty"` +} + +type automationAPIItemData[T any] struct { + Item T `json:"item"` +} + +type automationAPIRunData struct { + Run interface{} `json:"run,omitempty"` + Summary string `json:"summary,omitempty"` + Result interface{} `json:"result,omitempty"` +} + +func writeAutomationAPIError(w http.ResponseWriter, status int, code string, message string, field string) { + writeJSON(w, status, automationAPIResponse[struct{}]{ + OK: false, + Status: "failed", + Error: &automationAPIError{ + Code: code, + Message: message, + Field: field, + }, + }) +} + +func writeAutomationAPISuccess[T any](w http.ResponseWriter, status int, message string, data T) { + writeJSON(w, status, automationAPIResponse[T]{ + OK: true, + Status: "success", + Message: message, + Data: data, + }) +} diff --git a/backend/internal/launchcode/server_http.go b/backend/internal/launchcode/server_http.go index 9f5844e1..d1cfc44d 100644 --- a/backend/internal/launchcode/server_http.go +++ b/backend/internal/launchcode/server_http.go @@ -9,6 +9,7 @@ func (s *LaunchServer) buildMux() *http.ServeMux { mux.HandleFunc("/api/automation/scripts/", s.handleAutomationScriptByID) mux.HandleFunc("/api/automation/scripts/run", s.handleAutomationScriptRun) mux.HandleFunc("/api/automation/scripts/runs", s.handleAutomationScriptRuns) + mux.HandleFunc("/api/automation/hooks/", s.handleAutomationPublicHook) mux.HandleFunc("/api/profiles", s.handleProfiles) mux.HandleFunc("/api/profiles/", s.handleProfileByID) mux.HandleFunc("/api/runtime/active", s.handleRuntimeActive) diff --git a/backend/test/launchcode/server_automation_test.go b/backend/test/launchcode/server_automation_test.go index 10acaad3..eef31dbf 100644 --- a/backend/test/launchcode/server_automation_test.go +++ b/backend/test/launchcode/server_automation_test.go @@ -117,30 +117,33 @@ func TestAutomationScriptsEndpointReturnsMetadata(t *testing.T) { } var resp struct { - OK bool `json:"ok"` - Count int `json:"count"` - Items []struct { - ID string `json:"id"` - Type string `json:"type"` - Status string `json:"status"` - Selector map[string]interface{} `json:"selector"` - Params map[string]interface{} `json:"params"` - } `json:"items"` + OK bool `json:"ok"` + Data struct { + Count int `json:"count"` + Items []struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Selector map[string]interface{} `json:"selector"` + Params map[string]interface{} `json:"params"` + } `json:"items"` + } `json:"data"` } if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("解析响应失败: %v", err) } - if !resp.OK || resp.Count != 1 || len(resp.Items) != 1 { + if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 { t.Fatalf("响应结构错误: %+v", resp) } - if resp.Items[0].ID != "news-query-txt" || resp.Items[0].Type != "playwright-cdp" || resp.Items[0].Status != "ready" { - t.Fatalf("脚本元数据错误: %+v", resp.Items[0]) + item := resp.Data.Items[0] + if item.ID != "news-query-txt" || item.Type != "playwright-cdp" || item.Status != "ready" { + t.Fatalf("脚本元数据错误: %+v", item) } - if resp.Items[0].Selector["code"] != "BUYER_001" { - t.Fatalf("selector 解析错误: %+v", resp.Items[0].Selector) + if item.Selector["code"] != "BUYER_001" { + t.Fatalf("selector 解析错误: %+v", item.Selector) } - if resp.Items[0].Params["keyword"] != "OpenAI" { - t.Fatalf("params 解析错误: %+v", resp.Items[0].Params) + if item.Params["keyword"] != "OpenAI" { + t.Fatalf("params 解析错误: %+v", item.Params) } } @@ -189,28 +192,31 @@ func TestAutomationScriptDetailEndpointReturnsSingleScript(t *testing.T) { var resp struct { OK bool `json:"ok"` - Item struct { - ID string `json:"id"` - PackageFormat string `json:"packageFormat"` - ManifestVersion int `json:"manifestVersion"` - Source automation.ScriptSource `json:"source"` - Selector map[string]interface{} `json:"selector"` - } `json:"item"` + Data struct { + Item struct { + ID string `json:"id"` + PackageFormat string `json:"packageFormat"` + ManifestVersion int `json:"manifestVersion"` + Source automation.ScriptSource `json:"source"` + Selector map[string]interface{} `json:"selector"` + } `json:"item"` + } `json:"data"` } if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("解析响应失败: %v", err) } - if !resp.OK || resp.Item.ID != "news-query-txt" { + item := resp.Data.Item + if !resp.OK || item.ID != "news-query-txt" { t.Fatalf("详情响应错误: %+v", resp) } - if resp.Item.PackageFormat != "ant-automation-script" || resp.Item.ManifestVersion != 1 { - t.Fatalf("详情元数据错误: %+v", resp.Item) + if item.PackageFormat != "ant-automation-script" || item.ManifestVersion != 1 { + t.Fatalf("详情元数据错误: %+v", item) } - if resp.Item.Source.Type != "git" || resp.Item.Source.URI != "https://example.com/repo.git" { - t.Fatalf("source 返回错误: %+v", resp.Item.Source) + if item.Source.Type != "git" || item.Source.URI != "https://example.com/repo.git" { + t.Fatalf("source 返回错误: %+v", item.Source) } - if resp.Item.Selector["code"] != "BUYER_001" { - t.Fatalf("selector 解析错误: %+v", resp.Item.Selector) + if item.Selector["code"] != "BUYER_001" { + t.Fatalf("selector 解析错误: %+v", item.Selector) } } @@ -232,9 +238,10 @@ func TestAutomationScriptRunEndpointConvertsObjectPayload(t *testing.T) { svc := newInMemoryService() starter := newMockAutomationStarter() starter.runResult = &automation.ScriptRunRecord{ - ID: "run-1", - ScriptID: "news-query-txt", - Status: "success", + ID: "run-1", + ScriptID: "news-query-txt", + Status: "success", + ResultText: `{"ok":true,"summary":"done","result":{"subject":"Hello","contentText":"Mail body"}}`, } handler := buildTestHandlerWithManager(svc, starter, nil) @@ -264,18 +271,24 @@ func TestAutomationScriptRunEndpointConvertsObjectPayload(t *testing.T) { } var resp struct { - OK bool `json:"ok"` - Run struct { - ID string `json:"id"` - Status string `json:"status"` - } `json:"run"` + OK bool `json:"ok"` + Data struct { + Result map[string]interface{} `json:"result"` + Run struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"run"` + } `json:"data"` } if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("解析响应失败: %v", err) } - if !resp.OK || resp.Run.ID != "run-1" || resp.Run.Status != "success" { + if !resp.OK || resp.Data.Run.ID != "run-1" || resp.Data.Run.Status != "success" { t.Fatalf("run 响应错误: %+v", resp) } + if resp.Data.Result["subject"] != "Hello" || resp.Data.Result["contentText"] != "Mail body" { + t.Fatalf("expected parsed result payload, got %+v", resp.Data.Result) + } } func TestAutomationScriptRunEndpointUsesScriptDefaultsWhenFieldsOmitted(t *testing.T) { @@ -299,6 +312,350 @@ func TestAutomationScriptRunEndpointUsesScriptDefaultsWhenFieldsOmitted(t *testi } } +func TestAutomationPublicHookStandardModeReturnsEnvelope(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "proton-mail-first-message", + Name: "Proton 邮件搜索并读取最新邮件", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "mail/proton-first-message", + RequestMode: "standard", + ResponseMode: "envelope", + TimeoutMs: 120000, + }, + }, + } + starter.runResult = &automation.ScriptRunRecord{ + ID: "run-hook-1", + ScriptID: "proton-mail-first-message", + ScriptName: "Proton 邮件搜索并读取最新邮件", + Status: "success", + Summary: "已返回最新命中邮件内容", + ResultText: `{"ok":true,"result":{"verificationCode":"429792","recipientEmail":"target@example.com"}}`, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-first-message", bytes.NewBufferString(`{ + "code":"BUYER_001", + "params":{"recipientQuery":"target@example.com"} + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastRunRequest.ScriptID != "proton-mail-first-message" { + t.Fatalf("scriptId 透传错误: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams { + t.Fatalf("公共 Hook 应使用请求里的 code/param: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` { + t.Fatalf("selectorText 转换错误: %s", starter.lastRunRequest.SelectorText) + } + if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` { + t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText) + } + + var resp struct { + OK bool `json:"ok"` + Status string `json:"status"` + Data map[string]interface{} `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if !resp.OK || resp.Status != "success" { + t.Fatalf("hook 响应错误: %+v", resp) + } + if resp.Data["verificationCode"] != "429792" { + t.Fatalf("expected data payload, got %+v", resp.Data) + } +} + +func TestAutomationPublicHookParamsOnlyModeReturnsResultOnly(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "proton-mail-first-message", + Name: "Proton 邮件搜索并读取最新邮件", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "mail/proton-result-only", + RequestMode: "params-only", + ResponseMode: "result-only", + TimeoutMs: 45000, + }, + }, + } + starter.runResult = &automation.ScriptRunRecord{ + ID: "run-hook-2", + ScriptID: "proton-mail-first-message", + ScriptName: "Proton 邮件搜索并读取最新邮件", + Status: "success", + Summary: "已返回最新命中邮件内容", + ResultText: `{"ok":true,"result":{"verificationCode":"429792","mailboxName":"ChatGPT"}}`, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/proton-result-only?timeoutMs=60000", bytes.NewBufferString(`{ + "code":"BUYER_001", + "params":{"recipientQuery":"target@example.com"} + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastRunRequest.UseScriptSelector || starter.lastRunRequest.UseScriptParams { + t.Fatalf("公共 Hook 应透传 code/param: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.ParamsText != `{"recipientQuery":"target@example.com"}` { + t.Fatalf("paramsText 转换错误: %s", starter.lastRunRequest.ParamsText) + } + if starter.lastRunRequest.TimeoutMs != 60000 { + t.Fatalf("timeoutMs 透传错误: %+v", starter.lastRunRequest) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + data, ok := resp["data"].(map[string]interface{}) + if !ok || data["verificationCode"] != "429792" || data["mailboxName"] != "ChatGPT" { + t.Fatalf("expected data payload, got %+v", resp) + } +} + +func TestAutomationPublicHookResultOnlyCompactsDownloadFields(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "grok-image-generate-download", + Name: "Grok 生成图片并下载", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "image/grok-generate-download", + RequestMode: "params-only", + ResponseMode: "result-only", + TimeoutMs: 300000, + }, + }, + } + starter.runResult = &automation.ScriptRunRecord{ + ID: "run-grok-image", + ScriptID: "grok-image-generate-download", + ScriptName: "Grok 生成图片并下载", + Status: "success", + Summary: "Grok 图片已生成并下载", + ResultText: `{"ok":true,"downloadAddress":"D:/tmp/grok.png","downloadPath":"D:/tmp/grok.png","sourceImageUrl":"https://example.com/image.png","steps":[{"step":"open"}],"startedAt":"2026-06-03T00:00:00Z"}`, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/grok-generate-download", bytes.NewBufferString(`{"code":"BUYER_001","params":{"prompt":"ant"}}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + data, ok := resp["data"].(map[string]interface{}) + if !ok || data["downloadAddress"] != "D:/tmp/grok.png" || data["downloadPath"] != "D:/tmp/grok.png" || data["sourceImageUrl"] != "https://example.com/image.png" { + t.Fatalf("下载字段缺失: %+v", resp) + } + if _, exists := data["steps"]; exists { + t.Fatalf("不应返回冗余 steps: %+v", resp) + } + if _, exists := data["runId"]; exists { + t.Fatalf("不应返回 runId: %+v", resp) + } +} + +func TestAutomationPublicHookAppliesRequestBodyVariables(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "image-generate", + Name: "图片生成", + ParamsText: `{"prompt":"默认提示词","selectors":{"promptInput":"#prompt-textarea","generatedImage":"img.generated"},"timeoutMs":300000}`, + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "image/generate", + RequestMode: "standard", + ResponseMode: "envelope", + TimeoutMs: 300000, + RequestBodyText: `{"code":"{{code}}","params":{"prompt":"{{prompt}}","outputFileName":"{{outputFileName}}"}}`, + Variables: []automation.ScriptPublicAPIVariable{ + {Name: "prompt", DefaultValue: "默认提示词", Required: true}, + {Name: "outputFileName", DefaultValue: "generated-image.png"}, + }, + }, + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/image/generate", bytes.NewBufferString(`{ + "code":"BUYER_001", + "params":{"prompt":"海边的机器人","outputFileName":"robot.png"} + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastRunRequest.UseScriptParams { + t.Fatalf("变量模板应生成 params: %+v", starter.lastRunRequest) + } + if starter.lastRunRequest.SelectorText != `{"code":"BUYER_001"}` { + t.Fatalf("变量模板应生成 selector: %+v", starter.lastRunRequest) + } + + var params map[string]interface{} + if err := json.Unmarshal([]byte(starter.lastRunRequest.ParamsText), ¶ms); err != nil { + t.Fatalf("解析 paramsText 失败: %v", err) + } + if params["prompt"] != "海边的机器人" || params["outputFileName"] != "robot.png" { + t.Fatalf("变量未映射到 params: %+v", params) + } + selectors, ok := params["selectors"].(map[string]interface{}) + if !ok || selectors["promptInput"] != "#prompt-textarea" || selectors["generatedImage"] != "img.generated" { + t.Fatalf("默认 selectors 不应被变量模板覆盖丢失: %+v", params) + } + if params["timeoutMs"] != float64(300000) { + t.Fatalf("默认 timeoutMs 不应被变量模板覆盖丢失: %+v", params) + } + if starter.lastRunRequest.TimeoutMs != 300000 { + t.Fatalf("timeoutMs 错误: %+v", starter.lastRunRequest) + } +} + +func TestAutomationPublicHookReturnsNotFoundWhenDisabled(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "disabled-hook", + Name: "Disabled Hook", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: false, + Path: "mail/disabled-hook", + }, + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/disabled-hook", bytes.NewBufferString(`{}`)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("期望 404,实际 %d,body=%s", w.Code, w.Body.String()) + } +} + +func TestAutomationPublicHookRejectsLegacyParamAndTopLevelVariables(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "strict-hook", + Name: "Strict Hook", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "mail/strict-hook", + TimeoutMs: 120000, + }, + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + cases := []struct { + name string + body string + }{ + {name: "legacy-param", body: `{"code":"BUYER_001","param":{"recipientQuery":"target@example.com"}}`}, + {name: "top-level-variable", body: `{"code":"BUYER_001","recipientQuery":"target@example.com"}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook", bytes.NewBufferString(tc.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if resp.OK || resp.Error.Code != "invalid_request" { + t.Fatalf("错误 envelope 不正确: %+v", resp) + } + }) + } +} + +func TestAutomationPublicHookRejectsInvalidTimeout(t *testing.T) { + svc := newInMemoryService() + starter := newMockAutomationStarter() + starter.scripts = []automation.ScriptRecord{ + { + ID: "strict-hook-timeout", + Name: "Strict Hook Timeout", + PublicAPI: automation.ScriptPublicAPIConfig{ + Enabled: true, + Method: "POST", + Path: "mail/strict-hook-timeout", + TimeoutMs: 120000, + }, + }, + } + + handler := buildTestHandlerWithManager(svc, starter, nil) + req := httptest.NewRequest(http.MethodPost, "/api/automation/hooks/mail/strict-hook-timeout", bytes.NewBufferString(`{"code":"BUYER_001","params":{},"timeoutMs":999}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") { + t.Fatalf("错误信息不正确: %s", w.Body.String()) + } +} + func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) { svc := newInMemoryService() starter := newMockAutomationStarter() @@ -320,14 +677,16 @@ func TestAutomationScriptRunsEndpointPassesLimit(t *testing.T) { } var resp struct { - OK bool `json:"ok"` - Count int `json:"count"` - Items []automation.ScriptRunRecord `json:"items"` + OK bool `json:"ok"` + Data struct { + Count int `json:"count"` + Items []automation.ScriptRunRecord `json:"items"` + } `json:"data"` } if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("解析响应失败: %v", err) } - if !resp.OK || resp.Count != 1 || len(resp.Items) != 1 { + if !resp.OK || resp.Data.Count != 1 || len(resp.Data.Items) != 1 { t.Fatalf("runs 响应错误: %+v", resp) } } @@ -374,4 +733,21 @@ func TestAutomationScriptRunEndpointRejectsInvalidBody(t *testing.T) { t.Fatalf("错误信息不正确: %s", w.Body.String()) } }) + + t.Run("timeout-must-be-in-range", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/automation/scripts/run", bytes.NewBufferString(`{ + "scriptId":"news-query-txt", + "timeoutMs":1800001 + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("期望 400,实际 %d,body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "timeoutMs must be between 1000 and 1800000") { + t.Fatalf("错误信息不正确: %s", w.Body.String()) + } + }) } diff --git a/data/README.md b/data/README.md new file mode 100644 index 00000000..e1ee4a70 --- /dev/null +++ b/data/README.md @@ -0,0 +1,13 @@ +本目录是运行时数据目录,不参与常规 Git 提交。 + +与自动化脚本相关的约定: + +- 用户新建、导入、刷新后的脚本快照默认落在 `data/automation/scripts/`。 +- `.gitignore` 已忽略 `data/*`,所以这些本地脚本不会直接被提交到 GitHub。 +- 目前仓库里真正跟代码一起维护的只有内置脚本;其他临时脚本建议继续放在 `data/automation/scripts/`。 + +如果需要按仓库管理脚本源码: + +- 可以把要发布的脚本单独放在一个明确的子目录里,例如 `scripts/published/demo`。 +- Git 导入时填写“脚本路径”,系统只会扫描那个指定子目录,不会把整个仓库都当成一个脚本包。 +- 留空“脚本路径”时,才会从仓库根目录解析。 diff --git a/frontend/scripts/dev-watcher.mjs b/frontend/scripts/dev-watcher.mjs index 645990c4..d1e81a0e 100644 --- a/frontend/scripts/dev-watcher.mjs +++ b/frontend/scripts/dev-watcher.mjs @@ -318,7 +318,9 @@ function main() { childKilledByRssLimit = false child = spawn(nodeExecutable, nodeArgs.args, { cwd: frontendDir, - stdio: 'inherit', + // Keep stdin open for Vite. In detached Windows launches the inherited + // stdin can close immediately, which makes Vite exit cleanly after boot. + stdio: ['pipe', 'inherit', 'inherit'], env: childEnv, }) diff --git a/frontend/src/modules/browser/api/profiles.ts b/frontend/src/modules/browser/api/profiles.ts index c6754085..caf628f1 100644 --- a/frontend/src/modules/browser/api/profiles.ts +++ b/frontend/src/modules/browser/api/profiles.ts @@ -1,4 +1,6 @@ -import type { BrowserProfile, BrowserProfileInput } from '../types' +import { applyBrowserProfileCopyOptionsToArgs, createBrowserProfileCopyOptions } from '../copyOptions' +import { buildBrowserProfileCopyName } from '../copyName' +import type { BrowserProfile, BrowserProfileCopyOptions, BrowserProfileInput } from '../types' import { getBindings, getMockProfiles, nowISOString, setMockProfiles } from './runtime' export async function fetchBrowserProfiles(): Promise { @@ -80,8 +82,18 @@ export async function deleteBrowserProfile(profileId: string): Promise return true } -export async function copyBrowserProfile(profileId: string, newName: string): Promise { +export async function copyBrowserProfile( + profileId: string, + newName: string, + options: BrowserProfileCopyOptions = createBrowserProfileCopyOptions(), +): Promise { const bindings: any = await getBindings() + if (bindings?.BrowserProfileCopyWithOptions) { + return (await bindings.BrowserProfileCopyWithOptions(profileId, newName, options)) || null + } + if (bindings?.BrowserProfileCopyWithMode) { + return (await bindings.BrowserProfileCopyWithMode(profileId, newName, options.mode)) || null + } if (bindings?.BrowserProfileCopy) { return (await bindings.BrowserProfileCopy(profileId, newName)) || null } @@ -96,8 +108,13 @@ export async function copyBrowserProfile(profileId: string, newName: string): Pr const copy: BrowserProfile = { ...source, profileId: `mock-${timestamp}`, - profileName: newName || `${source.profileName} (副本)`, + profileName: newName.trim() || buildBrowserProfileCopyName(source.profileName), userDataDir: `mock-${timestamp}`, + fingerprintArgs: applyBrowserProfileCopyOptionsToArgs( + source.fingerprintArgs || [], + [], + options, + ), launchCode, running: false, debugReady: false, diff --git a/frontend/src/modules/browser/api/runtime.ts b/frontend/src/modules/browser/api/runtime.ts index 74e5c6eb..50a49db7 100644 --- a/frontend/src/modules/browser/api/runtime.ts +++ b/frontend/src/modules/browser/api/runtime.ts @@ -22,6 +22,7 @@ export function createDefaultBrowserSettings(): BrowserSettings { defaultFingerprintArgs: [], defaultLaunchArgs: [], defaultStartUrls: [], + lightStartEnabled: true, restoreLastSession: false, startReadyTimeoutMs: 3000, startStableWindowMs: 1200, diff --git a/frontend/src/modules/browser/automationScriptApi.ts b/frontend/src/modules/browser/automationScriptApi.ts index 9ef87a91..d3af2d70 100644 --- a/frontend/src/modules/browser/automationScriptApi.ts +++ b/frontend/src/modules/browser/automationScriptApi.ts @@ -2,6 +2,7 @@ import { exportAutomationScript, importAutomationScript, loadAutomationScripts, + normalizeAutomationScriptPublicAPIConfig, normalizeAutomationScriptRecordPayload, normalizeAutomationScriptTargetConfig, saveAutomationScripts, @@ -47,6 +48,7 @@ function normalizeAutomationScriptRecord(payload: any): AutomationScriptRecord { scriptText: String(payload?.scriptText || ""), notes: String(payload?.notes || ""), targetConfig: normalizeAutomationScriptTargetConfig(payload?.targetConfig), + publicAPI: normalizeAutomationScriptPublicAPIConfig(payload?.publicAPI), source: { type: String(payload?.source?.type || ""), uri: String(payload?.source?.uri || ""), @@ -97,6 +99,46 @@ export interface AutomationScriptExportResult { fileCount: number; } +export interface AutomationScriptImportIssue { + path: string; + message: string; +} + +export interface AutomationScriptBatchImportResult { + imported: AutomationScriptRecord[]; + failed: AutomationScriptImportIssue[]; + scanned: number; +} + +export interface AutomationScriptPublicApiInvokeInput { + url: string; + method?: string; + bodyText?: string; + apiKey?: string; + authHeader?: string; + timeoutMs?: number; +} + +export interface AutomationScriptPublicApiInvokeResult { + ok: boolean; + status: number; + statusText: string; + bodyText: string; + bodyJson: unknown | null; +} + +function normalizeAutomationScriptPublicApiInvokeResult( + payload: any, +): AutomationScriptPublicApiInvokeResult { + return { + ok: payload?.ok === true, + status: Number(payload?.status) || 0, + statusText: String(payload?.statusText || ""), + bodyText: String(payload?.bodyText || ""), + bodyJson: payload?.bodyJson ?? null, + }; +} + function normalizeAutomationScriptRunInput( input: string | AutomationScriptRunInput, ): AutomationScriptRunInput { @@ -104,6 +146,7 @@ function normalizeAutomationScriptRunInput( return { scriptId: input, selectorText: "", + targetInput: {}, paramsText: "", useScriptSelector: true, useScriptParams: true, @@ -116,6 +159,10 @@ function normalizeAutomationScriptRunInput( return { scriptId: String(input?.scriptId || ""), selectorText: String(input?.selectorText || ""), + targetInput: + input?.targetInput && typeof input.targetInput === "object" + ? { ...input.targetInput } + : {}, paramsText: String(input?.paramsText || ""), useScriptSelector: input?.useScriptSelector !== false, useScriptParams: input?.useScriptParams !== false, @@ -255,6 +302,46 @@ export async function importAutomationScriptFromLocalDirectory(): Promise ({ + path: String(item?.path || ""), + message: String(item?.message || ""), + })) + : [], + scanned: + Number.isFinite(Number(payload?.scanned)) && Number(payload.scanned) > 0 + ? Math.round(Number(payload.scanned)) + : imported.length, + }; +} + +export async function importAutomationScriptFromLocalLibrary(): Promise { + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptImportLocalLibrary) { + return normalizeAutomationScriptBatchImportResult( + await bindings.AutomationScriptImportLocalLibrary(), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptImportLocalLibrary === "function") { + return normalizeAutomationScriptBatchImportResult( + await goApp.AutomationScriptImportLocalLibrary(), + ); + } + + throw new Error("当前环境不支持本地脚本库导入"); +} + export async function importAutomationScriptFromRemote(url: string): Promise { const normalizedURL = String(url || "").trim(); if (!normalizedURL) { @@ -555,3 +642,79 @@ export async function fetchAutomationScriptRuns( return []; } + +export async function invokeAutomationScriptPublicApi( + input: AutomationScriptPublicApiInvokeInput, +): Promise { + const url = String(input?.url || "").trim(); + if (!url) { + throw new Error("接口地址不能为空"); + } + + const method = String(input?.method || "POST").trim().toUpperCase() || "POST"; + const authHeader = String(input?.authHeader || "X-Ant-Api-Key").trim() || "X-Ant-Api-Key"; + const apiKey = String(input?.apiKey || "").trim(); + const bodyText = String(input?.bodyText || "").trim(); + const timeoutMs = Number.isFinite(Number(input?.timeoutMs)) + ? Math.max(1000, Math.round(Number(input?.timeoutMs))) + : 0; + + const bindings: any = await getBindings(); + if (bindings?.AutomationScriptInvokePublicAPI) { + return normalizeAutomationScriptPublicApiInvokeResult( + await bindings.AutomationScriptInvokePublicAPI({ + url, + method, + bodyText, + apiKey, + authHeader, + timeoutMs, + }), + ); + } + + const goApp = (window as any).go?.main?.App; + if (typeof goApp?.AutomationScriptInvokePublicAPI === "function") { + return normalizeAutomationScriptPublicApiInvokeResult( + await goApp.AutomationScriptInvokePublicAPI({ + url, + method, + bodyText, + apiKey, + authHeader, + timeoutMs, + }), + ); + } + + const headers: Record = { + "Content-Type": "application/json", + }; + if (apiKey) { + headers[authHeader] = apiKey; + } + + const response = await fetch(url, { + method, + headers, + body: bodyText || "{}", + }); + + const rawText = await response.text(); + let bodyJson: unknown | null = null; + if (rawText.trim()) { + try { + bodyJson = JSON.parse(rawText); + } catch { + bodyJson = null; + } + } + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + bodyText: rawText, + bodyJson, + }; +} diff --git a/frontend/src/modules/browser/automationScripts.ts b/frontend/src/modules/browser/automationScripts.ts index a8165a03..77076f7c 100644 --- a/frontend/src/modules/browser/automationScripts.ts +++ b/frontend/src/modules/browser/automationScripts.ts @@ -1,122 +1,68 @@ -import type { BrowserProfile } from "./types"; +import { + AUTOMATION_SCRIPT_MANIFEST_VERSION, + AUTOMATION_SCRIPT_PACKAGE_FORMAT, + DUAL_INSTANCE_RUNTIME_SCRIPT_ID, + type AutomationScriptRecord, + type AutomationScriptType, +} from "./automationScripts/definitions"; +import { + createAutomationScriptPublicAPIConfig, + prepareAutomationScriptPublicAPIConfigForSave, +} from "./automationScripts/publicApi"; +import { + normalizeAutomationScriptTargetConfig, +} from "./automationScripts/targets"; +import { + buildDefaultAutomationScripts, + buildNotesTemplate, + buildParamsTemplate, + buildScriptTemplate, + buildSelectorTemplate, + createDualInstanceRuntimeScriptDraft, + normalizeDualInstanceRuntimeParamsText, +} from "./automationScripts/builtins"; +import { + normalizeAutomationScriptSource, +} from "./automationScripts/metadata"; -export type AutomationScriptType = "playwright-cdp" | "launch-api"; - -export type AutomationScriptStatus = "draft" | "ready" | "disabled"; - -export type AutomationScriptTargetMode = - | "manual" - | "existing" - | "create" - | "rotate"; - -export interface AutomationScriptSource { - type: string; - uri: string; - ref: string; - path: string; - importedAt: string; -} - -export interface AutomationScriptTargetSelector { - code: string; - profileId: string; - profileName: string; - groupId: string; - keywords: string[]; - tags: string[]; -} - -export interface AutomationScriptTargetConfig { - mode: AutomationScriptTargetMode; - selector: AutomationScriptTargetSelector; - templateSelector: AutomationScriptTargetSelector; - createNameTemplate: string; -} - -export interface AutomationScriptRecord { - packageFormat: string; - manifestVersion: number; - id: string; - name: string; - description: string; - type: AutomationScriptType; - status: AutomationScriptStatus; - entryFile: string; - tags: string[]; - selectorText: string; - paramsText: string; - scriptText: string; - notes: string; - targetConfig: AutomationScriptTargetConfig; - source: AutomationScriptSource; - createdAt: string; - updatedAt: string; -} - -export interface AutomationScriptRunRecord { - id: string; - scriptId: string; - scriptName: string; - scriptType: string; - status: "success" | "failed" | "running"; - summary: string; - error: string; - resultText: string; - startedAt: string; - finishedAt: string; - durationMs: number; -} - -export interface AutomationScriptRunInput { - scriptId: string; - selectorText?: string; - paramsText?: string; - useScriptSelector?: boolean; - useScriptParams?: boolean; - timeoutMs?: number; - launchCode?: string; - startByCodeBeforeRun?: boolean; -} +export * from "./automationScripts/definitions"; +export { + AUTOMATION_SCRIPT_PUBLIC_API_METHOD_OPTIONS, + AUTOMATION_SCRIPT_PUBLIC_API_REQUEST_MODE_OPTIONS, + AUTOMATION_SCRIPT_PUBLIC_API_RESPONSE_MODE_OPTIONS, + applyAutomationScriptPublicAPIVariables, + buildAutomationScriptPublicAPIPath, + buildAutomationScriptPublicAPIRequestExample, + buildAutomationScriptPublicAPIResponseExample, + collectAutomationScriptPublicAPIVariableValues, + createAutomationScriptPublicAPIConfig, + getAutomationScriptPublicAPIRequestModeLabel, + getAutomationScriptPublicAPIResponseModeLabel, + isAutomationScriptPublicAPIVariableName, + normalizeAutomationScriptPublicAPIConfig, + prepareAutomationScriptPublicAPIConfigForSave, + resolveAutomationScriptPublicAPIConfig, + suggestAutomationScriptPublicAPIPath, +} from "./automationScripts/publicApi"; +export { + canRefreshAutomationScriptSource, + getAutomationScriptRefreshLabel, + getAutomationScriptSourceLabel, + getAutomationScriptStatusLabel, + getAutomationScriptTypeLabel, + normalizeAutomationScriptSource, +} from "./automationScripts/metadata"; +export { + createAutomationScriptTargetSelector, + describeAutomationScriptTargetConfig, + findAutomationTargetProfile, + formatAutomationTargetIdentity, + getAutomationScriptTargetModeLabel, + normalizeAutomationScriptTargetConfig, + normalizeAutomationScriptTargetSelector, +} from "./automationScripts/targets"; const AUTOMATION_SCRIPTS_STORAGE_KEY = "automation_scripts_v1"; -export const AUTOMATION_SCRIPT_PACKAGE_FORMAT = "ant-automation-script"; -export const AUTOMATION_SCRIPT_MANIFEST_VERSION = 1; - -export const AUTOMATION_SCRIPT_TYPE_OPTIONS: Array<{ - value: AutomationScriptType; - label: string; -}> = [ - { value: "playwright-cdp", label: "Playwright CDP" }, - { value: "launch-api", label: "Launch API" }, -]; - -export const AUTOMATION_SCRIPT_STATUS_OPTIONS: Array<{ - value: AutomationScriptStatus; - label: string; -}> = [ - { value: "draft", label: "草稿" }, - { value: "ready", label: "可用" }, - { value: "disabled", label: "停用" }, -]; - -export const AUTOMATION_SCRIPT_TARGET_MODE_OPTIONS: Array<{ - value: AutomationScriptTargetMode; - label: string; -}> = [ - { value: "manual", label: "手动 selector" }, - { value: "existing", label: "使用已有实例" }, - { value: "create", label: "按模板新建实例" }, - { value: "rotate", label: "按条件轮询实例" }, -]; - -export const DUAL_INSTANCE_RUNTIME_SCRIPT_ID = "dual-instance-runtime-switch"; -const DUAL_INSTANCE_DEFAULT_CODES = ["BUYER_001", "BUYER_002"] as const; -const DUAL_INSTANCE_DEFAULT_START_URLS = [ - "https://finance.sina.com.cn/", - "https://map.baidu.com/", -] as const; - function nowIso(): string { return new Date().toISOString(); } @@ -131,1393 +77,6 @@ function createScriptId(): string { return `script-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } -function normalizeSource(source: unknown): AutomationScriptSource { - if (!source || typeof source !== "object") { - return { - type: "", - uri: "", - ref: "", - path: "", - importedAt: "", - }; - } - - const raw = source as Partial; - return { - type: typeof raw.type === "string" ? raw.type.trim() : "", - uri: typeof raw.uri === "string" ? raw.uri.trim() : "", - ref: typeof raw.ref === "string" ? raw.ref.trim() : "", - path: typeof raw.path === "string" ? raw.path.trim() : "", - importedAt: - typeof raw.importedAt === "string" ? raw.importedAt.trim() : "", - }; -} - -function normalizeTargetTerms(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - - const deduped = new Set(); - for (const item of value) { - const normalized = String(item || "").trim(); - if (normalized) { - deduped.add(normalized); - } - } - return Array.from(deduped); -} - -function normalizeTargetSelector( - selector: unknown, -): AutomationScriptTargetSelector { - if (!selector || typeof selector !== "object") { - return { - code: "", - profileId: "", - profileName: "", - groupId: "", - keywords: [], - tags: [], - }; - } - - const raw = selector as Partial; - return { - code: - typeof raw.code === "string" - ? raw.code.trim().toUpperCase() - : typeof (selector as { launchCode?: unknown }).launchCode === "string" - ? String((selector as { launchCode?: unknown }).launchCode) - .trim() - .toUpperCase() - : "", - profileId: - typeof raw.profileId === "string" ? raw.profileId.trim() : "", - profileName: - typeof raw.profileName === "string" ? raw.profileName.trim() : "", - groupId: typeof raw.groupId === "string" ? raw.groupId.trim() : "", - keywords: normalizeTargetTerms(raw.keywords), - tags: normalizeTargetTerms(raw.tags), - }; -} - -export function createAutomationScriptTargetSelector(): AutomationScriptTargetSelector { - return { - code: "", - profileId: "", - profileName: "", - groupId: "", - keywords: [], - tags: [], - }; -} - -export function normalizeAutomationScriptTargetConfig( - config: unknown, -): AutomationScriptTargetConfig { - if (!config || typeof config !== "object") { - return { - mode: "manual", - selector: createAutomationScriptTargetSelector(), - templateSelector: createAutomationScriptTargetSelector(), - createNameTemplate: "", - }; - } - - const raw = config as Partial; - const mode: AutomationScriptTargetMode = - raw.mode === "existing" || - raw.mode === "create" || - raw.mode === "rotate" - ? raw.mode - : "manual"; - - return { - mode, - selector: normalizeTargetSelector(raw.selector), - templateSelector: normalizeTargetSelector(raw.templateSelector), - createNameTemplate: - typeof raw.createNameTemplate === "string" - ? raw.createNameTemplate.trim() - : "", - }; -} - -function selectorSummaryParts(selector: AutomationScriptTargetSelector): string[] { - const parts: string[] = []; - if (selector.code) { - parts.push(`Code=${selector.code}`); - } - if (selector.profileName) { - parts.push(`实例=${selector.profileName}`); - } - if (selector.profileId && !selector.code) { - parts.push(`实例ID=${selector.profileId}`); - } - if (selector.groupId) { - parts.push(`分组=${selector.groupId}`); - } - if (selector.tags.length > 0) { - parts.push(`标签=${selector.tags.join(" / ")}`); - } - if (selector.keywords.length > 0) { - parts.push(`关键字=${selector.keywords.join(" / ")}`); - } - return parts; -} - -export function getAutomationScriptTargetModeLabel( - mode: AutomationScriptTargetMode, -): string { - return ( - AUTOMATION_SCRIPT_TARGET_MODE_OPTIONS.find((item) => item.value === mode) - ?.label || mode - ); -} - -function normalizeSelectorCode(value?: string): string { - return String(value || "") - .trim() - .toUpperCase(); -} - -function normalizeSelectorText(value?: string): string { - return String(value || "").trim(); -} - -export function findAutomationTargetProfile( - selector: AutomationScriptTargetSelector, - profiles: BrowserProfile[], -): BrowserProfile | null { - const normalizedProfileId = normalizeSelectorText(selector.profileId); - if (normalizedProfileId) { - const matchedById = profiles.find( - (profile) => normalizeSelectorText(profile.profileId) === normalizedProfileId, - ); - if (matchedById) { - return matchedById; - } - } - - const normalizedCode = normalizeSelectorCode(selector.code); - if (normalizedCode) { - const matchedByCode = profiles.find( - (profile) => normalizeSelectorCode(profile.launchCode) === normalizedCode, - ); - if (matchedByCode) { - return matchedByCode; - } - } - - const normalizedProfileName = normalizeSelectorText(selector.profileName); - if (normalizedProfileName) { - const matchedByName = profiles.find( - (profile) => - normalizeSelectorText(profile.profileName).toLowerCase() === - normalizedProfileName.toLowerCase(), - ); - if (matchedByName) { - return matchedByName; - } - } - - return null; -} - -export function formatAutomationTargetIdentity( - selector: AutomationScriptTargetSelector, - profiles: BrowserProfile[], - options?: { - includeProfileId?: boolean; - fallback?: string; - }, -): string { - const profile = findAutomationTargetProfile(selector, profiles); - const code = normalizeSelectorCode(profile?.launchCode || selector.code); - const profileName = normalizeSelectorText( - profile?.profileName || selector.profileName, - ); - const profileId = normalizeSelectorText(profile?.profileId || selector.profileId); - - const parts = [code, profileName].filter(Boolean); - if (options?.includeProfileId && profileId) { - parts.push(profileId); - } - - if (parts.length > 0) { - return parts.join(" · "); - } - if (profileId) { - return options?.includeProfileId ? profileId : `实例 ID ${profileId}`; - } - - return options?.fallback || "-"; -} - -export function describeAutomationScriptTargetConfig( - config: AutomationScriptTargetConfig, -): string { - switch (config.mode) { - case "existing": { - const parts = selectorSummaryParts(config.selector); - return parts.length > 0 - ? `使用已有实例:${parts.join(" · ")}` - : "使用已有实例"; - } - case "create": { - const parts = selectorSummaryParts(config.templateSelector); - const namePart = config.createNameTemplate - ? `命名=${config.createNameTemplate}` - : ""; - return [ - "按模板新建实例", - ...parts, - namePart, - ] - .filter(Boolean) - .join(" · "); - } - case "rotate": { - const parts = selectorSummaryParts(config.selector); - return parts.length > 0 - ? `按条件轮询实例:${parts.join(" · ")}` - : "按条件轮询实例"; - } - default: - return "手动填写 selector JSON"; - } -} - -export function getAutomationScriptSourceLabel(source: AutomationScriptSource): string { - switch (source.type) { - case "builtin": - return "内置基线"; - case "local-file": - return "本地文件"; - case "local-dir": - return "本地目录"; - case "remote-url": - return "远程 URL"; - case "git": - return "Git"; - case "text": - return "文本导入"; - case "manual": - return "手动维护"; - default: - return source.type || "未标记"; - } -} - -export function canRefreshAutomationScriptSource( - source: AutomationScriptSource, -): boolean { - return ( - source.type === "local-file" || - source.type === "local-dir" || - source.type === "remote-url" || - source.type === "git" - ); -} - -export function getAutomationScriptRefreshLabel( - source: AutomationScriptSource, -): string { - return source.type === "git" ? "重新拉取" : "重新导入"; -} - -export function getAutomationScriptTypeLabel( - type: AutomationScriptType, -): string { - return ( - AUTOMATION_SCRIPT_TYPE_OPTIONS.find((item) => item.value === type)?.label || - type - ); -} - -export function getAutomationScriptStatusLabel( - status: AutomationScriptStatus, -): string { - return ( - AUTOMATION_SCRIPT_STATUS_OPTIONS.find((item) => item.value === status) - ?.label || status - ); -} - -function buildSelectorTemplate(type: AutomationScriptType): string { - if (type === "launch-api") { - return `{ - "code": "BUYER_001" -}`; - } - - return ""; -} - -function buildParamsTemplate(type: AutomationScriptType): string { - if (type === "launch-api") { - return `{ - "startUrls": ["https://example.com"], - "skipDefaultStartUrls": true -}`; - } - - return `{ - "url": "https://www.baidu.com", - "keyword": "OpenAI", - "timeoutMs": 30000, - "waitAfterSearchMs": 1500, - "captureScreenshot": true -}`; -} - -function buildScriptTemplate(type: AutomationScriptType): string { - if (type === "launch-api") { - return `export async function run({ baseUrl, apiKey, selector, params }) { - const response = await fetch(\`\${baseUrl}/api/launch\`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(apiKey ? { 'X-Ant-Api-Key': apiKey } : {}), - }, - body: JSON.stringify({ - selector, - ...(params || {}), - }), - }) - - if (!response.ok) { - throw new Error(\`launch failed: \${response.status}\`) - } - - return await response.json() -}`; - } - - return `module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => { - const targetUrl = - typeof params.url === 'string' && params.url.trim() - ? params.url.trim() - : 'https://www.baidu.com' - const keyword = - typeof params.keyword === 'string' && params.keyword.trim() - ? params.keyword.trim() - : 'OpenAI' - const timeout = - Number.isFinite(Number(params.timeoutMs)) && Number(params.timeoutMs) > 0 - ? Math.round(Number(params.timeoutMs)) - : 30000 - const waitAfterSearchMs = - Number.isFinite(Number(params.waitAfterSearchMs)) && Number(params.waitAfterSearchMs) >= 0 - ? Math.round(Number(params.waitAfterSearchMs)) - : 1500 - - const session = await launch({ - selector, - startUrls: params.startUrls || [targetUrl], - skipDefaultStartUrls: true, - }) - - const connection = await connect(session) - const browser = connection.browser - const context = connection.context || browser.contexts()[0] - const page = connection.page || context.pages()[0] || await context.newPage() - - await page.goto(targetUrl, { - waitUntil: 'domcontentloaded', - timeout, - }) - - const searchInput = page.locator('textarea[name="wd"], input[name="wd"]').first() - await searchInput.waitFor({ - state: 'visible', - timeout, - }) - await searchInput.fill(keyword) - await searchInput.press('Enter').catch(async () => { - const submitButton = page.locator('#su, input[type="submit"]').first() - await submitButton.click({ timeout }) - }) - await page.waitForURL(/wd=/, { timeout }).catch(() => {}) - - if (waitAfterSearchMs > 0) { - await page.waitForTimeout(waitAfterSearchMs) - } - - if (params.captureScreenshot !== false) { - await page.screenshot({ - path: artifact('baidu-search.png'), - fullPage: true, - }) - } - - const title = await page.title() - log('keyword', keyword) - log('title', title) - - return { - ok: true, - summary: \`已在百度搜索 \${keyword}\`, - keyword, - url: page.url(), - title, - } -}`; -} - -function buildNotesTemplate(type: AutomationScriptType): string { - if (type === "launch-api") { - return "适合外部调度器或 HTTP 中台。脚本负责组装 selector 和 launch 参数,不直接接管页面。"; - } - - return "默认示例会启动浏览器并搜索 keyword。首次执行可先选择已有实例,或创建一个新实例后再执行。"; -} - -function buildDualInstanceRuntimeParamsText(codes = [...DUAL_INSTANCE_DEFAULT_CODES]): string { - return `{ - "browsers": [ - { - "code": "${codes[0] || DUAL_INSTANCE_DEFAULT_CODES[0]}", - "skipDefaultStartUrls": true, - "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[0]}"] - }, - { - "code": "${codes[1] || DUAL_INSTANCE_DEFAULT_CODES[1]}", - "skipDefaultStartUrls": true, - "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[1]}"] - } - ], - "timeoutMs": 45000 -}`; -} - -function buildDualInstanceRuntimeScriptText(): string { - return `export async function run({ baseUrl, apiKey, params, log }) { - const normalizeCode = (value, fallback) => - String(value || fallback || "").trim().toUpperCase() - const normalizeStringArray = (value) => - Array.isArray(value) - ? value - .map((item) => String(item || "").trim()) - .filter(Boolean) - : [] - const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => { - const raw = value && typeof value === "object" ? value : {} - const code = normalizeCode(raw.code || raw.launchCode, fallbackCode) - if (!code) { - return null - } - const startUrls = normalizeStringArray(raw.startUrls) - const fallbackUrls = normalizeStringArray(fallbackStartUrls) - const launchArgs = normalizeStringArray(raw.launchArgs) - - return { - code, - skipDefaultStartUrls: - raw.skipDefaultStartUrls !== undefined - ? raw.skipDefaultStartUrls !== false - : defaultSkip, - startUrls: startUrls.length > 0 ? startUrls : fallbackUrls, - launchArgs, - } - } - - const timeoutMs = Number.isFinite(Number(params.timeoutMs)) - ? Math.max(1000, Math.round(Number(params.timeoutMs))) - : 45000 - const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false - - let browsers = Array.isArray(params.browsers) - ? params.browsers - .map((item, index) => - normalizeBrowserInput( - item, - ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_CODES])}[index] || "", - ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_START_URLS])}[index] || [], - defaultSkipDefaultStartUrls, - ), - ) - .filter(Boolean) - : [] - - if (browsers.length === 0) { - browsers = [ - normalizeBrowserInput( - { code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, - ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[0])}, - ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[0]])}, - defaultSkipDefaultStartUrls, - ), - normalizeBrowserInput( - { code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, - ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[1])}, - ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[1]])}, - defaultSkipDefaultStartUrls, - ), - ].filter(Boolean) - } - - if (browsers.length === 0) { - throw new Error("params.browsers 不能为空") - } - - const headers = { - "Content-Type": "application/json", - ...(apiKey ? { "X-Ant-Api-Key": apiKey } : {}), - } - - const post = async (path, payload) => { - const response = await fetch(\`\${baseUrl}\${path}\`, { - method: "POST", - headers, - body: JSON.stringify(payload), - }) - const text = await response.text() - let body = text - try { - body = text ? JSON.parse(text) : null - } catch { - body = text - } - if (!response.ok) { - throw new Error(\`\${path} failed: \${response.status} \${text}\`) - } - return body - } - - const sessions = [] - - for (const browser of browsers) { - const sessionResult = await post("/api/runtime/session", { - selector: { code: browser.code, matchMode: "unique" }, - skipDefaultStartUrls: browser.skipDefaultStartUrls, - ...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}), - ...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}), - timeoutMs, - }) - - sessions.push(sessionResult) - } - - const browserCodes = browsers.map((item) => item.code) - log("browserCodes", browserCodes) - - return { - ok: true, - summary: \`\${browserCodes.length} 个浏览器已就绪:\${browserCodes.join(" / ")}\`, - browserCodes, - sessions, - } -}`; -} - -function normalizeDualInstanceRuntimeParamsText(text: string): string { - const fallback = buildDualInstanceRuntimeParamsText(); - - try { - const parsed = JSON.parse(text); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return fallback; - } - - const raw = parsed as Record; - const topLevelSkipDefaultStartUrls = raw.skipDefaultStartUrls !== false; - const rawBrowsers = Array.isArray(raw.browsers) ? raw.browsers : []; - const browsers = rawBrowsers - .map((item, index) => { - if (!item || typeof item !== "object") { - return null; - } - const entry = item as Record; - const code = normalizeTargetSelector({ - code: - typeof entry.code === "string" - ? entry.code - : typeof entry.launchCode === "string" - ? entry.launchCode - : "", - }).code; - if (!code) { - return null; - } - - const startUrls = Array.isArray(entry.startUrls) - ? entry.startUrls - .map((value) => String(value || "").trim()) - .filter(Boolean) - : []; - const launchArgs = Array.isArray(entry.launchArgs) - ? entry.launchArgs - .map((value) => String(value || "").trim()) - .filter(Boolean) - : []; - - const fallbackStartUrls = DUAL_INSTANCE_DEFAULT_START_URLS[index] - ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] - : []; - - return { - code: code || DUAL_INSTANCE_DEFAULT_CODES[index] || "", - skipDefaultStartUrls: - entry.skipDefaultStartUrls !== undefined - ? entry.skipDefaultStartUrls !== false - : topLevelSkipDefaultStartUrls, - startUrls: startUrls.length > 0 ? startUrls : fallbackStartUrls, - ...(launchArgs.length > 0 ? { launchArgs } : {}), - }; - }) - .filter( - ( - item, - ): item is { - code: string; - skipDefaultStartUrls: boolean; - startUrls: string[]; - launchArgs?: string[]; - } => item !== null, - ); - - const legacyCodes = [ - normalizeTargetSelector({ - code: typeof raw.primaryCode === "string" ? raw.primaryCode : "", - }).code, - normalizeTargetSelector({ - code: typeof raw.secondaryCode === "string" ? raw.secondaryCode : "", - }).code, - ].filter(Boolean); - - const normalizedBrowsers = - browsers.length > 0 - ? browsers - : legacyCodes.length > 0 - ? legacyCodes.map((code, index) => ({ - code, - skipDefaultStartUrls: topLevelSkipDefaultStartUrls, - startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index] - ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] - : [], - })) - : DUAL_INSTANCE_DEFAULT_CODES.map((code, index) => ({ - code, - skipDefaultStartUrls: true, - startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index] - ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] - : [], - })); - - const timeoutMs = - Number.isFinite(Number(raw.timeoutMs)) && Number(raw.timeoutMs) > 0 - ? Math.round(Number(raw.timeoutMs)) - : 45000; - - return JSON.stringify( - { - browsers: normalizedBrowsers, - timeoutMs, - }, - null, - 2, - ); - } catch { - return fallback; - } -} - -function createNewsTxtScriptDraft(): AutomationScriptRecord { - const createdAt = nowIso(); - - return { - packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT, - manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION, - id: "news-query-txt", - name: "查询新闻并写 TXT", - description: "通过 Bing 搜索新闻关键词,提取结果并写入本地 txt 文件。", - type: "playwright-cdp", - status: "ready", - entryFile: "index.cjs", - tags: ["Playwright", "新闻", "TXT"], - selectorText: "", - paramsText: `{ - "keyword": "OpenAI", - "limit": 10, - "timeRange": "week", - "outputFileName": "openai-news.txt", - "timeoutMs": 30000, - "waitAfterLoadMs": 1500, - "captureScreenshot": false -}`, - scriptText: String.raw`const fs = require('fs') - -const DEFAULT_EXCLUDED_DOMAINS = [ - 'zhihu.com', - 'baidu.com', - 'qq.com', - '36kr.com', - 'apifox.com', - 'chatgpt-chinese.com', - 'openwebui.cn', - 'open-openai.com', - 'xiniushu.com', - 'reddit.com', - 'quora.com', - 'tieba.baidu.com', - 'weibo.com', - 'x.com', - 'twitter.com', - 'youtube.com', - 'bilibili.com', - 'douyin.com', - 'xiaohongshu.com', -] - -function normalizeInt(value, fallback, min, max) { - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - return fallback - } - - const rounded = Math.round(parsed) - if (rounded < min) { - return min - } - if (rounded > max) { - return max - } - return rounded -} - -function normalizeText(value) { - return String(value || '').trim() -} - -function normalizeDomainList(value) { - if (!Array.isArray(value)) { - return [] - } - - const deduped = new Set() - for (const item of value) { - const normalized = normalizeText(item).replace(/^https?:\/\//, '').replace(/^www\./, '').toLowerCase() - if (normalized) { - deduped.add(normalized) - } - } - return Array.from(deduped) -} - -function buildDefaultQuery(keyword) { - const normalizedKeyword = normalizeText(keyword) || 'OpenAI' - if (/[\u3400-\u9fff]/.test(normalizedKeyword)) { - return normalizedKeyword + ' 新闻' - } - return normalizedKeyword + ' news' -} - -function buildFallbackQueries(keyword, baseQuery) { - const normalizedKeyword = normalizeText(keyword) || 'OpenAI' - const normalizedBaseQuery = normalizeText(baseQuery) - const candidates = [ - normalizedBaseQuery, - ] - - if (/[\u3400-\u9fff]/.test(normalizedKeyword)) { - candidates.push(normalizedKeyword + ' 最新新闻') - } else { - candidates.push(normalizedKeyword + ' latest news') - } - - const deduped = new Set() - for (const item of candidates) { - const normalized = normalizeText(item) - if (normalized) { - deduped.add(normalized) - } - } - return Array.from(deduped) -} - -function buildSearchQuery(baseQuery, excludedDomains) { - const normalizedBaseQuery = normalizeText(baseQuery) - const normalizedDomains = normalizeDomainList(excludedDomains) - const parts = [normalizedBaseQuery] - - for (const domain of normalizedDomains) { - parts.push('-site:' + domain) - } - - return parts.filter(Boolean).join(' ') -} - -function mapTimeRangeToBingFilter(value) { - switch (normalizeText(value).toLowerCase()) { - case 'day': - case '24h': - case 'today': - return 'ex1:"ez1"' - case 'week': - return 'ex1:"ez2"' - case 'month': - return 'ex1:"ez3"' - default: - return '' - } -} - -function buildSearchURL(query, timeRange, firstResultIndex) { - const searchParams = new URLSearchParams({ q: query }) - const filter = mapTimeRangeToBingFilter(timeRange) - if (filter) { - searchParams.set('filters', filter) - } - if (Number.isFinite(firstResultIndex) && firstResultIndex > 1) { - searchParams.set('first', String(firstResultIndex)) - } - return 'https://www.bing.com/search?' + searchParams.toString() -} - -function splitSnippet(snippet) { - const normalized = normalizeText(snippet) - if (!normalized) { - return { publishedAt: '', summary: '' } - } - - const match = normalized.match(/^([^·]{0,40})\s*·\s*(.+)$/) - if ( - match && - /(前|分钟|小时|天前|周前|月前|昨天|\d{4}|\d{1,2}[/-]\d{1,2})/.test(match[1]) - ) { - return { - publishedAt: normalizeText(match[1]), - summary: normalizeText(match[2]), - } - } - - return { - publishedAt: '', - summary: normalized, - } -} - -function parseHostname(rawUrl) { - const normalized = normalizeText(rawUrl) - if (!normalized) { - return '' - } - - try { - return new URL(normalized).hostname.replace(/^www\./, '').toLowerCase() - } catch { - return '' - } -} - -function parsePathname(rawUrl) { - const normalized = normalizeText(rawUrl) - if (!normalized) { - return '' - } - - try { - const pathname = new URL(normalized).pathname.replace(/\/+/g, '/').toLowerCase() - if (!pathname) { - return '' - } - return pathname === '/' ? pathname : pathname.replace(/\/$/, '') - } catch { - return '' - } -} - -function looksLikeQuestionTitle(title) { - const normalized = normalizeText(title) - if (!normalized) { - return false - } - - if (/[??]/.test(normalized)) { - return true - } - - return /^(如何|为什么|怎么看|怎样|怎么|是否|有没有|谁能|请问|评价|如何评价|如何看待|为什么说)/.test(normalized) -} - -function looksLikeAggregateText(text) { - const normalized = normalizeText(text).toLowerCase() - if (!normalized) { - return false - } - - return /(roundup|digest|flash report|llm news today|ai news today|daily ai news|news today|model releases)/.test(normalized) -} - -function looksLikeListingPath(pathname) { - const normalized = normalizeText(pathname).toLowerCase() - if (!normalized || normalized === '/') { - return false - } - - if (/(^|\/)(tag|tags|topic|topics|category|categories|label|labels|brand|brands)(\/|$)/.test(normalized)) { - return true - } - - if (/(^|\/)(news|latest|headlines|insights)$/.test(normalized)) { - return true - } - - return /\/news\/(brand|brands|topic|topics|tag|tags)(\/|$)/.test(normalized) -} - -function looksLikeListingText(text) { - const normalized = normalizeText(text).toLowerCase() - if (!normalized) { - return false - } - - return /(latest news|breaking headlines|news and insights|news and analysis|everything you need to know|get the latest|最新资讯|最新动态|实时追踪|热点快讯|快讯)/.test(normalized) -} - -function isBlockedHostname(hostname) { - const normalized = normalizeText(hostname).toLowerCase() - if (!normalized) { - return false - } - - const blockedSuffixes = DEFAULT_EXCLUDED_DOMAINS - const blockedKeywords = [ - 'aitrack', - 'aitoolly', - 'aiflashreport', - 'llm-stats', - 'opentools', - ] - - if (blockedSuffixes.some(function (suffix) { - return normalized === suffix || normalized.endsWith('.' + suffix) - })) { - return true - } - - return blockedKeywords.some(function (keyword) { - return normalized.includes(keyword) - }) -} - -function evaluateNewsItem(item) { - const hostname = parseHostname(item.url) - const pathname = parsePathname(item.url) - const summary = normalizeText(item.summary) - const source = normalizeText(item.source) - const reasons = [] - - if (!normalizeText(item.url)) { - reasons.push('missing-url') - } - if (!hostname) { - reasons.push('invalid-url') - } - if (hostname && isBlockedHostname(hostname)) { - reasons.push('blocked-host') - } - if (!source) { - reasons.push('missing-source') - } - if (summary.length < 20) { - reasons.push('summary-too-short') - } - if (looksLikeQuestionTitle(item.title)) { - reasons.push('question-title') - } - if (looksLikeAggregateText(item.title) || looksLikeAggregateText(summary)) { - reasons.push('aggregate-page') - } - if (looksLikeListingPath(pathname) || looksLikeListingText(item.title) || looksLikeListingText(summary)) { - reasons.push('listing-page') - } - - return Object.assign({}, item, { - hostname: hostname, - pathname: pathname, - qualityAccepted: reasons.length === 0, - qualityReasons: reasons, - }) -} - -function formatRejectedReason(reason) { - switch (reason) { - case 'missing-url': - return '缺少链接' - case 'invalid-url': - return '链接无效' - case 'blocked-host': - return '来源站点已过滤' - case 'missing-source': - return '缺少来源' - case 'summary-too-short': - return '摘要过短' - case 'question-title': - return '标题更像问答' - case 'aggregate-page': - return '更像聚合页' - case 'listing-page': - return '更像列表页/专题页' - default: - return reason - } -} - -function formatReport(items, metadata) { - const lines = [ - '新闻抓取结果', - '查询词: ' + metadata.query, - '抓取时间: ' + metadata.generatedAt, - '搜索地址: ' + metadata.searchUrl, - '原始结果: ' + metadata.rawCount, - '通过校验: ' + items.length, - '过滤数量: ' + metadata.rejectedItems.length, - '', - ] - - for (const item of items) { - lines.push(item.rank + '. ' + item.title) - if (item.source) { - lines.push('来源: ' + item.source) - } - if (item.publishedAt) { - lines.push('时间: ' + item.publishedAt) - } - lines.push('链接: ' + item.url) - if (item.summary) { - lines.push('摘要: ' + item.summary) - } - lines.push('') - } - - if (metadata.rejectedItems.length > 0) { - lines.push('被过滤结果(最多展示 5 条)') - lines.push('') - for (const item of metadata.rejectedItems.slice(0, 5)) { - lines.push(item.rank + '. ' + item.title) - if (item.hostname) { - lines.push('站点: ' + item.hostname) - } - lines.push('原因: ' + item.qualityReasons.map(formatRejectedReason).join(' / ')) - lines.push('') - } - } - - return lines.join('\n') -} - -function pickBestAttempt(current, candidate) { - if (!current) { - return candidate - } - - if (candidate.acceptedItems.length !== current.acceptedItems.length) { - return candidate.acceptedItems.length > current.acceptedItems.length ? candidate : current - } - - if (candidate.distinctHostCount !== current.distinctHostCount) { - return candidate.distinctHostCount > current.distinctHostCount ? candidate : current - } - - if (candidate.rawItems.length !== current.rawItems.length) { - return candidate.rawItems.length > current.rawItems.length ? candidate : current - } - - return candidate -} - -module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => { - const timeout = normalizeInt(params.timeoutMs, 30000, 1000, 120000) - const waitAfterLoadMs = normalizeInt(params.waitAfterLoadMs, 1500, 0, 10000) - const limit = normalizeInt(params.limit, 10, 1, 50) - const maxPages = normalizeInt(params.maxPages, 3, 1, 5) - const baseQuery = normalizeText(params.query) || buildDefaultQuery(params.keyword) - const excludedDomains = normalizeDomainList(params.excludeDomains).length > 0 - ? normalizeDomainList(params.excludeDomains) - : DEFAULT_EXCLUDED_DOMAINS - const outputFileName = normalizeText(params.outputFileName) || 'news-results.txt' - const scanLimit = Math.max(10, Math.min(20, limit * 2)) - const startUrls = Array.isArray(params.startUrls) && params.startUrls.length > 0 - ? params.startUrls - : undefined - - const session = await launch({ - selector, - startUrls, - skipDefaultStartUrls: true, - }) - - const connection = await connect(session) - const browser = connection.browser - const context = connection.context || browser.contexts()[0] - const page = await context.newPage() - const closeRunnerPage = async function () { - if (!page.isClosed()) { - await page.close().catch(function () {}) - } - } - - const searchCandidates = buildFallbackQueries(params.keyword, baseQuery) - const minAcceptedCount = Math.min(limit, Math.max(2, Math.ceil(limit * 0.2))) - const minDistinctHostCount = Math.min(3, minAcceptedCount) - let bestAttempt = null - - try { - for (const candidateQuery of searchCandidates) { - const searchQuery = buildSearchQuery(candidateQuery, excludedDomains) - const normalizedItems = [] - const seenUrls = new Set() - let scannedPageCount = 0 - let firstSearchUrl = '' - - for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { - const firstResultIndex = pageIndex * 10 + 1 - const searchUrl = buildSearchURL(searchQuery, params.timeRange, firstResultIndex) - - try { - await page.goto(searchUrl, { - waitUntil: 'domcontentloaded', - timeout, - }) - await page.waitForSelector('li.b_algo', { timeout }) - } catch (error) { - if (pageIndex > 0 && normalizedItems.length > 0) { - break - } - throw error - } - - if (waitAfterLoadMs > 0) { - await page.waitForTimeout(waitAfterLoadMs) - } - - if (!firstSearchUrl) { - firstSearchUrl = page.url() - } - - const pageItems = await page.$$eval('li.b_algo', function (nodes, maxItems) { - const clean = function (value) { - return String(value || '').replace(/\s+/g, ' ').trim() - } - - return nodes - .slice(0, maxItems) - .map(function (node) { - const titleLink = node.querySelector('h2 a') - const title = clean(titleLink && titleLink.textContent) - const url = titleLink ? titleLink.href : '' - const sourceNode = node.querySelector('.tptt') - const source = clean(sourceNode && sourceNode.textContent) - const citeNode = node.querySelector('.b_attribution cite') - const cite = clean(citeNode && citeNode.textContent) - const snippetNode = node.querySelector('.b_caption p') - const snippet = clean(snippetNode && snippetNode.textContent) - - if (!title) { - return null - } - - return { - title, - url, - source: source || cite, - snippet, - } - }) - .filter(Boolean) - }, scanLimit) - - let appendedCount = 0 - for (const item of pageItems) { - const dedupeKey = normalizeText(item.url) - if (!dedupeKey || seenUrls.has(dedupeKey)) { - continue - } - - seenUrls.add(dedupeKey) - normalizedItems.push( - evaluateNewsItem( - Object.assign( - { - rank: normalizedItems.length + 1, - }, - item, - splitSnippet(item.snippet) - ) - ) - ) - appendedCount += 1 - } - - scannedPageCount += 1 - if (appendedCount === 0 || pageItems.length < 8) { - break - } - } - - const acceptedItems = normalizedItems.filter(function (item) { - return item.qualityAccepted - }).slice(0, limit) - const rejectedItems = normalizedItems.filter(function (item) { - return !item.qualityAccepted - }) - const distinctHostCount = new Set( - acceptedItems - .map(function (item) { - return item.hostname - }) - .filter(Boolean) - ).size - - log('searchQuery', searchQuery) - log('rawItemCount', normalizedItems.length) - log('acceptedItemCount', acceptedItems.length) - log('rejectedItemCount', rejectedItems.length) - log('distinctHostCount', distinctHostCount) - log('scannedPageCount', scannedPageCount) - - bestAttempt = pickBestAttempt(bestAttempt, { - baseQuery: candidateQuery, - searchQuery: searchQuery, - searchUrl: firstSearchUrl || page.url(), - rawItems: normalizedItems, - acceptedItems: acceptedItems, - rejectedItems: rejectedItems, - distinctHostCount: distinctHostCount, - scannedPageCount: scannedPageCount, - }) - - if (acceptedItems.length >= minAcceptedCount && distinctHostCount >= minDistinctHostCount) { - break - } - } - } catch (error) { - await closeRunnerPage() - throw error - } - - if (!bestAttempt || bestAttempt.rawItems.length === 0) { - await closeRunnerPage() - throw new Error('未抓到新闻搜索结果,当前页面: ' + page.url()) - } - - const normalizedItems = bestAttempt.rawItems - const acceptedItems = bestAttempt.acceptedItems - const rejectedItems = bestAttempt.rejectedItems - const distinctHostCount = bestAttempt.distinctHostCount - const searchUrl = bestAttempt.searchUrl - const scannedPageCount = bestAttempt.scannedPageCount || 1 - - const outputName = outputFileName.toLowerCase().endsWith('.txt') - ? outputFileName - : outputFileName + '.txt' - const outputPath = artifact(outputName) - const reportText = formatReport(acceptedItems, { - query: bestAttempt.baseQuery, - generatedAt: new Date().toISOString(), - searchUrl: searchUrl, - rawCount: normalizedItems.length, - rejectedItems: rejectedItems, - }) - fs.writeFileSync(outputPath, reportText, 'utf8') - - let screenshotPath = '' - if (params.captureScreenshot === true) { - screenshotPath = artifact('news-search.png') - await page.screenshot({ - path: screenshotPath, - fullPage: true, - }) - } - - log('outputPath', outputPath) - await closeRunnerPage() - - if (acceptedItems.length < minAcceptedCount || distinctHostCount < minDistinctHostCount) { - return { - ok: false, - summary: '新闻结果质量不足,仅 ' + acceptedItems.length + '/' + normalizedItems.length + ' 条通过校验', - error: '搜索结果更像普通搜索、问答页或聚合页,未达到新闻抓取标准', - query: bestAttempt.baseQuery, - searchQuery: bestAttempt.searchQuery, - searchUrl: searchUrl, - outputPath, - screenshotPath, - rawItemCount: normalizedItems.length, - itemCount: acceptedItems.length, - rejectedCount: rejectedItems.length, - distinctHostCount: distinctHostCount, - scannedPageCount: scannedPageCount, - firstTitle: acceptedItems[0] ? acceptedItems[0].title : '', - } - } - - return { - ok: true, - summary: '已筛出 ' + acceptedItems.length + ' 条有效新闻并写入 TXT', - query: bestAttempt.baseQuery, - searchQuery: bestAttempt.searchQuery, - searchUrl: searchUrl, - outputPath, - screenshotPath, - rawItemCount: normalizedItems.length, - itemCount: acceptedItems.length, - rejectedCount: rejectedItems.length, - distinctHostCount: distinctHostCount, - scannedPageCount: scannedPageCount, - firstTitle: acceptedItems[0] ? acceptedItems[0].title : '', - } -}`, - notes: - "脚本会优先使用 Bing 搜索真实新闻结果,并自动追加时间过滤、排除问答/聚合站点、回退查询词和质量校验;只有达到新闻质量门槛时才会判定成功,并把结果写入本地 txt。执行成功后可在结果里的 outputPath 找到文件。", - targetConfig: normalizeAutomationScriptTargetConfig(null), - source: { - type: "builtin", - uri: "repo://backend/internal/automation/default_scripts.go", - ref: "HEAD", - path: "news-query-txt", - importedAt: "", - }, - createdAt, - updatedAt: createdAt, - }; -} - -function createDualInstanceRuntimeScriptDraft(): AutomationScriptRecord { - const createdAt = nowIso(); - - return { - packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT, - manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION, - id: DUAL_INSTANCE_RUNTIME_SCRIPT_ID, - name: "双实例启动与 Runtime 切换", - description: - "通过 Launch API 分别启动两个实例,切换 Runtime 会话后交给 OpenClaw 执行。", - type: "launch-api", - status: "ready", - entryFile: "index.cjs", - tags: ["Launch API", "OpenClaw", "双实例"], - selectorText: "", - paramsText: buildDualInstanceRuntimeParamsText(), - scriptText: buildDualInstanceRuntimeScriptText(), - notes: - "先通过接口启动两个实例并切换 Runtime 会话;随后把实例信息交给 OpenClaw 执行自动化动作。", - targetConfig: normalizeAutomationScriptTargetConfig(null), - source: { - type: "builtin", - uri: "repo://backend/internal/automation/default_scripts.go", - ref: "HEAD", - path: "dual-instance-runtime-switch", - importedAt: "", - }, - createdAt, - updatedAt: createdAt, - }; -} - function normalizeTags(tags: unknown): string[] { if (!Array.isArray(tags)) { return []; @@ -1552,11 +111,10 @@ function normalizeScriptRecord(raw: unknown): AutomationScriptRecord | null { typeof source.updatedAt === "string" && source.updatedAt.trim() ? source.updatedAt : createdAt; - const normalizedSource = normalizeSource(source.source); + const normalizedSource = normalizeAutomationScriptSource(source.source); const normalizedTargetConfig = normalizeAutomationScriptTargetConfig( source.targetConfig, ); - const record: AutomationScriptRecord = { packageFormat: typeof source.packageFormat === "string" && source.packageFormat.trim() @@ -1600,11 +158,20 @@ function normalizeScriptRecord(raw: unknown): AutomationScriptRecord | null { ? source.notes : buildNotesTemplate(type), targetConfig: normalizedTargetConfig, + publicAPI: createAutomationScriptPublicAPIConfig(), source: normalizedSource, createdAt, updatedAt, }; + record.publicAPI = prepareAutomationScriptPublicAPIConfigForSave({ + id: record.id, + name: record.name, + selectorText: record.selectorText, + paramsText: record.paramsText, + publicAPI: source.publicAPI ?? null, + }); + if (record.id === DUAL_INSTANCE_RUNTIME_SCRIPT_ID) { const dualInstanceDraft = createDualInstanceRuntimeScriptDraft(); const usesLegacyDualInstanceScript = @@ -1657,6 +224,7 @@ export function createAutomationScriptDraft( scriptText: buildScriptTemplate(type), notes: buildNotesTemplate(type), targetConfig: normalizeAutomationScriptTargetConfig(null), + publicAPI: createAutomationScriptPublicAPIConfig(), source: { type: "manual", uri: "", @@ -1678,6 +246,12 @@ export function duplicateAutomationScript( id: createScriptId(), name: `${script.name} - 副本`, status: "draft", + publicAPI: { + ...createAutomationScriptPublicAPIConfig(), + requestBodyText: script.publicAPI.requestBodyText, + responseBodyText: script.publicAPI.responseBodyText, + variables: script.publicAPI.variables.map((variable) => ({ ...variable })), + }, createdAt, updatedAt: createdAt, }; @@ -1768,6 +342,13 @@ export function importAutomationScript(text: string): AutomationScriptRecord { typeof parsed.manifest.targetConfig === "object" ? parsed.manifest.targetConfig : null, + publicAPI: + parsed?.publicAPI && typeof parsed.publicAPI === "object" + ? parsed.publicAPI + : parsed?.manifest?.publicAPI && + typeof parsed.manifest.publicAPI === "object" + ? parsed.manifest.publicAPI + : null, source: parsed?.source && typeof parsed.source === "object" ? parsed.source @@ -1790,10 +371,7 @@ export function importAutomationScript(text: string): AutomationScriptRecord { } function buildDefaultScripts(): AutomationScriptRecord[] { - const dualInstanceScript = createDualInstanceRuntimeScriptDraft(); - const newsScript = createNewsTxtScriptDraft(); - - return [newsScript, dualInstanceScript]; + return buildDefaultAutomationScripts(); } function sortScripts( @@ -1866,6 +444,7 @@ export function exportAutomationScript(script: AutomationScriptRecord): string { tags: script.tags, notes: script.notes, targetConfig: script.targetConfig, + publicAPI: script.publicAPI, source: script.source, createdAt: script.createdAt, updatedAt: script.updatedAt, @@ -1877,6 +456,7 @@ export function exportAutomationScript(script: AutomationScriptRecord): string { script: script.scriptText, notes: script.notes, targetConfig: script.targetConfig, + publicAPI: script.publicAPI, source: script.source, }, null, diff --git a/frontend/src/modules/browser/automationScripts/builtins.ts b/frontend/src/modules/browser/automationScripts/builtins.ts new file mode 100644 index 00000000..1f837b73 --- /dev/null +++ b/frontend/src/modules/browser/automationScripts/builtins.ts @@ -0,0 +1,568 @@ +import { + AUTOMATION_SCRIPT_MANIFEST_VERSION, + AUTOMATION_SCRIPT_PACKAGE_FORMAT, + DUAL_INSTANCE_RUNTIME_SCRIPT_ID, + type AutomationScriptRecord, + type AutomationScriptType, +} from "./definitions"; +import { createAutomationScriptPublicAPIConfig } from "./publicApi"; +import { + normalizeAutomationScriptTargetConfig, + normalizeAutomationScriptTargetSelector, +} from "./targets"; + +const BACKEND_BUILTIN_SCRIPT_PLACEHOLDER = `module.exports.run = async () => { + throw new Error('内置脚本源码由后端 demo-library 提供,请在桌面应用后端环境中加载或从脚本包导入。') +}`; + +const DUAL_INSTANCE_DEFAULT_CODES = ["BUYER_001", "BUYER_002"] as const; +const DUAL_INSTANCE_DEFAULT_START_URLS = [ + "https://finance.sina.com.cn/", + "https://map.baidu.com/", +] as const; + +function nowIso(): string { + return new Date().toISOString(); +} + +export function buildSelectorTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return `{ + "code": "BUYER_001" +}`; + } + + return ""; +} + +export function buildParamsTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return `{ + "startUrls": ["https://example.com"], + "skipDefaultStartUrls": true +}`; + } + + return `{ + "url": "https://www.baidu.com", + "keyword": "OpenAI", + "timeoutMs": 30000, + "waitAfterSearchMs": 1500, + "captureScreenshot": true +}`; +} + +export function buildScriptTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return `export async function run({ baseUrl, apiKey, selector, params }) { + const response = await fetch(\`\${baseUrl}/api/launch\`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'X-Ant-Api-Key': apiKey } : {}), + }, + body: JSON.stringify({ + selector, + ...(params || {}), + }), + }) + + if (!response.ok) { + throw new Error(\`launch failed: \${response.status}\`) + } + + return await response.json() +}`; + } + + return `module.exports.run = async ({ useBrowser, browserFetch, selector, params, log, artifact }) => { + const targetUrl = + typeof params.url === 'string' && params.url.trim() + ? params.url.trim() + : 'https://www.baidu.com' + const keyword = + typeof params.keyword === 'string' && params.keyword.trim() + ? params.keyword.trim() + : 'OpenAI' + const timeout = + Number.isFinite(Number(params.timeoutMs)) && Number(params.timeoutMs) > 0 + ? Math.round(Number(params.timeoutMs)) + : 30000 + const waitAfterSearchMs = + Number.isFinite(Number(params.waitAfterSearchMs)) && Number(params.waitAfterSearchMs) >= 0 + ? Math.round(Number(params.waitAfterSearchMs)) + : 1500 + + const runtime = await useBrowser({ + selector, + startUrls: params.startUrls || [targetUrl], + skipDefaultStartUrls: true, + url: targetUrl, + timeoutMs: timeout, + reuseCurrentPage: true, + }) + const page = runtime.page + + const searchInput = page.locator('textarea[name="wd"], input[name="wd"]').first() + await searchInput.waitFor({ + state: 'visible', + timeout, + }) + await searchInput.fill(keyword) + await searchInput.press('Enter').catch(async () => { + const submitButton = page.locator('#su, input[type="submit"]').first() + await submitButton.click({ timeout }) + }) + await page.waitForURL(/wd=/, { timeout }).catch(() => {}) + + if (waitAfterSearchMs > 0) { + await page.waitForTimeout(waitAfterSearchMs) + } + + if (params.captureScreenshot !== false) { + await page.screenshot({ + path: artifact('baidu-search.png'), + fullPage: true, + }) + } + + const title = await page.title() + let apiResult = null + const apiUrl = typeof params.apiUrl === 'string' ? params.apiUrl.trim() : '' + if (apiUrl) { + const apiRequest = { + url: apiUrl, + method: params.apiBody === undefined ? 'GET' : 'POST', + timeoutMs: timeout, + } + if (params.apiBody !== undefined) { + apiRequest.json = params.apiBody + } + apiResult = await browserFetch(page, apiRequest) + } + log('keyword', keyword) + log('title', title) + + return { + ok: true, + summary: \`已在百度搜索 \${keyword}\`, + keyword, + url: page.url(), + title, + apiResult, + } +}`; +} + +export function buildNotesTemplate(type: AutomationScriptType): string { + if (type === "launch-api") { + return "适合外部调度器或 HTTP 中台。脚本负责组装 selector 和 launch 参数,不直接接管页面。"; + } + + return "默认示例使用 useBrowser 启动并接管页面;需要调用站内接口时传 apiUrl/apiBody,会通过 browserFetch 在浏览器上下文发起请求。"; +} + +function buildDualInstanceRuntimeParamsText( + codes = [...DUAL_INSTANCE_DEFAULT_CODES], +): string { + return `{ + "browsers": [ + { + "code": "${codes[0] || DUAL_INSTANCE_DEFAULT_CODES[0]}", + "skipDefaultStartUrls": true, + "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[0]}"] + }, + { + "code": "${codes[1] || DUAL_INSTANCE_DEFAULT_CODES[1]}", + "skipDefaultStartUrls": true, + "startUrls": ["${DUAL_INSTANCE_DEFAULT_START_URLS[1]}"] + } + ], + "timeoutMs": 45000 +}`; +} + +function buildDualInstanceRuntimeScriptText(): string { + return `export async function run({ baseUrl, apiKey, params, log }) { + const normalizeCode = (value, fallback) => + String(value || fallback || "").trim().toUpperCase() + const normalizeStringArray = (value) => + Array.isArray(value) + ? value + .map((item) => String(item || "").trim()) + .filter(Boolean) + : [] + const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => { + const raw = value && typeof value === "object" ? value : {} + const code = normalizeCode(raw.code || raw.launchCode, fallbackCode) + if (!code) { + return null + } + const startUrls = normalizeStringArray(raw.startUrls) + const fallbackUrls = normalizeStringArray(fallbackStartUrls) + const launchArgs = normalizeStringArray(raw.launchArgs) + + return { + code, + skipDefaultStartUrls: + raw.skipDefaultStartUrls !== undefined + ? raw.skipDefaultStartUrls !== false + : defaultSkip, + startUrls: startUrls.length > 0 ? startUrls : fallbackUrls, + launchArgs, + } + } + + const timeoutMs = Number.isFinite(Number(params.timeoutMs)) + ? Math.max(1000, Math.round(Number(params.timeoutMs))) + : 45000 + const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false + + let browsers = Array.isArray(params.browsers) + ? params.browsers + .map((item, index) => + normalizeBrowserInput( + item, + ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_CODES])}[index] || "", + ${JSON.stringify([...DUAL_INSTANCE_DEFAULT_START_URLS])}[index] || [], + defaultSkipDefaultStartUrls, + ), + ) + .filter(Boolean) + : [] + + if (browsers.length === 0) { + browsers = [ + normalizeBrowserInput( + { code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, + ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[0])}, + ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[0]])}, + defaultSkipDefaultStartUrls, + ), + normalizeBrowserInput( + { code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls }, + ${JSON.stringify(DUAL_INSTANCE_DEFAULT_CODES[1])}, + ${JSON.stringify([DUAL_INSTANCE_DEFAULT_START_URLS[1]])}, + defaultSkipDefaultStartUrls, + ), + ].filter(Boolean) + } + + if (browsers.length === 0) { + throw new Error("params.browsers 不能为空") + } + + const headers = { + "Content-Type": "application/json", + ...(apiKey ? { "X-Ant-Api-Key": apiKey } : {}), + } + + const post = async (path, payload) => { + const response = await fetch(\`\${baseUrl}\${path}\`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }) + const text = await response.text() + let body = text + try { + body = text ? JSON.parse(text) : null + } catch { + body = text + } + if (!response.ok) { + throw new Error(\`\${path} failed: \${response.status} \${text}\`) + } + return body + } + + const sessions = [] + + for (const browser of browsers) { + const sessionResult = await post("/api/runtime/session", { + selector: { code: browser.code, matchMode: "unique" }, + skipDefaultStartUrls: browser.skipDefaultStartUrls, + ...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}), + ...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}), + timeoutMs, + }) + + sessions.push(sessionResult) + } + + const browserCodes = browsers.map((item) => item.code) + log("browserCodes", browserCodes) + + return { + ok: true, + summary: \`\${browserCodes.length} 个浏览器已就绪:\${browserCodes.join(" / ")}\`, + browserCodes, + sessions, + } +}`; +} + +export function normalizeDualInstanceRuntimeParamsText(text: string): string { + const fallback = buildDualInstanceRuntimeParamsText(); + + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return fallback; + } + + const raw = parsed as Record; + const topLevelSkipDefaultStartUrls = raw.skipDefaultStartUrls !== false; + const rawBrowsers = Array.isArray(raw.browsers) ? raw.browsers : []; + const browsers = rawBrowsers + .map((item, index) => { + if (!item || typeof item !== "object") { + return null; + } + const entry = item as Record; + const code = normalizeAutomationScriptTargetSelector({ + code: + typeof entry.code === "string" + ? entry.code + : typeof entry.launchCode === "string" + ? entry.launchCode + : "", + }).code; + if (!code) { + return null; + } + + const startUrls = Array.isArray(entry.startUrls) + ? entry.startUrls + .map((value) => String(value || "").trim()) + .filter(Boolean) + : []; + const launchArgs = Array.isArray(entry.launchArgs) + ? entry.launchArgs + .map((value) => String(value || "").trim()) + .filter(Boolean) + : []; + + const fallbackStartUrls = DUAL_INSTANCE_DEFAULT_START_URLS[index] + ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] + : []; + + return { + code: code || DUAL_INSTANCE_DEFAULT_CODES[index] || "", + skipDefaultStartUrls: + entry.skipDefaultStartUrls !== undefined + ? entry.skipDefaultStartUrls !== false + : topLevelSkipDefaultStartUrls, + startUrls: startUrls.length > 0 ? startUrls : fallbackStartUrls, + ...(launchArgs.length > 0 ? { launchArgs } : {}), + }; + }) + .filter( + ( + item, + ): item is { + code: string; + skipDefaultStartUrls: boolean; + startUrls: string[]; + launchArgs?: string[]; + } => item !== null, + ); + + const legacyCodes = [ + normalizeAutomationScriptTargetSelector({ + code: typeof raw.primaryCode === "string" ? raw.primaryCode : "", + }).code, + normalizeAutomationScriptTargetSelector({ + code: typeof raw.secondaryCode === "string" ? raw.secondaryCode : "", + }).code, + ].filter(Boolean); + + const normalizedBrowsers = + browsers.length > 0 + ? browsers + : legacyCodes.length > 0 + ? legacyCodes.map((code, index) => ({ + code, + skipDefaultStartUrls: topLevelSkipDefaultStartUrls, + startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index] + ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] + : [], + })) + : DUAL_INSTANCE_DEFAULT_CODES.map((code, index) => ({ + code, + skipDefaultStartUrls: true, + startUrls: DUAL_INSTANCE_DEFAULT_START_URLS[index] + ? [DUAL_INSTANCE_DEFAULT_START_URLS[index]] + : [], + })); + + const timeoutMs = + Number.isFinite(Number(raw.timeoutMs)) && Number(raw.timeoutMs) > 0 + ? Math.round(Number(raw.timeoutMs)) + : 45000; + + return JSON.stringify( + { + browsers: normalizedBrowsers, + timeoutMs, + }, + null, + 2, + ); + } catch { + return fallback; + } +} + +export function createNewsTxtScriptDraft(): AutomationScriptRecord { + const createdAt = nowIso(); + + return { + packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT, + manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION, + id: "news-query-txt", + name: "查询新闻并写 TXT", + description: "通过 Bing 搜索新闻关键词,提取结果并写入本地 txt 文件。", + type: "playwright-cdp", + status: "ready", + entryFile: "index.cjs", + tags: ["Playwright", "新闻", "TXT"], + selectorText: "", + paramsText: `{ + "keyword": "OpenAI", + "limit": 10, + "timeRange": "week", + "outputFileName": "openai-news.txt", + "timeoutMs": 30000, + "waitAfterLoadMs": 1500, + "captureScreenshot": false +}`, + scriptText: BACKEND_BUILTIN_SCRIPT_PLACEHOLDER, + notes: + "脚本会优先使用 Bing 搜索真实新闻结果,并自动追加时间过滤、排除问答/聚合站点、回退查询词和质量校验;只有达到新闻质量门槛时才会判定成功,并把结果写入本地 txt。执行成功后可在结果里的 outputPath 找到文件。", + targetConfig: normalizeAutomationScriptTargetConfig(null), + publicAPI: createAutomationScriptPublicAPIConfig(), + source: { + type: "builtin", + uri: "repo://backend/internal/automation/demo-library/news-query-txt", + ref: "HEAD", + path: "news-query-txt", + importedAt: "", + }, + createdAt, + updatedAt: createdAt, + }; +} + +export function createDualInstanceRuntimeScriptDraft(): AutomationScriptRecord { + const createdAt = nowIso(); + + return { + packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT, + manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION, + id: DUAL_INSTANCE_RUNTIME_SCRIPT_ID, + name: "双实例启动与 Runtime 切换", + description: + "通过 Launch API 分别启动两个实例,切换 Runtime 会话后交给 OpenClaw 执行。", + type: "launch-api", + status: "ready", + entryFile: "index.cjs", + tags: ["Launch API", "OpenClaw", "双实例"], + selectorText: "", + paramsText: buildDualInstanceRuntimeParamsText(), + scriptText: buildDualInstanceRuntimeScriptText(), + notes: + "先通过接口启动两个实例并切换 Runtime 会话;随后把实例信息交给 OpenClaw 执行自动化动作。", + targetConfig: normalizeAutomationScriptTargetConfig(null), + publicAPI: createAutomationScriptPublicAPIConfig(), + source: { + type: "builtin", + uri: "repo://backend/internal/automation/demo-library/dual-instance-runtime-switch", + ref: "HEAD", + path: "dual-instance-runtime-switch", + importedAt: "", + }, + createdAt, + updatedAt: createdAt, + }; +} + +export function createWebImageGenerateDownloadScriptDraft(): AutomationScriptRecord { + const createdAt = nowIso(); + + return { + packageFormat: AUTOMATION_SCRIPT_PACKAGE_FORMAT, + manifestVersion: AUTOMATION_SCRIPT_MANIFEST_VERSION, + id: "web-image-generate-download", + name: "网页图片生成并下载", + description: + "打开指定网页,创建新会话,发送图片生成消息,等待图片生成后下载图片。当前是等待补充页面信息的脚手架。", + type: "playwright-cdp", + status: "draft", + entryFile: "index.cjs", + tags: ["Playwright", "图片生成", "下载", "脚手架"], + selectorText: "", + paramsText: `{ + "pageUrl": "https://chatgpt.com/", + "prompt": "A cinematic chrome ant browser mascot, premium product lighting", + "outputFileName": "generated-image.png", + "selectors": { + "newSessionButton": "", + "promptInput": "#prompt-textarea[contenteditable=\"true\"], textarea[name=\"prompt-textarea\"]", + "sendButton": "button[data-testid=\"send-button\"], button[aria-label*=\"发送\"], button.composer-submit-button-color", + "generatedImage": "img[src*=\"/backend-api/estuary/content\"], img[alt*=\"已生成图片\"], img[src*=\"oaiusercontent\"], img[src*=\"oaidalleapiprodscus\"], img[alt*=\"生成\"], img[alt*=\"image\" i]", + "downloadButton": "" + }, + "timeoutMs": 300000, + "waitAfterLoadMs": 1200, + "settleMs": 2500, + "captureScreenshot": false +}`, + scriptText: BACKEND_BUILTIN_SCRIPT_PLACEHOLDER, + notes: + "脚本默认打开 ChatGPT,输入图片生成提示词并发送;等待 img[src*=\"/backend-api/estuary/content\"] 或 alt 包含“已生成图片”的结果出现后,使用页面登录态读取图片地址并保存到本地。", + targetConfig: normalizeAutomationScriptTargetConfig(null), + publicAPI: { + ...createAutomationScriptPublicAPIConfig(), + enabled: true, + path: "image/chatgpt-generate-download", + timeoutMs: 300000, + requestBodyText: `{ + "params": { + "prompt": "{{prompt}}" + } +}`, + responseBodyText: `{ + "ok": true, + "outputPath": "\${artifactsDir}/generated-image.png", + "downloadAddress": "\${artifactsDir}/generated-image.png" +}`, + variables: [ + { + name: "prompt", + defaultValue: + "A cinematic chrome ant browser mascot, premium product lighting", + description: "发送到 ChatGPT 的图片生成提示词。", + required: true, + }, + ], + }, + source: { + type: "builtin", + uri: "repo://backend/internal/automation/demo-library/web-image-generate-download", + ref: "HEAD", + path: "web-image-generate-download", + importedAt: "", + }, + createdAt, + updatedAt: createdAt, + }; +} + +export function buildDefaultAutomationScripts(): AutomationScriptRecord[] { + return [ + createNewsTxtScriptDraft(), + createDualInstanceRuntimeScriptDraft(), + createWebImageGenerateDownloadScriptDraft(), + ]; +} diff --git a/frontend/src/modules/browser/automationScripts/definitions.ts b/frontend/src/modules/browser/automationScripts/definitions.ts new file mode 100644 index 00000000..8d4a3f4a --- /dev/null +++ b/frontend/src/modules/browser/automationScripts/definitions.ts @@ -0,0 +1,142 @@ +export type AutomationScriptType = "playwright-cdp" | "launch-api"; + +export type AutomationScriptStatus = "draft" | "ready" | "disabled"; + +export type AutomationScriptTargetMode = + | "manual" + | "existing" + | "create" + | "rotate"; + +export type AutomationScriptPublicAPIRequestMode = + | "standard" + | "params-only"; + +export type AutomationScriptPublicAPIResponseMode = + | "envelope" + | "result-only"; + +export interface AutomationScriptPublicAPIVariable { + name: string; + defaultValue: string; + description: string; + required: boolean; +} + +export interface AutomationScriptSource { + type: string; + uri: string; + ref: string; + path: string; + importedAt: string; +} + +export interface AutomationScriptTargetSelector { + code: string; + profileId: string; + profileName: string; + groupId: string; + keywords: string[]; + tags: string[]; +} + +export interface AutomationScriptTargetConfig { + mode: AutomationScriptTargetMode; + selector: AutomationScriptTargetSelector; + templateSelector: AutomationScriptTargetSelector; + createNameTemplate: string; +} + +export interface AutomationScriptPublicAPIConfig { + enabled: boolean; + method: "POST"; + path: string; + requestMode: AutomationScriptPublicAPIRequestMode; + responseMode: AutomationScriptPublicAPIResponseMode; + timeoutMs: number; + requestBodyText: string; + responseBodyText: string; + variables: AutomationScriptPublicAPIVariable[]; +} + +export interface AutomationScriptRecord { + packageFormat: string; + manifestVersion: number; + id: string; + name: string; + description: string; + type: AutomationScriptType; + status: AutomationScriptStatus; + entryFile: string; + tags: string[]; + selectorText: string; + paramsText: string; + scriptText: string; + notes: string; + targetConfig: AutomationScriptTargetConfig; + publicAPI: AutomationScriptPublicAPIConfig; + source: AutomationScriptSource; + createdAt: string; + updatedAt: string; +} + +export interface AutomationScriptRunRecord { + id: string; + scriptId: string; + scriptName: string; + scriptType: string; + status: "success" | "failed" | "running"; + summary: string; + error: string; + resultText: string; + startedAt: string; + finishedAt: string; + durationMs: number; +} + +export interface AutomationScriptRunInput { + scriptId: string; + selectorText?: string; + targetInput?: Record; + paramsText?: string; + useScriptSelector?: boolean; + useScriptParams?: boolean; + timeoutMs?: number; + launchCode?: string; + startByCodeBeforeRun?: boolean; +} + +export const AUTOMATION_SCRIPT_PACKAGE_FORMAT = "ant-automation-script"; +export const AUTOMATION_SCRIPT_MANIFEST_VERSION = 1; +export const AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH = + "/api/automation/hooks"; +export const AUTOMATION_SCRIPT_PUBLIC_API_DEFAULT_TIMEOUT_MS = 300000; + +export const AUTOMATION_SCRIPT_TYPE_OPTIONS: Array<{ + value: AutomationScriptType; + label: string; +}> = [ + { value: "playwright-cdp", label: "Playwright CDP" }, + { value: "launch-api", label: "Launch API" }, +]; + +export const AUTOMATION_SCRIPT_STATUS_OPTIONS: Array<{ + value: AutomationScriptStatus; + label: string; +}> = [ + { value: "draft", label: "草稿" }, + { value: "ready", label: "可用" }, + { value: "disabled", label: "停用" }, +]; + +export const AUTOMATION_SCRIPT_TARGET_MODE_OPTIONS: Array<{ + value: AutomationScriptTargetMode; + label: string; +}> = [ + { value: "manual", label: "传入实例" }, + { value: "existing", label: "传入实例" }, + { value: "create", label: "按模板新建实例" }, + { value: "rotate", label: "按条件轮询实例" }, +]; + +export const DUAL_INSTANCE_RUNTIME_SCRIPT_ID = "dual-instance-runtime-switch"; diff --git a/frontend/src/modules/browser/automationScripts/metadata.ts b/frontend/src/modules/browser/automationScripts/metadata.ts new file mode 100644 index 00000000..07296276 --- /dev/null +++ b/frontend/src/modules/browser/automationScripts/metadata.ts @@ -0,0 +1,91 @@ +import { + AUTOMATION_SCRIPT_STATUS_OPTIONS, + AUTOMATION_SCRIPT_TYPE_OPTIONS, + type AutomationScriptSource, + type AutomationScriptStatus, + type AutomationScriptType, +} from "./definitions"; + +export function normalizeAutomationScriptSource( + source: unknown, +): AutomationScriptSource { + if (!source || typeof source !== "object") { + return { + type: "", + uri: "", + ref: "", + path: "", + importedAt: "", + }; + } + + const raw = source as Partial; + return { + type: typeof raw.type === "string" ? raw.type.trim() : "", + uri: typeof raw.uri === "string" ? raw.uri.trim() : "", + ref: typeof raw.ref === "string" ? raw.ref.trim() : "", + path: typeof raw.path === "string" ? raw.path.trim() : "", + importedAt: + typeof raw.importedAt === "string" ? raw.importedAt.trim() : "", + }; +} + +export function getAutomationScriptSourceLabel(source: AutomationScriptSource): string { + switch (source.type) { + case "builtin": + return "内置基线"; + case "local-file": + return "本地文件"; + case "local-dir": + return "本地目录"; + case "remote-url": + return "远程 URL"; + case "git": + return "Git"; + case "text": + return "文本导入"; + case "manual": + return "手动维护"; + default: + return source.type || "未标记"; + } +} + +export function canRefreshAutomationScriptSource( + source: AutomationScriptSource, +): boolean { + return ( + source.type === "builtin" || + source.type === "local-file" || + source.type === "local-dir" || + source.type === "remote-url" || + source.type === "git" + ); +} + +export function getAutomationScriptRefreshLabel( + source: AutomationScriptSource, +): string { + if (source.type === "builtin") { + return "恢复基线"; + } + return source.type === "git" ? "重新拉取" : "重新导入"; +} + +export function getAutomationScriptTypeLabel( + type: AutomationScriptType, +): string { + return ( + AUTOMATION_SCRIPT_TYPE_OPTIONS.find((item) => item.value === type)?.label || + type + ); +} + +export function getAutomationScriptStatusLabel( + status: AutomationScriptStatus, +): string { + return ( + AUTOMATION_SCRIPT_STATUS_OPTIONS.find((item) => item.value === status) + ?.label || status + ); +} diff --git a/frontend/src/modules/browser/automationScripts/publicApi.ts b/frontend/src/modules/browser/automationScripts/publicApi.ts new file mode 100644 index 00000000..399f5260 --- /dev/null +++ b/frontend/src/modules/browser/automationScripts/publicApi.ts @@ -0,0 +1,445 @@ +import { + AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH, + AUTOMATION_SCRIPT_PUBLIC_API_DEFAULT_TIMEOUT_MS, + type AutomationScriptPublicAPIConfig, + type AutomationScriptPublicAPIRequestMode, + type AutomationScriptPublicAPIResponseMode, + type AutomationScriptRecord, +} from "./definitions"; +import { + normalizeAutomationScriptPublicAPIVariableList, + safeParseAutomationScriptPublicAPIJSONObject, +} from "./publicApiUtils"; + +export { + applyAutomationScriptPublicAPIVariables, + collectAutomationScriptPublicAPIVariableValues, + isAutomationScriptPublicAPIVariableName, +} from "./publicApiUtils"; + +export const AUTOMATION_SCRIPT_PUBLIC_API_METHOD_OPTIONS = [ + { value: "POST", label: "POST" }, +] as const; + +export const AUTOMATION_SCRIPT_PUBLIC_API_REQUEST_MODE_OPTIONS: Array<{ + value: AutomationScriptPublicAPIRequestMode; + label: string; +}> = [ + { value: "standard", label: "标准请求" }, + { value: "params-only", label: "仅透传 params" }, +]; + +export const AUTOMATION_SCRIPT_PUBLIC_API_RESPONSE_MODE_OPTIONS: Array<{ + value: AutomationScriptPublicAPIResponseMode; + label: string; +}> = [ + { value: "envelope", label: "标准信封" }, + { value: "result-only", label: "仅返回 result" }, +]; + +function normalizeAutomationScriptPublicAPIPathSegment(value: string): string { + let result = ""; + let lastDash = false; + + for (const char of value.trim()) { + const lower = char.toLowerCase(); + if ( + (lower >= "a" && lower <= "z") || + (lower >= "0" && lower <= "9") + ) { + result += lower; + lastDash = false; + continue; + } + if (lower === "-" || lower === "_" || lower === ".") { + result += lower; + lastDash = false; + continue; + } + if (!lastDash) { + result += "-"; + lastDash = true; + } + } + + return result.replace(/^-+|-+$/g, ""); +} + +function normalizeAutomationScriptPublicAPIPath(value: unknown): string { + const source = String(value || "") + .trim() + .replace(/\\/g, "/"); + if (!source) { + return ""; + } + + const lower = source.toLowerCase(); + const lowerBase = AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH.toLowerCase(); + const trimmed = + lower.startsWith(`${lowerBase}/`) + ? source.slice(AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH.length + 1) + : lower.startsWith(`${lowerBase.slice(1)}/`) + ? source.slice(AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH.length) + : source; + + const segments = trimmed + .split("/") + .map((item) => normalizeAutomationScriptPublicAPIPathSegment(item)) + .filter(Boolean); + + return segments.join("/"); +} + +export function buildAutomationScriptPublicAPIPath(path: string): string { + const normalizedPath = normalizeAutomationScriptPublicAPIPath(path); + return normalizedPath + ? `${AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH}/${normalizedPath}` + : AUTOMATION_SCRIPT_PUBLIC_API_BASE_PATH; +} + +export function suggestAutomationScriptPublicAPIPath( + script: Pick, +): string { + const namePath = normalizeAutomationScriptPublicAPIPath(script.name); + if (namePath) { + return namePath; + } + return normalizeAutomationScriptPublicAPIPath(script.id); +} + +export function createAutomationScriptPublicAPIConfig(): AutomationScriptPublicAPIConfig { + return { + enabled: false, + method: "POST", + path: "", + requestMode: "standard", + responseMode: "envelope", + timeoutMs: AUTOMATION_SCRIPT_PUBLIC_API_DEFAULT_TIMEOUT_MS, + requestBodyText: "", + responseBodyText: "", + variables: [], + }; +} + +export function normalizeAutomationScriptPublicAPIConfig( + config: unknown, +): AutomationScriptPublicAPIConfig { + if (!config || typeof config !== "object") { + return createAutomationScriptPublicAPIConfig(); + } + + const raw = config as Partial; + const timeoutMs = Number.isFinite(Number(raw.timeoutMs)) + ? Math.round(Number(raw.timeoutMs)) + : AUTOMATION_SCRIPT_PUBLIC_API_DEFAULT_TIMEOUT_MS; + + return { + enabled: raw.enabled === true, + method: "POST", + path: normalizeAutomationScriptPublicAPIPath(raw.path), + requestMode: raw.requestMode === "params-only" ? "params-only" : "standard", + responseMode: + raw.responseMode === "result-only" ? "result-only" : "envelope", + timeoutMs: + timeoutMs < 1000 + ? 1000 + : timeoutMs > 30 * 60 * 1000 + ? 30 * 60 * 1000 + : timeoutMs, + requestBodyText: + typeof raw.requestBodyText === "string" ? raw.requestBodyText.trim() : "", + responseBodyText: + typeof raw.responseBodyText === "string" + ? raw.responseBodyText.trim() + : "", + variables: normalizeAutomationScriptPublicAPIVariableList( + (raw as { variables?: unknown }).variables, + ), + }; +} + +function isPlainAutomationJSONObject( + value: unknown, +): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function hasSameAutomationJSONShape( + left: Record, + right: Record, +): boolean { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + for (let index = 0; index < leftKeys.length; index += 1) { + if (leftKeys[index] !== rightKeys[index]) { + return false; + } + } + + return leftKeys.every((key) => { + const leftValue = left[key]; + const rightValue = right[key]; + + if (Array.isArray(leftValue) || Array.isArray(rightValue)) { + return Array.isArray(leftValue) && Array.isArray(rightValue); + } + if ( + isPlainAutomationJSONObject(leftValue) && + isPlainAutomationJSONObject(rightValue) + ) { + return hasSameAutomationJSONShape(leftValue, rightValue); + } + return true; + }); +} + +function buildAutomationScriptPublicAPIDefaultRequestExample( + script: Pick, + config: AutomationScriptPublicAPIConfig, +): string { + const params = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText) || {}; + + return JSON.stringify( + { + code: "", + params, + timeoutMs: config.timeoutMs, + }, + null, + 2, + ); +} + +function buildAutomationScriptPublicAPIDefaultResponseExample(): string { + return JSON.stringify( + { + ok: true, + status: "success", + message: "已返回脚本结果", + data: { + verificationCode: "429792", + }, + }, + null, + 2, + ); +} + + +function isLegacyAutomationScriptPublicAPIRequestExample( + script: Pick, + parsedBody: Record, +): boolean { + const allowedLegacyKeys = new Set(["code", "launchCode", "param", "params", "timeoutMs"]); + if (Object.keys(parsedBody).some((key) => !allowedLegacyKeys.has(key))) { + return false; + } + + const targetCode = String(parsedBody.code || parsedBody.launchCode || "").trim(); + if (targetCode) { + return false; + } + + const paramsValue = "param" in parsedBody ? parsedBody.param : parsedBody.params; + if (paramsValue !== undefined && !isPlainAutomationJSONObject(paramsValue)) { + return false; + } + + const expectedParams = safeParseAutomationScriptPublicAPIJSONObject(script.paramsText); + if ( + expectedParams && + paramsValue !== undefined && + !hasSameAutomationJSONShape(paramsValue, expectedParams) + ) { + return false; + } + + return true; +} + +function shouldUseDerivedAutomationScriptPublicAPIRequestBody( + script: Pick, + config: AutomationScriptPublicAPIConfig, +): boolean { + const sourceText = config.requestBodyText.trim(); + if (!sourceText) { + return true; + } + + if ( + sourceText === + buildAutomationScriptPublicAPIDefaultRequestExample(script, { + ...config, + requestBodyText: "", + }) + ) { + return true; + } + + const parsedBody = safeParseAutomationScriptPublicAPIJSONObject(sourceText); + if (!parsedBody) { + return false; + } + + if (isLegacyAutomationScriptPublicAPIRequestExample(script, parsedBody)) { + return true; + } + + const allowedKeys = new Set(["code", "params", "timeoutMs"]); + if (Object.keys(parsedBody).some((key) => !allowedKeys.has(key))) { + return false; + } + + if (!("code" in parsedBody)) { + return false; + } + + const targetCode = String(parsedBody.code || "").trim(); + if (targetCode) { + return false; + } + + const paramsValue = parsedBody.params; + if (paramsValue !== undefined && !isPlainAutomationJSONObject(paramsValue)) { + return false; + } + + const expectedParams = safeParseAutomationScriptPublicAPIJSONObject( + script.paramsText, + ); + if ( + expectedParams && + paramsValue !== undefined && + !hasSameAutomationJSONShape(paramsValue, expectedParams) + ) { + return false; + } + + if ( + "timeoutMs" in parsedBody && + !Number.isFinite(Number(parsedBody.timeoutMs)) + ) { + return false; + } + + return true; +} + +function shouldUseDerivedAutomationScriptPublicAPIResponseBody( + config: AutomationScriptPublicAPIConfig, +): boolean { + const sourceText = config.responseBodyText.trim(); + if (!sourceText) { + return true; + } + + return ( + sourceText === + buildAutomationScriptPublicAPIDefaultResponseExample() + ); +} + +export function buildAutomationScriptPublicAPIRequestExample( + script: Pick, + config: AutomationScriptPublicAPIConfig, +): string { + if (!shouldUseDerivedAutomationScriptPublicAPIRequestBody(script, config)) { + return config.requestBodyText.trim(); + } + + return buildAutomationScriptPublicAPIDefaultRequestExample(script, { + ...config, + requestBodyText: "", + }); +} + +export function buildAutomationScriptPublicAPIResponseExample( + _script: Pick, + config: AutomationScriptPublicAPIConfig, +): string { + if (!shouldUseDerivedAutomationScriptPublicAPIResponseBody(config)) { + return config.responseBodyText.trim(); + } + + return buildAutomationScriptPublicAPIDefaultResponseExample(); +} + +export function prepareAutomationScriptPublicAPIConfigForSave( + script: Pick< + AutomationScriptRecord, + "id" | "name" | "paramsText" | "selectorText" + > & { + publicAPI: unknown; + }, +): AutomationScriptPublicAPIConfig { + const config = normalizeAutomationScriptPublicAPIConfig(script.publicAPI); + + return { + ...config, + requestBodyText: shouldUseDerivedAutomationScriptPublicAPIRequestBody( + script, + config, + ) + ? "" + : config.requestBodyText.trim(), + responseBodyText: shouldUseDerivedAutomationScriptPublicAPIResponseBody(config) + ? "" + : config.responseBodyText.trim(), + }; +} + +export function resolveAutomationScriptPublicAPIConfig( + script: Pick< + AutomationScriptRecord, + "id" | "name" | "paramsText" | "selectorText" | "publicAPI" + >, +): AutomationScriptPublicAPIConfig { + const config = prepareAutomationScriptPublicAPIConfigForSave(script); + const path = config.path.trim() + ? config.path + : suggestAutomationScriptPublicAPIPath(script); + const requestBodyText = buildAutomationScriptPublicAPIRequestExample(script, { + ...config, + path, + requestBodyText: config.requestBodyText, + }); + const responseBodyText = buildAutomationScriptPublicAPIResponseExample( + script, + { + ...config, + path, + responseBodyText: config.responseBodyText, + }, + ); + + return { + ...config, + path, + requestBodyText, + responseBodyText, + }; +} + +export function getAutomationScriptPublicAPIRequestModeLabel( + mode: AutomationScriptPublicAPIRequestMode, +): string { + return ( + AUTOMATION_SCRIPT_PUBLIC_API_REQUEST_MODE_OPTIONS.find( + (item) => item.value === mode, + )?.label || mode + ); +} + +export function getAutomationScriptPublicAPIResponseModeLabel( + mode: AutomationScriptPublicAPIResponseMode, +): string { + return ( + AUTOMATION_SCRIPT_PUBLIC_API_RESPONSE_MODE_OPTIONS.find( + (item) => item.value === mode, + )?.label || mode + ); +} diff --git a/frontend/src/modules/browser/automationScripts/publicApiUtils.ts b/frontend/src/modules/browser/automationScripts/publicApiUtils.ts new file mode 100644 index 00000000..ce624ab5 --- /dev/null +++ b/frontend/src/modules/browser/automationScripts/publicApiUtils.ts @@ -0,0 +1,173 @@ +import type { + AutomationScriptPublicAPIConfig, + AutomationScriptPublicAPIVariable, +} from "./definitions"; + +export function parseAutomationScriptPublicAPIJSONText( + text: string, +): { ok: boolean; value: unknown | null; error: string } { + const sourceText = String(text || "").trim(); + if (!sourceText) { + return { ok: true, value: null, error: "" }; + } + + try { + return { + ok: true, + value: JSON.parse(sourceText), + error: "", + }; + } catch (error: unknown) { + return { + ok: false, + value: null, + error: error instanceof Error ? error.message : "JSON 解析失败", + }; + } +} + +export function safeParseAutomationScriptPublicAPIJSONObject( + text: string, +): Record | null { + const parsed = parseAutomationScriptPublicAPIJSONText(text); + if (!parsed.ok || !parsed.value || typeof parsed.value !== "object") { + return null; + } + if (Array.isArray(parsed.value)) { + return null; + } + return parsed.value as Record; +} + +export function stringifyAutomationScriptPublicAPIJSONBlock( + value: unknown, + fallback: string, +): string { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + + if (value && typeof value === "object") { + try { + return JSON.stringify(value, null, 2); + } catch { + return fallback; + } + } + + return fallback; +} + +function normalizeAutomationScriptPublicAPIVariables( + value: unknown, +): AutomationScriptPublicAPIVariable[] { + const rawItems: unknown[] = Array.isArray(value) + ? value + : value && typeof value === "object" + ? Object.entries(value as Record).map( + ([name, rawValue]) => { + if ( + rawValue && + typeof rawValue === "object" && + !Array.isArray(rawValue) + ) { + return { name, ...(rawValue as Record) }; + } + return { name, defaultValue: rawValue }; + }, + ) + : []; + + const seen = new Set(); + const variables: AutomationScriptPublicAPIVariable[] = []; + for (const item of rawItems) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + continue; + } + const raw = item as Record; + const name = String(raw.name ?? raw.key ?? "").trim(); + if (!name || seen.has(name)) { + continue; + } + seen.add(name); + variables.push({ + name, + defaultValue: String( + raw.defaultValue ?? raw.default ?? raw.value ?? "", + ).trim(), + description: String( + raw.description ?? raw.label ?? raw.note ?? "", + ).trim(), + required: + raw.required === true || + String(raw.required ?? "").trim().toLowerCase() === "true", + }); + } + return variables; +} + +export function normalizeAutomationScriptPublicAPIVariableList( + value: unknown, +): AutomationScriptPublicAPIVariable[] { + return normalizeAutomationScriptPublicAPIVariables(value); +} + +export function isAutomationScriptPublicAPIVariableName(value: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value.trim()); +} + +function escapeAutomationScriptPublicAPIRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function escapeAutomationScriptPublicAPIJSONStringValue(value: string): string { + const encoded = JSON.stringify(String(value ?? "")); + return encoded.slice(1, -1); +} + +export function collectAutomationScriptPublicAPIVariableValues( + config: AutomationScriptPublicAPIConfig, +): Record { + return config.variables.reduce>((values, variable) => { + values[variable.name] = variable.defaultValue; + return values; + }, {}); +} + +export function applyAutomationScriptPublicAPIVariables( + text: string, + variables: AutomationScriptPublicAPIVariable[], + values: Record = {}, +): { bodyText: string; missingRequired: string[]; usedVariables: string[] } { + const sourceText = String(text || ""); + let bodyText = sourceText; + const missingRequired: string[] = []; + const usedVariables: string[] = []; + + for (const variable of variables) { + if (!isAutomationScriptPublicAPIVariableName(variable.name)) { + continue; + } + const rawValue = values[variable.name] ?? variable.defaultValue ?? ""; + const value = String(rawValue); + const placeholders = [`\${${variable.name}}`, `{{${variable.name}}}`]; + const used = placeholders.some((placeholder) => + sourceText.includes(placeholder), + ); + if (!used) { + continue; + } + usedVariables.push(variable.name); + if (variable.required && !value.trim()) { + missingRequired.push(variable.name); + } + for (const placeholder of placeholders) { + bodyText = bodyText.replace( + new RegExp(escapeAutomationScriptPublicAPIRegex(placeholder), "g"), + escapeAutomationScriptPublicAPIJSONStringValue(value), + ); + } + } + + return { bodyText, missingRequired, usedVariables }; +} diff --git a/frontend/src/modules/browser/automationScripts/targets.ts b/frontend/src/modules/browser/automationScripts/targets.ts new file mode 100644 index 00000000..d2a6fc0d --- /dev/null +++ b/frontend/src/modules/browser/automationScripts/targets.ts @@ -0,0 +1,234 @@ +import type { BrowserProfile } from "../types"; +import { + AUTOMATION_SCRIPT_TARGET_MODE_OPTIONS, + type AutomationScriptTargetConfig, + type AutomationScriptTargetMode, + type AutomationScriptTargetSelector, +} from "./definitions"; + +function normalizeTargetTerms(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const deduped = new Set(); + for (const item of value) { + const normalized = String(item || "").trim(); + if (normalized) { + deduped.add(normalized); + } + } + return Array.from(deduped); +} + +export function normalizeAutomationScriptTargetSelector( + selector: unknown, +): AutomationScriptTargetSelector { + if (!selector || typeof selector !== "object") { + return createAutomationScriptTargetSelector(); + } + + const raw = selector as Partial; + return { + code: + typeof raw.code === "string" + ? raw.code.trim().toUpperCase() + : typeof (selector as { launchCode?: unknown }).launchCode === "string" + ? String((selector as { launchCode?: unknown }).launchCode) + .trim() + .toUpperCase() + : "", + profileId: + typeof raw.profileId === "string" ? raw.profileId.trim() : "", + profileName: + typeof raw.profileName === "string" ? raw.profileName.trim() : "", + groupId: typeof raw.groupId === "string" ? raw.groupId.trim() : "", + keywords: normalizeTargetTerms(raw.keywords), + tags: normalizeTargetTerms(raw.tags), + }; +} + +export function createAutomationScriptTargetSelector(): AutomationScriptTargetSelector { + return { + code: "", + profileId: "", + profileName: "", + groupId: "", + keywords: [], + tags: [], + }; +} + +export function normalizeAutomationScriptTargetConfig( + config: unknown, +): AutomationScriptTargetConfig { + if (!config || typeof config !== "object") { + return { + mode: "manual", + selector: createAutomationScriptTargetSelector(), + templateSelector: createAutomationScriptTargetSelector(), + createNameTemplate: "", + }; + } + + const raw = config as Partial; + const mode: AutomationScriptTargetMode = + raw.mode === "existing" || + raw.mode === "create" || + raw.mode === "rotate" + ? raw.mode + : "manual"; + + return { + mode, + selector: normalizeAutomationScriptTargetSelector(raw.selector), + templateSelector: normalizeAutomationScriptTargetSelector( + raw.templateSelector, + ), + createNameTemplate: + typeof raw.createNameTemplate === "string" + ? raw.createNameTemplate.trim() + : "", + }; +} + +function selectorSummaryParts(selector: AutomationScriptTargetSelector): string[] { + const parts: string[] = []; + if (selector.code) { + parts.push(`Code=${selector.code}`); + } + if (selector.profileName) { + parts.push(`实例=${selector.profileName}`); + } + if (selector.profileId && !selector.code) { + parts.push(`实例ID=${selector.profileId}`); + } + if (selector.groupId) { + parts.push(`分组=${selector.groupId}`); + } + if (selector.tags.length > 0) { + parts.push(`标签=${selector.tags.join(" / ")}`); + } + if (selector.keywords.length > 0) { + parts.push(`关键字=${selector.keywords.join(" / ")}`); + } + return parts; +} + +export function getAutomationScriptTargetModeLabel( + mode: AutomationScriptTargetMode, +): string { + return ( + AUTOMATION_SCRIPT_TARGET_MODE_OPTIONS.find((item) => item.value === mode) + ?.label || mode + ); +} + +function normalizeSelectorCode(value?: string): string { + return String(value || "") + .trim() + .toUpperCase(); +} + +function normalizeSelectorText(value?: string): string { + return String(value || "").trim(); +} + +export function findAutomationTargetProfile( + selector: AutomationScriptTargetSelector, + profiles: BrowserProfile[], +): BrowserProfile | null { + const normalizedProfileId = normalizeSelectorText(selector.profileId); + if (normalizedProfileId) { + const matchedById = profiles.find( + (profile) => normalizeSelectorText(profile.profileId) === normalizedProfileId, + ); + if (matchedById) { + return matchedById; + } + } + + const normalizedCode = normalizeSelectorCode(selector.code); + if (normalizedCode) { + const matchedByCode = profiles.find( + (profile) => normalizeSelectorCode(profile.launchCode) === normalizedCode, + ); + if (matchedByCode) { + return matchedByCode; + } + } + + const normalizedProfileName = normalizeSelectorText(selector.profileName); + if (normalizedProfileName) { + const matchedByName = profiles.find( + (profile) => + normalizeSelectorText(profile.profileName).toLowerCase() === + normalizedProfileName.toLowerCase(), + ); + if (matchedByName) { + return matchedByName; + } + } + + return null; +} + +export function formatAutomationTargetIdentity( + selector: AutomationScriptTargetSelector, + profiles: BrowserProfile[], + options?: { + includeProfileId?: boolean; + fallback?: string; + }, +): string { + const profile = findAutomationTargetProfile(selector, profiles); + const code = normalizeSelectorCode(profile?.launchCode || selector.code); + const profileName = normalizeSelectorText( + profile?.profileName || selector.profileName, + ); + const profileId = normalizeSelectorText(profile?.profileId || selector.profileId); + + const parts = [code, profileName].filter(Boolean); + if (options?.includeProfileId && profileId) { + parts.push(profileId); + } + + if (parts.length > 0) { + return parts.join(" · "); + } + if (profileId) { + return options?.includeProfileId ? profileId : `实例 ID ${profileId}`; + } + + return options?.fallback || "-"; +} + +export function describeAutomationScriptTargetConfig( + config: AutomationScriptTargetConfig, +): string { + switch (config.mode) { + case "existing": { + const parts = selectorSummaryParts(config.selector); + return parts.length > 0 + ? `传入实例:${parts.join(" · ")}` + : "传入实例"; + } + case "create": { + const parts = selectorSummaryParts(config.templateSelector); + const namePart = config.createNameTemplate + ? `命名=${config.createNameTemplate}` + : ""; + return ["按模板新建实例", ...parts, namePart] + .filter(Boolean) + .join(" · "); + } + case "rotate": { + const parts = selectorSummaryParts(config.selector); + return parts.length > 0 + ? `按条件轮询实例:${parts.join(" · ")}` + : "按条件轮询实例"; + } + default: + return "传入实例"; + } +} diff --git a/frontend/src/modules/browser/components/AutomationInstanceSelector.tsx b/frontend/src/modules/browser/components/AutomationInstanceSelector.tsx new file mode 100644 index 00000000..75683aeb --- /dev/null +++ b/frontend/src/modules/browser/components/AutomationInstanceSelector.tsx @@ -0,0 +1,250 @@ +import type { ReactNode } from "react"; +import { FormItem, Input, Select } from "../../../shared/components"; +import type { AutomationScriptTargetMode } from "../automationScripts"; +import type { BrowserProfile } from "../types"; + +export type AutomationInstanceSelectorMode = + | AutomationScriptTargetMode + | "select" + | "code"; + +export interface AutomationInstanceOption { + value: string; + label: string; +} + +interface AutomationInstanceSelectorProps { + title?: string; + mode: AutomationInstanceSelectorMode; + modes?: AutomationInstanceSelectorMode[]; + codeSelectLabel?: string; + disabled?: boolean; + loading?: boolean; + required?: boolean; + showFields?: boolean; + error?: string; + selectedProfileId?: string; + selectedCode?: string; + createName?: string; + templateProfileId?: string; + profiles?: BrowserProfile[]; + profileOptions?: AutomationInstanceOption[]; + templateOptions?: AutomationInstanceOption[]; + selectPlaceholder?: string; + templatePlaceholder?: string; + codePlaceholder?: string; + createNamePlaceholder?: string; + hint?: string; + extra?: ReactNode; + onModeChange?: (mode: AutomationInstanceSelectorMode) => void; + onSelectProfile?: (profileId: string) => void; + onCodeChange?: (code: string) => void; + onCreateNameChange?: (name: string) => void; + onTemplateChange?: (profileId: string) => void; +} + +function normalizeLaunchCode(value: unknown): string { + return String(value || "").trim().toUpperCase(); +} + +function buildCodeOptions(profiles: BrowserProfile[]): AutomationInstanceOption[] { + return profiles + .filter((profile) => normalizeLaunchCode(profile.launchCode)) + .map((profile) => { + const code = normalizeLaunchCode(profile.launchCode); + return { + value: code, + label: `${code} · ${profile.profileName || profile.profileId}`, + }; + }); +} + +function modeLabel(mode: AutomationInstanceSelectorMode): string { + if (mode === "manual") return "传入实例"; + if (mode === "existing") return "传入实例"; + if (mode === "create") return "模板创建"; + if (mode === "rotate") return "条件轮询"; + if (mode === "code") return "输入 Code"; + return "已有实例"; +} + +function optionsWithFallback( + options: AutomationInstanceOption[], + placeholder: string, + loading?: boolean, +): AutomationInstanceOption[] { + if (options.length > 0) return options; + return [{ value: "", label: loading ? "正在加载..." : placeholder }]; +} + +export function AutomationInstanceSelector({ + title = "实例选择", + mode, + modes = ["select", "create", "code"], + codeSelectLabel = "选择实例", + disabled = false, + loading = false, + required = false, + showFields = true, + error = "", + selectedProfileId = "", + selectedCode = "", + createName = "", + templateProfileId = "", + profiles = [], + profileOptions, + templateOptions = [], + selectPlaceholder = "暂无可选实例", + templatePlaceholder = "暂无模板", + codePlaceholder = "例如 EQV8K0", + createNamePlaceholder = "实例名称", + hint, + extra, + onModeChange, + onSelectProfile, + onCodeChange, + onCreateNameChange, + onTemplateChange, +}: AutomationInstanceSelectorProps) { + const resolvedProfileOptions = profileOptions || buildCodeOptions(profiles); + const codeOptions = optionsWithFallback( + resolvedProfileOptions, + selectPlaceholder, + loading, + ); + const selectableCodeOptions = resolvedProfileOptions.length + ? [{ value: "", label: selectPlaceholder }, ...resolvedProfileOptions] + : codeOptions; + const selectedCodeOption = resolvedProfileOptions.some( + (option) => option.value === selectedCode, + ) + ? selectedCode + : ""; + const showTabs = modes.length > 1; + const isExistingMode = mode === "select"; + const showManualInstanceFields = mode === "manual" || mode === "existing"; + const renderCodeSelector = (selectValue: string) => ( +
+ + onCodeChange?.(event.target.value)} + placeholder={codePlaceholder} + className="font-mono uppercase" + disabled={disabled} + /> + + + onSelectProfile?.(event.target.value)} + disabled={disabled || resolvedProfileOptions.length === 0} + /> + + {extra} +
+ ) : null} + + {showFields && showManualInstanceFields ? ( +
+ {renderCodeSelector(onSelectProfile ? selectedProfileId : selectedCodeOption)} + {extra} +
+ ) : null} + + {showFields && mode === "create" ? ( +
+
+ + onCreateNameChange?.(event.target.value)} + placeholder={createNamePlaceholder} + disabled={disabled} + /> + + + setApiKey(event.target.value)} + placeholder="留空则使用当前应用里的 Launch API Key" + /> + + ) : ( +
+ 当前 Launch API 未启用认证,可以直接测试。 +
+ )} +
+ +
+
+
+ 返回结果 +
+ {invokeResult ? ( +
+ HTTP {invokeResult.status} {invokeResult.statusText} +
+ ) : null} +
+ + {invokeError ? ( +
+ {invokeError} +
+ ) : null} + + {!invokeError && !invokeResult ? ( +
+ 发送一次测试请求后,这里显示真实响应。 +
+ ) : null} + + {invokeResult ? ( +
+                  {formatInvokeResult(invokeResult)}
+                
+ ) : null} +
+
+ +
+
+ + + +
+ {resolvedConfig.enabled ? "已启用" : "未启用"} + updateConfig({ enabled: checked })} + disabled={busy} + /> +
+
+ +
+ +
+ updateConfig({ path: event.target.value })} + placeholder="mail/proton-first-message" + className="font-mono" + disabled={busy} + /> + +
+
+ {fullPath} +
+
+ + + + updateConfig({ timeoutMs: Number(event.target.value) || 0 }) + } + disabled={busy} + /> + +
+
+ +
+
+
+ 变量 +
+ +
+ + {resolvedConfig.variables.length > 0 ? ( +
+ {resolvedConfig.variables.map((variable, index) => ( +
+ + updateVariable(index, { name: event.target.value }) + } + placeholder="searchQuery" + className="font-mono" + disabled={busy} + /> + + updateVariable(index, { defaultValue: event.target.value }) + } + placeholder="默认值" + disabled={busy} + /> + + updateVariable(index, { description: event.target.value }) + } + placeholder="说明" + disabled={busy} + /> + + +
+ ))} +
+ ) : ( +
+ 未配置变量 +
+ )} + + {variableError ? ( +

+ {variableError} +

+ ) : ( +

+ Body 中使用 {"${name}"},测试和 curl 会替换为默认值。 +

+ )} +
+ + {isDualInstanceRuntimeScript ? ( +
+ handleDualTargetCodeChange(0, code)} + /> + handleDualTargetCodeChange(1, code)} + /> +
+ ) : ( + + )} + +
+
+
+
+ Body 入参 +
+
+ +
+
+