@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,64 @@
|
|
|
1
|
+
import type {FetcherOptions} from '../types'
|
|
2
|
+
|
|
3
|
+
export class Fetcher {
|
|
4
|
+
private readonly defaultOptions: Required<FetcherOptions>
|
|
5
|
+
|
|
6
|
+
constructor(options: FetcherOptions = {}) {
|
|
7
|
+
this.defaultOptions = {
|
|
8
|
+
timeout: options.timeout ?? 30000,
|
|
9
|
+
userAgent: options.userAgent ?? 'bun-crawl/0.1.0',
|
|
10
|
+
headers: options.headers ?? {},
|
|
11
|
+
followRedirects: options.followRedirects ?? true,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async fetch(
|
|
16
|
+
url: string,
|
|
17
|
+
): Promise<{html: string; url: string; status: number}> {
|
|
18
|
+
const controller = new AbortController()
|
|
19
|
+
const timeoutId = setTimeout(
|
|
20
|
+
() => controller.abort(),
|
|
21
|
+
this.defaultOptions.timeout,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(url, {
|
|
26
|
+
method: 'GET',
|
|
27
|
+
headers: {
|
|
28
|
+
'User-Agent': this.defaultOptions.userAgent,
|
|
29
|
+
Accept:
|
|
30
|
+
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
31
|
+
'Accept-Language': 'en-US,en;q=0.5',
|
|
32
|
+
...this.defaultOptions.headers,
|
|
33
|
+
},
|
|
34
|
+
redirect: this.defaultOptions.followRedirects ? 'follow' : 'manual',
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
clearTimeout(timeoutId)
|
|
39
|
+
|
|
40
|
+
const html = await response.text()
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
html,
|
|
44
|
+
url: response.url,
|
|
45
|
+
status: response.status,
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
clearTimeout(timeoutId)
|
|
49
|
+
|
|
50
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Request timeout after ${this.defaultOptions.timeout}ms: ${url}`,
|
|
53
|
+
{cause: error},
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const caughtError =
|
|
58
|
+
error instanceof Error ? error : new Error(String(error))
|
|
59
|
+
throw new Error(`Failed to fetch ${url}: ${caughtError.message}`, {
|
|
60
|
+
cause: caughtError,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import TurndownService from 'turndown'
|
|
2
|
+
|
|
3
|
+
export interface MarkdownOptions {
|
|
4
|
+
headingStyle?: 'setext' | 'atx'
|
|
5
|
+
codeBlockStyle?: 'fenced' | 'indented'
|
|
6
|
+
bulletListMarker?: '-' | '*' | '+'
|
|
7
|
+
emDelimiter?: '_' | '*'
|
|
8
|
+
strongDelimiter?: '__' | '**'
|
|
9
|
+
linkStyle?: 'inlined' | 'referenced'
|
|
10
|
+
linkReferenceStyle?: 'full' | 'collapsed' | 'shortcut'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class MarkdownConverter {
|
|
14
|
+
private readonly turndown: TurndownService
|
|
15
|
+
|
|
16
|
+
constructor(options: MarkdownOptions = {}) {
|
|
17
|
+
this.turndown = new TurndownService({
|
|
18
|
+
headingStyle: options.headingStyle ?? 'atx',
|
|
19
|
+
codeBlockStyle: options.codeBlockStyle ?? 'fenced',
|
|
20
|
+
bulletListMarker: options.bulletListMarker ?? '-',
|
|
21
|
+
emDelimiter: options.emDelimiter ?? '_',
|
|
22
|
+
strongDelimiter: options.strongDelimiter ?? '**',
|
|
23
|
+
linkStyle: options.linkStyle ?? 'inlined',
|
|
24
|
+
linkReferenceStyle: options.linkReferenceStyle ?? 'full',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
this.configureRules()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private configureRules(): void {
|
|
31
|
+
this.turndown.addRule('strikeThrough', {
|
|
32
|
+
filter: ['del', 's'],
|
|
33
|
+
replacement: content => `~~${content}~~`,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
this.turndown.addRule('underline', {
|
|
37
|
+
filter: 'u',
|
|
38
|
+
replacement: content => `<u>${content}</u>`,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
this.turndown.addRule('subscript', {
|
|
42
|
+
filter: 'sub',
|
|
43
|
+
replacement: content => `~${content}~`,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
this.turndown.addRule('superscript', {
|
|
47
|
+
filter: 'sup',
|
|
48
|
+
replacement: content => `^${content}^`,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
this.turndown.addRule('details', {
|
|
52
|
+
filter: 'details',
|
|
53
|
+
replacement: (content, node) => {
|
|
54
|
+
const summary = node.querySelector('summary')?.textContent || 'Details'
|
|
55
|
+
return `\n<details>\n<summary>${summary}</summary>\n${content}\n</details>\n`
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
this.turndown.addRule('taskListItems', {
|
|
60
|
+
filter: node =>
|
|
61
|
+
node.nodeName === 'INPUT' && node.getAttribute('type') === 'checkbox',
|
|
62
|
+
replacement: (_, node) => {
|
|
63
|
+
const checked = node.hasAttribute('checked') ? 'x' : ' '
|
|
64
|
+
return `- [${checked}] `
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
this.turndown.addRule('table', {
|
|
69
|
+
filter: 'table',
|
|
70
|
+
replacement: (content, _node) => {
|
|
71
|
+
return `\n${content}\n`
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
this.turndown.addRule('tableRow', {
|
|
76
|
+
filter: 'tr',
|
|
77
|
+
replacement: (content, node) => {
|
|
78
|
+
const isHeader = node.parentElement?.nodeName === 'THEAD'
|
|
79
|
+
const cells = node.querySelectorAll('th, td')
|
|
80
|
+
const cellContents = Array.from(cells).map(
|
|
81
|
+
(cell: Element) =>
|
|
82
|
+
cell.textContent?.replace(/\|/g, '\\|').trim() || '',
|
|
83
|
+
)
|
|
84
|
+
const separator = isHeader
|
|
85
|
+
? cellContents.map(() => '---').join('|')
|
|
86
|
+
: ''
|
|
87
|
+
return `| ${cellContents.join(' | ')} |\n${separator ? `| ${separator} |\n` : ''}`
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
this.turndown.addRule('pre', {
|
|
92
|
+
filter: 'pre',
|
|
93
|
+
replacement: (content, node) => {
|
|
94
|
+
const code = node.querySelector('code')
|
|
95
|
+
const language = code?.className?.match(/language-(\w+)/)?.[1] || ''
|
|
96
|
+
return `\n\`\`\`${language}\n${content}\n\`\`\`\n`
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
convert(html: string): string {
|
|
102
|
+
return this.turndown.turndown(html).trim()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getTurndownService(): TurndownService {
|
|
106
|
+
return this.turndown
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type {AIExtractor, AIExtractorConfig} from './ai/interface'
|
|
2
|
+
export {NullAIExtractor} from './ai/null-ai'
|
|
3
|
+
export {ZenAIExtractor} from './ai/zen-ai'
|
|
4
|
+
export {Cleaner, type CleanerResult} from './core/cleaner'
|
|
5
|
+
export {Crawler} from './core/crawler'
|
|
6
|
+
export {Fetcher} from './core/fetcher'
|
|
7
|
+
export {MarkdownConverter, type MarkdownOptions} from './core/markdown'
|
|
8
|
+
export type {
|
|
9
|
+
CrawlOptions,
|
|
10
|
+
CrawlResult,
|
|
11
|
+
FetcherOptions,
|
|
12
|
+
ScrapeResult,
|
|
13
|
+
} from './types'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface ScrapeResult {
|
|
2
|
+
url: string
|
|
3
|
+
title: string
|
|
4
|
+
markdown: string
|
|
5
|
+
html: string
|
|
6
|
+
metadata: {
|
|
7
|
+
description?: string
|
|
8
|
+
author?: string
|
|
9
|
+
publishedTime?: string
|
|
10
|
+
[key: string]: unknown
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CrawlOptions {
|
|
15
|
+
depth: number
|
|
16
|
+
limit: number
|
|
17
|
+
sameDomain: boolean
|
|
18
|
+
concurrency: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CrawlResult {
|
|
22
|
+
pages: ScrapeResult[]
|
|
23
|
+
errors: Array<{url: string; error: string}>
|
|
24
|
+
stats: {
|
|
25
|
+
total: number
|
|
26
|
+
succeeded: number
|
|
27
|
+
failed: number
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FetcherOptions {
|
|
32
|
+
timeout?: number
|
|
33
|
+
userAgent?: string
|
|
34
|
+
headers?: Record<string, string>
|
|
35
|
+
followRedirects?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AIExtractorConfig {
|
|
39
|
+
enabled?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CliOptions {
|
|
43
|
+
[key: string]: string | boolean | undefined
|
|
44
|
+
output?: string | boolean
|
|
45
|
+
format?: string | boolean
|
|
46
|
+
depth?: string | boolean
|
|
47
|
+
limit?: string | boolean
|
|
48
|
+
outDir?: string | boolean
|
|
49
|
+
sameDomain?: boolean
|
|
50
|
+
concurrency?: string | boolean
|
|
51
|
+
timeout?: string | boolean
|
|
52
|
+
userAgent?: string | boolean
|
|
53
|
+
json?: boolean
|
|
54
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"outDir": "./dist",
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"allowImportingTsExtensions": true,
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"moduleDetection": "force",
|
|
18
|
+
"types": ["bun"]
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*"],
|
|
21
|
+
"exclude": ["node_modules", "dist"]
|
|
22
|
+
}
|