@detiq/crawler 1.1.9 → 1.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accessibility-auditor.js +54 -74
- package/dist/ai-login.js +227 -249
- package/dist/auth-token-rotator.js +57 -67
- package/dist/auth.js +299 -336
- package/dist/browser-extension.js +104 -142
- package/dist/captcha-solver.js +98 -125
- package/dist/color-contrast.js +77 -89
- package/dist/content-chunker.js +126 -126
- package/dist/content-dedup.js +54 -71
- package/dist/cookie-consent.js +68 -83
- package/dist/cookie-jar.js +53 -60
- package/dist/crawler.js +4494 -5353
- package/dist/custom-js-injector.js +45 -61
- package/dist/dark-mode-capture.js +67 -81
- package/dist/dead-link-detector.js +47 -55
- package/dist/element-hash.js +8 -12
- package/dist/email-otp.js +154 -195
- package/dist/engine-router.js +87 -98
- package/dist/export/bulk-export.js +70 -55
- package/dist/export/vector-export.js +77 -105
- package/dist/extractors/advanced.js +249 -266
- package/dist/extractors/artifact-envelope.js +59 -58
- package/dist/failure-analysis-agent.js +37 -33
- package/dist/feature-flag-detector.js +51 -66
- package/dist/form-detector.js +55 -75
- package/dist/frontier.js +76 -101
- package/dist/gap-pass.js +68 -89
- package/dist/graphql-detector.js +62 -72
- package/dist/http-engine.js +282 -330
- package/dist/incremental-diff.js +54 -87
- package/dist/index.js +67 -28
- package/dist/input-fuzzer.js +93 -97
- package/dist/interactive-probe.js +715 -827
- package/dist/job-queue.js +2151 -2169
- package/dist/js-coverage.js +52 -56
- package/dist/js-router-detector.js +86 -117
- package/dist/language-detector.js +51 -63
- package/dist/link-classifier.js +32 -50
- package/dist/live-view.js +91 -108
- package/dist/locator-extractor.js +151 -187
- package/dist/logger.js +9 -7
- package/dist/markdown-extract.js +48 -61
- package/dist/native-crawler.js +780 -946
- package/dist/network-observer.js +135 -156
- package/dist/network-throttle.js +29 -40
- package/dist/openapi-generator.js +77 -92
- package/dist/pdf-extractor.js +72 -90
- package/dist/performance-metrics.js +80 -102
- package/dist/phash.js +125 -144
- package/dist/pii-scrubber.js +65 -68
- package/dist/playwright-recorder.js +70 -58
- package/dist/policies/crawl-safety.js +31 -34
- package/dist/profiles/domain-profile.js +130 -125
- package/dist/proxy.js +146 -172
- package/dist/rate-limiter.js +58 -65
- package/dist/redirect-tracker.js +60 -64
- package/dist/redis-frontier.js +116 -136
- package/dist/redis-queue.js +157 -144
- package/dist/response-cache.js +127 -140
- package/dist/responsive-detector.js +42 -46
- package/dist/retry-policy.js +46 -50
- package/dist/robots-txt.js +69 -83
- package/dist/screen-reader.js +45 -70
- package/dist/screenshot-masker.js +59 -77
- package/dist/seo-checker.js +88 -107
- package/dist/session-capture.js +192 -212
- package/dist/session-export.js +107 -133
- package/dist/sitemap-writer.js +38 -41
- package/dist/sitemap.js +141 -202
- package/dist/skyvern.js +123 -148
- package/dist/sqs-queue.js +42 -42
- package/dist/sso-token.js +74 -90
- package/dist/stealth/human-behavior.js +156 -173
- package/dist/telemetry.js +68 -82
- package/dist/test-executor.js +309 -335
- package/dist/tls-fingerprint.js +77 -88
- package/dist/url-pattern-completer.js +71 -90
- package/dist/visual-diff-verdict.js +23 -38
- package/dist/webhook-notifier.js +71 -69
- package/dist/websocket-detector.js +56 -68
- package/dist/worker.js +29 -38
- package/package.json +1 -1
package/dist/auth.js
CHANGED
|
@@ -1,366 +1,329 @@
|
|
|
1
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
2
1
|
async function firstVisible(page, selector) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
const loc = page.locator(selector);
|
|
3
|
+
const count = await loc.count().catch(() => 0);
|
|
4
|
+
for (let i = 0; i < count; i += 1) {
|
|
5
|
+
const item = loc.nth(i);
|
|
6
|
+
if (await item.isVisible().catch(() => false)) return item;
|
|
7
|
+
}
|
|
8
|
+
for (const frame of page.frames()) {
|
|
9
|
+
if (frame === page.mainFrame()) continue;
|
|
10
|
+
const floc = frame.locator(selector);
|
|
11
|
+
const fcount = await floc.count().catch(() => 0);
|
|
12
|
+
for (let i = 0; i < fcount; i += 1) {
|
|
13
|
+
const item = floc.nth(i);
|
|
14
|
+
if (await item.isVisible().catch(() => false)) return item;
|
|
9
15
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (frame === page.mainFrame())
|
|
13
|
-
continue;
|
|
14
|
-
const floc = frame.locator(selector);
|
|
15
|
-
const fcount = await floc.count().catch(() => 0);
|
|
16
|
-
for (let i = 0; i < fcount; i += 1) {
|
|
17
|
-
const item = floc.nth(i);
|
|
18
|
-
if (await item.isVisible().catch(() => false))
|
|
19
|
-
return item;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return null;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
23
18
|
}
|
|
24
|
-
/** Type into framework-controlled inputs — handles React, Angular, Vue, and plain HTML forms. */
|
|
25
19
|
async function reactFill(field, value) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
20
|
+
if (!field) return;
|
|
21
|
+
await field.click();
|
|
22
|
+
await field.selectText().catch(() => {
|
|
23
|
+
});
|
|
24
|
+
await field.press("Control+a").catch(() => {
|
|
25
|
+
});
|
|
26
|
+
await field.press("Backspace").catch(() => {
|
|
27
|
+
});
|
|
28
|
+
await field.pressSequentially(value, { delay: 30 });
|
|
29
|
+
await field.evaluate((el, v) => {
|
|
30
|
+
const input = el;
|
|
31
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
|
32
|
+
if (nativeInputValueSetter) nativeInputValueSetter.call(input, v);
|
|
33
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
34
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
35
|
+
input.dispatchEvent(new Event("blur", { bubbles: true }));
|
|
36
|
+
}, value).catch(() => {
|
|
37
|
+
});
|
|
44
38
|
}
|
|
45
|
-
/**
|
|
46
|
-
* Find and click the most likely submit button.
|
|
47
|
-
* Waits up to 2 s for React's disabled={!email||!password} to clear before clicking.
|
|
48
|
-
*/
|
|
49
39
|
async function clickLikelySubmit(page) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const text = (await c.textContent().catch(() => '')) ?? '';
|
|
70
|
-
if (/continue\s+with\s+/i.test(text))
|
|
71
|
-
continue; // skip "Continue with Google/GitHub/Apple"
|
|
72
|
-
button = c;
|
|
73
|
-
break;
|
|
74
|
-
}
|
|
40
|
+
let button = await firstVisible(page, [
|
|
41
|
+
'button[type="submit"]',
|
|
42
|
+
'input[type="submit"]',
|
|
43
|
+
'button:has-text("Next")',
|
|
44
|
+
'button:has-text("Sign in")',
|
|
45
|
+
'button:has-text("Log in")',
|
|
46
|
+
'button:has-text("Login")',
|
|
47
|
+
'button:has-text("Sign up")'
|
|
48
|
+
].join(", "));
|
|
49
|
+
if (!button) {
|
|
50
|
+
const candidates = page.locator('button:has-text("Continue")');
|
|
51
|
+
const count = await candidates.count().catch(() => 0);
|
|
52
|
+
for (let i = 0; i < count; i++) {
|
|
53
|
+
const c = candidates.nth(i);
|
|
54
|
+
if (!await c.isVisible().catch(() => false)) continue;
|
|
55
|
+
const text = await c.textContent().catch(() => "") ?? "";
|
|
56
|
+
if (/continue\s+with\s+/i.test(text)) continue;
|
|
57
|
+
button = c;
|
|
58
|
+
break;
|
|
75
59
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
await Promise.all([
|
|
89
|
-
page.waitForLoadState('networkidle').catch(() => { }),
|
|
90
|
-
button.click({ timeout: 5_000 }).catch(() => { }), // page may navigate before click completes — ignore
|
|
91
|
-
]);
|
|
92
|
-
return true;
|
|
60
|
+
}
|
|
61
|
+
if (button) {
|
|
62
|
+
const handle = await button.elementHandle().catch(() => null);
|
|
63
|
+
if (handle) {
|
|
64
|
+
const deadline = Date.now() + 2e3;
|
|
65
|
+
while (Date.now() < deadline) {
|
|
66
|
+
const disabled = await handle.evaluate((el) => el.disabled).catch(() => false);
|
|
67
|
+
if (!disabled) break;
|
|
68
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
69
|
+
}
|
|
93
70
|
}
|
|
94
|
-
await
|
|
95
|
-
|
|
96
|
-
|
|
71
|
+
await Promise.all([
|
|
72
|
+
page.waitForLoadState("networkidle").catch(() => {
|
|
73
|
+
}),
|
|
74
|
+
button.click({ timeout: 5e3 }).catch(() => {
|
|
75
|
+
})
|
|
76
|
+
// page may navigate before click completes — ignore
|
|
77
|
+
]);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
await page.keyboard.press("Enter");
|
|
81
|
+
await page.waitForLoadState("networkidle").catch(() => {
|
|
82
|
+
});
|
|
83
|
+
return false;
|
|
97
84
|
}
|
|
98
|
-
/** Dismiss cookie/GDPR consent banners so they don't block form fields. */
|
|
99
85
|
async function dismissCookieBanners(page) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
86
|
+
const acceptSel = [
|
|
87
|
+
'button:has-text("Accept")',
|
|
88
|
+
'button:has-text("Accept all")',
|
|
89
|
+
'button:has-text("Allow all")',
|
|
90
|
+
'button:has-text("I agree")',
|
|
91
|
+
'button:has-text("Agree")',
|
|
92
|
+
'button:has-text("OK")',
|
|
93
|
+
'button:has-text("Got it")',
|
|
94
|
+
'button:has-text("Close")',
|
|
95
|
+
'[aria-label*="Accept" i]',
|
|
96
|
+
'[aria-label*="Dismiss" i]',
|
|
97
|
+
'[class*="cookie"] button',
|
|
98
|
+
'[id*="cookie"] button',
|
|
99
|
+
'[class*="consent"] button',
|
|
100
|
+
'[class*="banner"] button[class*="close" i]'
|
|
101
|
+
].join(", ");
|
|
102
|
+
const btn = await firstVisible(page, acceptSel);
|
|
103
|
+
if (btn) await btn.click().catch(() => {
|
|
104
|
+
});
|
|
105
|
+
await page.waitForTimeout(300).catch(() => {
|
|
106
|
+
});
|
|
120
107
|
}
|
|
121
|
-
/**
|
|
122
|
-
* Look for a Sign In / Log In nav link on a marketing homepage and navigate to the actual login page.
|
|
123
|
-
* Returns true if navigation happened.
|
|
124
|
-
*/
|
|
125
108
|
async function navigateToLoginPage(page, appUrl) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
109
|
+
const origin = (() => {
|
|
110
|
+
try {
|
|
111
|
+
return new URL(appUrl).origin;
|
|
112
|
+
} catch {
|
|
113
|
+
return "";
|
|
129
114
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (href && href.startsWith('http')) {
|
|
155
|
-
await page.goto(href, { waitUntil: 'networkidle', timeout: 15_000 }).catch(() => { });
|
|
156
|
-
return true;
|
|
157
|
-
}
|
|
158
|
-
// Click and immediately check for email field (modal opened) OR wait for navigation
|
|
159
|
-
await navLink.click();
|
|
160
|
-
const emailAppeared = await page.waitForSelector(EMAIL_SELECTOR, { timeout: 3_000 })
|
|
161
|
-
.then(() => true).catch(() => false);
|
|
162
|
-
if (!emailAppeared) {
|
|
163
|
-
// No modal — likely navigating to a new page
|
|
164
|
-
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
|
|
165
|
-
}
|
|
166
|
-
return true;
|
|
115
|
+
})();
|
|
116
|
+
const loginPaths = ["/login", "/sign-in", "/signin", "/auth/login", "/auth", "/account/login", "/user/login", "/app/login"];
|
|
117
|
+
const navLoginSel = [
|
|
118
|
+
'a:has-text("Sign in")',
|
|
119
|
+
'a:has-text("Sign In")',
|
|
120
|
+
'a:has-text("Log in")',
|
|
121
|
+
'a:has-text("Log In")',
|
|
122
|
+
'a:has-text("Login")',
|
|
123
|
+
'a:has-text("Get started")',
|
|
124
|
+
'a[href*="login" i]',
|
|
125
|
+
'a[href*="sign-in" i]',
|
|
126
|
+
'a[href*="signin" i]',
|
|
127
|
+
'a[href*="auth" i]',
|
|
128
|
+
'button:has-text("Sign in")',
|
|
129
|
+
'button:has-text("Log in")',
|
|
130
|
+
'button:has-text("Login")'
|
|
131
|
+
].join(", ");
|
|
132
|
+
const navLink = await firstVisible(page, navLoginSel);
|
|
133
|
+
if (navLink) {
|
|
134
|
+
const href = await navLink.getAttribute("href").catch(() => null);
|
|
135
|
+
if (href && href.startsWith("http")) {
|
|
136
|
+
await page.goto(href, { waitUntil: "networkidle", timeout: 15e3 }).catch(() => {
|
|
137
|
+
});
|
|
138
|
+
return true;
|
|
167
139
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (res && res.status() < 400) {
|
|
174
|
-
// Check if this page actually has a login form
|
|
175
|
-
const hasForm = (await page.locator('input[type="email"], input[type="password"], input[name*="email" i], input[name*="user" i]').count().catch(() => 0)) > 0;
|
|
176
|
-
if (hasForm)
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
// Restore original page
|
|
181
|
-
await page.goto(appUrl, { waitUntil: 'networkidle', timeout: 15_000 }).catch(() => { });
|
|
140
|
+
await navLink.click();
|
|
141
|
+
const emailAppeared = await page.waitForSelector(EMAIL_SELECTOR, { timeout: 3e3 }).then(() => true).catch(() => false);
|
|
142
|
+
if (!emailAppeared) {
|
|
143
|
+
await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
144
|
+
});
|
|
182
145
|
}
|
|
183
|
-
return
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
if (origin) {
|
|
149
|
+
for (const p of loginPaths) {
|
|
150
|
+
const loginUrl = `${origin}${p}`;
|
|
151
|
+
const res = await page.goto(loginUrl, { waitUntil: "networkidle", timeout: 1e4 }).catch(() => null);
|
|
152
|
+
if (res && res.status() < 400) {
|
|
153
|
+
const hasForm = await page.locator(
|
|
154
|
+
'input[type="email"], input[type="password"], input[name*="email" i], input[name*="user" i]'
|
|
155
|
+
).count().catch(() => 0) > 0;
|
|
156
|
+
if (hasForm) return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
await page.goto(appUrl, { waitUntil: "networkidle", timeout: 15e3 }).catch(() => {
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
184
163
|
}
|
|
185
|
-
/** Detect if current page redirected to a 3rd-party OAuth provider (SSO flow we can't automate). */
|
|
186
164
|
function isOAuthProviderUrl(url) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (u.includes('okta.com'))
|
|
199
|
-
return 'Okta';
|
|
200
|
-
if (u.includes('auth0.com'))
|
|
201
|
-
return 'Auth0';
|
|
202
|
-
if (u.includes('onelogin.com'))
|
|
203
|
-
return 'OneLogin';
|
|
204
|
-
if (u.includes('pingidentity.com'))
|
|
205
|
-
return 'Ping Identity';
|
|
206
|
-
return null;
|
|
165
|
+
const u = url.toLowerCase();
|
|
166
|
+
if (u.includes("accounts.google.com")) return "Google";
|
|
167
|
+
if (u.includes("github.com/login")) return "GitHub";
|
|
168
|
+
if (u.includes("login.microsoftonline.com")) return "Microsoft";
|
|
169
|
+
if (u.includes("login.live.com")) return "Microsoft";
|
|
170
|
+
if (u.includes("appleid.apple.com")) return "Apple";
|
|
171
|
+
if (u.includes("okta.com")) return "Okta";
|
|
172
|
+
if (u.includes("auth0.com")) return "Auth0";
|
|
173
|
+
if (u.includes("onelogin.com")) return "OneLogin";
|
|
174
|
+
if (u.includes("pingidentity.com")) return "Ping Identity";
|
|
175
|
+
return null;
|
|
207
176
|
}
|
|
208
177
|
const EMAIL_SELECTOR = 'input[type="email"], input[name*="user" i], input[name*="email" i], input[autocomplete="username"]';
|
|
209
178
|
const PASS_SELECTOR = 'input[type="password"], input[autocomplete="current-password"]';
|
|
210
|
-
/** Replay recorded login steps from the Chrome extension. Returns true if login succeeded. */
|
|
211
179
|
async function replayLoginRecipe(page, steps, creds) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
await page.waitForTimeout(200).catch(() => { });
|
|
233
|
-
}
|
|
234
|
-
catch {
|
|
235
|
-
return false;
|
|
236
|
-
}
|
|
180
|
+
for (const step of steps) {
|
|
181
|
+
try {
|
|
182
|
+
if (step.action === "wait") {
|
|
183
|
+
await page.waitForTimeout(step.ms ?? 500).catch(() => {
|
|
184
|
+
});
|
|
185
|
+
} else if (step.action === "click") {
|
|
186
|
+
const el = await firstVisible(page, step.selector);
|
|
187
|
+
if (!el) return false;
|
|
188
|
+
await el.click({ timeout: 5e3 }).catch(() => {
|
|
189
|
+
});
|
|
190
|
+
} else if (step.action === "fill") {
|
|
191
|
+
const el = await firstVisible(page, step.selector);
|
|
192
|
+
if (!el) return false;
|
|
193
|
+
const val = (step.value ?? "").replace("{username}", creds.username).replace("{password}", creds.password);
|
|
194
|
+
await reactFill(el, val);
|
|
195
|
+
}
|
|
196
|
+
await page.waitForTimeout(200).catch(() => {
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
237
200
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
201
|
+
}
|
|
202
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
|
|
203
|
+
});
|
|
204
|
+
const postUrl = page.url();
|
|
205
|
+
const LOGIN_PATH_RE = /\/(login|signin|sign-in|auth|log-in|sso|oauth)(\/|$|\?)/i;
|
|
206
|
+
if (!LOGIN_PATH_RE.test(new URL(postUrl).pathname)) return true;
|
|
207
|
+
const passCount = await page.locator('input[type="password"]').count().catch(() => 0);
|
|
208
|
+
return passCount === 0;
|
|
245
209
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
await
|
|
259
|
-
|
|
260
|
-
// No email field on the landing page — this is a marketing homepage.
|
|
261
|
-
// Try to navigate to the actual login page.
|
|
210
|
+
async function login(page, appUrl, creds) {
|
|
211
|
+
await page.goto(appUrl, { waitUntil: "networkidle", timeout: 3e4 }).catch(() => {
|
|
212
|
+
});
|
|
213
|
+
if (creds.loginRecipe && creds.loginRecipe.length > 0) {
|
|
214
|
+
const recipeOk = await replayLoginRecipe(page, creds.loginRecipe, creds);
|
|
215
|
+
if (recipeOk) return true;
|
|
216
|
+
await page.goto(appUrl, { waitUntil: "networkidle", timeout: 3e4 }).catch(() => {
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
await dismissCookieBanners(page);
|
|
220
|
+
let userField = await firstVisible(page, EMAIL_SELECTOR);
|
|
221
|
+
if (!userField) {
|
|
222
|
+
await navigateToLoginPage(page, appUrl);
|
|
223
|
+
userField = await firstVisible(page, EMAIL_SELECTOR);
|
|
262
224
|
if (!userField) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
userField = await firstVisible(page, EMAIL_SELECTOR);
|
|
267
|
-
if (!userField) {
|
|
268
|
-
// Last attempt: wait up to 5s for form to appear (SPA redirect or slow modal)
|
|
269
|
-
await page.waitForSelector(EMAIL_SELECTOR, { timeout: 5_000 }).catch(() => { });
|
|
270
|
-
userField = await firstVisible(page, EMAIL_SELECTOR);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
if (!userField)
|
|
274
|
-
return false;
|
|
275
|
-
await reactFill(userField, creds.username);
|
|
276
|
-
await page.keyboard.press('Tab');
|
|
277
|
-
let passField = await firstVisible(page, PASS_SELECTOR);
|
|
278
|
-
if (!passField) {
|
|
279
|
-
// Multi-step form: email-first, then password on next screen
|
|
280
|
-
await clickLikelySubmit(page);
|
|
281
|
-
// Wait for password field to appear (up to 8s) rather than a fixed 2s sleep —
|
|
282
|
-
// slow IdP servers (Okta, Azure) take 3-5s to validate email and render the next step.
|
|
283
|
-
await page.waitForSelector(PASS_SELECTOR, { timeout: 8_000 }).catch(() => { });
|
|
284
|
-
// Check for SSO redirect after email submission
|
|
285
|
-
const ssoProvider = isOAuthProviderUrl(page.url());
|
|
286
|
-
if (ssoProvider) {
|
|
287
|
-
throw Object.assign(new Error(`This app uses ${ssoProvider} SSO — ZeTa cannot automate OAuth login. ` +
|
|
288
|
-
`Use the ZeTa Capture Chrome Extension instead: log in normally in Chrome, ` +
|
|
289
|
-
`then click the extension to capture your session.`), { isLoginError: true, isSSOError: true, ssoProvider });
|
|
290
|
-
}
|
|
291
|
-
passField = await firstVisible(page, PASS_SELECTOR);
|
|
292
|
-
}
|
|
293
|
-
if (!passField) {
|
|
294
|
-
// Still no password field — check if we landed on an SSO provider
|
|
295
|
-
const ssoProvider = isOAuthProviderUrl(page.url());
|
|
296
|
-
if (ssoProvider) {
|
|
297
|
-
throw Object.assign(new Error(`This app uses ${ssoProvider} SSO — ZeTa cannot automate OAuth login. ` +
|
|
298
|
-
`Use the ZeTa Capture Chrome Extension instead: log in normally in Chrome, ` +
|
|
299
|
-
`then click the extension to capture your session.`), { isLoginError: true, isSSOError: true, ssoProvider });
|
|
300
|
-
}
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
await reactFill(passField, creds.password);
|
|
304
|
-
// Press Enter — submits regardless of button disabled state
|
|
305
|
-
await page.waitForTimeout(150).catch(() => { });
|
|
306
|
-
await Promise.all([
|
|
307
|
-
page.waitForLoadState('networkidle').catch(() => { }),
|
|
308
|
-
page.keyboard.press('Enter'),
|
|
309
|
-
]);
|
|
310
|
-
// Fallback: if Enter didn't navigate away, try Tab → click
|
|
311
|
-
const stillHasPass = await page.locator(PASS_SELECTOR).count().catch(() => 0);
|
|
312
|
-
if (stillHasPass > 0) {
|
|
313
|
-
await page.keyboard.press('Tab');
|
|
314
|
-
await clickLikelySubmit(page);
|
|
315
|
-
}
|
|
316
|
-
// Wait for post-submit navigation to complete — waitForLoadState resolves immediately
|
|
317
|
-
// if page is already idle, so use waitForURL to detect actual navigation away from login.
|
|
318
|
-
const LOGIN_PATH_RE_NAV = /\/(login|signin|sign-in|auth|log-in|sso|oauth)(\/|$|\?)/i;
|
|
319
|
-
if (LOGIN_PATH_RE_NAV.test(new URL(page.url()).pathname)) {
|
|
320
|
-
await page.waitForURL((url) => { try {
|
|
321
|
-
return !LOGIN_PATH_RE_NAV.test(new URL(url).pathname);
|
|
322
|
-
}
|
|
323
|
-
catch {
|
|
324
|
-
return true;
|
|
325
|
-
} }, { timeout: 12_000 }).catch(() => { });
|
|
225
|
+
await page.waitForSelector(EMAIL_SELECTOR, { timeout: 5e3 }).catch(() => {
|
|
226
|
+
});
|
|
227
|
+
userField = await firstVisible(page, EMAIL_SELECTOR);
|
|
326
228
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
229
|
+
}
|
|
230
|
+
if (!userField) return false;
|
|
231
|
+
await reactFill(userField, creds.username);
|
|
232
|
+
await page.keyboard.press("Tab");
|
|
233
|
+
let passField = await firstVisible(page, PASS_SELECTOR);
|
|
234
|
+
if (!passField) {
|
|
235
|
+
await clickLikelySubmit(page);
|
|
236
|
+
await page.waitForSelector(PASS_SELECTOR, { timeout: 8e3 }).catch(() => {
|
|
237
|
+
});
|
|
238
|
+
const ssoProvider = isOAuthProviderUrl(page.url());
|
|
239
|
+
if (ssoProvider) {
|
|
240
|
+
throw Object.assign(
|
|
241
|
+
new Error(
|
|
242
|
+
`This app uses ${ssoProvider} SSO \u2014 ZeTa cannot automate OAuth login. Use the ZeTa Capture Chrome Extension instead: log in normally in Chrome, then click the extension to capture your session.`
|
|
243
|
+
),
|
|
244
|
+
{ isLoginError: true, isSSOError: true, ssoProvider }
|
|
245
|
+
);
|
|
332
246
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
return false;
|
|
345
|
-
await reactFill(otpField, code);
|
|
346
|
-
await clickLikelySubmit(page);
|
|
247
|
+
passField = await firstVisible(page, PASS_SELECTOR);
|
|
248
|
+
}
|
|
249
|
+
if (!passField) {
|
|
250
|
+
const ssoProvider = isOAuthProviderUrl(page.url());
|
|
251
|
+
if (ssoProvider) {
|
|
252
|
+
throw Object.assign(
|
|
253
|
+
new Error(
|
|
254
|
+
`This app uses ${ssoProvider} SSO \u2014 ZeTa cannot automate OAuth login. Use the ZeTa Capture Chrome Extension instead: log in normally in Chrome, then click the extension to capture your session.`
|
|
255
|
+
),
|
|
256
|
+
{ isLoginError: true, isSSOError: true, ssoProvider }
|
|
257
|
+
);
|
|
347
258
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
await reactFill(passField, creds.password);
|
|
262
|
+
await page.waitForTimeout(150).catch(() => {
|
|
263
|
+
});
|
|
264
|
+
await Promise.all([
|
|
265
|
+
page.waitForLoadState("networkidle").catch(() => {
|
|
266
|
+
}),
|
|
267
|
+
page.keyboard.press("Enter")
|
|
268
|
+
]);
|
|
269
|
+
const stillHasPass = await page.locator(PASS_SELECTOR).count().catch(() => 0);
|
|
270
|
+
if (stillHasPass > 0) {
|
|
271
|
+
await page.keyboard.press("Tab");
|
|
272
|
+
await clickLikelySubmit(page);
|
|
273
|
+
}
|
|
274
|
+
const LOGIN_PATH_RE_NAV = /\/(login|signin|sign-in|auth|log-in|sso|oauth)(\/|$|\?)/i;
|
|
275
|
+
if (LOGIN_PATH_RE_NAV.test(new URL(page.url()).pathname)) {
|
|
276
|
+
await page.waitForURL(
|
|
277
|
+
(url) => {
|
|
278
|
+
try {
|
|
279
|
+
return !LOGIN_PATH_RE_NAV.test(new URL(url).pathname);
|
|
280
|
+
} catch {
|
|
281
|
+
return true;
|
|
362
282
|
}
|
|
363
|
-
|
|
283
|
+
},
|
|
284
|
+
{ timeout: 12e3 }
|
|
285
|
+
).catch(() => {
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const ssoAfterLogin = isOAuthProviderUrl(page.url());
|
|
289
|
+
if (ssoAfterLogin) {
|
|
290
|
+
throw Object.assign(
|
|
291
|
+
new Error(
|
|
292
|
+
`This app uses ${ssoAfterLogin} SSO \u2014 ZeTa cannot automate OAuth login. Use the ZeTa Capture Chrome Extension instead.`
|
|
293
|
+
),
|
|
294
|
+
{ isLoginError: true, isSSOError: true, ssoProvider: ssoAfterLogin }
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const otpField = await firstVisible(page, [
|
|
298
|
+
'input[autocomplete="one-time-code"]',
|
|
299
|
+
'input[name*="otp" i]',
|
|
300
|
+
'input[name*="code" i]',
|
|
301
|
+
'input[aria-label*="code" i]',
|
|
302
|
+
'input[placeholder*="code" i]'
|
|
303
|
+
].join(", "));
|
|
304
|
+
if (otpField) {
|
|
305
|
+
const code = creds.oneTimeCode ?? await creds.getOneTimeCode?.();
|
|
306
|
+
if (!code) return false;
|
|
307
|
+
await reactFill(otpField, code);
|
|
308
|
+
await clickLikelySubmit(page);
|
|
309
|
+
}
|
|
310
|
+
const postLoginUrl = page.url();
|
|
311
|
+
const LOGIN_PATH_RE = /\/(login|signin|sign-in|auth|log-in|sso|oauth)(\/|$|\?)/i;
|
|
312
|
+
const stillOnLoginPath = LOGIN_PATH_RE.test(new URL(postLoginUrl).pathname);
|
|
313
|
+
const stillOnLogin = await page.locator(PASS_SELECTOR).count();
|
|
314
|
+
if (stillOnLoginPath && stillOnLogin > 0) {
|
|
315
|
+
const errorLoc = page.locator('[role="alert"], .error, [class*="error"], [class*="alert"]');
|
|
316
|
+
await errorLoc.first().waitFor({ state: "visible", timeout: 2e3 }).catch(() => {
|
|
317
|
+
});
|
|
318
|
+
const pageError = await errorLoc.first().textContent().catch(() => null);
|
|
319
|
+
if (pageError?.trim()) {
|
|
320
|
+
throw Object.assign(new Error(pageError.trim()), { isLoginError: true });
|
|
364
321
|
}
|
|
365
|
-
return
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
return true;
|
|
366
325
|
}
|
|
326
|
+
export {
|
|
327
|
+
login
|
|
328
|
+
};
|
|
329
|
+
//# sourceMappingURL=auth.js.map
|