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 +37 -7
- package/package.json +1 -1
- package/src/banner.js +3 -2
- package/src/browser.js +9 -3
- package/src/client.js +149 -6
- package/src/config.js +18 -1
- package/src/handlers/previewHandler.js +11 -3
- package/src/handlers/scrapeHandler.js +266 -23
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
>
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
@@ -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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|