fetch

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 13, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package fetch retrieves web content under egress, politeness, and rate controls, and classifies every attempt.

The guards are not bolted on around a fetcher — they are the fetcher. §3.3 and §3.4 require them present from the first commit precisely because retrofitting them means auditing every call site that has since appeared.

Index

Constants

View Source
const DefaultUserAgent = "Mole/0.1 (+https://github.com/lajosdeme/mole)"

DefaultUserAgent identifies the crawler and points at the project.

View Source
const MaxCrawlDelay = 30 * time.Second

MaxCrawlDelay bounds what a site's Crawl-delay can cost us.

View Source
const MinUsableText = 250

MinUsableText is the extracted-character floor below which a page is treated as having produced nothing. Short legitimate pages exist, but below this a summarizer has nothing to work with either way.

Exported because the extractor and the search providers apply the same floor; three independent copies of this number would drift.

Variables

This section is empty.

Functions

func DefaultDenyDomains

func DefaultDenyDomains() []string

DefaultDenyDomains ships with the binary. These are the sites whose terms clearly disallow automated access — the ones rev 1 removed first-party scrapers for. Removing the scraper without denying the domain would have left the exposure exactly where it was, just reached by a different code path.

func DomainOf

func DomainOf(rawURL string) string

registrableish reduces a host to something stable enough to group by in the per-cause domain ranking (§10.4).

It is NOT a public-suffix implementation: "bbc.co.uk" reduces to "co.uk". That is acceptable for ranking which domains cause which failures, and a real PSL is a dependency this does not yet justify. Revisit if the M2 report turns out to be misleading because of it. DomainOf reduces a URL to the same grouping key Result.Domain carries, so callers recording an outcome for a URL they never fetched bucket it identically to one they did.

func HasStructuredData

func HasStructuredData(html string) bool

HasStructuredData reports whether the document carries content in a machine-readable block that a parser could recover without executing JS.

OpenGraph deliberately does not qualify. og:title and og:description are metadata a page emits alongside its content, capped at a sentence or two — no parser turns them into the MinUsableText characters that would make this fetch OK. Counting them here filed every SPA as structured_only and held js_required near zero, which is the one number §17.1's decision reads.

func ProviderSupplied

func ProviderSupplied(outcomes []*store.FetchOutcome) map[string]bool

ProviderSupplied is the set of URLs whose text came from the search provider rather than a fetch (§10.4).

Shared because re-reading one is a mistake two callers independently have to avoid: the text was extracted by the provider, so a fresh HTML extraction of the same URL produces different bytes and any comparison against it manufactures a mismatch. Both eval's citation accuracy and §11.5's grounding check need exactly this set, and the verifier's copy was written citing eval's reasoning without sharing its code.

Types

type Config

type Config struct {
	// UserAgent identifies Mole. Required: an anonymous crawler is impolite and
	// gives site operators no way to contact anyone or block selectively.
	UserAgent string

	MaxBytes     int64
	Timeout      time.Duration
	MaxRedirects int

	// IgnoreRobots disables robots.txt. Off by default; every use is logged at
	// WARN so it cannot happen quietly.
	IgnoreRobots bool

	// RobotsTTL is how long a robots.txt is cached.
	RobotsTTL time.Duration

	// PerDomain is the default rate limit applied to each host.
	PerDomain limiter.Limit
}

Config tunes an HTTPFetcher.

type DomainDeny

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

DomainDeny matches hosts against a suffix list.

func NewDomainDeny

func NewDomainDeny(domains []string) *DomainDeny

func (*DomainDeny) Denied

func (d *DomainDeny) Denied(host string) bool

Denied reports whether host is on the list, matching the domain and any subdomain of it but not a domain that merely ends with the same letters ("notlinkedin.com" is not "linkedin.com").

type Fetcher

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

Fetcher retrieves a URL.

It returns a non-nil *Result even on failure, because the outcome of a failed fetch is data the project needs (§10.4) — a fetcher that returned only an error would throw away the input to the headless-browser decision.

type Guard

type Guard struct {
	// AllowedSchemes defaults to http and https.
	AllowedSchemes []string
	// AllowedPorts defaults to 80 and 443.
	AllowedPorts []int
	// ExtraDeny blocks additional ranges — an operator's internal supernets.
	ExtraDeny []netip.Prefix

	// AllowPrivateNetworks permits loopback and RFC1918 destinations.
	//
	// DANGEROUS. It exists so tests can reach an httptest server on 127.0.0.1
	// and so an operator can deliberately point Mole at an intranet. Never
	// enable it for a session that fetches attacker-influenced URLs.
	//
	// It unblocks loopback and RFC1918 and nothing else. Link-local,
	// multicast, the unspecified address, and every prefix in deniedPrefixes
	// stay refused — see CheckAddr. Cloud metadata stays unreachable either
	// way, including via its NAT64 and 6to4 encodings.
	AllowPrivateNetworks bool

	// Resolver defaults to net.DefaultResolver.
	Resolver *net.Resolver
	// DialTimeout defaults to 10s.
	DialTimeout time.Duration
}

