app-manager-edge-worker 1.0.1 → 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
@@ -55,21 +55,48 @@ app-manager-edge-worker
55
55
 
56
56
  ## 📱 Turn Any Android Phone into a 24/7 Scraping Node (Termux)
57
57
 
58
- 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)
59
61
 
60
62
  ```bash
61
- # 1. Update Termux packages & install Node.js + Chromium
62
- 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
63
69
 
64
- # 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)
65
85
  export PUPPETEER_EXECUTABLE_PATH=/data/data/com.termux/files/usr/bin/chromium-browser
86
+ export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
66
87
 
67
- # 3. Launch the edge worker
88
+ # 6. Launch the distributed edge worker
68
89
  npx app-manager-edge-worker --server https://apps-manager.onrender.com --key <YOUR_WORKER_KEY>
69
90
  ```
70
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
+
71
95
  > [!TIP]
72
- > 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.
73
100
 
74
101
  ---
75
102
 
@@ -128,7 +155,7 @@ The worker **automatically downloads and installs it** according to your operati
128
155
  | **Windows** (x64/x86) | Downloads official stable Chrome for Testing from Google CDN with a progress bar into `~/.cache/puppeteer`. |
129
156
  | **macOS** (Intel / Apple Silicon M1-M4) | Detects system architecture (`mac` or `mac_arm`) and downloads matching stable Chrome from Google CDN. |
130
157
  | **Linux Desktop / VPS** (Ubuntu, Debian, Fedora, Arch) | Downloads official Linux x64/arm64 Chrome for Testing via `@puppeteer/browsers`. |
131
- | **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`). |
132
159
  | **Alpine Linux** | Automatically invokes Alpine package manager (`apk add --no-cache chromium`). |
133
160
 
134
161
  No manual setup required!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-manager-edge-worker",
3
- "version": "1.0.1",
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
@@ -21,7 +21,7 @@ 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.1${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}`);
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
@@ -2,6 +2,7 @@ import { io } from "socket.io-client";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import axios from "axios";
5
+ import { execSync } from "child_process";
5
6
  import localBrowserManager from "./browser.js";
6
7
  import { handleChromiumScrape, handleHttpScrape } from "./handlers/scrapeHandler.js";
7
8
  import { handlePreviewPage, handleInspectElement } from "./handlers/previewHandler.js";
@@ -20,6 +21,15 @@ export async function startWorkerClient(config) {
20
21
  keepAliveMinutes,
21
22
  });
22
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
+
23
33
  logInfo("Checking Chromium browser availability...");
24
34
  let chromiumExecutable = null;
25
35
  try {
@@ -52,11 +62,58 @@ export async function startWorkerClient(config) {
52
62
  },
53
63
  });
54
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
+
55
104
  let activeJobs = 0;
105
+ let activeConcurrency = concurrency;
56
106
 
57
107
  socket.on("connect", () => {
58
108
  logSuccess(`Connected to cluster! (Socket ID: ${socket.id})`);
59
- 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
+ }
60
117
  });
61
118
 
62
119
  socket.on("connect_error", (err) => {
@@ -102,7 +159,7 @@ export async function startWorkerClient(config) {
102
159
  const durationMs = Date.now() - startTime;
103
160
  logSuccess(`Completed ${jobType} for ${targetUrl} in ${durationMs}ms`);
104
161
 
105
- socket.emit("job:result", {
162
+ sendJobResult({
106
163
  jobId,
107
164
  success: true,
108
165
  data: resultData,
@@ -112,7 +169,7 @@ export async function startWorkerClient(config) {
112
169
  const durationMs = Date.now() - startTime;
113
170
  logError(`Failed ${jobType} for ${targetUrl}: ${err.message}`);
114
171
 
115
- socket.emit("job:result", {
172
+ sendJobResult({
116
173
  jobId,
117
174
  success: false,
118
175
  error: {
@@ -139,35 +196,49 @@ export async function startWorkerClient(config) {
139
196
  let keepAliveTimer = null;
140
197
  let initialKeepAliveTimer = null;
141
198
 
142
- if (keepAliveMinutes > 0) {
143
- const pingKeepAlive = async () => {
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
144
224
  try {
145
- // Try the dedicated keep-alive endpoint first
146
- const endpoint = `${serverUrl}/api/workers/keep-alive`;
147
- const res = await axios.get(endpoint, {
148
- timeout: 15000,
225
+ const fallbackUrl = `${serverUrl}/health`;
226
+ await axios.get(fallbackUrl, {
227
+ timeout: 10000,
149
228
  headers: {
150
- "x-worker-key": workerKey || "",
151
- "user-agent": "AppsManagerEdgeWorker/1.0.1",
229
+ "user-agent": "AppsManagerEdgeWorker/1.0.2",
152
230
  },
153
231
  });
154
- logInfo(`[Keep-Alive] Server awake ping OK (${res.status}) - Render instance active`);
155
- } catch (err) {
156
- // Fallback to /health
157
- try {
158
- const fallbackUrl = `${serverUrl}/health`;
159
- await axios.get(fallbackUrl, {
160
- timeout: 10000,
161
- headers: {
162
- "user-agent": "AppsManagerEdgeWorker/1.0.1",
163
- },
164
- });
165
- logInfo(`[Keep-Alive] Fallback health ping OK - Render instance active`);
166
- } catch (fallbackErr) {
167
- logWarn(`[Keep-Alive] Server awake ping warning (${err.message}) - will retry next cycle`);
168
- }
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`);
169
235
  }
170
- };
236
+ }
237
+ };
238
+
239
+ const startKeepAlive = () => {
240
+ if (keepAliveMinutes <= 0) return;
241
+ clearKeepAliveTimers();
171
242
 
172
243
  // Send initial ping 15s after startup to warm up/verify
173
244
  initialKeepAliveTimer = setTimeout(pingKeepAlive, 15000);
@@ -176,15 +247,40 @@ export async function startWorkerClient(config) {
176
247
  const intervalMs = Math.max(1, keepAliveMinutes) * 60 * 1000;
177
248
  keepAliveTimer = setInterval(pingKeepAlive, intervalMs);
178
249
  if (keepAliveTimer.unref) keepAliveTimer.unref();
250
+ };
251
+
252
+ if (keepAliveMinutes > 0) {
253
+ startKeepAlive();
179
254
  }
180
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
+
181
280
  // Graceful shutdown handling
182
281
  const shutdown = async () => {
183
- if (keepAliveTimer) clearInterval(keepAliveTimer);
184
- if (initialKeepAliveTimer) clearTimeout(initialKeepAliveTimer);
185
282
  logInfo("\nGracefully shutting down edge worker...");
186
- socket.disconnect();
187
- await localBrowserManager.close();
283
+ await socket.closeClient();
188
284
  logSuccess("Edge worker stopped cleanly.");
189
285
  process.exit(0);
190
286
  };
package/src/config.js CHANGED
@@ -106,7 +106,7 @@ export async function resolveWorkerConfig() {
106
106
 
107
107
  if (cli.help) {
108
108
  console.log(`
109
- Apps Manager Scraper Edge Worker CLI v1.0.1
109
+ Apps Manager Scraper Edge Worker CLI v1.0.2
110
110
 
111
111
  Usage:
112
112
  npx app-manager-edge-worker [options]
@@ -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
  }