app-manager-edge-worker 1.0.0

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 ADDED
@@ -0,0 +1,168 @@
1
+ # app-manager-edge-worker
2
+
3
+ > **Distributed Scraper Edge Worker** for Apps Manager. Run on your local laptop, desktop, VPS, or Android phone (via Termux) to offload web scraping, visual element selection, and bypass Cloudflare WAF restrictions with zero proxy cost.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/app-manager-edge-worker.svg)](https://www.npmjs.com/package/app-manager-edge-worker)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+ [![Platform Support](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20Termux-brightgreen)](https://apps-manager.onrender.com)
8
+ [![Hosted Platform](https://img.shields.io/badge/Hosted%20Platform-apps--manager.onrender.com-blue)](https://apps-manager.onrender.com)
9
+
10
+ ---
11
+
12
+ ## ✨ Why Distributed Edge Workers?
13
+
14
+ Cloud datacenter IPs (Render, AWS, DigitalOcean, Hetzner) are frequently blocked or challenged by Cloudflare, Akamai, and anti-bot systems. **`app-manager-edge-worker`** turns your existing hardware into an autonomous residential scraping cluster:
15
+
16
+ - 🌐 **True Residential IP Routing**: Requests originate directly from your home/office broadband or mobile data (Airtel, Jio, Comcast, etc.).
17
+ - 🔒 **Zero Port Forwarding**: Persistent outbound WebSockets (`wss://.../workers`) effortlessly traverse home Wi-Fi routers, firewalls, and carrier-grade NAT (CGNAT).
18
+ - ⚡ **Autonomous Chromium Engine**: Checks your system for Chrome/Edge or automatically installs stable Chromium without any manual intervention.
19
+ - 📱 **Android Phone Support**: Transform an old spare Android phone into a 24/7 scraping powerhouse using Termux and Wake Lock.
20
+ - 🚀 **Smart Single-Flight Deduplication**: Concurrent scrapes for the exact same target URL coalesce into a single execution flight.
21
+
22
+ ---
23
+
24
+ ## ⚡ Quick Start
25
+
26
+ ### Option 1: Run Instantly via NPX (Recommended)
27
+
28
+ ```bash
29
+ npx app-manager-edge-worker
30
+ ```
31
+
32
+ On first run, the CLI interactively asks for your **Worker Key**. Paste it from your Apps Manager dashboard, hit `Enter`, and you are immediately connected! Credentials are automatically saved to `~/.app-manager-edge-worker/config.json` for subsequent runs.
33
+
34
+ ---
35
+
36
+ ### Option 2: Run with Flags Directly
37
+
38
+ ```bash
39
+ npx app-manager-edge-worker --key wkey_live_your_key_here --server https://apps-manager.onrender.com
40
+ ```
41
+
42
+ ---
43
+
44
+ ### Option 3: Global NPM Installation
45
+
46
+ ```bash
47
+ npm install -g app-manager-edge-worker
48
+
49
+ # Run directly
50
+ app-manager-edge-worker
51
+ ```
52
+
53
+ ---
54
+
55
+ ## 📱 Turn Any Android Phone into a 24/7 Scraping Node (Termux)
56
+
57
+ You can turn any old Android phone into an ultra-low-power, 24/7 residential scraping node using **Termux** (available free from F-Droid):
58
+
59
+ ```bash
60
+ # 1. Update Termux packages & install Node.js + Chromium
61
+ pkg update -y && pkg install -y nodejs-lts chromium
62
+
63
+ # 2. Point Puppeteer to Termux's native Chromium ARM64 binary
64
+ export PUPPETEER_EXECUTABLE_PATH=/data/data/com.termux/files/usr/bin/chromium-browser
65
+
66
+ # 3. Launch the edge worker
67
+ npx app-manager-edge-worker --server https://apps-manager.onrender.com --key <YOUR_WORKER_KEY>
68
+ ```
69
+
70
+ > [!TIP]
71
+ > In Termux, swipe down your Android notification shade and tap **"Acquire Wake Lock"**. Disable Android OS battery optimization for Termux so your phone runs uninterrupted in the background.
72
+
73
+ ---
74
+
75
+ ## 🔄 Production Background Service (PM2)
76
+
77
+ To run the worker continuously as a daemon on Linux, macOS, or Windows:
78
+
79
+ ```bash
80
+ # 1. Install PM2 and the worker CLI globally
81
+ npm install -g pm2 app-manager-edge-worker
82
+
83
+ # 2. Start the scraper worker as a managed process
84
+ pm2 start app-manager-edge-worker --name "apps-worker" -- --server https://apps-manager.onrender.com --key <YOUR_WORKER_KEY>
85
+
86
+ # 3. Configure auto-start on machine boot
87
+ pm2 save
88
+ pm2 startup
89
+ ```
90
+
91
+ Useful PM2 commands:
92
+ - Check status: `pm2 status`
93
+ - View live stream logs: `pm2 logs apps-worker`
94
+ - Restart worker: `pm2 restart apps-worker`
95
+
96
+ ---
97
+
98
+ ## 🐳 Docker Container
99
+
100
+ Run in an isolated container on any VPS or local Docker host:
101
+
102
+ ```bash
103
+ docker run -d \
104
+ --name app-manager-edge-worker \
105
+ --restart unless-stopped \
106
+ -e SERVER_URL="https://apps-manager.onrender.com" \
107
+ -e WORKER_KEY="<YOUR_WORKER_KEY>" \
108
+ node:20-slim sh -c "apt-get update && apt-get install -y chromium && npx app-manager-edge-worker"
109
+ ```
110
+
111
+ ---
112
+
113
+ ## 🌐 Autonomous Chromium Detection & Self-Installation
114
+
115
+ When `app-manager-edge-worker` initializes, it executes a multi-layer verification routine to detect a usable Chromium executable:
116
+
117
+ 1. **Explicit Environment Variables**: Checks `PUPPETEER_EXECUTABLE_PATH`, `CHROME_BIN`, `CHROME_PATH`, `CHROMIUM_PATH`.
118
+ 2. **Pre-Installed System Browsers**: Scans standard OS locations for Google Chrome, Microsoft Edge, Brave, or Chromium, and checks system `PATH`.
119
+ 3. **Bundled/Cached Browsers**: Scans `~/.cache/puppeteer` using `@puppeteer/browsers`.
120
+ 4. **Android Termux Detection**: Automatically detects `/data/data/com.termux/files/usr/bin/chromium-browser`.
121
+
122
+ ### What happens if NO Chromium is found?
123
+ The worker **automatically downloads and installs it** according to your operating system:
124
+
125
+ | Operating System | Auto-Installation Action |
126
+ |------------------|--------------------------|
127
+ | **Windows** (x64/x86) | Downloads official stable Chrome for Testing from Google CDN with a progress bar into `~/.cache/puppeteer`. |
128
+ | **macOS** (Intel / Apple Silicon M1-M4) | Detects system architecture (`mac` or `mac_arm`) and downloads matching stable Chrome from Google CDN. |
129
+ | **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`). |
131
+ | **Alpine Linux** | Automatically invokes Alpine package manager (`apk add --no-cache chromium`). |
132
+
133
+ No manual setup required!
134
+
135
+ ---
136
+
137
+ ## ⚙️ CLI Options & Flags
138
+
139
+ | Flag | Description | Default |
140
+ |------|-------------|---------|
141
+ | `-k, --key <key>` | Worker authorization key (`wkey_live_...`) | Prompted / Stored |
142
+ | `-s, --server <url>` | Apps Manager Server URL | `https://apps-manager.onrender.com` |
143
+ | `-c, --concurrency <n>` | Max concurrent Chromium browser tabs | `3` |
144
+ | `-n, --name <name>` | Custom display name for this worker | Device hostname |
145
+ | `--reset` | Clear stored credentials and prompt again | - |
146
+ | `-h, --help` | Display help screen | - |
147
+
148
+ ---
149
+
150
+ ## 🔒 Security & Sandboxing
151
+
152
+ - **Outbound Only**: All socket tunnels are initiated outbound to the central Apps Manager server over TLS (`wss://`). No public IP, dynamic DNS, or port forwarding required.
153
+ - **Granular Scoping**: Workers can be dedicated to a specific application or organization team, or shared with the public pool.
154
+ - **Incognito Sandboxing**: Every scraping job and visual selector preview is executed within an isolated browser context that is cleanly destroyed upon task completion.
155
+
156
+ ---
157
+
158
+ ## 👤 Author
159
+
160
+ **Chetan Mohite**
161
+ - GitHub: [@codes-by-chetan](https://github.com/codes-by-chetan)
162
+ - Platform: [Apps Manager](https://apps-manager.onrender.com)
163
+
164
+ ---
165
+
166
+ ## 📄 License
167
+
168
+ MIT © [Chetan Mohite](https://github.com/codes-by-chetan/Apps_Manager)
package/bin/worker.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolveWorkerConfig } from "../src/config.js";
4
+ import { startWorkerClient } from "../src/client.js";
5
+
6
+ async function main() {
7
+ try {
8
+ const config = await resolveWorkerConfig();
9
+ await startWorkerClient(config);
10
+ } catch (err) {
11
+ console.error("Fatal Worker Error:", err);
12
+ process.exit(1);
13
+ }
14
+ }
15
+
16
+ main();
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "app-manager-edge-worker",
3
+ "version": "1.0.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "app-manager-edge-worker": "./bin/worker.js",
8
+ "apps-manager-worker": "./bin/worker.js"
9
+ },
10
+ "main": "./src/client.js",
11
+ "scripts": {
12
+ "start": "node ./bin/worker.js"
13
+ },
14
+ "keywords": [
15
+ "app-manager-edge-worker",
16
+ "apps-manager",
17
+ "scraper",
18
+ "edge-worker",
19
+ "puppeteer",
20
+ "residential-proxy",
21
+ "distributed-scraping",
22
+ "termux"
23
+ ],
24
+ "author": "Chetan Mohite <chetanmohite2128@gmail.com> (https://github.com/codes-by-chetan)",
25
+ "license": "MIT",
26
+ "homepage": "https://apps-manager.onrender.com",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/codes-by-chetan/Apps_Manager.git",
30
+ "directory": "packages/worker"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "@puppeteer/browsers": "^3.2.2",
37
+ "axios": "^1.8.1",
38
+ "cheerio": "^1.0.0",
39
+ "puppeteer": "^24.3.0",
40
+ "puppeteer-core": "^24.3.0",
41
+ "puppeteer-extra": "^3.3.6",
42
+ "puppeteer-extra-plugin-stealth": "^2.11.2",
43
+ "socket.io-client": "^4.8.1"
44
+ }
45
+ }
package/src/banner.js ADDED
@@ -0,0 +1,55 @@
1
+ // ANSI color helpers
2
+ const colors = {
3
+ reset: "\x1b[0m",
4
+ bright: "\x1b[1m",
5
+ dim: "\x1b[2m",
6
+ cyan: "\x1b[36m",
7
+ green: "\x1b[32m",
8
+ yellow: "\x1b[33m",
9
+ red: "\x1b[31m",
10
+ magenta: "\x1b[35m",
11
+ gray: "\x1b[90m",
12
+ };
13
+
14
+ export function printBanner({ workerName, serverUrl, concurrency, platform, isTermux }) {
15
+ console.clear();
16
+ console.log(`${colors.cyan}${colors.bright}`);
17
+ console.log(" █████╗ ██████╗ ██████╗ ███████╗ ███╗ ███╗ █████╗ ███╗ ██╗ █████╗ ██████╗ ███████╗██████╗ ");
18
+ console.log(" ██╔══██╗██╔══██╗██╔══██╗██╔════╝ ████╗ ████║██╔══██╗████╗ ██║██╔══██╗██╔════╝ ██╔════╝██╔══██╗");
19
+ console.log(" ███████║██████╔╝██████╔╝███████╗ ██╔████╔██║███████║██╔██╗ ██║███████║██║ ███╗█████╗ ██████╔╝");
20
+ console.log(" ██╔══██║██╔═══╝ ██╔═══╝ ╚════██║ ██║╚██╔╝██║██╔══██║██║╚██╗██║██╔══██║██║ ██║██╔══╝ ██╔══██╗");
21
+ console.log(" ██║ ██║██║ ██║ ███████║██╗██║ ╚═╝ ██║██║ ██║██║ ╚████║██║ ██║╚██████╔╝███████╗██║ ██║");
22
+ console.log(" ╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝");
23
+ console.log(`${colors.reset}`);
24
+ console.log(`${colors.bright} 🌐 Distributed Scraper Edge Worker Client v1.0.0${colors.reset}`);
25
+ console.log(`${colors.dim} Zero-cost, persistent tunneling & residential edge scraping${colors.reset}`);
26
+ console.log("--------------------------------------------------------------------------------");
27
+ console.log(` ${colors.bright}Device Name :${colors.reset} ${colors.green}${workerName}${colors.reset}`);
28
+ console.log(` ${colors.bright}Server URL :${colors.reset} ${colors.cyan}${serverUrl}${colors.reset}`);
29
+ console.log(` ${colors.bright}Concurrency :${colors.reset} ${colors.yellow}${concurrency} concurrent tabs${colors.reset}`);
30
+ console.log(` ${colors.bright}Environment :${colors.reset} ${platform} ${isTermux ? "(Termux Android)" : ""}`);
31
+ console.log("--------------------------------------------------------------------------------\n");
32
+ }
33
+
34
+ export function logInfo(msg) {
35
+ console.log(`${colors.cyan}[INFO]${colors.reset} ${msg}`);
36
+ }
37
+
38
+ export function logSuccess(msg) {
39
+ console.log(`${colors.green}[SUCCESS]${colors.reset} ${msg}`);
40
+ }
41
+
42
+ export function logWarn(msg) {
43
+ console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
44
+ }
45
+
46
+ export function logError(msg) {
47
+ console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
48
+ }
49
+
50
+ export function logJob(jobType, url, durationMs = null) {
51
+ const timeStr = durationMs ? ` ${colors.gray}(${durationMs}ms)${colors.reset}` : "";
52
+ console.log(
53
+ `${colors.magenta}[TASK]${colors.reset} ${colors.bright}${jobType}${colors.reset} -> ${url}${timeStr}`
54
+ );
55
+ }
package/src/browser.js ADDED
@@ -0,0 +1,360 @@
1
+ import puppeteer from "puppeteer-extra";
2
+ import StealthPlugin from "puppeteer-extra-plugin-stealth";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import os from "os";
6
+ import { execSync } from "child_process";
7
+ import {
8
+ Browser,
9
+ BrowserTag,
10
+ detectBrowserPlatform,
11
+ resolveBuildId,
12
+ install,
13
+ getInstalledBrowsers,
14
+ } from "@puppeteer/browsers";
15
+ import { logInfo, logSuccess, logWarn, logError } from "./banner.js";
16
+
17
+ // Apply stealth plugin globally
18
+ puppeteer.use(StealthPlugin());
19
+
20
+ class LocalBrowserManager {
21
+ constructor() {
22
+ this.browser = null;
23
+ this.isLaunching = false;
24
+ this.launchPromise = null;
25
+ this.resolvedExecutablePath = null;
26
+ }
27
+
28
+ /**
29
+ * Detects if running inside Android Termux environment.
30
+ */
31
+ isTermux() {
32
+ return Boolean(
33
+ process.env.TERMUX_VERSION ||
34
+ (process.env.PREFIX && process.env.PREFIX.includes("com.termux"))
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Checks if a given file path exists and is executable.
40
+ */
41
+ isValidExecutable(filePath) {
42
+ if (!filePath || typeof filePath !== "string") return false;
43
+ try {
44
+ if (!fs.existsSync(filePath)) return false;
45
+ const stat = fs.statSync(filePath);
46
+ if (!stat.isFile()) return false;
47
+ if (process.platform !== "win32") {
48
+ fs.accessSync(filePath, fs.constants.X_OK);
49
+ }
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Scans known system paths for pre-installed browsers (Chrome, Edge, Brave, Chromium).
58
+ */
59
+ findPreInstalledSystemBrowser() {
60
+ if (this.isTermux()) {
61
+ const termuxCandidates = [
62
+ "/data/data/com.termux/files/usr/bin/chromium-browser",
63
+ "/data/data/com.termux/files/usr/bin/chromium",
64
+ ];
65
+ for (const p of termuxCandidates) {
66
+ if (this.isValidExecutable(p)) return p;
67
+ }
68
+ }
69
+
70
+ const platform = os.platform();
71
+ const candidates = [];
72
+
73
+ if (platform === "win32") {
74
+ const progFiles = process.env["ProgramFiles"] || "C:\\Program Files";
75
+ const progFiles86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
76
+ const localApp = process.env["LOCALAPPDATA"] || path.join(os.homedir(), "AppData", "Local");
77
+
78
+ candidates.push(
79
+ path.join(progFiles, "Google", "Chrome", "Application", "chrome.exe"),
80
+ path.join(progFiles86, "Google", "Chrome", "Application", "chrome.exe"),
81
+ path.join(localApp, "Google", "Chrome", "Application", "chrome.exe"),
82
+ path.join(progFiles86, "Microsoft", "Edge", "Application", "msedge.exe"),
83
+ path.join(progFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
84
+ path.join(progFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")
85
+ );
86
+ } else if (platform === "darwin") {
87
+ candidates.push(
88
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
89
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
90
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
91
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
92
+ );
93
+ } else if (platform === "linux") {
94
+ candidates.push(
95
+ "/usr/bin/google-chrome-stable",
96
+ "/usr/bin/google-chrome",
97
+ "/usr/bin/chromium-browser",
98
+ "/usr/bin/chromium",
99
+ "/snap/bin/chromium"
100
+ );
101
+ }
102
+
103
+ for (const p of candidates) {
104
+ if (this.isValidExecutable(p)) return p;
105
+ }
106
+
107
+ // System PATH lookup fallback
108
+ try {
109
+ const cmd = platform === "win32" ? "where chrome" : "which google-chrome || which chromium || which chromium-browser";
110
+ const output = execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
111
+ const firstLine = output.split(/[\r\n]+/)[0]?.trim();
112
+ if (this.isValidExecutable(firstLine)) {
113
+ return firstLine;
114
+ }
115
+ } catch {
116
+ // Ignore which/where command failure
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Checks if a bundled or cached Puppeteer Chromium installation exists.
124
+ */
125
+ async findCachedPuppeteerBrowser() {
126
+ // 1. Try Puppeteer's native resolution
127
+ try {
128
+ const nativePath = await puppeteer.executablePath();
129
+ if (this.isValidExecutable(nativePath)) {
130
+ return nativePath;
131
+ }
132
+ } catch {
133
+ // Native check failed, inspect cache directories
134
+ }
135
+
136
+ // 2. Inspect ~/.cache/puppeteer and local .cache/puppeteer
137
+ const cacheDirs = [
138
+ path.join(os.homedir(), ".cache", "puppeteer"),
139
+ path.resolve(".cache", "puppeteer"),
140
+ process.env.PUPPETEER_CACHE_DIR,
141
+ ].filter(Boolean);
142
+
143
+ for (const cacheDir of cacheDirs) {
144
+ if (!fs.existsSync(cacheDir)) continue;
145
+ try {
146
+ const installed = await getInstalledBrowsers({ cacheDir });
147
+ for (const b of installed) {
148
+ if (this.isValidExecutable(b.executablePath)) {
149
+ return b.executablePath;
150
+ }
151
+ }
152
+ } catch {
153
+ // Ignore cache read errors
154
+ }
155
+ }
156
+
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * Automatically downloads or installs Chromium appropriate for the current OS.
162
+ * - Termux: runs `pkg install -y chromium`
163
+ * - Alpine Linux: runs `apk add --no-cache chromium`
164
+ * - Windows / macOS / Linux: downloads official Chrome via @puppeteer/browsers with terminal progress
165
+ */
166
+ async installBrowserForCurrentOS() {
167
+ const platform = os.platform();
168
+
169
+ // 1. Android Termux
170
+ if (this.isTermux()) {
171
+ logWarn("Termux Android environment detected without Chromium.");
172
+ logInfo("Attempting auto-install via Termux package manager (pkg install -y chromium)...");
173
+ try {
174
+ execSync("pkg update -y && pkg install -y chromium", { stdio: "inherit" });
175
+ const termuxPaths = [
176
+ "/data/data/com.termux/files/usr/bin/chromium-browser",
177
+ "/data/data/com.termux/files/usr/bin/chromium",
178
+ ];
179
+ for (const p of termuxPaths) {
180
+ if (this.isValidExecutable(p)) {
181
+ logSuccess(`Termux Chromium installed successfully at: ${p}`);
182
+ return p;
183
+ }
184
+ }
185
+ } catch (err) {
186
+ logError(`Termux auto-install failed: ${err.message}`);
187
+ logWarn("Please run manually in Termux: pkg install chromium -y");
188
+ }
189
+ }
190
+
191
+ // 2. Alpine Linux
192
+ if (fs.existsSync("/etc/alpine-release")) {
193
+ logWarn("Alpine Linux detected without Chromium.");
194
+ logInfo("Attempting auto-install via apk add --no-cache chromium...");
195
+ try {
196
+ execSync("apk add --no-cache chromium", { stdio: "inherit" });
197
+ if (this.isValidExecutable("/usr/bin/chromium-browser")) {
198
+ logSuccess("Alpine Chromium installed successfully!");
199
+ return "/usr/bin/chromium-browser";
200
+ }
201
+ } catch (err) {
202
+ logError(`Alpine apk install failed: ${err.message}`);
203
+ }
204
+ }
205
+
206
+ // 3. Desktop / Standard Linux (Windows, macOS, Linux x64/arm64) via @puppeteer/browsers
207
+ try {
208
+ const browserPlatform = detectBrowserPlatform();
209
+ if (!browserPlatform) {
210
+ throw new Error(`Unsupported OS platform for automated Chrome download: ${platform} ${os.arch()}`);
211
+ }
212
+
213
+ logWarn("No system or cached Chromium found on this machine.");
214
+ logInfo(`Resolving latest stable Chrome build for ${browserPlatform}...`);
215
+
216
+ const buildId = await resolveBuildId(Browser.CHROME, browserPlatform, BrowserTag.STABLE);
217
+ const cacheDir = path.join(os.homedir(), ".cache", "puppeteer");
218
+
219
+ logInfo(`Downloading Chrome v${buildId} to ${cacheDir}...`);
220
+
221
+ let lastPercent = -1;
222
+ const installed = await install({
223
+ browser: Browser.CHROME,
224
+ platform: browserPlatform,
225
+ buildId,
226
+ cacheDir,
227
+ downloadProgressCallback: (downloadedBytes, totalBytes) => {
228
+ if (totalBytes > 0) {
229
+ const percent = Math.round((downloadedBytes / totalBytes) * 100);
230
+ if (percent % 10 === 0 && percent !== lastPercent) {
231
+ lastPercent = percent;
232
+ process.stdout.write(
233
+ `\r[DOWNLOAD] Chrome: ${percent}% (${(downloadedBytes / 1024 / 1024).toFixed(1)}MB / ${(totalBytes / 1024 / 1024).toFixed(1)}MB)`
234
+ );
235
+ }
236
+ }
237
+ },
238
+ });
239
+
240
+ console.log(""); // New line after progress finishes
241
+ logSuccess(`Chrome installed and verified at: ${installed.executablePath}`);
242
+ return installed.executablePath;
243
+ } catch (err) {
244
+ logError(`Automated Chrome download failed: ${err.message}`);
245
+ throw new Error(`Failed to find or install Chromium executable on ${platform}: ${err.message}`);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Resolves the Chromium executable path or auto-installs if missing.
251
+ */
252
+ async ensureChromiumExecutable() {
253
+ if (this.resolvedExecutablePath && this.isValidExecutable(this.resolvedExecutablePath)) {
254
+ return this.resolvedExecutablePath;
255
+ }
256
+
257
+ // 1. Environment Variable check
258
+ const envCandidates = [
259
+ process.env.PUPPETEER_EXECUTABLE_PATH,
260
+ process.env.CHROME_BIN,
261
+ process.env.CHROME_PATH,
262
+ process.env.CHROMIUM_PATH,
263
+ ];
264
+ for (const p of envCandidates) {
265
+ if (this.isValidExecutable(p)) {
266
+ logInfo(`Using Chromium from environment variable: ${p}`);
267
+ this.resolvedExecutablePath = p;
268
+ return p;
269
+ }
270
+ }
271
+
272
+ // 2. Bundled / Cached Puppeteer Browser check
273
+ const cachedBrowser = await this.findCachedPuppeteerBrowser();
274
+ if (cachedBrowser) {
275
+ this.resolvedExecutablePath = cachedBrowser;
276
+ return cachedBrowser;
277
+ }
278
+
279
+ // 3. Pre-installed System Browser check (Chrome, Edge, Brave, etc.)
280
+ const systemBrowser = this.findPreInstalledSystemBrowser();
281
+ if (systemBrowser) {
282
+ logInfo(`Found system browser at: ${systemBrowser}`);
283
+ this.resolvedExecutablePath = systemBrowser;
284
+ return systemBrowser;
285
+ }
286
+
287
+ // 4. If neither bundled nor system browser exists, auto-install for this OS!
288
+ const newlyInstalledPath = await this.installBrowserForCurrentOS();
289
+ this.resolvedExecutablePath = newlyInstalledPath;
290
+ return newlyInstalledPath;
291
+ }
292
+
293
+ /**
294
+ * Launches or returns existing healthy Puppeteer browser instance.
295
+ */
296
+ async getBrowser() {
297
+ if (this.browser && this.browser.connected) {
298
+ return this.browser;
299
+ }
300
+
301
+ if (this.isLaunching && this.launchPromise) {
302
+ return this.launchPromise;
303
+ }
304
+
305
+ this.isLaunching = true;
306
+ this.launchPromise = (async () => {
307
+ try {
308
+ // Ensure executable path is resolved or auto-installed
309
+ const executablePath = await this.ensureChromiumExecutable();
310
+
311
+ const launchArgs = [
312
+ "--no-sandbox",
313
+ "--disable-setuid-sandbox",
314
+ "--disable-dev-shm-usage",
315
+ "--disable-accelerated-2d-canvas",
316
+ "--no-first-run",
317
+ "--no-zygote",
318
+ "--disable-gpu",
319
+ "--window-size=1366,900",
320
+ ];
321
+
322
+ const launchOptions = {
323
+ headless: "new",
324
+ args: launchArgs,
325
+ executablePath,
326
+ };
327
+
328
+ this.browser = await puppeteer.launch(launchOptions);
329
+
330
+ this.browser.on("disconnected", () => {
331
+ this.browser = null;
332
+ });
333
+
334
+ return this.browser;
335
+ } finally {
336
+ this.isLaunching = false;
337
+ this.launchPromise = null;
338
+ }
339
+ })();
340
+
341
+ return this.launchPromise;
342
+ }
343
+
344
+ /**
345
+ * Safely closes browser if running.
346
+ */
347
+ async close() {
348
+ if (this.browser) {
349
+ try {
350
+ await this.browser.close();
351
+ } catch {
352
+ // Ignore error on shutdown
353
+ }
354
+ this.browser = null;
355
+ }
356
+ }
357
+ }
358
+
359
+ const localBrowserManager = new LocalBrowserManager();
360
+ export default localBrowserManager;
package/src/client.js ADDED
@@ -0,0 +1,149 @@
1
+ import { io } from "socket.io-client";
2
+ import os from "os";
3
+ import path from "path";
4
+ import localBrowserManager from "./browser.js";
5
+ import { handleChromiumScrape, handleHttpScrape } from "./handlers/scrapeHandler.js";
6
+ import { handlePreviewPage, handleInspectElement } from "./handlers/previewHandler.js";
7
+ import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
8
+
9
+ export async function startWorkerClient(config) {
10
+ const { workerKey, serverUrl, concurrency, workerName } = config;
11
+ const isTermux = localBrowserManager.isTermux();
12
+
13
+ printBanner({
14
+ workerName,
15
+ serverUrl,
16
+ concurrency,
17
+ platform: `${os.platform()} ${os.arch()}`,
18
+ isTermux,
19
+ });
20
+
21
+ logInfo("Checking Chromium browser availability...");
22
+ let chromiumExecutable = null;
23
+ try {
24
+ chromiumExecutable = await localBrowserManager.ensureChromiumExecutable();
25
+ logSuccess(`Chromium ready: ${chromiumExecutable}`);
26
+ } catch (browserErr) {
27
+ logWarn(`Chromium initialization warning: ${browserErr.message}`);
28
+ }
29
+
30
+ logInfo("Connecting to Apps Manager cluster...");
31
+
32
+ const socket = io(`${serverUrl}/workers`, {
33
+ transports: ["websocket", "polling"],
34
+ reconnection: true,
35
+ reconnectionAttempts: Infinity,
36
+ reconnectionDelay: 1000,
37
+ reconnectionDelayMax: 5000,
38
+ auth: {
39
+ workerKey,
40
+ metadata: {
41
+ hostname: workerName || os.hostname(),
42
+ platform: os.platform(),
43
+ arch: os.arch(),
44
+ osVersion: os.release(),
45
+ nodeVersion: process.version,
46
+ chromiumVersion: chromiumExecutable ? path.basename(chromiumExecutable) : "",
47
+ chromiumPath: chromiumExecutable || "",
48
+ isTermux,
49
+ },
50
+ },
51
+ });
52
+
53
+ let activeJobs = 0;
54
+
55
+ socket.on("connect", () => {
56
+ logSuccess(`Connected to cluster! (Socket ID: ${socket.id})`);
57
+ logInfo(`Ready to accept scraping and visual preview jobs (Concurrency: ${concurrency}).\n`);
58
+ });
59
+
60
+ socket.on("connect_error", (err) => {
61
+ if (err.message && err.message.includes("Authentication failed")) {
62
+ logError(`Authentication Failed: ${err.message}`);
63
+ logWarn("Please verify your worker key or run with '--reset' to enter a new key.");
64
+ } else {
65
+ logWarn(`Connection failed (${err.message}). Retrying in a few seconds...`);
66
+ }
67
+ });
68
+
69
+ socket.on("disconnect", (reason) => {
70
+ logWarn(`Disconnected from cluster: ${reason}`);
71
+ });
72
+
73
+ // Handle incoming job dispatch from server
74
+ socket.on("job:dispatch", async (job) => {
75
+ const jobId = job.jobId;
76
+ const jobType = job.jobType || job.type;
77
+ const payload = job.payload || {};
78
+ const targetUrl = payload.url || "(no url)";
79
+
80
+ logJob(jobType, targetUrl);
81
+ activeJobs++;
82
+
83
+ const startTime = Date.now();
84
+
85
+ try {
86
+ let resultData = null;
87
+
88
+ if (jobType === "SCRAPE_CHROMIUM") {
89
+ resultData = await handleChromiumScrape(payload);
90
+ } else if (jobType === "SCRAPE_HTTP") {
91
+ resultData = await handleHttpScrape(payload);
92
+ } else if (jobType === "PREVIEW_PAGE") {
93
+ resultData = await handlePreviewPage(payload);
94
+ } else if (jobType === "INSPECT_ELEMENT") {
95
+ resultData = await handleInspectElement(payload);
96
+ } else {
97
+ throw new Error(`Unsupported job type: ${jobType}`);
98
+ }
99
+
100
+ const durationMs = Date.now() - startTime;
101
+ logSuccess(`Completed ${jobType} for ${targetUrl} in ${durationMs}ms`);
102
+
103
+ socket.emit("job:result", {
104
+ jobId,
105
+ success: true,
106
+ data: resultData,
107
+ durationMs,
108
+ });
109
+ } catch (err) {
110
+ const durationMs = Date.now() - startTime;
111
+ logError(`Failed ${jobType} for ${targetUrl}: ${err.message}`);
112
+
113
+ socket.emit("job:result", {
114
+ jobId,
115
+ success: false,
116
+ error: {
117
+ message: err.message,
118
+ code: err.code || "JOB_EXECUTION_ERROR",
119
+ },
120
+ durationMs,
121
+ });
122
+ } finally {
123
+ activeJobs = Math.max(0, activeJobs - 1);
124
+ }
125
+ });
126
+
127
+ // Handle cluster heartbeat ping
128
+ socket.on("ping", () => {
129
+ socket.emit("pong", {
130
+ activeJobs,
131
+ memory: process.memoryUsage(),
132
+ uptime: process.uptime(),
133
+ });
134
+ });
135
+
136
+ // Graceful shutdown handling
137
+ const shutdown = async () => {
138
+ logInfo("\nGracefully shutting down edge worker...");
139
+ socket.disconnect();
140
+ await localBrowserManager.close();
141
+ logSuccess("Edge worker stopped cleanly.");
142
+ process.exit(0);
143
+ };
144
+
145
+ process.on("SIGINT", shutdown);
146
+ process.on("SIGTERM", shutdown);
147
+
148
+ return socket;
149
+ }
package/src/config.js ADDED
@@ -0,0 +1,201 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import readline from "readline";
5
+
6
+ const LEGACY_CONFIG_DIR = path.join(os.homedir(), ".apps-manager-worker");
7
+ const NEW_CONFIG_DIR = path.join(os.homedir(), ".app-manager-edge-worker");
8
+ const CONFIG_DIR = fs.existsSync(NEW_CONFIG_DIR)
9
+ ? NEW_CONFIG_DIR
10
+ : fs.existsSync(LEGACY_CONFIG_DIR)
11
+ ? LEGACY_CONFIG_DIR
12
+ : NEW_CONFIG_DIR;
13
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
14
+
15
+ /**
16
+ * Ensures the config directory exists.
17
+ */
18
+ function ensureConfigDir() {
19
+ if (!fs.existsSync(CONFIG_DIR)) {
20
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Loads stored configuration from disk.
26
+ */
27
+ export function loadStoredConfig() {
28
+ try {
29
+ if (fs.existsSync(CONFIG_FILE)) {
30
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
31
+ return JSON.parse(raw);
32
+ }
33
+ } catch {
34
+ // Fall back to empty object on read/parse error
35
+ }
36
+ return {};
37
+ }
38
+
39
+ /**
40
+ * Saves configuration to disk.
41
+ */
42
+ export function saveStoredConfig(cfg) {
43
+ try {
44
+ ensureConfigDir();
45
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
46
+ } catch (err) {
47
+ console.warn(`[Config] Failed to save config to ${CONFIG_FILE}:`, err.message);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Parses command-line arguments.
53
+ */
54
+ export function parseCliArgs() {
55
+ const args = process.argv.slice(2);
56
+ const parsed = {};
57
+
58
+ for (let i = 0; i < args.length; i++) {
59
+ const arg = args[i];
60
+ if (arg === "--key" || arg === "-k") {
61
+ parsed.workerKey = args[++i];
62
+ } else if (arg === "--server" || arg === "-s") {
63
+ parsed.serverUrl = args[++i];
64
+ } else if (arg === "--concurrency" || arg === "-c") {
65
+ parsed.concurrency = parseInt(args[++i], 10);
66
+ } else if (arg === "--name" || arg === "-n") {
67
+ parsed.name = args[++i];
68
+ } else if (arg === "--reset") {
69
+ parsed.reset = true;
70
+ } else if (arg === "--help" || arg === "-h") {
71
+ parsed.help = true;
72
+ }
73
+ }
74
+
75
+ return parsed;
76
+ }
77
+
78
+ /**
79
+ * Prompts the user interactively in the terminal via native readline.
80
+ * @param {string} query
81
+ * @returns {Promise<string>}
82
+ */
83
+ function promptUser(query) {
84
+ const rl = readline.createInterface({
85
+ input: process.stdin,
86
+ output: process.stdout,
87
+ });
88
+
89
+ return new Promise((resolve) => {
90
+ rl.question(query, (answer) => {
91
+ rl.close();
92
+ resolve(answer.trim());
93
+ });
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Resolves the active configuration, prompting interactively if workerKey is missing.
99
+ */
100
+ export async function resolveWorkerConfig() {
101
+ const cli = parseCliArgs();
102
+
103
+ if (cli.help) {
104
+ console.log(`
105
+ Apps Manager Scraper Edge Worker CLI
106
+
107
+ Usage:
108
+ npx app-manager-edge-worker [options]
109
+
110
+ Options:
111
+ -k, --key <workerKey> Your worker secret key (wkey_live_...)
112
+ -s, --server <url> Apps Manager Server URL (e.g. https://apps-manager.onrender.com)
113
+ -c, --concurrency <number> Maximum concurrent browser tabs (default: 3)
114
+ -n, --name <name> Custom display name for this worker device
115
+ --reset Clear stored credentials and prompt again
116
+ -h, --help Display this help message
117
+ `);
118
+ process.exit(0);
119
+ }
120
+
121
+ if (cli.reset) {
122
+ if (fs.existsSync(CONFIG_FILE)) {
123
+ fs.unlinkSync(CONFIG_FILE);
124
+ console.log(`[Config] Cleared saved credentials in ${CONFIG_FILE}`);
125
+ }
126
+ }
127
+
128
+ const stored = loadStoredConfig();
129
+
130
+ // Priority: CLI > Env > Stored > Default
131
+ let workerKey =
132
+ cli.workerKey ||
133
+ process.env.APPS_MANAGER_WORKER_KEY ||
134
+ process.env.WORKER_KEY ||
135
+ stored.workerKey;
136
+
137
+ let serverUrl =
138
+ cli.serverUrl ||
139
+ process.env.APPS_MANAGER_SERVER_URL ||
140
+ process.env.SERVER_URL ||
141
+ stored.serverUrl ||
142
+ "https://apps-manager.onrender.com";
143
+
144
+ const concurrency =
145
+ cli.concurrency ||
146
+ parseInt(process.env.CONCURRENCY_LIMIT || "3", 10) ||
147
+ stored.concurrency ||
148
+ 3;
149
+
150
+ const workerName =
151
+ cli.name ||
152
+ process.env.WORKER_NAME ||
153
+ stored.workerName ||
154
+ `${os.hostname()} (${os.platform()})`;
155
+
156
+ // Interactive prompt if workerKey is still missing
157
+ if (!workerKey) {
158
+ console.log("\n==================================================");
159
+ console.log("🚀 Apps Manager Edge Worker Setup");
160
+ console.log("==================================================");
161
+ console.log("No worker key found on this device.\n");
162
+ console.log("To get a Worker Key:");
163
+ console.log("1. Go to your Apps Manager Dashboard (Admin or App Detail > Workers)");
164
+ console.log("2. Click 'Register New Worker'");
165
+ console.log("3. Copy the generated worker key\n");
166
+
167
+ workerKey = await promptUser("🔑 Paste your Worker Key (wkey_live_...): ");
168
+
169
+ if (!workerKey) {
170
+ console.error("❌ Error: Worker key is required to connect to the cluster.");
171
+ process.exit(1);
172
+ }
173
+
174
+ // Also prompt for server URL if default might not be what they want (e.g., local dev)
175
+ const customServer = await promptUser(
176
+ `🌐 Server URL (Press Enter for default: ${serverUrl}): `
177
+ );
178
+ if (customServer) {
179
+ serverUrl = customServer.replace(/\/+$/, "");
180
+ }
181
+
182
+ // Save for subsequent runs
183
+ saveStoredConfig({
184
+ workerKey,
185
+ serverUrl,
186
+ concurrency,
187
+ workerName,
188
+ });
189
+ console.log(`\n✅ Saved configuration to: ${CONFIG_FILE}\n`);
190
+ }
191
+
192
+ // Clean up trailing slash
193
+ serverUrl = serverUrl.replace(/\/+$/, "");
194
+
195
+ return {
196
+ workerKey: workerKey.trim(),
197
+ serverUrl,
198
+ concurrency,
199
+ workerName,
200
+ };
201
+ }
@@ -0,0 +1,176 @@
1
+ import localBrowserManager from "../browser.js";
2
+
3
+ /**
4
+ * Handles PREVIEW_PAGE task: captures full-page screenshot and dimensions on the edge worker.
5
+ */
6
+ export async function handlePreviewPage(payload) {
7
+ const { url, timeout = 25000 } = payload;
8
+ const browser = await localBrowserManager.getBrowser();
9
+ const page = await browser.newPage();
10
+
11
+ try {
12
+ await page.setViewport({ width: 1366, height: 900, deviceScaleFactor: 1 });
13
+
14
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout });
15
+ await new Promise((r) => setTimeout(r, 1500));
16
+
17
+ const title = (await page.title()) || new URL(url).hostname;
18
+ const currentUrl = page.url();
19
+
20
+ const dimensions = await page.evaluate(() => ({
21
+ width: document.documentElement.scrollWidth || 1366,
22
+ height: document.documentElement.scrollHeight || 900,
23
+ }));
24
+
25
+ // Hide scrollbars before screenshot
26
+ await page
27
+ .addStyleTag({
28
+ content: `
29
+ ::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
30
+ html, body, * { scrollbar-width: none !important; -ms-overflow-style: none !important; }
31
+ `,
32
+ })
33
+ .catch(() => {});
34
+
35
+ const screenshotBuffer = await page.screenshot({
36
+ encoding: "binary",
37
+ type: "jpeg",
38
+ quality: 85,
39
+ fullPage: true,
40
+ });
41
+
42
+ const screenshotBase64 = Buffer.from(screenshotBuffer).toString("base64");
43
+
44
+ return {
45
+ title,
46
+ url: currentUrl,
47
+ dimensions,
48
+ screenshotBase64: `data:image/jpeg;base64,${screenshotBase64}`,
49
+ };
50
+ } finally {
51
+ await page.close().catch(() => {});
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Handles INSPECT_ELEMENT task: resolves DOM element coordinates or evaluates candidate selectors on the edge worker.
57
+ */
58
+ export async function handleInspectElement(payload) {
59
+ const { url, selector, x, y, timeout = 20000 } = payload;
60
+ const browser = await localBrowserManager.getBrowser();
61
+ const page = await browser.newPage();
62
+
63
+ try {
64
+ await page.setViewport({ width: 1366, height: 900, deviceScaleFactor: 1 });
65
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout });
66
+ await new Promise((r) => setTimeout(r, 1000));
67
+
68
+ if (x !== undefined && y !== undefined) {
69
+ const elementInfo = await page.evaluate((px, py) => {
70
+ const scrollY = Math.max(0, py - 250);
71
+ const scrollX = Math.max(0, px - 250);
72
+ window.scrollTo(scrollX, scrollY);
73
+
74
+ const clientX = px - window.scrollX;
75
+ const clientY = py - window.scrollY;
76
+
77
+ const el = document.elementFromPoint(clientX, clientY);
78
+ if (!el) return null;
79
+
80
+ const rect = el.getBoundingClientRect();
81
+ const boundingRect = {
82
+ left: Math.round(rect.left + window.scrollX),
83
+ top: Math.round(rect.top + window.scrollY),
84
+ width: Math.round(rect.width),
85
+ height: Math.round(rect.height),
86
+ };
87
+
88
+ const candidates = [];
89
+ if (el.id) {
90
+ candidates.push({ type: "css", value: `#${el.id}`, attribute: "text" });
91
+ }
92
+
93
+ for (const attr of el.attributes) {
94
+ if (attr.name.startsWith("data-") && attr.value) {
95
+ candidates.push({
96
+ type: "css",
97
+ value: `[${attr.name}='${attr.value}']`,
98
+ attribute: "text",
99
+ });
100
+ }
101
+ }
102
+
103
+ if (el.className && typeof el.className === "string") {
104
+ const classes = el.className
105
+ .split(/\s+/)
106
+ .filter((c) => c && !c.includes(":") && !c.includes("/") && !c.includes("["));
107
+ if (classes.length > 0) {
108
+ candidates.push({
109
+ type: "css",
110
+ value: `${el.tagName.toLowerCase()}.${classes.slice(0, 2).join(".")}`,
111
+ attribute: "text",
112
+ });
113
+ }
114
+ }
115
+
116
+ const tagName = el.tagName.toLowerCase();
117
+ candidates.push({ type: "css", value: tagName, attribute: "text" });
118
+
119
+ const text = el.innerText ? el.innerText.trim() : el.textContent?.trim() || "";
120
+
121
+ return {
122
+ found: true,
123
+ tagName,
124
+ text,
125
+ attributes: Array.from(el.attributes).reduce((acc, a) => {
126
+ acc[a.name] = a.value;
127
+ return acc;
128
+ }, {}),
129
+ candidates,
130
+ boundingRect,
131
+ };
132
+ }, x, y);
133
+
134
+ return elementInfo || { found: false, message: "No element found at specified coordinates." };
135
+ }
136
+
137
+ if (selector) {
138
+ const testResult = await page.evaluate((sel) => {
139
+ try {
140
+ const el = document.querySelector(sel);
141
+ if (!el) return { found: false, count: 0 };
142
+
143
+ const rect = el.getBoundingClientRect();
144
+ const boundingRect = {
145
+ left: Math.round(rect.left + window.scrollX),
146
+ top: Math.round(rect.top + window.scrollY),
147
+ width: Math.round(rect.width),
148
+ height: Math.round(rect.height),
149
+ };
150
+
151
+ const count = document.querySelectorAll(sel).length;
152
+
153
+ return {
154
+ found: true,
155
+ count,
156
+ tagName: el.tagName.toLowerCase(),
157
+ text: el.innerText ? el.innerText.trim() : el.textContent?.trim() || "",
158
+ attributes: Array.from(el.attributes).reduce((acc, a) => {
159
+ acc[a.name] = a.value;
160
+ return acc;
161
+ }, {}),
162
+ boundingRect,
163
+ };
164
+ } catch (e) {
165
+ return { found: false, error: e.message };
166
+ }
167
+ }, selector);
168
+
169
+ return testResult;
170
+ }
171
+
172
+ return { message: "Specify coordinates (x, y) or selector to inspect." };
173
+ } finally {
174
+ await page.close().catch(() => {});
175
+ }
176
+ }
@@ -0,0 +1,190 @@
1
+ import axios from "axios";
2
+ import * as cheerio from "cheerio";
3
+ import localBrowserManager from "../browser.js";
4
+
5
+ /**
6
+ * Handles SCRAPE_CHROMIUM task via local stealth Puppeteer.
7
+ */
8
+ export async function handleChromiumScrape(payload) {
9
+ const startTime = Date.now();
10
+ const {
11
+ url,
12
+ timeout = 30000,
13
+ waitForSelector = null,
14
+ waitForTimeout = 1000,
15
+ actions = [],
16
+ extractSelectors = [],
17
+ headers = {},
18
+ cookies = [],
19
+ viewport = { width: 1366, height: 900 },
20
+ } = payload;
21
+
22
+ const browser = await localBrowserManager.getBrowser();
23
+ const page = await browser.newPage();
24
+
25
+ try {
26
+ await page.setViewport({
27
+ width: viewport.width || 1366,
28
+ height: viewport.height || 900,
29
+ deviceScaleFactor: 1,
30
+ });
31
+
32
+ if (Object.keys(headers).length > 0) {
33
+ await page.setExtraHTTPHeaders(headers);
34
+ }
35
+
36
+ if (Array.isArray(cookies) && cookies.length > 0) {
37
+ await page.setCookie(...cookies);
38
+ }
39
+
40
+ // Navigate
41
+ const response = await page.goto(url, {
42
+ waitUntil: "domcontentloaded",
43
+ timeout,
44
+ });
45
+
46
+ const status = response ? response.status() : 200;
47
+
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
54
+ }
55
+ }
56
+
57
+ // Brief delay for dynamic content
58
+ if (waitForTimeout > 0) {
59
+ await new Promise((r) => setTimeout(r, Math.min(waitForTimeout, 10000)));
60
+ }
61
+
62
+ // Execute sequential interactive actions if configured
63
+ if (Array.isArray(actions) && actions.length > 0) {
64
+ for (const action of actions) {
65
+ try {
66
+ if (action.type === "click" && action.selector) {
67
+ await page.click(action.selector);
68
+ } else if (action.type === "type" && action.selector && action.text) {
69
+ await page.type(action.selector, action.text);
70
+ } else if (action.type === "scroll") {
71
+ await page.evaluate((y) => window.scrollBy(0, y || 500), action.distance);
72
+ } else if (action.type === "wait") {
73
+ await new Promise((r) => setTimeout(r, action.duration || 1000));
74
+ }
75
+ } catch (actionErr) {
76
+ console.warn(`[ScrapeHandler] Action ${action.type} failed:`, actionErr.message);
77
+ }
78
+ }
79
+ }
80
+
81
+ const pageTitle = (await page.title()) || "";
82
+ const finalUrl = page.url();
83
+ const html = await page.content();
84
+
85
+ // Extract structured data if extraction rules are defined
86
+ const extractedData = {};
87
+ if (Array.isArray(extractSelectors) && extractSelectors.length > 0) {
88
+ for (const item of extractSelectors) {
89
+ const { field, selector, type = "text", attribute = null } = item;
90
+ if (!field || !selector) continue;
91
+
92
+ try {
93
+ const values = await page.evaluate(
94
+ (sel, t, attr) => {
95
+ const elements = Array.from(document.querySelectorAll(sel));
96
+ return elements.map((el) => {
97
+ if (t === "attribute" && attr) {
98
+ return el.getAttribute(attr) || "";
99
+ }
100
+ if (t === "html") {
101
+ return el.innerHTML;
102
+ }
103
+ return el.innerText ? el.innerText.trim() : el.textContent?.trim() || "";
104
+ });
105
+ },
106
+ selector,
107
+ type,
108
+ attribute
109
+ );
110
+
111
+ extractedData[field] = values.length <= 1 ? (values[0] ?? null) : values;
112
+ } catch {
113
+ extractedData[field] = null;
114
+ }
115
+ }
116
+ }
117
+
118
+ const executionTimeMs = Date.now() - startTime;
119
+
120
+ return {
121
+ status,
122
+ title: pageTitle,
123
+ url: finalUrl,
124
+ html,
125
+ extractedData,
126
+ executionTimeMs,
127
+ };
128
+ } finally {
129
+ await page.close().catch(() => {});
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Handles fast SCRAPE_HTTP task via Cheerio.
135
+ */
136
+ export async function handleHttpScrape(payload) {
137
+ const startTime = Date.now();
138
+ const { url, timeout = 15000, headers = {}, extractSelectors = [] } = payload;
139
+
140
+ const response = await axios.get(url, {
141
+ timeout,
142
+ headers: {
143
+ "User-Agent":
144
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
145
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
146
+ "Accept-Language": "en-US,en;q=0.9",
147
+ ...headers,
148
+ },
149
+ });
150
+
151
+ const html = typeof response.data === "string" ? response.data : "";
152
+ const $ = cheerio.load(html);
153
+ const title = $("title").first().text().trim() || "";
154
+
155
+ const extractedData = {};
156
+ if (Array.isArray(extractSelectors) && extractSelectors.length > 0) {
157
+ for (const item of extractSelectors) {
158
+ const { field, selector, type = "text", attribute = null } = item;
159
+ if (!field || !selector) continue;
160
+
161
+ try {
162
+ const values = [];
163
+ $(selector).each((_, el) => {
164
+ if (type === "attribute" && attribute) {
165
+ values.push($(el).attr(attribute) || "");
166
+ } else if (type === "html") {
167
+ values.push($(el).html() || "");
168
+ } else {
169
+ values.push($(el).text().trim());
170
+ }
171
+ });
172
+
173
+ extractedData[field] = values.length <= 1 ? (values[0] ?? null) : values;
174
+ } catch {
175
+ extractedData[field] = null;
176
+ }
177
+ }
178
+ }
179
+
180
+ const executionTimeMs = Date.now() - startTime;
181
+
182
+ return {
183
+ status: response.status,
184
+ title,
185
+ url,
186
+ html,
187
+ extractedData,
188
+ executionTimeMs,
189
+ };
190
+ }