Guard is the egress control on outbound fetches.

The Fetcher is the daemon's network position, and it fetches URLs chosen by a search engine and by an LLM. Without this, "summarize https://…" is a request to read the cloud metadata endpoint, an internal admin panel, or a service bound to loopback.

Two independent layers, because either alone has a hole:

  • CheckURL rejects on scheme, port, and literal-IP host. It runs per redirect hop, since a public URL that 302s to 169.254.169.254 is the standard bypass.
  • DialContext resolves the hostname, checks every returned address, and dials the address it checked. Checking and then handing the *name* to the dialer leaves a DNS-rebinding window where the second lookup returns a different address.

func (*Guard) CheckAddr

func (g *Guard) CheckAddr(addr netip.Addr) string

CheckAddr reports why an address is refused, or "" if it is allowed.

func (*Guard) CheckURL

func (g *Guard) CheckURL(u *url.URL) error

CheckURL validates scheme, port, and a literal-IP host. It does not resolve; DialContext does that, so that the check and the connection cannot disagree.

func (*Guard) DialContext

func (g *Guard) DialContext(ctx context.Context, network, address string) (net.Conn, error)

DialContext resolves, checks every address, and connects to one it checked.

If ANY resolved address is denied the whole dial is refused, rather than picking an allowed one. A host that round-robins between a public and a private address would otherwise be reachable on retry.

type GuardError

type GuardError struct {
	URL    string
	Host   string
	Addr   string
	Reason string
}

GuardError explains a refusal. Callers map it to FetchGuardDenied, which is deliberately not counted as evidence that a headless browser is needed (§17.1) — a blocked request is the system working.

func (*GuardError) Error

func (e *GuardError) Error() string

type HTTPFetcher

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

HTTPFetcher is the only web-fetch mechanism. There is no per-site adapter, and whether a headless browser is ever added is gated on §17.1.

func NewHTTP

func NewHTTP(cfg Config, opts Options) *HTTPFetcher

NewHTTP builds a fetcher. A nil Guard gets the default (deny private networks), which is the safe direction for a missing argument.

func (*HTTPFetcher) Fetch

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

Fetch retrieves rawURL.

func (*HTTPFetcher) Limiter

func (f *HTTPFetcher) Limiter() *limiter.Limiter

Limiter exposes the rate limiter so callers can tighten a specific host.

type Options

type Options struct {
	Guard *Guard
	Deny  *DomainDeny
	Lim   *limiter.Limiter
	Log   *slog.Logger

	// Transport lets the record/replay cassette layer wrap the guarded
	// transport, so tests are deterministic and cost nothing.
	Transport func(base http.RoundTripper) http.RoundTripper
}

Options are the collaborators an HTTPFetcher needs.

type Outcome

type Outcome string

Outcome classifies why a fetch did or did not yield usable text.

This taxonomy is cheap to record and is the only thing that turns "do we need a headless browser?" from an argument into a number. Recording it is an M1 deliverable precisely because adding it later means re-running the whole eval corpus to get the data.

const (
	OutcomeOK Outcome = "ok"

	// OutcomeProviderContent is a source read without fetching it, because the
	// search provider already returned usable page text.
	//
	// Distinct from ok on purpose. Every per-cause rate in §10.4 is a fraction
	// of attempted fetches, and filing these as ok inflated that denominator
	// with requests never made — dragging js_required and bot_block down by
	// however much of the corpus the provider happened to cover.
	OutcomeProviderContent Outcome = "provider_content"

	// OutcomeJSRequired is the SPA signature: the extractor produced almost
	// nothing, but the document is script-heavy and has an app-root element.
	// This is the ONLY outcome that counts as evidence for a headless browser.
	OutcomeJSRequired Outcome = "js_required"

	// OutcomeStructuredOnly means readability failed but __NEXT_DATA__,
	// JSON-LD, or OpenGraph carried the content. A day of parser work converts
	// these into OK — which is why they must not be lumped in with js_required.
	OutcomeStructuredOnly Outcome = "structured_only"

	OutcomeConsentWall Outcome = "consent_wall"
	OutcomeBotBlock    Outcome = "bot_block"
	OutcomePaywall     Outcome = "paywall"
	OutcomeNotFound    Outcome = "not_found"
	OutcomeTimeout     Outcome = "timeout"

	// OutcomeRobotsDenied and OutcomeGuardDenied are the system working
	// correctly. They are separate constants so they can be excluded from any
	// "failure rate" that informs a capability decision; folding them in would
	// inflate the apparent case for a browser.
	OutcomeRobotsDenied Outcome = "robots_denied"
	OutcomeGuardDenied  Outcome = "guard_denied"

	OutcomeExtractFailed Outcome = "extract_failed"
	OutcomeServerError   Outcome = "server_error"
	OutcomeTooLarge      Outcome = "too_large"
	OutcomeUnsupported   Outcome = "unsupported_type"
	OutcomeNetworkError  Outcome = "network_error"
)

