app-manager-edge-worker 1.3.0 → 1.3.2

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.
@@ -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.
@@ -86,13 +87,89 @@ async function purgeSession(sessionId) {
86
87
 
87
88
  try {
88
89
  if (session.page && !session.page.isClosed()) {
89
- await session.page.close().catch(() => {});
90
+ await session.page.close().catch(() => { });
90
91
  }
91
- } catch (_) {}
92
+ } catch (_) { }
92
93
 
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 = [],
@@ -139,32 +217,36 @@ export async function handleInteractiveSessionInit(payload) {
139
217
  path: c.path || "/",
140
218
  }))
141
219
  )
142
- .catch(() => {});
220
+ .catch(() => { });
143
221
  }
144
222
  if (authentication.type === "HEADERS" && authentication.headers) {
145
- await page.setExtraHTTPHeaders(authentication.headers).catch(() => {});
223
+ await page.setExtraHTTPHeaders(authentication.headers).catch(() => { });
146
224
  }
147
225
  }
148
226
 
149
- // Navigate to target portal
150
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
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: 3500 }).catch(() => {});
153
- } catch (_) {}
233
+ await page.waitForNetworkIdle({ idleTime: 500, timeout: 4000 }).catch(() => { });
234
+ } catch (_) { }
154
235
 
155
236
  // Execute pre-scrape actions if present
156
237
  if (Array.isArray(preScrapeActions) && preScrapeActions.length > 0) {
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: action.timeoutMs || 4000 });
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: action.timeoutMs || 4000 });
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: action.timeoutMs || 8000 });
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,597 +257,821 @@ 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
- await page.waitForSelector(input.selector, { timeout: 6000 });
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 page.click(input.selector, { clickCount: 3 }).catch(() => {});
186
- await page.keyboard.press("Backspace").catch(() => {});
283
+ await element.click({ clickCount: 3 }).catch(() => { });
284
+ await page.keyboard.press("Backspace").catch(() => { });
187
285
  }
188
286
 
189
287
  if (input.actionType === "SELECT") {
190
- await page.select(input.selector, String(input.value));
288
+ await target.select(matchedSelector, String(input.value));
191
289
  } else {
192
- await page.type(input.selector, String(input.value), { delay: input.delayMs || 30 });
290
+ await element.type(String(input.value), { delay: input.delayMs || 30 });
193
291
  }
194
292
 
195
- // Explicitly dispatch input, change, and blur events to trigger AngularJS/React/portal validation listeners
196
- await page.evaluate((sel) => {
197
- const el = document.querySelector(sel);
198
- if (el) {
199
- el.dispatchEvent(new Event("input", { bubbles: true }));
200
- el.dispatchEvent(new Event("change", { bubbles: true }));
201
- el.dispatchEvent(new Event("blur", { bubbles: true }));
202
- }
203
- }, input.selector).catch(() => {});
204
- await page.keyboard.press("Tab").catch(() => {});
293
+ // Explicitly dispatch input, change, keydown, keyup, and blur events, plus sync AngularJS ngModel if present
294
+ await target.evaluate((sel) => {
295
+ const el = document.querySelector(sel);
296
+ if (el) {
297
+ el.dispatchEvent(new Event("input", { bubbles: true }));
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" }));
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
+ }
319
+ }
320
+ }, matchedSelector).catch(() => { });
205
321
 
206
- await new Promise((r) => setTimeout(r, 250));
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 imageSelector = captchaConfig.imageSelector || "img[src*='captcha'], #imgCaptcha, #captchaImg";
212
- await page.waitForSelector(imageSelector, { timeout: 8000 });
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
+ );
348
+ }
213
349
 
214
- const captchaElement = await page.$(imageSelector);
215
- if (!captchaElement) {
216
- throw new Error(`Captcha element not found with selector: ${imageSelector}`);
217
- }
350
+ const captchaElement = locatedImage.element;
351
+
352
+ // Capture screenshot of the exact visual captcha DOM node
353
+ const screenshotBuffer = await captchaElement.screenshot({
354
+ encoding: "binary",
355
+ type: "png",
356
+ });
357
+ const imageBase64 = Buffer.from(screenshotBuffer).toString("base64");
358
+
359
+ // Settle briefly for form state & portal validation UI
360
+ await new Promise((r) => setTimeout(r, 400));
361
+
362
+ // Capture full-page screenshot showing the filled inputs and revealed captcha
363
+ const pageScreenshotBuffer = await page.screenshot({
364
+ encoding: "binary",
365
+ fullPage: false,
366
+ type: "jpeg",
367
+ quality: 85,
368
+ });
369
+ const pageScreenshotBase64 = `data:image/jpeg;base64,${Buffer.from(pageScreenshotBuffer).toString("base64")}`;
370
+ const title = (await page.title().catch(() => "")) || "Interactive Portal Session";
371
+ const dimensions = await page
372
+ .evaluate(() => ({
373
+ width: document.documentElement.scrollWidth || window.innerWidth,
374
+ height: document.documentElement.scrollHeight || window.innerHeight,
375
+ }))
376
+ .catch(() => ({ width: 1366, height: 900 }));
377
+
378
+ const sessionTtlSeconds = captchaConfig.sessionTimeoutSeconds || 90;
379
+
380
+ // Set auto-purge timer to prevent resource leaks
381
+ const timeoutTimer = setTimeout(() => {
382
+ purgeSession(sessionId).catch(() => { });
383
+ }, sessionTtlSeconds * 1000);
384
+
385
+ // Store active session in memory
386
+ activeSessions.set(sessionId, {
387
+ page,
388
+ timeoutTimer,
389
+ createdAt: Date.now(),
390
+ url,
391
+ captchaConfig,
392
+ fieldMappings,
393
+ collections,
394
+ });
218
395
 
