app-manager-edge-worker 1.1.5 → 1.2.1
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 +19 -0
- package/package.json +1 -1
- package/src/banner.js +1 -1
- package/src/client.js +15 -1
- package/src/handlers/interactiveHandler.js +466 -0
- package/src/handlers/previewHandler.js +336 -13
package/README.md
CHANGED
|
@@ -203,6 +203,25 @@ Log levels: `[INFO]` · `[SUCCESS]` · `[WARN]` · `[ERROR]` · `[TASK]` (job di
|
|
|
203
203
|
|
|
204
204
|
## 📝 Changelog
|
|
205
205
|
|
|
206
|
+
### v1.2.1
|
|
207
|
+
- **Smart Form Field & Leaf Control Element Resolution**:
|
|
208
|
+
- Replaced single-surface `elementFromPoint` with full-stack `elementsFromPoint` coordinate sampling and multi-offset probing.
|
|
209
|
+
- Implemented intelligent priority scoring favoring form inputs (`<input>`, `<textarea>`, `<select>`), captcha images (`<img>`, `<canvas>`), and interactive action buttons over generic wrapper `<div>` tags.
|
|
210
|
+
- Added severe scoring penalties (-50,000) for full-screen dimmer overlays, modal backdrops (`div.dimmer-holder`, `div.ui.dimmer`, `div.backdrop`), and transparent blocker masks.
|
|
211
|
+
- Added automatic container drill-down to pick child form inputs when clicking inside input groups or labels.
|
|
212
|
+
- Extended candidate selector generation to include form `name`, `placeholder`, `type`, and image `alt`/`captcha` attributes.
|
|
213
|
+
- Added stacked `alternates` metadata return for rapid element switching in the Visual Element Inspector.
|
|
214
|
+
|
|
215
|
+
### v1.2.0
|
|
216
|
+
- **Human-In-The-Loop (HITL) Interactive Scraping Engine**:
|
|
217
|
+
- Added stateful interactive session handlers: `HITL_SESSION_INIT`, `HITL_SESSION_REFRESH`, `HITL_SESSION_SOLVE`, and `HITL_SESSION_ABORT`.
|
|
218
|
+
- Automated form field pre-filling (GSTIN, PAN, registration numbers) with clear-first input semantics.
|
|
219
|
+
- Direct DOM element screenshotting for precise visual captcha capture (`imageSelector`).
|
|
220
|
+
- MD5 visual pixel hash comparison and DOM `src` watcher for guaranteed captcha refresh verification.
|
|
221
|
+
- Post-submit dual-outcome race between success selectors and in-page error messages (handling HTTP 200 responses with error banners).
|
|
222
|
+
- Fast-retry loop: on invalid captcha entry, preserves active browser page, resets TTL timer, and captures newly regenerated captcha without restarting form from scratch.
|
|
223
|
+
- Resource protection with active session maps and configurable TTL auto-close timers to prevent worker memory leaks.
|
|
224
|
+
|
|
206
225
|
### v1.1.5
|
|
207
226
|
- **Real-Time WhatsApp Task & Document Streaming Telemetry**:
|
|
208
227
|
- Added timestamped terminal logging for incoming WhatsApp text and document task receipts.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "app-manager-edge-worker",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
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
|
@@ -8,11 +8,17 @@ import { handleChromiumScrape, handleHttpScrape } from "./handlers/scrapeHandler
|
|
|
8
8
|
import { handlePreviewPage, handleInspectElement } from "./handlers/previewHandler.js";
|
|
9
9
|
import { handleSendEmail } from "./handlers/mailHandler.js";
|
|
10
10
|
import { handleAiSmartRepair } from "./handlers/aiRepairHandler.js";
|
|
11
|
+
import {
|
|
12
|
+
handleInteractiveSessionInit,
|
|
13
|
+
handleInteractiveSessionRefresh,
|
|
14
|
+
handleInteractiveSessionSolve,
|
|
15
|
+
handleInteractiveSessionAbort,
|
|
16
|
+
} from "./handlers/interactiveHandler.js";
|
|
11
17
|
import fs from "fs";
|
|
12
18
|
import { getWhatsAppHandler } from "./handlers/whatsappHandler.js";
|
|
13
19
|
import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
|
|
14
20
|
|
|
15
|
-
let workerPkgVersion = "1.1
|
|
21
|
+
let workerPkgVersion = "1.2.1";
|
|
16
22
|
try {
|
|
17
23
|
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
18
24
|
if (pkg?.version) workerPkgVersion = pkg.version;
|
|
@@ -249,6 +255,14 @@ export async function startWorkerClient(config) {
|
|
|
249
255
|
resultData = await waHandler.sendDocument(payload);
|
|
250
256
|
} else if (jobType === "WHATSAPP_RECONCILE") {
|
|
251
257
|
resultData = await waHandler.reconcileDelivery(payload.recipientPhone, payload.idempotencyKey);
|
|
258
|
+
} else if (jobType === "HITL_SESSION_INIT") {
|
|
259
|
+
resultData = await handleInteractiveSessionInit(payload);
|
|
260
|
+
} else if (jobType === "HITL_SESSION_REFRESH") {
|
|
261
|
+
resultData = await handleInteractiveSessionRefresh(payload);
|
|
262
|
+
} else if (jobType === "HITL_SESSION_SOLVE") {
|
|
263
|
+
resultData = await handleInteractiveSessionSolve(payload);
|
|
264
|
+
} else if (jobType === "HITL_SESSION_ABORT") {
|
|
265
|
+
resultData = await handleInteractiveSessionAbort(payload);
|
|
252
266
|
} else {
|
|
253
267
|
throw new Error(`Unsupported job type: ${jobType}`);
|
|
254
268
|
}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import localBrowserManager from "../browser.js";
|
|
3
|
+
import { runTransformPipeline, castValueToType } from "./transformUtils.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-memory map of active interactive sessions on this Edge Worker.
|
|
7
|
+
* sessionId -> { page, timeoutTimer, createdAt, url, captchaConfig, fieldMappings, collections }
|
|
8
|
+
*/
|
|
9
|
+
const activeSessions = new Map();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extracts a single field from a Puppeteer page given a strategy.
|
|
13
|
+
*/
|
|
14
|
+
async function extractBrowserField(page, strategy) {
|
|
15
|
+
if (!strategy || !strategy.value) return null;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const type = strategy.type || "css";
|
|
19
|
+
const attr = strategy.attribute || "text";
|
|
20
|
+
|
|
21
|
+
if (type === "css") {
|
|
22
|
+
const el = await page.$(strategy.value);
|
|
23
|
+
if (!el) return null;
|
|
24
|
+
|
|
25
|
+
if (attr === "text") {
|
|
26
|
+
const text = await page.evaluate((element) => element.textContent, el);
|
|
27
|
+
return text ? text.trim() : null;
|
|
28
|
+
} else if (attr === "html") {
|
|
29
|
+
return await page.evaluate((element) => element.innerHTML, el);
|
|
30
|
+
} else {
|
|
31
|
+
const val = await page.evaluate((element, a) => element.getAttribute(a), el, attr);
|
|
32
|
+
return val !== null && val !== undefined ? String(val).trim() : null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (type === "xpath") {
|
|
37
|
+
const val = await page.evaluate(
|
|
38
|
+
(xpathStr, attrName) => {
|
|
39
|
+
try {
|
|
40
|
+
const result = document.evaluate(
|
|
41
|
+
xpathStr,
|
|
42
|
+
document,
|
|
43
|
+
null,
|
|
44
|
+
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
|
45
|
+
null
|
|
46
|
+
);
|
|
47
|
+
const node = result.singleNodeValue;
|
|
48
|
+
if (!node) return null;
|
|
49
|
+
if (attrName === "text") {
|
|
50
|
+
return node.textContent ? node.textContent.trim() : null;
|
|
51
|
+
}
|
|
52
|
+
return node.getAttribute ? node.getAttribute(attrName) : null;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
strategy.value,
|
|
58
|
+
attr
|
|
59
|
+
);
|
|
60
|
+
return val;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
} catch (_) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Closes and purges an interactive session safely.
|
|
71
|
+
*/
|
|
72
|
+
async function purgeSession(sessionId) {
|
|
73
|
+
const session = activeSessions.get(sessionId);
|
|
74
|
+
if (!session) return;
|
|
75
|
+
|
|
76
|
+
if (session.timeoutTimer) {
|
|
77
|
+
clearTimeout(session.timeoutTimer);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
if (session.page && !session.page.isClosed()) {
|
|
82
|
+
await session.page.close().catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
} catch (_) {}
|
|
85
|
+
|
|
86
|
+
activeSessions.delete(sessionId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 1. Handles HITL_SESSION_INIT:
|
|
91
|
+
* Launches page, pre-fills initial form inputs, captures captcha screenshot,
|
|
92
|
+
* and keeps the page alive with an auto-close TTL timer.
|
|
93
|
+
*/
|
|
94
|
+
export async function handleInteractiveSessionInit(payload) {
|
|
95
|
+
const {
|
|
96
|
+
sessionId,
|
|
97
|
+
url,
|
|
98
|
+
formInputs = [],
|
|
99
|
+
captchaConfig = {},
|
|
100
|
+
preScrapeActions = [],
|
|
101
|
+
authentication = null,
|
|
102
|
+
timeoutMs = 30000,
|
|
103
|
+
fieldMappings = [],
|
|
104
|
+
collections = [],
|
|
105
|
+
} = payload;
|
|
106
|
+
|
|
107
|
+
if (!sessionId || !url) {
|
|
108
|
+
throw new Error("sessionId and url are required for HITL_SESSION_INIT");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Clean up any existing session with this ID
|
|
112
|
+
if (activeSessions.has(sessionId)) {
|
|
113
|
+
await purgeSession(sessionId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const browser = await localBrowserManager.getBrowser();
|
|
117
|
+
const page = await browser.newPage();
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await page.setViewport({ width: 1366, height: 900, deviceScaleFactor: 1 });
|
|
121
|
+
|
|
122
|
+
// Apply authentication headers or cookies if provided
|
|
123
|
+
if (authentication) {
|
|
124
|
+
if (authentication.type === "COOKIES" && Array.isArray(authentication.cookies)) {
|
|
125
|
+
const parsedUrl = new URL(url);
|
|
126
|
+
await page
|
|
127
|
+
.setCookie(
|
|
128
|
+
...authentication.cookies.map((c) => ({
|
|
129
|
+
name: c.name,
|
|
130
|
+
value: c.value,
|
|
131
|
+
domain: c.domain || parsedUrl.hostname,
|
|
132
|
+
path: c.path || "/",
|
|
133
|
+
}))
|
|
134
|
+
)
|
|
135
|
+
.catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
if (authentication.type === "HEADERS" && authentication.headers) {
|
|
138
|
+
await page.setExtraHTTPHeaders(authentication.headers).catch(() => {});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Navigate to target portal
|
|
143
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
144
|
+
try {
|
|
145
|
+
await page.waitForNetworkIdle({ idleTime: 500, timeout: 3500 }).catch(() => {});
|
|
146
|
+
} catch (_) {}
|
|
147
|
+
|
|
148
|
+
// Execute pre-scrape actions if present
|
|
149
|
+
if (Array.isArray(preScrapeActions) && preScrapeActions.length > 0) {
|
|
150
|
+
for (const action of preScrapeActions) {
|
|
151
|
+
try {
|
|
152
|
+
const actType = (action.type || action.actionType || "").toUpperCase();
|
|
153
|
+
if (actType === "CLICK" && action.selector) {
|
|
154
|
+
await page.waitForSelector(action.selector, { timeout: action.timeoutMs || 4000 });
|
|
155
|
+
await page.click(action.selector);
|
|
156
|
+
} else if (actType === "TYPE" && action.selector) {
|
|
157
|
+
await page.waitForSelector(action.selector, { timeout: action.timeoutMs || 4000 });
|
|
158
|
+
await page.type(action.selector, action.value || "", { delay: 30 });
|
|
159
|
+
} else if (actType === "WAIT_FOR" && action.selector) {
|
|
160
|
+
await page.waitForSelector(action.selector, { timeout: action.timeoutMs || 8000 });
|
|
161
|
+
}
|
|
162
|
+
if (action.delayMs > 0) {
|
|
163
|
+
await new Promise((r) => setTimeout(r, Math.min(action.delayMs, 4000)));
|
|
164
|
+
}
|
|
165
|
+
} catch (actErr) {
|
|
166
|
+
if (action.optional === false) throw actErr;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Fill Form Inputs (e.g. GSTIN, PAN, registration number)
|
|
172
|
+
if (Array.isArray(formInputs) && formInputs.length > 0) {
|
|
173
|
+
for (const input of formInputs) {
|
|
174
|
+
if (!input.selector || input.value === undefined || input.value === null) continue;
|
|
175
|
+
await page.waitForSelector(input.selector, { timeout: 6000 });
|
|
176
|
+
|
|
177
|
+
if (input.clearFirst !== false) {
|
|
178
|
+
await page.click(input.selector, { clickCount: 3 }).catch(() => {});
|
|
179
|
+
await page.keyboard.press("Backspace").catch(() => {});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (input.actionType === "SELECT") {
|
|
183
|
+
await page.select(input.selector, String(input.value));
|
|
184
|
+
} else {
|
|
185
|
+
await page.type(input.selector, String(input.value), { delay: input.delayMs || 30 });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Locate and Screenshot Captcha DOM Element
|
|
193
|
+
const imageSelector = captchaConfig.imageSelector || "img[src*='captcha'], #imgCaptcha, #captchaImg";
|
|
194
|
+
await page.waitForSelector(imageSelector, { timeout: 8000 });
|
|
195
|
+
|
|
196
|
+
const captchaElement = await page.$(imageSelector);
|
|
197
|
+
if (!captchaElement) {
|
|
198
|
+
throw new Error(`Captcha element not found with selector: ${imageSelector}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Capture screenshot of the exact visual captcha DOM node
|
|
202
|
+
const screenshotBuffer = await captchaElement.screenshot({
|
|
203
|
+
encoding: "binary",
|
|
204
|
+
type: "png",
|
|
205
|
+
});
|
|
206
|
+
const imageBase64 = Buffer.from(screenshotBuffer).toString("base64");
|
|
207
|
+
|
|
208
|
+
const sessionTtlSeconds = captchaConfig.sessionTimeoutSeconds || 90;
|
|
209
|
+
|
|
210
|
+
// Set auto-purge timer to prevent resource leaks
|
|
211
|
+
const timeoutTimer = setTimeout(() => {
|
|
212
|
+
purgeSession(sessionId).catch(() => {});
|
|
213
|
+
}, sessionTtlSeconds * 1000);
|
|
214
|
+
|
|
215
|
+
// Store active session in memory
|
|
216
|
+
activeSessions.set(sessionId, {
|
|
217
|
+
page,
|
|
218
|
+
timeoutTimer,
|
|
219
|
+
createdAt: Date.now(),
|
|
220
|
+
url,
|
|
221
|
+
captchaConfig,
|
|
222
|
+
fieldMappings,
|
|
223
|
+
collections,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
sessionId,
|
|
228
|
+
imageBase64: `data:image/png;base64,${imageBase64}`,
|
|
229
|
+
expiresInSeconds: sessionTtlSeconds,
|
|
230
|
+
};
|
|
231
|
+
} catch (err) {
|
|
232
|
+
await page.close().catch(() => {});
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 2. Handles HITL_SESSION_REFRESH:
|
|
239
|
+
* Clicks the refresh button and verifies the visual MD5 hash of the captcha changed.
|
|
240
|
+
*/
|
|
241
|
+
export async function handleInteractiveSessionRefresh(payload) {
|
|
242
|
+
const { sessionId, captchaConfig = {} } = payload;
|
|
243
|
+
const session = activeSessions.get(sessionId);
|
|
244
|
+
|
|
245
|
+
if (!session || !session.page || session.page.isClosed()) {
|
|
246
|
+
throw new Error("Interactive session not found or already expired");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const { page } = session;
|
|
250
|
+
const cfg = { ...session.captchaConfig, ...captchaConfig };
|
|
251
|
+
const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
|
|
252
|
+
const refreshSelector = cfg.refreshSelector;
|
|
253
|
+
|
|
254
|
+
if (!refreshSelector) {
|
|
255
|
+
throw new Error("refreshSelector is not configured for this captcha");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 1. Snapshot current image hash and src attribute
|
|
259
|
+
const oldEl = await page.$(imageSelector);
|
|
260
|
+
if (!oldEl) {
|
|
261
|
+
throw new Error(`Captcha element not found with selector: ${imageSelector}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const oldBuffer = await oldEl.screenshot({ encoding: "binary" });
|
|
265
|
+
const oldHash = crypto.createHash("md5").update(oldBuffer).digest("hex");
|
|
266
|
+
|
|
267
|
+
const oldSrc = await page.evaluate((sel) => {
|
|
268
|
+
const el = document.querySelector(sel);
|
|
269
|
+
return el ? el.getAttribute("src") : null;
|
|
270
|
+
}, imageSelector);
|
|
271
|
+
|
|
272
|
+
// 2. Click the reload button
|
|
273
|
+
await page.waitForSelector(refreshSelector, { timeout: 3000 });
|
|
274
|
+
await page.click(refreshSelector);
|
|
275
|
+
|
|
276
|
+
// 3. Wait for src attribute change first
|
|
277
|
+
await page
|
|
278
|
+
.waitForFunction(
|
|
279
|
+
(sel, prevSrc) => {
|
|
280
|
+
const el = document.querySelector(sel);
|
|
281
|
+
return el && el.getAttribute("src") !== prevSrc;
|
|
282
|
+
},
|
|
283
|
+
{ timeout: 3500 },
|
|
284
|
+
imageSelector,
|
|
285
|
+
oldSrc
|
|
286
|
+
)
|
|
287
|
+
.catch(() => {});
|
|
288
|
+
|
|
289
|
+
// 4. Verify visual pixel change via MD5 hash check
|
|
290
|
+
let newBuffer = null;
|
|
291
|
+
let retries = 0;
|
|
292
|
+
|
|
293
|
+
while (retries < 6) {
|
|
294
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
295
|
+
const newEl = await page.$(imageSelector);
|
|
296
|
+
if (newEl) {
|
|
297
|
+
newBuffer = await newEl.screenshot({ encoding: "binary" });
|
|
298
|
+
const newHash = crypto.createHash("md5").update(newBuffer).digest("hex");
|
|
299
|
+
if (newHash !== oldHash) {
|
|
300
|
+
break; // Verified fresh captcha
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
retries++;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!newBuffer) {
|
|
307
|
+
newBuffer = await oldEl.screenshot({ encoding: "binary" });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const imageBase64 = Buffer.from(newBuffer).toString("base64");
|
|
311
|
+
return {
|
|
312
|
+
sessionId,
|
|
313
|
+
imageBase64: `data:image/png;base64,${imageBase64}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 3. Handles HITL_SESSION_SOLVE:
|
|
319
|
+
* Types the captcha solution, clicks submit, and performs a post-submit race.
|
|
320
|
+
* If captcha is invalid, keeps session alive and snaps new captcha for retry.
|
|
321
|
+
* If success, extracts data and closes page.
|
|
322
|
+
*/
|
|
323
|
+
export async function handleInteractiveSessionSolve(payload) {
|
|
324
|
+
const { sessionId, captchaSolution, captchaConfig = {}, fieldMappings = [], collections = [] } = payload;
|
|
325
|
+
const session = activeSessions.get(sessionId);
|
|
326
|
+
|
|
327
|
+
if (!session || !session.page || session.page.isClosed()) {
|
|
328
|
+
throw new Error("Interactive session not found or already expired");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const { page } = session;
|
|
332
|
+
const cfg = { ...session.captchaConfig, ...captchaConfig };
|
|
333
|
+
const mappings = fieldMappings.length > 0 ? fieldMappings : session.fieldMappings || [];
|
|
334
|
+
const colls = collections.length > 0 ? collections : session.collections || [];
|
|
335
|
+
|
|
336
|
+
const inputSelector = cfg.inputSelector || "#captcha, input[name='captcha']";
|
|
337
|
+
const submitSelector = cfg.submitSelector || "button[type='submit']";
|
|
338
|
+
const successSelector = cfg.waitForAfterSubmit?.selector || ".table-responsive, table, .result-pane";
|
|
339
|
+
const errorSelector = cfg.errorDetection?.selector || ".alert-danger, .has-error, #error-msg, .ui-messages-error";
|
|
340
|
+
const invalidPattern = cfg.errorDetection?.invalidCaptchaPattern || "captcha.*(incorrect|invalid|wrong|mismatch|enter text)";
|
|
341
|
+
|
|
342
|
+
// 1. Type captcha solution
|
|
343
|
+
await page.waitForSelector(inputSelector, { timeout: 4000 });
|
|
344
|
+
await page.click(inputSelector, { clickCount: 3 }).catch(() => {});
|
|
345
|
+
await page.keyboard.press("Backspace").catch(() => {});
|
|
346
|
+
await page.type(inputSelector, String(captchaSolution).trim(), { delay: 35 });
|
|
347
|
+
|
|
348
|
+
// 2. Click submit button
|
|
349
|
+
await page.waitForSelector(submitSelector, { timeout: 4000 });
|
|
350
|
+
await page.click(submitSelector);
|
|
351
|
+
|
|
352
|
+
// 3. Post-Submit Race: Wait for Success Selector OR Error Selector
|
|
353
|
+
const matchedOutcome = await page
|
|
354
|
+
.waitForFunction(
|
|
355
|
+
(succ, err) => {
|
|
356
|
+
if (document.querySelector(succ)) return "SUCCESS";
|
|
357
|
+
const errEl = document.querySelector(err);
|
|
358
|
+
if (errEl && errEl.innerText && errEl.innerText.trim().length > 0) return "ERROR";
|
|
359
|
+
return null;
|
|
360
|
+
},
|
|
361
|
+
{ timeout: cfg.waitForAfterSubmit?.timeoutMs || 15000 },
|
|
362
|
+
successSelector,
|
|
363
|
+
errorSelector
|
|
364
|
+
)
|
|
365
|
+
.then((handle) => handle.jsonValue())
|
|
366
|
+
.catch(() => "TIMEOUT");
|
|
367
|
+
|
|
368
|
+
// Case A: Success Result Page
|
|
369
|
+
if (matchedOutcome === "SUCCESS") {
|
|
370
|
+
const raw = {};
|
|
371
|
+
for (const mapping of mappings) {
|
|
372
|
+
let val = null;
|
|
373
|
+
const strategies = mapping.strategies || (mapping.strategy ? [mapping.strategy] : []);
|
|
374
|
+
for (const strat of strategies) {
|
|
375
|
+
val = await extractBrowserField(page, strat);
|
|
376
|
+
if (val !== null && val !== undefined && val !== "") break;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (mapping.transforms && mapping.transforms.length > 0) {
|
|
380
|
+
val = runTransformPipeline(val, mapping.transforms);
|
|
381
|
+
}
|
|
382
|
+
if (mapping.type) {
|
|
383
|
+
val = castValueToType(val, mapping.type);
|
|
384
|
+
}
|
|
385
|
+
raw[mapping.field || mapping.key] = val;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Clean up session and close page
|
|
389
|
+
await purgeSession(sessionId);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
status: "COMPLETED",
|
|
393
|
+
success: true,
|
|
394
|
+
data: raw,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Case B: In-Page Error Banner Detected
|
|
399
|
+
if (matchedOutcome === "ERROR") {
|
|
400
|
+
const errorText = await page.evaluate((sel) => {
|
|
401
|
+
const el = document.querySelector(sel);
|
|
402
|
+
return el ? el.innerText.trim() : "";
|
|
403
|
+
}, errorSelector);
|
|
404
|
+
|
|
405
|
+
const regex = new RegExp(invalidPattern, "i");
|
|
406
|
+
const isCaptchaError = regex.test(errorText);
|
|
407
|
+
|
|
408
|
+
if (isCaptchaError) {
|
|
409
|
+
// Keep page alive! Reset TTL timer for retry
|
|
410
|
+
if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
|
|
411
|
+
const retryTtlSeconds = cfg.sessionTimeoutSeconds || 90;
|
|
412
|
+
session.timeoutTimer = setTimeout(() => {
|
|
413
|
+
purgeSession(sessionId).catch(() => {});
|
|
414
|
+
}, retryTtlSeconds * 1000);
|
|
415
|
+
|
|
416
|
+
// Give the target portal 400ms to re-render the refreshed captcha image
|
|
417
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
418
|
+
const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
|
|
419
|
+
const newCaptchaEl = await page.$(imageSelector);
|
|
420
|
+
let newImageBase64 = null;
|
|
421
|
+
|
|
422
|
+
if (newCaptchaEl) {
|
|
423
|
+
const newBuffer = await newCaptchaEl.screenshot({ encoding: "binary", type: "png" });
|
|
424
|
+
newImageBase64 = `data:image/png;base64,${Buffer.from(newBuffer).toString("base64")}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
status: "INVALID_CAPTCHA",
|
|
429
|
+
success: false,
|
|
430
|
+
message: errorText || "The captcha entered was incorrect.",
|
|
431
|
+
canRetry: true,
|
|
432
|
+
newImageBase64,
|
|
433
|
+
};
|
|
434
|
+
} else {
|
|
435
|
+
// Business error (e.g. "No record found for this number")
|
|
436
|
+
await purgeSession(sessionId);
|
|
437
|
+
return {
|
|
438
|
+
status: "BUSINESS_ERROR",
|
|
439
|
+
success: false,
|
|
440
|
+
message: errorText || "Target portal returned an error.",
|
|
441
|
+
canRetry: false,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Case C: Timeout
|
|
447
|
+
await purgeSession(sessionId);
|
|
448
|
+
return {
|
|
449
|
+
status: "TIMEOUT",
|
|
450
|
+
success: false,
|
|
451
|
+
message: "Target portal did not respond with results within the expected time window.",
|
|
452
|
+
canRetry: false,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* 4. Handles HITL_SESSION_ABORT:
|
|
458
|
+
* Explicit client abort or session abandonment.
|
|
459
|
+
*/
|
|
460
|
+
export async function handleInteractiveSessionAbort(payload) {
|
|
461
|
+
const { sessionId } = payload;
|
|
462
|
+
if (sessionId) {
|
|
463
|
+
await purgeSession(sessionId);
|
|
464
|
+
}
|
|
465
|
+
return { success: true, aborted: true };
|
|
466
|
+
}
|
|
@@ -111,14 +111,66 @@ export async function handlePreviewPage(payload) {
|
|
|
111
111
|
* Handles INSPECT_ELEMENT task: resolves DOM element coordinates or evaluates candidate selectors on the edge worker.
|
|
112
112
|
*/
|
|
113
113
|
export async function handleInspectElement(payload) {
|
|
114
|
-
const { url, selector, strategyType, strategies, fingerprint, x, y, timeout = 20000 } = payload;
|
|
114
|
+
const { url, preScrapeActions, authentication, selector, strategyType, strategies, fingerprint, x, y, timeout = 20000 } = payload;
|
|
115
115
|
const browser = await localBrowserManager.getBrowser();
|
|
116
116
|
const page = await browser.newPage();
|
|
117
117
|
|
|
118
118
|
try {
|
|
119
119
|
await page.setViewport({ width: 1366, height: 900, deviceScaleFactor: 1 });
|
|
120
|
+
|
|
121
|
+
// Apply authentication if provided
|
|
122
|
+
if (authentication) {
|
|
123
|
+
if (authentication.type === "COOKIES" && Array.isArray(authentication.cookies)) {
|
|
124
|
+
const parsedUrl = new URL(url);
|
|
125
|
+
await page.setCookie(...authentication.cookies.map(c => ({
|
|
126
|
+
name: c.name,
|
|
127
|
+
value: c.value,
|
|
128
|
+
domain: c.domain || parsedUrl.hostname,
|
|
129
|
+
path: c.path || "/",
|
|
130
|
+
}))).catch(() => {});
|
|
131
|
+
}
|
|
132
|
+
if (authentication.type === "HEADERS" && authentication.headers) {
|
|
133
|
+
await page.setExtraHTTPHeaders(authentication.headers).catch(() => {});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
120
137
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
121
|
-
|
|
138
|
+
try {
|
|
139
|
+
await page.waitForNetworkIdle({ idleTime: 500, timeout: 3500 }).catch(() => {});
|
|
140
|
+
} catch (_) {}
|
|
141
|
+
await page.evaluate(() => document.fonts?.ready).catch(() => {});
|
|
142
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
143
|
+
|
|
144
|
+
// Execute pre-scrape actions if present
|
|
145
|
+
if (Array.isArray(preScrapeActions) && preScrapeActions.length > 0) {
|
|
146
|
+
for (const action of preScrapeActions) {
|
|
147
|
+
try {
|
|
148
|
+
if (action.actionType === "CLICK" && action.selector) {
|
|
149
|
+
await page.waitForSelector(action.selector, { timeout: 4000 });
|
|
150
|
+
await page.click(action.selector);
|
|
151
|
+
} else if (action.actionType === "TYPE" && action.selector && action.value) {
|
|
152
|
+
await page.waitForSelector(action.selector, { timeout: 4000 });
|
|
153
|
+
await page.type(action.selector, action.value, { delay: 30 });
|
|
154
|
+
} else if (action.actionType === "SCROLL") {
|
|
155
|
+
const scrollPixels = parseInt(action.value, 10) || 500;
|
|
156
|
+
await page.evaluate((px) => window.scrollBy(0, px), scrollPixels);
|
|
157
|
+
} else if (action.actionType === "WAIT_FOR" && action.selector) {
|
|
158
|
+
await page.waitForSelector(action.selector, { timeout: 8000 });
|
|
159
|
+
} else if (action.actionType === "SELECT_OPTION" && action.selector && action.value) {
|
|
160
|
+
await page.waitForSelector(action.selector, { timeout: 4000 });
|
|
161
|
+
await page.select(action.selector, action.value);
|
|
162
|
+
} else if (action.actionType === "PRESS_KEY" && action.value) {
|
|
163
|
+
await page.keyboard.press(action.value);
|
|
164
|
+
}
|
|
165
|
+
if (action.delayMs && action.delayMs > 0) {
|
|
166
|
+
await new Promise((r) => setTimeout(r, Math.min(action.delayMs, 5000)));
|
|
167
|
+
}
|
|
168
|
+
} catch (actErr) {
|
|
169
|
+
console.warn(`[WorkerInspect] Pre-scrape action ${action.actionType} (${action.selector}) skipped: ${actErr.message}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
173
|
+
}
|
|
122
174
|
|
|
123
175
|
if (x !== undefined && y !== undefined) {
|
|
124
176
|
const elementInfo = await page.evaluate((px, py) => {
|
|
@@ -129,8 +181,168 @@ export async function handleInspectElement(payload) {
|
|
|
129
181
|
const clientX = px - window.scrollX;
|
|
130
182
|
const clientY = py - window.scrollY;
|
|
131
183
|
|
|
132
|
-
|
|
133
|
-
|
|
184
|
+
// ─── Step 1: Collect candidates using elementsFromPoint + surrounding offsets ───
|
|
185
|
+
const candidateNodes = new Set();
|
|
186
|
+
const sampleOffsets = [
|
|
187
|
+
[0, 0],
|
|
188
|
+
[-4, 0], [4, 0], [0, -4], [0, 4],
|
|
189
|
+
[-8, 0], [8, 0], [0, -8], [0, 8],
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
for (const [dx, dy] of sampleOffsets) {
|
|
193
|
+
const cx = clientX + dx;
|
|
194
|
+
const cy = clientY + dy;
|
|
195
|
+
if (cx >= 0 && cy >= 0 && cx <= window.innerWidth && cy <= window.innerHeight) {
|
|
196
|
+
if (typeof document.elementsFromPoint === "function") {
|
|
197
|
+
const list = document.elementsFromPoint(cx, cy) || [];
|
|
198
|
+
for (const node of list) {
|
|
199
|
+
if (node && node.nodeType === 1) candidateNodes.add(node);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (candidateNodes.size === 0) {
|
|
206
|
+
const single = document.elementFromPoint(clientX, clientY);
|
|
207
|
+
if (single && single.nodeType === 1) candidateNodes.add(single);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const rawElements = Array.from(candidateNodes);
|
|
211
|
+
if (rawElements.length === 0) return null;
|
|
212
|
+
|
|
213
|
+
// ─── Step 2: Intelligent priority scoring ───
|
|
214
|
+
const scoreElement = (node) => {
|
|
215
|
+
if (!node || node === document.body || node === document.documentElement) return -999999;
|
|
216
|
+
const tag = node.tagName.toLowerCase();
|
|
217
|
+
const cls = (typeof node.className === "string" ? node.className : "").toLowerCase();
|
|
218
|
+
const id = (node.id || "").toLowerCase();
|
|
219
|
+
const style = window.getComputedStyle ? window.getComputedStyle(node) : null;
|
|
220
|
+
|
|
221
|
+
if (style) {
|
|
222
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
|
|
223
|
+
return -80000;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const rect = node.getBoundingClientRect();
|
|
228
|
+
const area = rect.width * rect.height;
|
|
229
|
+
|
|
230
|
+
// Severe penalty for dimmer, overlay, backdrop, modal blocker containers
|
|
231
|
+
const isDimmer =
|
|
232
|
+
cls.includes("dimmer") ||
|
|
233
|
+
cls.includes("backdrop") ||
|
|
234
|
+
cls.includes("overlay") ||
|
|
235
|
+
cls.includes("mask") ||
|
|
236
|
+
cls.includes("blocker") ||
|
|
237
|
+
id.includes("dimmer") ||
|
|
238
|
+
id.includes("backdrop") ||
|
|
239
|
+
id.includes("overlay");
|
|
240
|
+
|
|
241
|
+
if (isDimmer) {
|
|
242
|
+
return -50000;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Severe penalty for giant viewport-covering containers
|
|
246
|
+
if (rect.width >= window.innerWidth * 0.85 && rect.height >= window.innerHeight * 0.7) {
|
|
247
|
+
if (["div", "section", "main", "body", "form"].includes(tag)) {
|
|
248
|
+
return -40000;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let score = 0;
|
|
253
|
+
|
|
254
|
+
// Form controls (Inputs, Textareas, Selects) - Top Priority!
|
|
255
|
+
if (tag === "input") {
|
|
256
|
+
const inputType = (node.type || "text").toLowerCase();
|
|
257
|
+
if (inputType === "hidden") return -10000;
|
|
258
|
+
if (["submit", "button", "reset"].includes(inputType)) {
|
|
259
|
+
score += 18000;
|
|
260
|
+
} else {
|
|
261
|
+
score += 20000;
|
|
262
|
+
}
|
|
263
|
+
} else if (tag === "textarea" || tag === "select" || node.getAttribute("contenteditable") === "true") {
|
|
264
|
+
score += 20000;
|
|
265
|
+
}
|
|
266
|
+
// Captchas & Media
|
|
267
|
+
else if (tag === "img") {
|
|
268
|
+
const src = (node.src || "").toLowerCase();
|
|
269
|
+
const alt = (node.getAttribute("alt") || "").toLowerCase();
|
|
270
|
+
if (src.includes("captcha") || alt.includes("captcha") || id.includes("captcha") || cls.includes("captcha")) {
|
|
271
|
+
score += 22000;
|
|
272
|
+
} else {
|
|
273
|
+
score += 15000;
|
|
274
|
+
}
|
|
275
|
+
} else if (tag === "canvas") {
|
|
276
|
+
score += 16000;
|
|
277
|
+
}
|
|
278
|
+
// Interactive Buttons & Links
|
|
279
|
+
else if (tag === "button" || node.getAttribute("role") === "button") {
|
|
280
|
+
score += 17000;
|
|
281
|
+
} else if (tag === "a" && (node.hasAttribute("href") || node.getAttribute("role") === "button")) {
|
|
282
|
+
score += 16000;
|
|
283
|
+
}
|
|
284
|
+
// Labels
|
|
285
|
+
else if (tag === "label") {
|
|
286
|
+
score += 13000;
|
|
287
|
+
}
|
|
288
|
+
// Table cells & headers
|
|
289
|
+
else if (tag === "td" || tag === "th") {
|
|
290
|
+
score += 12000;
|
|
291
|
+
}
|
|
292
|
+
// Headings
|
|
293
|
+
else if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) {
|
|
294
|
+
score += 11000;
|
|
295
|
+
}
|
|
296
|
+
// Text leaves
|
|
297
|
+
else if (["span", "p", "strong", "b", "em", "i", "li", "small"].includes(tag)) {
|
|
298
|
+
const txt = (node.innerText || node.textContent || "").trim();
|
|
299
|
+
if (txt.length > 0 && node.children.length === 0) {
|
|
300
|
+
score += 10000;
|
|
301
|
+
} else {
|
|
302
|
+
score += 7000;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Generic wrappers
|
|
306
|
+
else if (["div", "form", "section", "article"].includes(tag)) {
|
|
307
|
+
score += 2000;
|
|
308
|
+
} else {
|
|
309
|
+
score += 1000;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Tightly-fitting area preference
|
|
313
|
+
if (area > 0) {
|
|
314
|
+
score -= Math.min(area / 100, 10000);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Exact coordinate hit bonus
|
|
318
|
+
if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
|
|
319
|
+
score += 1500;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return score;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
rawElements.sort((a, b) => scoreElement(b) - scoreElement(a));
|
|
326
|
+
|
|
327
|
+
let el = rawElements[0];
|
|
328
|
+
if (!el || el === document.body || el === document.documentElement) {
|
|
329
|
+
el = rawElements.find(n => n !== document.body && n !== document.documentElement) || document.body;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ─── Step 3: Drill-down inspection ───
|
|
333
|
+
// If top-scoring element is a container, check if it directly contains an interactive control near click
|
|
334
|
+
if (el && ["div", "span", "label", "p", "form", "section"].includes(el.tagName.toLowerCase())) {
|
|
335
|
+
const innerControls = Array.from(el.querySelectorAll('input:not([type="hidden"]), textarea, select, button, img, canvas'));
|
|
336
|
+
for (const ctrl of innerControls) {
|
|
337
|
+
const cRect = ctrl.getBoundingClientRect();
|
|
338
|
+
const pad = 25;
|
|
339
|
+
if (clientX >= cRect.left - pad && clientX <= cRect.right + pad &&
|
|
340
|
+
clientY >= cRect.top - pad && clientY <= cRect.bottom + pad) {
|
|
341
|
+
el = ctrl;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
134
346
|
|
|
135
347
|
const rect = el.getBoundingClientRect();
|
|
136
348
|
const boundingRect = {
|
|
@@ -237,6 +449,34 @@ export async function handleInspectElement(payload) {
|
|
|
237
449
|
return "/" + parts.join("/");
|
|
238
450
|
};
|
|
239
451
|
|
|
452
|
+
// ─── Collect Alternates for quick switching ───
|
|
453
|
+
const alternates = [];
|
|
454
|
+
const seenAltKeys = new Set();
|
|
455
|
+
for (const cand of rawElements) {
|
|
456
|
+
if (cand === el || cand === document.body || cand === document.documentElement) continue;
|
|
457
|
+
const cScore = scoreElement(cand);
|
|
458
|
+
if (cScore < 0) continue;
|
|
459
|
+
const cTag = cand.tagName.toLowerCase();
|
|
460
|
+
const cId = cand.id && /^[a-zA-Z][\w-]*$/.test(cand.id) ? `#${cand.id}` : "";
|
|
461
|
+
const cCls = (cand.className && typeof cand.className === "string")
|
|
462
|
+
? cand.className.split(/\s+/).filter(c => c && /^[a-zA-Z_-][\w-]*$/.test(c))[0]
|
|
463
|
+
: "";
|
|
464
|
+
const altKey = `${cTag}${cId}${cCls ? `.${cCls}` : ""}`;
|
|
465
|
+
if (seenAltKeys.has(altKey)) continue;
|
|
466
|
+
seenAltKeys.add(altKey);
|
|
467
|
+
|
|
468
|
+
const altSel = cId ? cId : (cCls ? `${cTag}.${cCls}` : cTag);
|
|
469
|
+
const altTxt = (cand.innerText || cand.textContent || "").trim().slice(0, 35);
|
|
470
|
+
alternates.push({
|
|
471
|
+
tagName: cTag,
|
|
472
|
+
selector: altSel,
|
|
473
|
+
id: cand.id || null,
|
|
474
|
+
text: altTxt,
|
|
475
|
+
label: `<${cTag}${cId || (cCls ? `.${cCls}` : "")}> ${altTxt ? `"${altTxt}"` : ""}`.trim()
|
|
476
|
+
});
|
|
477
|
+
if (alternates.length >= 4) break;
|
|
478
|
+
}
|
|
479
|
+
|
|
240
480
|
// ─── Build ranked candidates ───
|
|
241
481
|
const candidates = [];
|
|
242
482
|
const seen = new Set();
|
|
@@ -252,7 +492,64 @@ export async function handleInspectElement(payload) {
|
|
|
252
492
|
addCandidate("css", `#${el.id}`, { specificity: "unique", label: "ID Selector" });
|
|
253
493
|
}
|
|
254
494
|
|
|
255
|
-
// 2.
|
|
495
|
+
// 2. Form Name attribute (essential for inputs/forms)
|
|
496
|
+
const nameAttr = el.getAttribute("name");
|
|
497
|
+
if (nameAttr && /^[a-zA-Z0-9_\-\[\]:]+$/.test(nameAttr)) {
|
|
498
|
+
const selName = `${el.tagName.toLowerCase()}[name="${nameAttr}"]`;
|
|
499
|
+
const mc = countMatches(selName);
|
|
500
|
+
addCandidate("css", selName, {
|
|
501
|
+
specificity: mc === 1 ? "unique" : "high",
|
|
502
|
+
label: `Form Name: ${nameAttr}`,
|
|
503
|
+
matchCount: mc
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 3. Form Placeholder attribute
|
|
508
|
+
const phAttr = el.getAttribute("placeholder");
|
|
509
|
+
if (phAttr && phAttr.length < 80) {
|
|
510
|
+
const selPh = `${el.tagName.toLowerCase()}[placeholder="${phAttr.replace(/"/g, '\\"')}"]`;
|
|
511
|
+
const mc = countMatches(selPh);
|
|
512
|
+
addCandidate("css", selPh, {
|
|
513
|
+
specificity: mc === 1 ? "unique" : "high",
|
|
514
|
+
label: `Placeholder: ${phAttr.slice(0, 25)}`,
|
|
515
|
+
matchCount: mc
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 4. Input Type / Role attribute
|
|
520
|
+
const typeAttr = el.getAttribute("type");
|
|
521
|
+
if (typeAttr && typeAttr !== "text" && ["input", "button"].includes(el.tagName.toLowerCase())) {
|
|
522
|
+
const selType = `${el.tagName.toLowerCase()}[type="${typeAttr}"]`;
|
|
523
|
+
const mc = countMatches(selType);
|
|
524
|
+
addCandidate("css", selType, {
|
|
525
|
+
specificity: mc === 1 ? "unique" : "medium",
|
|
526
|
+
label: `Input Type: ${typeAttr}`,
|
|
527
|
+
matchCount: mc
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// 5. Image Alt / Captcha attribute
|
|
532
|
+
if (el.tagName.toLowerCase() === "img") {
|
|
533
|
+
const altAttr = el.getAttribute("alt");
|
|
534
|
+
if (altAttr && altAttr.length < 60) {
|
|
535
|
+
const selAlt = `img[alt="${altAttr.replace(/"/g, '\\"')}"]`;
|
|
536
|
+
const mc = countMatches(selAlt);
|
|
537
|
+
addCandidate("css", selAlt, {
|
|
538
|
+
specificity: mc === 1 ? "unique" : "high",
|
|
539
|
+
label: `Image Alt: ${altAttr.slice(0, 25)}`,
|
|
540
|
+
matchCount: mc
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
const srcAttr = el.getAttribute("src");
|
|
544
|
+
if (srcAttr && (srcAttr.toLowerCase().includes("captcha") || srcAttr.toLowerCase().includes("secur"))) {
|
|
545
|
+
addCandidate("css", `img[src*="captcha"]`, {
|
|
546
|
+
specificity: "high",
|
|
547
|
+
label: "Captcha Source Pattern"
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// 6. data-* and aria-* attributes
|
|
256
553
|
for (const attr of el.attributes) {
|
|
257
554
|
if ((attr.name.startsWith("data-") || attr.name.startsWith("aria-")) && attr.value && attr.value.length < 80) {
|
|
258
555
|
addCandidate("css", `${el.tagName.toLowerCase()}[${attr.name}="${attr.value}"]`, {
|
|
@@ -261,7 +558,7 @@ export async function handleInspectElement(payload) {
|
|
|
261
558
|
}
|
|
262
559
|
}
|
|
263
560
|
|
|
264
|
-
//
|
|
561
|
+
// 7. Shortest unique CSS path
|
|
265
562
|
const shortUnique = buildShortUniqueSelector(el);
|
|
266
563
|
if (shortUnique) {
|
|
267
564
|
const mc = countMatches(shortUnique);
|
|
@@ -272,7 +569,7 @@ export async function handleInspectElement(payload) {
|
|
|
272
569
|
});
|
|
273
570
|
}
|
|
274
571
|
|
|
275
|
-
//
|
|
572
|
+
// 8. Class-based selectors (increasing combos until unique)
|
|
276
573
|
const classes = (el.className && typeof el.className === "string")
|
|
277
574
|
? el.className.split(/\s+/).filter(c => c && /^[a-zA-Z_-][\w-]*$/.test(c))
|
|
278
575
|
: [];
|
|
@@ -287,7 +584,7 @@ export async function handleInspectElement(payload) {
|
|
|
287
584
|
if (mc === 1) break;
|
|
288
585
|
}
|
|
289
586
|
|
|
290
|
-
//
|
|
587
|
+
// 9. Table-specific selectors
|
|
291
588
|
const tr = el.closest("tr");
|
|
292
589
|
if (tr && tr.parentElement) {
|
|
293
590
|
const rowIdx = Array.from(tr.parentElement.children).indexOf(tr) + 1;
|
|
@@ -303,11 +600,11 @@ export async function handleInspectElement(payload) {
|
|
|
303
600
|
}
|
|
304
601
|
}
|
|
305
602
|
|
|
306
|
-
//
|
|
603
|
+
// 10. Full structural CSS path
|
|
307
604
|
const fullCss = buildCssPath(el, 8);
|
|
308
605
|
addCandidate("css", fullCss, { specificity: "unique", label: "Full CSS Path (Structural)" });
|
|
309
606
|
|
|
310
|
-
//
|
|
607
|
+
// 11. XPath — most reliable for scrapers
|
|
311
608
|
const xpath = buildXPath(el);
|
|
312
609
|
addCandidate("xpath", xpath, { specificity: "high", label: "XPath" });
|
|
313
610
|
|
|
@@ -481,6 +778,7 @@ export async function handleInspectElement(payload) {
|
|
|
481
778
|
candidates,
|
|
482
779
|
hierarchy,
|
|
483
780
|
boundingRect,
|
|
781
|
+
alternates,
|
|
484
782
|
tableDetection,
|
|
485
783
|
repeatingDetection,
|
|
486
784
|
};
|
|
@@ -608,9 +906,34 @@ export async function handleInspectElement(payload) {
|
|
|
608
906
|
currH = currH.parentElement;
|
|
609
907
|
}
|
|
610
908
|
|
|
611
|
-
const candidates = [
|
|
612
|
-
|
|
613
|
-
|
|
909
|
+
const candidates = [];
|
|
910
|
+
const seen = new Set();
|
|
911
|
+
const addCandidate = (type, value, extra = {}) => {
|
|
912
|
+
if (!value || seen.has(value)) return;
|
|
913
|
+
seen.add(value);
|
|
914
|
+
const matchCount = type === "css" ? countMatches(value) : null;
|
|
915
|
+
candidates.push({ type, value, attribute: "text", matchCount, ...extra });
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
addCandidate(strategyType, matchedSelector, { specificity: count === 1 ? "unique" : "medium", label: "Selected Query", matchCount: count });
|
|
919
|
+
|
|
920
|
+
if (el.id && /^[a-zA-Z][\w-]*$/.test(el.id)) {
|
|
921
|
+
addCandidate("css", `#${el.id}`, { specificity: "unique", label: "ID Selector" });
|
|
922
|
+
}
|
|
923
|
+
const nameAttr = el.getAttribute("name");
|
|
924
|
+
if (nameAttr && /^[a-zA-Z0-9_\-\[\]:]+$/.test(nameAttr)) {
|
|
925
|
+
addCandidate("css", `${el.tagName.toLowerCase()}[name="${nameAttr}"]`, { specificity: "unique", label: `Form Name: ${nameAttr}` });
|
|
926
|
+
}
|
|
927
|
+
const phAttr = el.getAttribute("placeholder");
|
|
928
|
+
if (phAttr && phAttr.length < 80) {
|
|
929
|
+
addCandidate("css", `${el.tagName.toLowerCase()}[placeholder="${phAttr.replace(/"/g, '\\"')}"]`, { specificity: "high", label: `Placeholder: ${phAttr.slice(0, 25)}` });
|
|
930
|
+
}
|
|
931
|
+
if (el.className && typeof el.className === "string") {
|
|
932
|
+
const cls = el.className.split(/\s+/).filter(c => c && /^[a-zA-Z_-][\w-]*$/.test(c));
|
|
933
|
+
if (cls.length > 0) {
|
|
934
|
+
addCandidate("css", `${el.tagName.toLowerCase()}.${cls[0]}`, { specificity: "medium", label: "Class Selector" });
|
|
935
|
+
}
|
|
936
|
+
}
|
|
614
937
|
|
|
615
938
|
return {
|
|
616
939
|
found: true,
|