app-manager-edge-worker 1.0.7 → 1.0.8
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/bin/worker.js +16 -16
- package/package.json +46 -46
- package/src/banner.js +80 -80
- package/src/client.js +300 -297
- package/src/config.js +38 -1
- package/src/handlers/aiRepairHandler.js +154 -0
- package/src/handlers/mailHandler.js +74 -74
- package/src/handlers/previewHandler.js +557 -484
- package/src/handlers/scrapeHandler.js +887 -801
- package/src/handlers/transformUtils.js +340 -340
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.8";
|
|
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
|
+
}
|
|
@@ -1,74 +1,74 @@
|
|
|
1
|
-
import nodemailer from "nodemailer";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Handles incoming SEND_EMAIL job from the cluster server.
|
|
5
|
-
*
|
|
6
|
-
* Security & Reliability Guarantees:
|
|
7
|
-
* 1. Zero credential retention: Credentials are received in-memory and destroyed after sending.
|
|
8
|
-
* 2. Fail-fast timeouts: Prevents socket hanging with strict 10s connection & socket timeouts.
|
|
9
|
-
* 3. Structured result payload: Returns delivery metadata (messageId, accepted, response).
|
|
10
|
-
*
|
|
11
|
-
* @param {Object} payload
|
|
12
|
-
* @param {Object} payload.mailOptions - { to, subject, html, text, fromName, fromEmail }
|
|
13
|
-
* @param {Object} payload.smtpConfig - { host, port, secure, auth: { user, pass } }
|
|
14
|
-
* @returns {Promise<Object>}
|
|
15
|
-
*/
|
|
16
|
-
export async function handleSendEmail(payload = {}) {
|
|
17
|
-
const { mailOptions, smtpConfig } = payload;
|
|
18
|
-
|
|
19
|
-
if (!mailOptions || !mailOptions.to || !mailOptions.subject) {
|
|
20
|
-
throw new Error("Invalid mailOptions: 'to' and 'subject' are required.");
|
|
21
|
-
}
|
|
22
|
-
if (!smtpConfig || !smtpConfig.auth?.user || !smtpConfig.auth?.pass) {
|
|
23
|
-
throw new Error("Invalid smtpConfig: credentials missing.");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const host = smtpConfig.host || "smtp.titan.email";
|
|
27
|
-
const port = Number(smtpConfig.port) || 465;
|
|
28
|
-
const secure = smtpConfig.secure !== undefined ? Boolean(smtpConfig.secure) : port === 465;
|
|
29
|
-
|
|
30
|
-
// Ephemeral Nodemailer transporter created strictly for this in-memory dispatch
|
|
31
|
-
const transporter = nodemailer.createTransport({
|
|
32
|
-
host,
|
|
33
|
-
port,
|
|
34
|
-
secure,
|
|
35
|
-
auth: {
|
|
36
|
-
user: smtpConfig.auth.user,
|
|
37
|
-
pass: smtpConfig.auth.pass,
|
|
38
|
-
},
|
|
39
|
-
connectionTimeout: 10000,
|
|
40
|
-
greetingTimeout: 10000,
|
|
41
|
-
socketTimeout: 15000,
|
|
42
|
-
tls: {
|
|
43
|
-
rejectUnauthorized: false, // Prevents certificate mismatches on custom domain mail
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
const fromName = mailOptions.fromName || "Apps Manager";
|
|
49
|
-
const fromAddress = mailOptions.fromEmail || smtpConfig.auth.user;
|
|
50
|
-
|
|
51
|
-
const info = await transporter.sendMail({
|
|
52
|
-
from: `"${fromName}" <${fromAddress}>`,
|
|
53
|
-
to: mailOptions.to,
|
|
54
|
-
subject: mailOptions.subject,
|
|
55
|
-
text: mailOptions.text,
|
|
56
|
-
html: mailOptions.html,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
success: true,
|
|
61
|
-
messageId: info.messageId,
|
|
62
|
-
accepted: info.accepted,
|
|
63
|
-
rejected: info.rejected,
|
|
64
|
-
response: info.response,
|
|
65
|
-
};
|
|
66
|
-
} finally {
|
|
67
|
-
// Explicitly close transporter socket to prevent lingering connections
|
|
68
|
-
try {
|
|
69
|
-
transporter.close();
|
|
70
|
-
} catch (_) {
|
|
71
|
-
// Best effort cleanup
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
1
|
+
import nodemailer from "nodemailer";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Handles incoming SEND_EMAIL job from the cluster server.
|
|
5
|
+
*
|
|
6
|
+
* Security & Reliability Guarantees:
|
|
7
|
+
* 1. Zero credential retention: Credentials are received in-memory and destroyed after sending.
|
|
8
|
+
* 2. Fail-fast timeouts: Prevents socket hanging with strict 10s connection & socket timeouts.
|
|
9
|
+
* 3. Structured result payload: Returns delivery metadata (messageId, accepted, response).
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} payload
|
|
12
|
+
* @param {Object} payload.mailOptions - { to, subject, html, text, fromName, fromEmail }
|
|
13
|
+
* @param {Object} payload.smtpConfig - { host, port, secure, auth: { user, pass } }
|
|
14
|
+
* @returns {Promise<Object>}
|
|
15
|
+
*/
|
|
16
|
+
export async function handleSendEmail(payload = {}) {
|
|
17
|
+
const { mailOptions, smtpConfig } = payload;
|
|
18
|
+
|
|
19
|
+
if (!mailOptions || !mailOptions.to || !mailOptions.subject) {
|
|
20
|
+
throw new Error("Invalid mailOptions: 'to' and 'subject' are required.");
|
|
21
|
+
}
|
|
22
|
+
if (!smtpConfig || !smtpConfig.auth?.user || !smtpConfig.auth?.pass) {
|
|
23
|
+
throw new Error("Invalid smtpConfig: credentials missing.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const host = smtpConfig.host || "smtp.titan.email";
|
|
27
|
+
const port = Number(smtpConfig.port) || 465;
|
|
28
|
+
const secure = smtpConfig.secure !== undefined ? Boolean(smtpConfig.secure) : port === 465;
|
|
29
|
+
|
|
30
|
+
// Ephemeral Nodemailer transporter created strictly for this in-memory dispatch
|
|
31
|
+
const transporter = nodemailer.createTransport({
|
|
32
|
+
host,
|
|
33
|
+
port,
|
|
34
|
+
secure,
|
|
35
|
+
auth: {
|
|
36
|
+
user: smtpConfig.auth.user,
|
|
37
|
+
pass: smtpConfig.auth.pass,
|
|
38
|
+
},
|
|
39
|
+
connectionTimeout: 10000,
|
|
40
|
+
greetingTimeout: 10000,
|
|
41
|
+
socketTimeout: 15000,
|
|
42
|
+
tls: {
|
|
43
|
+
rejectUnauthorized: false, // Prevents certificate mismatches on custom domain mail
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const fromName = mailOptions.fromName || "Apps Manager";
|
|
49
|
+
const fromAddress = mailOptions.fromEmail || smtpConfig.auth.user;
|
|
50
|
+
|
|
51
|
+
const info = await transporter.sendMail({
|
|
52
|
+
from: `"${fromName}" <${fromAddress}>`,
|
|
53
|
+
to: mailOptions.to,
|
|
54
|
+
subject: mailOptions.subject,
|
|
55
|
+
text: mailOptions.text,
|
|
56
|
+
html: mailOptions.html,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
success: true,
|
|
61
|
+
messageId: info.messageId,
|
|
62
|
+
accepted: info.accepted,
|
|
63
|
+
rejected: info.rejected,
|
|
64
|
+
response: info.response,
|
|
65
|
+
};
|
|
66
|
+
} finally {
|
|
67
|
+
// Explicitly close transporter socket to prevent lingering connections
|
|
68
|
+
try {
|
|
69
|
+
transporter.close();
|
|
70
|
+
} catch (_) {
|
|
71
|
+
// Best effort cleanup
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|