219
- // Capture screenshot of the exact visual captcha DOM node
220
- const screenshotBuffer = await captchaElement.screenshot({
221
- encoding: "binary",
222
- type: "png",
223
- });
224
- const imageBase64 = Buffer.from(screenshotBuffer).toString("base64");
225
-
226
- // Settle briefly for form state & portal validation UI
227
- await new Promise((r) => setTimeout(r, 400));
228
-
229
- // Capture full-page screenshot showing the filled inputs and revealed captcha
230
- const pageScreenshotBuffer = await page.screenshot({
231
- encoding: "binary",
232
- fullPage: false,
233
- type: "jpeg",
234
- quality: 85,
235
- });
236
- const pageScreenshotBase64 = `data:image/jpeg;base64,${Buffer.from(pageScreenshotBuffer).toString("base64")}`;
237
- const title = (await page.title().catch(() => "")) || "Interactive Portal Session";
238
- const dimensions = await page
239
- .evaluate(() => ({
240
- width: document.documentElement.scrollWidth || window.innerWidth,
241
- height: document.documentElement.scrollHeight || window.innerHeight,
242
- }))
243
- .catch(() => ({ width: 1366, height: 900 }));
244
-
245
- const sessionTtlSeconds = captchaConfig.sessionTimeoutSeconds || 90;
246
-
247
- // Set auto-purge timer to prevent resource leaks
248
- const timeoutTimer = setTimeout(() => {
249
- purgeSession(sessionId).catch(() => {});
250
- }, sessionTtlSeconds * 1000);
251
-
252
- // Store active session in memory
253
- activeSessions.set(sessionId, {
254
- page,
255
- timeoutTimer,
256
- createdAt: Date.now(),
257
- url,
258
- captchaConfig,
259
- fieldMappings,
260
- collections,
261
- });
262
-
263
- return {
264
- sessionId,
265
- imageBase64: `data:image/png;base64,${imageBase64}`,
266
- pageScreenshotBase64,
267
- dimensions,
268
- title,
269
- expiresInSeconds: sessionTtlSeconds,
270
- };
271
- } catch (err) {
272
- await page.close().catch(() => {});
273
- throw err;
396
+ return {
397
+ sessionId,
398
+ imageBase64: `data:image/png;base64,${imageBase64}`,
399
+ pageScreenshotBase64,
400
+ dimensions,
401
+ title,
402
+ expiresInSeconds: sessionTtlSeconds,
403
+ };
404
+ } catch (err) {
405
+ await page.close().catch(() => { });
406
+ throw err;
407
+ }
274
408
  }
275
- }
276
409
 
277
410
  /**
278
411
  * 2. Handles HITL_SESSION_REFRESH:
279
412
  * Clicks the refresh button and verifies the visual MD5 hash of the captcha changed.
280
413
  */
281
414
  export async function handleInteractiveSessionRefresh(payload) {
282
- const { sessionId, captchaConfig = {} } = payload;
283
- const session = activeSessions.get(sessionId);
415
+ const { sessionId, captchaConfig = {} } = payload;
416
+ const session = activeSessions.get(sessionId);
284
417
 
285
- if (!session || !session.page || session.page.isClosed()) {
286
- throw new Error("Interactive session not found or already expired");
287
- }
418
+ if (!session || !session.page || session.page.isClosed()) {
419
+ throw new Error("Interactive session not found or already expired");
420
+ }
288
421
 
289
- const { page } = session;
290
- const cfg = { ...session.captchaConfig, ...captchaConfig };
291
- const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
292
- const refreshSelector = cfg.refreshSelector;
422
+ const { page } = session;
423
+ const cfg = { ...session.captchaConfig, ...captchaConfig };
424
+ const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
425
+ const refreshSelector = cfg.refreshSelector;
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
+
435
+ // 1. Snapshot current image hash and src attribute
436
+ const locatedOldImg = await locateElementInPageOrFrames(page, candidateImageSelectors, 8000);
437
+ if (!locatedOldImg || !locatedOldImg.element) {
438
+ throw new Error(`Captcha element not found with selector: ${imageSelector}`);
439
+ }
440
+ const oldEl = locatedOldImg.element;
441
+ const effectiveImageSelector = locatedOldImg.matchedSelector;
293
442
 
294
- // 1. Snapshot current image hash and src attribute
295
- const oldEl = await page.$(imageSelector);
296
- if (!oldEl) {
297
- throw new Error(`Captcha element not found with selector: ${imageSelector}`);
298
- }
443
+ const oldBuffer = await oldEl.screenshot({ encoding: "binary" });
444
+ const oldHash = crypto.createHash("md5").update(oldBuffer).digest("hex");
299
445
 
300
- const oldBuffer = await oldEl.screenshot({ encoding: "binary" });
301
- const oldHash = crypto.createHash("md5").update(oldBuffer).digest("hex");
302
-
303
- const oldSrc = await page.evaluate((sel) => {
304
- const el = document.querySelector(sel);
305
- return el ? el.getAttribute("src") : null;
306
- }, imageSelector);
307
-
308
- // 2. Click the reload button with robust candidate fallbacks
309
- const candidateSelectors = [
310
- refreshSelector,
311
- "button[ng-click*='refreshCaptcha' i]",
312
- "button[ng-click*='captcha' i]",
313
- "*[ng-click*='refreshCaptcha' i]",
314
- "*[ng-click*='captcha' i]",
315
- "#reloadCaptcha",
316
- "#refreshCaptcha",
317
- "a[title*='refresh' i]",
318
- "button[title*='refresh' i]",
319
- "a[title*='reload' i]",
320
- "button[title*='reload' i]",
321
- ".captcha-refresh",
322
- ".reload-captcha",
323
- ".glyphicon-refresh",
324
- "i.fa-refresh",
325
- "i.fa-arrows-rotate",
326
- ].filter(Boolean);
327
-
328
- let clicked = false;
329
- for (const sel of candidateSelectors) {
330
- try {
331
- const btn = await page.$(sel);
332
- if (btn) {
333
- await btn.click();
334
- clicked = true;
335
- break;
336
- }
337
- } catch (_) {}
338
- }
446
+ const oldSrc = await locatedOldImg.target.evaluate((sel) => {
447
+ const el = document.querySelector(sel);
448
+ return el ? el.getAttribute("src") : null;
449
+ }, effectiveImageSelector);
450
+
451
+ // 2. Click the reload button with robust candidate fallbacks
452
+ const candidateSelectors = [
453
+ refreshSelector,
454
+ "button[ng-click*='refreshCaptcha' i]",
455
+ "button[ng-click*='captcha' i]",
456
+ "*[ng-click*='refreshCaptcha' i]",
457
+ "*[ng-click*='captcha' i]",
458
+ "#reloadCaptcha",
459
+ "#refreshCaptcha",
460
+ "a[title*='refresh' i]",
461
+ "button[title*='refresh' i]",
462
+ "a[title*='reload' i]",
463
+ "button[title*='reload' i]",
464
+ ".captcha-refresh",
465
+ ".reload-captcha",
466
+ ".glyphicon-refresh",
467
+ "i.fa-refresh",
468
+ "i.fa-arrows-rotate",
469
+ ].filter(Boolean);
470
+
471
+ let clicked = false;
472
+ for (const sel of candidateSelectors) {
473
+ try {
474
+ const locatedBtn = await locateElementInPageOrFrames(page, [sel], 500);
475
+ if (locatedBtn && locatedBtn.element) {
476
+ await locatedBtn.element.click();
477
+ clicked = true;
478
+ break;
479
+ }
480
+ } catch (_) { }
481
+ }
339
482
 
340
483
  if (!clicked) {
341
- throw new Error(`Captcha refresh button not found on page (tried: ${refreshSelector || "standard reload selectors"})`);
342
- }
484
+ throw new Error(`Captcha refresh button not found on page (tried: ${refreshSelector || "standard reload selectors"})`);
485
+ }
343
486
 
