app-manager-edge-worker 1.2.5 → 1.3.1
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 +22 -0
- package/package.json +1 -1
- package/src/banner.js +1 -1
- package/src/client.js +4 -1
- package/src/handlers/interactiveHandler.js +442 -102
package/README.md
CHANGED
|
@@ -201,6 +201,21 @@ Log levels: `[INFO]` · `[SUCCESS]` · `[WARN]` · `[ERROR]` · `[TASK]` (job di
|
|
|
201
201
|
|
|
202
202
|
---
|
|
203
203
|
|
|
204
|
+
### v1.3.1
|
|
205
|
+
- **Robust Portal Input Resolution & Dynamic SPA Lifecycle Engine**:
|
|
206
|
+
- **Dynamic Multi-Selector Fallback Generation**: Form input selectors now automatically generate and evaluate candidate fallbacks (e.g. `input[name="for_gstin"]` expands to include `#for_gstin`, `[name="for_gstin"]`, `input#for_gstin`, `input[data-ng-model*="gstin"]`, and placeholder variants), resolving elements regardless of framework attribute formatting.
|
|
207
|
+
- **IFrame-Aware Element Discovery**: Introduced `locateElementInPageOrFrames` across `HITL_SESSION_INIT`, `HITL_SESSION_REFRESH`, and `HITL_SESSION_SOLVE`, querying the main page as well as all nested frames/iframes for inputs, captchas, and submit buttons.
|
|
208
|
+
- **Realistic Timeout Scaling**: Replaced fragile hardcoded timeouts (4s - 6s) with dynamic portal timeout scaling (`Math.max(timeoutMs, 20000)`), granting heavy client-side frameworks (AngularJS, React) sufficient headroom to download scripts and compile views.
|
|
209
|
+
- **AngularJS Scope & Model Synchronization**: Typed input values now dispatch full keyboard cascades (`input`, `change`, `keydown`, `keyup`, `blur`) and directly synchronize AngularJS `ngModel` (`$setViewValue`, `$render`, `$apply`) to trigger dynamic validation and captcha loaders (e.g. `showCap1`).
|
|
210
|
+
- **Actionable Diagnostic Logging**: Failure to locate an element now logs the browser's active URL and document title in terminal output with detailed context.
|
|
211
|
+
|
|
212
|
+
### v1.3.0
|
|
213
|
+
- **Multi-Error Identifier Rules & Inspection Resilience Engine**:
|
|
214
|
+
- **Custom Error Identifier Rules**: Supported typed error detection rules (`errorDetection.rules`) allowing classification of multiple distinct errors (e.g. `INVALID_GSTIN`, `INVALID_CAPTCHA`, `NOT_FOUND`) with tailored actions (`RETRY_CAPTCHA`, `BUSINESS_ERROR`, `ABORT_SESSION`).
|
|
215
|
+
- **Inspection Mode Zero-Purge Resilience**: When `preservePageForInspection: true`, the worker never terminates the session on errors or timeouts. Instead, it settles the DOM, takes a full-page snapshot, and returns the live updated screenshot to the visual element inspector so users can inspect red error messages and map error selectors.
|
|
216
|
+
- **Broadened Intelligent Error Heuristics**: Added automatic recognition for standard Indian government and SPA error selectors (`p#alertMsg`, `#alertMsg`, `.alertMsg`, `.text-danger`, `.invalid-feedback`, `[aria-invalid='true']`), resolving outcome races in sub-second time without 15s timeouts.
|
|
217
|
+
- **Added `HITL_SESSION_SNAPSHOT`**: On-demand snapshot handler allowing the visual inspector to capture and synchronize current browser page state at any point.
|
|
218
|
+
|
|
204
219
|
### v1.2.5
|
|
205
220
|
- **Interactive Session Visual Inspector Sync & Robust Captcha Refresh**:
|
|
206
221
|
- `HITL_SESSION_INIT` now captures and returns full-page `pageScreenshotBase64`, dimensions, and title after typing initial form inputs, keeping the Visual Element Inspector synchronized with the active session.
|
|
@@ -256,6 +271,13 @@ Log levels: `[INFO]` · `[SUCCESS]` · `[WARN]` · `[ERROR]` · `[TASK]` (job di
|
|
|
256
271
|
- Automatic backup of pre-existing local disk sessions to cluster database on initial connection.
|
|
257
272
|
- Coordinated session wiping on remote logout and phone unlinking.
|
|
258
273
|
|
|
274
|
+
### v1.2.5
|
|
275
|
+
- **Interactive Human-In-The-Loop (HITL) Web Scraping**:
|
|
276
|
+
- Pinned stateful browser sessions for interactive portals (GSTIN, PAN, Parivahan, e-Courts).
|
|
277
|
+
- Multi-step execution protocol: `HITL_SESSION_INIT`, `HITL_SESSION_REFRESH`, `HITL_SESSION_SOLVE`, `HITL_SESSION_EXTRACT`, and `HITL_SESSION_ABORT`.
|
|
278
|
+
- Memory-safe binary captcha streaming and bounded 90s session TTL auto-termination.
|
|
279
|
+
- Server URL resolution propagation for Baileys companion document relay.
|
|
280
|
+
|
|
259
281
|
### v1.1.0
|
|
260
282
|
- **WhatsApp Multi-Device Companion Relay**:
|
|
261
283
|
- Independent companion device pairing via live QR streaming to Admin Dashboard.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "app-manager-edge-worker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Distributed Scraper Edge Worker for Apps Manager. Run on any local machine, VPS, or Termux Android device to serve browser and HTTP scraping jobs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/banner.js
CHANGED
package/src/client.js
CHANGED
|
@@ -14,12 +14,13 @@ import {
|
|
|
14
14
|
handleInteractiveSessionSolve,
|
|
15
15
|
handleInteractiveSessionAbort,
|
|
16
16
|
handleInteractiveSessionExtract,
|
|
17
|
+
handleInteractiveSessionSnapshot,
|
|
17
18
|
} from "./handlers/interactiveHandler.js";
|
|
18
19
|
import fs from "fs";
|
|
19
20
|
import { getWhatsAppHandler } from "./handlers/whatsappHandler.js";
|
|
20
21
|
import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
|
|
21
22
|
|
|
22
|
-
let workerPkgVersion = "1.
|
|
23
|
+
let workerPkgVersion = "1.3.1";
|
|
23
24
|
try {
|
|
24
25
|
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
25
26
|
if (pkg?.version) workerPkgVersion = pkg.version;
|
|
@@ -263,6 +264,8 @@ export async function startWorkerClient(config) {
|
|
|
263
264
|
resultData = await handleInteractiveSessionRefresh(payload);
|
|
264
265
|
} else if (jobType === "HITL_SESSION_SOLVE") {
|
|
265
266
|
resultData = await handleInteractiveSessionSolve(payload);
|
|
267
|
+
} else if (jobType === "HITL_SESSION_SNAPSHOT") {
|
|
268
|
+
resultData = await handleInteractiveSessionSnapshot(payload);
|
|
266
269
|
} else if (jobType === "HITL_SESSION_EXTRACT") {
|
|
267
270
|
resultData = await handleInteractiveSessionExtract(payload);
|
|
268
271
|
} else if (jobType === "HITL_SESSION_ABORT") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import localBrowserManager from "../browser.js";
|
|
3
3
|
import { runTransformPipeline, castValueToType } from "./transformUtils.js";
|
|
4
|
+
import { logInfo, logWarn, logError } from "../banner.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* In-memory map of active interactive sessions on this Edge Worker.
|
|
@@ -93,6 +94,82 @@ async function purgeSession(sessionId) {
|
|
|
93
94
|
activeSessions.delete(sessionId);
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Constructs a robust set of CSS selectors for an input field based on its configured selector and paramKey.
|
|
99
|
+
* Handles IDs, name attributes, ng-model attributes, placeholders, and comma-separated selectors.
|
|
100
|
+
*/
|
|
101
|
+
function buildRobustInputSelectors(selector, paramKey) {
|
|
102
|
+
const set = new Set();
|
|
103
|
+
if (selector && typeof selector === "string") {
|
|
104
|
+
selector.split(",").map((s) => s.trim()).filter(Boolean).forEach((s) => set.add(s));
|
|
105
|
+
|
|
106
|
+
// If selector contains [name="xxx"] or [name='xxx'], extract the name
|
|
107
|
+
const nameMatch = selector.match(/\[name=["']?([^"'\]\s]+)["']?\]/i);
|
|
108
|
+
if (nameMatch && nameMatch[1]) {
|
|
109
|
+
const name = nameMatch[1];
|
|
110
|
+
set.add(`#${name}`);
|
|
111
|
+
set.add(`input[name="${name}"]`);
|
|
112
|
+
set.add(`[name="${name}"]`);
|
|
113
|
+
set.add(`input#${name}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If selector contains #xxx or [id="xxx"], extract the id
|
|
117
|
+
const idMatch = selector.match(/#([a-zA-Z0-9_\-]+)/) || selector.match(/\[id=["']?([^"'\]\s]+)["']?\]/i);
|
|
118
|
+
if (idMatch && idMatch[1]) {
|
|
119
|
+
const id = idMatch[1];
|
|
120
|
+
set.add(`#${id}`);
|
|
121
|
+
set.add(`input[name="${id}"]`);
|
|
122
|
+
set.add(`[name="${id}"]`);
|
|
123
|
+
set.add(`input#${id}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (paramKey && typeof paramKey === "string") {
|
|
128
|
+
const pk = paramKey.trim();
|
|
129
|
+
set.add(`#${pk}`);
|
|
130
|
+
set.add(`input[name="${pk}"]`);
|
|
131
|
+
set.add(`[name="${pk}"]`);
|
|
132
|
+
set.add(`input[data-ng-model*="${pk}"]`);
|
|
133
|
+
set.add(`input[ng-model*="${pk}"]`);
|
|
134
|
+
set.add(`input[placeholder*="${pk}" i]`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return Array.from(set);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Searches for an element in the main page or any child iframe, waiting up to timeoutMs.
|
|
142
|
+
* Returns { target: page | frame, element, matchedSelector } or null.
|
|
143
|
+
*/
|
|
144
|
+
async function locateElementInPageOrFrames(page, selectorList, timeoutMs = 15000) {
|
|
145
|
+
const combinedSelector = Array.isArray(selectorList) ? selectorList.join(", ") : String(selectorList);
|
|
146
|
+
|
|
147
|
+
// 1. Try waiting on the top page first
|
|
148
|
+
try {
|
|
149
|
+
const el = await page.waitForSelector(combinedSelector, { timeout: timeoutMs });
|
|
150
|
+
if (el) {
|
|
151
|
+
return { target: page, element: el, matchedSelector: combinedSelector };
|
|
152
|
+
}
|
|
153
|
+
} catch (_) {
|
|
154
|
+
// Top-level wait timed out or failed; inspect child frames next
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 2. Scan child frames/iframes if not found on top-level page
|
|
158
|
+
try {
|
|
159
|
+
const frames = page.frames();
|
|
160
|
+
for (const frame of frames) {
|
|
161
|
+
try {
|
|
162
|
+
const fEl = await frame.$(combinedSelector);
|
|
163
|
+
if (fEl) {
|
|
164
|
+
return { target: frame, element: fEl, matchedSelector: combinedSelector };
|
|
165
|
+
}
|
|
166
|
+
} catch (_) {}
|
|
167
|
+
}
|
|
168
|
+
} catch (_) {}
|
|
169
|
+
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
96
173
|
/**
|
|
97
174
|
* 1. Handles HITL_SESSION_INIT:
|
|
98
175
|
* Launches page, pre-fills initial form inputs, captures captcha screenshot,
|
|
@@ -106,6 +183,7 @@ export async function handleInteractiveSessionInit(payload) {
|
|
|
106
183
|
captchaConfig = {},
|
|
107
184
|
preScrapeActions = [],
|
|
108
185
|
authentication = null,
|
|
186
|
+
navigation = {},
|
|
109
187
|
timeoutMs = 30000,
|
|
110
188
|
fieldMappings = [],
|
|
111
189
|
collections = [],
|
|
@@ -146,10 +224,13 @@ export async function handleInteractiveSessionInit(payload) {
|
|
|
146
224
|
}
|
|
147
225
|
}
|
|
148
226
|
|
|
149
|
-
// Navigate to target portal
|
|
150
|
-
|
|
227
|
+
// Navigate to target portal with flexible waitUntil and realistic timeout
|
|
228
|
+
const navTimeout = navigation.timeoutMs || timeoutMs || 30000;
|
|
229
|
+
const navWaitUntil = navigation.waitUntil || "domcontentloaded";
|
|
230
|
+
|
|
231
|
+
await page.goto(url, { waitUntil: navWaitUntil, timeout: navTimeout });
|
|
151
232
|
try {
|
|
152
|
-
await page.waitForNetworkIdle({ idleTime: 500, timeout:
|
|
233
|
+
await page.waitForNetworkIdle({ idleTime: 500, timeout: 4000 }).catch(() => {});
|
|
153
234
|
} catch (_) {}
|
|
154
235
|
|
|
155
236
|
// Execute pre-scrape actions if present
|
|
@@ -157,14 +238,15 @@ export async function handleInteractiveSessionInit(payload) {
|
|
|
157
238
|
for (const action of preScrapeActions) {
|
|
158
239
|
try {
|
|
159
240
|
const actType = (action.type || action.actionType || "").toUpperCase();
|
|
241
|
+
const actionTimeout = Math.max(action.timeoutMs || 0, 10000);
|
|
160
242
|
if (actType === "CLICK" && action.selector) {
|
|
161
|
-
await page.waitForSelector(action.selector, { timeout:
|
|
243
|
+
await page.waitForSelector(action.selector, { timeout: actionTimeout });
|
|
162
244
|
await page.click(action.selector);
|
|
163
245
|
} else if (actType === "TYPE" && action.selector) {
|
|
164
|
-
await page.waitForSelector(action.selector, { timeout:
|
|
246
|
+
await page.waitForSelector(action.selector, { timeout: actionTimeout });
|
|
165
247
|
await page.type(action.selector, action.value || "", { delay: 30 });
|
|
166
248
|
} else if (actType === "WAIT_FOR" && action.selector) {
|
|
167
|
-
await page.waitForSelector(action.selector, { timeout:
|
|
249
|
+
await page.waitForSelector(action.selector, { timeout: actionTimeout });
|
|
168
250
|
}
|
|
169
251
|
if (action.delayMs > 0) {
|
|
170
252
|
await new Promise((r) => setTimeout(r, Math.min(action.delayMs, 4000)));
|
|
@@ -175,47 +257,98 @@ export async function handleInteractiveSessionInit(payload) {
|
|
|
175
257
|
}
|
|
176
258
|
}
|
|
177
259
|
|
|
178
|
-
// Fill Form Inputs (e.g. GSTIN, PAN, registration number)
|
|
260
|
+
// Fill Form Inputs (e.g. GSTIN, PAN, registration number) with robust selector fallbacks & frame support
|
|
179
261
|
if (Array.isArray(formInputs) && formInputs.length > 0) {
|
|
180
262
|
for (const input of formInputs) {
|
|
181
263
|
if (!input.selector || input.value === undefined || input.value === null) continue;
|
|
182
|
-
|
|
264
|
+
|
|
265
|
+
const inputTimeout = Math.max(input.timeoutMs || 0, timeoutMs || 30000, 20000);
|
|
266
|
+
const robustSelectors = buildRobustInputSelectors(input.selector, input.paramKey);
|
|
267
|
+
|
|
268
|
+
const located = await locateElementInPageOrFrames(page, robustSelectors, inputTimeout);
|
|
269
|
+
if (!located || !located.element) {
|
|
270
|
+
const currentUrl = page.url();
|
|
271
|
+
const pageTitle = (await page.title().catch(() => "")) || "Untitled";
|
|
272
|
+
logWarn(
|
|
273
|
+
`[HITL_SESSION_INIT] Input selector '${input.selector}' (or fallbacks: ${robustSelectors.join(", ")}) not found after ${inputTimeout}ms on page "${pageTitle}" (${currentUrl})`
|
|
274
|
+
);
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Waiting for selector '${input.selector}' failed on page "${pageTitle}" (${currentUrl}) after ${inputTimeout}ms. Ensure portal is reachable and selector is correct.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const { target, element, matchedSelector } = located;
|
|
183
281
|
|
|
184
282
|
if (input.clearFirst !== false) {
|
|
185
|
-
await
|
|
283
|
+
await element.click({ clickCount: 3 }).catch(() => {});
|
|
186
284
|
await page.keyboard.press("Backspace").catch(() => {});
|
|
187
285
|
}
|
|
188
286
|
|
|
189
287
|
if (input.actionType === "SELECT") {
|
|
190
|
-
await
|
|
288
|
+
await target.select(matchedSelector, String(input.value));
|
|
191
289
|
} else {
|
|
192
|
-
await
|
|
290
|
+
await element.type(String(input.value), { delay: input.delayMs || 30 });
|
|
193
291
|
}
|
|
194
292
|
|
|
195
|
-
// Explicitly dispatch input, change, and blur events
|
|
196
|
-
await
|
|
293
|
+
// Explicitly dispatch input, change, keydown, keyup, and blur events, plus sync AngularJS ngModel if present
|
|
294
|
+
await target.evaluate((sel) => {
|
|
197
295
|
const el = document.querySelector(sel);
|
|
198
296
|
if (el) {
|
|
199
297
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
200
298
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
299
|
+
el.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
300
|
+
el.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "Enter" }));
|
|
201
301
|
el.dispatchEvent(new Event("blur", { bubbles: true }));
|
|
302
|
+
|
|
303
|
+
// AngularJS scope & ngModel synchronization
|
|
304
|
+
if (window.angular) {
|
|
305
|
+
try {
|
|
306
|
+
const ngEl = window.angular.element(el);
|
|
307
|
+
const ngModel = ngEl.controller("ngModel");
|
|
308
|
+
if (ngModel) {
|
|
309
|
+
ngModel.$setViewValue(el.value);
|
|
310
|
+
ngModel.$render();
|
|
311
|
+
ngModel.$commitViewValue();
|
|
312
|
+
}
|
|
313
|
+
const scope = ngEl.scope();
|
|
314
|
+
if (scope && !scope.$$phase) {
|
|
315
|
+
scope.$apply();
|
|
316
|
+
}
|
|
317
|
+
} catch (_) {}
|
|
318
|
+
}
|
|
202
319
|
}
|
|
203
|
-
},
|
|
204
|
-
await page.keyboard.press("Tab").catch(() => {});
|
|
320
|
+
}, matchedSelector).catch(() => {});
|
|
205
321
|
|
|
206
|
-
await
|
|
322
|
+
await page.keyboard.press("Tab").catch(() => {});
|
|
323
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
207
324
|
}
|
|
208
325
|
}
|
|
209
326
|
|
|
210
|
-
// Locate and Screenshot Captcha DOM Element
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
327
|
+
// Locate and Screenshot Captcha DOM Element with robust candidate fallbacks
|
|
328
|
+
const imageTimeout = Math.max(captchaConfig.timeoutMs || 0, timeoutMs || 30000, 15000);
|
|
329
|
+
const candidateImageSelectors = [
|
|
330
|
+
captchaConfig.imageSelector,
|
|
331
|
+
"#imgCaptcha",
|
|
332
|
+
"#captchaImg",
|
|
333
|
+
"img[src*='captcha' i]",
|
|
334
|
+
"img[id*='captcha' i]",
|
|
335
|
+
"img[alt*='captcha' i]",
|
|
336
|
+
"canvas[id*='captcha' i]",
|
|
337
|
+
".captcha-image img",
|
|
338
|
+
"#captcha_image",
|
|
339
|
+
].filter(Boolean);
|
|
340
|
+
|
|
341
|
+
const locatedImage = await locateElementInPageOrFrames(page, candidateImageSelectors, imageTimeout);
|
|
342
|
+
if (!locatedImage || !locatedImage.element) {
|
|
343
|
+
const currentUrl = page.url();
|
|
344
|
+
const pageTitle = (await page.title().catch(() => "")) || "Untitled";
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Captcha element not found with selector '${captchaConfig.imageSelector || candidateImageSelectors[0]}' on page "${pageTitle}" (${currentUrl}) after ${imageTimeout}ms`
|
|
347
|
+
);
|
|
217
348
|
}
|
|
218
349
|
|
|
350
|
+
const captchaElement = locatedImage.element;
|
|
351
|
+
|
|
219
352
|
// Capture screenshot of the exact visual captcha DOM node
|
|
220
353
|
const screenshotBuffer = await captchaElement.screenshot({
|
|
221
354
|
encoding: "binary",
|
|
@@ -291,19 +424,29 @@ export async function handleInteractiveSessionRefresh(payload) {
|
|
|
291
424
|
const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
|
|
292
425
|
const refreshSelector = cfg.refreshSelector;
|
|
293
426
|
|
|
427
|
+
const candidateImageSelectors = [
|
|
428
|
+
cfg.imageSelector,
|
|
429
|
+
"#imgCaptcha",
|
|
430
|
+
"#captchaImg",
|
|
431
|
+
"img[src*='captcha' i]",
|
|
432
|
+
"img[id*='captcha' i]",
|
|
433
|
+
].filter(Boolean);
|
|
434
|
+
|
|
294
435
|
// 1. Snapshot current image hash and src attribute
|
|
295
|
-
const
|
|
296
|
-
if (!
|
|
436
|
+
const locatedOldImg = await locateElementInPageOrFrames(page, candidateImageSelectors, 8000);
|
|
437
|
+
if (!locatedOldImg || !locatedOldImg.element) {
|
|
297
438
|
throw new Error(`Captcha element not found with selector: ${imageSelector}`);
|
|
298
439
|
}
|
|
440
|
+
const oldEl = locatedOldImg.element;
|
|
441
|
+
const effectiveImageSelector = locatedOldImg.matchedSelector;
|
|
299
442
|
|
|
300
443
|
const oldBuffer = await oldEl.screenshot({ encoding: "binary" });
|
|
301
444
|
const oldHash = crypto.createHash("md5").update(oldBuffer).digest("hex");
|
|
302
445
|
|
|
303
|
-
const oldSrc = await
|
|
446
|
+
const oldSrc = await locatedOldImg.target.evaluate((sel) => {
|
|
304
447
|
const el = document.querySelector(sel);
|
|
305
448
|
return el ? el.getAttribute("src") : null;
|
|
306
|
-
},
|
|
449
|
+
}, effectiveImageSelector);
|
|
307
450
|
|
|
308
451
|
// 2. Click the reload button with robust candidate fallbacks
|
|
309
452
|
const candidateSelectors = [
|
|
@@ -328,9 +471,9 @@ export async function handleInteractiveSessionRefresh(payload) {
|
|
|
328
471
|
let clicked = false;
|
|
329
472
|
for (const sel of candidateSelectors) {
|
|
330
473
|
try {
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
333
|
-
await
|
|
474
|
+
const locatedBtn = await locateElementInPageOrFrames(page, [sel], 500);
|
|
475
|
+
if (locatedBtn && locatedBtn.element) {
|
|
476
|
+
await locatedBtn.element.click();
|
|
334
477
|
clicked = true;
|
|
335
478
|
break;
|
|
336
479
|
}
|
|
@@ -422,31 +565,118 @@ export async function handleInteractiveSessionSolve(payload) {
|
|
|
422
565
|
const inputSelector = cfg.inputSelector || "#captcha, input[name='captcha']";
|
|
423
566
|
const submitSelector = cfg.submitSelector || "button[type='submit']";
|
|
424
567
|
const successSelector = cfg.waitForAfterSubmit?.selector || ".table-responsive, table, .result-pane";
|
|
425
|
-
|
|
568
|
+
|
|
569
|
+
// Broad default error selectors covering Indian gov and modern SPA patterns
|
|
570
|
+
const defaultBroadErrorSelector =
|
|
571
|
+
"p#alertMsg, #alertMsg, .alertMsg, .alert-danger, .alert-warning, " +
|
|
572
|
+
".has-error, .text-danger, #error-msg, .error-message, .err-msg, " +
|
|
573
|
+
".ui-messages-error, [class*='error-msg'], [class*='alert-message'], " +
|
|
574
|
+
".invalid-feedback, .help-block.error, .form-error, [aria-invalid='true']";
|
|
575
|
+
|
|
576
|
+
const customRules = cfg.errorDetection?.rules || [];
|
|
577
|
+
const ruleSelectors = customRules.map((r) => r.selector).filter(Boolean);
|
|
578
|
+
const combinedErrorSelectors = [
|
|
579
|
+
cfg.errorDetection?.selector,
|
|
580
|
+
...ruleSelectors,
|
|
581
|
+
defaultBroadErrorSelector,
|
|
582
|
+
]
|
|
583
|
+
.filter(Boolean)
|
|
584
|
+
.join(", ");
|
|
585
|
+
|
|
426
586
|
const invalidPattern = cfg.errorDetection?.invalidCaptchaPattern || "captcha.*(incorrect|invalid|wrong|mismatch|enter text)";
|
|
427
587
|
|
|
428
|
-
// 1. Type captcha solution
|
|
429
|
-
|
|
430
|
-
|
|
588
|
+
// 1. Type captcha solution with robust candidate fallbacks & frame search
|
|
589
|
+
const solveTimeout = Math.max(cfg.inputTimeoutMs || 0, 15000);
|
|
590
|
+
const candidateInputSelectors = [
|
|
591
|
+
inputSelector,
|
|
592
|
+
"#fo-captcha",
|
|
593
|
+
"#captcha",
|
|
594
|
+
"input[name='cap']",
|
|
595
|
+
"input[name*='captcha' i]",
|
|
596
|
+
"input[id*='captcha' i]",
|
|
597
|
+
"input[placeholder*='captcha' i]",
|
|
598
|
+
].filter(Boolean);
|
|
599
|
+
|
|
600
|
+
const locatedInput = await locateElementInPageOrFrames(page, candidateInputSelectors, solveTimeout);
|
|
601
|
+
if (!locatedInput || !locatedInput.element) {
|
|
602
|
+
throw new Error(`Captcha input field not found with selector: ${inputSelector}`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
await locatedInput.element.click({ clickCount: 3 }).catch(() => {});
|
|
431
606
|
await page.keyboard.press("Backspace").catch(() => {});
|
|
432
|
-
await
|
|
607
|
+
await locatedInput.element.type(String(captchaSolution).trim(), { delay: 35 });
|
|
608
|
+
|
|
609
|
+
// Sync input events and AngularJS ngModel for captcha input
|
|
610
|
+
await locatedInput.target.evaluate((sel) => {
|
|
611
|
+
const el = document.querySelector(sel);
|
|
612
|
+
if (el) {
|
|
613
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
614
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
615
|
+
el.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
616
|
+
el.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "Enter" }));
|
|
617
|
+
el.dispatchEvent(new Event("blur", { bubbles: true }));
|
|
618
|
+
|
|
619
|
+
if (window.angular) {
|
|
620
|
+
try {
|
|
621
|
+
const ngEl = window.angular.element(el);
|
|
622
|
+
const ngModel = ngEl.controller("ngModel");
|
|
623
|
+
if (ngModel) {
|
|
624
|
+
ngModel.$setViewValue(el.value);
|
|
625
|
+
ngModel.$render();
|
|
626
|
+
ngModel.$commitViewValue();
|
|
627
|
+
}
|
|
628
|
+
const scope = ngEl.scope();
|
|
629
|
+
if (scope && !scope.$$phase) scope.$apply();
|
|
630
|
+
} catch (_) {}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}, locatedInput.matchedSelector).catch(() => {});
|
|
634
|
+
|
|
635
|
+
// 2. Click submit button with candidate fallbacks & frame search
|
|
636
|
+
const candidateSubmitSelectors = [
|
|
637
|
+
submitSelector,
|
|
638
|
+
"#lotsearch",
|
|
639
|
+
"button[type='submit']",
|
|
640
|
+
"input[type='submit']",
|
|
641
|
+
"button[id*='search' i]",
|
|
642
|
+
"button[id*='submit' i]",
|
|
643
|
+
"button.btn-primary",
|
|
644
|
+
].filter(Boolean);
|
|
645
|
+
|
|
646
|
+
const locatedSubmit = await locateElementInPageOrFrames(page, candidateSubmitSelectors, solveTimeout);
|
|
647
|
+
if (!locatedSubmit || !locatedSubmit.element) {
|
|
648
|
+
throw new Error(`Submit button not found with selector: ${submitSelector}`);
|
|
649
|
+
}
|
|
433
650
|
|
|
434
|
-
|
|
435
|
-
await page.waitForSelector(submitSelector, { timeout: 4000 });
|
|
436
|
-
await page.click(submitSelector);
|
|
651
|
+
await locatedSubmit.element.click();
|
|
437
652
|
|
|
438
653
|
// 3. Post-Submit Race: Wait for Success Selector OR Error Selector
|
|
654
|
+
// In inspection mode, use a shorter safety window (5-7s) so users aren't stalled waiting on empty selectors
|
|
655
|
+
const safetyTimeoutMs = preservePageForInspection
|
|
656
|
+
? Math.min(cfg.waitForAfterSubmit?.timeoutMs || 8000, 6000)
|
|
657
|
+
: (cfg.waitForAfterSubmit?.timeoutMs || 15000);
|
|
658
|
+
|
|
439
659
|
const matchedOutcome = await page
|
|
440
660
|
.waitForFunction(
|
|
441
661
|
(succ, err) => {
|
|
442
662
|
if (document.querySelector(succ)) return "SUCCESS";
|
|
443
|
-
|
|
444
|
-
|
|
663
|
+
try {
|
|
664
|
+
const elements = document.querySelectorAll(err);
|
|
665
|
+
for (const el of elements) {
|
|
666
|
+
const txt = (el.innerText || el.textContent || "").trim();
|
|
667
|
+
if (txt.length > 0) {
|
|
668
|
+
const style = window.getComputedStyle(el);
|
|
669
|
+
if (style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0") {
|
|
670
|
+
return "ERROR";
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
} catch (e) {}
|
|
445
675
|
return null;
|
|
446
676
|
},
|
|
447
|
-
{ timeout:
|
|
677
|
+
{ timeout: safetyTimeoutMs },
|
|
448
678
|
successSelector,
|
|
449
|
-
|
|
679
|
+
combinedErrorSelectors
|
|
450
680
|
)
|
|
451
681
|
.then((handle) => handle.jsonValue())
|
|
452
682
|
.catch(() => "TIMEOUT");
|
|
@@ -482,28 +712,12 @@ export async function handleInteractiveSessionSolve(payload) {
|
|
|
482
712
|
// Settle time for tables & styles
|
|
483
713
|
await new Promise((r) => setTimeout(r, 600));
|
|
484
714
|
|
|
485
|
-
const
|
|
486
|
-
encoding: "binary",
|
|
487
|
-
fullPage: false,
|
|
488
|
-
type: "jpeg",
|
|
489
|
-
quality: 85,
|
|
490
|
-
});
|
|
491
|
-
const screenshotBase64 = Buffer.from(screenshotBuffer).toString("base64");
|
|
492
|
-
const title = await page.title().catch(() => "");
|
|
493
|
-
const dimensions = await page
|
|
494
|
-
.evaluate(() => ({
|
|
495
|
-
width: document.documentElement.scrollWidth || window.innerWidth,
|
|
496
|
-
height: document.documentElement.scrollHeight || window.innerHeight,
|
|
497
|
-
}))
|
|
498
|
-
.catch(() => ({ width: 1366, height: 900 }));
|
|
499
|
-
|
|
715
|
+
const snap = await captureInspectionSnapshot(page);
|
|
500
716
|
return {
|
|
501
717
|
status: "COMPLETED",
|
|
502
718
|
success: true,
|
|
503
719
|
data: raw,
|
|
504
|
-
|
|
505
|
-
dimensions,
|
|
506
|
-
title: title || "Results Page",
|
|
720
|
+
...snap,
|
|
507
721
|
sessionId,
|
|
508
722
|
isInspectionMode: true,
|
|
509
723
|
};
|
|
@@ -519,55 +733,129 @@ export async function handleInteractiveSessionSolve(payload) {
|
|
|
519
733
|
};
|
|
520
734
|
}
|
|
521
735
|
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
736
|
+
// Evaluate error rules and messages if matchedOutcome === "ERROR" or if we are inspecting
|
|
737
|
+
let matchedRule = null;
|
|
738
|
+
let errorText = "";
|
|
739
|
+
|
|
740
|
+
// 1. Check custom configured rules first
|
|
741
|
+
for (const rule of customRules) {
|
|
742
|
+
if (!rule.selector) continue;
|
|
743
|
+
const check = await page.evaluate((sel, pat) => {
|
|
744
|
+
try {
|
|
745
|
+
const els = document.querySelectorAll(sel);
|
|
746
|
+
for (const el of els) {
|
|
747
|
+
const txt = (el.innerText || el.textContent || "").trim();
|
|
748
|
+
if (txt) {
|
|
749
|
+
const style = window.getComputedStyle(el);
|
|
750
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue;
|
|
751
|
+
if (!pat) return { matched: true, text: txt };
|
|
752
|
+
const re = new RegExp(pat, "i");
|
|
753
|
+
if (re.test(txt)) return { matched: true, text: txt };
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
} catch (e) {}
|
|
757
|
+
return null;
|
|
758
|
+
}, rule.selector, rule.pattern);
|
|
759
|
+
|
|
760
|
+
if (check && check.matched) {
|
|
761
|
+
matchedRule = rule;
|
|
762
|
+
errorText = check.text;
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// 2. If no custom rule matched, extract text from combined error selectors
|
|
768
|
+
if (!matchedRule) {
|
|
769
|
+
errorText = await page.evaluate((sel) => {
|
|
770
|
+
try {
|
|
771
|
+
const els = document.querySelectorAll(sel);
|
|
772
|
+
for (const el of els) {
|
|
773
|
+
const txt = (el.innerText || el.textContent || "").trim();
|
|
774
|
+
if (txt) {
|
|
775
|
+
const style = window.getComputedStyle(el);
|
|
776
|
+
if (style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0") {
|
|
777
|
+
return txt;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} catch (e) {}
|
|
782
|
+
return "";
|
|
783
|
+
}, combinedErrorSelectors);
|
|
784
|
+
}
|
|
528
785
|
|
|
529
|
-
|
|
530
|
-
|
|
786
|
+
const regex = new RegExp(invalidPattern, "i");
|
|
787
|
+
const isCaptchaError = matchedRule
|
|
788
|
+
? matchedRule.action === "RETRY_CAPTCHA"
|
|
789
|
+
: (Boolean(errorText) && regex.test(errorText));
|
|
531
790
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
791
|
+
// Check if new captcha image was rendered on page
|
|
792
|
+
const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
|
|
793
|
+
let newImageBase64 = null;
|
|
794
|
+
try {
|
|
795
|
+
const newCaptchaEl = await page.$(imageSelector);
|
|
796
|
+
if (newCaptchaEl) {
|
|
797
|
+
const newBuffer = await newCaptchaEl.screenshot({ encoding: "binary", type: "png" });
|
|
798
|
+
newImageBase64 = `data:image/png;base64,${Buffer.from(newBuffer).toString("base64")}`;
|
|
799
|
+
}
|
|
800
|
+
} catch (e) {}
|
|
801
|
+
|
|
802
|
+
// CRITICAL FOR INSPECTOR: In inspection mode, NEVER purge the session on errors or timeout!
|
|
803
|
+
// Always snap the live DOM and return it so the user can inspect the red text and configure selectors
|
|
804
|
+
if (preservePageForInspection) {
|
|
805
|
+
if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
|
|
806
|
+
const inspectTtl = 300;
|
|
807
|
+
session.timeoutTimer = setTimeout(() => {
|
|
808
|
+
purgeSession(sessionId).catch(() => {});
|
|
809
|
+
}, inspectTtl * 1000);
|
|
539
810
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
let newImageBase64 = null;
|
|
811
|
+
const snap = await captureInspectionSnapshot(page);
|
|
812
|
+
const effectiveStatus = isCaptchaError
|
|
813
|
+
? "INVALID_CAPTCHA"
|
|
814
|
+
: (errorText ? "BUSINESS_ERROR" : "INSPECTION_MODE");
|
|
545
815
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
816
|
+
return {
|
|
817
|
+
status: effectiveStatus,
|
|
818
|
+
success: true, // Allows frontend to render the inspection screenshot
|
|
819
|
+
errorCode: (matchedRule && matchedRule.id) || (isCaptchaError ? "INVALID_CAPTCHA" : (errorText ? "BUSINESS_ERROR" : "INSPECTION_MODE")),
|
|
820
|
+
message: (matchedRule && matchedRule.customMessage) || errorText || (matchedOutcome === "TIMEOUT" ? "Page updated (no success table found). You can now inspect elements or error messages." : "Page outcome revealed for inspection."),
|
|
821
|
+
newImageBase64,
|
|
822
|
+
matchedRule: matchedRule ? { id: matchedRule.id, name: matchedRule.name, action: matchedRule.action } : null,
|
|
823
|
+
sessionId,
|
|
824
|
+
isInspectionMode: true,
|
|
825
|
+
...snap,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
550
828
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
829
|
+
// Runtime non-inspection mode:
|
|
830
|
+
if (isCaptchaError) {
|
|
831
|
+
if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
|
|
832
|
+
const retryTtlSeconds = cfg.sessionTimeoutSeconds || 90;
|
|
833
|
+
session.timeoutTimer = setTimeout(() => {
|
|
834
|
+
purgeSession(sessionId).catch(() => {});
|
|
835
|
+
}, retryTtlSeconds * 1000);
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
status: "INVALID_CAPTCHA",
|
|
839
|
+
success: false,
|
|
840
|
+
message: (matchedRule && matchedRule.customMessage) || errorText || "The captcha entered was incorrect.",
|
|
841
|
+
canRetry: true,
|
|
842
|
+
newImageBase64,
|
|
843
|
+
errorCode: (matchedRule && matchedRule.id) || "INVALID_CAPTCHA",
|
|
844
|
+
};
|
|
568
845
|
}
|
|
569
846
|
|
|
570
|
-
|
|
847
|
+
if (errorText || matchedRule) {
|
|
848
|
+
await purgeSession(sessionId);
|
|
849
|
+
return {
|
|
850
|
+
status: "BUSINESS_ERROR",
|
|
851
|
+
success: false,
|
|
852
|
+
errorCode: (matchedRule && matchedRule.id) || "BUSINESS_ERROR",
|
|
853
|
+
message: (matchedRule && matchedRule.customMessage) || errorText || "Target portal returned an error.",
|
|
854
|
+
canRetry: false,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Runtime Timeout
|
|
571
859
|
await purgeSession(sessionId);
|
|
572
860
|
return {
|
|
573
861
|
status: "TIMEOUT",
|
|
@@ -577,6 +865,58 @@ export async function handleInteractiveSessionSolve(payload) {
|
|
|
577
865
|
};
|
|
578
866
|
}
|
|
579
867
|
|
|
868
|
+
/**
|
|
869
|
+
* Capture full inspection snapshot helper
|
|
870
|
+
*/
|
|
871
|
+
async function captureInspectionSnapshot(page) {
|
|
872
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
873
|
+
const screenshotBuffer = await page.screenshot({
|
|
874
|
+
encoding: "binary",
|
|
875
|
+
fullPage: false,
|
|
876
|
+
type: "jpeg",
|
|
877
|
+
quality: 85,
|
|
878
|
+
});
|
|
879
|
+
const screenshotBase64 = `data:image/jpeg;base64,${Buffer.from(screenshotBuffer).toString("base64")}`;
|
|
880
|
+
const title = await page.title().catch(() => "");
|
|
881
|
+
const dimensions = await page
|
|
882
|
+
.evaluate(() => ({
|
|
883
|
+
width: document.documentElement.scrollWidth || window.innerWidth,
|
|
884
|
+
height: document.documentElement.scrollHeight || window.innerHeight,
|
|
885
|
+
}))
|
|
886
|
+
.catch(() => ({ width: 1366, height: 900 }));
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
screenshotBase64,
|
|
890
|
+
dimensions,
|
|
891
|
+
title: title || "Inspection Page",
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Force capture snapshot of the active interactive session's page
|
|
897
|
+
*/
|
|
898
|
+
export async function handleInteractiveSessionSnapshot(payload) {
|
|
899
|
+
const { sessionId } = payload;
|
|
900
|
+
const session = activeSessions.get(sessionId);
|
|
901
|
+
|
|
902
|
+
if (!session || !session.page || session.page.isClosed()) {
|
|
903
|
+
throw new Error("Interactive session not found or already expired");
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const { page } = session;
|
|
907
|
+
if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
|
|
908
|
+
session.timeoutTimer = setTimeout(() => {
|
|
909
|
+
purgeSession(sessionId).catch(() => {});
|
|
910
|
+
}, 300 * 1000);
|
|
911
|
+
|
|
912
|
+
const snap = await captureInspectionSnapshot(page);
|
|
913
|
+
return {
|
|
914
|
+
success: true,
|
|
915
|
+
sessionId,
|
|
916
|
+
...snap,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
580
920
|
/**
|
|
581
921
|
* 4. Handles HITL_SESSION_ABORT:
|
|
582
922
|
* Explicit client abort or session abandonment.
|