Extract custom fields from any structured data - products, jobs, listings, forums. Not limited to articles.
Use callbacks for:
- E-commerce (products, prices, ratings)
- Job boards (titles, companies, salaries)
- Real estate (properties, prices, features)
- Forums (posts, authors, replies)
- Any non-article structured data
Use parse_article for:
- News, blogs, documentation
- Content with title/content/author/date structure
{
"rules": [{"allow": ["/product/.*"], "callback": "parse_product"}],
"callbacks": {
"parse_product": {
"extract": {
"name": {"css": "h1::text"},
"price": {
"css": "span.price::text",
"processors": [
{"type": "strip"},
{"type": "regex", "pattern": "\\$([\\d.]+)"},
{"type": "cast", "to": "float"}
]
}
}
}
}
}Selectors:
- CSS:
{"css": "h1::text"},{"css": "img::attr(src)"} - XPath:
{"xpath": "//h1/text()"} - Lists:
{"css": "li::text", "get_all": true}
Nested lists:
{
"reviews": {
"type": "nested_list",
"selector": "div.review",
"extract": {
"author": {"css": "span.author::text"},
"rating": {"css": "span.stars::attr(data-rating)", "processors": [{"type": "cast", "to": "int"}]},
"comment": {"css": "p.text::text"}
}
}
}Max depth: 3 levels.
8 available - see processors.md:
strip- Remove whitespacereplace- Replace substringregex- Extract with patterncast- Convert type (int, float, bool, str)join- Join list to stringdefault- Fallback valuelowercase- Convert to lowercaseparse_datetime- Parse dates (stores as ISO strings)
Chain processors:
{"processors": [
{"type": "strip"},
{"type": "regex", "pattern": "\\$([\\d.]+)"},
{"type": "cast", "to": "float"}
]}For sites where data spans two pages — e.g., a ranking table (listing) links to individual detail pages — use iterate to loop over rows, extract per-row fields, and follow links to detail pages with that data passed along.
Use iterate when:
- Rankings/directories where rank lives on the listing page but details live on linked pages
- Search results where you need data from both the result snippet and the full page
- Any listing → detail pattern where you need fields from both pages
{
"callbacks": {
"parse_listing": {
"iterate": {
"selector": "table tr:has(td.rank)",
"follow": {
"url": {"css": "td.name a::attr(href)"},
"callback": "parse_detail"
}
},
"extract": {
"rank": {"css": "td.rank::text", "processors": [{"type": "strip"}, {"type": "cast", "to": "int"}]},
"name": {"css": "td.name a::text"}
}
},
"parse_detail": {
"extract": {
"website": {"css": "a.website::attr(href)"},
"description": {"css": "div.about::text"}
}
}
}
}parse_listingloops over each row matchingselector- For each row,
extractfields are pulled from the row element (not full page) - The
follow.urlselector extracts the link from the row - A request is made to that URL with
callback: "parse_detail" - Extracted row fields are passed via Scrapy
metaaslisting_data parse_detailreceives the response, mergeslisting_datainto its item, then extracts its own fields
The final item contains fields from both pages (listing + detail) at the top level.
Extract fields from the page URL using regex (useful when URL contains data like country codes):
{
"iterate": {
"selector": "table tr",
"url_context": {
"country_code": {"regex": "/(\\w{2})/"},
"state": {"regex": "/\\w{2}/([\\w-]+)\\.htm"}
},
"follow": {
"url": {"css": "td a::attr(href)"},
"callback": "parse_detail"
}
}
}url_context fields are extracted once per page and included in every row's listing_data.
extractin iterate mode uses the row as scope (not full response)url_contextregex must have exactly one capture groupfollow.callbackmust reference a defined callback (orparse_article)- Rows without a matching URL are silently skipped
- Items are only counted when the detail callback yields (not the iterate callback)
extractis optional in iterate callbacks (you can follow without extracting row fields)
Complete working examples in templates/:
- E-commerce:
templates/spider-ecommerce.json - Job boards:
templates/spider-jobs.json - Real estate:
templates/spider-realestate.json
Use as starting points - adjust selectors to match target site.
Never use: parse_article, parse_start_url, start_requests, from_crawler, closed, parse
- Standard fields (url, title, content, author, published_date) → DB columns
- Custom fields →
metadata_jsoncolumn showcommand displays custom fields- Exports flatten custom fields to top-level columns/keys
- Analyze sample page:
./scrapai analyze page.html - Identify fields and discover selectors:
./scrapai analyze page.html --test "h1::text" - Build callback config with processors
- Test on multiple pages to verify selectors work
- Import and test:
./scrapai crawl spider --limit 5 --project proj
Extract price:
{"processors": [
{"type": "strip"},
{"type": "regex", "pattern": "\\$([\\d,.]+)"},
{"type": "replace", "old": ",", "new": ""},
{"type": "cast", "to": "float"}
]}Extract boolean:
{"processors": [
{"type": "lowercase"},
{"type": "regex", "pattern": "(yes|true|available)"},
{"type": "cast", "to": "bool"}
]}Handle missing fields:
{"processors": [
{"type": "strip"},
{"type": "default", "default": null}
]}Field returns None:
- Test selector:
./scrapai analyze page.html --test "selector" - Check if page needs
--browser(for JS-rendered or Cloudflare-protected sites) - Verify processor chain (failed processor may return None)
Wrong type in output:
- Add
castprocessor:{"type": "cast", "to": "float"}
Rule references undefined callback:
- Add callback to
callbacksdict - Or use
callback: nullfor navigation-only rules