app-manager-edge-worker 1.2.1 → 1.2.4

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
@@ -203,6 +203,22 @@ Log levels: `[INFO]` · `[SUCCESS]` · `[WARN]` · `[ERROR]` · `[TASK]` (job di
203
203
 
204
204
  ## 📝 Changelog
205
205
 
206
+ ### v1.2.4
207
+ - **Preserved Live Inspection & Field Extraction on Results Pages**:
208
+ - Added `preservePageForInspection` capability to `HITL_SESSION_SOLVE`: keeps Puppeteer browser page alive upon successful captcha solve and returns full-page Results Page screenshot with dimensions and metadata.
209
+ - Linked active interactive sessions directly to `INSPECT_ELEMENT`: enables clicking and resolving exact selectors on the live Results Page without reloading or losing post-submit DOM state.
210
+ - Added `HITL_SESSION_EXTRACT` job handler to test and evaluate field mappings and transforms directly against the active Results Page in real-time.
211
+
212
+ ### v1.2.3
213
+ - **Resilient Dynamic Attachment Streaming**:
214
+ - Implemented automatic loopback host and port reconciliation for WhatsApp document streaming (`WHATSAPP_SEND_DOCUMENT`).
215
+ - Automatically redirects loopback/localhost requests to the worker's active connected cluster address, preventing `ECONNREFUSED ::1:3000` errors when cluster port or host differs from server defaults or when worker runs remotely on mobile/VPS.
216
+
217
+ ### v1.2.2
218
+ - **Reactive Portal Form & Dynamic Captcha Reveal Compatibility**:
219
+ - Enhanced form input typing in `HITL_SESSION_INIT` and pre-scrape `TYPE` actions (`PREVIEW_PAGE`, `INSPECT_ELEMENT`) to dispatch DOM `input`, `change`, and `blur` events with a simulated `Tab` keypress.
220
+ - Guarantees immediate captcha generation on reactive frontend frameworks (AngularJS, React, Vue, jQuery) requiring input validation/blur before unhiding dynamic captcha elements.
221
+
206
222
  ### v1.2.1
207
223
  - **Smart Form Field & Leaf Control Element Resolution**:
208
224
  - Replaced single-surface `elementFromPoint` with full-stack `elementsFromPoint` coordinate sampling and multi-offset probing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-manager-edge-worker",
3
- "version": "1.2.1",
3
+ "version": "1.2.4",
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.1";
4
+ let workerVersion = "1.2.4";
5
5
  try {
6
6
  const pkg = require("../package.json");
7
7
  if (pkg?.version) workerVersion = pkg.version;
package/src/client.js CHANGED
@@ -13,12 +13,13 @@ import {
13
13
  handleInteractiveSessionRefresh,
14
14
  handleInteractiveSessionSolve,
15
15
  handleInteractiveSessionAbort,
16
+ handleInteractiveSessionExtract,
16
17
  } from "./handlers/interactiveHandler.js";
17
18
  import fs from "fs";
18
19
  import { getWhatsAppHandler } from "./handlers/whatsappHandler.js";
19
20
  import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
20
21
 
21
- let workerPkgVersion = "1.2.1";
22
+ let workerPkgVersion = "1.2.4";
22
23
  try {
23
24
  const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
24
25
  if (pkg?.version) workerPkgVersion = pkg.version;
@@ -122,6 +123,7 @@ export async function startWorkerClient(config) {
122
123
 
123
124
  // Initialize WhatsApp Companion Device Engine
124
125
  const waHandler = getWhatsAppHandler({
126
+ serverUrl: config.serverUrl || config.server,
125
127
  onStatusChange: (statusData) => {
126
128
  socket.emit("whatsapp:status_update", statusData);
127
129
  },
@@ -252,7 +254,7 @@ export async function startWorkerClient(config) {
252
254
  } else if (jobType === "WHATSAPP_SEND_MESSAGE") {
253
255
  resultData = await waHandler.sendMessage(payload);
254
256
  } else if (jobType === "WHATSAPP_SEND_DOCUMENT") {
255
- resultData = await waHandler.sendDocument(payload);
257
+ resultData = await waHandler.sendDocument({ ...payload, serverUrl: config.serverUrl || config.server });
256
258
  } else if (jobType === "WHATSAPP_RECONCILE") {
257
259
  resultData = await waHandler.reconcileDelivery(payload.recipientPhone, payload.idempotencyKey);
258
260
  } else if (jobType === "HITL_SESSION_INIT") {
@@ -261,6 +263,8 @@ export async function startWorkerClient(config) {
261
263
  resultData = await handleInteractiveSessionRefresh(payload);
262
264
  } else if (jobType === "HITL_SESSION_SOLVE") {
263
265
  resultData = await handleInteractiveSessionSolve(payload);
266
+ } else if (jobType === "HITL_SESSION_EXTRACT") {
267
+ resultData = await handleInteractiveSessionExtract(payload);
264
268
  } else if (jobType === "HITL_SESSION_ABORT") {
265
269
  resultData = await handleInteractiveSessionAbort(payload);
266
270
  } else {
@@ -8,6 +8,13 @@ import { runTransformPipeline, castValueToType } from "./transformUtils.js";
8
8
  */
9
9
  const activeSessions = new Map();
10
10
 
11
+ /**
12
+ * Returns an active interactive session by ID if alive.
13
+ */
14
+ export function getActiveInteractiveSession(sessionId) {
15
+ return activeSessions.get(sessionId) || null;
16
+ }
17
+
11
18
  /**
12
19
  * Extracts a single field from a Puppeteer page given a strategy.
13
20
  */
@@ -185,7 +192,18 @@ export async function handleInteractiveSessionInit(payload) {
185
192
  await page.type(input.selector, String(input.value), { delay: input.delayMs || 30 });
186
193
  }
187
194
 
188
- await new Promise((r) => setTimeout(r, 150));
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(() => {});
205
+
206
+ await new Promise((r) => setTimeout(r, 250));
189
207
  }
190
208
  }
191
209
 
@@ -321,7 +339,14 @@ export async function handleInteractiveSessionRefresh(payload) {
321
339
  * If success, extracts data and closes page.
322
340
  */
323
341
  export async function handleInteractiveSessionSolve(payload) {
324
- const { sessionId, captchaSolution, captchaConfig = {}, fieldMappings = [], collections = [] } = payload;
342
+ const {
343
+ sessionId,
344
+ captchaSolution,
345
+ captchaConfig = {},
346
+ fieldMappings = [],
347
+ collections = [],
348
+ preservePageForInspection = false,
349
+ } = payload;
325
350
  const session = activeSessions.get(sessionId);
326
351
 
327
352
  if (!session || !session.page || session.page.isClosed()) {
@@ -385,6 +410,44 @@ export async function handleInteractiveSessionSolve(payload) {
385
410
  raw[mapping.field || mapping.key] = val;
386
411
  }
387
412
 
413
+ if (preservePageForInspection) {
414
+ // Keep the page alive for visual inspection & testing
415
+ if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
416
+ const inspectTtl = 300; // 5 minutes for interactive element picking
417
+ session.timeoutTimer = setTimeout(() => {
418
+ purgeSession(sessionId).catch(() => {});
419
+ }, inspectTtl * 1000);
420
+
421
+ // Settle time for tables & styles
422
+ await new Promise((r) => setTimeout(r, 600));
423
+
424
+ const screenshotBuffer = await page.screenshot({
425
+ encoding: "binary",
426
+ fullPage: false,
427
+ type: "jpeg",
428
+ quality: 85,
429
+ });
430
+ const screenshotBase64 = Buffer.from(screenshotBuffer).toString("base64");
431
+ const title = await page.title().catch(() => "");
432
+ const dimensions = await page
433
+ .evaluate(() => ({
434
+ width: document.documentElement.scrollWidth || window.innerWidth,
435
+ height: document.documentElement.scrollHeight || window.innerHeight,
436
+ }))
437
+ .catch(() => ({ width: 1366, height: 900 }));
438
+
439
+ return {
440
+ status: "COMPLETED",
441
+ success: true,
442
+ data: raw,
443
+ screenshotBase64: `data:image/jpeg;base64,${screenshotBase64}`,
444
+ dimensions,
445
+ title: title || "Results Page",
446
+ sessionId,
447
+ isInspectionMode: true,
448
+ };
449
+ }
450
+
388
451
  // Clean up session and close page
389
452
  await purgeSession(sessionId);
390
453
 
@@ -464,3 +527,42 @@ export async function handleInteractiveSessionAbort(payload) {
464
527
  }
465
528
  return { success: true, aborted: true };
466
529
  }
530
+
531
+ /**
532
+ * 5. Handles HITL_SESSION_EXTRACT:
533
+ * Extracts fields directly from an active interactive session's live page.
534
+ */
535
+ export async function handleInteractiveSessionExtract(payload) {
536
+ const { sessionId, fieldMappings = [], collections = [] } = payload;
537
+ const session = activeSessions.get(sessionId);
538
+
539
+ if (!session || !session.page || session.page.isClosed()) {
540
+ throw new Error("Interactive session not found or already expired");
541
+ }
542
+
543
+ const { page } = session;
544
+ const raw = {};
545
+
546
+ for (const mapping of fieldMappings) {
547
+ let val = null;
548
+ const strategies = mapping.strategies || (mapping.strategy ? [mapping.strategy] : []);
549
+ for (const strat of strategies) {
550
+ val = await extractBrowserField(page, strat);
551
+ if (val !== null && val !== undefined && val !== "") break;
552
+ }
553
+
554
+ if (mapping.transforms && mapping.transforms.length > 0) {
555
+ val = runTransformPipeline(val, mapping.transforms);
556
+ }
557
+ if (mapping.type) {
558
+ val = castValueToType(val, mapping.type);
559
+ }
560
+ raw[mapping.field || mapping.key] = val;
561
+ }
562
+
563
+ return {
564
+ success: true,
565
+ data: raw,
566
+ };
567
+ }
568
+
@@ -1,4 +1,5 @@
1
1
  import localBrowserManager from "../browser.js";
2
+ import { getActiveInteractiveSession } from "./interactiveHandler.js";
2
3
 
3
4
  /**
4
5
  * Handles PREVIEW_PAGE task: captures full-page screenshot and dimensions on the edge worker.
@@ -43,7 +44,16 @@ export async function handlePreviewPage(payload) {
43
44
  await page.click(action.selector);
44
45
  } else if (action.actionType === "TYPE" && action.selector && action.value) {
45
46
  await page.waitForSelector(action.selector, { timeout: 4000 });
46
- await page.type(action.selector, action.value, { delay: 30 });
47
+ await page.type(action.selector, action.value, { delay: action.delayMs || 30 });
48
+ await page.evaluate((sel) => {
49
+ const el = document.querySelector(sel);
50
+ if (el) {
51
+ el.dispatchEvent(new Event("input", { bubbles: true }));
52
+ el.dispatchEvent(new Event("change", { bubbles: true }));
53
+ el.dispatchEvent(new Event("blur", { bubbles: true }));
54
+ }
55
+ }, action.selector).catch(() => {});
56
+ await page.keyboard.press("Tab").catch(() => {});
47
57
  } else if (action.actionType === "SCROLL") {
48
58
  const scrollPixels = parseInt(action.value, 10) || 500;
49
59
  await page.evaluate((px) => window.scrollBy(0, px), scrollPixels);
@@ -111,65 +121,89 @@ export async function handlePreviewPage(payload) {
111
121
  * Handles INSPECT_ELEMENT task: resolves DOM element coordinates or evaluates candidate selectors on the edge worker.
112
122
  */
113
123
  export async function handleInspectElement(payload) {
114
- const { url, preScrapeActions, authentication, selector, strategyType, strategies, fingerprint, x, y, timeout = 20000 } = payload;
115
- const browser = await localBrowserManager.getBrowser();
116
- const page = await browser.newPage();
124
+ const { sessionId, url, preScrapeActions, authentication, selector, strategyType, strategies, fingerprint, x, y, timeout = 20000 } = payload;
125
+ let page = null;
126
+ let isLiveSession = false;
127
+
128
+ if (sessionId) {
129
+ const liveSession = getActiveInteractiveSession(sessionId);
130
+ if (liveSession && liveSession.page && !liveSession.page.isClosed()) {
131
+ page = liveSession.page;
132
+ isLiveSession = true;
133
+ }
134
+ }
117
135
 
118
- try {
119
- await page.setViewport({ width: 1366, height: 900, deviceScaleFactor: 1 });
136
+ if (!page) {
137
+ const browser = await localBrowserManager.getBrowser();
138
+ page = await browser.newPage();
139
+ }
120
140
 
121
- // Apply authentication if provided
122
- if (authentication) {
123
- if (authentication.type === "COOKIES" && Array.isArray(authentication.cookies)) {
124
- const parsedUrl = new URL(url);
125
- await page.setCookie(...authentication.cookies.map(c => ({
126
- name: c.name,
127
- value: c.value,
128
- domain: c.domain || parsedUrl.hostname,
129
- path: c.path || "/",
130
- }))).catch(() => {});
131
- }
132
- if (authentication.type === "HEADERS" && authentication.headers) {
133
- await page.setExtraHTTPHeaders(authentication.headers).catch(() => {});
141
+ try {
142
+ if (!isLiveSession) {
143
+ await page.setViewport({ width: 1366, height: 900, deviceScaleFactor: 1 });
144
+
145
+ // Apply authentication if provided
146
+ if (authentication) {
147
+ if (authentication.type === "COOKIES" && Array.isArray(authentication.cookies)) {
148
+ const parsedUrl = new URL(url);
149
+ await page.setCookie(...authentication.cookies.map(c => ({
150
+ name: c.name,
151
+ value: c.value,
152
+ domain: c.domain || parsedUrl.hostname,
153
+ path: c.path || "/",
154
+ }))).catch(() => {});
155
+ }
156
+ if (authentication.type === "HEADERS" && authentication.headers) {
157
+ await page.setExtraHTTPHeaders(authentication.headers).catch(() => {});
158
+ }
134
159
  }
135
- }
136
-
137
- await page.goto(url, { waitUntil: "domcontentloaded", timeout });
138
- try {
139
- await page.waitForNetworkIdle({ idleTime: 500, timeout: 3500 }).catch(() => {});
140
- } catch (_) {}
141
- await page.evaluate(() => document.fonts?.ready).catch(() => {});
142
- await new Promise((r) => setTimeout(r, 800));
143
160
 
144
- // Execute pre-scrape actions if present
145
- if (Array.isArray(preScrapeActions) && preScrapeActions.length > 0) {
146
- for (const action of preScrapeActions) {
147
- try {
148
- if (action.actionType === "CLICK" && action.selector) {
149
- await page.waitForSelector(action.selector, { timeout: 4000 });
150
- await page.click(action.selector);
151
- } else if (action.actionType === "TYPE" && action.selector && action.value) {
152
- await page.waitForSelector(action.selector, { timeout: 4000 });
153
- await page.type(action.selector, action.value, { delay: 30 });
154
- } else if (action.actionType === "SCROLL") {
155
- const scrollPixels = parseInt(action.value, 10) || 500;
156
- await page.evaluate((px) => window.scrollBy(0, px), scrollPixels);
157
- } else if (action.actionType === "WAIT_FOR" && action.selector) {
158
- await page.waitForSelector(action.selector, { timeout: 8000 });
159
- } else if (action.actionType === "SELECT_OPTION" && action.selector && action.value) {
160
- await page.waitForSelector(action.selector, { timeout: 4000 });
161
- await page.select(action.selector, action.value);
162
- } else if (action.actionType === "PRESS_KEY" && action.value) {
163
- await page.keyboard.press(action.value);
164
- }
165
- if (action.delayMs && action.delayMs > 0) {
166
- await new Promise((r) => setTimeout(r, Math.min(action.delayMs, 5000)));
161
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout });
162
+ try {
163
+ await page.waitForNetworkIdle({ idleTime: 500, timeout: 3500 }).catch(() => {});
164
+ } catch (_) {}
165
+ await page.evaluate(() => document.fonts?.ready).catch(() => {});
166
+ await new Promise((r) => setTimeout(r, 800));
167
+
168
+ // Execute pre-scrape actions if present
169
+ if (Array.isArray(preScrapeActions) && preScrapeActions.length > 0) {
170
+ for (const action of preScrapeActions) {
171
+ try {
172
+ if (action.actionType === "CLICK" && action.selector) {
173
+ await page.waitForSelector(action.selector, { timeout: 4000 });
174
+ await page.click(action.selector);
175
+ } else if (action.actionType === "TYPE" && action.selector && action.value) {
176
+ await page.waitForSelector(action.selector, { timeout: 4000 });
177
+ await page.type(action.selector, action.value, { delay: action.delayMs || 30 });
178
+ await page.evaluate((sel) => {
179
+ const el = document.querySelector(sel);
180
+ if (el) {
181
+ el.dispatchEvent(new Event("input", { bubbles: true }));
182
+ el.dispatchEvent(new Event("change", { bubbles: true }));
183
+ el.dispatchEvent(new Event("blur", { bubbles: true }));
184
+ }
185
+ }, action.selector).catch(() => {});
186
+ await page.keyboard.press("Tab").catch(() => {});
187
+ } else if (action.actionType === "SCROLL") {
188
+ const scrollPixels = parseInt(action.value, 10) || 500;
189
+ await page.evaluate((px) => window.scrollBy(0, px), scrollPixels);
190
+ } else if (action.actionType === "WAIT_FOR" && action.selector) {
191
+ await page.waitForSelector(action.selector, { timeout: 8000 });
192
+ } else if (action.actionType === "SELECT_OPTION" && action.selector && action.value) {
193
+ await page.waitForSelector(action.selector, { timeout: 4000 });
194
+ await page.select(action.selector, action.value);
195
+ } else if (action.actionType === "PRESS_KEY" && action.value) {
196
+ await page.keyboard.press(action.value);
197
+ }
198
+ if (action.delayMs && action.delayMs > 0) {
199
+ await new Promise((r) => setTimeout(r, Math.min(action.delayMs, 5000)));
200
+ }
201
+ } catch (actErr) {
202
+ console.warn(`[WorkerInspect] Pre-scrape action ${action.actionType} (${action.selector}) skipped: ${actErr.message}`);
167
203
  }
168
- } catch (actErr) {
169
- console.warn(`[WorkerInspect] Pre-scrape action ${action.actionType} (${action.selector}) skipped: ${actErr.message}`);
170
204
  }
205
+ await new Promise((r) => setTimeout(r, 600));
171
206
  }
172
- await new Promise((r) => setTimeout(r, 600));
173
207
  }
174
208
 
175
209
  if (x !== undefined && y !== undefined) {
@@ -965,6 +999,8 @@ export async function handleInspectElement(payload) {
965
999
 
966
1000
  return { message: "Specify coordinates (x, y) or selector to inspect." };
967
1001
  } finally {
968
- await page.close().catch(() => {});
1002
+ if (!isLiveSession && page) {
1003
+ await page.close().catch(() => {});
1004
+ }
969
1005
  }
970
1006
  }
@@ -29,6 +29,7 @@ export class WhatsAppWorkerHandler {
29
29
  const homeDir = os.homedir();
30
30
  const baseDir = options.authDir || path.join(homeDir, ".app-manager-edge-worker", "wa-session");
31
31
  this.authDir = baseDir;
32
+ this.serverUrl = options.serverUrl || null;
32
33
  this.sock = null;
33
34
  this.status = "DISCONNECTED"; // DISCONNECTED | PAIRING | CONNECTED | STANDBY
34
35
  this.user = null; // { id, name, phone }
@@ -533,6 +534,7 @@ export class WhatsAppWorkerHandler {
533
534
  mimetype = "application/pdf",
534
535
  caption = "",
535
536
  idempotencyKey = null,
537
+ serverUrl = null,
536
538
  }) {
537
539
  logInfo(`[WhatsApp] Incoming document task for ${recipientPhone} (${fileName})`);
538
540
  await this.initialize(false);
@@ -565,8 +567,27 @@ export class WhatsAppWorkerHandler {
565
567
  const targetJid = verified.jid || jid;
566
568
 
567
569
  // 3. Stream PDF from authenticated URL (Zero buffer in central WebSocket)
568
- logInfo(`[WhatsApp] Streaming document attachment (${fileName})...`);
569
- const response = await axios.get(downloadUrl, {
570
+ let resolvedDownloadUrl = downloadUrl;
571
+ const activeServerUrl = serverUrl || this.serverUrl;
572
+ if (resolvedDownloadUrl && activeServerUrl) {
573
+ try {
574
+ const targetObj = new URL(resolvedDownloadUrl);
575
+ const srvObj = new URL(activeServerUrl);
576
+ const isTargetLoopback = ["localhost", "127.0.0.1", "::1"].includes(targetObj.hostname);
577
+ const isSrvLoopback = ["localhost", "127.0.0.1", "::1"].includes(srvObj.hostname);
578
+
579
+ // If target is loopback/localhost with mismatching port, or if worker connects to a remote server
580
+ if (isTargetLoopback && (!isSrvLoopback || targetObj.port !== srvObj.port)) {
581
+ targetObj.protocol = srvObj.protocol;
582
+ targetObj.hostname = srvObj.hostname;
583
+ targetObj.port = srvObj.port;
584
+ resolvedDownloadUrl = targetObj.toString();
585
+ }
586
+ } catch (_) {}
587
+ }
588
+
589
+ logInfo(`[WhatsApp] Streaming document attachment (${fileName}) from ${resolvedDownloadUrl}...`);
590
+ const response = await axios.get(resolvedDownloadUrl, {
570
591
  responseType: "arraybuffer",
571
592
  timeout: 25000,
572
593
  });