Crawl and extract
Find every page of one kind on a site, then pull the same fields from each into JSONL and CSV. The crawl follows only links that match a pattern (product pages, say, not categories), and extract reads 10 pages per call with one schema. Each price is then checked against the page's own crawled text: a row whose price is not written on its page is marked verified: false, so a model slip does not go unnoticed.
When to use it: You want the same fields from every page of one kind on a site, as JSONL and CSV, with the prices checked.
| Uses | crawl with include, extract over 10 pages per call with a schema |
|---|---|
| Needs | a model on the API (no model key of your own) |
| Site | books.toscrape.com (a demo shop): every book in its Poetry category |
| Output | output/items.jsonl, output/items.csv, output/result.json |
| Languages | Node and Python |
For your own site: set START_URL to a listing page, INCLUDE to a regular expression your item pages' addresses match, and change the schema and prompt to the fields you need. The crawl respects robots.txt and crawl-delay.
The code
The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.
/** * Crawl and extract: find every page of one kind on a site (the crawl follows only links that match a pattern), then * pull the same fields from each with one schema, 10 pages per extract call, into JSONL and CSV. Each price is then * checked against the crawled page's own text, so a model slip shows up as "not verified". * * npx tsx node/index.ts (BOXLINE_API_KEY; START_URL, INCLUDE, MAX_PAGES) * * The default: every book in a books.toscrape.com category (a demo shop). The crawl respects robots.txt. * Writes output/items.jsonl, output/items.csv and output/result.json. */import { mkdirSync, writeFileSync } from "node:fs";import { join } from "node:path";import { Boxline } from "@boxline/sdk";const out = process.env.OUTPUT_DIR ?? "output";const start = process.env.START_URL ?? "https://books.toscrape.com/catalogue/category/books/poetry_23/index.html";const include = process.env.INCLUDE ?? "/catalogue/[^/]+_\\d+/index\\.html$"; // product pages, not categoriesconst maxPages = Number(process.env.MAX_PAGES ?? 40);const bx = new Boxline();type Item = { url: string; title: string; price: number; currency: string; rating: number | null; inStock: boolean; stockCount: number | null };// 1. Discover: the start page, then only links that match the pattern (one level deep).const job = await bx.crawl.start({ url: start, maxPages, maxDepth: 1, include: [include], format: "markdown" });const done = await bx.crawl.wait(job.id, { pollMs: 1500, timeoutMs: 10 * 60_000 });const pattern = new RegExp(include);const found = done.data.filter((p) => pattern.test(p.finalUrl ?? p.url) && !p.error && (p.status ?? 0) < 400);console.log(`Crawled ${done.pagesDone} pages from ${start}; ${found.length} match ${include}`);if (!found.length) throw new Error("no page matched INCLUDE; check the pattern against the site's addresses");// 2. Extract: the same schema for every page, 10 pages per call (each page's row says which page it is).const items: (Item & { verified: boolean })[] = [];let modelUsd = 0;for (let i = 0; i < found.length; i += 10) { const batch = found.slice(i, i + 10); const r = await bx.extract<{ items: Item[] }>({ urls: batch.map((p) => p.finalUrl ?? p.url), prompt: "One row per page: the product on it. url: the page's address as given. price: a number without the currency sign; currency: ISO 4217. rating: 1 to 5 stars, null when none. stockCount: the number available when the page says.", schema: { type: "object", properties: { items: { type: "array", items: { type: "object", properties: { url: { type: "string" }, title: { type: "string" }, price: { type: "number" }, currency: { type: "string" }, rating: { type: ["integer", "null"] }, inStock: { type: "boolean" }, stockCount: { type: ["integer", "null"] }, }, required: ["url", "title", "price", "currency", "rating", "inStock", "stockCount"], }, }, }, required: ["items"], }, }); modelUsd += r.usage.costUsd; // 3. Verify: the price must be written on that page (as crawled), with two decimals. for (const it of r.data.items) { const page = batch.find((p) => (p.finalUrl ?? p.url) === it.url || p.url === it.url); items.push({ ...it, verified: Boolean(page?.content?.includes(it.price.toFixed(2))) }); } console.log(` pages ${i + 1}–${i + batch.length}: ${r.data.items.length} rows ($${r.usage.costUsd.toFixed(4)})`);}const unverified = items.filter((x) => !x.verified);console.log(`${items.length} rows, ${items.length - unverified.length} prices verified against the page${unverified.length ? `; check: ${unverified.map((x) => x.title).join(", ")}` : ""}`);for (const x of items.slice(0, 5)) console.log(` ${x.title}: ${x.price} ${x.currency}, ${x.rating ?? "?"}★, ${x.inStock ? `in stock (${x.stockCount ?? "?"})` : "out of stock"}`);const cols = ["title", "price", "currency", "rating", "inStock", "stockCount", "verified", "url"] as const;const cell = (v: unknown) => (v === null || v === undefined ? "" : /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v));mkdirSync(out, { recursive: true });writeFileSync(join(out, "items.jsonl"), items.map((x) => JSON.stringify(x)).join("\n") + "\n");writeFileSync(join(out, "items.csv"), [cols.join(","), ...items.map((x) => cols.map((c) => cell(x[c])).join(","))].join("\n") + "\n");writeFileSync(join(out, "result.json"), JSON.stringify({ start, include, crawlId: job.id, crawled: done.pagesDone, matched: found.map((p) => p.finalUrl ?? p.url), rows: items.length, verified: items.length - unverified.length, unverified: unverified.map((x) => x.url), usage: { modelUsd } }, null, 2));"""Crawl and extract: find every page of one kind on a site (the crawl follows only links that match a pattern), thenpull the same fields from each with one schema, 10 pages per extract call, into JSONL and CSV. Each price is thenchecked against the crawled page's own text, so a model slip shows up as "not verified". python python/main.py (BOXLINE_API_KEY; START_URL, INCLUDE, MAX_PAGES)The default: every book in a books.toscrape.com category (a demo shop). The crawl respects robots.txt.Writes output/items.jsonl, output/items.csv and output/result.json."""import csvimport jsonimport osimport refrom pathlib import Pathfrom boxline import Boxlineout = Path(os.environ.get("OUTPUT_DIR", "output"))start = os.environ.get("START_URL", "https://books.toscrape.com/catalogue/category/books/poetry_23/index.html")include = os.environ.get("INCLUDE", r"/catalogue/[^/]+_\d+/index\.html$") # product pages, not categoriesmax_pages = int(os.environ.get("MAX_PAGES", "40"))bx = Boxline()# 1. Discover: the start page, then only links that match the pattern (one level deep).job = bx.crawl.start(start, max_pages=max_pages, max_depth=1, include=[include], format="markdown")done = bx.crawl.wait(job["id"], poll=1.5, timeout=600)pattern = re.compile(include)found = [p for p in done["data"] if pattern.search(p.get("finalUrl") or p["url"]) and not p.get("error") and (p.get("status") or 0) < 400]print(f"Crawled {done['pagesDone']} pages from {start}; {len(found)} match {include}")if not found: raise SystemExit("no page matched INCLUDE; check the pattern against the site's addresses")SCHEMA = { "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object", "properties": { "url": {"type": "string"}, "title": {"type": "string"}, "price": {"type": "number"}, "currency": {"type": "string"}, "rating": {"type": ["integer", "null"]}, "inStock": {"type": "boolean"}, "stockCount": {"type": ["integer", "null"]}, }, "required": ["url", "title", "price", "currency", "rating", "inStock", "stockCount"], }, } }, "required": ["items"],}# 2. Extract: the same schema for every page, 10 pages per call (each page's row says which page it is).items, model_usd = [], 0.0for i in range(0, len(found), 10): batch = found[i : i + 10] r = bx.extract( urls=[p.get("finalUrl") or p["url"] for p in batch], prompt="One row per page: the product on it. url: the page's address as given. price: a number without the currency sign; currency: ISO 4217. rating: 1 to 5 stars, null when none. stockCount: the number available when the page says.", schema=SCHEMA, ) model_usd += r["usage"]["costUsd"] # 3. Verify: the price must be written on that page (as crawled), with two decimals. for it in r["data"]["items"]: page = next((p for p in batch if it["url"] in ((p.get("finalUrl") or p["url"]), p["url"])), None) items.append({**it, "verified": bool(page and f"{it['price']:.2f}" in (page.get("content") or ""))}) print(f" pages {i + 1}–{i + len(batch)}: {len(r['data']['items'])} rows (${r['usage']['costUsd']:.4f})")unverified = [x for x in items if not x["verified"]]print(f"{len(items)} rows, {len(items) - len(unverified)} prices verified against the page" + (f"; check: {', '.join(x['title'] for x in unverified)}" if unverified else ""))for x in items[:5]: stock = f"in stock ({x['stockCount'] if x['stockCount'] is not None else '?'})" if x["inStock"] else "out of stock" print(f" {x['title']}: {x['price']} {x['currency']}, {x['rating'] if x['rating'] is not None else '?'}★, {stock}")out.mkdir(parents=True, exist_ok=True)(out / "items.jsonl").write_text("".join(json.dumps(x, ensure_ascii=False) + "\n" for x in items))cols = ["title", "price", "currency", "rating", "inStock", "stockCount", "verified", "url"]with open(out / "items.csv", "w", newline="") as f: w = csv.writer(f) w.writerow(cols) for x in items: w.writerow(["" if x[c] is None else ("true" if x[c] is True else "false" if x[c] is False else x[c]) for c in cols])result = {"start": start, "include": include, "crawlId": job["id"], "crawled": done["pagesDone"], "matched": [p.get("finalUrl") or p["url"] for p in found], "rows": len(items), "verified": len(items) - len(unverified), "unverified": [x["url"] for x in unverified], "usage": {"modelUsd": model_usd}}(out / "result.json").write_text(json.dumps(result, indent=2, ensure_ascii=False))Inputs
Environment variables the example reads:
START_URL,INCLUDE,MAX_PAGES(default 40).
Run it
Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/crawl-and-extract. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.
# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts# Python 3.9 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keypip install boxline-sdkpython python/main.pyThen check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.
What its check proves
check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.
There is one row per matched page (10 or more), no row for a page that was not crawled, prices and ratings are plausible, at least 90% of prices are written on their page, and "A Light in the Attic" costs £51.77 with 22 in stock.
Source
github.com/Boxline-dev/examples/crawl-and-extract on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.