dembrandt 0.12.1 → 0.12.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/README.md +16 -0
- package/lib/extractors/components.js +82 -120
- package/lib/extractors/index.js +2 -1
- package/lib/extractors/logo.js +327 -172
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ dembrandt example.com --sitemap # Discover pages from sitemap.xml instead
|
|
|
71
71
|
dembrandt example.com --pages 10 --sitemap # Combine: up to 10 pages discovered via sitemap
|
|
72
72
|
dembrandt example.com --no-sandbox # Disable Chromium sandbox (required for Docker/CI)
|
|
73
73
|
dembrandt example.com --browser=firefox # Use Firefox instead of Chromium (better for Cloudflare bypass)
|
|
74
|
+
dembrandt example.com --wcag # WCAG 2.1 contrast analysis — real DOM pairs, AA/AAA grades
|
|
74
75
|
```
|
|
75
76
|
|
|
76
77
|
Default: formatted terminal display only. Use `--save-output` to persist results as JSON files. Browser automatically retries in visible mode if headless extraction fails.
|
|
@@ -140,6 +141,16 @@ dembrandt example.com --design-md
|
|
|
140
141
|
# Saves to: output/example.com/DESIGN.md
|
|
141
142
|
```
|
|
142
143
|
|
|
144
|
+
### WCAG Contrast Analysis
|
|
145
|
+
|
|
146
|
+
Use `--wcag` to check accessibility contrast ratios across the page. Unlike palette-based checkers, dembrandt walks the actual DOM and finds what color is rendered on top of what background — per element.
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
dembrandt stripe.com --wcag
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Returns every text/background pair with contrast ratio and WCAG 2.1 grade (AA, AA-Large, AAA, or fail), sorted by how often each pair appears. Results are shown in terminal and included in JSON output as `wcag`.
|
|
153
|
+
|
|
143
154
|
### Brand Guide PDF
|
|
144
155
|
|
|
145
156
|
Use `--brand-guide` to generate a printable PDF summarizing the extracted design system: colors, typography, components, and logo on a single document.
|
|
@@ -222,6 +233,11 @@ dembrandt stripe.com --design-md
|
|
|
222
233
|
# Point your agent at the output DESIGN.md
|
|
223
234
|
```
|
|
224
235
|
|
|
236
|
+
**Accessibility audit** — check contrast on any live URL
|
|
237
|
+
```bash
|
|
238
|
+
dembrandt stripe.com --wcag
|
|
239
|
+
```
|
|
240
|
+
|
|
225
241
|
**Regression baseline** — snapshot now, catch drift later
|
|
226
242
|
```bash
|
|
227
243
|
dembrandt myapp.com --save-output --dtcg
|
|
@@ -1,137 +1,99 @@
|
|
|
1
1
|
export async function extractButtonStyles(page) {
|
|
2
2
|
return await page.evaluate(() => {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
[role="button"],
|
|
8
|
-
[role="tab"],
|
|
9
|
-
[role="menuitem"],
|
|
10
|
-
[role="switch"],
|
|
11
|
-
[aria-pressed],
|
|
12
|
-
[aria-expanded],
|
|
13
|
-
.btn,
|
|
14
|
-
[class*="btn"],
|
|
15
|
-
[class*="button"],
|
|
16
|
-
[class*="cta"],
|
|
17
|
-
[data-cta]
|
|
18
|
-
`)
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
const extractState = (btn) => {
|
|
22
|
-
const computed = getComputedStyle(btn);
|
|
23
|
-
return {
|
|
24
|
-
backgroundColor: computed.backgroundColor,
|
|
25
|
-
color: computed.color,
|
|
26
|
-
padding: computed.padding,
|
|
27
|
-
borderRadius: computed.borderRadius,
|
|
28
|
-
border: computed.border || `${computed.borderWidth} ${computed.borderStyle} ${computed.borderColor}`,
|
|
29
|
-
boxShadow: computed.boxShadow,
|
|
30
|
-
outline: computed.outline,
|
|
31
|
-
transform: computed.transform,
|
|
32
|
-
opacity: computed.opacity,
|
|
33
|
-
};
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const buttonStyles = [];
|
|
37
|
-
|
|
38
|
-
buttons.forEach((btn) => {
|
|
39
|
-
const computed = getComputedStyle(btn);
|
|
40
|
-
const rect = btn.getBoundingClientRect();
|
|
41
|
-
if (rect.width === 0 || rect.height === 0 || computed.display === 'none' || computed.visibility === 'hidden') return;
|
|
42
|
-
|
|
43
|
-
const bg = computed.backgroundColor;
|
|
44
|
-
const border = computed.border;
|
|
45
|
-
const borderWidth = computed.borderWidth;
|
|
46
|
-
const borderColor = computed.borderColor;
|
|
47
|
-
const boxShadow = computed.boxShadow;
|
|
48
|
-
|
|
49
|
-
const hasBorder = borderWidth && parseFloat(borderWidth) > 0 && border !== 'none' && borderColor !== 'rgba(0, 0, 0, 0)' && borderColor !== 'transparent';
|
|
50
|
-
const hasBackground = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
|
|
51
|
-
const hasShadow = boxShadow && boxShadow !== 'none' && boxShadow !== 'rgba(0, 0, 0, 0)';
|
|
3
|
+
// Only real interactive buttons — not tabs, menus, dropdowns
|
|
4
|
+
const candidates = Array.from(document.querySelectorAll(
|
|
5
|
+
'button, a[href], [role="button"]'
|
|
6
|
+
));
|
|
52
7
|
|
|
53
|
-
|
|
8
|
+
const isTransparent = (color) =>
|
|
9
|
+
!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)';
|
|
54
10
|
|
|
55
|
-
|
|
56
|
-
const isNativeButton = btn.tagName === "BUTTON";
|
|
57
|
-
const isButtonRole = ['button', 'tab', 'menuitem', 'switch'].includes(role);
|
|
58
|
-
const hasAriaPressed = btn.hasAttribute('aria-pressed');
|
|
59
|
-
const hasAriaExpanded = btn.hasAttribute('aria-expanded');
|
|
60
|
-
const isHighConfidence = isNativeButton || isButtonRole || hasAriaPressed || hasAriaExpanded;
|
|
61
|
-
|
|
62
|
-
const className = typeof btn.className === 'string' ? btn.className : btn.className.baseVal || '';
|
|
63
|
-
|
|
64
|
-
const defaultState = extractState(btn);
|
|
65
|
-
const states = { default: defaultState, hover: null, active: null, focus: null };
|
|
11
|
+
const results = [];
|
|
66
12
|
|
|
13
|
+
for (const el of candidates) {
|
|
67
14
|
try {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
15
|
+
const computed = getComputedStyle(el);
|
|
16
|
+
const rect = el.getBoundingClientRect();
|
|
17
|
+
|
|
18
|
+
if (rect.width === 0 || rect.height === 0) continue;
|
|
19
|
+
if (computed.display === 'none' || computed.visibility === 'hidden') continue;
|
|
20
|
+
|
|
21
|
+
// Skip nav/menu context elements
|
|
22
|
+
const role = el.getAttribute('role');
|
|
23
|
+
if (['tab', 'menuitem', 'option', 'switch', 'treeitem'].includes(role)) continue;
|
|
24
|
+
if (el.closest('[role="tablist"], [role="menu"], [role="menubar"], nav, footer')) continue;
|
|
25
|
+
|
|
26
|
+
// Must have a visible background or border — not just inherited
|
|
27
|
+
const bg = computed.backgroundColor;
|
|
28
|
+
const borderWidth = parseFloat(computed.borderWidth);
|
|
29
|
+
const hasBackground = !isTransparent(bg);
|
|
30
|
+
const hasBorder = borderWidth > 0 && !isTransparent(computed.borderColor);
|
|
31
|
+
|
|
32
|
+
if (!hasBackground && !hasBorder) continue;
|
|
33
|
+
|
|
34
|
+
// Size sanity: real buttons aren't huge or tiny
|
|
35
|
+
if (rect.height < 24 || rect.height > 100) continue;
|
|
36
|
+
if (rect.width < 40 || rect.width > 600) continue;
|
|
37
|
+
|
|
38
|
+
// Prefer above-the-fold
|
|
39
|
+
const aboveFold = rect.top < window.innerHeight;
|
|
40
|
+
|
|
41
|
+
// Score by prominence
|
|
42
|
+
let score = 0;
|
|
43
|
+
if (el.tagName === 'BUTTON') score += 30;
|
|
44
|
+
if (role === 'button') score += 20;
|
|
45
|
+
if (hasBackground) score += 20;
|
|
46
|
+
if (hasBorder && !hasBackground) score += 10;
|
|
47
|
+
if (aboveFold) score += 15;
|
|
48
|
+
if (rect.top < 300) score += 10;
|
|
49
|
+
|
|
50
|
+
// Skip buttons with no visible text
|
|
51
|
+
const text = el.textContent?.trim().replace(/\s+/g, ' ') || '';
|
|
52
|
+
if (text.length === 0) continue;
|
|
53
|
+
|
|
54
|
+
// Skip fully transparent backgrounds with no border (ghost-only inherited bg)
|
|
55
|
+
if (isTransparent(bg) && !hasBorder) continue;
|
|
56
|
+
|
|
57
|
+
results.push({
|
|
58
|
+
el,
|
|
59
|
+
score,
|
|
60
|
+
state: {
|
|
61
|
+
backgroundColor: bg,
|
|
62
|
+
color: computed.color,
|
|
63
|
+
padding: computed.padding,
|
|
64
|
+
borderRadius: computed.borderRadius,
|
|
65
|
+
border: `${computed.borderWidth} ${computed.borderStyle} ${computed.borderColor}`,
|
|
66
|
+
boxShadow: computed.boxShadow !== 'none' ? computed.boxShadow : undefined,
|
|
67
|
+
fontSize: computed.fontSize,
|
|
68
|
+
fontWeight: computed.fontWeight,
|
|
69
|
+
},
|
|
70
|
+
text: text.slice(0, 40),
|
|
71
|
+
});
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
113
74
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
fontWeight: computed.fontWeight,
|
|
117
|
-
fontSize: computed.fontSize,
|
|
118
|
-
classes: className.substring(0, 50),
|
|
119
|
-
confidence: isHighConfidence ? "high" : "medium",
|
|
120
|
-
});
|
|
121
|
-
});
|
|
75
|
+
// Sort by score, deduplicate by visual fingerprint
|
|
76
|
+
results.sort((a, b) => b.score - a.score);
|
|
122
77
|
|
|
123
|
-
const uniqueButtons = [];
|
|
124
78
|
const seen = new Set();
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
79
|
+
const unique = [];
|
|
80
|
+
for (const r of results) {
|
|
81
|
+
const s = r.state;
|
|
82
|
+
const key = `${s.backgroundColor}|${s.color}|${s.borderRadius}|${s.border}`;
|
|
128
83
|
if (!seen.has(key)) {
|
|
129
84
|
seen.add(key);
|
|
130
|
-
|
|
85
|
+
unique.push({
|
|
86
|
+
states: { default: s },
|
|
87
|
+
fontWeight: s.fontWeight,
|
|
88
|
+
fontSize: s.fontSize,
|
|
89
|
+
text: r.text,
|
|
90
|
+
confidence: r.score >= 40 ? 'high' : 'medium',
|
|
91
|
+
});
|
|
131
92
|
}
|
|
93
|
+
if (unique.length >= 8) break;
|
|
132
94
|
}
|
|
133
95
|
|
|
134
|
-
return
|
|
96
|
+
return unique;
|
|
135
97
|
});
|
|
136
98
|
}
|
|
137
99
|
|
package/lib/extractors/index.js
CHANGED
|
@@ -169,7 +169,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
169
169
|
|
|
170
170
|
spinner.start("Analyzing design system (16 parallel tasks)...");
|
|
171
171
|
const [
|
|
172
|
-
{ logo, favicons },
|
|
172
|
+
{ logo, instances: logoInstances, favicons },
|
|
173
173
|
colors,
|
|
174
174
|
typography,
|
|
175
175
|
spacing,
|
|
@@ -438,6 +438,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
438
438
|
extractedAt: new Date().toISOString(),
|
|
439
439
|
siteName,
|
|
440
440
|
logo,
|
|
441
|
+
logoInstances,
|
|
441
442
|
favicons,
|
|
442
443
|
colors,
|
|
443
444
|
typography,
|
package/lib/extractors/logo.js
CHANGED
|
@@ -32,216 +32,371 @@ export async function extractSiteName(page) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export async function extractLogo(page, url) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
(el.id || "") +
|
|
46
|
-
" " +
|
|
47
|
-
(el.getAttribute("alt") || "")
|
|
48
|
-
).toLowerCase();
|
|
49
|
-
|
|
50
|
-
if (attrs.includes("logo") || attrs.includes("brand")) return true;
|
|
51
|
-
|
|
52
|
-
if (el.tagName === "svg" || el.tagName === "SVG") {
|
|
53
|
-
const useElements = el.querySelectorAll("use");
|
|
54
|
-
for (const use of useElements) {
|
|
55
|
-
const href =
|
|
56
|
-
use.getAttribute("href") || use.getAttribute("xlink:href") || "";
|
|
57
|
-
if (
|
|
58
|
-
href.toLowerCase().includes("logo") ||
|
|
59
|
-
href.toLowerCase().includes("brand")
|
|
60
|
-
) {
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const inHeader = el.closest('header, nav, [role="banner"], [class*="header"], [class*="Header"], [id*="header"]');
|
|
67
|
-
if (inHeader) {
|
|
68
|
-
const parentLink = el.closest('a');
|
|
69
|
-
if (parentLink) {
|
|
70
|
-
const href = parentLink.getAttribute('href') || '';
|
|
71
|
-
const ariaLabel = (parentLink.getAttribute('aria-label') || '').toLowerCase();
|
|
72
|
-
if (href === '/' || href === baseUrl || href === baseUrl + '/' ||
|
|
73
|
-
href.match(/^https?:\/\/[^/]+\/?$/) ||
|
|
74
|
-
href.match(/^https?:\/\/[^/]+\/[a-z]{2}(-[a-z]{2})?\/?$/) ||
|
|
75
|
-
ariaLabel.includes('homepage') || ariaLabel.includes('home page')) {
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
35
|
+
// Extract manifest.json for PWA icons
|
|
36
|
+
const manifestIcons = await page.evaluate((baseUrl) => {
|
|
37
|
+
try {
|
|
38
|
+
const manifestLink = document.querySelector('link[rel="manifest"]');
|
|
39
|
+
if (!manifestLink) return [];
|
|
40
|
+
return [{ manifestUrl: new URL(manifestLink.getAttribute('href'), baseUrl).href }];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}, url);
|
|
80
45
|
|
|
81
|
-
|
|
46
|
+
let pwaIcons = [];
|
|
47
|
+
if (manifestIcons.length > 0) {
|
|
48
|
+
try {
|
|
49
|
+
const manifestUrl = manifestIcons[0].manifestUrl;
|
|
50
|
+
const response = await page.evaluate(async (mUrl) => {
|
|
51
|
+
try {
|
|
52
|
+
const r = await fetch(mUrl);
|
|
53
|
+
if (!r.ok) return null;
|
|
54
|
+
return await r.json();
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
}, manifestUrl);
|
|
57
|
+
|
|
58
|
+
if (response?.icons) {
|
|
59
|
+
pwaIcons = response.icons.map(icon => ({
|
|
60
|
+
type: 'pwa',
|
|
61
|
+
url: new URL(icon.src, url).href,
|
|
62
|
+
sizes: icon.sizes || null,
|
|
63
|
+
purpose: icon.purpose || 'any',
|
|
64
|
+
}));
|
|
82
65
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
let logoData = null;
|
|
86
|
-
if (candidates.length > 0) {
|
|
87
|
-
const siteDomain = new URL(baseUrl).hostname.replace('www.', '').split('.')[0].toLowerCase();
|
|
88
|
-
|
|
89
|
-
const scored = candidates.map(el => {
|
|
90
|
-
let score = 0;
|
|
91
|
-
const rect = el.getBoundingClientRect();
|
|
92
|
-
const parentLink = el.closest('a');
|
|
93
|
-
const linkHref = parentLink?.getAttribute('href') || '';
|
|
94
|
-
const imgSrc = el.tagName === 'IMG' ? (el.src || '') : '';
|
|
95
|
-
const altText = (el.getAttribute('alt') || '').toLowerCase();
|
|
96
|
-
const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
|
|
97
|
-
|
|
98
|
-
const inHeader = el.closest('header, nav, [role="banner"], [class*="header"], [class*="nav"], [id*="header"], [id*="nav"]');
|
|
99
|
-
if (inHeader) score += 50;
|
|
100
|
-
|
|
101
|
-
if (imgSrc.toLowerCase().includes(siteDomain) || altText.includes(siteDomain) || className.includes(siteDomain)) {
|
|
102
|
-
score += 40;
|
|
103
|
-
}
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
104
68
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (href === '/' || href === baseUrl || href === baseUrl + '/' || href.endsWith('://' + new URL(baseUrl).hostname + '/') || href.endsWith('://' + new URL(baseUrl).hostname)) {
|
|
108
|
-
score += 30;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
69
|
+
const result = await page.evaluate((baseUrl) => {
|
|
70
|
+
const siteDomain = new URL(baseUrl).hostname.replace('www.', '').split('.')[0].toLowerCase();
|
|
111
71
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
72
|
+
// Canvas for background color detection
|
|
73
|
+
const canvas = document.createElement('canvas');
|
|
74
|
+
canvas.width = canvas.height = 1;
|
|
75
|
+
const ctx = canvas.getContext('2d');
|
|
76
|
+
|
|
77
|
+
function toHex(color) {
|
|
78
|
+
if (!color || color === 'transparent') return null;
|
|
79
|
+
try {
|
|
80
|
+
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
81
|
+
if (m) {
|
|
82
|
+
if (m[4] !== undefined && parseFloat(m[4]) < 0.1) return null;
|
|
83
|
+
return `#${parseInt(m[1]).toString(16).padStart(2,'0')}${parseInt(m[2]).toString(16).padStart(2,'0')}${parseInt(m[3]).toString(16).padStart(2,'0')}`;
|
|
84
|
+
}
|
|
85
|
+
if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase();
|
|
86
|
+
if (!ctx) return null;
|
|
87
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
88
|
+
ctx.fillStyle = 'rgba(0,0,0,0)';
|
|
89
|
+
ctx.fillStyle = color;
|
|
90
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
91
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
92
|
+
if (a < 25) return null;
|
|
93
|
+
return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
|
94
|
+
} catch { return null; }
|
|
95
|
+
}
|
|
115
96
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
97
|
+
function findBgColor(el) {
|
|
98
|
+
let node = el;
|
|
99
|
+
while (node && node.tagName !== 'HTML') {
|
|
100
|
+
try {
|
|
101
|
+
const bg = toHex(getComputedStyle(node).backgroundColor);
|
|
102
|
+
if (bg) return bg;
|
|
103
|
+
} catch {}
|
|
104
|
+
node = node.parentElement;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
120
108
|
|
|
121
|
-
|
|
122
|
-
|
|
109
|
+
function isLight(hex) {
|
|
110
|
+
if (!hex) return true;
|
|
111
|
+
const r = parseInt(hex.slice(1,3), 16);
|
|
112
|
+
const g = parseInt(hex.slice(3,5), 16);
|
|
113
|
+
const b = parseInt(hex.slice(5,7), 16);
|
|
114
|
+
return (0.299*r + 0.587*g + 0.114*b) / 255 > 0.5;
|
|
115
|
+
}
|
|
123
116
|
|
|
124
|
-
|
|
117
|
+
function detectLogoType(el, altText) {
|
|
118
|
+
const text = (altText || '').toLowerCase().trim();
|
|
119
|
+
const hasText = text.length > 0 && !/^logo$|^brand$|^icon$/i.test(text);
|
|
120
|
+
const rect = el.getBoundingClientRect();
|
|
121
|
+
const ratio = rect.width / (rect.height || 1);
|
|
122
|
+
|
|
123
|
+
// Wide element with text in alt = wordmark
|
|
124
|
+
if (hasText && ratio > 3) return 'wordmark';
|
|
125
|
+
// Squarish = logomark/icon
|
|
126
|
+
if (ratio < 1.5 && ratio > 0.5) return 'logomark';
|
|
127
|
+
// Wide with no meaningful alt = likely combination or wordmark
|
|
128
|
+
if (ratio > 2) return 'wordmark';
|
|
129
|
+
return 'combination';
|
|
130
|
+
}
|
|
125
131
|
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
function scoreLogo(el, context) {
|
|
133
|
+
let score = 0;
|
|
134
|
+
const rect = el.getBoundingClientRect();
|
|
135
|
+
const s = getComputedStyle(el);
|
|
136
|
+
const parentLink = el.closest('a');
|
|
137
|
+
const linkHref = parentLink?.getAttribute('href') || '';
|
|
138
|
+
const imgSrc = el.tagName === 'IMG' ? (el.getAttribute('src') || '') : '';
|
|
139
|
+
const altText = (el.getAttribute('alt') || '').toLowerCase();
|
|
140
|
+
const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
|
|
141
|
+
|
|
142
|
+
if (context === 'header') score += 50;
|
|
143
|
+
if (context === 'footer') score += 20;
|
|
144
|
+
if (context === 'hero') score += 15;
|
|
145
|
+
|
|
146
|
+
if (imgSrc.toLowerCase().includes(siteDomain) || altText.includes(siteDomain) || className.includes(siteDomain)) score += 40;
|
|
147
|
+
if (className.includes('logo') || el.id?.toLowerCase().includes('logo')) score += 30;
|
|
148
|
+
|
|
149
|
+
if (parentLink) {
|
|
150
|
+
const href = linkHref.toLowerCase();
|
|
151
|
+
if (href === '/' || href === baseUrl || href.endsWith('://' + new URL(baseUrl).hostname + '/') || href.endsWith('://' + new URL(baseUrl).hostname)) {
|
|
152
|
+
score += 30;
|
|
128
153
|
}
|
|
154
|
+
}
|
|
129
155
|
|
|
130
|
-
|
|
131
|
-
|
|
156
|
+
if (rect.top < 200) score += 10;
|
|
157
|
+
if (rect.left < 400) score += 10;
|
|
132
158
|
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
159
|
+
const width = el.naturalWidth || el.width?.baseVal?.value || rect.width;
|
|
160
|
+
const height = el.naturalHeight || el.height?.baseVal?.value || rect.height;
|
|
161
|
+
if (width < 20 || height < 20) score -= 30;
|
|
162
|
+
if (width > 600 || height > 400) score -= 40;
|
|
163
|
+
if (altText.length > 50) score -= 30;
|
|
164
|
+
if (width > height && width < 400 && width > 40 && height > 10 && height < 120) score += 15;
|
|
165
|
+
|
|
166
|
+
return score;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function extractLogoFromEl(el, context, baseUrl) {
|
|
170
|
+
const rect = el.getBoundingClientRect();
|
|
171
|
+
const computed = getComputedStyle(el);
|
|
172
|
+
const parent = el.parentElement;
|
|
173
|
+
const parentComputed = parent ? getComputedStyle(parent) : null;
|
|
174
|
+
const parentLink = el.closest('a');
|
|
175
|
+
const bg = findBgColor(el);
|
|
176
|
+
const altText = el.getAttribute('alt') || '';
|
|
138
177
|
|
|
139
178
|
const safeZone = {
|
|
140
|
-
top:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
parseFloat(computed.marginRight) +
|
|
145
|
-
(parentComputed ? parseFloat(parentComputed.paddingRight) : 0),
|
|
146
|
-
bottom:
|
|
147
|
-
parseFloat(computed.marginBottom) +
|
|
148
|
-
(parentComputed ? parseFloat(parentComputed.paddingBottom) : 0),
|
|
149
|
-
left:
|
|
150
|
-
parseFloat(computed.marginLeft) +
|
|
151
|
-
(parentComputed ? parseFloat(parentComputed.paddingLeft) : 0),
|
|
179
|
+
top: parseFloat(computed.marginTop) + (parentComputed ? parseFloat(parentComputed.paddingTop) : 0),
|
|
180
|
+
right: parseFloat(computed.marginRight) + (parentComputed ? parseFloat(parentComputed.paddingRight) : 0),
|
|
181
|
+
bottom: parseFloat(computed.marginBottom) + (parentComputed ? parseFloat(parentComputed.paddingBottom) : 0),
|
|
182
|
+
left: parseFloat(computed.marginLeft) + (parentComputed ? parseFloat(parentComputed.paddingLeft) : 0),
|
|
152
183
|
};
|
|
153
184
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
185
|
+
const logoType = detectLogoType(el, altText);
|
|
186
|
+
const reversed = bg ? !isLight(bg) : false;
|
|
187
|
+
|
|
188
|
+
if (el.tagName === 'IMG') {
|
|
189
|
+
// Handle picture element -- prefer highest-res source
|
|
190
|
+
const picture = el.closest('picture');
|
|
191
|
+
let src = el.src;
|
|
192
|
+
if (picture) {
|
|
193
|
+
const sources = picture.querySelectorAll('source');
|
|
194
|
+
for (const source of sources) {
|
|
195
|
+
const srcset = source.getAttribute('srcset');
|
|
196
|
+
if (srcset) {
|
|
197
|
+
const best = srcset.split(',').map(s => s.trim().split(/\s+/)).sort((a, b) => {
|
|
198
|
+
const wa = parseFloat(a[1]) || 0;
|
|
199
|
+
const wb = parseFloat(b[1]) || 0;
|
|
200
|
+
return wb - wa;
|
|
201
|
+
})[0];
|
|
202
|
+
if (best?.[0]) { src = new URL(best[0], baseUrl).href; break; }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
161
205
|
}
|
|
162
|
-
walker = walker.parentElement;
|
|
163
|
-
}
|
|
164
206
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
url: new URL(
|
|
169
|
-
width:
|
|
170
|
-
height:
|
|
171
|
-
alt:
|
|
207
|
+
return {
|
|
208
|
+
source: 'img',
|
|
209
|
+
context,
|
|
210
|
+
url: new URL(src, baseUrl).href,
|
|
211
|
+
width: el.naturalWidth || rect.width,
|
|
212
|
+
height: el.naturalHeight || rect.height,
|
|
213
|
+
alt: altText,
|
|
214
|
+
type: logoType,
|
|
215
|
+
reversed,
|
|
216
|
+
background: bg,
|
|
172
217
|
safeZone,
|
|
173
|
-
|
|
218
|
+
position: { top: rect.top, left: rect.left },
|
|
174
219
|
};
|
|
175
|
-
} else {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
url: parentLink ? parentLink.href :
|
|
180
|
-
width:
|
|
181
|
-
height:
|
|
220
|
+
} else if (el.tagName === 'SVG' || el.tagName === 'svg') {
|
|
221
|
+
return {
|
|
222
|
+
source: 'svg',
|
|
223
|
+
context,
|
|
224
|
+
url: parentLink ? parentLink.href : baseUrl,
|
|
225
|
+
width: el.width?.baseVal?.value || rect.width,
|
|
226
|
+
height: el.height?.baseVal?.value || rect.height,
|
|
227
|
+
type: logoType,
|
|
228
|
+
reversed,
|
|
229
|
+
background: bg,
|
|
182
230
|
safeZone,
|
|
183
|
-
|
|
231
|
+
position: { top: rect.top, left: rect.left },
|
|
184
232
|
};
|
|
185
233
|
}
|
|
234
|
+
return null;
|
|
186
235
|
}
|
|
187
236
|
|
|
188
|
-
|
|
237
|
+
function findLogosInZone(container, context) {
|
|
238
|
+
if (!container) return [];
|
|
239
|
+
const candidates = [];
|
|
189
240
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
241
|
+
// img and svg
|
|
242
|
+
container.querySelectorAll('img, svg').forEach(el => {
|
|
243
|
+
try {
|
|
244
|
+
const s = getComputedStyle(el);
|
|
245
|
+
if (s.display === 'none' || s.visibility === 'hidden') return;
|
|
246
|
+
const rect = el.getBoundingClientRect();
|
|
247
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
248
|
+
|
|
249
|
+
const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
|
|
250
|
+
const attrs = (className + ' ' + (el.id || '') + ' ' + (el.getAttribute('alt') || '')).toLowerCase();
|
|
251
|
+
|
|
252
|
+
let qualifies = attrs.includes('logo') || attrs.includes('brand');
|
|
253
|
+
|
|
254
|
+
if (!qualifies && el.tagName === 'svg') {
|
|
255
|
+
const useEls = el.querySelectorAll('use');
|
|
256
|
+
for (const use of useEls) {
|
|
257
|
+
const href = use.getAttribute('href') || use.getAttribute('xlink:href') || '';
|
|
258
|
+
if (href.toLowerCase().includes('logo') || href.toLowerCase().includes('brand')) { qualifies = true; break; }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!qualifies) {
|
|
263
|
+
const parentLink = el.closest('a');
|
|
264
|
+
if (parentLink) {
|
|
265
|
+
const href = (parentLink.getAttribute('href') || '').toLowerCase();
|
|
266
|
+
const ariaLabel = (parentLink.getAttribute('aria-label') || '').toLowerCase();
|
|
267
|
+
if (href === '/' || href.match(/^https?:\/\/[^/]+\/?$/) || ariaLabel.includes('home')) qualifies = true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (qualifies) {
|
|
272
|
+
const score = scoreLogo(el, context);
|
|
273
|
+
candidates.push({ el, score, context });
|
|
274
|
+
}
|
|
275
|
+
} catch {}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// CSS background-image logos
|
|
279
|
+
container.querySelectorAll('a, [class*="logo"], [id*="logo"], header > *, nav > *').forEach(el => {
|
|
280
|
+
try {
|
|
281
|
+
const s = getComputedStyle(el);
|
|
282
|
+
const bg = s.backgroundImage;
|
|
283
|
+
if (!bg || bg === 'none') return;
|
|
284
|
+
const urlMatch = bg.match(/url\(["']?([^"')]+)["']?\)/);
|
|
285
|
+
if (!urlMatch) return;
|
|
286
|
+
const imgUrl = urlMatch[1];
|
|
287
|
+
if (!/\.(svg|png|webp|gif)(\?|$)/i.test(imgUrl) && !imgUrl.includes('logo') && !imgUrl.includes('brand')) return;
|
|
288
|
+
const rect = el.getBoundingClientRect();
|
|
289
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
290
|
+
|
|
291
|
+
candidates.push({
|
|
292
|
+
el: null,
|
|
293
|
+
score: scoreLogo(el, context) + 10,
|
|
294
|
+
context,
|
|
295
|
+
cssBackground: {
|
|
296
|
+
source: 'css-background',
|
|
297
|
+
context,
|
|
298
|
+
url: new URL(imgUrl, window.location.href).href,
|
|
299
|
+
width: rect.width,
|
|
300
|
+
height: rect.height,
|
|
301
|
+
type: rect.width / rect.height > 2 ? 'wordmark' : 'logomark',
|
|
302
|
+
reversed: !isLight(toHex(s.backgroundColor)),
|
|
303
|
+
background: toHex(s.backgroundColor),
|
|
304
|
+
safeZone: { top: parseFloat(s.paddingTop), right: parseFloat(s.paddingRight), bottom: parseFloat(s.paddingBottom), left: parseFloat(s.paddingLeft) },
|
|
305
|
+
position: { top: rect.top, left: rect.left },
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
} catch {}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return candidates;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const headerEl = document.querySelector('header, [role="banner"], [class*="header"], [id*="header"]');
|
|
315
|
+
const navEl = document.querySelector('nav, [role="navigation"]');
|
|
316
|
+
const footerEl = document.querySelector('footer, [role="contentinfo"], [class*="footer"], [id*="footer"]');
|
|
317
|
+
|
|
318
|
+
// Hero: first large section that's not header/footer
|
|
319
|
+
const heroEl = (() => {
|
|
320
|
+
const sections = document.querySelectorAll('main > *:first-child, [class*="hero"], [class*="Hero"], [class*="banner"]:not([role="banner"])');
|
|
321
|
+
for (const s of sections) {
|
|
322
|
+
const rect = s.getBoundingClientRect();
|
|
323
|
+
if (rect.height > 200) return s;
|
|
198
324
|
}
|
|
199
|
-
|
|
325
|
+
return null;
|
|
326
|
+
})();
|
|
327
|
+
|
|
328
|
+
// Deduplicate zones — nav might be inside header, avoid double-scanning
|
|
329
|
+
const headerCandidates = findLogosInZone(headerEl, 'header');
|
|
330
|
+
const navCandidates = (navEl && !headerEl?.contains(navEl))
|
|
331
|
+
? findLogosInZone(navEl, 'header') // same score weight as header
|
|
332
|
+
: [];
|
|
333
|
+
|
|
334
|
+
const allCandidates = [
|
|
335
|
+
...headerCandidates,
|
|
336
|
+
...navCandidates,
|
|
337
|
+
...findLogosInZone(footerEl, 'footer'),
|
|
338
|
+
...findLogosInZone(heroEl, 'hero'),
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
allCandidates.sort((a, b) => b.score - a.score);
|
|
342
|
+
|
|
343
|
+
// Primary logo = highest scoring
|
|
344
|
+
const primary = allCandidates[0];
|
|
345
|
+
let primaryLogo = null;
|
|
346
|
+
if (primary) {
|
|
347
|
+
if (primary.cssBackground) {
|
|
348
|
+
primaryLogo = primary.cssBackground;
|
|
349
|
+
} else if (primary.el) {
|
|
350
|
+
primaryLogo = extractLogoFromEl(primary.el, primary.context, baseUrl);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Collect all unique instances (header, footer, hero)
|
|
355
|
+
const instances = [];
|
|
356
|
+
const seenContexts = new Set();
|
|
357
|
+
for (const c of allCandidates) {
|
|
358
|
+
if (seenContexts.has(c.context)) continue;
|
|
359
|
+
seenContexts.add(c.context);
|
|
360
|
+
let inst = null;
|
|
361
|
+
if (c.cssBackground) inst = c.cssBackground;
|
|
362
|
+
else if (c.el) inst = extractLogoFromEl(c.el, c.context, baseUrl);
|
|
363
|
+
if (inst) instances.push(inst);
|
|
364
|
+
}
|
|
200
365
|
|
|
201
|
-
|
|
202
|
-
|
|
366
|
+
// Favicons
|
|
367
|
+
const favicons = [];
|
|
368
|
+
document.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"]').forEach(link => {
|
|
369
|
+
const href = link.getAttribute('href');
|
|
203
370
|
if (href) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
371
|
+
try {
|
|
372
|
+
favicons.push({
|
|
373
|
+
type: link.getAttribute('rel'),
|
|
374
|
+
url: new URL(href, baseUrl).href,
|
|
375
|
+
sizes: link.getAttribute('sizes') || null,
|
|
376
|
+
});
|
|
377
|
+
} catch {}
|
|
209
378
|
}
|
|
210
379
|
});
|
|
211
380
|
|
|
212
381
|
const ogImage = document.querySelector('meta[property="og:image"]');
|
|
213
|
-
if (ogImage) {
|
|
214
|
-
|
|
215
|
-
if (content) {
|
|
216
|
-
favicons.push({
|
|
217
|
-
type: "og:image",
|
|
218
|
-
url: new URL(content, baseUrl).href,
|
|
219
|
-
sizes: null,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
382
|
+
if (ogImage?.getAttribute('content')) {
|
|
383
|
+
try { favicons.push({ type: 'og:image', url: new URL(ogImage.getAttribute('content'), baseUrl).href, sizes: null }); } catch {}
|
|
222
384
|
}
|
|
223
385
|
|
|
224
386
|
const twitterImage = document.querySelector('meta[name="twitter:image"]');
|
|
225
|
-
if (twitterImage) {
|
|
226
|
-
|
|
227
|
-
if (content) {
|
|
228
|
-
favicons.push({
|
|
229
|
-
type: "twitter:image",
|
|
230
|
-
url: new URL(content, baseUrl).href,
|
|
231
|
-
sizes: null,
|
|
232
|
-
});
|
|
233
|
-
}
|
|
387
|
+
if (twitterImage?.getAttribute('content')) {
|
|
388
|
+
try { favicons.push({ type: 'twitter:image', url: new URL(twitterImage.getAttribute('content'), baseUrl).href, sizes: null }); } catch {}
|
|
234
389
|
}
|
|
235
390
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
favicons.push({
|
|
239
|
-
type: "favicon.ico",
|
|
240
|
-
url: new URL("/favicon.ico", baseUrl).href,
|
|
241
|
-
sizes: null,
|
|
242
|
-
});
|
|
391
|
+
if (!favicons.some(f => f.url.endsWith('/favicon.ico'))) {
|
|
392
|
+
favicons.push({ type: 'favicon.ico', url: new URL('/favicon.ico', baseUrl).href, sizes: null });
|
|
243
393
|
}
|
|
244
394
|
|
|
245
|
-
return { logo:
|
|
395
|
+
return { logo: primaryLogo, instances, favicons };
|
|
246
396
|
}, url);
|
|
397
|
+
|
|
398
|
+
// Merge PWA icons into favicons
|
|
399
|
+
result.favicons = [...result.favicons, ...pwaIcons];
|
|
400
|
+
|
|
401
|
+
return result;
|
|
247
402
|
}
|