@avocadostudio-ai/migration-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/block-codegen.d.ts +5 -0
- package/dist/block-codegen.js +224 -0
- package/dist/design-tokens.d.ts +26 -0
- package/dist/design-tokens.js +733 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/scraper.d.ts +27 -0
- package/dist/scraper.js +1088 -0
- package/dist/section-extractor.d.ts +50 -0
- package/dist/section-extractor.js +911 -0
- package/dist/section-spec.d.ts +22 -0
- package/dist/section-spec.js +526 -0
- package/dist/types.d.ts +403 -0
- package/dist/types.js +2 -0
- package/package.json +47 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section spec generator — assembles block-type-agnostic section specifications
|
|
3
|
+
* from extracted content, computed styles, and page outline data.
|
|
4
|
+
*
|
|
5
|
+
* The spec is a faithful representation of the source section, NOT a pre-mapped
|
|
6
|
+
* prop bag. The LLM decides whether to use an existing block or code a new one.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtractedSection, SectionStyles, PageOutline, SectionSpec, FullPageScrape } from "./types.ts";
|
|
9
|
+
/**
|
|
10
|
+
* Build a block-type-agnostic section spec from extracted content, computed styles,
|
|
11
|
+
* and page outline data. The LLM uses this to decide: existing block or custom block.
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildSectionSpec(section: ExtractedSection, sectionStyles?: SectionStyles, outlineSection?: PageOutline["sections"][number]): SectionSpec;
|
|
14
|
+
/**
|
|
15
|
+
* Build section specs for all sections of a scraped page.
|
|
16
|
+
*
|
|
17
|
+
* Uses visual sections (from bounding-box gap analysis) as the primary source
|
|
18
|
+
* when available — this is CMS-agnostic and correctly identifies sections on
|
|
19
|
+
* sites that don't use semantic HTML tags. Falls back to regex-based
|
|
20
|
+
* extractSections() when visual data isn't available.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildPageSpecs(scrape: FullPageScrape): SectionSpec[];
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section spec generator — assembles block-type-agnostic section specifications
|
|
3
|
+
* from extracted content, computed styles, and page outline data.
|
|
4
|
+
*
|
|
5
|
+
* The spec is a faithful representation of the source section, NOT a pre-mapped
|
|
6
|
+
* prop bag. The LLM decides whether to use an existing block or code a new one.
|
|
7
|
+
*/
|
|
8
|
+
import { classifySection } from "./section-extractor.js";
|
|
9
|
+
// ── Structure analysis helpers ──
|
|
10
|
+
function childSignature(node) {
|
|
11
|
+
return node.children
|
|
12
|
+
.map((c) => c.tag)
|
|
13
|
+
.join(" + ");
|
|
14
|
+
}
|
|
15
|
+
/** Detect repeated child patterns in a style tree */
|
|
16
|
+
function detectRepeats(root) {
|
|
17
|
+
if (root.children.length < 2)
|
|
18
|
+
return { count: 0 };
|
|
19
|
+
// Group children by their child-tag signature
|
|
20
|
+
const sigMap = new Map();
|
|
21
|
+
for (const child of root.children) {
|
|
22
|
+
const sig = childSignature(child);
|
|
23
|
+
if (!sig)
|
|
24
|
+
continue;
|
|
25
|
+
sigMap.set(sig, (sigMap.get(sig) ?? 0) + 1);
|
|
26
|
+
}
|
|
27
|
+
// Find the most frequent signature with 2+ occurrences
|
|
28
|
+
let bestSig = "";
|
|
29
|
+
let bestCount = 0;
|
|
30
|
+
for (const [sig, count] of sigMap) {
|
|
31
|
+
if (count > bestCount) {
|
|
32
|
+
bestCount = count;
|
|
33
|
+
bestSig = sig;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Also check one level deeper — repeated items might be inside a wrapper div
|
|
37
|
+
if (bestCount < 2) {
|
|
38
|
+
for (const wrapper of root.children) {
|
|
39
|
+
if (wrapper.children.length < 2)
|
|
40
|
+
continue;
|
|
41
|
+
const innerSigMap = new Map();
|
|
42
|
+
for (const child of wrapper.children) {
|
|
43
|
+
const sig = childSignature(child);
|
|
44
|
+
if (!sig)
|
|
45
|
+
continue;
|
|
46
|
+
innerSigMap.set(sig, (innerSigMap.get(sig) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
for (const [sig, count] of innerSigMap) {
|
|
49
|
+
if (count > bestCount) {
|
|
50
|
+
bestCount = count;
|
|
51
|
+
bestSig = sig;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return bestCount >= 2 ? { count: bestCount, signature: bestSig } : { count: 0 };
|
|
57
|
+
}
|
|
58
|
+
/** Detect repeated patterns from content (headings at same level) when no style tree available */
|
|
59
|
+
function detectRepeatsFromContent(content) {
|
|
60
|
+
// Count headings by level — repeated same-level headings suggest repeated items
|
|
61
|
+
const levelCounts = new Map();
|
|
62
|
+
for (const h of content.headings) {
|
|
63
|
+
levelCounts.set(h.level, (levelCounts.get(h.level) ?? 0) + 1);
|
|
64
|
+
}
|
|
65
|
+
// Find the level with most repetitions (minimum 2)
|
|
66
|
+
let bestLevel = 0;
|
|
67
|
+
let bestCount = 0;
|
|
68
|
+
for (const [level, count] of levelCounts) {
|
|
69
|
+
if (count > bestCount && count >= 2) {
|
|
70
|
+
bestCount = count;
|
|
71
|
+
bestLevel = level;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (bestCount >= 2) {
|
|
75
|
+
// Check if there are also lists that correlate (e.g., pricing items with feature lists)
|
|
76
|
+
const hasLists = content.lists.length > 0;
|
|
77
|
+
const signature = hasLists ? `h${bestLevel} + list` : `h${bestLevel}`;
|
|
78
|
+
return { count: bestCount, signature };
|
|
79
|
+
}
|
|
80
|
+
return { count: 0 };
|
|
81
|
+
}
|
|
82
|
+
/** Infer layout pattern from computed styles */
|
|
83
|
+
function inferPattern(root, repeats) {
|
|
84
|
+
const s = root.styles;
|
|
85
|
+
const display = s.display ?? "";
|
|
86
|
+
const flexDir = s.flexDirection ?? "";
|
|
87
|
+
const gridCols = s.gridTemplateColumns ?? "";
|
|
88
|
+
// Grid layout
|
|
89
|
+
if (display === "grid" && gridCols) {
|
|
90
|
+
const colCount = gridCols.split(/\s+/).filter((v) => v && v !== "0px").length;
|
|
91
|
+
if (repeats.count > 0) {
|
|
92
|
+
return `${colCount}-column grid of ${repeats.count} items`;
|
|
93
|
+
}
|
|
94
|
+
return `${colCount}-column grid`;
|
|
95
|
+
}
|
|
96
|
+
// Flex row
|
|
97
|
+
if (display === "flex" && flexDir === "row") {
|
|
98
|
+
if (repeats.count > 0)
|
|
99
|
+
return `horizontal row of ${repeats.count} items`;
|
|
100
|
+
return "side-by-side layout";
|
|
101
|
+
}
|
|
102
|
+
// Flex column (stacked)
|
|
103
|
+
if (display === "flex" && (flexDir === "column" || !flexDir)) {
|
|
104
|
+
if (repeats.count > 0)
|
|
105
|
+
return `stacked list of ${repeats.count} items`;
|
|
106
|
+
return "vertically stacked";
|
|
107
|
+
}
|
|
108
|
+
// Fallback
|
|
109
|
+
if (repeats.count > 0)
|
|
110
|
+
return `${repeats.count} repeated items`;
|
|
111
|
+
return "single section";
|
|
112
|
+
}
|
|
113
|
+
/** Infer interaction model from section content and DOM structure */
|
|
114
|
+
function inferInteractionModel(section, root, repeats) {
|
|
115
|
+
const html = section.rawHtml.toLowerCase();
|
|
116
|
+
if (html.includes("<details") || html.includes("accordion"))
|
|
117
|
+
return "accordion";
|
|
118
|
+
if (html.includes("role=\"tablist\"") || html.includes("tab-content") || html.includes("tabpanel"))
|
|
119
|
+
return "tabs";
|
|
120
|
+
if (html.includes("carousel") || html.includes("swiper") || html.includes("slider") || html.includes("slick"))
|
|
121
|
+
return "carousel";
|
|
122
|
+
if (html.includes("scroll-snap") || html.includes("scroll-driven"))
|
|
123
|
+
return "scroll-driven";
|
|
124
|
+
// Horizontal scroll container with repeated items is likely a carousel
|
|
125
|
+
if (root && repeats.count >= 3) {
|
|
126
|
+
const overflow = root.styles.overflow ?? root.styles.overflowX ?? "";
|
|
127
|
+
if ((overflow === "hidden" || overflow === "scroll") && root.styles.display === "flex" && root.styles.flexDirection === "row") {
|
|
128
|
+
return "carousel";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return "static";
|
|
132
|
+
}
|
|
133
|
+
function countElements(node) {
|
|
134
|
+
let count = 1;
|
|
135
|
+
for (const child of node.children) {
|
|
136
|
+
count += countElements(child);
|
|
137
|
+
}
|
|
138
|
+
return count;
|
|
139
|
+
}
|
|
140
|
+
// ── Style extraction helpers ──
|
|
141
|
+
function findFirst(node, predicate) {
|
|
142
|
+
if (predicate(node))
|
|
143
|
+
return node;
|
|
144
|
+
for (const child of node.children) {
|
|
145
|
+
const found = findFirst(child, predicate);
|
|
146
|
+
if (found)
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
152
|
+
/** Extract role-based style summaries from the computed style tree */
|
|
153
|
+
function extractRoleStyles(root, repeats) {
|
|
154
|
+
const styles = {
|
|
155
|
+
container: root.styles,
|
|
156
|
+
};
|
|
157
|
+
// Heading — first h1-h3
|
|
158
|
+
const heading = findFirst(root, (n) => HEADING_TAGS.has(n.tag));
|
|
159
|
+
if (heading)
|
|
160
|
+
styles.heading = heading.styles;
|
|
161
|
+
// Body text — first <p>
|
|
162
|
+
const bodyP = findFirst(root, (n) => n.tag === "p" && !!n.text && n.text.length > 10);
|
|
163
|
+
if (bodyP)
|
|
164
|
+
styles.bodyText = bodyP.styles;
|
|
165
|
+
// Repeated item — styles of first repeated child
|
|
166
|
+
if (repeats.count > 0 && repeats.signature) {
|
|
167
|
+
const sig = repeats.signature;
|
|
168
|
+
// Find first child whose signature matches
|
|
169
|
+
for (const child of root.children) {
|
|
170
|
+
if (childSignature(child) === sig) {
|
|
171
|
+
styles.repeatedItem = child.styles;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Try one level deeper if not found
|
|
176
|
+
if (!styles.repeatedItem) {
|
|
177
|
+
for (const wrapper of root.children) {
|
|
178
|
+
for (const child of wrapper.children) {
|
|
179
|
+
if (childSignature(child) === sig) {
|
|
180
|
+
styles.repeatedItem = child.styles;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (styles.repeatedItem)
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// CTA — first <a> or <button> with a background color
|
|
190
|
+
const cta = findFirst(root, (n) => {
|
|
191
|
+
if (n.tag !== "a" && n.tag !== "button")
|
|
192
|
+
return false;
|
|
193
|
+
const bg = n.styles.backgroundColor ?? "";
|
|
194
|
+
return !!bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent";
|
|
195
|
+
});
|
|
196
|
+
if (cta)
|
|
197
|
+
styles.cta = cta.styles;
|
|
198
|
+
return styles;
|
|
199
|
+
}
|
|
200
|
+
/** Build designNotes from extracted styles */
|
|
201
|
+
function buildDesignNotes(styles) {
|
|
202
|
+
const container = styles.container;
|
|
203
|
+
const heading = styles.heading ?? {};
|
|
204
|
+
return {
|
|
205
|
+
backgroundColor: container.backgroundColor ?? container.background ?? "",
|
|
206
|
+
textColor: heading.color ?? styles.bodyText?.color ?? "",
|
|
207
|
+
headingFont: heading.fontFamily ?? "",
|
|
208
|
+
headingSize: heading.fontSize ?? "",
|
|
209
|
+
layout: container.display ?? "block",
|
|
210
|
+
hasGradient: !!(container.background ?? container.backgroundImage ?? "").includes("gradient"),
|
|
211
|
+
hasShadow: !!(container.boxShadow ?? ""),
|
|
212
|
+
borderRadius: container.borderRadius ?? "",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// ── Public API ──
|
|
216
|
+
/**
|
|
217
|
+
* Build a block-type-agnostic section spec from extracted content, computed styles,
|
|
218
|
+
* and page outline data. The LLM uses this to decide: existing block or custom block.
|
|
219
|
+
*/
|
|
220
|
+
export function buildSectionSpec(section, sectionStyles, outlineSection) {
|
|
221
|
+
const root = sectionStyles?.root;
|
|
222
|
+
// Content — carry forward from section, enriching images with background flag
|
|
223
|
+
const images = section.content.images.map((img) => ({
|
|
224
|
+
src: img.src,
|
|
225
|
+
alt: img.alt,
|
|
226
|
+
isBackground: false,
|
|
227
|
+
}));
|
|
228
|
+
// Inject background images from computed styles — root level and per-item children.
|
|
229
|
+
// Elementor event/service cards use CSS background-image on each card div, not <img> tags.
|
|
230
|
+
if (root) {
|
|
231
|
+
const bgImg = root.styles.backgroundImage ?? root.styles.background ?? "";
|
|
232
|
+
const urlMatch = bgImg.match(/url\(["']?([^"')]+)["']?\)/);
|
|
233
|
+
if (urlMatch?.[1] && !urlMatch[1].startsWith("data:")) {
|
|
234
|
+
images.push({ src: urlMatch[1], alt: "", isBackground: true });
|
|
235
|
+
}
|
|
236
|
+
// Walk direct children (and one level deeper for wrapper divs) to collect
|
|
237
|
+
// per-item background images from card/event grids.
|
|
238
|
+
const existingSrcs = new Set(images.map((i) => i.src));
|
|
239
|
+
const collectChildBgImages = (nodes) => {
|
|
240
|
+
for (const node of nodes) {
|
|
241
|
+
const childBg = node.styles.backgroundImage ?? node.styles.background ?? "";
|
|
242
|
+
const childMatch = childBg.match(/url\(["']?([^"')]+)["']?\)/);
|
|
243
|
+
if (childMatch?.[1] && !childMatch[1].startsWith("data:") && !existingSrcs.has(childMatch[1])) {
|
|
244
|
+
images.push({ src: childMatch[1], alt: "", isBackground: true });
|
|
245
|
+
existingSrcs.add(childMatch[1]);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
collectChildBgImages(root.children);
|
|
250
|
+
for (const child of root.children) {
|
|
251
|
+
collectChildBgImages(child.children);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const content = {
|
|
255
|
+
headings: section.content.headings,
|
|
256
|
+
paragraphs: section.content.paragraphs,
|
|
257
|
+
images,
|
|
258
|
+
links: section.content.links,
|
|
259
|
+
lists: section.content.lists,
|
|
260
|
+
};
|
|
261
|
+
// Structure analysis
|
|
262
|
+
let repeats = root ? detectRepeats(root) : { count: 0 };
|
|
263
|
+
// Content-based repeat detection fallback — when no style tree,
|
|
264
|
+
// detect repeated patterns from headings at the same level
|
|
265
|
+
if (repeats.count < 2 && !root && content.headings.length >= 2) {
|
|
266
|
+
repeats = detectRepeatsFromContent(content);
|
|
267
|
+
}
|
|
268
|
+
const pattern = root ? inferPattern(root, repeats) : (repeats.count >= 2 ? `${repeats.count} repeated items` : "unknown");
|
|
269
|
+
const interactionModel = inferInteractionModel(section, root, repeats);
|
|
270
|
+
const elementCount = root ? countElements(root) : 0;
|
|
271
|
+
const structure = {
|
|
272
|
+
pattern,
|
|
273
|
+
repeatCount: repeats.count,
|
|
274
|
+
repeatSignature: repeats.signature,
|
|
275
|
+
elementCount,
|
|
276
|
+
interactionModel,
|
|
277
|
+
};
|
|
278
|
+
// Styles — role-based summaries from computed style tree
|
|
279
|
+
const roleStyles = root
|
|
280
|
+
? extractRoleStyles(root, repeats)
|
|
281
|
+
: { container: {} };
|
|
282
|
+
// Design notes
|
|
283
|
+
const designNotes = buildDesignNotes(roleStyles);
|
|
284
|
+
// Heuristic suggestion — carried from section extractor, not authoritative
|
|
285
|
+
let suggestedBlockType = section.suggestedBlockType;
|
|
286
|
+
let suggestedConfidence = suggestedBlockType ? 0.5 : 0;
|
|
287
|
+
// Structural override: repeated items with headings should be CardGrid/FeatureGrid, not RichText
|
|
288
|
+
if (repeats.count >= 2 && content.headings.length >= 2) {
|
|
289
|
+
if (!suggestedBlockType || suggestedBlockType === "RichText") {
|
|
290
|
+
const hasImages = content.images.length > 0 || content.links.length > 0;
|
|
291
|
+
suggestedBlockType = hasImages ? "CardGrid" : "FeatureGrid";
|
|
292
|
+
suggestedConfidence = Math.max(suggestedConfidence, 0.6);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Structural override: h1 + image → Hero (overrides RichText since h1+image is hero-like)
|
|
296
|
+
if (content.headings.some(h => h.level === 1) && content.images.length > 0 &&
|
|
297
|
+
(!suggestedBlockType || suggestedBlockType === "RichText")) {
|
|
298
|
+
suggestedBlockType = "Hero";
|
|
299
|
+
suggestedConfidence = Math.max(suggestedConfidence, 0.7);
|
|
300
|
+
}
|
|
301
|
+
// Boost confidence if outline data corroborates
|
|
302
|
+
if (outlineSection && suggestedBlockType) {
|
|
303
|
+
if (outlineSection.repeatGroups && outlineSection.repeatGroups.length > 0) {
|
|
304
|
+
suggestedConfidence = Math.min(suggestedConfidence + 0.15, 1);
|
|
305
|
+
}
|
|
306
|
+
if (outlineSection.type !== "unknown") {
|
|
307
|
+
suggestedConfidence = Math.min(suggestedConfidence + 0.1, 1);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Boost if computed styles confirm the pattern
|
|
311
|
+
if (root && suggestedBlockType) {
|
|
312
|
+
if (repeats.count >= 3)
|
|
313
|
+
suggestedConfidence = Math.min(suggestedConfidence + 0.1, 1);
|
|
314
|
+
if (Object.keys(roleStyles.container).length > 5)
|
|
315
|
+
suggestedConfidence = Math.min(suggestedConfidence + 0.05, 1);
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
sectionIndex: section.index,
|
|
319
|
+
content,
|
|
320
|
+
structure,
|
|
321
|
+
styles: roleStyles,
|
|
322
|
+
designNotes,
|
|
323
|
+
suggestedBlockType,
|
|
324
|
+
suggestedConfidence,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Build section specs for all sections of a scraped page.
|
|
329
|
+
*
|
|
330
|
+
* Uses visual sections (from bounding-box gap analysis) as the primary source
|
|
331
|
+
* when available — this is CMS-agnostic and correctly identifies sections on
|
|
332
|
+
* sites that don't use semantic HTML tags. Falls back to regex-based
|
|
333
|
+
* extractSections() when visual data isn't available.
|
|
334
|
+
*/
|
|
335
|
+
export function buildPageSpecs(scrape) {
|
|
336
|
+
const stylesMap = new Map();
|
|
337
|
+
if (scrape.sectionStyles) {
|
|
338
|
+
for (const ss of scrape.sectionStyles) {
|
|
339
|
+
stylesMap.set(ss.sectionIndex, ss);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Collect ALL images from all regex-extracted sections for redistribution by Y-position
|
|
343
|
+
const allExtractedImages = scrape.sections.flatMap((s) => s.content.images.map((img) => ({ ...img, sectionIndex: s.index })));
|
|
344
|
+
// Build fallback content map for sections where findElementForRange failed
|
|
345
|
+
const fallbackMap = new Map();
|
|
346
|
+
if (scrape.sectionFallbackContent) {
|
|
347
|
+
for (const fb of scrape.sectionFallbackContent) {
|
|
348
|
+
fallbackMap.set(fb.sectionIndex, fb);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// If we have visual sections AND computed styles from them, use those as primary
|
|
352
|
+
if (scrape.visualSections && scrape.visualSections.length > 0 && scrape.sectionStyles && scrape.sectionStyles.length > 0) {
|
|
353
|
+
return scrape.visualSections.map((vs, i) => {
|
|
354
|
+
const styles = stylesMap.get(i);
|
|
355
|
+
const styleHeadings = styles?.root ? findHeadingTexts(styles.root) : [];
|
|
356
|
+
// Match by heading text first, then by content overlap
|
|
357
|
+
const matchedSection = scrape.sections.find((s) => s.content.headings.some((h) => styleHeadings.includes(h.text)));
|
|
358
|
+
// Find matching outline section
|
|
359
|
+
const outlineSection = scrape.outline.sections.find((os) => os.heading && styleHeadings.includes(os.heading)) ?? scrape.outline.sections[i];
|
|
360
|
+
// Build content: start with matched section's content (best images from regex parser),
|
|
361
|
+
// then enrich with style tree data and page-level image data for anything missing
|
|
362
|
+
const styleContent = extractContentFromStyleTree(styles?.root, scrape.embeds, vs, scrape.videos);
|
|
363
|
+
const baseContent = matchedSection?.content ?? { headings: [], paragraphs: [], images: [], links: [], lists: [] };
|
|
364
|
+
// Fallback content from Y-range text scan (for sections where findElementForRange failed)
|
|
365
|
+
const fallback = fallbackMap.get(i);
|
|
366
|
+
// Collect page images that fall within this visual section's Y range
|
|
367
|
+
const sectionPageImages = [];
|
|
368
|
+
if (scrape.pageImages) {
|
|
369
|
+
for (const img of scrape.pageImages) {
|
|
370
|
+
if (img.y >= vs.y && img.y < vs.y + vs.height) {
|
|
371
|
+
sectionPageImages.push({ src: img.src, alt: img.alt, isLazy: false });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Merge content from all sources: regex-extracted > style tree > fallback Y-scan
|
|
376
|
+
const mergedContent = {
|
|
377
|
+
headings: pickFirst(baseContent.headings, styleContent.headings, fallback?.headings ?? []),
|
|
378
|
+
paragraphs: pickFirst(baseContent.paragraphs, styleContent.paragraphs, fallback?.paragraphs ?? []),
|
|
379
|
+
images: mergeImages(mergeImages(baseContent.images, sectionPageImages), styleContent.images),
|
|
380
|
+
links: pickFirst(baseContent.links, styleContent.links, fallback?.links ?? []),
|
|
381
|
+
lists: pickFirst(baseContent.lists, styleContent.lists, fallback?.lists ?? []),
|
|
382
|
+
};
|
|
383
|
+
// Classify if no type inherited from matched regex section
|
|
384
|
+
let suggestedBlockType = matchedSection?.suggestedBlockType;
|
|
385
|
+
let classHints = matchedSection?.classHints ?? [];
|
|
386
|
+
if (!suggestedBlockType && matchedSection?.rawHtml) {
|
|
387
|
+
const classified = classifySection(`<${matchedSection.tag}>`, matchedSection.rawHtml, { headings: mergedContent.headings });
|
|
388
|
+
suggestedBlockType = classified.suggestedBlockType;
|
|
389
|
+
if (classified.classHints.length > 0)
|
|
390
|
+
classHints = classified.classHints;
|
|
391
|
+
}
|
|
392
|
+
// Also classify from style tree content if still unclassified
|
|
393
|
+
if (!suggestedBlockType && styles?.root) {
|
|
394
|
+
const treeHtml = reconstructHtmlFromStyleTree(styles.root);
|
|
395
|
+
if (treeHtml.length > 20) {
|
|
396
|
+
const classified = classifySection("<div>", treeHtml, { headings: mergedContent.headings });
|
|
397
|
+
suggestedBlockType = classified.suggestedBlockType;
|
|
398
|
+
if (classified.classHints.length > 0)
|
|
399
|
+
classHints = classified.classHints;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const section = {
|
|
403
|
+
index: i,
|
|
404
|
+
tag: matchedSection?.tag ?? "div",
|
|
405
|
+
classHints,
|
|
406
|
+
suggestedBlockType,
|
|
407
|
+
content: mergedContent,
|
|
408
|
+
rawHtml: matchedSection?.rawHtml ?? "",
|
|
409
|
+
};
|
|
410
|
+
const spec = buildSectionSpec(section, styles, outlineSection);
|
|
411
|
+
// Attach interaction states captured during interaction sweep
|
|
412
|
+
if (scrape.interactionStates) {
|
|
413
|
+
const sectionInteractions = scrape.interactionStates
|
|
414
|
+
.filter(is => is.sectionY >= vs.y && is.sectionY < vs.y + vs.height)
|
|
415
|
+
.flatMap(is => is.states);
|
|
416
|
+
if (sectionInteractions.length > 0) {
|
|
417
|
+
spec.interactionStates = sectionInteractions;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return spec;
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// Fallback: use regex-based sections
|
|
424
|
+
return scrape.sections.map((section, i) => {
|
|
425
|
+
const styles = stylesMap.get(i);
|
|
426
|
+
const outlineSection = scrape.outline.sections[i];
|
|
427
|
+
return buildSectionSpec(section, styles, outlineSection);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/** Reconstruct approximate HTML from a computed style tree for classification heuristics. */
|
|
431
|
+
function reconstructHtmlFromStyleTree(node) {
|
|
432
|
+
const parts = [];
|
|
433
|
+
if (node.text) {
|
|
434
|
+
const tag = HEADING_TAGS.has(node.tag) ? node.tag : "p";
|
|
435
|
+
parts.push(`<${tag}>${node.text}</${tag}>`);
|
|
436
|
+
}
|
|
437
|
+
if (node.image) {
|
|
438
|
+
parts.push(`<img src="${node.image}" alt="">`);
|
|
439
|
+
}
|
|
440
|
+
for (const child of node.children) {
|
|
441
|
+
parts.push(reconstructHtmlFromStyleTree(child));
|
|
442
|
+
}
|
|
443
|
+
return parts.join("");
|
|
444
|
+
}
|
|
445
|
+
/** Return the first non-empty array from the candidates. */
|
|
446
|
+
function pickFirst(...candidates) {
|
|
447
|
+
for (const c of candidates) {
|
|
448
|
+
if (c.length > 0)
|
|
449
|
+
return c;
|
|
450
|
+
}
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
/** Merge images from two sources, deduplicating by src. */
|
|
454
|
+
function mergeImages(a, b) {
|
|
455
|
+
const seen = new Set();
|
|
456
|
+
const result = [];
|
|
457
|
+
for (const img of [...a, ...b]) {
|
|
458
|
+
if (!img.src || seen.has(img.src))
|
|
459
|
+
continue;
|
|
460
|
+
seen.add(img.src);
|
|
461
|
+
result.push({ src: img.src, alt: img.alt, isLazy: img.isLazy ?? false });
|
|
462
|
+
}
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
/** Extract heading texts from a computed style tree for section matching. */
|
|
466
|
+
function findHeadingTexts(node) {
|
|
467
|
+
const texts = [];
|
|
468
|
+
if (HEADING_TAGS.has(node.tag) && node.text)
|
|
469
|
+
texts.push(node.text);
|
|
470
|
+
for (const child of node.children) {
|
|
471
|
+
texts.push(...findHeadingTexts(child));
|
|
472
|
+
}
|
|
473
|
+
return texts;
|
|
474
|
+
}
|
|
475
|
+
/** Build content from a computed style tree when no regex-extracted section matches. */
|
|
476
|
+
function extractContentFromStyleTree(root, embeds, vs, videos) {
|
|
477
|
+
const headings = [];
|
|
478
|
+
const paragraphs = [];
|
|
479
|
+
const images = [];
|
|
480
|
+
const links = [];
|
|
481
|
+
if (root) {
|
|
482
|
+
walkForContent(root, headings, paragraphs, images);
|
|
483
|
+
}
|
|
484
|
+
// Add embeds that fall within this visual section's Y range
|
|
485
|
+
if (embeds) {
|
|
486
|
+
for (const embed of embeds) {
|
|
487
|
+
if (embed.y >= vs.y && embed.y < vs.y + vs.height) {
|
|
488
|
+
links.push({ href: embed.src, text: `[${embed.type} embed]` });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Add <video> elements that fall within this visual section's Y range
|
|
493
|
+
if (videos) {
|
|
494
|
+
for (const video of videos) {
|
|
495
|
+
if (video.y >= vs.y && video.y < vs.y + vs.height) {
|
|
496
|
+
links.push({ href: video.src, text: `[video${video.autoplay ? " autoplay" : ""}${video.loop ? " loop" : ""}]` });
|
|
497
|
+
if (video.poster) {
|
|
498
|
+
images.push({ src: video.poster, alt: "Video poster", isLazy: false });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return { headings, paragraphs, images, links, lists: [] };
|
|
504
|
+
}
|
|
505
|
+
/** Recursively extract content from a computed style tree. */
|
|
506
|
+
function walkForContent(node, headings, paragraphs, images) {
|
|
507
|
+
if (HEADING_TAGS.has(node.tag) && node.text) {
|
|
508
|
+
const level = parseInt(node.tag[1]);
|
|
509
|
+
headings.push({ level, text: node.text });
|
|
510
|
+
}
|
|
511
|
+
else if (node.tag === "p" && node.text && node.text.length > 10) {
|
|
512
|
+
paragraphs.push(node.text);
|
|
513
|
+
}
|
|
514
|
+
if (node.image) {
|
|
515
|
+
images.push({ src: node.image.src, alt: node.image.alt, isLazy: false });
|
|
516
|
+
}
|
|
517
|
+
// Check for background images
|
|
518
|
+
const bgImg = node.styles.backgroundImage ?? "";
|
|
519
|
+
const bgMatch = bgImg.match(/url\(["']?([^"')]+)["']?\)/);
|
|
520
|
+
if (bgMatch?.[1] && !bgMatch[1].startsWith("data:")) {
|
|
521
|
+
images.push({ src: bgMatch[1], alt: "", isLazy: false });
|
|
522
|
+
}
|
|
523
|
+
for (const child of node.children) {
|
|
524
|
+
walkForContent(child, headings, paragraphs, images);
|
|
525
|
+
}
|
|
526
|
+
}
|