Validation and warnings
How Kaabist turns page text into typed values, what it does when a value is missing or unreadable, and every warning a job can carry.
Every extracted field goes through the same pipeline: find it, keep the raw string, parse it with the configuration your program gives, check its constraints, and apply its policy. When something is off, you get a typed null and a warning that says why, rather than a guess that looks like data.
#When to use it
Read this page when you write the extract part of a program, when a result has warnings you need to act on, or when you decide how strict a job should be. The defaults favour keeping the job useful: one unreadable price out of forty is content, and the other thirty-nine still arrive. A program that would rather fail than return anything doubtful sets policies.strict or per-field on_invalid: "fail".
#The pipeline
locate → raw → null markers → transforms → parse → constraints → policy
- Locate: the field's locator finds an element, a JSON value or a captured response. See Locators.
- Raw: the element's normalised text (or the attribute named by
attr) is kept as the raw string. - Null markers: a raw string that is a null marker becomes
null. - Transforms: pure, bounded string operations (
trim,replace,split,sliceand the rest), in order. - Parse: the string becomes the field's type, using the field's parse configuration.
- Constraints:
min,max,patternand the rest are checked against the parsed value. - Policy: a failure at steps 5 or 6 is handled by the field's
on_invalid.
Cross-field $asserts run after every field is read.
#The raw string is always kept
Whatever a field ends up as, the string it was read from is kept. A price that came out null because the page printed an em dash (U+2014) is a page behaving normally; the same null from a page that printed 1.337,40 TL is a parser configured wrongly, and only the raw string tells the two apart.
A job returns raw for every field that failed or came out null. Set policies.include_raw: true to get it for every field.
#Types
type | Produces |
|---|---|
string (default) | the text, as printed |
text | multi-line text with whitespace normalised |
number, integer | a number, read with the field's locale; allow_suffix: true also reads 1.2k, 1,5M |
money | { "amount": 1337.4, "currency": "TRY" } |
boolean | true / false from true_values / false_values; presence: true reads whether the element exists |
date | YYYY-MM-DD |
datetime | an ISO-8601 string with an explicit offset |
url | an absolute URL, resolved against the page (or base_url); javascript: is refused |
email | an address |
phone | E.164, with the region from country or the locale |
enum | one of values (case-insensitive by default) |
list, object | implied by many: true and fields |
#Parsing numbers, money and dates
#Kaabist does not guess your locale
1.299 is one thousand two hundred and ninety-nine in Turkey and one and 299 thousandths in the United States, and no amount of cleverness makes one of those readings correct. So a numeric field is read with the locale (or the explicit decimal and thousands) the program gives it, and with en when it gives none. The builder proposes a configuration from the value you clicked on, and shows you the runner-up reading when the sample cannot decide for itself; publishing the program is where the choice becomes yours. Lint warns (W005) about a number or money field with no locale whose sample contains both . and ,.
Two places do read a value without being told how, because the value says so itself:
- a number that came out of the page's own embedded JSON is already in JSON's format, and is read that way whatever
localesays; - a price whose printed shape settles the question (
1.337,40has its decimal separator last in every locale) is read from the shape when the field named no locale.
#Locales
These are the locales the parser knows. A tag it does not know falls back to its language (de-AT reads as de), and an unknown language to en. A space in the thousands column means any space: an ordinary space, a no-break space and a narrow no-break space are the same mark.
locale | Decimal | Thousands | Month names | Default phone region |
|---|---|---|---|---|
en | . | , | yes | none |
en-us | . | , | yes | US |
en-gb | . | , | yes | GB |
en-in | . | , | yes | IN |
tr | , | . | yes | TR |
de | , | . | yes | DE |
de-ch | . | ' | yes | CH |
fr | , | space | yes | FR |
es | , | . | yes | ES |
it | , | . | yes | IT |
pt | , | . | yes | PT |
pt-br | , | . | yes | BR |
nl | , | . | yes | NL |
pl | , | space | no | PL |
ru | , | space | yes | RU |
sv | , | space | no | SE |
da | , | . | no | DK |
fi | , | space | no | FI |
nb, no | , | space | no | NO |
cs | , | space | no | CZ |
hu | , | space | no | HU |
ro | , | . | no | RO |
ar | . | , | no | none |
ja | . | , | no | JP |
zh | . | , | no | CN |
ko | . | , | no | KR |
"Month names" means a date_format with MMMM or MMM can read the locale's month names (12 Ekim 2026 in tr).
#Money
The currency is read from the string when the page prints one (TL, ₺, TRY, €, $ and the other common symbols and codes) and falls back to the field's currency when it does not. The amount is read with, in order: the field's locale; the currency's own locale, for currencies that have exactly one (TRY reads as tr, BRL as pt-br, CHF as de-ch, JPY as ja, and so on); the printed shape, when it is unambiguous; and finally en.
#Dates
A date or datetime field accepts ISO-8601 always, then each pattern in date_format (a string or a list, in ICU tokens such as dd.MM.yyyy) in order. A value printed without an offset is read in the field's timezone, UTC by default. Relative dates such as "2 days ago" are not parsed: the moment a page was rendered is not the moment a job re-reads it, and a date that changes on replay is worse than no date.
#Null markers
A raw string that matches a null marker becomes null, with the warning null_marker (or field_empty when the string was empty). The default markers are "", -, the em dash (U+2014), the en dash (U+2013), N/A, n/a, null and None; set null_markers on a field to replace the list. A marker is not a failure: it is a page saying "no value", and the field's policy does not apply.
#Constraints
| Key | Applies to | Fails as |
|---|---|---|
required (default false) | any field | required_missing |
nullable (default true) | any field; false means a null is not acceptable | required_missing |
min, max | numbers, money, and date/datetime (as ISO-8601 strings) | out_of_range |
min_length, max_length | strings | out_of_range |
pattern | strings (a regular expression) | out_of_range |
values | enum | out_of_range |
min_items | lists | list_short |
max_items | lists | out_of_range |
unique | lists of scalars, or unique: "field" for a list of objects | out_of_range |
required and nullable: false are not governed by on_invalid: a required field that is missing or null fails the job with validation_failed, unless the step is optional.
#What happens when a field fails
A field that does not parse, or breaks a constraint, is handled by its on_invalid:
on_invalid | The field | The job |
|---|---|---|
null (default) | null, status invalid | continues |
raw | keeps the unparsed string | continues |
fail | null | fails with validation_failed |
drop (list items only) | the item is removed from its list, status dropped | continues |
Every case adds the matching warning (parse_failed, out_of_range or list_short). Lint refuses drop outside a list.
#Field statuses
| Status | What happened | Warning |
|---|---|---|
ok | found and parsed | none |
null | the page printed a null marker | null_marker, or field_empty for an empty string |
invalid | found but not parsable, or broke a constraint | parse_failed, out_of_range, list_short |
missing | no locator matched | field_empty, or required_missing when the field is required |
dropped | removed from its list by on_invalid: "drop" | the warning that caused it |
#policies.strict
Set "policies": { "strict": true } on a job or a program to turn any extraction warning into a failure: the job fails with validation_failed, and error.details lists the fields and warnings that caused it. Use it when a downstream system would rather have nothing than something it has to check. A job that is not strict and failed after at least one extract ran still returns what it extracted.
#Cross-field checks
$asserts compares two fields, or a field against a literal, with <, <=, ==, !=, >= or >:
{ "$asserts": [{ "left": "price", "op": "<=", "right": "list_price" }] }
A failing assert adds out_of_range on the left-hand field, under that field's own policy, or fails the job when it is strict. An assert whose side has no value is skipped, not failed.
#Warnings
Warnings are a closed set; a job never carries a code outside this list. Each has a code and, where it applies, field, raw, step, message, detail, count and of.
| Code | When it appears |
|---|---|
null_marker | the page printed a null marker for a field |
field_empty | a field's element printed nothing, or an optional field's locator matched nothing; on a step, a template referenced a value that was missing |
parse_failed | a field's text could not be parsed as its type |
out_of_range | a parsed value broke min, max, min_length, max_length, pattern, values, max_items or unique, or a $asserts comparison failed |
required_missing | a required or nullable: false field had no value; the job fails unless the step is optional |
list_short | a list had fewer items than min_items |
fill_rate_drop | across the program version's recent runs, this field has stopped filling as it used to; a fix is probably waiting in the review queue (see How healing works) |
escalated | the job moved to a stronger tier to finish; message says why (see Tiers and escalation) |
healed_locator_used | a step's locator no longer matched and a healed one carried this run only |
obstacle_dismissed | something covering the page was closed before the step ran; detail names how (see Obstacles) |
step_skipped | an optional step, or one with on_error: "skip", failed and was skipped; message says what failed |
cache_hit | reserved for a result served from the cache; check meta.cache, which is "hit" for such a result |
byo_fallback_used | your own proxy failed and fallback: "platform" sent the traffic through Kaabist's (see Egress and proxies) |
fill_rate_drop is the one warning that is not about this job: the data in this result may look fine. It carries the finding in detail, for example products[].price: fill rate 97% → 20% over the last 340 samples, and it clears when the field recovers or a healed version is published.
#List warnings are aggregated
A warning that applies to items of a list is reported once for the list, with how many items it covered, instead of once per item:
{ "code": "null_marker", "field": "products[].price", "count": 3, "of": 40 }
#Worked example
A shop's catalogue at https://shop.example.com prints Turkish prices (1.337,40 TL) on forty product cards, and the product pages of the app at https://app.example.com print - for a price that JavaScript fills in later. A list field over the catalogue:
{
"kaab": "1",
"options": { "max_tier": "http" },
"steps": [
{ "goto": "https://shop.example.com/?page=1" },
{
"extract": {
"products": {
"selector": ".product-card",
"many": true,
"min_items": 1,
"fields": {
"title": ".title",
"price": {
"selector": ".price",
"type": "money",
"locale": "tr",
"currency": "TRY",
"min": 0,
"null_markers": ["-", "N/A"],
"sample": { "raw": "1.337,40 TL", "value": 1337.4 }
},
"url": { "selector": "a", "attr": "href", "type": "url" }
}
}
}
}
],
"policies": { "include_raw": true }
}
Each card's price comes back as { "amount": 1337.4, "currency": "TRY" }, each url absolute against the page, and raw holds 1.337,40 TL beside it because include_raw is on.
Now change one thing at a time and watch the warnings:
- Remove
"locale": "tr". The result does not change: the currency is TRY, and TRY reads astr. Remove"currency"too, and the printedTLstill names it. The shape1.337,40would settle it on its own. - Read the same field with
"locale": "en"on a value like1.299 TL. It reads as one point two nine nine, which is why the locale is yours to set:1.299has no shape that decides. - Read the same
pricefield fromhttps://app.example.com/p/1withmax_tier: "http". The server printed-(JavaScript fills the real price in later), sopriceisnullwith anull_markerwarning whoserawis-, and the job succeeds. Make the field"required": trueand the job fails withvalidation_failedinstead. Tiers and escalation shows how anexpecton the price lets the job move to a browser and read the filled value instead. - Add
"max": 100. Every price breaks the constraint: each isnull, and one aggregatedout_of_rangewarning covers the list. With"on_invalid": "drop", the items are removed instead; with"on_invalid": "fail", the job fails.
To try a change like this without fetching the page again, replay the new version against a stored snapshot; see Snapshots.
#Pitfalls
- Set a locale on every number and money field. Without one, the parser reads
en, which is wrong for most of the world's prices. - A
nullwith no warning is not possible. If a field isnull, the warnings say whether the page printed nothing, printed a marker, or printed something your configuration could not read. Readrawbefore changing the locator. requiredfails the job,on_invaliddoes not by default. Userequiredfor the fields a result is useless without, and leave the rest nullable.strictcounts every extraction warning. A singlenull_markeron an optional field fails a strict job; give such fields their ownnull_markersor accept them in your code instead.- Relative dates do not parse. Read the absolute date from an attribute (
attr: "datetime"on a<time>element) when the page offers one.
#Reference
- Extract schema and the field options: type, locale, currency, null-markers, required, on-invalid, transforms, sample
- Assertions and policies
- Warning codes and error codes
- Job schema and extract schema