Same-Domain Web Crawl (BFS)
AnthropicSoftware Engineer · OnsiteLast updated
Implement a breadth-first search crawler that traverses pages starting from a seed URL, filtering for pages sharing the same domain and properly deduplicating visited URLs.
Problem Statement
You are building a focused web crawler. Given a starting URL start_url and an interface HtmlFetcher, implement a breadth-first search (BFS) crawler that visits accessible pages and returns a list of crawled URLs in the order they were first visited.
The crawler must adhere to the following rules:
- Same-Domain Only: Only crawl URLs that have the exact same hostname/domain as
start_url. For example, ifstart_urlis"https://anthropic.com/research", you should crawl"https://anthropic.com/careers", but not"https://news.anthropic.com"or"https://example.com". - URL Normalization:
- Strip URL fragments (anything from
#onwards). For example,https://anthropic.com/about#teamshould be normalized tohttps://anthropic.com/about. - Do not alter query parameters or paths otherwise.
- Strip URL fragments (anything from
- Deduplication: Do not fetch or visit the same normalized URL more than once.
- Traversal Order: Traverse the link graph level-by-level using Breadth-First Search (BFS).
- Page Limit: Stop crawling once you have fetched
max_pagesunique URLs, or when no more same-domain links are available in the queue.
Interface
class HtmlFetcher:
def get_urls(self, url: str) -> list[str]:
"""Returns raw link URLs found on the given page."""
pass
def crawl(start_url: str, fetcher: HtmlFetcher, max_pages: int = 50) -> list[str]:
pass
Constraints
1 <= max_pages <= 1000start_urlis a valid, well-formed HTTP/HTTPS URL.HtmlFetcher.get_urls(url)will return valid HTTP/HTTPS URLs as strings.- You may use standard library modules such as
urllib.parse.
Examples
Example 1
# Graph:
# "http://example.com/" -> ["http://example.com/a", "http://other.com/", "http://example.com/b"]
# "http://example.com/a" -> ["http://example.com/c", "http://example.com/#top"]
# "http://example.com/b" -> []
# "http://example.com/c" -> []
fetcher = MockFetcher(...) # with graph above
crawl("http://example.com/", fetcher, max_pages=10)
# Output: ["http://example.com/", "http://example.com/a", "http://example.com/b", "http://example.com/c"]
Explanation: http://other.com/ is skipped because its domain does not match example.com. http://example.com/#top normalizes to http://example.com/, which was already visited.
Example 2
# Graph:
# "https://site.org/start" -> ["https://site.org/1", "https://site.org/2"]
# "https://site.org/1" -> ["https://site.org/3"]
# "https://site.org/2" -> ["https://site.org/4"]
fetcher = MockFetcher(...)
crawl("https://site.org/start", fetcher, max_pages=3)
# Output: ["https://site.org/start", "https://site.org/1", "https://site.org/2"]
Explanation: Crawling stops immediately once max_pages (3) is reached, without calling fetcher.get_urls on subsequent items in the queue.
Hints
Hint 1
Use `urllib.parse.urlparse` to extract the network location (`netloc` or domain) and `urllib.parse.urldefrag` to cleanly strip fragments.
Hint 2
Add URLs to your `visited` set immediately when you push them to the BFS queue to prevent the same URL from being enqueued multiple times from different parent pages.
Hint 3
Ensure you check the `max_pages` limit both before fetching and when deciding whether to enqueue more URLs, or simply stop processing when `len(visited_order) == max_pages`.
Follow-up questions
- How would you adapt this crawler to fetch pages concurrently across multiple worker threads or asynchronous tasks using `asyncio`?
- How should the crawler handle relative URLs (e.g., `/blog/post-1`) and URL redirects returned by the fetcher?
- If the graph contains millions of links, keeping all visited URLs in a standard Python `set` in memory may be expensive. How would you handle deduplication at scale?
Solution walkthrough is for members
Get the full walkthrough with reference code and complexity analysis, plus Kitty Coach at 300 messages a day. Hints, the code runner and community answers stay free.