Get the product data out of a shop page

Get the name, reference, brand, price and availability of an item from the page that shows it.

No AI needed

FreeInstantYour data never leaves

The verdict

N0, because the data is already there: the shop writes it for search engines, typed and named by schema.org, and reading it is a call to your language’s JSON parser. The code is only interesting for what it refuses to settle — several products on one page, one offer among several, a price written “1 234,56” that is not a number — and for two production details: no amount goes through a float, so a price written `24.90` as a JSON number comes back `24.90` and not `24.9`; and the blocks are scanned by hand, at both levels, because a regular expression over a page you did not write collapses, twenty thousand unclosed tags being enough to push it past ten seconds. Move up to N3 for the shop that emits nothing, knowing that the guard that makes the call defensible there — dropping any value absent from the page — drops correct values too.

The JSON-LD block the page publishes about itself

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/extract-product-data-from-a-shop-page/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Read the product a shop page already publishes about itself.

Rung N0. Almost every shop built on a commerce platform emits its product as
JSON-LD, in a `<script type="application/ld+json">` block, because search
engines ask for it. The name, the reference, the brand, the price, the currency
and the availability are already there, named by schema.org, typed, and written
by the shop itself. Reading them is parsing JSON.

That is the whole of this rung, and it is why writing selectors — or sending
the page to a model — is the most documented piece of over-engineering in web
scraping: both go looking in the rendering for what the page states in plain
text a few lines above.

Two things are refused rather than guessed. A page may carry several products —
the one it is about, and the carousel of related items — and nothing in the
format says which is which, so every one found is returned and the caller
chooses. And a price written « 1 234,56 » is not a number for schema.org, which
asks for a dot: rather than read it as one thousand or as one, `price` comes
back as None with the text the shop wrote in `price_text`.

No number in the document is turned into a float, `price` included. A shop that
writes `"price": 24.90` — as a JSON number, which several platforms do — has
written two decimals, and a float has no way to remember that: it comes back
`24.9`, from the very field whose job is to keep the cents. So the parser is
told to hand every number over as the digits it read, and `price` is a string
of digits the caller turns into whatever its own money type is.

