quarry

package module
v0.2.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: 14 Imported by: 0

README

quarry

Deterministic, declarative, drift-resilient extraction of structured data from HTML for Go: define the fields you want as configuration, get typed data out, and be told when the page's markup changes instead of silently getting empty strings.

type Article struct {
    Title  string   `quarry:"h1 || [property='og:title']@content"`
    Author string   `quarry:".byline .author,optional"`
    Body   string   `quarry:"article .content@html"`
    Tags   []string `quarry:".tags a"`
}

var a Article
err := quarry.Extract(htmlBytes, &a)   // an error NAMES the field that failed
go get github.com/ophymx/quarry

Why

A hand-rolled doc.Find(".price").Text() returns "" both when the price is genuinely absent and when the site renamed the class last night — and you find out weeks later, from bad data. Across every scraping ecosystem (we surveyed ~30 tools), that silent failure is the default. quarry makes "the selector stopped matching" a first-class, detectable event:

  • Fields are required by default. A required field whose selectors all fail is a machine-readable *FieldError — field path, spec, and per-alternative match counts — not a zero value.
  • Selectors carry ordered fallbacks. || chains degrade gracefully under markup churn, and quarry records which alternative fired.
  • Drift is observable before it breaks. Schema.Check probes a page without extracting; Aggregate folds reports across pages into coverage. "The primary selector died, a fallback is carrying the field" is a report line, not an outage.
  • Fixes are config, not releases. The same grammar drives struct tags and runtime-loaded YAML schemas; Schema.Merge applies a drift patch at runtime.

The selector grammar

A field spec is one or more ||-separated alternatives; the first alternative that produces a non-empty value wins. Each alternative is a CSS selector, optionally suffixed with @ and a source:

spec extracts
".price" text of the first non-empty match (trimmed at the ends)
"meta[itemprop=price]@content" an attribute value
"article .body@html" inner HTML
"div.card@outerHtml" the element itself
".title || h1 || [property='og:title']@content" alternatives, first win takes it
".price || jsonld:offers.price" JSON-LD fallback — the sturdiest rung

Notes that matter:

  • An element that merely exists does not win: empty text, an absent or empty attribute, whitespace-only markup — all fall through to the next alternative. <meta property="og:title" content=""> cannot pin an empty string.
  • || exists because CSS's own comma is document order, not preference order — "h1, .title" can't say "prefer the og tag".
  • Hashed CSS-module classes (_item_167zw_4): use the standard substring selector, [class*='_item'].
  • @text, @html, @outerHtml are reserved words; attribute names are matched case-insensitively.
  • Alternatives are evaluated in order, at most once per extraction, and extraction short-circuits on the first win.
  • An alternative can be a JSON-LD path instead of CSS: jsonld:headline, jsonld:author.name (array steps map across elements), jsonld:@id (@ is part of the key). Evaluated against the application/ld+json blocks in the current scope; numbers come out exactly as written in the JSON; null, objects and empty strings fall through like an empty CSS match.

Slices take every non-empty value; struct, *struct and []struct fields scope their inner tags to matching containers:

type Result struct {
    Title string `quarry:"a.title"`
    URL   string `quarry:"a.title@href"`
}
type Listing struct {
    Results []Result `quarry:"[class*='result-item']"`  // one per container
}

A *html.Node / []*html.Node field receives deep clones of the winning elements themselves — mutate and re-render without a lossy round trip (a <tr> fragment re-parsed from an @outerHtml string outside its table is mangled by HTML5 error recovery). The Document stays immutable; node binding is typed-API only, dynamic schemas stay strings-only.

Required means required: a required slice that matches nothing is an error. ,optional is the only optionality spelling. quarry.Lenient() relaxes everything for best-effort runs — the only silent mode, and you have to ask for it.

Two more tag options sharpen the win condition, because shape and count are stronger drift signals than presence:

Price string   `quarry:".price,match='^\\$\\d+\\.\\d{2}$'"` // "Sign in to see price" ≠ a price
Rows  []Result `quarry:".result-row,min=30"`                // 2 rows ≠ a listing page

,match=<re> drops mis-shaped values exactly like empty ones (Go regexp, unanchored — quote a regex containing commas); ,min=N makes an alternative win only with ≥ N usable values (containers: N matching containers). Both compose with || — a drifted primary falls through to the next rung, and a required field with no rung left fails loud. Options come after the whole spec, in any order.

