@avocadostudio-ai/migration-sdk 0.1.0

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.
@@ -0,0 +1,911 @@
1
+ /**
2
+ * HTML section extractor — converts raw HTML into structured sections
3
+ * for LLM-based block mapping.
4
+ *
5
+ * Instead of sending 50KB of raw HTML to the LLM, this module identifies
6
+ * semantic section boundaries, classifies them by CSS class patterns,
7
+ * extracts structured content, and resolves lazy-loaded images.
8
+ */
9
+ /** CSS class/id patterns → suggested block type */
10
+ const CLASS_PATTERNS = [
11
+ // Navigation/chrome (skip)
12
+ { pattern: /\b(header|nav|navbar|menu|top-bar|site-header)\b/i, blockType: "__header__" },
13
+ { pattern: /\b(footer|site-footer|bottom-bar)\b/i, blockType: "__footer__" },
14
+ // Content blocks
15
+ { pattern: /\b(hero|banner|jumbotron|masthead|cover|above-fold)\b/i, blockType: "Hero" },
16
+ { pattern: /\b(features?|benefits?|services?|usp|advantages?|why-us|icon-box)\b/i, blockType: "FeatureGrid" },
17
+ { pattern: /\b(testimonials?|reviews?|quote|feedback|client-say|customer-say)\b/i, blockType: "Testimonials" },
18
+ { pattern: /\b(faq|accordion|question|q-and-a)\b/i, blockType: "FAQAccordion" },
19
+ { pattern: /\b(pricing|plan|tier|price-table|price-card)\b/i, blockType: "CardGrid" },
20
+ { pattern: /\b(stat|counter|number|metric|count-up|fun-fact)\b/i, blockType: "Stats" },
21
+ { pattern: /\b(gallery|portfolio|lightbox|image-grid|photo)\b/i, blockType: "Gallery" },
22
+ { pattern: /\b(team|member|staff|people|employee|about-us)\b/i, blockType: "CardGrid" },
23
+ { pattern: /\b(cta|call-to-action|contact-form|get-started|sign-up)\b/i, blockType: "CTA" },
24
+ { pattern: /\b(card|grid|post|blog|news|article-list)\b/i, blockType: "CardGrid" },
25
+ { pattern: /\b(tab|tabbed|tab-content)\b/i, blockType: "Tabs" },
26
+ { pattern: /\b(carousel|slider|swiper|slideshow)\b/i, blockType: "Carousel" },
27
+ { pattern: /\b(video|youtube|vimeo|media-player)\b/i, blockType: "Video" },
28
+ { pattern: /\b(embed|iframe|map|widget)\b/i, blockType: "Embed" },
29
+ { pattern: /\b(table|data-table|comparison)\b/i, blockType: "Table" },
30
+ { pattern: /\b(rich-text|text-block|content-area|wysiwyg)\b/i, blockType: "RichText" },
31
+ { pattern: /\b(two-col|split|columns|side-by-side)\b/i, blockType: "TwoColumn" },
32
+ ];
33
+ /** Elementor-specific widget type → block type */
34
+ const ELEMENTOR_WIDGET_MAP = {
35
+ "heading": "RichText",
36
+ "text-editor": "RichText",
37
+ "image": "Gallery",
38
+ "image-box": "FeatureGrid",
39
+ "icon-box": "FeatureGrid",
40
+ "icon-list": "FeatureGrid",
41
+ "counter": "Stats",
42
+ "testimonial": "Testimonials",
43
+ "tabs": "Tabs",
44
+ "accordion": "FAQAccordion",
45
+ "toggle": "FAQAccordion",
46
+ "video": "Video",
47
+ "image-gallery": "Gallery",
48
+ "image-carousel": "Carousel",
49
+ "google-maps": "Embed",
50
+ "button": "CTA",
51
+ "call-to-action": "CTA",
52
+ "price-table": "CardGrid",
53
+ "price-list": "CardGrid",
54
+ };
55
+ /** Attributes that hold lazy-loaded image sources */
56
+ const LAZY_SRC_ATTRS = ["data-src", "data-lazy-src", "data-original", "data-bg", "data-background-image"];
57
+ // ── HTML parsing helpers (regex-based, no external DOM parser) ──
58
+ /** Extract attribute value from a tag string */
59
+ function getAttr(tag, attr) {
60
+ // Match attr="value" or attr='value'
61
+ const re = new RegExp(`${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i");
62
+ const m = tag.match(re);
63
+ return m ? (m[1] ?? m[2] ?? null) : null;
64
+ }
65
+ /** Strip HTML tags, decode basic entities, collapse whitespace */
66
+ function stripTags(html) {
67
+ return html
68
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
69
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
70
+ .replace(/<[^>]+>/g, " ")
71
+ .replace(/&amp;/g, "&")
72
+ .replace(/&lt;/g, "<")
73
+ .replace(/&gt;/g, ">")
74
+ .replace(/&quot;/g, '"')
75
+ .replace(/&#039;/g, "'")
76
+ .replace(/&nbsp;/g, " ")
77
+ .replace(/\s+/g, " ")
78
+ .trim();
79
+ }
80
+ /** Check if inline style contains display:none or visibility:hidden */
81
+ function isHidden(tag) {
82
+ const style = getAttr(tag, "style");
83
+ if (!style)
84
+ return false;
85
+ return /display\s*:\s*none/i.test(style) || /visibility\s*:\s*hidden/i.test(style);
86
+ }
87
+ // ── Content extraction from a section's HTML ──
88
+ function extractHeadings(sectionHtml) {
89
+ const headings = [];
90
+ const re = /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi;
91
+ let m;
92
+ while ((m = re.exec(sectionHtml)) !== null) {
93
+ const text = stripTags(m[2]);
94
+ if (text)
95
+ headings.push({ level: Number(m[1]), text });
96
+ }
97
+ return headings;
98
+ }
99
+ function extractParagraphs(sectionHtml) {
100
+ const paragraphs = [];
101
+ const re = /<p[^>]*>([\s\S]*?)<\/p>/gi;
102
+ let m;
103
+ while ((m = re.exec(sectionHtml)) !== null) {
104
+ const text = stripTags(m[1]);
105
+ if (text && text.length > 5)
106
+ paragraphs.push(text);
107
+ }
108
+ return paragraphs;
109
+ }
110
+ function extractImages(sectionHtml, baseUrl) {
111
+ const images = [];
112
+ const re = /<img[^>]*>/gi;
113
+ let m;
114
+ while ((m = re.exec(sectionHtml)) !== null) {
115
+ const tag = m[0];
116
+ let src = getAttr(tag, "src") ?? "";
117
+ let isLazy = false;
118
+ // Check lazy-load attributes
119
+ for (const attr of LAZY_SRC_ATTRS) {
120
+ const lazySrc = getAttr(tag, attr);
121
+ if (lazySrc && lazySrc.startsWith("http")) {
122
+ src = lazySrc;
123
+ isLazy = true;
124
+ break;
125
+ }
126
+ }
127
+ // Check srcset for highest resolution
128
+ const srcset = getAttr(tag, "srcset");
129
+ if (srcset && !src.startsWith("http")) {
130
+ const parts = srcset.split(",").map(s => s.trim().split(/\s+/));
131
+ const best = parts.sort((a, b) => {
132
+ const aW = parseInt(a[1] ?? "0");
133
+ const bW = parseInt(b[1] ?? "0");
134
+ return bW - aW;
135
+ })[0];
136
+ if (best?.[0]) {
137
+ src = best[0];
138
+ isLazy = true;
139
+ }
140
+ }
141
+ // Resolve relative URLs
142
+ if (src && !src.startsWith("data:") && !src.startsWith("http")) {
143
+ try {
144
+ src = new URL(src, baseUrl).href;
145
+ }
146
+ catch { /* keep as-is */ }
147
+ }
148
+ // Skip tiny placeholders, data URIs, and tracking pixels
149
+ if (!src || src.startsWith("data:") || src.includes("pixel") || src.includes("spacer"))
150
+ continue;
151
+ const alt = getAttr(tag, "alt") ?? "";
152
+ images.push({ src, alt, isLazy });
153
+ }
154
+ // Check background images: inline styles, data-bg attributes, Elementor data attributes
155
+ const bgPatterns = [
156
+ // Inline style background-image
157
+ /style\s*=\s*["'][^"']*background(?:-image)?\s*:\s*url\(\s*['"]?([^'")]+)['"]?\s*\)/gi,
158
+ // Elementor data-bg attribute
159
+ /data-bg\s*=\s*["']([^"']+)["']/gi,
160
+ // Generic data-background attributes
161
+ /data-background(?:-image)?\s*=\s*["']([^"']+)["']/gi,
162
+ // data-src on non-img elements (lazy background)
163
+ /data-src\s*=\s*["']([^"']+\.(?:jpg|jpeg|png|webp|avif))["']/gi,
164
+ ];
165
+ for (const bgRe of bgPatterns) {
166
+ let bgm;
167
+ while ((bgm = bgRe.exec(sectionHtml)) !== null) {
168
+ let src = bgm[1];
169
+ if (src && !src.startsWith("data:")) {
170
+ try {
171
+ src = new URL(src, baseUrl).href;
172
+ }
173
+ catch { /* keep as-is */ }
174
+ if (!images.some(img => img.src === src)) {
175
+ images.push({ src, alt: "", isLazy: true });
176
+ }
177
+ }
178
+ }
179
+ }
180
+ return images;
181
+ }
182
+ function extractLinks(sectionHtml, baseUrl) {
183
+ const links = [];
184
+ const re = /<a[^>]*href\s*=\s*(?:"([^"]*)"|'([^']*)')([^>]*)>([\s\S]*?)<\/a>/gi;
185
+ let m;
186
+ while ((m = re.exec(sectionHtml)) !== null) {
187
+ let href = m[1] ?? m[2] ?? "";
188
+ const text = stripTags(m[4]);
189
+ if (!href || href.startsWith("#") || href.startsWith("javascript:") || href.startsWith("mailto:") || href.startsWith("tel:"))
190
+ continue;
191
+ if (!href.startsWith("http")) {
192
+ try {
193
+ href = new URL(href, baseUrl).href;
194
+ }
195
+ catch {
196
+ continue;
197
+ }
198
+ }
199
+ if (text)
200
+ links.push({ href, text });
201
+ }
202
+ return links;
203
+ }
204
+ function extractLists(sectionHtml) {
205
+ const lists = [];
206
+ const listRe = /<(?:ul|ol)[^>]*>([\s\S]*?)<\/(?:ul|ol)>/gi;
207
+ let m;
208
+ while ((m = listRe.exec(sectionHtml)) !== null) {
209
+ const itemRe = /<li[^>]*>([\s\S]*?)<\/li>/gi;
210
+ const items = [];
211
+ let im;
212
+ while ((im = itemRe.exec(m[1])) !== null) {
213
+ const text = stripTags(im[1]);
214
+ if (text)
215
+ items.push(text);
216
+ }
217
+ if (items.length > 0)
218
+ lists.push(items);
219
+ }
220
+ return lists;
221
+ }
222
+ // ── Section boundary detection ──
223
+ /** Find top-level section containers in HTML */
224
+ function findSectionBoundaries(html) {
225
+ const sections = [];
226
+ // Try semantic tags first
227
+ const semanticRe = /<(section|article|aside)((?:\s[^>]*)?)>([\s\S]*?)<\/\1>/gi;
228
+ let m;
229
+ while ((m = semanticRe.exec(html)) !== null) {
230
+ sections.push({
231
+ tag: m[1],
232
+ openTag: `<${m[1]}${m[2]}>`,
233
+ startIdx: m.index,
234
+ endIdx: m.index + m[0].length,
235
+ innerHTML: m[3],
236
+ });
237
+ }
238
+ // If we found semantic sections, use them
239
+ if (sections.length > 0)
240
+ return sections;
241
+ // Strategy 2: Try Elementor containers
242
+ const elementorRe = /<div([^>]*class="[^"]*(?:elementor-section|e-con(?:\s|"))[^"]*"[^>]*)>([\s\S]*?)(?=<div[^>]*class="[^"]*(?:elementor-section|e-con(?:\s|"))[^"]*"|<\/(?:main|body)>|$)/gi;
243
+ while ((m = elementorRe.exec(html)) !== null) {
244
+ sections.push({
245
+ tag: "div",
246
+ openTag: `<div${m[1]}>`,
247
+ startIdx: m.index,
248
+ endIdx: m.index + m[0].length,
249
+ innerHTML: m[2],
250
+ });
251
+ }
252
+ if (sections.length > 0)
253
+ return sections;
254
+ // Strategy 3: Top-level divs inside <main> or <body>
255
+ // Find <main> content first, fall back to <body>
256
+ const mainMatch = html.match(/<main[^>]*>([\s\S]*?)<\/main>/i);
257
+ const containerHtml = mainMatch?.[1] ?? html;
258
+ // Match top-level divs with id or class
259
+ const divRe = /<div([^>]*(?:id|class)\s*=[^>]*)>([\s\S]*?)<\/div>(?=\s*<div[^>]*(?:id|class)\s*=|\s*<\/(?:main|body)>|\s*$)/gi;
260
+ while ((m = divRe.exec(containerHtml)) !== null) {
261
+ const innerText = stripTags(m[2]);
262
+ if (innerText.length < 20)
263
+ continue; // Skip tiny divs
264
+ sections.push({
265
+ tag: "div",
266
+ openTag: `<div${m[1]}>`,
267
+ startIdx: m.index,
268
+ endIdx: m.index + m[0].length,
269
+ innerHTML: m[2],
270
+ });
271
+ }
272
+ return sections;
273
+ }
274
+ /** Classify a section by its CSS classes and content */
275
+ export function classifySection(openTag, innerHTML, preExtracted) {
276
+ const classAttr = getAttr(openTag, "class") ?? "";
277
+ const idAttr = getAttr(openTag, "id") ?? "";
278
+ const combined = `${classAttr} ${idAttr}`.toLowerCase();
279
+ // Extract semantic class hints
280
+ const classHints = [];
281
+ const hintPatterns = [
282
+ "hero", "banner", "feature", "testimonial", "faq", "accordion", "pricing",
283
+ "stat", "counter", "gallery", "team", "cta", "contact", "card", "grid",
284
+ "tab", "carousel", "slider", "video", "embed", "table", "quote",
285
+ "footer", "header", "nav", "menu", "rich-text", "two-col", "split",
286
+ ];
287
+ for (const hint of hintPatterns) {
288
+ if (combined.includes(hint))
289
+ classHints.push(hint);
290
+ }
291
+ // Check Elementor widget type
292
+ const widgetType = getAttr(openTag, "data-widget_type")?.replace(/\.\w+$/, "");
293
+ if (widgetType && ELEMENTOR_WIDGET_MAP[widgetType]) {
294
+ classHints.push(`elementor:${widgetType}`);
295
+ return { classHints, suggestedBlockType: ELEMENTOR_WIDGET_MAP[widgetType] };
296
+ }
297
+ // Match against class patterns
298
+ for (const { pattern, blockType } of CLASS_PATTERNS) {
299
+ if (pattern.test(combined)) {
300
+ return { classHints, suggestedBlockType: blockType };
301
+ }
302
+ }
303
+ // Content-based heuristics when classes don't help
304
+ const headings = preExtracted?.headings ?? extractHeadings(innerHTML);
305
+ const images = (innerHTML.match(/<img[^>]*>/gi) ?? []).length;
306
+ const links = (innerHTML.match(/<a[^>]*>/gi) ?? []).length;
307
+ const detailsTags = (innerHTML.match(/<details[^>]*>/gi) ?? []).length;
308
+ const listItems = (innerHTML.match(/<li[^>]*>/gi) ?? []).length;
309
+ const tables = (innerHTML.match(/<table[^>]*>/gi) ?? []).length;
310
+ if (detailsTags >= 2)
311
+ return { classHints, suggestedBlockType: "FAQAccordion" };
312
+ if (tables > 0)
313
+ return { classHints, suggestedBlockType: "Table" };
314
+ if (images >= 4 && links < 2)
315
+ return { classHints, suggestedBlockType: "Gallery" };
316
+ if (images >= 2 && links >= 2)
317
+ return { classHints, suggestedBlockType: "CardGrid" };
318
+ // First section with h1 + image is likely Hero
319
+ if (headings.some(h => h.level === 1) && images >= 1) {
320
+ return { classHints, suggestedBlockType: "Hero" };
321
+ }
322
+ // Section with many list items and no images → FeatureGrid
323
+ if (listItems >= 3 && images === 0)
324
+ return { classHints, suggestedBlockType: "FeatureGrid" };
325
+ // Numeric content → Stats
326
+ const text = stripTags(innerHTML);
327
+ const numberMatches = text.match(/\b\d[\d,.]*[+%]?\b/g) ?? [];
328
+ if (numberMatches.length >= 3 && text.length < 500) {
329
+ return { classHints, suggestedBlockType: "Stats" };
330
+ }
331
+ // Short section with 1-2 links → CTA
332
+ if (text.length < 300 && links >= 1 && headings.length <= 1) {
333
+ return { classHints, suggestedBlockType: "CTA" };
334
+ }
335
+ // Default: RichText for text-heavy sections
336
+ if (text.length > 100)
337
+ return { classHints, suggestedBlockType: "RichText" };
338
+ return { classHints };
339
+ }
340
+ // ── Main export ──
341
+ /**
342
+ * Extract structured sections from HTML.
343
+ *
344
+ * Identifies section boundaries using semantic tags, Elementor containers,
345
+ * and generic div analysis. Classifies each section by CSS class patterns
346
+ * and content heuristics. Resolves lazy-loaded images.
347
+ */
348
+ export function extractSections(html, baseUrl) {
349
+ // Strip scripts and style blocks to reduce noise
350
+ const cleanHtml = html
351
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
352
+ .replace(/<style[\s\S]*?<\/style>/gi, "");
353
+ const boundaries = findSectionBoundaries(cleanHtml);
354
+ const sections = [];
355
+ const MAX_SECTIONS = 30;
356
+ for (let i = 0; i < boundaries.length && sections.length < MAX_SECTIONS; i++) {
357
+ const { tag, openTag, innerHTML } = boundaries[i];
358
+ // Skip hidden elements
359
+ if (isHidden(openTag))
360
+ continue;
361
+ // Skip empty sections (but keep sections with images even if text is short)
362
+ const text = stripTags(innerHTML);
363
+ const hasImages = /<img[^>]*>/i.test(innerHTML);
364
+ if (text.length < 10 && !hasImages)
365
+ continue;
366
+ // Extract content first (reused by classifier)
367
+ const content = {
368
+ headings: extractHeadings(innerHTML),
369
+ paragraphs: extractParagraphs(innerHTML),
370
+ images: extractImages(innerHTML, baseUrl),
371
+ links: extractLinks(innerHTML, baseUrl),
372
+ lists: extractLists(innerHTML),
373
+ };
374
+ // Classify using pre-extracted content
375
+ const { classHints, suggestedBlockType } = classifySection(openTag, innerHTML, content);
376
+ // Skip chrome sections
377
+ if (suggestedBlockType === "__header__" || suggestedBlockType === "__footer__")
378
+ continue;
379
+ // Trim rawHtml to max ~5KB
380
+ const rawHtml = innerHTML.length > 5000
381
+ ? innerHTML.slice(0, 5000) + "\n<!-- truncated -->"
382
+ : innerHTML;
383
+ sections.push({
384
+ index: sections.length,
385
+ tag,
386
+ id: getAttr(openTag, "id") ?? undefined,
387
+ classHints,
388
+ suggestedBlockType,
389
+ content,
390
+ rawHtml,
391
+ });
392
+ }
393
+ return sections;
394
+ }
395
+ /**
396
+ * Extract navigation structure from the source site's HTML.
397
+ * Looks for <nav> or <header> elements and extracts link hierarchy.
398
+ */
399
+ // ── Visual layout analysis ──
400
+ const VISUAL_GAP_THRESHOLD = 60; // px gap between sections
401
+ const MIN_REPEAT_GROUP = 3;
402
+ /**
403
+ * Segment layout nodes into visual sections by detecting vertical gaps.
404
+ */
405
+ export function segmentByVisualGaps(nodes, viewportWidth = 1440) {
406
+ // Find section-level elements: wide enough to be sections, not so tall they're page wrappers.
407
+ // Use depth <= 5 to handle deeply nested CMS layouts (WordPress/Elementor often nest at depth 3-5).
408
+ // Exclude elements taller than 3000px (likely full-page wrappers, not individual sections).
409
+ const topLevel = nodes
410
+ .filter(n => n.depth <= 5 && n.rect.w > viewportWidth * 0.5 && n.rect.h < 3000 && n.rect.h >= 100)
411
+ .sort((a, b) => a.rect.y - b.rect.y);
412
+ // Deduplicate overlapping nodes — keep the shallowest (most likely the section root)
413
+ const deduped = [];
414
+ for (const node of topLevel) {
415
+ const overlaps = deduped.some(existing => Math.abs(existing.rect.y - node.rect.y) < 50 && Math.abs(existing.rect.h - node.rect.h) < 50);
416
+ if (!overlaps)
417
+ deduped.push(node);
418
+ }
419
+ const filtered = deduped.length > 0 ? deduped : topLevel;
420
+ if (filtered.length === 0)
421
+ return [];
422
+ const sections = [];
423
+ let currentNodes = [filtered[0]];
424
+ for (let i = 1; i < filtered.length; i++) {
425
+ const prev = filtered[i - 1];
426
+ const curr = filtered[i];
427
+ const gap = curr.rect.y - (prev.rect.y + prev.rect.h);
428
+ if (gap > VISUAL_GAP_THRESHOLD) {
429
+ // Gap detected — flush current section
430
+ sections.push(buildVisualSection(currentNodes, nodes));
431
+ currentNodes = [curr];
432
+ }
433
+ else {
434
+ currentNodes.push(curr);
435
+ }
436
+ }
437
+ if (currentNodes.length > 0) {
438
+ sections.push(buildVisualSection(currentNodes, nodes));
439
+ }
440
+ return sections;
441
+ }
442
+ function buildVisualSection(sectionNodes, allNodes) {
443
+ const y = Math.min(...sectionNodes.map(n => n.rect.y));
444
+ const maxBottom = Math.max(...sectionNodes.map(n => n.rect.y + n.rect.h));
445
+ // Include all descendant nodes within this Y range
446
+ const contained = allNodes.filter(n => n.rect.y >= y && n.rect.y + n.rect.h <= maxBottom + 10);
447
+ return {
448
+ y,
449
+ height: maxBottom - y,
450
+ nodes: contained,
451
+ textLength: contained.reduce((sum, n) => sum + n.text.length, 0),
452
+ imgCount: contained.reduce((sum, n) => sum + n.imgCount, 0),
453
+ linkCount: contained.reduce((sum, n) => sum + n.linkCount, 0),
454
+ };
455
+ }
456
+ /**
457
+ * Detect repeated structural patterns within a set of layout nodes.
458
+ * Groups nodes by structural signature and classifies repeat groups.
459
+ */
460
+ export function detectRepeatedPatterns(nodes) {
461
+ // Only consider leaf-ish nodes (depth 2-5, not too deep)
462
+ const candidates = nodes.filter(n => n.depth >= 2 && n.depth <= 6 && n.text.length > 5);
463
+ // Compute structural signature for each node
464
+ function signature(n) {
465
+ const textBucket = n.text.length === 0 ? "0" : n.text.length < 50 ? "S" : n.text.length < 200 ? "M" : "L";
466
+ return `${n.tag}|c${n.childCount}|i${n.imgCount > 0 ? 1 : 0}|l${n.linkCount > 0 ? 1 : 0}|t${textBucket}|w${Math.round(n.rect.w / 50) * 50}`;
467
+ }
468
+ // Group by signature
469
+ const groups = new Map();
470
+ for (const node of candidates) {
471
+ const sig = signature(node);
472
+ const group = groups.get(sig);
473
+ if (group)
474
+ group.push(node);
475
+ else
476
+ groups.set(sig, [node]);
477
+ }
478
+ // Filter to groups with 3+ items
479
+ const repeatGroups = [];
480
+ for (const [sig, items] of groups) {
481
+ if (items.length < MIN_REPEAT_GROUP)
482
+ continue;
483
+ // Classify the repeat group
484
+ const hasImages = items.every(n => n.imgCount > 0);
485
+ const hasLinks = items.every(n => n.linkCount > 0);
486
+ const avgTextLen = items.reduce((s, n) => s + n.text.length, 0) / items.length;
487
+ const hasPricing = items.some(n => /(?:CHF|€|\$|Fr\.)\s*\d|\d+[.,]\d{2}/i.test(n.text));
488
+ const hasNumbers = items.every(n => /\b\d[\d,.]*[+%]?\b/.test(n.text)) && avgTextLen < 80;
489
+ let inferredType = "unknown";
490
+ if (hasPricing)
491
+ inferredType = "pricing";
492
+ else if (hasNumbers && avgTextLen < 80)
493
+ inferredType = "stat";
494
+ else if (hasImages && hasLinks)
495
+ inferredType = "card";
496
+ else if (hasImages)
497
+ inferredType = "card";
498
+ else if (avgTextLen < 100)
499
+ inferredType = "feature";
500
+ else if (avgTextLen > 150)
501
+ inferredType = "testimonial";
502
+ repeatGroups.push({
503
+ signature: sig,
504
+ count: items.length,
505
+ inferredType,
506
+ itemTexts: items.map(n => n.text.slice(0, 50)),
507
+ });
508
+ }
509
+ // Sort by count descending
510
+ return repeatGroups.sort((a, b) => b.count - a.count);
511
+ }
512
+ const PRICING_RE = /(?:CHF|€|\$|USD|EUR|Fr\.)\s*\d|\bab\s+\d|\d+[.,]\d{2}\s*(?:CHF|€|\$|Fr\.)/i;
513
+ const CONTACT_RE = /\b(?:kontakt|contact|adresse|address|öffnungszeiten|opening\s+hours|standort|location|anfahrt|directions)\b/i;
514
+ const VIDEO_RE = /<(?:video|iframe)[^>]*(?:youtube|vimeo|video)/i;
515
+ /**
516
+ * Classify a text chunk into a section type based on content patterns.
517
+ */
518
+ function classifyOutlineSection(heading, text, innerHTML, imageCount, linkCount, listItemCount) {
519
+ const combined = `${heading} ${text}`.toLowerCase();
520
+ // Check specific content patterns
521
+ if (/<details[^>]*>/i.test(innerHTML))
522
+ return "faq";
523
+ if (PRICING_RE.test(text))
524
+ return "pricing";
525
+ if (CONTACT_RE.test(combined))
526
+ return "contact";
527
+ if (VIDEO_RE.test(innerHTML))
528
+ return "video";
529
+ if (/<form[^>]*>/i.test(innerHTML))
530
+ return "contact";
531
+ // Check heading/class patterns
532
+ if (/\b(hero|banner|erlebnis|experience|willkommen|welcome)\b/i.test(combined))
533
+ return "hero";
534
+ if (/\b(feature|vorteil|benefit|service|leistung|usp|vorteile)\b/i.test(combined))
535
+ return "features";
536
+ if (/\b(testimonial|review|bewertung|kundenstimme|feedback)\b/i.test(combined))
537
+ return "text";
538
+ if (/\b(gallerie|gallery|fotos?|photos?|bilder|portfolio)\b/i.test(combined))
539
+ return "gallery";
540
+ if (/\b(faq|fragen|questions?|häufig)\b/i.test(combined))
541
+ return "faq";
542
+ if (/\b(preis|price|pricing|tarif|paket|package|angebot)\b/i.test(combined))
543
+ return "pricing";
544
+ if (/\b(team|mitarbeiter|staff|member|über\s+uns|about\s+us)\b/i.test(combined))
545
+ return "cards";
546
+ if (/\b(event|veranstaltung|anlass|teamevent|polterabend|geburtstag)\b/i.test(combined))
547
+ return "cards";
548
+ if (/\b(info|download|link|resource|dokument|formulare?)\b/i.test(combined) && linkCount >= 3)
549
+ return "info-hub";
550
+ if (/\b(buche|book|reserv|jetzt|start|anfrage|contact)\b/i.test(combined) && text.length < 300)
551
+ return "cta";
552
+ // Infer from content shape
553
+ if (imageCount >= 4 && linkCount < 3)
554
+ return "gallery";
555
+ if (imageCount >= 2 && linkCount >= 2)
556
+ return "cards";
557
+ if (listItemCount >= 3 && imageCount === 0 && text.length < 500)
558
+ return "features";
559
+ // Large numbers → stats
560
+ const numberMatches = text.match(/\b\d[\d,.]*[+%]?\b/g) ?? [];
561
+ if (numberMatches.length >= 3 && text.length < 400)
562
+ return "stats";
563
+ // Short text with link → CTA
564
+ if (text.length < 250 && linkCount >= 1)
565
+ return "cta";
566
+ // Default: text block
567
+ if (text.length > 50)
568
+ return "text";
569
+ return "unknown";
570
+ }
571
+ /**
572
+ * Extract a compact page outline from HTML.
573
+ *
574
+ * Splits the page at heading boundaries (h1/h2) to produce a section-per-heading
575
+ * representation that captures the FULL page structure in ~2KB regardless of HTML size.
576
+ * This ensures the LLM sees every section even when the raw HTML is truncated.
577
+ */
578
+ export function extractPageOutline(html, baseUrl, layoutNodes) {
579
+ // Strip scripts and styles
580
+ const cleanHtml = html
581
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
582
+ .replace(/<style[\s\S]*?<\/style>/gi, "");
583
+ // Extract ALL headings in document order
584
+ const headingRe = /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi;
585
+ const allHeadings = [];
586
+ let hm;
587
+ while ((hm = headingRe.exec(cleanHtml)) !== null) {
588
+ const text = stripTags(hm[2]).trim();
589
+ if (text && text.length > 1) {
590
+ allHeadings.push({ level: Number(hm[1]), text, index: hm.index });
591
+ }
592
+ }
593
+ // Count total images and links
594
+ const totalImages = (cleanHtml.match(/<img[^>]*>/gi) ?? []).length;
595
+ const totalLinks = (cleanHtml.match(/<a[^>]*href/gi) ?? []).length;
596
+ // Split at h1/h2 boundaries — h3+ become sub-items within the parent section
597
+ const majorHeadings = allHeadings.filter(h => h.level <= 2);
598
+ if (majorHeadings.length === 0) {
599
+ const text = stripTags(cleanHtml).slice(0, 120);
600
+ return {
601
+ headings: allHeadings.map(h => ({ level: h.level, text: h.text })),
602
+ sections: [{
603
+ type: classifyOutlineSection("", text, cleanHtml, totalImages, totalLinks, 0),
604
+ contentSummary: text,
605
+ imageCount: totalImages,
606
+ linkCount: totalLinks,
607
+ listItemCount: 0,
608
+ hasForm: /<form[^>]*>/i.test(cleanHtml),
609
+ hasPricing: PRICING_RE.test(cleanHtml),
610
+ hasVideo: VIDEO_RE.test(cleanHtml),
611
+ }],
612
+ totalImages,
613
+ totalLinks,
614
+ };
615
+ }
616
+ const sections = [];
617
+ // Pre-heading content (nav, hero above first h1/h2)
618
+ const preHeadingHtml = cleanHtml.slice(0, majorHeadings[0].index);
619
+ const preHeadingText = stripTags(preHeadingHtml).trim();
620
+ if (preHeadingText.length > 30) {
621
+ const imgCount = (preHeadingHtml.match(/<img[^>]*>/gi) ?? []).length;
622
+ const lnkCount = (preHeadingHtml.match(/<a[^>]*href/gi) ?? []).length;
623
+ sections.push({
624
+ type: imgCount > 0 ? "hero" : "text",
625
+ contentSummary: preHeadingText.slice(0, 120),
626
+ imageCount: imgCount,
627
+ linkCount: lnkCount,
628
+ listItemCount: 0,
629
+ hasForm: false,
630
+ hasPricing: false,
631
+ hasVideo: VIDEO_RE.test(preHeadingHtml),
632
+ });
633
+ }
634
+ // Build sections at h1/h2 boundaries, collecting h3s as sub-items
635
+ for (let i = 0; i < majorHeadings.length; i++) {
636
+ const startIdx = majorHeadings[i].index;
637
+ const endIdx = i + 1 < majorHeadings.length ? majorHeadings[i + 1].index : cleanHtml.length;
638
+ const chunkHtml = cleanHtml.slice(startIdx, endIdx);
639
+ const chunkText = stripTags(chunkHtml).trim();
640
+ if (chunkText.length < 3)
641
+ continue;
642
+ // Collect h3 sub-headings within this section
643
+ const subItems = allHeadings
644
+ .filter(h => h.level >= 3 && h.index > startIdx && h.index < endIdx)
645
+ .map(h => h.text);
646
+ const imgCount = (chunkHtml.match(/<img[^>]*>/gi) ?? []).length;
647
+ const lnkCount = (chunkHtml.match(/<a[^>]*href/gi) ?? []).length;
648
+ const liCount = (chunkHtml.match(/<li[^>]*>/gi) ?? []).length;
649
+ const widgetTypes = [...chunkHtml.matchAll(/data-widget_type="([^"]+)"/gi)].map(m => m[1].replace(/\.\w+$/, ""));
650
+ const hasPricing = PRICING_RE.test(chunkText);
651
+ // Classify with content awareness
652
+ let sectionType = classifyOutlineSection(majorHeadings[i].text, chunkText, chunkHtml, imgCount, lnkCount, liCount);
653
+ // Info hub detection: section with many toggle/accordion sub-items
654
+ if (subItems.length >= 3 && widgetTypes.includes("toggle"))
655
+ sectionType = "info-hub";
656
+ // Cards detection: section heading + multiple h3 sub-items with CTAs
657
+ if (subItems.length >= 3 && lnkCount >= subItems.length)
658
+ sectionType = "cards";
659
+ sections.push({
660
+ type: sectionType,
661
+ heading: majorHeadings[i].text,
662
+ contentSummary: chunkText.slice(0, 120),
663
+ ...(subItems.length > 0 ? { subItems } : {}),
664
+ imageCount: imgCount,
665
+ linkCount: lnkCount,
666
+ listItemCount: liCount,
667
+ hasForm: /<form[^>]*>/i.test(chunkHtml),
668
+ hasPricing,
669
+ hasVideo: VIDEO_RE.test(chunkHtml),
670
+ ...(widgetTypes.length > 0 ? { widgetTypes: [...new Set(widgetTypes)] } : {}),
671
+ });
672
+ }
673
+ // Enrich with visual layout data if available
674
+ if (layoutNodes && layoutNodes.length > 0) {
675
+ // Find Y positions of each section's heading in the layout
676
+ const sectionYs = sections.map(s => {
677
+ if (!s.heading)
678
+ return 0;
679
+ const heading = s.heading.slice(0, 40); // match prefix to handle truncation
680
+ // Try exact h tag first, then any node containing the heading text
681
+ const node = layoutNodes.find(n => /^h[1-3]$/i.test(n.tag) && n.text.includes(heading))
682
+ ?? layoutNodes.find(n => n.text.includes(heading) && n.rect.h < 200);
683
+ return node?.rect.y ?? 0;
684
+ });
685
+ // For each section, detect repeat patterns from layout nodes in its Y range
686
+ for (let i = 0; i < sections.length; i++) {
687
+ const yStart = sectionYs[i];
688
+ const yEnd = i + 1 < sectionYs.length && sectionYs[i + 1] > yStart
689
+ ? sectionYs[i + 1]
690
+ : yStart + 1500;
691
+ // Only include nodes whose CENTER is within this section's range
692
+ const nodesInRange = layoutNodes.filter(n => {
693
+ const centerY = n.rect.y + n.rect.h / 2;
694
+ return centerY >= yStart && centerY < yEnd;
695
+ });
696
+ if (nodesInRange.length < 3) {
697
+ sections[i].detectedBy = "heading";
698
+ continue;
699
+ }
700
+ const repeats = detectRepeatedPatterns(nodesInRange);
701
+ if (repeats.length > 0) {
702
+ sections[i].repeatGroups = repeats.slice(0, 2);
703
+ }
704
+ sections[i].detectedBy = "both";
705
+ }
706
+ // Check for visual-gap sections not covered by headings
707
+ const visualSections = segmentByVisualGaps(layoutNodes);
708
+ for (const vs of visualSections) {
709
+ const coveredByHeading = sectionYs.some(sy => sy >= vs.y && sy < vs.y + vs.height);
710
+ if (!coveredByHeading && vs.textLength > 30) {
711
+ const repeats = detectRepeatedPatterns(vs.nodes);
712
+ const firstText = vs.nodes.find(n => n.text.length > 5)?.text.slice(0, 120) ?? "";
713
+ const inferredType = repeats.length > 0
714
+ ? (repeats[0].inferredType === "feature" ? "features" : "cards")
715
+ : "unknown";
716
+ sections.push({
717
+ type: inferredType,
718
+ contentSummary: firstText,
719
+ imageCount: vs.imgCount,
720
+ linkCount: vs.linkCount,
721
+ listItemCount: 0,
722
+ hasForm: false,
723
+ hasPricing: vs.nodes.some(n => PRICING_RE.test(n.text)),
724
+ hasVideo: false,
725
+ repeatGroups: repeats.length > 0 ? repeats.slice(0, 3) : undefined,
726
+ detectedBy: "visual-gap",
727
+ });
728
+ }
729
+ }
730
+ }
731
+ return {
732
+ headings: allHeadings.map(h => ({ level: h.level, text: h.text })),
733
+ sections,
734
+ totalImages,
735
+ totalLinks,
736
+ };
737
+ }
738
+ // ── Navigation extraction ──
739
+ export function extractNavigation(html, baseUrl) {
740
+ const origin = new URL(baseUrl).origin;
741
+ // Find logo image in header/nav area
742
+ // Try greedy match first (captures full header), then non-greedy, then full HTML fallback
743
+ const headerMatch = html.match(/<header[^>]*>([\s\S]*)<\/header>/i)
744
+ ?? html.match(/<nav[^>]*>([\s\S]*?)<\/nav>/i);
745
+ const headerHtml = headerMatch?.[1] ?? "";
746
+ let logoUrl;
747
+ let siteName;
748
+ // Look for logo: img inside a link to "/" or brand/logo class
749
+ // Search header first, then full page (for CMS sites without semantic header tags)
750
+ const searchAreas = headerHtml ? [headerHtml, html] : [html];
751
+ for (const area of searchAreas) {
752
+ if (logoUrl)
753
+ break;
754
+ const logoImgMatch = area.match(/<a[^>]*href\s*=\s*["']\/["'][^>]*>[\s\S]*?<img[^>]*src\s*=\s*["']([^"']+)["']/i)
755
+ ?? area.match(/<img[^>]*class\s*=\s*["'][^"']*logo[^"']*["'][^>]*src\s*=\s*["']([^"']+)["']/i)
756
+ ?? area.match(/<img[^>]*src\s*=\s*["']([^"']+)["'][^>]*class\s*=\s*["'][^"']*logo[^"']*["']/i)
757
+ // Elementor: widget-type="site-logo" or widget_type="theme-site-logo"
758
+ ?? area.match(/widget[_-]type\s*=\s*["'][^"']*logo[^"']*["'][^>]*>[\s\S]*?<img[^>]*src\s*=\s*["']([^"']+)["']/i);
759
+ if (logoImgMatch?.[1]) {
760
+ logoUrl = logoImgMatch[1];
761
+ if (logoUrl && !logoUrl.startsWith("http") && !logoUrl.startsWith("data:")) {
762
+ try {
763
+ logoUrl = new URL(logoUrl, baseUrl).href;
764
+ }
765
+ catch { /* keep */ }
766
+ }
767
+ }
768
+ }
769
+ // Look for site name in header brand text or page title
770
+ const brandMatch = headerHtml.match(/<a[^>]*href\s*=\s*["']\/["'][^>]*>([\s\S]*?)<\/a>/i)
771
+ ?? html.match(/<a[^>]*href\s*=\s*["']\/["'][^>]*class\s*=\s*["'][^"']*(?:brand|logo|site)[^"']*["'][^>]*>([\s\S]*?)<\/a>/i);
772
+ if (brandMatch) {
773
+ const brandText = stripTags(brandMatch[1]).trim();
774
+ if (brandText && brandText.length < 60)
775
+ siteName = brandText;
776
+ }
777
+ // Fallback: extract from <title> tag
778
+ if (!siteName) {
779
+ const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
780
+ if (titleMatch) {
781
+ // Take first part before separator (e.g., "Paintball Arena Bern - Homepage" → "Paintball Arena Bern")
782
+ const title = titleMatch[1].trim().split(/\s*[-–|»]\s*/)[0].trim();
783
+ if (title && title.length < 60)
784
+ siteName = title;
785
+ }
786
+ }
787
+ // Extract nav links — handle both flat and nested (dropdown) structures
788
+ const items = [];
789
+ // Find all <nav> elements
790
+ const navRe = /<nav[^>]*>([\s\S]*?)<\/nav>/gi;
791
+ let navMatch;
792
+ let navHtml = "";
793
+ while ((navMatch = navRe.exec(html)) !== null) {
794
+ navHtml += navMatch[1];
795
+ }
796
+ if (!navHtml)
797
+ navHtml = headerHtml;
798
+ // Find top-level <li> items (may contain nested <ul> for dropdowns)
799
+ const seenHrefs = new Set();
800
+ const MAX_NAV_ITEMS = 15;
801
+ const liRe = /<li[^>]*>([\s\S]*?)<\/li>/gi;
802
+ let liMatch;
803
+ while ((liMatch = liRe.exec(navHtml)) !== null) {
804
+ if (items.length >= MAX_NAV_ITEMS)
805
+ break;
806
+ const liContent = liMatch[1];
807
+ // Check for nested <ul> (dropdown)
808
+ const subMenuMatch = liContent.match(/<ul[^>]*>([\s\S]*?)<\/ul>/i);
809
+ const linkMatch = liContent.match(/<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i);
810
+ if (subMenuMatch && linkMatch) {
811
+ // Parent with children
812
+ const parentLabel = stripTags(linkMatch[2]).trim();
813
+ const parentHref = linkMatch[1];
814
+ const children = [];
815
+ const childLiRe = /<li[^>]*>[\s\S]*?<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
816
+ let childMatch;
817
+ while ((childMatch = childLiRe.exec(subMenuMatch[1])) !== null) {
818
+ let childHref = childMatch[1];
819
+ const childLabel = stripTags(childMatch[2]).trim();
820
+ if (!childLabel || childHref.startsWith("#") || childHref.startsWith("javascript:"))
821
+ continue;
822
+ if (!childHref.startsWith("http")) {
823
+ try {
824
+ childHref = new URL(childHref, baseUrl).href;
825
+ }
826
+ catch {
827
+ continue;
828
+ }
829
+ }
830
+ if (childHref.startsWith(origin)) {
831
+ children.push({ label: childLabel, href: new URL(childHref).pathname });
832
+ }
833
+ }
834
+ // Deduplicate children
835
+ const dedupedChildren = children.filter(c => {
836
+ if (seenHrefs.has(c.href))
837
+ return false;
838
+ seenHrefs.add(c.href);
839
+ return true;
840
+ });
841
+ if (dedupedChildren.length > 0) {
842
+ items.push({ label: parentLabel, children: dedupedChildren });
843
+ }
844
+ else if (parentLabel) {
845
+ let href = parentHref;
846
+ if (!href.startsWith("http")) {
847
+ try {
848
+ href = new URL(href, baseUrl).href;
849
+ }
850
+ catch { /* skip */ }
851
+ }
852
+ if (href.startsWith(origin)) {
853
+ const path = new URL(href).pathname;
854
+ if (!seenHrefs.has(path)) {
855
+ seenHrefs.add(path);
856
+ items.push({ label: parentLabel, href: path });
857
+ }
858
+ }
859
+ }
860
+ }
861
+ else if (linkMatch) {
862
+ let href = linkMatch[1];
863
+ const label = stripTags(linkMatch[2]).trim();
864
+ if (!label || href.startsWith("#") || href.startsWith("javascript:"))
865
+ continue;
866
+ if (!href.startsWith("http")) {
867
+ try {
868
+ href = new URL(href, baseUrl).href;
869
+ }
870
+ catch {
871
+ continue;
872
+ }
873
+ }
874
+ if (href.startsWith(origin)) {
875
+ const path = new URL(href).pathname;
876
+ if (!seenHrefs.has(path)) {
877
+ seenHrefs.add(path);
878
+ items.push({ label, href: path });
879
+ }
880
+ }
881
+ }
882
+ }
883
+ return { siteName, logoUrl, items };
884
+ }
885
+ /**
886
+ * Resolve lazy-loaded image sources in HTML.
887
+ * Replaces data-src, data-lazy-src, etc. with actual src attributes.
888
+ */
889
+ export function resolveLazyImages(html) {
890
+ // For each <img> tag, if it has a lazy-src attribute, replace/add src
891
+ return html.replace(/<img[^>]*>/gi, (imgTag) => {
892
+ for (const attr of LAZY_SRC_ATTRS) {
893
+ const re = new RegExp(`${attr}\\s*=\\s*"([^"]*)"`, "i");
894
+ const m = imgTag.match(re);
895
+ if (m && m[1]) {
896
+ const lazyValue = m[1];
897
+ // Remove the data-* attribute
898
+ let newTag = imgTag.replace(re, "");
899
+ // Replace or add src
900
+ if (/\bsrc\s*=\s*"/i.test(newTag)) {
901
+ newTag = newTag.replace(/\bsrc\s*=\s*"[^"]*"/i, `src="${lazyValue}"`);
902
+ }
903
+ else {
904
+ newTag = newTag.replace(/<img/i, `<img src="${lazyValue}"`);
905
+ }
906
+ return newTag.replace(/\s+/g, " ").replace(/\s+>/g, ">");
907
+ }
908
+ }
909
+ return imgTag;
910
+ });
911
+ }