app-manager-edge-worker 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-manager-edge-worker",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
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": {
@@ -287,6 +287,142 @@ export async function handleInspectElement(payload) {
287
287
 
288
288
  const text = el.innerText ? el.innerText.trim() : el.textContent?.trim() || "";
289
289
 
290
+ // ─── Table Detection (US-01, US-02, US-03) ───
291
+ let tableDetection = null;
292
+ const tableEl = el.closest("table");
293
+ if (tableEl) {
294
+ const rows = Array.from(tableEl.querySelectorAll("tr"));
295
+ if (rows.length > 0) {
296
+ let tableSelector = "table";
297
+ if (tableEl.id) {
298
+ tableSelector = `table#${tableEl.id}`;
299
+ } else if (tableEl.className && typeof tableEl.className === "string") {
300
+ const cls = tableEl.className.split(/\s+/).filter(c => c && /^[a-zA-Z_-][\w-]*$/.test(c));
301
+ if (cls.length > 0) tableSelector = `table.${cls.slice(0, 2).join(".")}`;
302
+ }
303
+
304
+ const theadThs = Array.from(tableEl.querySelectorAll("thead th, thead td"));
305
+ const firstRowCells = Array.from(rows[0].querySelectorAll("th, td"));
306
+ const headerCells = theadThs.length > 0 ? theadThs : firstRowCells;
307
+
308
+ const columns = headerCells.map((c, colIdx) => {
309
+ const rawHeader = (c.innerText?.trim() || c.textContent?.trim() || "").replace(/\s+/g, " ");
310
+ const normalizedKey = rawHeader.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || `column_${colIdx + 1}`;
311
+ return { key: normalizedKey, label: rawHeader || `Column ${colIdx + 1}`, columnIndex: colIdx, type: "string", sampleValues: [] };
312
+ });
313
+
314
+ const sampleRows = [];
315
+ for (const dr of rows.slice(1, 6)) {
316
+ const cells = Array.from(dr.querySelectorAll("td, th")).map(c => c.innerText?.trim() || c.textContent?.trim() || "");
317
+ const rec = {};
318
+ columns.forEach((col, idx) => {
319
+ rec[col.key] = cells[idx] !== undefined ? cells[idx] : "";
320
+ });
321
+ sampleRows.push(rec);
322
+ }
323
+
324
+ tableDetection = {
325
+ isTable: true,
326
+ tableSelector,
327
+ hasHeaders: true,
328
+ headerRowIndex: 0,
329
+ totalRows: rows.length,
330
+ columns,
331
+ sampleRows,
332
+ };
333
+ }
334
+ }
335
+
336
+ // ─── Repeating Structure Detection (US-04, US-05, US-23) ───
337
+ let repeatingDetection = null;
338
+ if (!tableEl) {
339
+ let currR = el;
340
+ let depth = 0;
341
+ while (currR && currR !== document.body && depth < 5) {
342
+ const parent = currR.parentElement;
343
+ if (parent && parent !== document.documentElement) {
344
+ const tag = currR.tagName.toLowerCase();
345
+ const classes = (currR.className && typeof currR.className === "string")
346
+ ? currR.className.split(/\s+/).filter(c => c && /^[a-zA-Z_-][\w-]*$/.test(c))
347
+ : [];
348
+ let selCandidate = tag;
349
+ if (classes.length > 0) selCandidate = `${tag}.${classes.slice(0, 2).join(".")}`;
350
+
351
+ const siblings = Array.from(parent.children).filter(c => {
352
+ if (c.tagName.toLowerCase() !== tag) return false;
353
+ if (classes.length > 0 && c.classList) {
354
+ return classes.some(cls => c.classList.contains(cls));
355
+ }
356
+ return true;
357
+ });
358
+
359
+ if (siblings.length >= 2) {
360
+ let parentSel = parent.tagName.toLowerCase();
361
+ if (parent.id) parentSel = `#${parent.id}`;
362
+ else if (parent.className && typeof parent.className === "string") {
363
+ const pCls = parent.className.split(/\s+/).filter(c => c && /^[a-zA-Z_-][\w-]*$/.test(c));
364
+ if (pCls.length > 0) parentSel = `${parentSel}.${pCls.slice(0, 2).join(".")}`;
365
+ }
366
+ const containerSelector = `${parentSel} > ${selCandidate}`;
367
+
368
+ const repCard = siblings[0];
369
+ const suggestedFields = [];
370
+
371
+ const heading = repCard.querySelector("h1, h2, h3, h4, h5, .title, [class*='title'], .name, [class*='name']");
372
+ if (heading) {
373
+ const tagH = heading.tagName.toLowerCase();
374
+ const clsH = heading.className && typeof heading.className === "string" ? heading.className.split(/\s+/)[0] : "";
375
+ suggestedFields.push({ key: "title", label: "Title", relativeSelector: clsH ? `.${clsH}` : tagH, attribute: "text", type: "string" });
376
+ }
377
+
378
+ const priceEl = repCard.querySelector(".price, [class*='price'], [data-price]");
379
+ if (priceEl) {
380
+ const clsP = priceEl.className && typeof priceEl.className === "string" ? priceEl.className.split(/\s+/)[0] : "";
381
+ suggestedFields.push({ key: "price", label: "Price", relativeSelector: clsP ? `.${clsP}` : "[class*='price']", attribute: "text", type: "number" });
382
+ }
383
+
384
+ const imgEl = repCard.querySelector("img");
385
+ if (imgEl && imgEl.getAttribute("src")) {
386
+ suggestedFields.push({ key: "image", label: "Image URL", relativeSelector: "img", attribute: "src", type: "string" });
387
+ }
388
+
389
+ const linkEl = repCard.querySelector("a[href]") || (repCard.tagName.toLowerCase() === "a" ? repCard : null);
390
+ if (linkEl && linkEl.getAttribute("href")) {
391
+ suggestedFields.push({ key: "url", label: "Link", relativeSelector: repCard.tagName.toLowerCase() === "a" ? "" : "a", attribute: "href", type: "string" });
392
+ }
393
+
394
+ const sampleItems = siblings.slice(0, 3).map((item) => {
395
+ const rec = {};
396
+ suggestedFields.forEach((sf) => {
397
+ let val = "";
398
+ if (sf.relativeSelector) {
399
+ const sub = item.querySelector(sf.relativeSelector);
400
+ if (sub) {
401
+ val = sf.attribute === "text" ? (sub.innerText?.trim() || sub.textContent?.trim()) : sub.getAttribute(sf.attribute);
402
+ }
403
+ } else {
404
+ val = sf.attribute === "text" ? (item.innerText?.trim() || item.textContent?.trim()) : item.getAttribute(sf.attribute);
405
+ }
406
+ rec[sf.key] = val || "";
407
+ });
408
+ return rec;
409
+ });
410
+
411
+ repeatingDetection = {
412
+ isRepeating: true,
413
+ containerSelector,
414
+ itemCount: siblings.length,
415
+ suggestedFields,
416
+ sampleItems,
417
+ };
418
+ break;
419
+ }
420
+ }
421
+ currR = currR.parentElement;
422
+ depth++;
423
+ }
424
+ }
425
+
290
426
  return {
291
427
  found: true,
292
428
  tagName: el.tagName.toLowerCase(),
@@ -298,6 +434,8 @@ export async function handleInspectElement(payload) {
298
434
  candidates,
299
435
  hierarchy,
300
436
  boundingRect,
437
+ tableDetection,
438
+ repeatingDetection,
301
439
  };
302
440
  }, x, y);