Dynamic schemas: ship a patch, not a release

The same grammar loads from YAML (or JSON) at runtime:

$expect: "#app .product-page"        # page-identity precondition
title: "h1 || [property='og:title']@content"
price: "meta[itemprop=price]@content || .price"
results:
  selector: "[class*='result-item']"
  list: true
  children:
    title: "a.title"
    url: "a.title@href"
schema, _ := quarry.LoadSchema(yamlBytes)
data, err := schema.Extract(htmlBytes)   // map[string]any

When the site moves, the fix is a config overlay:

patch, _ := quarry.LoadSchema([]byte(`price: ".pricing-v2 .amount"`))
data, err = schema.Merge(patch).Extract(htmlBytes)

Validate a patch the way self-healing systems do: run the merged schema over archived HTML and compare against known-good output (quarry's own test suite does exactly this against real committed pages).

$expect distinguishes "this is a login wall / captcha / error page" (*ExpectError, no field noise) from "a field drifted" (*FieldError).

Drift monitoring

report := schema.Check(htmlBytes)     // probe, don't extract
report.OK()                           // all required fields match
report.Missing()                      // which don't
report.Degraded()                     // matching, but on a fallback — fix it
                                      // before the fallback dies too

cov := quarry.Aggregate(reports...)   // across many pages:
// per field: MatchRate(), Degraded count, WonBy (which alternative wins
// where), min/max/total match counts (a list going 30 → 3 is drift even
// though it "matched")

Check is exhaustive where extraction short-circuits: every alternative is censused, so a dead fallback behind a healthy primary is visible too. Thresholds and alerting are yours — a field legitimately absent on some pages is a coverage number, not a hardcoded policy.

Embedded structured data

Often the most drift-resistant data is what the site publishes for SEO — sites break CSS classes weekly and their schema.org markup almost never. JSON-LD is wired straight into the selector grammar as a fallback rung (".headline || jsonld:headline"); the structured subpackage gives you the full documents when you want more than single values:

og, _    := structured.OpenGraph(htmlBytes) // map[string][]string, repeated og:image kept
items, _ := structured.JSONLD(htmlBytes)    // []map[string]any; bad blocks skipped & reported
md, _    := structured.Microdata(htmlBytes) // itemscope/itemprop tree

Pure functions, stdlib-only, no URL resolution. (No Go equivalent of Python's extruct existed; this is it, minus the syntaxes the modern web abandoned.)

Fetching (optional)

The core never touches the network. fetch is a polite single-page GET for the batteries-included case — cross-goroutine rate limiting, bounded retries with backoff, Retry-After respect:

c := fetch.New(fetch.WithMinInterval(time.Second), fetch.WithRetries(3))
htmlBytes, err := c.Get(ctx, url)

Not a crawler. If you need frontier management, use a crawler and feed quarry the HTML.

The pipeline

quarry is the extraction stage of a text-corpus pipeline; it hands raw strings downstream, deliberately un-normalized:

htmlBytes, _ := c.Get(ctx, url)     // fetch     (quarry/fetch)
var a Article
_ = quarry.Extract(htmlBytes, &a)   // extract   (quarry)
clean := normalize(a.Body)          // normalize (your text normalizer)
sig := sketcher.Sketch(clean)       // dedup     (github.com/ophymx/semblance)

Writing drift-resistant selectors

Distilled from the wrapper-maintenance literature (Robula+, SIGMOD'09, VLDB'11) and a survey of what actually breaks:

  1. Anchor on meaning, not styling. id, itemprop, data-*, [property='og:*'] outlive class names by years; classes tied to a design system die with the next redesign.
  2. Never position. :nth-child(3), deep descendant chains, and anything that encodes today's layout are the most fragile selectors you can write.
  3. Shortest discriminating selector wins. Every extra step is another thing that can change.
  4. Make alternatives independent. A fallback chain of three spellings of the same class shares one failure mode; anchor each rung on a different page mechanism — a semantic attribute || a styling class || a jsonld: path. Embedded structured data is usually the sturdiest rung of all, and it sits right in the grammar.
  5. Watch Degraded(). A field running on its fallback is a field one selector away from an outage. Fallback depth is a fragility score.

Determinism & stability

Extraction is a pure function of (HTML bytes, schema): no network, no clock, no locale, no randomness. Malformed markup is repaired by golang.org/x/net/html's HTML5 recovery, deterministic per module version (pinned in go.mod); well-formed input is unconditionally stable.

The selector grammar and struct-tag semantics are frozen. Two regions are pre-reserved so the grammar never needs a breaking change: scheme-prefixed alternatives and ,key=value tag options — errors today, features some day. (jsonld: is the first activated scheme, and ,match=/,min= the first activated options — schemas using them are loudly rejected, never misread, by older quarry versions; other schemes and option keys remain reserved.) Golden tests against committed real pages (HN, Wikipedia, go.dev) pin behavior; deviations from the original design are recorded in docs/design-notes.md.

What quarry is not

  • Not a DOM library. goquery gives you a jQuery; quarry takes a declaration. (And unlike parsel's ::text/::attr() pseudo-elements, quarry's @source suffix keeps the selector standard CSS you can test in a browser console.)
  • Not a crawler (Colly/Geziyor), not a query language runtime (Ferret), not a headless browser — pages needing JavaScript need a rendering step upstream.
  • Not normalization — raw strings out; normalize downstream.
  • Not heuristic extraction — quarry is the precise complement to trafilatura-style automatic content extractors.
  • Among Go struct-tag binders: pagser has transform pipes but fails silently; goq binds but has no fallbacks, no required fields, no drift report. Fail-loud + fallbacks + check is the point.

License

MIT

Documentation

Overview

Package quarry extracts structured data from HTML declaratively: describe the fields you want as CSS selector specs — in struct tags or in a runtime-loaded schema — and get typed data out, with an error (not a silent empty string) when the page's markup no longer matches.

Selector grammar

A field's spec is one or more ||-separated alternatives; the first alternative that produces at least one non-empty value wins:

".price"                          text of the first match (default)
"meta[itemprop=price]@content"    the value of an attribute
"article .body@html"              inner HTML (markup preserved)
".title || h1 || [property='og:title']@content"
".price || jsonld:offers.price"   JSON-LD fallback (see below)

Each alternative is a CSS selector optionally suffixed with @ and a source: @text (the default), @html (inner HTML), @outerHtml (the element itself), or any @attrName for an attribute value. The words "text", "html" and "outerHtml" are reserved in source position; attributes with those literal names are unreachable.

An element that merely exists does not win. For @text the trimmed text must be non-empty; for @attrName the attribute must be present with a non-empty value; for @html and @outerHtml the rendered markup must contain more than whitespace. So <meta property="og:title" content=""> falls through to the next alternative instead of pinning an empty string, and a dropped attribute falls through instead of silently extracting "". Fall-through-on-empty is what makes fallback cascades degrade gracefully under markup drift.

@text concatenates the element's descendant text, trimming leading and trailing whitespace only — internal whitespace is preserved, and normalization is deliberately out of scope. Text inside <script>, <style> and <template> descendants is skipped; explicit selection overrides the hiding — a <script> or <style> element selected directly yields its raw content, a <template> the text of its inert content. Attribute names are matched case-insensitively.

Alternatives are preference-ordered and short-circuit: each field's alternatives are evaluated at most once per extraction, in order, and the winner is recorded. This is why quarry has || at all — CSS's own comma group ("h1, .title") matches in document order, not author-preference order, and cannot express "prefer the og tag, fall back to h1". (Because || is quarry's separator, the exotic CSS column combinator, also spelled ||, is unavailable; the underlying selector engine does not support it either.)

JSON-LD alternatives

An alternative may be a JSON-LD path instead of a CSS selector: "jsonld:" followed by dot-separated object keys, evaluated against every <script type="application/ld+json"> block in the current scope — the whole document for top-level fields, the matched container's subtree for nested ones. Embedded structured data is usually the most drift-resistant thing on a page (search ranking depends on it), which makes a jsonld: path the sturdiest rung of a fallback cascade:

"jsonld:headline"
".price || jsonld:offers.price"
"jsonld:author.name"

Blocks are read in document order; top-level arrays and @graph containers are unwrapped. A path step over an array maps across its elements, so "jsonld:author.name" collects every author's name. Strings are taken verbatim, numbers exactly as written in the JSON, booleans as "true" or "false"; null, objects, arrays and empty strings are not values, so the alternative falls through like any other. Malformed blocks contribute nothing (the structured subpackage reports their parse errors). After "jsonld:", @ has no source meaning — it is part of the key, as in "jsonld:@id". Because a jsonld: path produces values rather than elements, it is not valid on container (struct-typed or children-bearing) fields; it is valid in a schema's $expect, where the path reaching any terminal is the precondition. Path segments may not contain whitespace, quotes, brackets, parens, '=', ',', '|' or '\' — that syntax is reserved for future filter steps (selecting by @type, say).

Two grammar regions are reserved for future versions and are errors today: alternatives beginning with a scheme prefix other than "jsonld:", and ",key=value" tag options with keys other than "match" and "min".

To survive hashed CSS-module class names such as "_item_167zw_4", use the substring form of the standard attribute selector: [class*="_item"]. That is plain CSS, not quarry syntax, but it is the recommended idiom.

Required by default, fail loud

Every field is required unless its tag ends in ",optional" — and the tag is the only optionality spelling; pointer types do not imply optional. For slice fields, required means at least one match: a required slice that matches nothing is an error, an optional one is empty. A required field none of whose alternatives produced a value yields a *FieldError carrying the field's path, its spec, and per-alternative Attempt counts — machine-readable, so monitoring and repair tooling can act on it. When several fields fail, the errors are joined; use errors.As to retrieve individual *FieldError values. The Lenient option relaxes every field to optional for best-effort extraction — the only silent mode quarry has, and it is explicit.

Two more tag options sharpen the win condition. ",match=<re>" keeps only values matching the Go regular expression: an element whose text drifted from "$19.90" to "Sign in to see price" stops counting, the alternative falls through, and a required field fails loud instead of shipping garbage — value shape is a stronger drift signal than presence. The regexp is unanchored (use ^ and $ for a full match); quote a regex containing a comma or spaces, as in ",match='\d{1,3}'" (unquoted braces survive too). ",min=N" requires at least N usable values — for container fields, N matching containers — before an alternative wins: a listing that suddenly yields 2 rows instead of 30 becomes an error, not a quietly short list. Options come after the whole spec, in any order; ",match=" is field-wide (it filters every alternative's values) and is not valid on container fields, which have no values.

Typed extraction

Extract fills a struct from `quarry` tags:

type Article struct {
    Title  string   `quarry:"h1 || [property='og:title']@content"`
    Author string   `quarry:".byline .author,optional"`
    Body   string   `quarry:"article .content@html"`
    Tags   []string `quarry:".tags a"`
}

var a Article
err := quarry.Extract(htmlBytes, &a)

A string field takes the first non-empty value of the winning alternative; a []string field takes every non-empty value. A struct or *struct field scopes its inner tags to the descendants of the first matching container, and a []struct field sub-extracts each matching container:

type Result struct {
    Title string `quarry:"a.title"`
    URL   string `quarry:"a.title@href"`
}
type Listing struct {
    Results []Result `quarry:"[class*='result-item']"`
}

A field of type *html.Node or []*html.Node (golang.org/x/net/html) receives the winning element(s) themselves — for pipelines that mutate extracted markup (stripping tracking attributes, say) before re-rendering it. Binding nodes instead of @outerHtml strings saves the render→reparse round trip, and for context-sensitive fragments it is the only correct route: a "<tr>…</tr>" string re-parsed outside its table is mangled by HTML5 error recovery. The win condition is exactly @outerHtml's — an element wins only if it renders to more than whitespace — so a fallback cascade behaves the same whether the field is a string with @outerHtml or a node. Returned nodes are deep clones: mutate them freely; the Document is untouched. A clone's Parent and sibling pointers are nil — it is the root of its own subtree, and callers cannot walk out of it. Because the type says what you get, an explicit @source is an error on a node field, as are jsonld: alternatives (paths produce values, not elements) and ,match= (no string value to shape-check); ,min= and ,optional work as usual. Node binding is typed-API only: a dynamic Schema stays strings-only.

Parse once and extract many times with Parse and Document.Extract.

Dynamic extraction

The same grammar drives a runtime Schema, loaded from YAML or JSON configuration with LoadSchema (or built as a literal) and extracted to a map[string]any with Schema.Extract. Schema.Merge overlays a patch schema — when a site's markup moves, the fix is a config change shipped without a rebuild:

schema, _ := quarry.LoadSchema(yamlBytes)
patch, _ := quarry.LoadSchema(patchBytes)
data, err := schema.Merge(patch).Extract(htmlBytes)

Drift monitoring

Schema.Check probes a page without extracting: the Report says per field whether it matched, on which alternative, and how many values it produced — so "the primary selector died and a fallback is carrying the field" is visible before anything breaks. Aggregate folds reports from many pages into per-field coverage for fleet-level monitoring. A schema may declare a $expect page-identity precondition; when it fails, extraction returns a *ExpectError instead of misleading field errors.

Determinism

Extraction is a pure function of (HTML bytes, schema): no network, no clock, no locale, no randomness. Input is parsed as a full HTML5 document; malformed markup is repaired by the error-recovery algorithm of golang.org/x/net/html, deterministic per module version (pinned in go.mod), and well-formed input is unconditionally stable. Node-typed fields return deep clones, so mutating extracted nodes never changes what a later extraction sees. Relative URLs are never resolved — a base URL is external state. The core has no dependencies beyond the parser and its CSS-selector engine; fetching, JavaScript rendering, and text normalization are other tools' jobs.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Extract

func Extract(htmlBytes []byte, dst any, opts ...Option) error

Extract parses the HTML and fills dst, which must be a non-nil pointer to a struct with `quarry` tags. Fields are required unless their tag ends in ",optional"; every failed required field contributes a *FieldError to the returned (joined) error. On error dst may be partially populated. For repeated extraction from one page, Parse once and use Document.Extract.

Example
package main

import (
	"fmt"

	"github.com/ophymx/quarry"
)

const page = `<html><head>
<meta property="og:title" content="Gopher statue sells for $1M">
</head><body>
<article>
  <h1></h1>
  <span class="byline">Pat Doe</span>
  <ul class="tags"><li><a>art</a></li><li><a>go</a></li></ul>
</article>
</body></html>`

func main() {
	type Article struct {
		// The h1 exists but is empty, so the og:title alternative wins.
		Title  string   `quarry:"h1 || [property='og:title']@content"`
		Byline string   `quarry:".byline,optional"`
		Tags   []string `quarry:".tags a"`
	}
	var a Article
	if err := quarry.Extract([]byte(page), &a); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(a.Title)
	fmt.Println(a.Byline, a.Tags)
}
Output:
Gopher statue sells for $1M
Pat Doe [art go]
Example (FieldError)
package main

import (
	"errors"
	"fmt"

	"github.com/ophymx/quarry"
)

const page = `<html><head>
<meta property="og:title" content="Gopher statue sells for $1M">
</head><body>
<article>
  <h1></h1>
  <span class="byline">Pat Doe</span>
  <ul class="tags"><li><a>art</a></li><li><a>go</a></li></ul>
</article>
</body></html>`

func main() {
	type Article struct {
		Price string `quarry:".price || meta[itemprop=price]@content"`
	}
	var a Article
	err := quarry.Extract([]byte(page), &a)
	var fe *quarry.FieldError
	if errors.As(err, &fe) {
		fmt.Println(fe.Field)
		for _, at := range fe.Attempts {
			fmt.Printf("%s: %d matched, %d non-empty\n", at.Selector, at.Matched, at.NonEmpty)
		}
	}
}
Output:
Price
.price: 0 matched, 0 non-empty
meta[itemprop=price]: 0 matched, 0 non-empty

Types

type Attempt

type Attempt struct {
	Selector string // the alternative as written: a CSS selector, or "jsonld:path"
	Source   string // "text", "html", "outerHtml", an attribute name, or "jsonld"
	Matched  int    // elements the selector matched / terminals the path reached
	NonEmpty int    // matches that produced a usable (non-empty, shape-valid) value
}

Attempt records how one alternative fared: how many elements its selector matched (for a jsonld: alternative, how many terminals its path reached), and how many of those produced a usable value — non-empty and, when the field has a ",match=" option, shape-valid. The distinction matters for drift diagnosis — "2 matched, 0 non-empty" means the elements are still there but their content moved or changed shape.

type CoverageReport

type CoverageReport struct {
	Pages  int
	Fields []FieldCoverage // sorted by field path; $expect included when present
}

CoverageReport aggregates Report values from many pages into per-field coverage — the cross-page view real monitoring needs, since a field can be legitimately absent on some pages (not every article has an author) and a single-page miss is a weaker signal than a coverage drop across fifty. Aggregate is a pure function of the reports; alert thresholds are the caller's policy.

func Aggregate

func Aggregate(reports ...Report) CoverageReport

Aggregate folds Check reports from many pages into per-field coverage. Fields are keyed by their stable un-indexed paths; a child whose container was missing on some pages simply appears on fewer (its Pages tells you how many). The $expect status aggregates under the field name "$expect".

type Document

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

Document is a parsed HTML document ready for extraction. Parse once and extract many times; a Document is safe for concurrent use.

func Parse

func Parse(b []byte) (*Document, error)

Parse parses HTML bytes into a Document. Input is always treated as a full HTML5 document: malformed or fragmentary markup is repaired by the error-recovery algorithm of golang.org/x/net/html (html, head and body elements are synthesized when absent), so Parse succeeds on arbitrary input. The repair is deterministic for a given version of that module, and well-formed input is unconditionally stable.

func (*Document) Extract

func (d *Document) Extract(dst any, opts ...Option) error

Extract fills dst from the parsed document; see Extract.

type ExpectError

type ExpectError struct {
	Spec     string    // the $expect spec
	Attempts []Attempt // per-alternative counts, as in FieldError
}

ExpectError reports a failed $expect page-identity precondition: the document is not the kind of page the schema is for (a login wall, a captcha, an error page), which is a different problem from field drift. Extraction refuses to proceed rather than emit misleading FieldErrors.

func (*ExpectError) Error

func (e *ExpectError) Error() string

type Field

type Field struct {
	Spec     string           `yaml:"selector"`
	Optional bool             `yaml:"optional"`
	List     bool             `yaml:"list"`
	Children map[string]Field `yaml:"children"`
}

Field is one field of a Schema.

Spec is a selector spec in the package grammar and may carry a trailing ",optional" exactly as a struct tag would; that spelling and the Optional flag are equivalent. A Field with Children is a container: its selector scopes the child fields, its Spec must not name a @source, and List selects between the first matching container (a nested map) and every matching container (a slice of maps). Without Children, List selects between the first non-empty value (a string) and every non-empty value (a []string).

func (*Field) UnmarshalYAML

func (f *Field) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML accepts the two schema spellings: a scalar compact spec string, or a mapping with selector/optional/list/children keys.

type FieldCoverage

type FieldCoverage struct {
	Field    string
	Optional bool
	Pages    int // reports in which this field appeared
	Matched  int // reports in which it matched
	Degraded int // reports in which it matched on a fallback (AltIndex > 0)
	// WonBy counts reports per winning alternative index. A drifting
	// primary shows up as mass moving from WonBy[0] to WonBy[1] before
	// anything fails.
	WonBy []int
	// Value counts across reports, for count-anomaly detection (a list
	// page that matched 30, 30, 30, 3).
	MinCount   int
	MaxCount   int
	TotalCount int
}

FieldCoverage is one field's aggregate across pages.

func (FieldCoverage) MatchRate

func (c FieldCoverage) MatchRate() float64

MatchRate is the fraction of appearances in which the field matched.

type FieldError

type FieldError struct {
	// Field is the path of the failed field: the Go struct field path for
	// the typed API (e.g. "Results[2].URL") or the schema key path for the
	// dynamic API.
	Field string
	// Spec is the field's original selector spec.
	Spec string
	// Attempts holds one entry per alternative tried, in spec order.
	Attempts []Attempt
}

FieldError reports a required field none of whose alternatives produced a value. It is machine-readable by design: external monitoring or repair tooling can act on the field path and per-alternative attempt counts. When several fields fail in one extraction the FieldErrors are joined; retrieve them with errors.As.

func (*FieldError) Error

func (e *FieldError) Error() string

type FieldStatus

type FieldStatus struct {
	Field    string // un-indexed schema path, e.g. "stories.url"
	Spec     string // the field's spec string
	Optional bool
	Err      string // spec compile error, if any ("" otherwise)

	// Matched reports whether the field produced a value everywhere
	// extraction would need one — for a child under a list container,
	// in every container in which it was probed. (A child is probed
	// only in containers where its own parent matched; the parent's
	// status tells the story of the others.)
	Matched bool
	// AltIndex is the index of the winning alternative (the worst one
	// across containers, for list children), or -1 when the field
	// matched nowhere. A field that matched in some containers but not
	// all has Matched false with AltIndex still set. AltIndex > 0 on a
	// required field means the primary selector is dead and a fallback
	// is carrying the field.
	AltIndex int
	// Selector is the CSS of the winning alternative ("" when none).
	Selector string
	// Count is the number of values produced: containers matched for a
	// container field, values across all containers for a child. A list
	// suddenly counting 3 instead of 30 is drift that Matched can't see.
	Count int
	// MissingIn is, for a child under a list container, the number of
	// probed containers in which the field did not match (0 elsewhere)
	// — "28 of 30" shows up here as MissingIn: 2.
	MissingIn int
	// Attempts is the exhaustive census of every alternative, summed
	// across containers — including alternatives extraction would never
	// reach past a winner. A dead fallback is worth knowing about before
	// the primary dies too.
	Attempts []Attempt
}

FieldStatus is one field's check result.

type Option

type Option func(*config)

Option configures an extraction.

func Lenient

func Lenient() Option

Lenient relaxes every field to optional for best-effort extraction: fields whose alternatives all fail produce zero values instead of a *FieldError. This is the only silent mode quarry has; prefer per-field ",optional" so that genuinely required fields keep failing loud.

type Report

type Report struct {
	// Expect is the status of the schema's $expect page-identity
	// precondition, or nil if the schema has none. Field checking
	// proceeds even when Expect fails — a probe should report
	// everything; only extraction refuses.
	Expect *FieldStatus
	// Fields holds one status per schema field, parents before children,
	// names sorted at each level. Paths are un-indexed ("stories.url"):
	// a child under a list container gets one aggregated status across
	// all containers, keeping paths stable for [Aggregate].
	Fields []FieldStatus
}

Report is the result of Schema.Check: a per-field account of what still matches on a page, without extracting anything.

Check is exhaustive where extraction is short-circuiting: every alternative of every field is evaluated, so the report can say not only "this field is fine" but "this field is running on fallback #2 and the primary selector is dead" — the earliest drift signal there is.

func (Report) Degraded

func (r Report) Degraded() []string

Degraded returns the paths of fields that matched, but not on their first alternative — extraction still succeeds, and the field is one selector away from breaking.

func (Report) Missing

func (r Report) Missing() []string

Missing returns the paths of required fields that did not match (including fields whose spec failed to compile).

func (Report) OK

func (r Report) OK() bool

OK reports whether every required field (and the $expect precondition, if present) matched.

type Schema

type Schema struct {
	// Expect optionally names a selector spec (the $expect key in a
	// schema document) that identifies the page itself — an element that
	// exists on every page this schema is for. When set and no
	// alternative matches, Extract returns a *ExpectError instead of
	// misleading per-field errors: "this is a login wall" is a different
	// problem from "the price selector drifted". Presence semantics: the
	// element existing is enough, and @sources are not allowed. Check
	// reports the precondition but still probes every field.
	Expect string
	Fields map[string]Field
}

Schema is a runtime-defined extraction schema: the dynamic counterpart of quarry struct tags, driven by the same selector grammar. Schemas are loaded from configuration with LoadSchema or built as literals, and patched without a rebuild with Schema.Merge — when a site's markup moves, ship a config change, not a release.

Treat a Schema as immutable once built: Merge returns copies that share nested Children maps with their sources.

func LoadSchema

func LoadSchema(b []byte) (Schema, error)

LoadSchema parses a schema document. The format is YAML (JSON, being a YAML subset, is accepted too): a mapping of field names to field specs, each either a compact spec string or a long-form mapping —

title: ".title || h1 || [property='og:title']@content"
author:
  selector: ".byline .author"
  optional: true
results:
  selector: "[class*='result-item']"
  list: true
  children:
    title: "a.title"
    url: "a.title@href"

A top-level $expect key declares a page-identity precondition (see Schema.Expect); other $-prefixed names are reserved. Every spec is validated eagerly so configuration mistakes surface at load time, not extraction time. An empty document is an error: a schema that can never extract anything is a configuration mistake, not a schema.

Parsed specs are cached process-wide, keyed by spec string, so re-loading the same schemas is cheap; a process that loads unbounded distinct specs (schemas synthesized per request, say) grows that cache without bound.

func (Schema) Check

func (s Schema) Check(htmlBytes []byte) Report

Check parses the HTML and probes every schema field without extracting. It never fails: malformed input parses to a repaired document and invalid specs are reported per-field via FieldStatus.Err.

Example
package main

import (
	"fmt"

	"github.com/ophymx/quarry"
)

const page = `<html><head>
<meta property="og:title" content="Gopher statue sells for $1M">
</head><body>
<article>
  <h1></h1>
  <span class="byline">Pat Doe</span>
  <ul class="tags"><li><a>art</a></li><li><a>go</a></li></ul>
</article>
</body></html>`

func main() {
	schema, _ := quarry.LoadSchema([]byte(`
title: "h1 || [property='og:title']@content"
byline: ".byline"
`))
	report := schema.Check([]byte(page))
	fmt.Println("ok:", report.OK())
	fmt.Println("degraded:", report.Degraded()) // title runs on its fallback
}
Output:
ok: true
degraded: [title]

func (Schema) CheckDocument

func (s Schema) CheckDocument(d *Document) Report

CheckDocument probes an already-parsed document; see Schema.Check.

func (Schema) Extract

func (s Schema) Extract(htmlBytes []byte, opts ...Option) (map[string]any, error)

Extract parses the HTML and extracts the schema's fields into a map. Scalar fields yield string ([]string with List); container fields yield map[string]any ([]map[string]any with List). Optional fields that fail to match are omitted from the map entirely — an absent key, not an empty value. Required fields that fail contribute *FieldError values to the returned (joined) error; on error the map still holds everything that did extract.

func (Schema) ExtractDocument

func (s Schema) ExtractDocument(d *Document, opts ...Option) (map[string]any, error)

ExtractDocument extracts from an already-parsed document; see Schema.Extract. A failed $expect precondition returns a *ExpectError and no field results — even under Lenient, since best-effort values from the wrong page are worse than none.

func (Schema) Merge

func (s Schema) Merge(patch Schema) Schema

Merge returns a schema whose fields are s's overlaid with patch's: fields present in patch replace same-named fields wholesale (spec, flags and children all come from the patch) and new fields are added. Whole-field replacement keeps drift patches predictable; there is deliberately no deep merge of children. The $expect precondition is carried over from s; a patch with its own $expect replaces it (a patch cannot remove one — build a literal for that). Neither receiver nor patch is modified.

Example
package main

import (
	"fmt"

	"github.com/ophymx/quarry"
)

const page = `<html><head>
<meta property="og:title" content="Gopher statue sells for $1M">
</head><body>
<article>
  <h1></h1>
  <span class="byline">Pat Doe</span>
  <ul class="tags"><li><a>art</a></li><li><a>go</a></li></ul>
</article>
</body></html>`

func main() {
	schema, _ := quarry.LoadSchema([]byte(`
title: ".old-headline"
byline: ".byline"
`))
	// .old-headline drifted; ship a config patch, not a release.
	patch, _ := quarry.LoadSchema([]byte(`
title: "h1 || [property='og:title']@content"
`))
	data, err := schema.Merge(patch).Extract([]byte(page))
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(data["title"], "/", data["byline"])
}
Output:
Gopher statue sells for $1M / Pat Doe

func (Schema) Validate

func (s Schema) Validate() error

Validate checks every field spec in the schema, joining all problems into one error. LoadSchema validates automatically; call this on hand-built Schema literals.

Directories

Path Synopsis
Package fetch is quarry's optional polite HTTP client: cross-goroutine rate limiting, bounded retries with backoff, and Retry-After respect, in front of a plain GET.
Package fetch is quarry's optional polite HTTP client: cross-goroutine rate limiting, bounded retries with backoff, and Retry-After respect, in front of a plain GET.
Package structured extracts the machine-readable data pages already publish for SEO and social embeds — JSON-LD, OpenGraph and microdata — as pure functions over the HTML.
Package structured extracts the machine-readable data pages already publish for SEO and social embeds — JSON-LD, OpenGraph and microdata — as pure functions over the HTML.

Jump to

Keyboard shortcuts

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