app-manager-edge-worker 1.0.7 → 1.0.9
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/package.json +1 -1
- package/src/banner.js +1 -1
- package/src/client.js +3 -0
- package/src/config.js +38 -1
- package/src/handlers/aiRepairHandler.js +154 -0
- package/src/handlers/previewHandler.js +176 -13
- package/src/handlers/scrapeHandler.js +109 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "app-manager-edge-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Distributed Scraper Edge Worker for Apps Manager. Run on any local machine, VPS, or Termux Android device to serve browser and HTTP scraping jobs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/banner.js
CHANGED
package/src/client.js
CHANGED
|
@@ -7,6 +7,7 @@ import localBrowserManager from "./browser.js";
|
|
|
7
7
|
import { handleChromiumScrape, handleHttpScrape } from "./handlers/scrapeHandler.js";
|
|
8
8
|
import { handlePreviewPage, handleInspectElement } from "./handlers/previewHandler.js";
|
|
9
9
|
import { handleSendEmail } from "./handlers/mailHandler.js";
|
|
10
|
+
import { handleAiSmartRepair } from "./handlers/aiRepairHandler.js";
|
|
10
11
|
import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
|
|
11
12
|
|
|
12
13
|
export async function startWorkerClient(config) {
|
|
@@ -157,6 +158,8 @@ export async function startWorkerClient(config) {
|
|
|
157
158
|
resultData = await handleInspectElement(payload);
|
|
158
159
|
} else if (jobType === "SEND_EMAIL") {
|
|
159
160
|
resultData = await handleSendEmail(payload);
|
|
161
|
+
} else if (jobType === "AI_SMART_REPAIR") {
|
|
162
|
+
resultData = await handleAiSmartRepair(payload, config);
|
|
160
163
|
} else {
|
|
161
164
|
throw new Error(`Unsupported job type: ${jobType}`);
|
|
162
165
|
}
|
package/src/config.js
CHANGED
|
@@ -69,6 +69,12 @@ export function parseCliArgs() {
|
|
|
69
69
|
parsed.keepAlive = parseInt(args[++i], 10);
|
|
70
70
|
} else if (arg === "--no-keep-alive") {
|
|
71
71
|
parsed.keepAlive = false;
|
|
72
|
+
} else if (arg === "--ai-endpoint") {
|
|
73
|
+
parsed.aiEndpoint = args[++i];
|
|
74
|
+
} else if (arg === "--ai-model") {
|
|
75
|
+
parsed.aiModel = args[++i];
|
|
76
|
+
} else if (arg === "--ai-key") {
|
|
77
|
+
parsed.aiApiKey = args[++i];
|
|
72
78
|
} else if (arg === "--reset") {
|
|
73
79
|
parsed.reset = true;
|
|
74
80
|
} else if (arg === "--help" || arg === "-h") {
|
|
@@ -105,8 +111,14 @@ export async function resolveWorkerConfig() {
|
|
|
105
111
|
const cli = parseCliArgs();
|
|
106
112
|
|
|
107
113
|
if (cli.help) {
|
|
114
|
+
let pkgVer = "1.0.9";
|
|
115
|
+
try {
|
|
116
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
117
|
+
if (pkg?.version) pkgVer = pkg.version;
|
|
118
|
+
} catch (_) {}
|
|
119
|
+
|
|
108
120
|
console.log(`
|
|
109
|
-
Apps Manager Scraper Edge Worker CLI
|
|
121
|
+
Apps Manager Scraper Edge Worker CLI v${pkgVer}
|
|
110
122
|
|
|
111
123
|
Usage:
|
|
112
124
|
npx app-manager-edge-worker [options]
|
|
@@ -118,6 +130,9 @@ Options:
|
|
|
118
130
|
-n, --name <name> Custom display name for this worker device
|
|
119
131
|
-p, --keep-alive <minutes> Interval in minutes to ping server and prevent Render free-tier sleep (default: 7)
|
|
120
132
|
--no-keep-alive Disable keep-alive ping
|
|
133
|
+
--ai-endpoint <url> Local/remote AI endpoint (default: http://127.0.0.1:11434 for Ollama)
|
|
134
|
+
--ai-model <model> Model name for Tier 4 AI Smart Repair (e.g. llama3, mistral)
|
|
135
|
+
--ai-key <apiKey> Optional API key for AI endpoint
|
|
121
136
|
--reset Clear stored credentials and prompt again
|
|
122
137
|
-h, --help Display this help message
|
|
123
138
|
`);
|
|
@@ -208,11 +223,33 @@ Options:
|
|
|
208
223
|
keepAliveMinutes = parseInt(process.env.KEEP_ALIVE_MINUTES, 10) || 7;
|
|
209
224
|
}
|
|
210
225
|
|
|
226
|
+
const aiEndpoint =
|
|
227
|
+
cli.aiEndpoint ||
|
|
228
|
+
process.env.AI_ENDPOINT ||
|
|
229
|
+
process.env.OLLAMA_ENDPOINT ||
|
|
230
|
+
stored.aiEndpoint ||
|
|
231
|
+
"http://localhost:11434/v1";
|
|
232
|
+
|
|
233
|
+
const aiModel =
|
|
234
|
+
cli.aiModel ||
|
|
235
|
+
process.env.AI_MODEL ||
|
|
236
|
+
stored.aiModel ||
|
|
237
|
+
"qwen2.5-coder:7b";
|
|
238
|
+
|
|
239
|
+
const aiApiKey =
|
|
240
|
+
cli.aiApiKey ||
|
|
241
|
+
process.env.AI_API_KEY ||
|
|
242
|
+
stored.aiApiKey ||
|
|
243
|
+
"ollama";
|
|
244
|
+
|
|
211
245
|
return {
|
|
212
246
|
workerKey: workerKey.trim(),
|
|
213
247
|
serverUrl,
|
|
214
248
|
concurrency,
|
|
215
249
|
workerName,
|
|
216
250
|
keepAliveMinutes,
|
|
251
|
+
aiEndpoint,
|
|
252
|
+
aiModel,
|
|
253
|
+
aiApiKey,
|
|
217
254
|
};
|
|
218
255
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import * as cheerio from "cheerio";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Handles AI_SMART_REPAIR jobs on the Edge Worker.
|
|
6
|
+
* Communicates with local Ollama, LocalAI, LM Studio, or OpenAI-compatible endpoints.
|
|
7
|
+
* This offloads all AI computation from the cloud server (Render free tier) to the local worker.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} payload - { field, expectedType, previousSelector, previousValue, regexPattern, htmlSnippet, aiConfig }
|
|
10
|
+
* @param {Object} workerConfig - Worker global config with default AI endpoint/model
|
|
11
|
+
* @returns {Promise<Object>} { success, field, repairedSelector, extractedValue, confidence, explanation, modelUsed }
|
|
12
|
+
*/
|
|
13
|
+
export async function handleAiSmartRepair(payload, workerConfig = {}) {
|
|
14
|
+
const {
|
|
15
|
+
field = "field",
|
|
16
|
+
expectedType = "string",
|
|
17
|
+
previousSelector = "",
|
|
18
|
+
previousValue = "",
|
|
19
|
+
regexPattern = "",
|
|
20
|
+
htmlSnippet = "",
|
|
21
|
+
aiConfig = {},
|
|
22
|
+
} = payload;
|
|
23
|
+
|
|
24
|
+
const endpoint =
|
|
25
|
+
aiConfig.endpoint ||
|
|
26
|
+
workerConfig.aiEndpoint ||
|
|
27
|
+
process.env.AI_ENDPOINT ||
|
|
28
|
+
"http://localhost:11434/v1";
|
|
29
|
+
|
|
30
|
+
const model =
|
|
31
|
+
aiConfig.model ||
|
|
32
|
+
workerConfig.aiModel ||
|
|
33
|
+
process.env.AI_MODEL ||
|
|
34
|
+
"qwen2.5-coder:7b";
|
|
35
|
+
|
|
36
|
+
const apiKey =
|
|
37
|
+
aiConfig.apiKey ||
|
|
38
|
+
workerConfig.aiApiKey ||
|
|
39
|
+
process.env.AI_API_KEY ||
|
|
40
|
+
"ollama";
|
|
41
|
+
|
|
42
|
+
if (!htmlSnippet) {
|
|
43
|
+
throw new Error("Cannot perform AI smart repair: HTML snippet is empty.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const systemPrompt = `You are an automated web scraping repair assistant.
|
|
47
|
+
A website redesign has broken an existing selector. Your job is to analyze the updated HTML snippet and identify the relocated element for the target field.
|
|
48
|
+
You MUST output ONLY a valid JSON object matching the requested schema with no surrounding Markdown or explanation.`;
|
|
49
|
+
|
|
50
|
+
const userPrompt = `FIELD REPAIR TARGET:
|
|
51
|
+
- Field Name: "${field}"
|
|
52
|
+
- Expected Data Type: "${expectedType}"
|
|
53
|
+
- Broken Previous Selector: "${previousSelector}"
|
|
54
|
+
- Last Known Sample Value: "${previousValue}"
|
|
55
|
+
${regexPattern ? `- Expected Value Format (Regex): "${regexPattern}"` : ""}
|
|
56
|
+
|
|
57
|
+
UPDATED HTML SNIPPET:
|
|
58
|
+
\`\`\`html
|
|
59
|
+
${htmlSnippet.slice(0, 8000)}
|
|
60
|
+
\`\`\`
|
|
61
|
+
|
|
62
|
+
TASK:
|
|
63
|
+
1. Locate the exact HTML element in the snippet that represents "${field}".
|
|
64
|
+
2. Construct a robust, resilient CSS selector (avoid dynamic or hashed classes like '_1a2b3c', prefer semantic tags, ids, data-* attributes, or structural paths).
|
|
65
|
+
3. Return ONLY a JSON object formatted as follows:
|
|
66
|
+
{
|
|
67
|
+
"repairedSelector": "css_selector_here",
|
|
68
|
+
"extractedValue": "exact_text_or_attribute_value",
|
|
69
|
+
"confidence": 0.95,
|
|
70
|
+
"explanation": "Brief explanation of why this element was chosen"
|
|
71
|
+
}`;
|
|
72
|
+
|
|
73
|
+
const cleanEndpoint = endpoint.replace(/\/+$/, "");
|
|
74
|
+
const chatCompletionsUrl = cleanEndpoint.endsWith("/chat/completions")
|
|
75
|
+
? cleanEndpoint
|
|
76
|
+
: `${cleanEndpoint}/chat/completions`;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const response = await axios.post(
|
|
80
|
+
chatCompletionsUrl,
|
|
81
|
+
{
|
|
82
|
+
model,
|
|
83
|
+
messages: [
|
|
84
|
+
{ role: "system", content: systemPrompt },
|
|
85
|
+
{ role: "user", content: userPrompt },
|
|
86
|
+
],
|
|
87
|
+
temperature: 0.1,
|
|
88
|
+
response_format: { type: "json_object" },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
headers: {
|
|
92
|
+
Authorization: `Bearer ${apiKey}`,
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
},
|
|
95
|
+
timeout: 45000,
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
let content = response.data?.choices?.[0]?.message?.content || "{}";
|
|
100
|
+
|
|
101
|
+
// Strip any markdown code fences if model returned ```json ... ```
|
|
102
|
+
content = content.replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```$/i, "").trim();
|
|
103
|
+
|
|
104
|
+
let parsedResult = {};
|
|
105
|
+
try {
|
|
106
|
+
parsedResult = JSON.parse(content);
|
|
107
|
+
} catch {
|
|
108
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
109
|
+
if (jsonMatch) {
|
|
110
|
+
parsedResult = JSON.parse(jsonMatch[0]);
|
|
111
|
+
} else {
|
|
112
|
+
throw new Error("Failed to parse JSON response from local AI model.");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const candidateSelector = parsedResult.repairedSelector?.trim() || "";
|
|
117
|
+
if (!candidateSelector) {
|
|
118
|
+
return {
|
|
119
|
+
success: false,
|
|
120
|
+
field,
|
|
121
|
+
error: "AI model could not determine a candidate selector.",
|
|
122
|
+
confidence: 0,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Verify the candidate selector locally in the HTML snippet using Cheerio
|
|
127
|
+
const $ = cheerio.load(htmlSnippet);
|
|
128
|
+
let matchEl = null;
|
|
129
|
+
try {
|
|
130
|
+
matchEl = $(candidateSelector);
|
|
131
|
+
} catch {
|
|
132
|
+
// In case model returned an invalid CSS syntax
|
|
133
|
+
matchEl = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const isVerified = matchEl && matchEl.length > 0;
|
|
137
|
+
const verifiedValue = isVerified ? matchEl.first().text().trim() : parsedResult.extractedValue || "";
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
success: isVerified,
|
|
141
|
+
field,
|
|
142
|
+
repairedSelector: candidateSelector,
|
|
143
|
+
extractedValue: verifiedValue,
|
|
144
|
+
confidence: isVerified ? Math.min(1.0, Math.max(0.6, parsedResult.confidence || 0.85)) : 0.3,
|
|
145
|
+
explanation: parsedResult.explanation || "Selector inferred via Edge Worker AI model.",
|
|
146
|
+
modelUsed: model,
|
|
147
|
+
endpointUsed: endpoint,
|
|
148
|
+
};
|
|
149
|
+
} catch (apiErr) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Edge Worker AI repair request failed on '${endpoint}' (${model}): ${apiErr.message}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -4,13 +4,29 @@ import localBrowserManager from "../browser.js";
|
|
|
4
4
|
* Handles PREVIEW_PAGE task: captures full-page screenshot and dimensions on the edge worker.
|
|
5
5
|
*/
|
|
6
6
|
export async function handlePreviewPage(payload) {
|
|
7
|
-
const { url, timeout = 25000 } = payload;
|
|
7
|
+
const { url, timeout = 25000, preScrapeActions, authentication } = payload;
|
|
8
8
|
const browser = await localBrowserManager.getBrowser();
|
|
9
9
|
const page = await browser.newPage();
|
|
10
10
|
|
|
11
11
|
try {
|
|
12
12
|
await page.setViewport({ width: 1366, height: 900, deviceScaleFactor: 1 });
|
|
13
13
|
|
|
14
|
+
// Apply authentication cookies & headers if provided
|
|
15
|
+
if (authentication) {
|
|
16
|
+
if (authentication.type === "COOKIES" && Array.isArray(authentication.cookies)) {
|
|
17
|
+
const parsedUrl = new URL(url);
|
|
18
|
+
await page.setCookie(...authentication.cookies.map(c => ({
|
|
19
|
+
name: c.name,
|
|
20
|
+
value: c.value,
|
|
21
|
+
domain: c.domain || parsedUrl.hostname,
|
|
22
|
+
path: c.path || "/",
|
|
23
|
+
}))).catch(() => {});
|
|
24
|
+
}
|
|
25
|
+
if (authentication.type === "HEADERS" && authentication.headers) {
|
|
26
|
+
await page.setExtraHTTPHeaders(authentication.headers).catch(() => {});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
15
31
|
try {
|
|
16
32
|
await page.waitForNetworkIdle({ idleTime: 500, timeout: 3500 }).catch(() => {});
|
|
@@ -18,6 +34,37 @@ export async function handlePreviewPage(payload) {
|
|
|
18
34
|
await page.evaluate(() => document.fonts?.ready).catch(() => {});
|
|
19
35
|
await new Promise((r) => setTimeout(r, 800));
|
|
20
36
|
|
|
37
|
+
// Execute pre-scrape actions if present
|
|
38
|
+
if (Array.isArray(preScrapeActions) && preScrapeActions.length > 0) {
|
|
39
|
+
for (const action of preScrapeActions) {
|
|
40
|
+
try {
|
|
41
|
+
if (action.actionType === "CLICK" && action.selector) {
|
|
42
|
+
await page.waitForSelector(action.selector, { timeout: 4000 });
|
|
43
|
+
await page.click(action.selector);
|
|
44
|
+
} else if (action.actionType === "TYPE" && action.selector && action.value) {
|
|
45
|
+
await page.waitForSelector(action.selector, { timeout: 4000 });
|
|
46
|
+
await page.type(action.selector, action.value, { delay: 30 });
|
|
47
|
+
} else if (action.actionType === "SCROLL") {
|
|
48
|
+
const scrollPixels = parseInt(action.value, 10) || 500;
|
|
49
|
+
await page.evaluate((px) => window.scrollBy(0, px), scrollPixels);
|
|
50
|
+
} else if (action.actionType === "WAIT_FOR" && action.selector) {
|
|
51
|
+
await page.waitForSelector(action.selector, { timeout: 8000 });
|
|
52
|
+
} else if (action.actionType === "SELECT_OPTION" && action.selector && action.value) {
|
|
53
|
+
await page.waitForSelector(action.selector, { timeout: 4000 });
|
|
54
|
+
await page.select(action.selector, action.value);
|
|
55
|
+
} else if (action.actionType === "PRESS_KEY" && action.value) {
|
|
56
|
+
await page.keyboard.press(action.value);
|
|
57
|
+
}
|
|
58
|
+
if (action.delayMs && action.delayMs > 0) {
|
|
59
|
+
await new Promise((r) => setTimeout(r, Math.min(action.delayMs, 5000)));
|
|
60
|
+
}
|
|
61
|
+
} catch (actErr) {
|
|
62
|
+
console.warn(`[WorkerPreview] Pre-scrape action ${action.actionType} (${action.selector}) skipped: ${actErr.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
66
|
+
}
|
|
67
|
+
|
|
21
68
|
const title = (await page.title()) || new URL(url).hostname;
|
|
22
69
|
const currentUrl = page.url();
|
|
23
70
|
|
|
@@ -64,7 +111,7 @@ export async function handlePreviewPage(payload) {
|
|
|
64
111
|
* Handles INSPECT_ELEMENT task: resolves DOM element coordinates or evaluates candidate selectors on the edge worker.
|
|
65
112
|
*/
|
|
66
113
|
export async function handleInspectElement(payload) {
|
|
67
|
-
const { url, selector, x, y, timeout = 20000 } = payload;
|
|
114
|
+
const { url, selector, strategyType, strategies, fingerprint, x, y, timeout = 20000 } = payload;
|
|
68
115
|
const browser = await localBrowserManager.getBrowser();
|
|
69
116
|
const page = await browser.newPage();
|
|
70
117
|
|
|
@@ -442,12 +489,98 @@ export async function handleInspectElement(payload) {
|
|
|
442
489
|
return elementInfo || { found: false, message: "No element found at specified coordinates." };
|
|
443
490
|
}
|
|
444
491
|
|
|
445
|
-
if (selector) {
|
|
446
|
-
const testResult = await page.evaluate((
|
|
492
|
+
if (selector || (Array.isArray(strategies) && strategies.length > 0) || fingerprint) {
|
|
493
|
+
const testResult = await page.evaluate((target) => {
|
|
447
494
|
try {
|
|
448
|
-
const
|
|
449
|
-
|
|
495
|
+
const countMatches = (sel) => {
|
|
496
|
+
try { return document.querySelectorAll(sel).length; } catch { return 999; }
|
|
497
|
+
};
|
|
450
498
|
|
|
499
|
+
const resolveOne = (selStr, typeHint) => {
|
|
500
|
+
if (!selStr) return null;
|
|
501
|
+
let s = String(selStr).trim();
|
|
502
|
+
if (!s) return null;
|
|
503
|
+
|
|
504
|
+
const isXPath = typeHint === "xpath" ||
|
|
505
|
+
s.startsWith("//") ||
|
|
506
|
+
s.startsWith("xpath=") ||
|
|
507
|
+
s.startsWith("xpath:") ||
|
|
508
|
+
s.startsWith("/html") ||
|
|
509
|
+
s.startsWith("(/");
|
|
510
|
+
|
|
511
|
+
if (s.startsWith("xpath=")) s = s.slice(6).trim();
|
|
512
|
+
if (s.startsWith("xpath:")) s = s.slice(6).trim();
|
|
513
|
+
|
|
514
|
+
if (isXPath) {
|
|
515
|
+
try {
|
|
516
|
+
const snap = document.evaluate(s, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
517
|
+
if (snap.snapshotLength > 0) {
|
|
518
|
+
const node = snap.snapshotItem(0);
|
|
519
|
+
if (node && node.nodeType === 1) {
|
|
520
|
+
return {
|
|
521
|
+
el: node,
|
|
522
|
+
count: snap.snapshotLength,
|
|
523
|
+
strategyType: "xpath",
|
|
524
|
+
matchedSelector: s,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
} catch (_) {}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const matches = document.querySelectorAll(s);
|
|
533
|
+
if (matches.length > 0) {
|
|
534
|
+
return {
|
|
535
|
+
el: matches[0],
|
|
536
|
+
count: matches.length,
|
|
537
|
+
strategyType: "css",
|
|
538
|
+
matchedSelector: s,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
} catch (_) {
|
|
542
|
+
if (!isXPath) {
|
|
543
|
+
try {
|
|
544
|
+
const snap = document.evaluate(s, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
545
|
+
if (snap.snapshotLength > 0) {
|
|
546
|
+
const node = snap.snapshotItem(0);
|
|
547
|
+
if (node && node.nodeType === 1) {
|
|
548
|
+
return {
|
|
549
|
+
el: node,
|
|
550
|
+
count: snap.snapshotLength,
|
|
551
|
+
strategyType: "xpath",
|
|
552
|
+
matchedSelector: s,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
} catch (_) {}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return null;
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const queue = [];
|
|
564
|
+
if (target.selector) queue.push({ value: target.selector, type: target.strategyType });
|
|
565
|
+
if (Array.isArray(target.strategies)) {
|
|
566
|
+
for (const st of target.strategies) {
|
|
567
|
+
if (st && st.value && !queue.some(q => q.value === st.value)) {
|
|
568
|
+
queue.push({ value: st.value, type: st.type });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
let resolved = null;
|
|
574
|
+
for (const item of queue) {
|
|
575
|
+
resolved = resolveOne(item.value, item.type);
|
|
576
|
+
if (resolved) break;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!resolved || !resolved.el) {
|
|
580
|
+
return { found: false, count: 0, message: "Element not found on page." };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const { el, count, strategyType, matchedSelector } = resolved;
|
|
451
584
|
const rect = el.getBoundingClientRect();
|
|
452
585
|
const boundingRect = {
|
|
453
586
|
left: Math.round(rect.left + window.scrollX),
|
|
@@ -456,23 +589,53 @@ export async function handleInspectElement(payload) {
|
|
|
456
589
|
height: Math.round(rect.height),
|
|
457
590
|
};
|
|
458
591
|
|
|
459
|
-
const
|
|
592
|
+
const rawText = el.innerText ? el.innerText.trim() : el.textContent?.trim() || "";
|
|
593
|
+
const attrs = Array.from(el.attributes).reduce((acc, a) => {
|
|
594
|
+
acc[a.name] = a.value;
|
|
595
|
+
return acc;
|
|
596
|
+
}, {});
|
|
597
|
+
|
|
598
|
+
const hierarchy = [];
|
|
599
|
+
let currH = el;
|
|
600
|
+
while (currH && currH !== document.body && hierarchy.length < 6) {
|
|
601
|
+
let desc = currH.tagName.toLowerCase();
|
|
602
|
+
if (currH.id) desc += `#${currH.id}`;
|
|
603
|
+
else if (currH.className && typeof currH.className === "string") {
|
|
604
|
+
const cls = currH.className.split(/\s+/).filter(c => /^[a-zA-Z_-][\w-]*$/.test(c)).slice(0, 2);
|
|
605
|
+
if (cls.length) desc += `.${cls.join(".")}`;
|
|
606
|
+
}
|
|
607
|
+
hierarchy.unshift(desc);
|
|
608
|
+
currH = currH.parentElement;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const candidates = [
|
|
612
|
+
{ type: strategyType, value: matchedSelector, attribute: "text", matchCount: count }
|
|
613
|
+
];
|
|
460
614
|
|
|
461
615
|
return {
|
|
462
616
|
found: true,
|
|
463
617
|
count,
|
|
618
|
+
strategyType,
|
|
619
|
+
matchedSelector,
|
|
464
620
|
tagName: el.tagName.toLowerCase(),
|
|
465
|
-
text:
|
|
466
|
-
attributes:
|
|
467
|
-
acc[a.name] = a.value;
|
|
468
|
-
return acc;
|
|
469
|
-
}, {}),
|
|
621
|
+
text: rawText,
|
|
622
|
+
attributes: attrs,
|
|
470
623
|
boundingRect,
|
|
624
|
+
hierarchy,
|
|
625
|
+
candidates,
|
|
626
|
+
fingerprint: {
|
|
627
|
+
tagName: el.tagName.toLowerCase(),
|
|
628
|
+
domHierarchy: hierarchy,
|
|
629
|
+
attributes: attrs,
|
|
630
|
+
textAnchors: [rawText.slice(0, 80)].filter(Boolean),
|
|
631
|
+
dataPattern: "",
|
|
632
|
+
sampleValues: [rawText.slice(0, 100)].filter(Boolean),
|
|
633
|
+
},
|
|
471
634
|
};
|
|
472
635
|
} catch (e) {
|
|
473
636
|
return { found: false, error: e.message };
|
|
474
637
|
}
|
|
475
|
-
}, selector);
|
|
638
|
+
}, { selector, strategyType, strategies, fingerprint });
|
|
476
639
|
|
|
477
640
|
return testResult;
|
|
478
641
|
}
|
|
@@ -349,15 +349,57 @@ export async function handleChromiumScrape(payload) {
|
|
|
349
349
|
deviceScaleFactor: 1,
|
|
350
350
|
});
|
|
351
351
|
|
|
352
|
-
|
|
353
|
-
|
|
352
|
+
// Merge auth headers & cookies
|
|
353
|
+
const effectiveHeaders = {
|
|
354
|
+
...(headers || {}),
|
|
355
|
+
...(payload.authentication?.headers || {}),
|
|
356
|
+
};
|
|
357
|
+
const effectiveCookies = [
|
|
358
|
+
...(Array.isArray(cookies) ? cookies : []),
|
|
359
|
+
...(Array.isArray(payload.authentication?.cookies) ? payload.authentication.cookies : []),
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
if (Object.keys(effectiveHeaders).length > 0) {
|
|
363
|
+
await page.setExtraHTTPHeaders(effectiveHeaders);
|
|
354
364
|
}
|
|
355
365
|
|
|
356
|
-
if (
|
|
357
|
-
await page.setCookie(...
|
|
366
|
+
if (effectiveCookies.length > 0) {
|
|
367
|
+
await page.setCookie(...effectiveCookies);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Automated Login Flow if configured
|
|
371
|
+
const auth = payload.authentication || {};
|
|
372
|
+
if (auth.type === "LOGIN_FLOW" && auth.loginFlow?.loginUrl) {
|
|
373
|
+
try {
|
|
374
|
+
const lf = auth.loginFlow;
|
|
375
|
+
await page.goto(lf.loginUrl, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
376
|
+
if (lf.usernameSelector && lf.usernameValue) {
|
|
377
|
+
await page.waitForSelector(lf.usernameSelector, { timeout: 6000 });
|
|
378
|
+
await page.click(lf.usernameSelector);
|
|
379
|
+
await page.type(lf.usernameSelector, lf.usernameValue, { delay: 30 });
|
|
380
|
+
}
|
|
381
|
+
if (lf.passwordSelector && lf.passwordValue) {
|
|
382
|
+
await page.waitForSelector(lf.passwordSelector, { timeout: 6000 });
|
|
383
|
+
await page.click(lf.passwordSelector);
|
|
384
|
+
await page.type(lf.passwordSelector, lf.passwordValue, { delay: 30 });
|
|
385
|
+
}
|
|
386
|
+
if (lf.submitSelector) {
|
|
387
|
+
await page.waitForSelector(lf.submitSelector, { timeout: 6000 });
|
|
388
|
+
await Promise.all([
|
|
389
|
+
page.click(lf.submitSelector),
|
|
390
|
+
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {}),
|
|
391
|
+
]);
|
|
392
|
+
}
|
|
393
|
+
if (lf.successIndicatorSelector) {
|
|
394
|
+
await page.waitForSelector(lf.successIndicatorSelector, { timeout: 8000 }).catch(() => {});
|
|
395
|
+
}
|
|
396
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
397
|
+
} catch (loginErr) {
|
|
398
|
+
console.warn(`[ScrapeHandler] Automated login flow error: ${loginErr.message}. Continuing with scrape...`);
|
|
399
|
+
}
|
|
358
400
|
}
|
|
359
401
|
|
|
360
|
-
// Navigate
|
|
402
|
+
// Navigate to target URL
|
|
361
403
|
const response = await page.goto(url, {
|
|
362
404
|
waitUntil: navWaitUntil,
|
|
363
405
|
timeout: navTimeout,
|
|
@@ -392,21 +434,53 @@ export async function handleChromiumScrape(payload) {
|
|
|
392
434
|
await new Promise((r) => setTimeout(r, Math.min(waitForTimeout, 10000)));
|
|
393
435
|
}
|
|
394
436
|
|
|
395
|
-
// Execute sequential interactive actions
|
|
396
|
-
|
|
397
|
-
|
|
437
|
+
// Execute sequential interactive actions (preScrapeActions or actions)
|
|
438
|
+
const combinedActions = [
|
|
439
|
+
...(Array.isArray(payload.preScrapeActions) ? payload.preScrapeActions : []),
|
|
440
|
+
...(Array.isArray(actions) ? actions : []),
|
|
441
|
+
];
|
|
442
|
+
|
|
443
|
+
if (combinedActions.length > 0) {
|
|
444
|
+
for (let aIdx = 0; aIdx < combinedActions.length; aIdx++) {
|
|
445
|
+
const action = combinedActions[aIdx];
|
|
446
|
+
const actType = (action.type || "").toUpperCase();
|
|
447
|
+
const opt = action.optional !== false;
|
|
398
448
|
try {
|
|
399
|
-
if (action.type === "click" && action.selector) {
|
|
449
|
+
if ((actType === "CLICK" || action.type === "click") && action.selector) {
|
|
450
|
+
await page.waitForSelector(action.selector, { timeout: action.timeoutMs || 4000 });
|
|
400
451
|
await page.click(action.selector);
|
|
401
|
-
} else if (action.type === "type" && action.selector
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
await page.
|
|
405
|
-
|
|
406
|
-
|
|
452
|
+
} else if ((actType === "TYPE" || action.type === "type") && action.selector) {
|
|
453
|
+
const txt = action.value !== undefined ? action.value : action.text || "";
|
|
454
|
+
await page.waitForSelector(action.selector, { timeout: action.timeoutMs || 4000 });
|
|
455
|
+
await page.click(action.selector);
|
|
456
|
+
await page.type(action.selector, txt, { delay: 30 });
|
|
457
|
+
} else if (actType === "SELECT_OPTION" && action.selector && action.value) {
|
|
458
|
+
await page.waitForSelector(action.selector, { timeout: action.timeoutMs || 4000 });
|
|
459
|
+
await page.select(action.selector, action.value);
|
|
460
|
+
} else if (actType === "PRESS_KEY") {
|
|
461
|
+
await page.keyboard.press(action.key || "Enter");
|
|
462
|
+
} else if (actType === "SCROLL" || action.type === "scroll") {
|
|
463
|
+
const dist = action.scrollDistance || action.distance || 500;
|
|
464
|
+
await page.evaluate((y) => window.scrollBy(0, y), dist);
|
|
465
|
+
} else if (actType === "WAIT_FOR" && action.selector) {
|
|
466
|
+
await page.waitForSelector(action.selector, {
|
|
467
|
+
visible: true,
|
|
468
|
+
timeout: action.timeoutMs || 6000,
|
|
469
|
+
});
|
|
470
|
+
} else if (actType === "DELAY" || action.type === "wait") {
|
|
471
|
+
const dur = action.delayMs || action.duration || 1000;
|
|
472
|
+
await new Promise((r) => setTimeout(r, dur));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (action.delayMs > 0) {
|
|
476
|
+
await new Promise((r) => setTimeout(r, action.delayMs));
|
|
407
477
|
}
|
|
408
478
|
} catch (actionErr) {
|
|
409
|
-
|
|
479
|
+
if (opt) {
|
|
480
|
+
console.warn(`[ScrapeHandler] Optional action #${aIdx + 1} (${action.type} '${action.selector || ""}') skipped: ${actionErr.message}`);
|
|
481
|
+
} else {
|
|
482
|
+
throw actionErr;
|
|
483
|
+
}
|
|
410
484
|
}
|
|
411
485
|
}
|
|
412
486
|
}
|
|
@@ -685,15 +759,27 @@ export async function handleHttpScrape(payload) {
|
|
|
685
759
|
includeHtml = false,
|
|
686
760
|
} = payload;
|
|
687
761
|
|
|
762
|
+
const auth = payload.authentication || {};
|
|
763
|
+
const effectiveHeaders = {
|
|
764
|
+
"User-Agent":
|
|
765
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
766
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
767
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
768
|
+
...(headers || {}),
|
|
769
|
+
...(auth.headers || {}),
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
const effectiveCookies = [
|
|
773
|
+
...(Array.isArray(payload.cookies) ? payload.cookies : []),
|
|
774
|
+
...(Array.isArray(auth.cookies) ? auth.cookies : []),
|
|
775
|
+
];
|
|
776
|
+
if (effectiveCookies.length > 0 && !effectiveHeaders.Cookie) {
|
|
777
|
+
effectiveHeaders.Cookie = effectiveCookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
778
|
+
}
|
|
779
|
+
|
|
688
780
|
const response = await axios.get(url, {
|
|
689
781
|
timeout,
|
|
690
|
-
headers:
|
|
691
|
-
"User-Agent":
|
|
692
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
693
|
-
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
694
|
-
"Accept-Language": "en-US,en;q=0.9",
|
|
695
|
-
...headers,
|
|
696
|
-
},
|
|
782
|
+
headers: effectiveHeaders,
|
|
697
783
|
});
|
|
698
784
|
|
|
699
785
|
let pageHtml = typeof response.data === "string" ? response.data : Buffer.isBuffer(response.data) ? response.data.toString("utf-8") : "";
|