iframer-toolkit 0.10.6 → 0.11.0
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 +66 -65
- package/dist/cli.js +2935 -1776
- package/dist/local-server.cjs +1503 -1407
- package/dist/mcp-server.cjs +59 -115
- package/extension/README.md +10 -8
- package/extension/background.js +135 -84
- package/extension/manifest.json +1 -1
- package/extension/native-host.cjs +105 -29
- package/extension/popup.js +13 -4
- package/package.json +3 -6
- package/skills/iframer.md +103 -31
package/dist/local-server.cjs
CHANGED
|
@@ -35,75 +35,18 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
35
35
|
mod
|
|
36
36
|
));
|
|
37
37
|
|
|
38
|
-
// src/lib/session/persistence.ts
|
|
39
|
-
var persistence_exports = {};
|
|
40
|
-
__export(persistence_exports, {
|
|
41
|
-
extractSession: () => extractSession,
|
|
42
|
-
injectCookies: () => injectCookies,
|
|
43
|
-
injectStorage: () => injectStorage
|
|
44
|
-
});
|
|
45
|
-
async function extractSession(context, page) {
|
|
46
|
-
const cookies = await context.cookies();
|
|
47
|
-
const { localStorage, sessionStorage } = await page.evaluate(() => {
|
|
48
|
-
const ls = {};
|
|
49
|
-
const ss = {};
|
|
50
|
-
for (let i = 0; i < window.localStorage.length; i++) {
|
|
51
|
-
const key = window.localStorage.key(i);
|
|
52
|
-
ls[key] = window.localStorage.getItem(key);
|
|
53
|
-
}
|
|
54
|
-
for (let i = 0; i < window.sessionStorage.length; i++) {
|
|
55
|
-
const key = window.sessionStorage.key(i);
|
|
56
|
-
ss[key] = window.sessionStorage.getItem(key);
|
|
57
|
-
}
|
|
58
|
-
return { localStorage: ls, sessionStorage: ss };
|
|
59
|
-
});
|
|
60
|
-
const origin = new URL(page.url()).origin;
|
|
61
|
-
return {
|
|
62
|
-
cookies,
|
|
63
|
-
localStorage: { [origin]: localStorage },
|
|
64
|
-
sessionStorage: { [origin]: sessionStorage },
|
|
65
|
-
extractedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
async function injectCookies(context, sessionData) {
|
|
69
|
-
if (sessionData?.cookies?.length > 0) {
|
|
70
|
-
await context.addCookies(sessionData.cookies);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
async function injectStorage(page, sessionData) {
|
|
74
|
-
if (!sessionData) return;
|
|
75
|
-
const origin = new URL(page.url()).origin;
|
|
76
|
-
const ls = sessionData.localStorage?.[origin];
|
|
77
|
-
const ss = sessionData.sessionStorage?.[origin];
|
|
78
|
-
if (ls && Object.keys(ls).length > 0) {
|
|
79
|
-
await page.evaluate((data) => {
|
|
80
|
-
for (const [key, value] of Object.entries(data)) {
|
|
81
|
-
window.localStorage.setItem(key, value);
|
|
82
|
-
}
|
|
83
|
-
}, ls);
|
|
84
|
-
}
|
|
85
|
-
if (ss && Object.keys(ss).length > 0) {
|
|
86
|
-
await page.evaluate((data) => {
|
|
87
|
-
for (const [key, value] of Object.entries(data)) {
|
|
88
|
-
window.sessionStorage.setItem(key, value);
|
|
89
|
-
}
|
|
90
|
-
}, ss);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
var init_persistence = __esm({
|
|
94
|
-
"src/lib/session/persistence.ts"() {
|
|
95
|
-
"use strict";
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
38
|
// src/lib/paths.ts
|
|
100
39
|
var paths_exports = {};
|
|
101
40
|
__export(paths_exports, {
|
|
102
|
-
getDataDir: () => getDataDir
|
|
41
|
+
getDataDir: () => getDataDir,
|
|
42
|
+
getScreenshotDir: () => getScreenshotDir
|
|
103
43
|
});
|
|
104
44
|
function getDataDir() {
|
|
105
45
|
return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
|
|
106
46
|
}
|
|
47
|
+
function getScreenshotDir() {
|
|
48
|
+
return import_path.default.join(getDataDir(), "screenshots");
|
|
49
|
+
}
|
|
107
50
|
var import_path, import_os;
|
|
108
51
|
var init_paths = __esm({
|
|
109
52
|
"src/lib/paths.ts"() {
|
|
@@ -115,27 +58,21 @@ var init_paths = __esm({
|
|
|
115
58
|
|
|
116
59
|
// index.ts
|
|
117
60
|
var import_express = __toESM(require("express"));
|
|
118
|
-
var
|
|
119
|
-
var
|
|
120
|
-
var
|
|
61
|
+
var import_path11 = __toESM(require("path"));
|
|
62
|
+
var import_fs11 = __toESM(require("fs"));
|
|
63
|
+
var import_url = require("url");
|
|
64
|
+
init_paths();
|
|
121
65
|
|
|
122
66
|
// src/api/routes.ts
|
|
123
67
|
var import_patchright3 = require("patchright");
|
|
124
68
|
|
|
125
69
|
// src/lib/iframer.ts
|
|
126
|
-
|
|
127
|
-
var import_url = require("url");
|
|
128
|
-
|
|
129
|
-
// src/lib/browser/session-manager.ts
|
|
130
|
-
var import_child_process = require("child_process");
|
|
131
|
-
var import_fs2 = __toESM(require("fs"));
|
|
70
|
+
init_paths();
|
|
132
71
|
|
|
133
72
|
// src/lib/browser/launcher.ts
|
|
134
|
-
var import_fs = __toESM(require("fs"));
|
|
135
73
|
var import_patchright = require("patchright");
|
|
136
74
|
|
|
137
75
|
// src/lib/browser/stealth.ts
|
|
138
|
-
var contextStealthScripts = /* @__PURE__ */ new Map();
|
|
139
76
|
var CHROME_VERSION = "136.0.7103.93";
|
|
140
77
|
var USER_AGENT = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_VERSION} Safari/537.36`;
|
|
141
78
|
var NATIVE_TOSTRING_HELPER = `
|
|
@@ -542,27 +479,207 @@ function createLogger(tag) {
|
|
|
542
479
|
};
|
|
543
480
|
}
|
|
544
481
|
|
|
545
|
-
// src/lib/browser/
|
|
546
|
-
var
|
|
547
|
-
var
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
482
|
+
// src/lib/browser/registry.ts
|
|
483
|
+
var import_fs = __toESM(require("fs"));
|
|
484
|
+
var import_path2 = __toESM(require("path"));
|
|
485
|
+
var import_child_process = require("child_process");
|
|
486
|
+
init_paths();
|
|
487
|
+
var log = createLogger("registry");
|
|
488
|
+
function browsersDir() {
|
|
489
|
+
const dir = import_path2.default.join(getDataDir(), "browsers");
|
|
490
|
+
import_fs.default.mkdirSync(dir, { recursive: true });
|
|
491
|
+
return dir;
|
|
492
|
+
}
|
|
493
|
+
function serverInfoPath() {
|
|
494
|
+
return import_path2.default.join(getDataDir(), "server.json");
|
|
495
|
+
}
|
|
496
|
+
function isPidAlive(pid) {
|
|
497
|
+
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
498
|
+
try {
|
|
499
|
+
process.kill(pid, 0);
|
|
500
|
+
return true;
|
|
501
|
+
} catch (e) {
|
|
502
|
+
return e.code === "EPERM";
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function pidMatchesMarker(pid, marker) {
|
|
506
|
+
if (!isPidAlive(pid)) return false;
|
|
507
|
+
try {
|
|
508
|
+
const cmd = (0, import_child_process.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
|
|
509
|
+
return cmd.includes(marker);
|
|
510
|
+
} catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function findChromePidByMarker(marker) {
|
|
515
|
+
try {
|
|
516
|
+
const out = (0, import_child_process.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
|
|
517
|
+
const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
|
|
518
|
+
if (pids.length === 0) return null;
|
|
519
|
+
return Math.min(...pids);
|
|
520
|
+
} catch {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function registerBrowser(rec) {
|
|
525
|
+
try {
|
|
526
|
+
import_fs.default.writeFileSync(import_path2.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
|
|
527
|
+
} catch (err) {
|
|
528
|
+
log.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function unregisterBrowser(chromePid) {
|
|
532
|
+
try {
|
|
533
|
+
import_fs.default.unlinkSync(import_path2.default.join(browsersDir(), `${chromePid}.json`));
|
|
534
|
+
} catch {
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
538
|
+
async function forceKillBrowser(rec) {
|
|
539
|
+
if (!isPidAlive(rec.chromePid)) return true;
|
|
540
|
+
if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
process.kill(rec.chromePid, "SIGKILL");
|
|
545
|
+
} catch {
|
|
546
|
+
}
|
|
547
|
+
const deadline = Date.now() + 2e3;
|
|
548
|
+
while (Date.now() < deadline) {
|
|
549
|
+
if (!isPidAlive(rec.chromePid)) return true;
|
|
550
|
+
await sleep(100);
|
|
551
|
+
}
|
|
552
|
+
return !isPidAlive(rec.chromePid);
|
|
553
|
+
}
|
|
554
|
+
async function reapOrphanBrowsers() {
|
|
555
|
+
let reaped = 0;
|
|
556
|
+
let skipped = 0;
|
|
557
|
+
let files = [];
|
|
558
|
+
try {
|
|
559
|
+
files = import_fs.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
|
|
560
|
+
} catch {
|
|
561
|
+
return { reaped, skipped };
|
|
562
|
+
}
|
|
563
|
+
for (const file of files) {
|
|
564
|
+
const full = import_path2.default.join(browsersDir(), file);
|
|
565
|
+
let rec;
|
|
566
|
+
try {
|
|
567
|
+
rec = JSON.parse(import_fs.default.readFileSync(full, "utf8"));
|
|
568
|
+
} catch {
|
|
569
|
+
try {
|
|
570
|
+
import_fs.default.unlinkSync(full);
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
576
|
+
try {
|
|
577
|
+
import_fs.default.unlinkSync(full);
|
|
578
|
+
} catch {
|
|
579
|
+
}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (isPidAlive(rec.ownerPid)) {
|
|
583
|
+
skipped++;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
log.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
|
|
587
|
+
if (await forceKillBrowser(rec)) {
|
|
588
|
+
try {
|
|
589
|
+
import_fs.default.unlinkSync(full);
|
|
590
|
+
} catch {
|
|
591
|
+
}
|
|
592
|
+
reaped++;
|
|
593
|
+
} else {
|
|
594
|
+
log.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return { reaped, skipped };
|
|
598
|
+
}
|
|
599
|
+
function writeServerInfo(info) {
|
|
600
|
+
import_fs.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
|
|
601
|
+
}
|
|
602
|
+
function readServerInfo() {
|
|
603
|
+
try {
|
|
604
|
+
const info = JSON.parse(import_fs.default.readFileSync(serverInfoPath(), "utf8"));
|
|
605
|
+
if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
|
|
606
|
+
return info;
|
|
607
|
+
} catch {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
552
610
|
}
|
|
611
|
+
function clearServerInfo(pid) {
|
|
612
|
+
const info = readServerInfo();
|
|
613
|
+
if (info && info.pid === pid) {
|
|
614
|
+
try {
|
|
615
|
+
import_fs.default.unlinkSync(serverInfoPath());
|
|
616
|
+
} catch {
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/lib/browser/launcher.ts
|
|
622
|
+
var log2 = createLogger("launcher");
|
|
553
623
|
var cachedBrowser = null;
|
|
554
624
|
var headedBrowser = null;
|
|
625
|
+
var headedPid = null;
|
|
626
|
+
var headedIdleTimer = null;
|
|
627
|
+
var HEADED_IDLE_MS = 5 * 60 * 1e3;
|
|
628
|
+
function armHeadedIdleTimer() {
|
|
629
|
+
if (headedIdleTimer) clearTimeout(headedIdleTimer);
|
|
630
|
+
headedIdleTimer = setTimeout(() => {
|
|
631
|
+
if (!headedBrowser) return;
|
|
632
|
+
const pages = headedBrowser.contexts().reduce((n, c) => n + c.pages().length, 0);
|
|
633
|
+
if (pages > 0) {
|
|
634
|
+
armHeadedIdleTimer();
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
log2.info("idle timeout for the pivot headed browser, closing");
|
|
638
|
+
void closeHeadedBrowser();
|
|
639
|
+
}, HEADED_IDLE_MS);
|
|
640
|
+
}
|
|
555
641
|
async function getHeadedBrowser() {
|
|
556
|
-
if (!headedBrowser?.isConnected())
|
|
642
|
+
if (!headedBrowser?.isConnected()) {
|
|
643
|
+
const marker = `--iframer-key=pivot-headed-${process.pid}-${Date.now()}`;
|
|
644
|
+
headedBrowser = await import_patchright.chromium.launch({ headless: false, args: [...STEALTH_ARGS, marker] });
|
|
645
|
+
headedPid = findChromePidByMarker(marker);
|
|
646
|
+
if (headedPid) {
|
|
647
|
+
registerBrowser({
|
|
648
|
+
key: "pivot::headed",
|
|
649
|
+
chromePid: headedPid,
|
|
650
|
+
ownerPid: process.pid,
|
|
651
|
+
marker,
|
|
652
|
+
launchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
653
|
+
});
|
|
654
|
+
} else {
|
|
655
|
+
log2.warn("could not resolve Chrome PID for the pivot headed browser \u2014 force-kill unavailable");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
armHeadedIdleTimer();
|
|
557
659
|
return headedBrowser;
|
|
558
660
|
}
|
|
661
|
+
async function closeHeadedBrowser() {
|
|
662
|
+
if (headedIdleTimer) {
|
|
663
|
+
clearTimeout(headedIdleTimer);
|
|
664
|
+
headedIdleTimer = null;
|
|
665
|
+
}
|
|
666
|
+
if (headedBrowser) {
|
|
667
|
+
await headedBrowser.close().catch(() => {
|
|
668
|
+
});
|
|
669
|
+
headedBrowser = null;
|
|
670
|
+
}
|
|
671
|
+
if (headedPid !== null) {
|
|
672
|
+
unregisterBrowser(headedPid);
|
|
673
|
+
headedPid = null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
559
676
|
async function getBrowser(_name = "chromium") {
|
|
560
677
|
if (cachedBrowser) {
|
|
561
678
|
if (cachedBrowser.isConnected()) return cachedBrowser;
|
|
562
679
|
try {
|
|
563
680
|
await cachedBrowser.close();
|
|
564
681
|
} catch (e) {
|
|
565
|
-
|
|
682
|
+
log2.warn(`stale browser close failed: ${e}`);
|
|
566
683
|
}
|
|
567
684
|
cachedBrowser = null;
|
|
568
685
|
}
|
|
@@ -573,352 +690,74 @@ async function getBrowser(_name = "chromium") {
|
|
|
573
690
|
return cachedBrowser;
|
|
574
691
|
}
|
|
575
692
|
async function closeBrowser() {
|
|
576
|
-
|
|
577
|
-
await headedBrowser.close().catch(() => {
|
|
578
|
-
});
|
|
579
|
-
headedBrowser = null;
|
|
580
|
-
}
|
|
693
|
+
await closeHeadedBrowser();
|
|
581
694
|
if (!cachedBrowser) return;
|
|
582
695
|
try {
|
|
583
696
|
await cachedBrowser.close();
|
|
584
697
|
} catch (e) {
|
|
585
|
-
|
|
698
|
+
log2.warn(`closeBrowser failed: ${e}`);
|
|
586
699
|
}
|
|
587
700
|
cachedBrowser = null;
|
|
588
701
|
}
|
|
589
702
|
async function getBrowserWithFallback(_preferred) {
|
|
590
703
|
return { browser: await getBrowser(), name: "chromium" };
|
|
591
704
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
705
|
+
|
|
706
|
+
// src/lib/auth/crypto.ts
|
|
707
|
+
var import_crypto = __toESM(require("crypto"));
|
|
708
|
+
var import_fs2 = __toESM(require("fs"));
|
|
709
|
+
var import_os2 = __toESM(require("os"));
|
|
710
|
+
var import_path3 = __toESM(require("path"));
|
|
711
|
+
init_paths();
|
|
712
|
+
var SALT = "iframer-session";
|
|
713
|
+
var INFO = "encryption";
|
|
714
|
+
var KEY_LENGTH = 32;
|
|
715
|
+
var IV_LENGTH = 12;
|
|
716
|
+
var TAG_LENGTH = 16;
|
|
717
|
+
function getLocalToken() {
|
|
718
|
+
if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
|
|
719
|
+
const candidates = [
|
|
720
|
+
import_path3.default.join(getDataDir(), "secret"),
|
|
721
|
+
import_path3.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
|
|
606
722
|
];
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
723
|
+
for (const file of candidates) {
|
|
724
|
+
try {
|
|
725
|
+
const existing = import_fs2.default.readFileSync(file, "utf8").trim();
|
|
726
|
+
if (existing) return existing;
|
|
727
|
+
} catch {
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
for (const file of candidates) {
|
|
731
|
+
try {
|
|
732
|
+
import_fs2.default.mkdirSync(import_path3.default.dirname(file), { recursive: true });
|
|
733
|
+
const secret = import_crypto.default.randomBytes(32).toString("hex");
|
|
734
|
+
import_fs2.default.writeFileSync(file, secret, { mode: 384 });
|
|
735
|
+
return secret;
|
|
736
|
+
} catch {
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
throw new Error(
|
|
740
|
+
`iframer: could not read or create a persistent encryption secret in any writable location (${candidates.join(", ")}). Set IFRAMER_SECRET to a stable value shared between the MCP server and CLI (openssl rand -hex 32).`
|
|
741
|
+
);
|
|
616
742
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
var import_fingerprint_generator = require("fingerprint-generator");
|
|
620
|
-
|
|
621
|
-
// src/lib/constants.ts
|
|
622
|
-
var TIMING = {
|
|
623
|
-
/** Mouse move settle delay */
|
|
624
|
-
MOUSE_MOVE: [50, 200],
|
|
625
|
-
/** Click down→up delay */
|
|
626
|
-
CLICK_HOLD: [30, 90],
|
|
627
|
-
/** Post-click settle */
|
|
628
|
-
POST_CLICK: [100, 300],
|
|
629
|
-
/** Per-character typing delay */
|
|
630
|
-
CHAR_DELAY: [30, 150],
|
|
631
|
-
/** Occasional word-boundary pause */
|
|
632
|
-
WORD_PAUSE: [200, 500],
|
|
633
|
-
/** Pre-action move range */
|
|
634
|
-
IDLE_MOUSE_X: [100, 400],
|
|
635
|
-
IDLE_MOUSE_Y: [100, 300],
|
|
636
|
-
/** Pre-checkbox random move */
|
|
637
|
-
PRE_CHECKBOX_X: [200, 600],
|
|
638
|
-
PRE_CHECKBOX_Y: [150, 400],
|
|
639
|
-
/** Pause after moving to checkbox area */
|
|
640
|
-
PRE_CHECKBOX_WAIT: [300, 800],
|
|
641
|
-
/** Wait after clicking reCAPTCHA checkbox */
|
|
642
|
-
POST_CHECKBOX_WAIT: 2500,
|
|
643
|
-
/** Wait after clicking verify button */
|
|
644
|
-
POST_VERIFY_WAIT: 2e3,
|
|
645
|
-
/** Wait between clicking captcha tiles */
|
|
646
|
-
TILE_CLICK_DELAY: [200, 500],
|
|
647
|
-
/** Time to wait for reCAPTCHA tile images to settle */
|
|
648
|
-
TILE_SETTLE: 800,
|
|
649
|
-
/** Delay before captcha auto-detection */
|
|
650
|
-
CAPTCHA_DETECT_WAIT: 1500,
|
|
651
|
-
/** Post-login navigation settle */
|
|
652
|
-
POST_LOGIN_WAIT: 1500,
|
|
653
|
-
/** Post-submit form delay */
|
|
654
|
-
POST_SUBMIT_WAIT: 500,
|
|
655
|
-
/** Post-submit form extended wait */
|
|
656
|
-
POST_SUBMIT_EXTENDED: 2e3,
|
|
657
|
-
/** Delay before navigating (human hesitation) */
|
|
658
|
-
PRE_NAVIGATE: [300, 700],
|
|
659
|
-
/** Per-digit input delay base */
|
|
660
|
-
DIGIT_DELAY_BASE: 80,
|
|
661
|
-
DIGIT_DELAY_RANGE: 120,
|
|
662
|
-
/** Post-click in forms */
|
|
663
|
-
POST_FORM_CLICK: 200,
|
|
664
|
-
/** Post-TOTP entry wait */
|
|
665
|
-
POST_TOTP_WAIT: 300,
|
|
666
|
-
/** Post-cookies inject wait */
|
|
667
|
-
POST_COOKIES_WAIT: 300,
|
|
668
|
-
/** Scroll step delay */
|
|
669
|
-
SCROLL_DELAY: 150,
|
|
670
|
-
/** Stale state check interval */
|
|
671
|
-
STALE_CHECK_INTERVAL: 2e3
|
|
672
|
-
};
|
|
673
|
-
var CAPTCHA_GRID = {
|
|
674
|
-
/** Pixels from bframe top to grid start (reCAPTCHA) */
|
|
675
|
-
RECAPTCHA_HEADER_HEIGHT: 112,
|
|
676
|
-
/** Pixels from bframe top to grid start (hCaptcha) — slightly different */
|
|
677
|
-
HCAPTCHA_HEADER_HEIGHT: 110,
|
|
678
|
-
/** Default tile size when bframeBox is unavailable */
|
|
679
|
-
DEFAULT_TILE_SIZE: 125,
|
|
680
|
-
/** Grid padding (left+right total) */
|
|
681
|
-
GRID_PADDING: 24,
|
|
682
|
-
/** Grid left margin */
|
|
683
|
-
GRID_MARGIN: 12,
|
|
684
|
-
/** Verify button offset from bframe bottom */
|
|
685
|
-
VERIFY_BTN_BOTTOM_OFFSET: 35,
|
|
686
|
-
/** Verify button offset from bframe right */
|
|
687
|
-
VERIFY_BTN_RIGHT_OFFSET: 60
|
|
688
|
-
};
|
|
689
|
-
var SCREEN_DEFAULTS = {
|
|
690
|
-
WIDTH: 1920,
|
|
691
|
-
HEIGHT: 1080,
|
|
692
|
-
AVAIL_HEIGHT: 1040,
|
|
693
|
-
DPR: 1.25
|
|
694
|
-
};
|
|
695
|
-
var THRESHOLDS = {
|
|
696
|
-
/** Minimum character change to consider page "changed" */
|
|
697
|
-
STALE_CHAR_CHANGE: 100,
|
|
698
|
-
/** Minimum percentage change to consider page "changed" */
|
|
699
|
-
STALE_PERCENT_CHANGE: 0.05,
|
|
700
|
-
/** Minimum body text length to not be considered "empty" */
|
|
701
|
-
MIN_BODY_TEXT: 200,
|
|
702
|
-
/** Maximum response text length to capture */
|
|
703
|
-
MAX_RESPONSE_TEXT: 1e5
|
|
704
|
-
};
|
|
705
|
-
var TIMEOUTS = {
|
|
706
|
-
DEFAULT_STALE: 2e4,
|
|
707
|
-
NAVIGATION: 6e4,
|
|
708
|
-
SELECTOR_WAIT: 1e4,
|
|
709
|
-
TOTP_INPUT: 5e3,
|
|
710
|
-
API_REQUEST: 18e4,
|
|
711
|
-
HEALTH_CHECK: 3e3,
|
|
712
|
-
CHALLENGE_FRAME_WAIT: 5e3,
|
|
713
|
-
// New-tab following: after a click, how long to wait for a just-opened tab's
|
|
714
|
-
// 'page' event to arrive (only applies when no tab has registered yet), and
|
|
715
|
-
// how long to let the followed tab reach domcontentloaded before continuing.
|
|
716
|
-
TAB_FOLLOW_SETTLE: 400,
|
|
717
|
-
TAB_LOAD: 15e3,
|
|
718
|
-
// A new tab usually opens at about:blank then navigates to its real URL —
|
|
719
|
-
// wait this long for that first real navigation before judging it blank.
|
|
720
|
-
TAB_BLANK_RESOLVE: 3e3
|
|
721
|
-
};
|
|
722
|
-
var CHROME_MIN_VERSION = 130;
|
|
723
|
-
|
|
724
|
-
// src/lib/browser/fingerprint.ts
|
|
725
|
-
var generator = new import_fingerprint_generator.FingerprintGenerator({
|
|
726
|
-
browsers: [{ name: "chrome", minVersion: CHROME_MIN_VERSION }],
|
|
727
|
-
operatingSystems: ["windows"],
|
|
728
|
-
devices: ["desktop"],
|
|
729
|
-
locales: ["en-US"]
|
|
730
|
-
});
|
|
731
|
-
function generateWindowsFingerprint() {
|
|
732
|
-
const fp = generator.getFingerprint();
|
|
733
|
-
const { navigator: nav, screen } = fp.fingerprint;
|
|
734
|
-
const dprOptions = [1.25, 1.5, 1.25, 1.5, 1];
|
|
735
|
-
const dpr = dprOptions[Math.floor(Math.random() * dprOptions.length)];
|
|
736
|
-
const w = screen.width || SCREEN_DEFAULTS.WIDTH;
|
|
737
|
-
const h = screen.height || SCREEN_DEFAULTS.HEIGHT;
|
|
738
|
-
return {
|
|
739
|
-
userAgent: nav.userAgent,
|
|
740
|
-
platform: "Win32",
|
|
741
|
-
screenWidth: w,
|
|
742
|
-
screenHeight: h,
|
|
743
|
-
screenAvailHeight: h - 40,
|
|
744
|
-
// Windows taskbar ~40px
|
|
745
|
-
colorDepth: 24,
|
|
746
|
-
deviceScaleFactor: dpr,
|
|
747
|
-
hardwareConcurrency: nav.hardwareConcurrency || 8,
|
|
748
|
-
deviceMemory: nav.deviceMemory || 8,
|
|
749
|
-
languages: nav.languages || ["en-US", "en"],
|
|
750
|
-
uaData: nav.userAgentData
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// src/lib/browser/session-manager.ts
|
|
755
|
-
var log2 = createLogger("session");
|
|
756
|
-
var BASE_DISPLAY = parseInt(process.env.VNC_BASE_DISPLAY || "99", 10);
|
|
757
|
-
var MAX_SESSIONS = parseInt(process.env.VNC_MAX_SESSIONS || "20", 10);
|
|
758
|
-
var SESSION_TIMEOUT = parseInt(process.env.VNC_SESSION_TIMEOUT_MS || "300000", 10);
|
|
759
|
-
var sessions = /* @__PURE__ */ new Map();
|
|
760
|
-
var usedDisplays = /* @__PURE__ */ new Set();
|
|
761
|
-
function allocateDisplay() {
|
|
762
|
-
for (let i = 0; i < MAX_SESSIONS; i++) {
|
|
763
|
-
const num = BASE_DISPLAY + i;
|
|
764
|
-
if (!usedDisplays.has(num)) {
|
|
765
|
-
usedDisplays.add(num);
|
|
766
|
-
return num;
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
throw new Error("No available displays. Max concurrent sessions reached.");
|
|
770
|
-
}
|
|
771
|
-
function freeDisplay(num) {
|
|
772
|
-
usedDisplays.delete(num);
|
|
773
|
-
}
|
|
774
|
-
function waitForSocket(displayNum, timeoutMs = 5e3) {
|
|
775
|
-
const socketPath = `/tmp/.X11-unix/X${displayNum}`;
|
|
776
|
-
return new Promise((resolve, reject) => {
|
|
777
|
-
const start = Date.now();
|
|
778
|
-
const check = () => {
|
|
779
|
-
if (import_fs2.default.existsSync(socketPath)) return resolve();
|
|
780
|
-
if (Date.now() - start > timeoutMs) return reject(new Error(`Xvfb socket not ready after ${timeoutMs}ms`));
|
|
781
|
-
setTimeout(check, 100);
|
|
782
|
-
};
|
|
783
|
-
check();
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
function killProcess(proc) {
|
|
787
|
-
if (proc && !proc.killed) {
|
|
788
|
-
try {
|
|
789
|
-
proc.kill("SIGTERM");
|
|
790
|
-
} catch {
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
async function startSession(userId) {
|
|
795
|
-
if (sessions.has(userId)) {
|
|
796
|
-
return sessions.get(userId);
|
|
797
|
-
}
|
|
798
|
-
const displayNum = allocateDisplay();
|
|
799
|
-
const vncPort = 5900 + displayNum;
|
|
800
|
-
const wsPort = 6080 + (displayNum - BASE_DISPLAY);
|
|
801
|
-
const xvfb = (0, import_child_process.spawn)("Xvfb", [`:${displayNum}`, "-screen", "0", "1920x1080x24", "-ac"], {
|
|
802
|
-
stdio: "ignore"
|
|
803
|
-
});
|
|
804
|
-
await waitForSocket(displayNum);
|
|
805
|
-
const x11vnc = (0, import_child_process.spawn)(
|
|
806
|
-
"x11vnc",
|
|
807
|
-
["-display", `:${displayNum}`, "-nopw", "-listen", "localhost", "-rfbport", String(vncPort), "-shared", "-forever"],
|
|
808
|
-
{ stdio: "ignore" }
|
|
809
|
-
);
|
|
810
|
-
const noVncPath = import_fs2.default.existsSync("/usr/share/novnc") ? "/usr/share/novnc" : "/usr/share/noVNC";
|
|
811
|
-
const websockify = (0, import_child_process.spawn)("websockify", ["--web", noVncPath, String(wsPort), `localhost:${vncPort}`], {
|
|
812
|
-
stdio: "ignore"
|
|
813
|
-
});
|
|
814
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
815
|
-
const browser = await launchHeadful(displayNum);
|
|
816
|
-
const fingerprint = generateWindowsFingerprint();
|
|
817
|
-
const ctxOpts = stealthContextOptions({}, userId, fingerprint);
|
|
818
|
-
const context = await browser.newContext(ctxOpts);
|
|
819
|
-
const stealthScript = buildStealthScript(fingerprint);
|
|
820
|
-
contextStealthScripts.set(context, stealthScript);
|
|
821
|
-
const page = await context.newPage();
|
|
822
|
-
log2.debug(`fingerprint: ${fingerprint.userAgent.slice(0, 60)}... DPR=${fingerprint.deviceScaleFactor} screen=${fingerprint.screenWidth}x${fingerprint.screenHeight}`);
|
|
823
|
-
const session = {
|
|
824
|
-
displayNum,
|
|
825
|
-
vncPort,
|
|
826
|
-
wsPort,
|
|
827
|
-
xvfb,
|
|
828
|
-
x11vnc,
|
|
829
|
-
websockify,
|
|
830
|
-
browser,
|
|
831
|
-
context,
|
|
832
|
-
page,
|
|
833
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
834
|
-
timeoutTimer: null
|
|
835
|
-
};
|
|
836
|
-
session.timeoutTimer = setTimeout(() => stopSession(userId), SESSION_TIMEOUT);
|
|
837
|
-
sessions.set(userId, session);
|
|
838
|
-
return session;
|
|
839
|
-
}
|
|
840
|
-
function resetTimeout(userId) {
|
|
841
|
-
const session = sessions.get(userId);
|
|
842
|
-
if (session) {
|
|
843
|
-
clearTimeout(session.timeoutTimer);
|
|
844
|
-
session.timeoutTimer = setTimeout(() => stopSession(userId), SESSION_TIMEOUT);
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
function getSession(userId) {
|
|
848
|
-
return sessions.get(userId) || null;
|
|
849
|
-
}
|
|
850
|
-
async function stopSession(userId) {
|
|
851
|
-
const session = sessions.get(userId);
|
|
852
|
-
if (!session) return null;
|
|
853
|
-
clearTimeout(session.timeoutTimer);
|
|
854
|
-
let sessionData = null;
|
|
743
|
+
function getPairingTokens() {
|
|
744
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
855
745
|
try {
|
|
856
|
-
|
|
857
|
-
sessionData = await extractSession2(session.context, session.page);
|
|
746
|
+
tokens.add(getLocalToken());
|
|
858
747
|
} catch {
|
|
859
748
|
}
|
|
860
|
-
contextStealthScripts.delete(session.context);
|
|
861
|
-
try {
|
|
862
|
-
await session.context.close();
|
|
863
|
-
} catch {
|
|
864
|
-
}
|
|
865
|
-
try {
|
|
866
|
-
await session.browser.close();
|
|
867
|
-
} catch {
|
|
868
|
-
}
|
|
869
|
-
killProcess(session.websockify);
|
|
870
|
-
killProcess(session.x11vnc);
|
|
871
|
-
killProcess(session.xvfb);
|
|
872
|
-
await new Promise((r) => setTimeout(r, 1e3));
|
|
873
|
-
try {
|
|
874
|
-
import_fs2.default.unlinkSync(`/tmp/.X11-unix/X${session.displayNum}`);
|
|
875
|
-
} catch {
|
|
876
|
-
}
|
|
877
|
-
freeDisplay(session.displayNum);
|
|
878
|
-
sessions.delete(userId);
|
|
879
|
-
return sessionData;
|
|
880
|
-
}
|
|
881
|
-
async function cleanupAllSessions() {
|
|
882
|
-
const userIds = [...sessions.keys()];
|
|
883
|
-
await Promise.all(userIds.map((id) => stopSession(id)));
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// src/lib/auth/crypto.ts
|
|
887
|
-
var import_crypto = __toESM(require("crypto"));
|
|
888
|
-
var import_fs3 = __toESM(require("fs"));
|
|
889
|
-
var import_os2 = __toESM(require("os"));
|
|
890
|
-
var import_path2 = __toESM(require("path"));
|
|
891
|
-
init_paths();
|
|
892
|
-
var SALT = "iframer-session";
|
|
893
|
-
var INFO = "encryption";
|
|
894
|
-
var KEY_LENGTH = 32;
|
|
895
|
-
var IV_LENGTH = 12;
|
|
896
|
-
var TAG_LENGTH = 16;
|
|
897
|
-
function getLocalToken() {
|
|
898
|
-
if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
|
|
899
749
|
const candidates = [
|
|
900
|
-
|
|
901
|
-
|
|
750
|
+
import_path3.default.join(getDataDir(), "secret"),
|
|
751
|
+
import_path3.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
|
|
902
752
|
];
|
|
903
753
|
for (const file of candidates) {
|
|
904
754
|
try {
|
|
905
|
-
|
|
906
|
-
if (existing) return existing;
|
|
907
|
-
} catch {
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
for (const file of candidates) {
|
|
911
|
-
try {
|
|
912
|
-
import_fs3.default.mkdirSync(import_path2.default.dirname(file), { recursive: true });
|
|
913
|
-
const secret = import_crypto.default.randomBytes(32).toString("hex");
|
|
914
|
-
import_fs3.default.writeFileSync(file, secret, { mode: 384 });
|
|
915
|
-
return secret;
|
|
755
|
+
tokens.add(import_fs2.default.readFileSync(file, "utf8").trim());
|
|
916
756
|
} catch {
|
|
917
757
|
}
|
|
918
758
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
);
|
|
759
|
+
tokens.delete("");
|
|
760
|
+
return [...tokens];
|
|
922
761
|
}
|
|
923
762
|
function deriveKey(token, purpose = INFO) {
|
|
924
763
|
return new Promise((resolve, reject) => {
|
|
@@ -967,77 +806,59 @@ function generateTOTP(secret, period = 30, digits = 6) {
|
|
|
967
806
|
return code.toString().padStart(digits, "0");
|
|
968
807
|
}
|
|
969
808
|
|
|
970
|
-
// src/lib/
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
809
|
+
// src/lib/session/persistence.ts
|
|
810
|
+
async function extractSession(context, page) {
|
|
811
|
+
const cookies = await context.cookies();
|
|
812
|
+
const { localStorage, sessionStorage } = await page.evaluate(() => {
|
|
813
|
+
const ls = {};
|
|
814
|
+
const ss = {};
|
|
815
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
816
|
+
const key = window.localStorage.key(i);
|
|
817
|
+
ls[key] = window.localStorage.getItem(key);
|
|
818
|
+
}
|
|
819
|
+
for (let i = 0; i < window.sessionStorage.length; i++) {
|
|
820
|
+
const key = window.sessionStorage.key(i);
|
|
821
|
+
ss[key] = window.sessionStorage.getItem(key);
|
|
822
|
+
}
|
|
823
|
+
return { localStorage: ls, sessionStorage: ss };
|
|
824
|
+
});
|
|
825
|
+
const origin = new URL(page.url()).origin;
|
|
826
|
+
return {
|
|
827
|
+
cookies,
|
|
828
|
+
localStorage: { [origin]: localStorage },
|
|
829
|
+
sessionStorage: { [origin]: sessionStorage },
|
|
830
|
+
extractedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
831
|
+
};
|
|
987
832
|
}
|
|
988
|
-
function
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
pruneScreenshots(dir);
|
|
833
|
+
async function injectCookies(context, sessionData) {
|
|
834
|
+
if (sessionData?.cookies?.length > 0) {
|
|
835
|
+
await context.addCookies(sessionData.cookies);
|
|
836
|
+
}
|
|
993
837
|
}
|
|
994
|
-
function
|
|
995
|
-
|
|
996
|
-
const
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
} catch {
|
|
1004
|
-
return null;
|
|
1005
|
-
}
|
|
1006
|
-
}).filter((e) => e !== null);
|
|
1007
|
-
let removed = 0;
|
|
1008
|
-
const survivors = [];
|
|
1009
|
-
for (const e of entries) {
|
|
1010
|
-
if (now - e.mtimeMs > maxAgeMs) {
|
|
1011
|
-
try {
|
|
1012
|
-
import_fs4.default.unlinkSync(e.full);
|
|
1013
|
-
removed++;
|
|
1014
|
-
} catch {
|
|
1015
|
-
}
|
|
1016
|
-
} else {
|
|
1017
|
-
survivors.push(e);
|
|
838
|
+
async function injectStorage(page, sessionData) {
|
|
839
|
+
if (!sessionData) return;
|
|
840
|
+
const origin = new URL(page.url()).origin;
|
|
841
|
+
const ls = sessionData.localStorage?.[origin];
|
|
842
|
+
const ss = sessionData.sessionStorage?.[origin];
|
|
843
|
+
if (ls && Object.keys(ls).length > 0) {
|
|
844
|
+
await page.evaluate((data) => {
|
|
845
|
+
for (const [key, value] of Object.entries(data)) {
|
|
846
|
+
window.localStorage.setItem(key, value);
|
|
1018
847
|
}
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
removed++;
|
|
1026
|
-
} catch {
|
|
1027
|
-
}
|
|
848
|
+
}, ls);
|
|
849
|
+
}
|
|
850
|
+
if (ss && Object.keys(ss).length > 0) {
|
|
851
|
+
await page.evaluate((data) => {
|
|
852
|
+
for (const [key, value] of Object.entries(data)) {
|
|
853
|
+
window.sessionStorage.setItem(key, value);
|
|
1028
854
|
}
|
|
1029
|
-
}
|
|
1030
|
-
if (removed > 0) log3.debug(`pruned ${removed} old screenshot(s) from ${dir}`);
|
|
1031
|
-
return removed;
|
|
1032
|
-
} catch (err) {
|
|
1033
|
-
log3.warn(`screenshot prune failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1034
|
-
return 0;
|
|
855
|
+
}, ss);
|
|
1035
856
|
}
|
|
1036
857
|
}
|
|
1037
858
|
|
|
1038
859
|
// src/lib/session/sqlite-store.ts
|
|
1039
860
|
var import_path4 = __toESM(require("path"));
|
|
1040
|
-
var
|
|
861
|
+
var import_fs3 = __toESM(require("fs"));
|
|
1041
862
|
var IS_BUN = typeof globalThis.Bun !== "undefined";
|
|
1042
863
|
function createBunDb(dbPath) {
|
|
1043
864
|
const { Database } = require("bun:sqlite");
|
|
@@ -1076,7 +897,7 @@ function createNodeDb(dbPath) {
|
|
|
1076
897
|
var SqliteStore = class {
|
|
1077
898
|
db;
|
|
1078
899
|
constructor(dataDir) {
|
|
1079
|
-
|
|
900
|
+
import_fs3.default.mkdirSync(dataDir, { recursive: true });
|
|
1080
901
|
const dbPath = import_path4.default.join(dataDir, "iframer.db");
|
|
1081
902
|
this.db = IS_BUN ? createBunDb(dbPath) : createNodeDb(dbPath);
|
|
1082
903
|
this.db.run(`
|
|
@@ -1157,11 +978,11 @@ var import_patchright2 = require("patchright");
|
|
|
1157
978
|
var import_crypto2 = require("crypto");
|
|
1158
979
|
|
|
1159
980
|
// src/lib/browser/chrome-downloader.ts
|
|
1160
|
-
var
|
|
981
|
+
var import_fs4 = __toESM(require("fs"));
|
|
1161
982
|
var import_path5 = __toESM(require("path"));
|
|
1162
983
|
var import_os3 = __toESM(require("os"));
|
|
1163
984
|
var import_child_process2 = require("child_process");
|
|
1164
|
-
var
|
|
985
|
+
var log3 = createLogger("chrome");
|
|
1165
986
|
var CHROME_VERSIONS_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
|
|
1166
987
|
var DEFAULT_INSTALL_DIR = import_path5.default.join(import_os3.default.homedir(), ".iframer", "chrome");
|
|
1167
988
|
function getPlatform() {
|
|
@@ -1175,24 +996,24 @@ function getPlatform() {
|
|
|
1175
996
|
function getChromeExecutablePath(installDir) {
|
|
1176
997
|
const platform = process.platform;
|
|
1177
998
|
if (platform === "darwin") {
|
|
1178
|
-
const entries =
|
|
999
|
+
const entries = import_fs4.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
|
|
1179
1000
|
const dir = entries[0] || "chrome-mac-arm64";
|
|
1180
1001
|
return import_path5.default.join(installDir, dir, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
|
|
1181
1002
|
}
|
|
1182
1003
|
if (platform === "linux") {
|
|
1183
|
-
const entries =
|
|
1004
|
+
const entries = import_fs4.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
|
|
1184
1005
|
const dir = entries[0] || "chrome-linux64";
|
|
1185
1006
|
return import_path5.default.join(installDir, dir, "chrome");
|
|
1186
1007
|
}
|
|
1187
1008
|
if (platform === "win32") {
|
|
1188
|
-
const entries =
|
|
1009
|
+
const entries = import_fs4.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
|
|
1189
1010
|
const dir = entries[0] || "chrome-win64";
|
|
1190
1011
|
return import_path5.default.join(installDir, dir, "chrome.exe");
|
|
1191
1012
|
}
|
|
1192
1013
|
throw new Error(`Unsupported platform: ${platform}`);
|
|
1193
1014
|
}
|
|
1194
1015
|
async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
|
|
1195
|
-
|
|
1016
|
+
log3.info("Downloading Chrome for Testing (first time only)...");
|
|
1196
1017
|
const res = await fetch(CHROME_VERSIONS_URL);
|
|
1197
1018
|
if (!res.ok) throw new Error(`Failed to fetch Chrome versions: ${res.status}`);
|
|
1198
1019
|
const data = await res.json();
|
|
@@ -1203,35 +1024,35 @@ async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
|
|
|
1203
1024
|
if (!download2) throw new Error(`No Chrome for Testing download for platform: ${platform}`);
|
|
1204
1025
|
const url = download2.url;
|
|
1205
1026
|
const version = channel.version;
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1027
|
+
log3.debug(`Version ${version} for ${platform}`);
|
|
1028
|
+
log3.debug(`URL: ${url}`);
|
|
1029
|
+
import_fs4.default.mkdirSync(installDir, { recursive: true });
|
|
1209
1030
|
const zipPath = import_path5.default.join(installDir, "chrome.zip");
|
|
1210
1031
|
const dlRes = await fetch(url);
|
|
1211
1032
|
if (!dlRes.ok) throw new Error(`Download failed: ${dlRes.status}`);
|
|
1212
1033
|
const buf = Buffer.from(await dlRes.arrayBuffer());
|
|
1213
|
-
|
|
1214
|
-
|
|
1034
|
+
import_fs4.default.writeFileSync(zipPath, buf);
|
|
1035
|
+
log3.info(`Downloaded ${(buf.length / 1024 / 1024).toFixed(1)}MB`);
|
|
1215
1036
|
(0, import_child_process2.execSync)(`unzip -o -q "${zipPath}" -d "${installDir}"`, { stdio: "inherit" });
|
|
1216
|
-
|
|
1037
|
+
import_fs4.default.unlinkSync(zipPath);
|
|
1217
1038
|
const execPath = getChromeExecutablePath(installDir);
|
|
1218
|
-
if (!
|
|
1039
|
+
if (!import_fs4.default.existsSync(execPath)) {
|
|
1219
1040
|
throw new Error(`Chrome executable not found after extraction: ${execPath}`);
|
|
1220
1041
|
}
|
|
1221
1042
|
if (process.platform !== "win32") {
|
|
1222
|
-
|
|
1043
|
+
import_fs4.default.chmodSync(execPath, 493);
|
|
1223
1044
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1045
|
+
import_fs4.default.writeFileSync(import_path5.default.join(installDir, "version.json"), JSON.stringify({ version, platform, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
1046
|
+
log3.info(`Installed at: ${execPath}`);
|
|
1226
1047
|
return execPath;
|
|
1227
1048
|
}
|
|
1228
1049
|
function findChromeForTesting() {
|
|
1229
1050
|
if (process.env.CHROME_EXECUTABLE) {
|
|
1230
|
-
if (
|
|
1051
|
+
if (import_fs4.default.existsSync(process.env.CHROME_EXECUTABLE)) return process.env.CHROME_EXECUTABLE;
|
|
1231
1052
|
}
|
|
1232
1053
|
try {
|
|
1233
1054
|
const execPath = getChromeExecutablePath(DEFAULT_INSTALL_DIR);
|
|
1234
|
-
if (
|
|
1055
|
+
if (import_fs4.default.existsSync(execPath)) return execPath;
|
|
1235
1056
|
} catch {
|
|
1236
1057
|
}
|
|
1237
1058
|
return null;
|
|
@@ -1247,22 +1068,13 @@ function findChrome() {
|
|
|
1247
1068
|
"/usr/bin/google-chrome-stable",
|
|
1248
1069
|
"/usr/bin/google-chrome",
|
|
1249
1070
|
"/usr/bin/chromium-browser",
|
|
1250
|
-
"/usr/bin/chromium"
|
|
1251
|
-
// Playwright/Patchright installed browsers (Docker containers)
|
|
1252
|
-
...(() => {
|
|
1253
|
-
try {
|
|
1254
|
-
const dirs = import_fs6.default.readdirSync("/ms-playwright").filter((d) => d.startsWith("chromium-")).sort().reverse();
|
|
1255
|
-
return dirs.map((d) => import_path5.default.join("/ms-playwright", d, "chrome-linux", "chrome"));
|
|
1256
|
-
} catch {
|
|
1257
|
-
return [];
|
|
1258
|
-
}
|
|
1259
|
-
})()
|
|
1071
|
+
"/usr/bin/chromium"
|
|
1260
1072
|
] : [
|
|
1261
1073
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
1262
1074
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
1263
1075
|
];
|
|
1264
1076
|
for (const p of systemPaths) {
|
|
1265
|
-
if (
|
|
1077
|
+
if (import_fs4.default.existsSync(p)) return p;
|
|
1266
1078
|
}
|
|
1267
1079
|
return null;
|
|
1268
1080
|
}
|
|
@@ -1272,10 +1084,10 @@ async function ensureChrome() {
|
|
|
1272
1084
|
try {
|
|
1273
1085
|
return await downloadChrome();
|
|
1274
1086
|
} catch (err) {
|
|
1275
|
-
|
|
1087
|
+
log3.error(`Failed to download Chrome for Testing: ${err instanceof Error ? err.message : String(err)}`);
|
|
1276
1088
|
const system = findChrome();
|
|
1277
1089
|
if (system) {
|
|
1278
|
-
|
|
1090
|
+
log3.warn(`Falling back to system Chrome: ${system}`);
|
|
1279
1091
|
return system;
|
|
1280
1092
|
}
|
|
1281
1093
|
throw new Error("No Chrome found. Download failed and no system Chrome available.");
|
|
@@ -1283,7 +1095,7 @@ async function ensureChrome() {
|
|
|
1283
1095
|
}
|
|
1284
1096
|
|
|
1285
1097
|
// src/lib/browser/cloak-browser.ts
|
|
1286
|
-
var
|
|
1098
|
+
var log4 = createLogger("cloak");
|
|
1287
1099
|
var _available = null;
|
|
1288
1100
|
function cloakEnabled() {
|
|
1289
1101
|
return process.env.IFRAMER_USE_CLOAKBROWSER === "1" || process.env.IFRAMER_USE_CLOAKBROWSER === "true";
|
|
@@ -1302,179 +1114,42 @@ async function ensureBinary() {
|
|
|
1302
1114
|
try {
|
|
1303
1115
|
const info = cloak.binaryInfo();
|
|
1304
1116
|
if (!info.installed) {
|
|
1305
|
-
|
|
1117
|
+
log4.info("Downloading CloakBrowser binary...");
|
|
1306
1118
|
await cloak.ensureBinary();
|
|
1307
|
-
|
|
1119
|
+
log4.info("CloakBrowser ready");
|
|
1308
1120
|
}
|
|
1309
1121
|
_available = true;
|
|
1310
1122
|
return true;
|
|
1311
1123
|
} catch (err) {
|
|
1312
|
-
|
|
1124
|
+
log4.warn(`CloakBrowser setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1313
1125
|
_available = false;
|
|
1314
1126
|
return false;
|
|
1315
1127
|
}
|
|
1316
1128
|
}
|
|
1317
1129
|
async function launchCloakBrowser(options) {
|
|
1318
1130
|
const cloak = await tryImport();
|
|
1319
|
-
if (!cloak) return null;
|
|
1320
|
-
try {
|
|
1321
|
-
const ok = await ensureBinary();
|
|
1322
|
-
if (!ok) return null;
|
|
1323
|
-
const browser = await cloak.launch({
|
|
1324
|
-
headless: options.headless,
|
|
1325
|
-
args: options.args
|
|
1326
|
-
});
|
|
1327
|
-
|
|
1328
|
-
return browser;
|
|
1329
|
-
} catch (err) {
|
|
1330
|
-
|
|
1331
|
-
return null;
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
// src/lib/browser/registry.ts
|
|
1336
|
-
var import_fs7 = __toESM(require("fs"));
|
|
1337
|
-
var import_path6 = __toESM(require("path"));
|
|
1338
|
-
var import_child_process3 = require("child_process");
|
|
1339
|
-
init_paths();
|
|
1340
|
-
var log6 = createLogger("registry");
|
|
1341
|
-
function browsersDir() {
|
|
1342
|
-
const dir = import_path6.default.join(getDataDir(), "browsers");
|
|
1343
|
-
import_fs7.default.mkdirSync(dir, { recursive: true });
|
|
1344
|
-
return dir;
|
|
1345
|
-
}
|
|
1346
|
-
function serverInfoPath() {
|
|
1347
|
-
return import_path6.default.join(getDataDir(), "server.json");
|
|
1348
|
-
}
|
|
1349
|
-
function isPidAlive(pid) {
|
|
1350
|
-
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
1351
|
-
try {
|
|
1352
|
-
process.kill(pid, 0);
|
|
1353
|
-
return true;
|
|
1354
|
-
} catch {
|
|
1355
|
-
return false;
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
function pidMatchesMarker(pid, marker) {
|
|
1359
|
-
if (!isPidAlive(pid)) return false;
|
|
1360
|
-
try {
|
|
1361
|
-
const cmd = (0, import_child_process3.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
|
|
1362
|
-
return cmd.includes(marker);
|
|
1363
|
-
} catch {
|
|
1364
|
-
return false;
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
function findChromePidByMarker(marker) {
|
|
1368
|
-
try {
|
|
1369
|
-
const out = (0, import_child_process3.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
|
|
1370
|
-
const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
|
|
1371
|
-
if (pids.length === 0) return null;
|
|
1372
|
-
return Math.min(...pids);
|
|
1373
|
-
} catch {
|
|
1374
|
-
return null;
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
function registerBrowser(rec) {
|
|
1378
|
-
try {
|
|
1379
|
-
import_fs7.default.writeFileSync(import_path6.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
|
|
1380
|
-
} catch (err) {
|
|
1381
|
-
log6.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
function unregisterBrowser(chromePid) {
|
|
1385
|
-
try {
|
|
1386
|
-
import_fs7.default.unlinkSync(import_path6.default.join(browsersDir(), `${chromePid}.json`));
|
|
1387
|
-
} catch {
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1391
|
-
async function forceKillBrowser(rec) {
|
|
1392
|
-
if (!isPidAlive(rec.chromePid)) return true;
|
|
1393
|
-
if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
1394
|
-
return true;
|
|
1395
|
-
}
|
|
1396
|
-
try {
|
|
1397
|
-
process.kill(rec.chromePid, "SIGKILL");
|
|
1398
|
-
} catch {
|
|
1399
|
-
}
|
|
1400
|
-
const deadline = Date.now() + 2e3;
|
|
1401
|
-
while (Date.now() < deadline) {
|
|
1402
|
-
if (!isPidAlive(rec.chromePid)) return true;
|
|
1403
|
-
await sleep(100);
|
|
1404
|
-
}
|
|
1405
|
-
return !isPidAlive(rec.chromePid);
|
|
1406
|
-
}
|
|
1407
|
-
async function reapOrphanBrowsers() {
|
|
1408
|
-
let reaped = 0;
|
|
1409
|
-
let skipped = 0;
|
|
1410
|
-
let files = [];
|
|
1411
|
-
try {
|
|
1412
|
-
files = import_fs7.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
|
|
1413
|
-
} catch {
|
|
1414
|
-
return { reaped, skipped };
|
|
1415
|
-
}
|
|
1416
|
-
for (const file of files) {
|
|
1417
|
-
const full = import_path6.default.join(browsersDir(), file);
|
|
1418
|
-
let rec;
|
|
1419
|
-
try {
|
|
1420
|
-
rec = JSON.parse(import_fs7.default.readFileSync(full, "utf8"));
|
|
1421
|
-
} catch {
|
|
1422
|
-
try {
|
|
1423
|
-
import_fs7.default.unlinkSync(full);
|
|
1424
|
-
} catch {
|
|
1425
|
-
}
|
|
1426
|
-
continue;
|
|
1427
|
-
}
|
|
1428
|
-
if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
|
|
1429
|
-
try {
|
|
1430
|
-
import_fs7.default.unlinkSync(full);
|
|
1431
|
-
} catch {
|
|
1432
|
-
}
|
|
1433
|
-
continue;
|
|
1434
|
-
}
|
|
1435
|
-
if (isPidAlive(rec.ownerPid)) {
|
|
1436
|
-
skipped++;
|
|
1437
|
-
continue;
|
|
1438
|
-
}
|
|
1439
|
-
log6.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
|
|
1440
|
-
if (await forceKillBrowser(rec)) {
|
|
1441
|
-
try {
|
|
1442
|
-
import_fs7.default.unlinkSync(full);
|
|
1443
|
-
} catch {
|
|
1444
|
-
}
|
|
1445
|
-
reaped++;
|
|
1446
|
-
} else {
|
|
1447
|
-
log6.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
return { reaped, skipped };
|
|
1451
|
-
}
|
|
1452
|
-
function writeServerInfo(info) {
|
|
1453
|
-
import_fs7.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
|
|
1454
|
-
}
|
|
1455
|
-
function readServerInfo() {
|
|
1456
|
-
try {
|
|
1457
|
-
const info = JSON.parse(import_fs7.default.readFileSync(serverInfoPath(), "utf8"));
|
|
1458
|
-
if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
|
|
1459
|
-
return info;
|
|
1460
|
-
} catch {
|
|
1131
|
+
if (!cloak) return null;
|
|
1132
|
+
try {
|
|
1133
|
+
const ok = await ensureBinary();
|
|
1134
|
+
if (!ok) return null;
|
|
1135
|
+
const browser = await cloak.launch({
|
|
1136
|
+
headless: options.headless,
|
|
1137
|
+
args: options.args
|
|
1138
|
+
});
|
|
1139
|
+
log4.info(`CloakBrowser launched (headless=${options.headless})`);
|
|
1140
|
+
return browser;
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
log4.warn(`CloakBrowser launch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1461
1143
|
return null;
|
|
1462
1144
|
}
|
|
1463
1145
|
}
|
|
1464
|
-
function clearServerInfo(pid) {
|
|
1465
|
-
const info = readServerInfo();
|
|
1466
|
-
if (info && info.pid === pid) {
|
|
1467
|
-
try {
|
|
1468
|
-
import_fs7.default.unlinkSync(serverInfoPath());
|
|
1469
|
-
} catch {
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
1146
|
|
|
1474
1147
|
// src/lib/browser/daemon.ts
|
|
1475
|
-
var
|
|
1148
|
+
var log5 = createLogger("daemon");
|
|
1476
1149
|
var DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1e3;
|
|
1477
1150
|
var CLOSE_GRACE_MS = 5e3;
|
|
1151
|
+
var BUSY_MAX_MS = 30 * 60 * 1e3;
|
|
1152
|
+
var BLANK_IDLE_MS = 2 * 60 * 1e3;
|
|
1478
1153
|
var DEFAULT_INSTANCE = "default";
|
|
1479
1154
|
function keyOf(mode, instanceId) {
|
|
1480
1155
|
return `${mode}::${instanceId}`;
|
|
@@ -1488,9 +1163,6 @@ var BrowserDaemon = class {
|
|
|
1488
1163
|
this.idleTimeout = idleTimeout;
|
|
1489
1164
|
}
|
|
1490
1165
|
async ensure(mode, instanceId = DEFAULT_INSTANCE, sessionProfile = instanceId) {
|
|
1491
|
-
if (mode === "docker-headful") {
|
|
1492
|
-
throw new Error("Docker mode doesn't use the daemon. Use the Docker API.");
|
|
1493
|
-
}
|
|
1494
1166
|
const key = keyOf(mode, instanceId);
|
|
1495
1167
|
let instance = this.instances.get(key);
|
|
1496
1168
|
if (instance) {
|
|
@@ -1501,11 +1173,11 @@ var BrowserDaemon = class {
|
|
|
1501
1173
|
try {
|
|
1502
1174
|
await page2.evaluate("1");
|
|
1503
1175
|
} catch {
|
|
1504
|
-
|
|
1176
|
+
log5.info(`Page for ${mode} is dead, creating fresh context`);
|
|
1505
1177
|
try {
|
|
1506
1178
|
await context2.close();
|
|
1507
1179
|
} catch (err) {
|
|
1508
|
-
|
|
1180
|
+
log5.warn(`dead-page context close failed: ${err}`);
|
|
1509
1181
|
}
|
|
1510
1182
|
context2 = await instance.browser.newContext();
|
|
1511
1183
|
page2 = await context2.newPage();
|
|
@@ -1518,18 +1190,18 @@ var BrowserDaemon = class {
|
|
|
1518
1190
|
}
|
|
1519
1191
|
} catch {
|
|
1520
1192
|
}
|
|
1521
|
-
|
|
1193
|
+
log5.info(`Browser for ${key} disconnected (window closed?), relaunching...`);
|
|
1522
1194
|
await this.stopMode(mode, instanceId);
|
|
1523
1195
|
}
|
|
1524
1196
|
const marker = `--iframer-key=${key}-${(0, import_crypto2.randomUUID)()}`;
|
|
1525
1197
|
let browser;
|
|
1526
1198
|
const cloakBrowser = await launchCloakBrowser({ headless: mode === "headless", args: [marker] });
|
|
1527
1199
|
if (cloakBrowser) {
|
|
1528
|
-
|
|
1200
|
+
log5.info(`CloakBrowser ${mode} ready`);
|
|
1529
1201
|
browser = cloakBrowser;
|
|
1530
1202
|
} else {
|
|
1531
1203
|
const executablePath = await ensureChrome();
|
|
1532
|
-
|
|
1204
|
+
log5.info(`Falling back to Chrome for Testing in ${mode} mode: ${executablePath}`);
|
|
1533
1205
|
browser = await import_patchright2.chromium.launch({
|
|
1534
1206
|
executablePath,
|
|
1535
1207
|
headless: mode === "headless",
|
|
@@ -1552,7 +1224,7 @@ var BrowserDaemon = class {
|
|
|
1552
1224
|
launchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1553
1225
|
});
|
|
1554
1226
|
} else {
|
|
1555
|
-
|
|
1227
|
+
log5.warn(`could not resolve Chrome PID for ${key} \u2014 force-kill unavailable for this instance`);
|
|
1556
1228
|
}
|
|
1557
1229
|
const context = await browser.newContext();
|
|
1558
1230
|
const page = await context.newPage();
|
|
@@ -1566,11 +1238,12 @@ var BrowserDaemon = class {
|
|
|
1566
1238
|
createdAt: /* @__PURE__ */ new Date(),
|
|
1567
1239
|
chromePid,
|
|
1568
1240
|
marker,
|
|
1569
|
-
active: 0
|
|
1241
|
+
active: 0,
|
|
1242
|
+
busySince: null
|
|
1570
1243
|
};
|
|
1571
1244
|
this.instances.set(key, instance);
|
|
1572
1245
|
this.resetIdleTimer(key);
|
|
1573
|
-
|
|
1246
|
+
log5.info(`Chrome ${key} ready (pid=${chromePid ?? "unknown"})`);
|
|
1574
1247
|
return { browser, context, page };
|
|
1575
1248
|
}
|
|
1576
1249
|
/** Mark an instance busy for the duration of a pipeline run. While busy,
|
|
@@ -1578,14 +1251,19 @@ var BrowserDaemon = class {
|
|
|
1578
1251
|
* Always pair with release() in a finally block. */
|
|
1579
1252
|
acquire(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1580
1253
|
const instance = this.instances.get(keyOf(mode, instanceId));
|
|
1581
|
-
if (instance)
|
|
1254
|
+
if (!instance) return;
|
|
1255
|
+
if (instance.active === 0) instance.busySince = Date.now();
|
|
1256
|
+
instance.active++;
|
|
1582
1257
|
}
|
|
1583
1258
|
release(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1584
1259
|
const key = keyOf(mode, instanceId);
|
|
1585
1260
|
const instance = this.instances.get(key);
|
|
1586
1261
|
if (!instance) return;
|
|
1587
1262
|
instance.active = Math.max(0, instance.active - 1);
|
|
1588
|
-
if (instance.active === 0)
|
|
1263
|
+
if (instance.active === 0) {
|
|
1264
|
+
instance.busySince = null;
|
|
1265
|
+
this.resetIdleTimer(key);
|
|
1266
|
+
}
|
|
1589
1267
|
}
|
|
1590
1268
|
isRunning(mode, instanceId = DEFAULT_INSTANCE) {
|
|
1591
1269
|
const instance = this.instances.get(keyOf(mode, instanceId));
|
|
@@ -1596,6 +1274,35 @@ var BrowserDaemon = class {
|
|
|
1596
1274
|
return false;
|
|
1597
1275
|
}
|
|
1598
1276
|
}
|
|
1277
|
+
/** Close instances that never left about:blank and have been idle a while.
|
|
1278
|
+
* Those are accidents — a pipeline that failed before its first navigate, or
|
|
1279
|
+
* a mode picked for a call that never needed a browser — and in headful mode
|
|
1280
|
+
* each one is an empty window sitting on the user's desktop. Called on the
|
|
1281
|
+
* server's reap tick, so it also catches instances whose idle timer was lost
|
|
1282
|
+
* (e.g. the daemon was restarted under them). */
|
|
1283
|
+
sweepBlankInstances() {
|
|
1284
|
+
const stopped = [];
|
|
1285
|
+
const now = Date.now();
|
|
1286
|
+
for (const [key, inst] of this.instances.entries()) {
|
|
1287
|
+
if (inst.active > 0) continue;
|
|
1288
|
+
if (now - inst.createdAt.getTime() < BLANK_IDLE_MS) continue;
|
|
1289
|
+
let blank = false;
|
|
1290
|
+
try {
|
|
1291
|
+
const pages = inst.context.pages();
|
|
1292
|
+
blank = pages.length === 0 || pages.every((p) => {
|
|
1293
|
+
const u = p.url();
|
|
1294
|
+
return u === "about:blank" || u === "" || u === "chrome://newtab/";
|
|
1295
|
+
});
|
|
1296
|
+
} catch {
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
if (!blank) continue;
|
|
1300
|
+
log5.info(`blank-orphan sweep: ${key} never left about:blank, stopping`);
|
|
1301
|
+
stopped.push(key);
|
|
1302
|
+
this.stopKey(key).catch((err) => log5.warn(`blank sweep stop failed for ${key}: ${err}`));
|
|
1303
|
+
}
|
|
1304
|
+
return stopped;
|
|
1305
|
+
}
|
|
1599
1306
|
/** Distinct modes that currently have at least one live instance. */
|
|
1600
1307
|
runningModes() {
|
|
1601
1308
|
return [...new Set(this.liveInstances().map((i) => i.mode))];
|
|
@@ -1664,17 +1371,17 @@ var BrowserDaemon = class {
|
|
|
1664
1371
|
const timer = this.idleTimers.get(key);
|
|
1665
1372
|
if (timer) clearTimeout(timer);
|
|
1666
1373
|
this.idleTimers.delete(key);
|
|
1667
|
-
|
|
1374
|
+
log5.info(`Stopping Chrome ${key} (pid=${instance.chromePid ?? "unknown"})...`);
|
|
1668
1375
|
const politeClose = (async () => {
|
|
1669
1376
|
try {
|
|
1670
1377
|
await instance.context.close();
|
|
1671
1378
|
} catch (err) {
|
|
1672
|
-
|
|
1379
|
+
log5.warn(`context.close failed for ${key}: ${err}`);
|
|
1673
1380
|
}
|
|
1674
1381
|
try {
|
|
1675
1382
|
await instance.browser.close();
|
|
1676
1383
|
} catch (err) {
|
|
1677
|
-
|
|
1384
|
+
log5.warn(`browser.close failed for ${key}: ${err}`);
|
|
1678
1385
|
}
|
|
1679
1386
|
})();
|
|
1680
1387
|
const closedInTime = await Promise.race([
|
|
@@ -1682,20 +1389,20 @@ var BrowserDaemon = class {
|
|
|
1682
1389
|
sleep2(CLOSE_GRACE_MS).then(() => false)
|
|
1683
1390
|
]);
|
|
1684
1391
|
if (!closedInTime) {
|
|
1685
|
-
|
|
1392
|
+
log5.warn(`polite close timed out after ${CLOSE_GRACE_MS}ms for ${key}, force-killing`);
|
|
1686
1393
|
}
|
|
1687
1394
|
if (instance.chromePid !== null) {
|
|
1688
1395
|
const dead = await forceKillBrowser({ chromePid: instance.chromePid, marker: instance.marker });
|
|
1689
1396
|
if (dead) {
|
|
1690
1397
|
unregisterBrowser(instance.chromePid);
|
|
1691
1398
|
} else {
|
|
1692
|
-
|
|
1399
|
+
log5.warn(`Chrome pid=${instance.chromePid} survived SIGKILL?! leaving registry record for reaper`);
|
|
1693
1400
|
}
|
|
1694
1401
|
} else if (!closedInTime) {
|
|
1695
|
-
|
|
1402
|
+
log5.warn(`no PID recorded for ${key} and polite close hung \u2014 this Chrome may leak until the next reap`);
|
|
1696
1403
|
}
|
|
1697
1404
|
this.instances.delete(key);
|
|
1698
|
-
|
|
1405
|
+
log5.info(`Stopped Chrome ${key}`);
|
|
1699
1406
|
}
|
|
1700
1407
|
/**
|
|
1701
1408
|
* Stop browsers. By default skips instances that are mid-pipeline (active>0)
|
|
@@ -1714,11 +1421,17 @@ var BrowserDaemon = class {
|
|
|
1714
1421
|
setTimeout(() => {
|
|
1715
1422
|
const instance = this.instances.get(key);
|
|
1716
1423
|
if (instance && instance.active > 0) {
|
|
1717
|
-
|
|
1718
|
-
|
|
1424
|
+
const busyMs = instance.busySince ? Date.now() - instance.busySince : 0;
|
|
1425
|
+
if (busyMs < BUSY_MAX_MS) {
|
|
1426
|
+
this.resetIdleTimer(key);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
log5.warn(`${key} reported busy for ${Math.round(busyMs / 6e4)}m \u2014 treating active=${instance.active} as leaked, stopping`);
|
|
1430
|
+
instance.active = 0;
|
|
1431
|
+
instance.busySince = null;
|
|
1719
1432
|
}
|
|
1720
|
-
|
|
1721
|
-
this.stopKey(key).catch((err) =>
|
|
1433
|
+
log5.info(`Idle timeout for ${key}, stopping...`);
|
|
1434
|
+
this.stopKey(key).catch((err) => log5.warn(`idle stop failed for ${key}: ${err}`));
|
|
1722
1435
|
}, this.idleTimeout)
|
|
1723
1436
|
);
|
|
1724
1437
|
}
|
|
@@ -1731,15 +1444,15 @@ var BrowserDaemon = class {
|
|
|
1731
1444
|
};
|
|
1732
1445
|
|
|
1733
1446
|
// src/lib/domain-modes.ts
|
|
1734
|
-
var
|
|
1735
|
-
var
|
|
1447
|
+
var import_fs5 = __toESM(require("fs"));
|
|
1448
|
+
var import_path6 = __toESM(require("path"));
|
|
1736
1449
|
init_paths();
|
|
1737
|
-
var
|
|
1450
|
+
var log6 = createLogger("domain-modes");
|
|
1738
1451
|
function defaultFile() {
|
|
1739
|
-
return
|
|
1452
|
+
return import_path6.default.join(getDataDir(), "domain-modes.json");
|
|
1740
1453
|
}
|
|
1741
1454
|
var TTL_DAYS = 14;
|
|
1742
|
-
var ESCALATION_LADDER = ["headless", "
|
|
1455
|
+
var ESCALATION_LADDER = ["headless", "binary-headful"];
|
|
1743
1456
|
var DomainModeStore = class {
|
|
1744
1457
|
data = {};
|
|
1745
1458
|
filePath;
|
|
@@ -1818,8 +1531,8 @@ var DomainModeStore = class {
|
|
|
1818
1531
|
}
|
|
1819
1532
|
load() {
|
|
1820
1533
|
try {
|
|
1821
|
-
if (
|
|
1822
|
-
this.data = JSON.parse(
|
|
1534
|
+
if (import_fs5.default.existsSync(this.filePath)) {
|
|
1535
|
+
this.data = JSON.parse(import_fs5.default.readFileSync(this.filePath, "utf-8"));
|
|
1823
1536
|
}
|
|
1824
1537
|
} catch {
|
|
1825
1538
|
this.data = {};
|
|
@@ -1827,10 +1540,10 @@ var DomainModeStore = class {
|
|
|
1827
1540
|
}
|
|
1828
1541
|
save() {
|
|
1829
1542
|
try {
|
|
1830
|
-
|
|
1831
|
-
|
|
1543
|
+
import_fs5.default.mkdirSync(import_path6.default.dirname(this.filePath), { recursive: true });
|
|
1544
|
+
import_fs5.default.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
|
|
1832
1545
|
} catch (err) {
|
|
1833
|
-
|
|
1546
|
+
log6.error("Failed to save:", err);
|
|
1834
1547
|
}
|
|
1835
1548
|
}
|
|
1836
1549
|
};
|
|
@@ -1946,7 +1659,7 @@ function failedStepResult(step, error, durationMs, stepIndex = -1) {
|
|
|
1946
1659
|
}
|
|
1947
1660
|
|
|
1948
1661
|
// src/lib/clipboard.ts
|
|
1949
|
-
var
|
|
1662
|
+
var import_child_process3 = require("child_process");
|
|
1950
1663
|
function platformTools(mode) {
|
|
1951
1664
|
if (process.platform === "darwin") return [[mode === "read" ? "pbpaste" : "pbcopy"]];
|
|
1952
1665
|
if (mode === "read") return [["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "-b", "-o"]];
|
|
@@ -1956,7 +1669,7 @@ function run(cmd, input) {
|
|
|
1956
1669
|
return new Promise((resolve) => {
|
|
1957
1670
|
let child;
|
|
1958
1671
|
try {
|
|
1959
|
-
child = (0,
|
|
1672
|
+
child = (0, import_child_process3.spawn)(cmd[0], cmd.slice(1));
|
|
1960
1673
|
} catch {
|
|
1961
1674
|
resolve({ ok: false, out: "", err: `spawn ${cmd[0]} failed` });
|
|
1962
1675
|
return;
|
|
@@ -1984,6 +1697,112 @@ async function clipboardRead() {
|
|
|
1984
1697
|
throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}). ${lastErr}`);
|
|
1985
1698
|
}
|
|
1986
1699
|
|
|
1700
|
+
// src/lib/constants.ts
|
|
1701
|
+
var TIMING = {
|
|
1702
|
+
/** Mouse move settle delay */
|
|
1703
|
+
MOUSE_MOVE: [50, 200],
|
|
1704
|
+
/** Click down→up delay */
|
|
1705
|
+
CLICK_HOLD: [30, 90],
|
|
1706
|
+
/** Post-click settle */
|
|
1707
|
+
POST_CLICK: [100, 300],
|
|
1708
|
+
/** Per-character typing delay */
|
|
1709
|
+
CHAR_DELAY: [30, 150],
|
|
1710
|
+
/** Occasional word-boundary pause */
|
|
1711
|
+
WORD_PAUSE: [200, 500],
|
|
1712
|
+
/** Pre-action move range */
|
|
1713
|
+
IDLE_MOUSE_X: [100, 400],
|
|
1714
|
+
IDLE_MOUSE_Y: [100, 300],
|
|
1715
|
+
/** Pre-checkbox random move */
|
|
1716
|
+
PRE_CHECKBOX_X: [200, 600],
|
|
1717
|
+
PRE_CHECKBOX_Y: [150, 400],
|
|
1718
|
+
/** Pause after moving to checkbox area */
|
|
1719
|
+
PRE_CHECKBOX_WAIT: [300, 800],
|
|
1720
|
+
/** Wait after clicking reCAPTCHA checkbox */
|
|
1721
|
+
POST_CHECKBOX_WAIT: 2500,
|
|
1722
|
+
/** Wait after clicking verify button */
|
|
1723
|
+
POST_VERIFY_WAIT: 2e3,
|
|
1724
|
+
/** Wait between clicking captcha tiles */
|
|
1725
|
+
TILE_CLICK_DELAY: [200, 500],
|
|
1726
|
+
/** Time to wait for reCAPTCHA tile images to settle */
|
|
1727
|
+
TILE_SETTLE: 800,
|
|
1728
|
+
/** Delay before captcha auto-detection */
|
|
1729
|
+
CAPTCHA_DETECT_WAIT: 1500,
|
|
1730
|
+
/** Post-login navigation settle */
|
|
1731
|
+
POST_LOGIN_WAIT: 1500,
|
|
1732
|
+
/** Post-submit form delay */
|
|
1733
|
+
POST_SUBMIT_WAIT: 500,
|
|
1734
|
+
/** Post-submit form extended wait */
|
|
1735
|
+
POST_SUBMIT_EXTENDED: 2e3,
|
|
1736
|
+
/** Delay before navigating (human hesitation) */
|
|
1737
|
+
PRE_NAVIGATE: [300, 700],
|
|
1738
|
+
/** Per-digit input delay base */
|
|
1739
|
+
DIGIT_DELAY_BASE: 80,
|
|
1740
|
+
DIGIT_DELAY_RANGE: 120,
|
|
1741
|
+
/** Post-click in forms */
|
|
1742
|
+
POST_FORM_CLICK: 200,
|
|
1743
|
+
/** Post-TOTP entry wait */
|
|
1744
|
+
POST_TOTP_WAIT: 300,
|
|
1745
|
+
/** Post-cookies inject wait */
|
|
1746
|
+
POST_COOKIES_WAIT: 300,
|
|
1747
|
+
/** Scroll step delay */
|
|
1748
|
+
SCROLL_DELAY: 150,
|
|
1749
|
+
/** Stale state check interval */
|
|
1750
|
+
STALE_CHECK_INTERVAL: 2e3,
|
|
1751
|
+
/** captureApi: grace period after the last step for a submit's XHR to fire */
|
|
1752
|
+
CAPTURE_SETTLE: 300,
|
|
1753
|
+
/** captureApi: max wait for in-flight requests to answer before stopping */
|
|
1754
|
+
CAPTURE_PENDING_MAX: 3e3,
|
|
1755
|
+
/** Post-failure probe ("does the selector exist right now?") budget */
|
|
1756
|
+
FAILURE_PROBE: 3e3
|
|
1757
|
+
};
|
|
1758
|
+
var CAPTCHA_GRID = {
|
|
1759
|
+
/** Pixels from bframe top to grid start (reCAPTCHA) */
|
|
1760
|
+
RECAPTCHA_HEADER_HEIGHT: 112,
|
|
1761
|
+
/** Pixels from bframe top to grid start (hCaptcha) — slightly different */
|
|
1762
|
+
HCAPTCHA_HEADER_HEIGHT: 110,
|
|
1763
|
+
/** Default tile size when bframeBox is unavailable */
|
|
1764
|
+
DEFAULT_TILE_SIZE: 125,
|
|
1765
|
+
/** Grid padding (left+right total) */
|
|
1766
|
+
GRID_PADDING: 24,
|
|
1767
|
+
/** Grid left margin */
|
|
1768
|
+
GRID_MARGIN: 12,
|
|
1769
|
+
/** Verify button offset from bframe bottom */
|
|
1770
|
+
VERIFY_BTN_BOTTOM_OFFSET: 35,
|
|
1771
|
+
/** Verify button offset from bframe right */
|
|
1772
|
+
VERIFY_BTN_RIGHT_OFFSET: 60
|
|
1773
|
+
};
|
|
1774
|
+
var THRESHOLDS = {
|
|
1775
|
+
/** Minimum character change to consider page "changed" */
|
|
1776
|
+
STALE_CHAR_CHANGE: 100,
|
|
1777
|
+
/** Minimum percentage change to consider page "changed" */
|
|
1778
|
+
STALE_PERCENT_CHANGE: 0.05,
|
|
1779
|
+
/** Minimum body text length to not be considered "empty" */
|
|
1780
|
+
MIN_BODY_TEXT: 200,
|
|
1781
|
+
/** Maximum response text length to capture */
|
|
1782
|
+
MAX_RESPONSE_TEXT: 1e5
|
|
1783
|
+
};
|
|
1784
|
+
var TIMEOUTS = {
|
|
1785
|
+
DEFAULT_STALE: 2e4,
|
|
1786
|
+
NAVIGATION: 6e4,
|
|
1787
|
+
SELECTOR_WAIT: 1e4,
|
|
1788
|
+
// Actionability wait for click. Under the 20s stale timer on purpose, so
|
|
1789
|
+
// Playwright's own reason (not visible, covered by another element…) reaches
|
|
1790
|
+
// the agent instead of a generic "no state change".
|
|
1791
|
+
CLICK: 8e3,
|
|
1792
|
+
TOTP_INPUT: 5e3,
|
|
1793
|
+
API_REQUEST: 18e4,
|
|
1794
|
+
HEALTH_CHECK: 3e3,
|
|
1795
|
+
CHALLENGE_FRAME_WAIT: 5e3,
|
|
1796
|
+
// New-tab following: after a click, how long to wait for a just-opened tab's
|
|
1797
|
+
// 'page' event to arrive (only applies when no tab has registered yet), and
|
|
1798
|
+
// how long to let the followed tab reach domcontentloaded before continuing.
|
|
1799
|
+
TAB_FOLLOW_SETTLE: 400,
|
|
1800
|
+
TAB_LOAD: 15e3,
|
|
1801
|
+
// A new tab usually opens at about:blank then navigates to its real URL —
|
|
1802
|
+
// wait this long for that first real navigation before judging it blank.
|
|
1803
|
+
TAB_BLANK_RESOLVE: 3e3
|
|
1804
|
+
};
|
|
1805
|
+
|
|
1987
1806
|
// src/lib/browser/humanize.ts
|
|
1988
1807
|
function rand(min, max) {
|
|
1989
1808
|
return Math.random() * (max - min) + min;
|
|
@@ -2021,8 +1840,8 @@ async function humanMove(page, toX, toY) {
|
|
|
2021
1840
|
const lastPos = mousePositions.get(page);
|
|
2022
1841
|
const fromX = lastPos?.x ?? randRange(TIMING.IDLE_MOUSE_X);
|
|
2023
1842
|
const fromY = lastPos?.y ?? randRange(TIMING.IDLE_MOUSE_Y);
|
|
2024
|
-
const
|
|
2025
|
-
for (const point of
|
|
1843
|
+
const path12 = generatePath(fromX, fromY, toX, toY);
|
|
1844
|
+
for (const point of path12) {
|
|
2026
1845
|
await mouse.move(point.x, point.y);
|
|
2027
1846
|
await sleep3(rand(2, 12));
|
|
2028
1847
|
}
|
|
@@ -2074,6 +1893,24 @@ async function humanType(page, selector, text, opts = {}) {
|
|
|
2074
1893
|
await sleep3(randRange(TIMING.POST_CLICK));
|
|
2075
1894
|
}
|
|
2076
1895
|
await assertFocused(page, selector, !opts.skipClick);
|
|
1896
|
+
if (opts.clear) {
|
|
1897
|
+
const hadText = await page.evaluate(() => {
|
|
1898
|
+
const el = document.activeElement;
|
|
1899
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
1900
|
+
el.select();
|
|
1901
|
+
return el.value.length > 0;
|
|
1902
|
+
}
|
|
1903
|
+
if (el?.isContentEditable) {
|
|
1904
|
+
window.getSelection()?.selectAllChildren(el);
|
|
1905
|
+
return (el.textContent || "").length > 0;
|
|
1906
|
+
}
|
|
1907
|
+
return false;
|
|
1908
|
+
});
|
|
1909
|
+
if (hadText) {
|
|
1910
|
+
await page.keyboard.press("Backspace");
|
|
1911
|
+
await sleep3(randRange(TIMING.CHAR_DELAY));
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
2077
1914
|
const factor = SPEED_FACTOR[opts.speed || "normal"] ?? 1;
|
|
2078
1915
|
for (const char of text) {
|
|
2079
1916
|
await page.keyboard.type(char);
|
|
@@ -2226,10 +2063,36 @@ function sleep3(ms) {
|
|
|
2226
2063
|
return new Promise((r) => setTimeout(r, ms));
|
|
2227
2064
|
}
|
|
2228
2065
|
|
|
2229
|
-
// src/lib/
|
|
2230
|
-
|
|
2066
|
+
// src/lib/budget.ts
|
|
2067
|
+
var BudgetExceededError = class extends Error {
|
|
2068
|
+
constructor(label, ms) {
|
|
2069
|
+
super(`${label} did not answer within ${ms}ms`);
|
|
2070
|
+
this.name = "BudgetExceededError";
|
|
2071
|
+
}
|
|
2072
|
+
};
|
|
2073
|
+
function withBudget(p, ms, label = "page call") {
|
|
2074
|
+
p.catch(() => void 0);
|
|
2075
|
+
let timer;
|
|
2076
|
+
return Promise.race([
|
|
2077
|
+
p,
|
|
2078
|
+
new Promise((_, reject) => {
|
|
2079
|
+
timer = setTimeout(() => reject(new BudgetExceededError(label, ms)), ms);
|
|
2080
|
+
timer.unref?.();
|
|
2081
|
+
})
|
|
2082
|
+
]).finally(() => {
|
|
2083
|
+
if (timer) clearTimeout(timer);
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2231
2086
|
|
|
2232
2087
|
// src/lib/actions/resolve-selector.ts
|
|
2088
|
+
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/;
|
|
2089
|
+
function assertCssSelector(selector) {
|
|
2090
|
+
if (PLAYWRIGHT_ONLY.test(selector)) {
|
|
2091
|
+
throw new Error(
|
|
2092
|
+
`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.`
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2233
2096
|
function resolveSelector(selector, ctx) {
|
|
2234
2097
|
if (selector.startsWith("@a:")) {
|
|
2235
2098
|
const name = selector.slice(3);
|
|
@@ -2250,42 +2113,194 @@ function resolveSelector(selector, ctx) {
|
|
|
2250
2113
|
}
|
|
2251
2114
|
return ref.selector;
|
|
2252
2115
|
}
|
|
2116
|
+
assertCssSelector(selector);
|
|
2253
2117
|
return selector;
|
|
2254
2118
|
}
|
|
2255
2119
|
function anchorNameOf(selector) {
|
|
2256
2120
|
return typeof selector === "string" && selector.startsWith("@a:") ? selector.slice(3) : null;
|
|
2257
2121
|
}
|
|
2258
2122
|
|
|
2123
|
+
// src/lib/reachability.ts
|
|
2124
|
+
var HONEYPOT_REASONS = /* @__PURE__ */ new Set(["zero-size", "hidden", "transparent", "offscreen", "clipped"]);
|
|
2125
|
+
var HONEYPOT_NAME_RE = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
|
|
2126
|
+
function isHoneypotReason(reason) {
|
|
2127
|
+
return !!reason && HONEYPOT_REASONS.has(reason);
|
|
2128
|
+
}
|
|
2129
|
+
function looksLikeBaitField(attrs) {
|
|
2130
|
+
const name = attrs.name || "";
|
|
2131
|
+
const id = attrs.id || "";
|
|
2132
|
+
if (HONEYPOT_NAME_RE.test(name) || HONEYPOT_NAME_RE.test(id)) return true;
|
|
2133
|
+
const textual = !attrs.type || /^(text|email|url|tel|search|number)$/i.test(attrs.type);
|
|
2134
|
+
if (textual && attrs.tabindex === "-1") return true;
|
|
2135
|
+
return false;
|
|
2136
|
+
}
|
|
2137
|
+
function honeypotRefusalMessage(selector, reason, name) {
|
|
2138
|
+
const what = name ? `"${name}" (${selector})` : selector;
|
|
2139
|
+
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.`;
|
|
2140
|
+
}
|
|
2141
|
+
async function probeField(page, selector) {
|
|
2142
|
+
const r = await page.evaluate((sel) => {
|
|
2143
|
+
const el = document.querySelector(sel);
|
|
2144
|
+
if (!el) return { found: false };
|
|
2145
|
+
const attrs = {
|
|
2146
|
+
name: el.getAttribute("name"),
|
|
2147
|
+
id: el.id || null,
|
|
2148
|
+
tabindex: el.getAttribute("tabindex"),
|
|
2149
|
+
type: el.getAttribute("type")
|
|
2150
|
+
};
|
|
2151
|
+
const tag = el.tagName.toLowerCase();
|
|
2152
|
+
{
|
|
2153
|
+
const r0 = el.getBoundingClientRect();
|
|
2154
|
+
const cx0 = r0.left + r0.width / 2, cy0 = r0.top + r0.height / 2;
|
|
2155
|
+
const dx = cx0 < 0 || cx0 >= window.innerWidth ? cx0 - window.innerWidth / 2 : 0;
|
|
2156
|
+
const dy = cy0 < 0 || cy0 >= window.innerHeight ? cy0 - window.innerHeight / 2 : 0;
|
|
2157
|
+
if (dx || dy) try {
|
|
2158
|
+
window.scrollBy(dx, dy);
|
|
2159
|
+
} catch {
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
const rect = el.getBoundingClientRect();
|
|
2163
|
+
let reason = null;
|
|
2164
|
+
if (rect.width === 0 || rect.height === 0) reason = "zero-size";
|
|
2165
|
+
const style = getComputedStyle(el);
|
|
2166
|
+
if (!reason && (style.display === "none" || style.visibility === "hidden")) reason = "hidden";
|
|
2167
|
+
if (!reason) {
|
|
2168
|
+
let opacity = 1;
|
|
2169
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
2170
|
+
const o = parseFloat(getComputedStyle(n).opacity);
|
|
2171
|
+
const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
|
|
2172
|
+
if (!isNaN(o) && !animating) opacity *= o;
|
|
2173
|
+
}
|
|
2174
|
+
if (opacity < 0.05) reason = "transparent";
|
|
2175
|
+
}
|
|
2176
|
+
if (!reason) {
|
|
2177
|
+
const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
|
|
2178
|
+
const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
|
|
2179
|
+
const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
|
|
2180
|
+
const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
|
|
2181
|
+
if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) reason = "offscreen";
|
|
2182
|
+
}
|
|
2183
|
+
if (!reason) {
|
|
2184
|
+
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
2185
|
+
const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
|
|
2186
|
+
let clipped = false;
|
|
2187
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
2188
|
+
const s = getComputedStyle(a);
|
|
2189
|
+
const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
|
|
2190
|
+
if (!clipsX && !clipsY) continue;
|
|
2191
|
+
const ar = a.getBoundingClientRect();
|
|
2192
|
+
const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
|
|
2193
|
+
const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
|
|
2194
|
+
if (outside || tiny) {
|
|
2195
|
+
clipped = true;
|
|
2196
|
+
break;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
if (inViewport) {
|
|
2200
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
2201
|
+
if (!(hit && (hit === el || el.contains(hit)))) reason = clipped ? "clipped" : "covered";
|
|
2202
|
+
} else if (clipped) {
|
|
2203
|
+
reason = "clipped";
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
return { found: true, reason, attrs, tag };
|
|
2207
|
+
}, selector);
|
|
2208
|
+
if (!r.found) return { found: false, hiddenReason: null, suspiciousName: false };
|
|
2209
|
+
return {
|
|
2210
|
+
found: true,
|
|
2211
|
+
hiddenReason: r.reason ?? null,
|
|
2212
|
+
suspiciousName: looksLikeBaitField(r.attrs),
|
|
2213
|
+
tag: r.tag,
|
|
2214
|
+
name: r.attrs.name ?? void 0
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2259
2218
|
// src/lib/actions/handlers/navigation.ts
|
|
2260
|
-
var
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2219
|
+
var log7 = createLogger("actions");
|
|
2220
|
+
var NAV_COMMIT_SUSPECT_MS = 12e3;
|
|
2221
|
+
async function navigate(page, step, ctx, monitor) {
|
|
2222
|
+
const waitUntil = step.waitUntil || "domcontentloaded";
|
|
2223
|
+
const urlBefore = page.url();
|
|
2224
|
+
const onDialog = (d) => {
|
|
2225
|
+
(d.type() === "beforeunload" ? d.accept() : d.dismiss()).catch(() => void 0);
|
|
2226
|
+
};
|
|
2227
|
+
page.on("dialog", onDialog);
|
|
2228
|
+
try {
|
|
2229
|
+
const nav = page.goto(step.url, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
|
|
2230
|
+
nav.catch(() => void 0);
|
|
2231
|
+
let suspectTimer;
|
|
2232
|
+
const outcome = await Promise.race([
|
|
2233
|
+
nav.then(() => "done"),
|
|
2234
|
+
new Promise((r) => {
|
|
2235
|
+
suspectTimer = setTimeout(() => r("slow"), NAV_COMMIT_SUSPECT_MS);
|
|
2236
|
+
})
|
|
2237
|
+
]).finally(() => clearTimeout(suspectTimer));
|
|
2238
|
+
if (outcome === "slow") {
|
|
2239
|
+
if (page.url() !== urlBefore) {
|
|
2240
|
+
await nav;
|
|
2241
|
+
} else {
|
|
2242
|
+
log7.warn(`navigate: no commit after ${NAV_COMMIT_SUSPECT_MS}ms, falling back to location.assign`);
|
|
2243
|
+
monitor?.reportActivity();
|
|
2244
|
+
await withBudget(page.evaluate((u) => {
|
|
2245
|
+
location.assign(u);
|
|
2246
|
+
}, step.url), 3e3, "location.assign").catch(() => void 0);
|
|
2247
|
+
try {
|
|
2248
|
+
await page.waitForURL((u) => u.toString() !== urlBefore, { timeout: 15e3, waitUntil });
|
|
2249
|
+
} catch {
|
|
2250
|
+
throw new Error(
|
|
2251
|
+
`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.`
|
|
2252
|
+
);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
} finally {
|
|
2257
|
+
page.off("dialog", onDialog);
|
|
2258
|
+
}
|
|
2267
2259
|
try {
|
|
2268
|
-
await page.evaluate(
|
|
2260
|
+
await page.evaluate(STEALTH_SCRIPT);
|
|
2269
2261
|
} catch (err) {
|
|
2270
|
-
|
|
2262
|
+
log7.warn(`stealth injection failed: ${err}`);
|
|
2271
2263
|
}
|
|
2272
2264
|
if (ctx.sessionData) {
|
|
2273
2265
|
try {
|
|
2274
2266
|
await injectStorage(page, ctx.sessionData);
|
|
2275
2267
|
} catch (err) {
|
|
2276
|
-
|
|
2268
|
+
log7.warn(`storage injection after navigate failed: ${err}`);
|
|
2277
2269
|
}
|
|
2278
2270
|
}
|
|
2279
2271
|
}
|
|
2280
2272
|
async function click(page, step, ctx) {
|
|
2281
|
-
await page.click(resolveSelector(step.selector, ctx));
|
|
2273
|
+
await page.click(resolveSelector(step.selector, ctx), { timeout: TIMEOUTS.CLICK });
|
|
2274
|
+
}
|
|
2275
|
+
async function isCombobox(page, selector) {
|
|
2276
|
+
return withBudget(
|
|
2277
|
+
page.evaluate((sel) => {
|
|
2278
|
+
const el = document.querySelector(sel);
|
|
2279
|
+
if (!el) return false;
|
|
2280
|
+
const role = el.getAttribute("role");
|
|
2281
|
+
return role === "combobox" || el.hasAttribute("aria-autocomplete") || el.getAttribute("aria-haspopup") === "listbox" || el.hasAttribute("list") || !!el.closest('[role="combobox"]');
|
|
2282
|
+
}, selector),
|
|
2283
|
+
3e3
|
|
2284
|
+
).catch(() => false);
|
|
2282
2285
|
}
|
|
2283
2286
|
async function fill(page, step, ctx) {
|
|
2284
2287
|
const selector = resolveSelector(step.selector, ctx);
|
|
2285
2288
|
const value = step.value;
|
|
2289
|
+
if (!step.force) {
|
|
2290
|
+
let probe = null;
|
|
2291
|
+
try {
|
|
2292
|
+
probe = await probeField(page, selector);
|
|
2293
|
+
} catch {
|
|
2294
|
+
probe = null;
|
|
2295
|
+
}
|
|
2296
|
+
if (probe?.found && isHoneypotReason(probe.hiddenReason)) {
|
|
2297
|
+
throw new Error(honeypotRefusalMessage(step.selector, probe.hiddenReason, probe.name));
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
const combobox = await isCombobox(page, selector);
|
|
2286
2301
|
await page.fill(selector, value);
|
|
2287
2302
|
const stuck = await page.evaluate(
|
|
2288
|
-
({ sel, val }) => {
|
|
2303
|
+
({ sel, val, keepFocus }) => {
|
|
2289
2304
|
const el = document.querySelector(sel);
|
|
2290
2305
|
if (!el) return false;
|
|
2291
2306
|
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
@@ -2301,13 +2316,15 @@ async function fill(page, step, ctx) {
|
|
|
2301
2316
|
}
|
|
2302
2317
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2303
2318
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2319
|
+
if (!keepFocus) {
|
|
2320
|
+
try {
|
|
2321
|
+
el.blur();
|
|
2322
|
+
} catch {
|
|
2323
|
+
}
|
|
2307
2324
|
}
|
|
2308
2325
|
return el.value === val;
|
|
2309
2326
|
},
|
|
2310
|
-
{ sel: selector, val: value }
|
|
2327
|
+
{ sel: selector, val: value, keepFocus: combobox }
|
|
2311
2328
|
);
|
|
2312
2329
|
if (!stuck) {
|
|
2313
2330
|
const loc = page.locator(selector);
|
|
@@ -2315,10 +2332,12 @@ async function fill(page, step, ctx) {
|
|
|
2315
2332
|
await loc.click();
|
|
2316
2333
|
await loc.fill("");
|
|
2317
2334
|
await loc.pressSequentially(value, { delay: 15 });
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2335
|
+
if (!combobox) {
|
|
2336
|
+
await page.evaluate((sel) => {
|
|
2337
|
+
const el = document.querySelector(sel);
|
|
2338
|
+
el?.blur?.();
|
|
2339
|
+
}, selector);
|
|
2340
|
+
}
|
|
2322
2341
|
} catch {
|
|
2323
2342
|
}
|
|
2324
2343
|
}
|
|
@@ -2342,7 +2361,7 @@ async function rightClick(page, step, ctx) {
|
|
|
2342
2361
|
}
|
|
2343
2362
|
}
|
|
2344
2363
|
async function humanTypeStep(page, step, ctx) {
|
|
2345
|
-
await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed });
|
|
2364
|
+
await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed, clear: step.clear });
|
|
2346
2365
|
}
|
|
2347
2366
|
async function selectOption(page, step, ctx) {
|
|
2348
2367
|
const selector = resolveSelector(step.selector, ctx);
|
|
@@ -2385,7 +2404,29 @@ async function scroll(page, step, ctx) {
|
|
|
2385
2404
|
{ dy: step.deltaY ?? 0, sel: selector }
|
|
2386
2405
|
);
|
|
2387
2406
|
}
|
|
2388
|
-
async function
|
|
2407
|
+
async function selectAllInFocused(page) {
|
|
2408
|
+
await page.evaluate(() => {
|
|
2409
|
+
const el = document.activeElement;
|
|
2410
|
+
if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
|
|
2411
|
+
el.select();
|
|
2412
|
+
} else if (el && el.isContentEditable) {
|
|
2413
|
+
const sel = window.getSelection();
|
|
2414
|
+
sel?.selectAllChildren(el);
|
|
2415
|
+
} else {
|
|
2416
|
+
window.getSelection()?.selectAllChildren(document.body);
|
|
2417
|
+
}
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
async function keyboard(page, step, ctx) {
|
|
2421
|
+
if ((step.meta || step.ctrl) && step.key.toLowerCase() === "a" && !step.shift && !step.alt) {
|
|
2422
|
+
await selectAllInFocused(page);
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
if ((step.meta || step.ctrl) && ctx.driver === "extension") {
|
|
2426
|
+
throw new Error(
|
|
2427
|
+
`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.`
|
|
2428
|
+
);
|
|
2429
|
+
}
|
|
2389
2430
|
const mods = [
|
|
2390
2431
|
step.meta ? "Meta" : null,
|
|
2391
2432
|
step.ctrl ? "Control" : null,
|
|
@@ -2425,7 +2466,7 @@ async function download(page, step) {
|
|
|
2425
2466
|
const status = resp.status();
|
|
2426
2467
|
if (!resp.ok()) throw new Error(`download: HTTP ${status} for ${step.url}`);
|
|
2427
2468
|
const buf = await resp.body();
|
|
2428
|
-
const
|
|
2469
|
+
const fs12 = await import("fs");
|
|
2429
2470
|
const pathMod = await import("path");
|
|
2430
2471
|
const { getDataDir: getDataDir2 } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
2431
2472
|
let outPath;
|
|
@@ -2439,8 +2480,8 @@ async function download(page, step) {
|
|
|
2439
2480
|
}
|
|
2440
2481
|
outPath = pathMod.join(getDataDir2(), "downloads", step.path || name);
|
|
2441
2482
|
}
|
|
2442
|
-
|
|
2443
|
-
|
|
2483
|
+
fs12.mkdirSync(pathMod.dirname(outPath), { recursive: true });
|
|
2484
|
+
fs12.writeFileSync(outPath, buf);
|
|
2444
2485
|
return { path: outPath, size: buf.length, status };
|
|
2445
2486
|
}
|
|
2446
2487
|
async function typeCode(page, step, ctx) {
|
|
@@ -2486,12 +2527,12 @@ async function find(page, step, ctx) {
|
|
|
2486
2527
|
const elInfo = await element.evaluate((el) => {
|
|
2487
2528
|
const tag = el.tagName.toLowerCase();
|
|
2488
2529
|
const text = (el.textContent?.trim() || "").slice(0, 60);
|
|
2489
|
-
const
|
|
2530
|
+
const path12 = [];
|
|
2490
2531
|
let current = el;
|
|
2491
2532
|
while (current && current !== document.body && current !== document.documentElement) {
|
|
2492
2533
|
let seg = current.tagName.toLowerCase();
|
|
2493
2534
|
if (current.id && /^[a-zA-Z][\w-]*$/.test(current.id)) {
|
|
2494
|
-
|
|
2535
|
+
path12.unshift(`#${current.id}`);
|
|
2495
2536
|
break;
|
|
2496
2537
|
}
|
|
2497
2538
|
const parent = current.parentElement;
|
|
@@ -2503,10 +2544,10 @@ async function find(page, step, ctx) {
|
|
|
2503
2544
|
seg += `:nth-of-type(${idx})`;
|
|
2504
2545
|
}
|
|
2505
2546
|
}
|
|
2506
|
-
|
|
2547
|
+
path12.unshift(seg);
|
|
2507
2548
|
current = parent;
|
|
2508
2549
|
}
|
|
2509
|
-
return { tag, text, selector:
|
|
2550
|
+
return { tag, text, selector: path12.join(" > ") };
|
|
2510
2551
|
});
|
|
2511
2552
|
const ref = `@e${ctx.nextRefId++}`;
|
|
2512
2553
|
const displayRole = step.role || elInfo.tag;
|
|
@@ -2526,6 +2567,73 @@ async function find(page, step, ctx) {
|
|
|
2526
2567
|
};
|
|
2527
2568
|
}
|
|
2528
2569
|
|
|
2570
|
+
// src/lib/screenshot.ts
|
|
2571
|
+
var import_crypto3 = require("crypto");
|
|
2572
|
+
var import_fs6 = __toESM(require("fs"));
|
|
2573
|
+
var import_path7 = __toESM(require("path"));
|
|
2574
|
+
var log8 = createLogger("screenshot");
|
|
2575
|
+
var MAX_AGE_MS = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_AGE_MS || String(24 * 60 * 60 * 1e3), 10);
|
|
2576
|
+
var MAX_FILES = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_FILES || "500", 10);
|
|
2577
|
+
var PRUNE_THROTTLE_MS = 5 * 60 * 1e3;
|
|
2578
|
+
var lastPruneAt = 0;
|
|
2579
|
+
function saveScreenshot(buffer, filename, screenshotDir, publicUrl) {
|
|
2580
|
+
import_fs6.default.mkdirSync(screenshotDir, { recursive: true, mode: 448 });
|
|
2581
|
+
const ext = import_path7.default.extname(filename);
|
|
2582
|
+
const unique = `${import_path7.default.basename(filename, ext)}-${(0, import_crypto3.randomBytes)(8).toString("hex")}${ext}`;
|
|
2583
|
+
import_fs6.default.writeFileSync(import_path7.default.join(screenshotDir, unique), buffer, { mode: 384 });
|
|
2584
|
+
maybePrune(screenshotDir);
|
|
2585
|
+
return `${publicUrl}/screenshots/${unique}`;
|
|
2586
|
+
}
|
|
2587
|
+
function maybePrune(dir) {
|
|
2588
|
+
const now = Date.now();
|
|
2589
|
+
if (now - lastPruneAt < PRUNE_THROTTLE_MS) return;
|
|
2590
|
+
lastPruneAt = now;
|
|
2591
|
+
pruneScreenshots(dir);
|
|
2592
|
+
}
|
|
2593
|
+
function pruneScreenshots(dir, opts = {}) {
|
|
2594
|
+
const maxAgeMs = opts.maxAgeMs ?? MAX_AGE_MS;
|
|
2595
|
+
const maxFiles = opts.maxFiles ?? MAX_FILES;
|
|
2596
|
+
const now = opts.now ?? Date.now();
|
|
2597
|
+
try {
|
|
2598
|
+
const entries = import_fs6.default.readdirSync(dir).filter((f) => f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".png")).map((f) => {
|
|
2599
|
+
const full = import_path7.default.join(dir, f);
|
|
2600
|
+
try {
|
|
2601
|
+
return { full, mtimeMs: import_fs6.default.statSync(full).mtimeMs };
|
|
2602
|
+
} catch {
|
|
2603
|
+
return null;
|
|
2604
|
+
}
|
|
2605
|
+
}).filter((e) => e !== null);
|
|
2606
|
+
let removed = 0;
|
|
2607
|
+
const survivors = [];
|
|
2608
|
+
for (const e of entries) {
|
|
2609
|
+
if (now - e.mtimeMs > maxAgeMs) {
|
|
2610
|
+
try {
|
|
2611
|
+
import_fs6.default.unlinkSync(e.full);
|
|
2612
|
+
removed++;
|
|
2613
|
+
} catch {
|
|
2614
|
+
}
|
|
2615
|
+
} else {
|
|
2616
|
+
survivors.push(e);
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
if (survivors.length > maxFiles) {
|
|
2620
|
+
survivors.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
2621
|
+
for (const e of survivors.slice(0, survivors.length - maxFiles)) {
|
|
2622
|
+
try {
|
|
2623
|
+
import_fs6.default.unlinkSync(e.full);
|
|
2624
|
+
removed++;
|
|
2625
|
+
} catch {
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
if (removed > 0) log8.debug(`pruned ${removed} old screenshot(s) from ${dir}`);
|
|
2630
|
+
return removed;
|
|
2631
|
+
} catch (err) {
|
|
2632
|
+
log8.warn(`screenshot prune failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2633
|
+
return 0;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2529
2637
|
// src/lib/snapshot.ts
|
|
2530
2638
|
async function takeSnapshot(page, ctx, options) {
|
|
2531
2639
|
const interactiveOnly = options?.interactiveOnly ?? true;
|
|
@@ -2570,13 +2678,63 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2570
2678
|
if (rect.bottom < 0 || rect.top > window.innerHeight + 200) return false;
|
|
2571
2679
|
return true;
|
|
2572
2680
|
}
|
|
2681
|
+
function isFillable(el) {
|
|
2682
|
+
if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true;
|
|
2683
|
+
if (el.hasAttribute("contenteditable") && el.getAttribute("contenteditable") !== "false") return true;
|
|
2684
|
+
if (el.tagName !== "INPUT") return false;
|
|
2685
|
+
const t = (el.type || "text").toLowerCase();
|
|
2686
|
+
return !["button", "submit", "reset", "image", "checkbox", "radio", "file", "hidden", "range", "color"].includes(t);
|
|
2687
|
+
}
|
|
2688
|
+
function unreachableReason(el) {
|
|
2689
|
+
const rect = el.getBoundingClientRect();
|
|
2690
|
+
const style = getComputedStyle(el);
|
|
2691
|
+
let opacity = 1;
|
|
2692
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
2693
|
+
const o = parseFloat(n === el ? style.opacity : getComputedStyle(n).opacity);
|
|
2694
|
+
const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
|
|
2695
|
+
if (!isNaN(o) && !animating) opacity *= o;
|
|
2696
|
+
}
|
|
2697
|
+
if (opacity < 0.05) return "transparent";
|
|
2698
|
+
const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
|
|
2699
|
+
const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
|
|
2700
|
+
const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
|
|
2701
|
+
const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
|
|
2702
|
+
if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) return "offscreen";
|
|
2703
|
+
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
|
2704
|
+
let clipped = false;
|
|
2705
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
2706
|
+
const s = getComputedStyle(a);
|
|
2707
|
+
const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
|
|
2708
|
+
if (!clipsX && !clipsY) continue;
|
|
2709
|
+
const ar = a.getBoundingClientRect();
|
|
2710
|
+
const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
|
|
2711
|
+
const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
|
|
2712
|
+
if (tiny) return "clipped";
|
|
2713
|
+
if (outside) clipped = true;
|
|
2714
|
+
}
|
|
2715
|
+
const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
|
|
2716
|
+
if (inViewport) {
|
|
2717
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
2718
|
+
if (hit && (hit === el || el.contains(hit))) return null;
|
|
2719
|
+
return clipped ? "clipped" : null;
|
|
2720
|
+
}
|
|
2721
|
+
return null;
|
|
2722
|
+
}
|
|
2723
|
+
const baitName = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
|
|
2724
|
+
function looksLikeBait(el) {
|
|
2725
|
+
const name = el.getAttribute("name") || "", id = el.id || "";
|
|
2726
|
+
if (baitName.test(name) || baitName.test(id)) return true;
|
|
2727
|
+
const type = (el.getAttribute("type") || "text").toLowerCase();
|
|
2728
|
+
const textual = /^(text|email|url|tel|search|number)$/.test(type);
|
|
2729
|
+
return textual && el.getAttribute("tabindex") === "-1";
|
|
2730
|
+
}
|
|
2573
2731
|
function buildSelector(el) {
|
|
2574
|
-
const
|
|
2732
|
+
const path12 = [];
|
|
2575
2733
|
let current = el;
|
|
2576
2734
|
while (current && current !== document.body && current !== document.documentElement) {
|
|
2577
2735
|
let seg = current.tagName.toLowerCase();
|
|
2578
2736
|
if (current.id && /^[a-zA-Z][\w-]*$/.test(current.id)) {
|
|
2579
|
-
|
|
2737
|
+
path12.unshift(`#${current.id}`);
|
|
2580
2738
|
break;
|
|
2581
2739
|
}
|
|
2582
2740
|
const parent = current.parentElement;
|
|
@@ -2588,10 +2746,10 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2588
2746
|
seg += `:nth-of-type(${idx})`;
|
|
2589
2747
|
}
|
|
2590
2748
|
}
|
|
2591
|
-
|
|
2749
|
+
path12.unshift(seg);
|
|
2592
2750
|
current = parent;
|
|
2593
2751
|
}
|
|
2594
|
-
return
|
|
2752
|
+
return path12.join(" > ");
|
|
2595
2753
|
}
|
|
2596
2754
|
function getName(el) {
|
|
2597
2755
|
const ariaLabel = el.getAttribute("aria-label");
|
|
@@ -2612,6 +2770,11 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2612
2770
|
if (results.length >= maxElements2) break;
|
|
2613
2771
|
if (interactiveOnly2 && !isInteractive(el)) continue;
|
|
2614
2772
|
if (!isVisible(el)) continue;
|
|
2773
|
+
let suspect;
|
|
2774
|
+
if (isFillable(el)) {
|
|
2775
|
+
if (unreachableReason(el)) continue;
|
|
2776
|
+
if (looksLikeBait(el)) suspect = "honeypot?";
|
|
2777
|
+
}
|
|
2615
2778
|
const tag = el.tagName.toLowerCase();
|
|
2616
2779
|
const role = el.getAttribute("role") || "";
|
|
2617
2780
|
const type = el.type || "";
|
|
@@ -2624,7 +2787,8 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2624
2787
|
checked: el.checked || false,
|
|
2625
2788
|
disabled: el.disabled || false,
|
|
2626
2789
|
selector: buildSelector(el),
|
|
2627
|
-
isVisible: true
|
|
2790
|
+
isVisible: true,
|
|
2791
|
+
suspect
|
|
2628
2792
|
});
|
|
2629
2793
|
}
|
|
2630
2794
|
return results;
|
|
@@ -2645,6 +2809,7 @@ async function takeSnapshot(page, ctx, options) {
|
|
|
2645
2809
|
const state = [];
|
|
2646
2810
|
if (el.disabled) state.push("disabled");
|
|
2647
2811
|
if (el.checked) state.push("checked");
|
|
2812
|
+
if (el.suspect) state.push(el.suspect);
|
|
2648
2813
|
let description = "";
|
|
2649
2814
|
if (el.placeholder && el.name !== el.placeholder) description = `placeholder="${el.placeholder}"`;
|
|
2650
2815
|
if (el.type && !["text", "submit", "button", ""].includes(el.type)) {
|
|
@@ -2716,12 +2881,12 @@ async function annotatedScreenshot(page, ctx) {
|
|
|
2716
2881
|
return true;
|
|
2717
2882
|
}
|
|
2718
2883
|
function buildSelector(el) {
|
|
2719
|
-
const
|
|
2884
|
+
const path12 = [];
|
|
2720
2885
|
let current = el;
|
|
2721
2886
|
while (current && current !== document.body && current !== document.documentElement) {
|
|
2722
2887
|
let seg = current.tagName.toLowerCase();
|
|
2723
2888
|
if (current.id && /^[a-zA-Z][\w-]*$/.test(current.id)) {
|
|
2724
|
-
|
|
2889
|
+
path12.unshift(`#${current.id}`);
|
|
2725
2890
|
break;
|
|
2726
2891
|
}
|
|
2727
2892
|
const parent = current.parentElement;
|
|
@@ -2733,10 +2898,10 @@ async function annotatedScreenshot(page, ctx) {
|
|
|
2733
2898
|
seg += `:nth-of-type(${idx})`;
|
|
2734
2899
|
}
|
|
2735
2900
|
}
|
|
2736
|
-
|
|
2901
|
+
path12.unshift(seg);
|
|
2737
2902
|
current = parent;
|
|
2738
2903
|
}
|
|
2739
|
-
return
|
|
2904
|
+
return path12.join(" > ");
|
|
2740
2905
|
}
|
|
2741
2906
|
function getName(el) {
|
|
2742
2907
|
const ariaLabel = el.getAttribute("aria-label");
|
|
@@ -2840,18 +3005,72 @@ async function snapshot(page, step, ctx) {
|
|
|
2840
3005
|
return { elementCount: snap.nodes.length, snapshot: snap.text };
|
|
2841
3006
|
}
|
|
2842
3007
|
|
|
2843
|
-
// src/lib/captcha/
|
|
3008
|
+
// src/lib/captcha/vision.ts
|
|
2844
3009
|
var import_sdk = __toESM(require("@anthropic-ai/sdk"));
|
|
2845
|
-
var
|
|
2846
|
-
var
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
var MODEL = "claude-haiku-4-5-20251001";
|
|
2850
|
-
function getClient() {
|
|
3010
|
+
var CAPTCHA_MODEL = process.env.IFRAMER_CAPTCHA_MODEL || "claude-haiku-4-5-20251001";
|
|
3011
|
+
var CaptchaApiError = class extends Error {
|
|
3012
|
+
};
|
|
3013
|
+
function getCaptchaClient() {
|
|
2851
3014
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
2852
3015
|
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not set \u2014 required for captcha auto-solve");
|
|
2853
3016
|
return new import_sdk.default({ apiKey });
|
|
2854
3017
|
}
|
|
3018
|
+
function toCaptchaApiError(err) {
|
|
3019
|
+
if (err instanceof import_sdk.default.APIConnectionError) {
|
|
3020
|
+
const cause = err.cause instanceof Error ? `: ${err.cause.message}` : "";
|
|
3021
|
+
return new CaptchaApiError(`Claude API call failed (no response): ${err.message}${cause}`);
|
|
3022
|
+
}
|
|
3023
|
+
if (err instanceof import_sdk.default.APIError) {
|
|
3024
|
+
const body = err.error;
|
|
3025
|
+
return new CaptchaApiError(`Claude API call failed (${err.status ?? "no response"}): ${body?.error?.message ?? err.message}`);
|
|
3026
|
+
}
|
|
3027
|
+
return new CaptchaApiError(`Claude API call failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3028
|
+
}
|
|
3029
|
+
async function askVision(client, params) {
|
|
3030
|
+
try {
|
|
3031
|
+
const response = await client.messages.create({ ...params, model: CAPTCHA_MODEL });
|
|
3032
|
+
return response.content.flatMap((b) => b.type === "text" ? [b.text] : []).join("\n").trim();
|
|
3033
|
+
} catch (err) {
|
|
3034
|
+
throw toCaptchaApiError(err);
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
var MAX_CLICKS = 12;
|
|
3038
|
+
function parseChallengeAnswer(text, width, height) {
|
|
3039
|
+
const candidates = text.match(/\{[^{}]*\}/g);
|
|
3040
|
+
if (!candidates) return null;
|
|
3041
|
+
const toPoint = (p) => {
|
|
3042
|
+
if (!Array.isArray(p) || p.length !== 2) return null;
|
|
3043
|
+
const [x, y] = p;
|
|
3044
|
+
if (typeof x !== "number" || typeof y !== "number") return null;
|
|
3045
|
+
return x >= 0 && y >= 0 && x <= width && y <= height ? { x, y } : null;
|
|
3046
|
+
};
|
|
3047
|
+
for (const raw of candidates.reverse()) {
|
|
3048
|
+
let parsed;
|
|
3049
|
+
try {
|
|
3050
|
+
parsed = JSON.parse(raw);
|
|
3051
|
+
} catch {
|
|
3052
|
+
continue;
|
|
3053
|
+
}
|
|
3054
|
+
if (Array.isArray(parsed.drag)) {
|
|
3055
|
+
if (parsed.drag.length !== 2) return null;
|
|
3056
|
+
const from = toPoint(parsed.drag[0]);
|
|
3057
|
+
const to = toPoint(parsed.drag[1]);
|
|
3058
|
+
return from && to ? { drag: { from, to } } : null;
|
|
3059
|
+
}
|
|
3060
|
+
if (Array.isArray(parsed.clicks)) {
|
|
3061
|
+
if (parsed.clicks.length > MAX_CLICKS) return null;
|
|
3062
|
+
const clicks = parsed.clicks.map(toPoint);
|
|
3063
|
+
return clicks.every((c) => c !== null) ? { clicks } : null;
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
return null;
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
// src/lib/captcha/recaptcha.ts
|
|
3070
|
+
var log9 = createLogger("captcha-solver");
|
|
3071
|
+
var MAX_ROUNDS = 8;
|
|
3072
|
+
var MAX_DURATION_MS = 45e3;
|
|
3073
|
+
var TILE_SETTLE_MS = TIMING.TILE_SETTLE;
|
|
2855
3074
|
function extractTarget(prompt) {
|
|
2856
3075
|
const lines = prompt.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
2857
3076
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -2880,7 +3099,7 @@ async function screenshotFullGrid(page, challengeInfo) {
|
|
|
2880
3099
|
const buf = await page.screenshot({ type: "jpeg", quality: 85, clip: gridClip });
|
|
2881
3100
|
return buf.toString("base64");
|
|
2882
3101
|
} catch (err) {
|
|
2883
|
-
|
|
3102
|
+
log9.error(`full grid screenshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2884
3103
|
return null;
|
|
2885
3104
|
}
|
|
2886
3105
|
}
|
|
@@ -2915,40 +3134,33 @@ async function classifyTiles(client, fullGridBase64, tiles, target, rows, cols)
|
|
|
2915
3134
|
tiles.map(async (tile) => {
|
|
2916
3135
|
const tileRow = Math.floor(tile.index / cols) + 1;
|
|
2917
3136
|
const tileCol = tile.index % cols + 1;
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
{
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
{
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
{
|
|
2935
|
-
type: "text",
|
|
2936
|
-
text: `Image 1 is a full picture divided into a ${rows}x${cols} grid. Image 2 is the tile at row ${tileRow}, column ${tileCol} of that grid.
|
|
3137
|
+
const answer = await askVision(client, {
|
|
3138
|
+
max_tokens: 256,
|
|
3139
|
+
messages: [
|
|
3140
|
+
{
|
|
3141
|
+
role: "user",
|
|
3142
|
+
content: [
|
|
3143
|
+
{
|
|
3144
|
+
type: "image",
|
|
3145
|
+
source: { type: "base64", media_type: "image/jpeg", data: fullGridBase64 }
|
|
3146
|
+
},
|
|
3147
|
+
{
|
|
3148
|
+
type: "image",
|
|
3149
|
+
source: { type: "base64", media_type: "image/jpeg", data: tile.imageBase64 }
|
|
3150
|
+
},
|
|
3151
|
+
{
|
|
3152
|
+
type: "text",
|
|
3153
|
+
text: `Image 1 is a full picture divided into a ${rows}x${cols} grid. Image 2 is the tile at row ${tileRow}, column ${tileCol} of that grid.
|
|
2937
3154
|
|
|
2938
3155
|
Does this tile contain a ${target}? Reply ONLY "yes" or "no".`
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
return { index: tile.index, match };
|
|
2948
|
-
} catch (err) {
|
|
2949
|
-
log10.error(`tile ${tile.index} classification failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2950
|
-
return { index: tile.index, match: false };
|
|
2951
|
-
}
|
|
3156
|
+
}
|
|
3157
|
+
]
|
|
3158
|
+
}
|
|
3159
|
+
]
|
|
3160
|
+
});
|
|
3161
|
+
const match = answer.toLowerCase().startsWith("yes");
|
|
3162
|
+
if (match) log9.debug(`tile ${tile.index} (r${tileRow}c${tileCol}): YES`);
|
|
3163
|
+
return { index: tile.index, match };
|
|
2952
3164
|
})
|
|
2953
3165
|
);
|
|
2954
3166
|
return results.filter((r) => r.match).map((r) => r.index);
|
|
@@ -2969,7 +3181,7 @@ async function submitForm(page) {
|
|
|
2969
3181
|
if (el) {
|
|
2970
3182
|
const visible = await el.isVisible();
|
|
2971
3183
|
if (visible) {
|
|
2972
|
-
|
|
3184
|
+
log9.info(`Submitting form via: ${selector}`);
|
|
2973
3185
|
await new Promise((r) => setTimeout(r, 500));
|
|
2974
3186
|
await humanClick(page, selector);
|
|
2975
3187
|
await new Promise((r) => setTimeout(r, 2e3));
|
|
@@ -2979,117 +3191,130 @@ async function submitForm(page) {
|
|
|
2979
3191
|
} catch {
|
|
2980
3192
|
}
|
|
2981
3193
|
}
|
|
2982
|
-
|
|
3194
|
+
log9.info("No submit button found \u2014 skipping form submission");
|
|
2983
3195
|
return false;
|
|
2984
3196
|
}
|
|
2985
3197
|
async function solveRecaptcha(page, monitor) {
|
|
2986
3198
|
const startTime = Date.now();
|
|
2987
|
-
const client =
|
|
3199
|
+
const client = getCaptchaClient();
|
|
2988
3200
|
let rounds = 0;
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3201
|
+
try {
|
|
3202
|
+
const checkboxResult = await clickRecaptchaCheckbox(page);
|
|
3203
|
+
if (checkboxResult.solved) {
|
|
3204
|
+
const submitted = await submitForm(page);
|
|
3205
|
+
return { solved: true, rounds: 0, durationMs: Date.now() - startTime, submitted };
|
|
3206
|
+
}
|
|
3207
|
+
if (!checkboxResult.challengeInfo) {
|
|
3208
|
+
return { solved: false, rounds: 0, durationMs: Date.now() - startTime, reason: "No challenge appeared after clicking checkbox" };
|
|
3209
|
+
}
|
|
3210
|
+
let challengeInfo = checkboxResult.challengeInfo;
|
|
3211
|
+
while (rounds < MAX_ROUNDS) {
|
|
3212
|
+
if (Date.now() - startTime > MAX_DURATION_MS) {
|
|
3213
|
+
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Timeout exceeded" };
|
|
3214
|
+
}
|
|
3215
|
+
rounds++;
|
|
3216
|
+
monitor?.reportActivity();
|
|
3217
|
+
if (!challengeInfo) {
|
|
3218
|
+
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Challenge info lost" };
|
|
3219
|
+
}
|
|
3220
|
+
const target = extractTarget(challengeInfo.prompt);
|
|
3221
|
+
log9.info(`Round ${rounds}: looking for "${target}" in ${challengeInfo.rows}x${challengeInfo.cols} grid`);
|
|
3222
|
+
const [fullGridImage, tileImages] = await Promise.all([
|
|
3223
|
+
screenshotFullGrid(page, challengeInfo),
|
|
3224
|
+
screenshotTiles(page, challengeInfo)
|
|
3225
|
+
]);
|
|
3226
|
+
if (!fullGridImage || tileImages.length === 0) {
|
|
3227
|
+
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Failed to screenshot challenge" };
|
|
3228
|
+
}
|
|
3229
|
+
monitor?.reportActivity();
|
|
3230
|
+
const matchingIndices = await classifyTiles(
|
|
3231
|
+
client,
|
|
3232
|
+
fullGridImage,
|
|
3233
|
+
tileImages,
|
|
3234
|
+
target,
|
|
3235
|
+
challengeInfo.rows,
|
|
3236
|
+
challengeInfo.cols
|
|
3237
|
+
);
|
|
3238
|
+
log9.info(`Round ${rounds}: matched tiles [${matchingIndices.join(", ")}]`);
|
|
3239
|
+
monitor?.reportActivity();
|
|
3240
|
+
if (matchingIndices.length > 0) {
|
|
3241
|
+
await clickChallengeTiles(page, matchingIndices);
|
|
3242
|
+
const isDynamic = challengeInfo.prompt.toLowerCase().includes("none left");
|
|
3243
|
+
if (isDynamic) {
|
|
3244
|
+
await new Promise((r) => setTimeout(r, TILE_SETTLE_MS));
|
|
3245
|
+
const newInfo = await getChallengeInfo(page);
|
|
3246
|
+
if (newInfo) {
|
|
3247
|
+
const [newFullGrid, newTiles] = await Promise.all([
|
|
3248
|
+
screenshotFullGrid(page, newInfo),
|
|
3249
|
+
screenshotTiles(page, newInfo)
|
|
3250
|
+
]);
|
|
3251
|
+
if (newFullGrid && newTiles.length > 0) {
|
|
3252
|
+
const replacedTiles = newTiles.filter((t) => matchingIndices.includes(t.index));
|
|
3253
|
+
if (replacedTiles.length > 0) {
|
|
3254
|
+
monitor?.reportActivity();
|
|
3255
|
+
const newMatches = await classifyTiles(
|
|
3256
|
+
client,
|
|
3257
|
+
newFullGrid,
|
|
3258
|
+
replacedTiles,
|
|
3259
|
+
target,
|
|
3260
|
+
newInfo.rows,
|
|
3261
|
+
newInfo.cols
|
|
3262
|
+
);
|
|
3263
|
+
if (newMatches.length > 0) {
|
|
3264
|
+
log9.info(`Round ${rounds}: dynamic tiles matched [${newMatches.join(", ")}]`);
|
|
3265
|
+
await clickChallengeTiles(page, newMatches);
|
|
3266
|
+
await new Promise((r) => setTimeout(r, TILE_SETTLE_MS));
|
|
3267
|
+
}
|
|
3054
3268
|
}
|
|
3055
3269
|
}
|
|
3056
3270
|
}
|
|
3057
3271
|
}
|
|
3058
3272
|
}
|
|
3273
|
+
const verifyResult = await clickChallengeVerify(page);
|
|
3274
|
+
monitor?.reportActivity();
|
|
3275
|
+
if (verifyResult.solved) {
|
|
3276
|
+
log9.info(`Solved in ${rounds} rounds, ${Date.now() - startTime}ms`);
|
|
3277
|
+
const submitted = await submitForm(page);
|
|
3278
|
+
return { solved: true, rounds, durationMs: Date.now() - startTime, submitted };
|
|
3279
|
+
}
|
|
3280
|
+
challengeInfo = verifyResult.challengeInfo || null;
|
|
3281
|
+
if (!challengeInfo) {
|
|
3282
|
+
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Challenge disappeared after verify" };
|
|
3283
|
+
}
|
|
3284
|
+
log9.info(`Round ${rounds}: not solved, new challenge appeared`);
|
|
3059
3285
|
}
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
if (
|
|
3063
|
-
|
|
3064
|
-
const submitted = await submitForm(page);
|
|
3065
|
-
return { solved: true, rounds, durationMs: Date.now() - startTime, submitted };
|
|
3066
|
-
}
|
|
3067
|
-
challengeInfo = verifyResult.challengeInfo || null;
|
|
3068
|
-
if (!challengeInfo) {
|
|
3069
|
-
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Challenge disappeared after verify" };
|
|
3286
|
+
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: `Max rounds (${MAX_ROUNDS}) exceeded` };
|
|
3287
|
+
} catch (err) {
|
|
3288
|
+
if (err instanceof CaptchaApiError) {
|
|
3289
|
+
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: err.message };
|
|
3070
3290
|
}
|
|
3071
|
-
|
|
3291
|
+
throw err;
|
|
3072
3292
|
}
|
|
3073
|
-
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: `Max rounds (${MAX_ROUNDS}) exceeded` };
|
|
3074
3293
|
}
|
|
3075
3294
|
|
|
3076
3295
|
// src/lib/captcha/hcaptcha.ts
|
|
3077
|
-
var
|
|
3078
|
-
var log11 = createLogger("hcaptcha-solver");
|
|
3296
|
+
var log10 = createLogger("hcaptcha-solver");
|
|
3079
3297
|
var MAX_ROUNDS2 = 8;
|
|
3080
3298
|
var MAX_DURATION_MS2 = 6e4;
|
|
3081
|
-
var
|
|
3082
|
-
function getClient2() {
|
|
3083
|
-
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
3084
|
-
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not set \u2014 required for captcha auto-solve");
|
|
3085
|
-
return new import_sdk2.default({ apiKey });
|
|
3086
|
-
}
|
|
3299
|
+
var CHALLENGE_FRAME = 'iframe[title="hCaptcha challenge"], iframe[title*="hcaptcha challenge" i]';
|
|
3087
3300
|
function rand2(min, max) {
|
|
3088
3301
|
return Math.random() * (max - min) + min;
|
|
3089
3302
|
}
|
|
3090
3303
|
function sleep4(ms) {
|
|
3091
3304
|
return new Promise((r) => setTimeout(r, ms));
|
|
3092
3305
|
}
|
|
3306
|
+
async function hasToken(page, waitMs = 0) {
|
|
3307
|
+
const deadline = Date.now() + waitMs;
|
|
3308
|
+
for (; ; ) {
|
|
3309
|
+
const found = await page.evaluate(
|
|
3310
|
+
() => Array.from(document.querySelectorAll('[name="h-captcha-response"]')).some(
|
|
3311
|
+
(el) => el.value.length > 0
|
|
3312
|
+
)
|
|
3313
|
+
).catch(() => false);
|
|
3314
|
+
if (found || Date.now() >= deadline) return found;
|
|
3315
|
+
await sleep4(250);
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3093
3318
|
async function clickCheckbox(page) {
|
|
3094
3319
|
const checkboxFrame = await page.waitForSelector(
|
|
3095
3320
|
'iframe[src*="hcaptcha.com"], iframe[data-hcaptcha-widget-id], iframe[title*="hCaptcha"]',
|
|
@@ -3104,187 +3329,119 @@ async function clickCheckbox(page) {
|
|
|
3104
3329
|
const cy = box.y + box.height * rand2(0.3, 0.7);
|
|
3105
3330
|
await humanClickXY(page, cx, cy);
|
|
3106
3331
|
await sleep4(2500);
|
|
3107
|
-
|
|
3108
|
-
if (frame) {
|
|
3109
|
-
try {
|
|
3110
|
-
const checked = await frame.evaluate(() => {
|
|
3111
|
-
const cb = document.querySelector("#checkbox");
|
|
3112
|
-
return cb?.getAttribute("aria-checked") === "true";
|
|
3113
|
-
});
|
|
3114
|
-
if (checked) return true;
|
|
3115
|
-
} catch {
|
|
3116
|
-
}
|
|
3117
|
-
}
|
|
3118
|
-
return false;
|
|
3332
|
+
return hasToken(page);
|
|
3119
3333
|
}
|
|
3120
|
-
async function
|
|
3121
|
-
const challengeFrame = await page.waitForSelector(
|
|
3122
|
-
'iframe[title="hCaptcha challenge"], iframe[title*="hcaptcha challenge" i]',
|
|
3123
|
-
{ timeout: 8e3 }
|
|
3124
|
-
).catch(() => null);
|
|
3334
|
+
async function getChallenge(page) {
|
|
3335
|
+
const challengeFrame = await page.waitForSelector(CHALLENGE_FRAME, { timeout: 8e3 }).catch(() => null);
|
|
3125
3336
|
if (!challengeFrame) return null;
|
|
3126
|
-
const frameBox = await challengeFrame.boundingBox();
|
|
3127
|
-
if (!frameBox) return null;
|
|
3128
3337
|
const frame = await challengeFrame.contentFrame();
|
|
3129
3338
|
if (!frame) return null;
|
|
3130
|
-
const
|
|
3131
|
-
const
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
return
|
|
3176
|
-
}).catch(() => null);
|
|
3177
|
-
const verifyButton = verifyBtnBox ? { x: Math.round(frameBox.x + verifyBtnBox.x), y: Math.round(frameBox.y + verifyBtnBox.y) } : { x: Math.round(frameBox.x + frameBox.width - 55), y: Math.round(frameBox.y + frameBox.height - 30) };
|
|
3178
|
-
log11.info(`Round challenge: "${info.prompt}" (${info.rows}x${info.cols})`);
|
|
3179
|
-
return { prompt: info.prompt, rows: info.rows, cols: info.cols, tiles, verifyButton, frameBox };
|
|
3180
|
-
}
|
|
3181
|
-
async function screenshotChallenge(page, challenge) {
|
|
3182
|
-
const { frameBox } = challenge;
|
|
3183
|
-
try {
|
|
3184
|
-
await sleep4(1e3);
|
|
3185
|
-
const challengeEl = await page.$('iframe[title="hCaptcha challenge"], iframe[title*="hcaptcha challenge" i]').catch(() => null);
|
|
3186
|
-
let buf;
|
|
3187
|
-
if (challengeEl) {
|
|
3188
|
-
buf = Buffer.from(await challengeEl.screenshot({ type: "jpeg", quality: 85 }));
|
|
3189
|
-
} else {
|
|
3190
|
-
buf = await page.screenshot({ type: "jpeg", quality: 85, clip: frameBox });
|
|
3191
|
-
}
|
|
3192
|
-
return buf.toString("base64");
|
|
3193
|
-
} catch (err) {
|
|
3194
|
-
log11.error(`screenshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3195
|
-
return null;
|
|
3339
|
+
const prompt = await frame.evaluate(() => {
|
|
3340
|
+
const el = document.querySelector(".prompt-text, .task-instructions, [class*='prompt'], [class*='task-description']");
|
|
3341
|
+
return el ? el.innerText.trim() : "";
|
|
3342
|
+
}).catch(() => "");
|
|
3343
|
+
if (!prompt) return null;
|
|
3344
|
+
const target = await frame.$("canvas").catch(() => null) ?? challengeFrame;
|
|
3345
|
+
const box = await target.boundingBox();
|
|
3346
|
+
if (!box) return null;
|
|
3347
|
+
log10.info(`Round challenge: "${prompt}" (${Math.round(box.width)}x${Math.round(box.height)})`);
|
|
3348
|
+
return { prompt, target, box, frame };
|
|
3349
|
+
}
|
|
3350
|
+
async function askForAnswer(client, challenge) {
|
|
3351
|
+
const buf = await challenge.target.screenshot({ type: "jpeg", quality: 85, scale: "css" });
|
|
3352
|
+
const width = Math.round(challenge.box.width);
|
|
3353
|
+
const height = Math.round(challenge.box.height);
|
|
3354
|
+
const text = await askVision(client, {
|
|
3355
|
+
max_tokens: 1024,
|
|
3356
|
+
messages: [{
|
|
3357
|
+
role: "user",
|
|
3358
|
+
content: [
|
|
3359
|
+
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: Buffer.from(buf).toString("base64") } },
|
|
3360
|
+
{
|
|
3361
|
+
type: "text",
|
|
3362
|
+
text: `This image is ${width}x${height} pixels. The task shown with it: "${challenge.prompt}".
|
|
3363
|
+
Work out the answer, then end your reply with one line of JSON, using pixel coordinates in this image.
|
|
3364
|
+
To click: {"clicks":[[x,y],[x,y]]}
|
|
3365
|
+
To drag: {"drag":[[fromX,fromY],[toX,toY]]}
|
|
3366
|
+
If you can't tell: {"clicks":[]}`
|
|
3367
|
+
}
|
|
3368
|
+
]
|
|
3369
|
+
}]
|
|
3370
|
+
});
|
|
3371
|
+
const answer = parseChallengeAnswer(text, width, height);
|
|
3372
|
+
if (!answer) log10.warn(`no usable answer in model reply: "${text.slice(0, 120)}"`);
|
|
3373
|
+
return answer;
|
|
3374
|
+
}
|
|
3375
|
+
async function perform(page, challenge, answer) {
|
|
3376
|
+
const { x, y } = challenge.box;
|
|
3377
|
+
if ("drag" in answer) {
|
|
3378
|
+
await humanMove(page, x + answer.drag.from.x, y + answer.drag.from.y);
|
|
3379
|
+
await sleep4(rand2(100, 250));
|
|
3380
|
+
await page.mouse.down();
|
|
3381
|
+
await humanMove(page, x + answer.drag.to.x, y + answer.drag.to.y);
|
|
3382
|
+
await sleep4(rand2(100, 250));
|
|
3383
|
+
await page.mouse.up();
|
|
3384
|
+
return;
|
|
3196
3385
|
}
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
const total = rows * cols;
|
|
3201
|
-
try {
|
|
3202
|
-
const response = await client.messages.create({
|
|
3203
|
-
model: MODEL2,
|
|
3204
|
-
max_tokens: 30,
|
|
3205
|
-
system: `Output ONLY a comma-separated list of tile numbers or "none". Valid examples: "0,3,5" or "2" or "none". Output nothing else.`,
|
|
3206
|
-
messages: [{
|
|
3207
|
-
role: "user",
|
|
3208
|
-
content: [
|
|
3209
|
-
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: screenshotBase64 } },
|
|
3210
|
-
{ type: "text", text: `Task: "${prompt}". Tiles 0-${total - 1} left-to-right top-to-bottom in a ${rows}\xD7${cols} grid. Which tiles match? Reply ONLY with numbers or "none".` }
|
|
3211
|
-
]
|
|
3212
|
-
}]
|
|
3213
|
-
});
|
|
3214
|
-
const text = (response.content[0].text ?? "").trim();
|
|
3215
|
-
log11.debug(`classify response: "${text}"`);
|
|
3216
|
-
if (text.toLowerCase().startsWith("none")) return [];
|
|
3217
|
-
return text.split(/[,\s]+/).map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n) && n >= 0 && n < total);
|
|
3218
|
-
} catch (err) {
|
|
3219
|
-
log11.error(`classification failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3220
|
-
return [];
|
|
3386
|
+
for (const click2 of answer.clicks) {
|
|
3387
|
+
await humanClickXY(page, x + click2.x, y + click2.y);
|
|
3388
|
+
await sleep4(rand2(150, 400));
|
|
3221
3389
|
}
|
|
3222
3390
|
}
|
|
3223
|
-
async function
|
|
3224
|
-
const
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
}
|
|
3229
|
-
async function clickVerify(page, challenge) {
|
|
3230
|
-
await humanClickXY(page, challenge.verifyButton.x, challenge.verifyButton.y);
|
|
3391
|
+
async function clickSubmit(page, challenge) {
|
|
3392
|
+
const button = await challenge.frame.$(".button-submit").catch(() => null);
|
|
3393
|
+
const box = button ? await button.boundingBox() : null;
|
|
3394
|
+
if (!box) throw new Error("hCaptcha submit button not found");
|
|
3395
|
+
await humanClickXY(page, box.x + box.width / 2, box.y + box.height / 2);
|
|
3231
3396
|
await sleep4(2500);
|
|
3232
|
-
return isSolved(page);
|
|
3233
3397
|
}
|
|
3234
3398
|
async function solveHCaptcha(page, monitor) {
|
|
3235
3399
|
const startTime = Date.now();
|
|
3236
|
-
const client =
|
|
3400
|
+
const client = getCaptchaClient();
|
|
3401
|
+
const result = (solved, reason) => ({
|
|
3402
|
+
solved,
|
|
3403
|
+
rounds,
|
|
3404
|
+
durationMs: Date.now() - startTime,
|
|
3405
|
+
...reason ? { reason } : {}
|
|
3406
|
+
});
|
|
3237
3407
|
let rounds = 0;
|
|
3238
|
-
let solvedOnCheckbox = false;
|
|
3239
3408
|
try {
|
|
3240
|
-
|
|
3409
|
+
if (await clickCheckbox(page)) {
|
|
3410
|
+
log10.info("Solved on checkbox click (no challenge)");
|
|
3411
|
+
return result(true);
|
|
3412
|
+
}
|
|
3413
|
+
while (rounds < MAX_ROUNDS2) {
|
|
3414
|
+
if (Date.now() - startTime > MAX_DURATION_MS2) return result(false, "Timeout exceeded");
|
|
3415
|
+
rounds++;
|
|
3416
|
+
monitor?.reportActivity();
|
|
3417
|
+
const challenge = await getChallenge(page);
|
|
3418
|
+
if (!challenge) {
|
|
3419
|
+
if (await hasToken(page, 3e3)) return result(true);
|
|
3420
|
+
return result(false, "The challenge closed without a token");
|
|
3421
|
+
}
|
|
3422
|
+
await sleep4(1e3);
|
|
3423
|
+
const answer = await askForAnswer(client, challenge);
|
|
3424
|
+
monitor?.reportActivity();
|
|
3425
|
+
log10.info(`Round ${rounds}: ${answer ? JSON.stringify(answer) : "no answer"}`);
|
|
3426
|
+
if (answer) await perform(page, challenge, answer);
|
|
3427
|
+
await sleep4(rand2(500, 1e3));
|
|
3428
|
+
await clickSubmit(page, challenge);
|
|
3429
|
+
monitor?.reportActivity();
|
|
3430
|
+
if (await hasToken(page)) {
|
|
3431
|
+
log10.info(`Solved in ${rounds} rounds, ${Date.now() - startTime}ms`);
|
|
3432
|
+
return result(true);
|
|
3433
|
+
}
|
|
3434
|
+
log10.info(`Round ${rounds}: not solved, retrying`);
|
|
3435
|
+
}
|
|
3436
|
+
return result(false, `Max rounds (${MAX_ROUNDS2}) exceeded`);
|
|
3241
3437
|
} catch (err) {
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
log11.info("Solved on checkbox click (no challenge)");
|
|
3246
|
-
return { solved: true, rounds: 0, durationMs: Date.now() - startTime };
|
|
3247
|
-
}
|
|
3248
|
-
while (rounds < MAX_ROUNDS2) {
|
|
3249
|
-
if (Date.now() - startTime > MAX_DURATION_MS2) {
|
|
3250
|
-
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Timeout exceeded" };
|
|
3251
|
-
}
|
|
3252
|
-
rounds++;
|
|
3253
|
-
monitor?.reportActivity();
|
|
3254
|
-
const challenge = await getChallengeInfo2(page);
|
|
3255
|
-
if (!challenge) {
|
|
3256
|
-
log11.info("Challenge frame gone \u2014 assuming solved");
|
|
3257
|
-
return { solved: true, rounds, durationMs: Date.now() - startTime };
|
|
3258
|
-
}
|
|
3259
|
-
const screenshotBase64 = await screenshotChallenge(page, challenge);
|
|
3260
|
-
if (!screenshotBase64) {
|
|
3261
|
-
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Failed to screenshot challenge" };
|
|
3262
|
-
}
|
|
3263
|
-
monitor?.reportActivity();
|
|
3264
|
-
const matchingIndices = await classifyTiles2(client, screenshotBase64, challenge);
|
|
3265
|
-
log11.info(`Round ${rounds}: clicking tiles [${matchingIndices.join(", ")}]`);
|
|
3266
|
-
monitor?.reportActivity();
|
|
3267
|
-
for (const idx of matchingIndices) {
|
|
3268
|
-
const tile = challenge.tiles[idx];
|
|
3269
|
-
if (!tile) continue;
|
|
3270
|
-
await humanClickXY(page, tile.centerX + rand2(-5, 5), tile.centerY + rand2(-5, 5));
|
|
3271
|
-
await sleep4(rand2(150, 400));
|
|
3272
|
-
}
|
|
3273
|
-
await sleep4(rand2(500, 1e3));
|
|
3274
|
-
const solved = await clickVerify(page, challenge);
|
|
3275
|
-
monitor?.reportActivity();
|
|
3276
|
-
if (solved) {
|
|
3277
|
-
log11.info(`Solved in ${rounds} rounds, ${Date.now() - startTime}ms`);
|
|
3278
|
-
return { solved: true, rounds, durationMs: Date.now() - startTime };
|
|
3279
|
-
}
|
|
3280
|
-
log11.info(`Round ${rounds}: not solved, retrying`);
|
|
3281
|
-
await sleep4(rand2(500, 1e3));
|
|
3282
|
-
}
|
|
3283
|
-
return { solved: false, rounds, durationMs: Date.now() - startTime, reason: `Max rounds (${MAX_ROUNDS2}) exceeded` };
|
|
3438
|
+
if (err instanceof CaptchaApiError) return result(false, err.message);
|
|
3439
|
+
return result(false, err instanceof Error ? err.message : String(err));
|
|
3440
|
+
}
|
|
3284
3441
|
}
|
|
3285
3442
|
|
|
3286
3443
|
// src/lib/actions/handlers/captcha.ts
|
|
3287
|
-
var
|
|
3444
|
+
var log11 = createLogger("actions");
|
|
3288
3445
|
async function screenshotTiles2(page, ci) {
|
|
3289
3446
|
const tileSize = ci.bframeBox ? Math.round((ci.bframeBox.width - CAPTCHA_GRID.GRID_PADDING) / ci.cols) : CAPTCHA_GRID.DEFAULT_TILE_SIZE;
|
|
3290
3447
|
const tiles = [];
|
|
@@ -3336,7 +3493,15 @@ async function recaptchaAnswer(page, step) {
|
|
|
3336
3493
|
return { solved: false, tiles: [] };
|
|
3337
3494
|
}
|
|
3338
3495
|
async function solveCaptcha(page, _step, _ctx, monitor) {
|
|
3496
|
+
const startTime = Date.now();
|
|
3339
3497
|
await page.waitForTimeout(TIMING.CAPTCHA_DETECT_WAIT);
|
|
3498
|
+
const widget = await page.waitForSelector(
|
|
3499
|
+
'iframe[src*="recaptcha/api2/anchor"], iframe[src*="recaptcha/enterprise/anchor"], iframe[title*="reCAPTCHA"], iframe[src*="hcaptcha.com"], iframe[title*="hCaptcha"]',
|
|
3500
|
+
{ timeout: 5e3 }
|
|
3501
|
+
).catch(() => null);
|
|
3502
|
+
if (!widget) {
|
|
3503
|
+
return { solved: false, noCaptcha: true, rounds: 0, durationMs: Date.now() - startTime, reason: "No captcha on this page" };
|
|
3504
|
+
}
|
|
3340
3505
|
const isHCaptcha = await page.evaluate(() => {
|
|
3341
3506
|
const iframes = Array.from(document.querySelectorAll("iframe"));
|
|
3342
3507
|
return iframes.some((f) => {
|
|
@@ -3345,11 +3510,15 @@ async function solveCaptcha(page, _step, _ctx, monitor) {
|
|
|
3345
3510
|
return src.includes("hcaptcha.com") || title.includes("hcaptcha") || !!document.querySelector("[data-hcaptcha-widget-id]");
|
|
3346
3511
|
});
|
|
3347
3512
|
}).catch((err) => {
|
|
3348
|
-
|
|
3513
|
+
log11.warn(`captcha detection failed: ${err}`);
|
|
3349
3514
|
return false;
|
|
3350
3515
|
});
|
|
3351
|
-
|
|
3352
|
-
|
|
3516
|
+
log11.info(`detected: ${isHCaptcha ? "hCaptcha" : "reCAPTCHA"}`);
|
|
3517
|
+
const result = isHCaptcha ? await solveHCaptcha(page, monitor) : await solveRecaptcha(page, monitor);
|
|
3518
|
+
if (!result.solved) {
|
|
3519
|
+
throw new Error(`Captcha not solved after ${result.rounds} round(s): ${result.reason ?? "unknown reason"}`);
|
|
3520
|
+
}
|
|
3521
|
+
return result;
|
|
3353
3522
|
}
|
|
3354
3523
|
|
|
3355
3524
|
// src/lib/browser/form-fill.ts
|
|
@@ -3407,10 +3576,10 @@ async function findSubmitButton(page, opts) {
|
|
|
3407
3576
|
}
|
|
3408
3577
|
|
|
3409
3578
|
// src/lib/knowledge.ts
|
|
3410
|
-
var
|
|
3579
|
+
var import_fs7 = __toESM(require("fs"));
|
|
3411
3580
|
var import_path8 = __toESM(require("path"));
|
|
3412
3581
|
init_paths();
|
|
3413
|
-
var
|
|
3582
|
+
var log12 = createLogger("knowledge");
|
|
3414
3583
|
function getKnowledgeDir() {
|
|
3415
3584
|
return import_path8.default.join(getDataDir(), "knowledge");
|
|
3416
3585
|
}
|
|
@@ -3449,12 +3618,12 @@ function sanitizeDomain(input) {
|
|
|
3449
3618
|
return normalized.replace(/[^a-z0-9.-]/g, "_");
|
|
3450
3619
|
}
|
|
3451
3620
|
function ensureDir() {
|
|
3452
|
-
|
|
3621
|
+
import_fs7.default.mkdirSync(getKnowledgeDir(), { recursive: true });
|
|
3453
3622
|
}
|
|
3454
3623
|
function readKnowledge(domain) {
|
|
3455
3624
|
const p = getKnowledgePath(domain);
|
|
3456
3625
|
try {
|
|
3457
|
-
return
|
|
3626
|
+
return import_fs7.default.readFileSync(p, "utf8");
|
|
3458
3627
|
} catch {
|
|
3459
3628
|
return null;
|
|
3460
3629
|
}
|
|
@@ -3477,8 +3646,8 @@ function mergeKnowledge(domain, updates) {
|
|
|
3477
3646
|
notes: dedupeNotes([...existing?.notes ?? [], ...updates.notes ?? []])
|
|
3478
3647
|
};
|
|
3479
3648
|
const md = renderMarkdown(merged);
|
|
3480
|
-
|
|
3481
|
-
|
|
3649
|
+
import_fs7.default.writeFileSync(getKnowledgePath(domain), md, "utf8");
|
|
3650
|
+
log12.info(`knowledge updated: ${merged.domain} (${merged.endpoints.length} endpoints)`);
|
|
3482
3651
|
}
|
|
3483
3652
|
function dedupeEndpoints(endpoints) {
|
|
3484
3653
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -3696,7 +3865,7 @@ var EMAIL_FORM_ANCHOR = 'input[type="email"], input[name*="email" i], input[type
|
|
|
3696
3865
|
var PASSWORD_FORM_ANCHOR = PASSWORD_SELECTOR;
|
|
3697
3866
|
|
|
3698
3867
|
// src/lib/actions/handlers/login/index.ts
|
|
3699
|
-
var
|
|
3868
|
+
var log13 = createLogger("actions");
|
|
3700
3869
|
async function login(page, step, ctx) {
|
|
3701
3870
|
const resolved = await resolveCredential(ctx.store, ctx.userId, ctx.token, step.domain);
|
|
3702
3871
|
if (!resolved) {
|
|
@@ -3708,7 +3877,7 @@ async function login(page, step, ctx) {
|
|
|
3708
3877
|
}
|
|
3709
3878
|
const { credential, matchedDomain } = resolved;
|
|
3710
3879
|
if (matchedDomain !== normalizeDomain(step.domain)) {
|
|
3711
|
-
|
|
3880
|
+
log13.info(`login: credentials for ${step.domain} resolved via parent domain ${matchedDomain}`);
|
|
3712
3881
|
}
|
|
3713
3882
|
const beforeUrl = page.url();
|
|
3714
3883
|
const hasExplicitSelectors = !!(step.usernameSelector || step.passwordSelector || step.submitSelector);
|
|
@@ -3741,7 +3910,7 @@ async function runExplicitFlow(page, step, ctx, credential) {
|
|
|
3741
3910
|
}
|
|
3742
3911
|
}
|
|
3743
3912
|
async function runAutoDetect(page, step, ctx, credential, beforeUrl) {
|
|
3744
|
-
|
|
3913
|
+
log13.info(`login: auto-detecting form on ${beforeUrl}`);
|
|
3745
3914
|
await page.waitForLoadState("domcontentloaded").catch(() => {
|
|
3746
3915
|
});
|
|
3747
3916
|
await page.waitForTimeout(500);
|
|
@@ -3751,7 +3920,7 @@ async function runAutoDetect(page, step, ctx, credential, beforeUrl) {
|
|
|
3751
3920
|
return { url: location.href, title: document.title, pwdVisible };
|
|
3752
3921
|
}, PASSWORD_SELECTOR).catch(() => ({ url: page.url(), title: "", pwdVisible: false }));
|
|
3753
3922
|
if (!initialCheck.pwdVisible && !LOGIN_URL_RE.test(initialCheck.url)) {
|
|
3754
|
-
|
|
3923
|
+
log13.info(`login: already logged in (no password field, URL=${initialCheck.url})`);
|
|
3755
3924
|
return {
|
|
3756
3925
|
loggedIn: true,
|
|
3757
3926
|
alreadyLoggedIn: true,
|
|
@@ -3771,7 +3940,7 @@ async function runAutoDetect(page, step, ctx, credential, beforeUrl) {
|
|
|
3771
3940
|
async function runNoPasswordBranch(page, step, ctx, credential, beforeUrl) {
|
|
3772
3941
|
const currentUrl = page.url();
|
|
3773
3942
|
if (!LOGIN_URL_RE.test(currentUrl)) {
|
|
3774
|
-
|
|
3943
|
+
log13.info(`login: no password field and URL left login area (${currentUrl}) \u2014 treating as success`);
|
|
3775
3944
|
return {
|
|
3776
3945
|
loggedIn: true,
|
|
3777
3946
|
alreadyLoggedIn: true,
|
|
@@ -3798,7 +3967,7 @@ async function runNoPasswordBranch(page, step, ctx, credential, beforeUrl) {
|
|
|
3798
3967
|
const hasCloudflare = !!document.querySelector('[class*="cf-" i], iframe[src*="challenges.cloudflare"]');
|
|
3799
3968
|
return { title: document.title, visibleText, inputCount, hiddenPassword, hasCaptcha, hasCloudflare };
|
|
3800
3969
|
}).catch(() => ({ title: "", visibleText: "", inputCount: 0, hiddenPassword: false, hasCaptcha: false, hasCloudflare: false }));
|
|
3801
|
-
|
|
3970
|
+
log13.warn(`login: no visible password or email field on ${currentUrl} \u2014 title="${pageDiag.title}", inputs=${pageDiag.inputCount}`);
|
|
3802
3971
|
const indicators = [];
|
|
3803
3972
|
if (pageDiag.hasCaptcha) indicators.push("CAPTCHA detected");
|
|
3804
3973
|
if (pageDiag.hasCloudflare) indicators.push("Cloudflare challenge");
|
|
@@ -3806,12 +3975,12 @@ async function runNoPasswordBranch(page, step, ctx, credential, beforeUrl) {
|
|
|
3806
3975
|
if (pageDiag.hiddenPassword) indicators.push("password field exists but is hidden/disabled");
|
|
3807
3976
|
const indicatorStr = indicators.length > 0 ? ` (${indicators.join(", ")})` : "";
|
|
3808
3977
|
throw new Error(
|
|
3809
|
-
`login: no visible password or email field on ${currentUrl} after 5000ms${indicatorStr}. Page title: "${pageDiag.title}". The site may be showing a bot-detection wall, captcha, or an unsupported login flow. Retry with a stronger browser mode (binary-headful
|
|
3978
|
+
`login: no visible password or email field on ${currentUrl} after 5000ms${indicatorStr}. Page title: "${pageDiag.title}". The site may be showing a bot-detection wall, captcha, or an unsupported login flow. Retry with a stronger browser mode (binary-headful).`
|
|
3810
3979
|
);
|
|
3811
3980
|
}
|
|
3812
3981
|
async function runEmailFirstFlow(page, credential, beforeUrl, emailEl) {
|
|
3813
3982
|
const currentUrl = page.url();
|
|
3814
|
-
|
|
3983
|
+
log13.info(`login: no password field but found email input \u2014 running email-first flow on ${currentUrl}`);
|
|
3815
3984
|
await fillHandleNative(page, emailEl, credential.username);
|
|
3816
3985
|
const submitEl = await findSubmitButton(page, {
|
|
3817
3986
|
formAnchor: EMAIL_FORM_ANCHOR,
|
|
@@ -3825,7 +3994,7 @@ async function runEmailFirstFlow(page, credential, beforeUrl, emailEl) {
|
|
|
3825
3994
|
await submitEl.evaluate((el) => el.click());
|
|
3826
3995
|
});
|
|
3827
3996
|
} else {
|
|
3828
|
-
|
|
3997
|
+
log13.warn("login: email-first flow, no submit button found \u2014 pressing Enter");
|
|
3829
3998
|
await emailEl.press("Enter").catch(() => {
|
|
3830
3999
|
});
|
|
3831
4000
|
}
|
|
@@ -3839,7 +4008,7 @@ async function runEmailFirstFlow(page, credential, beforeUrl, emailEl) {
|
|
|
3839
4008
|
]);
|
|
3840
4009
|
const laterPasswordHandle = await page.$(PASSWORD_SELECTOR);
|
|
3841
4010
|
if (laterPasswordHandle && credential.password) {
|
|
3842
|
-
|
|
4011
|
+
log13.info("login: password field appeared after email submit \u2014 filling it");
|
|
3843
4012
|
await fillHandleNative(page, laterPasswordHandle, credential.password);
|
|
3844
4013
|
const laterSubmit = await page.$('button[type="submit"]:not([disabled])');
|
|
3845
4014
|
if (laterSubmit) {
|
|
@@ -3878,7 +4047,7 @@ async function runPasswordFlow(page, credential, beforeUrl, passwordHandle) {
|
|
|
3878
4047
|
if (usernameEl && credential.username) {
|
|
3879
4048
|
await fillHandleNative(page, usernameEl, credential.username);
|
|
3880
4049
|
} else if (!usernameEl) {
|
|
3881
|
-
|
|
4050
|
+
log13.warn("login: no username field detected, proceeding with password only");
|
|
3882
4051
|
}
|
|
3883
4052
|
if (credential.password) {
|
|
3884
4053
|
await fillHandleNative(page, passwordHandle, credential.password);
|
|
@@ -3895,7 +4064,7 @@ async function runPasswordFlow(page, credential, beforeUrl, passwordHandle) {
|
|
|
3895
4064
|
await submitEl.evaluate((el) => el.click());
|
|
3896
4065
|
});
|
|
3897
4066
|
} else {
|
|
3898
|
-
|
|
4067
|
+
log13.warn("login: no submit button detected, pressing Enter in password field");
|
|
3899
4068
|
await passwordHandle.press("Enter").catch(() => {
|
|
3900
4069
|
});
|
|
3901
4070
|
}
|
|
@@ -3913,13 +4082,13 @@ async function handleOtp(page, step, ctx, credential, beforeUrl) {
|
|
|
3913
4082
|
let code = null;
|
|
3914
4083
|
if (credential.totp_secret) {
|
|
3915
4084
|
code = generateTOTP(credential.totp_secret);
|
|
3916
|
-
|
|
4085
|
+
log13.info(`login: generated TOTP from stored secret for ${step.domain}`);
|
|
3917
4086
|
} else if (ctx.elicitOtp) {
|
|
3918
|
-
|
|
4087
|
+
log13.info(`login: prompting user for OTP for ${step.domain}`);
|
|
3919
4088
|
try {
|
|
3920
4089
|
code = await ctx.elicitOtp(step.domain);
|
|
3921
4090
|
} catch (err) {
|
|
3922
|
-
|
|
4091
|
+
log13.warn(`login: OTP elicitation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3923
4092
|
}
|
|
3924
4093
|
if (!code) {
|
|
3925
4094
|
throw new Error(`login: OTP required but user did not provide one for ${step.domain}`);
|
|
@@ -3993,6 +4162,8 @@ async function executeAction(page, step, ctx, monitor) {
|
|
|
3993
4162
|
|
|
3994
4163
|
// src/lib/stale-monitor.ts
|
|
3995
4164
|
var DEFAULT_STALE_TIMEOUT_MS = 2e4;
|
|
4165
|
+
var PROBE_BUDGET_MS = 3e3;
|
|
4166
|
+
var UNRESPONSIVE_GRACE_MS = 8e3;
|
|
3996
4167
|
var StaleStateMonitor = class _StaleStateMonitor {
|
|
3997
4168
|
page;
|
|
3998
4169
|
timeoutMs;
|
|
@@ -4004,21 +4175,24 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4004
4175
|
}
|
|
4005
4176
|
async snapshot() {
|
|
4006
4177
|
try {
|
|
4007
|
-
const
|
|
4008
|
-
|
|
4178
|
+
const url = this.page.url();
|
|
4179
|
+
let unresponsive = false;
|
|
4180
|
+
const state = await withBudget(
|
|
4009
4181
|
this.page.evaluate(() => ({
|
|
4010
4182
|
documentReadyState: document.readyState,
|
|
4011
4183
|
bodyTextLength: document.body?.innerText?.length || 0,
|
|
4012
4184
|
elementCount: document.querySelectorAll("*").length
|
|
4013
|
-
}))
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4185
|
+
})),
|
|
4186
|
+
PROBE_BUDGET_MS,
|
|
4187
|
+
"state probe"
|
|
4188
|
+
).catch((e) => {
|
|
4189
|
+
unresponsive = e instanceof BudgetExceededError;
|
|
4190
|
+
return { documentReadyState: "unknown", bodyTextLength: 0, elementCount: 0 };
|
|
4191
|
+
});
|
|
4019
4192
|
return {
|
|
4020
4193
|
url,
|
|
4021
4194
|
...state,
|
|
4195
|
+
unresponsive,
|
|
4022
4196
|
timestamp: Date.now()
|
|
4023
4197
|
};
|
|
4024
4198
|
} catch {
|
|
@@ -4062,6 +4236,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4062
4236
|
return new Promise((resolve, reject) => {
|
|
4063
4237
|
let resolved = false;
|
|
4064
4238
|
let checkInterval = null;
|
|
4239
|
+
let probing = false;
|
|
4240
|
+
let unresponsiveSince = null;
|
|
4065
4241
|
const cleanup = () => {
|
|
4066
4242
|
resolved = true;
|
|
4067
4243
|
if (checkInterval) clearInterval(checkInterval);
|
|
@@ -4069,11 +4245,25 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4069
4245
|
this.timer = null;
|
|
4070
4246
|
};
|
|
4071
4247
|
checkInterval = setInterval(async () => {
|
|
4072
|
-
if (resolved) return;
|
|
4248
|
+
if (resolved || probing) return;
|
|
4073
4249
|
const elapsed = Date.now() - this.lastActivity;
|
|
4074
4250
|
if (elapsed < this.timeoutMs) return;
|
|
4251
|
+
probing = true;
|
|
4075
4252
|
try {
|
|
4076
4253
|
const currentSnapshot = await this.snapshot();
|
|
4254
|
+
if (resolved) return;
|
|
4255
|
+
if (currentSnapshot.unresponsive) {
|
|
4256
|
+
unresponsiveSince ??= Date.now();
|
|
4257
|
+
if (Date.now() - unresponsiveSince < UNRESPONSIVE_GRACE_MS) return;
|
|
4258
|
+
cleanup();
|
|
4259
|
+
reject(new StaleStateError(
|
|
4260
|
+
`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)`,
|
|
4261
|
+
this.timeoutMs,
|
|
4262
|
+
true
|
|
4263
|
+
));
|
|
4264
|
+
return;
|
|
4265
|
+
}
|
|
4266
|
+
unresponsiveSince = null;
|
|
4077
4267
|
if (_StaleStateMonitor.hasChanged(beforeSnapshot, currentSnapshot)) {
|
|
4078
4268
|
this.lastActivity = Date.now();
|
|
4079
4269
|
return;
|
|
@@ -4081,6 +4271,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4081
4271
|
} catch {
|
|
4082
4272
|
this.lastActivity = Date.now();
|
|
4083
4273
|
return;
|
|
4274
|
+
} finally {
|
|
4275
|
+
probing = false;
|
|
4084
4276
|
}
|
|
4085
4277
|
cleanup();
|
|
4086
4278
|
reject(new StaleStateError(
|
|
@@ -4104,10 +4296,13 @@ var StaleStateMonitor = class _StaleStateMonitor {
|
|
|
4104
4296
|
};
|
|
4105
4297
|
var StaleStateError = class extends Error {
|
|
4106
4298
|
timeoutMs;
|
|
4107
|
-
|
|
4299
|
+
/** True when the page stopped answering at all (vs. answering but not changing). */
|
|
4300
|
+
unresponsive;
|
|
4301
|
+
constructor(message, timeoutMs, unresponsive = false) {
|
|
4108
4302
|
super(message);
|
|
4109
4303
|
this.name = "StaleStateError";
|
|
4110
4304
|
this.timeoutMs = timeoutMs;
|
|
4305
|
+
this.unresponsive = unresponsive;
|
|
4111
4306
|
}
|
|
4112
4307
|
};
|
|
4113
4308
|
|
|
@@ -4119,7 +4314,7 @@ var RecaptchaDetector = class {
|
|
|
4119
4314
|
() => [
|
|
4120
4315
|
'iframe[src*="recaptcha/api2/anchor"], iframe[title*="reCAPTCHA"]',
|
|
4121
4316
|
'iframe[src*="recaptcha/api2/bframe"]',
|
|
4122
|
-
'.g-recaptcha:not([data-size="invisible"]), [data-sitekey]:not([data-size="invisible"])'
|
|
4317
|
+
'.g-recaptcha:not([data-size="invisible"]), [data-sitekey]:not([data-size="invisible"]):not(.h-captcha):not(.cf-turnstile)'
|
|
4123
4318
|
].some((sel) => {
|
|
4124
4319
|
const el = document.querySelector(sel);
|
|
4125
4320
|
if (!el) return false;
|
|
@@ -4142,7 +4337,8 @@ var HCaptchaDetector = class {
|
|
|
4142
4337
|
() => [
|
|
4143
4338
|
'iframe[src*="hcaptcha.com"]',
|
|
4144
4339
|
'iframe[title*="hCaptcha"]',
|
|
4145
|
-
"[data-hcaptcha-widget-id]"
|
|
4340
|
+
"[data-hcaptcha-widget-id]",
|
|
4341
|
+
'.h-captcha:not([data-size="invisible"])'
|
|
4146
4342
|
].some((sel) => {
|
|
4147
4343
|
const el = document.querySelector(sel);
|
|
4148
4344
|
if (!el) return false;
|
|
@@ -4206,8 +4402,8 @@ var LoginWallDetector = class {
|
|
|
4206
4402
|
}
|
|
4207
4403
|
};
|
|
4208
4404
|
var defaultDetectors = [
|
|
4209
|
-
new RecaptchaDetector(),
|
|
4210
4405
|
new HCaptchaDetector(),
|
|
4406
|
+
new RecaptchaDetector(),
|
|
4211
4407
|
new CookieConsentDetector(),
|
|
4212
4408
|
new LoginWallDetector()
|
|
4213
4409
|
];
|
|
@@ -4318,10 +4514,14 @@ async function capturePageState(page, ctx, opts) {
|
|
|
4318
4514
|
url = page.url();
|
|
4319
4515
|
} catch {
|
|
4320
4516
|
}
|
|
4321
|
-
const title = await page.title().catch(() => "");
|
|
4517
|
+
const title = await withBudget(page.title(), 2e3, "title").catch(() => "");
|
|
4322
4518
|
if (!screenshot2) return { url, title };
|
|
4323
4519
|
try {
|
|
4324
|
-
const buf = await
|
|
4520
|
+
const buf = await withBudget(
|
|
4521
|
+
page.screenshot({ type: "jpeg", quality: 50, fullPage: false, timeout: 12e3 }),
|
|
4522
|
+
13e3,
|
|
4523
|
+
"screenshot"
|
|
4524
|
+
);
|
|
4325
4525
|
const screenshotUrl = saveScreenshot(buf, `${namePrefix}-${Date.now()}.jpg`, ctx.screenshotDir, ctx.publicUrl);
|
|
4326
4526
|
return { url, title, screenshotUrl };
|
|
4327
4527
|
} catch {
|
|
@@ -4389,8 +4589,8 @@ function isLikelyId(segment) {
|
|
|
4389
4589
|
function isRecord(x) {
|
|
4390
4590
|
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
4391
4591
|
}
|
|
4392
|
-
function parameterizePath(
|
|
4393
|
-
const parts =
|
|
4592
|
+
function parameterizePath(path12) {
|
|
4593
|
+
const parts = path12.split("/");
|
|
4394
4594
|
let idCount = 0;
|
|
4395
4595
|
const parameterized = parts.map((part) => {
|
|
4396
4596
|
if (part && isLikelyId(part)) {
|
|
@@ -4479,16 +4679,16 @@ function gqlActionFromBody(body) {
|
|
|
4479
4679
|
return void 0;
|
|
4480
4680
|
}
|
|
4481
4681
|
function classifyRequest(req) {
|
|
4482
|
-
const
|
|
4483
|
-
const lowerPath =
|
|
4682
|
+
const path12 = req.path;
|
|
4683
|
+
const lowerPath = path12.toLowerCase();
|
|
4484
4684
|
const ct = (req.requestHeaders["content-type"] || req.requestHeaders["Content-Type"] || "").toLowerCase();
|
|
4485
4685
|
const body = req.requestBody;
|
|
4486
4686
|
if (ct.includes("application/grpc")) {
|
|
4487
|
-
return { protocol: "grpc-web", action:
|
|
4687
|
+
return { protocol: "grpc-web", action: path12.replace(/^\//, "") };
|
|
4488
4688
|
}
|
|
4489
4689
|
const soapAction = req.requestHeaders["soapaction"] || req.requestHeaders["SOAPAction"];
|
|
4490
4690
|
if (soapAction || ct.includes("text/xml") || ct.includes("application/soap+xml")) {
|
|
4491
|
-
return { protocol: "soap", action: (soapAction ||
|
|
4691
|
+
return { protocol: "soap", action: (soapAction || path12).replace(/^["/]|["/]$/g, "") };
|
|
4492
4692
|
}
|
|
4493
4693
|
if (/\/graphql\b/.test(lowerPath) || hasGraphQLShape(body)) {
|
|
4494
4694
|
return { protocol: "graphql", action: gqlActionFromBody(body) ?? "anonymous" };
|
|
@@ -4503,7 +4703,7 @@ function classifyRequest(req) {
|
|
|
4503
4703
|
const friendly = gqlActionFromBody(body);
|
|
4504
4704
|
if (friendly) return { protocol: "form-rpc", action: friendly };
|
|
4505
4705
|
}
|
|
4506
|
-
return { protocol: "rest", action: `${req.method} ${parameterizePath(
|
|
4706
|
+
return { protocol: "rest", action: `${req.method} ${parameterizePath(path12)}` };
|
|
4507
4707
|
}
|
|
4508
4708
|
function inferVerb(protocol, action, method, responseBody) {
|
|
4509
4709
|
const lower = action.toLowerCase();
|
|
@@ -4538,8 +4738,8 @@ function buildFunctionName(protocol, action, method, verb) {
|
|
|
4538
4738
|
if (protocol === "rest") {
|
|
4539
4739
|
const parts = action.split(" ");
|
|
4540
4740
|
const httpMethod = parts[0];
|
|
4541
|
-
const
|
|
4542
|
-
const segs =
|
|
4741
|
+
const path12 = parts.slice(1).join(" ");
|
|
4742
|
+
const segs = path12.split("/").filter((s) => s && !s.startsWith("{"));
|
|
4543
4743
|
const verbPrefix = httpMethod === "GET" ? verb === "list" ? "list" : "get" : httpMethod === "POST" ? "create" : httpMethod === "PUT" ? "update" : httpMethod === "PATCH" ? "patch" : httpMethod === "DELETE" ? "delete" : httpMethod.toLowerCase();
|
|
4544
4744
|
return camelCase(`${verbPrefix} ${segs.join(" ")}`) || camelCase(action);
|
|
4545
4745
|
}
|
|
@@ -4693,10 +4893,37 @@ var ApiCapture = class {
|
|
|
4693
4893
|
await new Promise((r) => setTimeout(r, 100));
|
|
4694
4894
|
}
|
|
4695
4895
|
}
|
|
4896
|
+
/** Requests fired but not yet answered. */
|
|
4897
|
+
hasPending() {
|
|
4898
|
+
return this.pendingRequests.size > 0;
|
|
4899
|
+
}
|
|
4696
4900
|
getResults() {
|
|
4697
4901
|
return buildCapturedApi(this.requests);
|
|
4698
4902
|
}
|
|
4903
|
+
/** Every HTTP >= 400 response, compact, in capture order. */
|
|
4904
|
+
getServerErrors() {
|
|
4905
|
+
return serverErrorsFrom(this.requests);
|
|
4906
|
+
}
|
|
4699
4907
|
};
|
|
4908
|
+
var SERVER_ERROR_BODY_MAX = 600;
|
|
4909
|
+
function serverErrorsFrom(requests) {
|
|
4910
|
+
const out = [];
|
|
4911
|
+
for (const r of requests) {
|
|
4912
|
+
if (r.responseStatus < 400) continue;
|
|
4913
|
+
let body;
|
|
4914
|
+
if (r.responseBody !== void 0 && r.responseBody !== null) {
|
|
4915
|
+
const raw = typeof r.responseBody === "string" ? r.responseBody : JSON.stringify(r.responseBody);
|
|
4916
|
+
const flat = raw.replace(/\s+/g, " ").trim();
|
|
4917
|
+
body = flat.length > SERVER_ERROR_BODY_MAX ? flat.slice(0, SERVER_ERROR_BODY_MAX) + "\u2026" : flat;
|
|
4918
|
+
if (/^\s*<(!doctype|html)/i.test(raw)) {
|
|
4919
|
+
const m = raw.match(/<title[^>]*>([^<]*)<\/title>|<h1[^>]*>([^<]*)<\/h1>/i);
|
|
4920
|
+
body = m ? `[html] ${(m[1] || m[2] || "").trim()}` : "[html page]";
|
|
4921
|
+
}
|
|
4922
|
+
}
|
|
4923
|
+
out.push({ stepIndex: r.triggeredAtStep, method: r.method, url: r.url, status: r.responseStatus, body });
|
|
4924
|
+
}
|
|
4925
|
+
return out;
|
|
4926
|
+
}
|
|
4700
4927
|
function extractAuth(requests) {
|
|
4701
4928
|
const auth2 = { cookies: {}, tokens: {} };
|
|
4702
4929
|
for (const req of requests) {
|
|
@@ -4784,7 +5011,7 @@ function buildCapturedApi(requests) {
|
|
|
4784
5011
|
}
|
|
4785
5012
|
|
|
4786
5013
|
// src/lib/browser/tab-tracker.ts
|
|
4787
|
-
var
|
|
5014
|
+
var log14 = createLogger("tabs");
|
|
4788
5015
|
var TabTracker = class {
|
|
4789
5016
|
constructor(context, initial) {
|
|
4790
5017
|
this.context = context;
|
|
@@ -4803,14 +5030,14 @@ var TabTracker = class {
|
|
|
4803
5030
|
this.pages.push(p);
|
|
4804
5031
|
this.newlyOpened.push(p);
|
|
4805
5032
|
p.on("close", () => this.onClose(p));
|
|
4806
|
-
|
|
5033
|
+
log14.debug(`new tab opened: ${safeUrl(p)}`);
|
|
4807
5034
|
};
|
|
4808
5035
|
onClose = (p) => {
|
|
4809
5036
|
this.pages = this.pages.filter((x) => x !== p);
|
|
4810
5037
|
this.newlyOpened = this.newlyOpened.filter((x) => x !== p);
|
|
4811
5038
|
if (this.activePage === p) {
|
|
4812
5039
|
this.activePage = this.pages[this.pages.length - 1] ?? p;
|
|
4813
|
-
|
|
5040
|
+
log14.debug(`active tab closed, fell back to: ${safeUrl(this.activePage)}`);
|
|
4814
5041
|
}
|
|
4815
5042
|
};
|
|
4816
5043
|
/** The page the pipeline should currently drive. */
|
|
@@ -4853,7 +5080,7 @@ var TabTracker = class {
|
|
|
4853
5080
|
});
|
|
4854
5081
|
this.activePage = target;
|
|
4855
5082
|
const sw = { url, title: await target.title().catch(() => "") };
|
|
4856
|
-
|
|
5083
|
+
log14.info(`followed new tab \u2192 ${sw.url}`);
|
|
4857
5084
|
return sw;
|
|
4858
5085
|
}
|
|
4859
5086
|
/** Drop pending new-tab opens without following them. Used when a step opened
|
|
@@ -4879,7 +5106,7 @@ function safeUrl(p) {
|
|
|
4879
5106
|
}
|
|
4880
5107
|
|
|
4881
5108
|
// src/lib/knowledge/component-map.ts
|
|
4882
|
-
var
|
|
5109
|
+
var import_fs8 = __toESM(require("fs"));
|
|
4883
5110
|
var import_path9 = __toESM(require("path"));
|
|
4884
5111
|
function anchorsPath(domain) {
|
|
4885
5112
|
return import_path9.default.join(getKnowledgeDir(), `${sanitizeDomain(domain)}.anchors.json`);
|
|
@@ -4887,7 +5114,7 @@ function anchorsPath(domain) {
|
|
|
4887
5114
|
function loadComponentMap(domain) {
|
|
4888
5115
|
const norm = normalizeDomain(domain);
|
|
4889
5116
|
try {
|
|
4890
|
-
const raw =
|
|
5117
|
+
const raw = import_fs8.default.readFileSync(anchorsPath(norm), "utf8");
|
|
4891
5118
|
const parsed = JSON.parse(raw);
|
|
4892
5119
|
return {
|
|
4893
5120
|
domain: parsed.domain || norm,
|
|
@@ -4905,8 +5132,8 @@ function loadAnchors(domain) {
|
|
|
4905
5132
|
return map;
|
|
4906
5133
|
}
|
|
4907
5134
|
function write(cm) {
|
|
4908
|
-
|
|
4909
|
-
|
|
5135
|
+
import_fs8.default.mkdirSync(getKnowledgeDir(), { recursive: true });
|
|
5136
|
+
import_fs8.default.writeFileSync(anchorsPath(cm.domain), JSON.stringify(cm, null, 2), "utf8");
|
|
4910
5137
|
}
|
|
4911
5138
|
function recordAnchorResult(domain, name, ok, now) {
|
|
4912
5139
|
try {
|
|
@@ -4935,6 +5162,7 @@ function safePageUrl(page) {
|
|
|
4935
5162
|
}
|
|
4936
5163
|
function classifyError(err, step) {
|
|
4937
5164
|
if (err instanceof StaleStateError) return "stale-state";
|
|
5165
|
+
if (step.type === "solve-captcha") return "captcha-unsolvable";
|
|
4938
5166
|
const msg = err.message.toLowerCase();
|
|
4939
5167
|
if (step.type === "login" && (msg.includes("no visible password field") || msg.includes("password field was not visible"))) {
|
|
4940
5168
|
return "bot-blocked";
|
|
@@ -4942,13 +5170,38 @@ function classifyError(err, step) {
|
|
|
4942
5170
|
if (msg.includes("timeout") || msg.includes("timed out")) return "timeout";
|
|
4943
5171
|
if (msg.includes("not found") || msg.includes("no element") || msg.includes("waiting for selector")) return "element-not-found";
|
|
4944
5172
|
if (msg.includes("navigation") || msg.includes("net::err")) return "navigation-failed";
|
|
4945
|
-
if (step.type === "solve-captcha") return "captcha-unsolvable";
|
|
4946
5173
|
return "action-failed";
|
|
4947
5174
|
}
|
|
5175
|
+
async function refineError(page, step, ctx, errorType, message) {
|
|
5176
|
+
if (errorType !== "stale-state" && errorType !== "timeout") return { errorType, message };
|
|
5177
|
+
const raw = step.selector;
|
|
5178
|
+
if (typeof raw !== "string") return { errorType, message };
|
|
5179
|
+
let resolved;
|
|
5180
|
+
try {
|
|
5181
|
+
resolved = resolveSelector(raw, ctx);
|
|
5182
|
+
} catch {
|
|
5183
|
+
return { errorType, message };
|
|
5184
|
+
}
|
|
5185
|
+
try {
|
|
5186
|
+
const probe = page.evaluate((sel) => !!document.querySelector(sel), resolved);
|
|
5187
|
+
const budget = new Promise((r) => setTimeout(() => r(null), TIMING.FAILURE_PROBE));
|
|
5188
|
+
const found = await Promise.race([probe, budget]);
|
|
5189
|
+
if (found !== false) return { errorType, message };
|
|
5190
|
+
} catch {
|
|
5191
|
+
return { errorType, message };
|
|
5192
|
+
}
|
|
5193
|
+
const shown = resolved === raw ? `"${raw}"` : `"${raw}" (\u2192 ${resolved})`;
|
|
5194
|
+
return {
|
|
5195
|
+
errorType: "element-not-found",
|
|
5196
|
+
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})`
|
|
5197
|
+
};
|
|
5198
|
+
}
|
|
4948
5199
|
function isRetryable(errorType) {
|
|
4949
5200
|
return errorType === "stale-state" || errorType === "timeout" || errorType === "element-not-found";
|
|
4950
5201
|
}
|
|
4951
|
-
|
|
5202
|
+
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.";
|
|
5203
|
+
function getSuggestion(errorType, step, message = "") {
|
|
5204
|
+
if (message.includes("tab stopped responding")) return UNRESPONSIVE_SUGGESTION;
|
|
4952
5205
|
const usedEphemeralRef = typeof step.selector === "string" && step.selector.startsWith("@e");
|
|
4953
5206
|
if (usedEphemeralRef && (errorType === "element-not-found" || errorType === "timeout" || errorType === "stale-state")) {
|
|
4954
5207
|
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.";
|
|
@@ -4957,11 +5210,11 @@ function getSuggestion(errorType, step) {
|
|
|
4957
5210
|
case "stale-state":
|
|
4958
5211
|
return "The page stopped responding. The step may have triggered a very slow load or the server may be unreachable.";
|
|
4959
5212
|
case "element-not-found":
|
|
4960
|
-
return
|
|
5213
|
+
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.";
|
|
4961
5214
|
case "navigation-failed":
|
|
4962
5215
|
return "Navigation failed. The URL may be unreachable, blocked, or require authentication.";
|
|
4963
5216
|
case "captcha-unsolvable":
|
|
4964
|
-
return "Automatic
|
|
5217
|
+
return "Automatic captcha solving failed. Ask a person to solve it in a binary-headful window, then continue from the same page.";
|
|
4965
5218
|
case "timeout":
|
|
4966
5219
|
return "Operation timed out. The page may be slow or the element may not appear.";
|
|
4967
5220
|
default:
|
|
@@ -4975,6 +5228,9 @@ var PipelineRunner = class {
|
|
|
4975
5228
|
}
|
|
4976
5229
|
ctx;
|
|
4977
5230
|
hooks;
|
|
5231
|
+
/** Live progress, readable while run() is still pending — lets an outer
|
|
5232
|
+
* watchdog report WHICH step hung and what already finished. */
|
|
5233
|
+
progress = { stepIndex: -1, results: [] };
|
|
4978
5234
|
async run(initialPage, pipeline) {
|
|
4979
5235
|
const tracker = new TabTracker(initialPage.context(), initialPage);
|
|
4980
5236
|
try {
|
|
@@ -4992,6 +5248,7 @@ var PipelineRunner = class {
|
|
|
4992
5248
|
const continueOnError = opts.continueOnError ?? false;
|
|
4993
5249
|
const results = [];
|
|
4994
5250
|
const obstacles = [];
|
|
5251
|
+
this.progress.results = results;
|
|
4995
5252
|
const navStep = pipeline.steps.find((s) => s.type === "navigate");
|
|
4996
5253
|
try {
|
|
4997
5254
|
const host = new URL(navStep?.url || safePageUrl(initialPage) || "http://x").hostname;
|
|
@@ -5009,12 +5266,18 @@ var PipelineRunner = class {
|
|
|
5009
5266
|
if (capture) capture.start();
|
|
5010
5267
|
const finishCapture = async () => {
|
|
5011
5268
|
if (!capture) return void 0;
|
|
5269
|
+
await capture.drain(TIMING.CAPTURE_SETTLE, TIMING.CAPTURE_PENDING_MAX);
|
|
5012
5270
|
capture.stop();
|
|
5271
|
+
for (const se of capture.getServerErrors()) {
|
|
5272
|
+
const r = results.find((x) => x.stepIndex === se.stepIndex);
|
|
5273
|
+
if (r) (r.serverErrors ??= []).push(se);
|
|
5274
|
+
}
|
|
5013
5275
|
return capture.getResults();
|
|
5014
5276
|
};
|
|
5015
5277
|
for (let i = 0; i < pipeline.steps.length; i++) {
|
|
5016
5278
|
if (capture) capture.setStep(i);
|
|
5017
5279
|
const step = pipeline.steps[i];
|
|
5280
|
+
this.progress.stepIndex = i;
|
|
5018
5281
|
const page = tracker.active();
|
|
5019
5282
|
const urlBefore = safePageUrl(page);
|
|
5020
5283
|
const monitor = new StaleStateMonitor(page, staleTimeoutMs);
|
|
@@ -5034,9 +5297,10 @@ var PipelineRunner = class {
|
|
|
5034
5297
|
throw err;
|
|
5035
5298
|
}
|
|
5036
5299
|
const asError = err instanceof Error ? err : new Error(String(err));
|
|
5037
|
-
const
|
|
5300
|
+
const refined = await refineError(tracker.active(), step, this.ctx, classifyError(asError, step), asError.message);
|
|
5301
|
+
const errorType = refined.errorType;
|
|
5038
5302
|
const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
|
|
5039
|
-
stepResult = failedStepResult(step,
|
|
5303
|
+
stepResult = failedStepResult(step, refined.message, Date.now() - startTime, i);
|
|
5040
5304
|
results.push(stepResult);
|
|
5041
5305
|
recordAnchor(step, false);
|
|
5042
5306
|
return {
|
|
@@ -5050,9 +5314,9 @@ var PipelineRunner = class {
|
|
|
5050
5314
|
failedAtStep: i,
|
|
5051
5315
|
failedStep: step,
|
|
5052
5316
|
errorType,
|
|
5053
|
-
message:
|
|
5317
|
+
message: refined.message,
|
|
5054
5318
|
pageState,
|
|
5055
|
-
suggestion: getSuggestion(errorType, step),
|
|
5319
|
+
suggestion: getSuggestion(errorType, step, refined.message),
|
|
5056
5320
|
retryable: isRetryable(errorType)
|
|
5057
5321
|
},
|
|
5058
5322
|
durationMs: Date.now() - startTime,
|
|
@@ -5086,8 +5350,16 @@ var PipelineRunner = class {
|
|
|
5086
5350
|
}
|
|
5087
5351
|
results.push(stepResult);
|
|
5088
5352
|
if (!stepResult.ok && !continueOnError) {
|
|
5353
|
+
const refined = await refineError(
|
|
5354
|
+
tracker.active(),
|
|
5355
|
+
step,
|
|
5356
|
+
this.ctx,
|
|
5357
|
+
classifyError(new Error(stepResult.error || ""), step),
|
|
5358
|
+
stepResult.error || "Step failed"
|
|
5359
|
+
);
|
|
5360
|
+
stepResult.error = refined.message;
|
|
5361
|
+
const errorType = refined.errorType;
|
|
5089
5362
|
const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
|
|
5090
|
-
const errorType = classifyError(new Error(stepResult.error || ""), step);
|
|
5091
5363
|
return {
|
|
5092
5364
|
ok: false,
|
|
5093
5365
|
completedSteps: i,
|
|
@@ -5101,7 +5373,7 @@ var PipelineRunner = class {
|
|
|
5101
5373
|
errorType,
|
|
5102
5374
|
message: stepResult.error || "Step failed",
|
|
5103
5375
|
pageState,
|
|
5104
|
-
suggestion: getSuggestion(errorType, step),
|
|
5376
|
+
suggestion: getSuggestion(errorType, step, refined.message),
|
|
5105
5377
|
retryable: isRetryable(errorType)
|
|
5106
5378
|
},
|
|
5107
5379
|
durationMs: Date.now() - startTime,
|
|
@@ -5111,7 +5383,7 @@ var PipelineRunner = class {
|
|
|
5111
5383
|
if (step.type === "navigate" && continueOnObstacle) {
|
|
5112
5384
|
const obstacleStart = Date.now();
|
|
5113
5385
|
const obstaclePage = tracker.active();
|
|
5114
|
-
const obstacle = await detectObstacles(obstaclePage);
|
|
5386
|
+
const obstacle = await withBudget(detectObstacles(obstaclePage), 1e4, "obstacle check").catch(() => null);
|
|
5115
5387
|
if (obstacle) {
|
|
5116
5388
|
const resolution = await resolveObstacle(obstaclePage, obstacle, this.ctx, monitor);
|
|
5117
5389
|
obstacles.push({
|
|
@@ -5161,11 +5433,8 @@ var PipelineRunner = class {
|
|
|
5161
5433
|
}
|
|
5162
5434
|
};
|
|
5163
5435
|
|
|
5164
|
-
// src/lib/execution/pipeline-executor.ts
|
|
5165
|
-
init_persistence();
|
|
5166
|
-
|
|
5167
5436
|
// src/lib/block-detection.ts
|
|
5168
|
-
var
|
|
5437
|
+
var log15 = createLogger("block-detection");
|
|
5169
5438
|
async function detectBlock(page) {
|
|
5170
5439
|
try {
|
|
5171
5440
|
const [title, url, bodyText] = await Promise.all([
|
|
@@ -5185,7 +5454,7 @@ async function detectBlock(page) {
|
|
|
5185
5454
|
const hasCfChallenge = await page.evaluate(() => {
|
|
5186
5455
|
return !!document.querySelector('iframe[src*="challenges.cloudflare.com"]');
|
|
5187
5456
|
}).catch((err) => {
|
|
5188
|
-
|
|
5457
|
+
log15.warn(`CF challenge check failed, assuming blocked: ${err}`);
|
|
5189
5458
|
return true;
|
|
5190
5459
|
});
|
|
5191
5460
|
if (hasCfChallenge) {
|
|
@@ -5204,7 +5473,7 @@ async function detectBlock(page) {
|
|
|
5204
5473
|
const hasCaptchaIframe = await page.evaluate(() => {
|
|
5205
5474
|
return !!(document.querySelector('iframe[src*="recaptcha"]') || document.querySelector('iframe[src*="hcaptcha"]'));
|
|
5206
5475
|
}).catch((err) => {
|
|
5207
|
-
|
|
5476
|
+
log15.warn(`captcha iframe check failed, assuming blocked: ${err}`);
|
|
5208
5477
|
return true;
|
|
5209
5478
|
});
|
|
5210
5479
|
if (hasCaptchaIframe) {
|
|
@@ -5218,7 +5487,7 @@ async function detectBlock(page) {
|
|
|
5218
5487
|
}
|
|
5219
5488
|
return { blocked: false };
|
|
5220
5489
|
} catch (err) {
|
|
5221
|
-
|
|
5490
|
+
log15.warn(`page evaluation failed, assuming blocked: ${err}`);
|
|
5222
5491
|
return { blocked: true, reason: "evaluation-failed" };
|
|
5223
5492
|
}
|
|
5224
5493
|
}
|
|
@@ -5306,14 +5575,14 @@ function extractKnowledgeFromRun(pipeline, result, sessionData, mode) {
|
|
|
5306
5575
|
// src/lib/extension/cdp-relay.ts
|
|
5307
5576
|
var import_http = __toESM(require("http"));
|
|
5308
5577
|
var import_ws2 = require("ws");
|
|
5309
|
-
var
|
|
5578
|
+
var import_crypto8 = require("crypto");
|
|
5310
5579
|
|
|
5311
5580
|
// src/lib/extension/bridge.ts
|
|
5312
5581
|
var import_ws = require("ws");
|
|
5313
|
-
var
|
|
5582
|
+
var import_crypto6 = __toESM(require("crypto"));
|
|
5314
5583
|
|
|
5315
5584
|
// src/lib/version.ts
|
|
5316
|
-
var
|
|
5585
|
+
var import_fs9 = __toESM(require("fs"));
|
|
5317
5586
|
var import_path10 = __toESM(require("path"));
|
|
5318
5587
|
var cached = null;
|
|
5319
5588
|
function getVersion() {
|
|
@@ -5328,7 +5597,7 @@ function getVersion() {
|
|
|
5328
5597
|
];
|
|
5329
5598
|
for (const p of candidates) {
|
|
5330
5599
|
try {
|
|
5331
|
-
const v = JSON.parse(
|
|
5600
|
+
const v = JSON.parse(import_fs9.default.readFileSync(p, "utf8")).version;
|
|
5332
5601
|
if (v) return cached = v;
|
|
5333
5602
|
} catch {
|
|
5334
5603
|
}
|
|
@@ -5339,8 +5608,12 @@ function getVersion() {
|
|
|
5339
5608
|
// src/lib/extension/bridge.ts
|
|
5340
5609
|
var REQUEST_TIMEOUT_MS = 18e4;
|
|
5341
5610
|
var HEARTBEAT_MS = 15e3;
|
|
5611
|
+
var CONNECT_GRACE_MS = 35e3;
|
|
5612
|
+
var NOT_CONNECTED_MESSAGE = "No iframer extension is connected. Open Chrome and make sure the iframer extension is enabled (setup: `iframer install extension chrome`). It pairs by itself; the popup says why if it can't.";
|
|
5342
5613
|
var ExtensionBridge = class {
|
|
5343
5614
|
wss = null;
|
|
5615
|
+
attachedAt = 0;
|
|
5616
|
+
clientWaiters = [];
|
|
5344
5617
|
clients = /* @__PURE__ */ new Map();
|
|
5345
5618
|
pending = /* @__PURE__ */ new Map();
|
|
5346
5619
|
nextReqId = 1;
|
|
@@ -5358,21 +5631,17 @@ var ExtensionBridge = class {
|
|
|
5358
5631
|
/** Attach the WS server to the already-listening HTTP server. Idempotent. */
|
|
5359
5632
|
attach(server2) {
|
|
5360
5633
|
if (this.wss) return;
|
|
5634
|
+
this.attachedAt = Date.now();
|
|
5361
5635
|
this.wss = new import_ws.WebSocketServer({ server: server2, path: "/extension/ws" });
|
|
5362
5636
|
this.wss.on("connection", (ws, req) => {
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
expected = getLocalToken();
|
|
5366
|
-
} catch {
|
|
5367
|
-
expected = "";
|
|
5368
|
-
}
|
|
5369
|
-
if (!expected) {
|
|
5637
|
+
const accepted = getPairingTokens();
|
|
5638
|
+
if (accepted.length === 0) {
|
|
5370
5639
|
ws.close(4001, "unauthorized");
|
|
5371
5640
|
return;
|
|
5372
5641
|
}
|
|
5373
5642
|
const queryToken = new URL(req.url || "", "http://127.0.0.1").searchParams.get("token");
|
|
5374
5643
|
if (queryToken !== null) {
|
|
5375
|
-
if (queryToken
|
|
5644
|
+
if (!accepted.includes(queryToken)) {
|
|
5376
5645
|
ws.close(4001, "unauthorized");
|
|
5377
5646
|
return;
|
|
5378
5647
|
}
|
|
@@ -5384,7 +5653,7 @@ var ExtensionBridge = class {
|
|
|
5384
5653
|
clearTimeout(timer);
|
|
5385
5654
|
try {
|
|
5386
5655
|
const m = JSON.parse(data.toString());
|
|
5387
|
-
if (m?.type === "auth" && m.token ===
|
|
5656
|
+
if (m?.type === "auth" && typeof m.token === "string" && accepted.includes(m.token)) {
|
|
5388
5657
|
this.acceptClient(ws);
|
|
5389
5658
|
return;
|
|
5390
5659
|
}
|
|
@@ -5396,7 +5665,7 @@ var ExtensionBridge = class {
|
|
|
5396
5665
|
}
|
|
5397
5666
|
acceptClient(ws) {
|
|
5398
5667
|
const client = {
|
|
5399
|
-
clientId:
|
|
5668
|
+
clientId: import_crypto6.default.randomUUID(),
|
|
5400
5669
|
socket: ws,
|
|
5401
5670
|
connectedAt: Date.now(),
|
|
5402
5671
|
tabs: [],
|
|
@@ -5404,6 +5673,7 @@ var ExtensionBridge = class {
|
|
|
5404
5673
|
};
|
|
5405
5674
|
this.clients.set(client.clientId, client);
|
|
5406
5675
|
this.startHeartbeat(client);
|
|
5676
|
+
for (const wake of this.clientWaiters.splice(0)) wake();
|
|
5407
5677
|
try {
|
|
5408
5678
|
ws.send(JSON.stringify({ type: "server_info", version: getVersion() }));
|
|
5409
5679
|
} catch {
|
|
@@ -5499,6 +5769,22 @@ var ExtensionBridge = class {
|
|
|
5499
5769
|
hasClients() {
|
|
5500
5770
|
return this.clients.size > 0;
|
|
5501
5771
|
}
|
|
5772
|
+
/** Resolves once a client is connected, or when the startup grace window ends.
|
|
5773
|
+
* After that window it returns at once, so a machine with no extension still
|
|
5774
|
+
* gets its "not connected" answer immediately. */
|
|
5775
|
+
async waitForClient() {
|
|
5776
|
+
if (this.clients.size > 0) return;
|
|
5777
|
+
const left = this.attachedAt + CONNECT_GRACE_MS - Date.now();
|
|
5778
|
+
if (left <= 0) return;
|
|
5779
|
+
await new Promise((resolve) => {
|
|
5780
|
+
const timer = setTimeout(resolve, left);
|
|
5781
|
+
timer.unref?.();
|
|
5782
|
+
this.clientWaiters.push(() => {
|
|
5783
|
+
clearTimeout(timer);
|
|
5784
|
+
resolve();
|
|
5785
|
+
});
|
|
5786
|
+
});
|
|
5787
|
+
}
|
|
5502
5788
|
status() {
|
|
5503
5789
|
return {
|
|
5504
5790
|
connected: this.clients.size > 0,
|
|
@@ -5515,6 +5801,7 @@ var ExtensionBridge = class {
|
|
|
5515
5801
|
/** List tabs across ALL connected clients, tagged with profile/client, and
|
|
5516
5802
|
* refresh the tabId → client ownership map used for routing. */
|
|
5517
5803
|
async listTabs() {
|
|
5804
|
+
await this.waitForClient();
|
|
5518
5805
|
const all = [];
|
|
5519
5806
|
this.tabOwner.clear();
|
|
5520
5807
|
this.collidingTabs.clear();
|
|
@@ -5550,10 +5837,9 @@ var ExtensionBridge = class {
|
|
|
5550
5837
|
if (!c) throw new Error(`No connected extension with clientId ${clientId}.`);
|
|
5551
5838
|
return c;
|
|
5552
5839
|
}
|
|
5840
|
+
await this.waitForClient();
|
|
5553
5841
|
if (this.clients.size === 0) {
|
|
5554
|
-
throw new Error(
|
|
5555
|
-
"No iframer extension is connected. Open Chrome, install/enable the iframer extension, and pair it (paste the token, dot goes green)."
|
|
5556
|
-
);
|
|
5842
|
+
throw new Error(NOT_CONNECTED_MESSAGE);
|
|
5557
5843
|
}
|
|
5558
5844
|
if (this.clients.size === 1) {
|
|
5559
5845
|
return [...this.clients.values()][0];
|
|
@@ -5578,16 +5864,15 @@ var ExtensionBridge = class {
|
|
|
5578
5864
|
}
|
|
5579
5865
|
/** Pick a client for a browser-level op that has no tabId (create tab, etc.).
|
|
5580
5866
|
* Explicit clientId wins; otherwise require exactly one connected profile. */
|
|
5581
|
-
resolveClientNoTab(clientId) {
|
|
5867
|
+
async resolveClientNoTab(clientId) {
|
|
5582
5868
|
if (clientId) {
|
|
5583
5869
|
const c = this.clients.get(clientId);
|
|
5584
5870
|
if (!c) throw new Error(`No connected extension with clientId ${clientId}.`);
|
|
5585
5871
|
return c;
|
|
5586
5872
|
}
|
|
5873
|
+
await this.waitForClient();
|
|
5587
5874
|
if (this.clients.size === 0) {
|
|
5588
|
-
throw new Error(
|
|
5589
|
-
"No iframer extension is connected. Open Chrome, install/enable the iframer extension, and pair it (paste the token, dot goes green)."
|
|
5590
|
-
);
|
|
5875
|
+
throw new Error(NOT_CONNECTED_MESSAGE);
|
|
5591
5876
|
}
|
|
5592
5877
|
if (this.clients.size === 1) return [...this.clients.values()][0];
|
|
5593
5878
|
throw new Error(
|
|
@@ -5605,7 +5890,7 @@ var ExtensionBridge = class {
|
|
|
5605
5890
|
}
|
|
5606
5891
|
/** Group tabs into a native Chrome tab group. */
|
|
5607
5892
|
async groupTabs(tabIds, opts = {}, clientId) {
|
|
5608
|
-
const client = clientId ? this.resolveClientNoTab(clientId) : await this.resolveClient(tabIds[0]);
|
|
5893
|
+
const client = clientId ? await this.resolveClientNoTab(clientId) : await this.resolveClient(tabIds[0]);
|
|
5609
5894
|
return this.send(client, "group_tabs", {
|
|
5610
5895
|
tabIds,
|
|
5611
5896
|
title: opts.title,
|
|
@@ -5616,22 +5901,22 @@ var ExtensionBridge = class {
|
|
|
5616
5901
|
}
|
|
5617
5902
|
/** Remove tabs from their group (chrome.tabs.ungroup). */
|
|
5618
5903
|
async ungroupTabs(tabIds, clientId) {
|
|
5619
|
-
const client = clientId ? this.resolveClientNoTab(clientId) : await this.resolveClient(tabIds[0]);
|
|
5904
|
+
const client = clientId ? await this.resolveClientNoTab(clientId) : await this.resolveClient(tabIds[0]);
|
|
5620
5905
|
return this.send(client, "ungroup_tabs", { tabIds });
|
|
5621
5906
|
}
|
|
5622
5907
|
/** Rename / recolor / collapse an existing group by id. */
|
|
5623
5908
|
async updateGroup(groupId, opts, clientId) {
|
|
5624
|
-
const client = this.resolveClientNoTab(clientId);
|
|
5909
|
+
const client = await this.resolveClientNoTab(clientId);
|
|
5625
5910
|
return this.send(client, "update_group", { groupId, ...opts });
|
|
5626
5911
|
}
|
|
5627
5912
|
/** List all tab groups across the browser. */
|
|
5628
5913
|
async listGroups(clientId) {
|
|
5629
|
-
const client = this.resolveClientNoTab(clientId);
|
|
5914
|
+
const client = await this.resolveClientNoTab(clientId);
|
|
5630
5915
|
return this.send(client, "list_groups", {});
|
|
5631
5916
|
}
|
|
5632
5917
|
/** Open a new tab in the user's real Chrome via chrome.tabs.create. */
|
|
5633
5918
|
async createTab(url, opts = {}, clientId) {
|
|
5634
|
-
const client = this.resolveClientNoTab(clientId);
|
|
5919
|
+
const client = await this.resolveClientNoTab(clientId);
|
|
5635
5920
|
const res = await this.send(client, "create_tab", {
|
|
5636
5921
|
url,
|
|
5637
5922
|
active: opts.active,
|
|
@@ -5679,7 +5964,7 @@ function cdpKey(clientId, tabId) {
|
|
|
5679
5964
|
var extensionBridge = new ExtensionBridge();
|
|
5680
5965
|
|
|
5681
5966
|
// src/lib/extension/cdp-relay.ts
|
|
5682
|
-
var
|
|
5967
|
+
var log16 = createLogger("cdp-relay");
|
|
5683
5968
|
var CdpRelay = class {
|
|
5684
5969
|
constructor(tabId, clientId, focus) {
|
|
5685
5970
|
this.tabId = tabId;
|
|
@@ -5693,7 +5978,7 @@ var CdpRelay = class {
|
|
|
5693
5978
|
wss = null;
|
|
5694
5979
|
pw = null;
|
|
5695
5980
|
port = 0;
|
|
5696
|
-
path = `/cdp/${(0,
|
|
5981
|
+
path = `/cdp/${(0, import_crypto8.randomUUID)()}`;
|
|
5697
5982
|
tabSessionId = "pw-tab-1";
|
|
5698
5983
|
targetInfo = null;
|
|
5699
5984
|
ownerClientId = "";
|
|
@@ -5739,11 +6024,11 @@ var CdpRelay = class {
|
|
|
5739
6024
|
res.end();
|
|
5740
6025
|
});
|
|
5741
6026
|
this.httpServer.on("upgrade", (req) => {
|
|
5742
|
-
if (process.env.IFRAMER_RELAY_DEBUG)
|
|
6027
|
+
if (process.env.IFRAMER_RELAY_DEBUG) log16.info(`[relay] upgrade request url=${req.url}`);
|
|
5743
6028
|
});
|
|
5744
6029
|
this.wss = new import_ws2.WebSocketServer({ server: this.httpServer, path: this.path });
|
|
5745
6030
|
this.wss.on("connection", (ws) => {
|
|
5746
|
-
if (process.env.IFRAMER_RELAY_DEBUG)
|
|
6031
|
+
if (process.env.IFRAMER_RELAY_DEBUG) log16.info(`[relay] playwright connected`);
|
|
5747
6032
|
if (this.pw) {
|
|
5748
6033
|
ws.close(4e3, "relay already has a client");
|
|
5749
6034
|
return;
|
|
@@ -5782,7 +6067,7 @@ var CdpRelay = class {
|
|
|
5782
6067
|
}
|
|
5783
6068
|
}
|
|
5784
6069
|
async onPwMessage(data) {
|
|
5785
|
-
if (process.env.IFRAMER_RELAY_DEBUG)
|
|
6070
|
+
if (process.env.IFRAMER_RELAY_DEBUG) log16.info(`[relay] raw pw msg (${data?.length ?? 0} bytes): ${data?.toString().slice(0, 120)}`);
|
|
5786
6071
|
let msg;
|
|
5787
6072
|
try {
|
|
5788
6073
|
msg = JSON.parse(data.toString());
|
|
@@ -5790,7 +6075,7 @@ var CdpRelay = class {
|
|
|
5790
6075
|
return;
|
|
5791
6076
|
}
|
|
5792
6077
|
const { id, sessionId, method, params } = msg;
|
|
5793
|
-
if (process.env.IFRAMER_RELAY_DEBUG)
|
|
6078
|
+
if (process.env.IFRAMER_RELAY_DEBUG) log16.info(`[relay] pw\u2192 ${method} (id=${id}, sess=${sessionId || "-"})`);
|
|
5794
6079
|
if (!method) return;
|
|
5795
6080
|
try {
|
|
5796
6081
|
const result = await this.handleCdpCommand(method, params, sessionId);
|
|
@@ -5852,7 +6137,7 @@ var CdpRelay = class {
|
|
|
5852
6137
|
return await Promise.race([
|
|
5853
6138
|
first,
|
|
5854
6139
|
new Promise((_, reject) => {
|
|
5855
|
-
const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")),
|
|
6140
|
+
const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 5e3);
|
|
5856
6141
|
t.unref?.();
|
|
5857
6142
|
})
|
|
5858
6143
|
]);
|
|
@@ -5903,14 +6188,32 @@ var CdpRelay = class {
|
|
|
5903
6188
|
try {
|
|
5904
6189
|
await extensionBridge.cdpDetach(this.ownerClientId, this.tabId);
|
|
5905
6190
|
} catch (e) {
|
|
5906
|
-
|
|
6191
|
+
log16.warn(`cdp detach failed: ${e}`);
|
|
5907
6192
|
}
|
|
5908
6193
|
}
|
|
5909
6194
|
}
|
|
5910
6195
|
};
|
|
5911
6196
|
|
|
5912
6197
|
// src/lib/execution/pipeline-executor.ts
|
|
5913
|
-
var
|
|
6198
|
+
var log17 = createLogger("iframer");
|
|
6199
|
+
function blankPipelineResult(pipeline, modeUsed, error) {
|
|
6200
|
+
return {
|
|
6201
|
+
ok: false,
|
|
6202
|
+
completedSteps: 0,
|
|
6203
|
+
totalSteps: pipeline.steps.length,
|
|
6204
|
+
results: [],
|
|
6205
|
+
obstacles: [],
|
|
6206
|
+
error: {
|
|
6207
|
+
failedAtStep: 0,
|
|
6208
|
+
failedStep: pipeline.steps[0],
|
|
6209
|
+
pageState: { url: "", title: "" },
|
|
6210
|
+
...error
|
|
6211
|
+
},
|
|
6212
|
+
durationMs: 0,
|
|
6213
|
+
modeUsed,
|
|
6214
|
+
finalState: { url: "", title: "" }
|
|
6215
|
+
};
|
|
6216
|
+
}
|
|
5914
6217
|
var PipelineExecutor = class {
|
|
5915
6218
|
constructor(deps) {
|
|
5916
6219
|
this.deps = deps;
|
|
@@ -5948,8 +6251,24 @@ var PipelineExecutor = class {
|
|
|
5948
6251
|
const forcedMode = opts.mode;
|
|
5949
6252
|
const autoEscalate = opts.autoEscalate !== false;
|
|
5950
6253
|
const instanceId = opts.instanceId || DEFAULT_INSTANCE;
|
|
6254
|
+
if (forcedMode === "extension") {
|
|
6255
|
+
return blankPipelineResult(pipeline, "extension", {
|
|
6256
|
+
errorType: "action-failed",
|
|
6257
|
+
message: 'mode="extension" requires options.tabId (the real-Chrome tab to drive).',
|
|
6258
|
+
suggestion: "Call the `tabs` tool (or GET /extension/tabs) to list open tabs, then pass that tab id. Nothing was launched.",
|
|
6259
|
+
retryable: false
|
|
6260
|
+
});
|
|
6261
|
+
}
|
|
5951
6262
|
const firstNav = pipeline.steps.find((s) => s.type === "navigate");
|
|
5952
6263
|
const domain = firstNav ? new URL(firstNav.url).hostname : null;
|
|
6264
|
+
if (!firstNav && !this.deps.daemon.findLiveMode(instanceId)) {
|
|
6265
|
+
return blankPipelineResult(pipeline, forcedMode || "headless", {
|
|
6266
|
+
errorType: "action-failed",
|
|
6267
|
+
message: `No navigate step and no live browser for instanceId "${instanceId}" to act on.`,
|
|
6268
|
+
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.",
|
|
6269
|
+
retryable: false
|
|
6270
|
+
});
|
|
6271
|
+
}
|
|
5953
6272
|
const availableModes = this.deps.availableModes();
|
|
5954
6273
|
const liveMode = forcedMode ? null : this.deps.daemon.findLiveMode(instanceId);
|
|
5955
6274
|
let mode;
|
|
@@ -5969,10 +6288,8 @@ var PipelineExecutor = class {
|
|
|
5969
6288
|
if (domain) this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
|
|
5970
6289
|
const nextMode = this.deps.domainModes.getNextMode(failedMode, availableModes);
|
|
5971
6290
|
if (nextMode) {
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
await this.deps.daemon.stopMode(failedMode, instanceId);
|
|
5975
|
-
}
|
|
6291
|
+
log17.info(`Auto-escalating from ${failedMode} to ${nextMode} for ${domain}`);
|
|
6292
|
+
await this.deps.daemon.stopMode(failedMode, instanceId);
|
|
5976
6293
|
result = await this.executeWithMode(userId, token, pipeline, nextMode, instanceId);
|
|
5977
6294
|
result.modeEscalated = true;
|
|
5978
6295
|
result.modeUsed = nextMode;
|
|
@@ -5982,10 +6299,8 @@ var PipelineExecutor = class {
|
|
|
5982
6299
|
this.deps.domainModes.recordFailure(domain, nextMode, result.error?.message || "blocked");
|
|
5983
6300
|
const thirdMode = this.deps.domainModes.getNextMode(nextMode, availableModes);
|
|
5984
6301
|
if (thirdMode) {
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
await this.deps.daemon.stopMode(nextMode, instanceId);
|
|
5988
|
-
}
|
|
6302
|
+
log17.info(`Auto-escalating from ${nextMode} to ${thirdMode} for ${domain}`);
|
|
6303
|
+
await this.deps.daemon.stopMode(nextMode, instanceId);
|
|
5989
6304
|
result = await this.executeWithMode(userId, token, pipeline, thirdMode, instanceId);
|
|
5990
6305
|
result.modeEscalated = true;
|
|
5991
6306
|
result.modeUsed = thirdMode;
|
|
@@ -6001,9 +6316,6 @@ var PipelineExecutor = class {
|
|
|
6001
6316
|
return result;
|
|
6002
6317
|
}
|
|
6003
6318
|
async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
|
|
6004
|
-
if (mode === "docker-headful") {
|
|
6005
|
-
return this.executeDocker(userId, token, pipeline, respectful);
|
|
6006
|
-
}
|
|
6007
6319
|
return this.executeLocal(userId, token, pipeline, mode, instanceId, respectful);
|
|
6008
6320
|
}
|
|
6009
6321
|
/** Execute against the user's real Chrome tab via the extension CDP relay,
|
|
@@ -6013,6 +6325,7 @@ var PipelineExecutor = class {
|
|
|
6013
6325
|
const startTime = Date.now();
|
|
6014
6326
|
const relay = new CdpRelay(tabId, clientId, pipeline.options?.focus);
|
|
6015
6327
|
let browser;
|
|
6328
|
+
let runner;
|
|
6016
6329
|
try {
|
|
6017
6330
|
await relay.start();
|
|
6018
6331
|
browser = await import_playwright_core.chromium.connectOverCDP(relay.httpEndpoint(), { timeout: 3e4 });
|
|
@@ -6025,7 +6338,8 @@ var PipelineExecutor = class {
|
|
|
6025
6338
|
if (!page) throw new Error("no page available for the tab (is it still open?)");
|
|
6026
6339
|
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
6027
6340
|
if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
|
|
6028
|
-
|
|
6341
|
+
ctx.driver = "extension";
|
|
6342
|
+
runner = new PipelineRunner(ctx);
|
|
6029
6343
|
const typeChars = pipeline.steps.reduce((n, s) => {
|
|
6030
6344
|
const v = s.value;
|
|
6031
6345
|
return (s.type === "human-type" || s.type === "type-code") && typeof v === "string" ? n + v.length : n;
|
|
@@ -6042,7 +6356,7 @@ var PipelineExecutor = class {
|
|
|
6042
6356
|
watchdog = setTimeout(
|
|
6043
6357
|
() => reject(
|
|
6044
6358
|
new Error(
|
|
6045
|
-
`pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014
|
|
6359
|
+
`pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 a call into the tab never returned.`
|
|
6046
6360
|
)
|
|
6047
6361
|
),
|
|
6048
6362
|
capMs
|
|
@@ -6059,30 +6373,33 @@ var PipelineExecutor = class {
|
|
|
6059
6373
|
try {
|
|
6060
6374
|
extractKnowledgeFromRun(pipeline, result, null, "extension");
|
|
6061
6375
|
} catch (e) {
|
|
6062
|
-
|
|
6376
|
+
log17.warn(`knowledge update failed: ${getErrorMessage(e)}`);
|
|
6063
6377
|
}
|
|
6064
6378
|
}
|
|
6065
6379
|
return result;
|
|
6066
6380
|
} catch (err) {
|
|
6067
6381
|
const msg = getErrorMessage(err);
|
|
6068
6382
|
const stalled = msg.includes("pipeline exceeded");
|
|
6383
|
+
const done = runner ? runner.progress.results.filter((r) => r.ok) : [];
|
|
6384
|
+
const hungAt = stalled && runner && runner.progress.stepIndex >= 0 ? runner.progress.stepIndex : 0;
|
|
6385
|
+
const hungStep = pipeline.steps[hungAt];
|
|
6069
6386
|
return {
|
|
6070
6387
|
ok: false,
|
|
6071
|
-
completedSteps: 0,
|
|
6388
|
+
completedSteps: stalled ? done.length : 0,
|
|
6072
6389
|
totalSteps: pipeline.steps.length,
|
|
6073
|
-
results: [],
|
|
6390
|
+
results: stalled ? done : [],
|
|
6074
6391
|
finalState: { url: "", title: "" },
|
|
6075
6392
|
obstacles: [],
|
|
6076
6393
|
durationMs: Date.now() - startTime,
|
|
6077
6394
|
modeUsed: "extension",
|
|
6078
6395
|
error: {
|
|
6079
|
-
failedAtStep:
|
|
6080
|
-
failedStep:
|
|
6081
|
-
errorType: "action-failed",
|
|
6082
|
-
message: `Extension mode failed: ${msg}`,
|
|
6396
|
+
failedAtStep: hungAt,
|
|
6397
|
+
failedStep: hungStep,
|
|
6398
|
+
errorType: stalled ? "stale-state" : "action-failed",
|
|
6399
|
+
message: stalled ? `Extension mode: step ${hungAt} (${hungStep?.type}) never returned; ${msg}` : `Extension mode failed: ${msg}`,
|
|
6083
6400
|
pageState: { url: "", title: "" },
|
|
6084
|
-
suggestion: stalled ?
|
|
6085
|
-
retryable:
|
|
6401
|
+
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.",
|
|
6402
|
+
retryable: stalled
|
|
6086
6403
|
}
|
|
6087
6404
|
};
|
|
6088
6405
|
} finally {
|
|
@@ -6096,42 +6413,6 @@ var PipelineExecutor = class {
|
|
|
6096
6413
|
}
|
|
6097
6414
|
}
|
|
6098
6415
|
}
|
|
6099
|
-
/** Execute via Docker session-manager. */
|
|
6100
|
-
async executeDocker(userId, token, pipeline, respectful = false) {
|
|
6101
|
-
let session = getSession(userId);
|
|
6102
|
-
if (!session) {
|
|
6103
|
-
const firstNav = pipeline.steps.find((s) => s.type === "navigate");
|
|
6104
|
-
await this.deps.startSession(userId, token, firstNav ? { url: firstNav.url } : {});
|
|
6105
|
-
session = getSession(userId);
|
|
6106
|
-
}
|
|
6107
|
-
resetTimeout(userId);
|
|
6108
|
-
const ctx = this.deps.refStore.makeContext(userId, token);
|
|
6109
|
-
const runner = new PipelineRunner(ctx, protectionHooks(false, respectful));
|
|
6110
|
-
const result = await runner.run(session.page, pipeline);
|
|
6111
|
-
if (result.ok) {
|
|
6112
|
-
const blockResult = await detectBlock(session.page);
|
|
6113
|
-
if (blockResult.blocked) {
|
|
6114
|
-
const pageState = await capturePageState(session.page, ctx, { screenshot: true, namePrefix: "block" });
|
|
6115
|
-
return {
|
|
6116
|
-
...result,
|
|
6117
|
-
ok: false,
|
|
6118
|
-
modeUsed: "docker-headful",
|
|
6119
|
-
error: {
|
|
6120
|
-
failedAtStep: result.completedSteps - 1,
|
|
6121
|
-
failedStep: pipeline.steps[result.completedSteps - 1],
|
|
6122
|
-
errorType: "bot-blocked",
|
|
6123
|
-
message: `Page blocked by bot detection: ${blockResult.reason}`,
|
|
6124
|
-
pageState,
|
|
6125
|
-
suggestion: "The page is blocked by bot detection. Try a different browser mode.",
|
|
6126
|
-
retryable: true
|
|
6127
|
-
}
|
|
6128
|
-
};
|
|
6129
|
-
}
|
|
6130
|
-
}
|
|
6131
|
-
this.deps.refStore.sync(userId, ctx);
|
|
6132
|
-
result.modeUsed = "docker-headful";
|
|
6133
|
-
return result;
|
|
6134
|
-
}
|
|
6135
6416
|
/** Execute via local Chrome daemon (headless + binary-headful). */
|
|
6136
6417
|
async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
|
|
6137
6418
|
const startTime = Date.now();
|
|
@@ -6165,7 +6446,7 @@ var PipelineExecutor = class {
|
|
|
6165
6446
|
acquired = false;
|
|
6166
6447
|
await this.deps.daemon.stopMode(mode, instanceId);
|
|
6167
6448
|
const available = this.deps.availableModes();
|
|
6168
|
-
const headed =
|
|
6449
|
+
const headed = "binary-headful";
|
|
6169
6450
|
if (!available.includes(headed)) throw new Error("A headed browser is required but unavailable.");
|
|
6170
6451
|
await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
|
|
6171
6452
|
const index = err.stepIndex;
|
|
@@ -6201,7 +6482,7 @@ var PipelineExecutor = class {
|
|
|
6201
6482
|
errorType: "bot-blocked",
|
|
6202
6483
|
message: `Page blocked by bot detection: ${blockResult.reason}`,
|
|
6203
6484
|
pageState,
|
|
6204
|
-
suggestion: `The page was blocked in ${mode} mode. ${mode === "headless" ? "Try
|
|
6485
|
+
suggestion: `The page was blocked in ${mode} mode. ${mode === "headless" ? "Try binary-headful mode." : "All modes exhausted."}`,
|
|
6205
6486
|
retryable: true
|
|
6206
6487
|
}
|
|
6207
6488
|
};
|
|
@@ -6220,7 +6501,7 @@ var PipelineExecutor = class {
|
|
|
6220
6501
|
try {
|
|
6221
6502
|
extractKnowledgeFromRun(pipeline, result, updatedSession, mode);
|
|
6222
6503
|
} catch (err) {
|
|
6223
|
-
|
|
6504
|
+
log17.warn(`knowledge update failed: ${getErrorMessage(err)}`);
|
|
6224
6505
|
}
|
|
6225
6506
|
}
|
|
6226
6507
|
this.deps.refStore.sync(userId, ctx);
|
|
@@ -6253,7 +6534,6 @@ var PipelineExecutor = class {
|
|
|
6253
6534
|
};
|
|
6254
6535
|
|
|
6255
6536
|
// src/lib/execution/fetch-service.ts
|
|
6256
|
-
init_persistence();
|
|
6257
6537
|
var FetchService = class {
|
|
6258
6538
|
constructor(store) {
|
|
6259
6539
|
this.store = store;
|
|
@@ -6422,12 +6702,10 @@ var CaptureManager = class {
|
|
|
6422
6702
|
|
|
6423
6703
|
// src/lib/auth/credential-store.ts
|
|
6424
6704
|
var CredentialStore = class {
|
|
6425
|
-
constructor(store
|
|
6705
|
+
constructor(store) {
|
|
6426
6706
|
this.store = store;
|
|
6427
|
-
this.config = config;
|
|
6428
6707
|
}
|
|
6429
6708
|
store;
|
|
6430
|
-
config;
|
|
6431
6709
|
async storeCredential(userId, token, credential) {
|
|
6432
6710
|
const credKey = await deriveKey(token, "credentials");
|
|
6433
6711
|
const normalizedDomain = normalizeDomain(credential.domain);
|
|
@@ -6450,53 +6728,16 @@ var CredentialStore = class {
|
|
|
6450
6728
|
async deleteCredential(userId, domain) {
|
|
6451
6729
|
await this.store.deleteCredential(userId, normalizeDomain(domain));
|
|
6452
6730
|
}
|
|
6453
|
-
async loginWithCredentials(userId, token, domain, selectors) {
|
|
6454
|
-
const session = getSession(userId);
|
|
6455
|
-
if (!session) return { ok: false, url: "", title: "", error: "No active interactive session. Start one first." };
|
|
6456
|
-
const resolved = await resolveCredential(this.store, userId, token, domain);
|
|
6457
|
-
if (!resolved) {
|
|
6458
|
-
const stored = await this.store.listCredentialDomains(userId);
|
|
6459
|
-
return { ok: false, url: "", title: "", error: `No credentials stored for ${normalizeDomain(domain)}. Stored: ${stored.join(", ") || "(none)"}` };
|
|
6460
|
-
}
|
|
6461
|
-
const { credential } = resolved;
|
|
6462
|
-
const page = session.page;
|
|
6463
|
-
resetTimeout(userId);
|
|
6464
|
-
if (selectors.username && credential.username) {
|
|
6465
|
-
await humanType(page, selectors.username, credential.username);
|
|
6466
|
-
await page.waitForTimeout(TIMING.PRE_NAVIGATE[0] + Math.random() * (TIMING.PRE_NAVIGATE[1] - TIMING.PRE_NAVIGATE[0]));
|
|
6467
|
-
}
|
|
6468
|
-
if (selectors.password && credential.password) {
|
|
6469
|
-
await humanType(page, selectors.password, credential.password);
|
|
6470
|
-
await page.waitForTimeout(TIMING.PRE_NAVIGATE[0] + Math.random() * (TIMING.PRE_NAVIGATE[1] - TIMING.PRE_NAVIGATE[0]));
|
|
6471
|
-
}
|
|
6472
|
-
if (selectors.submit) {
|
|
6473
|
-
await humanClick(page, selectors.submit);
|
|
6474
|
-
await page.waitForLoadState("domcontentloaded").catch(() => {
|
|
6475
|
-
});
|
|
6476
|
-
await page.waitForTimeout(TIMING.POST_LOGIN_WAIT);
|
|
6477
|
-
}
|
|
6478
|
-
if (selectors.totp && credential.totp_secret) {
|
|
6479
|
-
const totp = generateTOTP(credential.totp_secret);
|
|
6480
|
-
await page.click(selectors.totp);
|
|
6481
|
-
await page.keyboard.type(totp, { delay: 50 });
|
|
6482
|
-
await page.waitForTimeout(TIMING.POST_TOTP_WAIT);
|
|
6483
|
-
}
|
|
6484
|
-
const buf = await page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
|
|
6485
|
-
const screenshotUrl = saveScreenshot(buf, `login-${Date.now()}.jpg`, this.config.screenshotDir, this.config.publicUrl);
|
|
6486
|
-
return { ok: true, url: page.url(), title: await page.title(), screenshotUrl };
|
|
6487
|
-
}
|
|
6488
6731
|
};
|
|
6489
6732
|
|
|
6490
6733
|
// src/lib/iframer.ts
|
|
6491
|
-
var
|
|
6492
|
-
var
|
|
6493
|
-
var DEFAULT_PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3021}`;
|
|
6734
|
+
var log18 = createLogger("iframer");
|
|
6735
|
+
var DEFAULT_PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3022}`;
|
|
6494
6736
|
var DEFAULT_STALE_TIMEOUT_MS3 = 2e4;
|
|
6495
6737
|
var Iframer = class {
|
|
6496
6738
|
store;
|
|
6497
6739
|
daemon;
|
|
6498
6740
|
domainModes;
|
|
6499
|
-
operatingMode;
|
|
6500
6741
|
config;
|
|
6501
6742
|
refStore;
|
|
6502
6743
|
executor;
|
|
@@ -6505,25 +6746,23 @@ var Iframer = class {
|
|
|
6505
6746
|
credentials;
|
|
6506
6747
|
constructor(config = {}) {
|
|
6507
6748
|
this.config = {
|
|
6508
|
-
screenshotDir: config.screenshotDir ||
|
|
6749
|
+
screenshotDir: config.screenshotDir || getScreenshotDir(),
|
|
6509
6750
|
publicUrl: config.publicUrl || DEFAULT_PUBLIC_URL,
|
|
6510
6751
|
staleTimeoutMs: config.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS3
|
|
6511
6752
|
};
|
|
6512
6753
|
this.store = createStore({ dataDir: config.dataDir });
|
|
6513
6754
|
this.daemon = new BrowserDaemon(config.sessionTimeoutMs);
|
|
6514
6755
|
this.domainModes = new DomainModeStore();
|
|
6515
|
-
this.operatingMode = config.mode || "local";
|
|
6516
6756
|
this.refStore = new RefStore(this.store, this.config);
|
|
6517
6757
|
this.fetchService = new FetchService(this.store);
|
|
6518
6758
|
this.captureManager = new CaptureManager(this.daemon);
|
|
6519
|
-
this.credentials = new CredentialStore(this.store
|
|
6759
|
+
this.credentials = new CredentialStore(this.store);
|
|
6520
6760
|
this.executor = new PipelineExecutor({
|
|
6521
6761
|
daemon: this.daemon,
|
|
6522
6762
|
store: this.store,
|
|
6523
6763
|
domainModes: this.domainModes,
|
|
6524
6764
|
refStore: this.refStore,
|
|
6525
|
-
availableModes: () => this.getAvailableModes()
|
|
6526
|
-
startSession: (userId, token, options) => this.startSession(userId, token, options)
|
|
6765
|
+
availableModes: () => this.getAvailableModes()
|
|
6527
6766
|
});
|
|
6528
6767
|
}
|
|
6529
6768
|
// ─── Mode Detection ─────────────────────────────────────────────────
|
|
@@ -6531,7 +6770,6 @@ var Iframer = class {
|
|
|
6531
6770
|
const modes = ["headless"];
|
|
6532
6771
|
const { binaryHeadful } = checkModeAvailability();
|
|
6533
6772
|
if (binaryHeadful) modes.push("binary-headful");
|
|
6534
|
-
if (this.operatingMode === "docker") modes.push("docker-headful");
|
|
6535
6773
|
return modes;
|
|
6536
6774
|
}
|
|
6537
6775
|
async getModeAvailability() {
|
|
@@ -6541,10 +6779,6 @@ var Iframer = class {
|
|
|
6541
6779
|
"binary-headful": {
|
|
6542
6780
|
available: binaryHeadful,
|
|
6543
6781
|
reason: binaryHeadful ? void 0 : "No display available"
|
|
6544
|
-
},
|
|
6545
|
-
"docker-headful": {
|
|
6546
|
-
available: this.operatingMode === "docker",
|
|
6547
|
-
reason: this.operatingMode === "docker" ? void 0 : "Docker not configured"
|
|
6548
6782
|
}
|
|
6549
6783
|
};
|
|
6550
6784
|
}
|
|
@@ -6556,36 +6790,7 @@ var Iframer = class {
|
|
|
6556
6790
|
execute(userId, token, pipeline, runtime) {
|
|
6557
6791
|
return this.executor.execute(userId, token, pipeline, runtime);
|
|
6558
6792
|
}
|
|
6559
|
-
// ───
|
|
6560
|
-
async startSession(userId, token, options = {}) {
|
|
6561
|
-
const existing = getSession(userId);
|
|
6562
|
-
if (existing) {
|
|
6563
|
-
resetTimeout(userId);
|
|
6564
|
-
return {
|
|
6565
|
-
noVncUrl: `http://localhost:${existing.wsPort}/vnc.html?autoconnect=true`,
|
|
6566
|
-
wsPort: existing.wsPort
|
|
6567
|
-
};
|
|
6568
|
-
}
|
|
6569
|
-
const session = await startSession(userId);
|
|
6570
|
-
const encryptionKey = await deriveKey(token);
|
|
6571
|
-
const blob = await this.store.getSession(userId);
|
|
6572
|
-
let sessionData = null;
|
|
6573
|
-
if (blob && blob.length > 0) {
|
|
6574
|
-
sessionData = JSON.parse(decrypt(blob, encryptionKey));
|
|
6575
|
-
if (sessionData) await injectCookies(session.context, sessionData);
|
|
6576
|
-
}
|
|
6577
|
-
if (options.url) {
|
|
6578
|
-
await session.page.goto(options.url, { waitUntil: "domcontentloaded", timeout: TIMEOUTS.NAVIGATION });
|
|
6579
|
-
if (sessionData) await injectStorage(session.page, sessionData);
|
|
6580
|
-
}
|
|
6581
|
-
return {
|
|
6582
|
-
noVncUrl: `http://localhost:${session.wsPort}/vnc.html?autoconnect=true`,
|
|
6583
|
-
wsPort: session.wsPort
|
|
6584
|
-
};
|
|
6585
|
-
}
|
|
6586
|
-
getSession(userId) {
|
|
6587
|
-
return getSession(userId);
|
|
6588
|
-
}
|
|
6793
|
+
// ─── Sessions ────────────────────────────────────────────────────
|
|
6589
6794
|
async stopSession(userId, token) {
|
|
6590
6795
|
let sessionSaved = false;
|
|
6591
6796
|
if (token) {
|
|
@@ -6599,17 +6804,10 @@ var Iframer = class {
|
|
|
6599
6804
|
sessionSaved = true;
|
|
6600
6805
|
}
|
|
6601
6806
|
} catch (err) {
|
|
6602
|
-
|
|
6807
|
+
log18.warn(`stopSession: failed to extract daemon state for ${inst.mode}::${inst.instanceId}: ${getErrorMessage(err)}`);
|
|
6603
6808
|
}
|
|
6604
6809
|
}
|
|
6605
6810
|
}
|
|
6606
|
-
const dockerSessionData = await stopSession(userId);
|
|
6607
|
-
if (dockerSessionData && token) {
|
|
6608
|
-
const encryptionKey = await deriveKey(token);
|
|
6609
|
-
const encrypted = encrypt(JSON.stringify(dockerSessionData), encryptionKey);
|
|
6610
|
-
await this.store.setSession(userId, encrypted);
|
|
6611
|
-
sessionSaved = true;
|
|
6612
|
-
}
|
|
6613
6811
|
await this.daemon.stopAll();
|
|
6614
6812
|
return { ok: true, sessionSaved };
|
|
6615
6813
|
}
|
|
@@ -6637,30 +6835,21 @@ var Iframer = class {
|
|
|
6637
6835
|
listInstances() {
|
|
6638
6836
|
return this.daemon.instancesInfo();
|
|
6639
6837
|
}
|
|
6838
|
+
/** Close browsers that never navigated anywhere. See
|
|
6839
|
+
* BrowserDaemon.sweepBlankInstances — called from the server's reap tick. */
|
|
6840
|
+
sweepBlankBrowsers() {
|
|
6841
|
+
return this.daemon.sweepBlankInstances();
|
|
6842
|
+
}
|
|
6640
6843
|
/** Kill all browser instances and reset state. Next execute call will
|
|
6641
6844
|
* launch a fresh browser automatically — no manual restart needed. */
|
|
6642
6845
|
async restartBrowser() {
|
|
6643
6846
|
const health = this.browserHealth();
|
|
6644
6847
|
await this.daemon.stopAll(true);
|
|
6645
|
-
await cleanupAllSessions();
|
|
6646
6848
|
return {
|
|
6647
6849
|
killed: health.modes,
|
|
6648
6850
|
message: health.modes.length > 0 ? `Killed browser(s): ${health.modes.join(", ")}. Next execute call will launch a fresh instance.` : "No browsers were running. Next execute call will launch fresh."
|
|
6649
6851
|
};
|
|
6650
6852
|
}
|
|
6651
|
-
// ─── Screenshots ─────────────────────────────────────────────────
|
|
6652
|
-
async screenshot(userId) {
|
|
6653
|
-
const session = getSession(userId);
|
|
6654
|
-
if (!session) return null;
|
|
6655
|
-
resetTimeout(userId);
|
|
6656
|
-
const buf = await session.page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
|
|
6657
|
-
const screenshotUrl = saveScreenshot(buf, `screenshot-${Date.now()}.jpg`, this.config.screenshotDir, this.config.publicUrl);
|
|
6658
|
-
return {
|
|
6659
|
-
screenshotUrl,
|
|
6660
|
-
url: session.page.url(),
|
|
6661
|
-
title: await session.page.title()
|
|
6662
|
-
};
|
|
6663
|
-
}
|
|
6664
6853
|
// ─── Credentials ─────────────────────────────────────────────────
|
|
6665
6854
|
storeCredential(userId, token, credential) {
|
|
6666
6855
|
return this.credentials.storeCredential(userId, token, credential);
|
|
@@ -6674,9 +6863,6 @@ var Iframer = class {
|
|
|
6674
6863
|
deleteCredential(userId, domain) {
|
|
6675
6864
|
return this.credentials.deleteCredential(userId, domain);
|
|
6676
6865
|
}
|
|
6677
|
-
loginWithCredentials(userId, token, domain, selectors) {
|
|
6678
|
-
return this.credentials.loginWithCredentials(userId, token, domain, selectors);
|
|
6679
|
-
}
|
|
6680
6866
|
// ─── Session Data ─────────────────────────────────────────────────
|
|
6681
6867
|
async clearSession(userId) {
|
|
6682
6868
|
await this.store.deleteSession(userId);
|
|
@@ -6685,7 +6871,6 @@ var Iframer = class {
|
|
|
6685
6871
|
async shutdown() {
|
|
6686
6872
|
await this.daemon.stopAll(true);
|
|
6687
6873
|
await closeBrowser();
|
|
6688
|
-
await cleanupAllSessions();
|
|
6689
6874
|
if ("close" in this.store && typeof this.store.close === "function") {
|
|
6690
6875
|
this.store.close();
|
|
6691
6876
|
}
|
|
@@ -6715,20 +6900,18 @@ function errorHandler(err, _req, res, _next) {
|
|
|
6715
6900
|
}
|
|
6716
6901
|
|
|
6717
6902
|
// src/api/routes.ts
|
|
6718
|
-
var
|
|
6903
|
+
var import_fs10 = __toESM(require("fs"));
|
|
6719
6904
|
function auth(req) {
|
|
6720
6905
|
return req;
|
|
6721
6906
|
}
|
|
6722
|
-
var iframer = new Iframer(
|
|
6723
|
-
mode: process.env.IFRAMER_MODE || "local"
|
|
6724
|
-
});
|
|
6907
|
+
var iframer = new Iframer();
|
|
6725
6908
|
function registerRoutes(app2) {
|
|
6726
6909
|
app2.get("/health", (_req, res) => res.json({ ok: true }));
|
|
6727
6910
|
app2.get("/browsers", async (_req, res) => {
|
|
6728
6911
|
const browsers = [];
|
|
6729
6912
|
try {
|
|
6730
6913
|
const execPath = import_patchright3.chromium.executablePath();
|
|
6731
|
-
browsers.push({ name: "chromium", installed:
|
|
6914
|
+
browsers.push({ name: "chromium", installed: import_fs10.default.existsSync(execPath), executablePath: execPath });
|
|
6732
6915
|
} catch {
|
|
6733
6916
|
browsers.push({ name: "chromium", installed: false, executablePath: null });
|
|
6734
6917
|
}
|
|
@@ -6773,88 +6956,11 @@ function registerRoutes(app2) {
|
|
|
6773
6956
|
const result = await iframer.execute(r.userId, r.token, { steps, options });
|
|
6774
6957
|
res.json(result);
|
|
6775
6958
|
}));
|
|
6776
|
-
app2.post("/interactive/start", asyncHandler(async (req, res) => {
|
|
6777
|
-
const { url } = req.body || {};
|
|
6778
|
-
const r = auth(req);
|
|
6779
|
-
const result = await iframer.startSession(r.userId, r.token, { url });
|
|
6780
|
-
const existing = iframer.getSession(r.userId);
|
|
6781
|
-
res.json({
|
|
6782
|
-
ok: true,
|
|
6783
|
-
noVncUrl: result.noVncUrl,
|
|
6784
|
-
wsPort: result.wsPort,
|
|
6785
|
-
message: existing ? "Session already active" : void 0
|
|
6786
|
-
});
|
|
6787
|
-
}));
|
|
6788
|
-
app2.get("/interactive/status", (req, res) => {
|
|
6789
|
-
const session = iframer.getSession(auth(req).userId);
|
|
6790
|
-
if (!session) return res.json({ ok: true, active: false });
|
|
6791
|
-
res.json({
|
|
6792
|
-
ok: true,
|
|
6793
|
-
active: true,
|
|
6794
|
-
noVncUrl: `http://localhost:${session.wsPort}/vnc.html?autoconnect=true`,
|
|
6795
|
-
wsPort: session.wsPort,
|
|
6796
|
-
createdAt: session.createdAt.toISOString()
|
|
6797
|
-
});
|
|
6798
|
-
});
|
|
6799
6959
|
app2.post("/interactive/stop", asyncHandler(async (req, res) => {
|
|
6800
6960
|
const r = auth(req);
|
|
6801
6961
|
const result = await iframer.stopSession(r.userId, r.token);
|
|
6802
6962
|
res.json(result);
|
|
6803
6963
|
}));
|
|
6804
|
-
app2.get("/interactive/screenshot", asyncHandler(async (req, res) => {
|
|
6805
|
-
const r = auth(req);
|
|
6806
|
-
const session = iframer.getSession(r.userId);
|
|
6807
|
-
if (!session) throw new AppError(404, "No active interactive session");
|
|
6808
|
-
if (req.query.format === "raw") {
|
|
6809
|
-
const buf = await session.page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
|
|
6810
|
-
res.set("Content-Type", "image/jpeg");
|
|
6811
|
-
res.send(buf);
|
|
6812
|
-
return;
|
|
6813
|
-
}
|
|
6814
|
-
const result = await iframer.screenshot(r.userId);
|
|
6815
|
-
if (!result) throw new AppError(404, "No active interactive session");
|
|
6816
|
-
res.json({ ok: true, ...result });
|
|
6817
|
-
}));
|
|
6818
|
-
app2.post("/interactive/act", asyncHandler(async (req, res) => {
|
|
6819
|
-
const r = auth(req);
|
|
6820
|
-
const session = iframer.getSession(r.userId);
|
|
6821
|
-
if (!session) throw new AppError(404, "No active interactive session");
|
|
6822
|
-
const { action, screenshot: wantScreenshot = true } = req.body || {};
|
|
6823
|
-
if (!action || !action.type) throw new AppError(400, "Missing action.type");
|
|
6824
|
-
const result = await iframer.execute(r.userId, r.token, {
|
|
6825
|
-
steps: [action],
|
|
6826
|
-
options: { screenshotAfterEach: wantScreenshot, continueOnObstacle: false }
|
|
6827
|
-
});
|
|
6828
|
-
const stepResult = result.results[0];
|
|
6829
|
-
res.json({
|
|
6830
|
-
ok: result.ok,
|
|
6831
|
-
result: stepResult?.result,
|
|
6832
|
-
screenshotUrl: stepResult?.screenshotUrl || result.finalState?.screenshotUrl,
|
|
6833
|
-
url: result.finalState?.url,
|
|
6834
|
-
title: result.finalState?.title,
|
|
6835
|
-
error: result.error?.message
|
|
6836
|
-
});
|
|
6837
|
-
}));
|
|
6838
|
-
app2.post("/interactive/batch", asyncHandler(async (req, res) => {
|
|
6839
|
-
const r = auth(req);
|
|
6840
|
-
const session = iframer.getSession(r.userId);
|
|
6841
|
-
if (!session) throw new AppError(404, "No active interactive session");
|
|
6842
|
-
const { actions, screenshot: wantScreenshot = true, continueOnError = false } = req.body || {};
|
|
6843
|
-
if (!Array.isArray(actions) || actions.length === 0) {
|
|
6844
|
-
throw new AppError(400, "actions must be a non-empty array");
|
|
6845
|
-
}
|
|
6846
|
-
const result = await iframer.execute(r.userId, r.token, {
|
|
6847
|
-
steps: actions,
|
|
6848
|
-
options: { continueOnError, screenshotAfterEach: false }
|
|
6849
|
-
});
|
|
6850
|
-
res.json({
|
|
6851
|
-
ok: result.ok,
|
|
6852
|
-
results: result.results.map((r2) => ({ index: r2.stepIndex, ok: r2.ok, result: r2.result, error: r2.error })),
|
|
6853
|
-
screenshotUrl: wantScreenshot ? result.finalState?.screenshotUrl : void 0,
|
|
6854
|
-
url: result.finalState?.url,
|
|
6855
|
-
title: result.finalState?.title
|
|
6856
|
-
});
|
|
6857
|
-
}));
|
|
6858
6964
|
app2.delete("/session", asyncHandler(async (req, res) => {
|
|
6859
6965
|
await iframer.clearSession(auth(req).userId);
|
|
6860
6966
|
res.json({ ok: true });
|
|
@@ -6877,20 +6983,6 @@ function registerRoutes(app2) {
|
|
|
6877
6983
|
await iframer.deleteCredential(auth(req).userId, req.params.domain);
|
|
6878
6984
|
res.json({ ok: true, message: `Credentials for ${req.params.domain} deleted` });
|
|
6879
6985
|
}));
|
|
6880
|
-
app2.post("/credentials/login", asyncHandler(async (req, res) => {
|
|
6881
|
-
const { domain, usernameSelector, passwordSelector, submitSelector, totpSelector } = req.body || {};
|
|
6882
|
-
if (!domain) throw new AppError(400, "Missing domain");
|
|
6883
|
-
const r = auth(req);
|
|
6884
|
-
const result = await iframer.loginWithCredentials(r.userId, r.token, domain, {
|
|
6885
|
-
username: usernameSelector,
|
|
6886
|
-
password: passwordSelector,
|
|
6887
|
-
submit: submitSelector,
|
|
6888
|
-
totp: totpSelector
|
|
6889
|
-
});
|
|
6890
|
-
if (!result.ok) throw new AppError(400, result.error || "Login failed");
|
|
6891
|
-
const { ok: _ok, ...resultRest } = result;
|
|
6892
|
-
res.json({ ok: true, message: "Login attempted", ...resultRest });
|
|
6893
|
-
}));
|
|
6894
6986
|
app2.get("/extension/status", (_req, res) => {
|
|
6895
6987
|
res.json({ ok: true, version: getVersion(), ...extensionBridge.status() });
|
|
6896
6988
|
});
|
|
@@ -6975,23 +7067,23 @@ function tokenAuth(req, res, next) {
|
|
|
6975
7067
|
|
|
6976
7068
|
// index.ts
|
|
6977
7069
|
var app = (0, import_express.default)();
|
|
6978
|
-
var PORT = parseInt(process.env.PORT || "
|
|
7070
|
+
var PORT = parseInt(process.env.PORT || "3022", 10);
|
|
6979
7071
|
var REAP_INTERVAL_MS = 6e4;
|
|
6980
7072
|
var IDLE_EXIT_MS = parseInt(process.env.IFRAMER_SERVER_IDLE_EXIT_MS || String(30 * 60 * 1e3), 10);
|
|
6981
7073
|
var SHUTDOWN_DEADLINE_MS = 1e4;
|
|
6982
|
-
var SERVER_DIR =
|
|
7074
|
+
var SERVER_DIR = import_path11.default.dirname((0, import_url.fileURLToPath)(importMetaUrl));
|
|
6983
7075
|
var OWN_VERSION = (() => {
|
|
6984
|
-
for (const p of [
|
|
7076
|
+
for (const p of [import_path11.default.join(SERVER_DIR, "package.json"), import_path11.default.join(SERVER_DIR, "..", "package.json")]) {
|
|
6985
7077
|
try {
|
|
6986
|
-
const pkg = JSON.parse(
|
|
7078
|
+
const pkg = JSON.parse(import_fs11.default.readFileSync(p, "utf8"));
|
|
6987
7079
|
if (pkg.name === "iframer-toolkit") return pkg.version;
|
|
6988
7080
|
} catch {
|
|
6989
7081
|
}
|
|
6990
7082
|
}
|
|
6991
7083
|
return void 0;
|
|
6992
7084
|
})();
|
|
6993
|
-
var SCREENSHOT_DIR =
|
|
6994
|
-
|
|
7085
|
+
var SCREENSHOT_DIR = getScreenshotDir();
|
|
7086
|
+
import_fs11.default.mkdirSync(SCREENSHOT_DIR, { recursive: true, mode: 448 });
|
|
6995
7087
|
app.use("/screenshots", import_express.default.static(SCREENSHOT_DIR));
|
|
6996
7088
|
app.use(import_express.default.json());
|
|
6997
7089
|
var lastActivity = Date.now();
|
|
@@ -7053,6 +7145,10 @@ var reapTimer = setInterval(async () => {
|
|
|
7053
7145
|
await reapOrphanBrowsers();
|
|
7054
7146
|
} catch {
|
|
7055
7147
|
}
|
|
7148
|
+
try {
|
|
7149
|
+
iframer.sweepBlankBrowsers();
|
|
7150
|
+
} catch {
|
|
7151
|
+
}
|
|
7056
7152
|
const idleMs = Date.now() - lastActivity;
|
|
7057
7153
|
if (idleMs > IDLE_EXIT_MS && !iframer.browserHealth().alive && !extensionBridge.hasClients()) {
|
|
7058
7154
|
gracefulShutdown(`idle for ${Math.round(idleMs / 6e4)}min with no browsers`);
|