brandpull 0.1.2
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 +21 -0
- package/README.md +116 -0
- package/assets/brandpull-preview-exa.png +0 -0
- package/bin/brandpull +2 -0
- package/package.json +58 -0
- package/src/branding/colors.ts +82 -0
- package/src/branding/index.ts +107 -0
- package/src/branding/llm.ts +269 -0
- package/src/branding/logo.ts +249 -0
- package/src/branding/page-script.ts +860 -0
- package/src/branding/preview.ts +583 -0
- package/src/branding/processor.ts +382 -0
- package/src/branding/types.ts +279 -0
- package/src/index.ts +226 -0
- package/src/ui.ts +45 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
import type { RawBrandingData } from "./types"
|
|
2
|
+
|
|
3
|
+
export function extractBrandingFromPage(): RawBrandingData {
|
|
4
|
+
const constants = {
|
|
5
|
+
buttonMinWidth: 50,
|
|
6
|
+
buttonMinHeight: 25,
|
|
7
|
+
buttonMinPaddingVertical: 3,
|
|
8
|
+
buttonMinPaddingHorizontal: 6,
|
|
9
|
+
maxParentTraversal: 5,
|
|
10
|
+
maxBackgroundSamples: 100,
|
|
11
|
+
minSignificantArea: 1000,
|
|
12
|
+
minLargeContainerArea: 10000,
|
|
13
|
+
minLogoSize: 25,
|
|
14
|
+
minAlphaThreshold: 0.1,
|
|
15
|
+
maxTransparentAlpha: 0.01,
|
|
16
|
+
topPageThreshold: 500,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const errors: Array<{ context: string; message: string; timestamp: number }> = []
|
|
20
|
+
const recordError = (context: string, error: unknown) => {
|
|
21
|
+
errors.push({
|
|
22
|
+
context,
|
|
23
|
+
message: error && (error as Error).message ? (error as Error).message : String(error),
|
|
24
|
+
timestamp: Date.now(),
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let nativeGetComputedStyle: (el: Element) => CSSStyleDeclaration
|
|
29
|
+
try {
|
|
30
|
+
const native = Window.prototype.getComputedStyle
|
|
31
|
+
nativeGetComputedStyle = native ? native.bind(window) : window.getComputedStyle.bind(window)
|
|
32
|
+
} catch {
|
|
33
|
+
nativeGetComputedStyle = () => ({}) as CSSStyleDeclaration
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const styleCache = new WeakMap<Element, CSSStyleDeclaration>()
|
|
37
|
+
const getStyle = (el: Element): CSSStyleDeclaration => {
|
|
38
|
+
try {
|
|
39
|
+
if (!el || !(el instanceof Element)) return nativeGetComputedStyle(document.documentElement)
|
|
40
|
+
const cached = styleCache.get(el)
|
|
41
|
+
if (cached) return cached
|
|
42
|
+
const style = nativeGetComputedStyle(el)
|
|
43
|
+
styleCache.set(el, style)
|
|
44
|
+
return style
|
|
45
|
+
} catch (error) {
|
|
46
|
+
recordError("getComputedStyle", error)
|
|
47
|
+
return nativeGetComputedStyle(document.documentElement)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const classString = (el: Element | null): string => {
|
|
52
|
+
if (!el) return ""
|
|
53
|
+
try {
|
|
54
|
+
const cn = el.className as unknown
|
|
55
|
+
if (typeof cn === "string") return cn
|
|
56
|
+
if (cn && typeof cn === "object" && "baseVal" in cn) return String((cn as { baseVal: string }).baseVal || "")
|
|
57
|
+
return el.getAttribute("class") || String(cn || "")
|
|
58
|
+
} catch {
|
|
59
|
+
return ""
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const toPx = (value: string | null | undefined): number | null => {
|
|
64
|
+
if (!value || value === "auto") return null
|
|
65
|
+
if (value.endsWith("px")) return Number.parseFloat(value)
|
|
66
|
+
if (value.endsWith("rem"))
|
|
67
|
+
return Number.parseFloat(value) * Number.parseFloat(getStyle(document.documentElement).fontSize || "16")
|
|
68
|
+
if (value.endsWith("em"))
|
|
69
|
+
return (
|
|
70
|
+
Number.parseFloat(value) *
|
|
71
|
+
Number.parseFloat(getStyle(document.body ?? document.documentElement).fontSize || "16")
|
|
72
|
+
)
|
|
73
|
+
if (value.endsWith("%")) return null
|
|
74
|
+
const parsed = Number.parseFloat(value)
|
|
75
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const textOf = (el: Element | null, limit = 100) =>
|
|
79
|
+
(el?.textContent || "").trim().replace(/\s+/g, " ").slice(0, limit)
|
|
80
|
+
|
|
81
|
+
const resolveUrl = (raw: string | null | undefined): string => {
|
|
82
|
+
if (!raw) return ""
|
|
83
|
+
if (raw.startsWith("data:")) return raw
|
|
84
|
+
try {
|
|
85
|
+
return new URL(raw, window.location.href).href
|
|
86
|
+
} catch {
|
|
87
|
+
return raw
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const isSameBrandHost = (a: string, b: string): boolean => {
|
|
92
|
+
if (a === b) return true
|
|
93
|
+
const al = a.replace(/^www\./, "").split(".")[0] || ""
|
|
94
|
+
const bl = b.replace(/^www\./, "").split(".")[0] || ""
|
|
95
|
+
return al.length > 1 && al === bl
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isHomeHref = (href: string | null | undefined): boolean => {
|
|
99
|
+
if (!href) return false
|
|
100
|
+
const trimmed = href.trim()
|
|
101
|
+
if (["", "/", "./", "/home", "/index", "/index.html", "#"].includes(trimmed)) return true
|
|
102
|
+
if (trimmed.startsWith("#") || trimmed.startsWith("?")) return true
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(trimmed, window.location.origin)
|
|
105
|
+
if (!isSameBrandHost(window.location.hostname.toLowerCase(), url.hostname.toLowerCase())) return false
|
|
106
|
+
const path = url.pathname.replace(/\/$/, "") || "/"
|
|
107
|
+
return (
|
|
108
|
+
path === "/" ||
|
|
109
|
+
path === "/home" ||
|
|
110
|
+
path === "/index" ||
|
|
111
|
+
path === "/index.html" ||
|
|
112
|
+
path.split("/").filter(Boolean).length === 1
|
|
113
|
+
)
|
|
114
|
+
} catch {
|
|
115
|
+
const parts = trimmed.split("/").filter(Boolean)
|
|
116
|
+
return parts.length === 1 && !trimmed.includes(".")
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isExternalServiceHref = (href: string): boolean => {
|
|
121
|
+
const lower = href.toLowerCase()
|
|
122
|
+
if (!(lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("//"))) return false
|
|
123
|
+
const services = [
|
|
124
|
+
"github.com",
|
|
125
|
+
"twitter.com",
|
|
126
|
+
"x.com",
|
|
127
|
+
"facebook.com",
|
|
128
|
+
"linkedin.com",
|
|
129
|
+
"instagram.com",
|
|
130
|
+
"youtube.com",
|
|
131
|
+
"discord.com",
|
|
132
|
+
"slack.com",
|
|
133
|
+
"npmjs.com",
|
|
134
|
+
"pypi.org",
|
|
135
|
+
"shields.io",
|
|
136
|
+
"vercel.com",
|
|
137
|
+
"netlify.com",
|
|
138
|
+
]
|
|
139
|
+
try {
|
|
140
|
+
const url = new URL(href, window.location.origin)
|
|
141
|
+
if (isSameBrandHost(window.location.hostname.toLowerCase(), url.hostname.toLowerCase())) return false
|
|
142
|
+
return services.some((service) => lower.includes(service))
|
|
143
|
+
} catch {
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const queryAllDeep = (selector: string): Element[] => {
|
|
149
|
+
const results: Element[] = []
|
|
150
|
+
const seen = new Set<Document | ShadowRoot>()
|
|
151
|
+
const walk = (root: Document | ShadowRoot) => {
|
|
152
|
+
if (!root || seen.has(root)) return
|
|
153
|
+
seen.add(root)
|
|
154
|
+
try {
|
|
155
|
+
root.querySelectorAll(selector).forEach((el) => {
|
|
156
|
+
results.push(el)
|
|
157
|
+
})
|
|
158
|
+
root.querySelectorAll("*").forEach((el) => {
|
|
159
|
+
const shadowRoot = (el as Element & { shadowRoot?: ShadowRoot }).shadowRoot
|
|
160
|
+
if (shadowRoot) walk(shadowRoot)
|
|
161
|
+
})
|
|
162
|
+
} catch (error) {
|
|
163
|
+
recordError(`queryAllDeep:${selector}`, error)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
walk(document)
|
|
167
|
+
return results
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isValidBackgroundColor = (color: string | null | undefined): boolean => {
|
|
171
|
+
if (!color) return false
|
|
172
|
+
const normalized = color.toLowerCase().trim()
|
|
173
|
+
if (normalized === "transparent" || normalized === "rgba(0, 0, 0, 0)") return false
|
|
174
|
+
const rgba = normalized.match(/rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*([\d.]+)\s*\)/)
|
|
175
|
+
if (rgba) return Number.parseFloat(rgba[1]!) >= constants.maxTransparentAlpha
|
|
176
|
+
return normalized.length > 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const normalizeColor = (color: string | null | undefined): string | null => {
|
|
180
|
+
if (!color) return null
|
|
181
|
+
const normalized = color.toLowerCase().trim()
|
|
182
|
+
if (!isValidBackgroundColor(normalized)) return null
|
|
183
|
+
return normalized.replace(/\s+/g, "")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const collectCSSData = () => {
|
|
187
|
+
const data = { colors: [] as string[], spacings: [] as number[], radii: [] as number[] }
|
|
188
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
189
|
+
let rules: CSSRuleList | null = null
|
|
190
|
+
try {
|
|
191
|
+
rules = sheet.cssRules
|
|
192
|
+
} catch {
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
for (const rule of Array.from(rules || [])) {
|
|
196
|
+
try {
|
|
197
|
+
if (rule.type !== CSSRule.STYLE_RULE) continue
|
|
198
|
+
const style = (rule as CSSStyleRule).style
|
|
199
|
+
for (const prop of ["color", "background-color", "border-color", "fill", "stroke"]) {
|
|
200
|
+
const value = style.getPropertyValue(prop)
|
|
201
|
+
if (value) data.colors.push(value)
|
|
202
|
+
}
|
|
203
|
+
for (const prop of [
|
|
204
|
+
"border-radius",
|
|
205
|
+
"border-top-left-radius",
|
|
206
|
+
"border-top-right-radius",
|
|
207
|
+
"border-bottom-left-radius",
|
|
208
|
+
"border-bottom-right-radius",
|
|
209
|
+
]) {
|
|
210
|
+
const value = toPx(style.getPropertyValue(prop))
|
|
211
|
+
if (value) data.radii.push(value)
|
|
212
|
+
}
|
|
213
|
+
for (const prop of [
|
|
214
|
+
"margin",
|
|
215
|
+
"margin-top",
|
|
216
|
+
"margin-right",
|
|
217
|
+
"margin-bottom",
|
|
218
|
+
"margin-left",
|
|
219
|
+
"padding",
|
|
220
|
+
"padding-top",
|
|
221
|
+
"padding-right",
|
|
222
|
+
"padding-bottom",
|
|
223
|
+
"padding-left",
|
|
224
|
+
"gap",
|
|
225
|
+
"row-gap",
|
|
226
|
+
"column-gap",
|
|
227
|
+
]) {
|
|
228
|
+
const value = toPx(style.getPropertyValue(prop))
|
|
229
|
+
if (value) data.spacings.push(value)
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
recordError("collectCSSData:rule", error)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return data
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const isButtonElement = (el: Element | null): boolean => {
|
|
240
|
+
if (!el || typeof el.matches !== "function") return false
|
|
241
|
+
const selector =
|
|
242
|
+
'button,input[type="submit"],input[type="button"],[role=button],[data-primary-button],[data-secondary-button],[data-cta],a.button,a.btn,[class*="btn"],[class*="button"],a[class*="bg-brand"],a[class*="bg-primary"],a[class*="bg-accent"]'
|
|
243
|
+
if (el.matches(selector)) return true
|
|
244
|
+
if (el.tagName.toLowerCase() !== "a") return false
|
|
245
|
+
const style = getStyle(el)
|
|
246
|
+
const rect = el.getBoundingClientRect()
|
|
247
|
+
const classes = classString(el).toLowerCase()
|
|
248
|
+
const hasButtonClasses =
|
|
249
|
+
/rounded(-md|-lg|-xl|-full)?|p[xy]?-\d+|border.*rounded|inline-flex.*items-center.*justify-center/.test(classes)
|
|
250
|
+
const hasPadding =
|
|
251
|
+
(Number.parseFloat(style.paddingTop) || 0) > constants.buttonMinPaddingVertical ||
|
|
252
|
+
(Number.parseFloat(style.paddingBottom) || 0) > constants.buttonMinPaddingVertical ||
|
|
253
|
+
(Number.parseFloat(style.paddingLeft) || 0) > constants.buttonMinPaddingHorizontal ||
|
|
254
|
+
(Number.parseFloat(style.paddingRight) || 0) > constants.buttonMinPaddingHorizontal
|
|
255
|
+
const hasSize = rect.width > constants.buttonMinWidth && rect.height > constants.buttonMinHeight
|
|
256
|
+
const hasShape =
|
|
257
|
+
(Number.parseFloat(style.borderRadius) || 0) > 0 || (Number.parseFloat(style.borderTopWidth) || 0) > 0
|
|
258
|
+
return (hasButtonClasses && hasSize) || (hasPadding && hasSize && hasShape)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const sampleElements = (): Element[] => {
|
|
262
|
+
const set = new Set<Element>()
|
|
263
|
+
const push = (selector: string, limit: number) => {
|
|
264
|
+
let count = 0
|
|
265
|
+
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
266
|
+
if (count >= limit) break
|
|
267
|
+
set.add(el)
|
|
268
|
+
count++
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
push('header img, header svg, nav img, nav svg, .site-logo img, img[alt*="logo" i], img[src*="logo" i]', 20)
|
|
272
|
+
push(
|
|
273
|
+
'button, input[type="submit"], input[type="button"], [role=button], [data-primary-button], [data-secondary-button], [data-cta], a.button, a.btn, [class*="btn"], [class*="button"], a[class*="bg-brand"], a[class*="bg-primary"], a[class*="bg-accent"]',
|
|
274
|
+
120,
|
|
275
|
+
)
|
|
276
|
+
for (const link of Array.from(document.querySelectorAll("a")).slice(0, 150)) {
|
|
277
|
+
if (!set.has(link) && isButtonElement(link)) set.add(link)
|
|
278
|
+
}
|
|
279
|
+
push('input, select, textarea, [class*="form-control"]', 35)
|
|
280
|
+
push("h1, h2, h3, p, a", 80)
|
|
281
|
+
return Array.from(set).filter(Boolean)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const getStyleSnapshot = (el: Element) => {
|
|
285
|
+
const style = getStyle(el)
|
|
286
|
+
const rect = el.getBoundingClientRect()
|
|
287
|
+
const tag = el.tagName.toLowerCase()
|
|
288
|
+
const classes = classString(el).toLowerCase()
|
|
289
|
+
const fontStack = (style.fontFamily || "")
|
|
290
|
+
.split(",")
|
|
291
|
+
.map((font) => font.replace(/["']/g, "").trim())
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
|
|
294
|
+
let background = style.getPropertyValue("background-color")
|
|
295
|
+
const transparent = background === "transparent" || background === "rgba(0, 0, 0, 0)"
|
|
296
|
+
const isInputElement = tag === "input" || tag === "select" || tag === "textarea"
|
|
297
|
+
if (transparent && !isInputElement) {
|
|
298
|
+
let parent = el.parentElement
|
|
299
|
+
let depth = 0
|
|
300
|
+
while (parent && depth < constants.maxParentTraversal) {
|
|
301
|
+
const parentBg = getStyle(parent).getPropertyValue("background-color")
|
|
302
|
+
if (isValidBackgroundColor(parentBg)) {
|
|
303
|
+
background = parentBg
|
|
304
|
+
break
|
|
305
|
+
}
|
|
306
|
+
parent = parent.parentElement
|
|
307
|
+
depth++
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const hasCTAIndicator =
|
|
312
|
+
el.matches('[data-primary-button],[data-secondary-button],[data-cta],[class*="cta"],[class*="hero"]') ||
|
|
313
|
+
el.getAttribute("data-primary-button") === "true" ||
|
|
314
|
+
el.getAttribute("data-secondary-button") === "true"
|
|
315
|
+
const isNavigation =
|
|
316
|
+
!hasCTAIndicator &&
|
|
317
|
+
(/nav-|nav-|nav-link|sidebar|menu|toggle|trigger/.test(classes) ||
|
|
318
|
+
el.matches('[role="tab"],[role="menuitem"],[aria-haspopup],[aria-expanded]') ||
|
|
319
|
+
!!el.closest(
|
|
320
|
+
'nav, [role="navigation"], [role="menu"], [role="menubar"], [class*="navigation"], [class*="dropdown"], [class*="sidebar"], aside',
|
|
321
|
+
))
|
|
322
|
+
const isButton = isButtonElement(el) && !isNavigation
|
|
323
|
+
const isInput = el.matches(
|
|
324
|
+
'input:not([type="submit"]):not([type="button"]),select,textarea,[class*="form-control"]',
|
|
325
|
+
)
|
|
326
|
+
const input = el as HTMLInputElement
|
|
327
|
+
const text =
|
|
328
|
+
tag === "input" && (input.type === "submit" || input.type === "button")
|
|
329
|
+
? (input.value || "").trim().slice(0, 100)
|
|
330
|
+
: textOf(el, 100)
|
|
331
|
+
|
|
332
|
+
const inputMetadata = isInput
|
|
333
|
+
? {
|
|
334
|
+
type: tag === "input" ? input.type || "text" : tag,
|
|
335
|
+
placeholder: input.placeholder || "",
|
|
336
|
+
value: tag === "input" ? input.value || "" : "",
|
|
337
|
+
required: input.required || false,
|
|
338
|
+
disabled: input.disabled || false,
|
|
339
|
+
name: input.name || "",
|
|
340
|
+
id: el.id || "",
|
|
341
|
+
label: (() => {
|
|
342
|
+
if (el.id) {
|
|
343
|
+
const label = document.querySelector(`label[for="${el.id.replace(/"/g, '\\"')}"]`)
|
|
344
|
+
if (label) return textOf(label, 100)
|
|
345
|
+
}
|
|
346
|
+
const parentLabel = el.closest("label")
|
|
347
|
+
if (!parentLabel) return ""
|
|
348
|
+
const clone = parentLabel.cloneNode(true) as HTMLElement
|
|
349
|
+
clone.querySelector("input,select,textarea")?.remove()
|
|
350
|
+
return textOf(clone, 100)
|
|
351
|
+
})(),
|
|
352
|
+
}
|
|
353
|
+
: null
|
|
354
|
+
|
|
355
|
+
const borderTop = style.getPropertyValue("border-top-color")
|
|
356
|
+
const borderRight = style.getPropertyValue("border-right-color")
|
|
357
|
+
const borderBottom = style.getPropertyValue("border-bottom-color")
|
|
358
|
+
const borderLeft = style.getPropertyValue("border-left-color")
|
|
359
|
+
const borderTopWidth = toPx(style.getPropertyValue("border-top-width"))
|
|
360
|
+
const borderRightWidth = toPx(style.getPropertyValue("border-right-width"))
|
|
361
|
+
const borderBottomWidth = toPx(style.getPropertyValue("border-bottom-width"))
|
|
362
|
+
const borderLeftWidth = toPx(style.getPropertyValue("border-left-width"))
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
tag,
|
|
366
|
+
classes,
|
|
367
|
+
text,
|
|
368
|
+
rect: { w: rect.width, h: rect.height },
|
|
369
|
+
colors: {
|
|
370
|
+
text: style.getPropertyValue("color"),
|
|
371
|
+
background,
|
|
372
|
+
border:
|
|
373
|
+
borderTop === borderRight && borderTop === borderBottom && borderTop === borderLeft ? borderTop : borderTop,
|
|
374
|
+
borderWidth:
|
|
375
|
+
borderTopWidth === borderRightWidth &&
|
|
376
|
+
borderTopWidth === borderBottomWidth &&
|
|
377
|
+
borderTopWidth === borderLeftWidth
|
|
378
|
+
? borderTopWidth
|
|
379
|
+
: borderTopWidth,
|
|
380
|
+
borderTop,
|
|
381
|
+
borderTopWidth,
|
|
382
|
+
borderRight,
|
|
383
|
+
borderRightWidth,
|
|
384
|
+
borderBottom,
|
|
385
|
+
borderBottomWidth,
|
|
386
|
+
borderLeft,
|
|
387
|
+
borderLeftWidth,
|
|
388
|
+
},
|
|
389
|
+
typography: {
|
|
390
|
+
fontStack,
|
|
391
|
+
size: style.getPropertyValue("font-size") || null,
|
|
392
|
+
weight: Number.parseInt(style.getPropertyValue("font-weight"), 10) || null,
|
|
393
|
+
},
|
|
394
|
+
radius: toPx(style.getPropertyValue("border-radius")),
|
|
395
|
+
borderRadius: {
|
|
396
|
+
topLeft: toPx(style.getPropertyValue("border-top-left-radius")),
|
|
397
|
+
topRight: toPx(style.getPropertyValue("border-top-right-radius")),
|
|
398
|
+
bottomRight: toPx(style.getPropertyValue("border-bottom-right-radius")),
|
|
399
|
+
bottomLeft: toPx(style.getPropertyValue("border-bottom-left-radius")),
|
|
400
|
+
},
|
|
401
|
+
shadow: style.getPropertyValue("box-shadow") || null,
|
|
402
|
+
isButton,
|
|
403
|
+
isNavigation,
|
|
404
|
+
hasCTAIndicator,
|
|
405
|
+
isInput,
|
|
406
|
+
inputMetadata,
|
|
407
|
+
isLink: el.matches("a"),
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const backgroundImageUrl = (value: string | null): string | null => {
|
|
412
|
+
if (!value || value === "none") return null
|
|
413
|
+
const quoted = value.match(/url\((["'])(.*?)\1\)/)
|
|
414
|
+
const simple = quoted?.[2] ?? value.match(/url\(([^)]+?)\)/)?.[1]
|
|
415
|
+
if (!simple) return null
|
|
416
|
+
return simple
|
|
417
|
+
.trim()
|
|
418
|
+
.replace(/^["']|["']$/g, "")
|
|
419
|
+
.replace(/"/g, '"')
|
|
420
|
+
.replace(/</g, "<")
|
|
421
|
+
.replace(/>/g, ">")
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const serializeSvg = (svg: SVGSVGElement): string => {
|
|
425
|
+
const clone = svg.cloneNode(true) as SVGSVGElement
|
|
426
|
+
const originals = [svg, ...Array.from(svg.querySelectorAll("*"))]
|
|
427
|
+
const clones = [clone, ...Array.from(clone.querySelectorAll("*"))]
|
|
428
|
+
const props = ["fill", "stroke", "color", "stop-color", "stroke-width", "opacity", "fill-opacity", "stroke-opacity"]
|
|
429
|
+
for (let i = 0; i < clones.length; i++) {
|
|
430
|
+
const original = originals[i]
|
|
431
|
+
const cloned = clones[i]
|
|
432
|
+
if (!original || !cloned) continue
|
|
433
|
+
const style = getStyle(original)
|
|
434
|
+
for (const prop of props) {
|
|
435
|
+
const value = style.getPropertyValue(prop)
|
|
436
|
+
if (value?.trim() && value !== "none") (cloned as HTMLElement).style.setProperty(prop, value)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(clone))}`
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const findImages = () => {
|
|
443
|
+
const images: Array<{ type: string; src: string }> = []
|
|
444
|
+
const logoCandidates: RawBrandingData["logoCandidates"] = []
|
|
445
|
+
const pushImage = (src: string | null | undefined, type: string) => {
|
|
446
|
+
const resolved = resolveUrl(src)
|
|
447
|
+
if (resolved) images.push({ type, src: resolved })
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
pushImage((document.querySelector('link[rel*="icon" i]') as HTMLLinkElement | null)?.href, "favicon")
|
|
451
|
+
pushImage((document.querySelector('meta[property="og:image" i]') as HTMLMetaElement | null)?.content, "og")
|
|
452
|
+
pushImage((document.querySelector('meta[name="twitter:image" i]') as HTMLMetaElement | null)?.content, "twitter")
|
|
453
|
+
|
|
454
|
+
const collect = (el: Element, source: string) => {
|
|
455
|
+
try {
|
|
456
|
+
const rect = el.getBoundingClientRect()
|
|
457
|
+
const style = getStyle(el)
|
|
458
|
+
const parentLink = el.closest("a")
|
|
459
|
+
const href = parentLink?.getAttribute("href") || ""
|
|
460
|
+
if (href && isExternalServiceHref(href)) return
|
|
461
|
+
|
|
462
|
+
const inHeader = !!el.closest(
|
|
463
|
+
'header, nav, [role="banner"], #navbar, [id*="navbar" i], [class*="navbar" i], [class*="globalnav" i], [role="menubar"]',
|
|
464
|
+
)
|
|
465
|
+
const inFooter = !!el.closest('footer, [class*="footer" i], [role="contentinfo"]')
|
|
466
|
+
const parentAria = parentLink?.getAttribute("aria-label") || ""
|
|
467
|
+
const ownAria = el.getAttribute("aria-label") || ""
|
|
468
|
+
const ownTitle = el.getAttribute("title") || el.querySelector("title")?.textContent || ""
|
|
469
|
+
const classes = classString(el).toLowerCase()
|
|
470
|
+
const id = (el.id || "").toLowerCase()
|
|
471
|
+
const tag = el.tagName.toLowerCase()
|
|
472
|
+
const bgUrl = backgroundImageUrl(style.getPropertyValue("background-image"))
|
|
473
|
+
const imgSrc = tag === "img" ? (el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src : ""
|
|
474
|
+
const isSvg = tag === "svg"
|
|
475
|
+
const alt =
|
|
476
|
+
tag === "img"
|
|
477
|
+
? ((el as HTMLImageElement).alt || parentAria || "").trim()
|
|
478
|
+
: (ownAria || ownTitle || textOf(el, 80) || id || "").trim()
|
|
479
|
+
const hasLogoData = /logo|brand|site-name|site-title/i.test(
|
|
480
|
+
`${classes} ${id} ${ownAria} ${ownTitle} ${parentAria}`,
|
|
481
|
+
)
|
|
482
|
+
const hasHome = isHomeHref(href)
|
|
483
|
+
const strongContext = inHeader || hasHome || hasLogoData
|
|
484
|
+
const isVisible =
|
|
485
|
+
rect.width > 0 &&
|
|
486
|
+
rect.height > 0 &&
|
|
487
|
+
style.display !== "none" &&
|
|
488
|
+
style.visibility !== "hidden" &&
|
|
489
|
+
style.opacity !== "0"
|
|
490
|
+
|
|
491
|
+
if (/flag|language|locale/i.test(`${classes} ${id} ${alt}`) && rect.width <= 32 && rect.height <= 32) return
|
|
492
|
+
if (
|
|
493
|
+
/search|magnif|hamburger|menu|cart|user|bell|chevron|arrow|caret|dropdown|close|settings/i.test(
|
|
494
|
+
`${classes} ${id} ${alt}`,
|
|
495
|
+
) &&
|
|
496
|
+
!hasLogoData
|
|
497
|
+
)
|
|
498
|
+
return
|
|
499
|
+
if (!strongContext && !/logo|brand/i.test(`${imgSrc} ${bgUrl || ""} ${alt}`)) return
|
|
500
|
+
|
|
501
|
+
let src = ""
|
|
502
|
+
let finalSvg = false
|
|
503
|
+
let svgScore = 0
|
|
504
|
+
if (isSvg) {
|
|
505
|
+
finalSvg = true
|
|
506
|
+
const image = el.querySelector("image")
|
|
507
|
+
const imageHref = image?.getAttribute("href") || image?.getAttribute("xlink:href") || ""
|
|
508
|
+
src = imageHref ? resolveUrl(imageHref) : serializeSvg(el as SVGSVGElement)
|
|
509
|
+
const pathCount = el.querySelectorAll("path").length
|
|
510
|
+
const groupCount = el.querySelectorAll("g").length
|
|
511
|
+
svgScore += Math.min(pathCount * 2, 40) + Math.min(groupCount, 20)
|
|
512
|
+
if (el.querySelector("text")) svgScore -= 50
|
|
513
|
+
if (el.querySelector("animate, animateTransform, animateMotion")) svgScore += 30
|
|
514
|
+
const area = rect.width * rect.height
|
|
515
|
+
if (area > 10000) svgScore += 20
|
|
516
|
+
else if (area > 5000) svgScore += 10
|
|
517
|
+
else if (area < 1000) svgScore -= 20
|
|
518
|
+
} else if (imgSrc) {
|
|
519
|
+
src = resolveUrl(imgSrc)
|
|
520
|
+
} else if (bgUrl && strongContext) {
|
|
521
|
+
src = resolveUrl(bgUrl)
|
|
522
|
+
finalSvg = src.startsWith("data:image/svg+xml") || /\.svg(\?|#|$)/i.test(src)
|
|
523
|
+
}
|
|
524
|
+
if (!src) return
|
|
525
|
+
|
|
526
|
+
const srcMatch = /logo|brand/i.test(src) || /logo/i.test(id)
|
|
527
|
+
const altMatch = /logo|brand/i.test(alt)
|
|
528
|
+
const classMatch = hasLogoData || !!el.closest('[class*="logo" i], [id*="logo" i]')
|
|
529
|
+
if (!isVisible && !strongContext) return
|
|
530
|
+
|
|
531
|
+
const position = {
|
|
532
|
+
top: isVisible ? rect.top : 0,
|
|
533
|
+
left: isVisible ? rect.left : 0,
|
|
534
|
+
width: rect.width || Number.parseFloat(el.getAttribute("width") || "0") || 0,
|
|
535
|
+
height: rect.height || Number.parseFloat(el.getAttribute("height") || "0") || 0,
|
|
536
|
+
}
|
|
537
|
+
logoCandidates.push({
|
|
538
|
+
src,
|
|
539
|
+
alt,
|
|
540
|
+
ariaLabel: ownAria || parentAria || undefined,
|
|
541
|
+
title: ownTitle || undefined,
|
|
542
|
+
isSvg: finalSvg,
|
|
543
|
+
isVisible,
|
|
544
|
+
location: inHeader ? "header" : inFooter ? "footer" : "body",
|
|
545
|
+
position,
|
|
546
|
+
indicators: {
|
|
547
|
+
inHeader,
|
|
548
|
+
altMatch,
|
|
549
|
+
srcMatch,
|
|
550
|
+
classMatch,
|
|
551
|
+
hrefMatch: hasHome,
|
|
552
|
+
},
|
|
553
|
+
href: href || undefined,
|
|
554
|
+
source,
|
|
555
|
+
logoSvgScore: finalSvg ? (src.startsWith("data:image/svg+xml") ? Math.max(80, svgScore) : svgScore) : 100,
|
|
556
|
+
})
|
|
557
|
+
} catch (error) {
|
|
558
|
+
recordError("collectLogoCandidate", error)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const selectors = [
|
|
563
|
+
"header a img, header a svg, header img, header svg",
|
|
564
|
+
"nav a img, nav a svg, nav img, nav svg",
|
|
565
|
+
'[role="banner"] a img, [role="banner"] a svg, [role="banner"] img, [role="banner"] svg',
|
|
566
|
+
'[class*="header" i] a img, [class*="header" i] a svg, [class*="header" i] img, [class*="header" i] svg',
|
|
567
|
+
'[id*="header" i] a img, [id*="header" i] a svg, [id*="header" i] img, [id*="header" i] svg',
|
|
568
|
+
'[class*="navbar" i] a img, [class*="navbar" i] a svg, [class*="navbar" i] img, [class*="navbar" i] svg',
|
|
569
|
+
'a[aria-label*="logo" i] img, a[aria-label*="logo" i] svg',
|
|
570
|
+
'a[aria-label*="home" i] img, a[aria-label*="home" i] svg',
|
|
571
|
+
'a[class*="logo" i] img, a[class*="logo" i] svg',
|
|
572
|
+
'[class*="logo" i] img, [class*="logo" i] svg',
|
|
573
|
+
'[id*="logo" i] img, [id*="logo" i] svg',
|
|
574
|
+
'img[class*="logo" i], svg[class*="logo" i]',
|
|
575
|
+
'img[src*="logo" i], img[alt*="logo" i]',
|
|
576
|
+
'a[href="/"] img, a[href="/"] svg, a[href="./"] img, a[href="./"] svg',
|
|
577
|
+
]
|
|
578
|
+
for (const selector of selectors) {
|
|
579
|
+
queryAllDeep(selector).forEach((el) => {
|
|
580
|
+
collect(el, selector)
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const backgroundSelectors = [
|
|
585
|
+
'[class*="logo" i]',
|
|
586
|
+
'[id*="logo" i]',
|
|
587
|
+
"header a > div, header a > span, nav a > div, nav a > span",
|
|
588
|
+
'a[aria-label*="logo" i] > div, a[aria-label*="home" i] > div',
|
|
589
|
+
'div[data-framer-name*="logo" i], span[data-framer-name*="logo" i], div[data-name*="logo" i], span[data-name*="logo" i]',
|
|
590
|
+
]
|
|
591
|
+
for (const selector of backgroundSelectors) {
|
|
592
|
+
queryAllDeep(selector).forEach((el) => {
|
|
593
|
+
if (backgroundImageUrl(getStyle(el).getPropertyValue("background-image"))) collect(el, `background:${selector}`)
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
queryAllDeep("img").forEach((img) => {
|
|
598
|
+
const alt = (img as HTMLImageElement).alt || ""
|
|
599
|
+
const src = (img as HTMLImageElement).src || ""
|
|
600
|
+
if (
|
|
601
|
+
/logo|brand/i.test(`${alt} ${src} ${classString(img)}`) &&
|
|
602
|
+
!img.closest('[class*="testimonial" i], [class*="client" i], [class*="partner" i], footer')
|
|
603
|
+
)
|
|
604
|
+
collect(img, "document.images")
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
queryAllDeep("svg").forEach((svg) => {
|
|
608
|
+
const rect = svg.getBoundingClientRect()
|
|
609
|
+
const existing = logoCandidates.some(
|
|
610
|
+
(c) =>
|
|
611
|
+
c.isSvg &&
|
|
612
|
+
Math.abs(c.position.top - rect.top) < 1 &&
|
|
613
|
+
Math.abs(c.position.left - rect.left) < 1 &&
|
|
614
|
+
Math.abs(c.position.width - rect.width) < 1,
|
|
615
|
+
)
|
|
616
|
+
if (!existing) {
|
|
617
|
+
const data = `${svg.id} ${classString(svg)} ${svg.getAttribute("aria-label") || ""} ${svg.querySelector("title")?.textContent || ""}`
|
|
618
|
+
if (
|
|
619
|
+
/logo|brand/i.test(data) ||
|
|
620
|
+
svg.closest('header, nav, [role="banner"], [class*="logo" i], [id*="logo" i]')
|
|
621
|
+
) {
|
|
622
|
+
collect(svg, "document.querySelectorAll(svg)")
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
queryAllDeep("a[href]")
|
|
628
|
+
.filter((a) => isHomeHref(a.getAttribute("href")))
|
|
629
|
+
.flatMap((a) => Array.from(a.querySelectorAll("img, svg")))
|
|
630
|
+
.filter((el) => {
|
|
631
|
+
const rect = el.getBoundingClientRect()
|
|
632
|
+
return rect.top >= 0 && rect.top < constants.topPageThreshold && rect.width > 0 && rect.height > 0
|
|
633
|
+
})
|
|
634
|
+
.forEach((el) => {
|
|
635
|
+
collect(el, "fallback-top-home-link")
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
const bySrc = new Map<string, RawBrandingData["logoCandidates"][number]>()
|
|
639
|
+
for (const candidate of logoCandidates) {
|
|
640
|
+
const existing = bySrc.get(candidate.src)
|
|
641
|
+
if (!existing) {
|
|
642
|
+
bySrc.set(candidate.src, candidate)
|
|
643
|
+
continue
|
|
644
|
+
}
|
|
645
|
+
const area = candidate.position.width * candidate.position.height
|
|
646
|
+
const existingArea = existing.position.width * existing.position.height
|
|
647
|
+
if (
|
|
648
|
+
(candidate.isVisible && !existing.isVisible) ||
|
|
649
|
+
(candidate.indicators.hrefMatch && !existing.indicators.hrefMatch) ||
|
|
650
|
+
area > existingArea
|
|
651
|
+
) {
|
|
652
|
+
bySrc.set(candidate.src, candidate)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const unique = Array.from(bySrc.values())
|
|
656
|
+
const pickable = unique.filter((c) => c.isVisible)
|
|
657
|
+
const best = (pickable.length ? pickable : unique).reduce<RawBrandingData["logoCandidates"][number] | null>(
|
|
658
|
+
(bestCandidate, candidate) => {
|
|
659
|
+
if (!bestCandidate) return candidate
|
|
660
|
+
if (
|
|
661
|
+
candidate.indicators.hrefMatch &&
|
|
662
|
+
candidate.indicators.inHeader &&
|
|
663
|
+
!(bestCandidate.indicators.hrefMatch && bestCandidate.indicators.inHeader)
|
|
664
|
+
)
|
|
665
|
+
return candidate
|
|
666
|
+
if (!candidate.isSvg && bestCandidate.isSvg) return candidate
|
|
667
|
+
if (candidate.indicators.inHeader && !bestCandidate.indicators.inHeader) return candidate
|
|
668
|
+
if (candidate.indicators.hrefMatch && !bestCandidate.indicators.hrefMatch) return candidate
|
|
669
|
+
const area = candidate.position.width * candidate.position.height
|
|
670
|
+
const bestArea = bestCandidate.position.width * bestCandidate.position.height
|
|
671
|
+
if (area >= constants.minSignificantArea && bestArea < constants.minSignificantArea) return candidate
|
|
672
|
+
return candidate.position.top < bestCandidate.position.top ? candidate : bestCandidate
|
|
673
|
+
},
|
|
674
|
+
null,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
if (best) pushImage(best.src, best.isSvg ? "logo-svg" : "logo")
|
|
678
|
+
return { images, logoCandidates: unique }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const pickFontStack = (el: Element | null): string[] =>
|
|
682
|
+
(getStyle(el ?? document.documentElement).fontFamily || "")
|
|
683
|
+
.split(",")
|
|
684
|
+
.map((font) => font.replace(/["']/g, "").trim())
|
|
685
|
+
.filter(Boolean)
|
|
686
|
+
|
|
687
|
+
const getTypography = () => {
|
|
688
|
+
const body = document.body ?? document.documentElement
|
|
689
|
+
const h1 = document.querySelector("h1") ?? body
|
|
690
|
+
const h2 = document.querySelector("h2") ?? h1
|
|
691
|
+
const p = document.querySelector("p") ?? body
|
|
692
|
+
return {
|
|
693
|
+
stacks: {
|
|
694
|
+
body: pickFontStack(body),
|
|
695
|
+
heading: pickFontStack(h1),
|
|
696
|
+
paragraph: pickFontStack(p),
|
|
697
|
+
},
|
|
698
|
+
sizes: {
|
|
699
|
+
h1: getStyle(h1).fontSize || "32px",
|
|
700
|
+
h2: getStyle(h2).fontSize || "24px",
|
|
701
|
+
body: getStyle(p).fontSize || "16px",
|
|
702
|
+
},
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const detectFrameworkHints = () => {
|
|
707
|
+
const hints = new Set<string>()
|
|
708
|
+
const generator = document.querySelector('meta[name="generator"]')?.getAttribute("content")
|
|
709
|
+
if (generator) hints.add(generator)
|
|
710
|
+
const scripts = Array.from(document.querySelectorAll("script[src]")).map((s) => s.getAttribute("src") || "")
|
|
711
|
+
if (scripts.some((s) => /tailwind|cdn\.tailwindcss/.test(s))) hints.add("tailwind")
|
|
712
|
+
if (scripts.some((s) => /bootstrap/.test(s))) hints.add("bootstrap")
|
|
713
|
+
if (scripts.some((s) => /mui|material-ui/.test(s))) hints.add("material-ui")
|
|
714
|
+
const classSample = Array.from(document.querySelectorAll("[class]")).slice(0, 250).map(classString).join(" ")
|
|
715
|
+
if (/\b(px|py|mx|my|bg|text|rounded|flex|grid|items-center|justify-)/.test(classSample)) hints.add("tailwind-like")
|
|
716
|
+
if (/\bbtn\b|\bcontainer\b|\brow\b|\bcol-/.test(classSample)) hints.add("bootstrap-like")
|
|
717
|
+
if (/Mui[A-Z]|chakra-|radix-|headlessui|react-aria/.test(classSample)) hints.add("component-library")
|
|
718
|
+
return Array.from(hints).filter(Boolean)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const detectColorScheme = (): "dark" | "light" => {
|
|
722
|
+
const html = document.documentElement
|
|
723
|
+
const body = document.body
|
|
724
|
+
const dark =
|
|
725
|
+
html.classList.contains("dark") ||
|
|
726
|
+
body?.classList.contains("dark") ||
|
|
727
|
+
html.getAttribute("data-theme") === "dark" ||
|
|
728
|
+
body?.getAttribute("data-theme") === "dark" ||
|
|
729
|
+
html.getAttribute("data-bs-theme") === "dark"
|
|
730
|
+
const light =
|
|
731
|
+
html.classList.contains("light") ||
|
|
732
|
+
body?.classList.contains("light") ||
|
|
733
|
+
html.getAttribute("data-theme") === "light" ||
|
|
734
|
+
body?.getAttribute("data-theme") === "light" ||
|
|
735
|
+
html.getAttribute("data-bs-theme") === "light"
|
|
736
|
+
if (dark) return "dark"
|
|
737
|
+
if (light) return "light"
|
|
738
|
+
const bg = getStyle(body ?? html).backgroundColor || getStyle(html).backgroundColor
|
|
739
|
+
const match = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
|
|
740
|
+
if (match) {
|
|
741
|
+
const r = Number.parseInt(match[1]!, 10)
|
|
742
|
+
const g = Number.parseInt(match[2]!, 10)
|
|
743
|
+
const b = Number.parseInt(match[3]!, 10)
|
|
744
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
745
|
+
if (luminance < 0.4) return "dark"
|
|
746
|
+
if (luminance > 0.6) return "light"
|
|
747
|
+
}
|
|
748
|
+
return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light"
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const extractBrandName = () => {
|
|
752
|
+
const site = document.querySelector('meta[property="og:site_name"]')?.getAttribute("content")?.trim()
|
|
753
|
+
const app = document.querySelector('meta[name="application-name"]')?.getAttribute("content")?.trim()
|
|
754
|
+
const title = document.title || ""
|
|
755
|
+
const h1 = textOf(document.querySelector("h1"), 80)
|
|
756
|
+
let domain = ""
|
|
757
|
+
try {
|
|
758
|
+
domain = window.location.hostname.replace(/^www\./, "").split(".")[0] || ""
|
|
759
|
+
domain = domain.charAt(0).toUpperCase() + domain.slice(1)
|
|
760
|
+
} catch {}
|
|
761
|
+
const titleParts = title
|
|
762
|
+
.split(/\s(?:[|–—-]|::)\s/)
|
|
763
|
+
.map((part) => part.trim())
|
|
764
|
+
.filter(Boolean)
|
|
765
|
+
const titleBrand =
|
|
766
|
+
titleParts.length > 1 ? titleParts[titleParts.length - 1] : title.replace(/\s*[-|–—:].*$/, "").trim()
|
|
767
|
+
return site || app || titleBrand || h1 || domain || ""
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const getBackgroundCandidates = () => {
|
|
771
|
+
const candidates: Array<{ color: string; source: string; priority: number; area?: number }> = []
|
|
772
|
+
const freq = new Map<string, number>()
|
|
773
|
+
for (const el of Array.from(
|
|
774
|
+
document.querySelectorAll("body, html, main, article, [role='main'], div, section"),
|
|
775
|
+
).slice(0, constants.maxBackgroundSamples)) {
|
|
776
|
+
try {
|
|
777
|
+
const bg = getStyle(el).backgroundColor
|
|
778
|
+
if (!isValidBackgroundColor(bg)) continue
|
|
779
|
+
const rect = el.getBoundingClientRect()
|
|
780
|
+
const area = rect.width * rect.height
|
|
781
|
+
if (area <= constants.minSignificantArea) continue
|
|
782
|
+
const key = normalizeColor(bg)
|
|
783
|
+
if (key) freq.set(key, (freq.get(key) ?? 0) + area)
|
|
784
|
+
} catch (error) {
|
|
785
|
+
recordError("backgroundCandidates:element", error)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
let mostCommon: string | null = null
|
|
789
|
+
let maxArea = 0
|
|
790
|
+
for (const [color, area] of freq.entries()) {
|
|
791
|
+
if (area > maxArea) {
|
|
792
|
+
maxArea = area
|
|
793
|
+
mostCommon = color
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const add = (color: string | null | undefined, source: string, priority: number, area?: number) => {
|
|
797
|
+
const normalized = normalizeColor(color)
|
|
798
|
+
if (normalized) candidates.push({ color: normalized, source, priority, area })
|
|
799
|
+
}
|
|
800
|
+
const bodyBg = getStyle(document.body ?? document.documentElement).backgroundColor
|
|
801
|
+
const htmlBg = getStyle(document.documentElement).backgroundColor
|
|
802
|
+
add(bodyBg, "body", normalizeColor(bodyBg) === mostCommon ? 15 : 10)
|
|
803
|
+
add(htmlBg, "html", normalizeColor(htmlBg) === mostCommon ? 14 : 9)
|
|
804
|
+
if (mostCommon && mostCommon !== normalizeColor(bodyBg) && mostCommon !== normalizeColor(htmlBg)) {
|
|
805
|
+
add(mostCommon, "most-common-visible", 12, maxArea)
|
|
806
|
+
}
|
|
807
|
+
const root = getStyle(document.documentElement)
|
|
808
|
+
for (const name of [
|
|
809
|
+
"--background",
|
|
810
|
+
"--background-light",
|
|
811
|
+
"--background-dark",
|
|
812
|
+
"--bg-background",
|
|
813
|
+
"--color-background",
|
|
814
|
+
"--color-background-light",
|
|
815
|
+
"--color-background-dark",
|
|
816
|
+
]) {
|
|
817
|
+
add(root.getPropertyValue(name).trim(), `css-var:${name}`, 8)
|
|
818
|
+
}
|
|
819
|
+
for (const el of Array.from(
|
|
820
|
+
document.querySelectorAll("main, article, [role='main'], header, .main, .container"),
|
|
821
|
+
).slice(0, 5)) {
|
|
822
|
+
const rect = el.getBoundingClientRect()
|
|
823
|
+
if (rect.width * rect.height > constants.minLargeContainerArea) {
|
|
824
|
+
add(getStyle(el).backgroundColor, `${el.tagName.toLowerCase()}-container`, 5, rect.width * rect.height)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
const seen = new Set<string>()
|
|
828
|
+
return candidates
|
|
829
|
+
.filter((candidate) => {
|
|
830
|
+
const key = normalizeColor(candidate.color)
|
|
831
|
+
if (!key || seen.has(key)) return false
|
|
832
|
+
seen.add(key)
|
|
833
|
+
return true
|
|
834
|
+
})
|
|
835
|
+
.sort((a, b) => b.priority - a.priority)
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const cssData = collectCSSData()
|
|
839
|
+
const snapshots = sampleElements().map(getStyleSnapshot)
|
|
840
|
+
const imageData = findImages()
|
|
841
|
+
const typography = getTypography()
|
|
842
|
+
const backgroundCandidates = getBackgroundCandidates()
|
|
843
|
+
const pageBackground = backgroundCandidates[0]?.color ?? null
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
cssData,
|
|
847
|
+
snapshots,
|
|
848
|
+
images: imageData.images,
|
|
849
|
+
logoCandidates: imageData.logoCandidates,
|
|
850
|
+
brandName: extractBrandName(),
|
|
851
|
+
pageTitle: document.title || "",
|
|
852
|
+
pageUrl: window.location.href,
|
|
853
|
+
typography,
|
|
854
|
+
frameworkHints: detectFrameworkHints(),
|
|
855
|
+
colorScheme: detectColorScheme(),
|
|
856
|
+
pageBackground,
|
|
857
|
+
backgroundCandidates,
|
|
858
|
+
errors: errors.length ? errors : undefined,
|
|
859
|
+
}
|
|
860
|
+
}
|