An offer is picked the same way a product is not: a page may publish several —
one per size, per colour, per merchant — and only the first is read. `offers`
in the report says how many there were, so a caller that sees more than one
knows the price it got is one of several.
"""

from __future__ import annotations

import json
import re

# schema.org writes availability as a URL, a bare name, or an http URL from
# before the site moved to https. All three mean the same thing.
AVAILABILITY = re.compile(r"^(?:https?://schema\.org/)?(\w+)$")

# A price is a number with a dot, says schema.org. Anything else is text.
PRICE = re.compile(r"^-?\d+(?:\.\d+)?$")

# Which of the three GTIN keys answered. Their length is an information of its
# own — eight, thirteen or fourteen digits — and « what is not read is named »
# applies to what is read too.
GTIN_KEYS = ("gtin", "gtin14", "gtin13", "gtin12", "gtin8")


def read_products(html) -> dict:
    """
    Every schema.org Product the page declares, in the order they appear.

    `source` says where the answer came from, so a caller that finds nothing
    knows whether the page is silent or whether its JSON-LD is broken.
    """
    if not isinstance(html, str):
        return {"source": None, "products": [], "reason": f"expected HTML, not {type(html).__name__}"}

    nodes, broken = [], 0
    for block in _blocks(html):
        try:
            # `parse_float=str` and `parse_int=str`: no number in this
            # document becomes a float. See the header — a price written 24.90
            # must come back as 24.90.
            nodes.extend(_flatten(json.loads(block, parse_float=str, parse_int=str)))
        except (ValueError, RecursionError):
            broken += 1

    products = [_product(node) for node in nodes if _is_product(node)]
    if products:
        return {"source": "json-ld", "products": products, "reason": None}
    reason = "the JSON-LD on this page could not be parsed" if broken else "no JSON-LD product"
    return {"source": None, "products": [], "reason": reason}


def _blocks(html: str) -> list:
    """
    The JSON-LD script blocks, scanned rather than matched.

    A regular expression would do it in one line and would be a denial of
    service on a page you did not write: « <script type="application/ld+json"> »
    repeated twenty thousand times with no closing tag makes the engine retry
    from every opening. This walks the string once.
    """
    found, cursor, lower = [], 0, html.lower()
    while True:
        start = lower.find("<script", cursor)
        if start < 0:
            return found
        opening = lower.find(">", start)
        closing = lower.find("</script", opening) if opening >= 0 else -1
        if opening < 0 or closing < 0:
            return found
        if "application/ld+json" in lower[start:opening]:
            found.append(html[opening + 1:closing])
        cursor = closing + len("</script")


def _flatten(value, depth: int = 0):
    """Every node of the document: a graph, a list of blocks, or one object."""
    if depth > 8:
        return []
    if isinstance(value, list):
        return [n for item in value for n in _flatten(item, depth + 1)]
    if not isinstance(value, dict):
        return []
    found = [value]
    for key in ("@graph", "mainEntity", "itemListElement", "item"):
        if key in value:
            found.extend(_flatten(value[key], depth + 1))
    return found


def _is_product(node) -> bool:
    kinds = node.get("@type")
    kinds = kinds if isinstance(kinds, list) else [kinds]
    return "Product" in kinds


def _product(node) -> dict:
    offers = _flatten(node.get("offers"))
    offer = offers[0] if offers else {}
    raw_price = offer.get("price")
    price = str(raw_price) if raw_price is not None else None
    brand = node.get("brand")
    availability = offer.get("availability")
    match = AVAILABILITY.match(availability) if isinstance(availability, str) else None
    gtin_key = next((k for k in GTIN_KEYS if _text(node.get(k))), None)
    return {
        "name": _text(node.get("name")),
        "sku": _text(node.get("sku")),
        "gtin": _text(node.get(gtin_key)) if gtin_key else None,
        "gtin_key": gtin_key,
        "brand": _text(brand.get("name") if isinstance(brand, dict) else brand),
        # None when the shop did not write a schema.org number, with the text
        # it did write beside it: a price read wrong is worse than a price not
        # read. Digits, never a float — see the header.
        "price": price if price and PRICE.match(price) else None,
        "price_text": price,
        "currency": _text(offer.get("priceCurrency")),
        "availability": match.group(1) if match else _text(availability),
        # How many offers the page published for this product. Only the first
        # is read; a caller that sees more than one knows it.
        "offers": len(offers),
    }


def _text(value):
    return value.strip() if isinstance(value, str) else None

JavaScript

snippets/extract-product-data-from-a-shop-page/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Read the product a shop page already publishes about itself.
 *
 * Rung N0. Almost every shop built on a commerce platform emits its product as
 * JSON-LD, in a `<script type="application/ld+json">` block, because search
 * engines ask for it. The name, the reference, the brand, the price, the
 * currency and the availability are already there, named by schema.org, typed,
 * and written by the shop itself. Reading them is parsing JSON.
 *
 * That is the whole of this rung, and it is why writing selectors — or sending
 * the page to a model — is the most documented piece of over-engineering in
 * web scraping: both go looking in the rendering for what the page states in
 * plain text a few lines above.
 *
 * Two things are refused rather than guessed. A page may carry several
 * products — the one it is about, and the carousel of related items — and
 * nothing in the format says which is which, so every one found is returned
 * and the caller chooses. And a price written « 1 234,56 » is not a number for
 * schema.org, which asks for a dot: rather than read it as one thousand or as
 * one, `price` comes back as null with the text the shop wrote in `price_text`.
 *
 * No number in the document is turned into a float, `price` included. A shop
 * that writes `"price": 24.90` — as a JSON number, which several platforms do
 * — has written two decimals, and a float has no way to remember that: it
 * comes back `24.9`, from the very field whose job is to keep the cents. So
 * the parser is told to hand every number over as the digits it read, and
 * `price` is a string of digits the caller turns into whatever its own money
 * type is.
 *
 * An offer is picked the same way a product is not: a page may publish several
 * — one per size, per colour, per merchant — and only the first is read.
 * `offers` in the report says how many there were, so a caller that sees more
 * than one knows the price it got is one of several.
 */

// schema.org writes availability as a URL, a bare name, or an http URL from
// before the site moved to https. All three mean the same thing.
const AVAILABILITY = /^(?:https?:\/\/schema\.org\/)?(\w+)$/;

// A price is a number with a dot, says schema.org. Anything else is text.
const PRICE = /^-?\d+(?:\.\d+)?$/;

// Which of the GTIN keys answered. Their length is an information of its own —
// eight, twelve, thirteen or fourteen digits — and « what is not read is
// named » applies to what is read too.
const GTIN_KEYS = ['gtin', 'gtin14', 'gtin13', 'gtin12', 'gtin8'];

/**
 * No number in this document becomes a float. `JSON.parse` hands the reviver
 * the source text it read, and that is what is kept: a price written 24.90
 * must come back as 24.90, not as 24.9.
 */
const parseKeepingDigits = (block) =>
  JSON.parse(block, (key, value, context) => (
    typeof value === 'number' && context && typeof context.source === 'string'
      ? context.source
      : value));

/**
 * Every schema.org Product the page declares, in the order they appear.
 *
 * `source` says where the answer came from, so a caller that finds nothing
 * knows whether the page is silent or whether its JSON-LD is broken.
 *
 * @param {string} html
 */
export function readProducts(html) {
  if (typeof html !== 'string') {
    return { source: null, products: [], reason: `expected HTML, not ${kindOf(html)}` };
  }

  const nodes = [];
  let broken = 0;
  for (const block of blocks(html)) {
    try {
      nodes.push(...flatten(parseKeepingDigits(block)));
    } catch {
      broken += 1;
    }
  }

  const products = nodes.filter(isProduct).map(product);
  if (products.length > 0) return { source: 'json-ld', products, reason: null };
  const reason = broken ? 'the JSON-LD on this page could not be parsed' : 'no JSON-LD product';
  return { source: null, products: [], reason };
}

/**
 * The JSON-LD script blocks, scanned rather than matched.
 *
 * A regular expression would do it in one line and would be a denial of
 * service on a page you did not write: « <script type="application/ld+json"> »
 * repeated twenty thousand times with no closing tag makes the engine retry
 * from every opening. This walks the string once.
 */
function blocks(html) {
  const found = [];
  const lower = html.toLowerCase();
  let cursor = 0;
  for (;;) {
    const start = lower.indexOf('<script', cursor);
    if (start < 0) return found;
    const opening = lower.indexOf('>', start);
    const closing = opening >= 0 ? lower.indexOf('</script', opening) : -1;
    if (opening < 0 || closing < 0) return found;
    if (lower.slice(start, opening).includes('application/ld+json')) {
      found.push(html.slice(opening + 1, closing));
    }
    cursor = closing + '</script'.length;
  }
}

/** Every node of the document: a graph, a list of blocks, or one object. */
function flatten(value, depth = 0) {
  if (depth > 8) return [];
  if (Array.isArray(value)) return value.flatMap((item) => flatten(item, depth + 1));
  if (typeof value !== 'object' || value === null) return [];
  const found = [value];
  for (const key of ['@graph', 'mainEntity', 'itemListElement', 'item']) {
    if (key in value) found.push(...flatten(value[key], depth + 1));
  }
  return found;
}

function isProduct(node) {
  const kinds = Array.isArray(node['@type']) ? node['@type'] : [node['@type']];
  return kinds.includes('Product');
}

function product(node) {
  const offers = flatten(node.offers);
  const offer = offers.length > 0 ? offers[0] : {};
  const rawPrice = offer.price;
  const price = rawPrice === undefined || rawPrice === null ? null : String(rawPrice);
  const brand = node.brand;
  const availability = offer.availability;
  const match = typeof availability === 'string' ? AVAILABILITY.exec(availability) : null;
  const gtinKey = GTIN_KEYS.find((k) => text(node[k])) ?? null;
  return {
    name: text(node.name),
    sku: text(node.sku),
    gtin: gtinKey ? text(node[gtinKey]) : null,
    gtin_key: gtinKey,
    brand: text(typeof brand === 'object' && brand !== null ? brand.name : brand),
    // null when the shop did not write a schema.org number, with the text it
    // did write beside it: a price read wrong is worse than a price not read.
    // Digits, never a float — see the header.
    price: price !== null && PRICE.test(price) ? price : null,
    price_text: price,
    currency: text(offer.priceCurrency),
    availability: match ? match[1] : text(availability),
    // How many offers the page published for this product. Only the first is
    // read; a caller that sees more than one knows it.
    offers: offers.length,
  };
}

const text = (value) => (typeof value === 'string' ? value.trim() : null);

const kindOf = (value) => (value === null ? 'null' : typeof value);

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

Paste the source of a product page, or just its JSON-LD block. Nothing goes out on the network: the code reads the text you give it.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

What it returns

1 product read

In detail

ProductNameSKUBrandPriceCurrencyAvailability
1Moulin à café LumièreMC-4501Lumière19.90EURInStock

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the page is read inside your own process and is sent to nobody
  • Scraping a third-party site is a matter of its terms of use and of applicable law, which the technique used does not change

Breaking point

The block is what the shop chose to publish, and it can fall out of step with the page: on a page running a promotion, the JSON-LD says 24.90 while the body shows 19.90. Witness in the same test: on a page in step with itself, the price read is the body’s.

When to move up a level

The shop emits no JSON-LD block at all, and its rendering changes often enough that writing selectors means rewriting them.

The four options

  1. RecommendedN0The JSON-LD block the page publishes about itselfFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. N3The page’s text sent to a model, with every value not in it droppedmodéré · About a second

The other options

N1Not worth it here

Not worth it here

Lightweight classic modelN1

Learning to recognise a price in a tree of tags is a genuine subject — wrapper induction — and here it runs into a comparison that makes it moot: the model would have to be retrained at every redesign of every shop, where the JSON-LD block is written by the shop itself and follows its own changes. On the pages that emit none, a model learned on one shop does not transfer to the next, and you would need a labelled set per site.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

A small self-hosted specialised model for reading a shop page is the same work as N3 with a machine to keep running, and without the one thing that makes N3 interesting here: that it has nothing to learn about the site. The trade-off is between reading a free declaration and paying for a reading; an intermediate level that pays for the reading without the convenience of the API has nowhere to sit.

N3The page’s text sent to a model, with every value not in it droppedmodéré · About a second

The page’s text sent to a model, with every value not in it dropped

General-purpose LLM APIN3modéréAbout a second

Python

snippets/extract-product-data-from-a-shop-page/n3.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
Read a product from a page that publishes nothing structured, by asking a model.

Rung N3. This is the level for the shop that emits no JSON-LD and whose
rendering changes every quarter, where writing selectors means rewriting them.
It earns its place there, and nowhere above it: on a page that already carries
its JSON-LD, rung N0 reads the same values for nothing.

Three things this code does that the model does not, and that are the real work
of this rung.

It cuts. A shop page is hundreds of kilobytes of markup, almost all of it menus,
tracking and styles; the tags come off and what is left is capped. The provider
bills what is sent, so the cap is a budget, and the entry on estimating a
call's cost is where that budget is computed.

It refuses an invented value. Every string the model returns is looked for in
the text that was sent: a name, a reference or a price that appears nowhere in
the page is dropped, and `invented` says which. This is the one guard that
separates a reading from a plausible answer, and it is worth more than the
prompt.

And it treats a refusal as a refusal. A provider that cannot answer raises;
`content` of None is not a value, and it is not handed to the JSON decoder in
the hope that the exception will do.
"""

