@webcrawl/mcp 1.4.2 → 1.4.3
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.d.ts +14 -0
- package/dist/core/engine/chrome.js +140 -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 +14 -0
- package/dist/core/extract/structured.js +144 -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 +56 -0
- package/dist/core/local-engine.js +667 -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 +719 -0
- package/dist/core/search/providers.d.ts +72 -0
- package/dist/core/search/providers.js +1270 -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 +3 -0
- package/package.json +11 -3
|
@@ -0,0 +1,135 @@
|
|
|
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 const INTERACT_ACTION_TYPES = [
|
|
6
|
+
"click",
|
|
7
|
+
"fill",
|
|
8
|
+
"type",
|
|
9
|
+
"hover",
|
|
10
|
+
"select",
|
|
11
|
+
"press",
|
|
12
|
+
"wait",
|
|
13
|
+
"scroll",
|
|
14
|
+
"evaluate",
|
|
15
|
+
"extract",
|
|
16
|
+
];
|
|
17
|
+
export const MAX_INTERACT_ACTIONS = 20;
|
|
18
|
+
/** Browser session length: 2 min by default (first minute in the base price), 10 min max. */
|
|
19
|
+
export const DEFAULT_SESSION_SECONDS = 120;
|
|
20
|
+
export const MAX_SESSION_SECONDS = 600;
|
|
21
|
+
const NEEDS_SELECTOR = new Set(["click", "fill", "type", "hover", "select", "extract"]);
|
|
22
|
+
const ALIASES = {
|
|
23
|
+
input: "fill",
|
|
24
|
+
write: "fill",
|
|
25
|
+
keypress: "press",
|
|
26
|
+
scrape: "extract",
|
|
27
|
+
wait_for: "wait",
|
|
28
|
+
waitfor: "wait",
|
|
29
|
+
js: "evaluate",
|
|
30
|
+
execute: "evaluate",
|
|
31
|
+
};
|
|
32
|
+
const str = (v) => typeof v === "string" ? v : typeof v === "number" ? String(v) : undefined;
|
|
33
|
+
const num = (v) => {
|
|
34
|
+
const n = typeof v === "string" ? Number(v) : v;
|
|
35
|
+
return typeof n === "number" && Number.isFinite(n) ? n : undefined;
|
|
36
|
+
};
|
|
37
|
+
export function normalizeActionType(raw) {
|
|
38
|
+
const t = String(raw ?? "").trim().toLowerCase();
|
|
39
|
+
if (INTERACT_ACTION_TYPES.includes(t))
|
|
40
|
+
return t;
|
|
41
|
+
return ALIASES[t];
|
|
42
|
+
}
|
|
43
|
+
/** Human label used in the trace, e.g. `Click #submit`. */
|
|
44
|
+
export function describeAction(a) {
|
|
45
|
+
switch (a.type) {
|
|
46
|
+
case "wait":
|
|
47
|
+
return a.selector ? "Wait for element" : `Wait ${a.ms ?? 500}ms`;
|
|
48
|
+
case "scroll":
|
|
49
|
+
return a.selector ? "Scroll to element" : `Scroll ${a.y ?? 800}px`;
|
|
50
|
+
case "press":
|
|
51
|
+
return `Press ${a.key ?? "Enter"}`;
|
|
52
|
+
case "fill":
|
|
53
|
+
return "Fill";
|
|
54
|
+
case "evaluate":
|
|
55
|
+
return "Run script";
|
|
56
|
+
default:
|
|
57
|
+
return a.type.charAt(0).toUpperCase() + a.type.slice(1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function validateInteractRequest(body) {
|
|
61
|
+
const rawUrl = str(body.url)?.trim() ?? "";
|
|
62
|
+
if (!rawUrl)
|
|
63
|
+
return { ok: false, error: "Missing `url` parameter" };
|
|
64
|
+
let url;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = new URL(/^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`);
|
|
67
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
68
|
+
throw new Error();
|
|
69
|
+
url = parsed.toString();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { ok: false, error: `Invalid url: ${rawUrl}` };
|
|
73
|
+
}
|
|
74
|
+
const rawActions = Array.isArray(body.actions)
|
|
75
|
+
? body.actions
|
|
76
|
+
: body.selector
|
|
77
|
+
? [{ type: "extract", selector: body.selector }]
|
|
78
|
+
: [];
|
|
79
|
+
if (rawActions.length > MAX_INTERACT_ACTIONS) {
|
|
80
|
+
return { ok: false, error: `Too many actions (${rawActions.length}). The limit is ${MAX_INTERACT_ACTIONS}.` };
|
|
81
|
+
}
|
|
82
|
+
const actions = [];
|
|
83
|
+
for (let i = 0; i < rawActions.length; i++) {
|
|
84
|
+
const raw = rawActions[i];
|
|
85
|
+
const a = typeof raw === "object" && raw !== null ? raw : { type: raw };
|
|
86
|
+
const type = normalizeActionType(a.type ?? a.action);
|
|
87
|
+
const at = `actions[${i}]`;
|
|
88
|
+
if (!type) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: `${at}: unknown type ${JSON.stringify(a.type ?? a.action ?? null)}. Valid types: ${INTERACT_ACTION_TYPES.join(", ")}.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const selector = str(a.selector)?.trim() || undefined;
|
|
95
|
+
if (NEEDS_SELECTOR.has(type) && !selector) {
|
|
96
|
+
return { ok: false, error: `${at}: \`${type}\` needs a \`selector\`.` };
|
|
97
|
+
}
|
|
98
|
+
const action = { type };
|
|
99
|
+
if (selector)
|
|
100
|
+
action.selector = selector.slice(0, 500);
|
|
101
|
+
const text = str(a.text) ?? str(a.value);
|
|
102
|
+
if (type === "fill" || type === "type") {
|
|
103
|
+
if (text === undefined)
|
|
104
|
+
return { ok: false, error: `${at}: \`${type}\` needs \`text\`.` };
|
|
105
|
+
action.text = text.slice(0, 5000);
|
|
106
|
+
}
|
|
107
|
+
if (type === "select") {
|
|
108
|
+
if (text === undefined)
|
|
109
|
+
return { ok: false, error: `${at}: \`select\` needs a \`value\`.` };
|
|
110
|
+
action.value = text.slice(0, 500);
|
|
111
|
+
}
|
|
112
|
+
if (type === "press")
|
|
113
|
+
action.key = (str(a.key) ?? "Enter").slice(0, 40);
|
|
114
|
+
if (type === "wait" && !selector) {
|
|
115
|
+
action.ms = Math.max(0, Math.min(num(a.ms ?? a.milliseconds) ?? 500, 15_000));
|
|
116
|
+
}
|
|
117
|
+
if (type === "scroll" && !selector)
|
|
118
|
+
action.y = Math.max(-20_000, Math.min(num(a.y ?? a.amount) ?? 800, 20_000));
|
|
119
|
+
if (type === "evaluate") {
|
|
120
|
+
const script = str(a.script ?? a.expression ?? a.code);
|
|
121
|
+
if (!script)
|
|
122
|
+
return { ok: false, error: `${at}: \`evaluate\` needs a \`script\`.` };
|
|
123
|
+
if (script.length > 10_000)
|
|
124
|
+
return { ok: false, error: `${at}: script is longer than 10,000 characters.` };
|
|
125
|
+
action.script = script;
|
|
126
|
+
}
|
|
127
|
+
const timeoutMs = num(a.timeoutMs ?? a.timeout);
|
|
128
|
+
if (timeoutMs !== undefined)
|
|
129
|
+
action.timeoutMs = Math.max(500, Math.min(timeoutMs, 30_000));
|
|
130
|
+
actions.push(action);
|
|
131
|
+
}
|
|
132
|
+
const rawSession = num(body.sessionSeconds ?? body.timeoutSeconds);
|
|
133
|
+
const sessionSeconds = Math.round(Math.max(30, Math.min(rawSession ?? DEFAULT_SESSION_SECONDS, MAX_SESSION_SECONDS)));
|
|
134
|
+
return { ok: true, url, actions, sessionSeconds };
|
|
135
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scripts that run inside the interact sandbox VM.
|
|
3
|
+
*
|
|
4
|
+
* Layout inside the VM:
|
|
5
|
+
* /opt/wc/node_modules/playwright Playwright client (used over CDP only)
|
|
6
|
+
* /opt/wc/step.mjs runs ONE action against the live browser
|
|
7
|
+
* Chromium listens on 127.0.0.1:9222 (started detached, survives across commands)
|
|
8
|
+
*
|
|
9
|
+
* The step runner is plain JS so it can also run locally against a desktop
|
|
10
|
+
* Chromium in tests (set WC_PLAYWRIGHT / WC_CDP).
|
|
11
|
+
*/
|
|
12
|
+
export declare const VM_ROOT = "/opt/wc";
|
|
13
|
+
export declare const CDP_PORT = 9222;
|
|
14
|
+
export declare const STEP_SCRIPT_PATH = "/opt/wc/step.mjs";
|
|
15
|
+
/** One-time VM setup. Baked into a snapshot; also used as a slow fallback. */
|
|
16
|
+
export declare const SETUP_SCRIPT: string;
|
|
17
|
+
export declare function chromeArgs(opts?: {
|
|
18
|
+
userAgent?: string;
|
|
19
|
+
width?: number;
|
|
20
|
+
height?: number;
|
|
21
|
+
proxy?: string;
|
|
22
|
+
}): string[];
|
|
23
|
+
/** Waits until the CDP endpoint answers. Exit 1 if it never does. */
|
|
24
|
+
export declare const WAIT_FOR_CDP_SCRIPT = "for i in $(seq 1 50); do curl -sf http://127.0.0.1:9222/json/version >/dev/null && echo up && exit 0; sleep 0.2; done; echo \"browser did not start\" >&2; exit 1";
|
|
25
|
+
/**
|
|
26
|
+
* Step runner. Usage: node step.mjs '<json action>'
|
|
27
|
+
* Prints exactly one line: `WCRESULT {"ok":true,...}`.
|
|
28
|
+
* Never throws: failures are reported as { ok: false, error }.
|
|
29
|
+
*/
|
|
30
|
+
export declare const STEP_SCRIPT: string;
|
|
31
|
+
export type StepResultLine = {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
url?: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
data?: {
|
|
37
|
+
status?: number | null;
|
|
38
|
+
html?: string;
|
|
39
|
+
value?: unknown;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
/** Parses the WCRESULT line emitted by STEP_SCRIPT. */
|
|
43
|
+
export declare function parseStepOutput(stdout: string, stderr?: string): StepResultLine;
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scripts that run inside the interact sandbox VM.
|
|
3
|
+
*
|
|
4
|
+
* Layout inside the VM:
|
|
5
|
+
* /opt/wc/node_modules/playwright Playwright client (used over CDP only)
|
|
6
|
+
* /opt/wc/step.mjs runs ONE action against the live browser
|
|
7
|
+
* Chromium listens on 127.0.0.1:9222 (started detached, survives across commands)
|
|
8
|
+
*
|
|
9
|
+
* The step runner is plain JS so it can also run locally against a desktop
|
|
10
|
+
* Chromium in tests (set WC_PLAYWRIGHT / WC_CDP).
|
|
11
|
+
*/
|
|
12
|
+
export const VM_ROOT = "/opt/wc";
|
|
13
|
+
export const CDP_PORT = 9222;
|
|
14
|
+
export const STEP_SCRIPT_PATH = `${VM_ROOT}/step.mjs`;
|
|
15
|
+
/** Shared libraries Chromium needs on Amazon Linux 2023 (sandbox base image). */
|
|
16
|
+
const CHROMIUM_DNF_DEPS = [
|
|
17
|
+
"nss",
|
|
18
|
+
"nspr",
|
|
19
|
+
"atk",
|
|
20
|
+
"at-spi2-atk",
|
|
21
|
+
"cups-libs",
|
|
22
|
+
"libdrm",
|
|
23
|
+
"libXcomposite",
|
|
24
|
+
"libXdamage",
|
|
25
|
+
"libXrandr",
|
|
26
|
+
"libXfixes",
|
|
27
|
+
"mesa-libgbm",
|
|
28
|
+
"pango",
|
|
29
|
+
"alsa-lib",
|
|
30
|
+
"libxkbcommon",
|
|
31
|
+
"libxshmfence",
|
|
32
|
+
].join(" ");
|
|
33
|
+
/** One-time VM setup. Baked into a snapshot; also used as a slow fallback. */
|
|
34
|
+
export const SETUP_SCRIPT = [
|
|
35
|
+
"set -e",
|
|
36
|
+
`dnf install -y ${CHROMIUM_DNF_DEPS} >/tmp/wc-dnf.log 2>&1`,
|
|
37
|
+
`mkdir -p ${VM_ROOT} && cd ${VM_ROOT}`,
|
|
38
|
+
"[ -f package.json ] || npm init -y >/dev/null",
|
|
39
|
+
"npm i playwright@1 >/tmp/wc-npm.log 2>&1",
|
|
40
|
+
"npx playwright install chromium >/tmp/wc-pw.log 2>&1",
|
|
41
|
+
`node -e "console.log(require('playwright').chromium.executablePath())" > ${VM_ROOT}/chrome-path`,
|
|
42
|
+
`cat ${VM_ROOT}/chrome-path`,
|
|
43
|
+
].join(" && ");
|
|
44
|
+
export function chromeArgs(opts = {}) {
|
|
45
|
+
const args = [
|
|
46
|
+
"--headless=new",
|
|
47
|
+
"--no-sandbox",
|
|
48
|
+
"--disable-gpu",
|
|
49
|
+
"--disable-dev-shm-usage",
|
|
50
|
+
"--no-first-run",
|
|
51
|
+
"--no-default-browser-check",
|
|
52
|
+
`--remote-debugging-port=${CDP_PORT}`,
|
|
53
|
+
"--remote-debugging-address=127.0.0.1",
|
|
54
|
+
"--user-data-dir=/tmp/wc-profile",
|
|
55
|
+
// Hide the automation flag (navigator.webdriver) that bot checks look for.
|
|
56
|
+
"--disable-blink-features=AutomationControlled",
|
|
57
|
+
`--window-size=${opts.width ?? 1280},${opts.height ?? 800}`,
|
|
58
|
+
];
|
|
59
|
+
if (opts.userAgent)
|
|
60
|
+
args.push(`--user-agent=${opts.userAgent}`);
|
|
61
|
+
// Residential-proxy browser tier (P2): all browser traffic egresses via the
|
|
62
|
+
// proxy. Chromium cannot parse credentials in --proxy-server, so they are
|
|
63
|
+
// stripped here and re-supplied per-page via proxy auth in the step script.
|
|
64
|
+
if (opts.proxy)
|
|
65
|
+
args.push(`--proxy-server=${opts.proxy.replace(/\/\/[^@/]+@/, "//")}`);
|
|
66
|
+
args.push("about:blank");
|
|
67
|
+
return args;
|
|
68
|
+
}
|
|
69
|
+
/** Waits until the CDP endpoint answers. Exit 1 if it never does. */
|
|
70
|
+
export const WAIT_FOR_CDP_SCRIPT = `for i in $(seq 1 50); do curl -sf http://127.0.0.1:${CDP_PORT}/json/version >/dev/null && echo up && exit 0; sleep 0.2; done; echo "browser did not start" >&2; exit 1`;
|
|
71
|
+
/**
|
|
72
|
+
* Step runner. Usage: node step.mjs '<json action>'
|
|
73
|
+
* Prints exactly one line: `WCRESULT {"ok":true,...}`.
|
|
74
|
+
* Never throws: failures are reported as { ok: false, error }.
|
|
75
|
+
*/
|
|
76
|
+
export const STEP_SCRIPT = String.raw `
|
|
77
|
+
const PW = process.env.WC_PLAYWRIGHT || "/opt/wc/node_modules/playwright/index.mjs";
|
|
78
|
+
const CDP = process.env.WC_CDP || "http://127.0.0.1:9222";
|
|
79
|
+
const { chromium } = await import(PW);
|
|
80
|
+
|
|
81
|
+
const action = JSON.parse(process.argv[2] || "{}");
|
|
82
|
+
const timeout = Math.max(500, Math.min(Number(action.timeoutMs) || 10000, 30000));
|
|
83
|
+
const out = (r) => { process.stdout.write("WCRESULT " + JSON.stringify(r) + "\n"); };
|
|
84
|
+
|
|
85
|
+
let browser;
|
|
86
|
+
try {
|
|
87
|
+
const cdpHeaders = process.env.WC_CDP_HEADERS ? JSON.parse(process.env.WC_CDP_HEADERS) : undefined;
|
|
88
|
+
browser = await chromium.connectOverCDP(CDP, { timeout: 15000, ...(cdpHeaders ? { headers: cdpHeaders } : {}) });
|
|
89
|
+
const ctx = browser.contexts()[0] ?? (await browser.newContext());
|
|
90
|
+
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
|
91
|
+
page.setDefaultTimeout(timeout);
|
|
92
|
+
const sel = action.selector ? String(action.selector) : undefined;
|
|
93
|
+
const need = () => { if (!sel) throw new Error("selector is required for " + action.type); return sel; };
|
|
94
|
+
// Fail fast with a clear message when the selector matches nothing.
|
|
95
|
+
const locate = async () => {
|
|
96
|
+
const loc = page.locator(need()).first();
|
|
97
|
+
try {
|
|
98
|
+
await loc.waitFor({ state: "attached", timeout });
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error("No element matches selector " + JSON.stringify(sel));
|
|
101
|
+
}
|
|
102
|
+
return loc;
|
|
103
|
+
};
|
|
104
|
+
let data;
|
|
105
|
+
|
|
106
|
+
switch (action.type) {
|
|
107
|
+
case "goto": {
|
|
108
|
+
const resp = await page.goto(String(action.url), { waitUntil: "domcontentloaded", timeout: Math.max(timeout, 20000) });
|
|
109
|
+
data = { status: resp ? resp.status() : null };
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "wait": {
|
|
113
|
+
if (sel) await (await locate()).waitFor({ state: "visible", timeout });
|
|
114
|
+
else await page.waitForTimeout(Math.max(0, Math.min(Number(action.ms) || 500, 15000)));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "click": await (await locate()).click({ timeout }); break;
|
|
118
|
+
case "hover": await (await locate()).hover({ timeout }); break;
|
|
119
|
+
case "fill": await (await locate()).fill(String(action.text ?? action.value ?? ""), { timeout }); break;
|
|
120
|
+
case "type": await (await locate()).pressSequentially(String(action.text ?? action.value ?? ""), { delay: 20, timeout }); break;
|
|
121
|
+
case "select": await (await locate()).selectOption(String(action.value ?? action.text ?? ""), { timeout }); break;
|
|
122
|
+
case "press": {
|
|
123
|
+
const key = String(action.key || "Enter");
|
|
124
|
+
if (sel) await (await locate()).press(key, { timeout });
|
|
125
|
+
else await page.keyboard.press(key);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case "scroll": {
|
|
129
|
+
if (sel) await (await locate()).scrollIntoViewIfNeeded({ timeout });
|
|
130
|
+
else await page.mouse.wheel(0, Number(action.y ?? action.amount ?? 800));
|
|
131
|
+
await page.waitForTimeout(300);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "evaluate": {
|
|
135
|
+
const script = String(action.script ?? action.expression ?? "");
|
|
136
|
+
if (!script) throw new Error("script is required for evaluate");
|
|
137
|
+
const value = await page.evaluate(script);
|
|
138
|
+
let json = JSON.stringify(value ?? null);
|
|
139
|
+
if (json.length > 8192) json = JSON.stringify(json.slice(0, 8192) + "…");
|
|
140
|
+
data = { value: JSON.parse(json) };
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "extract": {
|
|
144
|
+
const loc = await locate();
|
|
145
|
+
const html = await loc.evaluate((el) => el.outerHTML);
|
|
146
|
+
data = { html: html.slice(0, 2000000) };
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "snapshot": {
|
|
150
|
+
try { await page.waitForLoadState("networkidle", { timeout: 3000 }); } catch {}
|
|
151
|
+
data = { html: (await page.content()).slice(0, 4000000) };
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case "fetchMany": {
|
|
155
|
+
// Stealth fetch: pass the bot check once on the entry page in a real browser,
|
|
156
|
+
// then read every item with the cleared session (same IP, cookies, TLS).
|
|
157
|
+
const items = Array.isArray(action.items) ? action.items.slice(0, 40) : [];
|
|
158
|
+
const budget = Math.max(5000, Math.min(Number(action.budgetMs) || 60000, 240000));
|
|
159
|
+
const solveMs = Math.max(2000, Math.min(Number(action.solveMs) || 20000, 45000));
|
|
160
|
+
const PER_DOC = 1500000;
|
|
161
|
+
const TOTAL = 10000000;
|
|
162
|
+
const started = Date.now();
|
|
163
|
+
const markers = ["vercel security checkpoint", "just a moment...", "cf-browser-verification", "challenge-platform", "_cf_chl_opt", "enable javascript and cookies to continue", "_vercel/security", "attention required! | cloudflare", "awswafcookiedomainlist", "awswafintegration", "aws-waf-token", 'id="challenge-container"', "/__challenge_"];
|
|
164
|
+
const looksBlocked = (s) => { const l = String(s || "").slice(0, 8000).toLowerCase(); return markers.some((m) => l.includes(m)); };
|
|
165
|
+
let ua = "";
|
|
166
|
+
try {
|
|
167
|
+
const cdp = await ctx.newCDPSession(page);
|
|
168
|
+
const ver = await cdp.send("Browser.getVersion");
|
|
169
|
+
ua = String(ver.userAgent || "").replace(/HeadlessChrome/g, "Chrome");
|
|
170
|
+
// A remote provider's browser already has a coherent fingerprint; overriding it would break that.
|
|
171
|
+
if (action.keepFingerprint) throw new Error("keep");
|
|
172
|
+
const major = (ua.match(/Chrome\/(\d+)/) || [])[1] || "140";
|
|
173
|
+
const full = (ua.match(/Chrome\/([\d.]+)/) || [])[1] || major + ".0.0.0";
|
|
174
|
+
await cdp.send("Emulation.setUserAgentOverride", {
|
|
175
|
+
userAgent: ua,
|
|
176
|
+
acceptLanguage: "en-US,en;q=0.9",
|
|
177
|
+
platform: "Linux x86_64",
|
|
178
|
+
userAgentMetadata: {
|
|
179
|
+
brands: [{ brand: "Chromium", version: major }, { brand: "Google Chrome", version: major }, { brand: "Not.A/Brand", version: "99" }],
|
|
180
|
+
fullVersion: full, platform: "Linux", platformVersion: "6.1.0", architecture: "x86", model: "", mobile: false,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: "Object.defineProperty(Navigator.prototype,'webdriver',{get:()=>false});" });
|
|
184
|
+
} catch {}
|
|
185
|
+
// Remote browsers bill proxy traffic per GB: skip bytes markdown never uses.
|
|
186
|
+
// The proxy tier (P2) blocks harder — stylesheets and analytics beacons
|
|
187
|
+
// too — because residential-proxy bandwidth is billed per MB and markdown
|
|
188
|
+
// only needs the document, its scripts and the challenge sensor (the
|
|
189
|
+
// sensor host is never on the ad blocklist). A byte cap aborts the rest
|
|
190
|
+
// of the page once the billed total blows past the budget.
|
|
191
|
+
// Anti-bot sensor/challenge hosts are ALWAYS allowed, before any block
|
|
192
|
+
// rule: blocking them would make the DataDome / Cloudflare / AWS WAF
|
|
193
|
+
// challenge unsolvable — no cookie is minted, the interstitial loops
|
|
194
|
+
// forever, and the failed sensor POST is itself a bot signal. The
|
|
195
|
+
// sensor payload is a few KB of form-encoded text, so allowing it
|
|
196
|
+
// costs essentially nothing against the byte budget.
|
|
197
|
+
const allowRe = /(api-js\.datadome\.co|js\.datadome\.co|ct\.captcha-delivery\.com|geo\.captcha-delivery\.com|challenges\.cloudflare\.com|[a-z0-9-]+\.awswaf\.com)/i;
|
|
198
|
+
let proxyBilled = 0;
|
|
199
|
+
if (action.lightweight || action.proxyUser) {
|
|
200
|
+
const skip = new Set(action.proxyUser ? ["image", "media", "font", "stylesheet"] : ["image", "media", "font"]);
|
|
201
|
+
const adRe = /(google-analytics|googletagmanager|doubleclick|adservice\.google|facebook\.net|connect\.facebook|hotjar|segment\.io|clarity\.ms|bat\.bing|scorecardresearch|newrelic|optimizely|criteo|taboola|outbrain)/i;
|
|
202
|
+
const cap = action.proxyUser ? Math.max(2_000_000, Math.min(Number(action.proxyByteCap) || 6_000_000, 12_000_000)) : Infinity;
|
|
203
|
+
page.on("response", (r) => { try { proxyBilled += Number(r.headers()["content-length"] || 0) || 0; } catch {} });
|
|
204
|
+
await page.route("**/*", (r) => {
|
|
205
|
+
try {
|
|
206
|
+
const req = r.request();
|
|
207
|
+
if (allowRe.test(req.url())) return r.continue(); // never block a challenge sensor, even past the cap
|
|
208
|
+
if (proxyBilled > cap) return r.abort();
|
|
209
|
+
if (skip.has(req.resourceType())) return r.abort();
|
|
210
|
+
if (adRe.test(req.url())) return r.abort();
|
|
211
|
+
return r.continue();
|
|
212
|
+
} catch { return r.continue(); }
|
|
213
|
+
}).catch(() => {});
|
|
214
|
+
if (action.proxyUser) {
|
|
215
|
+
try { await page.authenticate({ username: String(action.proxyUser), password: String(action.proxyPass || "") }); } catch {}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
let navStatus = null;
|
|
219
|
+
page.on("response", (r) => { try { if (r.request().isNavigationRequest() && r.frame() === page.mainFrame()) navStatus = r.status(); } catch {} });
|
|
220
|
+
const render = async (url, wait) => {
|
|
221
|
+
navStatus = null;
|
|
222
|
+
try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: Math.min(30000, wait + 10000) }); } catch (e) { if (navStatus === null) throw e; }
|
|
223
|
+
const deadline = Date.now() + wait;
|
|
224
|
+
let html = "";
|
|
225
|
+
for (;;) {
|
|
226
|
+
html = await page.content().catch(() => "");
|
|
227
|
+
if ((html && !looksBlocked(html)) || Date.now() > deadline) break;
|
|
228
|
+
await page.waitForLoadState("load", { timeout: 2000 }).catch(() => {});
|
|
229
|
+
await page.waitForTimeout(700);
|
|
230
|
+
}
|
|
231
|
+
try { await page.waitForLoadState("networkidle", { timeout: 2500 }); } catch {}
|
|
232
|
+
html = await page.content().catch(() => html);
|
|
233
|
+
const blocked = looksBlocked(html);
|
|
234
|
+
return { status: blocked ? navStatus : (navStatus && navStatus < 400 ? navStatus : 200), contentType: "text/html", body: html, finalUrl: page.url(), title: await page.title().catch(() => ""), blocked };
|
|
235
|
+
};
|
|
236
|
+
const raw = async (url) => {
|
|
237
|
+
let sameOrigin = false;
|
|
238
|
+
try { sameOrigin = new URL(url).origin === new URL(page.url()).origin; } catch {}
|
|
239
|
+
if (sameOrigin) {
|
|
240
|
+
const r = await page.evaluate(async (u) => {
|
|
241
|
+
const res = await fetch(u, { credentials: "include", redirect: "follow" });
|
|
242
|
+
return { status: res.status, contentType: res.headers.get("content-type") || "", body: await res.text(), finalUrl: res.url };
|
|
243
|
+
}, url);
|
|
244
|
+
return { ...r, title: "", blocked: r.status === 429 || looksBlocked(r.body) };
|
|
245
|
+
}
|
|
246
|
+
const res = await ctx.request.get(url, { timeout: 15000 });
|
|
247
|
+
const body = await res.text();
|
|
248
|
+
return { status: res.status(), contentType: res.headers()["content-type"] || "", body, finalUrl: res.url(), title: "", blocked: res.status() === 429 || looksBlocked(body) };
|
|
249
|
+
};
|
|
250
|
+
let total = 0;
|
|
251
|
+
const clip = (d) => {
|
|
252
|
+
const room = Math.max(0, Math.min(PER_DOC, TOTAL - total));
|
|
253
|
+
const body = String(d.body || "").slice(0, room);
|
|
254
|
+
total += body.length;
|
|
255
|
+
return { ...d, body };
|
|
256
|
+
};
|
|
257
|
+
let entry = null;
|
|
258
|
+
if (action.entry) {
|
|
259
|
+
try { entry = clip(await render(String(action.entry), solveMs)); } catch (e) { entry = { blocked: true, error: String(e && e.message || e).split("\n")[0].slice(0, 300) }; }
|
|
260
|
+
}
|
|
261
|
+
const docs = [];
|
|
262
|
+
const queue = items.slice();
|
|
263
|
+
const seen = new Set(queue.map((it) => String(it && it.url || "")));
|
|
264
|
+
const maxFollow = Math.max(0, Math.min(Number(action.followSitemaps) || 0, 20));
|
|
265
|
+
let followed = 0;
|
|
266
|
+
// Queue sitemaps named in robots.txt or a sitemap index, read in this same session.
|
|
267
|
+
const follow = (d) => {
|
|
268
|
+
if (!maxFollow || !d.body || d.blocked || (d.status || 200) >= 400) return;
|
|
269
|
+
const found = [];
|
|
270
|
+
if (/\/robots\.txt(\?|$)/i.test(d.url)) {
|
|
271
|
+
for (const m of String(d.body).matchAll(/^\s*Sitemap:\s*(\S+)/gim)) found.push(m[1].trim());
|
|
272
|
+
} else if (/<sitemapindex[\s>]/i.test(d.body)) {
|
|
273
|
+
for (const m of String(d.body).matchAll(/<loc>\s*(?:<!\[CDATA\[)?([^<\]]+?)(?:\]\]>)?\s*<\/loc>/gi)) found.push(m[1].trim().replace(/&/g, "&"));
|
|
274
|
+
}
|
|
275
|
+
for (const u of found) {
|
|
276
|
+
if (followed >= maxFollow || seen.has(u)) continue;
|
|
277
|
+
seen.add(u); followed++;
|
|
278
|
+
queue.push({ url: u, raw: true });
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
while (queue.length) {
|
|
282
|
+
const it = queue.shift();
|
|
283
|
+
const url = String(it && it.url || "");
|
|
284
|
+
if (!url) continue;
|
|
285
|
+
if (Date.now() - started > budget) { docs.push({ url, ok: false, error: "Ran out of time" }); continue; }
|
|
286
|
+
try {
|
|
287
|
+
let d = it.raw ? await raw(url) : await render(url, Math.min(solveMs, 10000));
|
|
288
|
+
// A raw read that still hits the check gets one rendered attempt.
|
|
289
|
+
if (d.blocked && it.raw) d = await render(url, Math.min(solveMs, 10000));
|
|
290
|
+
const doc = { url, ok: true, ...clip(d) };
|
|
291
|
+
docs.push(doc);
|
|
292
|
+
follow(doc);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
docs.push({ url, ok: false, error: String(e && e.message || e).split("\n")[0].slice(0, 300) });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Session capture: cookies + UA ride the result so the solved session can
|
|
298
|
+
// be replayed on the lightweight tier or the sandbox kept warm + reconnected.
|
|
299
|
+
let cookies = [];
|
|
300
|
+
try {
|
|
301
|
+
cookies = (await ctx.cookies())
|
|
302
|
+
.filter((c) => !c.expires || c.expires > Date.now() / 1000)
|
|
303
|
+
.slice(0, 50)
|
|
304
|
+
.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, expires: c.expires }));
|
|
305
|
+
} catch {}
|
|
306
|
+
data = { value: { entry, docs, solved: entry ? !entry.blocked : true, userAgent: ua, cookies } };
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
default:
|
|
310
|
+
throw new Error("Unsupported action type " + JSON.stringify(action.type));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
out({ ok: true, url: page.url(), title: await page.title().catch(() => ""), data });
|
|
314
|
+
} catch (err) {
|
|
315
|
+
const msg = err && err.message ? String(err.message).split("\n")[0] : String(err);
|
|
316
|
+
out({ ok: false, error: msg.slice(0, 500) });
|
|
317
|
+
} finally {
|
|
318
|
+
// Disconnect only; the browser keeps running for the next step.
|
|
319
|
+
if (browser) await browser.close().catch(() => {});
|
|
320
|
+
}
|
|
321
|
+
`;
|
|
322
|
+
/** Parses the WCRESULT line emitted by STEP_SCRIPT. */
|
|
323
|
+
export function parseStepOutput(stdout, stderr = "") {
|
|
324
|
+
const line = stdout
|
|
325
|
+
.split("\n")
|
|
326
|
+
.reverse()
|
|
327
|
+
.find((l) => l.startsWith("WCRESULT "));
|
|
328
|
+
if (!line) {
|
|
329
|
+
const tail = (stderr || stdout).trim().split("\n").slice(-3).join(" ").slice(0, 400);
|
|
330
|
+
return { ok: false, error: tail || "Step produced no result" };
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
return JSON.parse(line.slice("WCRESULT ".length));
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return { ok: false, error: "Step result was not valid JSON" };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface LocalEngineBackend {
|
|
2
|
+
readonly baseUrl: string;
|
|
3
|
+
search(p: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
4
|
+
scrape(p: {
|
|
5
|
+
url: string;
|
|
6
|
+
formats?: Array<"markdown" | "links" | "html">;
|
|
7
|
+
noCache?: boolean;
|
|
8
|
+
}): Promise<Record<string, unknown>>;
|
|
9
|
+
parse(p: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
10
|
+
run(id: string): Promise<Record<string, unknown>>;
|
|
11
|
+
map(p: {
|
|
12
|
+
url: string;
|
|
13
|
+
limit?: number;
|
|
14
|
+
search?: string;
|
|
15
|
+
}): Promise<Record<string, unknown>>;
|
|
16
|
+
crawl(p: {
|
|
17
|
+
url: string;
|
|
18
|
+
maxPages?: number;
|
|
19
|
+
extractStructured?: boolean;
|
|
20
|
+
}): Promise<Record<string, unknown>>;
|
|
21
|
+
extract(p: {
|
|
22
|
+
url: string;
|
|
23
|
+
prompt?: string;
|
|
24
|
+
schema?: Record<string, unknown>;
|
|
25
|
+
}): Promise<Record<string, unknown>>;
|
|
26
|
+
rerank(p: {
|
|
27
|
+
query: string;
|
|
28
|
+
documents: Array<string | Record<string, unknown>>;
|
|
29
|
+
topK?: number;
|
|
30
|
+
}): Promise<Record<string, unknown>>;
|
|
31
|
+
categories(): Promise<Record<string, unknown>>;
|
|
32
|
+
targets(): Promise<Record<string, unknown>>;
|
|
33
|
+
usage(): Promise<Record<string, unknown>>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Scrape result for a page the site refused (bot check, 403/429/503).
|
|
37
|
+
* `success: false` with no markdown, so callers never mistake the block page
|
|
38
|
+
* for content. Exported for unit tests.
|
|
39
|
+
*/
|
|
40
|
+
export declare function blockedScrapeResult(url: string, statusCode: number, tier: string, latencyMs: number, reason?: string): Record<string, unknown>;
|
|
41
|
+
/** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
|
|
42
|
+
export declare const MIN_CACHEABLE_MARKDOWN = 200;
|
|
43
|
+
/** Attach source HTML when the caller asked for the `html` format. */
|
|
44
|
+
export declare function withOptionalHtml(result: Record<string, unknown>, html: string | undefined, formats?: Array<"markdown" | "links" | "html">): Record<string, unknown>;
|
|
45
|
+
/** Structured extract payload: custom prompt/schema → only those fields, no `target` sidecar. */
|
|
46
|
+
export declare function extractOutputData(extracted: Record<string, unknown>, opts: {
|
|
47
|
+
prompt?: string;
|
|
48
|
+
schema?: Record<string, unknown>;
|
|
49
|
+
target?: unknown;
|
|
50
|
+
}): Record<string, unknown>;
|
|
51
|
+
/**
|
|
52
|
+
* Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
|
|
53
|
+
* Matching is a case-insensitive substring of the URL. Exported for unit tests.
|
|
54
|
+
*/
|
|
55
|
+
export declare function filterMappedLinks(links: string[], search?: string, limit?: number): string[];
|
|
56
|
+
export declare function createLocalEngineClient(): LocalEngineBackend;
|