344
- // 3. Wait for src attribute change first
345
- await page
346
- .waitForFunction(
347
- (sel, prevSrc) => {
348
- const el = document.querySelector(sel);
349
- return el && el.getAttribute("src") !== prevSrc;
350
- },
351
- { timeout: 3500 },
352
- imageSelector,
353
- oldSrc
354
- )
355
- .catch(() => {});
356
-
357
- // 4. Verify visual pixel change via MD5 hash check
358
- let newBuffer = null;
359
- let retries = 0;
360
-
361
- while (retries < 6) {
362
- await new Promise((r) => setTimeout(r, 350));
363
- const newEl = await page.$(imageSelector);
364
- if (newEl) {
365
- newBuffer = await newEl.screenshot({ encoding: "binary" });
366
- const newHash = crypto.createHash("md5").update(newBuffer).digest("hex");
367
- if (newHash !== oldHash) {
368
- break; // Verified fresh captcha
369
- }
370
- }
371
- retries++;
372
- }
487
+ // 3. Wait for src attribute change first
488
+ await page
489
+ .waitForFunction(
490
+ (sel, prevSrc) => {
491
+ const el = document.querySelector(sel);
492
+ return el && el.getAttribute("src") !== prevSrc;
493
+ },
494
+ { timeout: 3500 },
495
+ imageSelector,
496
+ oldSrc
497
+ )
498
+ .catch(() => { });
499
+
500
+ // 4. Verify visual pixel change via MD5 hash check
501
+ let newBuffer = null;
502
+ let retries = 0;
503
+
504
+ while (retries < 6) {
505
+ await new Promise((r) => setTimeout(r, 350));
506
+ const newEl = await page.$(imageSelector);
507
+ if (newEl) {
508
+ newBuffer = await newEl.screenshot({ encoding: "binary" });
509
+ const newHash = crypto.createHash("md5").update(newBuffer).digest("hex");
510
+ if (newHash !== oldHash) {
511
+ break; // Verified fresh captcha
512
+ }
513
+ }
514
+ retries++;
515
+ }
373
516
 
374
- if (!newBuffer) {
375
- newBuffer = await oldEl.screenshot({ encoding: "binary" });
376
- }
517
+ if (!newBuffer) {
518
+ newBuffer = await oldEl.screenshot({ encoding: "binary" });
519
+ }
377
520
 
378
- const imageBase64 = Buffer.from(newBuffer).toString("base64");
521
+ const imageBase64 = Buffer.from(newBuffer).toString("base64");
522
+
523
+ // Capture updated full page screenshot showing new captcha
524
+ const pageScreenshotBuffer = await page.screenshot({
525
+ encoding: "binary",
526
+ fullPage: false,
527
+ type: "jpeg",
528
+ quality: 85,
529
+ });
530
+ const pageScreenshotBase64 = `data:image/jpeg;base64,${Buffer.from(pageScreenshotBuffer).toString("base64")}`;
531
+
532
+ return {
533
+ sessionId,
534
+ imageBase64: `data:image/png;base64,${imageBase64}`,
535
+ pageScreenshotBase64,
536
+ };
537
+ }
379
538
 
380
- // Capture updated full page screenshot showing new captcha
381
- const pageScreenshotBuffer = await page.screenshot({
382
- encoding: "binary",
383
- fullPage: false,
384
- type: "jpeg",
385
- quality: 85,
386
- });
387
- const pageScreenshotBase64 = `data:image/jpeg;base64,${Buffer.from(pageScreenshotBuffer).toString("base64")}`;
539
+ /**
540
+ * 3. Handles HITL_SESSION_SOLVE:
541
+ * Types the captcha solution, clicks submit, and performs a post-submit race.
542
+ * If captcha is invalid, keeps session alive and snaps new captcha for retry.
543
+ * If success, extracts data and closes page.
544
+ */
545
+ export async function handleInteractiveSessionSolve(payload) {
546
+ const {
547
+ sessionId,
548
+ captchaSolution,
549
+ captchaConfig = {},
550
+ fieldMappings = [],
551
+ collections = [],
552
+ preservePageForInspection = false,
553
+ } = payload;
554
+ const session = activeSessions.get(sessionId);
555
+
556
+ if (!session || !session.page || session.page.isClosed()) {
557
+ throw new Error("Interactive session not found or already expired");
558
+ }
388
559
 
389
- return {
390
- sessionId,
391
- imageBase64: `data:image/png;base64,${imageBase64}`,
392
- pageScreenshotBase64,
393
- };
394
- }
560
+ const { page } = session;
561
+ const cfg = { ...session.captchaConfig, ...captchaConfig };
562
+ const mappings = fieldMappings.length > 0 ? fieldMappings : session.fieldMappings || [];
563
+ const colls = collections.length > 0 ? collections : session.collections || [];
564
+
565
+ const inputSelector = cfg.inputSelector || "#captcha, input[name='captcha']";
566
+ const submitSelector = cfg.submitSelector || "button[type='submit']";
567
+ const successSelector = cfg.waitForAfterSubmit?.selector || ".table-responsive, table, .result-pane";
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
+ ".err, span.err, p.err, div.err, [class*='err-'], [class*='-error'], " +
576
+ "[data-ng-bind*='ERR'], [data-ng-bind*='err'], [data-ng-show*='err'], " +
577
+ "[data-ng-show*='w_gst'], [data-ng-if*='captcha'], [data-ng-show*='captcha'], [role='alert'], " +
578
+ "[style*='color:red'], [style*='color: red']";
579
+
580
+ const customRules = cfg.errorDetection?.rules || [];
581
+ const ruleSelectors = customRules.map((r) => r.selector).filter(Boolean);
582
+ const combinedErrorSelectors = [
583
+ cfg.errorDetection?.selector,
584
+ ...ruleSelectors,
585
+ defaultBroadErrorSelector,
586
+ ]
587
+ .filter(Boolean)
588
+ .join(", ");
589
+
590
+ const invalidPattern = cfg.errorDetection?.invalidCaptchaPattern || "captcha.*(incorrect|invalid|wrong|mismatch|enter text)";
591
+
592
+ // 1. Type captcha solution with robust candidate fallbacks & frame search
593
+ const solveTimeout = Math.max(cfg.inputTimeoutMs || 0, 15000);
594
+ const candidateInputSelectors = [
595
+ inputSelector,
596
+ "#fo-captcha",
597
+ "#captcha",
598
+ "input[name='cap']",
599
+ "input[name*='captcha' i]",
600
+ "input[id*='captcha' i]",
601
+ "input[placeholder*='captcha' i]",
602
+ ].filter(Boolean);
603
+
604
+ const locatedInput = await locateElementInPageOrFrames(page, candidateInputSelectors, solveTimeout);
605
+ if (!locatedInput || !locatedInput.element) {
606
+ throw new Error(`Captcha input field not found with selector: ${inputSelector}`);
607
+ }
395
608
 
