@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
package/dist/scraper.js
ADDED
|
@@ -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
|
+
}
|