Field Notes
Coffee, tea, and the machinery of both
- Yirgacheffe
- Huila
- Sencha
- Assam
- House
| Item | Price |
|---|---|
| Huila | 22 |
| Sencha | 9 |
Prices in USD, roasted weekly.
Notes
One shipment a week.
the selector board
These tables are not examples of what the engines should answer — they are what they answered. Each row is a query, the number of nodes it returned, and those nodes, printed by a program that ran against the document in the box below.
A page, not a form. The fields take typing, the boxes tick, the list opens — the controls work, and they submit nothing. They are here to be selected.
Under the box, the document in full: the bytes the engine read to make these tables. Parse it with @html.parse and every answer below falls out again.
Sections 02 to 07 give the selectors, the query as written, the nodes it answered, and those nodes. Section 06 adds what a node yields when read from — its text, its attribute, its markup, its typed XPath value.
Coffee, tea, and the machinery of both
| Item | Price |
|---|---|
| Huila | 22 |
| Sencha | 9 |
Prices in USD, roasted weekly.
One shipment a week.
the document, in full
<!DOCTYPE html>
<html lang="en">
<head>
<title>Field Notes</title>
</head>
<body>
<article id="shop" class="catalogue" data-region="eu">
<header id="masthead" class="masthead">
<h1>Field Notes</h1>
<p class="dek">Coffee, tea, and the machinery of both</p>
</header>
<ul id="items" class="list">
<li class="item" data-kind="bean" data-price="18">Yirgacheffe</li>
<li class="item" data-kind="bean" data-price="22" lang="en-GB">Huila</li>
<li class="item sold" data-kind="leaf" data-price="9">Sencha</li>
<li class="item" data-kind="leaf" data-price="14">Assam</li>
<li class="item" data-kind="blend" data-price="16"><a href="https://example.com/b/1">House</a></li>
</ul>
<form id="order" action="/order" method="post">
<label for="qty">Quantity</label>
<input id="qty" name="qty" type="number" value="2" required>
<input id="gift" name="gift" type="checkbox" disabled>
<select name="grind">
<option value="fine">Fine</option>
<option value="coarse" selected>Coarse</option>
</select>
<button id="send" type="submit" class="btn primary">Order</button>
<span id="hollow" class="flag"></span>
</form>
<table id="prices">
<caption>Per 250 g</caption>
<thead><tr><th>Item</th><th>Price</th></tr></thead>
<tbody>
<tr data-band="high"><td>Huila</td><td>22</td></tr>
<tr data-band="low"><td>Sencha</td><td>9</td></tr>
</tbody>
</table>
<p class="note">Prices in <abbr title="United States dollars">USD</abbr>, roasted weekly.</p>
<section id="aside">
<h2>Notes</h2>
<p>One shipment a week.</p>
</section>
</article>
</body>
</html>
Hover a query — or click to pin it, Esc to let go — and the nodes it answered light up in the document above. The page does not run the query again: it follows the routes the engine answered with.
A node is labelled tag#id.class "text" when it is an element, and #text "…" when it is not — #text (blank) is the whitespace between two tags, which is a node like any other. The head, title and html rows select from the whole document, not from the article shown.
Type, the universal, class, id, and every attribute form: present, equal, a word, a prefix, a suffix, a substring, a language, and the case-sensitivity flag. A selector list answers the union, in document order.
| Query | Nodes | Answer |
|---|---|---|
* | 42 | html · head "Field Notes" · title "Field Notes" · body · article#shop.catalogue · +37 |
li | 5 | li.item "Yirgacheffe" · li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" |
.item | 5 | li.item "Yirgacheffe" · li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" |
.sold | 1 | li.item "Sencha" |
#items | 1 | ul#items.list |
[data-price] | 5 | li.item "Yirgacheffe" · li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" |
[data-price="18"] | 1 | li.item "Yirgacheffe" |
[data-price^="1"] | 3 | li.item "Yirgacheffe" · li.item "Assam" · li.item "House" |
[data-price$="2"] | 1 | li.item "Huila" |
[data-price*="4"] | 1 | li.item "Assam" |
[lang|="en"] | 2 | html · li.item "Huila" |
[class~="sold"] | 1 | li.item "Sencha" |
[data-kind="BEAN" i] | 2 | li.item "Yirgacheffe" · li.item "Huila" |
[data-kind="BEAN" s] | 0 | nothing |
[data-kind="bean"] | 2 | li.item "Yirgacheffe" · li.item "Huila" |
li, td | 9 | li.item "Yirgacheffe" · li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" · +4 |
Descendant, child, the adjacent sibling and the general one. A compound on either side works the same way, so the last row here is a chain of three.
| Query | Nodes | Answer |
|---|---|---|
#items > li | 5 | li.item "Yirgacheffe" · li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" |
#items li | 5 | li.item "Yirgacheffe" · li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" |
li + li | 4 | li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" |
li ~ li | 4 | li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" |
#shop > ul > li | 5 | li.item "Yirgacheffe" · li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" |
#prices tr > td:last-child | 2 | td "22" · td "9" |
The ones a document can settle: position among siblings, the functionals — :not, :is, :where, :has — and the form states. :hover and :visited are not here because they need a browser, and the engine refuses them by name rather than answering wrongly.
| Query | Nodes | Answer |
|---|---|---|
li:first-child | 1 | li.item "Yirgacheffe" |
li:last-child | 1 | li.item "House" |
li:nth-child(2) | 1 | li.item "Huila" |
li:nth-child(odd) | 3 | li.item "Yirgacheffe" · li.item "Sencha" · li.item "House" |
li:nth-child(3n+1) | 2 | li.item "Yirgacheffe" · li.item "Assam" |
li:nth-last-child(1) | 1 | li.item "House" |
td:nth-of-type(2) | 2 | td "22" · td "9" |
p:first-of-type | 3 | p.dek · p.note · p "One shipment a week." |
td:last-of-type | 2 | td "22" · td "9" |
#aside > p:only-of-type | 1 | p "One shipment a week." |
:root | 1 | html |
#hollow:empty | 1 | span#hollow.flag |
option:checked | 1 | option "Coarse" |
input:disabled | 1 | input#gift |
input:enabled | 1 | input#qty |
input:required | 1 | input#qty |
input:optional | 1 | input#gift |
li > a:only-child | 1 | a "House" |
td:nth-last-of-type(1) | 2 | td "22" · td "9" |
li:lang(en-GB) | 1 | li.item "Huila" |
li:lang(en) | 5 | li.item "Yirgacheffe" · li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" |
a:link | 1 | a "House" |
a:any-link | 1 | a "House" |
li:not(.sold) | 4 | li.item "Yirgacheffe" · li.item "Huila" · li.item "Assam" · li.item "House" |
li:is(.sold, [data-kind="blend"]) | 2 | li.item "Sencha" · li.item "House" |
:where(.item, .btn) | 6 | li.item "Yirgacheffe" · li.item "Huila" · li.item "Sencha" · li.item "Assam" · li.item "House" · button#send.btn "Order" |
#order:has(option[selected]) | 1 | form#order "Quantity Fine Coarse Order" |
li:has(> a) | 1 | li.item "House" |
li:has(+ li.sold) | 1 | li.item "Huila" |
Every query answers a Selection, so queries compose: narrow a selection again, switch engine mid-flight, take the first or the last, step to the parent, to the children, to the nearest ancestor. pricey in one of these rows is a selector registered at run time.
| Query | Nodes | Answer |
|---|---|---|
page.css("#items").css("li[data-kind=leaf]") | 2 | li.item "Sencha" · li.item "Assam" |
page.css("tbody tr").xpath("./td[2]") | 2 | td "22" · td "9" |
page.css("li").first() | 1 | li.item "Yirgacheffe" |
page.css("li").last() | 1 | li.item "House" |
page.css("li").eq(2) | 1 | li.item "Sencha" |
page.css("td").parent() | 2 | tr "Huila22" · tr "Sencha9" |
page.css("#prices").children() | 7 | #text (blank) · caption "Per 250 g" · #text (blank) · thead "ItemPrice" · #text (blank) · +2 |
page.css("abbr").closest("p") | 1 | p.note |
page.by_id("order") | 1 | form#order "Quantity Fine Coarse Order" |
page.by_tag("option") | 2 | option "Fine" · option "Coarse" |
page.by_class("sold") | 1 | li.item "Sencha" |
page.by_name("grind") | 1 | select "Fine Coarse" |
page.selector("pricey", "") | 3 | li.item "Yirgacheffe" · li.item "Huila" · li.item "House" |
page.css("li").items[4].css("a") | 1 | a "House" |
xpathpage.xpath("//li[last()]") | 1 | li.item "House" |
xpathpage.xpath("//tr[td[2] > 15]") | 1 | tr "Huila22" |
A selection answers the text it holds, the attribute it was asked for, and the markup inside it; xpath_value answers a count, a string, a node-set or a boolean, typed. Note that text() on a group concatenates — the library does not guess where one node's text ends.
| Query | Nodes | Answer |
|---|---|---|
textpage.css("#items li").text() | — | YirgacheffeHuilaSenchaAssamHouse |
textpage.css("li.sold").text() | — | Sencha |
attrpage.css("li.sold").attr("data-price") | — | 9 |
attrpage.css("li").attr("data-kind") | — | bean |
attrpage.css("abbr").attr("title") | — | United States dollars |
attrpage.css("option[selected]").attr("value") | — | coarse |
attrpage.css("#prices tbody tr").attr("data-band") | — | high |
htmlpage.css("#items li").last().html() | — | <a href="https://example.com/b/1">House</a> |
htmlpage.css("#order").html() | — | <label for="qty">Quantity</label> <input id="qty" name="qty" type="number" value="2" required> <input id="gift" name="gift" type="checkbox" disabled> <select name="grind"> <option value="fine">Fine</option> <option value="coarse" selected>Coarse</option> </select> <button id="send" type="submit" class="btn primary">Order</button> <span id="hollow" class="flag"></span> |
xpathpage.css("#items").xpath("./li/@data-price") | — | 182291416 |
xpathpage.xpath_value("count(//li)") | — | 5 |
xpathpage.xpath_value("string(//abbr/@title)") | — | United States dollars |
xpathpage.xpath_value("//li[@data-price > 15]") | — | 3 nodes |
xpathpage.xpath_value("//li[@data-price > 15] and count(//li) = 5") | — | true |
Seven queries, two engines, one element. This is the point of a single tree: the node does not know or care which query found it.
| Query | Nodes | Answer |
|---|---|---|
#order | 1 | form#order "Quantity Fine Coarse Order" |
form | 1 | form#order "Quantity Fine Coarse Order" |
form[action="/order"] | 1 | form#order "Quantity Fine Coarse Order" |
#shop > form | 1 | form#order "Quantity Fine Coarse Order" |
article :is(form) | 1 | form#order "Quantity Fine Coarse Order" |
xpath//form | 1 | form#order "Quantity Fine Coarse Order" |
xpath//*[@action] | 1 | form#order "Quantity Fine Coarse Order" |
:hover, :active, :focus, :visited and :target describe a browser's state, not a document's, so the engine refuses them by name — @moonxml.Refused.BadQuery with the offset — instead of answering a question it cannot see. Everything a tree can settle is above; page.css("a:hover") is a sentence the library will not pretend to understand.
❦