396
- /**
397
- * 3. Handles HITL_SESSION_SOLVE:
398
- * Types the captcha solution, clicks submit, and performs a post-submit race.
399
- * If captcha is invalid, keeps session alive and snaps new captcha for retry.
400
- * If success, extracts data and closes page.
401
- */
402
- export async function handleInteractiveSessionSolve(payload) {
403
- const {
404
- sessionId,
405
- captchaSolution,
406
- captchaConfig = {},
407
- fieldMappings = [],
408
- collections = [],
409
- preservePageForInspection = false,
410
- } = payload;
411
- const session = activeSessions.get(sessionId);
609
+ await locatedInput.element.click({ clickCount: 3 }).catch(() => { });
610
+ await page.keyboard.press("Backspace").catch(() => { });
611
+ await locatedInput.element.type(String(captchaSolution).trim(), { delay: 35 });
412
612
 
413
- if (!session || !session.page || session.page.isClosed()) {
414
- throw new Error("Interactive session not found or already expired");
415
- }
613
+ // Sync input events and AngularJS ngModel for captcha input
614
+ await locatedInput.target.evaluate((sel) => {
615
+ const el = document.querySelector(sel);
616
+ if (el) {
617
+ el.dispatchEvent(new Event("input", { bubbles: true }));
618
+ el.dispatchEvent(new Event("change", { bubbles: true }));
619
+ el.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
620
+ el.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "Enter" }));
621
+ el.dispatchEvent(new Event("blur", { bubbles: true }));
416
622
 
417
- const { page } = session;
418
- const cfg = { ...session.captchaConfig, ...captchaConfig };
419
- const mappings = fieldMappings.length > 0 ? fieldMappings : session.fieldMappings || [];
420
- const colls = collections.length > 0 ? collections : session.collections || [];
421
-
422
- const inputSelector = cfg.inputSelector || "#captcha, input[name='captcha']";
423
- const submitSelector = cfg.submitSelector || "button[type='submit']";
424
- const successSelector = cfg.waitForAfterSubmit?.selector || ".table-responsive, table, .result-pane";
425
-
426
- // Broad default error selectors covering Indian gov and modern SPA patterns
427
- const defaultBroadErrorSelector =
428
- "p#alertMsg, #alertMsg, .alertMsg, .alert-danger, .alert-warning, " +
429
- ".has-error, .text-danger, #error-msg, .error-message, .err-msg, " +
430
- ".ui-messages-error, [class*='error-msg'], [class*='alert-message'], " +
431
- ".invalid-feedback, .help-block.error, .form-error, [aria-invalid='true']";
432
-
433
- const customRules = cfg.errorDetection?.rules || [];
434
- const ruleSelectors = customRules.map((r) => r.selector).filter(Boolean);
435
- const combinedErrorSelectors = [
436
- cfg.errorDetection?.selector,
437
- ...ruleSelectors,
438
- defaultBroadErrorSelector,
439
- ]
440
- .filter(Boolean)
441
- .join(", ");
442
-
443
- const invalidPattern = cfg.errorDetection?.invalidCaptchaPattern || "captcha.*(incorrect|invalid|wrong|mismatch|enter text)";
444
-
445
- // 1. Type captcha solution
446
- await page.waitForSelector(inputSelector, { timeout: 4000 });
447
- await page.click(inputSelector, { clickCount: 3 }).catch(() => {});
448
- await page.keyboard.press("Backspace").catch(() => {});
449
- await page.type(inputSelector, String(captchaSolution).trim(), { delay: 35 });
450
-
451
- // 2. Click submit button
452
- await page.waitForSelector(submitSelector, { timeout: 4000 });
453
- await page.click(submitSelector);
454
-
455
- // 3. Post-Submit Race: Wait for Success Selector OR Error Selector
456
- // In inspection mode, use a shorter safety window (5-7s) so users aren't stalled waiting on empty selectors
457
- const safetyTimeoutMs = preservePageForInspection
458
- ? Math.min(cfg.waitForAfterSubmit?.timeoutMs || 8000, 6000)
459
- : (cfg.waitForAfterSubmit?.timeoutMs || 15000);
460
-
461
- const matchedOutcome = await page
462
- .waitForFunction(
463
- (succ, err) => {
464
- if (document.querySelector(succ)) return "SUCCESS";
465
- try {
466
- const elements = document.querySelectorAll(err);
467
- for (const el of elements) {
468
- const txt = (el.innerText || el.textContent || "").trim();
469
- if (txt.length > 0) {
470
- const style = window.getComputedStyle(el);
471
- if (style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0") {
472
- return "ERROR";
473
- }
623
+ if (window.angular) {
624
+ try {
625
+ const ngEl = window.angular.element(el);
626
+ const ngModel = ngEl.controller("ngModel");
627
+ if (ngModel) {
628
+ ngModel.$setViewValue(el.value);
629
+ ngModel.$render();
630
+ ngModel.$commitViewValue();
631
+ }
632
+ const scope = ngEl.scope();
633
+ if (scope && !scope.$$phase) scope.$apply();
634
+ } catch (_) { }
474
635
  }
475
636
  }
476
- } catch (e) {}
477
- return null;
478
- },
479
- { timeout: safetyTimeoutMs },
480
- successSelector,
481
- combinedErrorSelectors
482
- )
483
- .then((handle) => handle.jsonValue())
484
- .catch(() => "TIMEOUT");
485
-
486
- // Case A: Success Result Page
487
- if (matchedOutcome === "SUCCESS") {
488
- const raw = {};
489
- for (const mapping of mappings) {
490
- let val = null;
491
- const strategies = mapping.strategies || (mapping.strategy ? [mapping.strategy] : []);
492
- for (const strat of strategies) {
493
- val = await extractBrowserField(page, strat);
494
- if (val !== null && val !== undefined && val !== "") break;
495
- }
496
-
497
- if (mapping.transforms && mapping.transforms.length > 0) {
498
- val = runTransformPipeline(val, mapping.transforms);
499
- }
500
- if (mapping.type) {
501
- val = castValueToType(val, mapping.type);
502
- }
503
- raw[mapping.field || mapping.key] = val;
504
- }
637
+ }, locatedInput.matchedSelector).catch(() => { });
638
+
639
+ // 2. Click submit button with candidate fallbacks & frame search
640
+ const candidateSubmitSelectors = [
641
+ submitSelector,
642
+ "#lotsearch",
643
+ "button[type='submit']",
644
+ "input[type='submit']",
645
+ "button[id*='search' i]",
646
+ "button[id*='submit' i]",
647
+ "button.btn-primary",
648
+ ].filter(Boolean);
649
+
650
+ const locatedSubmit = await locateElementInPageOrFrames(page, candidateSubmitSelectors, solveTimeout);
651
+ if (!locatedSubmit || !locatedSubmit.element) {
652
+ throw new Error(`Submit button not found with selector: ${submitSelector}`);
653
+ }
505
654
 
506
- if (preservePageForInspection) {
507
- // Keep the page alive for visual inspection & testing
508
- if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
509
- const inspectTtl = 300; // 5 minutes for interactive element picking
510
- session.timeoutTimer = setTimeout(() => {
511
- purgeSession(sessionId).catch(() => {});
512
- }, inspectTtl * 1000);
655
+ await locatedSubmit.element.click().catch(() => { });
656
+ // Also dispatch native click & submit events to ensure AngularJS/React/SPA form listeners fire
657
+ await locatedSubmit.target.evaluate((sel) => {
658
+ const btn = document.querySelector(sel);
659
+ if (btn) {
660
+ try { btn.click(); } catch (_) { }
661
+ if (btn.form) {
662
+ try {
663
+ btn.form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
664
+ } catch (_) { }
665
+ }
666
+ }
667
+ }, locatedSubmit.matchedSelector).catch(() => { });
668
+
669
+ // 3. Post-Submit Race: Wait for Success Selector OR Error Selector
670
+ // In inspection mode, use a shorter safety window (5-7s) so users aren't stalled waiting on empty selectors
671
+ const safetyTimeoutMs = preservePageForInspection
672
+ ? Math.min(cfg.waitForAfterSubmit?.timeoutMs || 8000, 6000)
673
+ : (cfg.waitForAfterSubmit?.timeoutMs || 15000);
674
+
675
+ const matchedOutcome = await page
676
+ .waitForFunction(
677
+ (succ, err) => {
678
+ const isVisible = (el) => {
679
+ if (!el) return false;
680
+ const style = window.getComputedStyle(el);
681
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false;
682
+ const rects = el.getClientRects();
683
+ if (rects.length === 0) return false;
684
+ return rects[0].width > 0 || rects[0].height > 0;
685
+ };
686
+
687
+ const isRealError = (txt) => {
688
+ if (!txt || txt.length < 3) return false;
689
+ const lower = txt.trim().toLowerCase();
690
+ if (lower.startsWith("loading") || lower.startsWith("please wait") || lower.startsWith("processing")) {
691
+ return false;
692
+ }
693
+ return true;
694
+ };
695
+
696
+ // 1. Error selectors check (Must run FIRST to prevent false positives from generic table selectors)
697
+ try {
698
+ const elements = document.querySelectorAll(err);
699
+ for (const el of elements) {
700
+ const txt = (el.innerText || el.textContent || "").trim();
701
+ if (isRealError(txt) && isVisible(el)) {
702
+ return "ERROR";
703
+ }
704
+ }
705
+ } catch (_) { }
706
+
707
+ // 2. Semantic error text scan across visible leaf elements
708
+ try {
709
+ const errorRegex = /\b(invalid\s*(gstin|uin|captcha|code|input|format|number|pan)|gstin.*invalid|does\s*not\s*exist|record\s*not\s*found|no\s*records?\s*found|incorrect\s*captcha|wrong\s*captcha|enter\s*valid\s*letters|enter\s*a\s*valid|not\s*registered|mandatory\s*field|action\s*failed)\b/i;
710
+ const candidates = document.querySelectorAll("span, p, div, label, font, b, strong");
711
+ for (const c of candidates) {
712
+ if (c.children.length === 0 || c.tagName === "P" || c.tagName === "SPAN") {
713
+ const txt = (c.innerText || c.textContent || "").trim();
714
+ if (txt.length >= 5 && txt.length < 250 && errorRegex.test(txt) && isRealError(txt)) {
715
+ if (isVisible(c)) {
716
+ return "ERROR";
717
+ }
718
+ }
719
+ }
720
+ }
721
+ } catch (_) { }
722
+
723
+ // 3. Success check: must be genuinely visible and contain substantive data
724
+ try {
725
+ const successElements = document.querySelectorAll(succ);
726
+ for (const el of successElements) {
727
+ if (isVisible(el)) {
728
+ // Ignore empty 1-cell layout tables like captcha wrapper tables
729
+ if (el.tagName === "TABLE" && (el.innerText || "").trim().length < 15) {
730
+ continue;
731
+ }
732
+ return "SUCCESS";
733
+ }
734
+ }
735
+ } catch (_) { }
736
+
737
+ return null;
738
+ },
739
+ { timeout: safetyTimeoutMs },
740
+ successSelector,
741
+ combinedErrorSelectors
742
+ )
743
+ .then((handle) => handle.jsonValue())
744
+ .catch(() => "TIMEOUT");
745
+
746
+ // Case A: Success Result Page
747
+ if (matchedOutcome === "SUCCESS") {
748
+ const raw = {};
749
+ for (const mapping of mappings) {
750
+ let val = null;
751
+ const strategies = mapping.strategies || (mapping.strategy ? [mapping.strategy] : []);
752
+ for (const strat of strategies) {
753
+ val = await extractBrowserField(page, strat);
754
+ if (val !== null && val !== undefined && val !== "") break;
755
+ }
513
756
 
514
- // Settle time for tables & styles
515
- await new Promise((r) => setTimeout(r, 600));
757
+ if (mapping.transforms && mapping.transforms.length > 0) {
758
+ val = runTransformPipeline(val, mapping.transforms);
759
+ }
760
+ if (mapping.type) {
761
+ val = castValueToType(val, mapping.type);
762
+ }
763
+ raw[mapping.field || mapping.key] = val;
764
+ }
516
765
 
517
- const snap = await captureInspectionSnapshot(page);
518
- return {
519
- status: "COMPLETED",
520
- success: true,
521
- data: raw,
522
- ...snap,
523
- sessionId,
524
- isInspectionMode: true,
525
- };
526
- }
766
+ if (preservePageForInspection) {
767
+ // Keep the page alive for visual inspection & testing
768
+ if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
769
+ const inspectTtl = 300; // 5 minutes for interactive element picking
770
+ session.timeoutTimer = setTimeout(() => {
771
+ purgeSession(sessionId).catch(() => { });
772
+ }, inspectTtl * 1000);
773
+
774
+ // Settle time for tables & styles
775
+ await new Promise((r) => setTimeout(r, 600));
776
+
777
+ const snap = await captureInspectionSnapshot(page);
778
+ return {
779
+ status: "COMPLETED",
780
+ success: true,
781
+ data: raw,
782
+ ...snap,
783
+ sessionId,
784
+ isInspectionMode: true,
785
+ };
786
+ }
527
787
 
528
- // Clean up session and close page
529
- await purgeSession(sessionId);
788
+ // Clean up session and close page
789
+ await purgeSession(sessionId);
530
790
 
531
- return {
532
- status: "COMPLETED",
533
- success: true,
534
- data: raw,
535
- };
536
- }
791
+ return {
792
+ status: "COMPLETED",
793
+ success: true,
794
+ data: raw,
795
+ };
796
+ }
537
797
 
