@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,1088 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join, extname } from "node:path";
3
+ import { extractSections, resolveLazyImages, extractNavigation, extractPageOutline, segmentByVisualGaps } from "./section-extractor.js";
4
+ const USER_AGENT = "MigrationBot/1.0 (ai-site-editor)";
5
+ const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
6
+ // ── Pure HTML processing (exported for testability) ──
7
+ export function processHtml(rawHtml, baseUrl) {
8
+ // Extract title
9
+ const titleMatch = rawHtml.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
10
+ const title = titleMatch ? titleMatch[1].trim() : "";
11
+ // Extract meta description
12
+ const metaMatch = rawHtml.match(/<meta\s+[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*/i) ?? rawHtml.match(/<meta\s+[^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']description["'][^>]*/i);
13
+ const metaDescription = metaMatch ? metaMatch[1].trim() : "";
14
+ // Extract inline <style> content
15
+ const styleBlocks = [];
16
+ const styleRe = /<style[^>]*>([\s\S]*?)<\/style>/gi;
17
+ let styleMatch;
18
+ while ((styleMatch = styleRe.exec(rawHtml)) !== null) {
19
+ const content = styleMatch[1].trim();
20
+ if (content)
21
+ styleBlocks.push(content);
22
+ }
23
+ const css = styleBlocks.join("\n\n");
24
+ // Strip <script> tags
25
+ let html = rawHtml.replace(/<script[\s\S]*?<\/script>/gi, "");
26
+ // Resolve relative URLs in href and src attributes
27
+ html = html.replace(/(\s(?:href|src)\s*=\s*["'])([^"']+)(["'])/gi, (_match, prefix, value, suffix) => {
28
+ // Skip data URIs, anchors, and already-absolute URLs
29
+ if (/^(https?:|data:|mailto:|tel:|#|javascript:)/i.test(value)) {
30
+ return prefix + value + suffix;
31
+ }
32
+ try {
33
+ const resolved = new URL(value, baseUrl).href;
34
+ return prefix + resolved + suffix;
35
+ }
36
+ catch {
37
+ return prefix + value + suffix;
38
+ }
39
+ });
40
+ return { html, css, title, metaDescription };
41
+ }
42
+ // ── Playwright helpers ──
43
+ async function launchBrowser() {
44
+ // playwright is an optional peer: the scraper is the only thing that needs a
45
+ // real browser, so consumers that never scrape are not made to download one.
46
+ let chromium;
47
+ try {
48
+ ({ chromium } = await import("playwright"));
49
+ }
50
+ catch (err) {
51
+ throw new Error("The migration scraper needs Playwright, which is an optional peer dependency of " +
52
+ "@avocadostudio-ai/migration-sdk. Install it with `npm i -D playwright` (then " +
53
+ "`npx playwright install chromium`) and try again. " +
54
+ `Original error: ${err instanceof Error ? err.message : String(err)}`);
55
+ }
56
+ return chromium.launch({ headless: true });
57
+ }
58
+ // ── Exported scraper functions ──
59
+ export async function fetchPageContent(url) {
60
+ const res = await fetch(url, {
61
+ headers: { "User-Agent": USER_AGENT },
62
+ signal: AbortSignal.timeout(15_000),
63
+ });
64
+ if (!res.ok)
65
+ throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
66
+ const rawHtml = await res.text();
67
+ const baseUrl = url;
68
+ const result = processHtml(rawHtml, baseUrl);
69
+ // Collect external stylesheet URLs, then fetch in parallel
70
+ const stylesheetUrls = [];
71
+ const linkRe = /<link[^>]+rel\s*=\s*["']stylesheet["'][^>]*>/gi;
72
+ let linkMatch;
73
+ while ((linkMatch = linkRe.exec(rawHtml)) !== null) {
74
+ const hrefMatch = linkMatch[0].match(/href\s*=\s*["']([^"']+)["']/i);
75
+ if (!hrefMatch)
76
+ continue;
77
+ try {
78
+ stylesheetUrls.push(new URL(hrefMatch[1], baseUrl).href);
79
+ }
80
+ catch { /* invalid URL */ }
81
+ }
82
+ const externalResults = await Promise.allSettled(stylesheetUrls.map(async (cssUrl) => {
83
+ const cssRes = await fetch(cssUrl, {
84
+ headers: { "User-Agent": USER_AGENT },
85
+ signal: AbortSignal.timeout(5_000),
86
+ });
87
+ return cssRes.ok ? cssRes.text() : "";
88
+ }));
89
+ const externalCssParts = externalResults
90
+ .filter((r) => r.status === "fulfilled" && !!r.value)
91
+ .map(r => r.value);
92
+ // Combine inline CSS (from processHtml) with external stylesheets
93
+ const css = [result.css, ...externalCssParts].filter(Boolean).join("\n\n");
94
+ const { html, title, metaDescription } = result;
95
+ return { html, css, baseUrl, title, metaDescription };
96
+ }
97
+ export async function takeScreenshot(url, _options) {
98
+ const browser = await launchBrowser();
99
+ try {
100
+ const width = 1440;
101
+ const height = 900;
102
+ const page = await browser.newPage();
103
+ await page.setViewportSize({ width, height });
104
+ const response = await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
105
+ const status = response?.status() ?? 0;
106
+ if (status < 200 || status >= 400) {
107
+ throw new Error(`HTTP ${status} from ${url} — refusing to screenshot an error page`);
108
+ }
109
+ const buffer = await page.screenshot({ fullPage: true, type: "jpeg", quality: 75 });
110
+ return { base64: buffer.toString("base64"), viewport: { width, height } };
111
+ }
112
+ finally {
113
+ await browser.close();
114
+ }
115
+ }
116
+ export async function downloadImage(url, _alt, outputDir) {
117
+ const dir = outputDir ?? process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? "./generated-images";
118
+ const res = await fetch(url, {
119
+ headers: { "User-Agent": USER_AGENT },
120
+ signal: AbortSignal.timeout(10_000),
121
+ });
122
+ if (!res.ok)
123
+ throw new Error(`Failed to download image ${url}: ${res.status}`);
124
+ const contentType = res.headers.get("content-type") ?? "";
125
+ if (!contentType.startsWith("image/")) {
126
+ throw new Error(`Not an image: content-type is "${contentType}"`);
127
+ }
128
+ // Pre-flight size check via Content-Length header when available
129
+ const contentLength = res.headers.get("content-length");
130
+ if (contentLength && Number(contentLength) > MAX_IMAGE_SIZE) {
131
+ throw new Error(`Image exceeds 10 MB limit (${contentLength} bytes)`);
132
+ }
133
+ const arrayBuf = await res.arrayBuffer();
134
+ if (arrayBuf.byteLength > MAX_IMAGE_SIZE) {
135
+ throw new Error(`Image exceeds 10 MB limit (${arrayBuf.byteLength} bytes)`);
136
+ }
137
+ // Derive extension from content-type or URL
138
+ let ext = extname(new URL(url).pathname).replace(/^\./, "") || "png";
139
+ if (ext.includes("?"))
140
+ ext = ext.split("?")[0];
141
+ // Normalize common MIME subtypes
142
+ const mimeToExt = {
143
+ "image/jpeg": "jpg",
144
+ "image/png": "png",
145
+ "image/gif": "gif",
146
+ "image/webp": "webp",
147
+ "image/svg+xml": "svg",
148
+ };
149
+ if (mimeToExt[contentType])
150
+ ext = mimeToExt[contentType];
151
+ const timestamp = Date.now();
152
+ const rand = Math.random().toString(36).slice(2, 8);
153
+ const fileName = `migrated_${timestamp}_${rand}.${ext}`;
154
+ const localPath = join(dir, fileName);
155
+ await mkdir(dir, { recursive: true });
156
+ await writeFile(localPath, Buffer.from(arrayBuf));
157
+ return { localPath, fileName, width: 0, height: 0 };
158
+ }
159
+ // ── Full page scrape (Playwright: rendered DOM + screenshot + sections) ──
160
+ /**
161
+ * Scrape a page using Playwright — combines screenshot, rendered DOM extraction,
162
+ * and section analysis in a single browser session.
163
+ *
164
+ * Handles JS-rendered content (Elementor, lazy loading, SPAs) that
165
+ * plain HTTP fetch misses. Uses auto-waiting for reliable rendering.
166
+ */
167
+ export async function scrapeFullPage(url) {
168
+ const browser = await launchBrowser();
169
+ try {
170
+ const width = 1440;
171
+ const height = 900;
172
+ const page = await browser.newPage();
173
+ await page.setViewportSize({ width, height });
174
+ await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
175
+ // Scroll to bottom slowly to trigger lazy loading (300ms per step for Intersection Observer)
176
+ /* eslint-disable @typescript-eslint/no-unsafe-return */
177
+ await page.evaluate(`(async () => {
178
+ const delay = ms => new Promise(r => setTimeout(r, ms));
179
+ const h = document.body.scrollHeight, step = window.innerHeight;
180
+ for (let y = 0; y < h; y += step) { window.scrollTo(0, y); await delay(300); }
181
+ window.scrollTo(0, 0);
182
+ await delay(500);
183
+ })()`);
184
+ // Wait for lazy-loaded content to settle
185
+ await page.waitForLoadState("networkidle").catch(() => { });
186
+ // Force-resolve lazy images that use data-src attributes (in the live page, before screenshots)
187
+ await page.evaluate(`(() => {
188
+ document.querySelectorAll('img[data-src], img[data-lazy-src], img[data-original], img[data-bg]').forEach(img => {
189
+ const lazySrc = img.getAttribute('data-src') || img.getAttribute('data-lazy-src') || img.getAttribute('data-original');
190
+ if (lazySrc) img.setAttribute('src', lazySrc);
191
+ const bgSrc = img.getAttribute('data-bg');
192
+ if (bgSrc) img.style.backgroundImage = 'url(' + bgSrc + ')';
193
+ });
194
+ })()`);
195
+ // Extract rendered DOM and computed CSS
196
+ const { renderedHtml, stylesheets } = await page.evaluate(`(() => {
197
+ const html = document.documentElement.outerHTML;
198
+ const sheets = [];
199
+ for (const sheet of document.styleSheets) {
200
+ try { sheets.push(Array.from(sheet.cssRules).map(r => r.cssText).join("\\n")); }
201
+ catch (e) {}
202
+ }
203
+ return { renderedHtml: html, stylesheets: sheets };
204
+ })()`);
205
+ // Extract visual layout metadata (bounding boxes for gap detection + repetition)
206
+ const layoutNodes = await page.evaluate(`(() => {
207
+ const nodes = [];
208
+ const walk = (el, depth) => {
209
+ if (depth > 8) return;
210
+ const rect = el.getBoundingClientRect();
211
+ if (rect.height < 40 || rect.width < 100) return;
212
+ const tag = el.tagName.toLowerCase();
213
+ if (['script','style','svg','path','link','meta','noscript','br','hr'].includes(tag)) return;
214
+ const text = (el.innerText || '').slice(0, 200).trim();
215
+ const imgs = el.querySelectorAll(':scope > img, :scope > picture img').length;
216
+ if (!text && !imgs && depth > 2) return;
217
+ nodes.push({
218
+ tag, depth,
219
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
220
+ text: text.slice(0, 150),
221
+ childCount: el.children.length,
222
+ imgCount: imgs,
223
+ linkCount: el.querySelectorAll(':scope > a').length,
224
+ classes: (el.className || '').toString().slice(0, 100),
225
+ role: el.getAttribute('role') || '',
226
+ widgetType: el.getAttribute('data-widget_type') || '',
227
+ });
228
+ for (const child of el.children) walk(child, depth + 1);
229
+ };
230
+ walk(document.body, 0);
231
+ return nodes;
232
+ })()`);
233
+ // Extract CSS background images from ALL visible elements (CMS-agnostic)
234
+ const bgImages = await page.evaluate(`(() => {
235
+ const results = [];
236
+ const seen = new Set();
237
+ const els = document.querySelectorAll('*');
238
+ for (const el of els) {
239
+ const style = window.getComputedStyle(el);
240
+ const bg = style.backgroundImage;
241
+ if (bg && bg !== 'none' && bg.includes('url(')) {
242
+ const match = bg.match(/url\\(["']?([^"')]+)["']?\\)/);
243
+ if (match && match[1] && !match[1].startsWith('data:') && !seen.has(match[1])) {
244
+ seen.add(match[1]);
245
+ const rect = el.getBoundingClientRect();
246
+ if (rect.height > 20 && rect.width > 20) {
247
+ results.push({ url: match[1], y: Math.round(rect.y), height: Math.round(rect.height) });
248
+ }
249
+ }
250
+ }
251
+ }
252
+ return results;
253
+ })()`);
254
+ // Extract embedded iframes AND video URLs from data attributes / consent placeholders
255
+ const embeds = await page.evaluate(`(() => {
256
+ const results = [];
257
+ const seen = new Set();
258
+
259
+ // 1. Standard iframes
260
+ document.querySelectorAll('iframe[src]').forEach(el => {
261
+ const src = el.src || '';
262
+ if (!src || seen.has(src)) return;
263
+ seen.add(src);
264
+ const rect = el.getBoundingClientRect();
265
+ let type = 'other';
266
+ if (/youtube\\.com|youtu\\.be/i.test(src)) type = 'youtube';
267
+ else if (/vimeo\\.com/i.test(src)) type = 'vimeo';
268
+ else if (/google\\.com\\/maps|maps\\.google/i.test(src)) type = 'map';
269
+ if (rect.height > 20) results.push({ src, type, y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) });
270
+ });
271
+
272
+ // 2. Grep the entire HTML for video URLs — catches any embed method
273
+ // (data attributes, inline scripts, JSON-LD, consent placeholders, etc.)
274
+ const html = document.documentElement.outerHTML;
275
+
276
+ // YouTube: youtu.be/ID or youtube.com/watch?v=ID or youtube.com/embed/ID
277
+ // Also matches JSON-escaped slashes (\\\\/) common in data attributes
278
+ const ytMatches = html.match(/youtu(?:\\.be[\\/\\\\\\\\]+|be\\.com[\\/\\\\\\\\]+(?:watch\\?v=|embed[\\/\\\\\\\\]+))([\\w-]{11})/g) || [];
279
+ for (const match of ytMatches) {
280
+ const id = match.match(/([\\w-]{11})$/)?.[1];
281
+ if (id && !seen.has(id)) {
282
+ seen.add(id);
283
+ // Try to find the element that contains this URL for Y position
284
+ const el = document.querySelector('[data-settings*=\"' + id + '\"], [src*=\"' + id + '\"], [data-src*=\"' + id + '\"]');
285
+ const rect = el ? el.getBoundingClientRect() : { y: 0, width: 0, height: 0 };
286
+ results.push({ src: 'https://www.youtube.com/embed/' + id, type: 'youtube', y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height || 400) });
287
+ }
288
+ }
289
+
290
+ // Vimeo: vimeo.com/ID or vimeo.com/video/ID
291
+ const vimeoMatches = html.match(/vimeo\\.com\\/(?:video\\/)?(\\d{6,})/g) || [];
292
+ for (const match of vimeoMatches) {
293
+ const id = match.match(/(\\d{6,})$/)?.[1];
294
+ if (id && !seen.has(id)) {
295
+ seen.add(id);
296
+ results.push({ src: 'https://player.vimeo.com/video/' + id, type: 'vimeo', y: 0, width: 0, height: 0 });
297
+ }
298
+ }
299
+
300
+ // Google Maps embed URLs
301
+ const mapMatches = html.match(/google\\.com\\/maps\\/embed[^\"'\\s]*/g) || [];
302
+ for (const src of mapMatches) {
303
+ if (!seen.has(src)) {
304
+ seen.add(src);
305
+ const el = document.querySelector('iframe[src*=\"maps/embed\"]');
306
+ const rect = el ? el.getBoundingClientRect() : { y: 0, width: 0, height: 0 };
307
+ results.push({ src: 'https://www.' + src, type: 'map', y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height || 400) });
308
+ }
309
+ }
310
+
311
+ return results;
312
+ })()`);
313
+ // Extract ALL page images with Y positions (for distributing to visual sections)
314
+ const pageImages = await page.evaluate(`(() => {
315
+ const results = [];
316
+ const seen = new Set();
317
+ // Regular <img> elements
318
+ document.querySelectorAll('img').forEach(img => {
319
+ const src = img.src || img.currentSrc || img.getAttribute('data-src') || img.getAttribute('data-lazy-src') || '';
320
+ if (!src || src.startsWith('data:') || seen.has(src)) return;
321
+ const rect = img.getBoundingClientRect();
322
+ if (rect.width < 30 || rect.height < 30) return;
323
+ seen.add(src);
324
+ const scrollY = window.scrollY;
325
+ results.push({ src, alt: img.alt || '', y: Math.round(rect.y + scrollY), width: Math.round(rect.width), height: Math.round(rect.height) });
326
+ });
327
+ return results;
328
+ })()`);
329
+ // Detect smooth scroll libraries (Lenis, Locomotive, native smooth-scroll)
330
+ const scrollBehavior = await page.evaluate(`(() => {
331
+ // Lenis
332
+ if (document.querySelector('.lenis') || document.querySelector('[data-lenis-prevent]') || window.__lenis) {
333
+ return { library: 'lenis', scrollContainer: '.lenis' };
334
+ }
335
+ // Locomotive Scroll
336
+ if (document.querySelector('[data-scroll-container]') || document.querySelector('[data-scroll-section]')) {
337
+ const container = document.querySelector('[data-scroll-container]');
338
+ return { library: 'locomotive', scrollContainer: container ? container.tagName.toLowerCase() + (container.className ? '.' + container.className.split(' ')[0] : '') : '[data-scroll-container]' };
339
+ }
340
+ // Native smooth scroll
341
+ const htmlStyle = getComputedStyle(document.documentElement).scrollBehavior;
342
+ if (htmlStyle === 'smooth') {
343
+ return { library: 'native-smooth' };
344
+ }
345
+ return { library: 'none' };
346
+ })()`);
347
+ // Detect layered image compositions (multiple positioned/z-indexed images overlapping)
348
+ const imageCompositions = await page.evaluate(`(() => {
349
+ const allImgs = [...document.querySelectorAll('img')].map(img => {
350
+ const rect = img.getBoundingClientRect();
351
+ if (rect.width < 30 || rect.height < 30) return null;
352
+ const cs = getComputedStyle(img);
353
+ const parentCs = img.parentElement ? getComputedStyle(img.parentElement) : null;
354
+ const scrollY = window.scrollY;
355
+ return {
356
+ src: img.src || img.currentSrc || '',
357
+ alt: img.alt || '',
358
+ zIndex: parseInt(cs.zIndex) || parseInt(parentCs?.zIndex || '0') || 0,
359
+ position: cs.position || 'static',
360
+ bounds: { x: Math.round(rect.x), y: Math.round(rect.y + scrollY), w: Math.round(rect.width), h: Math.round(rect.height) }
361
+ };
362
+ }).filter(Boolean);
363
+
364
+ // Also include elements with background images
365
+ const bgEls = [...document.querySelectorAll('*')].map(el => {
366
+ const cs = getComputedStyle(el);
367
+ const bg = cs.backgroundImage;
368
+ if (!bg || bg === 'none' || !bg.includes('url(')) return null;
369
+ const match = bg.match(/url\\(["']?([^"')]+)["']?\\)/);
370
+ if (!match || !match[1] || match[1].startsWith('data:')) return null;
371
+ const rect = el.getBoundingClientRect();
372
+ if (rect.width < 50 || rect.height < 50) return null;
373
+ const scrollY = window.scrollY;
374
+ return {
375
+ src: match[1],
376
+ alt: '',
377
+ zIndex: parseInt(cs.zIndex) || 0,
378
+ position: cs.position || 'static',
379
+ bounds: { x: Math.round(rect.x), y: Math.round(rect.y + scrollY), w: Math.round(rect.width), h: Math.round(rect.height) }
380
+ };
381
+ }).filter(Boolean);
382
+
383
+ const all = [...allImgs, ...bgEls];
384
+
385
+ // Find overlapping groups
386
+ function overlaps(a, b) {
387
+ return !(a.bounds.x + a.bounds.w < b.bounds.x || b.bounds.x + b.bounds.w < a.bounds.x ||
388
+ a.bounds.y + a.bounds.h < b.bounds.y || b.bounds.y + b.bounds.h < a.bounds.y);
389
+ }
390
+
391
+ const compositions = [];
392
+ const used = new Set();
393
+ for (let i = 0; i < all.length; i++) {
394
+ if (used.has(i)) continue;
395
+ const group = [all[i]];
396
+ used.add(i);
397
+ for (let j = i + 1; j < all.length; j++) {
398
+ if (used.has(j)) continue;
399
+ if (group.some(g => overlaps(g, all[j]))) {
400
+ group.push(all[j]);
401
+ used.add(j);
402
+ }
403
+ }
404
+ if (group.length >= 2) {
405
+ // Multiple overlapping images = composition
406
+ const hasZDiff = new Set(group.map(g => g.zIndex)).size > 1;
407
+ compositions.push({
408
+ layers: group.sort((a, b) => a.zIndex - b.zIndex),
409
+ compositeType: hasZDiff ? 'overlay' : 'stacked'
410
+ });
411
+ }
412
+ }
413
+ return compositions;
414
+ })()`);
415
+ // Extract actual rendered fonts via getComputedStyle (more reliable than CSS regex)
416
+ const computedFonts = await page.evaluate(`(() => {
417
+ const fonts = { heading: null, body: null };
418
+ // Get heading font from first h1/h2
419
+ const heading = document.querySelector('h1, h2');
420
+ if (heading) {
421
+ const family = getComputedStyle(heading).fontFamily.split(',')[0].trim().replace(/['"]/g, '');
422
+ if (family && family !== 'inherit' && family !== 'initial') fonts.heading = family;
423
+ }
424
+ // Get body font from first visible paragraph or body
425
+ const body = document.querySelector('p') || document.body;
426
+ if (body) {
427
+ const family = getComputedStyle(body).fontFamily.split(',')[0].trim().replace(/['"]/g, '');
428
+ if (family && family !== 'inherit' && family !== 'initial') fonts.body = family;
429
+ }
430
+ // Also check for Google Fonts links
431
+ const googleFonts = [...document.querySelectorAll('link[href*="fonts.googleapis.com"]')]
432
+ .map(l => l.getAttribute('href'))
433
+ .filter(Boolean);
434
+ return { ...fonts, googleFontLinks: googleFonts };
435
+ })()`);
436
+ // Extract <video> elements (background videos, hero videos, inline video players)
437
+ const videos = await page.evaluate(`(() => {
438
+ const results = [];
439
+ const seen = new Set();
440
+ document.querySelectorAll('video').forEach(vid => {
441
+ // Get src from <video src=""> or <source src="">
442
+ let src = vid.src || '';
443
+ if (!src) {
444
+ const source = vid.querySelector('source[src]');
445
+ if (source) src = source.src || source.getAttribute('src') || '';
446
+ }
447
+ if (!src || src.startsWith('data:') || seen.has(src)) return;
448
+ seen.add(src);
449
+ const rect = vid.getBoundingClientRect();
450
+ if (rect.width < 30 || rect.height < 30) return;
451
+ const scrollY = window.scrollY;
452
+ results.push({
453
+ src,
454
+ poster: vid.poster || undefined,
455
+ autoplay: vid.autoplay,
456
+ loop: vid.loop,
457
+ muted: vid.muted,
458
+ y: Math.round(rect.y + scrollY),
459
+ width: Math.round(rect.width),
460
+ height: Math.round(rect.height),
461
+ });
462
+ });
463
+ return results;
464
+ })()`);
465
+ // Resolve CSS custom properties to actual computed values (fixes var() references in design tokens)
466
+ const resolvedCssVars = await page.evaluate(`(() => {
467
+ const vars = {};
468
+ const root = document.documentElement;
469
+ const rootStyles = getComputedStyle(root);
470
+ // Collect all CSS custom properties declared on :root / html
471
+ for (const sheet of document.styleSheets) {
472
+ try {
473
+ for (const rule of sheet.cssRules) {
474
+ if (rule.selectorText && /^(:root|html)$/i.test(rule.selectorText.trim())) {
475
+ for (const prop of rule.style) {
476
+ if (prop.startsWith('--')) {
477
+ const resolved = rootStyles.getPropertyValue(prop).trim();
478
+ if (resolved) vars[prop] = resolved;
479
+ }
480
+ }
481
+ }
482
+ }
483
+ } catch(e) {}
484
+ }
485
+ return vars;
486
+ })()`);
487
+ // Compute visual section boundaries from layout nodes (CMS-agnostic)
488
+ const visualSections = segmentByVisualGaps(layoutNodes);
489
+ // Extract computed styles per visual section (Site_Clone technique: getComputedStyle walker)
490
+ // Pass Y-ranges from visual gap analysis so the browser finds elements by position, not by tag
491
+ const sectionYRanges = visualSections.map(vs => ({ y: vs.y, h: vs.height }));
492
+ const sectionStylesRaw = await page.evaluate(`((yRanges) => {
493
+ const PROPS = [
494
+ 'fontSize','fontWeight','fontFamily','lineHeight','letterSpacing','color',
495
+ 'textTransform','textDecoration','textAlign',
496
+ 'backgroundColor','background','backgroundImage',
497
+ 'padding','paddingTop','paddingRight','paddingBottom','paddingLeft',
498
+ 'margin','marginTop','marginRight','marginBottom','marginLeft',
499
+ 'width','height','maxWidth','minWidth','maxHeight','minHeight',
500
+ 'display','flexDirection','justifyContent','alignItems','gap',
501
+ 'gridTemplateColumns','gridTemplateRows',
502
+ 'borderRadius','border','boxShadow',
503
+ 'overflow','position','top','right','bottom','left','zIndex',
504
+ 'opacity','transform','transition','cursor',
505
+ 'objectFit','objectPosition'
506
+ ];
507
+ const DEFAULTS = new Set(['none','normal','auto','0px','0','rgba(0, 0, 0, 0)','','0px 0px','0px 0px 0px 0px','start','stretch','visible','static']);
508
+ const MAX_NODES = 500;
509
+ const MAX_DEPTH = 8;
510
+
511
+ function extractStyles(el) {
512
+ const cs = getComputedStyle(el);
513
+ const styles = {};
514
+ for (const p of PROPS) {
515
+ const v = cs[p];
516
+ if (v && !DEFAULTS.has(v)) styles[p] = v;
517
+ }
518
+ return styles;
519
+ }
520
+
521
+ function selectorFor(el, parent) {
522
+ const tag = el.tagName.toLowerCase();
523
+ if (!parent) return tag;
524
+ const siblings = [...parent.children].filter(c => c.tagName === el.tagName);
525
+ if (siblings.length === 1) return tag;
526
+ const idx = siblings.indexOf(el) + 1;
527
+ return tag + ':nth-child(' + idx + ')';
528
+ }
529
+
530
+ let nodeCount = 0;
531
+ function walk(el, depth, parentSelector) {
532
+ if (depth > MAX_DEPTH || nodeCount >= MAX_NODES) return null;
533
+ const rect = el.getBoundingClientRect();
534
+ if (rect.height < 5 || rect.width < 5) return null;
535
+ const tag = el.tagName.toLowerCase();
536
+ if (['script','style','svg','path','link','meta','noscript'].includes(tag)) return null;
537
+ const cs = getComputedStyle(el);
538
+ if (cs.display === 'none' || cs.visibility === 'hidden') return null;
539
+
540
+ nodeCount++;
541
+ const seg = selectorFor(el, el.parentElement);
542
+ const selector = parentSelector ? parentSelector + ' > ' + seg : seg;
543
+
544
+ const isLeaf = el.children.length === 0;
545
+ const isTextElement = ['h1','h2','h3','h4','h5','h6','p','span','a','li','label','strong','em','b','i','blockquote','figcaption','dt','dd'].includes(tag);
546
+ const text = (isLeaf || isTextElement) && el.textContent ? el.textContent.trim().slice(0, 200) : null;
547
+ const image = tag === 'img' ? {
548
+ src: el.src || el.currentSrc || '',
549
+ alt: el.alt || '',
550
+ naturalWidth: el.naturalWidth || 0,
551
+ naturalHeight: el.naturalHeight || 0
552
+ } : null;
553
+
554
+ const children = [];
555
+ for (const child of el.children) {
556
+ if (nodeCount >= MAX_NODES) break;
557
+ const c = walk(child, depth + 1, selector);
558
+ if (c) children.push(c);
559
+ }
560
+
561
+ return { tag, depth, selector, styles: extractStyles(el), text, image, children };
562
+ }
563
+
564
+ // Find the best DOM element for each visual section Y-range.
565
+ // Uses document-relative coordinates (getBoundingClientRect + scrollY).
566
+ // CMS-agnostic — works on any site regardless of HTML structure.
567
+ function findElementForRange(y, h) {
568
+ const scrollY = window.scrollY;
569
+ const allEls = document.querySelectorAll('body *');
570
+ let best = null;
571
+ let bestScore = Infinity;
572
+ for (const el of allEls) {
573
+ const tag = el.tagName.toLowerCase();
574
+ if (['script','style','svg','path','link','meta','noscript','br','hr'].includes(tag)) continue;
575
+ const rect = el.getBoundingClientRect();
576
+ const absY = rect.y + scrollY;
577
+ const absH = rect.height;
578
+ if (absH < 40 || rect.width < 100) continue;
579
+ const overlapStart = Math.max(absY, y);
580
+ const overlapEnd = Math.min(absY + absH, y + h);
581
+ if (overlapEnd <= overlapStart) continue;
582
+ const overlap = overlapEnd - overlapStart;
583
+ const coverage = overlap / h;
584
+ if (coverage < 0.5) continue;
585
+ // Skip elements > 3x target height — page containers
586
+ if (absH / h > 3) continue;
587
+ // Score: prefer elements whose height is closest to the target
588
+ const heightDiff = Math.abs(absH - h) / h;
589
+ if (heightDiff < bestScore) {
590
+ bestScore = heightDiff;
591
+ best = el;
592
+ }
593
+ }
594
+ return best;
595
+ }
596
+
597
+ return yRanges.slice(0, 20).map((range, i) => {
598
+ const el = findElementForRange(range.y, range.h);
599
+ if (!el) return { sectionIndex: i, root: null, matched: false };
600
+ nodeCount = 0;
601
+ const root = walk(el, 0, '');
602
+ return root ? { sectionIndex: i, root, matched: true } : { sectionIndex: i, root: null, matched: false };
603
+ });
604
+ })(${JSON.stringify(sectionYRanges)})`);
605
+ // Identify unmatched visual sections (no DOM element found for Y-range)
606
+ const unmatchedIndices = sectionStylesRaw
607
+ .filter(s => !s.matched)
608
+ .map(s => s.sectionIndex);
609
+ // Extract the actual SectionStyles (filtering out unmatched)
610
+ const sectionStyles = sectionStylesRaw
611
+ .filter(s => s.matched && s.root)
612
+ .map(s => ({ sectionIndex: s.sectionIndex, root: s.root }));
613
+ // Fallback content extraction for unmatched visual sections.
614
+ // When findElementForRange fails, collect all text-bearing elements within the Y-range.
615
+ let sectionFallbackContent;
616
+ if (unmatchedIndices.length > 0) {
617
+ const unmatchedRanges = unmatchedIndices.map(i => ({
618
+ sectionIndex: i,
619
+ ...sectionYRanges[i],
620
+ }));
621
+ sectionFallbackContent = await page.evaluate(`((ranges) => {
622
+ const scrollY = window.scrollY;
623
+ return ranges.map(({ sectionIndex, y, h }) => {
624
+ const headings = [];
625
+ const paragraphs = [];
626
+ const links = [];
627
+ const listItems = [];
628
+ const seen = new Set();
629
+
630
+ // Scan all relevant elements by Y position
631
+ const allEls = document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,a,li,span,div,td,th,dt,dd,label,figcaption,blockquote');
632
+ for (const el of allEls) {
633
+ const rect = el.getBoundingClientRect();
634
+ const absY = rect.y + scrollY;
635
+ if (rect.height < 5 || rect.width < 50) continue;
636
+ // Element must be within section Y-range
637
+ if (absY < y - 20 || absY > y + h + 20) continue;
638
+
639
+ const tag = el.tagName.toLowerCase();
640
+ const text = el.textContent ? el.textContent.trim().slice(0, 300) : '';
641
+ if (!text || text.length < 2) continue;
642
+
643
+ // Deduplicate by text content (Elementor often has nested wrappers with same text)
644
+ const key = tag + ':' + text.slice(0, 80);
645
+ if (seen.has(key)) continue;
646
+ seen.add(key);
647
+
648
+ if (['h1','h2','h3','h4','h5','h6'].includes(tag)) {
649
+ const level = parseInt(tag[1]);
650
+ headings.push({ level, text });
651
+ } else if (tag === 'a' && el.href) {
652
+ links.push({ href: el.href, text });
653
+ } else if (tag === 'li') {
654
+ listItems.push(text);
655
+ } else if (tag === 'p' || (tag === 'div' && el.children.length === 0 && text.length > 20)) {
656
+ // Include leaf div text as paragraphs (Elementor uses divs for text)
657
+ paragraphs.push(text);
658
+ }
659
+ }
660
+
661
+ // Group list items into lists (each contiguous group = one list)
662
+ const lists = listItems.length > 0 ? [listItems] : [];
663
+
664
+ return { sectionIndex, headings, paragraphs, links, lists };
665
+ });
666
+ })(${JSON.stringify(unmatchedRanges)})`);
667
+ }
668
+ // Take full-page screenshots at desktop and mobile viewports
669
+ let screenshot = null;
670
+ let mobileScreenshot = null;
671
+ try {
672
+ const buffer = await page.screenshot({ fullPage: true, type: "jpeg", quality: 75 });
673
+ screenshot = { base64: buffer.toString("base64"), viewport: { width, height } };
674
+ }
675
+ catch { /* non-fatal */ }
676
+ // Mobile screenshot (390px) — captures responsive layout for better block decisions
677
+ try {
678
+ const mobileWidth = 390;
679
+ await page.setViewportSize({ width: mobileWidth, height });
680
+ await page.waitForTimeout(500); // let responsive styles settle
681
+ const buffer = await page.screenshot({ fullPage: true, type: "jpeg", quality: 60 });
682
+ mobileScreenshot = { base64: buffer.toString("base64"), viewport: { width: mobileWidth, height } };
683
+ // Restore desktop viewport
684
+ await page.setViewportSize({ width, height });
685
+ }
686
+ catch { /* non-fatal */ }
687
+ // ── Interaction sweep: click tabs/accordions, detect scroll triggers ──
688
+ // Captures style changes from dynamic interactions for better block classification.
689
+ let interactionStates;
690
+ try {
691
+ interactionStates = await page.evaluate(`(async () => {
692
+ const delay = ms => new Promise(r => setTimeout(r, ms));
693
+ const STYLE_PROPS = ['display','visibility','opacity','height','maxHeight','transform','backgroundColor','color','overflow'];
694
+ const results = [];
695
+
696
+ function getStyles(el) {
697
+ const cs = getComputedStyle(el);
698
+ const s = {};
699
+ for (const p of STYLE_PROPS) { s[p] = cs[p]; }
700
+ return s;
701
+ }
702
+
703
+ function diffStyles(before, after) {
704
+ const changed = {};
705
+ for (const p of STYLE_PROPS) {
706
+ if (before[p] !== after[p]) changed[p] = { before: before[p], after: after[p] };
707
+ }
708
+ return Object.keys(changed).length > 0 ? changed : null;
709
+ }
710
+
711
+ // 1. Tab clicks: find [role=tab] or elements with tab-like classes
712
+ const tabTriggers = [...document.querySelectorAll('[role="tab"], [data-toggle="tab"], .tab-link, .tabs__tab, .e-n-tab-title')].slice(0, 10);
713
+ for (const trigger of tabTriggers) {
714
+ const rect = trigger.getBoundingClientRect();
715
+ const scrollY = window.scrollY;
716
+ const sectionY = Math.round(rect.y + scrollY);
717
+ // Find the associated panel
718
+ const panelId = trigger.getAttribute('aria-controls') || trigger.getAttribute('data-target');
719
+ const panel = panelId ? document.getElementById(panelId) || document.querySelector(panelId) : trigger.closest('[role="tablist"]')?.parentElement?.querySelector('[role="tabpanel"]');
720
+ if (!panel) continue;
721
+
722
+ const beforeStyles = getStyles(panel);
723
+ trigger.click();
724
+ await delay(350);
725
+ const afterStyles = getStyles(panel);
726
+ const changed = diffStyles(beforeStyles, afterStyles);
727
+ if (changed) {
728
+ const transition = getComputedStyle(panel).transition || '';
729
+ results.push({
730
+ sectionY,
731
+ states: [{ trigger: 'click', triggerTarget: 'tab: ' + (trigger.textContent || '').trim().slice(0, 50), changedStyles: changed, transitionDuration: transition.includes('0s') ? undefined : transition.split(',')[0]?.trim() }]
732
+ });
733
+ }
734
+ }
735
+
736
+ // 2. Accordion clicks: find <details>, [data-toggle="collapse"], accordion triggers
737
+ const accordionTriggers = [...document.querySelectorAll('details > summary, [data-toggle="collapse"], .accordion-header, .accordion-trigger, .e-n-accordion-item-title')].slice(0, 10);
738
+ for (const trigger of accordionTriggers) {
739
+ const rect = trigger.getBoundingClientRect();
740
+ const scrollY = window.scrollY;
741
+ const sectionY = Math.round(rect.y + scrollY);
742
+ const parent = trigger.closest('details') || trigger.parentElement;
743
+ if (!parent) continue;
744
+ const content = parent.querySelector('.accordion-content, .accordion-body, .collapse, [role="region"], details > :not(summary)') || (trigger.tagName === 'SUMMARY' ? trigger.parentElement : null);
745
+ if (!content) continue;
746
+
747
+ const beforeStyles = getStyles(content);
748
+ trigger.click();
749
+ await delay(350);
750
+ const afterStyles = getStyles(content);
751
+ const changed = diffStyles(beforeStyles, afterStyles);
752
+ if (changed) {
753
+ const transition = getComputedStyle(content).transition || '';
754
+ results.push({
755
+ sectionY,
756
+ states: [{ trigger: 'click', triggerTarget: 'accordion: ' + (trigger.textContent || '').trim().slice(0, 50), changedStyles: changed, transitionDuration: transition.includes('0s') ? undefined : transition.split(',')[0]?.trim() }]
757
+ });
758
+ }
759
+ }
760
+
761
+ // 3. Scroll-triggered elements: check for elements that change on scroll
762
+ const stickyEls = [...document.querySelectorAll('header, nav, [class*="sticky"], [class*="fixed"]')].slice(0, 5);
763
+ if (stickyEls.length > 0) {
764
+ window.scrollTo(0, 0);
765
+ await delay(200);
766
+ const beforeMap = stickyEls.map(el => ({ el, styles: getStyles(el) }));
767
+ window.scrollTo(0, 500);
768
+ await delay(400);
769
+ for (const { el, styles: before } of beforeMap) {
770
+ const after = getStyles(el);
771
+ const changed = diffStyles(before, after);
772
+ if (changed) {
773
+ const rect = el.getBoundingClientRect();
774
+ results.push({
775
+ sectionY: 0, // scroll triggers are typically at page top
776
+ states: [{ trigger: 'scroll', triggerTarget: el.tagName.toLowerCase() + (el.className ? '.' + el.className.toString().split(' ')[0] : ''), changedStyles: changed }]
777
+ });
778
+ }
779
+ }
780
+ window.scrollTo(0, 0);
781
+ await delay(200);
782
+ }
783
+
784
+ return results;
785
+ })()`);
786
+ // 4. Hover state capture for buttons and links (uses Playwright hover API)
787
+ const hoverTargets = await page.evaluate(`(() => {
788
+ const targets = [];
789
+ const els = document.querySelectorAll('a, button, [role="button"]');
790
+ for (const el of [...els].slice(0, 15)) {
791
+ const rect = el.getBoundingClientRect();
792
+ if (rect.width < 20 || rect.height < 20) continue;
793
+ const cs = getComputedStyle(el);
794
+ if (cs.display === 'none' || cs.visibility === 'hidden') continue;
795
+ // Only capture elements that have non-transparent backgrounds (likely CTA/buttons)
796
+ const bg = cs.backgroundColor;
797
+ const hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
798
+ const hasBoxShadow = cs.boxShadow && cs.boxShadow !== 'none';
799
+ if (!hasBg && !hasBoxShadow && el.tagName !== 'BUTTON') continue;
800
+ const scrollY = window.scrollY;
801
+ targets.push({
802
+ selector: el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.toString().split(' ').filter(Boolean).join('.') : ''),
803
+ y: Math.round(rect.y + scrollY),
804
+ beforeStyles: {
805
+ backgroundColor: cs.backgroundColor,
806
+ color: cs.color,
807
+ transform: cs.transform,
808
+ boxShadow: cs.boxShadow,
809
+ borderColor: cs.borderColor,
810
+ opacity: cs.opacity,
811
+ transition: cs.transition,
812
+ }
813
+ });
814
+ }
815
+ return targets;
816
+ })()`);
817
+ for (const target of hoverTargets.slice(0, 8)) {
818
+ try {
819
+ const el = page.locator(target.selector).first();
820
+ if (!await el.isVisible().catch(() => false))
821
+ continue;
822
+ await el.hover({ timeout: 1000 });
823
+ await page.waitForTimeout(200);
824
+ const afterStyles = await el.evaluate(`(el) => {
825
+ const cs = getComputedStyle(el);
826
+ return {
827
+ backgroundColor: cs.backgroundColor,
828
+ color: cs.color,
829
+ transform: cs.transform,
830
+ boxShadow: cs.boxShadow,
831
+ borderColor: cs.borderColor,
832
+ opacity: cs.opacity,
833
+ };
834
+ }`);
835
+ const changed = {};
836
+ for (const [prop, before] of Object.entries(target.beforeStyles)) {
837
+ if (prop === "transition")
838
+ continue;
839
+ const after = afterStyles[prop] ?? before;
840
+ if (before !== after)
841
+ changed[prop] = { before, after };
842
+ }
843
+ if (Object.keys(changed).length > 0) {
844
+ if (!interactionStates)
845
+ interactionStates = [];
846
+ interactionStates.push({
847
+ sectionY: target.y,
848
+ states: [{
849
+ trigger: "hover",
850
+ triggerTarget: target.selector,
851
+ changedStyles: changed,
852
+ transitionDuration: target.beforeStyles.transition?.includes("0s") ? undefined : target.beforeStyles.transition?.split(",")[0]?.trim(),
853
+ }],
854
+ });
855
+ }
856
+ }
857
+ catch { /* non-fatal */ }
858
+ }
859
+ }
860
+ catch {
861
+ // Non-fatal — interaction sweep failure shouldn't block scraping
862
+ }
863
+ // Process the rendered HTML
864
+ const resolvedHtml = resolveLazyImages(renderedHtml);
865
+ const processed = processHtml(resolvedHtml, url);
866
+ const css = [processed.css, ...stylesheets].join("\n\n");
867
+ const content = {
868
+ html: processed.html,
869
+ css,
870
+ baseUrl: url,
871
+ title: processed.title,
872
+ metaDescription: processed.metaDescription,
873
+ };
874
+ const sections = extractSections(processed.html, url);
875
+ const outline = extractPageOutline(processed.html, url, layoutNodes);
876
+ const nav = extractNavigation(processed.html, url);
877
+ // Inject CSS background images into sections/outline that have zero images
878
+ if (bgImages.length > 0) {
879
+ for (const bg of bgImages) {
880
+ const resolvedUrl = bg.url.startsWith("http") ? bg.url : (() => { try {
881
+ return new URL(bg.url, url).href;
882
+ }
883
+ catch {
884
+ return bg.url;
885
+ } })();
886
+ // Add to the first section with zero images whose Y range overlaps
887
+ for (const section of sections) {
888
+ if (section.content.images.length === 0) {
889
+ section.content.images.push({ src: resolvedUrl, alt: "", isLazy: false });
890
+ break;
891
+ }
892
+ }
893
+ // Also add to outline sections
894
+ for (const os of outline.sections) {
895
+ if (os.imageCount === 0 && os.type === "hero") {
896
+ os.imageCount = 1;
897
+ break;
898
+ }
899
+ }
900
+ }
901
+ }
902
+ return { content, screenshot, mobileScreenshot, sections, outline, nav, sectionStyles, visualSections, embeds, videos, scrollBehavior, computedFonts, pageImages, imageCompositions, sectionFallbackContent, resolvedCssVars, interactionStates };
903
+ }
904
+ finally {
905
+ await browser.close();
906
+ }
907
+ }
908
+ // ── Site structure discovery ──
909
+ /** Derive a slug from a URL path */
910
+ function urlToSlug(urlStr) {
911
+ try {
912
+ const u = new URL(urlStr);
913
+ const path = u.pathname.replace(/\/+$/, "") || "/";
914
+ return path;
915
+ }
916
+ catch {
917
+ return urlStr.startsWith("/") ? urlStr : `/${urlStr}`;
918
+ }
919
+ }
920
+ /** Derive a page title from a slug */
921
+ function slugToTitle(slug) {
922
+ if (slug === "/")
923
+ return "Home";
924
+ return slug
925
+ .replace(/^\//, "")
926
+ .split(/[/-]/)
927
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
928
+ .join(" ");
929
+ }
930
+ /**
931
+ * Discover pages on a website via sitemap.xml, robots.txt, and link crawling.
932
+ *
933
+ * Strategy (in priority order):
934
+ * 1. Try sitemap.xml at the site root
935
+ * 2. Try robots.txt for Sitemap: directives
936
+ * 3. Fall back to extracting <a> links from the homepage
937
+ */
938
+ export async function discoverSitePages(url) {
939
+ const origin = new URL(url).origin;
940
+ // 1. Try sitemap.xml
941
+ const sitemapPages = await fetchSitemap(`${origin}/sitemap.xml`, origin);
942
+ if (sitemapPages.length > 0) {
943
+ return { origin, pages: sitemapPages, source: "sitemap", totalFound: sitemapPages.length };
944
+ }
945
+ // 2. Try robots.txt for Sitemap: directives
946
+ const robotsSitemapUrls = await fetchRobotsSitemaps(origin);
947
+ for (const sitemapUrl of robotsSitemapUrls) {
948
+ const pages = await fetchSitemap(sitemapUrl, origin);
949
+ if (pages.length > 0) {
950
+ return { origin, pages, source: "robots", totalFound: pages.length };
951
+ }
952
+ }
953
+ // 3. Fall back to BFS link crawling (depth 2, max 50 pages)
954
+ const linkPages = await bfsCrawlLinks(url, origin, 2, 50);
955
+ if (linkPages.length > 0) {
956
+ return { origin, pages: linkPages, source: "links", totalFound: linkPages.length };
957
+ }
958
+ // 4. Single page fallback
959
+ return {
960
+ origin,
961
+ pages: [{ url, slug: "/", title: "Home" }],
962
+ source: "single",
963
+ totalFound: 1,
964
+ };
965
+ }
966
+ async function fetchSitemap(sitemapUrl, origin) {
967
+ try {
968
+ const res = await fetch(sitemapUrl, {
969
+ headers: { "User-Agent": USER_AGENT },
970
+ signal: AbortSignal.timeout(10_000),
971
+ });
972
+ if (!res.ok)
973
+ return [];
974
+ const xml = await res.text();
975
+ // Parse <loc> tags
976
+ const locRe = /<loc>([\s\S]*?)<\/loc>/gi;
977
+ const pages = [];
978
+ const seen = new Set();
979
+ let match;
980
+ while ((match = locRe.exec(xml)) !== null) {
981
+ const pageUrl = match[1].trim();
982
+ // Filter to same origin
983
+ if (!pageUrl.startsWith(origin))
984
+ continue;
985
+ const slug = urlToSlug(pageUrl);
986
+ if (seen.has(slug))
987
+ continue;
988
+ seen.add(slug);
989
+ pages.push({ url: pageUrl, slug, title: slugToTitle(slug) });
990
+ }
991
+ return pages;
992
+ }
993
+ catch {
994
+ return [];
995
+ }
996
+ }
997
+ async function fetchRobotsSitemaps(origin) {
998
+ try {
999
+ const res = await fetch(`${origin}/robots.txt`, {
1000
+ headers: { "User-Agent": USER_AGENT },
1001
+ signal: AbortSignal.timeout(5_000),
1002
+ });
1003
+ if (!res.ok)
1004
+ return [];
1005
+ const text = await res.text();
1006
+ const urls = [];
1007
+ for (const line of text.split("\n")) {
1008
+ const match = line.match(/^Sitemap:\s*(.+)/i);
1009
+ if (match)
1010
+ urls.push(match[1].trim());
1011
+ }
1012
+ return urls;
1013
+ }
1014
+ catch {
1015
+ return [];
1016
+ }
1017
+ }
1018
+ /** BFS crawl: discover pages by following internal links up to maxDepth levels, capped at maxPages */
1019
+ async function bfsCrawlLinks(startUrl, origin, maxDepth, maxPages) {
1020
+ const seen = new Set();
1021
+ const pages = [];
1022
+ const queue = [{ url: startUrl, depth: 0 }];
1023
+ while (queue.length > 0 && pages.length < maxPages) {
1024
+ const { url, depth } = queue.shift();
1025
+ if (depth > maxDepth)
1026
+ continue;
1027
+ const slug = urlToSlug(url);
1028
+ if (seen.has(slug))
1029
+ continue;
1030
+ seen.add(slug);
1031
+ pages.push({ url, slug, title: slugToTitle(slug) });
1032
+ // Only crawl deeper if below max depth
1033
+ if (depth < maxDepth) {
1034
+ const childPages = await extractLinksFromPage(url, origin);
1035
+ for (const child of childPages) {
1036
+ if (!seen.has(child.slug) && pages.length + queue.length < maxPages * 2) {
1037
+ queue.push({ url: child.url, depth: depth + 1 });
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ return pages;
1043
+ }
1044
+ async function extractLinksFromPage(url, origin) {
1045
+ try {
1046
+ const res = await fetch(url, {
1047
+ headers: { "User-Agent": USER_AGENT },
1048
+ signal: AbortSignal.timeout(10_000),
1049
+ });
1050
+ if (!res.ok)
1051
+ return [];
1052
+ const html = await res.text();
1053
+ // Extract all <a href="..."> links
1054
+ const linkRe = /<a[^>]+href\s*=\s*["']([^"'#]+)["']/gi;
1055
+ const seen = new Set();
1056
+ const pages = [];
1057
+ let match;
1058
+ while ((match = linkRe.exec(html)) !== null) {
1059
+ let href = match[1].trim();
1060
+ if (!href || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:"))
1061
+ continue;
1062
+ // Resolve relative URLs
1063
+ try {
1064
+ const resolved = new URL(href, url).href;
1065
+ if (!resolved.startsWith(origin))
1066
+ continue; // skip external links
1067
+ href = resolved;
1068
+ }
1069
+ catch {
1070
+ continue;
1071
+ }
1072
+ const slug = urlToSlug(href);
1073
+ if (seen.has(slug))
1074
+ continue;
1075
+ // Skip asset/API paths
1076
+ if (/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|pdf|zip)$/i.test(slug))
1077
+ continue;
1078
+ if (/^\/(api|_next|static|assets|cdn|wp-content|wp-admin|wp-includes)\//i.test(slug))
1079
+ continue;
1080
+ seen.add(slug);
1081
+ pages.push({ url: href, slug, title: slugToTitle(slug) });
1082
+ }
1083
+ return pages;
1084
+ }
1085
+ catch {
1086
+ return [];
1087
+ }
1088
+ }