Docs
Dashboard
Examples / Data for AIBeta

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.

Usescrawl with include, extract over 10 pages per call with a schema
Needsa model on the API (no model key of your own)
Sitebooks.toscrape.com (a demo shop): every book in its Poetry category
Outputoutput/items.jsonl, output/items.csv, output/result.json
LanguagesNode 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));

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

Then 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.