@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.
- package/.github/FUNDING.yml +10 -0
- package/.prettierignore +41 -0
- package/AGENTS.md +357 -0
- package/IMPLEMENTATION_PLAN.md +452 -0
- package/Plan.md +83 -0
- package/README.md +180 -2
- package/dist/bun-crawl.js +101 -0
- package/eslint.config.mjs +37 -0
- package/package.json +40 -5
- package/src/ai/interface.ts +7 -0
- package/src/ai/null-ai.ts +10 -0
- package/src/ai/zen-ai.ts +57 -0
- package/src/cli.ts +309 -0
- package/src/core/cleaner.ts +47 -0
- package/src/core/crawler.ts +146 -0
- package/src/core/fetcher.ts +64 -0
- package/src/core/markdown.ts +108 -0
- package/src/index.ts +13 -0
- package/src/types.ts +54 -0
- package/tsconfig.json +22 -0
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|
package/src/ai/zen-ai.ts
ADDED
|
@@ -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
|
+
}
|