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']"`
}
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. 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 ¶
- func Extract(htmlBytes []byte, dst any, opts ...Option) error
- type Attempt
- type CoverageReport
- type Document
- type ExpectError
- type Field
- type FieldCoverage
- type FieldError
- type FieldStatus
- type Option
- type Report
- type Schema
- func (s Schema) Check(htmlBytes []byte) Report
- func (s Schema) CheckDocument(d *Document) Report
- func (s Schema) Extract(htmlBytes []byte, opts ...Option) (map[string]any, error)
- func (s Schema) ExtractDocument(d *Document, opts ...Option) (map[string]any, error)
- func (s Schema) Merge(patch Schema) Schema
- func (s Schema) Validate() error
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Extract ¶
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 ¶
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.
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).
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 ¶
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.
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 ¶
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 ¶
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 ¶
CheckDocument probes an already-parsed document; see Schema.Check.
func (Schema) Extract ¶
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 ¶
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 ¶
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
Source Files
¶
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. |