🧠 Web Crawler — Thought Process
📊 Class Diagram
classDiagram
class Crawler {
-opts Options
+Crawl(ctx Context, seeds []string) Stats, error
-process(ctx Context, hosts hostTable, j job) result
-robotsFor(ctx Context, u URL, h hostState) Robots
-allowed(u URL) bool
}
class Options {
+Workers int
+MaxDepth int
+MaxPages int
+PerHostDelay Duration
+FetchTimeout Duration
+UserAgent string
+Filters []URLFilter
+Fetcher Fetcher
+OnPage func Page to error
}
class Fetcher {
<<interface>>
+Fetch(ctx Context, rawURL string) Response, error
}
class HTTPFetcher {
+Client http.Client
+UserAgent string
+MaxBody int64
+Fetch(ctx Context, rawURL string) Response, error
}
class Response {
+URL string
+StatusCode int
+ContentType string
+Body []byte
}
class URLFilter {
<<func type>>
func u URL to bool
}
class crawlGroup {
-wg sync.WaitGroup
-once sync.Once
-err error
-cancel context.CancelFunc
+Go(f func error)
+Wait() error
}
class hostTable {
-mu sync.Mutex
-hosts map string to hostState
+get(u URL) hostState
}
class hostState {
-robotsOnce sync.Once
-robots Robots
-mu sync.Mutex
-next Time
+waitTurn(ctx Context, gap Duration) error
}
class Robots {
-rules []robotsRule
-crawlDelay Duration
-disallowAll bool
+Allowed(u URL) bool
}
class job {
-u URL
-depth int
}
class result {
-page Page
-links []URL
}
class Page {
+URL string
+Depth int
+StatusCode int
+Links int
+Err error
}
class Stats {
+Fetched int
+Failed int
+Disallowed int
+Discovered int
}
Crawler *-- Options
Options --> Fetcher
Options --> "*" URLFilter
Options ..> Page : OnPage callback
HTTPFetcher ..|> Fetcher
Fetcher ..> Response : returns
Crawler ..> crawlGroup : coordinator and worker lifecycle
Crawler ..> hostTable : one per Crawl
hostTable "1" *-- "*" hostState : per scheme and host
hostState --> Robots : fetched once
Crawler ..> job : coordinator sends on jobs chan
Crawler ..> result : workers send on results chan
result *-- Page
Crawler ..> Stats : returns
Problem Breakdown
Step 1: Core Components
- Frontier: URLs waiting to be crawled. A FIFO gives BFS by depth.
- Seen-set: normalized URL → already enqueued. Mark it on enqueue, not on fetch.
- Fetcher: an interface, so tests run against an in-memory web.
- Link extractor + filters: resolve relative links, normalize, scope by host, scheme and extension.
Step 2: Concurrency Model
- Crawling is I/O-bound, so a worker pool of goroutines fits.
- One coordinator goroutine owns the frontier, the seen-set and the counters. Workers only do I/O. No shared mutable crawl state means no locks and no check-then-act races.
- Termination: frontier empty and nothing in flight.
select with a nil-channel case lets the coordinator send jobs and receive results without deadlocking.
Step 3: Politeness
- robots.txt once per host (
sync.Once), longest-match rules, Crawl-delay.
- Minimum gap between requests to one host, claimed under the host lock after a ctx-aware wait.
- An explicit User-Agent.
Step 4: Bounds and Shutdown
MaxDepth, an exact MaxPages budget, a per-request timeout, a body size limit.
- errgroup-style lifecycle: the first error or ctx cancel stops everything, and
Crawl returns only after every goroutine exits.
Key Decisions
| Decision |
Why |
Coordinator owns state (no sync.Map) |
Atomic dedup and exact budgets without locks |
| Mark seen on enqueue |
Each URL dispatched once; no duplicate jobs in the frontier |
Unbuffered jobs/results + select |
Backpressure; no arbitrary buffer sizes to tune |
Synchronous Crawl with an OnPage callback |
The caller can't leak goroutines by not draining a channel |
Fetcher interface + FakeWeb |
Deterministic, offline demo and tests |
| Conservative normalization |
Wrongly merging two distinct URLs is worse than crawling one twice |
⏱️ How to run this in a 45–60 min interview
| Time |
Phase |
What to do |
What to say out loud |
| 0–5 |
Clarify |
Questions below; sketch Crawl(ctx, seeds) and Fetcher |
"I'll put fetching behind an interface so I can test without the network." |
| 5–12 |
Entities |
job, result, Page, Options, filters, Normalize |
"Dedup key = the normalized URL; I'll be conservative about what I normalize." |
| 12–25 |
Core |
Single-threaded BFS first, then split into coordinator + workers |
"One goroutine owns the frontier and seen-set, so I don't need locks there." |
| 25–35 |
Concurrency |
Termination (inFlight), nil-channel select, errgroup, ctx |
"Empty frontier isn't 'done': in-flight pages can add links." |
| 35–45 |
Politeness |
robots.txt + per-host gap |
"RFC 9309: robots 5xx means stay out. Longest rule wins." |
| 45–60 |
Extension |
MaxPages, per-host concurrency, distributed version |
Show the change is local; talk about Mercator queues and a Bloom filter. |
Clarifying questions worth asking
- Scope: one site, a list of domains, or the open web? Do we follow subdomains?
- Bounds: max depth, max pages, time budget?
- Politeness: obey robots.txt? Default delay per host? Max concurrent connections per host?
- What do we produce per page: links only, content, metadata? Do we store bodies?
- Do we render JavaScript-heavy pages?
- What counts as "the same page": exact URL, normalized URL, or same content?