app-manager-edge-worker 1.0.0 → 1.0.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.
package/README.md CHANGED
@@ -17,6 +17,7 @@ Cloud datacenter IPs (Render, AWS, DigitalOcean, Hetzner) are frequently blocked
17
17
  - 🔒 **Zero Port Forwarding**: Persistent outbound WebSockets (`wss://.../workers`) effortlessly traverse home Wi-Fi routers, firewalls, and carrier-grade NAT (CGNAT).
18
18
  - ⚡ **Autonomous Chromium Engine**: Checks your system for Chrome/Edge or automatically installs stable Chromium without any manual intervention.
19
19
  - 📱 **Android Phone Support**: Transform an old spare Android phone into a 24/7 scraping powerhouse using Termux and Wake Lock.
20
+ - ⏰ **Automated Render Anti-Sleep Keep-Alive**: Periodically pings the central server (every 7 minutes by default) to keep Render free-tier instances warm and eliminate cold-start spindowns.
20
21
  - 🚀 **Smart Single-Flight Deduplication**: Concurrent scrapes for the exact same target URL coalesce into a single execution flight.
21
22
 
22
23
  ---
@@ -54,21 +55,48 @@ app-manager-edge-worker
54
55
 
55
56
  ## 📱 Turn Any Android Phone into a 24/7 Scraping Node (Termux)
56
57
 
