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 +34 -7
- package/package.json +1 -1
- package/src/banner.js +1 -1
- package/src/browser.js +9 -3
- package/src/client.js +127 -31
- package/src/config.js +1 -1
- package/src/handlers/previewHandler.js +11 -3
- package/src/handlers/scrapeHandler.js +266 -23
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
>
|
|
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.
|
|
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.
|
|
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 (
|
|
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", {
|
|
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
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
timeout: 15000,
|
|
225
|
+
const fallbackUrl = `${serverUrl}/health`;
|
|
226
|
+
await axios.get(fallbackUrl, {
|
|
227
|
+
timeout: 10000,
|
|
149
228
|
headers: {
|
|
150
|
-
"
|
|
151
|
-
"user-agent": "AppsManagerEdgeWorker/1.0.1",
|
|
229
|
+
"user-agent": "AppsManagerEdgeWorker/1.0.2",
|
|
152
230
|
},
|
|
153
231
|
});
|
|
154
|
-
logInfo(`[Keep-Alive]
|
|
155
|
-
} catch (
|
|
156
|
-
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
43
|
-
timeout,
|
|
185
|
+
waitUntil: navWaitUntil,
|
|
186
|
+
timeout: navTimeout,
|
|
44
187
|
});
|
|
45
188
|
|
|
46
189
|
const status = response ? response.status() : 200;
|
|
47
190
|
|
|
48
|
-
//
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
86
|
-
const
|
|
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
|
-
|
|
293
|
+
const extractedVal = values.length <= 1 ? (values[0] ?? null) : values;
|
|
294
|
+
if (raw[field] === undefined) {
|
|
295
|
+
raw[field] = extractedVal;
|
|
296
|
+
}
|
|
112
297
|
} catch {
|
|
113
|
-
|
|
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 {
|
|
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
|
|
152
|
-
const $ = cheerio.load(
|
|
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
|
|
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
|
-
|
|
406
|
+
const extractedVal = values.length <= 1 ? (values[0] ?? null) : values;
|
|
407
|
+
if (raw[field] === undefined) {
|
|
408
|
+
raw[field] = extractedVal;
|
|
409
|
+
}
|
|
174
410
|
} catch {
|
|
175
|
-
|
|
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
|
-
|
|
187
|
-
|
|
427
|
+
raw,
|
|
428
|
+
strategySummary,
|
|
429
|
+
extractedData: raw,
|
|
430
|
+
html: includeHtml || (!hasExtractedValues && fieldMappings.length > 0) ? pageHtml : "",
|
|
188
431
|
executionTimeMs,
|
|
189
432
|
};
|
|
190
433
|
}
|