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
package/src/index.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { dirname, resolve } from "node:path"
|
|
4
|
+
import type { BrandingProfile } from "./branding/types"
|
|
5
|
+
import { createSpinner } from "./ui"
|
|
6
|
+
|
|
7
|
+
interface Config {
|
|
8
|
+
mode: "branding" | "preview"
|
|
9
|
+
url: string
|
|
10
|
+
out: string | null
|
|
11
|
+
llm: boolean
|
|
12
|
+
includeRaw: boolean
|
|
13
|
+
waitMs: number
|
|
14
|
+
timeoutMs: number
|
|
15
|
+
webPreview: boolean
|
|
16
|
+
previewPort: number
|
|
17
|
+
openPreview: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const help = `
|
|
21
|
+
brandpull - Extract website branding JSON
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
brandpull <url> [options] Extract branding, save JSON, open preview
|
|
25
|
+
brandpull branding <url> [options] Extract branding JSON
|
|
26
|
+
brandpull preview <file.json> [options] Preview saved branding JSON
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
-o, --out <file> Write JSON to file instead of stdout
|
|
30
|
+
--web-preview Open a local browser preview for the branding JSON
|
|
31
|
+
--no-preview Save JSON without starting the preview in shorthand mode
|
|
32
|
+
--preview-port <n> Preferred preview server port (default: 4177)
|
|
33
|
+
--no-open Start preview server without opening a browser
|
|
34
|
+
--llm Use OpenAI enhancement when OPENAI_API_KEY is set
|
|
35
|
+
--raw Include raw candidates/buttons in debug output
|
|
36
|
+
--wait <ms> Extra page settle wait (default: 2000)
|
|
37
|
+
--timeout <ms> Page navigation timeout (default: 30000)
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
const toBrandingOut = (hostname: string) =>
|
|
41
|
+
`./${hostname
|
|
42
|
+
.replace(/^www\./, "")
|
|
43
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
44
|
+
.replace(/^-|-$/g, "")
|
|
45
|
+
.toLowerCase()}-branding.json`
|
|
46
|
+
|
|
47
|
+
const parseUrl = (input: string): URL => {
|
|
48
|
+
let raw = input
|
|
49
|
+
if (!/^https?:\/\//i.test(raw)) raw = `https://${raw}`
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
return new URL(raw)
|
|
53
|
+
} catch {
|
|
54
|
+
console.error(`Bad URL: ${input}`)
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const parseArgs = (args: string[]): Config => {
|
|
60
|
+
if (!args.length || args.includes("-h") || args.includes("--help")) {
|
|
61
|
+
console.log(help)
|
|
62
|
+
process.exit(0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const argv = [...args]
|
|
66
|
+
let mode: Config["mode"] = "branding"
|
|
67
|
+
let implicitBranding = true
|
|
68
|
+
|
|
69
|
+
if (argv[0] === "branding" || argv[0] === "brand") {
|
|
70
|
+
implicitBranding = false
|
|
71
|
+
argv.shift()
|
|
72
|
+
} else if (argv[0] === "preview" || argv[0] === "web-preview") {
|
|
73
|
+
mode = "preview"
|
|
74
|
+
implicitBranding = false
|
|
75
|
+
argv.shift()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const input = argv[0]
|
|
79
|
+
if (!input) {
|
|
80
|
+
console.error(mode === "preview" ? "Missing JSON file" : "Missing URL")
|
|
81
|
+
process.exit(1)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let urlHref = input
|
|
85
|
+
let hostname = ""
|
|
86
|
+
if (mode === "branding") {
|
|
87
|
+
const url = parseUrl(input)
|
|
88
|
+
urlHref = url.href
|
|
89
|
+
hostname = url.hostname
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let out: string | null = null
|
|
93
|
+
let llm = false
|
|
94
|
+
let includeRaw = false
|
|
95
|
+
let waitMs = 2000
|
|
96
|
+
let timeoutMs = 30000
|
|
97
|
+
let webPreview = implicitBranding
|
|
98
|
+
let previewPort = 4177
|
|
99
|
+
let openPreview = true
|
|
100
|
+
|
|
101
|
+
for (let i = 1; i < argv.length; i++) {
|
|
102
|
+
const arg = argv[i]
|
|
103
|
+
const next = argv[i + 1]
|
|
104
|
+
if (("-o" === arg || "--out" === arg) && next) {
|
|
105
|
+
out = next
|
|
106
|
+
i++
|
|
107
|
+
} else if ("--llm" === arg) {
|
|
108
|
+
llm = true
|
|
109
|
+
} else if ("--no-llm" === arg) {
|
|
110
|
+
llm = false
|
|
111
|
+
} else if ("--raw" === arg || "--debug-branding" === arg) {
|
|
112
|
+
includeRaw = true
|
|
113
|
+
} else if ("--wait" === arg && next) {
|
|
114
|
+
waitMs = +next
|
|
115
|
+
i++
|
|
116
|
+
} else if ("--timeout" === arg && next) {
|
|
117
|
+
timeoutMs = +next
|
|
118
|
+
i++
|
|
119
|
+
} else if ("--web-preview" === arg || "--preview" === arg) {
|
|
120
|
+
webPreview = true
|
|
121
|
+
} else if ("--no-preview" === arg) {
|
|
122
|
+
webPreview = false
|
|
123
|
+
} else if ("--preview-port" === arg && next) {
|
|
124
|
+
previewPort = +next
|
|
125
|
+
i++
|
|
126
|
+
} else if ("--no-open" === arg) {
|
|
127
|
+
openPreview = false
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (mode === "branding" && (implicitBranding || webPreview) && !out) {
|
|
132
|
+
out = toBrandingOut(hostname)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
mode,
|
|
137
|
+
url: mode === "preview" ? resolve(input) : urlHref,
|
|
138
|
+
out: out ? resolve(out) : null,
|
|
139
|
+
llm,
|
|
140
|
+
includeRaw,
|
|
141
|
+
waitMs,
|
|
142
|
+
timeoutMs,
|
|
143
|
+
webPreview,
|
|
144
|
+
previewPort,
|
|
145
|
+
openPreview,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const runPreview = async (config: Config) => {
|
|
150
|
+
const { serveBrandingPreview } = await import("./branding/preview")
|
|
151
|
+
process.stderr.write(`\n \x1b[1mbrandpull preview\x1b[0m \x1b[90m- loading branding JSON...\x1b[0m\n`)
|
|
152
|
+
process.stderr.write(` \x1b[90m${config.url}\x1b[0m\n\n`)
|
|
153
|
+
|
|
154
|
+
let profile: unknown
|
|
155
|
+
try {
|
|
156
|
+
profile = JSON.parse(await Bun.file(config.url).text())
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw new Error(`Could not read branding JSON: ${error instanceof Error ? error.message : String(error)}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!profile || typeof profile !== "object" || Array.isArray(profile)) {
|
|
162
|
+
throw new Error("Branding preview file must contain a JSON object")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await serveBrandingPreview(profile as BrandingProfile, {
|
|
166
|
+
open: config.openPreview,
|
|
167
|
+
port: config.previewPort,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const runBranding = async (config: Config) => {
|
|
172
|
+
const { extractBranding } = await import("./branding")
|
|
173
|
+
const target = config.out ? ` -> ${config.out}` : config.webPreview ? " -> preview" : " -> stdout"
|
|
174
|
+
process.stderr.write(`\n \x1b[1mbrandpull\x1b[0m \x1b[90m- rendering page...\x1b[0m\n`)
|
|
175
|
+
process.stderr.write(` \x1b[90m${config.url}${target}\x1b[0m\n\n`)
|
|
176
|
+
|
|
177
|
+
const spinner = createSpinner("Rendering page with Chromium")
|
|
178
|
+
let profile: BrandingProfile
|
|
179
|
+
try {
|
|
180
|
+
profile = await extractBranding(config.url, {
|
|
181
|
+
debug: config.includeRaw,
|
|
182
|
+
includeRaw: config.includeRaw,
|
|
183
|
+
llm: config.llm,
|
|
184
|
+
waitMs: config.waitMs,
|
|
185
|
+
timeoutMs: config.timeoutMs,
|
|
186
|
+
})
|
|
187
|
+
spinner.stop("\x1b[32mExtracted\x1b[0m branding signals")
|
|
188
|
+
} catch (error) {
|
|
189
|
+
spinner.stop("\x1b[31mFailed\x1b[0m branding extraction")
|
|
190
|
+
throw error
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const json = `${JSON.stringify(profile, null, 2)}\n`
|
|
194
|
+
if (config.out) {
|
|
195
|
+
const writeSpinner = createSpinner("Writing branding JSON")
|
|
196
|
+
await mkdir(dirname(config.out), { recursive: true })
|
|
197
|
+
await Bun.write(config.out, json)
|
|
198
|
+
writeSpinner.stop(`\x1b[32mSaved\x1b[0m branding JSON: \x1b[90m${config.out}\x1b[0m`)
|
|
199
|
+
} else if (!config.webPreview) {
|
|
200
|
+
process.stdout.write(json)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (config.webPreview) {
|
|
204
|
+
const previewSpinner = createSpinner("Starting local preview")
|
|
205
|
+
const { serveBrandingPreview } = await import("./branding/preview")
|
|
206
|
+
previewSpinner.stop()
|
|
207
|
+
await serveBrandingPreview(profile, {
|
|
208
|
+
open: config.openPreview,
|
|
209
|
+
port: config.previewPort,
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function main() {
|
|
215
|
+
const config = parseArgs(process.argv.slice(2))
|
|
216
|
+
if (config.mode === "preview") {
|
|
217
|
+
await runPreview(config)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
await runBranding(config)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
main().catch((error) => {
|
|
224
|
+
console.error(error)
|
|
225
|
+
process.exit(1)
|
|
226
|
+
})
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const Y = "\x1b[33m"
|
|
2
|
+
const D = "\x1b[90m"
|
|
3
|
+
const RST = "\x1b[0m"
|
|
4
|
+
|
|
5
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
6
|
+
const CLEAR = "\x1b[2K"
|
|
7
|
+
|
|
8
|
+
export function createSpinner(label: string) {
|
|
9
|
+
const started = performance.now()
|
|
10
|
+
const enabled = process.stderr.isTTY
|
|
11
|
+
let frame = 0
|
|
12
|
+
let text = label
|
|
13
|
+
let stopped = false
|
|
14
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
15
|
+
|
|
16
|
+
const render = () => {
|
|
17
|
+
if (stopped) return
|
|
18
|
+
const elapsed = ((performance.now() - started) / 1000).toFixed(1)
|
|
19
|
+
const spin = `${Y}${SPINNER[frame % SPINNER.length]}${RST}`
|
|
20
|
+
frame++
|
|
21
|
+
process.stderr.write(`\r${CLEAR} ${spin} ${text} ${D}${elapsed}s${RST}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (enabled) {
|
|
25
|
+
render()
|
|
26
|
+
timer = setInterval(render, 80)
|
|
27
|
+
} else {
|
|
28
|
+
process.stderr.write(` ${label}...\n`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
update(next: string) {
|
|
33
|
+
text = next
|
|
34
|
+
if (enabled) render()
|
|
35
|
+
else process.stderr.write(` ${next}...\n`)
|
|
36
|
+
},
|
|
37
|
+
stop(message?: string) {
|
|
38
|
+
if (stopped) return
|
|
39
|
+
stopped = true
|
|
40
|
+
if (timer) clearInterval(timer)
|
|
41
|
+
if (enabled) process.stderr.write(`\r${CLEAR}`)
|
|
42
|
+
if (message) process.stderr.write(` ${message}\n`)
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable", "WebWorker"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|