Skip to content

Commit 5eb441e

Browse files
committed
Concurrent BFS crawling with single-pass scrape
1 parent 617d85d commit 5eb441e

1 file changed

Lines changed: 70 additions & 58 deletions

File tree

src/crawler.ts

Lines changed: 70 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pLimit from "p-limit";
12
import { parseHTML } from "linkedom";
23
import {
34
resolveUrl,
@@ -8,24 +9,23 @@ import {
89
shouldIncludeUrl,
910
} from "./utils/url-helpers";
1011
import { fetchRobotsTxt, isUrlAllowed, type RobotsRules } from "./utils/robots-parser";
11-
import { rateLimit } from "./utils/rate-limiter";
1212
import { createLogger } from "./utils/logger";
1313
import { scrape } from "./scraper";
1414
import 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
*/
2423
export 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

Comments
 (0)