crawler

package
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 29, 2026 License: MIT Imports: 29 Imported by: 0

Documentation

Overview

Package crawler implements gocrawl's concurrent crawl engine. It fetches pages within a configurable scope, captures redirect chains and robots.txt data, and produces a Result that analyzers consume. The engine has no knowledge of specific SEO/SEA checks.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func SanitizeSeed added in v0.4.0

func SanitizeSeed(raw string) (seed, user, pass string)

SanitizeSeed strips any userinfo (user:pass@) embedded in a seed URL and returns it separately, so callers can route it through Options.BasicAuthUser/BasicAuthPass instead of letting it ride along in the URL string — where it would propagate into Result.Seed, every report format, and `gocrawl history`, and (via Go's URL-resolution semantics) into every link resolved against the seed once crawled. raw is expected to already have a scheme (callers prefix a bare host with "https://" before calling this).

Types

type AdaptiveFetcher added in v0.4.0

type AdaptiveFetcher struct {
	Fetcher Fetcher
	Limiter *AdaptiveLimiter
}

AdaptiveFetcher wraps a Fetcher so every call waits on Limiter beforehand and lets it observe the response afterward. This is how tools that fetch a bounded list of URLs directly (rather than crawling through Engine) still back off politely on 429/503.

func (*AdaptiveFetcher) Fetch added in v0.4.0

func (f *AdaptiveFetcher) Fetch(ctx context.Context, rawURL string) (*Page, error)

Fetch implements Fetcher.

type AdaptiveLimiter added in v0.4.0

type AdaptiveLimiter struct {
	// contains filtered or unexported fields
}

AdaptiveLimiter is a requests-per-second limiter that halves its rate (down to a floor) whenever a fetch reports HTTP 429 or 503, honoring any Retry-After header, and debounces bursts of concurrent triggers into a single adjustment. It is shared by anything that fetches a live site directly — the crawl Engine and tools like check-redirects — so a run that starts too aggressively for the target backs off instead of hammering it into a bot-mitigation block.

func NewAdaptiveLimiter added in v0.4.0

func NewAdaptiveLimiter(ratePerSecond float64, enabled bool) *AdaptiveLimiter

NewAdaptiveLimiter creates a limiter starting at ratePerSecond (0 = unlimited). Wait always enforces the current rate; when enabled is false, OnResponse never backs it off.

func (*AdaptiveLimiter) CurrentRate added in v0.4.0

func (a *AdaptiveLimiter) CurrentRate() float64

CurrentRate reports the rate in effect after any backoff (0 if still unrestricted).

func (*AdaptiveLimiter) OnResponse added in v0.4.0

func (a *AdaptiveLimiter) OnResponse(statusCode int, header http.Header) (adjusted bool, newRate float64)

OnResponse inspects a fetch outcome and, if it signals overload (429/503), halves the current rate down to backoffMinRate, never faster than any Retry-After header demands. It reports whether this call triggered an adjustment (false if disabled, the status wasn't 429/503, or a trigger already landed within backoffDebounce) and the resulting rate.

func (*AdaptiveLimiter) ThrottleCount added in v0.4.0

func (a *AdaptiveLimiter) ThrottleCount() int

ThrottleCount reports how many times OnResponse has backed off the rate.

func (*AdaptiveLimiter) Wait added in v0.4.0

func (a *AdaptiveLimiter) Wait(ctx context.Context) error

Wait blocks until the current rate allows another request.

type CertInfo added in v0.5.0

type CertInfo struct {
	Subject            string
	Issuer             string
	NotBefore          time.Time
	NotAfter           time.Time
	SignatureAlgorithm x509.SignatureAlgorithm
	// KeyType and KeyBits describe the public key ("RSA"/2048, "ECDSA"/256, "Ed25519"/256).
	// KeyBits is 0 for a key type we don't measure.
	KeyType string
	KeyBits int
	IsCA    bool
	// SelfSigned is true when issuer and subject are the same distinguished name — either a
	// root CA at the top of the chain, or an untrusted self-signed server certificate.
	SelfSigned bool
}

CertInfo is the subset of an X.509 certificate the security audit reads. The full certificate is not retained: the audit only needs identity, validity window, and the strength of the signature and key.

type Cookie struct {
	Name   string
	Domain string
	Path   string
	// Session is true for a cookie with no expiry, which the browser drops when it closes.
	Session bool
	// Expires is the cookie's expiry, zero for a session cookie.
	Expires  time.Time
	Secure   bool
	HTTPOnly bool
	SameSite string
}

Cookie is one entry of the browser's cookie jar, captured during a headless render. It carries what a compliance check needs — identity, scope, and lifetime — and never the value, which is frequently a personal identifier and has no place in a report.

type Coverage added in v0.2.1

type Coverage struct {
	// Complete is true when no in-scope, robots-allowed URL was left un-fetched because of a
	// depth or page-count limit.
	Complete bool `json:"complete"`
	// DiscoveredNotCrawled is the number of distinct in-scope URLs that were discovered but
	// never fetched because a limit was reached.
	DiscoveredNotCrawled int `json:"discovered_not_crawled,omitempty"`
	// PageLimitReached / DepthLimitReached record which configured limit cut the crawl short.
	PageLimitReached  bool `json:"page_limit_reached,omitempty"`
	DepthLimitReached bool `json:"depth_limit_reached,omitempty"`
	// Interrupted is true when the crawl's context was canceled before it finished on its own
	// (e.g. an operator's Ctrl-C) rather than a configured limit being reached. The site may
	// be far less covered than DiscoveredNotCrawled reflects, since much of it may never have
	// been discovered at all.
	Interrupted bool `json:"interrupted,omitempty"`
	// DurationLimitReached is true when the crawl stopped because it hit its --max-duration
	// wall-clock budget rather than being interrupted externally. Also implies Interrupted,
	// since the same context-cancellation path stops the crawl either way.
	DurationLimitReached bool `json:"duration_limit_reached,omitempty"`
	// MaxPages / MaxDepth echo the limits in effect (0 = unlimited), for the report message.
	MaxPages int `json:"max_pages,omitempty"`
	MaxDepth int `json:"max_depth,omitempty"`
}

Coverage summarizes how much of the in-scope site the crawl actually fetched. It exists so the report can warn that "0 broken links" might mean "the broken ones weren't reached" rather than "the site is clean".

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine crawls a website concurrently within the configured scope.

func New

func New(opts Options, fetcher Fetcher) *Engine

New creates an Engine. fetcher is used to retrieve pages (raw or headless); a separate raw fetcher is always used for robots.txt regardless of render mode.

func (*Engine) Crawl

func (e *Engine) Crawl(ctx context.Context, seed string) (*Result, error)

Crawl walks the site starting at seed and returns the collected Result.

type Fetcher

type Fetcher interface {
	Fetch(ctx context.Context, rawURL string) (*Page, error)
}

Fetcher retrieves a single URL and returns a populated Page. Implementations include the raw HTTPFetcher and the (stubbed) headless renderer.

type HTTPFetcher

type HTTPFetcher struct {
	// contains filtered or unexported fields
}

HTTPFetcher fetches pages over HTTP(S). It follows redirects manually so the full redirect chain is recorded on the resulting Page.

func NewHTTPFetcher

func NewHTTPFetcher(opts Options) *HTTPFetcher

NewHTTPFetcher builds a fetcher from the given options. When opts.Proxies is non-empty the client routes each request through a proxy chosen by opts.ProxyRotation; otherwise Go's default proxy behavior (environment variables) applies. The User-Agent header is chosen per request from opts.UserAgents / opts.UserAgent via opts.UserAgentRotation.

func (*HTTPFetcher) Fetch

func (f *HTTPFetcher) Fetch(ctx context.Context, rawURL string) (*Page, error)

Fetch retrieves rawURL, following redirects manually up to maxRedirects.

func (*HTTPFetcher) RestrictBasicAuthToHost added in v0.6.0

func (f *HTTPFetcher) RestrictBasicAuthToHost(seedHost string, allowSubdomains bool)

RestrictBasicAuthToHost limits this fetcher's Basic Auth to requests whose host is seedHost, or one of its subdomains when allowSubdomains is set — the same scope rule the crawl itself uses (see Engine.authHostAllowed). Exported so packages outside crawler that build their own HTTPFetcher for crawl-scoped work (currently runner.Run, for the sitemap/geo/wordpress analyzers) can apply the same restriction Engine.New wires onto its own fetchers.

type Link struct {
	URL      string `json:"url"`                // absolute, normalized for dedup (trailing slash stripped)
	Resolved string `json:"resolved,omitempty"` // absolute, normalized but trailing slash preserved
	Anchor   string `json:"anchor"`             // visible text
	Rel      string `json:"rel"`
	Nofollow bool   `json:"nofollow"`
	External bool   `json:"external"` // points outside the seed host
}

Link is an outbound link discovered on a page.

type Options

type Options struct {
	MaxDepth      int
	MaxPages      int
	Concurrency   int
	RatePerSecond float64
	UserAgent     string
	// UserAgents is an optional pool of User-Agent strings to rotate across. When non-empty it
	// supersedes UserAgent; UserAgentRotation picks one per request.
	UserAgents        []string
	UserAgentRotation RotationStrategy
	// Proxies is an optional pool of proxy URLs (http, https, or socks5) to route requests
	// through. When non-empty, ProxyRotation picks one per request. Empty leaves Go's default
	// proxy behavior (honoring HTTP_PROXY/HTTPS_PROXY/NO_PROXY) in place.
	Proxies       []*url.URL
	ProxyRotation RotationStrategy
	// BasicAuthUser and BasicAuthPass, when BasicAuthUser is non-empty, are sent as an HTTP
	// Basic Authorization header on every request. This targets server-level Basic Auth (the
	// realm challenge common on staging/acceptance environments), sent preemptively rather
	// than in response to a 401 challenge.
	BasicAuthUser   string
	BasicAuthPass   string
	Include         []*regexp.Regexp
	Exclude         []*regexp.Regexp
	RespectRobots   bool
	AllowSubdomains bool
	FollowExternal  bool
	FollowNofollow  bool
	// StripQuery drops the query string when normalizing URLs, so URLs that differ only by
	// their query collapse to one and are crawled once.
	StripQuery   bool
	Timeout      time.Duration
	MaxBodyBytes int64
	MaxRedirects int
	// Verbose logs each fetch and every rate-limit change to stderr while crawling.
	Verbose bool
	// AdaptiveDelay automatically slows the crawl (halving requests-per-second, honoring any
	// Retry-After header) when the server responds with HTTP 429 or 503.
	AdaptiveDelay bool
	// MaxDuration bounds the crawl's total wall-clock time. Zero means unlimited. When it
	// elapses, the crawl stops early and still returns everything fetched so far as a
	// partial result (see Coverage.DurationLimitReached), the same way a canceled context
	// (e.g. an operator's Ctrl-C) does.
	MaxDuration time.Duration
}

Options controls crawl scope and politeness.

func DefaultOptions

func DefaultOptions() Options

DefaultOptions returns conservative, polite defaults. By default the crawl is bounded by the total page budget (MaxPages), not by link depth (MaxDepth = 0 = unlimited): a shallow depth cap silently hides whole sections of a site — and the broken links in them — whereas a page budget walks the site breadth-first and stops at a predictable, reported size.

type Page

type Page struct {
	RequestedURL string      `json:"requested_url"`
	FinalURL     string      `json:"final_url"`
	StatusCode   int         `json:"status_code"`
	Header       http.Header `json:"-"`
	// TLS records the handshake behind the final response: protocol version, cipher suite, and
	// the certificate chain the server presented. Nil for plain HTTP, and in headless render
	// mode, where the response never passes through Go's TLS stack.
	TLS         *TLSInfo          `json:"-"`
	ContentType string            `json:"content_type"`
	Body        []byte            `json:"-"`
	RawBody     []byte            `json:"-"` // pre-JS HTML captured during headless render; nil in raw mode
	Doc         *goquery.Document `json:"-"` // nil if not HTML or parse failed
	Redirects   []Redirect        `json:"redirects,omitempty"`
	Links       []Link            `json:"-"`
	Depth       int               `json:"depth"`
	Referrer    string            `json:"referrer,omitempty"`
	Duration    time.Duration     `json:"duration_ms"`
	FetchedAt   time.Time         `json:"fetched_at"`
	Err         string            `json:"error,omitempty"`
	Render      *RenderResult     `json:"render,omitempty"`
	// Truncated reports whether Body was cut short of the real response — either because it
	// hit the fetcher's body-size cap or because the connection failed partway through the
	// read. A truncated body may be missing elements (e.g. </head>, the closing tag of a
	// sitemap) that a downstream analyzer would otherwise expect to find, so treat findings
	// like "missing title" or "invalid sitemap" on a truncated page with that in mind.
	Truncated bool `json:"truncated,omitempty"`
}

Page is the unit passed from the engine to analyzers.

func (*Page) IsHTML

func (p *Page) IsHTML() bool

IsHTML reports whether the page body was parsed as an HTML document.

type Redirect

type Redirect struct {
	From   string `json:"from"`
	To     string `json:"to"`
	Status int    `json:"status"`
}

Redirect is one hop in a redirect chain.

type RenderResult

type RenderResult struct {
	Implemented bool   `json:"implemented"`
	Note        string `json:"note,omitempty"`
	// RawFallback is set when the rendered DOM came back substantially thinner than the raw
	// HTML — a sign the page had not finished rendering when it was snapshotted. The analyzed
	// Body/Doc are then taken from the raw fetch (so structural checks like the H1 and meta
	// tags aren't false-negatives), while the Core Web Vitals below stay from the render but
	// should be treated as unreliable for this page. RenderedBytes/RawBytes record the sizes
	// that triggered it.
	RawFallback   bool    `json:"raw_fallback,omitempty"`
	RenderedBytes int     `json:"rendered_bytes,omitempty"`
	RawBytes      int     `json:"raw_bytes,omitempty"`
	LCP           float64 `json:"lcp_ms,omitempty"`
	FCP           float64 `json:"fcp_ms,omitempty"`
	CLS           float64 `json:"cls,omitempty"`
	TBT           float64 `json:"tbt_ms,omitempty"`
	TTFB          float64 `json:"ttfb_ms,omitempty"`

	// DataLayerPresent reports whether window.dataLayer was an array after the page rendered.
	DataLayerPresent bool `json:"data_layer_present,omitempty"`
	// DataLayer is the post-render snapshot of window.dataLayer, each element the raw JSON of
	// one entry (a GTM event push or a gtag() arguments object). It is deliberately not
	// serialized into reports: it can carry PII, so the datalayer analyzer emits sanitized
	// findings from it instead. Nil in raw mode or when the page has no dataLayer.
	DataLayer []json.RawMessage `json:"-"`
	// Requests holds outbound request URLs observed during render, bounded to avoid unbounded
	// growth. The datalayer analyzer uses it to confirm analytics/marketing tags actually
	// fired a network beacon. Not serialized.
	Requests []string `json:"-"`
	// Cookies is the browser's cookie jar after the page settled — every cookie actually
	// stored, first- and third-party, however it was set (Set-Cookie header, document.cookie,
	// or a third-party script). Response headers alone cannot see the last two, which is why
	// this is captured separately.
	//
	// gocrawl never interacts with a consent banner, so this jar is the site's *pre-consent*
	// state: the consent analyzer reads it to find tracking cookies dropped before a visitor
	// has agreed to anything. The jar belongs to the browser, not the tab, so it accumulates
	// across the pages of one crawl — treat it as a site-level observation rather than proof
	// that a particular page set a particular cookie. Nil in raw mode.
	Cookies []Cookie `json:"-"`
}

RenderResult holds headless-rendering output, including lab-mode Core Web Vitals. Implemented is true when chromedp produced real measurements; false when rendering fell back to a raw fetch (Note carries the reason). Metric times are milliseconds; CLS is the unitless layout-shift score. Zero means not collected.

INP is a field-only metric and is not collected in lab mode. TBT (Total Blocking Time) is reported as a lab-mode proxy for responsiveness, matching Lighthouse.

type Result

type Result struct {
	Seed      string                 `json:"seed"`
	Pages     []*Page                `json:"pages"`
	Robots    map[string]*RobotsData `json:"robots,omitempty"`
	StartedAt time.Time              `json:"started_at"`
	Finished  time.Time              `json:"finished_at"`
	Opts      Options                `json:"-"`

	// ThrottleEvents counts how many times adaptive delay reduced the crawl rate after HTTP
	// 429/503 responses. FinalRate is the requests-per-second in effect when the crawl ended;
	// it is meaningful only when ThrottleEvents > 0.
	ThrottleEvents int     `json:"throttle_events,omitempty"`
	FinalRate      float64 `json:"final_rate,omitempty"`

	// Coverage reports whether the crawl visited every in-scope URL it discovered, or stopped
	// at a configured limit. When not Complete, findings that depend on fetching a page —
	// broken links above all — may be incomplete.
	Coverage Coverage `json:"coverage"`
	// contains filtered or unexported fields
}

Result is the complete output of a crawl, consumed by analyzers.

func (*Result) LinkPointsToRedirect added in v0.2.2

func (r *Result) LinkPointsToRedirect(resolved string, target *Page) bool

LinkPointsToRedirect reports whether an internal link whose resolved (slash-preserving) target is `resolved` points at `target`, a crawled page that redirects to a genuinely different URL. It returns false when the page's only redirect is the trailing slash the crawl index strips before fetching, so a link authored with the site's canonical trailing slash (e.g. WordPress permalinks) is not reported as pointing at a redirect.

func (*Result) Page

func (r *Result) Page(rawURL string) (*Page, bool)

Page looks up a crawled page by URL (matched against requested and final URLs after normalization). The second return value reports whether it was found.

func (*Result) Reindex

func (r *Result) Reindex()

Reindex rebuilds the URL lookup index from r.Pages. The engine populates the index incrementally during a crawl, so production code never needs this; it lets callers that construct a Result by hand (notably tests) make Page lookups resolve.

func (*Result) ReleaseBodies added in v0.4.0

func (r *Result) ReleaseBodies()

ReleaseBodies drops every page's Body, RawBody, and parsed Doc, freeing the memory they hold. Call this once analysis and report-building have both finished reading pages — nothing in this codebase reads a page's body or DOM afterward, and a large crawl of heavy pages can otherwise hold multiple GB of raw bytes and parsed DOM trees in memory for the rest of the process's life. That matters most for a long-lived caller handling many crawls in one process (e.g. the MCP server), where the memory would otherwise accumulate across calls until the next GC cycle catches up.

func (*Result) ResolveHref added in v0.4.0

func (r *Result) ResolveHref(from *Page, href string) (target *Page, resolved string, ok bool)

ResolveHref resolves href relative to from's own URL (mirroring how the crawl engine resolves an <a href> against a page's base — including an in-document <base href>, if any — via extractLinks) and looks up the resulting page in the crawl result. It exists for analyzers that read a raw href straight from the DOM (e.g. <link rel="amphtml"|"next"|"prev">) rather than from the engine's own extracted Links, so a relative href is resolved the same way an anchor link would be instead of failing the lookup outright. resolved is the slash-preserving absolute form, suitable for LinkPointsToRedirect. ok is false for an unusable href (empty, fragment-only, non-http(s) after resolution) or one with no match in the crawl.

type RobotsData

type RobotsData struct {
	Host     string   `json:"host"`
	Found    bool     `json:"found"`
	Status   int      `json:"status"`
	Sitemaps []string `json:"sitemaps,omitempty"`
	// contains filtered or unexported fields
}

RobotsData is the parsed robots.txt for a single host.

func (*RobotsData) TestAgent

func (r *RobotsData) TestAgent(path, userAgent string) bool

TestAgent reports whether the given path is allowed for userAgent. Per RFC 9309, a 4xx robots.txt response means no rules apply (allow all); an unreachable robots.txt (network error or 5xx) means we can't know the site's intent, so crawling is disallowed until it's reachable. A 200 response we simply couldn't parse degrades to allow-all.

type RotationStrategy

type RotationStrategy int

RotationStrategy selects how a pool (of proxies or User-Agent strings) picks an entry for a given request. It is shared by the proxy pool and the User-Agent pool so both rotate with identical semantics.

const (
	// RotateRoundRobin cycles through the pool in order, one entry per request. It is the
	// default once a pool has more than one entry.
	RotateRoundRobin RotationStrategy = iota
	// RotateRandom picks a uniformly random entry per request.
	RotateRandom
	// RotateStickyHost maps each target host to a fixed entry (by hash), so every request to a
	// given host uses the same proxy/User-Agent. For proxies this avoids a server seeing a
	// single session arrive from several IPs.
	RotateStickyHost
	// RotateOff disables rotation: the first entry is always used. A pool with a single entry
	// behaves this way regardless of strategy.
	RotateOff
)

func ParseRotation

func ParseRotation(s string) (RotationStrategy, error)

ParseRotation converts a config string into a RotationStrategy. An empty string defaults to round-robin, so simply supplying a multi-entry pool rotates without extra configuration.

type TLSInfo added in v0.5.0

type TLSInfo struct {
	Version     uint16
	CipherSuite uint16
	// ALPN is the application protocol negotiated during the handshake ("h2", "http/1.1"), or
	// empty when the server offered none.
	ALPN string
	// Chain is the certificate chain the server presented, leaf first. It is what the server
	// actually sent, not the verified chain, so a server that omits its intermediates shows up
	// here as a one-element chain even though the fetch succeeded.
	Chain []CertInfo
}

TLSInfo is a snapshot of the TLS handshake that carried a page's final response: the negotiated protocol version and cipher suite, the ALPN protocol, and the certificate chain the server presented (leaf first). The engine captures it and knows nothing more about it; interpreting it is the security analyzer's opt-in audit mode's job.

It is deliberately not serialized into reports: every page of a crawl shares one host's handshake, so repeating an identical chain per page would bloat the JSON for no benefit. Findings derived from it travel as analyze.Issue values instead.

func (*TLSInfo) CipherName added in v0.5.0

func (t *TLSInfo) CipherName() string

CipherName renders the negotiated cipher suite ("TLS_AES_128_GCM_SHA256").

func (*TLSInfo) Leaf added in v0.5.0

func (t *TLSInfo) Leaf() *CertInfo

Leaf returns the server (end-entity) certificate, or nil when the chain is empty.

func (*TLSInfo) VersionName added in v0.5.0

func (t *TLSInfo) VersionName() string

VersionName renders the negotiated protocol version ("TLS 1.3").

type UAPool

type UAPool struct {
	// contains filtered or unexported fields
}

UAPool selects a User-Agent string per request. It is built from Options and shared by the raw HTTP fetcher and the headless renderer so both rotate identically. A nil pool, or one with no agents, yields the empty string (callers then leave the header unset).

func NewUAPool

func NewUAPool(opts Options) *UAPool

NewUAPool builds a User-Agent pool from opts. When UserAgents is set it is the pool; otherwise the single UserAgent (if any) is the only entry.

func (*UAPool) Default

func (p *UAPool) Default() string

Default returns the pool's first agent without advancing rotation state. The headless renderer uses it as the browser-level User-Agent; per-navigation rotation is layered on top.

func (*UAPool) Next

func (p *UAPool) Next(host string) string

Next returns the User-Agent to use for a request to host, advancing rotation state as the strategy requires.

func (*UAPool) Rotates

func (p *UAPool) Rotates() bool

Rotates reports whether the pool has more than one agent (i.e. rotation can occur).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL