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,249 @@
|
|
|
1
|
+
import type { LogoCandidate } from "./types"
|
|
2
|
+
|
|
3
|
+
export interface LogoSelection {
|
|
4
|
+
selectedIndex: number
|
|
5
|
+
confidence: number
|
|
6
|
+
source: "heuristic" | "fallback" | "none"
|
|
7
|
+
reasoning: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const confidence = {
|
|
11
|
+
strongScore: 60,
|
|
12
|
+
goodScore: 45,
|
|
13
|
+
moderateScore: 30,
|
|
14
|
+
strongSeparation: 20,
|
|
15
|
+
goodSeparation: 15,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function logoArea(position: LogoCandidate["position"]): number {
|
|
19
|
+
return Math.max(0, position.width || 0) * Math.max(0, position.height || 0)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function filename(src: string): string {
|
|
23
|
+
return src.split("?")[0]?.split("#")[0]?.split("/").pop() ?? src
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function detectVariants(candidates: LogoCandidate[]) {
|
|
27
|
+
const groups = new Map<number, number[]>()
|
|
28
|
+
const processed = new Set<number>()
|
|
29
|
+
|
|
30
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
31
|
+
if (processed.has(index)) continue
|
|
32
|
+
const similar = [index]
|
|
33
|
+
processed.add(index)
|
|
34
|
+
|
|
35
|
+
for (const [otherIndex, other] of candidates.entries()) {
|
|
36
|
+
if (index === otherIndex || processed.has(otherIndex)) continue
|
|
37
|
+
const sameAlt =
|
|
38
|
+
candidate.alt &&
|
|
39
|
+
other.alt &&
|
|
40
|
+
candidate.alt.toLowerCase().replace(/\s+/g, "") === other.alt.toLowerCase().replace(/\s+/g, "")
|
|
41
|
+
const sameFile = filename(candidate.src) && filename(candidate.src) === filename(other.src)
|
|
42
|
+
const samePosition =
|
|
43
|
+
Math.abs(candidate.position.top - other.position.top) < 20 &&
|
|
44
|
+
Math.abs(candidate.position.left - other.position.left) < 50 &&
|
|
45
|
+
Math.abs(candidate.position.width - other.position.width) < 30
|
|
46
|
+
|
|
47
|
+
if (candidate.src === other.src || sameAlt || sameFile || samePosition) {
|
|
48
|
+
similar.push(otherIndex)
|
|
49
|
+
processed.add(otherIndex)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
groups.set(index, similar)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return groups
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pickBestVariant(candidates: LogoCandidate[], indices: number[]): number {
|
|
60
|
+
return indices.reduce((best, current) => {
|
|
61
|
+
const b = candidates[best]!
|
|
62
|
+
const c = candidates[current]!
|
|
63
|
+
if (c.isVisible && !b.isVisible) return current
|
|
64
|
+
if (!c.isVisible && b.isVisible) return best
|
|
65
|
+
if (c.indicators.inHeader && !b.indicators.inHeader) return current
|
|
66
|
+
if (!c.indicators.inHeader && b.indicators.inHeader) return best
|
|
67
|
+
if (c.position.top < b.position.top) return current
|
|
68
|
+
if (c.position.top > b.position.top) return best
|
|
69
|
+
if (c.indicators.hrefMatch && !b.indicators.hrefMatch) return current
|
|
70
|
+
return best
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function repeatedLogos(candidates: LogoCandidate[]) {
|
|
75
|
+
const repeated = new Set<number>()
|
|
76
|
+
const groups = new Map<string, number[]>()
|
|
77
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
78
|
+
const key = filename(candidate.src).toLowerCase() || candidate.src
|
|
79
|
+
groups.set(key, [...(groups.get(key) ?? []), index])
|
|
80
|
+
}
|
|
81
|
+
for (const indices of groups.values()) {
|
|
82
|
+
const locations = new Set(indices.map((i) => candidates[i]!.location))
|
|
83
|
+
if (indices.length > 1 && locations.size > 1) {
|
|
84
|
+
for (const index of indices) repeated.add(index)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return repeated
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hrefHeaderScore(candidate: LogoCandidate) {
|
|
91
|
+
let score = 0
|
|
92
|
+
const reasons: string[] = []
|
|
93
|
+
if (candidate.indicators.hrefMatch && candidate.indicators.inHeader) {
|
|
94
|
+
score += 50
|
|
95
|
+
reasons.push("header logo linking to homepage")
|
|
96
|
+
} else if (candidate.indicators.hrefMatch) {
|
|
97
|
+
score += 35
|
|
98
|
+
reasons.push("links to homepage")
|
|
99
|
+
} else if (candidate.indicators.inHeader) {
|
|
100
|
+
score += 25
|
|
101
|
+
reasons.push("in header")
|
|
102
|
+
}
|
|
103
|
+
if (!candidate.href?.trim()) {
|
|
104
|
+
score -= 15
|
|
105
|
+
reasons.push("no homepage link")
|
|
106
|
+
}
|
|
107
|
+
return { score, reasons }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function selectLogoWithConfidence(candidates: LogoCandidate[], brandName?: string): LogoSelection {
|
|
111
|
+
if (!candidates.length) {
|
|
112
|
+
return { selectedIndex: -1, confidence: 0, source: "none", reasoning: "No logo candidates found" }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const variants = detectVariants(candidates)
|
|
116
|
+
const repeated = repeatedLogos(candidates)
|
|
117
|
+
const indicesToScore = new Set<number>()
|
|
118
|
+
const variantBonus = new Map<number, number>()
|
|
119
|
+
|
|
120
|
+
for (const group of variants.values()) {
|
|
121
|
+
const best = pickBestVariant(candidates, group)
|
|
122
|
+
indicesToScore.add(best)
|
|
123
|
+
if (group.some((i) => repeated.has(i))) variantBonus.set(best, 15)
|
|
124
|
+
if (group.length > 1) variantBonus.set(best, (variantBonus.get(best) ?? 0) + 8)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const scored = candidates
|
|
128
|
+
.map((candidate, index) => {
|
|
129
|
+
if (!indicesToScore.has(index)) return { index, score: -999, reasons: ["duplicate variant"] }
|
|
130
|
+
let score = variantBonus.get(index) ?? 0
|
|
131
|
+
const reasons: string[] = score > 0 ? [`variant bonus ${score}`] : []
|
|
132
|
+
|
|
133
|
+
const href = hrefHeaderScore(candidate)
|
|
134
|
+
score += href.score
|
|
135
|
+
reasons.push(...href.reasons)
|
|
136
|
+
|
|
137
|
+
if (candidate.location === "header") {
|
|
138
|
+
score += 20
|
|
139
|
+
reasons.push("header location")
|
|
140
|
+
}
|
|
141
|
+
if (candidate.isVisible) {
|
|
142
|
+
score += 15
|
|
143
|
+
reasons.push("visible")
|
|
144
|
+
} else {
|
|
145
|
+
score -= 25
|
|
146
|
+
reasons.push("hidden")
|
|
147
|
+
}
|
|
148
|
+
if (candidate.position.top < 100 && candidate.position.left < 300) {
|
|
149
|
+
score += 10
|
|
150
|
+
reasons.push("top-left")
|
|
151
|
+
}
|
|
152
|
+
if (
|
|
153
|
+
candidates.every((other, otherIndex) => otherIndex === index || candidate.position.top <= other.position.top)
|
|
154
|
+
) {
|
|
155
|
+
score += 12
|
|
156
|
+
reasons.push("highest candidate")
|
|
157
|
+
}
|
|
158
|
+
if (candidate.indicators.altMatch) score += 8
|
|
159
|
+
if (candidate.indicators.srcMatch) score += 5
|
|
160
|
+
if (candidate.indicators.classMatch) score += 5
|
|
161
|
+
|
|
162
|
+
if (brandName) {
|
|
163
|
+
const alt = candidate.alt.toLowerCase().trim()
|
|
164
|
+
const brand = brandName.toLowerCase().trim()
|
|
165
|
+
if (alt === brand) {
|
|
166
|
+
score += 20
|
|
167
|
+
reasons.push("alt exactly matches brand")
|
|
168
|
+
} else if (alt && (alt.includes(brand) || brand.includes(alt))) {
|
|
169
|
+
score += 12
|
|
170
|
+
reasons.push("alt matches brand")
|
|
171
|
+
}
|
|
172
|
+
if (candidate.src.toLowerCase().includes(brand.replace(/\s+/g, ""))) {
|
|
173
|
+
score += 6
|
|
174
|
+
reasons.push("src matches brand")
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const area = logoArea(candidate.position)
|
|
179
|
+
const square = Math.abs(candidate.position.width - candidate.position.height) < 5
|
|
180
|
+
if (area > 1000 && area < 50000) score += 5
|
|
181
|
+
else if (area < 500) score -= 8
|
|
182
|
+
else if (area >= 50000 && area <= 200000) score -= 10
|
|
183
|
+
else if (area > 200000) score -= 20
|
|
184
|
+
if (square && candidate.position.width < 40) score -= 12
|
|
185
|
+
if (candidate.isSvg) score += 3
|
|
186
|
+
if (candidate.location === "footer") score -= 15
|
|
187
|
+
if (candidate.location === "body" && !candidate.indicators.inHeader) score -= 10
|
|
188
|
+
|
|
189
|
+
return { index, score, reasons }
|
|
190
|
+
})
|
|
191
|
+
.filter((item) => item.score > -900)
|
|
192
|
+
.sort((a, b) => b.score - a.score)
|
|
193
|
+
|
|
194
|
+
if (!scored.length) {
|
|
195
|
+
return { selectedIndex: -1, confidence: 0, source: "fallback", reasoning: "Only duplicate variants were found" }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const top = scored[0]!
|
|
199
|
+
const second = scored[1]
|
|
200
|
+
const separation = second ? top.score - second.score : top.score
|
|
201
|
+
if (top.score >= confidence.strongScore && separation >= confidence.strongSeparation) {
|
|
202
|
+
return {
|
|
203
|
+
selectedIndex: top.index,
|
|
204
|
+
confidence: 0.9,
|
|
205
|
+
source: "heuristic",
|
|
206
|
+
reasoning: `Strong logo indicators: ${top.reasons.join(", ")}. Score ${top.score}, clear by ${separation}.`,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (top.score >= confidence.goodScore && separation >= confidence.goodSeparation) {
|
|
210
|
+
return {
|
|
211
|
+
selectedIndex: top.index,
|
|
212
|
+
confidence: 0.75,
|
|
213
|
+
source: "heuristic",
|
|
214
|
+
reasoning: `Good logo indicators: ${top.reasons.join(", ")}. Score ${top.score}, clear by ${separation}.`,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (top.score >= confidence.moderateScore) {
|
|
218
|
+
return {
|
|
219
|
+
selectedIndex: top.index,
|
|
220
|
+
confidence: 0.6,
|
|
221
|
+
source: "heuristic",
|
|
222
|
+
reasoning: `Moderate logo indicators: ${top.reasons.join(", ")}. Score ${top.score}.`,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
selectedIndex: top.index,
|
|
227
|
+
confidence: 0.4,
|
|
228
|
+
source: "heuristic",
|
|
229
|
+
reasoning: `Weak logo indicators: ${top.reasons.join(", ")}. Score ${top.score}.`,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function topLogoCandidates(candidates: LogoCandidate[], maxCandidates = 20) {
|
|
234
|
+
if (candidates.length <= maxCandidates) return { candidates, indexMap: candidates.map((_, i) => i) }
|
|
235
|
+
const scored = candidates.map((candidate, index) => {
|
|
236
|
+
let score = 0
|
|
237
|
+
score += hrefHeaderScore(candidate).score
|
|
238
|
+
if (candidate.location === "header") score += 20
|
|
239
|
+
if (candidate.isVisible) score += 15
|
|
240
|
+
if (candidate.indicators.classMatch) score += 10
|
|
241
|
+
if (candidate.indicators.srcMatch) score += 10
|
|
242
|
+
if (candidate.indicators.altMatch) score += 5
|
|
243
|
+
if (candidate.source === "document.images") score += 15
|
|
244
|
+
return { candidate, index, score }
|
|
245
|
+
})
|
|
246
|
+
scored.sort((a, b) => b.score - a.score)
|
|
247
|
+
const top = scored.slice(0, maxCandidates)
|
|
248
|
+
return { candidates: top.map((x) => x.candidate), indexMap: top.map((x) => x.index) }
|
|
249
|
+
}
|