@involvex/bun-crawl 0.0.0-stage → 0.1.0

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,37 @@
1
+ import js from '@eslint/js'
2
+ import tseslint from 'typescript-eslint'
3
+
4
+ export default tseslint.config(
5
+ {
6
+ ignores: [
7
+ 'dist/',
8
+ 'node_modules/',
9
+ '*.exe',
10
+ '*.md',
11
+ 'bun.lock',
12
+ 'IMPLEMENTATION_PLAN.md',
13
+ ],
14
+ },
15
+ js.configs.recommended,
16
+ ...tseslint.configs.recommended,
17
+ {
18
+ files: ['src/**/*.ts'],
19
+ languageOptions: {
20
+ ecmaVersion: 'latest',
21
+ sourceType: 'module',
22
+ parserOptions: {
23
+ project: './tsconfig.json',
24
+ tsconfigRootDir: import.meta.dirname,
25
+ },
26
+ },
27
+ rules: {
28
+ '@typescript-eslint/no-explicit-any': 'warn',
29
+ '@typescript-eslint/no-unused-vars': ['warn', {argsIgnorePattern: '^_'}],
30
+ '@typescript-eslint/consistent-type-imports': 'error',
31
+ '@typescript-eslint/no-floating-promises': 'warn',
32
+ '@typescript-eslint/await-thenable': 'error',
33
+ 'preserve-caught-error': 'off',
34
+ 'no-console': ['warn', {allow: ['warn', 'error']}],
35
+ },
36
+ },
37
+ )
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
- "name": "@involvex/bun-crawl",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@involvex/bun-crawl",
3
+ "version": "0.1.0",
4
+ "author": "involvex",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "bin": {
8
+ "bun-crawl": "./src/cli.ts",
9
+ "buncrawl": "./dist/bun-crawl.js"
10
+ },
11
+ "scripts": {
12
+ "prebuild": "bun run format && bun run lint:fix && bun run typecheck",
13
+ "build": "bun build --target node --minify ./src/index.ts --outfile dist/bun-crawl.js",
14
+ "dev": "bun run src/cli.ts",
15
+ "format": "prettier . --write",
16
+ "lint": "eslint .",
17
+ "lint:fix": "eslint . --fix",
18
+ "test": "bun test",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "prettier": "@involvex/prettier-config",
22
+ "dependencies": {
23
+ "@mozilla/readability": "^0.6.0",
24
+ "citty": "^0.2.2",
25
+ "linkedom": "^0.18.13",
26
+ "turndown": "^7.2.4"
27
+ },
28
+ "devDependencies": {
29
+ "@eslint/js": "^10.0.1",
30
+ "@involvex/prettier-config": "^0.0.6",
31
+ "@types/bun": "latest",
32
+ "@types/mozilla-readability": "^0.2.1",
33
+ "@types/turndown": "^5.0.6",
34
+ "@typescript-eslint/eslint-plugin": "^8.70.0",
35
+ "@typescript-eslint/parser": "^8.70.0",
36
+ "eslint": "^10.10.0",
37
+ "prettier": "^3.9.6",
38
+ "typescript": "~5.6.0",
39
+ "typescript-eslint": "^8.70.0"
40
+ }
41
+ }
@@ -0,0 +1,7 @@
1
+ export interface AIExtractor {
2
+ extract<T = string>(markdown: string, schemaPrompt: string): Promise<T>
3
+ }
4
+
5
+ export interface AIExtractorConfig {
6
+ enabled?: boolean
7
+ }
@@ -0,0 +1,10 @@
1
+ import type {AIExtractor} from './interface'
2
+
3
+ export class NullAIExtractor implements AIExtractor {
4
+ async extract<T = string>(
5
+ markdown: string,
6
+ _schemaPrompt: string,
7
+ ): Promise<T> {
8
+ return markdown as T
9
+ }
10
+ }
@@ -0,0 +1,57 @@
1
+ // TODO: Implement when OpenCode Zen API is available
2
+ // This is a template for future OpenAI-compatible Chat Completions integration
3
+
4
+ /*
5
+ export interface ZenAIConfig {
6
+ endpoint: string; // e.g. "https://api.opencode.ai/v1/chat/completions"
7
+ apiKey: string;
8
+ model?: string; // e.g. "gpt-4o", "claude-3.5-sonnet"
9
+ temperature?: number;
10
+ maxTokens?: number;
11
+ }
12
+
13
+ export class ZenAIExtractor implements AIExtractor {
14
+ constructor(private config: ZenAIConfig) {}
15
+
16
+ async extract<T = string>(markdown: string, schemaPrompt: string): Promise<T> {
17
+ const response = await fetch(this.config.endpoint, {
18
+ method: 'POST',
19
+ headers: {
20
+ 'Authorization': `Bearer ${this.config.apiKey}`,
21
+ 'Content-Type': 'application/json',
22
+ },
23
+ body: JSON.stringify({
24
+ model: this.config.model || 'gpt-4o',
25
+ messages: [
26
+ { role: 'system', content: schemaPrompt },
27
+ { role: 'user', content: markdown }
28
+ ],
29
+ temperature: this.config.temperature ?? 0,
30
+ max_tokens: this.config.maxTokens ?? 4000,
31
+ }),
32
+ });
33
+
34
+ if (!response.ok) {
35
+ const error = await response.text();
36
+ throw new Error(`Zen AI API error: ${response.status} - ${error}`);
37
+ }
38
+
39
+ const data = await response.json();
40
+ const content = data.choices[0]?.message?.content;
41
+
42
+ if (!content) {
43
+ throw new Error('No content in AI response');
44
+ }
45
+
46
+ return content as T;
47
+ }
48
+ }
49
+ */
50
+
51
+ export class ZenAIExtractor {
52
+ constructor() {
53
+ throw new Error(
54
+ 'ZenAIExtractor not yet implemented. See src/ai/zen-ai.ts for template.',
55
+ )
56
+ }
57
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import {writeFile} from 'fs/promises'
4
+ import {parseArgs} from 'node:util'
5
+ import {dirname, join} from 'path'
6
+ import {NullAIExtractor} from './ai/null-ai'
7
+ import {Cleaner} from './core/cleaner'
8
+ import {Crawler} from './core/crawler'
9
+ import {Fetcher} from './core/fetcher'
10
+ import {MarkdownConverter} from './core/markdown'
11
+ import type {CrawlOptions, ScrapeResult} from './types'
12
+
13
+ const pkg = await import('../package.json', {with: {type: 'json'}}).then(
14
+ m => m.default,
15
+ )
16
+
17
+ function createFetcher(options: {
18
+ timeout?: number
19
+ userAgent?: string
20
+ }): Fetcher {
21
+ return new Fetcher({
22
+ timeout: options.timeout,
23
+ userAgent: options.userAgent,
24
+ })
25
+ }
26
+
27
+ function createCleaner(): Cleaner {
28
+ return new Cleaner()
29
+ }
30
+
31
+ function createConverter(): MarkdownConverter {
32
+ return new MarkdownConverter()
33
+ }
34
+
35
+ function createCrawler(
36
+ fetcher: Fetcher,
37
+ cleaner: Cleaner,
38
+ converter: MarkdownConverter,
39
+ options: CrawlOptions,
40
+ ): Crawler {
41
+ return new Crawler(fetcher, cleaner, converter, options)
42
+ }
43
+
44
+ import {mkdir} from 'fs/promises'
45
+
46
+ async function writeOutput(output: string, filePath?: string): Promise<void> {
47
+ if (filePath) {
48
+ const dir = dirname(filePath)
49
+ if (dir !== '.') {
50
+ await mkdir(dir, {recursive: true})
51
+ }
52
+ await writeFile(filePath, output, 'utf-8')
53
+ console.error(`Written to ${filePath}`)
54
+ } else {
55
+ // eslint-disable-next-line no-console
56
+ console.log(output)
57
+ }
58
+ }
59
+
60
+ function showHelp(): void {
61
+ // eslint-disable-next-line no-console
62
+ console.log(`
63
+ bun-crawl v${pkg.version} - Open-source Firecrawl alternative built with Bun
64
+
65
+ USAGE:
66
+ bun-crawl <command> <url> [options]
67
+
68
+ COMMANDS:
69
+ scrape Scrape a single URL and output markdown
70
+ crawl Crawl a website recursively
71
+
72
+ SCRAPE OPTIONS:
73
+ -o, --output <file> Output file path (stdout if omitted)
74
+ -f, --format <format> Output format: markdown or json (default: markdown)
75
+ -t, --timeout <ms> Request timeout in milliseconds (default: 30000)
76
+ -u, --user-agent <ua> Custom User-Agent string (default: bun-crawl/0.1.0)
77
+ --json Output machine-readable JSON (alias for --format json)
78
+
79
+ CRAWL OPTIONS:
80
+ -d, --depth <num> Maximum crawl depth (default: 2)
81
+ -l, --limit <num> Maximum pages to crawl (default: 20)
82
+ -O, --out-dir <dir> Output directory for markdown files (default: ./crawl-output)
83
+ --same-domain Stay on the same domain (default: true)
84
+ -c, --concurrency <num> Concurrent requests (default: 5)
85
+ -t, --timeout <ms> Request timeout in milliseconds (default: 30000)
86
+ -u, --user-agent <ua> Custom User-Agent string (default: bun-crawl/0.1.0)
87
+ --json Output machine-readable JSON summary
88
+
89
+ EXAMPLES:
90
+ bun-crawl scrape https://example.com -o page.md
91
+ bun-crawl scrape https://example.com --format json
92
+ bun-crawl crawl https://example.com -d 3 -l 50 -O ./output
93
+ bun-crawl crawl https://example.com --json
94
+ `)
95
+ }
96
+
97
+ function parseCliArgs(rawArgs: string[]): {
98
+ command: string
99
+ url: string
100
+ options: Record<string, unknown>
101
+ } {
102
+ if (rawArgs.length === 0 || rawArgs[0] === '--help' || rawArgs[0] === '-h') {
103
+ showHelp()
104
+ process.exit(0)
105
+ }
106
+
107
+ if (rawArgs.length < 2) {
108
+ console.error('Error: Missing command and URL')
109
+ showHelp()
110
+ process.exit(1)
111
+ }
112
+
113
+ const command = rawArgs[0]
114
+ const url = rawArgs[1]
115
+
116
+ if (!['scrape', 'crawl'].includes(command)) {
117
+ console.error(
118
+ `Error: Unknown command "${command}". Use 'scrape' or 'crawl'`,
119
+ )
120
+ showHelp()
121
+ process.exit(1)
122
+ }
123
+
124
+ const optionArgs = rawArgs.slice(2)
125
+
126
+ const {values} = parseArgs({
127
+ args: optionArgs,
128
+ options: {
129
+ // Scrape options
130
+ output: {type: 'string', short: 'o'},
131
+ format: {type: 'string', short: 'f', default: 'markdown'},
132
+ // Crawl options
133
+ depth: {type: 'string', short: 'd', default: '2'},
134
+ limit: {type: 'string', short: 'l', default: '20'},
135
+ outDir: {type: 'string', short: 'O', default: './crawl-output'},
136
+ sameDomain: {type: 'boolean', default: true},
137
+ concurrency: {type: 'string', short: 'c', default: '5'},
138
+ // Common options
139
+ timeout: {type: 'string', short: 't', default: '30000'},
140
+ userAgent: {type: 'string', short: 'u', default: 'bun-crawl/0.1.0'},
141
+ json: {type: 'boolean'},
142
+ },
143
+ allowPositionals: true,
144
+ strict: false,
145
+ })
146
+
147
+ return {command, url, options: values}
148
+ }
149
+
150
+ function getNumberOption(
151
+ options: Record<string, unknown>,
152
+ name: string,
153
+ defaultValue: number,
154
+ ): number {
155
+ const val = options[name]
156
+ if (val === undefined || val === '') return defaultValue
157
+ const parsed = Number(val)
158
+ return isNaN(parsed) ? defaultValue : parsed
159
+ }
160
+
161
+ function getBooleanOption(
162
+ options: Record<string, unknown>,
163
+ name: string,
164
+ defaultValue: boolean,
165
+ ): boolean {
166
+ const val = options[name]
167
+ if (val === undefined) return defaultValue
168
+ return val === true || val === 'true'
169
+ }
170
+
171
+ function getStringOption(
172
+ options: Record<string, unknown>,
173
+ name: string,
174
+ defaultValue: string,
175
+ ): string {
176
+ const val = options[name]
177
+ if (val === undefined || val === '') return defaultValue
178
+ return String(val)
179
+ }
180
+
181
+ async function runScrape(
182
+ url: string,
183
+ options: Record<string, unknown>,
184
+ ): Promise<void> {
185
+ const timeout = getNumberOption(options, 'timeout', 30000)
186
+ const userAgent = getStringOption(options, 'userAgent', 'bun-crawl/0.1.0')
187
+
188
+ const fetcher = createFetcher({timeout, userAgent})
189
+ const cleaner = createCleaner()
190
+ const converter = createConverter()
191
+ const ai = new NullAIExtractor()
192
+
193
+ const {html, url: finalUrl, status} = await fetcher.fetch(url)
194
+
195
+ if (status >= 400) {
196
+ throw new Error(`HTTP ${status}: ${finalUrl}`)
197
+ }
198
+
199
+ const {content, title, metadata} = cleaner.clean(html, finalUrl)
200
+ let markdown = converter.convert(content)
201
+ markdown = await ai.extract(markdown, '')
202
+
203
+ const result: ScrapeResult = {
204
+ url: finalUrl,
205
+ title,
206
+ markdown,
207
+ html: content,
208
+ metadata,
209
+ }
210
+
211
+ const outputFormat = getBooleanOption(options, 'json', false)
212
+ ? 'json'
213
+ : getStringOption(options, 'format', 'markdown')
214
+
215
+ if (outputFormat === 'json') {
216
+ await writeOutput(
217
+ JSON.stringify(result, null, 2),
218
+ getStringOption(options, 'output', ''),
219
+ )
220
+ } else {
221
+ await writeOutput(markdown, getStringOption(options, 'output', ''))
222
+ }
223
+ }
224
+
225
+ async function runCrawl(
226
+ url: string,
227
+ options: Record<string, unknown>,
228
+ ): Promise<void> {
229
+ const depth = getNumberOption(options, 'depth', 2)
230
+ const limit = getNumberOption(options, 'limit', 20)
231
+ const outDir = getStringOption(options, 'outDir', './crawl-output')
232
+ const sameDomain = getBooleanOption(options, 'sameDomain', true)
233
+ const concurrency = getNumberOption(options, 'concurrency', 5)
234
+ const timeout = getNumberOption(options, 'timeout', 30000)
235
+ const userAgent = getStringOption(options, 'userAgent', 'bun-crawl/0.1.0')
236
+ const outputJson = getBooleanOption(options, 'json', false)
237
+
238
+ const fetcher = createFetcher({timeout, userAgent})
239
+ const cleaner = createCleaner()
240
+ const converter = createConverter()
241
+
242
+ const crawlOptions: CrawlOptions = {
243
+ depth,
244
+ limit,
245
+ sameDomain,
246
+ concurrency,
247
+ }
248
+
249
+ const crawler = createCrawler(fetcher, cleaner, converter, crawlOptions)
250
+
251
+ console.error(`Starting crawl: ${url}`)
252
+ console.error(`Depth: ${depth}, Limit: ${limit}, Concurrency: ${concurrency}`)
253
+
254
+ const result = await crawler.crawl(url)
255
+
256
+ console.error(
257
+ `Crawl complete: ${result.stats.succeeded} succeeded, ${result.stats.failed} failed`,
258
+ )
259
+
260
+ if (outputJson) {
261
+ const summary = {
262
+ stats: result.stats,
263
+ pages: result.pages.map(p => ({
264
+ url: p.url,
265
+ title: p.title,
266
+ })),
267
+ errors: result.errors,
268
+ }
269
+ await writeOutput(JSON.stringify(summary, null, 2), undefined)
270
+ } else {
271
+ for (const page of result.pages) {
272
+ const urlObj = new URL(page.url)
273
+ const pathName =
274
+ urlObj.pathname === '/'
275
+ ? 'index'
276
+ : urlObj.pathname.replace(/^\//, '').replace(/\//g, '_')
277
+ const fileName = `${pathName || 'index'}.md`
278
+ const filePath = join(outDir, fileName)
279
+
280
+ await writeOutput(page.markdown, filePath)
281
+ }
282
+
283
+ if (result.errors.length > 0) {
284
+ console.error('\nErrors:')
285
+ for (const err of result.errors) {
286
+ console.error(` ${err.url}: ${err.error}`)
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ async function main() {
293
+ const {command, url, options} = parseCliArgs(process.argv.slice(2))
294
+
295
+ try {
296
+ if (command === 'scrape') {
297
+ await runScrape(url, options)
298
+ } else if (command === 'crawl') {
299
+ await runCrawl(url, options)
300
+ }
301
+ } catch (error) {
302
+ console.error(
303
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
304
+ )
305
+ process.exit(1)
306
+ }
307
+ }
308
+
309
+ await main()
@@ -0,0 +1,47 @@
1
+ import {Readability} from '@mozilla/readability'
2
+ import {parseHTML} from 'linkedom'
3
+ import type {ScrapeResult} from '../types'
4
+
5
+ export interface CleanerResult {
6
+ content: string
7
+ title: string
8
+ metadata: Record<string, unknown>
9
+ }
10
+
11
+ export class Cleaner {
12
+ clean(html: string, url: string): CleanerResult {
13
+ const {document} = parseHTML(html, {url})
14
+ const reader = new Readability(document)
15
+ const article = reader.parse()
16
+
17
+ if (!article) {
18
+ return {
19
+ content: '',
20
+ title: document.title || '',
21
+ metadata: {},
22
+ }
23
+ }
24
+
25
+ const metadata: Record<string, unknown> = {}
26
+ if (article.excerpt) metadata.description = article.excerpt
27
+ if (article.byline) metadata.author = article.byline
28
+ if (article.publishedTime) metadata.publishedTime = article.publishedTime
29
+
30
+ return {
31
+ content: article.content ?? '',
32
+ title: article.title || document.title || '',
33
+ metadata,
34
+ }
35
+ }
36
+
37
+ createScrapeResult(url: string, html: string): ScrapeResult {
38
+ const {content, title, metadata} = this.clean(html, url)
39
+ return {
40
+ url,
41
+ title,
42
+ markdown: '',
43
+ html: content,
44
+ metadata,
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,146 @@
1
+ import {URL} from 'url'
2
+ import type {CrawlOptions, CrawlResult, ScrapeResult} from '../types'
3
+ import type {Cleaner} from './cleaner'
4
+ import type {Fetcher} from './fetcher'
5
+ import type {MarkdownConverter} from './markdown'
6
+
7
+ export class Crawler {
8
+ private readonly fetcher: Fetcher
9
+ private readonly cleaner: Cleaner
10
+ private readonly converter: MarkdownConverter
11
+ private readonly options: CrawlOptions
12
+ private readonly visited: Set<string> = new Set()
13
+ private readonly queue: Array<{url: string; depth: number}> = []
14
+
15
+ constructor(
16
+ fetcher: Fetcher,
17
+ cleaner: Cleaner,
18
+ converter: MarkdownConverter,
19
+ options: CrawlOptions,
20
+ ) {
21
+ this.fetcher = fetcher
22
+ this.cleaner = cleaner
23
+ this.converter = converter
24
+ this.options = options
25
+ }
26
+
27
+ async crawl(startUrl: string): Promise<CrawlResult> {
28
+ const baseUrl = new URL(startUrl)
29
+ const results: ScrapeResult[] = []
30
+ const errors: Array<{url: string; error: string}> = []
31
+
32
+ this.queue.push({url: startUrl, depth: 0})
33
+
34
+ while (this.queue.length > 0 && results.length < this.options.limit) {
35
+ const batch = this.queue.splice(0, this.options.concurrency)
36
+ const promises = batch.map(async ({url, depth}) => {
37
+ if (this.visited.has(url)) return null
38
+ if (depth > this.options.depth) return null
39
+
40
+ this.visited.add(url)
41
+
42
+ try {
43
+ const result = await this.scrapePage(url, baseUrl, depth)
44
+ if (result) {
45
+ results.push(result)
46
+ }
47
+ } catch (error) {
48
+ errors.push({
49
+ url,
50
+ error: error instanceof Error ? error.message : String(error),
51
+ })
52
+ }
53
+ })
54
+
55
+ await Promise.all(promises)
56
+ }
57
+
58
+ return {
59
+ pages: results,
60
+ errors,
61
+ stats: {
62
+ total: results.length + errors.length,
63
+ succeeded: results.length,
64
+ failed: errors.length,
65
+ },
66
+ }
67
+ }
68
+
69
+ private async scrapePage(
70
+ url: string,
71
+ baseUrl: URL,
72
+ depth: number,
73
+ ): Promise<ScrapeResult | null> {
74
+ const {html, url: finalUrl, status} = await this.fetcher.fetch(url)
75
+
76
+ if (status >= 400) {
77
+ throw new Error(`HTTP ${status}: ${finalUrl}`)
78
+ }
79
+
80
+ const {content, title, metadata} = this.cleaner.clean(html, finalUrl)
81
+ const markdown = this.converter.convert(content)
82
+
83
+ const result: ScrapeResult = {
84
+ url: finalUrl,
85
+ title,
86
+ markdown,
87
+ html: content,
88
+ metadata,
89
+ }
90
+
91
+ if (depth < this.options.depth) {
92
+ const links = await this.extractLinks(html, finalUrl)
93
+ for (const link of links) {
94
+ if (!this.visited.has(link) && this.shouldCrawl(link, baseUrl)) {
95
+ this.queue.push({url: link, depth: depth + 1})
96
+ }
97
+ }
98
+ }
99
+
100
+ return result
101
+ }
102
+
103
+ private async extractLinks(html: string, baseUrl: string): Promise<string[]> {
104
+ const links: string[] = []
105
+
106
+ const rewriter = new HTMLRewriter().on('a', {
107
+ element(element) {
108
+ const href = element.getAttribute('href')
109
+ if (href) links.push(href)
110
+ },
111
+ })
112
+
113
+ try {
114
+ const response = rewriter.transform(new Response(html))
115
+ await response.text()
116
+ } catch {
117
+ // Ignore parsing errors
118
+ }
119
+
120
+ return links
121
+ .map(href => this.resolveUrl(href, baseUrl))
122
+ .filter((url): url is string => url !== null)
123
+ }
124
+
125
+ private resolveUrl(href: string, baseUrl: string): string | null {
126
+ try {
127
+ const url = new URL(href, baseUrl)
128
+ url.hash = ''
129
+ return url.toString()
130
+ } catch {
131
+ return null
132
+ }
133
+ }
134
+
135
+ private shouldCrawl(url: string, baseUrl: URL): boolean {
136
+ try {
137
+ const targetUrl = new URL(url)
138
+ if (this.options.sameDomain && targetUrl.host !== baseUrl.host) {
139
+ return false
140
+ }
141
+ return targetUrl.protocol === 'http:' || targetUrl.protocol === 'https:'
142
+ } catch {
143
+ return false
144
+ }
145
+ }
146
+ }