app-manager-edge-worker 1.0.6 → 1.0.7

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.
@@ -1,801 +1,801 @@
1
- import axios from "axios";
2
- import * as cheerio from "cheerio";
3
- import localBrowserManager from "../browser.js";
4
- import { extractSmartCheerioText, runTransformPipeline, castValueToType } from "./transformUtils.js";
5
-
6
- /**
7
- * Extracts a single field value from a Puppeteer page given a strategy.
8
- */
9
- async function extractBrowserField(page, strategy) {
10
- if (!strategy || !strategy.value) return null;
11
-
12
- try {
13
- const type = strategy.type || "css";
14
- const attr = strategy.attribute || "text";
15
-
16
- if (type === "css") {
17
- const el = await page.$(strategy.value);
18
- if (!el) return null;
19
-
20
- if (attr === "text") {
21
- const text = await page.evaluate((element) => element.textContent, el);
22
- return text ? text.trim() : null;
23
- } else if (attr === "html") {
24
- return await page.evaluate((element) => element.innerHTML, el);
25
- } else {
26
- const val = await page.evaluate((element, a) => element.getAttribute(a), el, attr);
27
- return val !== null && val !== undefined ? String(val).trim() : null;
28
- }
29
- }
30
-
31
- if (type === "xpath") {
32
- const val = await page.evaluate(
33
- (xpathStr, attrName) => {
34
- try {
35
- const result = document.evaluate(
36
- xpathStr,
37
- document,
38
- null,
39
- XPathResult.FIRST_ORDERED_NODE_TYPE,
40
- null
41
- );
42
- const node = result.singleNodeValue;
43
- if (!node) return null;
44
- if (attrName === "text") {
45
- return node.textContent ? node.textContent.trim() : null;
46
- }
47
- return node.getAttribute ? node.getAttribute(attrName) : null;
48
- } catch (e) {
49
- return null;
50
- }
51
- },
52
- strategy.value,
53
- attr
54
- );
55
- return val;
56
- }
57
-
58
- if (type === "text-relative") {
59
- return await page.evaluate(
60
- (anchorText, relation) => {
61
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
62
- let node;
63
- while ((node = walker.nextNode())) {
64
- if (node.textContent.includes(anchorText)) {
65
- const parent = node.parentElement;
66
- if (relation === "next-sibling") {
67
- const sibling = parent.nextElementSibling;
68
- return sibling ? (sibling.innerText || sibling.textContent)?.trim() : null;
69
- }
70
- if (relation === "parent") {
71
- const grandParent = parent.parentElement;
72
- return grandParent ? (grandParent.innerText || grandParent.textContent)?.trim() : null;
73
- }
74
- return (parent.innerText || parent.textContent)?.trim();
75
- }
76
- }
77
- return null;
78
- },
79
- strategy.anchor || strategy.value,
80
- strategy.relation || "self"
81
- );
82
- }
83
-
84
- return null;
85
- } catch (err) {
86
- return null;
87
- }
88
- }
89
-
90
- /**
91
- * Extracts a single field from a Cheerio instance given a strategy.
92
- */
93
- function extractCheerioField($, strategy) {
94
- if (!strategy || !strategy.value) return null;
95
-
96
- try {
97
- const type = strategy.type || "css";
98
- const attr = strategy.attribute || "text";
99
-
100
- if (type === "css") {
101
- const el = $(strategy.value);
102
- if (!el || el.length === 0) return null;
103
-
104
- if (attr === "text") return extractSmartCheerioText($, el.first());
105
- if (attr === "html") return el.first().html();
106
- return el.first().attr(attr) || null;
107
- }
108
-
109
- if (type === "xpath") {
110
- let css = String(strategy.value).trim();
111
- css = css.replace(/\/\/\*\[@id=["']([^"']+)["']\]/g, "#$1");
112
- css = css.replace(/\/([a-zA-Z0-9_-]+)\[([0-9]+)\]/g, " > $1:nth-of-type($2)");
113
- css = css.replace(/\/([a-zA-Z0-9_-]+)/g, " > $1");
114
- css = css.replace(/^\/+/g, "").replace(/^>\s*/, "").trim();
115
-
116
- if (css) {
117
- try {
118
- const el = $(css);
119
- if (el && el.length > 0) {
120
- if (attr === "text") return extractSmartCheerioText($, el.first());
121
- if (attr === "html") return el.first().html();
122
- return el.first().attr(attr) || null;
123
- }
124
- } catch {
125
- // Ignore parse error
126
- }
127
- }
128
- }
129
-
130
- if (type === "text-relative") {
131
- const anchor = strategy.anchor || strategy.value;
132
- const match = $(`:contains("${anchor}")`).last();
133
- if (match && match.length > 0) return extractSmartCheerioText($, match);
134
- }
135
-
136
- return null;
137
- } catch {
138
- return null;
139
- }
140
- }
141
-
142
- /**
143
- * Extracts a table collection using Cheerio.
144
- */
145
- function extractTableCollectionCheerio($, collectionDef) {
146
- if (!collectionDef) return [];
147
- const selector = collectionDef.containerSelector || collectionDef.tableSelector || "table";
148
- const tableEl = $(selector).first();
149
- if (!tableEl || tableEl.length === 0) return [];
150
-
151
- const hasHeaders = collectionDef.hasHeaders !== false;
152
- const headerRowIndex = typeof collectionDef.headerRowIndex === "number" ? collectionDef.headerRowIndex : 0;
153
- const maxItems = collectionDef.maxItems || 100;
154
- const columns = collectionDef.columns || [];
155
-
156
- const rows = [];
157
- tableEl.find("tr").each((rIdx, rowEl) => {
158
- const $row = $(rowEl);
159
- const cells = [];
160
- $row.find("th, td").each((_, cellEl) => {
161
- cells.push(extractSmartCheerioText($, $(cellEl)));
162
- });
163
-
164
- if (cells.length > 0) {
165
- rows.push({ rowIndex: rIdx, cells, rowEl: $row });
166
- }
167
- });
168
-
169
- if (rows.length === 0) return [];
170
-
171
- let activeColumns = [...columns];
172
- if (activeColumns.length === 0) {
173
- if (hasHeaders && rows[headerRowIndex]) {
174
- activeColumns = rows[headerRowIndex].cells.map((headerText, colIdx) => {
175
- const normalizedKey =
176
- headerText
177
- .toLowerCase()
178
- .replace(/[^a-z0-9]+/g, "_")
179
- .replace(/^_+|_+$/g, "") || `column_${colIdx + 1}`;
180
- return {
181
- key: normalizedKey,
182
- label: headerText || `Column ${colIdx + 1}`,
183
- columnIndex: colIdx,
184
- type: "string",
185
- excluded: false,
186
- };
187
- });
188
- } else {
189
- activeColumns = rows[0].cells.map((_, colIdx) => ({
190
- key: `column_${colIdx + 1}`,
191
- label: `Column ${colIdx + 1}`,
192
- columnIndex: colIdx,
193
- type: "string",
194
- excluded: false,
195
- }));
196
- }
197
- }
198
-
199
- const startRowIdx = hasHeaders ? headerRowIndex + 1 : 0;
200
- const dataRows = rows.slice(startRowIdx, startRowIdx + maxItems);
201
- const results = [];
202
-
203
- for (const { cells, rowEl } of dataRows) {
204
- const record = {};
205
- for (let colIdx = 0; colIdx < activeColumns.length; colIdx++) {
206
- const col = activeColumns[colIdx];
207
- if (col.excluded) continue;
208
- let rawVal = null;
209
- const colSelector = col.relativeSelector || col.selector;
210
-
211
- if (colSelector) {
212
- const subEl = rowEl.find(colSelector).first();
213
- if (subEl && subEl.length > 0) {
214
- const attr = col.attribute || "text";
215
- rawVal = attr === "text" ? extractSmartCheerioText($, subEl) : subEl.attr(attr) || null;
216
- }
217
- } else if (typeof col.columnIndex === "number" && col.columnIndex < cells.length) {
218
- rawVal = cells[col.columnIndex];
219
- } else if (colIdx < cells.length) {
220
- rawVal = cells[colIdx];
221
- }
222
-
223
- if ((rawVal === null || rawVal === undefined || rawVal === "") && col.defaultValue !== undefined) {
224
- rawVal = col.defaultValue;
225
- }
226
-
227
- // Apply column transforms
228
- if (col.transforms && col.transforms.length > 0) {
229
- rawVal = runTransformPipeline(rawVal, col.transforms);
230
- }
231
-
232
- // Cast to target type
233
- if (col.type) {
234
- rawVal = castValueToType(rawVal, col.type);
235
- }
236
-
237
- record[col.key] = rawVal;
238
- }
239
- results.push(record);
240
- }
241
-
242
- return results;
243
- }
244
-
245
- /**
246
- * Extracts repeating items/cards using Cheerio.
247
- */
248
- function extractRepeatingCollectionCheerio($, collectionDef) {
249
- if (!collectionDef) return [];
250
-
251
- let items;
252
- if (collectionDef.rowSelector) {
253
- if (collectionDef.containerSelector) {
254
- items = $(collectionDef.containerSelector).find(collectionDef.rowSelector);
255
- }
256
- if (!items || items.length === 0) {
257
- items = $(collectionDef.rowSelector);
258
- }
259
- } else if (collectionDef.containerSelector) {
260
- items = $(collectionDef.containerSelector);
261
- } else {
262
- return [];
263
- }
264
-
265
- if (!items || items.length === 0) return [];
266
-
267
- const maxItems = collectionDef.maxItems || 100;
268
- const columns = collectionDef.columns || [];
269
- const results = [];
270
-
271
- items.slice(0, maxItems).each((_, itemEl) => {
272
- const $item = $(itemEl);
273
- const record = {};
274
-
275
- for (const col of columns) {
276
- if (col.excluded) continue;
277
- let rawVal = null;
278
- const colSelector = col.relativeSelector || col.selector;
279
-
280
- if (colSelector) {
281
- const target = $item.find(colSelector).first();
282
- if (target && target.length > 0) {
283
- const attr = col.attribute || "text";
284
- rawVal = attr === "text" ? extractSmartCheerioText($, target) : target.attr(attr) || null;
285
- }
286
- } else {
287
- const attr = col.attribute || "text";
288
- rawVal = attr === "text" ? extractSmartCheerioText($, $item) : $item.attr(attr) || null;
289
- }
290
-
291
- if ((rawVal === null || rawVal === undefined || rawVal === "") && col.defaultValue !== undefined) {
292
- rawVal = col.defaultValue;
293
- }
294
-
295
- // Apply column transforms
296
- if (col.transforms && col.transforms.length > 0) {
297
- rawVal = runTransformPipeline(rawVal, col.transforms);
298
- }
299
-
300
- // Cast to target type
301
- if (col.type) {
302
- rawVal = castValueToType(rawVal, col.type);
303
- }
304
-
305
- record[col.key] = rawVal;
306
- }
307
-
308
- const hasValue = Object.values(record).some((v) => v !== null && v !== undefined && v !== "");
309
- if (hasValue) {
310
- results.push(record);
311
- }
312
- });
313
-
314
- return results;
315
- }
316
-
317
- /**
318
- * Handles SCRAPE_CHROMIUM task via local stealth Puppeteer.
319
- */
320
- export async function handleChromiumScrape(payload) {
321
- const startTime = Date.now();
322
- const {
323
- url,
324
- fieldMappings = [],
325
- collections = [],
326
- navigation = {},
327
- waitFor = {},
328
- timeout = 30000,
329
- waitForSelector = null,
330
- waitForTimeout = 1000,
331
- actions = [],
332
- extractSelectors = [],
333
- headers = {},
334
- cookies = [],
335
- viewport = { width: 1366, height: 900 },
336
- includeHtml = false,
337
- } = payload;
338
-
339
- const navTimeout = navigation.timeoutMs || timeout || 30000;
340
- const navWaitUntil = navigation.waitUntil || "domcontentloaded";
341
-
342
- const browser = await localBrowserManager.getBrowser();
343
- const page = await browser.newPage();
344
-
345
- try {
346
- await page.setViewport({
347
- width: viewport.width || 1366,
348
- height: viewport.height || 900,
349
- deviceScaleFactor: 1,
350
- });
351
-
352
- if (Object.keys(headers).length > 0) {
353
- await page.setExtraHTTPHeaders(headers);
354
- }
355
-
356
- if (Array.isArray(cookies) && cookies.length > 0) {
357
- await page.setCookie(...cookies);
358
- }
359
-
360
- // Navigate
361
- const response = await page.goto(url, {
362
- waitUntil: navWaitUntil,
363
- timeout: navTimeout,
364
- });
365
-
366
- const status = response ? response.status() : 200;
367
-
368
- // Handle wait conditions
369
- if (waitFor && waitFor.type) {
370
- const waitTimeout = waitFor.timeoutMs || 10000;
371
- if (waitFor.type === "SELECTOR_EXISTS" && waitFor.selector) {
372
- await page.waitForSelector(waitFor.selector, { timeout: waitTimeout }).catch(() => {});
373
- } else if (waitFor.type === "SELECTOR_VISIBLE" && waitFor.selector) {
374
- await page.waitForSelector(waitFor.selector, { visible: true, timeout: waitTimeout }).catch(() => {});
375
- } else if (waitFor.type === "TEXT_PRESENT" && waitFor.text) {
376
- await page
377
- .waitForFunction(
378
- (t) => document.body && document.body.innerText.includes(t),
379
- { timeout: waitTimeout },
380
- waitFor.text
381
- )
382
- .catch(() => {});
383
- } else if (waitFor.type === "FIXED_DELAY" && waitFor.delayMs > 0) {
384
- await new Promise((r) => setTimeout(r, Math.min(waitFor.delayMs, 10000)));
385
- }
386
- } else if (waitForSelector) {
387
- await page.waitForSelector(waitForSelector, { timeout: 10000 }).catch(() => {});
388
- }
389
-
390
- // Optional brief delay for dynamic content
391
- if (waitForTimeout > 0) {
392
- await new Promise((r) => setTimeout(r, Math.min(waitForTimeout, 10000)));
393
- }
394
-
395
- // Execute sequential interactive actions if configured
396
- if (Array.isArray(actions) && actions.length > 0) {
397
- for (const action of actions) {
398
- try {
399
- if (action.type === "click" && action.selector) {
400
- await page.click(action.selector);
401
- } else if (action.type === "type" && action.selector && action.text) {
402
- await page.type(action.selector, action.text);
403
- } else if (action.type === "scroll") {
404
- await page.evaluate((y) => window.scrollBy(0, y || 500), action.distance);
405
- } else if (action.type === "wait") {
406
- await new Promise((r) => setTimeout(r, action.duration || 1000));
407
- }
408
- } catch (actionErr) {
409
- console.warn(`[ScrapeHandler] Action ${action.type} failed:`, actionErr.message);
410
- }
411
- }
412
- }
413
-
414
- const pageTitle = (await page.title()) || "";
415
- const finalUrl = page.url();
416
-
417
- const raw = {};
418
- const strategySummary = {};
419
-
420
- // 1. Extract mapped fields (Field Schema & Strategies)
421
- if (Array.isArray(fieldMappings) && fieldMappings.length > 0) {
422
- for (const mapping of fieldMappings) {
423
- let extractedVal = null;
424
- let successfulStrategy = null;
425
-
426
- const strategies = mapping.strategies || [];
427
- for (let i = 0; i < strategies.length; i++) {
428
- const strat = strategies[i];
429
- const val = await extractBrowserField(page, strat);
430
- if (val !== null && val !== undefined && val !== "") {
431
- extractedVal = val;
432
- successfulStrategy = strat.type || `strategy_${i + 1}`;
433
- break;
434
- }
435
- }
436
-
437
- if (mapping.transforms && mapping.transforms.length > 0) {
438
- extractedVal = runTransformPipeline(extractedVal, mapping.transforms);
439
- }
440
- if (mapping.type) {
441
- extractedVal = castValueToType(extractedVal, mapping.type);
442
- }
443
-
444
- raw[mapping.field] = extractedVal;
445
- strategySummary[mapping.field] = {
446
- extracted: extractedVal !== null,
447
- strategyUsed: successfulStrategy,
448
- };
449
- }
450
- }
451
-
452
- // 2. Extract legacy extractSelectors if present
453
- if (Array.isArray(extractSelectors) && extractSelectors.length > 0) {
454
- for (const item of extractSelectors) {
455
- const { field, selector, type = "text", attribute = null } = item;
456
- if (!field || !selector) continue;
457
-
458
- try {
459
- const values = await page.evaluate(
460
- (sel, t, attr) => {
461
- const elements = Array.from(document.querySelectorAll(sel));
462
- return elements.map((el) => {
463
- if (t === "attribute" && attr) {
464
- return el.getAttribute(attr) || "";
465
- }
466
- if (t === "html") {
467
- return el.innerHTML;
468
- }
469
- return el.innerText ? el.innerText.trim() : el.textContent?.trim() || "";
470
- });
471
- },
472
- selector,
473
- type,
474
- attribute
475
- );
476
-
477
- const extractedVal = values.length <= 1 ? (values[0] ?? null) : values;
478
- if (raw[field] === undefined) {
479
- raw[field] = extractedVal;
480
- }
481
- } catch {
482
- if (raw[field] === undefined) {
483
- raw[field] = null;
484
- }
485
- }
486
- }
487
- }
488
-
489
- // 5. Extract structured collections (Tables & Repeating items)
490
- const extractedCollections = {};
491
- if (Array.isArray(collections) && collections.length > 0) {
492
- for (const coll of collections) {
493
- if (!coll || !coll.name) continue;
494
- try {
495
- if (coll.type === "TABLE") {
496
- extractedCollections[coll.name] = await page.evaluate((cDef) => {
497
- const sel = cDef.containerSelector || "table";
498
- const table = document.querySelector(sel);
499
- if (!table) return [];
500
-
501
- const hasHeaders = cDef.hasHeaders !== false;
502
- const headerRowIndex = typeof cDef.headerRowIndex === "number" ? cDef.headerRowIndex : 0;
503
- const maxItems = cDef.maxItems || 100;
504
- const columns = cDef.columns || [];
505
-
506
- const rowEls = Array.from(table.querySelectorAll("tr"));
507
- const rowsData = rowEls
508
- .map((r) => {
509
- const cells = Array.from(r.querySelectorAll("th, td")).map(
510
- (c) => c.innerText?.trim() || c.textContent?.trim() || ""
511
- );
512
- return { cells, el: r };
513
- })
514
- .filter((r) => r.cells.length > 0);
515
-
516
- if (rowsData.length === 0) return [];
517
-
518
- let activeColumns = [...columns];
519
- if (activeColumns.length === 0) {
520
- if (hasHeaders && rowsData[headerRowIndex]) {
521
- activeColumns = rowsData[headerRowIndex].cells.map((h, idx) => {
522
- const key =
523
- h.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") ||
524
- `col_${idx + 1}`;
525
- return { key, label: h || `Column ${idx + 1}`, columnIndex: idx, excluded: false };
526
- });
527
- } else {
528
- activeColumns = rowsData[0].cells.map((_, idx) => ({
529
- key: `col_${idx + 1}`,
530
- label: `Column ${idx + 1}`,
531
- columnIndex: idx,
532
- excluded: false,
533
- }));
534
- }
535
- }
536
-
537
- const startIdx = hasHeaders ? headerRowIndex + 1 : 0;
538
- const sliceRows = rowsData.slice(startIdx, startIdx + maxItems);
539
- const results = [];
540
-
541
- for (const row of sliceRows) {
542
- const rec = {};
543
- for (let colIdx = 0; colIdx < activeColumns.length; colIdx++) {
544
- const col = activeColumns[colIdx];
545
- if (col.excluded) continue;
546
- let val = null;
547
- const colSel = col.relativeSelector || col.selector;
548
- if (colSel) {
549
- const subEl = row.el.querySelector(colSel);
550
- if (subEl) {
551
- const attr = col.attribute || "text";
552
- val = attr === "text" ? (subEl.innerText?.trim() || subEl.textContent?.trim()) : subEl.getAttribute(attr);
553
- }
554
- } else if (typeof col.columnIndex === "number" && col.columnIndex < row.cells.length) {
555
- val = row.cells[col.columnIndex];
556
- } else if (colIdx < row.cells.length) {
557
- val = row.cells[colIdx];
558
- }
559
- if ((val === null || val === undefined || val === "") && col.defaultValue !== undefined) {
560
- val = col.defaultValue;
561
- }
562
- rec[col.key] = val;
563
- }
564
- results.push(rec);
565
- }
566
- return results;
567
- }, coll);
568
- } else {
569
- extractedCollections[coll.name] = await page.evaluate((cDef) => {
570
- let items = [];
571
- if (cDef.rowSelector) {
572
- if (cDef.containerSelector) {
573
- const container = document.querySelector(cDef.containerSelector);
574
- if (container) {
575
- items = Array.from(container.querySelectorAll(cDef.rowSelector));
576
- }
577
- }
578
- if (items.length === 0) {
579
- items = Array.from(document.querySelectorAll(cDef.rowSelector));
580
- }
581
- } else if (cDef.containerSelector) {
582
- items = Array.from(document.querySelectorAll(cDef.containerSelector));
583
- }
584
-
585
- if (items.length === 0) return [];
586
-
587
- const maxItems = cDef.maxItems || 100;
588
- const columns = cDef.columns || [];
589
- const results = [];
590
-
591
- for (const item of items.slice(0, maxItems)) {
592
- const rec = {};
593
- for (const col of columns) {
594
- if (col.excluded) continue;
595
- let val = null;
596
- const colSel = col.relativeSelector || col.selector;
597
- if (colSel) {
598
- const target = item.querySelector(colSel);
599
- if (target) {
600
- const attr = col.attribute || "text";
601
- val = attr === "text" ? (target.innerText?.trim() || target.textContent?.trim()) : target.getAttribute(attr);
602
- }
603
- } else {
604
- const attr = col.attribute || "text";
605
- val = attr === "text" ? (item.innerText?.trim() || item.textContent?.trim()) : item.getAttribute(attr);
606
- }
607
- if ((val === null || val === undefined || val === "") && col.defaultValue !== undefined) {
608
- val = col.defaultValue;
609
- }
610
- rec[col.key] = val;
611
- }
612
- const hasVal = Object.values(rec).some((v) => v !== null && v !== undefined && v !== "");
613
- if (hasVal) results.push(rec);
614
- }
615
- return results;
616
- }, coll);
617
- }
618
-
619
- // Apply column transforms and type casting
620
- if (Array.isArray(extractedCollections[coll.name]) && coll.columns) {
621
- extractedCollections[coll.name] = extractedCollections[coll.name].map((rec) => {
622
- const transformed = { ...rec };
623
- for (const col of coll.columns) {
624
- if (col.excluded) continue;
625
- let val = transformed[col.key];
626
- if ((val === null || val === undefined || val === "") && col.defaultValue !== undefined) {
627
- val = col.defaultValue;
628
- }
629
- if (col.transforms && col.transforms.length > 0) {
630
- val = runTransformPipeline(val, col.transforms);
631
- }
632
- if (col.type) {
633
- val = castValueToType(val, col.type);
634
- }
635
- transformed[col.key] = val;
636
- }
637
- return transformed;
638
- });
639
- }
640
- } catch {
641
- extractedCollections[coll.name] = [];
642
- }
643
- }
644
- }
645
-
646
- const executionTimeMs = Date.now() - startTime;
647
- const hasExtractedValues = Object.values(raw).some(
648
- (v) => v !== null && v !== undefined && v !== ""
649
- );
650
-
651
- // Avoid transferring huge raw HTML unless requested or nothing was extracted
652
- let html = "";
653
- if (includeHtml || (!hasExtractedValues && fieldMappings.length > 0)) {
654
- html = await page.content().catch(() => "");
655
- }
656
-
657
- return {
658
- status,
659
- title: pageTitle,
660
- url: finalUrl,
661
- raw,
662
- strategySummary,
663
- extractedData: raw,
664
- collections: extractedCollections,
665
- html,
666
- executionTimeMs,
667
- };
668
- } finally {
669
- await page.close().catch(() => {});
670
- }
671
- }
672
-
673
- /**
674
- * Handles fast SCRAPE_HTTP task via Cheerio.
675
- */
676
- export async function handleHttpScrape(payload) {
677
- const startTime = Date.now();
678
- const {
679
- url,
680
- fieldMappings = [],
681
- collections = [],
682
- timeout = 15000,
683
- headers = {},
684
- extractSelectors = [],
685
- includeHtml = false,
686
- } = payload;
687
-
688
- const response = await axios.get(url, {
689
- 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
- },
697
- });
698
-
699
- let pageHtml = typeof response.data === "string" ? response.data : Buffer.isBuffer(response.data) ? response.data.toString("utf-8") : "";
700
- const $ = cheerio.load(pageHtml);
701
- const title = $("title").first().text().trim() || "";
702
-
703
- const raw = {};
704
- const strategySummary = {};
705
-
706
- // 1. Extract fieldMappings
707
- if (Array.isArray(fieldMappings) && fieldMappings.length > 0) {
708
- for (const mapping of fieldMappings) {
709
- let extractedVal = null;
710
- let successfulStrategy = null;
711
-
712
- const strategies = mapping.strategies || [];
713
- for (let i = 0; i < strategies.length; i++) {
714
- const strat = strategies[i];
715
- const val = extractCheerioField($, strat);
716
- if (val !== null && val !== undefined && val !== "") {
717
- extractedVal = val;
718
- successfulStrategy = strat.type || `strategy_${i + 1}`;
719
- break;
720
- }
721
- }
722
-
723
- if (mapping.transforms && mapping.transforms.length > 0) {
724
- extractedVal = runTransformPipeline(extractedVal, mapping.transforms);
725
- }
726
- if (mapping.type) {
727
- extractedVal = castValueToType(extractedVal, mapping.type);
728
- }
729
-
730
- raw[mapping.field] = extractedVal;
731
- strategySummary[mapping.field] = {
732
- extracted: extractedVal !== null,
733
- strategyUsed: successfulStrategy,
734
- };
735
- }
736
- }
737
-
738
- // 2. Extract legacy extractSelectors
739
- if (Array.isArray(extractSelectors) && extractSelectors.length > 0) {
740
- for (const item of extractSelectors) {
741
- const { field, selector, type = "text", attribute = null } = item;
742
- if (!field || !selector) continue;
743
-
744
- try {
745
- const values = [];
746
- $(selector).each((_, el) => {
747
- if (type === "attribute" && attribute) {
748
- values.push($(el).attr(attribute) || "");
749
- } else if (type === "html") {
750
- values.push($(el).html() || "");
751
- } else {
752
- values.push($(el).text().trim());
753
- }
754
- });
755
-
756
- const extractedVal = values.length <= 1 ? (values[0] ?? null) : values;
757
- if (raw[field] === undefined) {
758
- raw[field] = extractedVal;
759
- }
760
- } catch {
761
- if (raw[field] === undefined) {
762
- raw[field] = null;
763
- }
764
- }
765
- }
766
- }
767
-
768
- // 3. Extract structured collections (Tables & Repeating items)
769
- const extractedCollections = {};
770
- if (Array.isArray(collections) && collections.length > 0) {
771
- for (const coll of collections) {
772
- if (!coll || !coll.name) continue;
773
- try {
774
- if (coll.type === "TABLE") {
775
- extractedCollections[coll.name] = extractTableCollectionCheerio($, coll);
776
- } else {
777
- extractedCollections[coll.name] = extractRepeatingCollectionCheerio($, coll);
778
- }
779
- } catch {
780
- extractedCollections[coll.name] = [];
781
- }
782
- }
783
- }
784
-
785
- const executionTimeMs = Date.now() - startTime;
786
- const hasExtractedValues = Object.values(raw).some(
787
- (v) => v !== null && v !== undefined && v !== ""
788
- );
789
-
790
- return {
791
- status: response.status,
792
- title,
793
- url,
794
- raw,
795
- strategySummary,
796
- extractedData: raw,
797
- collections: extractedCollections,
798
- html: includeHtml || (!hasExtractedValues && fieldMappings.length > 0) ? pageHtml : "",
799
- executionTimeMs,
800
- };
801
- }
1
+ import axios from "axios";
2
+ import * as cheerio from "cheerio";
3
+ import localBrowserManager from "../browser.js";
4
+ import { extractSmartCheerioText, runTransformPipeline, castValueToType } from "./transformUtils.js";
5
+
6
+ /**
7
+ * Extracts a single field value from a Puppeteer page given a strategy.
8
+ */
9
+ async function extractBrowserField(page, strategy) {
10
+ if (!strategy || !strategy.value) return null;
11
+
12
+ try {
13
+ const type = strategy.type || "css";
14
+ const attr = strategy.attribute || "text";
15
+
16
+ if (type === "css") {
17
+ const el = await page.$(strategy.value);
18
+ if (!el) return null;
19
+
20
+ if (attr === "text") {
21
+ const text = await page.evaluate((element) => element.textContent, el);
22
+ return text ? text.trim() : null;
23
+ } else if (attr === "html") {
24
+ return await page.evaluate((element) => element.innerHTML, el);
25
+ } else {
26
+ const val = await page.evaluate((element, a) => element.getAttribute(a), el, attr);
27
+ return val !== null && val !== undefined ? String(val).trim() : null;
28
+ }
29
+ }
30
+
31
+ if (type === "xpath") {
32
+ const val = await page.evaluate(
33
+ (xpathStr, attrName) => {
34
+ try {
35
+ const result = document.evaluate(
36
+ xpathStr,
37
+ document,
38
+ null,
39
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
40
+ null
41
+ );
42
+ const node = result.singleNodeValue;
43
+ if (!node) return null;
44
+ if (attrName === "text") {
45
+ return node.textContent ? node.textContent.trim() : null;
46
+ }
47
+ return node.getAttribute ? node.getAttribute(attrName) : null;
48
+ } catch (e) {
49
+ return null;
50
+ }
51
+ },
52
+ strategy.value,
53
+ attr
54
+ );
55
+ return val;
56
+ }
57
+
58
+ if (type === "text-relative") {
59
+ return await page.evaluate(
60
+ (anchorText, relation) => {
61
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
62
+ let node;
63
+ while ((node = walker.nextNode())) {
64
+ if (node.textContent.includes(anchorText)) {
65
+ const parent = node.parentElement;
66
+ if (relation === "next-sibling") {
67
+ const sibling = parent.nextElementSibling;
68
+ return sibling ? (sibling.innerText || sibling.textContent)?.trim() : null;
69
+ }
70
+ if (relation === "parent") {
71
+ const grandParent = parent.parentElement;
72
+ return grandParent ? (grandParent.innerText || grandParent.textContent)?.trim() : null;
73
+ }
74
+ return (parent.innerText || parent.textContent)?.trim();
75
+ }
76
+ }
77
+ return null;
78
+ },
79
+ strategy.anchor || strategy.value,
80
+ strategy.relation || "self"
81
+ );
82
+ }
83
+
84
+ return null;
85
+ } catch (err) {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Extracts a single field from a Cheerio instance given a strategy.
92
+ */
93
+ function extractCheerioField($, strategy) {
94
+ if (!strategy || !strategy.value) return null;
95
+
96
+ try {
97
+ const type = strategy.type || "css";
98
+ const attr = strategy.attribute || "text";
99
+
100
+ if (type === "css") {
101
+ const el = $(strategy.value);
102
+ if (!el || el.length === 0) return null;
103
+
104
+ if (attr === "text") return extractSmartCheerioText($, el.first());
105
+ if (attr === "html") return el.first().html();
106
+ return el.first().attr(attr) || null;
107
+ }
108
+
109
+ if (type === "xpath") {
110
+ let css = String(strategy.value).trim();
111
+ css = css.replace(/\/\/\*\[@id=["']([^"']+)["']\]/g, "#$1");
112
+ css = css.replace(/\/([a-zA-Z0-9_-]+)\[([0-9]+)\]/g, " > $1:nth-of-type($2)");
113
+ css = css.replace(/\/([a-zA-Z0-9_-]+)/g, " > $1");
114
+ css = css.replace(/^\/+/g, "").replace(/^>\s*/, "").trim();
115
+
116
+ if (css) {
117
+ try {
118
+ const el = $(css);
119
+ if (el && el.length > 0) {
120
+ if (attr === "text") return extractSmartCheerioText($, el.first());
121
+ if (attr === "html") return el.first().html();
122
+ return el.first().attr(attr) || null;
123
+ }
124
+ } catch {
125
+ // Ignore parse error
126
+ }
127
+ }
128
+ }
129
+
130
+ if (type === "text-relative") {
131
+ const anchor = strategy.anchor || strategy.value;
132
+ const match = $(`:contains("${anchor}")`).last();
133
+ if (match && match.length > 0) return extractSmartCheerioText($, match);
134
+ }
135
+
136
+ return null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Extracts a table collection using Cheerio.
144
+ */
145
+ function extractTableCollectionCheerio($, collectionDef) {
146
+ if (!collectionDef) return [];
147
+ const selector = collectionDef.containerSelector || collectionDef.tableSelector || "table";
148
+ const tableEl = $(selector).first();
149
+ if (!tableEl || tableEl.length === 0) return [];
150
+
151
+ const hasHeaders = collectionDef.hasHeaders !== false;
152
+ const headerRowIndex = typeof collectionDef.headerRowIndex === "number" ? collectionDef.headerRowIndex : 0;
153
+ const maxItems = collectionDef.maxItems || 100;
154
+ const columns = collectionDef.columns || [];
155
+
156
+ const rows = [];
157
+ tableEl.find("tr").each((rIdx, rowEl) => {
158
+ const $row = $(rowEl);
159
+ const cells = [];
160
+ $row.find("th, td").each((_, cellEl) => {
161
+ cells.push(extractSmartCheerioText($, $(cellEl)));
162
+ });
163
+
164
+ if (cells.length > 0) {
165
+ rows.push({ rowIndex: rIdx, cells, rowEl: $row });
166
+ }
167
+ });
168
+
169
+ if (rows.length === 0) return [];
170
+
171
+ let activeColumns = [...columns];
172
+ if (activeColumns.length === 0) {
173
+ if (hasHeaders && rows[headerRowIndex]) {
174
+ activeColumns = rows[headerRowIndex].cells.map((headerText, colIdx) => {
175
+ const normalizedKey =
176
+ headerText
177
+ .toLowerCase()
178
+ .replace(/[^a-z0-9]+/g, "_")
179
+ .replace(/^_+|_+$/g, "") || `column_${colIdx + 1}`;
180
+ return {
181
+ key: normalizedKey,
182
+ label: headerText || `Column ${colIdx + 1}`,
183
+ columnIndex: colIdx,
184
+ type: "string",
185
+ excluded: false,
186
+ };
187
+ });
188
+ } else {
189
+ activeColumns = rows[0].cells.map((_, colIdx) => ({
190
+ key: `column_${colIdx + 1}`,
191
+ label: `Column ${colIdx + 1}`,
192
+ columnIndex: colIdx,
193
+ type: "string",
194
+ excluded: false,
195
+ }));
196
+ }
197
+ }
198
+
199
+ const startRowIdx = hasHeaders ? headerRowIndex + 1 : 0;
200
+ const dataRows = rows.slice(startRowIdx, startRowIdx + maxItems);
201
+ const results = [];
202
+
203
+ for (const { cells, rowEl } of dataRows) {
204
+ const record = {};
205
+ for (let colIdx = 0; colIdx < activeColumns.length; colIdx++) {
206
+ const col = activeColumns[colIdx];
207
+ if (col.excluded) continue;
208
+ let rawVal = null;
209
+ const colSelector = col.relativeSelector || col.selector;
210
+
211
+ if (colSelector) {
212
+ const subEl = rowEl.find(colSelector).first();
213
+ if (subEl && subEl.length > 0) {
214
+ const attr = col.attribute || "text";
215
+ rawVal = attr === "text" ? extractSmartCheerioText($, subEl) : subEl.attr(attr) || null;
216
+ }
217
+ } else if (typeof col.columnIndex === "number" && col.columnIndex < cells.length) {
218
+ rawVal = cells[col.columnIndex];
219
+ } else if (colIdx < cells.length) {
220
+ rawVal = cells[colIdx];
221
+ }
222
+
223
+ if ((rawVal === null || rawVal === undefined || rawVal === "") && col.defaultValue !== undefined) {
224
+ rawVal = col.defaultValue;
225
+ }
226
+
227
+ // Apply column transforms
228
+ if (col.transforms && col.transforms.length > 0) {
229
+ rawVal = runTransformPipeline(rawVal, col.transforms);
230
+ }
231
+
232
+ // Cast to target type
233
+ if (col.type) {
234
+ rawVal = castValueToType(rawVal, col.type);
235
+ }
236
+
237
+ record[col.key] = rawVal;
238
+ }
239
+ results.push(record);
240
+ }
241
+
242
+ return results;
243
+ }
244
+
245
+ /**
246
+ * Extracts repeating items/cards using Cheerio.
247
+ */
248
+ function extractRepeatingCollectionCheerio($, collectionDef) {
249
+ if (!collectionDef) return [];
250
+
251
+ let items;
252
+ if (collectionDef.rowSelector) {
253
+ if (collectionDef.containerSelector) {
254
+ items = $(collectionDef.containerSelector).find(collectionDef.rowSelector);
255
+ }
256
+ if (!items || items.length === 0) {
257
+ items = $(collectionDef.rowSelector);
258
+ }
259
+ } else if (collectionDef.containerSelector) {
260
+ items = $(collectionDef.containerSelector);
261
+ } else {
262
+ return [];
263
+ }
264
+
265
+ if (!items || items.length === 0) return [];
266
+
267
+ const maxItems = collectionDef.maxItems || 100;
268
+ const columns = collectionDef.columns || [];
269
+ const results = [];
270
+
271
+ items.slice(0, maxItems).each((_, itemEl) => {
272
+ const $item = $(itemEl);
273
+ const record = {};
274
+
275
+ for (const col of columns) {
276
+ if (col.excluded) continue;
277
+ let rawVal = null;
278
+ const colSelector = col.relativeSelector || col.selector;
279
+
280
+ if (colSelector) {
281
+ const target = $item.find(colSelector).first();
282
+ if (target && target.length > 0) {
283
+ const attr = col.attribute || "text";
284
+ rawVal = attr === "text" ? extractSmartCheerioText($, target) : target.attr(attr) || null;
285
+ }
286
+ } else {
287
+ const attr = col.attribute || "text";
288
+ rawVal = attr === "text" ? extractSmartCheerioText($, $item) : $item.attr(attr) || null;
289
+ }
290
+
291
+ if ((rawVal === null || rawVal === undefined || rawVal === "") && col.defaultValue !== undefined) {
292
+ rawVal = col.defaultValue;
293
+ }
294
+
295
+ // Apply column transforms
296
+ if (col.transforms && col.transforms.length > 0) {
297
+ rawVal = runTransformPipeline(rawVal, col.transforms);
298
+ }
299
+
300
+ // Cast to target type
301
+ if (col.type) {
302
+ rawVal = castValueToType(rawVal, col.type);
303
+ }
304
+
305
+ record[col.key] = rawVal;
306
+ }
307
+
308
+ const hasValue = Object.values(record).some((v) => v !== null && v !== undefined && v !== "");
309
+ if (hasValue) {
310
+ results.push(record);
311
+ }
312
+ });
313
+
314
+ return results;
315
+ }
316
+
317
+ /**
318
+ * Handles SCRAPE_CHROMIUM task via local stealth Puppeteer.
319
+ */
320
+ export async function handleChromiumScrape(payload) {
321
+ const startTime = Date.now();
322
+ const {
323
+ url,
324
+ fieldMappings = [],
325
+ collections = [],
326
+ navigation = {},
327
+ waitFor = {},
328
+ timeout = 30000,
329
+ waitForSelector = null,
330
+ waitForTimeout = 1000,
331
+ actions = [],
332
+ extractSelectors = [],
333
+ headers = {},
334
+ cookies = [],
335
+ viewport = { width: 1366, height: 900 },
336
+ includeHtml = false,
337
+ } = payload;
338
+
339
+ const navTimeout = navigation.timeoutMs || timeout || 30000;
340
+ const navWaitUntil = navigation.waitUntil || "domcontentloaded";
341
+
342
+ const browser = await localBrowserManager.getBrowser();
343
+ const page = await browser.newPage();
344
+
345
+ try {
346
+ await page.setViewport({
347
+ width: viewport.width || 1366,
348
+ height: viewport.height || 900,
349
+ deviceScaleFactor: 1,
350
+ });
351
+
352
+ if (Object.keys(headers).length > 0) {
353
+ await page.setExtraHTTPHeaders(headers);
354
+ }
355
+
356
+ if (Array.isArray(cookies) && cookies.length > 0) {
357
+ await page.setCookie(...cookies);
358
+ }
359
+
360
+ // Navigate
361
+ const response = await page.goto(url, {
362
+ waitUntil: navWaitUntil,
363
+ timeout: navTimeout,
364
+ });
365
+
366
+ const status = response ? response.status() : 200;
367
+
368
+ // Handle wait conditions
369
+ if (waitFor && waitFor.type) {
370
+ const waitTimeout = waitFor.timeoutMs || 10000;
371
+ if (waitFor.type === "SELECTOR_EXISTS" && waitFor.selector) {
372
+ await page.waitForSelector(waitFor.selector, { timeout: waitTimeout }).catch(() => {});
373
+ } else if (waitFor.type === "SELECTOR_VISIBLE" && waitFor.selector) {
374
+ await page.waitForSelector(waitFor.selector, { visible: true, timeout: waitTimeout }).catch(() => {});
375
+ } else if (waitFor.type === "TEXT_PRESENT" && waitFor.text) {
376
+ await page
377
+ .waitForFunction(
378
+ (t) => document.body && document.body.innerText.includes(t),
379
+ { timeout: waitTimeout },
380
+ waitFor.text
381
+ )
382
+ .catch(() => {});
383
+ } else if (waitFor.type === "FIXED_DELAY" && waitFor.delayMs > 0) {
384
+ await new Promise((r) => setTimeout(r, Math.min(waitFor.delayMs, 10000)));
385
+ }
386
+ } else if (waitForSelector) {
387
+ await page.waitForSelector(waitForSelector, { timeout: 10000 }).catch(() => {});
388
+ }
389
+
390
+ // Optional brief delay for dynamic content
391
+ if (waitForTimeout > 0) {
392
+ await new Promise((r) => setTimeout(r, Math.min(waitForTimeout, 10000)));
393
+ }
394
+
395
+ // Execute sequential interactive actions if configured
396
+ if (Array.isArray(actions) && actions.length > 0) {
397
+ for (const action of actions) {
398
+ try {
399
+ if (action.type === "click" && action.selector) {
400
+ await page.click(action.selector);
401
+ } else if (action.type === "type" && action.selector && action.text) {
402
+ await page.type(action.selector, action.text);
403
+ } else if (action.type === "scroll") {
404
+ await page.evaluate((y) => window.scrollBy(0, y || 500), action.distance);
405
+ } else if (action.type === "wait") {
406
+ await new Promise((r) => setTimeout(r, action.duration || 1000));
407
+ }
408
+ } catch (actionErr) {
409
+ console.warn(`[ScrapeHandler] Action ${action.type} failed:`, actionErr.message);
410
+ }
411
+ }
412
+ }
413
+
414
+ const pageTitle = (await page.title()) || "";
415
+ const finalUrl = page.url();
416
+
417
+ const raw = {};
418
+ const strategySummary = {};
419
+
420
+ // 1. Extract mapped fields (Field Schema & Strategies)
421
+ if (Array.isArray(fieldMappings) && fieldMappings.length > 0) {
422
+ for (const mapping of fieldMappings) {
423
+ let extractedVal = null;
424
+ let successfulStrategy = null;
425
+
426
+ const strategies = mapping.strategies || [];
427
+ for (let i = 0; i < strategies.length; i++) {
428
+ const strat = strategies[i];
429
+ const val = await extractBrowserField(page, strat);
430
+ if (val !== null && val !== undefined && val !== "") {
431
+ extractedVal = val;
432
+ successfulStrategy = strat.type || `strategy_${i + 1}`;
433
+ break;
434
+ }
435
+ }
436
+
437
+ if (mapping.transforms && mapping.transforms.length > 0) {
438
+ extractedVal = runTransformPipeline(extractedVal, mapping.transforms);
439
+ }
440
+ if (mapping.type) {
441
+ extractedVal = castValueToType(extractedVal, mapping.type);
442
+ }
443
+
444
+ raw[mapping.field] = extractedVal;
445
+ strategySummary[mapping.field] = {
446
+ extracted: extractedVal !== null,
447
+ strategyUsed: successfulStrategy,
448
+ };
449
+ }
450
+ }
451
+
452
+ // 2. Extract legacy extractSelectors if present
453
+ if (Array.isArray(extractSelectors) && extractSelectors.length > 0) {
454
+ for (const item of extractSelectors) {
455
+ const { field, selector, type = "text", attribute = null } = item;
456
+ if (!field || !selector) continue;
457
+
458
+ try {
459
+ const values = await page.evaluate(
460
+ (sel, t, attr) => {
461
+ const elements = Array.from(document.querySelectorAll(sel));
462
+ return elements.map((el) => {
463
+ if (t === "attribute" && attr) {
464
+ return el.getAttribute(attr) || "";
465
+ }
466
+ if (t === "html") {
467
+ return el.innerHTML;
468
+ }
469
+ return el.innerText ? el.innerText.trim() : el.textContent?.trim() || "";
470
+ });
471
+ },
472
+ selector,
473
+ type,
474
+ attribute
475
+ );
476
+
477
+ const extractedVal = values.length <= 1 ? (values[0] ?? null) : values;
478
+ if (raw[field] === undefined) {
479
+ raw[field] = extractedVal;
480
+ }
481
+ } catch {
482
+ if (raw[field] === undefined) {
483
+ raw[field] = null;
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ // 5. Extract structured collections (Tables & Repeating items)
490
+ const extractedCollections = {};
491
+ if (Array.isArray(collections) && collections.length > 0) {
492
+ for (const coll of collections) {
493
+ if (!coll || !coll.name) continue;
494
+ try {
495
+ if (coll.type === "TABLE") {
496
+ extractedCollections[coll.name] = await page.evaluate((cDef) => {
497
+ const sel = cDef.containerSelector || "table";
498
+ const table = document.querySelector(sel);
499
+ if (!table) return [];
500
+
501
+ const hasHeaders = cDef.hasHeaders !== false;
502
+ const headerRowIndex = typeof cDef.headerRowIndex === "number" ? cDef.headerRowIndex : 0;
503
+ const maxItems = cDef.maxItems || 100;
504
+ const columns = cDef.columns || [];
505
+
506
+ const rowEls = Array.from(table.querySelectorAll("tr"));
507
+ const rowsData = rowEls
508
+ .map((r) => {
509
+ const cells = Array.from(r.querySelectorAll("th, td")).map(
510
+ (c) => c.innerText?.trim() || c.textContent?.trim() || ""
511
+ );
512
+ return { cells, el: r };
513
+ })
514
+ .filter((r) => r.cells.length > 0);
515
+
516
+ if (rowsData.length === 0) return [];
517
+
518
+ let activeColumns = [...columns];
519
+ if (activeColumns.length === 0) {
520
+ if (hasHeaders && rowsData[headerRowIndex]) {
521
+ activeColumns = rowsData[headerRowIndex].cells.map((h, idx) => {
522
+ const key =
523
+ h.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") ||
524
+ `col_${idx + 1}`;
525
+ return { key, label: h || `Column ${idx + 1}`, columnIndex: idx, excluded: false };
526
+ });
527
+ } else {
528
+ activeColumns = rowsData[0].cells.map((_, idx) => ({
529
+ key: `col_${idx + 1}`,
530
+ label: `Column ${idx + 1}`,
531
+ columnIndex: idx,
532
+ excluded: false,
533
+ }));
534
+ }
535
+ }
536
+
537
+ const startIdx = hasHeaders ? headerRowIndex + 1 : 0;
538
+ const sliceRows = rowsData.slice(startIdx, startIdx + maxItems);
539
+ const results = [];
540
+
541
+ for (const row of sliceRows) {
542
+ const rec = {};
543
+ for (let colIdx = 0; colIdx < activeColumns.length; colIdx++) {
544
+ const col = activeColumns[colIdx];
545
+ if (col.excluded) continue;
546
+ let val = null;
547
+ const colSel = col.relativeSelector || col.selector;
548
+ if (colSel) {
549
+ const subEl = row.el.querySelector(colSel);
550
+ if (subEl) {
551
+ const attr = col.attribute || "text";
552
+ val = attr === "text" ? (subEl.innerText?.trim() || subEl.textContent?.trim()) : subEl.getAttribute(attr);
553
+ }
554
+ } else if (typeof col.columnIndex === "number" && col.columnIndex < row.cells.length) {
555
+ val = row.cells[col.columnIndex];
556
+ } else if (colIdx < row.cells.length) {
557
+ val = row.cells[colIdx];
558
+ }
559
+ if ((val === null || val === undefined || val === "") && col.defaultValue !== undefined) {
560
+ val = col.defaultValue;
561
+ }
562
+ rec[col.key] = val;
563
+ }
564
+ results.push(rec);
565
+ }
566
+ return results;
567
+ }, coll);
568
+ } else {
569
+ extractedCollections[coll.name] = await page.evaluate((cDef) => {
570
+ let items = [];
571
+ if (cDef.rowSelector) {
572
+ if (cDef.containerSelector) {
573
+ const container = document.querySelector(cDef.containerSelector);
574
+ if (container) {
575
+ items = Array.from(container.querySelectorAll(cDef.rowSelector));
576
+ }
577
+ }
578
+ if (items.length === 0) {
579
+ items = Array.from(document.querySelectorAll(cDef.rowSelector));
580
+ }
581
+ } else if (cDef.containerSelector) {
582
+ items = Array.from(document.querySelectorAll(cDef.containerSelector));
583
+ }
584
+
585
+ if (items.length === 0) return [];
586
+
587
+ const maxItems = cDef.maxItems || 100;
588
+ const columns = cDef.columns || [];
589
+ const results = [];
590
+
591
+ for (const item of items.slice(0, maxItems)) {
592
+ const rec = {};
593
+ for (const col of columns) {
594
+ if (col.excluded) continue;
595
+ let val = null;
596
+ const colSel = col.relativeSelector || col.selector;
597
+ if (colSel) {
598
+ const target = item.querySelector(colSel);
599
+ if (target) {
600
+ const attr = col.attribute || "text";
601
+ val = attr === "text" ? (target.innerText?.trim() || target.textContent?.trim()) : target.getAttribute(attr);
602
+ }
603
+ } else {
604
+ const attr = col.attribute || "text";
605
+ val = attr === "text" ? (item.innerText?.trim() || item.textContent?.trim()) : item.getAttribute(attr);
606
+ }
607
+ if ((val === null || val === undefined || val === "") && col.defaultValue !== undefined) {
608
+ val = col.defaultValue;
609
+ }
610
+ rec[col.key] = val;
611
+ }
612
+ const hasVal = Object.values(rec).some((v) => v !== null && v !== undefined && v !== "");
613
+ if (hasVal) results.push(rec);
614
+ }
615
+ return results;
616
+ }, coll);
617
+ }
618
+
619
+ // Apply column transforms and type casting
620
+ if (Array.isArray(extractedCollections[coll.name]) && coll.columns) {
621
+ extractedCollections[coll.name] = extractedCollections[coll.name].map((rec) => {
622
+ const transformed = { ...rec };
623
+ for (const col of coll.columns) {
624
+ if (col.excluded) continue;
625
+ let val = transformed[col.key];
626
+ if ((val === null || val === undefined || val === "") && col.defaultValue !== undefined) {
627
+ val = col.defaultValue;
628
+ }
629
+ if (col.transforms && col.transforms.length > 0) {
630
+ val = runTransformPipeline(val, col.transforms);
631
+ }
632
+ if (col.type) {
633
+ val = castValueToType(val, col.type);
634
+ }
635
+ transformed[col.key] = val;
636
+ }
637
+ return transformed;
638
+ });
639
+ }
640
+ } catch {
641
+ extractedCollections[coll.name] = [];
642
+ }
643
+ }
644
+ }
645
+
646
+ const executionTimeMs = Date.now() - startTime;
647
+ const hasExtractedValues = Object.values(raw).some(
648
+ (v) => v !== null && v !== undefined && v !== ""
649
+ );
650
+
651
+ // Avoid transferring huge raw HTML unless requested or nothing was extracted
652
+ let html = "";
653
+ if (includeHtml || (!hasExtractedValues && fieldMappings.length > 0)) {
654
+ html = await page.content().catch(() => "");
655
+ }
656
+
657
+ return {
658
+ status,
659
+ title: pageTitle,
660
+ url: finalUrl,
661
+ raw,
662
+ strategySummary,
663
+ extractedData: raw,
664
+ collections: extractedCollections,
665
+ html,
666
+ executionTimeMs,
667
+ };
668
+ } finally {
669
+ await page.close().catch(() => {});
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Handles fast SCRAPE_HTTP task via Cheerio.
675
+ */
676
+ export async function handleHttpScrape(payload) {
677
+ const startTime = Date.now();
678
+ const {
679
+ url,
680
+ fieldMappings = [],
681
+ collections = [],
682
+ timeout = 15000,
683
+ headers = {},
684
+ extractSelectors = [],
685
+ includeHtml = false,
686
+ } = payload;
687
+
688
+ const response = await axios.get(url, {
689
+ 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
+ },
697
+ });
698
+
699
+ let pageHtml = typeof response.data === "string" ? response.data : Buffer.isBuffer(response.data) ? response.data.toString("utf-8") : "";
700
+ const $ = cheerio.load(pageHtml);
701
+ const title = $("title").first().text().trim() || "";
702
+
703
+ const raw = {};
704
+ const strategySummary = {};
705
+
706
+ // 1. Extract fieldMappings
707
+ if (Array.isArray(fieldMappings) && fieldMappings.length > 0) {
708
+ for (const mapping of fieldMappings) {
709
+ let extractedVal = null;
710
+ let successfulStrategy = null;
711
+
712
+ const strategies = mapping.strategies || [];
713
+ for (let i = 0; i < strategies.length; i++) {
714
+ const strat = strategies[i];
715
+ const val = extractCheerioField($, strat);
716
+ if (val !== null && val !== undefined && val !== "") {
717
+ extractedVal = val;
718
+ successfulStrategy = strat.type || `strategy_${i + 1}`;
719
+ break;
720
+ }
721
+ }
722
+
723
+ if (mapping.transforms && mapping.transforms.length > 0) {
724
+ extractedVal = runTransformPipeline(extractedVal, mapping.transforms);
725
+ }
726
+ if (mapping.type) {
727
+ extractedVal = castValueToType(extractedVal, mapping.type);
728
+ }
729
+
730
+ raw[mapping.field] = extractedVal;
731
+ strategySummary[mapping.field] = {
732
+ extracted: extractedVal !== null,
733
+ strategyUsed: successfulStrategy,
734
+ };
735
+ }
736
+ }
737
+
738
+ // 2. Extract legacy extractSelectors
739
+ if (Array.isArray(extractSelectors) && extractSelectors.length > 0) {
740
+ for (const item of extractSelectors) {
741
+ const { field, selector, type = "text", attribute = null } = item;
742
+ if (!field || !selector) continue;
743
+
744
+ try {
745
+ const values = [];
746
+ $(selector).each((_, el) => {
747
+ if (type === "attribute" && attribute) {
748
+ values.push($(el).attr(attribute) || "");
749
+ } else if (type === "html") {
750
+ values.push($(el).html() || "");
751
+ } else {
752
+ values.push($(el).text().trim());
753
+ }
754
+ });
755
+
756
+ const extractedVal = values.length <= 1 ? (values[0] ?? null) : values;
757
+ if (raw[field] === undefined) {
758
+ raw[field] = extractedVal;
759
+ }
760
+ } catch {
761
+ if (raw[field] === undefined) {
762
+ raw[field] = null;
763
+ }
764
+ }
765
+ }
766
+ }
767
+
768
+ // 3. Extract structured collections (Tables & Repeating items)
769
+ const extractedCollections = {};
770
+ if (Array.isArray(collections) && collections.length > 0) {
771
+ for (const coll of collections) {
772
+ if (!coll || !coll.name) continue;
773
+ try {
774
+ if (coll.type === "TABLE") {
775
+ extractedCollections[coll.name] = extractTableCollectionCheerio($, coll);
776
+ } else {
777
+ extractedCollections[coll.name] = extractRepeatingCollectionCheerio($, coll);
778
+ }
779
+ } catch {
780
+ extractedCollections[coll.name] = [];
781
+ }
782
+ }
783
+ }
784
+
785
+ const executionTimeMs = Date.now() - startTime;
786
+ const hasExtractedValues = Object.values(raw).some(
787
+ (v) => v !== null && v !== undefined && v !== ""
788
+ );
789
+
790
+ return {
791
+ status: response.status,
792
+ title,
793
+ url,
794
+ raw,
795
+ strategySummary,
796
+ extractedData: raw,
797
+ collections: extractedCollections,
798
+ html: includeHtml || (!hasExtractedValues && fieldMappings.length > 0) ? pageHtml : "",
799
+ executionTimeMs,
800
+ };
801
+ }