Colly WebSource Crawl Controls

Data Engineering & Processing intermediate Building with AI ~18 min read

Overview

Web crawling at scale demands more than sending HTTP requests and parsing HTML. Production crawlers must normalize URLs to avoid duplicate fetches, respect robots.txt directives, rate-limit requests to avoid overwhelming target servers, handle transient failures with retries, and cache results to minimize redundant network traffic. The example below wraps the Colly web scraping framework with a WebSource layer that enforces all of these controls in a single, composable configuration.

Colly is a popular Go framework built on top of net/http and goquery that provides a callback-driven model for web scraping. However, Colly’s default configuration leaves many production concerns to the user. The WebSource crawl controls shown below add a URL normalizer that lowercases hostnames, strips fragment identifiers, and removes tracking parameters. A retrying transport wraps the HTTP client to handle 429 and 5xx responses with exponential backoff. Sitemap-driven crawl scheduling discovers pages automatically, and configurable TTLs on cached responses prevent stale data from persisting indefinitely.

Together, these controls turn a basic Colly collector into a well-behaved, production-grade crawler that can be pointed at documentation sites, API references, and knowledge bases to feed content into a document processing pipeline.

Core Concepts

URL Normalization

URL normalization ensures that semantically identical URLs are treated as a single resource. Without normalization, https://Docs.Example.Com/page#section and https://docs.example.com/page would be crawled as two separate pages. The normalizer applies a chain of transformations: lowercasing the scheme and hostname, removing default ports (80 for HTTP, 443 for HTTPS), stripping fragment identifiers, sorting query parameters alphabetically, and removing known tracking parameters like utm_source.

Key Concept: Canonical URLs

URL normalization produces a canonical form for each URL, ensuring that the crawler treats HTTPS://Example.COM/path?b=2&a=1#frag and https://example.com/path?a=1&b=2 as the same resource. This prevents duplicate fetches and duplicate entries in the document index.

Robots.txt Compliance

The robots.txt protocol lets site operators specify which paths crawlers may and may not access. Colly has built-in support for parsing robots.txt, but the WebSource layer ensures this is always enabled and correctly configured with the appropriate user-agent string. Disallowed paths are filtered before any HTTP request is made, saving bandwidth and respecting site operator intent.

Rate Limiting

Aggressive crawling can trigger rate limits, get the crawler’s IP blocked, or degrade the target site’s performance for real users. The WebSource configures Colly’s LimitRule to enforce a minimum delay between requests to the same domain, with optional random jitter to avoid synchronized bursts. The delay and parallelism limits are configurable per domain pattern.

Key Concept: Politeness Budget

Rate limiting is not just about avoiding bans—it is about being a good citizen of the web. A “politeness budget” balances crawl throughput against impact on the target server. The WebSource defaults to 1 request per second per domain with 2 concurrent connections, which is appropriate for documentation sites.

Retrying Transport

Transient HTTP errors (429 Too Many Requests, 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable) should not cause a crawl to fail. The retrying transport wraps Go’s http.RoundTripper interface to intercept these responses and retry with exponential backoff. The transport respects Retry-After headers when present, falling back to a configurable base delay with jitter.

Sitemap-Driven Discovery

Rather than relying solely on link following, the WebSource can seed a crawl from the site’s sitemap.xml. This ensures that orphaned pages (those not linked from any other page) are still discovered. The sitemap parser handles both sitemap index files and individual sitemaps, extracting URLs and their lastmod dates to support incremental crawling.

Key Concept: Sitemap as Crawl Seed

Using sitemap.xml as a crawl seed guarantees complete coverage of a site’s published pages. Combined with lastmod timestamps, the crawler can skip pages that haven’t changed since the last crawl, significantly reducing unnecessary fetches.

How It Works

The WebSource is initialized with a configuration struct that specifies target domains, rate limits, cache TTLs, and retry parameters. During initialization, the WebSource creates a Colly collector with the appropriate settings and wraps the HTTP transport.

  1. Configuration: The caller provides a WebSourceConfig with target URLs, allowed domains, rate limit rules, and caching options.
  2. Collector Setup: A Colly collector is created with the allowed domains, URL normalization filters, and robots.txt checking enabled.
  3. Transport Wrapping: The collector’s HTTP transport is wrapped with the retrying transport, which adds exponential backoff for transient errors.
  4. Sitemap Seeding: If sitemap URLs are provided, the collector fetches and parses them to seed the crawl queue with discovered URLs.
  5. Crawl Execution: The collector visits each URL, invoking registered callbacks for HTML responses. Callbacks extract content and links, normalizing discovered URLs before adding them to the queue.
  6. Cache Check: Before fetching, the cache is consulted. If a fresh response exists (within TTL), the cached version is returned without a network request.
