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 +168 -0
- package/bin/worker.js +16 -0
- package/package.json +45 -0
- package/src/banner.js +55 -0
- package/src/browser.js +360 -0
- package/src/client.js +149 -0
- package/src/config.js +201 -0
- package/src/handlers/previewHandler.js +176 -0
- package/src/handlers/scrapeHandler.js +190 -0
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
|
+
[](https://www.npmjs.com/package/app-manager-edge-worker)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://apps-manager.onrender.com)
|
|
8
|
+
[](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
|
+
}
|