from __future__ import annotations

import json
import re

# What the model is asked for, named as schema.org names it so that a caller
# can put an N0 answer and an N3 answer in the same table.
FIELDS = ("name", "sku", "brand", "price", "currency", "availability")

# A shop page is markup; this is what is left once the tags are gone. The
# pattern carries no nested quantifier on purpose: the one that reads
# « <(script|style)...>.*?</\1> » in a single line is the collapse the N0
# snippet of this entry was written to avoid, and it cost tens of seconds of
# CPU here, before the model was even called. The blocks are scanned instead,
# in `_without_scripts`.
TAGS = re.compile(r"<[^>]+>")
SPACES = re.compile(r"[ \t\r\f\v]*\n\s*|[ \t]{2,}")

# The budget in characters of markup, applied before anything reads the page.
# The charte asks that an input too large be refused before the call; a cap
# applied after the cleaning protects nothing, since the cleaning is the part
# that costs.
MAX_HTML = 400_000

# The budget, in characters of text. A page that does not fit is cut, not
# refused: a scraper that raises on a long page returns nothing at all.
MAX_CHARACTERS = 6000

# A single code fence around the whole answer is a common shape, and refusing
# it would buy another call for nothing.
FENCE = re.compile(r"\A\s*```(?:json)?\s*(.*?)\s*```\s*\Z", re.DOTALL)

MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts

PROMPT = (
    "Read this shop page and return the product it is about.\n"
    "Answer with JSON only, with these keys: {fields}.\n"
    "Copy each value from the page. Use null for anything the page does not say.\n\n"
    "Page:\n{page}"
)


class ProviderClient:
    """The one call this snippet makes, on top of the provider's SDK."""

    def __init__(self, sdk=None, model: str = MODEL):
        if sdk is None:  # pragma: no cover - needs a key and a network
            from openai import OpenAI

            sdk = OpenAI()
        self.sdk, self.model = sdk, model

    def complete(self, *, prompt: str, temperature: float) -> str:
        response = self.sdk.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            temperature=temperature,
        )
        return response.choices[0].message.content


class ReadingUnavailable(Exception):
    """The provider could not be reached, or answered something unusable."""


def _without_scripts(html: str) -> str:
    """
    The markup with every script and style block removed, scanned rather than
    matched — the same walk as `_blocks` in the N0 snippet, and for the same
    reason. An unclosed block swallows the rest of the page, which is what a
    browser does too.
    """
    kept, cursor, lower = [], 0, html.lower()
    while cursor < len(html):
        starts = [i for i in (lower.find("<script", cursor), lower.find("<style", cursor)) if i >= 0]
        if not starts:
            kept.append(html[cursor:])
            break
        start = min(starts)
        kept.append(html[cursor:start])
        tag = "script" if lower.startswith("<script", start) else "style"
        closing = lower.find(f"</{tag}", start)
        if closing < 0:
            break
        cursor = closing
    return "".join(kept)


