iframer-toolkit 0.10.5 → 0.10.8
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 +165 -65
- package/dist/cli.js +775 -78
- package/dist/local-server.cjs +948 -303
- package/dist/mcp-server.cjs +35 -5
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/skills/iframer.md +65 -10
package/dist/local-server.cjs
CHANGED
|
@@ -35,6 +35,23 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
35
35
|
mod
|
|
36
36
|
));
|
|
37
37
|
|
|
38
|
+
// src/lib/paths.ts
|
|
39
|
+
var paths_exports = {};
|
|
40
|
+
__export(paths_exports, {
|
|
41
|
+
getDataDir: () => getDataDir
|
|
42
|
+
});
|
|
43
|
+
function getDataDir() {
|
|
44
|
+
return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
|
|
45
|
+
}
|
|
46
|
+
var import_path, import_os;
|
|
47
|
+
var init_paths = __esm({
|
|
48
|
+
"src/lib/paths.ts"() {
|
|
49
|
+
"use strict";
|
|
50
|
+
import_path = __toESM(require("path"));
|
|
51
|
+
import_os = __toESM(require("os"));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
38
55
|
// src/lib/session/persistence.ts
|
|
39
56
|
var persistence_exports = {};
|
|
40
57
|
__export(persistence_exports, {
|
|
@@ -96,23 +113,6 @@ var init_persistence = __esm({
|
|
|
96
113
|
}
|
|
97
114
|
});
|
|
98
115
|
|
|
99
|
-
// src/lib/paths.ts
|
|
100
|
-
var paths_exports = {};
|
|
101
|
-
__export(paths_exports, {
|
|
102
|
-
getDataDir: () => getDataDir
|
|
103
|
-
});
|
|
104
|
-
function getDataDir() {
|
|
105
|
-
return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
|
|
106
|
-
}
|
|
107
|
-
var import_path, import_os;
|
|
108
|
-
var init_paths = __esm({
|
|
109
|
-
"src/lib/paths.ts"() {
|
|
110
|
-
"use strict";
|
|
111
|
-
import_path = __toESM(require("path"));
|
|
112
|
-
import_os = __toESM(require("os"));
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
116
|
// index.ts
|
|
117
117
|
var import_express = __toESM(require("express"));
|
|
118
118
|
var import_path12 = __toESM(require("path"));
|
|
@@ -127,11 +127,11 @@ var import_path11 = __toESM(require("path"));
|
|
|
127
127
|
var import_url = require("url");
|
|
128
128
|
|
|
129
129
|
// src/lib/browser/session-manager.ts
|
|
130
|
-
var
|
|
131
|
-
var
|
|
130
|
+
var import_child_process2 = require("child_process");
|
|
131
|
+
var import_fs3 = __toESM(require("fs"));
|
|
132
132
|
|
|
133
133
|
// src/lib/browser/launcher.ts
|
|
134
|
-
var
|
|
134
|
+
var import_fs2 = __toESM(require("fs"));
|
|
135
135
|
var import_patchright = require("patchright");
|
|
136
136
|
|
|
137
137
|
// src/lib/browser/stealth.ts
|
|
@@ -542,22 +542,213 @@ function createLogger(tag) {
|
|
|
542
542
|
};
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
// src/lib/browser/registry.ts
|
|
546
|
+
var import_fs = __toESM(require("fs"));
|
|
547
|
+
var import_path2 = __toESM(require("path"));
|
|
548
|
+
var import_child_process = require("child_process");
|
|
549
|
+
init_paths();
|
|
550
|
+
var log = createLogger("registry");
|
|
551
|
+
function browsersDir() {
|
|
552
|
+
const dir = import_path2.default.join(getDataDir(), "browsers");
|
|
553
|
+
import_fs.default.mkdirSync(dir, { recursive: true });
|
|
554
|
+
return dir;
|
|
555
|
+
}
|
|
556
|
+
function serverInfoPath() {
|
|
557
|
+
return import_path2.default.join(getDataDir(), "server.json");
|
|
558
|
+
}
|
|
559
|
+
function isPidAlive(pid) {
|
|
560
|
+
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
561
|
+
try {
|
|
562
|
+
process.kill(pid, 0);
|
|
563
|
+
return true;
|
|
564
|
+
} catch {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function pidMatchesMarker(pid, marker) {
|
|
569
|
+
if (!isPidAlive(pid)) return false;
|
|
570
|
+
try {
|
|
571
|
+
const cmd = (0, import_child_process.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
|
|
572
|
+
return cmd.includes(marker);
|
|
573
|
+
} catch {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function findChromePidByMarker(marker) {
|
|
578
|
+
try {
|
|
579
|
+
const out = (0, import_child_process.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
|
|
580
|
+
const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
|
|
581
|
+
if (pids.length === 0) return null;
|
|
582
|
+
return Math.min(...pids);
|
|
583
|
+
} catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function registerBrowser(rec) {
|
|
588
|
+
try {
|
|
589
|
+
import_fs.default.writeFileSync(import_path2.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
|
|
590
|
+
} catch (err) {
|
|
591
|
+
log.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function unregisterBrowser(chromePid) {
|
|
595
|
+
try {
|
|
596
|
+
import_fs.default.unlinkSync(import_path2.default.join(browsersDir(), `${chromePid}.json`));
|
|
597
|
+
} catch {
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
601
|
+
async function forceKillBrowser(rec) {
|
|
602
|
+
if (!isPidAlive(rec.chromePid)) return true;
|
|
603
|
+
if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
process.kill(rec.chromePid, "SIGKILL");
|
|
608
|
+
} catch {
|
|
609
|
+
}
|
|
610
|
+
const deadline = Date.now() + 2e3;
|
|
611
|
+
while (Date.now() < deadline) {
|
|
612
|
+
if (!isPidAlive(rec.chromePid)) return true;
|
|
613
|
+
await sleep(100);
|
|
614
|
+
}
|
|
615
|
+
return !isPidAlive(rec.chromePid);
|
|
616
|
+
}
|
|
617
|
+
async function reapOrphanBrowsers() {
|
|
618
|
+
let reaped = 0;
|
|
619
|
+
let skipped = 0;
|
|
620
|
+
let files = [];
|
|
621
|
+
try {
|
|
622
|
+
files = import_fs.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
|
|
623
|
+
} catch {
|
|
624
|
+
return { reaped, skipped };
|
|
625
|
+
}
|
|
626
|
+
for (const file of files) {
|
|
627
|
+
const full = import_path2.default.join(browsersDir(), file);
|
|
628
|
+
let rec;
|
|
629
|
+
try {
|
|
630
|
+
rec = JSON.parse(import_fs.default.readFileSync(full, "utf8"));
|
|
631
|
+
} catch {
|
|
632
|
+
try {
|
|
633
|
+
import_fs.default.unlinkSync(full);
|
|
634
|
+
} catch {
|
|
635
|
+
}
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
639
|
+
try {
|
|
640
|
+
import_fs.default.unlinkSync(full);
|
|
641
|
+
} catch {
|
|
642
|
+
}
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (isPidAlive(rec.ownerPid)) {
|
|
646
|
+
skipped++;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
log.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
|
|
650
|
+
if (await forceKillBrowser(rec)) {
|
|
651
|
+
try {
|
|
652
|
+
import_fs.default.unlinkSync(full);
|
|
653
|
+
} catch {
|
|
654
|
+
}
|
|
655
|
+
reaped++;
|
|
656
|
+
} else {
|
|
657
|
+
log.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return { reaped, skipped };
|
|
661
|
+
}
|
|
662
|
+
function writeServerInfo(info) {
|
|
663
|
+
import_fs.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
|
|
664
|
+
}
|
|
665
|
+
function readServerInfo() {
|
|
666
|
+
try {
|
|
667
|
+
const info = JSON.parse(import_fs.default.readFileSync(serverInfoPath(), "utf8"));
|
|
668
|
+
if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
|
|
669
|
+
return info;
|
|
670
|
+
} catch {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function clearServerInfo(pid) {
|
|
675
|
+
const info = readServerInfo();
|
|
676
|
+
if (info && info.pid === pid) {
|
|
677
|
+
try {
|
|
678
|
+
import_fs.default.unlinkSync(serverInfoPath());
|
|
679
|
+
} catch {
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
545
684
|
// src/lib/browser/launcher.ts
|
|
546
|
-
var
|
|
685
|
+
var log2 = createLogger("launcher");
|
|
547
686
|
var UBLOCK_PATH = "/extensions/uBlock0.chromium";
|
|
548
687
|
function findChromeExecutable() {
|
|
549
688
|
if (process.env.CHROME_EXECUTABLE) return process.env.CHROME_EXECUTABLE;
|
|
550
|
-
if (
|
|
689
|
+
if (import_fs2.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
|
|
551
690
|
return void 0;
|
|
552
691
|
}
|
|
553
692
|
var cachedBrowser = null;
|
|
693
|
+
var headedBrowser = null;
|
|
694
|
+
var headedPid = null;
|
|
695
|
+
var headedIdleTimer = null;
|
|
696
|
+
var HEADED_IDLE_MS = 5 * 60 * 1e3;
|
|
697
|
+
function armHeadedIdleTimer() {
|
|
698
|
+
if (headedIdleTimer) clearTimeout(headedIdleTimer);
|
|
699
|
+
headedIdleTimer = setTimeout(() => {
|
|
700
|
+
if (!headedBrowser) return;
|
|
701
|
+
const pages = headedBrowser.contexts().reduce((n, c) => n + c.pages().length, 0);
|
|
702
|
+
if (pages > 0) {
|
|
703
|
+
armHeadedIdleTimer();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
log2.info("idle timeout for the pivot headed browser, closing");
|
|
707
|
+
void closeHeadedBrowser();
|
|
708
|
+
}, HEADED_IDLE_MS);
|
|
709
|
+
}
|
|
710
|
+
async function getHeadedBrowser() {
|
|
711
|
+
if (!headedBrowser?.isConnected()) {
|
|
712
|
+
const marker = `--iframer-key=pivot-headed-${process.pid}-${Date.now()}`;
|
|
713
|
+
headedBrowser = await import_patchright.chromium.launch({ headless: false, args: [...STEALTH_ARGS, marker] });
|
|
714
|
+
headedPid = findChromePidByMarker(marker);
|
|
715
|
+
if (headedPid) {
|
|
716
|
+
registerBrowser({
|
|
717
|
+
key: "pivot::headed",
|
|
718
|
+
chromePid: headedPid,
|
|
719
|
+
ownerPid: process.pid,
|
|
720
|
+
marker,
|
|
721
|
+
launchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
722
|
+
});
|
|
723
|
+
} else {
|
|
724
|
+
log2.warn("could not resolve Chrome PID for the pivot headed browser \u2014 force-kill unavailable");
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
armHeadedIdleTimer();
|
|
728
|
+
return headedBrowser;
|
|
729
|
+
}
|
|
730
|
+
async function closeHeadedBrowser() {
|
|
731
|
+
if (headedIdleTimer) {
|
|
732
|
+
clearTimeout(headedIdleTimer);
|
|
733
|
+
headedIdleTimer = null;
|
|
734
|
+
}
|
|
735
|
+
if (headedBrowser) {
|
|
736
|
+
await headedBrowser.close().catch(() => {
|
|
737
|
+
});
|
|
738
|
+
headedBrowser = null;
|
|
739
|
+
}
|
|
740
|
+
if (headedPid !== null) {
|
|
741
|
+
unregisterBrowser(headedPid);
|
|
742
|
+
headedPid = null;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
554
745
|
async function getBrowser(_name = "chromium") {
|
|
555
746
|
if (cachedBrowser) {
|
|
556
747
|
if (cachedBrowser.isConnected()) return cachedBrowser;
|
|
557
748
|
try {
|
|
558
749
|
await cachedBrowser.close();
|
|
559
750
|
} catch (e) {
|
|
560
|
-
|
|
751
|
+
log2.warn(`stale browser close failed: ${e}`);
|
|
561
752
|
}
|
|
562
753
|
cachedBrowser = null;
|
|
563
754
|
}
|
|
@@ -568,11 +759,12 @@ async function getBrowser(_name = "chromium") {
|
|
|
568
759
|
return cachedBrowser;
|
|
569
760
|
}
|
|
570
761
|
async function closeBrowser() {
|
|
762
|
+
await closeHeadedBrowser();
|
|
571
763
|
if (!cachedBrowser) return;
|
|
572
764
|
try {
|
|
573
765
|
await cachedBrowser.close();
|
|
574
766
|
} catch (e) {
|
|
575
|
-
|
|
767
|
+
log2.warn(`closeBrowser failed: ${e}`);
|
|
576
768
|
}
|
|
577
769
|
cachedBrowser = null;
|
|
578
770
|
}
|
|
@@ -581,7 +773,7 @@ async function getBrowserWithFallback(_preferred) {
|
|
|
581
773
|
}
|
|
582
774
|
async function launchHeadful(displayNum) {
|
|
583
775
|
const executablePath = findChromeExecutable();
|
|
584
|
-
const hasExtensions =
|
|
776
|
+
const hasExtensions = import_fs2.default.existsSync(UBLOCK_PATH);
|
|
585
777
|
const args = [
|
|
586
778
|
"--no-sandbox",
|
|
587
779
|
"--disable-setuid-sandbox",
|
|
@@ -601,7 +793,7 @@ async function launchHeadful(displayNum) {
|
|
|
601
793
|
env: { ...process.env, DISPLAY: `:${displayNum}` }
|
|
602
794
|
};
|
|
603
795
|
if (executablePath) launchOpts.executablePath = executablePath;
|
|
604
|
-
|
|
796
|
+
log2.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
|
|
605
797
|
return import_patchright.chromium.launch(launchOpts);
|
|
606
798
|
}
|
|
607
799
|
|
|
@@ -658,7 +850,13 @@ var TIMING = {
|
|
|
658
850
|
/** Scroll step delay */
|
|
659
851
|
SCROLL_DELAY: 150,
|
|
660
852
|
/** Stale state check interval */
|
|
661
|
-
STALE_CHECK_INTERVAL: 2e3
|
|
853
|
+
STALE_CHECK_INTERVAL: 2e3,
|
|
854
|
+
/** captureApi: grace period after the last step for a submit's XHR to fire */
|
|
855
|
+
CAPTURE_SETTLE: 300,
|
|
856
|
+
/** captureApi: max wait for in-flight requests to answer before stopping */
|
|
857
|
+
CAPTURE_PENDING_MAX: 3e3,
|
|
858
|
+
/** Post-failure probe ("does the selector exist right now?") budget */
|
|
859
|
+
FAILURE_PROBE: 3e3
|
|
662
860
|
};
|
|
663
861
|
var CAPTCHA_GRID = {
|
|
664
862
|
/** Pixels from bframe top to grid start (reCAPTCHA) */
|
|
@@ -696,6 +894,10 @@ var TIMEOUTS = {
|
|
|
696
894
|
DEFAULT_STALE: 2e4,
|
|
697
895
|
NAVIGATION: 6e4,
|
|
698
896
|
SELECTOR_WAIT: 1e4,
|
|
897
|
+
// Actionability wait for click. Under the 20s stale timer on purpose, so
|
|
898
|
+
// Playwright's own reason (not visible, covered by another element…) reaches
|
|
899
|
+
// the agent instead of a generic "no state change".
|
|
900
|
+
CLICK: 8e3,
|
|
699
901
|
TOTP_INPUT: 5e3,
|
|
700
902
|
API_REQUEST: 18e4,
|
|
701
903
|
HEALTH_CHECK: 3e3,
|
|
@@ -742,7 +944,7 @@ function generateWindowsFingerprint() {
|
|
|
742
944
|
}
|
|
743
945
|
|
|
744
946
|
// src/lib/browser/session-manager.ts
|
|
745
|
-
var
|
|
947
|
+
var log3 = createLogger("session");
|
|
746
948
|
var BASE_DISPLAY = parseInt(process.env.VNC_BASE_DISPLAY || "99", 10);
|
|
747
949
|
var MAX_SESSIONS = parseInt(process.env.VNC_MAX_SESSIONS || "20", 10);
|
|
748
950
|
var SESSION_TIMEOUT = parseInt(process.env.VNC_SESSION_TIMEOUT_MS || "300000", 10);
|
|
@@ -766,7 +968,7 @@ function waitForSocket(displayNum, timeoutMs = 5e3) {
|
|
|
766
968
|
return new Promise((resolve, reject) => {
|
|
767
969
|
const start = Date.now();
|
|
768
970
|
const check = () => {
|
|
769
|
-
if (
|
|
971
|
+
if (import_fs3.default.existsSync(socketPath)) return resolve();
|
|
770
972
|
if (Date.now() - start > timeoutMs) return reject(new Error(`Xvfb socket not ready after ${timeoutMs}ms`));
|
|
771
973
|
setTimeout(check, 100);
|
|
772
974
|
};
|
|
@@ -788,17 +990,17 @@ async function startSession(userId) {
|
|
|
788
990
|
const displayNum = allocateDisplay();
|
|
789
991
|
const vncPort = 5900 + displayNum;
|
|
790
992
|
const wsPort = 6080 + (displayNum - BASE_DISPLAY);
|
|
791
|
-
const xvfb = (0,
|
|
993
|
+
const xvfb = (0, import_child_process2.spawn)("Xvfb", [`:${displayNum}`, "-screen", "0", "1920x1080x24", "-ac"], {
|
|
792
994
|
stdio: "ignore"
|
|
793
995
|
});
|
|
794
996
|
await waitForSocket(displayNum);
|
|
795
|
-
const x11vnc = (0,
|
|
997
|
+
const x11vnc = (0, import_child_process2.spawn)(
|
|
796
998
|
"x11vnc",
|
|
797
999
|
["-display", `:${displayNum}`, "-nopw", "-listen", "localhost", "-rfbport", String(vncPort), "-shared", "-forever"],
|
|
798
1000
|
{ stdio: "ignore" }
|
|
799
1001
|
);
|
|
800
|
-
const noVncPath =
|
|
801
|
-
const websockify = (0,
|
|
1002
|
+
const noVncPath = import_fs3.default.existsSync("/usr/share/novnc") ? "/usr/share/novnc" : "/usr/share/noVNC";
|
|
1003
|
+
const websockify = (0, import_child_process2.spawn)("websockify", ["--web", noVncPath, String(wsPort), `localhost:${vncPort}`], {
|
|
802
1004
|
stdio: "ignore"
|
|
803
1005
|
});
|
|
804
1006
|
await new Promise((r) => setTimeout(r, 500));
|
|
@@ -809,7 +1011,7 @@ async function startSession(userId) {
|
|
|
809
1011
|
const stealthScript = buildStealthScript(fingerprint);
|
|
810
1012
|
contextStealthScripts.set(context, stealthScript);
|
|
811
1013
|
const page = await context.newPage();
|
|
812
|
-
|
|
1014
|
+
log3.debug(`fingerprint: ${fingerprint.userAgent.slice(0, 60)}... DPR=${fingerprint.deviceScaleFactor} screen=${fingerprint.screenWidth}x${fingerprint.screenHeight}`);
|
|
813
1015
|
const session = {
|
|
814
1016
|
displayNum,
|
|
815
1017
|
vncPort,
|
|
@@ -861,7 +1063,7 @@ async function stopSession(userId) {
|
|
|
861
1063
|
killProcess(session.xvfb);
|
|
862
1064
|
await new Promise((r) => setTimeout(r, 1e3));
|
|
863
1065
|
try {
|
|
864
|
-
|
|
1066
|
+
import_fs3.default.unlinkSync(`/tmp/.X11-unix/X${session.displayNum}`);
|
|
865
1067
|
} catch {
|
|
866
1068
|
}
|
|
867
1069
|
freeDisplay(session.displayNum);
|
|
@@ -875,9 +1077,9 @@ async function cleanupAllSessions() {
|
|
|
875
1077
|
|
|
876
1078
|
// src/lib/auth/crypto.ts
|
|
877
1079
|
var import_crypto = __toESM(require("crypto"));
|
|
878
|
-
var
|
|
1080
|
+
var import_fs4 = __toESM(require("fs"));
|
|
879
1081
|
var import_os2 = __toESM(require("os"));
|
|
880
|
-
var
|
|
1082
|
+
var import_path3 = __toESM(require("path"));
|
|
881
1083
|
init_paths();
|
|
882
1084
|
var SALT = "iframer-session";
|
|
883
1085
|
var INFO = "encryption";
|
|
@@ -887,21 +1089,21 @@ var TAG_LENGTH = 16;
|
|
|
887
1089
|
function getLocalToken() {
|
|
888
1090
|
if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
|
|
889
1091
|
const candidates = [
|
|
890
|
-
|
|
891
|
-
|
|
1092
|
+
import_path3.default.join(getDataDir(), "secret"),
|
|
1093
|
+
import_path3.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
|
|
892
1094
|
];
|
|
893
1095
|
for (const file of candidates) {
|
|
894
1096
|
try {
|
|
895
|
-
const existing =
|
|
1097
|
+
const existing = import_fs4.default.readFileSync(file, "utf8").trim();
|
|
896
1098
|
if (existing) return existing;
|
|
897
1099
|
} catch {
|
|
898
1100
|
}
|
|
899
1101
|
}
|
|
900
1102
|
for (const file of candidates) {
|
|
901
1103
|
try {
|
|
902
|
-
|
|
1104
|
+
import_fs4.default.mkdirSync(import_path3.default.dirname(file), { recursive: true });
|
|
903
1105
|
const secret = import_crypto.default.randomBytes(32).toString("hex");
|
|
904
|
-
|
|
1106
|
+
import_fs4.default.writeFileSync(file, secret, { mode: 384 });
|
|
905
1107
|
return secret;
|
|
906
1108
|
} catch {
|
|
907
1109
|
}
|
|
@@ -961,17 +1163,17 @@ function generateTOTP(secret, period = 30, digits = 6) {
|
|
|
961
1163
|
init_persistence();
|
|
962
1164
|
|
|
963
1165
|
// src/lib/screenshot.ts
|
|
964
|
-
var
|
|
965
|
-
var
|
|
966
|
-
var
|
|
1166
|
+
var import_fs5 = __toESM(require("fs"));
|
|
1167
|
+
var import_path4 = __toESM(require("path"));
|
|
1168
|
+
var log4 = createLogger("screenshot");
|
|
967
1169
|
var MAX_AGE_MS = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_AGE_MS || String(24 * 60 * 60 * 1e3), 10);
|
|
968
1170
|
var MAX_FILES = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_FILES || "500", 10);
|
|
969
1171
|
var PRUNE_THROTTLE_MS = 5 * 60 * 1e3;
|
|
970
1172
|
var lastPruneAt = 0;
|
|
971
1173
|
function saveScreenshot(buffer, filename, screenshotDir, publicUrl) {
|
|
972
|
-
|
|
973
|
-
const filePath =
|
|
974
|
-
|
|
1174
|
+
import_fs5.default.mkdirSync(screenshotDir, { recursive: true });
|
|
1175
|
+
const filePath = import_path4.default.join(screenshotDir, filename);
|
|
1176
|
+
import_fs5.default.writeFileSync(filePath, buffer);
|
|
975
1177
|
maybePrune(screenshotDir);
|
|
976
1178
|
return `${publicUrl}/screenshots/${filename}`;
|
|
977
1179
|
}
|
|
@@ -986,10 +1188,10 @@ function pruneScreenshots(dir, opts = {}) {
|
|
|
986
1188
|
const maxFiles = opts.maxFiles ?? MAX_FILES;
|
|
987
1189
|
const now = opts.now ?? Date.now();
|
|
988
1190
|
try {
|
|
989
|
-
const entries =
|
|
990
|
-
const full =
|
|
1191
|
+
const entries = import_fs5.default.readdirSync(dir).filter((f) => f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".png")).map((f) => {
|
|
1192
|
+
const full = import_path4.default.join(dir, f);
|
|
991
1193
|
try {
|
|
992
|
-
return { full, mtimeMs:
|
|
1194
|
+
return { full, mtimeMs: import_fs5.default.statSync(full).mtimeMs };
|
|
993
1195
|
} catch {
|
|
994
1196
|
return null;
|
|
995
1197
|
}
|
|
@@ -999,7 +1201,7 @@ function pruneScreenshots(dir, opts = {}) {
|
|
|
999
1201
|
for (const e of entries) {
|
|
1000
1202
|
if (now - e.mtimeMs > maxAgeMs) {
|
|
1001
1203
|
try {
|
|
1002
|
-
|
|
1204
|
+
import_fs5.default.unlinkSync(e.full);
|
|
1003
1205
|
removed++;
|
|
1004
1206
|
} catch {
|
|
1005
1207
|
}
|
|
@@ -1011,23 +1213,23 @@ function pruneScreenshots(dir, opts = {}) {
|
|
|
1011
1213
|
survivors.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
1012
1214
|
for (const e of survivors.slice(0, survivors.length - maxFiles)) {
|
|
1013
1215
|
try {
|
|
1014
|
-
|
|
1216
|
+
import_fs5.default.unlinkSync(e.full);
|
|
1015
1217
|
removed++;
|
|
1016
1218
|
} catch {
|
|
1017
1219
|
}
|
|
1018
1220
|
}
|
|
1019
1221
|
}
|
|
1020
|
-
if (removed > 0)
|
|
1222
|
+
if (removed > 0) log4.debug(`pruned ${removed} old screenshot(s) from ${dir}`);
|
|
1021
1223
|
return removed;
|
|
1022
1224
|
} catch (err) {
|
|
1023
|
-
|
|
1225
|
+
log4.warn(`screenshot prune failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1024
1226
|
return 0;
|
|
1025
1227
|
}
|
|
1026
1228
|
}
|
|
1027
1229
|
|
|
1028
1230
|
// src/lib/session/sqlite-store.ts
|
|
1029
|
-
var
|
|
1030
|
-
var
|
|
1231
|
+
var import_path5 = __toESM(require("path"));
|
|
1232
|
+
var import_fs6 = __toESM(require("fs"));
|
|
1031
1233
|
var IS_BUN = typeof globalThis.Bun !== "undefined";
|
|
1032
1234
|
function createBunDb(dbPath) {
|
|
1033
1235
|
const { Database } = require("bun:sqlite");
|
|
@@ -1066,8 +1268,8 @@ function createNodeDb(dbPath) {
|
|
|
1066
1268
|
var SqliteStore = class {
|
|
1067
1269
|
db;
|
|
1068
1270
|
constructor(dataDir) {
|
|
1069
|
-
|
|
1070
|
-
const dbPath =
|
|
1271
|
+
import_fs6.default.mkdirSync(dataDir, { recursive: true });
|
|
1272
|
+
const dbPath = import_path5.default.join(dataDir, "iframer.db");
|
|
1071
1273
|
this.db = IS_BUN ? createBunDb(dbPath) : createNodeDb(dbPath);
|
|
1072
1274
|
this.db.run(`
|
|
1073
1275
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
@@ -1147,13 +1349,13 @@ var import_patchright2 = require("patchright");
|
|
|
1147
1349
|
var import_crypto2 = require("crypto");
|
|
1148
1350
|
|
|
1149
1351
|
// src/lib/browser/chrome-downloader.ts
|
|
1150
|
-
var
|
|
1151
|
-
var
|
|
1352
|
+
var import_fs7 = __toESM(require("fs"));
|
|
1353
|
+
var import_path6 = __toESM(require("path"));
|
|
1152
1354
|
var import_os3 = __toESM(require("os"));
|
|
1153
|
-
var
|
|
1154
|
-
var
|
|
1355
|
+
var import_child_process3 = require("child_process");
|
|
1356
|
+
var log5 = createLogger("chrome");
|
|
1155
1357
|
var CHROME_VERSIONS_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
|
|
1156
|
-
var DEFAULT_INSTALL_DIR =
|
|
1358
|
+
var DEFAULT_INSTALL_DIR = import_path6.default.join(import_os3.default.homedir(), ".iframer", "chrome");
|
|
1157
1359
|
function getPlatform() {
|
|
1158
1360
|
const arch = process.arch;
|
|
1159
1361
|
const platform = process.platform;
|
|
@@ -1165,24 +1367,24 @@ function getPlatform() {
|
|
|
1165
1367
|
function getChromeExecutablePath(installDir) {
|
|
1166
1368
|
const platform = process.platform;
|
|
1167
1369
|
if (platform === "darwin") {
|
|
1168
|
-
const entries =
|
|
1370
|
+
const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
|
|
1169
1371
|
const dir = entries[0] || "chrome-mac-arm64";
|
|
1170
|
-
return
|
|
1372
|
+
return import_path6.default.join(installDir, dir, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
|
|
1171
1373
|
}
|
|
1172
1374
|
if (platform === "linux") {
|
|
1173
|
-
const entries =
|
|
1375
|
+
const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
|
|
1174
1376
|
const dir = entries[0] || "chrome-linux64";
|
|
1175
|
-
return
|
|
1377
|
+
return import_path6.default.join(installDir, dir, "chrome");
|
|
1176
1378
|
}
|
|
1177
1379
|
if (platform === "win32") {
|
|
1178
|
-
const entries =
|
|
1380
|
+
const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
|
|
1179
1381
|
const dir = entries[0] || "chrome-win64";
|
|
1180
|
-
return
|
|
1382
|
+
return import_path6.default.join(installDir, dir, "chrome.exe");
|
|
1181
1383
|
}
|
|
1182
1384
|
throw new Error(`Unsupported platform: ${platform}`);
|
|
1183
1385
|
}
|
|
1184
1386
|
async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
|
|
1185
|
-
|
|
1387
|
+
log5.info("Downloading Chrome for Testing (first time only)...");
|
|
1186
1388
|
const res = await fetch(CHROME_VERSIONS_URL);
|
|
1187
1389
|
if (!res.ok) throw new Error(`Failed to fetch Chrome versions: ${res.status}`);
|
|
1188
1390
|
const data = await res.json();
|
|
@@ -1193,35 +1395,35 @@ async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
|
|
|
1193
1395
|
if (!download2) throw new Error(`No Chrome for Testing download for platform: ${platform}`);
|
|
1194
1396
|
const url = download2.url;
|
|
1195
1397
|
const version = channel.version;
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
const zipPath =
|
|
1398
|
+
log5.debug(`Version ${version} for ${platform}`);
|
|
1399
|
+
log5.debug(`URL: ${url}`);
|
|
1400
|
+
import_fs7.default.mkdirSync(installDir, { recursive: true });
|
|
1401
|
+
const zipPath = import_path6.default.join(installDir, "chrome.zip");
|
|
1200
1402
|
const dlRes = await fetch(url);
|
|
1201
1403
|
if (!dlRes.ok) throw new Error(`Download failed: ${dlRes.status}`);
|
|
1202
1404
|
const buf = Buffer.from(await dlRes.arrayBuffer());
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
(0,
|
|
1206
|
-
|
|
1405
|
+
import_fs7.default.writeFileSync(zipPath, buf);
|
|
1406
|
+
log5.info(`Downloaded ${(buf.length / 1024 / 1024).toFixed(1)}MB`);
|
|
1407
|
+
(0, import_child_process3.execSync)(`unzip -o -q "${zipPath}" -d "${installDir}"`, { stdio: "inherit" });
|
|
1408
|
+
import_fs7.default.unlinkSync(zipPath);
|
|
1207
1409
|
const execPath = getChromeExecutablePath(installDir);
|
|
1208
|
-
if (!
|
|
1410
|
+
if (!import_fs7.default.existsSync(execPath)) {
|
|
1209
1411
|
throw new Error(`Chrome executable not found after extraction: ${execPath}`);
|
|
1210
1412
|
}
|
|
1211
1413
|
if (process.platform !== "win32") {
|
|
1212
|
-
|
|
1414
|
+
import_fs7.default.chmodSync(execPath, 493);
|
|
1213
1415
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1416
|
+
import_fs7.default.writeFileSync(import_path6.default.join(installDir, "version.json"), JSON.stringify({ version, platform, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
1417
|
+
log5.info(`Installed at: ${execPath}`);
|
|
1216
1418
|
return execPath;
|
|
1217
1419
|
}
|
|
1218
1420
|
function findChromeForTesting() {
|
|
1219
1421
|
if (process.env.CHROME_EXECUTABLE) {
|
|
1220
|
-
if (
|
|
1422
|
+
if (import_fs7.default.existsSync(process.env.CHROME_EXECUTABLE)) return process.env.CHROME_EXECUTABLE;
|
|
1221
1423
|
}
|
|
1222
1424
|
try {
|
|
1223
1425
|
const execPath = getChromeExecutablePath(DEFAULT_INSTALL_DIR);
|
|
1224
|
-
if (
|
|
1426
|
+
if (import_fs7.default.existsSync(execPath)) return execPath;
|
|
1225
1427
|
} catch {
|
|
1226
1428
|
}
|
|
1227
1429
|
return null;
|
|
@@ -1241,8 +1443,8 @@ function findChrome() {
|
|
|
1241
1443
|
// Playwright/Patchright installed browsers (Docker containers)
|
|
1242
1444
|
...(() => {
|
|
1243
1445
|
try {
|
|
1244
|
-
const dirs =
|
|
1245
|
-
return dirs.map((d) =>
|
|
1446
|
+
const dirs = import_fs7.default.readdirSync("/ms-playwright").filter((d) => d.startsWith("chromium-")).sort().reverse();
|
|
1447
|
+
return dirs.map((d) => import_path6.default.join("/ms-playwright", d, "chrome-linux", "chrome"));
|
|
1246
1448
|
} catch {
|
|
1247
1449
|
return [];
|
|
1248
1450
|
}
|
|
@@ -1252,7 +1454,7 @@ function findChrome() {
|
|
|
1252
1454
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
1253
1455
|
];
|
|
1254
1456
|
for (const p of systemPaths) {
|
|
1255
|
-
if (
|
|
1457
|
+
if (import_fs7.default.existsSync(p)) return p;
|
|
1256
1458
|
}
|
|
1257
1459
|
return null;
|
|
1258
1460
|
}
|
|
@@ -1262,10 +1464,10 @@ async function ensureChrome() {
|
|
|
1262
1464
|
try {
|
|
1263
1465
|
return await downloadChrome();
|
|
1264
1466
|
} catch (err) {
|
|
1265
|
-
|
|
1467
|
+
log5.error(`Failed to download Chrome for Testing: ${err instanceof Error ? err.message : String(err)}`);
|
|
1266
1468
|
const system = findChrome();
|
|
1267
1469
|
if (system) {
|
|
1268
|
-
|
|
1470
|
+
log5.warn(`Falling back to system Chrome: ${system}`);
|
|
1269
1471
|
return system;
|
|
1270
1472
|
}
|
|
1271
1473
|
throw new Error("No Chrome found. Download failed and no system Chrome available.");
|
|
@@ -1273,7 +1475,7 @@ async function ensureChrome() {
|
|
|
1273
1475
|
}
|
|
1274
1476
|
|
|
1275
1477
|
// src/lib/browser/cloak-browser.ts
|
|
1276
|
-
var
|
|
1478
|
+
var log6 = createLogger("cloak");
|
|
1277
1479
|
var _available = null;
|
|
1278
1480
|
function cloakEnabled() {
|
|
1279
1481
|
return process.env.IFRAMER_USE_CLOAKBROWSER === "1" || process.env.IFRAMER_USE_CLOAKBROWSER === "true";
|
|
@@ -1292,14 +1494,14 @@ async function ensureBinary() {
|
|
|
1292
1494
|
try {
|
|
1293
1495
|
const info = cloak.binaryInfo();
|
|
1294
1496
|
if (!info.installed) {
|
|
1295
|
-
|
|
1497
|
+
log6.info("Downloading CloakBrowser binary...");
|
|
1296
1498
|
await cloak.ensureBinary();
|
|
1297
|
-
|
|
1499
|
+
log6.info("CloakBrowser ready");
|
|
1298
1500
|
}
|
|
1299
1501
|
_available = true;
|
|
1300
1502
|
return true;
|
|
1301
1503
|
} catch (err) {
|
|
1302
|
-
|
|
1504
|
+
log6.warn(`CloakBrowser setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1303
1505
|
_available = false;
|
|
1304
1506
|
return false;
|
|
1305
1507
|
}
|
|
@@ -1314,157 +1516,20 @@ async function launchCloakBrowser(options) {
|
|
|
1314
1516
|
headless: options.headless,
|
|
1315
1517
|
args: options.args
|
|
1316
1518
|
});
|
|
1317
|
-
|
|
1519
|
+
log6.info(`CloakBrowser launched (headless=${options.headless})`);
|
|
1318
1520
|
return browser;
|
|
1319
1521
|
} catch (err) {
|
|
1320
|
-
|
|
1522
|
+
log6.warn(`CloakBrowser launch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1321
1523
|
return null;
|
|
1322
1524
|
}
|
|
1323
1525
|
}
|
|
1324
1526
|
|
|
1325
|
-
// src/lib/browser/registry.ts
|
|
1326
|
-
var import_fs7 = __toESM(require("fs"));
|
|
1327
|
-
var import_path6 = __toESM(require("path"));
|
|
1328
|
-
var import_child_process3 = require("child_process");
|
|
1329
|
-
init_paths();
|
|
1330
|
-
var log6 = createLogger("registry");
|
|
1331
|
-
function browsersDir() {
|
|
1332
|
-
const dir = import_path6.default.join(getDataDir(), "browsers");
|
|
1333
|
-
import_fs7.default.mkdirSync(dir, { recursive: true });
|
|
1334
|
-
return dir;
|
|
1335
|
-
}
|
|
1336
|
-
function serverInfoPath() {
|
|
1337
|
-
return import_path6.default.join(getDataDir(), "server.json");
|
|
1338
|
-
}
|
|
1339
|
-
function isPidAlive(pid) {
|
|
1340
|
-
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
1341
|
-
try {
|
|
1342
|
-
process.kill(pid, 0);
|
|
1343
|
-
return true;
|
|
1344
|
-
} catch {
|
|
1345
|
-
return false;
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
function pidMatchesMarker(pid, marker) {
|
|
1349
|
-
if (!isPidAlive(pid)) return false;
|
|
1350
|
-
try {
|
|
1351
|
-
const cmd = (0, import_child_process3.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
|
|
1352
|
-
return cmd.includes(marker);
|
|
1353
|
-
} catch {
|
|
1354
|
-
return false;
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
function findChromePidByMarker(marker) {
|
|
1358
|
-
try {
|
|
1359
|
-
const out = (0, import_child_process3.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
|
|
1360
|
-
const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
|
|
1361
|
-
if (pids.length === 0) return null;
|
|
1362
|
-
return Math.min(...pids);
|
|
1363
|
-
} catch {
|
|
1364
|
-
return null;
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
function registerBrowser(rec) {
|
|
1368
|
-
try {
|
|
1369
|
-
import_fs7.default.writeFileSync(import_path6.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
|
|
1370
|
-
} catch (err) {
|
|
1371
|
-
log6.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
function unregisterBrowser(chromePid) {
|
|
1375
|
-
try {
|
|
1376
|
-
import_fs7.default.unlinkSync(import_path6.default.join(browsersDir(), `${chromePid}.json`));
|
|
1377
|
-
} catch {
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1381
|
-
async function forceKillBrowser(rec) {
|
|
1382
|
-
if (!isPidAlive(rec.chromePid)) return true;
|
|
1383
|
-
if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
1384
|
-
return true;
|
|
1385
|
-
}
|
|
1386
|
-
try {
|
|
1387
|
-
process.kill(rec.chromePid, "SIGKILL");
|
|
1388
|
-
} catch {
|
|
1389
|
-
}
|
|
1390
|
-
const deadline = Date.now() + 2e3;
|
|
1391
|
-
while (Date.now() < deadline) {
|
|
1392
|
-
if (!isPidAlive(rec.chromePid)) return true;
|
|
1393
|
-
await sleep(100);
|
|
1394
|
-
}
|
|
1395
|
-
return !isPidAlive(rec.chromePid);
|
|
1396
|
-
}
|
|
1397
|
-
async function reapOrphanBrowsers() {
|
|
1398
|
-
let reaped = 0;
|
|
1399
|
-
let skipped = 0;
|
|
1400
|
-
let files = [];
|
|
1401
|
-
try {
|
|
1402
|
-
files = import_fs7.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
|
|
1403
|
-
} catch {
|
|
1404
|
-
return { reaped, skipped };
|
|
1405
|
-
}
|
|
1406
|
-
for (const file of files) {
|
|
1407
|
-
const full = import_path6.default.join(browsersDir(), file);
|
|
1408
|
-
let rec;
|
|
1409
|
-
try {
|
|
1410
|
-
rec = JSON.parse(import_fs7.default.readFileSync(full, "utf8"));
|
|
1411
|
-
} catch {
|
|
1412
|
-
try {
|
|
1413
|
-
import_fs7.default.unlinkSync(full);
|
|
1414
|
-
} catch {
|
|
1415
|
-
}
|
|
1416
|
-
continue;
|
|
1417
|
-
}
|
|
1418
|
-
if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
1419
|
-
try {
|
|
1420
|
-
import_fs7.default.unlinkSync(full);
|
|
1421
|
-
} catch {
|
|
1422
|
-
}
|
|
1423
|
-
continue;
|
|
1424
|
-
}
|
|
1425
|
-
if (isPidAlive(rec.ownerPid)) {
|
|
1426
|
-
skipped++;
|
|
1427
|
-
continue;
|
|
1428
|
-
}
|
|
1429
|
-
log6.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
|
|
1430
|
-
if (await forceKillBrowser(rec)) {
|
|
1431
|
-
try {
|
|
1432
|
-
import_fs7.default.unlinkSync(full);
|
|
1433
|
-
} catch {
|
|
1434
|
-
}
|
|
1435
|
-
reaped++;
|
|
1436
|
-
} else {
|
|
1437
|
-
log6.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
return { reaped, skipped };
|
|
1441
|
-
}
|
|
1442
|
-
function writeServerInfo(info) {
|
|
1443
|
-
import_fs7.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
|
|
1444
|
-
}
|
|
1445
|
-
function readServerInfo() {
|
|
1446
|
-
try {
|
|
1447
|
-
const info = JSON.parse(import_fs7.default.readFileSync(serverInfoPath(), "utf8"));
|
|
1448
|
-
if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
|
|
1449
|
-
return info;
|
|
1450
|
-
} catch {
|
|
1451
|
-
return null;
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
function clearServerInfo(pid) {
|
|
1455
|
-
const info = readServerInfo();
|
|
1456
|
-
if (info && info.pid === pid) {
|
|
1457
|
-
try {
|
|
1458
|
-
import_fs7.default.unlinkSync(serverInfoPath());
|
|
1459
|
-
} catch {
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
1527
|
// src/lib/browser/daemon.ts
|
|
1465
1528
|
var log7 = createLogger("daemon");
|
|
1466
1529
|
var DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1e3;
|
|
1467
1530
|
var CLOSE_GRACE_MS = 5e3;
|
|
1531
|
+
var BUSY_MAX_MS = 30 * 60 * 1e3;
|
|
1532
|
+
var BLANK_IDLE_MS = 2 * 60 * 1e3;
|
|
1468
1533
|
var DEFAULT_INSTANCE = "default";
|
|
1469
1534
|
function keyOf(mode, instanceId) {
|
|
1470
1535
|
return `${mode}::${instanceId}`;
|
|
@@ -1556,7 +1621,8 @@ var BrowserDaemon = class {
|
|
|
1556
1621
|
createdAt: /* @__PURE__ */ new Date(),
|
|
1557
1622
|
chromePid,
|
|
1558
1623
|
marker,
|
|
1559
|
-
active: 0
|
|
1624
|
+
active: 0,
|
|
1625
|
+
busySince: null
|
|
1560
1626
|
};
|
|
1561
1627
|
this.instances.set(key, instance);
|
|
1562
1628
|
this.resetIdleTimer(key);
|
|
@@ -1568,14 +1634,19 @@ var BrowserDaemon = class {
|
|
|
1568
1634
|
* Always pair with release() in a finally block. */
|
|
1569
1635
|
acquire(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1570
1636
|
const instance = this.instances.get(keyOf(mode, instanceId));
|
|
1571
|
-
if (instance)
|
|
1637
|
+
if (!instance) return;
|
|
1638
|
+
if (instance.active === 0) instance.busySince = Date.now();
|
|
1639
|
+
instance.active++;
|
|
1572
1640
|
}
|
|
1573
1641
|
release(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1574
1642
|
const key = keyOf(mode, instanceId);
|
|
1575
1643
|
const instance = this.instances.get(key);
|
|
1576
1644
|
if (!instance) return;
|
|
1577
1645
|
instance.active = Math.max(0, instance.active - 1);
|
|
1578
|
-
if (instance.active === 0)
|
|
1646
|
+
if (instance.active === 0) {
|
|
1647
|
+
instance.busySince = null;
|
|
1648
|
+
this.resetIdleTimer(key);
|
|
1649
|
+
}
|
|
1579
1650
|
}
|
|
1580
1651
|
isRunning(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1581
1652
|
const instance = this.instances.get(keyOf(mode, instanceId));
|
|
@@ -1586,6 +1657,35 @@ var BrowserDaemon = class {
|
|
|
1586
1657
|
return false;
|
|
1587
1658
|
}
|
|
1588
1659
|
}
|
|
1660
|
+
/** Close instances that never left about:blank and have been idle a while.
|
|
1661
|
+
* Those are accidents — a pipeline that failed before its first navigate, or
|
|
1662
|
+
* a mode picked for a call that never needed a browser — and in headful mode
|
|
1663
|
+
* each one is an empty window sitting on the user's desktop. Called on the
|
|
1664
|
+
* server's reap tick, so it also catches instances whose idle timer was lost
|
|
1665
|
+
* (e.g. the daemon was restarted under them). */
|
|
1666
|
+
sweepBlankInstances() {
|
|
1667
|
+
const stopped = [];
|
|
1668
|
+
const now = Date.now();
|
|
1669
|
+
for (const [key, inst] of this.instances.entries()) {
|
|
1670
|
+
if (inst.active > 0) continue;
|
|
1671
|
+
if (now - inst.createdAt.getTime() < BLANK_IDLE_MS) continue;
|
|
1672
|
+
let blank = false;
|
|
1673
|
+
try {
|
|
1674
|
+
const pages = inst.context.pages();
|
|
1675
|
+
blank = pages.length === 0 || pages.every((p) => {
|
|
1676
|
+
const u = p.url();
|
|
1677
|
+
return u === "about:blank" || u === "" || u === "chrome://newtab/";
|
|
1678
|
+
});
|
|
1679
|
+
} catch {
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
if (!blank) continue;
|
|
1683
|
+
log7.info(`blank-orphan sweep: ${key} never left about:blank, stopping`);
|
|
1684
|
+
stopped.push(key);
|
|
1685
|
+
this.stopKey(key).catch((err) => log7.warn(`blank sweep stop failed for ${key}: ${err}`));
|
|
1686
|
+
}
|
|
1687
|
+
return stopped;
|
|
1688
|
+
}
|
|
1589
1689
|
/** Distinct modes that currently have at least one live instance. */
|
|
1590
1690
|
runningModes() {
|
|
1591
1691
|
return [...new Set(this.liveInstances().map((i) => i.mode))];
|
|
@@ -1704,8 +1804,14 @@ var BrowserDaemon = class {
|
|
|
1704
1804
|
setTimeout(() => {
|
|
1705
1805
|
const instance = this.instances.get(key);
|
|
1706
1806
|
if (instance && instance.active > 0) {
|
|
1707
|
-
|
|
1708
|
-
|
|
1807
|
+
const busyMs = instance.busySince ? Date.now() - instance.busySince : 0;
|
|
1808
|
+
if (busyMs < BUSY_MAX_MS) {
|
|
1809
|
+
this.resetIdleTimer(key);
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
log7.warn(`${key} reported busy for ${Math.round(busyMs / 6e4)}m \u2014 treating active=${instance.active} as leaked, stopping`);
|
|
1813
|
+
instance.active = 0;
|
|
1814
|
+
instance.busySince = null;
|
|
1709
1815
|
}
|
|
1710
1816
|
log7.info(`Idle timeout for ${key}, stopping...`);
|
|
1711
1817
|
this.stopKey(key).catch((err) => log7.warn(`idle stop failed for ${key}: ${err}`));
|
|
@@ -1880,6 +1986,53 @@ var RefStore = class {
|
|
|
1880
1986
|
}
|
|
1881
1987
|
};
|
|
1882
1988
|
|
|
1989
|
+
// src/lib/site-protection.ts
|
|
1990
|
+
var PROTECTED_INTERVAL_MS = 3e3;
|
|
1991
|
+
var nextRequest = /* @__PURE__ */ new Map();
|
|
1992
|
+
async function paceProtectedDomain(domain) {
|
|
1993
|
+
const now = Date.now();
|
|
1994
|
+
const slot = Math.max(now, nextRequest.get(domain) || 0);
|
|
1995
|
+
nextRequest.set(domain, slot + PROTECTED_INTERVAL_MS);
|
|
1996
|
+
if (slot > now) await new Promise((resolve) => setTimeout(resolve, slot - now));
|
|
1997
|
+
}
|
|
1998
|
+
async function detectSiteProtection(page) {
|
|
1999
|
+
const cookies = await page.context().cookies(page.url()).catch(() => []);
|
|
2000
|
+
if (cookies.some((c) => /^(incap_ses_|visid_incap_|nlbi_)/i.test(c.name))) return "imperva";
|
|
2001
|
+
if (cookies.some((c) => /^(?:_abck|bm_sz|ak_bmsc|bm_sv)$/.test(c.name))) return "akamai";
|
|
2002
|
+
const html = await page.content().catch(() => "");
|
|
2003
|
+
if (/\/[_]Incapsula_Resource|incapsula incident id|powered by imperva/i.test(html)) return "imperva";
|
|
2004
|
+
if (/errors\.edgesuite\.net|akamai bot manager/i.test(html)) return "akamai";
|
|
2005
|
+
return null;
|
|
2006
|
+
}
|
|
2007
|
+
var HeadedRequiredError = class extends Error {
|
|
2008
|
+
constructor(url) {
|
|
2009
|
+
super("This site requires a headed browser.");
|
|
2010
|
+
this.url = url;
|
|
2011
|
+
}
|
|
2012
|
+
url;
|
|
2013
|
+
stepIndex = 0;
|
|
2014
|
+
results = [];
|
|
2015
|
+
obstacles = [];
|
|
2016
|
+
};
|
|
2017
|
+
function protectionHooks(headless, respectful = false) {
|
|
2018
|
+
return {
|
|
2019
|
+
before: async (page, step) => {
|
|
2020
|
+
const protection = await detectSiteProtection(page);
|
|
2021
|
+
if (protection) {
|
|
2022
|
+
respectful = true;
|
|
2023
|
+
if (headless) throw new HeadedRequiredError(page.url());
|
|
2024
|
+
}
|
|
2025
|
+
if (respectful) await paceProtectedDomain(new URL(step.url || page.url()).hostname);
|
|
2026
|
+
},
|
|
2027
|
+
after: async (page) => {
|
|
2028
|
+
const protection = await detectSiteProtection(page);
|
|
2029
|
+
if (!protection) return;
|
|
2030
|
+
respectful = true;
|
|
2031
|
+
if (headless) throw new HeadedRequiredError(page.url());
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
|
|
1883
2036
|
// src/lib/execution/pipeline-executor.ts
|
|
1884
2037
|
var import_playwright_core = require("playwright-core");
|
|
1885
2038
|
|
|
@@ -2017,6 +2170,24 @@ async function humanType(page, selector, text, opts = {}) {
|
|
|
2017
2170
|
await sleep3(randRange(TIMING.POST_CLICK));
|
|
2018
2171
|
}
|
|
2019
2172
|
await assertFocused(page, selector, !opts.skipClick);
|
|
2173
|
+
if (opts.clear) {
|
|
2174
|
+
const hadText = await page.evaluate(() => {
|
|
2175
|
+
const el = document.activeElement;
|
|
2176
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
2177
|
+
el.select();
|
|
2178
|
+
return el.value.length > 0;
|
|
2179
|
+
}
|
|
2180
|
+
if (el?.isContentEditable) {
|
|
2181
|
+
window.getSelection()?.selectAllChildren(el);
|
|
2182
|
+
return (el.textContent || "").length > 0;
|
|
2183
|
+
}
|
|
2184
|
+
return false;
|
|
2185
|
+
});
|
|
2186
|
+
if (hadText) {
|
|
2187
|
+
await page.keyboard.press("Backspace");
|
|
2188
|
+
await sleep3(randRange(TIMING.CHAR_DELAY));
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2020
2191
|
const factor = SPEED_FACTOR[opts.speed || "normal"] ?? 1;
|
|
2021
2192
|
for (const char of text) {
|
|
2022
2193
|
await page.keyboard.type(char);
|
|
@@ -2172,7 +2343,36 @@ function sleep3(ms) {
|
|
|
2172
2343
|
// src/lib/actions/handlers/navigation.ts
|
|
2173
2344
|
init_persistence();
|
|
2174
2345
|
|
|
2346
|
+
// src/lib/budget.ts
|
|
2347
|
+
var BudgetExceededError = class extends Error {
|
|
2348
|
+
constructor(label, ms) {
|
|
2349
|
+
super(`${label} did not answer within ${ms}ms`);
|
|
2350
|
+
this.name = "BudgetExceededError";
|
|
2351
|
+
}
|
|
2352
|
+
};
|
|
2353
|
+
function withBudget(p, ms, label = "page call") {
|
|
2354
|
+
p.catch(() => void 0);
|
|
2355
|
+
let timer;
|
|
2356
|
+
return Promise.race([
|
|
2357
|
+
p,
|
|
2358
|
+
new Promise((_, reject) => {
|
|
2359
|
+
timer = setTimeout(() => reject(new BudgetExceededError(label, ms)), ms);
|
|
2360
|
+
timer.unref?.();
|
|
2361
|
+
})
|
|
2362
|
+
]).finally(() => {
|
|
2363
|
+
if (timer) clearTimeout(timer);
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2175
2367
|
// src/lib/actions/resolve-selector.ts
|
|
2368
|
+
var PLAYWRIGHT_ONLY = /^\s*(text|xpath|role|id|data-testid|internal:[\w-]+)\s*=|:(has-text|text|text-is|text-matches|visible|nth-match|right-of|left-of|above|below|near)\b|\s>>\s/;
|
|
2369
|
+
function assertCssSelector(selector) {
|
|
2370
|
+
if (PLAYWRIGHT_ONLY.test(selector)) {
|
|
2371
|
+
throw new Error(
|
|
2372
|
+
`Unsupported selector "${selector}": Playwright text/role selectors (text=, :has-text(), :text-is(), >>) aren't supported. Locate the element by its text with a \`find\` step (e.g. { type: "find", text: "Texas" } or { role: "option", name: "Texas" }) and act on the @e ref it returns, in the same execute call.`
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2176
2376
|
function resolveSelector(selector, ctx) {
|
|
2177
2377
|
if (selector.startsWith("@a:")) {
|
|
2178
2378
|
const name = selector.slice(3);
|
|
@@ -2193,19 +2393,149 @@ function resolveSelector(selector, ctx) {
|
|
|
2193
2393
|
}
|
|
2194
2394
|
return ref.selector;
|
|
2195
2395
|
}
|
|
2396
|
+
assertCssSelector(selector);
|
|
2196
2397
|
return selector;
|
|
2197
2398
|
}
|
|
2198
2399
|
function anchorNameOf(selector) {
|
|
2199
2400
|
return typeof selector === "string" && selector.startsWith("@a:") ? selector.slice(3) : null;
|
|
2200
2401
|
}
|
|
2201
2402
|
|
|
2403
|
+
// src/lib/reachability.ts
|
|
2404
|
+
var HONEYPOT_REASONS = /* @__PURE__ */ new Set(["zero-size", "hidden", "transparent", "offscreen", "clipped"]);
|
|
2405
|
+
var HONEYPOT_NAME_RE = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
|
|
2406
|
+
function isHoneypotReason(reason) {
|
|
2407
|
+
return !!reason && HONEYPOT_REASONS.has(reason);
|
|
2408
|
+
}
|
|
2409
|
+
function looksLikeBaitField(attrs) {
|
|
2410
|
+
const name = attrs.name || "";
|
|
2411
|
+
const id = attrs.id || "";
|
|
2412
|
+
if (HONEYPOT_NAME_RE.test(name) || HONEYPOT_NAME_RE.test(id)) return true;
|
|
2413
|
+
const textual = !attrs.type || /^(text|email|url|tel|search|number)$/i.test(attrs.type);
|
|
2414
|
+
if (textual && attrs.tabindex === "-1") return true;
|
|
2415
|
+
return false;
|
|
2416
|
+
}
|
|
2417
|
+
function honeypotRefusalMessage(selector, reason, name) {
|
|
2418
|
+
const what = name ? `"${name}" (${selector})` : selector;
|
|
2419
|
+
return `fill: refusing to fill ${what} \u2014 no human can reach this field (${reason}). It is almost certainly a honeypot: filling it marks the submission as a bot and the form fails with a generic error. Skip it. If you are certain it is a real field, retry with force: true.`;
|
|
2420
|
+
}
|
|
2421
|
+
async function probeField(page, selector) {
|
|
2422
|
+
const r = await page.evaluate((sel) => {
|
|
2423
|
+
const el = document.querySelector(sel);
|
|
2424
|
+
if (!el) return { found: false };
|
|
2425
|
+
const attrs = {
|
|
2426
|
+
name: el.getAttribute("name"),
|
|
2427
|
+
id: el.id || null,
|
|
2428
|
+
tabindex: el.getAttribute("tabindex"),
|
|
2429
|
+
type: el.getAttribute("type")
|
|
2430
|
+
};
|
|
2431
|
+
const tag = el.tagName.toLowerCase();
|
|
2432
|
+
{
|
|
2433
|
+
const r0 = el.getBoundingClientRect();
|
|
2434
|
+
const cx0 = r0.left + r0.width / 2, cy0 = r0.top + r0.height / 2;
|
|
2435
|
+
const dx = cx0 < 0 || cx0 >= window.innerWidth ? cx0 - window.innerWidth / 2 : 0;
|
|
2436
|
+
const dy = cy0 < 0 || cy0 >= window.innerHeight ? cy0 - window.innerHeight / 2 : 0;
|
|
2437
|
+
if (dx || dy) try {
|
|
2438
|
+
window.scrollBy(dx, dy);
|
|
2439
|
+
} catch {
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
const rect = el.getBoundingClientRect();
|
|
2443
|
+
let reason = null;
|
|
2444
|
+
if (rect.width === 0 || rect.height === 0) reason = "zero-size";
|
|
2445
|
+
const style = getComputedStyle(el);
|
|
2446
|
+
if (!reason && (style.display === "none" || style.visibility === "hidden")) reason = "hidden";
|
|
2447
|
+
if (!reason) {
|
|
2448
|
+
let opacity = 1;
|
|
2449
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
2450
|
+
const o = parseFloat(getComputedStyle(n).opacity);
|
|
2451
|
+
const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
|
|
2452
|
+
if (!isNaN(o) && !animating) opacity *= o;
|
|
2453
|
+
}
|
|
2454
|
+
if (opacity < 0.05) reason = "transparent";
|
|
2455
|
+
}
|
|
2456
|
+
if (!reason) {
|
|
2457
|
+
const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
|
|
2458
|
+
const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
|
|
2459
|
+
const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
|
|
2460
|
+
const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
|
|
2461
|
+
if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) reason = "offscreen";
|
|
2462
|
+
}
|
|
2463
|
+
if (!reason) {
|
|
2464
|
+
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
2465
|
+
const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
|
|
2466
|
+
let clipped = false;
|
|
2467
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
2468
|
+
const s = getComputedStyle(a);
|
|
2469
|
+
const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
|
|
2470
|
+
if (!clipsX && !clipsY) continue;
|
|
2471
|
+
const ar = a.getBoundingClientRect();
|
|
2472
|
+
const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
|
|
2473
|
+
const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
|
|
2474
|
+
if (outside || tiny) {
|
|
2475
|
+
clipped = true;
|
|
2476
|
+
break;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
if (inViewport) {
|
|
2480
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
2481
|
+
if (!(hit && (hit === el || el.contains(hit)))) reason = clipped ? "clipped" : "covered";
|
|
2482
|
+
} else if (clipped) {
|
|
2483
|
+
reason = "clipped";
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
return { found: true, reason, attrs, tag };
|
|
2487
|
+
}, selector);
|
|
2488
|
+
if (!r.found) return { found: false, hiddenReason: null, suspiciousName: false };
|
|
2489
|
+
return {
|
|
2490
|
+
found: true,
|
|
2491
|
+
hiddenReason: r.reason ?? null,
|
|
2492
|
+
suspiciousName: looksLikeBaitField(r.attrs),
|
|
2493
|
+
tag: r.tag,
|
|
2494
|
+
name: r.attrs.name ?? void 0
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2202
2498
|
// src/lib/actions/handlers/navigation.ts
|
|
2203
2499
|
var log9 = createLogger("actions");
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2500
|
+
var NAV_COMMIT_SUSPECT_MS = 12e3;
|
|
2501
|
+
async function navigate(page, step, ctx, monitor) {
|
|
2502
|
+
const waitUntil = step.waitUntil || "domcontentloaded";
|
|
2503
|
+
const urlBefore = page.url();
|
|
2504
|
+
const onDialog = (d) => {
|
|
2505
|
+
(d.type() === "beforeunload" ? d.accept() : d.dismiss()).catch(() => void 0);
|
|
2506
|
+
};
|
|
2507
|
+
page.on("dialog", onDialog);
|
|
2508
|
+
try {
|
|
2509
|
+
const nav = page.goto(step.url, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
|
|
2510
|
+
nav.catch(() => void 0);
|
|
2511
|
+
let suspectTimer;
|
|
2512
|
+
const outcome = await Promise.race([
|
|
2513
|
+
nav.then(() => "done"),
|
|
2514
|
+
new Promise((r) => {
|
|
2515
|
+
suspectTimer = setTimeout(() => r("slow"), NAV_COMMIT_SUSPECT_MS);
|
|
2516
|
+
})
|
|
2517
|
+
]).finally(() => clearTimeout(suspectTimer));
|
|
2518
|
+
if (outcome === "slow") {
|
|
2519
|
+
if (page.url() !== urlBefore) {
|
|
2520
|
+
await nav;
|
|
2521
|
+
} else {
|
|
2522
|
+
log9.warn(`navigate: no commit after ${NAV_COMMIT_SUSPECT_MS}ms, falling back to location.assign`);
|
|
2523
|
+
monitor?.reportActivity();
|
|
2524
|
+
await withBudget(page.evaluate((u) => {
|
|
2525
|
+
location.assign(u);
|
|
2526
|
+
}, step.url), 3e3, "location.assign").catch(() => void 0);
|
|
2527
|
+
try {
|
|
2528
|
+
await page.waitForURL((u) => u.toString() !== urlBefore, { timeout: 15e3, waitUntil });
|
|
2529
|
+
} catch {
|
|
2530
|
+
throw new Error(
|
|
2531
|
+
`navigate: the page never left ${urlBefore} for ${step.url} (tried a normal navigation, then location.assign). The site is blocking the route change. Use a click on the site's own link to get there.`
|
|
2532
|
+
);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
} finally {
|
|
2537
|
+
page.off("dialog", onDialog);
|
|
2538
|
+
}
|
|
2209
2539
|
const stealthScript = contextStealthScripts.get(page.context()) ?? STEALTH_SCRIPT;
|
|
2210
2540
|
try {
|
|
2211
2541
|
await page.evaluate(stealthScript);
|
|
@@ -2221,14 +2551,37 @@ async function navigate(page, step, ctx) {
|
|
|
2221
2551
|
}
|
|
2222
2552
|
}
|
|
2223
2553
|
async function click(page, step, ctx) {
|
|
2224
|
-
await page.click(resolveSelector(step.selector, ctx));
|
|
2554
|
+
await page.click(resolveSelector(step.selector, ctx), { timeout: TIMEOUTS.CLICK });
|
|
2555
|
+
}
|
|
2556
|
+
async function isCombobox(page, selector) {
|
|
2557
|
+
return withBudget(
|
|
2558
|
+
page.evaluate((sel) => {
|
|
2559
|
+
const el = document.querySelector(sel);
|
|
2560
|
+
if (!el) return false;
|
|
2561
|
+
const role = el.getAttribute("role");
|
|
2562
|
+
return role === "combobox" || el.hasAttribute("aria-autocomplete") || el.getAttribute("aria-haspopup") === "listbox" || el.hasAttribute("list") || !!el.closest('[role="combobox"]');
|
|
2563
|
+
}, selector),
|
|
2564
|
+
3e3
|
|
2565
|
+
).catch(() => false);
|
|
2225
2566
|
}
|
|
2226
2567
|
async function fill(page, step, ctx) {
|
|
2227
2568
|
const selector = resolveSelector(step.selector, ctx);
|
|
2228
2569
|
const value = step.value;
|
|
2570
|
+
if (!step.force) {
|
|
2571
|
+
let probe = null;
|
|
2572
|
+
try {
|
|
2573
|
+
probe = await probeField(page, selector);
|
|
2574
|
+
} catch {
|
|
2575
|
+
probe = null;
|
|
2576
|
+
}
|
|
2577
|
+
if (probe?.found && isHoneypotReason(probe.hiddenReason)) {
|
|
2578
|
+
throw new Error(honeypotRefusalMessage(step.selector, probe.hiddenReason, probe.name));
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
const combobox = await isCombobox(page, selector);
|
|
2229
2582
|
await page.fill(selector, value);
|
|
2230
2583
|
const stuck = await page.evaluate(
|
|
2231
|
-
({ sel, val }) => {
|
|
2584
|
+
({ sel, val, keepFocus }) => {
|
|
2232
2585
|
const el = document.querySelector(sel);
|
|
2233
2586
|
if (!el) return false;
|
|
2234
2587
|
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
@@ -2244,13 +2597,15 @@ async function fill(page, step, ctx) {
|
|
|
2244
2597
|
}
|
|
2245
2598
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2246
2599
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2600
|
+
if (!keepFocus) {
|
|
2601
|
+
try {
|
|
2602
|
+
el.blur();
|
|
2603
|
+
} catch {
|
|
2604
|
+
}
|
|
2250
2605
|
}
|
|
2251
2606
|
return el.value === val;
|
|
2252
2607
|
},
|
|
2253
|
-
{ sel: selector, val: value }
|
|
2608
|
+
{ sel: selector, val: value, keepFocus: combobox }
|
|
2254
2609
|
);
|
|
2255
2610
|
if (!stuck) {
|
|
2256
2611
|
const loc = page.locator(selector);
|
|
@@ -2258,10 +2613,12 @@ async function fill(page, step, ctx) {
|
|
|
2258
2613
|
await loc.click();
|
|
2259
2614
|
await loc.fill("");
|
|
2260
2615
|
await loc.pressSequentially(value, { delay: 15 });
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2616
|
+
if (!combobox) {
|
|
2617
|
+
await page.evaluate((sel) => {
|
|
2618
|
+
const el = document.querySelector(sel);
|
|
2619
|
+
el?.blur?.();
|
|
2620
|
+
}, selector);
|
|
2621
|
+
}
|
|
2265
2622
|
} catch {
|
|
2266
2623
|
}
|
|
2267
2624
|
}
|
|
@@ -2285,7 +2642,7 @@ async function rightClick(page, step, ctx) {
|
|
|
2285
2642
|
}
|
|
2286
2643
|
}
|
|
2287
2644
|
async function humanTypeStep(page, step, ctx) {
|
|
2288
|
-
await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed });
|
|
2645
|
+
await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed, clear: step.clear });
|
|
2289
2646
|
}
|
|
2290
2647
|
async function selectOption(page, step, ctx) {
|
|
2291
2648
|
const selector = resolveSelector(step.selector, ctx);
|
|
@@ -2328,7 +2685,29 @@ async function scroll(page, step, ctx) {
|
|
|
2328
2685
|
{ dy: step.deltaY ?? 0, sel: selector }
|
|
2329
2686
|
);
|
|
2330
2687
|
}
|
|
2331
|
-
async function
|
|
2688
|
+
async function selectAllInFocused(page) {
|
|
2689
|
+
await page.evaluate(() => {
|
|
2690
|
+
const el = document.activeElement;
|
|
2691
|
+
if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
|
|
2692
|
+
el.select();
|
|
2693
|
+
} else if (el && el.isContentEditable) {
|
|
2694
|
+
const sel = window.getSelection();
|
|
2695
|
+
sel?.selectAllChildren(el);
|
|
2696
|
+
} else {
|
|
2697
|
+
window.getSelection()?.selectAllChildren(document.body);
|
|
2698
|
+
}
|
|
2699
|
+
});
|
|
2700
|
+
}
|
|
2701
|
+
async function keyboard(page, step, ctx) {
|
|
2702
|
+
if ((step.meta || step.ctrl) && step.key.toLowerCase() === "a" && !step.shift && !step.alt) {
|
|
2703
|
+
await selectAllInFocused(page);
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
if ((step.meta || step.ctrl) && ctx.driver === "extension") {
|
|
2707
|
+
throw new Error(
|
|
2708
|
+
`keyboard: ${step.meta ? "Cmd" : "Ctrl"}+${step.key} can't be sent in extension mode (the relay drops Cmd/Ctrl, so it would arrive as a plain "${step.key}"). Nothing was pressed. Instead: select-all \u2192 keyboard {key:"a", meta:true} (handled in-page); replace a field's text \u2192 fill, or human-type with clear:true; paste \u2192 the paste step.`
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2332
2711
|
const mods = [
|
|
2333
2712
|
step.meta ? "Meta" : null,
|
|
2334
2713
|
step.ctrl ? "Control" : null,
|
|
@@ -2513,6 +2892,56 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2513
2892
|
if (rect.bottom < 0 || rect.top > window.innerHeight + 200) return false;
|
|
2514
2893
|
return true;
|
|
2515
2894
|
}
|
|
2895
|
+
function isFillable(el) {
|
|
2896
|
+
if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true;
|
|
2897
|
+
if (el.hasAttribute("contenteditable") && el.getAttribute("contenteditable") !== "false") return true;
|
|
2898
|
+
if (el.tagName !== "INPUT") return false;
|
|
2899
|
+
const t = (el.type || "text").toLowerCase();
|
|
2900
|
+
return !["button", "submit", "reset", "image", "checkbox", "radio", "file", "hidden", "range", "color"].includes(t);
|
|
2901
|
+
}
|
|
2902
|
+
function unreachableReason(el) {
|
|
2903
|
+
const rect = el.getBoundingClientRect();
|
|
2904
|
+
const style = getComputedStyle(el);
|
|
2905
|
+
let opacity = 1;
|
|
2906
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
2907
|
+
const o = parseFloat(n === el ? style.opacity : getComputedStyle(n).opacity);
|
|
2908
|
+
const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
|
|
2909
|
+
if (!isNaN(o) && !animating) opacity *= o;
|
|
2910
|
+
}
|
|
2911
|
+
if (opacity < 0.05) return "transparent";
|
|
2912
|
+
const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
|
|
2913
|
+
const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
|
|
2914
|
+
const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
|
|
2915
|
+
const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
|
|
2916
|
+
if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) return "offscreen";
|
|
2917
|
+
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
2918
|
+
let clipped = false;
|
|
2919
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
2920
|
+
const s = getComputedStyle(a);
|
|
2921
|
+
const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
|
|
2922
|
+
if (!clipsX && !clipsY) continue;
|
|
2923
|
+
const ar = a.getBoundingClientRect();
|
|
2924
|
+
const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
|
|
2925
|
+
const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
|
|
2926
|
+
if (tiny) return "clipped";
|
|
2927
|
+
if (outside) clipped = true;
|
|
2928
|
+
}
|
|
2929
|
+
const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
|
|
2930
|
+
if (inViewport) {
|
|
2931
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
2932
|
+
if (hit && (hit === el || el.contains(hit))) return null;
|
|
2933
|
+
return clipped ? "clipped" : null;
|
|
2934
|
+
}
|
|
2935
|
+
return null;
|
|
2936
|
+
}
|
|
2937
|
+
const baitName = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
|
|
2938
|
+
function looksLikeBait(el) {
|
|
2939
|
+
const name = el.getAttribute("name") || "", id = el.id || "";
|
|
2940
|
+
if (baitName.test(name) || baitName.test(id)) return true;
|
|
2941
|
+
const type = (el.getAttribute("type") || "text").toLowerCase();
|
|
2942
|
+
const textual = /^(text|email|url|tel|search|number)$/.test(type);
|
|
2943
|
+
return textual && el.getAttribute("tabindex") === "-1";
|
|
2944
|
+
}
|
|
2516
2945
|
function buildSelector(el) {
|
|
2517
2946
|
const path13 = [];
|
|
2518
2947
|
let current = el;
|
|
@@ -2555,6 +2984,11 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2555
2984
|
if (results.length >= maxElements2) break;
|
|
2556
2985
|
if (interactiveOnly2 && !isInteractive(el)) continue;
|
|
2557
2986
|
if (!isVisible(el)) continue;
|
|
2987
|
+
let suspect;
|
|
2988
|
+
if (isFillable(el)) {
|
|
2989
|
+
if (unreachableReason(el)) continue;
|
|
2990
|
+
if (looksLikeBait(el)) suspect = "honeypot?";
|
|
2991
|
+
}
|
|
2558
2992
|
const tag = el.tagName.toLowerCase();
|
|
2559
2993
|
const role = el.getAttribute("role") || "";
|
|
2560
2994
|
const type = el.type || "";
|
|
@@ -2567,7 +3001,8 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2567
3001
|
checked: el.checked || false,
|
|
2568
3002
|
disabled: el.disabled || false,
|
|
2569
3003
|
selector: buildSelector(el),
|
|
2570
|
-
isVisible: true
|
|
3004
|
+
isVisible: true,
|
|
3005
|
+
suspect
|
|
2571
3006
|
});
|
|
2572
3007
|
}
|
|
2573
3008
|
return results;
|
|
@@ -2588,6 +3023,7 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2588
3023
|
const state = [];
|
|
2589
3024
|
if (el.disabled) state.push("disabled");
|
|
2590
3025
|
if (el.checked) state.push("checked");
|
|
3026
|
+
if (el.suspect) state.push(el.suspect);
|
|
2591
3027
|
let description = "";
|
|
2592
3028
|
if (el.placeholder && el.name !== el.placeholder) description = `placeholder="${el.placeholder}"`;
|
|
2593
3029
|
if (el.type && !["text", "submit", "button", ""].includes(el.type)) {
|
|
@@ -3936,6 +4372,8 @@ async function executeAction(page, step, ctx, monitor) {
|
|
|
3936
4372
|
|
|
3937
4373
|
// src/lib/stale-monitor.ts
|
|
3938
4374
|
var DEFAULT_STALE_TIMEOUT_MS = 2e4;
|
|
4375
|
+
var PROBE_BUDGET_MS = 3e3;
|
|
4376
|
+
var UNRESPONSIVE_GRACE_MS = 8e3;
|
|
3939
4377
|
var StaleStateMonitor = class _StaleStateMonitor {
|
|
3940
4378
|
page;
|
|
3941
4379
|
timeoutMs;
|
|
@@ -3947,21 +4385,24 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
3947
4385
|
}
|
|
3948
4386
|
async snapshot() {
|
|
3949
4387
|
try {
|
|
3950
|
-
const
|
|
3951
|
-
|
|
4388
|
+
const url = this.page.url();
|
|
4389
|
+
let unresponsive = false;
|
|
4390
|
+
const state = await withBudget(
|
|
3952
4391
|
this.page.evaluate(() => ({
|
|
3953
4392
|
documentReadyState: document.readyState,
|
|
3954
4393
|
bodyTextLength: document.body?.innerText?.length || 0,
|
|
3955
4394
|
elementCount: document.querySelectorAll("*").length
|
|
3956
|
-
}))
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
4395
|
+
})),
|
|
4396
|
+
PROBE_BUDGET_MS,
|
|
4397
|
+
"state probe"
|
|
4398
|
+
).catch((e) => {
|
|
4399
|
+
unresponsive = e instanceof BudgetExceededError;
|
|
4400
|
+
return { documentReadyState: "unknown", bodyTextLength: 0, elementCount: 0 };
|
|
4401
|
+
});
|
|
3962
4402
|
return {
|
|
3963
4403
|
url,
|
|
3964
4404
|
...state,
|
|
4405
|
+
unresponsive,
|
|
3965
4406
|
timestamp: Date.now()
|
|
3966
4407
|
};
|
|
3967
4408
|
} catch {
|
|
@@ -4005,6 +4446,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4005
4446
|
return new Promise((resolve, reject) => {
|
|
4006
4447
|
let resolved = false;
|
|
4007
4448
|
let checkInterval = null;
|
|
4449
|
+
let probing = false;
|
|
4450
|
+
let unresponsiveSince = null;
|
|
4008
4451
|
const cleanup = () => {
|
|
4009
4452
|
resolved = true;
|
|
4010
4453
|
if (checkInterval) clearInterval(checkInterval);
|
|
@@ -4012,11 +4455,25 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4012
4455
|
this.timer = null;
|
|
4013
4456
|
};
|
|
4014
4457
|
checkInterval = setInterval(async () => {
|
|
4015
|
-
if (resolved) return;
|
|
4458
|
+
if (resolved || probing) return;
|
|
4016
4459
|
const elapsed = Date.now() - this.lastActivity;
|
|
4017
4460
|
if (elapsed < this.timeoutMs) return;
|
|
4461
|
+
probing = true;
|
|
4018
4462
|
try {
|
|
4019
4463
|
const currentSnapshot = await this.snapshot();
|
|
4464
|
+
if (resolved) return;
|
|
4465
|
+
if (currentSnapshot.unresponsive) {
|
|
4466
|
+
unresponsiveSince ??= Date.now();
|
|
4467
|
+
if (Date.now() - unresponsiveSince < UNRESPONSIVE_GRACE_MS) return;
|
|
4468
|
+
cleanup();
|
|
4469
|
+
reject(new StaleStateError(
|
|
4470
|
+
`The tab stopped responding: its JavaScript did not answer for ${Math.round((Date.now() - unresponsiveSince) / 1e3)}s (a blocking dialog, a frozen re-render, or a navigation that never finished)`,
|
|
4471
|
+
this.timeoutMs,
|
|
4472
|
+
true
|
|
4473
|
+
));
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
unresponsiveSince = null;
|
|
4020
4477
|
if (_StaleStateMonitor.hasChanged(beforeSnapshot, currentSnapshot)) {
|
|
4021
4478
|
this.lastActivity = Date.now();
|
|
4022
4479
|
return;
|
|
@@ -4024,6 +4481,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4024
4481
|
} catch {
|
|
4025
4482
|
this.lastActivity = Date.now();
|
|
4026
4483
|
return;
|
|
4484
|
+
} finally {
|
|
4485
|
+
probing = false;
|
|
4027
4486
|
}
|
|
4028
4487
|
cleanup();
|
|
4029
4488
|
reject(new StaleStateError(
|
|
@@ -4047,10 +4506,13 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4047
4506
|
};
|
|
4048
4507
|
var StaleStateError = class extends Error {
|
|
4049
4508
|
timeoutMs;
|
|
4050
|
-
|
|
4509
|
+
/** True when the page stopped answering at all (vs. answering but not changing). */
|
|
4510
|
+
unresponsive;
|
|
4511
|
+
constructor(message, timeoutMs, unresponsive = false) {
|
|
4051
4512
|
super(message);
|
|
4052
4513
|
this.name = "StaleStateError";
|
|
4053
4514
|
this.timeoutMs = timeoutMs;
|
|
4515
|
+
this.unresponsive = unresponsive;
|
|
4054
4516
|
}
|
|
4055
4517
|
};
|
|
4056
4518
|
|
|
@@ -4261,10 +4723,14 @@ async function capturePageState(page, ctx, opts) {
|
|
|
4261
4723
|
url = page.url();
|
|
4262
4724
|
} catch {
|
|
4263
4725
|
}
|
|
4264
|
-
const title = await page.title().catch(() => "");
|
|
4726
|
+
const title = await withBudget(page.title(), 2e3, "title").catch(() => "");
|
|
4265
4727
|
if (!screenshot2) return { url, title };
|
|
4266
4728
|
try {
|
|
4267
|
-
const buf = await
|
|
4729
|
+
const buf = await withBudget(
|
|
4730
|
+
page.screenshot({ type: "jpeg", quality: 50, fullPage: false, timeout: 12e3 }),
|
|
4731
|
+
13e3,
|
|
4732
|
+
"screenshot"
|
|
4733
|
+
);
|
|
4268
4734
|
const screenshotUrl = saveScreenshot(buf, `${namePrefix}-${Date.now()}.jpg`, ctx.screenshotDir, ctx.publicUrl);
|
|
4269
4735
|
return { url, title, screenshotUrl };
|
|
4270
4736
|
} catch {
|
|
@@ -4636,10 +5102,37 @@ var ApiCapture = class {
|
|
|
4636
5102
|
await new Promise((r) => setTimeout(r, 100));
|
|
4637
5103
|
}
|
|
4638
5104
|
}
|
|
5105
|
+
/** Requests fired but not yet answered. */
|
|
5106
|
+
hasPending() {
|
|
5107
|
+
return this.pendingRequests.size > 0;
|
|
5108
|
+
}
|
|
4639
5109
|
getResults() {
|
|
4640
5110
|
return buildCapturedApi(this.requests);
|
|
4641
5111
|
}
|
|
5112
|
+
/** Every HTTP >= 400 response, compact, in capture order. */
|
|
5113
|
+
getServerErrors() {
|
|
5114
|
+
return serverErrorsFrom(this.requests);
|
|
5115
|
+
}
|
|
4642
5116
|
};
|
|
5117
|
+
var SERVER_ERROR_BODY_MAX = 600;
|
|
5118
|
+
function serverErrorsFrom(requests) {
|
|
5119
|
+
const out = [];
|
|
5120
|
+
for (const r of requests) {
|
|
5121
|
+
if (r.responseStatus < 400) continue;
|
|
5122
|
+
let body;
|
|
5123
|
+
if (r.responseBody !== void 0 && r.responseBody !== null) {
|
|
5124
|
+
const raw = typeof r.responseBody === "string" ? r.responseBody : JSON.stringify(r.responseBody);
|
|
5125
|
+
const flat = raw.replace(/\s+/g, " ").trim();
|
|
5126
|
+
body = flat.length > SERVER_ERROR_BODY_MAX ? flat.slice(0, SERVER_ERROR_BODY_MAX) + "\u2026" : flat;
|
|
5127
|
+
if (/^\s*<(!doctype|html)/i.test(raw)) {
|
|
5128
|
+
const m = raw.match(/<title[^>]*>([^<]*)<\/title>|<h1[^>]*>([^<]*)<\/h1>/i);
|
|
5129
|
+
body = m ? `[html] ${(m[1] || m[2] || "").trim()}` : "[html page]";
|
|
5130
|
+
}
|
|
5131
|
+
}
|
|
5132
|
+
out.push({ stepIndex: r.triggeredAtStep, method: r.method, url: r.url, status: r.responseStatus, body });
|
|
5133
|
+
}
|
|
5134
|
+
return out;
|
|
5135
|
+
}
|
|
4643
5136
|
function extractAuth(requests) {
|
|
4644
5137
|
const auth2 = { cookies: {}, tokens: {} };
|
|
4645
5138
|
for (const req of requests) {
|
|
@@ -4888,10 +5381,36 @@ function classifyError(err, step) {
|
|
|
4888
5381
|
if (step.type === "solve-captcha") return "captcha-unsolvable";
|
|
4889
5382
|
return "action-failed";
|
|
4890
5383
|
}
|
|
5384
|
+
async function refineError(page, step, ctx, errorType, message) {
|
|
5385
|
+
if (errorType !== "stale-state" && errorType !== "timeout") return { errorType, message };
|
|
5386
|
+
const raw = step.selector;
|
|
5387
|
+
if (typeof raw !== "string") return { errorType, message };
|
|
5388
|
+
let resolved;
|
|
5389
|
+
try {
|
|
5390
|
+
resolved = resolveSelector(raw, ctx);
|
|
5391
|
+
} catch {
|
|
5392
|
+
return { errorType, message };
|
|
5393
|
+
}
|
|
5394
|
+
try {
|
|
5395
|
+
const probe = page.evaluate((sel) => !!document.querySelector(sel), resolved);
|
|
5396
|
+
const budget = new Promise((r) => setTimeout(() => r(null), TIMING.FAILURE_PROBE));
|
|
5397
|
+
const found = await Promise.race([probe, budget]);
|
|
5398
|
+
if (found !== false) return { errorType, message };
|
|
5399
|
+
} catch {
|
|
5400
|
+
return { errorType, message };
|
|
5401
|
+
}
|
|
5402
|
+
const shown = resolved === raw ? `"${raw}"` : `"${raw}" (\u2192 ${resolved})`;
|
|
5403
|
+
return {
|
|
5404
|
+
errorType: "element-not-found",
|
|
5405
|
+
message: `No element matches ${shown} on the current page \u2014 the step waited for it to appear and it never did. It most likely existed when you looked and was re-rendered away before this step ran. (${message})`
|
|
5406
|
+
};
|
|
5407
|
+
}
|
|
4891
5408
|
function isRetryable(errorType) {
|
|
4892
5409
|
return errorType === "stale-state" || errorType === "timeout" || errorType === "element-not-found";
|
|
4893
5410
|
}
|
|
4894
|
-
|
|
5411
|
+
var UNRESPONSIVE_SUGGESTION = "The tab's JavaScript stopped answering during this step. Run a single `read` or `snapshot` on its own to see if it recovered (it usually does). If this step froze it, don't repeat it as-is: for a route change in a single-page app use `evaluate` with `location.assign(url)`; for a click, use a fresh `@e` ref from `find`; avoid `evaluate` calls that scroll or re-render.";
|
|
5412
|
+
function getSuggestion(errorType, step, message = "") {
|
|
5413
|
+
if (message.includes("tab stopped responding")) return UNRESPONSIVE_SUGGESTION;
|
|
4895
5414
|
const usedEphemeralRef = typeof step.selector === "string" && step.selector.startsWith("@e");
|
|
4896
5415
|
if (usedEphemeralRef && (errorType === "element-not-found" || errorType === "timeout" || errorType === "stale-state")) {
|
|
4897
5416
|
return "The @e ref went stale \u2014 the page (likely a React/SPA) re-rendered and dropped it since the snapshot. Take a fresh `snapshot`/`find` right before acting, or save the element as a durable `@a:` anchor with the `remember` tool.";
|
|
@@ -4900,7 +5419,7 @@ function getSuggestion(errorType, step) {
|
|
|
4900
5419
|
case "stale-state":
|
|
4901
5420
|
return "The page stopped responding. The step may have triggered a very slow load or the server may be unreachable.";
|
|
4902
5421
|
case "element-not-found":
|
|
4903
|
-
return
|
|
5422
|
+
return "Selector not found. Either it is wrong, or the page re-rendered the element away (SPAs swap DOM nodes on every state change, so a selector that worked a second ago can return nothing now). Take a fresh `snapshot`/`find` immediately before acting and retry; if it keeps vanishing, `wait-for` it first.";
|
|
4904
5423
|
case "navigation-failed":
|
|
4905
5424
|
return "Navigation failed. The URL may be unreachable, blocked, or require authentication.";
|
|
4906
5425
|
case "captcha-unsolvable":
|
|
@@ -4912,10 +5431,15 @@ function getSuggestion(errorType, step) {
|
|
|
4912
5431
|
}
|
|
4913
5432
|
}
|
|
4914
5433
|
var PipelineRunner = class {
|
|
4915
|
-
constructor(ctx) {
|
|
5434
|
+
constructor(ctx, hooks) {
|
|
4916
5435
|
this.ctx = ctx;
|
|
5436
|
+
this.hooks = hooks;
|
|
4917
5437
|
}
|
|
4918
5438
|
ctx;
|
|
5439
|
+
hooks;
|
|
5440
|
+
/** Live progress, readable while run() is still pending — lets an outer
|
|
5441
|
+
* watchdog report WHICH step hung and what already finished. */
|
|
5442
|
+
progress = { stepIndex: -1, results: [] };
|
|
4919
5443
|
async run(initialPage, pipeline) {
|
|
4920
5444
|
const tracker = new TabTracker(initialPage.context(), initialPage);
|
|
4921
5445
|
try {
|
|
@@ -4933,6 +5457,7 @@ var PipelineRunner = class {
|
|
|
4933
5457
|
const continueOnError = opts.continueOnError ?? false;
|
|
4934
5458
|
const results = [];
|
|
4935
5459
|
const obstacles = [];
|
|
5460
|
+
this.progress.results = results;
|
|
4936
5461
|
const navStep = pipeline.steps.find((s) => s.type === "navigate");
|
|
4937
5462
|
try {
|
|
4938
5463
|
const host = new URL(navStep?.url || safePageUrl(initialPage) || "http://x").hostname;
|
|
@@ -4950,27 +5475,41 @@ var PipelineRunner = class {
|
|
|
4950
5475
|
if (capture) capture.start();
|
|
4951
5476
|
const finishCapture = async () => {
|
|
4952
5477
|
if (!capture) return void 0;
|
|
5478
|
+
await capture.drain(TIMING.CAPTURE_SETTLE, TIMING.CAPTURE_PENDING_MAX);
|
|
4953
5479
|
capture.stop();
|
|
5480
|
+
for (const se of capture.getServerErrors()) {
|
|
5481
|
+
const r = results.find((x) => x.stepIndex === se.stepIndex);
|
|
5482
|
+
if (r) (r.serverErrors ??= []).push(se);
|
|
5483
|
+
}
|
|
4954
5484
|
return capture.getResults();
|
|
4955
5485
|
};
|
|
4956
5486
|
for (let i = 0; i < pipeline.steps.length; i++) {
|
|
4957
5487
|
if (capture) capture.setStep(i);
|
|
4958
5488
|
const step = pipeline.steps[i];
|
|
5489
|
+
this.progress.stepIndex = i;
|
|
4959
5490
|
const page = tracker.active();
|
|
4960
5491
|
const urlBefore = safePageUrl(page);
|
|
4961
5492
|
const monitor = new StaleStateMonitor(page, staleTimeoutMs);
|
|
4962
5493
|
let stepResult;
|
|
4963
5494
|
try {
|
|
5495
|
+
await this.hooks?.before(page, step);
|
|
4964
5496
|
stepResult = await monitor.withMonitoring(async () => {
|
|
4965
5497
|
const r = await executeAction(page, step, this.ctx, monitor);
|
|
5498
|
+
if (step.type === "navigate") await this.hooks?.after(page, step.url);
|
|
4966
5499
|
r.stepIndex = i;
|
|
4967
5500
|
return r;
|
|
4968
5501
|
});
|
|
4969
5502
|
} catch (err) {
|
|
5503
|
+
if (err instanceof HeadedRequiredError) {
|
|
5504
|
+
Object.assign(err, { stepIndex: i, results, obstacles });
|
|
5505
|
+
await finishCapture();
|
|
5506
|
+
throw err;
|
|
5507
|
+
}
|
|
4970
5508
|
const asError = err instanceof Error ? err : new Error(String(err));
|
|
4971
|
-
const
|
|
5509
|
+
const refined = await refineError(tracker.active(), step, this.ctx, classifyError(asError, step), asError.message);
|
|
5510
|
+
const errorType = refined.errorType;
|
|
4972
5511
|
const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
|
|
4973
|
-
stepResult = failedStepResult(step,
|
|
5512
|
+
stepResult = failedStepResult(step, refined.message, Date.now() - startTime, i);
|
|
4974
5513
|
results.push(stepResult);
|
|
4975
5514
|
recordAnchor(step, false);
|
|
4976
5515
|
return {
|
|
@@ -4984,9 +5523,9 @@ var PipelineRunner = class {
|
|
|
4984
5523
|
failedAtStep: i,
|
|
4985
5524
|
failedStep: step,
|
|
4986
5525
|
errorType,
|
|
4987
|
-
message:
|
|
5526
|
+
message: refined.message,
|
|
4988
5527
|
pageState,
|
|
4989
|
-
suggestion: getSuggestion(errorType, step),
|
|
5528
|
+
suggestion: getSuggestion(errorType, step, refined.message),
|
|
4990
5529
|
retryable: isRetryable(errorType)
|
|
4991
5530
|
},
|
|
4992
5531
|
durationMs: Date.now() - startTime,
|
|
@@ -5020,8 +5559,16 @@ var PipelineRunner = class {
|
|
|
5020
5559
|
}
|
|
5021
5560
|
results.push(stepResult);
|
|
5022
5561
|
if (!stepResult.ok && !continueOnError) {
|
|
5562
|
+
const refined = await refineError(
|
|
5563
|
+
tracker.active(),
|
|
5564
|
+
step,
|
|
5565
|
+
this.ctx,
|
|
5566
|
+
classifyError(new Error(stepResult.error || ""), step),
|
|
5567
|
+
stepResult.error || "Step failed"
|
|
5568
|
+
);
|
|
5569
|
+
stepResult.error = refined.message;
|
|
5570
|
+
const errorType = refined.errorType;
|
|
5023
5571
|
const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
|
|
5024
|
-
const errorType = classifyError(new Error(stepResult.error || ""), step);
|
|
5025
5572
|
return {
|
|
5026
5573
|
ok: false,
|
|
5027
5574
|
completedSteps: i,
|
|
@@ -5035,7 +5582,7 @@ var PipelineRunner = class {
|
|
|
5035
5582
|
errorType,
|
|
5036
5583
|
message: stepResult.error || "Step failed",
|
|
5037
5584
|
pageState,
|
|
5038
|
-
suggestion: getSuggestion(errorType, step),
|
|
5585
|
+
suggestion: getSuggestion(errorType, step, refined.message),
|
|
5039
5586
|
retryable: isRetryable(errorType)
|
|
5040
5587
|
},
|
|
5041
5588
|
durationMs: Date.now() - startTime,
|
|
@@ -5045,7 +5592,7 @@ var PipelineRunner = class {
|
|
|
5045
5592
|
if (step.type === "navigate" && continueOnObstacle) {
|
|
5046
5593
|
const obstacleStart = Date.now();
|
|
5047
5594
|
const obstaclePage = tracker.active();
|
|
5048
|
-
const obstacle = await detectObstacles(obstaclePage);
|
|
5595
|
+
const obstacle = await withBudget(detectObstacles(obstaclePage), 1e4, "obstacle check").catch(() => null);
|
|
5049
5596
|
if (obstacle) {
|
|
5050
5597
|
const resolution = await resolveObstacle(obstaclePage, obstacle, this.ctx, monitor);
|
|
5051
5598
|
obstacles.push({
|
|
@@ -5743,7 +6290,7 @@ var CdpRelay = class {
|
|
|
5743
6290
|
return {};
|
|
5744
6291
|
case "Browser.close":
|
|
5745
6292
|
return {};
|
|
5746
|
-
//
|
|
6293
|
+
// Disconnect without closing the user's Chrome.
|
|
5747
6294
|
case "Target.setDiscoverTargets":
|
|
5748
6295
|
return {};
|
|
5749
6296
|
case "Target.getTargets":
|
|
@@ -5786,7 +6333,7 @@ var CdpRelay = class {
|
|
|
5786
6333
|
return await Promise.race([
|
|
5787
6334
|
first,
|
|
5788
6335
|
new Promise((_, reject) => {
|
|
5789
|
-
const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")),
|
|
6336
|
+
const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 5e3);
|
|
5790
6337
|
t.unref?.();
|
|
5791
6338
|
})
|
|
5792
6339
|
]);
|
|
@@ -5845,13 +6392,31 @@ var CdpRelay = class {
|
|
|
5845
6392
|
|
|
5846
6393
|
// src/lib/execution/pipeline-executor.ts
|
|
5847
6394
|
var log18 = createLogger("iframer");
|
|
6395
|
+
function blankPipelineResult(pipeline, modeUsed, error) {
|
|
6396
|
+
return {
|
|
6397
|
+
ok: false,
|
|
6398
|
+
completedSteps: 0,
|
|
6399
|
+
totalSteps: pipeline.steps.length,
|
|
6400
|
+
results: [],
|
|
6401
|
+
obstacles: [],
|
|
6402
|
+
error: {
|
|
6403
|
+
failedAtStep: 0,
|
|
6404
|
+
failedStep: pipeline.steps[0],
|
|
6405
|
+
pageState: { url: "", title: "" },
|
|
6406
|
+
...error
|
|
6407
|
+
},
|
|
6408
|
+
durationMs: 0,
|
|
6409
|
+
modeUsed,
|
|
6410
|
+
finalState: { url: "", title: "" }
|
|
6411
|
+
};
|
|
6412
|
+
}
|
|
5848
6413
|
var PipelineExecutor = class {
|
|
5849
6414
|
constructor(deps) {
|
|
5850
6415
|
this.deps = deps;
|
|
5851
6416
|
}
|
|
5852
6417
|
deps;
|
|
5853
|
-
/**
|
|
5854
|
-
*
|
|
6418
|
+
/** Shared runtime hook read by local and extension execution;
|
|
6419
|
+
* execute() clears it when the call finishes. */
|
|
5855
6420
|
pendingElicitOtp;
|
|
5856
6421
|
/** One pipeline per real tab at a time: chrome.debugger and the CDP relay
|
|
5857
6422
|
* can't share a tab, so concurrent executes on the same tab queue up here. */
|
|
@@ -5882,8 +6447,24 @@ var PipelineExecutor = class {
|
|
|
5882
6447
|
const forcedMode = opts.mode;
|
|
5883
6448
|
const autoEscalate = opts.autoEscalate !== false;
|
|
5884
6449
|
const instanceId = opts.instanceId || DEFAULT_INSTANCE;
|
|
6450
|
+
if (forcedMode === "extension") {
|
|
6451
|
+
return blankPipelineResult(pipeline, "extension", {
|
|
6452
|
+
errorType: "action-failed",
|
|
6453
|
+
message: 'mode="extension" requires options.tabId (the real-Chrome tab to drive).',
|
|
6454
|
+
suggestion: "Call the `tabs` tool (or GET /extension/tabs) to list open tabs, then pass that tab id. Nothing was launched.",
|
|
6455
|
+
retryable: false
|
|
6456
|
+
});
|
|
6457
|
+
}
|
|
5885
6458
|
const firstNav = pipeline.steps.find((s) => s.type === "navigate");
|
|
5886
6459
|
const domain = firstNav ? new URL(firstNav.url).hostname : null;
|
|
6460
|
+
if (!firstNav && !this.deps.daemon.findLiveMode(instanceId)) {
|
|
6461
|
+
return blankPipelineResult(pipeline, forcedMode || "headless", {
|
|
6462
|
+
errorType: "action-failed",
|
|
6463
|
+
message: `No navigate step and no live browser for instanceId "${instanceId}" to act on.`,
|
|
6464
|
+
suggestion: "Add a navigate step, or reattach to a live window (check `status` / `iframer instances` for instanceId). To drive a real Chrome tab, pass options.tabId from the `tabs` tool. Nothing was launched.",
|
|
6465
|
+
retryable: false
|
|
6466
|
+
});
|
|
6467
|
+
}
|
|
5887
6468
|
const availableModes = this.deps.availableModes();
|
|
5888
6469
|
const liveMode = forcedMode ? null : this.deps.daemon.findLiveMode(instanceId);
|
|
5889
6470
|
let mode;
|
|
@@ -5897,6 +6478,7 @@ var PipelineExecutor = class {
|
|
|
5897
6478
|
mode = availableModes[0] || "headless";
|
|
5898
6479
|
}
|
|
5899
6480
|
let result = await this.executeWithMode(userId, token, pipeline, mode, instanceId);
|
|
6481
|
+
mode = result.modeUsed || mode;
|
|
5900
6482
|
if (!result.ok && autoEscalate && domain && result.error?.errorType === "bot-blocked") {
|
|
5901
6483
|
const failedMode = mode;
|
|
5902
6484
|
if (domain) this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
|
|
@@ -5933,11 +6515,11 @@ var PipelineExecutor = class {
|
|
|
5933
6515
|
}
|
|
5934
6516
|
return result;
|
|
5935
6517
|
}
|
|
5936
|
-
async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
|
|
6518
|
+
async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
|
|
5937
6519
|
if (mode === "docker-headful") {
|
|
5938
|
-
return this.executeDocker(userId, token, pipeline);
|
|
6520
|
+
return this.executeDocker(userId, token, pipeline, respectful);
|
|
5939
6521
|
}
|
|
5940
|
-
return this.executeLocal(userId, token, pipeline, mode, instanceId);
|
|
6522
|
+
return this.executeLocal(userId, token, pipeline, mode, instanceId, respectful);
|
|
5941
6523
|
}
|
|
5942
6524
|
/** Execute against the user's real Chrome tab via the extension CDP relay,
|
|
5943
6525
|
* using the SAME PipelineRunner as every other mode. No session inject/extract
|
|
@@ -5946,6 +6528,7 @@ var PipelineExecutor = class {
|
|
|
5946
6528
|
const startTime = Date.now();
|
|
5947
6529
|
const relay = new CdpRelay(tabId, clientId, pipeline.options?.focus);
|
|
5948
6530
|
let browser;
|
|
6531
|
+
let runner;
|
|
5949
6532
|
try {
|
|
5950
6533
|
await relay.start();
|
|
5951
6534
|
browser = await import_playwright_core.chromium.connectOverCDP(relay.httpEndpoint(), { timeout: 3e4 });
|
|
@@ -5958,7 +6541,8 @@ var PipelineExecutor = class {
|
|
|
5958
6541
|
if (!page) throw new Error("no page available for the tab (is it still open?)");
|
|
5959
6542
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
5960
6543
|
if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
|
|
5961
|
-
|
|
6544
|
+
ctx.driver = "extension";
|
|
6545
|
+
runner = new PipelineRunner(ctx);
|
|
5962
6546
|
const typeChars = pipeline.steps.reduce((n, s) => {
|
|
5963
6547
|
const v = s.value;
|
|
5964
6548
|
return (s.type === "human-type" || s.type === "type-code") && typeof v === "string" ? n + v.length : n;
|
|
@@ -5975,7 +6559,7 @@ var PipelineExecutor = class {
|
|
|
5975
6559
|
watchdog = setTimeout(
|
|
5976
6560
|
() => reject(
|
|
5977
6561
|
new Error(
|
|
5978
|
-
`pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014
|
|
6562
|
+
`pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 a call into the tab never returned.`
|
|
5979
6563
|
)
|
|
5980
6564
|
),
|
|
5981
6565
|
capMs
|
|
@@ -5999,23 +6583,26 @@ var PipelineExecutor = class {
|
|
|
5999
6583
|
} catch (err) {
|
|
6000
6584
|
const msg = getErrorMessage(err);
|
|
6001
6585
|
const stalled = msg.includes("pipeline exceeded");
|
|
6586
|
+
const done = runner ? runner.progress.results.filter((r) => r.ok) : [];
|
|
6587
|
+
const hungAt = stalled && runner && runner.progress.stepIndex >= 0 ? runner.progress.stepIndex : 0;
|
|
6588
|
+
const hungStep = pipeline.steps[hungAt];
|
|
6002
6589
|
return {
|
|
6003
6590
|
ok: false,
|
|
6004
|
-
completedSteps: 0,
|
|
6591
|
+
completedSteps: stalled ? done.length : 0,
|
|
6005
6592
|
totalSteps: pipeline.steps.length,
|
|
6006
|
-
results: [],
|
|
6593
|
+
results: stalled ? done : [],
|
|
6007
6594
|
finalState: { url: "", title: "" },
|
|
6008
6595
|
obstacles: [],
|
|
6009
6596
|
durationMs: Date.now() - startTime,
|
|
6010
6597
|
modeUsed: "extension",
|
|
6011
6598
|
error: {
|
|
6012
|
-
failedAtStep:
|
|
6013
|
-
failedStep:
|
|
6014
|
-
errorType: "action-failed",
|
|
6015
|
-
message: `Extension mode failed: ${msg}`,
|
|
6599
|
+
failedAtStep: hungAt,
|
|
6600
|
+
failedStep: hungStep,
|
|
6601
|
+
errorType: stalled ? "stale-state" : "action-failed",
|
|
6602
|
+
message: stalled ? `Extension mode: step ${hungAt} (${hungStep?.type}) never returned; ${msg}` : `Extension mode failed: ${msg}`,
|
|
6016
6603
|
pageState: { url: "", title: "" },
|
|
6017
|
-
suggestion: stalled ?
|
|
6018
|
-
retryable:
|
|
6604
|
+
suggestion: stalled ? `Step ${hungAt} (${hungStep?.type}) hung inside the tab. Steps before it completed. Run a single \`read\` or \`snapshot\` on its own to check the tab recovered (it usually does), then continue from that step in its own execute call. Don't repeat the same step unchanged: for a route change use \`evaluate\` with \`location.assign(url)\`, for a click use a fresh \`@e\` ref from \`find\`. If the tab stays unresponsive, tell the user.` : "Ensure the iframer extension is connected (green dot) and the tab is still open. See chrome://extensions.",
|
|
6605
|
+
retryable: stalled
|
|
6019
6606
|
}
|
|
6020
6607
|
};
|
|
6021
6608
|
} finally {
|
|
@@ -6030,7 +6617,7 @@ var PipelineExecutor = class {
|
|
|
6030
6617
|
}
|
|
6031
6618
|
}
|
|
6032
6619
|
/** Execute via Docker session-manager. */
|
|
6033
|
-
async executeDocker(userId, token, pipeline) {
|
|
6620
|
+
async executeDocker(userId, token, pipeline, respectful = false) {
|
|
6034
6621
|
let session = getSession(userId);
|
|
6035
6622
|
if (!session) {
|
|
6036
6623
|
const firstNav = pipeline.steps.find((s) => s.type === "navigate");
|
|
@@ -6039,7 +6626,7 @@ var PipelineExecutor = class {
|
|
|
6039
6626
|
}
|
|
6040
6627
|
resetTimeout(userId);
|
|
6041
6628
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
6042
|
-
const runner = new PipelineRunner(ctx);
|
|
6629
|
+
const runner = new PipelineRunner(ctx, protectionHooks(false, respectful));
|
|
6043
6630
|
const result = await runner.run(session.page, pipeline);
|
|
6044
6631
|
if (result.ok) {
|
|
6045
6632
|
const blockResult = await detectBlock(session.page);
|
|
@@ -6066,7 +6653,7 @@ var PipelineExecutor = class {
|
|
|
6066
6653
|
return result;
|
|
6067
6654
|
}
|
|
6068
6655
|
/** Execute via local Chrome daemon (headless + binary-headful). */
|
|
6069
|
-
async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
|
|
6656
|
+
async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
|
|
6070
6657
|
const startTime = Date.now();
|
|
6071
6658
|
let acquired = false;
|
|
6072
6659
|
try {
|
|
@@ -6088,8 +6675,38 @@ var PipelineExecutor = class {
|
|
|
6088
6675
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
6089
6676
|
if (sessionData) ctx.sessionData = sessionData;
|
|
6090
6677
|
if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
|
|
6091
|
-
const runner = new PipelineRunner(ctx);
|
|
6092
|
-
|
|
6678
|
+
const runner = new PipelineRunner(ctx, protectionHooks(mode === "headless", respectful));
|
|
6679
|
+
let result;
|
|
6680
|
+
try {
|
|
6681
|
+
result = await runner.run(page, pipeline);
|
|
6682
|
+
} catch (err) {
|
|
6683
|
+
if (!(err instanceof HeadedRequiredError)) throw err;
|
|
6684
|
+
this.deps.daemon.release(mode, instanceId);
|
|
6685
|
+
acquired = false;
|
|
6686
|
+
await this.deps.daemon.stopMode(mode, instanceId);
|
|
6687
|
+
const available = this.deps.availableModes();
|
|
6688
|
+
const headed = available.includes("binary-headful") ? "binary-headful" : "docker-headful";
|
|
6689
|
+
if (!available.includes(headed)) throw new Error("A headed browser is required but unavailable.");
|
|
6690
|
+
await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
|
|
6691
|
+
const index = err.stepIndex;
|
|
6692
|
+
const remaining = pipeline.steps.slice(index);
|
|
6693
|
+
const insertedNavigation = remaining[0]?.type !== "navigate";
|
|
6694
|
+
if (insertedNavigation) remaining.unshift({ type: "navigate", url: err.url });
|
|
6695
|
+
const resumed = await this.executeWithMode(userId, token, { ...pipeline, steps: remaining }, headed, instanceId, true);
|
|
6696
|
+
const offset = index - Number(insertedNavigation);
|
|
6697
|
+
resumed.results = [...err.results, ...resumed.results.filter((r) => !insertedNavigation || r.stepIndex > 0).map((r) => ({ ...r, stepIndex: r.stepIndex + offset }))];
|
|
6698
|
+
resumed.completedSteps = index + Math.max(0, resumed.completedSteps - Number(insertedNavigation));
|
|
6699
|
+
resumed.totalSteps = pipeline.steps.length;
|
|
6700
|
+
resumed.obstacles = [...err.obstacles, ...resumed.obstacles];
|
|
6701
|
+
if (resumed.error) {
|
|
6702
|
+
resumed.error.failedAtStep = Math.max(index, resumed.error.failedAtStep + offset);
|
|
6703
|
+
resumed.error.failedStep = pipeline.steps[resumed.error.failedAtStep];
|
|
6704
|
+
}
|
|
6705
|
+
resumed.durationMs = Date.now() - startTime;
|
|
6706
|
+
resumed.modeEscalated = true;
|
|
6707
|
+
resumed.modeUsed = headed;
|
|
6708
|
+
return resumed;
|
|
6709
|
+
}
|
|
6093
6710
|
if (result.ok) {
|
|
6094
6711
|
const blockResult = await detectBlock(page);
|
|
6095
6712
|
if (blockResult.blocked) {
|
|
@@ -6180,12 +6797,30 @@ var FetchService = class {
|
|
|
6180
6797
|
const { browser, name: browserName } = await getBrowserWithFallback(preferredBrowser);
|
|
6181
6798
|
context = await browser.newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
|
|
6182
6799
|
if (sessionData) await injectCookies(context, sessionData);
|
|
6183
|
-
|
|
6800
|
+
let page = await context.newPage();
|
|
6184
6801
|
await applyStealthToPage(page);
|
|
6185
6802
|
await page.goto(url, { waitUntil: waitUntil || "domcontentloaded", timeout: TIMEOUTS.NAVIGATION });
|
|
6803
|
+
let protection = false;
|
|
6804
|
+
const pivotIfDetected = async () => {
|
|
6805
|
+
if (protection || !await detectSiteProtection(page)) return;
|
|
6806
|
+
protection = true;
|
|
6807
|
+
const targetUrl = page.url();
|
|
6808
|
+
const cookies = await context.cookies();
|
|
6809
|
+
await context.close();
|
|
6810
|
+
context = await (await getHeadedBrowser()).newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
|
|
6811
|
+
await context.addCookies(cookies);
|
|
6812
|
+
page = await context.newPage();
|
|
6813
|
+
await applyStealthToPage(page);
|
|
6814
|
+
await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
|
|
6815
|
+
await paceProtectedDomain(new URL(targetUrl).hostname);
|
|
6816
|
+
await page.goto(targetUrl, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
|
|
6817
|
+
};
|
|
6818
|
+
await pivotIfDetected();
|
|
6186
6819
|
if (sessionData) await injectStorage(page, sessionData);
|
|
6187
6820
|
if (waitForSelector) await page.waitForSelector(waitForSelector, { timeout: TIMEOUTS.SELECTOR_WAIT });
|
|
6188
6821
|
for (const action of actions) {
|
|
6822
|
+
await pivotIfDetected();
|
|
6823
|
+
if (protection) await paceProtectedDomain(new URL(page.url()).hostname);
|
|
6189
6824
|
switch (action.type) {
|
|
6190
6825
|
case "click":
|
|
6191
6826
|
await page.click(action.selector);
|
|
@@ -6216,6 +6851,7 @@ var FetchService = class {
|
|
|
6216
6851
|
break;
|
|
6217
6852
|
}
|
|
6218
6853
|
}
|
|
6854
|
+
await pivotIfDetected();
|
|
6219
6855
|
const finalUrl = page.url();
|
|
6220
6856
|
const html = returnHtml ? await page.content() : void 0;
|
|
6221
6857
|
const result = extract2 ? await page.evaluate(extract2) : void 0;
|
|
@@ -6521,6 +7157,11 @@ var Iframer = class {
|
|
|
6521
7157
|
listInstances() {
|
|
6522
7158
|
return this.daemon.instancesInfo();
|
|
6523
7159
|
}
|
|
7160
|
+
/** Close browsers that never navigated anywhere. See
|
|
7161
|
+
* BrowserDaemon.sweepBlankInstances — called from the server's reap tick. */
|
|
7162
|
+
sweepBlankBrowsers() {
|
|
7163
|
+
return this.daemon.sweepBlankInstances();
|
|
7164
|
+
}
|
|
6524
7165
|
/** Kill all browser instances and reset state. Next execute call will
|
|
6525
7166
|
* launch a fresh browser automatically — no manual restart needed. */
|
|
6526
7167
|
async restartBrowser() {
|
|
@@ -6937,6 +7578,10 @@ var reapTimer = setInterval(async () => {
|
|
|
6937
7578
|
await reapOrphanBrowsers();
|
|
6938
7579
|
} catch {
|
|
6939
7580
|
}
|
|
7581
|
+
try {
|
|
7582
|
+
iframer.sweepBlankBrowsers();
|
|
7583
|
+
} catch {
|
|
7584
|
+
}
|
|
6940
7585
|
const idleMs = Date.now() - lastActivity;
|
|
6941
7586
|
if (idleMs > IDLE_EXIT_MS && !iframer.browserHealth().alive && !extensionBridge.hasClients()) {
|
|
6942
7587
|
gracefulShutdown(`idle for ${Math.round(idleMs / 6e4)}min with no browsers`);
|