dataapi

package
v1.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 15, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package dataapi is the Go port of hyper-html-api's read engine: it turns a document plus a set of extraction rules into JSON. It is a pure library — no net/http, no session, no filesystem — so the server can wrap it without the engine knowing anything about requests.

The contract it implements is not "reasonable JSON extraction", it is "byte-for-byte what the JavaScript engine already returns", quirks included. Three JS hosts (hyperclay, hyperclay-local, makerclay) answer this API today and their answers are what pages depend on. Where this package deliberately differs, the difference is recorded in the conformance corpus as a tier-3 case, never left to be discovered.

See plans/htmlclay/data-api-plan.md for the full parity ledger.

Index

Constants

View Source
const SupportedRulesVersion = "1"

SupportedRulesVersion is the only data-rules-version this engine accepts.

Variables

This section is empty.

Functions

func Find

func Find(ctx *html.Node, selector string, opts FindOpts) ([]*html.Node, error)

Find returns the descendants of ctx matching selector, in document order. Self is never included, matching cheerio's .find().

func Marshal

func Marshal(v Value) ([]byte, error)

Marshal renders a Value the way the JS hosts do: HTML characters are NOT escaped, because JSON.stringify does not escape them and extracted values routinely carry < and &. Go's default encoder escapes all three of < > & into \u00XX, which would make every response differ from the reference.

Two byte-level exceptions we cannot close and therefore document: Go escapes U+2028 and U+2029 even with escaping off, and Go replaces invalid UTF-8 with U+FFFD where JS preserves the lone surrogate. Lone surrogates are rejected upstream in rules.go rather than silently mangled here.

Types

type Document

type Document struct {
	Root *html.Node
	// contains filtered or unexported fields
}

Document is a parsed page with its <template> content detached.