def to_text(html: str) -> str:
    """The page without its markup, collapsed, and cut to the budget."""
    return SPACES.sub("\n", TAGS.sub(" ", _without_scripts(html[:MAX_HTML]))).strip()[:MAX_CHARACTERS]


def read_product(html: str, client=None, *, attempts: int = 3) -> dict:
    """
    The product a model reads on this page, with what it made up removed.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.
    """
    client = client or ProviderClient()
    page = to_text(html)
    answer = _ask(client, page, attempts)

    product, invented = {}, []
    haystack = page.casefold()
    for field in FIELDS:
        value = answer.get(field) if isinstance(answer, dict) else None
        if value is None:
            product[field] = None
        elif str(value).strip().casefold() in haystack:
            product[field] = str(value).strip()
        else:
            # The model wrote something the page does not contain. It is not a
            # reading, so it is not kept — and the caller is told.
            product[field] = None
            invented.append(field)
    return {"source": "model", "product": product, "invented": invented,
            "characters_sent": len(page)}


def _ask(client, page: str, attempts: int) -> dict:
    prompt = PROMPT.format(fields=", ".join(FIELDS), page=page)
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = client.complete(prompt=prompt, temperature=0)
            if not isinstance(answer, str):  # a refusal comes back as no content
                last_error = ValueError("the model answered no text")
                continue
            fenced = FENCE.match(answer)
            return json.loads(fenced.group(1) if fenced else answer)
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise ReadingUnavailable(str(last_error))

JavaScript

snippets/extract-product-data-from-a-shop-page/n3.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * Read a product from a page that publishes nothing structured, by asking a model.
 *
 * Rung N3. This is the level for the shop that emits no JSON-LD and whose
 * rendering changes every quarter, where writing selectors means rewriting
 * them. It earns its place there, and nowhere above it: on a page that already
 * carries its JSON-LD, rung N0 reads the same values for nothing.
 *
 * Three things this code does that the model does not, and that are the real
 * work of this rung.
 *
 * It cuts. A shop page is hundreds of kilobytes of markup, almost all of it
 * menus, tracking and styles; the tags come off and what is left is capped.
 * The provider bills what is sent, so the cap is a budget, and the entry on
 * estimating a call's cost is where that budget is computed.
 *
 * It refuses an invented value. Every string the model returns is looked for
 * in the text that was sent: a name, a reference or a price that appears
 * nowhere in the page is dropped, and `invented` says which. This is the one
 * guard that separates a reading from a plausible answer, and it is worth more
 * than the prompt.
 *
 * And it treats a refusal as a refusal. A provider that cannot answer throws;
 * `content` of null is not a value, and it is not handed to the JSON decoder
 * in the hope that the exception will do.
 */

// What the model is asked for, named as schema.org names it so that a caller
// can put an N0 answer and an N3 answer in the same table.
export const FIELDS = ['name', 'sku', 'brand', 'price', 'currency', 'availability'];

// A shop page is markup; this is what is left once the tags are gone. The
// pattern carries no nested quantifier on purpose: the one that reads
// « <(script|style)...>[\s\S]*?</\1> » in a single line is the collapse the N0
// snippet of this entry was written to avoid. The blocks are scanned instead,
// in `withoutScripts`.
const TAGS = /<[^>]+>/g;
const SPACES = /[ \t\r\f\v]*\n\s*|[ \t]{2,}/g;