func ClassifyBody

func ClassifyBody(html string, extractedLen int) Outcome

ClassifyBody decides the outcome for a 2xx response whose extraction has already been attempted.

extractedLen is the length of the text a readability pass produced. Order matters: bot-block and consent markers are checked before the SPA signature, because a challenge page is also script-heavy with an empty body and would otherwise be miscounted as js_required — inflating the one number §17.1's decision actually turns on.

func ClassifyStatus

func ClassifyStatus(status int, body string) Outcome

ClassifyStatus maps a non-2xx response to an outcome.

func (Outcome) Attempted

func (o Outcome) Attempted() bool

Attempted reports whether a request was actually made. It is the denominator for every per-cause rate: provider_content never touched the network.

func (Outcome) CapabilityGap

func (o Outcome) CapabilityGap() bool

CapabilityGap reports whether this outcome is evidence that Mole lacks a capability, as opposed to the system correctly declining or the server failing. Only js_required qualifies — see §17.1's gate.

func (Outcome) SystemWorking

func (o Outcome) SystemWorking() bool

SystemWorking reports whether the outcome is a deliberate refusal by Mole rather than a failure.

func (Outcome) Usable

func (o Outcome) Usable() bool

Usable reports whether the fetch produced text an actor can work with.

type Result

type Result struct {
	URL        string
	Domain     string
	Outcome    Outcome
	StatusCode int
	Bytes      int64
	Duration   time.Duration
	Err        string

	// ContentType and Content are populated on a usable fetch.
	ContentType string
	Content     []byte
	// FinalURL differs from URL when redirects were followed.
	FinalURL string
}

Result is one fetch attempt, recorded whether or not it succeeded.

func (*Result) Refine

func (r *Result) Refine(extractedLen int)

Refine upgrades a transport-level OK to a body-level classification once the extractor has run. Splitting it this way keeps the fetcher out of the business of deciding what counts as usable text.

type RobotsCache

type RobotsCache struct {

	// Fetch retrieves a robots.txt body. Injected so the cache can be tested
	// without a network and so the real one reuses the guarded transport.
	Fetch func(ctx context.Context, robotsURL string) (status int, body []byte, err error)
	// contains filtered or unexported fields
}

RobotsCache fetches and caches robots.txt per origin.

func NewRobotsCache

func NewRobotsCache(ttl time.Duration, fetch func(context.Context, string) (int, []byte, error)) *RobotsCache

func (*RobotsCache) Get

func (c *RobotsCache) Get(ctx context.Context, origin string) *Rules

Get returns the rules for an origin ("https://example.com").

Concurrent callers for the same origin share one fetch. The entry must be created once and then mutated in place — replacing it on each cache miss hands every caller its own sync.Once, which is a stampede wearing the costume of a singleflight.

type Rules

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

Rules is a parsed robots.txt.

func AllowAllRules

func AllowAllRules() *Rules

AllowAllRules is used when a site has no robots.txt (404), which the standard treats as unrestricted.

func DenyAllRules

func DenyAllRules() *Rules

DenyAllRules is used when robots.txt could not be read due to a server error. The standard treats a 5xx as a full disallow; erring the other way would mean a flaky server silently converts into permission we were never given.

func ParseRobots

func ParseRobots(r io.Reader) *Rules

ParseRobots reads a robots.txt body.

func (*Rules) Allowed

func (r *Rules) Allowed(userAgent, path string) bool

Allowed reports whether userAgent may fetch path.

Precedence follows the standard: the longest matching pattern wins, and on a tie Allow beats Disallow. Getting this backwards would silently over-block (harmless but useless) or under-block (the thing we are trying not to do).

func (*Rules) CrawlDelay

func (r *Rules) CrawlDelay(userAgent string) time.Duration

CrawlDelay reports the group's Crawl-delay, or 0.

Jump to

Keyboard shortcuts

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