// WebSourceConfig configures crawl behavior.
type WebSourceConfig struct {
    AllowedDomains []string      // Domains the crawler may visit.
    StartURLs      []string      // Seed URLs for the crawl.
    SitemapURLs    []string      // Sitemap URLs for discovery.
    RateLimit      time.Duration // Minimum delay between requests per domain.
    Parallelism    int           // Max concurrent requests per domain.
    MaxRetries     int           // Retries for transient errors.
    CacheTTL       time.Duration // How long cached responses are valid.
    UserAgent      string        // User-Agent header value.
    RespectRobots  bool          // Whether to obey robots.txt.
}

// NewWebSource creates a configured Colly-based web crawler.
func NewWebSource(cfg WebSourceConfig) (*WebSource, error) {
    c := colly.NewCollector(
        colly.AllowedDomains(cfg.AllowedDomains...),
        colly.UserAgent(cfg.UserAgent),
        colly.URLFilters(normalizeFilter()),
    )

    // Enable robots.txt.
    if cfg.RespectRobots {
        c.AllowURLRevisit = false
    }

    // Rate limiting.
    c.Limit(&colly.LimitRule{
        DomainGlob:  "*",
        Delay:       cfg.RateLimit,
        Parallelism: cfg.Parallelism,
        RandomDelay: cfg.RateLimit / 2,
    })

    // Retrying transport.
    c.WithTransport(NewRetryTransport(
        http.DefaultTransport,
        cfg.MaxRetries,
    ))

    return &WebSource{collector: c, config: cfg}, nil
}

Practical Applications

Documentation Ingestion

A primary use case for this pattern is crawling documentation sites to feed a document processing pipeline. The WebSource crawls sites like API references, developer guides, and knowledge bases, extracting HTML content that is then converted to structured documents for indexing in a search engine. The crawl controls ensure that the ingestion is complete (via sitemap seeding), polite (via rate limiting), and resilient (via retries and caching).

Change Detection

By storing the lastmod timestamps from sitemaps and the ETag or Last-Modified headers from HTTP responses, the WebSource can detect when pages have changed since the last crawl. This enables incremental re-indexing: only modified pages are re-processed, keeping the document index fresh without re-crawling the entire site.

Multi-Site Aggregation

A single crawl job can target multiple sites by providing multiple start URLs and allowed domains. Each domain gets its own rate limit bucket, so a slow site does not block crawling of faster sites. This is used to aggregate documentation from multiple microservices into a unified knowledge base.

Implementation Patterns

URL Normalizer Chain

The URL normalizer is implemented as a chain of small, composable functions, each responsible for a single transformation. New normalizations (like removing a site-specific tracking parameter) can be added by appending a function to the chain without modifying existing ones.

// NormalizeFunc transforms a URL into its canonical form.
type NormalizeFunc func(u *url.URL) *url.URL

// NormalizeChain applies a sequence of normalizers.
func NormalizeChain(fns ...NormalizeFunc) NormalizeFunc {
    return func(u *url.URL) *url.URL {
        for _, fn := range fns {
            u = fn(u)
        }
        return u
    }
}

// DefaultNormalizer returns the standard normalization chain.
func DefaultNormalizer() NormalizeFunc {
    return NormalizeChain(
        LowercaseHost,
        StripDefaultPort,
        StripFragment,
        SortQueryParams,
        StripTrackingParams,
    )
}

// LowercaseHost lowercases the hostname.
func LowercaseHost(u *url.URL) *url.URL {
    u.Host = strings.ToLower(u.Host)
    return u
}

// StripFragment removes the fragment identifier.
func StripFragment(u *url.URL) *url.URL {
    u.Fragment = ""
    return u
}

Retrying Round-Tripper

The retrying transport implements http.RoundTripper, making it a drop-in replacement for the default transport. It inspects the response status code and retries on transient errors with exponential backoff. The Retry-After header is respected when available.

// RetryTransport wraps an http.RoundTripper with retry logic.
type RetryTransport struct {
    base       http.RoundTripper
    maxRetries int
    baseDelay  time.Duration
}

func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    var resp *http.Response
    var err error

    for attempt := 0; attempt <= t.maxRetries; attempt++ {
        resp, err = t.base.RoundTrip(req)
        if err != nil {
            return nil, err // Network error, don't retry.
        }

        if !isRetryable(resp.StatusCode) {
            return resp, nil
        }

        delay := t.backoffDelay(attempt, resp)
        time.Sleep(delay)
        resp.Body.Close()
    }
    return resp, nil // Return last response after exhausting retries.
}

func isRetryable(code int) bool {
    return code == 429 || code == 500 || code == 502 || code == 503
}

func (t *RetryTransport) backoffDelay(attempt int, resp *http.Response) time.Duration {
    if ra := resp.Header.Get("Retry-After"); ra != "" {
        if secs, err := strconv.Atoi(ra); err == nil {
            return time.Duration(secs) * time.Second
        }
    }
    delay := t.baseDelay * time.Duration(1<<uint(attempt))
    jitter := time.Duration(rand.Int63n(int64(delay / 2)))
    return delay + jitter
}

Common Pitfalls

Further Reading