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.
@@ -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
+ }