@proagentstore/cli 0.1.18 → 0.1.19
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/dist/browser-runner/browser-actions.js +177 -0
- package/dist/browser-runner/browser-profile.js +78 -0
- package/dist/browser-runner/challenge.js +86 -0
- package/dist/browser-runner/errors.js +13 -0
- package/dist/browser-runner/human-mouse.js +71 -0
- package/dist/browser-runner/runner.js +566 -430
- package/dist/browser-runner/server.js +56 -0
- package/dist/browser-runner/store.js +24 -0
- package/dist/browser-runner/test-job-server.js +29 -2
- package/dist/index.js +68 -37
- package/package.json +1 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { RunnerInputError } from "./errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* Execute one selector-free browser action — address by ARIA role + accessible
|
|
6
|
+
* name, with robust fallbacks for custom comboboxes, typeaheads, fuzzy <select>
|
|
7
|
+
* matches, hidden checkboxes, and hidden file inputs. Pure over `page`; the
|
|
8
|
+
* runner passes its clickRobustly helper. The caller handles post-action settle.
|
|
9
|
+
*/
|
|
10
|
+
export async function performBrowserAction(page, action, clickRobustly) {
|
|
11
|
+
const locate = () => {
|
|
12
|
+
const role = action.role;
|
|
13
|
+
let loc = role
|
|
14
|
+
? page.getByRole(role, action.name ? { name: action.name } : undefined)
|
|
15
|
+
: page.getByText(action.name ?? "", { exact: false });
|
|
16
|
+
loc = typeof action.nth === "number" ? loc.nth(action.nth) : loc.first();
|
|
17
|
+
return loc;
|
|
18
|
+
};
|
|
19
|
+
switch (action.action) {
|
|
20
|
+
case "navigate":
|
|
21
|
+
if (!action.url || !/^https?:\/\//.test(action.url))
|
|
22
|
+
throw new RunnerInputError("navigate requires an http(s) url");
|
|
23
|
+
await page.goto(action.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
24
|
+
break;
|
|
25
|
+
case "type": {
|
|
26
|
+
const text = String(action.text ?? "");
|
|
27
|
+
const loc = locate();
|
|
28
|
+
// Plain fill, then combobox/label fill (the field may be a combobox or
|
|
29
|
+
// typeahead, not a bare textbox), then click + keyboard type (typeaheads).
|
|
30
|
+
if (await loc.fill(text, { timeout: 6_000 }).then(() => true).catch(() => false))
|
|
31
|
+
break;
|
|
32
|
+
if (action.name && (await page.getByRole("combobox", { name: action.name }).fill(text, { timeout: 3_000 }).then(() => true).catch(() => false)))
|
|
33
|
+
break;
|
|
34
|
+
if (action.name && (await page.getByLabel(action.name).fill(text, { timeout: 3_000 }).then(() => true).catch(() => false)))
|
|
35
|
+
break;
|
|
36
|
+
await clickRobustly(page, loc);
|
|
37
|
+
await page.keyboard.type(text, { delay: 15 }).catch(() => undefined);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case "click":
|
|
41
|
+
if (!(await clickRobustly(page, locate())))
|
|
42
|
+
throw new RunnerInputError("could not click the target");
|
|
43
|
+
break;
|
|
44
|
+
case "select": {
|
|
45
|
+
const value = String(action.text ?? "");
|
|
46
|
+
const loc = locate();
|
|
47
|
+
const pickOption = async () => {
|
|
48
|
+
const opt = page.getByRole("option", { name: value, exact: false }).first();
|
|
49
|
+
if ((await opt.count().catch(() => 0)) === 0)
|
|
50
|
+
return false;
|
|
51
|
+
return opt.click({ timeout: 2_500 }).then(() => true).catch(() => false);
|
|
52
|
+
};
|
|
53
|
+
// 1. Native <select>: exact label/value, then a fuzzy (case/punctuation
|
|
54
|
+
// -insensitive) match so "Decline to self-identify" hits the option
|
|
55
|
+
// "Decline To Self Identify".
|
|
56
|
+
if (await loc.selectOption({ label: value }, { timeout: 4_000 }).then(() => true).catch(() => false))
|
|
57
|
+
break;
|
|
58
|
+
if (await loc.selectOption(value, { timeout: 2_500 }).then(() => true).catch(() => false))
|
|
59
|
+
break;
|
|
60
|
+
const fuzzy = await loc.evaluate((el, want) => {
|
|
61
|
+
if (!(el instanceof HTMLSelectElement))
|
|
62
|
+
return false;
|
|
63
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
64
|
+
const w = norm(want);
|
|
65
|
+
const opt = Array.from(el.options).find((o) => { const t = norm(o.textContent || ""); return t === w || (w.length > 3 && (t.includes(w) || w.includes(t))); });
|
|
66
|
+
if (!opt)
|
|
67
|
+
return false;
|
|
68
|
+
el.value = opt.value;
|
|
69
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
70
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
71
|
+
return true;
|
|
72
|
+
}, value).catch(() => false);
|
|
73
|
+
if (fuzzy)
|
|
74
|
+
break;
|
|
75
|
+
// 2. Custom combobox / typeahead: open it, try a visible matching option.
|
|
76
|
+
await clickRobustly(page, loc);
|
|
77
|
+
await page.waitForTimeout(350).catch(() => undefined);
|
|
78
|
+
if (await pickOption())
|
|
79
|
+
break;
|
|
80
|
+
// 3. Typeahead: type to filter, then pick the suggestion — by click, else
|
|
81
|
+
// keyboard (ArrowDown+Enter), which beats clicking a dropdown that
|
|
82
|
+
// closes on blur.
|
|
83
|
+
await page.keyboard.type(value, { delay: 15 }).catch(() => undefined);
|
|
84
|
+
await page.waitForTimeout(550).catch(() => undefined);
|
|
85
|
+
if (await pickOption())
|
|
86
|
+
break;
|
|
87
|
+
await page.keyboard.press("ArrowDown").catch(() => undefined);
|
|
88
|
+
await page.keyboard.press("Enter").catch(() => undefined);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "check": {
|
|
92
|
+
const loc = locate();
|
|
93
|
+
// Custom checkboxes hide the real <input> (opacity:0 / behind a label /
|
|
94
|
+
// a div[role=checkbox]). Try Playwright's check, then force, then a
|
|
95
|
+
// direct DOM tick matched by the checkbox's label text.
|
|
96
|
+
if (await loc.check({ timeout: 5_000 }).then(() => true).catch(() => false))
|
|
97
|
+
break;
|
|
98
|
+
if (await loc.check({ force: true, timeout: 3_000 }).then(() => true).catch(() => false))
|
|
99
|
+
break;
|
|
100
|
+
const ticked = await page
|
|
101
|
+
.evaluate((rawName) => {
|
|
102
|
+
const norm = (s) => (s || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
103
|
+
const needle = norm(rawName).slice(0, 30);
|
|
104
|
+
const boxes = Array.from(document.querySelectorAll('input[type="checkbox"], [role="checkbox"]'));
|
|
105
|
+
const labelOf = (b) => {
|
|
106
|
+
const forId = b.id ? document.querySelector(`label[for="${b.id}"]`)?.textContent : "";
|
|
107
|
+
return norm(b.closest("label")?.textContent || b.getAttribute("aria-label") || forId || b.parentElement?.textContent || "");
|
|
108
|
+
};
|
|
109
|
+
const tick = (b) => {
|
|
110
|
+
if (b instanceof HTMLInputElement) {
|
|
111
|
+
b.checked = true;
|
|
112
|
+
b.dispatchEvent(new Event("input", { bubbles: true }));
|
|
113
|
+
b.dispatchEvent(new Event("change", { bubbles: true }));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
b.setAttribute("aria-checked", "true");
|
|
117
|
+
}
|
|
118
|
+
b.click?.();
|
|
119
|
+
};
|
|
120
|
+
const match = boxes.find((b) => { const l = labelOf(b); return needle && (l.includes(needle) || (l.length > 12 && needle.includes(l.slice(0, 12)))); });
|
|
121
|
+
const target = match || (boxes.length === 1 ? boxes[0] : undefined);
|
|
122
|
+
if (!target)
|
|
123
|
+
return false;
|
|
124
|
+
tick(target);
|
|
125
|
+
return true;
|
|
126
|
+
}, action.name ?? "")
|
|
127
|
+
.catch(() => false);
|
|
128
|
+
if (ticked)
|
|
129
|
+
break;
|
|
130
|
+
if (!(await clickRobustly(page, locate())))
|
|
131
|
+
throw new RunnerInputError("could not find/tick the checkbox");
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "upload": {
|
|
135
|
+
// Always attach via Playwright — never a native dialog. Handles both a
|
|
136
|
+
// direct <input type=file> (set files on it) and a styled "Upload"
|
|
137
|
+
// button that opens a native chooser (intercept the filechooser).
|
|
138
|
+
const file = resolve(String(action.file ?? ""));
|
|
139
|
+
if (!file || !existsSync(file))
|
|
140
|
+
throw new RunnerInputError("upload requires an existing local file path");
|
|
141
|
+
let done = false;
|
|
142
|
+
// 1. Label-associated input (some forms).
|
|
143
|
+
if (action.name) {
|
|
144
|
+
done = await page.getByLabel(action.name).setInputFiles(file, { timeout: 4_000 }).then(() => true).catch(() => false);
|
|
145
|
+
}
|
|
146
|
+
// 2. The real <input type=file> directly — most ATS (Greenhouse, Lever…)
|
|
147
|
+
// hide it behind a styled "Attach" button; setInputFiles works on a
|
|
148
|
+
// hidden input and fires the change event, no native dialog.
|
|
149
|
+
if (!done) {
|
|
150
|
+
done = await page.locator('input[type="file"]').first().setInputFiles(file, { timeout: 4_000 }).then(() => true).catch(() => false);
|
|
151
|
+
}
|
|
152
|
+
// 3. Styled uploader with no input at all: click the trigger + intercept
|
|
153
|
+
// the native file chooser.
|
|
154
|
+
if (!done) {
|
|
155
|
+
const chooserP = page.waitForEvent("filechooser", { timeout: 8_000 }).catch(() => null);
|
|
156
|
+
await locate().click({ timeout: 8_000 }).catch(() => undefined);
|
|
157
|
+
const chooser = await chooserP;
|
|
158
|
+
if (chooser)
|
|
159
|
+
await chooser.setFiles(file);
|
|
160
|
+
else
|
|
161
|
+
throw new RunnerInputError("no file upload control found for upload action");
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "key":
|
|
166
|
+
await page.keyboard.press(String(action.key ?? "Enter"));
|
|
167
|
+
break;
|
|
168
|
+
case "scroll":
|
|
169
|
+
await page.mouse.wheel(0, action.dy ?? 600);
|
|
170
|
+
break;
|
|
171
|
+
case "wait":
|
|
172
|
+
await page.waitForTimeout(Math.min(5_000, action.ms ?? 1_000));
|
|
173
|
+
break;
|
|
174
|
+
default:
|
|
175
|
+
throw new RunnerInputError(`Unknown browser action: ${action.action}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Seed a dedicated profile directory with a copy of the user's real Chrome
|
|
6
|
+
* profile — cookies, logins, history, local storage. This gives the runner the
|
|
7
|
+
* user's signed-in sessions and a human browsing reputation WITHOUT attaching to
|
|
8
|
+
* the live profile, so it never fights Chrome's single-instance lock and the
|
|
9
|
+
* user can keep their normal Chrome open. Seeds once; delete the dir to refresh.
|
|
10
|
+
* Returns the seeded user-data-dir, or null if the source profile is unreadable.
|
|
11
|
+
*/
|
|
12
|
+
export function seedProfileCopy(real, destUserDataDir) {
|
|
13
|
+
if (existsSync(destUserDataDir))
|
|
14
|
+
return destUserDataDir; // already seeded
|
|
15
|
+
const srcProfile = join(real.userDataDir, real.profile);
|
|
16
|
+
if (!existsSync(srcProfile))
|
|
17
|
+
return null;
|
|
18
|
+
const destProfile = join(destUserDataDir, "Default");
|
|
19
|
+
mkdirSync(destProfile, { recursive: true });
|
|
20
|
+
try {
|
|
21
|
+
// "Local State" holds the os_crypt key (Keychain-wrapped) that decrypts
|
|
22
|
+
// cookies + saved passwords — without it the copied cookies are unreadable.
|
|
23
|
+
copyFileSync(join(real.userDataDir, "Local State"), join(destUserDataDir, "Local State"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// best-effort
|
|
27
|
+
}
|
|
28
|
+
const items = [
|
|
29
|
+
"Cookies",
|
|
30
|
+
"Cookies-journal",
|
|
31
|
+
"Login Data",
|
|
32
|
+
"Login Data-journal",
|
|
33
|
+
"Web Data",
|
|
34
|
+
"History",
|
|
35
|
+
"Preferences",
|
|
36
|
+
"Bookmarks",
|
|
37
|
+
"Favicons",
|
|
38
|
+
"Network",
|
|
39
|
+
"Local Storage",
|
|
40
|
+
"Session Storage",
|
|
41
|
+
"Sessions",
|
|
42
|
+
"IndexedDB",
|
|
43
|
+
];
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
try {
|
|
46
|
+
cpSync(join(srcProfile, item), join(destProfile, item), { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// best-effort per item
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return destUserDataDir;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the user's real Chrome profile when real-profile mode is enabled
|
|
56
|
+
* (PAGS_RUNNER_REAL_PROFILE=1 or an explicit PAGS_RUNNER_CHROME_USER_DATA_DIR),
|
|
57
|
+
* so the runner reuses their cookies/logins/history. Returns null otherwise.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveRealChromeProfileDir() {
|
|
60
|
+
const explicit = process.env.PAGS_RUNNER_CHROME_USER_DATA_DIR;
|
|
61
|
+
if (process.env.PAGS_RUNNER_REAL_PROFILE !== "1" && !explicit)
|
|
62
|
+
return null;
|
|
63
|
+
const expand = (p) => (p.startsWith("~") ? join(homedir(), p.slice(1)) : p);
|
|
64
|
+
const profile = process.env.PAGS_RUNNER_CHROME_PROFILE || "Default";
|
|
65
|
+
if (explicit)
|
|
66
|
+
return { userDataDir: expand(explicit), profile };
|
|
67
|
+
let userDataDir;
|
|
68
|
+
if (process.platform === "darwin") {
|
|
69
|
+
userDataDir = join(homedir(), "Library", "Application Support", "Google", "Chrome");
|
|
70
|
+
}
|
|
71
|
+
else if (process.platform === "win32") {
|
|
72
|
+
userDataDir = join(homedir(), "AppData", "Local", "Google", "Chrome", "User Data");
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
userDataDir = join(homedir(), ".config", "google-chrome");
|
|
76
|
+
}
|
|
77
|
+
return existsSync(userDataDir) ? { userDataDir, profile } : null;
|
|
78
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect an anti-bot human challenge on the page (reCAPTCHA, hCaptcha,
|
|
3
|
+
* Cloudflare Turnstile, generic captcha). These can't be solved by the model,
|
|
4
|
+
* so they trigger a handoff to a human rather than wasted retries.
|
|
5
|
+
*/
|
|
6
|
+
export async function detectHumanChallenge(page) {
|
|
7
|
+
// hCaptcha + Turnstile mount as explicit widgets — their presence means a
|
|
8
|
+
// human challenge (these aren't sprinkled invisibly across the web).
|
|
9
|
+
for (const [type, selector] of [
|
|
10
|
+
["hcaptcha", 'iframe[src*="hcaptcha.com"], .h-captcha'],
|
|
11
|
+
["cloudflare-turnstile", 'iframe[src*="challenges.cloudflare.com"], .cf-turnstile'],
|
|
12
|
+
["arkose-funcaptcha", 'iframe[src*="arkoselabs"], iframe[src*="funcaptcha"], #arkose, #FunCaptcha, [data-callback*="arkose"]'],
|
|
13
|
+
["geetest", '.geetest_holder, .geetest_panel, [class*="geetest_"]'],
|
|
14
|
+
]) {
|
|
15
|
+
if ((await page.locator(selector).count().catch(() => 0)) > 0)
|
|
16
|
+
return type;
|
|
17
|
+
}
|
|
18
|
+
// reCAPTCHA is the trap: the invisible v3 "protected by reCAPTCHA" badge is on
|
|
19
|
+
// countless pages and needs NO human. Only hand off for a VISIBLE, interactive
|
|
20
|
+
// widget — the "I'm not a robot" checkbox (anchor) or the image-challenge popup
|
|
21
|
+
// (bframe) — never the badge or the size=invisible variant.
|
|
22
|
+
const recaptcha = await page
|
|
23
|
+
.evaluate(() => {
|
|
24
|
+
const visible = (el) => {
|
|
25
|
+
const r = el.getBoundingClientRect();
|
|
26
|
+
const s = getComputedStyle(el);
|
|
27
|
+
return r.width > 100 && r.height > 50 && s.visibility !== "hidden" && s.display !== "none" && s.opacity !== "0";
|
|
28
|
+
};
|
|
29
|
+
for (const f of Array.from(document.querySelectorAll("iframe"))) {
|
|
30
|
+
const src = f.getAttribute("src") || "";
|
|
31
|
+
if (!/recaptcha/.test(src))
|
|
32
|
+
continue;
|
|
33
|
+
if (f.closest(".grecaptcha-badge"))
|
|
34
|
+
continue; // invisible v3 badge
|
|
35
|
+
if (/size=invisible/.test(src))
|
|
36
|
+
continue; // invisible variant
|
|
37
|
+
if (/bframe/.test(src) && visible(f))
|
|
38
|
+
return true; // image-challenge popup
|
|
39
|
+
if (/anchor/.test(src) && visible(f))
|
|
40
|
+
return true; // checkbox
|
|
41
|
+
}
|
|
42
|
+
const div = document.querySelector('.g-recaptcha:not([data-size="invisible"])');
|
|
43
|
+
return !!(div && visible(div));
|
|
44
|
+
})
|
|
45
|
+
.catch(() => false);
|
|
46
|
+
if (recaptcha)
|
|
47
|
+
return "recaptcha";
|
|
48
|
+
// Unknown-vendor captchas (e.g. PageUp/Bendigo): an explicit "confirm you are
|
|
49
|
+
// not a robot" prompt is a near-certain human challenge. Match the specific
|
|
50
|
+
// phrasing (not just the word "robot") to avoid flagging article text.
|
|
51
|
+
const robotPrompt = await page
|
|
52
|
+
.evaluate(() => {
|
|
53
|
+
const t = (document.body?.innerText || "").toLowerCase();
|
|
54
|
+
return /confirm (that )?you('?re| are) not a robot|i'?m not a robot|verify (that )?you('?re| are) (a )?human|prove you('?re| are) (not a robot|human)/.test(t);
|
|
55
|
+
})
|
|
56
|
+
.catch(() => false);
|
|
57
|
+
return robotPrompt ? "captcha" : null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether a detected challenge has actually been solved — i.e. the widget has
|
|
61
|
+
* produced a response token. A solved captcha keeps its widget in the DOM, so
|
|
62
|
+
* presence alone isn't "still blocked"; the token is the real signal.
|
|
63
|
+
*/
|
|
64
|
+
export async function challengeSolved(page) {
|
|
65
|
+
return page
|
|
66
|
+
.evaluate(() => {
|
|
67
|
+
const names = ["h-captcha-response", "g-recaptcha-response", "cf-turnstile-response"];
|
|
68
|
+
for (const n of names) {
|
|
69
|
+
const el = document.querySelector(`textarea[name="${n}"], input[name="${n}"]`);
|
|
70
|
+
if (el && typeof el.value === "string" && el.value.length > 0)
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
})
|
|
75
|
+
.catch(() => false);
|
|
76
|
+
}
|
|
77
|
+
/** Capture a downscaled JPEG screenshot as a data URL for the human-takeover UI. */
|
|
78
|
+
export async function captureScreenshotDataUrl(page) {
|
|
79
|
+
try {
|
|
80
|
+
const buf = await page.screenshot({ type: "jpeg", quality: 55 });
|
|
81
|
+
return `data:image/jpeg;base64,${buf.toString("base64")}`;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Raised when the model/runner cannot proceed and a human must take over. */
|
|
2
|
+
export class HumanHandoffError extends Error {
|
|
3
|
+
handoff;
|
|
4
|
+
constructor(message, handoff) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.handoff = handoff;
|
|
7
|
+
this.name = "HumanHandoffError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** A bad client request to the runner (maps to HTTP 400). */
|
|
11
|
+
export class RunnerInputError extends Error {
|
|
12
|
+
status = 400;
|
|
13
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const rand = (min, max) => min + Math.random() * (max - min);
|
|
2
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3
|
+
// easeInOutQuad — slow start, fast middle, slow approach (how a hand decelerates onto a target).
|
|
4
|
+
const ease = (t) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
|
|
5
|
+
function cubicBezier(p0, p1, p2, p3, t) {
|
|
6
|
+
const u = 1 - t;
|
|
7
|
+
return {
|
|
8
|
+
x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
|
|
9
|
+
y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Move the cursor from `from` to `to` along a randomized human-like arc. */
|
|
13
|
+
export async function humanMoveTo(cdp, from, to) {
|
|
14
|
+
const dist = Math.hypot(to.x - from.x, to.y - from.y);
|
|
15
|
+
const steps = Math.max(12, Math.min(42, Math.round(dist / 9)));
|
|
16
|
+
// Control points pushed off the straight line so the path bows like a real
|
|
17
|
+
// hand's arc — bigger arc for longer travel.
|
|
18
|
+
const off = Math.min(90, dist * 0.3);
|
|
19
|
+
const c1 = {
|
|
20
|
+
x: from.x + (to.x - from.x) * 0.33 + rand(-off, off),
|
|
21
|
+
y: from.y + (to.y - from.y) * 0.33 + rand(-off, off),
|
|
22
|
+
};
|
|
23
|
+
const c2 = {
|
|
24
|
+
x: from.x + (to.x - from.x) * 0.66 + rand(-off, off),
|
|
25
|
+
y: from.y + (to.y - from.y) * 0.66 + rand(-off, off),
|
|
26
|
+
};
|
|
27
|
+
for (let i = 1; i <= steps; i++) {
|
|
28
|
+
const p = cubicBezier(from, c1, c2, to, ease(i / steps));
|
|
29
|
+
await cdp.send("Input.dispatchMouseEvent", {
|
|
30
|
+
type: "mouseMoved",
|
|
31
|
+
x: p.x + rand(-0.6, 0.6),
|
|
32
|
+
y: p.y + rand(-0.6, 0.6),
|
|
33
|
+
});
|
|
34
|
+
await sleep(rand(5, 15));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Click `to` with a human approach + natural press/release dwell. */
|
|
38
|
+
export async function humanClickAt(cdp, from, to) {
|
|
39
|
+
await humanMoveTo(cdp, from, to);
|
|
40
|
+
await sleep(rand(40, 130));
|
|
41
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x: to.x, y: to.y, button: "left", buttons: 1, clickCount: 1 });
|
|
42
|
+
await sleep(rand(40, 100));
|
|
43
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: to.x, y: to.y, button: "left", buttons: 0, clickCount: 1 });
|
|
44
|
+
}
|
|
45
|
+
/** Pick a natural target point inside a box (not dead-center) + an off-screen-ish start. */
|
|
46
|
+
export function targetIn(box) {
|
|
47
|
+
const to = {
|
|
48
|
+
x: box.x + box.width / 2 + rand(-box.width * 0.2, box.width * 0.2),
|
|
49
|
+
y: box.y + box.height / 2 + rand(-box.height * 0.25, box.height * 0.25),
|
|
50
|
+
};
|
|
51
|
+
const from = { x: to.x + rand(-260, -40), y: to.y - rand(60, 260) };
|
|
52
|
+
return { from, to };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Best-effort humanized approach to an element before the caller's real click,
|
|
56
|
+
* so the cursor arrives the way a hand would. Never throws — humanization must
|
|
57
|
+
* not break the underlying action.
|
|
58
|
+
*/
|
|
59
|
+
export async function humanApproach(page, box) {
|
|
60
|
+
if (!box)
|
|
61
|
+
return;
|
|
62
|
+
try {
|
|
63
|
+
const cdp = await page.context().newCDPSession(page);
|
|
64
|
+
const { from, to } = targetIn(box);
|
|
65
|
+
await humanMoveTo(cdp, from, to);
|
|
66
|
+
await cdp.detach().catch(() => undefined);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// humanization is an enhancement, never a hard dependency
|
|
70
|
+
}
|
|
71
|
+
}
|