538
- // Evaluate error rules and messages if matchedOutcome === "ERROR" or if we are inspecting
539
- let matchedRule = null;
540
- let errorText = "";
798
+ // Evaluate error rules and messages if matchedOutcome === "ERROR" or if we are inspecting or if timed out
799
+ let matchedRule = null;
800
+ let errorText = "";
541
801
 
542
- // 1. Check custom configured rules first
543
- for (const rule of customRules) {
544
- if (!rule.selector) continue;
545
- const check = await page.evaluate((sel, pat) => {
546
- try {
547
- const els = document.querySelectorAll(sel);
548
- for (const el of els) {
549
- const txt = (el.innerText || el.textContent || "").trim();
550
- if (txt) {
802
+ const checkRulesAndText = await page.evaluate((rules, errorSelector) => {
803
+ const isVisible = (el) => {
804
+ if (!el) return false;
551
805
  const style = window.getComputedStyle(el);
552
- if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue;
553
- if (!pat) return { matched: true, text: txt };
554
- const re = new RegExp(pat, "i");
555
- if (re.test(txt)) return { matched: true, text: txt };
806
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false;
807
+ const rects = el.getClientRects();
808
+ if (rects.length === 0) return false;
809
+ return rects[0].width > 0 || rects[0].height > 0;
810
+ };
811
+
812
+ const isRealError = (txt) => {
813
+ if (!txt || txt.length < 3) return false;
814
+ const lower = txt.trim().toLowerCase();
815
+ if (lower.startsWith("loading") || lower.startsWith("please wait") || lower.startsWith("processing")) {
816
+ return false;
817
+ }
818
+ return true;
819
+ };
820
+
821
+ // 1. Check custom rules with rule.selector
822
+ for (const rule of rules) {
823
+ if (!rule.selector) continue;
824
+ try {
825
+ const els = document.querySelectorAll(rule.selector);
826
+ for (const el of els) {
827
+ const txt = (el.innerText || el.textContent || "").trim();
828
+ if (isRealError(txt) && isVisible(el)) {
829
+ if (!rule.pattern) return { matchedRuleId: rule.id, text: txt };
830
+ const re = new RegExp(rule.pattern, "i");
831
+ if (re.test(txt)) return { matchedRuleId: rule.id, text: txt };
832
+ }
833
+ }
834
+ } catch (_) { }
556
835
  }
557
- }
558
- } catch (e) {}
559
- return null;
560
- }, rule.selector, rule.pattern);
561
-
562
- if (check && check.matched) {
563
- matchedRule = rule;
564
- errorText = check.text;
565
- break;
566
- }
567
- }
568
836
 
569
- // 2. If no custom rule matched, extract text from combined error selectors
570
- if (!matchedRule) {
571
- errorText = await page.evaluate((sel) => {
572
- try {
573
- const els = document.querySelectorAll(sel);
574
- for (const el of els) {
575
- const txt = (el.innerText || el.textContent || "").trim();
576
- if (txt) {
577
- const style = window.getComputedStyle(el);
578
- if (style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0") {
579
- return txt;
837
+ // 2. Extract visible text from combinedErrorSelectors
838
+ let foundText = "";
839
+ try {
840
+ const els = document.querySelectorAll(errorSelector);
841
+ for (const el of els) {
842
+ const txt = (el.innerText || el.textContent || "").trim();
843
+ if (isRealError(txt) && isVisible(el)) {
844
+ foundText = txt;
845
+ break;
846
+ }
847
+ }
848
+ } catch (_) { }
849
+
850
+ // 3. Semantic fallback if foundText is still empty
851
+ if (!foundText) {
852
+ const errorRegex = /\b(invalid\s*(gstin|uin|captcha|code|input|format|number|pan)|gstin.*invalid|does\s*not\s*exist|record\s*not\s*found|no\s*records?\s*found|incorrect\s*captcha|wrong\s*captcha|enter\s*valid\s*letters|enter\s*a\s*valid|not\s*registered|mandatory\s*field|action\s*failed)\b/i;
853
+ const candidates = document.querySelectorAll("span, p, div, label, font, b, strong");
854
+ for (const c of candidates) {
855
+ if (c.children.length === 0 || c.tagName === "P" || c.tagName === "SPAN") {
856
+ const txt = (c.innerText || c.textContent || "").trim();
857
+ if (txt.length >= 5 && txt.length < 250 && errorRegex.test(txt) && isRealError(txt)) {
858
+ if (isVisible(c)) {
859
+ foundText = txt;
860
+ break;
861
+ }
862
+ }
863
+ }
580
864
  }
581
865
  }
582
- }
583
- } catch (e) {}
584
- return "";
585
- }, combinedErrorSelectors);
586
- }
587
866
 
588
- const regex = new RegExp(invalidPattern, "i");
589
- const isCaptchaError = matchedRule
590
- ? matchedRule.action === "RETRY_CAPTCHA"
591
- : (Boolean(errorText) && regex.test(errorText));
867
+ // 4. If we found text, check if any custom rule matches this text by pattern (even if rule.selector didn't match!)
868
+ let fallbackRuleId = null;
869
+ if (foundText) {
870
+ for (const rule of rules) {
871
+ if (rule.pattern) {
872
+ try {
873
+ const re = new RegExp(rule.pattern, "i");
874
+ if (re.test(foundText)) {
875
+ fallbackRuleId = rule.id;
876
+ break;
877
+ }
878
+ } catch (_) { }
879
+ }
880
+ }
881
+ }
592
882
 
593
- // Check if new captcha image was rendered on page
594
- const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
595
- let newImageBase64 = null;
596
- try {
597
- const newCaptchaEl = await page.$(imageSelector);
598
- if (newCaptchaEl) {
599
- const newBuffer = await newCaptchaEl.screenshot({ encoding: "binary", type: "png" });
600
- newImageBase64 = `data:image/png;base64,${Buffer.from(newBuffer).toString("base64")}`;
601
- }
602
- } catch (e) {}
603
-
604
- // CRITICAL FOR INSPECTOR: In inspection mode, NEVER purge the session on errors or timeout!
605
- // Always snap the live DOM and return it so the user can inspect the red text and configure selectors
606
- if (preservePageForInspection) {
607
- if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
608
- const inspectTtl = 300;
609
- session.timeoutTimer = setTimeout(() => {
610
- purgeSession(sessionId).catch(() => {});
611
- }, inspectTtl * 1000);
612
-
613
- const snap = await captureInspectionSnapshot(page);
614
- const effectiveStatus = isCaptchaError
615
- ? "INVALID_CAPTCHA"
616
- : (errorText ? "BUSINESS_ERROR" : "INSPECTION_MODE");
617
-
618
- return {
619
- status: effectiveStatus,
620
- success: true, // Allows frontend to render the inspection screenshot
621
- errorCode: (matchedRule && matchedRule.id) || (isCaptchaError ? "INVALID_CAPTCHA" : (errorText ? "BUSINESS_ERROR" : "INSPECTION_MODE")),
622
- 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."),
623
- newImageBase64,
624
- matchedRule: matchedRule ? { id: matchedRule.id, name: matchedRule.name, action: matchedRule.action } : null,
625
- sessionId,
626
- isInspectionMode: true,
627
- ...snap,
628
- };
629
- }
883
+ return {
884
+ matchedRuleId: fallbackRuleId,
885
+ text: foundText,
886
+ };
887
+ }, customRules, combinedErrorSelectors);
630
888
 
631
- // Runtime non-inspection mode:
632
- if (isCaptchaError) {
633
- if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
634
- const retryTtlSeconds = cfg.sessionTimeoutSeconds || 90;
635
- session.timeoutTimer = setTimeout(() => {
636
- purgeSession(sessionId).catch(() => {});
637
- }, retryTtlSeconds * 1000);
638
-
639
- return {
640
- status: "INVALID_CAPTCHA",
641
- success: false,
642
- message: (matchedRule && matchedRule.customMessage) || errorText || "The captcha entered was incorrect.",
643
- canRetry: true,
644
- newImageBase64,
645
- errorCode: (matchedRule && matchedRule.id) || "INVALID_CAPTCHA",
646
- };
647
- }
889
+ if (checkRulesAndText && checkRulesAndText.matchedRuleId) {
890
+ matchedRule = customRules.find((r) => r.id === checkRulesAndText.matchedRuleId) || null;
891
+ }
892
+ errorText = (checkRulesAndText && checkRulesAndText.text) || "";
648
893
 
649
- if (errorText || matchedRule) {
650
- await purgeSession(sessionId);
651
- return {
652
- status: "BUSINESS_ERROR",
653
- success: false,
654
- errorCode: (matchedRule && matchedRule.id) || "BUSINESS_ERROR",
655
- message: (matchedRule && matchedRule.customMessage) || errorText || "Target portal returned an error.",
656
- canRetry: false,
657
- };
658
- }
894
+ const regex = new RegExp(invalidPattern, "i");
895
+ const isCaptchaError = matchedRule
896
+ ? matchedRule.action === "RETRY_CAPTCHA"
897
+ : (Boolean(errorText) && regex.test(errorText));
659
898
 
660
- // Runtime Timeout
661
- await purgeSession(sessionId);
662
- return {
663
- status: "TIMEOUT",
664
- success: false,
665
- message: "Target portal did not respond with results within the expected time window.",
666
- canRetry: false,
667
- };
668
- }
899
+ // Check if new captcha image was rendered on page
900
+ const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
901
+ let newImageBase64 = null;
902
+ try {
903
+ const newCaptchaEl = await page.$(imageSelector);
904
+ if (newCaptchaEl) {
905
+ const newBuffer = await newCaptchaEl.screenshot({ encoding: "binary", type: "png" });
906
+ newImageBase64 = `data:image/png;base64,${Buffer.from(newBuffer).toString("base64")}`;
907
+ }
908
+ } catch (e) { }
909
+
910
+ // CRITICAL FOR INSPECTOR: In inspection mode, NEVER purge the session on errors or timeout!
911
+ // Always snap the live DOM and return it so the user can inspect the red text and configure selectors
912
+ if (preservePageForInspection) {
913
+ if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
914
+ const inspectTtl = 300;
915
+ session.timeoutTimer = setTimeout(() => {
916
+ purgeSession(sessionId).catch(() => { });
917
+ }, inspectTtl * 1000);
918
+
919
+ const snap = await captureInspectionSnapshot(page);
920
+ const effectiveStatus = isCaptchaError
921
+ ? "INVALID_CAPTCHA"
922
+ : (errorText ? "BUSINESS_ERROR" : "INSPECTION_MODE");
923
+
924
+ return {
925
+ status: effectiveStatus,
926
+ success: true, // Allows frontend to render the inspection screenshot
927
+ errorCode: (matchedRule && matchedRule.id) || (isCaptchaError ? "INVALID_CAPTCHA" : (errorText ? "BUSINESS_ERROR" : "INSPECTION_MODE")),
928
+ 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."),
929
+ newImageBase64,
930
+ matchedRule: matchedRule ? { id: matchedRule.id, name: matchedRule.name, action: matchedRule.action } : null,
931
+ sessionId,
932
+ isInspectionMode: true,
933
+ ...snap,
934
+ };
935
+ }
669
936
 
670
- /**
671
- * Capture full inspection snapshot helper
672
- */
673
- async function captureInspectionSnapshot(page) {
674
- await new Promise((r) => setTimeout(r, 500));
675
- const screenshotBuffer = await page.screenshot({
676
- encoding: "binary",
677
- fullPage: false,
678
- type: "jpeg",
679
- quality: 85,
680
- });
681
- const screenshotBase64 = `data:image/jpeg;base64,${Buffer.from(screenshotBuffer).toString("base64")}`;
682
- const title = await page.title().catch(() => "");
683
- const dimensions = await page
684
- .evaluate(() => ({
685
- width: document.documentElement.scrollWidth || window.innerWidth,
686
- height: document.documentElement.scrollHeight || window.innerHeight,
687
- }))
688
- .catch(() => ({ width: 1366, height: 900 }));
689
-
690
- return {
691
- screenshotBase64,
692
- dimensions,
693
- title: title || "Inspection Page",
694
- };
695
- }
937
+ // Runtime non-inspection mode:
938
+ if (isCaptchaError) {
939
+ if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
940
+ const retryTtlSeconds = cfg.sessionTimeoutSeconds || 90;
941
+ session.timeoutTimer = setTimeout(() => {
942
+ purgeSession(sessionId).catch(() => { });
943
+ }, retryTtlSeconds * 1000);
944
+
945
+ return {
946
+ status: "INVALID_CAPTCHA",
947
+ success: false,
948
+ message: (matchedRule && matchedRule.customMessage) || errorText || "The captcha entered was incorrect.",
949
+ canRetry: true,
950
+ newImageBase64,
951
+ errorCode: (matchedRule && matchedRule.id) || "INVALID_CAPTCHA",
952
+ };
953
+ }
696
954
 
697
- /**
698
- * Force capture snapshot of the active interactive session's page
699
- */
700
- export async function handleInteractiveSessionSnapshot(payload) {
701
- const { sessionId } = payload;
702
- const session = activeSessions.get(sessionId);
955
+ if (errorText || matchedRule) {
956
+ await purgeSession(sessionId);
957
+ return {
958
+ status: "BUSINESS_ERROR",
959
+ success: false,
960
+ errorCode: (matchedRule && matchedRule.id) || "BUSINESS_ERROR",
961
+ message: (matchedRule && matchedRule.customMessage) || errorText || "Target portal returned an error.",
962
+ canRetry: false,
963
+ };
964
+ }
703
965
 
704
- if (!session || !session.page || session.page.isClosed()) {
705
- throw new Error("Interactive session not found or already expired");
706
- }
966
+ // Runtime Timeout
967
+ await purgeSession(sessionId);
968
+ return {
969
+ status: "TIMEOUT",
970
+ success: false,
971
+ message: "Target portal did not respond with results within the expected time window.",
972
+ canRetry: false,
973
+ };
974
+ }
707
975
 
708
- const { page } = session;
709
- if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
710
- session.timeoutTimer = setTimeout(() => {
711
- purgeSession(sessionId).catch(() => {});
712
- }, 300 * 1000);
976
+ /**
977
+ * Capture full inspection snapshot helper
978
+ */
979
+ async function captureInspectionSnapshot(page) {
980
+ await new Promise((r) => setTimeout(r, 500));
981
+ const screenshotBuffer = await page.screenshot({
982
+ encoding: "binary",
983
+ fullPage: false,
984
+ type: "jpeg",
985
+ quality: 85,
986
+ });
987
+ const screenshotBase64 = `data:image/jpeg;base64,${Buffer.from(screenshotBuffer).toString("base64")}`;
988
+ const title = await page.title().catch(() => "");
989
+ const dimensions = await page
990
+ .evaluate(() => ({
991
+ width: document.documentElement.scrollWidth || window.innerWidth,
992
+ height: document.documentElement.scrollHeight || window.innerHeight,
993
+ }))
994
+ .catch(() => ({ width: 1366, height: 900 }));
995
+
996
+ return {
997
+ screenshotBase64,
998
+ dimensions,
999
+ title: title || "Inspection Page",
1000
+ };
1001
+ }
713
1002
 
714
- const snap = await captureInspectionSnapshot(page);
715
- return {
716
- success: true,
717
- sessionId,
718
- ...snap,
719
- };
720
- }
1003
+ /**
1004
+ * Force capture snapshot of the active interactive session's page
1005
+ */
1006
+ export async function handleInteractiveSessionSnapshot(payload) {
1007
+ const { sessionId } = payload;
1008
+ const session = activeSessions.get(sessionId);
721
1009
 
722
- /**
723
- * 4. Handles HITL_SESSION_ABORT:
724
- * Explicit client abort or session abandonment.
725
- */
726
- export async function handleInteractiveSessionAbort(payload) {
727
- const { sessionId } = payload;
728
- if (sessionId) {
729
- await purgeSession(sessionId);
730
- }
731
- return { success: true, aborted: true };
732
- }
1010
+ if (!session || !session.page || session.page.isClosed()) {
1011
+ throw new Error("Interactive session not found or already expired");
1012
+ }
733
1013
 
734
- /**
735
- * 5. Handles HITL_SESSION_EXTRACT:
736
- * Extracts fields directly from an active interactive session's live page.
737
- */
738
- export async function handleInteractiveSessionExtract(payload) {
739
- const { sessionId, fieldMappings = [], collections = [] } = payload;
740
- const session = activeSessions.get(sessionId);
1014
+ const { page } = session;
1015
+ if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
1016
+ session.timeoutTimer = setTimeout(() => {
1017
+ purgeSession(sessionId).catch(() => { });
1018
+ }, 300 * 1000);
1019
+
1020
+ const snap = await captureInspectionSnapshot(page);
1021
+ return {
1022
+ success: true,
1023
+ sessionId,
1024
+ ...snap,
1025
+ };
1026
+ }
741
1027
 
742
- if (!session || !session.page || session.page.isClosed()) {
743
- throw new Error("Interactive session not found or already expired");
744
- }
1028
+ /**
1029
+ * 4. Handles HITL_SESSION_ABORT:
1030
+ * Explicit client abort or session abandonment.
1031
+ */
1032
+ export async function handleInteractiveSessionAbort(payload) {
1033
+ const { sessionId } = payload;
1034
+ if (sessionId) {
1035
+ await purgeSession(sessionId);
1036
+ }
1037
+ return { success: true, aborted: true };
1038
+ }
745
1039
 
746
- const { page } = session;
747
- const raw = {};
1040
+ /**
1041
+ * 5. Handles HITL_SESSION_EXTRACT:
1042
+ * Extracts fields directly from an active interactive session's live page.
1043
+ */
1044
+ export async function handleInteractiveSessionExtract(payload) {
1045
+ const { sessionId, fieldMappings = [], collections = [] } = payload;
1046
+ const session = activeSessions.get(sessionId);
748
1047
 
749
- for (const mapping of fieldMappings) {
750
- let val = null;
751
- const strategies = mapping.strategies || (mapping.strategy ? [mapping.strategy] : []);
752
- for (const strat of strategies) {
753
- val = await extractBrowserField(page, strat);
754
- if (val !== null && val !== undefined && val !== "") break;
755
- }
1048
+ if (!session || !session.page || session.page.isClosed()) {
1049
+ throw new Error("Interactive session not found or already expired");
1050
+ }
756
1051
 
757
- if (mapping.transforms && mapping.transforms.length > 0) {
758
- val = runTransformPipeline(val, mapping.transforms);
759
- }
760
- if (mapping.type) {
761
- val = castValueToType(val, mapping.type);
762
- }
763
- raw[mapping.field || mapping.key] = val;
764
- }
1052
+ const { page } = session;
1053
+ const raw = {};
765
1054
 
766
- return {
767
- success: true,
768
- data: raw,
769
- };
770
- }
1055
+ for (const mapping of fieldMappings) {
1056
+ let val = null;
1057
+ const strategies = mapping.strategies || (mapping.strategy ? [mapping.strategy] : []);
1058
+ for (const strat of strategies) {
1059
+ val = await extractBrowserField(page, strat);
1060
+ if (val !== null && val !== undefined && val !== "") break;
1061
+ }
1062
+
1063
+ if (mapping.transforms && mapping.transforms.length > 0) {
1064
+ val = runTransformPipeline(val, mapping.transforms);
1065
+ }
1066
+ if (mapping.type) {
1067
+ val = castValueToType(val, mapping.type);
1068
+ }
1069
+ raw[mapping.field || mapping.key] = val;
1070
+ }
1071
+
1072
+ return {
1073
+ success: true,
1074
+ data: raw,
1075
+ };
1076
+ }
771
1077