Detaching is the only way to get browser semantics. A skip inside find() is not enough, because relational and structural pseudos still OBSERVE the skipped nodes: measured on <body><template><b id=inside>x</b></template></body>, cascadia matches body:has(b) and body:has(#inside) and does not consider the body empty. Filtering <b> out of the results still returns the wrong <body>. Once the children are off the tree there is no mechanism left that can see them.

The content is kept here rather than thrown away because @innerHTML and @outerHTML must still serialize the template markup, exactly as a browser does. So the accessor matrix falls out:

find() and every pseudo        never see template content
text(), @textContent           exclude it, transitively, because it is off the tree
@innerHTML, @outerHTML         keep it, because the renderer consults this map

func Parse

func Parse(r io.Reader) (*Document, error)

Parse reads a document, detaches its template content, and undoes the parser's attribute sort.

The source bytes are buffered because restoreAttrOrder needs a second, independent pass over them.

func ParseBytes

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

ParseBytes is Parse over a byte slice, which is how the server has the file.

func (*Document) Extract

func (d *Document) Extract(rules Value) (Value, error)

Extract turns a document and a rule tree into a Value. Ported from extract.js.

The context node for a top-level rule is the document root, and matching runs over the shadow tree — template content was detached at parse time, so no selector here can observe it.

func (*Document) FindRulesIn

func (d *Document) FindRulesIn(token string) (*RulesTag, error)

FindRulesIn returns the first script[data-rules-name~="token"] in root, with its body parsed. A missing tag is (nil, nil) — not an error — because the HTTP status for "this page publishes no such endpoint" is the server's call, not the engine's.

The JS also has a no-token form that takes the first data-rules-name script of any name. That is a tooling path with no caller on the server side, so it is not ported.

type FindOpts

type FindOpts struct {
	// IncludeRulesTag keeps script[data-rules-name] elements in the result. Only the rules-tag
	// lookup sets it, and it MUST: isRulesTag matches any such script, so without the flag the
	// lookup would filter out the very tag it selects.
	IncludeRulesTag bool
}

FindOpts is the read-path subset of the JS adapter's find options. The adapter also takes `skip` and `templateAttr`, both of which exist only for the CMS write path; leaving them out keeps the one caller that matters honest rather than carrying dead configuration.

type InvalidRulesToken

type InvalidRulesToken struct {
	Token string
}

InvalidRulesToken is a rules-tag token that failed validation before reaching the selector.

func (*InvalidRulesToken) Error

func (e *InvalidRulesToken) Error() string

type MaxRuleDepthExceeded

type MaxRuleDepthExceeded struct {
	Path []string
}

MaxRuleDepthExceeded mirrors the JS error of the same name, message included, because the path it names is genuinely useful for finding the offending branch of a large rule tree.

func (*MaxRuleDepthExceeded) Error

func (e *MaxRuleDepthExceeded) Error() string

type Object

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

Object is a JSON object that remembers its key order, because the JS engine's output order is observable and pages rely on it. encoding/json marshals a Go map in sorted order, which would silently reorder every response.

func NewObject

func NewObject() *Object

func (*Object) Define

func (o *Object) Define(key string, v Value)

Define stores a key the way JSON.parse does, keeping "__proto__" as a real own property.

The difference from Set is not a nicety, it is the whole reason both exist. JSON.parse builds objects with CreateDataProperty, which bypasses the prototype setter; assignment does not. So a "__proto__" rule key survives parsing, gets iterated, and has its selector resolved — errors and all — and then vanishes when the extractor assigns the result. Parse with Define, build output with Set, and both halves of that behavior fall out. Measured against node, both directions.

func (*Object) Get

func (o *Object) Get(key string) (Value, bool)

func (*Object) Keys

func (o *Object) Keys() []string

Keys returns the JS property order: canonical array indices first in ascending numeric order, then every other key in insertion order. This is the ordinary-object [[OwnPropertyKeys]] rule, and it is why {b:…, "2":…, "0":…, a:…} serializes as 0, 2, b, a.

func (*Object) Len

func (o *Object) Len() int

func (*Object) MarshalJSON

func (o *Object) MarshalJSON() ([]byte, error)

func (*Object) Set

func (o *Object) Set(key string, v Value)

Set stores a key, preserving first-insertion position on overwrite — JS assignment semantics, where re-assigning an existing property updates the value and leaves the key where it was.

__proto__ is dropped rather than stored. In JS, extract.js builds results with `const result = {}` and assigns `result[key] = …`, so an own "__proto__" key hits the legacy prototype setter instead of creating a property, and the key never appears in the output. A Go map would happily emit it. Matching JS here is the whole point; the upstream fix is to build with Object.create(null), and when that lands this drops out with a corpus change.

type RulesParseError

type RulesParseError struct {
	Message string
	Cause   error
}

RulesParseError is returned when rules cannot be parsed, from either face. It mirrors the JS engine's error of the same name.

The message text does NOT match the JS engine's, and cannot: it wraps Go's encoding/json diagnostics rather than V8's. That is deliberate and safe, because htmlclay maps errors to HTTP status by TYPE. The JS hosts map by sniffing message text, which is exactly why two sibling selector failures answer 400 and 500 there. See the error-parity table in the plan.

func (*RulesParseError) Error

func (e *RulesParseError) Error() string

func (*RulesParseError) Unwrap

func (e *RulesParseError) Unwrap() error

type RulesTag

type RulesTag struct {
	Rules Value
	Node  *html.Node
}

RulesTag is a page's own published endpoint definition.

type SelectorError

type SelectorError struct {
	Selector string
	Cause    error
}

SelectorError is a selector cascadia would not compile.

This is the clearest place htmlclay departs from the JS hosts on STATUS rather than on data. Of the sixteen message classes css-what can produce, twelve carry no word the JS hosts' sniffing recognises, so those answer 500 while their siblings answer 400. Two failures of the same kind getting different statuses is not a contract worth reproducing; every selector failure is 400 here. Recorded as a divergence, not parity.

func (*SelectorError) Error

func (e *SelectorError) Error() string

func (*SelectorError) Unwrap

func (e *SelectorError) Unwrap() error

type SelectorFailure

type SelectorFailure interface {
	error
	// contains filtered or unexported methods
}

SelectorFailure is implemented by both selector errors, so a caller mapping HTTP status can answer 400 for either without knowing which. The two stay distinct types because they mean different things to a human reading the log: one selector is broken, the other is fine but ambiguous across engines.

type UnknownRulesVersion

type UnknownRulesVersion struct {
	Version string
}

UnknownRulesVersion is a rules tag whose data-rules-version is not "1". A missing attribute reports as empty, matching the JS, where undefined !== "1" fails the same way.

func (*UnknownRulesVersion) Error

func (e *UnknownRulesVersion) Error() string

type UnsupportedSelector

type UnsupportedSelector struct {
	Selector string
	Reason   string
}

UnsupportedSelector is the gate's refusal: a selector cheerio would accept and answer, which htmlclay declines because the two engines do not agree on what it means.

It is deliberately NOT the same type as SelectorError, even though both answer 400. A SelectorError is a selector nobody can run. An UnsupportedSelector is a divergence made loud on purpose: the reference produces data and htmlclay produces an error, which is the safe direction to differ but is still a difference, and it should be countable rather than blended into the generic parse failures. Reason says what actually differs, since "unsupported" alone leaves the author with nothing to act on.

func (*UnsupportedSelector) Error

func (e *UnsupportedSelector) Error() string

type Value

type Value any

Value is anything extraction can produce: nil, string, []Value, or *Object. There is no number or boolean case — the engine reads text and attributes, so every scalar is a string, and a non-string rule extracts to null (see extract.go).

func ParseRelaxed

func ParseRelaxed(queryString string) (Value, error)

ParseRelaxed parses the ?data= parameter and script-tag bodies. Strict JSON is tried first and returned as-is when it works; anything else goes through a tokenizer that rewrites the input into JSON and parses that. The tokenizer came from hyperclay's legacy data-extractor.js and its behavior on odd input is load-bearing, not incidental — see the rules-* conformance cases.

func ParseStrict

func ParseStrict(body string) (Value, error)

ParseStrict parses rules as strict JSON: no unquoted keys, no single quotes, no trailing commas. The script-tag face uses ParseRelaxed instead; this exists for callers that want strict semantics, matching the JS engine's public export.

Jump to

Keyboard shortcuts

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