similarbuild 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/CHANGELOG.md +110 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/bin/install.js +256 -0
- package/lib/copy-templates.mjs +52 -0
- package/lib/install-deps.mjs +62 -0
- package/lib/prompt-config.mjs +83 -0
- package/lib/verify-env.mjs +19 -0
- package/package.json +63 -0
- package/scripts/sync-templates.mjs +71 -0
- package/templates/commands/build-page.md +490 -0
- package/templates/commands/build-site.md +548 -0
- package/templates/commands/clip-section.md +519 -0
- package/templates/memory/anti-patterns.md +212 -0
- package/templates/memory/design-knowledge.md +225 -0
- package/templates/memory/fixes.md +163 -0
- package/templates/memory/patterns.md +681 -0
- package/templates/presets/shopify-section.yaml +51 -0
- package/templates/presets/wp-elementor.yaml +49 -0
- package/templates/reports/fixtures/mock-run-1.json +115 -0
- package/templates/reports/fixtures/mock-run-2.json +72 -0
- package/templates/reports/report-renderer.mjs +218 -0
- package/templates/reports/report-template.html +571 -0
- package/templates/skills/sb-build-shopify/SKILL.md +104 -0
- package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
- package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
- package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
- package/templates/skills/sb-build-wp/SKILL.md +83 -0
- package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
- package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
- package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
- package/templates/skills/sb-compare-visual/SKILL.md +121 -0
- package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
- package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
- package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
- package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
- package/templates/skills/sb-extract-assets/SKILL.md +112 -0
- package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
- package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
- package/templates/skills/sb-inspect-live/SKILL.md +105 -0
- package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
- package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
- package/templates/skills/sb-review-checks/SKILL.md +113 -0
- package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
- package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
- package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
- package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
- package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
- package/templates/skills/sb-tweak/SKILL.md +130 -0
- package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
- package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
- package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
- package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
- package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
- package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
- package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
- package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
- package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
- package/templates/skills/sb-validate-render/SKILL.md +120 -0
- package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
- package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// inspect-live.mjs — Capture a live page's DOM, computed tokens, screenshot,
|
|
3
|
+
// pseudo-elements, and image URLs for SimilarBuild visual cloning.
|
|
4
|
+
//
|
|
5
|
+
// Mobile-first by default (390x844). Stealth chromium with realistic iPhone UA.
|
|
6
|
+
// Triggers lazy-load via step-scroll + force-eager rewrite of <img loading="lazy">.
|
|
7
|
+
// Outputs a single JSON object to stdout AND writes inspection.json + screenshot.png
|
|
8
|
+
// into --output-dir. Logs progress to stderr.
|
|
9
|
+
|
|
10
|
+
import { parseArgs } from 'node:util'
|
|
11
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { join, resolve } from 'node:path'
|
|
13
|
+
import { statSync } from 'node:fs'
|
|
14
|
+
|
|
15
|
+
// playwright-extra + puppeteer-extra-plugin-stealth are imported lazily inside
|
|
16
|
+
// main() so --help and arg validation work without the deps installed.
|
|
17
|
+
|
|
18
|
+
const HELP = `
|
|
19
|
+
inspect-live.mjs — Inspect a live URL for SimilarBuild visual cloning.
|
|
20
|
+
|
|
21
|
+
Required:
|
|
22
|
+
--url <url> Absolute URL to inspect.
|
|
23
|
+
--output-dir <dir> Directory for screenshot.png + inspection.json.
|
|
24
|
+
|
|
25
|
+
Optional:
|
|
26
|
+
--viewport-width <px> Default 390 (mobile-first).
|
|
27
|
+
--viewport-height <px> Default 844.
|
|
28
|
+
--selector <css> Scope inspection to one element + descendants.
|
|
29
|
+
--wait-strategy <name> lazy-load (default) | auto | kaching-bundles | judge-me
|
|
30
|
+
--max-depth <n> DOM walk max depth (default 8).
|
|
31
|
+
--max-children <n> Max children kept per node (default 60).
|
|
32
|
+
--max-text <n> Max chars of direct text per node (default 240).
|
|
33
|
+
--timeout <ms> Per-step timeout (default 30000).
|
|
34
|
+
--help Show this message.
|
|
35
|
+
|
|
36
|
+
Exit codes: 0=ok, 1=script error, 2=invalid args.
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
function fail(msg, code = 2) {
|
|
40
|
+
process.stderr.write(`[sb-inspect-live] ${msg}\n`)
|
|
41
|
+
process.exit(code)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function log(msg) {
|
|
45
|
+
process.stderr.write(`[sb-inspect-live] ${msg}\n`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { values } = parseArgs({
|
|
49
|
+
options: {
|
|
50
|
+
url: { type: 'string' },
|
|
51
|
+
'viewport-width': { type: 'string', default: '390' },
|
|
52
|
+
'viewport-height': { type: 'string', default: '844' },
|
|
53
|
+
selector: { type: 'string' },
|
|
54
|
+
'wait-strategy': { type: 'string', default: 'lazy-load' },
|
|
55
|
+
'output-dir': { type: 'string' },
|
|
56
|
+
'max-depth': { type: 'string', default: '8' },
|
|
57
|
+
'max-children': { type: 'string', default: '60' },
|
|
58
|
+
'max-text': { type: 'string', default: '240' },
|
|
59
|
+
timeout: { type: 'string', default: '30000' },
|
|
60
|
+
help: { type: 'boolean', default: false },
|
|
61
|
+
},
|
|
62
|
+
strict: false,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (values.help) {
|
|
66
|
+
process.stdout.write(HELP)
|
|
67
|
+
process.exit(0)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!values.url) fail('missing --url')
|
|
71
|
+
if (!values['output-dir']) fail('missing --output-dir')
|
|
72
|
+
|
|
73
|
+
const URL = values.url
|
|
74
|
+
const VIEWPORT_W = parseInt(values['viewport-width'], 10)
|
|
75
|
+
const VIEWPORT_H = parseInt(values['viewport-height'], 10)
|
|
76
|
+
const SELECTOR = values.selector || null
|
|
77
|
+
const WAIT_STRATEGY = values['wait-strategy']
|
|
78
|
+
const OUTPUT_DIR = resolve(values['output-dir'])
|
|
79
|
+
const MAX_DEPTH = parseInt(values['max-depth'], 10)
|
|
80
|
+
const MAX_CHILDREN = parseInt(values['max-children'], 10)
|
|
81
|
+
const MAX_TEXT = parseInt(values['max-text'], 10)
|
|
82
|
+
const TIMEOUT = parseInt(values.timeout, 10)
|
|
83
|
+
|
|
84
|
+
if (!Number.isFinite(VIEWPORT_W) || !Number.isFinite(VIEWPORT_H)) fail('viewport must be numeric')
|
|
85
|
+
|
|
86
|
+
// Known widget waits — selectors for third-party scripts whose content arrives
|
|
87
|
+
// after the main page load. Hardcoded list, evolved as new widgets are encountered.
|
|
88
|
+
const WIDGET_WAITS = {
|
|
89
|
+
'kaching-bundles': ['.kaching-bundles', '[class*="kaching"]', '[id*="kaching"]'],
|
|
90
|
+
'judge-me': ['.jdgm-rev-widg', '.jdgm-widget', '.jdgm-prev-badge', '[id*="jdgm"]'],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Bot-challenge / blocked-page heuristics. If body text matches any of these
|
|
94
|
+
// after settle, the script flags widgetBlocked: true so the orchestrator can
|
|
95
|
+
// invoke Plan B instead of building from a placeholder.
|
|
96
|
+
const BLOCK_PATTERNS = [
|
|
97
|
+
/just a moment/i,
|
|
98
|
+
/checking your browser/i,
|
|
99
|
+
/enable javascript( and cookies)? to continue/i,
|
|
100
|
+
/cloudflare/i,
|
|
101
|
+
/access denied/i,
|
|
102
|
+
/verifying you are human/i,
|
|
103
|
+
/please complete the security check/i,
|
|
104
|
+
/captcha/i,
|
|
105
|
+
/forbidden/i,
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
// Title-only block patterns (Cloudflare etc. often put the challenge in
|
|
109
|
+
// <title> with a near-empty body — pattern #23 of the plan covers this).
|
|
110
|
+
const BLOCK_TITLE_PATTERNS = [
|
|
111
|
+
/just a moment/i,
|
|
112
|
+
/attention required/i,
|
|
113
|
+
/access denied/i,
|
|
114
|
+
/forbidden/i,
|
|
115
|
+
/captcha/i,
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
async function main() {
|
|
119
|
+
await mkdir(OUTPUT_DIR, { recursive: true })
|
|
120
|
+
|
|
121
|
+
const screenshotPath = join(OUTPUT_DIR, 'screenshot.png')
|
|
122
|
+
const jsonPath = join(OUTPUT_DIR, 'inspection.json')
|
|
123
|
+
|
|
124
|
+
let chromium, devices, StealthPlugin
|
|
125
|
+
try {
|
|
126
|
+
;({ chromium, devices } = await import('playwright-extra'))
|
|
127
|
+
StealthPlugin = (await import('puppeteer-extra-plugin-stealth')).default
|
|
128
|
+
} catch (err) {
|
|
129
|
+
process.stderr.write(
|
|
130
|
+
`[sb-inspect-live] missing dependency: ${err?.message || err}\n` +
|
|
131
|
+
`Install with: npm i playwright-extra puppeteer-extra-plugin-stealth playwright && npx playwright install chromium\n`,
|
|
132
|
+
)
|
|
133
|
+
process.exit(1)
|
|
134
|
+
}
|
|
135
|
+
chromium.use(StealthPlugin())
|
|
136
|
+
|
|
137
|
+
log(`launching chromium (stealth) for ${URL} @ ${VIEWPORT_W}x${VIEWPORT_H}`)
|
|
138
|
+
|
|
139
|
+
const browser = await chromium.launch({ headless: true })
|
|
140
|
+
|
|
141
|
+
// Use Playwright's iPhone 14 device profile as the base, but force the requested
|
|
142
|
+
// viewport so mobile-first 390 default is honored even if the device profile differs.
|
|
143
|
+
const iphone = devices['iPhone 14'] || {}
|
|
144
|
+
const context = await browser.newContext({
|
|
145
|
+
...iphone,
|
|
146
|
+
viewport: { width: VIEWPORT_W, height: VIEWPORT_H },
|
|
147
|
+
userAgent:
|
|
148
|
+
iphone.userAgent ||
|
|
149
|
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
|
|
150
|
+
deviceScaleFactor: iphone.deviceScaleFactor || 3,
|
|
151
|
+
isMobile: true,
|
|
152
|
+
hasTouch: true,
|
|
153
|
+
locale: 'en-US',
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const page = await context.newPage()
|
|
157
|
+
page.setDefaultTimeout(TIMEOUT)
|
|
158
|
+
|
|
159
|
+
const result = {
|
|
160
|
+
url: URL,
|
|
161
|
+
viewport: { width: VIEWPORT_W, height: VIEWPORT_H },
|
|
162
|
+
sectionType: 'unknown',
|
|
163
|
+
sectionBoundingBox: null,
|
|
164
|
+
tokens: {},
|
|
165
|
+
dom: [],
|
|
166
|
+
pseudoElements: [],
|
|
167
|
+
imgUrls: [],
|
|
168
|
+
screenshot: screenshotPath,
|
|
169
|
+
widgetBlocked: false,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
log('navigating (waitUntil: domcontentloaded)')
|
|
174
|
+
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT })
|
|
175
|
+
|
|
176
|
+
// Initial settle. Many themes hydrate ~2-3s after DCL.
|
|
177
|
+
await page.waitForTimeout(3500)
|
|
178
|
+
|
|
179
|
+
// Step-scroll to trigger lazy-load observers. Range 0→12000 covers
|
|
180
|
+
// most long mobile pages; pages shorter than that scroll past the bottom
|
|
181
|
+
// and the browser clamps — harmless.
|
|
182
|
+
log('step-scrolling to trigger lazy-load')
|
|
183
|
+
await page.evaluate(async () => {
|
|
184
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
185
|
+
for (let y = 0; y <= 12000; y += 400) {
|
|
186
|
+
window.scrollTo(0, y)
|
|
187
|
+
await sleep(150)
|
|
188
|
+
}
|
|
189
|
+
window.scrollTo(0, 0)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Force-eager: any <img loading="lazy"> that didn't intersect during scroll
|
|
193
|
+
// gets rewritten to eager and re-fetched. <picture> sources too.
|
|
194
|
+
log('forcing eager image fetch')
|
|
195
|
+
await page.evaluate(async () => {
|
|
196
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
197
|
+
const imgs = Array.from(document.querySelectorAll('img'))
|
|
198
|
+
for (const img of imgs) {
|
|
199
|
+
try {
|
|
200
|
+
img.loading = 'eager'
|
|
201
|
+
if (!img.complete || img.naturalWidth === 0) {
|
|
202
|
+
const src = img.currentSrc || img.src
|
|
203
|
+
if (src) {
|
|
204
|
+
img.src = ''
|
|
205
|
+
img.src = src
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch (_) {}
|
|
209
|
+
}
|
|
210
|
+
await sleep(1200)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Apply additional widget waits if requested.
|
|
214
|
+
const widgetsToWait =
|
|
215
|
+
WAIT_STRATEGY === 'auto'
|
|
216
|
+
? Object.values(WIDGET_WAITS).flat()
|
|
217
|
+
: WIDGET_WAITS[WAIT_STRATEGY] || []
|
|
218
|
+
|
|
219
|
+
for (const sel of widgetsToWait) {
|
|
220
|
+
try {
|
|
221
|
+
await page.waitForSelector(sel, { timeout: 4000, state: 'attached' })
|
|
222
|
+
log(`widget appeared: ${sel}`)
|
|
223
|
+
} catch (_) {
|
|
224
|
+
// widget not present on this page — fine
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Final settle to let any post-widget reflow finish.
|
|
229
|
+
await page.waitForTimeout(800)
|
|
230
|
+
|
|
231
|
+
// Detect block / challenge pages with a composite signal — Pattern #23 of
|
|
232
|
+
// the plan. The old `bodyHtmlLen < 1024` heuristic alone caused false
|
|
233
|
+
// positives on legitimately minimalist pages (example.com 528B, info.cern.ch
|
|
234
|
+
// 646B). New rule:
|
|
235
|
+
// 1. extreme-tiny body (< 256B) → blocked (real pages always have at
|
|
236
|
+
// least a meta/h1/p stack that exceeds this)
|
|
237
|
+
// 2. pattern in body text → blocked (covers Cloudflare/CAPTCHA prose)
|
|
238
|
+
// 3. pattern in <title> → blocked (Cloudflare often hides challenge in
|
|
239
|
+
// the title alone with near-empty body)
|
|
240
|
+
// 4. body 256-1024B AND meaningful-children count < 2 → blocked
|
|
241
|
+
// (small AND structurally bare = challenge page; small alone = OK)
|
|
242
|
+
const docState = await page.evaluate(() => ({
|
|
243
|
+
title: (document.title || '').slice(0, 200),
|
|
244
|
+
bodyText: (document.body?.innerText || '').slice(0, 4000),
|
|
245
|
+
bodyHtmlLen: (document.body?.innerHTML || '').length,
|
|
246
|
+
meaningfulChildren: (() => {
|
|
247
|
+
if (!document.body) return 0
|
|
248
|
+
let n = 0
|
|
249
|
+
for (const el of document.body.children) {
|
|
250
|
+
const tag = (el.tagName || '').toLowerCase()
|
|
251
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript') continue
|
|
252
|
+
n++
|
|
253
|
+
}
|
|
254
|
+
return n
|
|
255
|
+
})(),
|
|
256
|
+
}))
|
|
257
|
+
const blockedByBodyPattern = BLOCK_PATTERNS.some((re) => re.test(docState.bodyText))
|
|
258
|
+
const blockedByTitlePattern = BLOCK_TITLE_PATTERNS.some((re) => re.test(docState.title))
|
|
259
|
+
const extremeTiny = docState.bodyHtmlLen < 256
|
|
260
|
+
const smallAndBare =
|
|
261
|
+
docState.bodyHtmlLen >= 256 &&
|
|
262
|
+
docState.bodyHtmlLen < 1024 &&
|
|
263
|
+
docState.meaningfulChildren < 2
|
|
264
|
+
if (blockedByBodyPattern || blockedByTitlePattern || extremeTiny || smallAndBare) {
|
|
265
|
+
result.widgetBlocked = true
|
|
266
|
+
log(
|
|
267
|
+
`widgetBlocked=true (bodyPattern=${blockedByBodyPattern}, titlePattern=${blockedByTitlePattern}, extremeTiny=${extremeTiny}, smallAndBare=${smallAndBare}, bodyLen=${docState.bodyHtmlLen}, children=${docState.meaningfulChildren}, title=${JSON.stringify(docState.title.slice(0, 60))})`,
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Screenshot — full page if no selector, element-only otherwise.
|
|
272
|
+
log(`capturing screenshot → ${screenshotPath}`)
|
|
273
|
+
if (SELECTOR) {
|
|
274
|
+
const el = await page.$(SELECTOR)
|
|
275
|
+
if (!el) {
|
|
276
|
+
log(`selector ${SELECTOR} matched no elements; falling back to full-page screenshot`)
|
|
277
|
+
await page.screenshot({ path: screenshotPath, fullPage: true })
|
|
278
|
+
} else {
|
|
279
|
+
await el.screenshot({ path: screenshotPath })
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
await page.screenshot({ path: screenshotPath, fullPage: true })
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The big in-page extraction. Everything below runs in the browser context.
|
|
286
|
+
log('extracting DOM, tokens, pseudo-elements, image URLs')
|
|
287
|
+
const extracted = await page.evaluate(extractInPage, {
|
|
288
|
+
selector: SELECTOR,
|
|
289
|
+
maxDepth: MAX_DEPTH,
|
|
290
|
+
maxChildren: MAX_CHILDREN,
|
|
291
|
+
maxText: MAX_TEXT,
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
Object.assign(result, extracted)
|
|
295
|
+
|
|
296
|
+
// Sanity check — DOM came back empty even though body had content.
|
|
297
|
+
if (!result.widgetBlocked && Array.isArray(result.dom) && result.dom.length === 0 && docState.bodyHtmlLen > 1024) {
|
|
298
|
+
log('warning: extracted DOM is empty but body has content — selector may be wrong')
|
|
299
|
+
}
|
|
300
|
+
} catch (err) {
|
|
301
|
+
process.stderr.write(`[sb-inspect-live] error: ${err?.stack ? err.stack : err}\n`)
|
|
302
|
+
await browser.close().catch(() => {})
|
|
303
|
+
process.exit(1)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
await browser.close()
|
|
307
|
+
|
|
308
|
+
// Persist + emit.
|
|
309
|
+
await writeFile(jsonPath, JSON.stringify(result, null, 2), 'utf8')
|
|
310
|
+
|
|
311
|
+
// Tiny screenshots are a soft signal of failed render even when not flagged.
|
|
312
|
+
try {
|
|
313
|
+
const sz = statSync(screenshotPath).size
|
|
314
|
+
if (sz < 4096 && !result.widgetBlocked) {
|
|
315
|
+
log(`warning: screenshot is only ${sz} bytes — page may not have rendered`)
|
|
316
|
+
result.widgetBlocked = true
|
|
317
|
+
result._reason = 'tiny_screenshot'
|
|
318
|
+
await writeFile(jsonPath, JSON.stringify(result, null, 2), 'utf8')
|
|
319
|
+
}
|
|
320
|
+
} catch (_) {}
|
|
321
|
+
|
|
322
|
+
process.stdout.write(JSON.stringify(result))
|
|
323
|
+
process.stdout.write('\n')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// =====================================================================
|
|
327
|
+
// In-page extraction. This function is serialized to a string and runs
|
|
328
|
+
// inside the browser via page.evaluate, so it must be self-contained.
|
|
329
|
+
// =====================================================================
|
|
330
|
+
function extractInPage({ selector, maxDepth, maxChildren, maxText }) {
|
|
331
|
+
const root = selector ? document.querySelector(selector) : document.body
|
|
332
|
+
if (!root) {
|
|
333
|
+
return {
|
|
334
|
+
sectionType: 'unknown',
|
|
335
|
+
sectionBoundingBox: null,
|
|
336
|
+
tokens: {},
|
|
337
|
+
dom: [],
|
|
338
|
+
pseudoElements: [],
|
|
339
|
+
imgUrls: [],
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---- Section-type heuristic ----
|
|
344
|
+
// Class/role-based first; falls back to heading presence. Lives here for
|
|
345
|
+
// the standalone build; production framework will source from
|
|
346
|
+
// <plugin>/memory/patterns.md and pass via flags.
|
|
347
|
+
function classifySection(el) {
|
|
348
|
+
const cls = (typeof el.className === 'string' ? el.className : '').toLowerCase()
|
|
349
|
+
const tag = el.tagName.toLowerCase()
|
|
350
|
+
const id = (el.id || '').toLowerCase()
|
|
351
|
+
const blob = `${tag} ${cls} ${id}`
|
|
352
|
+
if (/\bhero\b/.test(blob)) return 'hero'
|
|
353
|
+
if (/\bbanner\b/.test(blob)) return 'banner'
|
|
354
|
+
if (/\bproduct(s|-grid|-list|-card)?\b/.test(blob)) return 'product-grid'
|
|
355
|
+
if (/\b(testimonial|review|jdgm)/.test(blob)) return 'testimonials'
|
|
356
|
+
if (/\b(cta|call.?to.?action)\b/.test(blob)) return 'cta'
|
|
357
|
+
if (/\bfooter\b/.test(blob) || tag === 'footer') return 'footer'
|
|
358
|
+
if (/\b(header|nav|navbar)\b/.test(blob) || tag === 'header' || tag === 'nav') return 'header'
|
|
359
|
+
if (/\bfeature/.test(blob)) return 'features'
|
|
360
|
+
if (/\b(faq|accordion)\b/.test(blob)) return 'faq'
|
|
361
|
+
if (/\b(bundle|kaching)\b/.test(blob)) return 'bundle'
|
|
362
|
+
if (/\bgallery\b/.test(blob)) return 'gallery'
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- Computed-style picker ----
|
|
367
|
+
// Whitelist of properties we care about. Reading getComputedStyle is cheap;
|
|
368
|
+
// building giant objects is not — so we stick to tokens that drive layout
|
|
369
|
+
// and visual fidelity for downstream reconstruction.
|
|
370
|
+
const TYPO_PROPS = [
|
|
371
|
+
'font-family',
|
|
372
|
+
'font-size',
|
|
373
|
+
'font-weight',
|
|
374
|
+
'font-style',
|
|
375
|
+
'line-height',
|
|
376
|
+
'letter-spacing',
|
|
377
|
+
'text-transform',
|
|
378
|
+
'text-align',
|
|
379
|
+
'color',
|
|
380
|
+
]
|
|
381
|
+
const BOX_PROPS = [
|
|
382
|
+
'display',
|
|
383
|
+
'position',
|
|
384
|
+
'top',
|
|
385
|
+
'right',
|
|
386
|
+
'bottom',
|
|
387
|
+
'left',
|
|
388
|
+
'width',
|
|
389
|
+
'height',
|
|
390
|
+
'max-width',
|
|
391
|
+
'min-height',
|
|
392
|
+
'margin',
|
|
393
|
+
'padding',
|
|
394
|
+
'gap',
|
|
395
|
+
'flex-direction',
|
|
396
|
+
'justify-content',
|
|
397
|
+
'align-items',
|
|
398
|
+
'flex-wrap',
|
|
399
|
+
'grid-template-columns',
|
|
400
|
+
'grid-template-rows',
|
|
401
|
+
'overflow',
|
|
402
|
+
'z-index',
|
|
403
|
+
]
|
|
404
|
+
const VISUAL_PROPS = [
|
|
405
|
+
'background-color',
|
|
406
|
+
'background-image',
|
|
407
|
+
'background-size',
|
|
408
|
+
'background-position',
|
|
409
|
+
'background-repeat',
|
|
410
|
+
'border',
|
|
411
|
+
'border-radius',
|
|
412
|
+
'box-shadow',
|
|
413
|
+
'opacity',
|
|
414
|
+
'transform',
|
|
415
|
+
'filter',
|
|
416
|
+
]
|
|
417
|
+
const PSEUDO_PROPS = [
|
|
418
|
+
'content',
|
|
419
|
+
'color',
|
|
420
|
+
'background-color',
|
|
421
|
+
'background-image',
|
|
422
|
+
'font-size',
|
|
423
|
+
'font-weight',
|
|
424
|
+
'position',
|
|
425
|
+
'top',
|
|
426
|
+
'right',
|
|
427
|
+
'bottom',
|
|
428
|
+
'left',
|
|
429
|
+
'width',
|
|
430
|
+
'height',
|
|
431
|
+
'transform',
|
|
432
|
+
'opacity',
|
|
433
|
+
'border-radius',
|
|
434
|
+
'box-shadow',
|
|
435
|
+
'display',
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
function pick(style, props) {
|
|
439
|
+
const out = {}
|
|
440
|
+
for (const p of props) {
|
|
441
|
+
const v = style.getPropertyValue(p)
|
|
442
|
+
if (v && v !== 'normal' && v !== 'auto') out[p] = v.trim()
|
|
443
|
+
}
|
|
444
|
+
return out
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function bbox(el) {
|
|
448
|
+
const r = el.getBoundingClientRect()
|
|
449
|
+
return {
|
|
450
|
+
x: Math.round(r.x + window.scrollX),
|
|
451
|
+
y: Math.round(r.y + window.scrollY),
|
|
452
|
+
w: Math.round(r.width),
|
|
453
|
+
h: Math.round(r.height),
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function directText(el) {
|
|
458
|
+
let t = ''
|
|
459
|
+
for (const n of el.childNodes) {
|
|
460
|
+
if (n.nodeType === 3) t += n.nodeValue
|
|
461
|
+
}
|
|
462
|
+
t = t.replace(/\s+/g, ' ').trim()
|
|
463
|
+
if (t.length > maxText) t = `${t.slice(0, maxText)}…`
|
|
464
|
+
return t
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Skip script/style/noscript/template — they don't contribute to visual output.
|
|
468
|
+
const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'meta', 'link'])
|
|
469
|
+
|
|
470
|
+
function walk(el, depth) {
|
|
471
|
+
if (depth > maxDepth) return null
|
|
472
|
+
if (SKIP_TAGS.has(el.tagName.toLowerCase())) return null
|
|
473
|
+
const style = window.getComputedStyle(el)
|
|
474
|
+
if (style.display === 'none' || style.visibility === 'hidden') return null
|
|
475
|
+
|
|
476
|
+
const node = {
|
|
477
|
+
tag: el.tagName.toLowerCase(),
|
|
478
|
+
classes: typeof el.className === 'string' ? el.className.split(/\s+/).filter(Boolean) : [],
|
|
479
|
+
id: el.id || null,
|
|
480
|
+
attrs: {},
|
|
481
|
+
text: directText(el),
|
|
482
|
+
bbox: bbox(el),
|
|
483
|
+
computed: {
|
|
484
|
+
...pick(style, TYPO_PROPS),
|
|
485
|
+
...pick(style, BOX_PROPS),
|
|
486
|
+
...pick(style, VISUAL_PROPS),
|
|
487
|
+
},
|
|
488
|
+
children: [],
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Capture salient attributes only — keeps payload small while preserving
|
|
492
|
+
// semantic info the builder needs.
|
|
493
|
+
for (const name of ['href', 'src', 'srcset', 'alt', 'role', 'aria-label', 'data-section-id', 'data-section-type', 'data-block-id']) {
|
|
494
|
+
const v = el.getAttribute?.(name)
|
|
495
|
+
if (v) node.attrs[name] = v
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
let count = 0
|
|
499
|
+
for (const child of el.children) {
|
|
500
|
+
if (count >= maxChildren) {
|
|
501
|
+
node.children.push({ tag: '_truncated_', remaining: el.children.length - count })
|
|
502
|
+
break
|
|
503
|
+
}
|
|
504
|
+
const c = walk(child, depth + 1)
|
|
505
|
+
if (c) {
|
|
506
|
+
node.children.push(c)
|
|
507
|
+
count++
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return node
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---- Pseudo-elements walk ----
|
|
515
|
+
// For every visible element, check ::before and ::after. Only keep those
|
|
516
|
+
// with non-empty content — getComputedStyle returns 'none' for absent
|
|
517
|
+
// pseudo-elements, so this filters automatically.
|
|
518
|
+
const pseudoElements = []
|
|
519
|
+
function pseudoSelectorFor(el) {
|
|
520
|
+
if (el.id) return `#${el.id}`
|
|
521
|
+
const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/).filter(Boolean) : []
|
|
522
|
+
if (cls.length) return `${el.tagName.toLowerCase()}.${cls.slice(0, 3).join('.')}`
|
|
523
|
+
return el.tagName.toLowerCase()
|
|
524
|
+
}
|
|
525
|
+
function walkPseudo(el) {
|
|
526
|
+
if (SKIP_TAGS.has(el.tagName.toLowerCase())) return
|
|
527
|
+
for (const which of ['::before', '::after']) {
|
|
528
|
+
try {
|
|
529
|
+
const ps = window.getComputedStyle(el, which)
|
|
530
|
+
const content = ps.getPropertyValue('content')
|
|
531
|
+
if (content && content !== 'none' && content !== 'normal') {
|
|
532
|
+
pseudoElements.push({
|
|
533
|
+
selector: pseudoSelectorFor(el),
|
|
534
|
+
pseudo: which,
|
|
535
|
+
content,
|
|
536
|
+
computed: pick(ps, PSEUDO_PROPS),
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
} catch (_) {}
|
|
540
|
+
}
|
|
541
|
+
for (const child of el.children) walkPseudo(child)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---- Image URL collection ----
|
|
545
|
+
const seen = new Set()
|
|
546
|
+
const imgUrls = []
|
|
547
|
+
function nearestSection(el) {
|
|
548
|
+
let cur = el
|
|
549
|
+
while (cur && cur !== document.body) {
|
|
550
|
+
const cls = (typeof cur.className === 'string' ? cur.className : '').toLowerCase()
|
|
551
|
+
if (cur.id || /section|hero|banner|footer|header|product|testimonial|gallery|feature|faq|cta|bundle/.test(cls)) {
|
|
552
|
+
return cur.id || cls.split(/\s+/).filter(Boolean)[0] || cur.tagName.toLowerCase()
|
|
553
|
+
}
|
|
554
|
+
cur = cur.parentElement
|
|
555
|
+
}
|
|
556
|
+
return 'page'
|
|
557
|
+
}
|
|
558
|
+
function pushImg(url, type, contextEl, alt) {
|
|
559
|
+
if (!url) return
|
|
560
|
+
const trimmed = url.trim()
|
|
561
|
+
if (!trimmed || trimmed.startsWith('data:')) return
|
|
562
|
+
let abs
|
|
563
|
+
try {
|
|
564
|
+
abs = new URL(trimmed, location.href).toString()
|
|
565
|
+
} catch (_) {
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
const key = `${type}|${abs}`
|
|
569
|
+
if (seen.has(key)) return
|
|
570
|
+
seen.add(key)
|
|
571
|
+
imgUrls.push({
|
|
572
|
+
url: abs,
|
|
573
|
+
type,
|
|
574
|
+
context: nearestSection(contextEl),
|
|
575
|
+
alt: alt || '',
|
|
576
|
+
bbox: bbox(contextEl),
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
// <img> elements
|
|
580
|
+
for (const img of root.querySelectorAll('img')) {
|
|
581
|
+
pushImg(img.currentSrc || img.src, 'img', img, img.alt)
|
|
582
|
+
if (img.srcset) {
|
|
583
|
+
for (const part of img.srcset.split(',')) {
|
|
584
|
+
const u = part.trim().split(/\s+/)[0]
|
|
585
|
+
pushImg(u, 'img-srcset', img, img.alt)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// <source> inside <picture>
|
|
590
|
+
for (const source of root.querySelectorAll('source')) {
|
|
591
|
+
if (source.srcset) {
|
|
592
|
+
for (const part of source.srcset.split(',')) {
|
|
593
|
+
const u = part.trim().split(/\s+/)[0]
|
|
594
|
+
pushImg(u, 'source-srcset', source.parentElement || source, source.getAttribute('alt') || '')
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (source.src) pushImg(source.src, 'source', source.parentElement || source, '')
|
|
598
|
+
}
|
|
599
|
+
// background-image on every visible element
|
|
600
|
+
const allEls = root === document.body ? document.querySelectorAll('*') : root.querySelectorAll('*')
|
|
601
|
+
const elsToScan = root === document.body ? [...allEls] : [root, ...allEls]
|
|
602
|
+
for (const el of elsToScan) {
|
|
603
|
+
const bg = window.getComputedStyle(el).getPropertyValue('background-image')
|
|
604
|
+
if (!bg || bg === 'none') continue
|
|
605
|
+
const matches = bg.matchAll(/url\((['"]?)([^'")]+)\1\)/g)
|
|
606
|
+
for (const m of matches) {
|
|
607
|
+
pushImg(m[2], 'background', el, el.getAttribute('alt') || '')
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---- Token aggregation ----
|
|
612
|
+
// Pick representative elements and capture the typography/color/layout
|
|
613
|
+
// signature. These feed sb-build-output's preset-resolution pass.
|
|
614
|
+
function token(el, props) {
|
|
615
|
+
if (!el) return null
|
|
616
|
+
return pick(window.getComputedStyle(el), props)
|
|
617
|
+
}
|
|
618
|
+
const firstButton = root.querySelector('button, .btn, [role="button"], a.button, a.btn')
|
|
619
|
+
const firstContainer = root.querySelector('.container, .wrapper, main, section')
|
|
620
|
+
const tokens = {
|
|
621
|
+
typography: {
|
|
622
|
+
h1: token(root.querySelector('h1'), TYPO_PROPS),
|
|
623
|
+
h2: token(root.querySelector('h2'), TYPO_PROPS),
|
|
624
|
+
h3: token(root.querySelector('h3'), TYPO_PROPS),
|
|
625
|
+
body: token(root.querySelector('p') || root, TYPO_PROPS),
|
|
626
|
+
button: token(firstButton, TYPO_PROPS),
|
|
627
|
+
link: token(root.querySelector('a'), TYPO_PROPS),
|
|
628
|
+
},
|
|
629
|
+
colors: {
|
|
630
|
+
background: window.getComputedStyle(document.body).getPropertyValue('background-color').trim(),
|
|
631
|
+
text: window.getComputedStyle(document.body).getPropertyValue('color').trim(),
|
|
632
|
+
primary: firstButton ? window.getComputedStyle(firstButton).getPropertyValue('background-color').trim() : null,
|
|
633
|
+
accent: token(root.querySelector('a'), ['color'])?.color || null,
|
|
634
|
+
},
|
|
635
|
+
layout: {
|
|
636
|
+
container: token(firstContainer, ['max-width', 'padding', 'margin', 'width']),
|
|
637
|
+
section: token(root, ['padding', 'margin', 'gap']),
|
|
638
|
+
},
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ---- Section element + bounding box ----
|
|
642
|
+
// The full-page screenshot covers the whole document, so a sectionType label
|
|
643
|
+
// alone leaves the comparator with no way to crop the live to match a
|
|
644
|
+
// section-only build. Anti-pattern #10. Surface the actual section bbox so
|
|
645
|
+
// the comparator can extract it before pixelmatch.
|
|
646
|
+
//
|
|
647
|
+
// Rules:
|
|
648
|
+
// - With explicit --selector, the live screenshot is element-only already
|
|
649
|
+
// (page.$(selector).screenshot at line ~236), so no crop is needed and
|
|
650
|
+
// sectionBoundingBox is null.
|
|
651
|
+
// - Without --selector, search root → descendants for the first element
|
|
652
|
+
// with a known section class/tag signature; expose its document-relative
|
|
653
|
+
// bbox in CSS pixels.
|
|
654
|
+
// - If only the h1 fallback fires (sectionType=hero by heading presence),
|
|
655
|
+
// bbox stays null — we don't know which subtree is "the hero" with
|
|
656
|
+
// enough confidence to crop on it.
|
|
657
|
+
function findSectionAndBox(rootEl, hasExplicitSelector) {
|
|
658
|
+
if (hasExplicitSelector) {
|
|
659
|
+
const direct = classifySection(rootEl)
|
|
660
|
+
const sectionType = direct || (rootEl.querySelector('h1') ? 'hero' : 'section')
|
|
661
|
+
return { sectionType, sectionBoundingBox: null }
|
|
662
|
+
}
|
|
663
|
+
const direct = classifySection(rootEl)
|
|
664
|
+
if (direct) {
|
|
665
|
+
return { sectionType: direct, sectionBoundingBox: bbox(rootEl) }
|
|
666
|
+
}
|
|
667
|
+
const queue = [...rootEl.children]
|
|
668
|
+
while (queue.length) {
|
|
669
|
+
const el = queue.shift()
|
|
670
|
+
if (SKIP_TAGS.has(el.tagName.toLowerCase())) continue
|
|
671
|
+
const t = classifySection(el)
|
|
672
|
+
if (t) {
|
|
673
|
+
return { sectionType: t, sectionBoundingBox: bbox(el) }
|
|
674
|
+
}
|
|
675
|
+
for (const c of el.children) queue.push(c)
|
|
676
|
+
}
|
|
677
|
+
if (rootEl.querySelector('h1')) {
|
|
678
|
+
return { sectionType: 'hero', sectionBoundingBox: null }
|
|
679
|
+
}
|
|
680
|
+
return { sectionType: 'section', sectionBoundingBox: null }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
walkPseudo(root)
|
|
684
|
+
const dom = [walk(root, 0)].filter(Boolean)
|
|
685
|
+
const { sectionType, sectionBoundingBox } = findSectionAndBox(root, !!selector)
|
|
686
|
+
|
|
687
|
+
return { sectionType, sectionBoundingBox, tokens, dom, pseudoElements, imgUrls }
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
main().catch((err) => {
|
|
691
|
+
process.stderr.write(`[sb-inspect-live] fatal: ${err?.stack ? err.stack : err}\n`)
|
|
692
|
+
process.exit(1)
|
|
693
|
+
})
|