@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.
- package/LICENSE +201 -0
- package/dist/block-codegen.d.ts +5 -0
- package/dist/block-codegen.js +224 -0
- package/dist/design-tokens.d.ts +26 -0
- package/dist/design-tokens.js +733 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/scraper.d.ts +27 -0
- package/dist/scraper.js +1088 -0
- package/dist/section-extractor.d.ts +50 -0
- package/dist/section-extractor.js +911 -0
- package/dist/section-spec.d.ts +22 -0
- package/dist/section-spec.js +526 -0
- package/dist/types.d.ts +403 -0
- package/dist/types.js +2 -0
- package/package.json +47 -0
|
@@ -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(/&/g, "&")
|
|
72
|
+
.replace(/</g, "<")
|
|
73
|
+
.replace(/>/g, ">")
|
|
74
|
+
.replace(/"/g, '"')
|
|
75
|
+
.replace(/'/g, "'")
|
|
76
|
+
.replace(/ /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
|
+
}
|