// The budget in characters of markup, applied before anything reads the page.
// The charte asks that an input too large be refused before the call; a cap
// applied after the cleaning protects nothing, since the cleaning is the part
// that costs.
export const MAX_HTML = 400_000;

// The budget, in characters of text. A page that does not fit is cut, not
// refused: a scraper that throws on a long page returns nothing at all.
export const MAX_CHARACTERS = 6000;

// A single code fence around the whole answer is a common shape, and refusing
// it would buy another call for nothing.
const FENCE = /^\s*```(?:json)?\s*([\s\S]*?)\s*```\s*$/;

export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

const PROMPT = [
  'Read this shop page and return the product it is about.',
  `Answer with JSON only, with these keys: ${FIELDS.join(', ')}.`,
  'Copy each value from the page. Use null for anything the page does not say.',
  '',
  'Page:',
].join('\n');

export async function providerClient(sdk, model = MODEL) {
  if (!sdk) {
    const { OpenAI } = await import('openai');
    sdk = new OpenAI();
  }
  return {
    async complete({ prompt, temperature }) {
      const response = await sdk.chat.completions.create({
        model,
        messages: [{ role: 'user', content: prompt }],
        temperature,
      });
      return response.choices[0].message.content;
    },
  };
}

export class ReadingUnavailable extends Error {}

/**
 * The markup with every script and style block removed, scanned rather than
 * matched — the same walk as `blocks` in the N0 snippet, and for the same
 * reason. An unclosed block swallows the rest of the page, which is what a
 * browser does too.
 */
function withoutScripts(html) {
  const lower = html.toLowerCase();
  const kept = [];
  let cursor = 0;
  while (cursor < html.length) {
    const starts = [lower.indexOf('<script', cursor), lower.indexOf('<style', cursor)]
      .filter((i) => i >= 0);
    if (starts.length === 0) { kept.push(html.slice(cursor)); break; }
    const start = Math.min(...starts);
    kept.push(html.slice(cursor, start));
    const tag = lower.startsWith('<script', start) ? 'script' : 'style';
    const closing = lower.indexOf(`</${tag}`, start);
    if (closing < 0) break;
    cursor = closing;
  }
  return kept.join('');
}

/** The page without its markup, collapsed, and cut to the budget. */
export function toText(html) {
  return withoutScripts(html.slice(0, MAX_HTML))
    .replace(TAGS, ' ').replace(SPACES, '\n').trim().slice(0, MAX_CHARACTERS);
}

/**
 * The product a model reads on this page, with what it made up removed.
 *
 * @param {string} html
 * @param {{client?: {complete: Function}, attempts?: number}} [options]
 */
export async function readProduct(html, { client, attempts = 3 } = {}) {
  client ??= await providerClient();
  const page = toText(html);
  const answer = await ask(client, page, attempts);

  const product = {};
  const invented = [];
  const haystack = page.toLowerCase();
  for (const field of FIELDS) {
    const value = answer && typeof answer === 'object' ? answer[field] : null;
    if (value === null || value === undefined) {
      product[field] = null;
    } else if (haystack.includes(String(value).trim().toLowerCase())) {
      product[field] = String(value).trim();
    } else {
      // The model wrote something the page does not contain. It is not a
      // reading, so it is not kept — and the caller is told.
      product[field] = null;
      invented.push(field);
    }
  }
  return { source: 'model', product, invented, characters_sent: page.length };
}

async function ask(client, page, attempts) {
  const prompt = `${PROMPT}\n${page}`;
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const answer = await client.complete({ prompt, temperature: 0 });
      if (typeof answer !== 'string') {
        lastError = new Error('the model answered no text'); // a refusal has no content
        continue;
      }
      const fenced = FENCE.exec(answer);
      return JSON.parse(fenced ? fenced[1] : answer);
    } catch (error) {
      lastError = error;
    }
  }
  throw new ReadingUnavailable(String(lastError));
}

What this means for you

  • Your data goes to a third party
  • The result varies from one call to the next
  • Hard to test
  • Depends on an outside provider
  • Moderate footprint

What this adds to your regulatory scope

  • Transfer of the page’s content to a processor, with the contractual arrangements that implies
  • Where the processing happens is to be checked with the provider

Breaking point

The guard checks where a value comes from, not what it means: a currency written “€” in the page and returned as “EUR” by the model is counted as invented, although it is right. Witness: a price absent from the page, 24.90 on a page at 19.90, is properly dropped.

When to move up a level

There is no level above this one.

Further reading

Where the figures come from

Metadata