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/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
+ }