@webcrawl/mcp 1.4.2 → 1.4.4
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/README.md +1 -1
- package/dist/bin.js +5 -7
- package/dist/core/engine/awswaf.d.ts +59 -0
- package/dist/core/engine/awswaf.js +164 -0
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/chrome-cdp.d.ts +22 -0
- package/dist/core/engine/chrome-cdp.js +318 -0
- package/dist/core/engine/chrome-launch.d.ts +38 -0
- package/dist/core/engine/chrome-launch.js +214 -0
- package/dist/core/engine/chrome.d.ts +28 -0
- package/dist/core/engine/chrome.js +208 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +152 -0
- package/dist/core/engine/crawler.js +918 -0
- package/dist/core/engine/fetcher.d.ts +92 -0
- package/dist/core/engine/fetcher.js +494 -0
- package/dist/core/engine/index.d.ts +7 -0
- package/dist/core/engine/index.js +7 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/render.d.ts +41 -0
- package/dist/core/engine/render.js +138 -0
- package/dist/core/engine/safe-url.d.ts +29 -0
- package/dist/core/engine/safe-url.js +109 -0
- package/dist/core/engine/session.d.ts +93 -0
- package/dist/core/engine/session.js +250 -0
- package/dist/core/engine/stealth.d.ts +83 -0
- package/dist/core/engine/stealth.js +222 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +9 -0
- package/dist/core/extract/index.js +9 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/extract/structured.d.ts +33 -0
- package/dist/core/extract/structured.js +316 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +72 -0
- package/dist/core/interact/driver.js +384 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +43 -0
- package/dist/core/interact/vm.js +338 -0
- package/dist/core/local-engine.d.ts +62 -0
- package/dist/core/local-engine.js +726 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +643 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +222 -0
- package/dist/core/search/catalog.d.ts +176 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +721 -0
- package/dist/core/search/providers.d.ts +84 -0
- package/dist/core/search/providers.js +1408 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- package/dist/format.js +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/load-local-engine.d.ts +7 -0
- package/dist/load-local-engine.js +22 -0
- package/dist/server.js +6 -0
- package/package.json +11 -3
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser driver for interact runs. Node-only: called from workflow steps.
|
|
3
|
+
*
|
|
4
|
+
* Two backends share one step runner (STEP_SCRIPT):
|
|
5
|
+
* - sandbox: Chromium inside a Vercel Sandbox, reached by sandboxId so any
|
|
6
|
+
* step invocation (on any function instance) can reattach.
|
|
7
|
+
* - local: Chromium on this machine for dev (INTERACT_LOCAL_PLAYWRIGHT=true).
|
|
8
|
+
* Only works while steps run in the same long-lived process
|
|
9
|
+
* (Local World / `next dev`).
|
|
10
|
+
*/
|
|
11
|
+
import { execFile, spawn } from "node:child_process";
|
|
12
|
+
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { Sandbox } from "@vercel/sandbox";
|
|
16
|
+
import { CDP_PORT, SETUP_SCRIPT, STEP_SCRIPT, STEP_SCRIPT_PATH, VM_ROOT, WAIT_FOR_CDP_SCRIPT, chromeArgs, parseStepOutput, } from "./vm.js";
|
|
17
|
+
import { startRemoteSession, stopRemoteSession } from "./remote.js";
|
|
18
|
+
import { WARM_SANDBOX_TIMEOUT_MS, WARM_TTL_MS } from "../engine/session.js";
|
|
19
|
+
const snapshotId = () => process.env.WEBCRAWL_INTERACT_SNAPSHOT_ID || process.env.INTERACT_SNAPSHOT_ID || "";
|
|
20
|
+
const localMode = () => process.env.INTERACT_LOCAL_PLAYWRIGHT === "true";
|
|
21
|
+
/**
|
|
22
|
+
* True when some browser backend can run. Checked before starting a run so we never fake success.
|
|
23
|
+
* On Vercel (VERCEL=1) the OIDC token is not in env at request time: it arrives in the
|
|
24
|
+
* `x-vercel-oidc-token` request header, which @vercel/sandbox reads via @vercel/oidc.
|
|
25
|
+
*/
|
|
26
|
+
export function interactBrowserAvailable() {
|
|
27
|
+
if (localMode())
|
|
28
|
+
return true;
|
|
29
|
+
return Boolean(process.env.VERCEL === "1" ||
|
|
30
|
+
process.env.VERCEL_OIDC_TOKEN ||
|
|
31
|
+
(process.env.VERCEL_TOKEN && process.env.VERCEL_TEAM_ID && process.env.VERCEL_PROJECT_ID));
|
|
32
|
+
}
|
|
33
|
+
export function interactBackend() {
|
|
34
|
+
if (localMode())
|
|
35
|
+
return "local";
|
|
36
|
+
return interactBrowserAvailable() ? "sandbox" : "none";
|
|
37
|
+
}
|
|
38
|
+
/* ----------------------------- sandbox backend ---------------------------- */
|
|
39
|
+
async function sh(sandbox, script, sudo = true) {
|
|
40
|
+
const r = await sandbox.runCommand({ cmd: "bash", args: ["-lc", script], sudo });
|
|
41
|
+
return { exitCode: r.exitCode, stdout: await r.stdout(), stderr: await r.stderr() };
|
|
42
|
+
}
|
|
43
|
+
const SANDBOX_RESOURCES = { vcpus: 2 };
|
|
44
|
+
/**
|
|
45
|
+
* Boots from the prebuilt snapshot (Chromium + Playwright already installed,
|
|
46
|
+
* see scripts/build-interact-snapshot.ts). If the snapshot is unset, expired
|
|
47
|
+
* or deleted, falls back to a cold VM and runs the full setup.
|
|
48
|
+
*/
|
|
49
|
+
async function createBrowserSandbox(timeoutMs) {
|
|
50
|
+
const snap = snapshotId();
|
|
51
|
+
if (snap) {
|
|
52
|
+
try {
|
|
53
|
+
const sandbox = await Sandbox.create({
|
|
54
|
+
source: { type: "snapshot", snapshotId: snap },
|
|
55
|
+
timeout: timeoutMs,
|
|
56
|
+
resources: SANDBOX_RESOURCES,
|
|
57
|
+
});
|
|
58
|
+
return { sandbox, fromSnapshot: true };
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
console.warn("[interact] snapshot boot failed, using cold setup", {
|
|
62
|
+
snapshotId: snap,
|
|
63
|
+
error: err instanceof Error ? err.message : String(err),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const sandbox = await Sandbox.create({ runtime: "node22", timeout: timeoutMs, resources: SANDBOX_RESOURCES });
|
|
68
|
+
return { sandbox, fromSnapshot: false };
|
|
69
|
+
}
|
|
70
|
+
async function startSandboxBrowser(opts) {
|
|
71
|
+
const { sandbox, fromSnapshot } = await createBrowserSandbox(opts.timeoutMs);
|
|
72
|
+
try {
|
|
73
|
+
if (!fromSnapshot) {
|
|
74
|
+
const setup = await sh(sandbox, SETUP_SCRIPT);
|
|
75
|
+
if (setup.exitCode !== 0)
|
|
76
|
+
throw new Error(`Browser setup failed: ${(setup.stderr || setup.stdout).slice(-300)}`);
|
|
77
|
+
}
|
|
78
|
+
// Always write the current step runner so a snapshot built from older code
|
|
79
|
+
// never runs a stale copy. It is a few KB, so this costs one round trip.
|
|
80
|
+
await sandbox.writeFiles([{ path: STEP_SCRIPT_PATH, content: Buffer.from(STEP_SCRIPT) }]);
|
|
81
|
+
const exe = (await sh(sandbox, `cat ${VM_ROOT}/chrome-path`)).stdout.trim().split("\n").pop();
|
|
82
|
+
if (!exe)
|
|
83
|
+
throw new Error("Browser binary not found in sandbox");
|
|
84
|
+
await sandbox.runCommand({ cmd: exe, args: chromeArgs({ userAgent: opts.userAgent, proxy: opts.proxy }), detached: true, sudo: true });
|
|
85
|
+
const ready = await sh(sandbox, WAIT_FOR_CDP_SCRIPT, false);
|
|
86
|
+
if (ready.exitCode !== 0)
|
|
87
|
+
throw new Error("Browser did not start");
|
|
88
|
+
return { kind: "sandbox", sandboxId: sandbox.sandboxId };
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
await sandbox.stop().catch(() => { });
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** True when the sandbox's Chromium still answers on CDP (warm reconnect check). */
|
|
96
|
+
async function sandboxChromeAlive(sandbox) {
|
|
97
|
+
const ping = await sh(sandbox, `curl -sf -m 3 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null && echo alive`, false);
|
|
98
|
+
return ping.exitCode === 0 && ping.stdout.includes("alive");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Warm reconnect: fetch the sandbox this domain solved its challenge in, verify
|
|
102
|
+
* Chromium still lives, and reuse it (same IP, same UA, same cleared cookies).
|
|
103
|
+
* Returns null when no warm entry exists or the sandbox is gone (cold boot).
|
|
104
|
+
*/
|
|
105
|
+
async function startWarmSandboxBrowser(domain) {
|
|
106
|
+
if (!domain)
|
|
107
|
+
return null;
|
|
108
|
+
const { getWarmSession } = await import("../engine/session.js");
|
|
109
|
+
let warm;
|
|
110
|
+
try {
|
|
111
|
+
warm = await getWarmSession(domain);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (!warm || !warm.sandboxId)
|
|
117
|
+
return null;
|
|
118
|
+
try {
|
|
119
|
+
const sandbox = await Sandbox.get({ sandboxId: warm.sandboxId });
|
|
120
|
+
if (!(await sandboxChromeAlive(sandbox))) {
|
|
121
|
+
await sandbox.stop().catch(() => { });
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
// Refresh the step runner in case this instance ships newer code.
|
|
125
|
+
await sandbox.writeFiles([{ path: STEP_SCRIPT_PATH, content: Buffer.from(STEP_SCRIPT) }]).catch(() => { });
|
|
126
|
+
console.info("[stealth] warm reconnect", { domain, sandboxId: warm.sandboxId });
|
|
127
|
+
return { kind: "sandbox", sandboxId: warm.sandboxId };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/* ------------------------------ local backend ----------------------------- */
|
|
134
|
+
/**
|
|
135
|
+
* Path to a real, user-installed Chrome when one exists, else null.
|
|
136
|
+
* Anti-bot vendors (DataDome, Akamai) fingerprint the automation build of
|
|
137
|
+
* Chromium and serve degraded pages to it, while the same engine driving a
|
|
138
|
+
* real installed Chrome gets full content — practitioner reports confirm
|
|
139
|
+
* this on DataDome-protected sites. Priority: explicit env override, then
|
|
140
|
+
* the standard per-OS install locations.
|
|
141
|
+
*/
|
|
142
|
+
export function installedChromePath() {
|
|
143
|
+
if (process.env.INTERACT_CHROME_PATH)
|
|
144
|
+
return process.env.INTERACT_CHROME_PATH;
|
|
145
|
+
const candidates = [];
|
|
146
|
+
if (process.platform === "darwin") {
|
|
147
|
+
candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", `${process.env.HOME || ""}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`);
|
|
148
|
+
}
|
|
149
|
+
else if (process.platform === "win32") {
|
|
150
|
+
candidates.push(`${process.env["ProgramFiles"] || "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
candidates.push("/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium-browser", "/usr/bin/chromium", "/snap/bin/chromium");
|
|
154
|
+
}
|
|
155
|
+
for (const p of candidates) {
|
|
156
|
+
try {
|
|
157
|
+
if (existsSync(p))
|
|
158
|
+
return p;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* not installed here */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
async function resolveLocalPlaywright() {
|
|
167
|
+
const candidates = [
|
|
168
|
+
process.env.WC_PLAYWRIGHT,
|
|
169
|
+
path.join(process.cwd(), "node_modules/playwright/index.mjs"),
|
|
170
|
+
path.join(process.cwd(), "../node_modules/playwright/index.mjs"),
|
|
171
|
+
].filter(Boolean);
|
|
172
|
+
for (const p of candidates) {
|
|
173
|
+
try {
|
|
174
|
+
const mod = (await import(/* webpackIgnore: true */ p));
|
|
175
|
+
// Prefer a real installed Chrome over playwright's bundled automation
|
|
176
|
+
// Chromium (see installedChromePath); fall back to the bundle when the
|
|
177
|
+
// host has no Chrome (CI, containers, minimal servers).
|
|
178
|
+
const chrome = installedChromePath() || mod.chromium.executablePath();
|
|
179
|
+
return { playwright: p, chrome };
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* try next */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
throw new Error("Local browser unavailable: install playwright or set WC_PLAYWRIGHT");
|
|
186
|
+
}
|
|
187
|
+
async function startLocalBrowser(opts) {
|
|
188
|
+
const { playwright, chrome } = await resolveLocalPlaywright();
|
|
189
|
+
const port = 9300 + Math.floor(Math.random() * 600);
|
|
190
|
+
const dir = mkdtempSync(path.join(tmpdir(), "wc-interact-"));
|
|
191
|
+
const args = chromeArgs({ userAgent: opts.userAgent, proxy: opts.proxy })
|
|
192
|
+
.map((a) => (a.startsWith("--remote-debugging-port=") ? `--remote-debugging-port=${port}` : a))
|
|
193
|
+
.map((a) => (a.startsWith("--user-data-dir=") ? `--user-data-dir=${dir}/profile` : a));
|
|
194
|
+
args.unshift("--disable-crash-reporter", "--disable-breakpad");
|
|
195
|
+
const child = spawn(chrome, args, { detached: true, stdio: "ignore" });
|
|
196
|
+
child.unref();
|
|
197
|
+
const cdp = `http://127.0.0.1:${port}`;
|
|
198
|
+
// A cold first launch (new profile) can take over 10s on macOS.
|
|
199
|
+
for (let i = 0; i < 75; i++) {
|
|
200
|
+
try {
|
|
201
|
+
const r = await fetch(`${cdp}/json/version`);
|
|
202
|
+
if (r.ok)
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
/* not up yet */
|
|
207
|
+
}
|
|
208
|
+
if (i === 74) {
|
|
209
|
+
try {
|
|
210
|
+
process.kill(child.pid);
|
|
211
|
+
}
|
|
212
|
+
catch { /* gone */ }
|
|
213
|
+
throw new Error("Local browser did not start");
|
|
214
|
+
}
|
|
215
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
216
|
+
}
|
|
217
|
+
const scriptPath = path.join(dir, "step.mjs");
|
|
218
|
+
writeFileSync(scriptPath, STEP_SCRIPT);
|
|
219
|
+
return { kind: "local", cdp, pid: child.pid, scriptPath, playwright };
|
|
220
|
+
}
|
|
221
|
+
/* ------------------------------ remote backend ---------------------------- */
|
|
222
|
+
/**
|
|
223
|
+
* A place to run STEP_SCRIPT without starting Chromium: the provider hosts the
|
|
224
|
+
* browser. Local Playwright when installed (dev), else a sandbox VM.
|
|
225
|
+
*/
|
|
226
|
+
async function startStepRunner(timeoutMs) {
|
|
227
|
+
if (localMode()) {
|
|
228
|
+
const { playwright } = await resolveLocalPlaywright();
|
|
229
|
+
const dir = mkdtempSync(path.join(tmpdir(), "wc-remote-"));
|
|
230
|
+
const scriptPath = path.join(dir, "step.mjs");
|
|
231
|
+
writeFileSync(scriptPath, STEP_SCRIPT);
|
|
232
|
+
return { kind: "local", scriptPath, playwright };
|
|
233
|
+
}
|
|
234
|
+
const { sandbox, fromSnapshot } = await createBrowserSandbox(timeoutMs);
|
|
235
|
+
try {
|
|
236
|
+
if (!fromSnapshot) {
|
|
237
|
+
const setup = await sh(sandbox, SETUP_SCRIPT);
|
|
238
|
+
if (setup.exitCode !== 0)
|
|
239
|
+
throw new Error(`Step runner setup failed: ${(setup.stderr || setup.stdout).slice(-300)}`);
|
|
240
|
+
}
|
|
241
|
+
await sandbox.writeFiles([{ path: STEP_SCRIPT_PATH, content: Buffer.from(STEP_SCRIPT) }]);
|
|
242
|
+
return { kind: "sandbox", sandboxId: sandbox.sandboxId };
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
await sandbox.stop().catch(() => { });
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function startRemoteBrowser(provider, timeoutMs) {
|
|
250
|
+
// Both start in parallel; the provider bills from session start, so stop it if the runner fails.
|
|
251
|
+
const [session, runner] = await Promise.allSettled([startRemoteSession({ provider, timeoutMs }), startStepRunner(timeoutMs)]);
|
|
252
|
+
if (session.status === "fulfilled" && runner.status === "fulfilled") {
|
|
253
|
+
const s = session.value;
|
|
254
|
+
return {
|
|
255
|
+
kind: "remote",
|
|
256
|
+
provider,
|
|
257
|
+
sessionId: s.sessionId,
|
|
258
|
+
cdpUrl: s.cdpUrl,
|
|
259
|
+
...(s.cdpHeaders ? { cdpHeaders: s.cdpHeaders } : {}),
|
|
260
|
+
startedAt: s.startedAt,
|
|
261
|
+
runner: runner.value,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (session.status === "fulfilled")
|
|
265
|
+
await stopRemoteSession(session.value);
|
|
266
|
+
if (runner.status === "fulfilled")
|
|
267
|
+
await stopRunner(runner.value);
|
|
268
|
+
const reason = session.status === "rejected" ? session.reason : runner.reason;
|
|
269
|
+
throw reason instanceof Error ? reason : new Error(String(reason));
|
|
270
|
+
}
|
|
271
|
+
async function stopRunner(runner) {
|
|
272
|
+
if (runner.kind === "local")
|
|
273
|
+
return;
|
|
274
|
+
try {
|
|
275
|
+
const sandbox = await Sandbox.get({ sandboxId: runner.sandboxId });
|
|
276
|
+
await sandbox.stop();
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
/* already stopped or expired */
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/* --------------------------------- public --------------------------------- */
|
|
283
|
+
export async function startBrowser(opts = {}) {
|
|
284
|
+
if (opts.remote)
|
|
285
|
+
return startRemoteBrowser(opts.remote, opts.timeoutMs ?? 5 * 60_000);
|
|
286
|
+
if (localMode())
|
|
287
|
+
return startLocalBrowser(opts);
|
|
288
|
+
if (!interactBrowserAvailable())
|
|
289
|
+
throw new Error("browser_unavailable");
|
|
290
|
+
// Warm reconnect first: a sandbox that already solved this domain's bot check
|
|
291
|
+
// keeps its IP / UA / cleared cookies — reusing it skips boot + re-solve.
|
|
292
|
+
// Never warm-reconnect when proxying: the warm sandbox egresses direct.
|
|
293
|
+
if (opts.domain && !opts.proxy) {
|
|
294
|
+
const warm = await startWarmSandboxBrowser(opts.domain).catch(() => null);
|
|
295
|
+
if (warm)
|
|
296
|
+
return warm;
|
|
297
|
+
}
|
|
298
|
+
return startSandboxBrowser({
|
|
299
|
+
// Long VM timeout headroom so the sandbox can stay warm after this call.
|
|
300
|
+
timeoutMs: Math.max(opts.timeoutMs ?? 5 * 60_000, WARM_SANDBOX_TIMEOUT_MS),
|
|
301
|
+
userAgent: opts.userAgent,
|
|
302
|
+
proxy: opts.proxy,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
function runLocalStep(scriptPath, playwright, cdp, action, extraEnv = {}) {
|
|
306
|
+
return new Promise((resolve) => {
|
|
307
|
+
execFile(process.execPath, [scriptPath, JSON.stringify(action)], {
|
|
308
|
+
env: { ...process.env, WC_PLAYWRIGHT: playwright, WC_CDP: cdp, ...extraEnv },
|
|
309
|
+
// A stealth fetchMany reads many documents in one step.
|
|
310
|
+
timeout: action.type === "fetchMany" ? 300_000 : 45_000,
|
|
311
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
312
|
+
}, (_err, stdout, stderr) => resolve(parseStepOutput(String(stdout ?? ""), String(stderr ?? ""))));
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
async function runSandboxStep(sandboxId, action, env) {
|
|
316
|
+
const sandbox = await Sandbox.get({ sandboxId });
|
|
317
|
+
const r = await sandbox.runCommand({ cmd: "node", args: [STEP_SCRIPT_PATH, JSON.stringify(action)], sudo: true, ...(env ? { env } : {}) });
|
|
318
|
+
return parseStepOutput(await r.stdout(), await r.stderr());
|
|
319
|
+
}
|
|
320
|
+
export async function runBrowserStep(handle, action) {
|
|
321
|
+
if (handle.kind === "local")
|
|
322
|
+
return runLocalStep(handle.scriptPath, handle.playwright, handle.cdp, action);
|
|
323
|
+
if (handle.kind === "remote") {
|
|
324
|
+
// The provider's browser carries its own fingerprint; overriding it would undo that.
|
|
325
|
+
const remoteAction = { ...action, keepFingerprint: true, lightweight: true };
|
|
326
|
+
const headerEnv = handle.cdpHeaders ? { WC_CDP_HEADERS: JSON.stringify(handle.cdpHeaders) } : {};
|
|
327
|
+
return handle.runner.kind === "local"
|
|
328
|
+
? runLocalStep(handle.runner.scriptPath, handle.runner.playwright, handle.cdpUrl, remoteAction, headerEnv)
|
|
329
|
+
: runSandboxStep(handle.runner.sandboxId, remoteAction, { WC_CDP: handle.cdpUrl, ...headerEnv });
|
|
330
|
+
}
|
|
331
|
+
return runSandboxStep(handle.sandboxId, action);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Stops the browser. For a remote provider, returns what the session cost (null
|
|
335
|
+
* when unknown). With `keepWarm`, a sandbox browser is left running and
|
|
336
|
+
* registered (domain + sandboxId + UA) so the next call reconnects to it via
|
|
337
|
+
* `Sandbox.get` instead of cold-booting — the caller must pass `domain`.
|
|
338
|
+
* Evicted warm entries (over the cap) are stopped here so idle compute
|
|
339
|
+
* does not linger.
|
|
340
|
+
*/
|
|
341
|
+
export async function stopBrowser(handle, opts = {}) {
|
|
342
|
+
if (handle.kind === "local") {
|
|
343
|
+
try {
|
|
344
|
+
process.kill(handle.pid);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
/* already gone */
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
if (handle.kind === "remote") {
|
|
352
|
+
const [cost] = await Promise.all([stopRemoteSession(handle), stopRunner(handle.runner)]);
|
|
353
|
+
return cost;
|
|
354
|
+
}
|
|
355
|
+
if (opts.keepWarm && opts.domain) {
|
|
356
|
+
const { saveWarmSession } = await import("../engine/session.js");
|
|
357
|
+
try {
|
|
358
|
+
const evicted = await saveWarmSession({
|
|
359
|
+
domain: opts.domain,
|
|
360
|
+
sandboxId: handle.sandboxId,
|
|
361
|
+
userAgent: opts.userAgent ?? "",
|
|
362
|
+
cookieHeader: opts.cookieHeader ?? "",
|
|
363
|
+
expiresAt: Date.now() + WARM_TTL_MS,
|
|
364
|
+
});
|
|
365
|
+
await Promise.all(evicted.map(async (id) => {
|
|
366
|
+
try {
|
|
367
|
+
const sandbox = await Sandbox.get({ sandboxId: id });
|
|
368
|
+
await sandbox.stop();
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
/* already stopped */
|
|
372
|
+
}
|
|
373
|
+
}));
|
|
374
|
+
console.info("[stealth] kept warm", { domain: opts.domain, sandboxId: handle.sandboxId, evicted: evicted.length });
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
/* registry unavailable — fall through to full stop */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
await stopRunner(handle);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
export { CDP_PORT };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote CDP browser providers for stealth fetches.
|
|
3
|
+
*
|
|
4
|
+
* A remote provider hosts the browser and hands back a CDP URL. Our step runner
|
|
5
|
+
* (sandbox or local Node) connects to it over CDP, so STEP_SCRIPT runs unchanged.
|
|
6
|
+
*
|
|
7
|
+
* WEBCRAWL_STEALTH_PROVIDER picks one:
|
|
8
|
+
* - browser-use: BROWSER_USE_API_KEY. Custom Chromium + residential proxy ($5/GB).
|
|
9
|
+
* - cloudflare: CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_BROWSER_TOKEN (Browser Rendering - Edit).
|
|
10
|
+
* Cloudflare Browser Run, $0.09 per browser hour, Cloudflare egress IPs.
|
|
11
|
+
* Only the stealth path (stealthFetch) uses it; /v1/interact keeps the sandbox.
|
|
12
|
+
*/
|
|
13
|
+
export type RemoteProvider = "browser-use" | "cloudflare";
|
|
14
|
+
export interface RemoteSession {
|
|
15
|
+
provider: RemoteProvider;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
cdpUrl: string;
|
|
18
|
+
/** Headers the CDP websocket needs (Cloudflare wants the bearer token on connect). */
|
|
19
|
+
cdpHeaders?: Record<string, string>;
|
|
20
|
+
startedAt: number;
|
|
21
|
+
}
|
|
22
|
+
export interface RemoteSessionCost {
|
|
23
|
+
/** Provider charge for the session in USD (browser time + proxy traffic). */
|
|
24
|
+
costUsd: number;
|
|
25
|
+
proxyMb: number;
|
|
26
|
+
}
|
|
27
|
+
/** Cloudflare Browser Run: $0.09 per browser hour past the included hours. */
|
|
28
|
+
export declare const CLOUDFLARE_BROWSER_HOUR_USD = 0.09;
|
|
29
|
+
/** The configured remote provider, or null when stealth runs on our own browser. */
|
|
30
|
+
export declare function remoteStealthProvider(): RemoteProvider | null;
|
|
31
|
+
/** Starts a remote browser. `timeoutMs` caps how long the provider keeps it (and bills it) if we never stop it. */
|
|
32
|
+
export declare function startRemoteSession(opts?: {
|
|
33
|
+
provider?: RemoteProvider;
|
|
34
|
+
timeoutMs: number;
|
|
35
|
+
fetchImpl?: typeof fetch;
|
|
36
|
+
}): Promise<RemoteSession>;
|
|
37
|
+
/** Stops the session (unused time is refunded) and returns what it cost. Never throws. */
|
|
38
|
+
export declare function stopRemoteSession(session: Pick<RemoteSession, "sessionId"> & Partial<Pick<RemoteSession, "provider" | "startedAt">>, opts?: {
|
|
39
|
+
fetchImpl?: typeof fetch;
|
|
40
|
+
now?: number;
|
|
41
|
+
}): Promise<RemoteSessionCost | null>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote CDP browser providers for stealth fetches.
|
|
3
|
+
*
|
|
4
|
+
* A remote provider hosts the browser and hands back a CDP URL. Our step runner
|
|
5
|
+
* (sandbox or local Node) connects to it over CDP, so STEP_SCRIPT runs unchanged.
|
|
6
|
+
*
|
|
7
|
+
* WEBCRAWL_STEALTH_PROVIDER picks one:
|
|
8
|
+
* - browser-use: BROWSER_USE_API_KEY. Custom Chromium + residential proxy ($5/GB).
|
|
9
|
+
* - cloudflare: CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_BROWSER_TOKEN (Browser Rendering - Edit).
|
|
10
|
+
* Cloudflare Browser Run, $0.09 per browser hour, Cloudflare egress IPs.
|
|
11
|
+
* Only the stealth path (stealthFetch) uses it; /v1/interact keeps the sandbox.
|
|
12
|
+
*/
|
|
13
|
+
/** Cloudflare Browser Run: $0.09 per browser hour past the included hours. */
|
|
14
|
+
export const CLOUDFLARE_BROWSER_HOUR_USD = 0.09;
|
|
15
|
+
const BROWSER_USE_API = () => (process.env.BROWSER_USE_API_URL || "https://api.browser-use.com/api/v4").replace(/\/+$/, "");
|
|
16
|
+
function cloudflareCreds() {
|
|
17
|
+
const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || "").trim();
|
|
18
|
+
const token = (process.env.CLOUDFLARE_BROWSER_TOKEN || process.env.CLOUDFLARE_API_TOKEN || "").trim();
|
|
19
|
+
return accountId && token ? { accountId, token } : null;
|
|
20
|
+
}
|
|
21
|
+
const CF_API = (accountId) => `${(process.env.CLOUDFLARE_API_URL || "https://api.cloudflare.com/client/v4").replace(/\/+$/, "")}/accounts/${encodeURIComponent(accountId)}/browser-run`;
|
|
22
|
+
/** The configured remote provider, or null when stealth runs on our own browser. */
|
|
23
|
+
export function remoteStealthProvider() {
|
|
24
|
+
const name = (process.env.WEBCRAWL_STEALTH_PROVIDER || "").trim().toLowerCase();
|
|
25
|
+
if (name === "browser-use" && process.env.BROWSER_USE_API_KEY)
|
|
26
|
+
return "browser-use";
|
|
27
|
+
if (name === "cloudflare" && cloudflareCreds())
|
|
28
|
+
return "cloudflare";
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function browserUseHeaders() {
|
|
32
|
+
return { "X-Browser-Use-API-Key": process.env.BROWSER_USE_API_KEY || "", "Content-Type": "application/json" };
|
|
33
|
+
}
|
|
34
|
+
function num(v) {
|
|
35
|
+
const n = typeof v === "number" ? v : Number.parseFloat(String(v ?? "0"));
|
|
36
|
+
return Number.isFinite(n) ? n : 0;
|
|
37
|
+
}
|
|
38
|
+
function errorDetail(body) {
|
|
39
|
+
const b = (body ?? {});
|
|
40
|
+
if (typeof b.detail === "string")
|
|
41
|
+
return b.detail;
|
|
42
|
+
if (Array.isArray(b.errors) && b.errors[0]?.message)
|
|
43
|
+
return String(b.errors[0].message);
|
|
44
|
+
return b.detail ? JSON.stringify(b.detail).slice(0, 200) : "";
|
|
45
|
+
}
|
|
46
|
+
/** Starts a remote browser. `timeoutMs` caps how long the provider keeps it (and bills it) if we never stop it. */
|
|
47
|
+
export async function startRemoteSession(opts = { timeoutMs: 5 * 60_000 }) {
|
|
48
|
+
const provider = opts.provider ?? "browser-use";
|
|
49
|
+
return provider === "cloudflare" ? startCloudflareSession(opts) : startBrowserUseSession(opts);
|
|
50
|
+
}
|
|
51
|
+
async function startBrowserUseSession(opts) {
|
|
52
|
+
const f = opts.fetchImpl ?? fetch;
|
|
53
|
+
const country = (process.env.BROWSER_USE_PROXY_COUNTRY || "us").trim().toLowerCase();
|
|
54
|
+
const res = await f(`${BROWSER_USE_API()}/browsers`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: browserUseHeaders(),
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
timeout: Math.max(1, Math.min(240, Math.ceil(opts.timeoutMs / 60_000))),
|
|
59
|
+
// "none" turns the residential proxy off (cheaper, weaker against IP reputation checks).
|
|
60
|
+
proxyCountryCode: country === "none" ? null : country,
|
|
61
|
+
solveCaptchas: process.env.BROWSER_USE_SOLVE_CAPTCHAS !== "false",
|
|
62
|
+
metadata: { app: "webcrawl" },
|
|
63
|
+
}),
|
|
64
|
+
signal: AbortSignal.timeout(30_000),
|
|
65
|
+
});
|
|
66
|
+
const body = (await res.json().catch(() => ({})));
|
|
67
|
+
if (!res.ok || !body.id || !body.cdpUrl) {
|
|
68
|
+
const detail = errorDetail(body);
|
|
69
|
+
throw new Error(`Browser Use session failed (HTTP ${res.status})${detail ? `: ${detail}` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
return { provider: "browser-use", sessionId: body.id, cdpUrl: body.cdpUrl, startedAt: Date.now() };
|
|
72
|
+
}
|
|
73
|
+
async function startCloudflareSession(opts) {
|
|
74
|
+
const creds = cloudflareCreds();
|
|
75
|
+
if (!creds)
|
|
76
|
+
throw new Error("Cloudflare Browser Run is not configured");
|
|
77
|
+
const f = opts.fetchImpl ?? fetch;
|
|
78
|
+
// keep_alive is how long the session idles before Cloudflare closes it: 10s..20min.
|
|
79
|
+
const keepAlive = Math.max(10_000, Math.min(1_200_000, opts.timeoutMs));
|
|
80
|
+
const res = await f(`${CF_API(creds.accountId)}/devtools/browser?keep_alive=${keepAlive}`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { Authorization: `Bearer ${creds.token}` },
|
|
83
|
+
signal: AbortSignal.timeout(30_000),
|
|
84
|
+
});
|
|
85
|
+
const raw = (await res.json().catch(() => ({})));
|
|
86
|
+
const body = raw.result ?? raw;
|
|
87
|
+
if (!res.ok || !body.sessionId) {
|
|
88
|
+
const detail = errorDetail(raw);
|
|
89
|
+
throw new Error(`Cloudflare Browser Run session failed (HTTP ${res.status})${detail ? `: ${detail}` : ""}`);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
provider: "cloudflare",
|
|
93
|
+
sessionId: body.sessionId,
|
|
94
|
+
cdpUrl: body.webSocketDebuggerUrl ||
|
|
95
|
+
`${CF_API(creds.accountId).replace(/^http/, "ws")}/devtools/browser/${encodeURIComponent(body.sessionId)}`,
|
|
96
|
+
cdpHeaders: { Authorization: `Bearer ${creds.token}` },
|
|
97
|
+
startedAt: Date.now(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Stops the session (unused time is refunded) and returns what it cost. Never throws. */
|
|
101
|
+
export async function stopRemoteSession(session, opts = {}) {
|
|
102
|
+
const f = opts.fetchImpl ?? fetch;
|
|
103
|
+
if (session.provider === "cloudflare") {
|
|
104
|
+
const creds = cloudflareCreds();
|
|
105
|
+
if (creds) {
|
|
106
|
+
await f(`${CF_API(creds.accountId)}/devtools/browser/${encodeURIComponent(session.sessionId)}`, {
|
|
107
|
+
method: "DELETE",
|
|
108
|
+
headers: { Authorization: `Bearer ${creds.token}` },
|
|
109
|
+
signal: AbortSignal.timeout(20_000),
|
|
110
|
+
}).catch(() => null);
|
|
111
|
+
}
|
|
112
|
+
// Cloudflare does not report per-session cost; bill browser time (rounded up to a minute).
|
|
113
|
+
if (!session.startedAt)
|
|
114
|
+
return null;
|
|
115
|
+
const minutes = Math.max(1, Math.ceil(((opts.now ?? Date.now()) - session.startedAt) / 60_000));
|
|
116
|
+
return { costUsd: (minutes / 60) * CLOUDFLARE_BROWSER_HOUR_USD, proxyMb: 0 };
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const res = await f(`${BROWSER_USE_API()}/browsers/${encodeURIComponent(session.sessionId)}`, {
|
|
120
|
+
method: "PATCH",
|
|
121
|
+
headers: browserUseHeaders(),
|
|
122
|
+
body: JSON.stringify({ action: "stop" }),
|
|
123
|
+
signal: AbortSignal.timeout(20_000),
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok)
|
|
126
|
+
return null;
|
|
127
|
+
const body = (await res.json().catch(() => ({})));
|
|
128
|
+
return { costUsd: num(body.proxyCost) + num(body.browserCost), proxyMb: num(body.proxyUsedMb) };
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span events streamed from interact / crawl workflow runs and rendered by
|
|
3
|
+
* the trace view. Kept free of Node APIs so the workflow body and the
|
|
4
|
+
* browser bundle can both import it.
|
|
5
|
+
*/
|
|
6
|
+
export type SpanStatus = "running" | "ok" | "failed" | "skipped";
|
|
7
|
+
export type SpanKind = "setup" | "open" | "action" | "read" | "map" | "page" | "charge" | "fetch" | "ocr" | "transcribe";
|
|
8
|
+
export interface Span {
|
|
9
|
+
/** Stable id within a run, e.g. `action-3`. Later events with the same id replace earlier ones. */
|
|
10
|
+
id: string;
|
|
11
|
+
kind: SpanKind;
|
|
12
|
+
label: string;
|
|
13
|
+
detail?: string;
|
|
14
|
+
status: SpanStatus;
|
|
15
|
+
/** Milliseconds since the run started. */
|
|
16
|
+
startMs: number;
|
|
17
|
+
durationMs?: number;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
export type RunEvent = {
|
|
21
|
+
type: "run";
|
|
22
|
+
runId: string;
|
|
23
|
+
kind: "interact" | "crawl" | "parse";
|
|
24
|
+
url: string;
|
|
25
|
+
startedAt: number;
|
|
26
|
+
} | {
|
|
27
|
+
type: "span";
|
|
28
|
+
span: Span;
|
|
29
|
+
} | {
|
|
30
|
+
type: "done";
|
|
31
|
+
status: "completed" | "failed";
|
|
32
|
+
error?: string;
|
|
33
|
+
};
|
|
34
|
+
/** Folds a list of events into the latest state of each span, in first-seen order. */
|
|
35
|
+
export declare function foldSpans(events: RunEvent[]): Span[];
|
|
36
|
+
export interface SpanSummary {
|
|
37
|
+
total: number;
|
|
38
|
+
ok: number;
|
|
39
|
+
failed: number;
|
|
40
|
+
skipped: number;
|
|
41
|
+
running: number;
|
|
42
|
+
durationMs: number;
|
|
43
|
+
/** 1-based index among spans of the given kind, of the first failure. */
|
|
44
|
+
firstFailedIndex?: number;
|
|
45
|
+
}
|
|
46
|
+
export declare function summarizeSpans(spans: Span[], kind?: SpanKind): SpanSummary;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span events streamed from interact / crawl workflow runs and rendered by
|
|
3
|
+
* the trace view. Kept free of Node APIs so the workflow body and the
|
|
4
|
+
* browser bundle can both import it.
|
|
5
|
+
*/
|
|
6
|
+
/** Folds a list of events into the latest state of each span, in first-seen order. */
|
|
7
|
+
export function foldSpans(events) {
|
|
8
|
+
const order = [];
|
|
9
|
+
const byId = new Map();
|
|
10
|
+
for (const e of events) {
|
|
11
|
+
if (e.type !== "span")
|
|
12
|
+
continue;
|
|
13
|
+
if (!byId.has(e.span.id))
|
|
14
|
+
order.push(e.span.id);
|
|
15
|
+
byId.set(e.span.id, e.span);
|
|
16
|
+
}
|
|
17
|
+
return order.map((id) => byId.get(id));
|
|
18
|
+
}
|
|
19
|
+
export function summarizeSpans(spans, kind) {
|
|
20
|
+
const list = kind ? spans.filter((s) => s.kind === kind) : spans;
|
|
21
|
+
let durationMs = 0;
|
|
22
|
+
let firstFailedIndex;
|
|
23
|
+
const s = { total: list.length, ok: 0, failed: 0, skipped: 0, running: 0 };
|
|
24
|
+
list.forEach((span, i) => {
|
|
25
|
+
s[span.status === "ok" ? "ok" : span.status] += 1;
|
|
26
|
+
if (span.status === "failed" && firstFailedIndex === undefined)
|
|
27
|
+
firstFailedIndex = i + 1;
|
|
28
|
+
});
|
|
29
|
+
for (const span of spans)
|
|
30
|
+
durationMs = Math.max(durationMs, span.startMs + (span.durationMs ?? 0));
|
|
31
|
+
return { ...s, durationMs, firstFailedIndex };
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input checks for /v1/interact. Pure functions so they can be unit-tested
|
|
3
|
+
* and reused by the API route before a workflow run is started.
|
|
4
|
+
*/
|
|
5
|
+
export declare const INTERACT_ACTION_TYPES: readonly ["click", "fill", "type", "hover", "select", "press", "wait", "scroll", "evaluate", "extract"];
|
|
6
|
+
export type InteractActionType = (typeof INTERACT_ACTION_TYPES)[number];
|
|
7
|
+
export declare const MAX_INTERACT_ACTIONS = 20;
|
|
8
|
+
export interface InteractAction {
|
|
9
|
+
type: InteractActionType;
|
|
10
|
+
selector?: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
value?: string;
|
|
13
|
+
key?: string;
|
|
14
|
+
ms?: number;
|
|
15
|
+
y?: number;
|
|
16
|
+
script?: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
/** Browser session length: 2 min by default (first minute in the base price), 10 min max. */
|
|
20
|
+
export declare const DEFAULT_SESSION_SECONDS = 120;
|
|
21
|
+
export declare const MAX_SESSION_SECONDS = 600;
|
|
22
|
+
export type ValidateResult = {
|
|
23
|
+
ok: true;
|
|
24
|
+
url: string;
|
|
25
|
+
actions: InteractAction[];
|
|
26
|
+
sessionSeconds: number;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function normalizeActionType(raw: unknown): InteractActionType | undefined;
|
|
32
|
+
/** Human label used in the trace, e.g. `Click #submit`. */
|
|
33
|
+
export declare function describeAction(a: InteractAction): string;
|
|
34
|
+
export declare function validateInteractRequest(body: Record<string, unknown>): ValidateResult;
|