1+ import pLimit from "p-limit" ;
12import { parseHTML } from "linkedom" ;
23import {
34 resolveUrl ,
@@ -8,24 +9,23 @@ import {
89 shouldIncludeUrl ,
910} from "./utils/url-helpers" ;
1011import { fetchRobotsTxt , isUrlAllowed , type RobotsRules } from "./utils/robots-parser" ;
11- import { rateLimit } from "./utils/rate-limiter" ;
1212import { createLogger } from "./utils/logger" ;
1313import { scrape } from "./scraper" ;
1414import type { CrawlOptions , CrawlResult , CrawlUrl , CrawlMetadata } from "./crawl-types" ;
15- import type { ScrapeResult } from "./types" ;
15+ import type { ScrapeResult , WebsiteScrapeResult } from "./types" ;
1616
1717/**
18- * Crawler class for discovering and optionally scraping pages.
18+ * Crawler class for discovering and scraping pages concurrently .
1919 *
20- * Discovery and scraping both go through the scraper, which handles
21- * Hero, proxy escalation, and timeouts. The crawler owns BFS traversal,
22- * link extraction, deduplication, robots.txt, and rate limiting.
20+ * BFS traversal with concurrent page fetching. Each page is scraped once
21+ * for both link discovery and content extraction (single pass).
2322 */
2423export class Crawler {
2524 private options : CrawlOptions ;
2625 private visited : Set < string > = new Set ( ) ;
2726 private queue : Array < { url : string ; depth : number } > = [ ] ;
2827 private urls : CrawlUrl [ ] = [ ] ;
28+ private scrapedPages : WebsiteScrapeResult [ ] = [ ] ;
2929 private logger = createLogger ( "crawler" ) ;
3030 private robotsRules : RobotsRules | null = null ;
3131
@@ -34,9 +34,9 @@ export class Crawler {
3434 depth : 1 ,
3535 maxPages : 20 ,
3636 scrape : false ,
37- delayMs : 1000 ,
37+ delayMs : 200 ,
3838 formats : [ "markdown" , "html" ] ,
39- scrapeConcurrency : 2 ,
39+ scrapeConcurrency : 3 ,
4040 verbose : false ,
4141 showChrome : false ,
4242 ...options ,
@@ -48,6 +48,9 @@ export class Crawler {
4848 */
4949 async crawl ( ) : Promise < CrawlResult > {
5050 const startTime = Date . now ( ) ;
51+ const maxPages = this . options . maxPages ?? 20 ;
52+ const concurrency = this . options . scrapeConcurrency ?? 3 ;
53+ const limit = pLimit ( concurrency ) ;
5154
5255 // Fetch robots.txt rules
5356 this . robotsRules = await fetchRobotsTxt ( this . options . url ) ;
@@ -62,37 +65,56 @@ export class Crawler {
6265 this . logger . warn ( `Seed URL blocked by robots.txt: ${ this . options . url } ` ) ;
6366 }
6467
65- // BFS crawl
66- while ( this . queue . length > 0 && this . urls . length < ( this . options . maxPages ?? 20 ) ) {
68+ // BFS crawl with concurrent fetching
69+ while ( this . queue . length > 0 && this . urls . length < maxPages ) {
6770 if ( this . options . timeoutMs && Date . now ( ) - startTime > this . options . timeoutMs ) {
6871 this . logger . warn ( `Crawl timed out after ${ this . options . timeoutMs } ms` ) ;
6972 break ;
7073 }
7174
72- const item = this . queue . shift ( ) ! ;
73- const urlKey = getUrlKey ( item . url ) ;
75+ // Grab a batch of items from the queue (up to concurrency limit)
76+ const remaining = maxPages - this . urls . length ;
77+ const batchSize = Math . min ( this . queue . length , concurrency , remaining ) ;
78+ const batch : Array < { url : string ; depth : number } > = [ ] ;
7479
75- if ( this . visited . has ( urlKey ) ) {
76- continue ;
80+ while ( batch . length < batchSize && this . queue . length > 0 ) {
81+ const item = this . queue . shift ( ) ! ;
82+ const urlKey = getUrlKey ( item . url ) ;
83+ if ( this . visited . has ( urlKey ) ) continue ;
84+ this . visited . add ( urlKey ) ;
85+ batch . push ( item ) ;
7786 }
7887
79- // Fetch page via scraper
80- const result = await this . fetchPage ( item . url ) ;
88+ if ( batch . length === 0 ) break ;
89+
90+ // Fetch all pages in the batch concurrently
91+ const results = await Promise . all (
92+ batch . map ( ( item ) =>
93+ limit ( async ( ) => {
94+ const result = await this . fetchPage ( item . url ) ;
95+ return { item, result } ;
96+ } )
97+ )
98+ ) ;
99+
100+ // Process results: collect URLs and extract links
101+ for ( const { item, result } of results ) {
102+ if ( ! result ) continue ;
103+ if ( this . urls . length >= maxPages ) break ;
81104
82- if ( result ) {
83105 this . urls . push ( result . crawlUrl ) ;
84- this . visited . add ( urlKey ) ;
85106
86- // Extract links if not at max depth
107+ // Store scraped content if scrape mode is on
108+ if ( this . options . scrape && result . scraped ) {
109+ this . scrapedPages . push ( result . scraped ) ;
110+ }
111+
112+ // Extract links for BFS if not at max depth
87113 if ( item . depth < ( this . options . depth ?? 1 ) ) {
88114 const links = this . extractLinks ( result . html , item . url , item . depth + 1 ) ;
89115 this . queue . push ( ...links ) ;
90116 }
91117 }
92-
93- // Rate limit
94- const delay = this . robotsRules ?. crawlDelay || ( this . options . delayMs ?? 1000 ) ;
95- await rateLimit ( delay ) ;
96118 }
97119
98120 const metadata : CrawlMetadata = {
@@ -102,10 +124,20 @@ export class Crawler {
102124 seedUrl : this . options . url ,
103125 } ;
104126
105- // Optionally scrape all discovered URLs for content
127+ // Build scraped result from collected pages
106128 let scraped : ScrapeResult | undefined ;
107- if ( this . options . scrape ) {
108- scraped = await this . scrapeDiscoveredUrls ( ) ;
129+ if ( this . options . scrape && this . scrapedPages . length > 0 ) {
130+ scraped = {
131+ data : this . scrapedPages ,
132+ batchMetadata : {
133+ totalUrls : this . scrapedPages . length ,
134+ successfulUrls : this . scrapedPages . length ,
135+ failedUrls : 0 ,
136+ scrapedAt : new Date ( ) . toISOString ( ) ,
137+ totalDuration : Date . now ( ) - startTime ,
138+ errors : [ ] ,
139+ } ,
140+ } ;
109141 }
110142
111143 return {
@@ -116,18 +148,21 @@ export class Crawler {
116148 }
117149
118150 /**
119- * Fetch a single page for discovery using the scraper.
120- *
121- * Calls scrape() with onlyMainContent=false so link extraction gets
122- * the full page HTML. The scraper handles Hero, proxy escalation,
123- * and timeouts internally.
151+ * Fetch a single page. Returns both discovery data (URL, title, raw HTML
152+ * for link extraction) and scraped content (markdown/html) in one pass.
124153 */
125- private async fetchPage ( url : string ) : Promise < { crawlUrl : CrawlUrl ; html : string } | null > {
154+ private async fetchPage ( url : string ) : Promise < {
155+ crawlUrl : CrawlUrl ;
156+ html : string ;
157+ scraped ?: WebsiteScrapeResult ;
158+ } | null > {
126159 try {
160+ const formats = this . options . scrape ? this . options . formats || [ "markdown" , "html" ] : [ ] ;
161+
127162 const result = await scrape ( {
128163 urls : [ url ] ,
129- formats : [ ] , // We only need rawHtml for discovery
130- onlyMainContent : false ,
164+ formats,
165+ onlyMainContent : this . options . scrape ? true : false ,
131166 proxy : this . options . proxy ,
132167 proxyTier : this . options . proxyTier ,
133168 timeoutMs : this . options . timeoutMs ,
@@ -153,6 +188,7 @@ export class Crawler {
153188 description : page . metadata . website ?. description ?? null ,
154189 } ,
155190 html : page . rawHtml ,
191+ scraped : this . options . scrape ? page : undefined ,
156192 } ;
157193 } catch ( error : unknown ) {
158194 const msg = error instanceof Error ? error . message : String ( error ) ;
@@ -232,30 +268,6 @@ export class Crawler {
232268
233269 return links ;
234270 }
235-
236- /**
237- * Scrape all discovered URLs for content.
238- */
239- private async scrapeDiscoveredUrls ( ) : Promise < ScrapeResult > {
240- const urls = this . urls . map ( ( u ) => u . url ) ;
241-
242- return scrape ( {
243- urls,
244- formats : this . options . formats || [ "markdown" , "html" ] ,
245- batchConcurrency : this . options . scrapeConcurrency || 2 ,
246- proxy : this . options . proxy ,
247- proxyTier : this . options . proxyTier ,
248- userAgent : this . options . userAgent ,
249- verbose : this . options . verbose ,
250- showChrome : this . options . showChrome ,
251- playwrightPool : this . options . playwrightPool ,
252- proxyGate : this . options . proxyGate ,
253- healthTracker : this . options . healthTracker ,
254- resolveProxy : this . options . resolveProxy ,
255- removeAds : this . options . removeAds ,
256- removeBase64Images : this . options . removeBase64Images ,
257- } ) ;
258- }
259271}
260272
261273/**
0 commit comments