Skip to content

In JavaScript

sluicer on npm is the Python package itself, run in Pyodide, Python compiled to WebAssembly. Nothing is rewritten in JavaScript: the package carries the wheel built from the same commit as the Python release of the same version, installs it into Pyodide when it starts, and hands every call to it. The answers are the Python package’s, field for field: its tests hold them to the native package’s on 22 pages, a page for each reader and each option.

To see it without installing anything, try it in your browser: paste a page’s HTML, and Sluicer reads it in the tab, sending it nowhere.

Install

npm install sluicer

It needs Node 18 or later, and is an ES module. It brings one dependency, pyodide, pinned to one version. The first createSluicer() on a machine downloads lxml, click and cssselect, 2.27 MB, from Pyodide’s package repository on jsDelivr; later ones download nothing.

import { readFile } from "node:fs/promises";
import { createSluicer, SluicerError } from "sluicer";

const sluicer = await createSluicer();   // about a second
const html = await readFile("brake-pads.html");
const found = sluicer.extract(html, { url: "https://example.com/p/bp-2210" });

found.summary.price;
// { value: '41.90', source: 'jsonld', key: 'Product.offers.price',
//   where: '/html/head/script[1]#/offers/price' }
found.normalised;   // { price: '41.90', currency: 'EUR', gtin: '4006381333931' }
found.conflicts.map((c) => c.question);   // [ 'price' ]

API

createSluicer(options) starts Pyodide, installs Sluicer in it and resolves to an object whose calls are synchronous: start it once and keep it. Its options:

Option
markdown Also install the markdown extra (trafilatura) from PyPI, for toMarkdown. Adds about three seconds and 14 MB of downloads to every start.
packageCacheDir Where Pyodide keeps the packages it downloads. By default, beside Pyodide in node_modules.
pyodide A Pyodide instance of your own, of the pinned version, to install into.

The object it resolves to:

extract(html, { url, induce, visible, headers }) What the page declares, as Python’s sluicer.extract: the summary, each answer’s source and place, conflicts, records, links and rights. induce: true also reads the rows a page repeats, visible: true guesses the byline and dates it shows, headers are the response’s, when the page came over HTTP.
compile(pages, { listing, want, names }) An extractor learnt from pages of one template, each { html, url }: the file sluicer compile writes, as an object.
run(extractor, html, { url }) That extractor replayed on a page, as sluicer run: ok is false when a check failed, and checks says which. The extractor is the object, or its JSON text: a file written by the Python CLI runs here, and one written here runs there.
toMarkdown(html, { url }) The page’s main text as Markdown. Only after createSluicer({ markdown: true }).
version, python, lxml The Sluicer, Python and lxml versions inside.
pyodide The Pyodide instance it runs in.

A page is a string, or its bytes: a Uint8Array, a Node Buffer or an ArrayBuffer. Give bytes when you have them: the page’s own charset declaration is still in them. url is never fetched; it resolves the page’s links. Answers are plain objects with the Python field names, in snake_case, the JSON sluicer extract prints; index.d.ts types them.

An error Sluicer raises is thrown as a SluicerError, with the Python exception’s name in type and its words in message:

try {
  sluicer.compile([{ html: "<html><body></body></html>" }]);
} catch (error) {
  if (!(error instanceof SluicerError)) throw error;
  error.type;      // 'NothingToLearn'
  error.message;   // 'these pages declare nothing and repeat nothing an extractor could keep'
}

An extractor learnt from two pages of a shop’s listing, and replayed on a page whose prices are gone:

const extractor = sluicer.compile([
  { html: await readFile("shop_v1.html"), url: "https://shop.example/c/1" },
  { html: await readFile("shop_v1_page2.html"), url: "https://shop.example/c/2" },
]);
const run = sluicer.run(extractor, await readFile("shop_prices_gone.html"));
run.ok;   // false: a check failed, and run.checks says which

What it does not do

It reads pages you hand it; it does not fetch them. Sluicer’s fetching is more than a request: it refuses private addresses, reads robots.txt, keeps one request per site at a time, and chooses how to fetch. None of that is in this first version, rather than a fetch without those guards. Fetch the page yourself, and hand over its bytes, address and headers:

const response = await fetch(url);
const found = sluicer.extract(new Uint8Array(await response.arrayBuffer()), {
  url: response.url,
  headers: Object.fromEntries(response.headers),
});

Nor does it offer what is built on fetching or on files: crawl, map, batch, feeds, web archives, audit, diff, heal, the command line, the MCP server and the HTTP API. The microformats reader, behind an extra in Python, is not offered either. For those, the Python package: uvx sluicer, or sluicer serve for the HTTP API from any language.

It runs in Node. The package reads its wheel from its own folder with node:fs, so a bundler will not carry it into a browser as it is; the try page shows how the same wheel runs in a browser, in docs/try/try.js.

Versions

The npm package’s version is the Python package’s: sluicer@0.8.0 on npm is the wheel of sluicer==0.8.0, built from the same commit. The build refuses to run when js/package.json and pyproject.toml differ, and a test on each side, Python’s and Node’s, holds them equal. CI (.github/workflows/js.yml) runs the Node tests on Node 18, 22 and 24, writes the native answers they compare with again and fails on any difference, and opens the try page in Chromium; a release tag publishes the package to npm only after all of them pass, from the tag whose version it carries. Pyodide is pinned to one version, Pyodide 314.0.7, with Python 3.14.2 and lxml 6.1.3 inside, the lxml the repository’s lockfile pins; a new Pyodide comes with a release of this package, never under it. sluicer.version, .python and .lxml say what is running.

The package’s licence is the Python package’s, MIT AND CC-BY-SA-3.0 AND Unicode-3.0: the wheel holds two data files under the other two, which LICENSES/ carries.

Measured

On an Apple M4, Node 26.1.0, sluicer 0.7.1 with Pyodide 314.0.7: node js/scripts/measure.mjs 5 in a checkout, after npm run build and npm ci in js/. Every start is a new process; medians of five, with the lowest and highest.

What npm install sluicer downloads, as the registry serves it:

Package Packed Unpacked
sluicer 0.7.1 378 KB 410 KB
pyodide 314.0.7 6.5 MB 13.9 MB
Together 6.9 MB 14.3 MB

What createSluicer() takes, in a new Node process:

createSluicer() Downloaded
First run on a machine 1.30 s (1.28-1.37) 2.25 MB, from cdn.jsdelivr.net
Every later run 1.14 s (1.10-1.26) nothing
First run, markdown: true 4.0 s (3.9-4.4) 17.9 MB
Every later run, markdown: true 3.9 s (3.3-4.0) 14.3 MB, from PyPI

The first run downloads lxml, click and cssselect, Sluicer’s requirements, from Pyodide’s own package repository on jsDelivr, and keeps them in Pyodide’s package cache (by default beside Pyodide in node_modules). The markdown extra installs trafilatura and its requirements from PyPI with micropip on every start, since that cache keeps only Pyodide’s own packages; ten of its 14 MB are babel, which courlan (trafilatura’s URL reader) needs.

Once started, extract() of the brake-pads example (1.2 KB) takes 7 ms the first time and 0.78 ms (0.76-0.91) after; the native package takes 0.31 ms on the same machine.

In a browser, the try page was opened in Chromium by Playwright with an empty cache (python scripts/check_try_page.py): ready in 1.8 s, after 8.9 MB over the wire, 8.5 MB of it Pyodide, its standard library and lxml from jsDelivr, and 0.38 MB the wheel from the site; reloaded, 1.0 s. It made 14 requests, every one a GET before Sluicer was ready, and none after 16 pages were pasted.