LEARNING OBJECTIVES ⌵
- Architect a scalable web crawler utilizing a Breadth-First Search (BFS) Frontier Queue.
- Implement URL normalization, deduplication, canonical link resolution, and spider trap evasion.
- Parse and strictly enforce the Robots Exclusion Protocol (
robots.txt/ RFC 9309) and HTML<meta name="robots">tags. - Design polite crawling pipelines featuring per-host rate limiting, concurrency throttles, and exponential backoff.
📖 The Mental Model & Story (Intuitive Foundation)
Imagine a cartographer tasked with mapping an archipelago of 10,000 islands connected by wooden suspension bridges. The cartographer starts on Island #1 (the Seed URL).
If the cartographer follows a single bridge from island to island indefinitely without keeping a map (Depth-First Search without tracking), they will quickly cross a circular bridge loop and walk in circles until they starve.
A professional cartographer brings a structured ledger:
- The Frontier Queue: A written list of islands waiting to be visited.
- The Visited Registry (Deduplication Set): A catalog of every island visited so far.
- The Local Law (robots.txt): Signposts posted at each harbor entrance declaring which islands or inland trails are off-limits to visitors.
+-----------------------------------------------------------------------------------+
| BFS WEB CRAWLER ARCHITECTURE |
+-----------------------------------------------------------------------------------+
| [ Seed URLs ] ---> [ URL Frontier Queue (FIFO) ] <----------------------------+ |
| | | |
| v (Pulls next URL) | |
| +-----------------------------------+ | |
| | robots.txt & Rate Limit Validator | | |
| +-----------------------------------+ | |
| | | |
| v (Permitted) | |
| +-----------------------------------+ | |
| | HTTP Fetcher & HTML Parser (DOM) | | |
| +-----------------------------------+ | |
| / \ | |
| / \ | |
| v v | |
| [ Store Data / Index ] [ Extract <a href> Links ] | |
| | | |
| v | |
| +-----------------------+ | |
| | Normalize URL & Dedupe| | |
| | (Visited Set / Bloom) | | |
| +-----------------------+ | |
| | | |
| v (Unseen URLs) | |
| [ Enqueue to Frontier ] ---------------------+ |
+-----------------------------------------------------------------------------------+
Technical Deep Dive & Specifications
BFS (Breadth-First Search) vs. DFS (Depth-First Search)
- Breadth-First Search (BFS): Explores all links at depth $N$ before moving to depth $N+1$. This is the gold standard for web crawlers because it indexes top-level, high-value pages first and distributes network load across diverse hosts.
- Depth-First Search (DFS): Follows links deep down a single path. This is prone to getting trapped in infinite calendar loops (
/calendar/2026/08/22...) or pagination spider traps.
URL Normalization & Canonical Resolution
Raw HTML contains relative paths, URL fragments, tracking queries, and varying casing. Crawlers must normalize URLs into a canonical format before deduplication:
Raw Href in HTML: "./products/../pricing.html?utm_source=twitter#faq"
Resolved against Base: "https://Example.COM:443/pricing.html?utm_source=twitter#faq"
Normalized Canonical: "https://example.com/pricing.html"
- Resolve Relative Paths:
new URL(href, currentUrl).href - Strip Fragment Identifiers: Remove
#hash(fragments point to internal page anchors and do not represent distinct HTTP resources). - Lowercase Hostnames:
EXAMPLE.COM->example.com - Remove Tracking Parameters: Strip
utm_*,fbclid,refquery parameters. - Honor Canonical Tags: If
<link rel="canonical" href="...">is present in the HTML<head>, index the target canonical URL instead.
The Robots Exclusion Protocol (robots.txt / RFC 9309)
Before fetching any page on a host, polite crawlers must fetch https://<host>/robots.txt:
User-agent: *
Disallow: /admin/
Disallow: /checkout/
Disallow: /api/
Allow: /api/public/
User-agent: MaliciousBot
Disallow: /
Sitemap: https://example.com/sitemap.xml
Crawl-delay: 2
In-Page Robots Directives
Webmasters can also specify indexing rules inside the HTML document itself:
<!-- Prevent indexing and link traversal -->
<meta name="robots" content="noindex, nofollow">
<!-- Specific anchor link not to be traversed -->
<a href="/internal-stats" rel="nofollow">Internal Stats</a>
💻 Interactive Code Playground
Here is a complete, production-ready BFS Web Crawler written in Node.js with URL normalization, internal link domain filtering, depth limiting, and deduplication.
Starter Code: bfs-crawler.mjs
Line-by-Line Code Breakdown
- Lines 13–32 (
normalizeUrl): Takes raw link strings (including relative paths like/about), resolves them against the parent base URL, strips URL fragments (#hash), removes tracking parameters (utm_*), and eliminates redundant trailing slashes. - Line 73 (
this.queue.shift()): Pops the oldest URL from the front of the array (First-In, First-Out), guaranteeing standard Breadth-First Search exploration. - Line 81 (
cheerio.load(html)): Parses the fetched HTML string in sub-millisecond time to extract headings and links. - Lines 94–98 (
linkHost !== rootHost): Enforces domain boundary protection, preventing the crawler from wandering onto external third-party websites. - Lines 101–105: Checks if the normalized URL is present in the
visitedSet. If unseen, marks it as visited and enqueues it for the next depth tier.
Expected Terminal Output
import * as cheerio from 'cheerio';
class SimpleBfsCrawler {
constructor(options = {}) {
this.maxDepth = options.maxDepth ?? 2;
this.maxPages = options.maxPages ?? 10;
this.visited = new Set();
this.queue = []; // FIFO Queue for BFS
this.crawledCount = 0;
}
// 1. URL Normalization Utility
normalizeUrl(rawUrl, baseUrl) {
try {
const parsed = new URL(rawUrl, baseUrl);
// Ignore non-HTTP protocols (mailto:, tel:, javascript:)
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
// Strip hash fragments and tracking query params
parsed.hash = '';
parsed.searchParams.delete('utm_source');
parsed.searchParams.delete('utm_medium');
parsed.searchParams.delete('utm_campaign');
// Normalize trailing slashes on pathname
if (parsed.pathname.endsWith('/') && parsed.pathname.length > 1) {
parsed.pathname = parsed.pathname.slice(0, -1);
}
return parsed.href;
} catch {
return null;
}
}
// 2. Mock network fetcher returning simulated HTML documents
async fetchPage(url) {
console.log(`[Fetcher] Requesting -> ${url}`);
// Mock site topology
const siteMap = {
'https://example.com': `
<html><body>
<h1>Home Hub</h1>
<a href="/about">About Us</a>
<a href="/docs/guide">Documentation</a>
<a href="https://external.com">External Partner</a>
</body></html>
`,
'https://example.com/about': `
<html><body>
<h1>About Page</h1>
<a href="/">Home</a>
<a href="/team">Our Team</a>
</body></html>
`,
'https://example.com/docs/guide': `
<html><body>
<h1>Docs Guide</h1>
<a href="/docs/api">API Reference</a>
</body></html>
`,
'https://example.com/team': `<html><body><h1>Leadership Team</h1></body></html>`,
'https://example.com/docs/api': `<html><body><h1>API Docs</h1></body></html>`
};
return siteMap[url] ?? null;
}
// 3. Main BFS Crawl Loop
async crawl(seedUrl) {
const rootHost = new URL(seedUrl).hostname;
const initialUrl = this.normalizeUrl(seedUrl, seedUrl);
this.queue.push({ url: initialUrl, depth: 0 });
this.visited.add(initialUrl);
console.log(`[Crawler] Starting BFS crawl from seed: ${initialUrl} (Max Depth: ${this.maxDepth})`);
while (this.queue.length > 0 && this.crawledCount < this.maxPages) {
// Dequeue next item (FIFO)
const { url, depth } = this.queue.shift();
this.crawledCount++;
console.log(`\n[Crawl Progress ${this.crawledCount}/${this.maxPages}] Depth ${depth}: ${url}`);
const html = await this.fetchPage(url);
if (!html) {
console.log(` - Page empty or 404. Skipping.`);
continue;
}
// Parse HTML with Cheerio
const $ = cheerio.load(html);
const pageTitle = $('h1').text().trim();
console.log(` - Extracted Title: "${pageTitle}"`);
// If we haven't reached max depth, extract links
if (depth < this.maxDepth) {
$('a[href]').each((_, el) => {
const rawHref = $(el).attr('href');
const normalized = this.normalizeUrl(rawHref, url);
if (!normalized) return;
// Stay within the same domain boundary
const linkHost = new URL(normalized).hostname;
if (linkHost !== rootHost) {
console.log(` - Skipped external link: ${normalized}`);
return;
}
// Deduplication Check
if (!this.visited.has(normalized)) {
this.visited.add(normalized);
this.queue.push({ url: normalized, depth: depth + 1 });
console.log(` + Enqueued (Depth ${depth + 1}): ${normalized}`);
}
});
}
}
console.log(`\n========================================`);
console.log(`[Summary] Crawl Complete!`);
console.log(`Total Pages Crawled: ${this.crawledCount}`);
console.log(`Total Unique URLs Discovered: ${this.visited.size}`);
console.log(`========================================`);
}
}
const crawler = new SimpleBfsCrawler({ maxDepth: 2, maxPages: 10 });
crawler.crawl('https://example.com');[Crawler] Starting BFS crawl from seed: https://example.com (Max Depth: 2)
[Crawl Progress 1/10] Depth 0: https://example.com
[Fetcher] Requesting -> https://example.com
- Extracted Title: "Home Hub"
+ Enqueued (Depth 1): https://example.com/about
+ Enqueued (Depth 1): https://example.com/docs/guide
- Skipped external link: https://external.com
[Crawl Progress 2/10] Depth 1: https://example.com/about
[Fetcher] Requesting -> https://example.com/about
- Extracted Title: "About Page"
+ Enqueued (Depth 2): https://example.com/team
[Crawl Progress 3/10] Depth 1: https://example.com/docs/guide
[Fetcher] Requesting -> https://example.com/docs/guide
- Extracted Title: "Docs Guide"
+ Enqueued (Depth 2): https://example.com/docs/api
[Crawl Progress 4/10] Depth 2: https://example.com/team
[Fetcher] Requesting -> https://example.com/team
- Extracted Title: "Leadership Team"
[Crawl Progress 5/10] Depth 2: https://example.com/docs/api
[Fetcher] Requesting -> https://example.com/docs/api
- Extracted Title: "API Docs"
========================================
[Summary] Crawl Complete!
Total Pages Crawled: 5
Total Unique URLs Discovered: 5
========================================🏋️ Hands-On Exercise
🎯 The Challenge: Build a robots.txt Disallow Matcher
Scenario: You are building a crawler that must respect website robots.txt rules. Given a parsed list of Disallow: path rules, write a function isPathAllowed(path, disallowRules) that determines if a crawler is permitted to fetch the URL path according to standard prefix matching rules.
Instructions:
- Given an array of disallow rules, e.g.:
['/admin/', '/private', '/checkout/']. - Check if the candidate path starts with any of the disallowed prefixes.
- Return
falseif blocked, ortrueif allowed. - Test with paths:
'/admin/dashboard'(blocked),'/private/keys'(blocked),'/blog/news'(allowed),'/checkout'(blocked),'/about'(allowed).
🏁 Starter Code Sandbox
⚠️ Common Pitfalls
- Spider Traps & Infinite Calendar Loops: Dynamic web applications with infinite pagination (e.g.
site.com/events?date=2026-08-21,2026-08-22...) will trap crawlers forever. Always enforce a hardmaxDepthlimit (e.g. depth <= 4) and a globalmaxPagesthreshold. - Memory Exhaustion from Unbounded
visitedSets: Storing 10 million raw URL strings in a JavaScriptSetconsumes multiple gigabytes of Node.js RAM. For massive multi-million page crawls, use a Bloom Filter or an external key-value database like Redis. - Aggressive Crawling Leading to IP Blacklisting: Flooding a web server with 100 requests per second will overwhelm origin servers and trigger immediate Cloudflare IP bans. Implement a token bucket or queue throttle that sleeps 500ms–1000ms between requests.
💡 Pro Tips
- Identify Your Bot Transparently: Always set a descriptive, professional
User-Agentcontaining your bot name and a contact URL or email:const headers = { 'User-Agent': 'AcmeDataBot/2.1 (+https://acme.com/bot-info; [email protected])' }; - Implement Exponential Backoff for 429 & 503 Responses: If a server responds with
429 Too Many Requests, inspect theRetry-AfterHTTP header or apply exponential backoff ($T = 2^{\text{attempt}} \times 1000\text{ms}$) rather than immediately hammering the server.
📌 Key Takeaways
- Web crawlers use Breadth-First Search (BFS) queues to index shallow, high-value pages first and distribute load across hosts.
- URL Normalization strips fragments (
#), removes tracking parameters (utm_*), and resolves relative paths to prevent duplicate fetching. - The Robots Exclusion Protocol (
robots.txt/ RFC 9309) governs crawler access viaUser-agent,Disallow, andAllowrules. - Avoid spider traps by enforcing strict
maxDepthandmaxPagesthresholds. - Polite crawlers maintain conservative concurrency limits and transparent
User-Agentheaders. - --