303
441
 
@@ -1,6 +1,7 @@
1
1
  import axios from "axios";
2
2
  import * as cheerio from "cheerio";
3
3
  import localBrowserManager from "../browser.js";
4
+ import { extractSmartCheerioText, runTransformPipeline, castValueToType } from "./transformUtils.js";
4
5
 
5
6
  /**
6
7
  * Extracts a single field value from a Puppeteer page given a strategy.
@@ -100,7 +101,7 @@ function extractCheerioField($, strategy) {
100
101
  const el = $(strategy.value);
101
102
  if (!el || el.length === 0) return null;
102
103
 
103
- if (attr === "text") return el.first().text().trim();
104
+ if (attr === "text") return extractSmartCheerioText($, el.first());
104
105
  if (attr === "html") return el.first().html();
105
106
  return el.first().attr(attr) || null;
106
107
  }
@@ -116,7 +117,7 @@ function extractCheerioField($, strategy) {
116
117
  try {
117
118
  const el = $(css);
118
119
  if (el && el.length > 0) {
119
- if (attr === "text") return el.first().text().trim();
120
+ if (attr === "text") return extractSmartCheerioText($, el.first());
120
121
  if (attr === "html") return el.first().html();
121
122
  return el.first().attr(attr) || null;
122
123
  }
@@ -129,7 +130,7 @@ function extractCheerioField($, strategy) {
129
130
  if (type === "text-relative") {
130
131
  const anchor = strategy.anchor || strategy.value;
131
132
  const match = $(`:contains("${anchor}")`).last();
132
- if (match && match.length > 0) return match.text().trim();
133
+ if (match && match.length > 0) return extractSmartCheerioText($, match);
133
134
  }
134
135
 
135
136
  return null;
@@ -138,6 +139,181 @@ function extractCheerioField($, strategy) {
138
139
  }
139
140
  }
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
+
141
317
  /**
142
318
  * Handles SCRAPE_CHROMIUM task via local stealth Puppeteer.
143
319
  */
