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 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.2.5",
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
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "module";
2
2
 
3
3
  const require = createRequire(import.meta.url);
4
- let workerVersion = "1.2.5";
4
+ let workerVersion = "1.3.1";
5
5
  try {
6
6
  const pkg = require("../package.json");
7
7
  if (pkg?.version) workerVersion = pkg.version;
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.2.5";
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
- 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(() => {});
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: 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,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
- 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(() => {});
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 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) => {
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
- }, input.selector).catch(() => {});
204
- await page.keyboard.press("Tab").catch(() => {});
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 });
213
-
214
- const captchaElement = await page.$(imageSelector);
215
- if (!captchaElement) {
216
- throw new Error(`Captcha element not found with selector: ${imageSelector}`);
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 oldEl = await page.$(imageSelector);
296
- if (!oldEl) {
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 page.evaluate((sel) => {
446
+ const oldSrc = await locatedOldImg.target.evaluate((sel) => {
304
447
  const el = document.querySelector(sel);
305
448
  return el ? el.getAttribute("src") : null;
306
- }, imageSelector);
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 btn = await page.$(sel);
332
- if (btn) {
333
- await btn.click();
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
- const errorSelector = cfg.errorDetection?.selector || ".alert-danger, .has-error, #error-msg, .ui-messages-error";
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
- await page.waitForSelector(inputSelector, { timeout: 4000 });
430
- await page.click(inputSelector, { clickCount: 3 }).catch(() => {});
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 page.type(inputSelector, String(captchaSolution).trim(), { delay: 35 });
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
- // 2. Click submit button
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
- const errEl = document.querySelector(err);
444
- if (errEl && errEl.innerText && errEl.innerText.trim().length > 0) return "ERROR";
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: cfg.waitForAfterSubmit?.timeoutMs || 15000 },
677
+ { timeout: safetyTimeoutMs },
448
678
  successSelector,
449
- errorSelector
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 screenshotBuffer = await page.screenshot({
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
- screenshotBase64: `data:image/jpeg;base64,${screenshotBase64}`,
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
- // Case B: In-Page Error Banner Detected
523
- if (matchedOutcome === "ERROR") {
524
- const errorText = await page.evaluate((sel) => {
525
- const el = document.querySelector(sel);
526
- return el ? el.innerText.trim() : "";
527
- }, errorSelector);
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
- const regex = new RegExp(invalidPattern, "i");
530
- const isCaptchaError = regex.test(errorText);
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
- if (isCaptchaError) {
533
- // Keep page alive! Reset TTL timer for retry
534
- if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
535
- const retryTtlSeconds = cfg.sessionTimeoutSeconds || 90;
536
- session.timeoutTimer = setTimeout(() => {
537
- purgeSession(sessionId).catch(() => {});
538
- }, retryTtlSeconds * 1000);
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
- // Give the target portal 400ms to re-render the refreshed captcha image
541
- await new Promise((r) => setTimeout(r, 400));
542
- const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
543
- const newCaptchaEl = await page.$(imageSelector);
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
- if (newCaptchaEl) {
547
- const newBuffer = await newCaptchaEl.screenshot({ encoding: "binary", type: "png" });
548
- newImageBase64 = `data:image/png;base64,${Buffer.from(newBuffer).toString("base64")}`;
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
- return {
552
- status: "INVALID_CAPTCHA",
553
- success: false,
554
- message: errorText || "The captcha entered was incorrect.",
555
- canRetry: true,
556
- newImageBase64,
557
- };
558
- } else {
559
- // Business error (e.g. "No record found for this number")
560
- await purgeSession(sessionId);
561
- return {
562
- status: "BUSINESS_ERROR",
563
- success: false,
564
- message: errorText || "Target portal returned an error.",
565
- canRetry: false,
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
- // Case C: Timeout
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.