@tea-agent/loop-agent 0.43.0-next.17 → 0.43.0-next.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -1
- package/dist/application/dag/args.js +8 -0
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +2 -0
- package/dist/cli/program.js +3 -1
- package/dist/cli/update/init-surface-notifier.js +2 -0
- package/dist/cli/update/policy.js +2 -0
- package/dist/commands/cursor-prompt.js +2 -0
- package/dist/commands/dag-approve.js +2 -0
- package/dist/commands/dag-final-verification.js +2 -0
- package/dist/commands/dag-init-hybrid.js +2 -0
- package/dist/commands/dag-reconcile-tasks.js +2 -0
- package/dist/commands/dag-reject.js +2 -0
- package/dist/commands/dag-report.js +2 -0
- package/dist/commands/dag-rerun.js +2 -0
- package/dist/commands/dag-resume.js +2 -0
- package/dist/commands/dag-workflow-compile.js +2 -0
- package/dist/commands/dag-workflow-plan.js +2 -0
- package/dist/commands/dag-workflow-validate.js +2 -0
- package/dist/commands/delegate.js +2 -0
- package/dist/commands/examples.js +2 -0
- package/dist/commands/import-prd.js +2 -0
- package/dist/commands/init-model-catalog.js +2 -0
- package/dist/commands/init.js +6 -4
- package/dist/commands/instructions.js +2 -0
- package/dist/commands/knowledge.js +2 -0
- package/dist/commands/loop-benchmark.js +2 -0
- package/dist/commands/pi-prompt.js +2 -0
- package/dist/commands/pi-reuse-benchmark.js +2 -0
- package/dist/commands/promote-run.js +2 -0
- package/dist/commands/stats.js +1 -1
- package/dist/commands/study-init.js +2 -0
- package/dist/commands/task-advance.js +2 -0
- package/dist/commands/task-source-prepare.js +2 -0
- package/dist/commands/worktree-create.js +2 -0
- package/dist/commands/worktree-remove.js +2 -0
- package/dist/executors/dag-pi-executor.js +25 -6
- package/dist/executors/shell-verification.js +3 -0
- package/dist/governance/exec-plans.js +6 -3
- package/dist/infrastructure/console/app-data.js +6 -0
- package/dist/shared/artifacts-core.js +2 -0
- package/dist/shared/git-progress.js +2 -0
- package/dist/shared/one-shot-prompt-args.js +2 -0
- package/dist/shared/package-metadata.js +6 -4
- package/dist/shared/pi-context-pressure/sift-bridge.js +1 -1
- package/dist/shared/reference-context.js +2 -0
- package/dist/task/contract/recover.js +6 -0
- package/dist/task/source-prepare/artifact-meta.js +9 -2
- package/dist/task/source-prepare/fragment-inventory.js +4 -1
- package/dist/task/source-prepare/parse-intent.js +36 -11
- package/dist/task/task-demand-routing.js +5 -2
- package/dist/worker/console/chat/browser-automation.js +468 -308
- package/dist/worker/console/chat/browser-routes.js +175 -57
- package/dist/worker/console/chat/explore-tools.js +5 -2
- package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +115 -20
- package/dist/worker/console/chat/pi-runtime.js +9 -1
- package/dist/worker/console/chat/routes.js +1 -1
- package/dist/worker/console/chat/sdd-data-alignment.js +1 -1
- package/dist/worker/console/chat/session-mode.js +1 -1
- package/dist/worker/console/chat/tools.js +7 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4NXyedy.js → abnfDiagram-N423BO3Z-B_UDknhR.js} +1 -1
- package/dist/worker/console/static/assets/{arc-NZ3qXvBI.js → arc-D6hU0drN.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-COZSRvml.js → architectureDiagram-T3A2C74G-DmVikdRK.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Ck0Gxi9C.js → blockDiagram-VBNYF7ZC-DydABCRg.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BX5HQ4Ox.js → c4Diagram-5PPSVZJV-DrXKC2Jl.js} +1 -1
- package/dist/worker/console/static/assets/channel-Drecd94a.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CoviemSL.js → chunk-2GRJ4B5K-D2-qFxr7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DbOdOkbZ.js → chunk-2Q5K7J3B-ChfeF41P.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BsG2pmdQ.js → chunk-5RXB4S5H-Drwrz55x.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DGdvrNm8.js → chunk-5VM5RSS4-CAQGErL7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BAy7Yd6y.js → chunk-6Q2QTUOP-DHO38w5U.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D9UA7hUb.js → chunk-GF5L2VYU-EYIN0EOI.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-DPz7SZ8V.js → chunk-JWPE2WC7-CrFLKqkT.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BmuEOIh7.js → chunk-KBJHAD2P-DMchffoo.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CIhBWnd8.js → chunk-RYQCIY6F-DaSvtKQa.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CScR6mDD.js → chunk-XXDRQBXY-DcW8W9Qj.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-8Udu0t8-.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-8Udu0t8-.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BjpfWaUr.js → cose-bilkent-JH36ORCC-BUirrMEi.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BFQohJgT.js → cynefin-VYW2F7L2-BXzO8iXR.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-D3gzRJnS.js → cynefinDiagram-MW4NZA55-DAtgaxIn.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-5eojkhoA.js → dagre-VZM6K2ZE-C1re9Noh.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CpD8LWu_.js → diagram-7IWD3JNH-N8Kj08J9.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B8_4qJ2a.js → diagram-B4RE2ZJO-D6BpSLs3.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-HOsXf0eu.js → diagram-LBJQPF4R-C2Vlgnl4.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BqwRwiWP.js → diagram-Q27KOJAE-CMbqtVLS.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-BRVCNZe1.js → diagram-UB23O5K3-A770Eb-0.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Cvb1TgGh.js → ebnfDiagram-BXEA7PRR-LV2w_2pT.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DS0O0qG9.js → erDiagram-JOGREHBK-bwqf56ah.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BHIRvRr3.js → flowDiagram-UKHOOZJN-BnVtHhZh.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DcCJ1JWx.js → ganttDiagram-PKOTCBZU-BGVToEqc.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-B9ia5ony.js → gitGraphDiagram-DS77QQ5N-DL2l7Vne.js} +1 -1
- package/dist/worker/console/static/assets/{index-wXlQohNs.js → index-B_V4wvXs.js} +44 -44
- package/dist/worker/console/static/assets/{index-B9goN3S5.css → index-DcudonhZ.css} +1 -1
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-3ogpgegn.js → infoDiagram-6WML65LV-BWIpubOx.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-4tgpQ5SL.js → ishikawaDiagram-WSZJBQD7-DjYBD8vv.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DNfgHqFx.js → journeyDiagram-NVQOT4AX-C0_mBaHb.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Dz7ngC8z.js → kanban-definition-27J2QSJJ-Cy71zbX6.js} +1 -1
- package/dist/worker/console/static/assets/{linear-DI7Jv62E.js → linear-Dt3_w3Vn.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-CQA4Nzmo.js → mermaid.core-DnNOlfEP.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CtsMc8Mf.js → mindmap-definition-FAOFIHXS-DAbTspwq.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-ByppE9yH.js → pegDiagram-VL7TDLO6-NNiM141p.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-B5PtgFgt.js → pieDiagram-7S7Q4E2Y-CN80Z1Do.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Cq4p0k2K.js → quadrantDiagram-CIZ2JOQS-Bxyfh0JU.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DjfkyljN.js → railroadDiagram-AXF67PYL-BlWnC79M.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DzynNRSl.js → requirementDiagram-LRYGKXZP-BrEu4P_e.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cj7TCpTJ.js → sankeyDiagram-W5VNT64P-Bt70igBr.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-C73C6ykA.js → sequenceDiagram-SI44F4Z6-C8DQR-r9.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-BiNLgN2x.js → sizeCapture-X5ZJPWSS-DEMinJAT.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-UwvjS64-.js → stateDiagram-OKZ733FA-DDdDBzQc.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-CyCiS0Lt.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CC7DH98r.js → swimlanes-SLNWSIFB-G2wcBs3T.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-U0lhi6N0.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-LSBXTR70.js → timeline-definition-Z64GVDOM-Dzn4g2gJ.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BAuxWwGP.js → vennDiagram-T6HMQDX7-5v2rP9oO.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y--E5_gjZx.js → wardleyDiagram-T6FBY63Y-B4EOx7ew.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-dJWNmyzF.js → xychartDiagram-ELKLHX3M-CQ1v8eYP.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +15 -5
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +2 -0
- package/dist/worker/console/static-src/prd-file-import.js +3 -1
- package/dist/worker/console/workspace-context.js +6 -1
- package/dist/worker/delivery/git-transaction.js +2 -0
- package/dist/worker/feature/fullstack-validate.js +2 -0
- package/dist/worker/loop-agent/loop-agent-client.js +8 -2
- package/dist/worker/materialize/harness-task-materializer.js +4 -1
- package/dist/worker/metrics/projector.js +1 -1
- package/dist/worker/observability/event-store.js +4 -1
- package/dist/worker/observability/read-model.js +4 -1
- package/dist/worker/observe/node-transparency.js +23 -1
- package/dist/worker/observe/routes.js +4 -0
- package/dist/worker/outcomes/declared-artifacts.js +2 -0
- package/dist/worker/preflight.js +3 -0
- package/dist/worker/task-spec/validate.js +4 -1
- package/dist/workflows/dag/decision-envelope.js +2 -0
- package/dist/workflows/dag/dynamic-runtime/shared.js +2 -2
- package/dist/workflows/dag/frontend-verification-trace.js +14 -10
- package/dist/workflows/dag/init-hybrid.js +105 -11
- package/dist/workflows/dag/interrupt-request.js +2 -0
- package/dist/workflows/dag/lifecycle.js +4 -0
- package/dist/workflows/dag/node-execution.js +2 -0
- package/dist/workflows/dag/reconcile-run.js +2 -0
- package/dist/workflows/dag/repair-artifact.js +3 -1
- package/dist/workflows/dag/report.js +2 -0
- package/dist/workflows/dag/rerun-task.js +2 -0
- package/dist/workflows/dag/types.js +21 -6
- package/dist/workflows/dag/validate.js +4 -0
- package/docs/governance/README.md +1 -0
- package/package.json +3 -1
- package/dist/worker/console/static/assets/channel-BxdTlb_5.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-_ByA1Nf8.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-_ByA1Nf8.js +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-nLgMZ0D3.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BcYqTady.js +0 -8
|
@@ -1,363 +1,523 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* panel and parses the server-visible HTML. Static GET forms are submitted as
|
|
7
|
-
* real navigations; dynamic scripts and POST forms are intentionally refused.
|
|
2
|
+
* Workspace-scoped Playwright browser used by both Operator Chat tools and
|
|
3
|
+
* the Browser panel. The browser profile lives outside the repository and is
|
|
4
|
+
* reused across Console restarts, so login state and downloads remain
|
|
5
|
+
* isolated from the user's normal browser without contaminating the repo.
|
|
8
6
|
*/
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { mkdir } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { chromium, } from "playwright-core";
|
|
9
11
|
import { normalizeBrowserUrl } from "./browser-policy.js";
|
|
12
|
+
export const BROWSER_VIEWPORT_WIDTH = 1280;
|
|
13
|
+
export const BROWSER_VIEWPORT_HEIGHT = 900;
|
|
14
|
+
const SEARCH_INPUT_SELECTORS = [
|
|
15
|
+
"input[name='wd']",
|
|
16
|
+
"input[name='q']",
|
|
17
|
+
"input[name='query']",
|
|
18
|
+
"input[type='search']",
|
|
19
|
+
"input[type='text']",
|
|
20
|
+
];
|
|
21
|
+
const SEARCH_SUBMIT_SELECTORS = [
|
|
22
|
+
"button[type='submit']",
|
|
23
|
+
"input[type='submit']",
|
|
24
|
+
"button:not([type])",
|
|
25
|
+
];
|
|
26
|
+
const DEFAULT_SEARCH_ENGINE_URL = "https://www.baidu.com/";
|
|
27
|
+
const SEARCH_TYPING_DELAY_MS = 45;
|
|
28
|
+
const DEFAULT_ACTION_TIMEOUT_MS = 15_000;
|
|
29
|
+
const MAX_SNAPSHOT_CHARS = 24_000;
|
|
30
|
+
const MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
|
|
10
31
|
export class BrowserAutomationService {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
32
|
+
options;
|
|
33
|
+
browserPromise;
|
|
34
|
+
activePage;
|
|
35
|
+
tabs = new Map();
|
|
36
|
+
nextTabId = 1;
|
|
37
|
+
disposed = false;
|
|
38
|
+
dialogQueue = [];
|
|
39
|
+
downloadQueue = [];
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.options = options;
|
|
16
42
|
}
|
|
17
|
-
async navigate(rawUrl
|
|
43
|
+
async navigate(rawUrl) {
|
|
18
44
|
const normalized = normalizeBrowserUrl(rawUrl);
|
|
19
45
|
if (normalized.kind !== "ok") {
|
|
20
46
|
return {
|
|
21
47
|
ok: false,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
48
|
+
code: normalized.kind === "blocked" ? "BAD_SCHEME" : "INVALID_URL",
|
|
49
|
+
error: normalized.kind === "blocked"
|
|
50
|
+
? "browser urls must use http(s)"
|
|
51
|
+
: "invalid browser url",
|
|
25
52
|
};
|
|
26
53
|
}
|
|
27
|
-
let snapshot;
|
|
28
|
-
const snapshotSource = rewriteBaiduSearchUrlForSnapshot(normalized.url);
|
|
29
|
-
if (options?.includeSnapshot) {
|
|
30
|
-
try {
|
|
31
|
-
snapshot = await this.inspect(snapshotSource);
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
// Navigation itself can still succeed when the panel cannot
|
|
35
|
-
// fetch the page server-side; the iframe remains the fallback.
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
const displayUrl = snapshotSource === normalized.url ? normalized.url : snapshotSource;
|
|
39
|
-
const command = {
|
|
40
|
-
kind: "navigate",
|
|
41
|
-
id: this.nextId(),
|
|
42
|
-
url: displayUrl,
|
|
43
|
-
...(snapshot ? { snapshot } : {}),
|
|
44
|
-
createdAt: new Date().toISOString(),
|
|
45
|
-
};
|
|
46
|
-
this.pending = [command];
|
|
47
|
-
return { ok: true, command };
|
|
48
|
-
}
|
|
49
|
-
reload() {
|
|
50
|
-
const command = {
|
|
51
|
-
kind: "reload",
|
|
52
|
-
id: this.nextId(),
|
|
53
|
-
createdAt: new Date().toISOString(),
|
|
54
|
-
};
|
|
55
|
-
this.pending = [command];
|
|
56
|
-
return { ok: true, command };
|
|
57
|
-
}
|
|
58
|
-
take() {
|
|
59
|
-
const commands = this.pending;
|
|
60
|
-
this.pending = [];
|
|
61
|
-
return commands;
|
|
62
|
-
}
|
|
63
|
-
async inspect(rawUrl) {
|
|
64
|
-
const normalized = normalizeBrowserUrl(rawUrl);
|
|
65
|
-
if (normalized.kind !== "ok")
|
|
66
|
-
throw new BrowserAutomationError("INVALID_URL", "invalid browser url");
|
|
67
|
-
const controller = new AbortController();
|
|
68
|
-
const timer = setTimeout(() => controller.abort(), 12_000);
|
|
69
54
|
try {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
});
|
|
75
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
76
|
-
if (!contentType.includes("html")) {
|
|
77
|
-
throw new BrowserAutomationError("NOT_HTML", "the browser page did not return an HTML document");
|
|
78
|
-
}
|
|
79
|
-
const html = await response.text();
|
|
80
|
-
if (html.length > 3_000_000) {
|
|
81
|
-
throw new BrowserAutomationError("PAGE_TOO_LARGE", "page exceeds 3MB");
|
|
82
|
-
}
|
|
83
|
-
return parseBrowserPage(html, response.url || normalized.url);
|
|
55
|
+
const page = await this.page();
|
|
56
|
+
await page.goto(normalized.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
57
|
+
const snapshot = await this.snapshot({ screenshot: false });
|
|
58
|
+
return { ok: true, page: snapshot };
|
|
84
59
|
}
|
|
85
60
|
catch (error) {
|
|
86
|
-
|
|
87
|
-
throw error;
|
|
88
|
-
throw new BrowserAutomationError("FETCH_FAILED", "could not load the page", {
|
|
89
|
-
cause: error,
|
|
90
|
-
});
|
|
61
|
+
return { ok: false, code: "NAVIGATION_FAILED", error: errorMessage(error) };
|
|
91
62
|
}
|
|
92
|
-
|
|
93
|
-
|
|
63
|
+
}
|
|
64
|
+
async reload() {
|
|
65
|
+
const page = await this.page();
|
|
66
|
+
await page.reload({ waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
67
|
+
return this.snapshot({ screenshot: false });
|
|
68
|
+
}
|
|
69
|
+
async goBack() {
|
|
70
|
+
const page = await this.page();
|
|
71
|
+
await page.goBack({ waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
72
|
+
return this.snapshot({ screenshot: false });
|
|
73
|
+
}
|
|
74
|
+
async goForward() {
|
|
75
|
+
const page = await this.page();
|
|
76
|
+
await page.goForward({ waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
77
|
+
return this.snapshot({ screenshot: false });
|
|
78
|
+
}
|
|
79
|
+
async snapshot(options) {
|
|
80
|
+
const page = await this.page();
|
|
81
|
+
const dialog = this.dialogFor(page);
|
|
82
|
+
if (dialog) {
|
|
83
|
+
return {
|
|
84
|
+
url: page.url(),
|
|
85
|
+
title: "浏览器弹窗",
|
|
86
|
+
tabs: await this.tabSummaries(),
|
|
87
|
+
dialog,
|
|
88
|
+
ariaSnapshot: "",
|
|
89
|
+
...(options?.screenshot === false
|
|
90
|
+
? {}
|
|
91
|
+
: { screenshot: await this.captureScreenshot(page) }),
|
|
92
|
+
};
|
|
94
93
|
}
|
|
94
|
+
const [title, aria] = await Promise.all([
|
|
95
|
+
page.title(),
|
|
96
|
+
page.ariaSnapshot({ mode: "ai", timeout: 10_000 }),
|
|
97
|
+
]);
|
|
98
|
+
const download = await this.nextDownloadResult();
|
|
99
|
+
const screenshot = options?.screenshot === false
|
|
100
|
+
? undefined
|
|
101
|
+
: await this.captureScreenshot(page);
|
|
102
|
+
return {
|
|
103
|
+
url: page.url(),
|
|
104
|
+
title,
|
|
105
|
+
tabs: await this.tabSummaries(),
|
|
106
|
+
dialog,
|
|
107
|
+
ariaSnapshot: truncateText(aria, MAX_SNAPSHOT_CHARS),
|
|
108
|
+
...(screenshot ? { screenshot } : {}),
|
|
109
|
+
...(download ? { download } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Lightweight state for iframe-driven UI. Avoids aria capture and PNG
|
|
114
|
+
* encoding on every panel poll.
|
|
115
|
+
*/
|
|
116
|
+
async state() {
|
|
117
|
+
if (!this.browserPromise && !this.disposed)
|
|
118
|
+
return emptyBrowserState();
|
|
119
|
+
const page = await this.page();
|
|
120
|
+
const state = {
|
|
121
|
+
running: true,
|
|
122
|
+
url: page.url(),
|
|
123
|
+
title: await page.title().catch(() => ""),
|
|
124
|
+
tabs: await this.tabSummaries(),
|
|
125
|
+
dialog: this.dialogFor(page),
|
|
126
|
+
ariaSnapshot: "",
|
|
127
|
+
};
|
|
128
|
+
return state;
|
|
95
129
|
}
|
|
96
130
|
async search(rawQuery, rawUrl) {
|
|
97
131
|
const query = rawQuery.trim();
|
|
98
132
|
if (!query)
|
|
99
133
|
throw new BrowserAutomationError("EMPTY_QUERY", "search query is empty");
|
|
100
|
-
|
|
134
|
+
const target = rawUrl?.trim() || DEFAULT_SEARCH_ENGINE_URL;
|
|
101
135
|
const normalized = normalizeBrowserUrl(target);
|
|
102
136
|
if (normalized.kind !== "ok") {
|
|
103
137
|
throw new BrowserAutomationError("INVALID_URL", "search engine url must use http(s)");
|
|
104
138
|
}
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
139
|
+
const page = await this.page();
|
|
140
|
+
await page.bringToFront();
|
|
141
|
+
await page.goto(normalized.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
142
|
+
const input = await findSearchInput(page);
|
|
143
|
+
if (!input) {
|
|
144
|
+
throw new BrowserAutomationError("SEARCH_INPUT_NOT_FOUND", "the search page did not expose a recognizable search input");
|
|
145
|
+
}
|
|
146
|
+
await input.fill("", { timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
147
|
+
await input.pressSequentially(query, {
|
|
148
|
+
delay: SEARCH_TYPING_DELAY_MS,
|
|
149
|
+
timeout: DEFAULT_ACTION_TIMEOUT_MS,
|
|
150
|
+
});
|
|
151
|
+
await Promise.all([
|
|
152
|
+
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 30_000 }),
|
|
153
|
+
submitSearchForm(page, input),
|
|
154
|
+
]);
|
|
155
|
+
return this.snapshot({ screenshot: false });
|
|
156
|
+
}
|
|
157
|
+
async act(request) {
|
|
158
|
+
const page = await this.page();
|
|
159
|
+
const ref = request.ref.trim();
|
|
160
|
+
if (!/^(f\d+)?e\d+$/.test(ref)) {
|
|
161
|
+
throw new BrowserAutomationError("INVALID_REF", "element ref must come from a fresh browser snapshot");
|
|
119
162
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
163
|
+
const locator = page.locator(`aria-ref=${ref}`);
|
|
164
|
+
try {
|
|
165
|
+
if (request.type === "click") {
|
|
166
|
+
await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
167
|
+
}
|
|
168
|
+
else if (request.type === "fill") {
|
|
169
|
+
await locator.fill(request.value, { timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
170
|
+
}
|
|
171
|
+
else if (request.type === "press") {
|
|
172
|
+
await locator.press(request.key, { timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
await locator.hover({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
123
176
|
}
|
|
124
|
-
const submitted = this.submitForm(snapshot, element, request.value);
|
|
125
|
-
const submittedSnapshot = await this.inspect(submitted.url);
|
|
126
|
-
const navigation = await this.navigate(submitted.url, { includeSnapshot: true });
|
|
127
|
-
if (!navigation.ok)
|
|
128
|
-
throw new BrowserAutomationError("INVALID_URL", navigation.error);
|
|
129
|
-
return {
|
|
130
|
-
...submittedSnapshot,
|
|
131
|
-
action: { type: "fill", selector: request.selector, value: request.value, url: submitted.url },
|
|
132
|
-
};
|
|
133
177
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
throw new BrowserAutomationError("NOT_CLICKABLE", "matched element has no safe click target");
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error instanceof Error && /ref .* not found/i.test(error.message)) {
|
|
180
|
+
throw new BrowserAutomationError("STALE_REF", "element ref is stale; take a fresh browser snapshot", { cause: error });
|
|
181
|
+
}
|
|
182
|
+
throw new BrowserAutomationError("ACTION_FAILED", errorMessage(error), { cause: error });
|
|
140
183
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
184
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 3_000 }).catch(() => undefined);
|
|
185
|
+
return { action: request.type, ref, page: await this.snapshot({ screenshot: false }) };
|
|
186
|
+
}
|
|
187
|
+
async scroll(input) {
|
|
188
|
+
const page = await this.page();
|
|
189
|
+
const amount = clamp(input.amount ?? 600, 50, 3000);
|
|
190
|
+
await page.mouse.wheel(0, input.direction === "down" ? amount : -amount);
|
|
191
|
+
await page.waitForTimeout(150);
|
|
192
|
+
return this.snapshot({ screenshot: false });
|
|
193
|
+
}
|
|
194
|
+
async pressKey(key) {
|
|
195
|
+
const page = await this.page();
|
|
196
|
+
await page.keyboard.press(key);
|
|
197
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 3_000 }).catch(() => undefined);
|
|
198
|
+
return this.snapshot({ screenshot: false });
|
|
199
|
+
}
|
|
200
|
+
async selectTab(tabId) {
|
|
201
|
+
const page = this.tabs.get(tabId);
|
|
202
|
+
if (!page)
|
|
203
|
+
throw new BrowserAutomationError("TAB_NOT_FOUND", `tab ${tabId} does not exist`);
|
|
204
|
+
this.activePage = page;
|
|
205
|
+
await page.bringToFront();
|
|
206
|
+
return this.snapshot({ screenshot: false });
|
|
207
|
+
}
|
|
208
|
+
async newTab(rawUrl) {
|
|
209
|
+
const context = await this.context();
|
|
210
|
+
const page = await context.newPage();
|
|
211
|
+
this.trackPage(page);
|
|
212
|
+
this.activePage = page;
|
|
213
|
+
if (rawUrl) {
|
|
214
|
+
const result = await this.navigate(rawUrl);
|
|
215
|
+
if (!result.ok)
|
|
216
|
+
throw new BrowserAutomationError(result.code, result.error);
|
|
217
|
+
return result.page;
|
|
218
|
+
}
|
|
219
|
+
return this.snapshot({ screenshot: false });
|
|
220
|
+
}
|
|
221
|
+
async closeTab(tabId) {
|
|
222
|
+
const page = tabId === undefined ? await this.page() : this.tabs.get(tabId);
|
|
223
|
+
if (!page)
|
|
224
|
+
throw new BrowserAutomationError("TAB_NOT_FOUND", `tab ${tabId} does not exist`);
|
|
225
|
+
this.tabs.delete(tabIdForValue(this.tabs, page) ?? 0);
|
|
226
|
+
await page.close();
|
|
227
|
+
if (this.activePage === page)
|
|
228
|
+
this.activePage = undefined;
|
|
229
|
+
return this.snapshot({ screenshot: false });
|
|
230
|
+
}
|
|
231
|
+
async handleDialog(action, promptText) {
|
|
232
|
+
const page = await this.page();
|
|
233
|
+
const index = this.dialogQueue.findIndex((entry) => entry.page === page);
|
|
234
|
+
const entry = index >= 0 ? this.dialogQueue[index] : undefined;
|
|
235
|
+
if (!entry)
|
|
236
|
+
throw new BrowserAutomationError("NO_DIALOG", "the active page has no pending dialog");
|
|
237
|
+
this.dialogQueue.splice(index, 1);
|
|
238
|
+
if (action === "accept")
|
|
239
|
+
await entry.dialog.accept(promptText);
|
|
240
|
+
else
|
|
241
|
+
await entry.dialog.dismiss();
|
|
242
|
+
return this.snapshot({ screenshot: false });
|
|
243
|
+
}
|
|
244
|
+
async dispatchPointer(input) {
|
|
245
|
+
const page = await this.page();
|
|
246
|
+
if (input.type === "mousemove")
|
|
247
|
+
await page.mouse.move(input.x, input.y);
|
|
248
|
+
else if (input.type === "wheel")
|
|
249
|
+
await page.mouse.wheel(input.deltaX, input.deltaY);
|
|
250
|
+
else if (input.type === "mousedown")
|
|
251
|
+
await page.mouse.down({ button: input.button ?? "left" });
|
|
252
|
+
else if (input.type === "mouseup")
|
|
253
|
+
await page.mouse.up({ button: input.button ?? "left" });
|
|
254
|
+
return { ok: true };
|
|
255
|
+
}
|
|
256
|
+
async dispatchKeyboard(input) {
|
|
257
|
+
const page = await this.page();
|
|
258
|
+
if (input.type === "keydown")
|
|
259
|
+
await page.keyboard.down(input.key);
|
|
260
|
+
else
|
|
261
|
+
await page.keyboard.up(input.key);
|
|
262
|
+
return { ok: true };
|
|
263
|
+
}
|
|
264
|
+
async inputText(text) {
|
|
265
|
+
const page = await this.page();
|
|
266
|
+
await page.keyboard.type(text);
|
|
267
|
+
return { ok: true };
|
|
268
|
+
}
|
|
269
|
+
async screenshot() {
|
|
270
|
+
return this.snapshot({ screenshot: true });
|
|
271
|
+
}
|
|
272
|
+
async focus() {
|
|
273
|
+
const page = await this.page();
|
|
274
|
+
await page.bringToFront();
|
|
275
|
+
return { ok: true };
|
|
276
|
+
}
|
|
277
|
+
async waitFor(ms) {
|
|
278
|
+
const page = await this.page();
|
|
279
|
+
await page.waitForTimeout(clamp(ms, 0, 10_000));
|
|
280
|
+
return this.snapshot({ screenshot: false });
|
|
281
|
+
}
|
|
282
|
+
async dispose() {
|
|
283
|
+
if (this.disposed)
|
|
284
|
+
return;
|
|
285
|
+
this.disposed = true;
|
|
286
|
+
const pending = this.browserPromise;
|
|
287
|
+
this.browserPromise = undefined;
|
|
288
|
+
this.tabs.clear();
|
|
289
|
+
this.activePage = undefined;
|
|
290
|
+
if (!pending)
|
|
291
|
+
return;
|
|
292
|
+
const context = await pending.catch(() => undefined);
|
|
293
|
+
await context?.close().catch(() => undefined);
|
|
152
294
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
295
|
+
async page() {
|
|
296
|
+
const context = await this.context();
|
|
297
|
+
if (this.activePage && !this.activePage.isClosed())
|
|
298
|
+
return this.activePage;
|
|
299
|
+
const existing = context.pages().find((page) => this.tabs.has(tabIdForValue(this.tabs, page) ?? -1));
|
|
300
|
+
if (existing) {
|
|
301
|
+
this.activePage = existing;
|
|
302
|
+
return existing;
|
|
156
303
|
}
|
|
157
|
-
|
|
158
|
-
|
|
304
|
+
const page = await context.newPage();
|
|
305
|
+
this.trackPage(page);
|
|
306
|
+
this.activePage = page;
|
|
307
|
+
return page;
|
|
308
|
+
}
|
|
309
|
+
async context() {
|
|
310
|
+
if (this.disposed)
|
|
311
|
+
throw new BrowserAutomationError("BROWSER_CLOSED", "browser service is disposed");
|
|
312
|
+
this.browserPromise ??= (async () => {
|
|
313
|
+
await Promise.all([
|
|
314
|
+
mkdir(this.options.profileDir, { recursive: true }),
|
|
315
|
+
mkdir(this.options.downloadsDir, { recursive: true }),
|
|
316
|
+
mkdir(this.options.screenshotsDir, { recursive: true }),
|
|
317
|
+
]);
|
|
318
|
+
const browser = await this.launchBrowser();
|
|
319
|
+
this.wireContext(browser);
|
|
320
|
+
const page = browser.pages()[0] ?? await browser.newPage();
|
|
321
|
+
this.trackPage(page);
|
|
322
|
+
this.activePage = page;
|
|
323
|
+
return browser;
|
|
324
|
+
})();
|
|
325
|
+
try {
|
|
326
|
+
return await this.browserPromise;
|
|
159
327
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
328
|
+
catch (error) {
|
|
329
|
+
this.browserPromise = undefined;
|
|
330
|
+
throw new BrowserAutomationError("BROWSER_START_FAILED", errorMessage(error), {
|
|
331
|
+
cause: error,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
wireContext(context) {
|
|
336
|
+
context.on("page", (page) => {
|
|
337
|
+
this.trackPage(page);
|
|
338
|
+
if (!this.activePage || this.activePage.isClosed())
|
|
339
|
+
this.activePage = page;
|
|
340
|
+
});
|
|
341
|
+
context.on("close", () => {
|
|
342
|
+
if (this.browserPromise) {
|
|
343
|
+
void this.browserPromise.then((current) => {
|
|
344
|
+
if (current === context) {
|
|
345
|
+
this.browserPromise = undefined;
|
|
346
|
+
this.activePage = undefined;
|
|
347
|
+
this.tabs.clear();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
168
350
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
351
|
+
});
|
|
352
|
+
context.on("dialog", (dialog) => {
|
|
353
|
+
const page = dialog.page();
|
|
354
|
+
if (page)
|
|
355
|
+
this.dialogQueue.push({ page, dialog });
|
|
356
|
+
});
|
|
357
|
+
context.on("download", (download) => {
|
|
358
|
+
this.downloadQueue.push({ download, consumed: false });
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
async launchBrowser() {
|
|
362
|
+
const candidates = chromiumLaunchCandidates();
|
|
363
|
+
const launchOptions = chromiumLaunchOptions(this.options.headless);
|
|
364
|
+
let lastError;
|
|
365
|
+
for (const candidate of candidates) {
|
|
366
|
+
try {
|
|
367
|
+
return await chromium.launchPersistentContext(this.options.profileDir, { ...launchOptions, ...candidate });
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
lastError = error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (candidates.length === 0) {
|
|
374
|
+
return await chromium.launchPersistentContext(this.options.profileDir, launchOptions);
|
|
173
375
|
}
|
|
174
|
-
|
|
376
|
+
throw lastError instanceof Error
|
|
377
|
+
? lastError
|
|
378
|
+
: new Error("no Chromium executable was available");
|
|
175
379
|
}
|
|
176
|
-
|
|
177
|
-
this.
|
|
178
|
-
|
|
380
|
+
trackPage(page) {
|
|
381
|
+
for (const [id, existing] of this.tabs) {
|
|
382
|
+
if (existing === page)
|
|
383
|
+
return id;
|
|
384
|
+
}
|
|
385
|
+
const id = this.nextTabId;
|
|
386
|
+
this.nextTabId += 1;
|
|
387
|
+
this.tabs.set(id, page);
|
|
388
|
+
page.on("close", () => {
|
|
389
|
+
this.tabs.delete(id);
|
|
390
|
+
if (this.activePage === page)
|
|
391
|
+
this.activePage = undefined;
|
|
392
|
+
});
|
|
393
|
+
return id;
|
|
179
394
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
395
|
+
dialogFor(page) {
|
|
396
|
+
const index = this.dialogQueue.findIndex((entry) => entry.page === page);
|
|
397
|
+
const dialog = index >= 0 ? this.dialogQueue[index]?.dialog : undefined;
|
|
398
|
+
if (!dialog)
|
|
399
|
+
return null;
|
|
400
|
+
return {
|
|
401
|
+
type: dialog.type(),
|
|
402
|
+
message: dialog.message(),
|
|
403
|
+
...(dialog.defaultValue() ? { defaultText: dialog.defaultValue() } : {}),
|
|
404
|
+
};
|
|
187
405
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return rawUrl;
|
|
196
|
-
const query = url.searchParams.get("wd") ?? url.searchParams.get("word");
|
|
197
|
-
if (query === null)
|
|
198
|
-
return rawUrl;
|
|
199
|
-
const mobile = new URL("https://m.baidu.com/s");
|
|
200
|
-
mobile.searchParams.set("wd", query);
|
|
201
|
-
return mobile.href;
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
return rawUrl;
|
|
406
|
+
async tabSummaries() {
|
|
407
|
+
return Promise.all([...this.tabs.entries()].map(async ([id, page]) => ({
|
|
408
|
+
id,
|
|
409
|
+
url: page.url(),
|
|
410
|
+
title: page.isClosed() ? "" : await page.title().catch(() => ""),
|
|
411
|
+
active: !page.isClosed() && page === this.activePage,
|
|
412
|
+
})));
|
|
205
413
|
}
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
const parentForForm = openTags[openTags.length - 1];
|
|
235
|
-
let form = parentForForm?.form;
|
|
236
|
-
if (tag === "form" && !form) {
|
|
237
|
-
form = {
|
|
238
|
-
index: state.nextFormIndex,
|
|
239
|
-
action: attrs.action,
|
|
240
|
-
method: attrs.method?.toLowerCase(),
|
|
414
|
+
async captureScreenshot(page) {
|
|
415
|
+
const buffer = await page.screenshot({
|
|
416
|
+
type: "png",
|
|
417
|
+
fullPage: false,
|
|
418
|
+
timeout: 15_000,
|
|
419
|
+
});
|
|
420
|
+
if (buffer.byteLength > MAX_SCREENSHOT_BYTES)
|
|
421
|
+
return undefined;
|
|
422
|
+
return `data:image/png;base64,${buffer.toString("base64")}`;
|
|
423
|
+
}
|
|
424
|
+
async nextDownloadResult() {
|
|
425
|
+
const index = this.downloadQueue.findIndex((entry) => !entry.consumed);
|
|
426
|
+
if (index < 0)
|
|
427
|
+
return undefined;
|
|
428
|
+
const entry = this.downloadQueue[index];
|
|
429
|
+
if (!entry)
|
|
430
|
+
return undefined;
|
|
431
|
+
entry.consumed = true;
|
|
432
|
+
const download = entry.download;
|
|
433
|
+
const target = path.join(this.options.downloadsDir, `${randomUUID()}-${sanitizeFilename(download.suggestedFilename())}`);
|
|
434
|
+
try {
|
|
435
|
+
await download.saveAs(target);
|
|
436
|
+
this.downloadQueue.splice(index, 1);
|
|
437
|
+
return {
|
|
438
|
+
url: download.url(),
|
|
439
|
+
suggestedFilename: download.suggestedFilename(),
|
|
440
|
+
path: target,
|
|
241
441
|
};
|
|
242
|
-
state.nextFormIndex += 1;
|
|
243
|
-
}
|
|
244
|
-
const element = { tag, attrs, text: "", form };
|
|
245
|
-
if (/\/>$/.test(match[0]) || isVoidTag(tag)) {
|
|
246
|
-
closeElement(state, element, baseUrl);
|
|
247
442
|
}
|
|
248
|
-
|
|
249
|
-
|
|
443
|
+
catch (error) {
|
|
444
|
+
this.downloadQueue.splice(index, 1);
|
|
445
|
+
throw error;
|
|
250
446
|
}
|
|
251
447
|
}
|
|
252
|
-
if (state.snapshot.elements.length > 300)
|
|
253
|
-
state.snapshot.elements.length = 300;
|
|
254
|
-
return state.snapshot;
|
|
255
448
|
}
|
|
256
|
-
function
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
449
|
+
function emptyBrowserState() {
|
|
450
|
+
return {
|
|
451
|
+
running: false,
|
|
452
|
+
url: "about:blank",
|
|
453
|
+
title: "",
|
|
454
|
+
tabs: [],
|
|
455
|
+
dialog: null,
|
|
456
|
+
ariaSnapshot: "",
|
|
457
|
+
};
|
|
264
458
|
}
|
|
265
|
-
function
|
|
266
|
-
return
|
|
459
|
+
function chromiumLaunchOptions(headless) {
|
|
460
|
+
return {
|
|
461
|
+
headless: headless ?? false,
|
|
462
|
+
viewport: undefined,
|
|
463
|
+
acceptDownloads: true,
|
|
464
|
+
locale: "zh-CN",
|
|
465
|
+
timezoneId: "Asia/Shanghai",
|
|
466
|
+
args: ["--start-maximized", "--disable-blink-features=AutomationControlled"],
|
|
467
|
+
ignoreDefaultArgs: ["--enable-automation"],
|
|
468
|
+
};
|
|
267
469
|
}
|
|
268
|
-
function
|
|
269
|
-
for (
|
|
270
|
-
|
|
271
|
-
|
|
470
|
+
async function findSearchInput(page) {
|
|
471
|
+
for (const selector of SEARCH_INPUT_SELECTORS) {
|
|
472
|
+
const locator = page.locator(selector).first();
|
|
473
|
+
if (await locator.isVisible().catch(() => false))
|
|
474
|
+
return locator;
|
|
272
475
|
}
|
|
273
|
-
return
|
|
274
|
-
}
|
|
275
|
-
function appendText(target, text) {
|
|
276
|
-
target.text = target.text ? `${target.text} ${text}` : text;
|
|
476
|
+
return undefined;
|
|
277
477
|
}
|
|
278
|
-
function
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
478
|
+
async function submitSearchForm(page, input) {
|
|
479
|
+
for (const selector of SEARCH_SUBMIT_SELECTORS) {
|
|
480
|
+
const locator = page.locator(selector).first();
|
|
481
|
+
if (!await locator.isVisible().catch(() => false))
|
|
482
|
+
continue;
|
|
483
|
+
await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
282
484
|
return;
|
|
283
485
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
href = undefined;
|
|
293
|
-
}
|
|
486
|
+
await input.press("Enter", { timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
487
|
+
}
|
|
488
|
+
export class BrowserAutomationError extends Error {
|
|
489
|
+
code;
|
|
490
|
+
constructor(code, message, options) {
|
|
491
|
+
super(message, options);
|
|
492
|
+
this.code = code;
|
|
493
|
+
this.name = "BrowserAutomationError";
|
|
294
494
|
}
|
|
295
|
-
const id = attrs.id;
|
|
296
|
-
const name = attrs.name;
|
|
297
|
-
const selector = buildSelector(tag, id, name, attrs.class, state.snapshot.elements.length);
|
|
298
|
-
const rawText = text || attrs.value || attrs.placeholder || attrs["aria-label"] || attrs.title || "";
|
|
299
|
-
state.snapshot.elements.push({
|
|
300
|
-
tag,
|
|
301
|
-
...(id ? { id } : {}),
|
|
302
|
-
text: rawText.replace(/\s+/g, " ").trim().slice(0, 240),
|
|
303
|
-
...(href ? { href } : {}),
|
|
304
|
-
...(name ? { name } : {}),
|
|
305
|
-
...(attrs.type ? { type: attrs.type } : {}),
|
|
306
|
-
...(attrs.value ? { value: attrs.value } : {}),
|
|
307
|
-
...(attrs.placeholder ? { placeholder: attrs.placeholder } : {}),
|
|
308
|
-
...(element.form ? { formIndex: element.form.index } : {}),
|
|
309
|
-
...(element.form ? {
|
|
310
|
-
formAction: element.form.action === undefined
|
|
311
|
-
? baseUrl
|
|
312
|
-
: new URL(element.form.action, baseUrl).href,
|
|
313
|
-
formMethod: element.form.method ?? "get",
|
|
314
|
-
} : {}),
|
|
315
|
-
selector,
|
|
316
|
-
clickable: tag === "a" || tag === "button" || (tag === "input" && ["submit", "button", "radio", "checkbox"].includes(attrs.type ?? "")),
|
|
317
|
-
});
|
|
318
495
|
}
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
return `${tag}:nth-of-type(${index + 1})`;
|
|
496
|
+
export function chromiumLaunchCandidates(env = process.env, platform = process.platform) {
|
|
497
|
+
const executablePath = env.LOOP_BROWSER_CHROMIUM_PATH?.trim();
|
|
498
|
+
if (executablePath)
|
|
499
|
+
return [{ executablePath }];
|
|
500
|
+
if (platform === "win32")
|
|
501
|
+
return [{ channel: "chrome" }, { channel: "msedge" }];
|
|
502
|
+
if (platform === "darwin")
|
|
503
|
+
return [{ channel: "chrome" }];
|
|
504
|
+
return [];
|
|
330
505
|
}
|
|
331
|
-
function
|
|
332
|
-
|
|
506
|
+
function tabIdForValue(map, page) {
|
|
507
|
+
for (const [id, value] of map)
|
|
508
|
+
if (value === page)
|
|
509
|
+
return id;
|
|
510
|
+
return undefined;
|
|
333
511
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (!selector)
|
|
337
|
-
return undefined;
|
|
338
|
-
return snapshot.elements.find((element) => element.selector === selector)
|
|
339
|
-
?? snapshot.elements.find((element) => element.id === selector.slice(1))
|
|
340
|
-
?? snapshot.elements.find((element) => element.name === selector)
|
|
341
|
-
?? snapshot.elements.find((element) => element.text.toLowerCase().includes(selector.toLowerCase()) && selector.length >= 2);
|
|
512
|
+
function truncateText(value, max) {
|
|
513
|
+
return value.length <= max ? value : `${value.slice(0, max)}\n... [snapshot truncated]`;
|
|
342
514
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
function decodeEntities(value) {
|
|
352
|
-
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, entity) => {
|
|
353
|
-
if (entity.startsWith("#x") || entity.startsWith("#X")) {
|
|
354
|
-
const code = Number.parseInt(entity.slice(2), 16);
|
|
355
|
-
return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
|
|
356
|
-
}
|
|
357
|
-
if (entity.startsWith("#")) {
|
|
358
|
-
const code = Number.parseInt(entity.slice(1), 10);
|
|
359
|
-
return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
|
|
360
|
-
}
|
|
361
|
-
return ENTITY_MAP[entity.toLowerCase()] ?? whole;
|
|
362
|
-
});
|
|
515
|
+
function clamp(value, min, max) {
|
|
516
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
|
|
517
|
+
}
|
|
518
|
+
function sanitizeFilename(value) {
|
|
519
|
+
return value.replace(/[\\/:*?"<>|]/g, "_").slice(0, 180) || "download";
|
|
520
|
+
}
|
|
521
|
+
function errorMessage(error) {
|
|
522
|
+
return error instanceof Error ? error.message : String(error);
|
|
363
523
|
}
|