dembrandt 0.12.1 → 0.12.3
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 +357 -171
- 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,402 @@ 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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
69
|
+
const result = await page.evaluate((baseUrl) => {
|
|
70
|
+
const siteDomain = new URL(baseUrl).hostname.replace('www.', '').split('.')[0].toLowerCase();
|
|
71
|
+
|
|
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')}`;
|
|
110
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
|
+
}
|
|
111
96
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
}
|
|
115
108
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
}
|
|
120
116
|
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
}
|
|
123
131
|
|
|
124
|
-
|
|
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
|
+
// SVG with aria-label="Homepage" directly in a home anchor — strong signal
|
|
150
|
+
if (el.tagName === 'svg' || el.tagName === 'SVG') {
|
|
151
|
+
const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
|
|
152
|
+
if (ariaLabel.includes('home') || ariaLabel.includes('logo')) score += 40;
|
|
153
|
+
}
|
|
125
154
|
|
|
126
|
-
|
|
127
|
-
|
|
155
|
+
if (parentLink) {
|
|
156
|
+
const href = linkHref.toLowerCase();
|
|
157
|
+
if (href === '/' || href === baseUrl || href.endsWith('://' + new URL(baseUrl).hostname + '/') || href.endsWith('://' + new URL(baseUrl).hostname)) {
|
|
158
|
+
score += 30;
|
|
128
159
|
}
|
|
160
|
+
}
|
|
129
161
|
|
|
130
|
-
|
|
131
|
-
|
|
162
|
+
if (rect.top < 200) score += 10;
|
|
163
|
+
if (rect.left < 400) score += 10;
|
|
164
|
+
|
|
165
|
+
// Penalize images hosted on a different domain (CDN paths on same domain are fine)
|
|
166
|
+
if (el.tagName === 'IMG') {
|
|
167
|
+
try {
|
|
168
|
+
const srcHost = new URL(el.src).hostname.replace('www.', '')
|
|
169
|
+
const pageHost = new URL(baseUrl).hostname.replace('www.', '')
|
|
170
|
+
// If neither is a subdomain/CDN of the other, it's third-party
|
|
171
|
+
if (!srcHost.endsWith(pageHost) && !pageHost.endsWith(srcHost)) score -= 60
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
132
174
|
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
175
|
+
// For SVGs use rendered rect — baseVal reflects viewBox coordinates, not display size
|
|
176
|
+
const width = el.tagName === 'IMG' ? (el.naturalWidth || rect.width) : rect.width;
|
|
177
|
+
const height = el.tagName === 'IMG' ? (el.naturalHeight || rect.height) : rect.height;
|
|
178
|
+
if (width < 20 || height < 20) score -= 30;
|
|
179
|
+
if (width > 600 || height > 400) score -= 40;
|
|
180
|
+
if (altText.length > 50) score -= 30;
|
|
181
|
+
if (width > height && width < 400 && width > 40 && height > 10 && height < 120) score += 15;
|
|
182
|
+
|
|
183
|
+
return score;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractLogoFromEl(el, context, baseUrl) {
|
|
187
|
+
const rect = el.getBoundingClientRect();
|
|
188
|
+
const computed = getComputedStyle(el);
|
|
189
|
+
const parent = el.parentElement;
|
|
190
|
+
const parentComputed = parent ? getComputedStyle(parent) : null;
|
|
191
|
+
const parentLink = el.closest('a');
|
|
192
|
+
const bg = findBgColor(el);
|
|
193
|
+
const altText = el.getAttribute('alt') || '';
|
|
138
194
|
|
|
139
195
|
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),
|
|
196
|
+
top: parseFloat(computed.marginTop) + (parentComputed ? parseFloat(parentComputed.paddingTop) : 0),
|
|
197
|
+
right: parseFloat(computed.marginRight) + (parentComputed ? parseFloat(parentComputed.paddingRight) : 0),
|
|
198
|
+
bottom: parseFloat(computed.marginBottom) + (parentComputed ? parseFloat(parentComputed.paddingBottom) : 0),
|
|
199
|
+
left: parseFloat(computed.marginLeft) + (parentComputed ? parseFloat(parentComputed.paddingLeft) : 0),
|
|
152
200
|
};
|
|
153
201
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
202
|
+
const logoType = detectLogoType(el, altText);
|
|
203
|
+
const reversed = bg ? !isLight(bg) : false;
|
|
204
|
+
|
|
205
|
+
if (el.tagName === 'IMG') {
|
|
206
|
+
// Handle picture element -- prefer highest-res source
|
|
207
|
+
const picture = el.closest('picture');
|
|
208
|
+
let src = el.src;
|
|
209
|
+
if (picture) {
|
|
210
|
+
const sources = picture.querySelectorAll('source');
|
|
211
|
+
for (const source of sources) {
|
|
212
|
+
const srcset = source.getAttribute('srcset');
|
|
213
|
+
if (srcset) {
|
|
214
|
+
const best = srcset.split(',').map(s => s.trim().split(/\s+/)).sort((a, b) => {
|
|
215
|
+
const wa = parseFloat(a[1]) || 0;
|
|
216
|
+
const wb = parseFloat(b[1]) || 0;
|
|
217
|
+
return wb - wa;
|
|
218
|
+
})[0];
|
|
219
|
+
if (best?.[0]) { src = new URL(best[0], baseUrl).href; break; }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
161
222
|
}
|
|
162
|
-
walker = walker.parentElement;
|
|
163
|
-
}
|
|
164
223
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
url: new URL(
|
|
169
|
-
width:
|
|
170
|
-
height:
|
|
171
|
-
alt:
|
|
224
|
+
return {
|
|
225
|
+
source: 'img',
|
|
226
|
+
context,
|
|
227
|
+
url: new URL(src, baseUrl).href,
|
|
228
|
+
width: el.naturalWidth || rect.width,
|
|
229
|
+
height: el.naturalHeight || rect.height,
|
|
230
|
+
alt: altText,
|
|
231
|
+
type: logoType,
|
|
232
|
+
reversed,
|
|
233
|
+
background: bg,
|
|
172
234
|
safeZone,
|
|
173
|
-
|
|
235
|
+
position: { top: rect.top, left: rect.left },
|
|
174
236
|
};
|
|
175
|
-
} else {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
url: parentLink ? parentLink.href :
|
|
180
|
-
width:
|
|
181
|
-
height:
|
|
237
|
+
} else if (el.tagName === 'SVG' || el.tagName === 'svg') {
|
|
238
|
+
return {
|
|
239
|
+
source: 'svg',
|
|
240
|
+
context,
|
|
241
|
+
url: parentLink ? parentLink.href : baseUrl,
|
|
242
|
+
width: el.width?.baseVal?.value || rect.width,
|
|
243
|
+
height: el.height?.baseVal?.value || rect.height,
|
|
244
|
+
type: logoType,
|
|
245
|
+
reversed,
|
|
246
|
+
background: bg,
|
|
182
247
|
safeZone,
|
|
183
|
-
|
|
248
|
+
position: { top: rect.top, left: rect.left },
|
|
184
249
|
};
|
|
185
250
|
}
|
|
251
|
+
return null;
|
|
186
252
|
}
|
|
187
253
|
|
|
188
|
-
|
|
254
|
+
function findLogosInZone(container, context) {
|
|
255
|
+
if (!container) return [];
|
|
256
|
+
const candidates = [];
|
|
257
|
+
|
|
258
|
+
// img and svg
|
|
259
|
+
container.querySelectorAll('img, svg').forEach(el => {
|
|
260
|
+
try {
|
|
261
|
+
const s = getComputedStyle(el);
|
|
262
|
+
if (s.display === 'none' || s.visibility === 'hidden') return;
|
|
263
|
+
const rect = el.getBoundingClientRect();
|
|
264
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
265
|
+
|
|
266
|
+
const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
|
|
267
|
+
const altText = (el.getAttribute('alt') || '').toLowerCase();
|
|
268
|
+
const attrs = (className + ' ' + (el.id || '') + ' ' + altText).toLowerCase();
|
|
269
|
+
|
|
270
|
+
// Disqualify third-party brand logos: alt like "Notion logo" / "Perplexity logo" / "Figma" where the brand isn't our site
|
|
271
|
+
// These appear in customer/integration/testimonial sections on marketing pages.
|
|
272
|
+
const altBrandMatch = altText.match(/^([a-z][a-z0-9\-\.]{1,30}?)(?:\s+(?:logo|icon|brand|wordmark))?$/i);
|
|
273
|
+
if (altBrandMatch) {
|
|
274
|
+
const altBrand = altBrandMatch[1].replace(/[\s\-\.]/g, '').toLowerCase();
|
|
275
|
+
if (altBrand && altBrand !== 'logo' && altBrand !== 'brand' && altBrand !== 'icon' && altBrand !== siteDomain && !altBrand.includes(siteDomain) && !siteDomain.includes(altBrand)) {
|
|
276
|
+
return; // third-party brand, skip entirely
|
|
277
|
+
}
|
|
278
|
+
}
|
|
189
279
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
280
|
+
let qualifies = attrs.includes('logo') || attrs.includes('brand');
|
|
281
|
+
|
|
282
|
+
if (!qualifies && el.tagName === 'svg') {
|
|
283
|
+
const useEls = el.querySelectorAll('use');
|
|
284
|
+
for (const use of useEls) {
|
|
285
|
+
const href = use.getAttribute('href') || use.getAttribute('xlink:href') || '';
|
|
286
|
+
if (href.toLowerCase().includes('logo') || href.toLowerCase().includes('brand')) { qualifies = true; break; }
|
|
287
|
+
}
|
|
288
|
+
// aria-label="Homepage" or similar on the SVG itself
|
|
289
|
+
const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
|
|
290
|
+
if (ariaLabel.includes('home') || ariaLabel.includes('logo')) qualifies = true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!qualifies) {
|
|
294
|
+
const parentLink = el.closest('a');
|
|
295
|
+
if (parentLink) {
|
|
296
|
+
const href = (parentLink.getAttribute('href') || '').toLowerCase();
|
|
297
|
+
const ariaLabel = (parentLink.getAttribute('aria-label') || '').toLowerCase();
|
|
298
|
+
if (href === '/' || href.match(/^https?:\/\/[^/]+\/?$/) || ariaLabel.includes('home')) qualifies = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (qualifies) {
|
|
303
|
+
const score = scoreLogo(el, context);
|
|
304
|
+
candidates.push({ el, score, context });
|
|
305
|
+
}
|
|
306
|
+
} catch {}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// CSS background-image logos
|
|
310
|
+
container.querySelectorAll('a, [class*="logo"], [id*="logo"], header > *, nav > *').forEach(el => {
|
|
311
|
+
try {
|
|
312
|
+
const s = getComputedStyle(el);
|
|
313
|
+
const bg = s.backgroundImage;
|
|
314
|
+
if (!bg || bg === 'none') return;
|
|
315
|
+
const urlMatch = bg.match(/url\(["']?([^"')]+)["']?\)/);
|
|
316
|
+
if (!urlMatch) return;
|
|
317
|
+
const imgUrl = urlMatch[1];
|
|
318
|
+
if (!/\.(svg|png|webp|gif)(\?|$)/i.test(imgUrl) && !imgUrl.includes('logo') && !imgUrl.includes('brand')) return;
|
|
319
|
+
const rect = el.getBoundingClientRect();
|
|
320
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
321
|
+
|
|
322
|
+
candidates.push({
|
|
323
|
+
el: null,
|
|
324
|
+
score: scoreLogo(el, context) + 10,
|
|
325
|
+
context,
|
|
326
|
+
cssBackground: {
|
|
327
|
+
source: 'css-background',
|
|
328
|
+
context,
|
|
329
|
+
url: new URL(imgUrl, window.location.href).href,
|
|
330
|
+
width: rect.width,
|
|
331
|
+
height: rect.height,
|
|
332
|
+
type: rect.width / rect.height > 2 ? 'wordmark' : 'logomark',
|
|
333
|
+
reversed: !isLight(toHex(s.backgroundColor)),
|
|
334
|
+
background: toHex(s.backgroundColor),
|
|
335
|
+
safeZone: { top: parseFloat(s.paddingTop), right: parseFloat(s.paddingRight), bottom: parseFloat(s.paddingBottom), left: parseFloat(s.paddingLeft) },
|
|
336
|
+
position: { top: rect.top, left: rect.left },
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
} catch {}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return candidates;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const headerEl = document.querySelector('header, [role="banner"], [class*="header"], [id*="header"]');
|
|
346
|
+
const navEl = document.querySelector('nav, [role="navigation"]');
|
|
347
|
+
const footerEl = document.querySelector('footer, [role="contentinfo"], [class*="footer"], [id*="footer"]');
|
|
348
|
+
|
|
349
|
+
// Hero: first large section that's not header/footer
|
|
350
|
+
const heroEl = (() => {
|
|
351
|
+
const sections = document.querySelectorAll('main > *:first-child, [class*="hero"], [class*="Hero"], [class*="banner"]:not([role="banner"])');
|
|
352
|
+
for (const s of sections) {
|
|
353
|
+
const rect = s.getBoundingClientRect();
|
|
354
|
+
if (rect.height > 200) return s;
|
|
198
355
|
}
|
|
199
|
-
|
|
356
|
+
return null;
|
|
357
|
+
})();
|
|
358
|
+
|
|
359
|
+
// Deduplicate zones — nav might be inside header, avoid double-scanning
|
|
360
|
+
const headerCandidates = findLogosInZone(headerEl, 'header');
|
|
361
|
+
const navCandidates = (navEl && !headerEl?.contains(navEl))
|
|
362
|
+
? findLogosInZone(navEl, 'header') // same score weight as header
|
|
363
|
+
: [];
|
|
364
|
+
|
|
365
|
+
const allCandidates = [
|
|
366
|
+
...headerCandidates,
|
|
367
|
+
...navCandidates,
|
|
368
|
+
...findLogosInZone(footerEl, 'footer'),
|
|
369
|
+
...findLogosInZone(heroEl, 'hero'),
|
|
370
|
+
];
|
|
371
|
+
|
|
372
|
+
allCandidates.sort((a, b) => b.score - a.score);
|
|
373
|
+
|
|
374
|
+
// Primary logo = highest scoring
|
|
375
|
+
const primary = allCandidates[0];
|
|
376
|
+
let primaryLogo = null;
|
|
377
|
+
if (primary) {
|
|
378
|
+
if (primary.cssBackground) {
|
|
379
|
+
primaryLogo = primary.cssBackground;
|
|
380
|
+
} else if (primary.el) {
|
|
381
|
+
primaryLogo = extractLogoFromEl(primary.el, primary.context, baseUrl);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
200
384
|
|
|
201
|
-
|
|
202
|
-
|
|
385
|
+
// Collect all unique instances (header, footer, hero)
|
|
386
|
+
const instances = [];
|
|
387
|
+
const seenContexts = new Set();
|
|
388
|
+
for (const c of allCandidates) {
|
|
389
|
+
if (seenContexts.has(c.context)) continue;
|
|
390
|
+
seenContexts.add(c.context);
|
|
391
|
+
let inst = null;
|
|
392
|
+
if (c.cssBackground) inst = c.cssBackground;
|
|
393
|
+
else if (c.el) inst = extractLogoFromEl(c.el, c.context, baseUrl);
|
|
394
|
+
if (inst) instances.push(inst);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Favicons
|
|
398
|
+
const favicons = [];
|
|
399
|
+
document.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"]').forEach(link => {
|
|
400
|
+
const href = link.getAttribute('href');
|
|
203
401
|
if (href) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
402
|
+
try {
|
|
403
|
+
favicons.push({
|
|
404
|
+
type: link.getAttribute('rel'),
|
|
405
|
+
url: new URL(href, baseUrl).href,
|
|
406
|
+
sizes: link.getAttribute('sizes') || null,
|
|
407
|
+
});
|
|
408
|
+
} catch {}
|
|
209
409
|
}
|
|
210
410
|
});
|
|
211
411
|
|
|
212
412
|
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
|
-
}
|
|
413
|
+
if (ogImage?.getAttribute('content')) {
|
|
414
|
+
try { favicons.push({ type: 'og:image', url: new URL(ogImage.getAttribute('content'), baseUrl).href, sizes: null }); } catch {}
|
|
222
415
|
}
|
|
223
416
|
|
|
224
417
|
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
|
-
}
|
|
418
|
+
if (twitterImage?.getAttribute('content')) {
|
|
419
|
+
try { favicons.push({ type: 'twitter:image', url: new URL(twitterImage.getAttribute('content'), baseUrl).href, sizes: null }); } catch {}
|
|
234
420
|
}
|
|
235
421
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
favicons.push({
|
|
239
|
-
type: "favicon.ico",
|
|
240
|
-
url: new URL("/favicon.ico", baseUrl).href,
|
|
241
|
-
sizes: null,
|
|
242
|
-
});
|
|
422
|
+
if (!favicons.some(f => f.url.endsWith('/favicon.ico'))) {
|
|
423
|
+
favicons.push({ type: 'favicon.ico', url: new URL('/favicon.ico', baseUrl).href, sizes: null });
|
|
243
424
|
}
|
|
244
425
|
|
|
245
|
-
return { logo:
|
|
426
|
+
return { logo: primaryLogo, instances, favicons };
|
|
246
427
|
}, url);
|
|
428
|
+
|
|
429
|
+
// Merge PWA icons into favicons
|
|
430
|
+
result.favicons = [...result.favicons, ...pwaIcons];
|
|
431
|
+
|
|
432
|
+
return result;
|
|
247
433
|
}
|