57
- You can turn any old Android phone into an ultra-low-power, 24/7 residential scraping node using **Termux** (available free from F-Droid):
58
+ Turn any spare or everyday Android phone into an ultra-low-power, 24/7 residential edge scraping node using **Termux** (download free from [F-Droid](https://f-droid.org/en/packages/com.termux/)):
59
+
60
+ ### Quick 1-Liner Setup (Copy & Paste into Termux)
58
61
 
59
62
  ```bash
60
- # 1. Update Termux packages & install Node.js + Chromium
61
- pkg update -y && pkg install -y nodejs-lts chromium
63
+ pkg update -y && pkg install -y x11-repo && pkg update -y && pkg install -y nodejs-lts chromium termux-tools && termux-wake-lock && npx app-manager-edge-worker --server https://apps-manager.onrender.com --key <YOUR_WORKER_KEY>
64
+ ```
65
+
66
+ ---
67
+
68
+ ### Step-by-Step Installation
62
69
 
63
- # 2. Point Puppeteer to Termux's native Chromium ARM64 binary
70
+ ```bash
71
+ # 1. Update Termux core packages
72
+ pkg update -y
73
+
74
+ # 2. Enable x11-repo (Termux hosts the Chromium ARM64 binary inside x11-repo)
75
+ pkg install -y x11-repo
76
+ pkg update -y
77
+
78
+ # 3. Install Node.js LTS, Chromium browser, and Termux tools
79
+ pkg install -y nodejs-lts chromium termux-tools
80
+
81
+ # 4. Acquire wake-lock (keeps CPU active when screen turns off)
82
+ termux-wake-lock
83
+
84
+ # 5. Point Puppeteer to Termux's native Chromium binary (auto-detected, or export explicitly)
64
85
  export PUPPETEER_EXECUTABLE_PATH=/data/data/com.termux/files/usr/bin/chromium-browser
86
+ export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
65
87
 
66
- # 3. Launch the edge worker
88
+ # 6. Launch the distributed edge worker
67
89
  npx app-manager-edge-worker --server https://apps-manager.onrender.com --key <YOUR_WORKER_KEY>
68
90
  ```
69
91
 
92
+ > [!IMPORTANT]
93
+ > **Why `x11-repo` is mandatory**: On Android aarch64, Termux maintains the `chromium` package within the `x11-repo` repository. Running `pkg install -y x11-repo` enables this repository so `pkg install chromium` succeeds without "package not found" errors.
94
+
70
95
  > [!TIP]
71
- > In Termux, swipe down your Android notification shade and tap **"Acquire Wake Lock"**. Disable Android OS battery optimization for Termux so your phone runs uninterrupted in the background.
96
+ > **Android Battery Optimization**:
97
+ > 1. In Termux, run `termux-wake-lock` or swipe down your Android notification panel and tap **"Acquire Wake Lock"** (our worker CLI also automatically acquires the wake-lock on startup).
98
+ > 2. In Android **Settings** → **Apps** → **Termux** → **Battery** → set to **"Unrestricted"** (or **"Don't optimize"**). This prevents Android's Doze mode or manufacturer task killers (Samsung, Xiaomi, OnePlus) from putting Termux to sleep when the screen is locked.
99
+ > 3. For low-RAM phones (2GB–3GB RAM), add `--concurrency 1` or `--concurrency 2` to keep memory consumption ultra-lean.
72
100
 
73
101
  ---
74
102
 
@@ -127,7 +155,7 @@ The worker **automatically downloads and installs it** according to your operati
127
155
  | **Windows** (x64/x86) | Downloads official stable Chrome for Testing from Google CDN with a progress bar into `~/.cache/puppeteer`. |
128
156
  | **macOS** (Intel / Apple Silicon M1-M4) | Detects system architecture (`mac` or `mac_arm`) and downloads matching stable Chrome from Google CDN. |
129
157
  | **Linux Desktop / VPS** (Ubuntu, Debian, Fedora, Arch) | Downloads official Linux x64/arm64 Chrome for Testing via `@puppeteer/browsers`. |
130
- | **Android (Termux)** | Automatically invokes Termux package manager (`pkg install -y chromium`). |
158
+ | **Android (Termux)** | Automatically enables `x11-repo` and invokes Termux package manager (`pkg update -y && pkg install -y x11-repo && pkg update -y && pkg install -y nodejs-lts chromium termux-tools`). |
131
159
  | **Alpine Linux** | Automatically invokes Alpine package manager (`apk add --no-cache chromium`). |
132
160
 
133
161
  No manual setup required!
@@ -142,6 +170,8 @@ No manual setup required!
142
170
  | `-s, --server <url>` | Apps Manager Server URL | `https://apps-manager.onrender.com` |
143
171
  | `-c, --concurrency <n>` | Max concurrent Chromium browser tabs | `3` |
144
172
  | `-n, --name <name>` | Custom display name for this worker | Device hostname |
173
+ | `-p, --keep-alive <minutes>` | Keep-alive ping interval to prevent Render free-tier cold sleep | `7` |
174
+ | `--no-keep-alive` | Disable keep-alive ping loop | - |
145
175
  | `--reset` | Clear stored credentials and prompt again | - |
146
176
  | `-h, --help` | Display help screen | - |
147
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-manager-edge-worker",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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
@@ -11,7 +11,7 @@ const colors = {
11
11
  gray: "\x1b[90m",
12
12
  };
13
13
 
14
- export function printBanner({ workerName, serverUrl, concurrency, platform, isTermux }) {
14
+ export function printBanner({ workerName, serverUrl, concurrency, platform, isTermux, keepAliveMinutes }) {
15
15
  console.clear();
16
16
  console.log(`${colors.cyan}${colors.bright}`);
17
17
  console.log(" █████╗ ██████╗ ██████╗ ███████╗ ███╗ ███╗ █████╗ ███╗ ██╗ █████╗ ██████╗ ███████╗██████╗ ");
@@ -21,13 +21,14 @@ export function printBanner({ workerName, serverUrl, concurrency, platform, isTe
21
21
  console.log(" ██║ ██║██║ ██║ ███████║██╗██║ ╚═╝ ██║██║ ██║██║ ╚████║██║ ██║╚██████╔╝███████╗██║ ██║");
22
22
  console.log(" ╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝");
23
23
  console.log(`${colors.reset}`);
24
- console.log(`${colors.bright} 🌐 Distributed Scraper Edge Worker Client v1.0.0${colors.reset}`);
24
+ console.log(`${colors.bright} 🌐 Distributed Scraper Edge Worker Client v1.0.2${colors.reset}`);
25
25
  console.log(`${colors.dim} Zero-cost, persistent tunneling & residential edge scraping${colors.reset}`);
26
26
  console.log("--------------------------------------------------------------------------------");
27
27
  console.log(` ${colors.bright}Device Name :${colors.reset} ${colors.green}${workerName}${colors.reset}`);
28
28
  console.log(` ${colors.bright}Server URL :${colors.reset} ${colors.cyan}${serverUrl}${colors.reset}`);
29
29
  console.log(` ${colors.bright}Concurrency :${colors.reset} ${colors.yellow}${concurrency} concurrent tabs${colors.reset}`);
30
30
  console.log(` ${colors.bright}Environment :${colors.reset} ${platform} ${isTermux ? "(Termux Android)" : ""}`);
31
+ console.log(` ${colors.bright}Keep-Alive :${colors.reset} ${keepAliveMinutes > 0 ? `${colors.green}Every ${keepAliveMinutes}m (Render Anti-Sleep Active)${colors.reset}` : `${colors.gray}Disabled${colors.reset}`}`);
31
32
  console.log("--------------------------------------------------------------------------------\n");
32
33
  }
33
34
 
package/src/browser.js CHANGED
@@ -169,9 +169,11 @@ class LocalBrowserManager {
169
169
  // 1. Android Termux
170
170
  if (this.isTermux()) {
171
171
  logWarn("Termux Android environment detected without Chromium.");
172
- logInfo("Attempting auto-install via Termux package manager (pkg install -y chromium)...");
172
+ logInfo("Attempting auto-install via Termux package manager (enabling x11-repo and installing chromium)...");
173
173
  try {
174
- execSync("pkg update -y && pkg install -y chromium", { stdio: "inherit" });
174
+ execSync("pkg update -y && pkg install -y x11-repo && pkg update -y && pkg install -y nodejs-lts chromium termux-tools", {
175
+ stdio: "inherit",
176
+ });
175
177
  const termuxPaths = [
176
178
  "/data/data/com.termux/files/usr/bin/chromium-browser",
177
179
  "/data/data/com.termux/files/usr/bin/chromium",
@@ -184,7 +186,7 @@ class LocalBrowserManager {
184
186
  }
185
187
  } catch (err) {
186
188
  logError(`Termux auto-install failed: ${err.message}`);
187
- logWarn("Please run manually in Termux: pkg install chromium -y");
189
+ logWarn("Please run manually in Termux:\n pkg update -y && pkg install -y x11-repo && pkg update -y && pkg install -y nodejs-lts chromium termux-tools");
188
190
  }
189
191
  }
190
192
 
@@ -319,6 +321,10 @@ class LocalBrowserManager {
319
321
  "--window-size=1366,900",
320
322
  ];
321
323
 
324
+ if (this.isTermux()) {
325
+ launchArgs.push("--single-process");
326
+ }
327
+
322
328
  const launchOptions = {
323
329
  headless: "new",
324
330
  args: launchArgs,
package/src/client.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { io } from "socket.io-client";
2
2
  import os from "os";
3
3
  import path from "path";
4
+ import axios from "axios";
5
+ import { execSync } from "child_process";
4
6
  import localBrowserManager from "./browser.js";
5
7
  import { handleChromiumScrape, handleHttpScrape } from "./handlers/scrapeHandler.js";
6
8
  import { handlePreviewPage, handleInspectElement } from "./handlers/previewHandler.js";
7
9
  import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
8
10
 
9
11
  export async function startWorkerClient(config) {
10
- const { workerKey, serverUrl, concurrency, workerName } = config;
12
+ const { workerKey, serverUrl, concurrency, workerName, keepAliveMinutes = 7 } = config;
11
13
  const isTermux = localBrowserManager.isTermux();
12
14
 
13
15
  printBanner({
@@ -16,8 +18,18 @@ export async function startWorkerClient(config) {
16
18
  concurrency,
17
19
  platform: `${os.platform()} ${os.arch()}`,
18
20
  isTermux,
21
+ keepAliveMinutes,
19
22
  });
20
23
 
24
+ if (isTermux) {
25
+ try {
26
+ execSync("termux-wake-lock", { stdio: "ignore" });
27
+ logSuccess("[Android Termux] Wake lock acquired to keep CPU awake in background.");
28
+ } catch {
29
+ // termux-tools might not be installed yet
30
+ }
31
+ }
32
+
21
33
  logInfo("Checking Chromium browser availability...");
22
34
  let chromiumExecutable = null;
23
35
  try {
@@ -50,11 +62,58 @@ export async function startWorkerClient(config) {
50
62
  },
51
63
  });
52
64
 
65
+ const CHUNK_SIZE = 256 * 1024; // 256 KB chunks to prevent any WebSocket frame buffer overflow
66
+
67
+ const sendJobResult = (payload) => {
68
+ let payloadStr = "";
69
+ try {
70
+ payloadStr = JSON.stringify(payload);
71
+ } catch {
72
+ socket.emit("job:result", payload);
73
+ return;
74
+ }
75
+
76
+ // Small payload: send directly
77
+ if (payloadStr.length <= CHUNK_SIZE) {
78
+ socket.emit("job:result", payload);
79
+ return;
80
+ }
81
+
82
+ // Large payload (e.g. screenshot or large scrape): stream in chunks
83
+ const totalChunks = Math.ceil(payloadStr.length / CHUNK_SIZE);
84
+ const jobId = payload.jobId;
85
+
86
+ socket.emit("job:result:stream:start", {
87
+ jobId,
88
+ totalChunks,
89
+ totalLength: payloadStr.length,
90
+ });
91
+
92
+ for (let i = 0; i < totalChunks; i++) {
93
+ const chunk = payloadStr.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
94
+ socket.emit("job:result:stream:chunk", {
95
+ jobId,
96
+ index: i,
97
+ chunk,
98
+ });
99
+ }
100
+
101
+ socket.emit("job:result:stream:end", { jobId });
102
+ };
103
+
53
104
  let activeJobs = 0;
105
+ let activeConcurrency = concurrency;
54
106
 
55
107
  socket.on("connect", () => {
56
108
  logSuccess(`Connected to cluster! (Socket ID: ${socket.id})`);
57
- logInfo(`Ready to accept scraping and visual preview jobs (Concurrency: ${concurrency}).\n`);
109
+ logInfo(`Ready to accept scraping and visual preview jobs (Concurrency: ${activeConcurrency}).\n`);
110
+ });
111
+
112
+ socket.on("worker:registered", (data) => {
113
+ if (data?.concurrencyLimit && typeof data.concurrencyLimit === "number") {
114
+ activeConcurrency = data.concurrencyLimit;
115
+ logInfo(`[Cluster Config] Worker registered as '${data.name || data.workerId}'. Active concurrency: ${activeConcurrency} concurrent tabs.`);
116
+ }
58
117
  });
59
118
 
60
119
  socket.on("connect_error", (err) => {
@@ -100,7 +159,7 @@ export async function startWorkerClient(config) {
100
159
  const durationMs = Date.now() - startTime;
101
160
  logSuccess(`Completed ${jobType} for ${targetUrl} in ${durationMs}ms`);
102
161
 
103
- socket.emit("job:result", {
162
+ sendJobResult({
104
163
  jobId,
105
164
  success: true,
106
165
  data: resultData,
@@ -110,7 +169,7 @@ export async function startWorkerClient(config) {
110
169
  const durationMs = Date.now() - startTime;
111
170
  logError(`Failed ${jobType} for ${targetUrl}: ${err.message}`);
112
171
 
113
- socket.emit("job:result", {
172
+ sendJobResult({
114
173
  jobId,
115
174
  success: false,
116
175
  error: {
@@ -133,11 +192,95 @@ export async function startWorkerClient(config) {
133
192
  });
134
193
  });
135
194
 
195
+ // Render Anti-Sleep Keep-Alive Ping
196
+ let keepAliveTimer = null;
197
+ let initialKeepAliveTimer = null;
198
+
199
+ const clearKeepAliveTimers = () => {
200
+ if (keepAliveTimer) {
201
+ clearInterval(keepAliveTimer);
202
+ keepAliveTimer = null;
203
+ }
204
+ if (initialKeepAliveTimer) {
205
+ clearTimeout(initialKeepAliveTimer);
206
+ initialKeepAliveTimer = null;
207
+ }
208
+ };
209
+
210
+ const pingKeepAlive = async () => {
211
+ try {
212
+ // Try the dedicated keep-alive endpoint first
213
+ const endpoint = `${serverUrl}/api/workers/keep-alive`;
214
+ const res = await axios.get(endpoint, {
215
+ timeout: 15000,
216
+ headers: {
217
+ "x-worker-key": workerKey || "",
218
+ "user-agent": "AppsManagerEdgeWorker/1.0.2",
219
+ },
220
+ });
221
+ logInfo(`[Keep-Alive] Server awake ping OK (${res.status}) - Render instance active`);
222
+ } catch (err) {
223
+ // Fallback to /health
224
+ try {
225
+ const fallbackUrl = `${serverUrl}/health`;
226
+ await axios.get(fallbackUrl, {
227
+ timeout: 10000,
228
+ headers: {
229
+ "user-agent": "AppsManagerEdgeWorker/1.0.2",
230
+ },
231
+ });
232
+ logInfo(`[Keep-Alive] Fallback health ping OK - Render instance active`);
233
+ } catch (fallbackErr) {
234
+ logWarn(`[Keep-Alive] Server awake ping warning (${err.message}) - will retry next cycle`);
235
+ }
236
+ }
237
+ };
238
+
239
+ const startKeepAlive = () => {
240
+ if (keepAliveMinutes <= 0) return;
241
+ clearKeepAliveTimers();
242
+
243
+ // Send initial ping 15s after startup to warm up/verify
244
+ initialKeepAliveTimer = setTimeout(pingKeepAlive, 15000);
245
+ if (initialKeepAliveTimer.unref) initialKeepAliveTimer.unref();
246
+
247
+ const intervalMs = Math.max(1, keepAliveMinutes) * 60 * 1000;
248
+ keepAliveTimer = setInterval(pingKeepAlive, intervalMs);
249
+ if (keepAliveTimer.unref) keepAliveTimer.unref();
250
+ };
251
+
252
+ if (keepAliveMinutes > 0) {
253
+ startKeepAlive();
254
+ }
255
+
256
+ // Clean up keep-alive timers on socket disconnect
257
+ socket.on("disconnect", (reason) => {
258
+ logWarn(`Disconnected from cluster: ${reason}`);
259
+ clearKeepAliveTimers();
260
+ });
261
+
262
+ socket.on("connect", () => {
263
+ if (keepAliveMinutes > 0 && !keepAliveTimer) {
264
+ startKeepAlive();
265
+ }
266
+ });
267
+
268
+ // Provide programmatic close for automated test harnesses
269
+ socket.closeClient = async () => {
270
+ clearKeepAliveTimers();
271
+ if (isTermux) {
272
+ try {
273
+ execSync("termux-wake-unlock", { stdio: "ignore" });
274
+ } catch {}
275
+ }
276
+ socket.disconnect();
277
+ await localBrowserManager.close();
278
+ };
279
+
136
280
  // Graceful shutdown handling
137
281
  const shutdown = async () => {
138
282
  logInfo("\nGracefully shutting down edge worker...");
139
- socket.disconnect();
140
- await localBrowserManager.close();
283
+ await socket.closeClient();
141
284
  logSuccess("Edge worker stopped cleanly.");
142
285
  process.exit(0);
143
286
  };
package/src/config.js CHANGED
@@ -65,6 +65,10 @@ export function parseCliArgs() {
65
65
  parsed.concurrency = parseInt(args[++i], 10);
66
66
  } else if (arg === "--name" || arg === "-n") {
67
67
  parsed.name = args[++i];
68
+ } else if (arg === "--keep-alive" || arg === "-p") {
69
+ parsed.keepAlive = parseInt(args[++i], 10);
70
+ } else if (arg === "--no-keep-alive") {
71
+ parsed.keepAlive = false;
68
72
  } else if (arg === "--reset") {
69
73
  parsed.reset = true;
70
74
  } else if (arg === "--help" || arg === "-h") {
@@ -102,7 +106,7 @@ export async function resolveWorkerConfig() {
102
106
 
103
107
  if (cli.help) {
104
108
  console.log(`
105
- Apps Manager Scraper Edge Worker CLI
109
+ Apps Manager Scraper Edge Worker CLI v1.0.2
106
110
 
107
111
  Usage:
108
112
  npx app-manager-edge-worker [options]
@@ -112,6 +116,8 @@ Options:
112
116
  -s, --server <url> Apps Manager Server URL (e.g. https://apps-manager.onrender.com)
113
117
  -c, --concurrency <number> Maximum concurrent browser tabs (default: 3)
114
118
  -n, --name <name> Custom display name for this worker device
119
+ -p, --keep-alive <minutes> Interval in minutes to ping server and prevent Render free-tier sleep (default: 7)
120
+ --no-keep-alive Disable keep-alive ping
115
121
  --reset Clear stored credentials and prompt again
116
122
  -h, --help Display this help message
117
123
  `);
@@ -192,10 +198,21 @@ Options:
192
198
  // Clean up trailing slash
193
199
  serverUrl = serverUrl.replace(/\/+$/, "");
194
200
 
201
+ // Keep-alive ping interval (default 7 minutes, 0 to disable)
202
+ let keepAliveMinutes = 7;
203
+ if (cli.keepAlive === false || process.env.KEEP_ALIVE === "false") {
204
+ keepAliveMinutes = 0;
205
+ } else if (typeof cli.keepAlive === "number" && !isNaN(cli.keepAlive)) {
206
+ keepAliveMinutes = Math.max(0, cli.keepAlive);
207
+ } else if (process.env.KEEP_ALIVE_MINUTES) {
208
+ keepAliveMinutes = parseInt(process.env.KEEP_ALIVE_MINUTES, 10) || 7;
209
+ }
210
+
195
211
  return {
196
212
  workerKey: workerKey.trim(),
197
213
  serverUrl,
198
214
  concurrency,
199
215
  workerName,
216
+ keepAliveMinutes,
200
217
  };
201
218
  }
@@ -12,15 +12,23 @@ export async function handlePreviewPage(payload) {
12
12
  await page.setViewport({ width: 1366, height: 900, deviceScaleFactor: 1 });
13
13
 
14
14
  await page.goto(url, { waitUntil: "domcontentloaded", timeout });
15
- await new Promise((r) => setTimeout(r, 1500));
15
+ try {
16
+ await page.waitForNetworkIdle({ idleTime: 500, timeout: 3500 }).catch(() => {});
17
+ } catch (_) {}
18
+ await page.evaluate(() => document.fonts?.ready).catch(() => {});
19
+ await new Promise((r) => setTimeout(r, 800));
16
20
 
17
21
  const title = (await page.title()) || new URL(url).hostname;
18
22
  const currentUrl = page.url();
19
23
 
20
- const dimensions = await page.evaluate(() => ({
24
+ const rawDimensions = await page.evaluate(() => ({
21
25
  width: document.documentElement.scrollWidth || 1366,
22
26
  height: document.documentElement.scrollHeight || 900,
23
27
  }));
28
+ const dimensions = {
29
+ width: Math.min(rawDimensions.width, 3840),
30
+ height: Math.min(rawDimensions.height, 12000),
31
+ };
24
32
 
25
33
  // Hide scrollbars before screenshot
26
34
  await page
@@ -35,7 +43,7 @@ export async function handlePreviewPage(payload) {
35
43
  const screenshotBuffer = await page.screenshot({
36
44
  encoding: "binary",
37
45
  type: "jpeg",
38
- quality: 85,
46
+ quality: 75,
39
47
  fullPage: true,
40
48
  });
41
49
 
@@ -2,6 +2,142 @@ import axios from "axios";
2
2
  import * as cheerio from "cheerio";
3
3
  import localBrowserManager from "../browser.js";
4
4
 
5
+ /**
6
+ * Extracts a single field value from a Puppeteer page given a strategy.
7
+ */
8
+ async function extractBrowserField(page, strategy) {
9
+ if (!strategy || !strategy.value) return null;
10
+
11
+ try {
12
+ const type = strategy.type || "css";
13
+ const attr = strategy.attribute || "text";
14
+
15
+ if (type === "css") {
16
+ const el = await page.$(strategy.value);
17
+ if (!el) return null;
18
+
19
+ if (attr === "text") {
20
+ const text = await page.evaluate((element) => element.textContent, el);
21
+ return text ? text.trim() : null;
22
+ } else if (attr === "html") {
23
+ return await page.evaluate((element) => element.innerHTML, el);
24
+ } else {
25
+ const val = await page.evaluate((element, a) => element.getAttribute(a), el, attr);
26
+ return val !== null && val !== undefined ? String(val).trim() : null;
27
+ }
28
+ }
29
+
30
+ if (type === "xpath") {
31
+ const val = await page.evaluate(
32
+ (xpathStr, attrName) => {
33
+ try {
34
+ const result = document.evaluate(
35
+ xpathStr,
36
+ document,
37
+ null,
38
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
39
+ null
40
+ );
41
+ const node = result.singleNodeValue;
42
+ if (!node) return null;
43
+ if (attrName === "text") {
44
+ return node.textContent ? node.textContent.trim() : null;
45
+ }
46
+ return node.getAttribute ? node.getAttribute(attrName) : null;
47
+ } catch (e) {
48
+ return null;
49
+ }
50
+ },
51
+ strategy.value,
52
+ attr
53
+ );
54
+ return val;
55
+ }
56
+
57
+ if (type === "text-relative") {
58
+ return await page.evaluate(
59
+ (anchorText, relation) => {
60
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
61
+ let node;
62
+ while ((node = walker.nextNode())) {
63
+ if (node.textContent.includes(anchorText)) {
64
+ const parent = node.parentElement;
65
+ if (relation === "next-sibling") {
66
+ const sibling = parent.nextElementSibling;
67
+ return sibling ? (sibling.innerText || sibling.textContent)?.trim() : null;
68
+ }
69
+ if (relation === "parent") {
70
+ const grandParent = parent.parentElement;
71
+ return grandParent ? (grandParent.innerText || grandParent.textContent)?.trim() : null;
72
+ }
73
+ return (parent.innerText || parent.textContent)?.trim();
74
+ }
75
+ }
76
+ return null;
77
+ },
78
+ strategy.anchor || strategy.value,
79
+ strategy.relation || "self"
80
+ );
81
+ }
82
+
83
+ return null;
84
+ } catch (err) {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Extracts a single field from a Cheerio instance given a strategy.
91
+ */
92
+ function extractCheerioField($, strategy) {
93
+ if (!strategy || !strategy.value) return null;
94
+
95
+ try {
96
+ const type = strategy.type || "css";
97
+ const attr = strategy.attribute || "text";
98
+
99
+ if (type === "css") {
100
+ const el = $(strategy.value);
101
+ if (!el || el.length === 0) return null;
102
+
103
+ if (attr === "text") return el.first().text().trim();
104
+ if (attr === "html") return el.first().html();
105
+ return el.first().attr(attr) || null;
106
+ }
107
+
108
+ if (type === "xpath") {
109
+ let css = String(strategy.value).trim();
110
+ css = css.replace(/\/\/\*\[@id=["']([^"']+)["']\]/g, "#$1");
111
+ css = css.replace(/\/([a-zA-Z0-9_-]+)\[([0-9]+)\]/g, " > $1:nth-of-type($2)");
112
+ css = css.replace(/\/([a-zA-Z0-9_-]+)/g, " > $1");
113
+ css = css.replace(/^\/+/g, "").replace(/^>\s*/, "").trim();
114
+
115
+ if (css) {
116
+ try {
117
+ const el = $(css);
118
+ if (el && el.length > 0) {
119
+ if (attr === "text") return el.first().text().trim();
120
+ if (attr === "html") return el.first().html();
121
+ return el.first().attr(attr) || null;
122
+ }
123
+ } catch {
124
+ // Ignore parse error
125
+ }
126
+ }
127
+ }
128
+
129
+ if (type === "text-relative") {
130
+ const anchor = strategy.anchor || strategy.value;
131
+ const match = $(`:contains("${anchor}")`).last();
132
+ if (match && match.length > 0) return match.text().trim();
133
+ }
134
+
135
+ return null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
5
141
  /**
6
142
  * Handles SCRAPE_CHROMIUM task via local stealth Puppeteer.
7
143
  */
@@ -9,6 +145,9 @@ export async function handleChromiumScrape(payload) {
9
145
  const startTime = Date.now();
10
146
  const {
11
147
  url,
148
+ fieldMappings = [],
149
+ navigation = {},
150
+ waitFor = {},
12
151
  timeout = 30000,
13
152
  waitForSelector = null,
14
153
  waitForTimeout = 1000,
@@ -17,8 +156,12 @@ export async function handleChromiumScrape(payload) {
17
156
  headers = {},
18
157
  cookies = [],
19
158
  viewport = { width: 1366, height: 900 },
159
+ includeHtml = false,
20
160
  } = payload;
21
161
 
162
+ const navTimeout = navigation.timeoutMs || timeout || 30000;
163
+ const navWaitUntil = navigation.waitUntil || "domcontentloaded";
164
+
22
165
  const browser = await localBrowserManager.getBrowser();
23
166
  const page = await browser.newPage();
24
167
 
@@ -39,22 +182,35 @@ export async function handleChromiumScrape(payload) {
39
182
 
40
183
  // Navigate
41
184
  const response = await page.goto(url, {
42
- waitUntil: "domcontentloaded",
43
- timeout,
185
+ waitUntil: navWaitUntil,
186
+ timeout: navTimeout,
44
187
  });
45
188
 
46
189
  const status = response ? response.status() : 200;
47
190
 
48
- // Custom selector wait
49
- if (waitForSelector) {
50
- try {
51
- await page.waitForSelector(waitForSelector, { timeout: 10000 });
52
- } catch {
53
- // Ignore timeout on optional selector wait
191
+ // Handle wait conditions
192
+ if (waitFor && waitFor.type) {
193
+ const waitTimeout = waitFor.timeoutMs || 10000;
194
+ if (waitFor.type === "SELECTOR_EXISTS" && waitFor.selector) {
195
+ await page.waitForSelector(waitFor.selector, { timeout: waitTimeout }).catch(() => {});
196
+ } else if (waitFor.type === "SELECTOR_VISIBLE" && waitFor.selector) {
197
+ await page.waitForSelector(waitFor.selector, { visible: true, timeout: waitTimeout }).catch(() => {});
198
+ } else if (waitFor.type === "TEXT_PRESENT" && waitFor.text) {
199
+ await page
200
+ .waitForFunction(
201
+ (t) => document.body && document.body.innerText.includes(t),
202
+ { timeout: waitTimeout },
203
+ waitFor.text
204
+ )
205
+ .catch(() => {});
206
+ } else if (waitFor.type === "FIXED_DELAY" && waitFor.delayMs > 0) {
207
+ await new Promise((r) => setTimeout(r, Math.min(waitFor.delayMs, 10000)));
54
208
  }
209
+ } else if (waitForSelector) {
210
+ await page.waitForSelector(waitForSelector, { timeout: 10000 }).catch(() => {});
55
211
  }
56
212
 
57
- // Brief delay for dynamic content
213
+ // Optional brief delay for dynamic content
58
214
  if (waitForTimeout > 0) {
59
215
  await new Promise((r) => setTimeout(r, Math.min(waitForTimeout, 10000)));
60
216
  }
@@ -80,10 +236,36 @@ export async function handleChromiumScrape(payload) {
80
236
 
81
237
  const pageTitle = (await page.title()) || "";
82
238
  const finalUrl = page.url();
83
- const html = await page.content();
84
239
 
85
- // Extract structured data if extraction rules are defined
86
- const extractedData = {};
240
+ const raw = {};
241
+ const strategySummary = {};
242
+
243
+ // 1. Extract mapped fields (Field Schema & Strategies)
244
+ if (Array.isArray(fieldMappings) && fieldMappings.length > 0) {
245
+ for (const mapping of fieldMappings) {
246
+ let extractedVal = null;
247
+ let successfulStrategy = null;
248
+
249
+ const strategies = mapping.strategies || [];
250
+ for (let i = 0; i < strategies.length; i++) {
251
+ const strat = strategies[i];
252
+ const val = await extractBrowserField(page, strat);
253
+ if (val !== null && val !== undefined && val !== "") {
254
+ extractedVal = val;
255
+ successfulStrategy = strat.type || `strategy_${i + 1}`;
256
+ break;
257
+ }
258
+ }
259
+
260
+ raw[mapping.field] = extractedVal;
261
+ strategySummary[mapping.field] = {
262
+ extracted: extractedVal !== null,
263
+ strategyUsed: successfulStrategy,
264
+ };
265
+ }
266
+ }
267
+
268
+ // 2. Extract legacy extractSelectors if present
87
269
  if (Array.isArray(extractSelectors) && extractSelectors.length > 0) {
88
270
  for (const item of extractSelectors) {
89
271
  const { field, selector, type = "text", attribute = null } = item;
@@ -108,21 +290,37 @@ export async function handleChromiumScrape(payload) {
108
290
  attribute
109
291
  );
110
292
 
111
- extractedData[field] = values.length <= 1 ? (values[0] ?? null) : values;
293
+ const extractedVal = values.length <= 1 ? (values[0] ?? null) : values;
294
+ if (raw[field] === undefined) {
295
+ raw[field] = extractedVal;
296
+ }
112
297
  } catch {
113
- extractedData[field] = null;
298
+ if (raw[field] === undefined) {
299
+ raw[field] = null;
300
+ }
114
301
  }
115
302
  }
116
303
  }
117
304
 
118
305
  const executionTimeMs = Date.now() - startTime;
306
+ const hasExtractedValues = Object.values(raw).some(
307
+ (v) => v !== null && v !== undefined && v !== ""
308
+ );
309
+
310
+ // Avoid transferring huge raw HTML unless requested or nothing was extracted
311
+ let html = "";
312
+ if (includeHtml || (!hasExtractedValues && fieldMappings.length > 0)) {
313
+ html = await page.content().catch(() => "");
314
+ }
119
315
 
120
316
  return {
121
317
  status,
122
318
  title: pageTitle,
123
319
  url: finalUrl,
320
+ raw,
321
+ strategySummary,
322
+ extractedData: raw,
124
323
  html,
125
- extractedData,
126
324
  executionTimeMs,
127
325
  };
128
326
  } finally {
@@ -135,7 +333,14 @@ export async function handleChromiumScrape(payload) {
135
333
  */
136
334
  export async function handleHttpScrape(payload) {
137
335
  const startTime = Date.now();
138
- const { url, timeout = 15000, headers = {}, extractSelectors = [] } = payload;
336
+ const {
337
+ url,
338
+ fieldMappings = [],
339
+ timeout = 15000,
340
+ headers = {},
341
+ extractSelectors = [],
342
+ includeHtml = false,
343
+ } = payload;
139
344
 
140
345
  const response = await axios.get(url, {
141
346
  timeout,
@@ -148,11 +353,39 @@ export async function handleHttpScrape(payload) {
148
353
  },
149
354
  });
150
355
 
151
- const html = typeof response.data === "string" ? response.data : "";
152
- const $ = cheerio.load(html);
356
+ const pageHtml = typeof response.data === "string" ? response.data : "";
357
+ const $ = cheerio.load(pageHtml);
153
358
  const title = $("title").first().text().trim() || "";
154
359
 
155
- const extractedData = {};
360
+ const raw = {};
361
+ const strategySummary = {};
362
+
363
+ // 1. Extract fieldMappings
364
+ if (Array.isArray(fieldMappings) && fieldMappings.length > 0) {
365
+ for (const mapping of fieldMappings) {
366
+ let extractedVal = null;
367
+ let successfulStrategy = null;
368
+
369
+ const strategies = mapping.strategies || [];
370
+ for (let i = 0; i < strategies.length; i++) {
371
+ const strat = strategies[i];
372
+ const val = extractCheerioField($, strat);
373
+ if (val !== null && val !== undefined && val !== "") {
374
+ extractedVal = val;
375
+ successfulStrategy = strat.type || `strategy_${i + 1}`;
376
+ break;
377
+ }
378
+ }
379
+
380
+ raw[mapping.field] = extractedVal;
381
+ strategySummary[mapping.field] = {
382
+ extracted: extractedVal !== null,
383
+ strategyUsed: successfulStrategy,
384
+ };
385
+ }
386
+ }
387
+
388
+ // 2. Extract legacy extractSelectors
156
389
  if (Array.isArray(extractSelectors) && extractSelectors.length > 0) {
157
390
  for (const item of extractSelectors) {
158
391
  const { field, selector, type = "text", attribute = null } = item;
@@ -170,21 +403,31 @@ export async function handleHttpScrape(payload) {
170
403
  }
171
404
  });
172
405
 
173
- extractedData[field] = values.length <= 1 ? (values[0] ?? null) : values;
406
+ const extractedVal = values.length <= 1 ? (values[0] ?? null) : values;
407
+ if (raw[field] === undefined) {
408
+ raw[field] = extractedVal;
409
+ }
174
410
  } catch {
175
- extractedData[field] = null;
411
+ if (raw[field] === undefined) {
412
+ raw[field] = null;
413
+ }
176
414
  }
177
415
  }
178
416
  }
179
417
 
180
418
  const executionTimeMs = Date.now() - startTime;
419
+ const hasExtractedValues = Object.values(raw).some(
420
+ (v) => v !== null && v !== undefined && v !== ""
421
+ );
181
422
 
182
423
  return {
183
424
  status: response.status,
184
425
  title,
185
426
  url,
186
- html,
187
- extractedData,
427
+ raw,
428
+ strategySummary,
429
+ extractedData: raw,
430
+ html: includeHtml || (!hasExtractedValues && fieldMappings.length > 0) ? pageHtml : "",
188
431
  executionTimeMs,
189
432
  };
190
433
  }