@@ -146,6 +322,7 @@ export async function handleChromiumScrape(payload) {
146
322
  const {
147
323
  url,
148
324
  fieldMappings = [],
325
+ collections = [],
149
326
  navigation = {},
150
327
  waitFor = {},
151
328
  timeout = 30000,
@@ -257,6 +434,13 @@ export async function handleChromiumScrape(payload) {
257
434
  }
258
435
  }
259
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
+
260
444
  raw[mapping.field] = extractedVal;
261
445
  strategySummary[mapping.field] = {
262
446
  extracted: extractedVal !== null,
@@ -302,6 +486,163 @@ export async function handleChromiumScrape(payload) {
302
486
  }
303
487
  }
304
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
+
305
646
  const executionTimeMs = Date.now() - startTime;
306
647
  const hasExtractedValues = Object.values(raw).some(
307
648
  (v) => v !== null && v !== undefined && v !== ""
@@ -320,6 +661,7 @@ export async function handleChromiumScrape(payload) {
320
661
  raw,
321
662
  strategySummary,
322
663
  extractedData: raw,
664
+ collections: extractedCollections,
323
665
  html,
324
666
  executionTimeMs,
325
667
  };
@@ -336,6 +678,7 @@ export async function handleHttpScrape(payload) {
336
678
  const {
337
679
  url,
338
680
  fieldMappings = [],
681
+ collections = [],
339
682
  timeout = 15000,
340
683
  headers = {},
341
684
  extractSelectors = [],
@@ -353,7 +696,7 @@ export async function handleHttpScrape(payload) {
353
696
  },
354
697
  });
355
698
 
356
- const pageHtml = typeof response.data === "string" ? response.data : "";
699
+ let pageHtml = typeof response.data === "string" ? response.data : Buffer.isBuffer(response.data) ? response.data.toString("utf-8") : "";
357
700
  const $ = cheerio.load(pageHtml);
358
701
  const title = $("title").first().text().trim() || "";
359
702
 
@@ -377,6 +720,13 @@ export async function handleHttpScrape(payload) {
377
720
  }
378
721
  }
379
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
+
380
730
  raw[mapping.field] = extractedVal;
381
731
  strategySummary[mapping.field] = {
382
732
  extracted: extractedVal !== null,
@@ -415,6 +765,23 @@ export async function handleHttpScrape(payload) {
415
765
  }
416
766
  }
417
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
+
418
785
  const executionTimeMs = Date.now() - startTime;
419
786
  const hasExtractedValues = Object.values(raw).some(
420
787
  (v) => v !== null && v !== undefined && v !== ""
@@ -427,6 +794,7 @@ export async function handleHttpScrape(payload) {
427
794
  raw,
428
795
  strategySummary,
429
796
  extractedData: raw,
797
+ collections: extractedCollections,
430
798
  html: includeHtml || (!hasExtractedValues && fieldMappings.length > 0) ? pageHtml : "",
431
799
  executionTimeMs,
432
800
  };
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Transformation and text extraction utilities for the Edge Worker.
3
+ */
4
+
5
+ /**
6
+ * Safely extracts text from Cheerio elements while preserving block/boundary line breaks (\n).
7
+ * Prevents glued text like "678.0013.30 (2.00%)" caused by native $(el).text() concatenation.
8
+ *
9
+ * @param {cheerio.CheerioAPI} $
10
+ * @param {cheerio.Cheerio} el
11
+ * @returns {string}
12
+ */
13
+ export function extractSmartCheerioText($, el) {
14
+ if (!el || el.length === 0) return "";
15
+ try {
16
+ const clone = $(el).clone();
17
+ clone.find("br").replaceWith("\n");
18
+ clone.find("div, p, span, small, button, li, h1, h2, h3, h4, h5, h6, tr, td, th, section, article, header, footer, nav, aside, dt, dd").before("\n").after("\n");
19
+ return clone
20
+ .text()
21
+ .split("\n")
22
+ .map((s) => s.trim())
23
+ .filter(Boolean)
24
+ .join("\n");
25
+ } catch {
26
+ return $(el).text().trim();
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Executes a single transformation step safely on an input value.
32
+ *
33
+ * @param {*} value
34
+ * @param {Object} step
35
+ * @returns {*}
36
+ */
37
+ export function applyTransformStep(value, step) {
38
+ if (!step) return value;
39
+
40
+ const op = (step.operation || step.op || "").toLowerCase();
41
+ if (!op) return value;
42
+
43
+ if (op === "default") {
44
+ if (value === null || value === undefined || value === "") {
45
+ const defVal = step.value !== undefined ? step.value : step.params?.value;
46
+ return defVal !== undefined ? defVal : null;
47
+ }
48
+ return value;
49
+ }
50
+
51
+ if (value === null || value === undefined) {
52
+ return value;
53
+ }
54
+
55
+ switch (op) {
56
+ case "trim": {
57
+ return typeof value === "string" ? value.trim() : String(value).trim();
58
+ }
59
+
60
+ case "lowercase": {
61
+ return typeof value === "string" ? value.toLowerCase() : String(value).toLowerCase();
62
+ }
63
+
64
+ case "uppercase": {
65
+ return typeof value === "string" ? value.toUpperCase() : String(value).toUpperCase();
66
+ }
67
+
68
+ case "remove": {
69
+ const toRemove = step.value !== undefined ? String(step.value) : step.params?.value !== undefined ? String(step.params.value) : "";
70
+ if (!toRemove) return value;
71
+ return String(value).split(toRemove).join("");
72
+ }
73
+
74
+ case "remove_prefix": {
75
+ const prefix = step.value !== undefined ? String(step.value) : step.params?.value !== undefined ? String(step.params.value) : "";
76
+ if (!prefix) return value;
77
+ const strVal = String(value).trim();
78
+ if (strVal.startsWith(prefix)) {
79
+ return strVal.slice(prefix.length).trim();
80
+ }
81
+ return strVal;
82
+ }
83
+
84
+ case "remove_suffix": {
85
+ const suffix = step.value !== undefined ? String(step.value) : step.params?.value !== undefined ? String(step.params.value) : "";
86
+ if (!suffix) return value;
87
+ const strVal = String(value).trim();
88
+ if (strVal.endsWith(suffix)) {
89
+ return strVal.slice(0, strVal.length - suffix.length).trim();
90
+ }
91
+ return strVal;
92
+ }
93
+
94
+ case "replace": {
95
+ const fromStr = step.from !== undefined ? String(step.from) : step.params?.from !== undefined ? String(step.params.from) : "";
96
+ const toStr = step.to !== undefined ? String(step.to) : step.params?.to !== undefined ? String(step.params.to) : "";
97
+ return String(value).split(fromStr).join(toStr);
98
+ }
99
+
100
+ case "regex_extract": {
101
+ const pattern = step.pattern || step.params?.pattern;
102
+ if (!pattern) return value;
103
+ try {
104
+ const regex = new RegExp(pattern);
105
+ const match = String(value).match(regex);
106
+ if (!match) return null;
107
+ return match[1] !== undefined ? match[1] : match[0];
108
+ } catch {
109
+ return value;
110
+ }
111
+ }
112
+
113
+ case "regex_replace": {
114
+ const pattern = step.pattern || step.params?.pattern;
115
+ if (!pattern) return value;
116
+ try {
117
+ const regex = new RegExp(pattern, "g");
118
+ const toStr = step.to !== undefined ? String(step.to) : step.params?.to !== undefined ? String(step.params.to) : "";
119
+ return String(value).replace(regex, toStr);
120
+ } catch {
121
+ return value;
122
+ }
123
+ }
124
+
125
+ case "split": {
126
+ let sep = step.separator !== undefined ? String(step.separator) : step.params?.separator !== undefined ? String(step.params.separator) : step.params?.delimiter !== undefined ? String(step.params.delimiter) : step.value !== undefined ? String(step.value) : "\n";
127
+ if (sep === "\\n") sep = "\n";
128
+ else if (sep === "\\t") sep = "\t";
129
+ const parts = String(value).split(sep).map((s) => s.trim());
130
+ const index = step.index !== undefined ? step.index : step.params?.index;
131
+ if (index !== undefined && index !== null) {
132
+ const idx = parseInt(index, 10);
133
+ if (isNaN(idx)) return null;
134
+ const targetIdx = idx < 0 ? parts.length + idx : idx;
135
+ return targetIdx >= 0 && targetIdx < parts.length ? parts[targetIdx] : null;
136
+ }
137
+ return parts;
138
+ }
139
+
140
+ case "first_line": {
141
+ const parts = String(value).split("\n").map((s) => s.trim()).filter(Boolean);
142
+ return parts.length > 0 ? parts[0] : "";
143
+ }
144
+
145
+ case "second_line": {
146
+ const parts = String(value).split("\n").map((s) => s.trim()).filter(Boolean);
147
+ return parts.length > 1 ? parts[1] : "";
148
+ }
149
+
150
+ case "line": {
151
+ const idx = parseInt(step.index ?? step.value ?? step.params?.index ?? step.params?.value ?? 0, 10);
152
+ const parts = String(value).split("\n").map((s) => s.trim()).filter(Boolean);
153
+ if (isNaN(idx)) return parts[0] || "";
154
+ const targetIdx = idx < 0 ? parts.length + idx : idx;
155
+ return targetIdx >= 0 && targetIdx < parts.length ? parts[targetIdx] : "";
156
+ }
157
+
158
+ case "contains":
159
+ case "includes":
160
+ case "has_badge": {
161
+ const searchStr = step.value !== undefined ? String(step.value) : step.pattern !== undefined ? String(step.pattern) : step.params?.badgeKeyword !== undefined ? String(step.params.badgeKeyword) : step.params?.value !== undefined ? String(step.params.value) : "";
162
+ if (!searchStr) return false;
163
+ const caseSensitive = step.caseSensitive === true || step.params?.caseSensitive === true;
164
+ const strVal = String(value);
165
+ return caseSensitive
166
+ ? strVal.includes(searchStr)
167
+ : strVal.toLowerCase().includes(searchStr.toLowerCase());
168
+ }
169
+
170
+ case "cast": {
171
+ const targetType = (step.targetType || step.params?.targetType || step.type || step.value || "string").toLowerCase();
172
+ return castValueToType(value, targetType);
173
+ }
174
+
175
+ case "join": {
176
+ const delimiter = step.delimiter !== undefined ? String(step.delimiter) : step.value !== undefined ? String(step.value) : ", ";
177
+ return Array.isArray(value) ? value.join(delimiter) : String(value);
178
+ }
179
+
180
+ case "currency_cleanup": {
181
+ if (typeof value === "number") return value;
182
+ const str = String(value).trim();
183
+ const cleaned = str
184
+ .replace(/[\u20B9$€£¥₹]/g, "")
185
+ .replace(/\b(USD|EUR|GBP|INR|CAD|AUD|JPY|CHF|AED|Rs\.?|Rupees?)\b/gi, "")
186
+ .replace(/\s+/g, " ")
187
+ .replace(/,/g, "")
188
+ .trim();
189
+ const numMatch = cleaned.match(/[-+]?[0-9]+(?:\.[0-9]+)?/);
190
+ return numMatch ? parseFloat(numMatch[0]) : null;
191
+ }
192
+
193
+ case "number":
194
+ case "decimal": {
195
+ if (typeof value === "number") return value;
196
+ const str = String(value).trim().replace(/,/g, "");
197
+ const numMatch = str.match(/[-+]?[0-9]+(?:\.[0-9]+)?/);
198
+ return numMatch ? parseFloat(numMatch[0]) : null;
199
+ }
200
+
201
+ case "integer": {
202
+ if (typeof value === "number") return Math.round(value);
203
+ const str = String(value).trim().replace(/,/g, "");
204
+ const numMatch = str.match(/[-+]?[0-9]+/);
205
+ return numMatch ? parseInt(numMatch[0], 10) : null;
206
+ }
207
+
208
+ case "parse_boolean":
209
+ case "boolean": {
210
+ if (typeof value === "boolean") return value;
211
+ const str = String(value).trim().toLowerCase();
212
+ if (["true", "yes", "y", "1", "in stock", "available", "on", "active"].includes(str)) return true;
213
+ if (["false", "no", "n", "0", "out of stock", "unavailable", "off", "inactive"].includes(str)) return false;
214
+ return Boolean(value);
215
+ }
216
+
217
+ case "has_value":
218
+ case "is_not_empty":
219
+ case "not_empty":
220
+ case "exists": {
221
+ if (value === null || value === undefined) return false;
222
+ const lineIdx = step.lineIndex !== undefined ? step.lineIndex : step.params?.lineIndex !== undefined ? step.params?.lineIndex : step.index !== undefined ? step.index : step.params?.index;
223
+ if (lineIdx !== undefined && lineIdx !== null && lineIdx !== "") {
224
+ const idx = parseInt(lineIdx, 10);
225
+ const parts = String(value).split("\n").map((s) => s.trim()).filter(Boolean);
226
+ const targetIdx = idx > 0 ? idx - 1 : idx; // 1-indexed line number
227
+ return targetIdx >= 0 && targetIdx < parts.length && parts[targetIdx].length > 0;
228
+ }
229
+ const str = String(value).trim();
230
+ return str.length > 0 && str.toLowerCase() !== "false" && str !== "0";
231
+ }
232
+
233
+ case "custom":
234
+ case "expression":
235
+ case "script": {
236
+ const code = step.code || step.expression || step.value || step.script || step.params?.code || step.params?.expression || step.params?.script;
237
+ if (!code || typeof code !== "string") return value;
238
+ try {
239
+ const trimmed = code.trim();
240
+ if (/\b(process|require|import|global|Buffer|__dirname|__filename|eval|Function)\b/.test(trimmed)) {
241
+ return value;
242
+ }
243
+
244
+ // Auto-quote unquoted string after == or === or != or !== e.g. == Vol Shocker -> == "Vol Shocker"
245
+ let sanitizedCode = trimmed.replace(/([=!]==?\s*)([A-Za-z][A-Za-z0-9 _-]*[A-Za-z0-9])(?=\s*$|\s*[;)])/g, (match, op, str) => {
246
+ const trimmedStr = str.trim();
247
+ if (['true', 'false', 'null', 'undefined', 'value', 'raw', 'lines'].includes(trimmedStr)) return match;
248
+ if (trimmedStr.startsWith('"') || trimmedStr.startsWith("'")) return match;
249
+ return `${op}"${trimmedStr}"`;
250
+ });
251
+
252
+ const lines = String(value).split("\n").map((s) => s.trim()).filter(Boolean);
253
+ const line = (n) => {
254
+ const idx = parseInt(n, 10);
255
+ const targetIdx = idx > 0 ? idx - 1 : idx;
256
+ return lines[targetIdx] || "";
257
+ };
258
+ const hasLine = (n) => {
259
+ const idx = parseInt(n, 10);
260
+ const targetIdx = idx > 0 ? idx - 1 : idx;
261
+ return targetIdx >= 0 && targetIdx < lines.length && lines[targetIdx].length > 0;
262
+ };
263
+ const has = (k) => String(value).toLowerCase().includes(String(k).toLowerCase());
264
+ const contains = has;
265
+ const hasValue = (v = value) => v !== null && v !== undefined && String(v).trim().length > 0;
266
+ const notEmpty = hasValue;
267
+
268
+ const fn = sanitizedCode.includes("return ")
269
+ ? new Function("value", "raw", "lines", "line", "hasLine", "has", "contains", "hasValue", "notEmpty", sanitizedCode)
270
+ : new Function("value", "raw", "lines", "line", "hasLine", "has", "contains", "hasValue", "notEmpty", `return (${sanitizedCode});`);
271
+ const result = fn(value, value, lines, line, hasLine, has, contains, hasValue, notEmpty);
272
+ return result !== undefined ? result : value;
273
+ } catch {
274
+ return value;
275
+ }
276
+ }
277
+
278
+ default:
279
+ return value;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Runs an array of transform steps sequentially on a raw extracted value.
285
+ *
286
+ * @param {*} rawValue
287
+ * @param {Array<Object>} pipeline
288
+ * @returns {*}
289
+ */
290
+ export function runTransformPipeline(rawValue, pipeline = []) {
291
+ if (!Array.isArray(pipeline) || pipeline.length === 0) {
292
+ return rawValue;
293
+ }
294
+
295
+ let current = rawValue;
296
+ for (const step of pipeline) {
297
+ current = applyTransformStep(current, step);
298
+ }
299
+ return current;
300
+ }
301
+
302
+ /**
303
+ * Casts a transformed value to the target data type.
304
+ *
305
+ * @param {*} val
306
+ * @param {string} type
307
+ * @returns {*}
308
+ */
309
+ export function castValueToType(val, type = "string") {
310
+ if (val === null || val === undefined) return val;
311
+
312
+ switch (type) {
313
+ case "integer": {
314
+ if (typeof val === "number") return Math.round(val);
315
+ const str = String(val).replace(/,/g, "").trim();
316
+ const num = parseInt(str, 10);
317
+ return isNaN(num) ? null : num;
318
+ }
319
+ case "number":
320
+ case "decimal": {
321
+ if (typeof val === "number") return val;
322
+ const str = String(val).replace(/[\u20B9$€£¥₹]/g, "").replace(/,/g, "").trim();
323
+ const num = parseFloat(str);
324
+ return isNaN(num) ? null : num;
325
+ }
326
+ case "boolean": {
327
+ if (typeof val === "boolean") return val;
328
+ const str = String(val).trim().toLowerCase();
329
+ return ["true", "yes", "1", "in stock", "available"].includes(str);
330
+ }
331
+ case "date": {
332
+ if (val instanceof Date) return val.toISOString();
333
+ const parsed = Date.parse(String(val));
334
+ return !isNaN(parsed) ? new Date(parsed).toISOString() : String(val);
335
+ }
336
+ case "string":
337
+ default:
338
+ return typeof val === "object" ? JSON.stringify(val) : String(val);
339
+ }
340
+ }