Documentation
¶
Index ¶
- func CanonicalizeURL(rawURL string) string
- type AutoscaleConfig
- type AutoscaledPool
- type BloomDeduplicator
- func (bd *BloomDeduplicator) Count() int
- func (bd *BloomDeduplicator) Export() []string
- func (bd *BloomDeduplicator) Import([]string)
- func (bd *BloomDeduplicator) IsSeen(rawURL string) bool
- func (bd *BloomDeduplicator) MarkIfUnseen(rawURL string) bool
- func (bd *BloomDeduplicator) MemoryStats() map[string]any
- func (bd *BloomDeduplicator) Reset()
- type BloomFilter
- type CheckpointManager
- func (cm *CheckpointManager) Clean() error
- func (cm *CheckpointManager) Dir() string
- func (cm *CheckpointManager) HasCheckpoint() bool
- func (cm *CheckpointManager) Load(frontier *Frontier, dedup Deduper, stats *Stats) error
- func (cm *CheckpointManager) Save(frontier *Frontier, dedup Deduper, stats *Stats) error
- type CircuitBreaker
- type Deduper
- type Deduplicator
- func (d *Deduplicator) Count() int
- func (d *Deduplicator) Export() []string
- func (d *Deduplicator) Import(hashes []string)
- func (d *Deduplicator) IsSeen(rawURL string) bool
- func (d *Deduplicator) MarkIfUnseen(rawURL string) bool
- func (d *Deduplicator) MarkSeen(rawURL string)
- func (d *Deduplicator) Reset()
- type DomainGate
- type DomainStats
- type Engine
- func (e *Engine) AddRequest(req *types.Request) error
- func (e *Engine) AddSeed(rawURL string) error
- func (e *Engine) ClearCheckpoint() error
- func (e *Engine) GetState() State
- func (e *Engine) HasCheckpoint() bool
- func (e *Engine) OnResponse(name string, cb ResponseCallback)
- func (e *Engine) Pause()
- func (e *Engine) ResultsChan() <-chan *types.Item
- func (e *Engine) Resume()
- func (e *Engine) ResumeFromCheckpoint() error
- func (e *Engine) SetFetcher(fetcherType string, f Fetcher)
- func (e *Engine) SetMetrics(m *observability.Metrics)
- func (e *Engine) SetParser(p Parser)
- func (e *Engine) SetPipeline(p Pipeline)
- func (e *Engine) SetStorage(s Storage)
- func (e *Engine) Start() error
- func (e *Engine) Stats() *Stats
- func (e *Engine) Stop()
- func (e *Engine) Wait()
- type Fetcher
- type Frontier
- func (f *Frontier) Close()
- func (f *Frontier) Drain() []*types.Request
- func (f *Frontier) IsClosed() bool
- func (f *Frontier) IsEmpty() bool
- func (f *Frontier) Len() int
- func (f *Frontier) Pop(ctx context.Context) *types.Request
- func (f *Frontier) PopReady(ctx context.Context, gate DomainGate) *types.Request
- func (f *Frontier) Push(req *types.Request)
- func (f *Frontier) RestoreAll(reqs []*types.Request)
- func (f *Frontier) Snapshot() []*types.Request
- func (f *Frontier) TryPop() *types.Request
- type Parser
- type Pipeline
- type ResponseCallback
- type RobotsManager
- type ScaleAction
- type ScaleDecision
- type Scheduler
- type State
- type Stats
- type Storage
- type Throttler
- func (t *Throttler) Allow(domain string) bool
- func (t *Throttler) Claim(domain string) bool
- func (t *Throttler) Delay(domain string) time.Duration
- func (t *Throttler) Enabled() bool
- func (t *Throttler) Len() int
- func (t *Throttler) Ready(domain string) bool
- func (t *Throttler) Reserve(domain string) time.Duration
- func (t *Throttler) Wait(ctx context.Context, domain string) error
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func CanonicalizeURL ¶
CanonicalizeURL normalizes a URL for deduplication: - lowercases scheme and host - removes fragment - sorts query parameters - removes trailing slash (except root) - removes default ports (80 for http, 443 for https)
Types ¶
type AutoscaleConfig ¶
type AutoscaleConfig struct {
// MinConcurrency is the minimum number of workers.
MinConcurrency int
// MaxConcurrency is the maximum number of workers.
MaxConcurrency int
// ScaleUpThreshold: if system load (0.0-1.0) is below this, add workers.
ScaleUpThreshold float64
// ScaleDownThreshold: if system load is above this, remove workers.
ScaleDownThreshold float64
// CheckInterval is how often to evaluate scaling decisions.
CheckInterval time.Duration
// CooldownPeriod is the minimum time between scaling actions.
CooldownPeriod time.Duration
}
AutoscaleConfig configures the autoscaled worker pool.
func DefaultAutoscaleConfig ¶
func DefaultAutoscaleConfig() *AutoscaleConfig
DefaultAutoscaleConfig returns sensible defaults.
type AutoscaledPool ¶
type AutoscaledPool struct {
// contains filtered or unexported fields
}
AutoscaledPool dynamically adjusts worker concurrency based on system load. Inspired by Crawlee's AutoscaledPool — ramp up when idle, back off when strained.
func NewAutoscaledPool ¶
func NewAutoscaledPool(cfg *AutoscaleConfig, queueSizeFn func() int, logger *slog.Logger) *AutoscaledPool
NewAutoscaledPool creates an autoscaled pool.
func (*AutoscaledPool) CurrentConcurrency ¶
func (p *AutoscaledPool) CurrentConcurrency() int
CurrentConcurrency returns the current worker count.
func (*AutoscaledPool) DesiredConcurrency ¶
func (p *AutoscaledPool) DesiredConcurrency() int
DesiredConcurrency returns the target worker count after the last evaluation.
func (*AutoscaledPool) Evaluate ¶
func (p *AutoscaledPool) Evaluate() ScaleDecision
Evaluate checks current system load and decides whether to scale up or down. Returns the new desired concurrency. Call this periodically from a monitoring goroutine.
func (*AutoscaledPool) RecordWorkerActive ¶
func (p *AutoscaledPool) RecordWorkerActive()
RecordWorkerActive increments the active worker counter.
func (*AutoscaledPool) RecordWorkerIdle ¶
func (p *AutoscaledPool) RecordWorkerIdle()
RecordWorkerIdle increments the idle worker counter.
func (*AutoscaledPool) SetWorkerCounts ¶
func (p *AutoscaledPool) SetWorkerCounts(active, idle int)
SetWorkerCounts sets the current worker counts directly.
type BloomDeduplicator ¶
type BloomDeduplicator struct {
// contains filtered or unexported fields
}
BloomDeduplicator is a memory-bounded, lossy deduplicator.
It holds a Bloom filter and nothing else. That is the point: the earlier version of this type kept a Bloom filter *and* a complete exact set, so it used strictly more memory than the plain map it was meant to improve on — the fast negative lookup was real, the advertised 10-100x memory saving was not.
The trade ¶
Bloom filters have no false negatives and some false positives. For crawl dedup that means: a URL genuinely seen is always reported seen (never re-crawled), and a URL never seen is occasionally reported seen — and therefore silently skipped. At the default 1% rate, roughly one URL in a hundred is dropped.
That is a real cost and it is why this is not the default. It is the right trade only when the crawl is large enough that an exact set will not fit, where the alternative is not "crawl everything" but "run out of memory".
Export and Import are deliberately unsupported: a Bloom filter cannot enumerate its members, so a checkpoint cannot round-trip through one. Resuming a Bloom crawl restarts with an empty filter, which re-crawls rather than drops — the safer direction of the two.
func NewBloomDeduplicator ¶
func NewBloomDeduplicator(expectedElements int, fpRate float64) *BloomDeduplicator
NewBloomDeduplicator creates a Bloom-backed deduplicator sized for the expected number of URLs, at the given false-positive rate. A rate of zero uses 1%.
func (*BloomDeduplicator) Count ¶
func (bd *BloomDeduplicator) Count() int
Count returns the number of URLs added.
func (*BloomDeduplicator) Export ¶
func (bd *BloomDeduplicator) Export() []string
Export returns nil: a Bloom filter cannot enumerate its members, so there is nothing to checkpoint. See the type comment.
func (*BloomDeduplicator) Import ¶
func (bd *BloomDeduplicator) Import([]string)
Import is a no-op, for the same reason as Export.
func (*BloomDeduplicator) IsSeen ¶
func (bd *BloomDeduplicator) IsSeen(rawURL string) bool
IsSeen reports whether a URL has been claimed, subject to the filter's false-positive rate.
func (*BloomDeduplicator) MarkIfUnseen ¶
func (bd *BloomDeduplicator) MarkIfUnseen(rawURL string) bool
MarkIfUnseen atomically claims a URL, reporting whether it was new.
func (*BloomDeduplicator) MemoryStats ¶
func (bd *BloomDeduplicator) MemoryStats() map[string]any
MemoryStats returns memory usage information.
type BloomFilter ¶
type BloomFilter struct {
// contains filtered or unexported fields
}
BloomFilter is a space-efficient probabilistic data structure for URL deduplication. At scale (millions of URLs), a Bloom filter uses ~10 bits per element instead of storing full URL hashes, reducing memory by 10-100x while maintaining <1% false positive rate.
func NewBloomFilter ¶
func NewBloomFilter(expectedElements int, fpRate float64) *BloomFilter
NewBloomFilter creates a new Bloom filter for the expected number of elements with the desired false positive rate (e.g., 0.01 = 1%).
Example: 1M URLs at 1% FP rate → ~1.2 MB memory (vs ~64 MB for map-based dedup)
func (*BloomFilter) Add ¶
func (bf *BloomFilter) Add(url string)
Add inserts a URL into the Bloom filter.
func (*BloomFilter) Contains ¶
func (bf *BloomFilter) Contains(url string) bool
Contains checks if a URL might be in the set. Returns true if the URL is PROBABLY in the set (with false positive rate). Returns false if the URL is DEFINITELY NOT in the set.
func (*BloomFilter) Count ¶
func (bf *BloomFilter) Count() uint64
Count returns the number of elements added (not unique, since we can't know exactly).
func (*BloomFilter) EstimatedFPRate ¶
func (bf *BloomFilter) EstimatedFPRate() float64
EstimatedFPRate returns the current estimated false positive rate.
func (*BloomFilter) MemoryUsageBytes ¶
func (bf *BloomFilter) MemoryUsageBytes() uint64
MemoryUsageBytes returns the approximate memory usage in bytes.
type CheckpointManager ¶
type CheckpointManager struct {
// contains filtered or unexported fields
}
CheckpointManager handles saving and loading crawl state for pause/resume.
func NewCheckpointManager ¶
func NewCheckpointManager(interval time.Duration) *CheckpointManager
NewCheckpointManager creates a new CheckpointManager.
func (*CheckpointManager) Clean ¶
func (cm *CheckpointManager) Clean() error
Clean removes the checkpoint file.
func (*CheckpointManager) Dir ¶
func (cm *CheckpointManager) Dir() string
Dir returns the directory checkpoints are written to.
func (*CheckpointManager) HasCheckpoint ¶
func (cm *CheckpointManager) HasCheckpoint() bool
HasCheckpoint returns true if a checkpoint file exists.
type CircuitBreaker ¶
type CircuitBreaker struct {
// contains filtered or unexported fields
}
CircuitBreaker stops a crawl from hammering a domain that is consistently failing.
Without one, a site that goes down absorbs the full request budget: every URL queued for it is attempted, retried to exhaustion, and fails, while the crawler reports the wasted work as ordinary errors. The breaker turns that into a fast rejection and gives the site room to recover.
Sized by the same LRU discipline as the throttler, since a broad crawl sees unboundedly many domains.
func NewCircuitBreaker ¶
func NewCircuitBreaker(threshold int, cooldown time.Duration, maxSize int) *CircuitBreaker
NewCircuitBreaker builds a breaker. A threshold of zero or less disables it.
func (*CircuitBreaker) Allow ¶
func (cb *CircuitBreaker) Allow(domain string) bool
Allow reports whether a request to domain may be attempted.
An open circuit lets exactly one probe through once the cooldown has elapsed. Letting several through would re-hammer a site that has not recovered, and the answer from one probe is as informative as the answer from fifty.
func (*CircuitBreaker) Enabled ¶
func (cb *CircuitBreaker) Enabled() bool
Enabled reports whether the breaker will reject anything.
func (*CircuitBreaker) RecordFailure ¶
func (cb *CircuitBreaker) RecordFailure(domain string) breakerState
RecordFailure reports a failed fetch. Returns the resulting state, so the caller can log the closed→open transition rather than every failure.
func (*CircuitBreaker) RecordSuccess ¶
func (cb *CircuitBreaker) RecordSuccess(domain string)
RecordSuccess reports a successful fetch, closing the circuit.
func (*CircuitBreaker) State ¶
func (cb *CircuitBreaker) State(domain string) breakerState
State returns a domain's current circuit state. For tests and diagnostics.
type Deduper ¶
type Deduper interface {
// MarkIfUnseen atomically claims a URL, reporting whether it was new.
MarkIfUnseen(rawURL string) bool
// IsSeen reports whether a URL has been claimed.
IsSeen(rawURL string) bool
// Count returns how many unique URLs have been claimed.
Count() int
// Export and Import carry state across a checkpoint.
Export() []string
Import(hashes []string)
// Reset clears all state.
Reset()
}
Deduper is the deduplication strategy the engine uses to decide whether a URL has already been claimed.
Two implementations, with genuinely different trades:
- Deduplicator keeps every URL hash in a map. Exact, and costs roughly 40 bytes per URL, so a billion-URL crawl needs tens of gigabytes.
- BloomDeduplicator keeps a Bloom filter and nothing else. Roughly 1.8 bytes per URL at a 1% false-positive rate, and *lossy*: a false positive means a URL is treated as already-seen and never crawled.
type Deduplicator ¶
type Deduplicator struct {
// contains filtered or unexported fields
}
Deduplicator tracks visited URLs to avoid re-crawling.
func NewDeduplicator ¶
func NewDeduplicator(estimatedCapacity int) *Deduplicator
NewDeduplicator creates a new Deduplicator with the given estimated capacity.
func (*Deduplicator) Count ¶
func (d *Deduplicator) Count() int
Count returns the number of unique URLs seen.
func (*Deduplicator) Export ¶
func (d *Deduplicator) Export() []string
Export returns all seen URL hashes (for checkpoint serialization).
func (*Deduplicator) Import ¶
func (d *Deduplicator) Import(hashes []string)
Import loads URL hashes (for checkpoint restore).
func (*Deduplicator) IsSeen ¶
func (d *Deduplicator) IsSeen(rawURL string) bool
IsSeen returns true if the URL (after canonicalization) has been seen before.
func (*Deduplicator) MarkIfUnseen ¶
func (d *Deduplicator) MarkIfUnseen(rawURL string) bool
MarkIfUnseen atomically marks a URL as seen and reports whether it was new.
This is the method the crawl path must use. Calling IsSeen followed by MarkSeen is a check-then-act race: two workers extracting the same link from two different pages can both observe "unseen" between the read lock and the write lock, and both enqueue it. The result is duplicate fetches, duplicate items, and a request budget spent twice on the same page.
func (*Deduplicator) MarkSeen ¶
func (d *Deduplicator) MarkSeen(rawURL string)
MarkSeen marks a URL as seen.
type DomainGate ¶
type DomainGate interface {
// Ready reports whether the domain could be dispatched now, without
// consuming anything. Used while scanning candidates, so that inspecting a
// request the worker then declines does not spend its budget.
Ready(domain string) bool
// Claim consumes the domain's allowance and reports whether it succeeded.
// Called only for the request actually being taken.
Claim(domain string) bool
// Delay reports how long until the domain is ready.
Delay(domain string) time.Duration
}
DomainGate decides whether a request to a given domain may be dispatched now. Throttler implements it; a nil gate means everything is always ready.
type DomainStats ¶
DomainStats tracks per-domain statistics.
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine is the core crawler orchestrator.
func (*Engine) AddRequest ¶
AddRequest adds a request to the crawl frontier.
func (*Engine) ClearCheckpoint ¶
ClearCheckpoint removes the checkpoint file. Called after a crawl finishes normally, so the next run does not resume work that is already done.
func (*Engine) HasCheckpoint ¶
HasCheckpoint reports whether a checkpoint from a previous run is available.
func (*Engine) OnResponse ¶
func (e *Engine) OnResponse(name string, cb ResponseCallback)
OnResponse registers a named callback for response processing.
func (*Engine) ResultsChan ¶
ResultsChan returns a channel that receives a copy of every scraped item.
Each call registers an independent subscriber, so multiple consumers each see the full stream and none of them competes with storage. Previously this handed back the same channel storeResults was draining, which meant every item went to exactly one of them at random: reading the "results" silently corrupted the output file.
Call this before Start. Subscribers registered mid-crawl only see items scraped after they register. The channel is closed when the crawl finishes.
A slow subscriber applies backpressure to the crawl once its buffer fills, which is deliberate — dropping items to keep a consumer fast is a worse failure than slowing down.
func (*Engine) ResumeFromCheckpoint ¶
ResumeFromCheckpoint restores frontier, dedup, and stats from the last checkpoint. Distinct from Resume, which un-pauses a running engine.
Call before Start and before adding seeds. Checkpointing was previously write-only: Save ran on a ticker and Load had no caller outside tests, so the crawler produced checkpoint files that nothing ever read.
Seeds added after a resume are still filtered through the restored dedup set, so re-running the same command with the same seeds does not re-crawl what the previous run already covered — the restored frontier is the remaining work, and the seeds are a no-op unless they are genuinely new.
func (*Engine) SetFetcher ¶
SetFetcher registers a fetcher for a given type.
func (*Engine) SetMetrics ¶
func (e *Engine) SetMetrics(m *observability.Metrics)
SetMetrics attaches a Prometheus metrics recorder. Call before Start. Passing nil (or never calling this) disables metric recording.
func (*Engine) SetPipeline ¶
SetPipeline sets the pipeline implementation.
func (*Engine) SetStorage ¶
SetStorage sets the storage implementation.
func (*Engine) Wait ¶
func (e *Engine) Wait()
Wait blocks until all work is done.
Safe to call more than once: the shutdown body runs under a sync.Once, so a second Wait blocks until the first has finished rather than panicking on a double channel close. Callers reasonably treat Wait as idempotent, and "close of closed channel" is a poor way to find out otherwise.
type Fetcher ¶
type Fetcher interface {
Fetch(ctx context.Context, req *types.Request) (*types.Response, error)
Close() error
}
Fetcher is the interface for all fetcher implementations.
type Frontier ¶
type Frontier struct {
// contains filtered or unexported fields
}
Frontier is a thread-safe priority queue of crawl requests.
Waiting is event-driven: a blocked Pop parks on a channel and is woken by the Push that gave it work, or by Close. The previous implementation polled the heap every 50 ms, which put a ~25 ms median floor under every dequeue and burned two lock acquisitions per worker per second while idle.
func (*Frontier) Close ¶
func (f *Frontier) Close()
Close closes the frontier, unblocking every waiting Pop. Safe to call more than once — the idle monitor and an explicit Stop can both reach it.
func (*Frontier) Pop ¶
Pop removes and returns the highest-priority request, blocking until one is available, the frontier is closed, or ctx is cancelled. Returns nil in the latter two cases.
func (*Frontier) PopReady ¶
PopReady removes and returns the highest-priority request whose domain is ready to be fetched, blocking until one exists, the frontier closes, or ctx is done.
This is what stops a throttled domain from parking a worker. The old scheduler dequeued first and *then* slept out the politeness delay while holding the domain's lock — so one slow domain occupied every worker slot in turn and every other domain starved. Here a worker skips past a throttled domain to whatever else is runnable, and only waits when nothing at all is.
func (*Frontier) RestoreAll ¶
RestoreAll adds multiple requests back (for checkpoint restore).
type Parser ¶
type Parser interface {
Parse(resp *types.Response, rules []config.ParseRule) ([]*types.Item, []string, error)
}
Parser is the interface for all parser implementations.
type ResponseCallback ¶
ResponseCallback is a function called when a response is received.
type RobotsManager ¶
type RobotsManager struct {
// contains filtered or unexported fields
}
RobotsManager handles robots.txt fetching, parsing, and enforcement.
func NewRobotsManager ¶
func NewRobotsManager(enabled bool, guard *safety.URLGuard) *RobotsManager
NewRobotsManager creates a new RobotsManager.
guard may be nil, in which case the default policy applies. The robots.txt URL is derived from the crawl target's own host, so it is exactly as untrusted as the page it governs and needs the same guarded client.
func (*RobotsManager) GetCrawlDelay ¶
func (rm *RobotsManager) GetCrawlDelay(domain string) time.Duration
GetCrawlDelay returns the crawl-delay for a domain, if specified.
func (*RobotsManager) GetSitemaps ¶
func (rm *RobotsManager) GetSitemaps(domain string) []string
GetSitemaps returns the sitemaps listed in robots.txt for a domain.
func (*RobotsManager) IsAllowed ¶
func (rm *RobotsManager) IsAllowed(rawURL string) bool
IsAllowed checks if a URL is allowed by the domain's robots.txt.
type ScaleAction ¶
type ScaleAction int
ScaleAction represents a scaling direction.
const ( ScaleNone ScaleAction = iota ScaleUp ScaleDown )
type ScaleDecision ¶
type ScaleDecision struct {
Current int
Desired int
Utilization float64
LoadScore float64
QueueSize int
Action ScaleAction
}
ScaleDecision describes the outcome of an Evaluate call.
type Scheduler ¶
type Scheduler struct {
// contains filtered or unexported fields
}
Scheduler manages worker goroutines that dequeue from the frontier and dispatch fetches.
func (*Scheduler) Pause ¶
func (s *Scheduler) Pause()
Pause pauses all workers.
The gate channel is swapped under pauseMu rather than reassigned in place: workers read it in a select, and an unsynchronised write both races with that read and can park a worker on the *new* channel, which the matching Resume has already closed past — a permanent missed wakeup.
type Stats ¶
type Stats struct {
RequestsSent atomic.Int64
RequestsFailed atomic.Int64
ResponsesOK atomic.Int64
ResponsesError atomic.Int64
ItemsScraped atomic.Int64
ItemsDropped atomic.Int64
URLsEnqueued atomic.Int64
URLsFiltered atomic.Int64
BytesDownloaded atomic.Int64
ActiveWorkers atomic.Int32
StartTime time.Time
// contains filtered or unexported fields
}
Stats tracks crawl statistics.
type Throttler ¶
type Throttler struct {
// contains filtered or unexported fields
}
Throttler enforces politeness per registrable domain.
It replaces a `time.Sleep` performed while holding the domain's mutex, after the worker had already dequeued a request. That arrangement had two costs. The sleep occupied a worker slot, so with `politeness_delay: 1s` and 100 workers on one domain the hundredth worker blocked for ~100 seconds doing nothing. And because the delay was applied after dequeue rather than at scheduling time, a single slow domain parked the entire pool and starved every *other* domain — effective concurrency on a single-domain crawl collapsed to one.
Two things change here. Waiting is done by a rate.Limiter rather than a sleep under a lock, so the domain's own mutex is never held across the wait and concurrent callers for different domains never contend. And Allow lets a worker ask whether a domain is ready *before* committing to it, so the scheduler can skip a throttled domain instead of parking on it.
func NewThrottler ¶
NewThrottler builds a throttler enforcing one request per delay per domain. A delay of zero or less disables throttling entirely.
func (*Throttler) Allow ¶
Allow reports whether a request to domain may proceed immediately, consuming a token if so. It never blocks, so a worker can use it to decide whether to take a request or leave it for later without ever parking on a slow domain.
func (*Throttler) Len ¶
Len returns the number of tracked domains. Used by tests to assert eviction.
func (*Throttler) Ready ¶
Ready reports whether domain could be dispatched now, without consuming its allowance. The scan in Frontier.PopReady inspects many candidates and takes at most one, so checking must not spend anything.
func (*Throttler) Reserve ¶
Reserve returns how long a request to domain must wait, without consuming a token. Used to decide how long to park when nothing is runnable, and to scan candidates the worker may not end up taking.
Deliberately not built on ReserveN+Cancel: rate.Reservation.Cancel restores the token only when the reservation's act time is still in the future, so cancelling a zero-delay reservation is a no-op and the token stays spent. That made the "non-consuming" probe consume one token per candidate inspected.
func (*Throttler) Wait ¶
Wait blocks until a request to domain may proceed, or ctx is done.
This is the fallback for when the scheduler has already committed to a request — the frontier held nothing else runnable — so someone has to wait. Unlike the old sleep, it holds no lock while waiting, so other domains are unaffected.