Skip to content
Classify Web Data with Jev and MrScraper: A Python Tutorial
Article

Classify Web Data with Jev and MrScraper: A Python Tutorial

Guide

Build a Python workflow that extracts web records with MrScraper, classifies them with Jev, validates results and preserves source evidence.

By MrScraper Team 14 min read

Jev web data classification works in two stages. MrScraper extracts each page into a structured record with a source URL and timestamp. Your code validates the record, then the TypeSafe Python SDK asks Jev a Choice question. Jev returns one label from your list, with probabilities and a confidence score you can use for review.

TL;DR

  • Keep extraction and classification separate. MrScraper gets the fields. Jev picks the label. Your code checks everything in between.
  • Never lose the evidence. Every label is stored with its record ID, source URL, timestamp, model version, and confidence.
  • Send unsure records to a person. Missing fields, low confidence, and "other" labels go to review instead of your database. You already pull product pages, listings, or articles into JSON. The next job is sorting those records into categories your team can use. This tutorial builds that step in plain Python. It is a custom workflow that connects two separate APIs, not a native integration.

If you are new to Jev, start with What Is Jev? A Practical Guide for Web Data Teams.

How does Jev web data classification work?

It splits the job into an extraction stage and a decision stage. MrScraper reads each page and returns named fields. Jev reads only the fields you pass and picks one label from a list you define. Code sits between them and after them.

Define the extraction and decision stages

Each stage has a different job, a different input, and a different way to fail.

Extraction stage (MrScraper) Decision stage (Jev)
Input A URL you are allowed to scrape Validated fields from one record
Output JSON fields, like title and description A label, probabilities, and confidence
You define A plain-English extraction prompt A Choice question and its options
Typical failure Field missing or page changed Wrong label or low confidence
How you catch it Required-field checks Confidence threshold and labeled test set

Keeping the stages apart has a practical payoff. When a label is wrong, you can tell right away if the input was bad or the decision was bad.

What do you need before starting?

You need two API keys, Python 3.10 or newer, and a short list of pages you are allowed to scrape. MrScraper and TypeSafe are separate services with separate accounts, keys, and billing.

API access, Python environment and permitted test pages

1. Get your keys.

text
# requirements.txt
requests==2.32.3
typesafe-sdk==0.7.3
bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export MRSCRAPER_API_TOKEN="your-mrscraper-token"
export TYPESAFE_API_KEY="your-typesafe-key"

The TypeSafe client reads TYPESAFE_API_KEY from the environment on its own. We read the MrScraper token ourselves.

3. Pick permitted test pages. This tutorial uses Books to Scrape, a sandbox site built for scraping practice. It is the same site MrScraper uses in its own API guide. For your own projects, only scrape pages you have permission to collect.

Books to Scrape has a useful bonus. Each book page shows the store's own category in its breadcrumb. We extract that category but never send it to Jev. Later, we use it as a free labeled test set.

How do you extract and validate source records?

Ask MrScraper for the exact fields you need, then check every record in code before Jev sees it. Records with missing required fields are kept and flagged, not dropped or filled with guesses.

Required fields, missing values, source URLs and timestamps

We use two documented MrScraper endpoints. POST /scrapers-ai creates a General agent scraper from the first URL. POST /scrapers-ai-rerun runs that same scraper on each other URL.

python
# extract.py
import hashlib
import os
from datetime import datetime, timezone
 
import requests
 
BASE = "https://api.app.mrscraper.com/api/v1"
HEADERS = {
    "accept": "application/json",
    "content-type": "application/json",
    "x-api-token": os.environ["MRSCRAPER_API_TOKEN"],
}
 
PROMPT = (
    "Extract these fields as JSON: "
    "title (string), "
    "description (the full product description text, string), "
    "site_category (the category name shown in the breadcrumb, string), "
    "price (string, as shown). "
    "Use null for any field that is not on the page."
)
 
REQUIRED = ["title", "description"]
EMPTY = {"", "null", "none", "n/a"}
 
 
def _unwrap(body: dict) -> dict:
    # Some responses wrap the result in {"message": ..., "data": ...}.
    return body["data"] if "message" in body else body
 
 
def create_scraper(url: str) -> tuple[str, dict]:
    resp = requests.post(
        f"{BASE}/scrapers-ai",
        headers=HEADERS,
        json={"url": url, "agent": "general", "message": PROMPT, "proxyCountry": ""},
        timeout=180,
    )
    resp.raise_for_status()
    result = _unwrap(resp.json())
    return result["scraperId"], result
 
 
def rerun_scraper(scraper_id: str, url: str) -> dict:
    resp = requests.post(
        f"{BASE}/scrapers-ai-rerun",
        headers=HEADERS,
        json={"scraperId": scraper_id, "url": url},
        timeout=180,
    )
    resp.raise_for_status()
    return _unwrap(resp.json())
 
 
def clean(value):
    if value is None:
        return None
    text = str(value).strip()
    return None if text.lower() in EMPTY else text
 
 
def to_record(url: str, result: dict) -> dict:
    fields = result.get("data") or {}
    record = {
        "record_id": hashlib.sha256(url.encode()).hexdigest()[:16],
        "source_url": url,
        "scraped_at": datetime.now(timezone.utc).isoformat(),
        "mrscraper_result_id": result.get("id"),
        "extract_status": result.get("status"),
        "title": clean(fields.get("title")),
        "description": clean(fields.get("description")),
        "site_category": clean(fields.get("site_category")),
        "price": clean(fields.get("price")),
    }
    record["missing"] = [f for f in REQUIRED if record[f] is None]
    return record

A few choices here are worth explaining.

  • Stable IDs. record_id is a hash of the source URL. The same page always gets the same ID, so you can rerun the pipeline and join new labels to old ones.
  • Missing means None. The clean function turns empty strings and text like "null" into None. A missing description should never reach Jev as an empty string, because Jev would still return a label.
  • Timestamps in UTC. scraped_at tells you how fresh the evidence was when the label was made. This is the record schema the rest of the pipeline relies on:
Field Source Required Sent to Jev
record_id Your code (URL hash) Yes No
source_url Your code Yes No
scraped_at Your code Yes No
mrscraper_result_id MrScraper No No
title MrScraper Yes Yes
description MrScraper Yes Yes
site_category MrScraper No No (test label only)
price MrScraper No No

Only two fields go to Jev. TypeSafe's Jev 1.13 notes say accuracy falls when the input is full of detail the question does not need, so send less.

How do you classify records with the TypeSafe Python SDK?

Build a small state from the validated fields, define one Choice question with clear option descriptions, and call client.system_one. Read choice, confidence and probabilities from the answer, and save the model version that answered.

Build state, Choice criteria and response handling

python
# classify.py
from typesafe_sdk import Choice, TypeSafeClient
 
MODEL = "jev-1.13.0"  # pin a version; "jev-latest" can move
 
CATEGORY = Choice(
    instructions="Which category best describes the book in `title` and `description`?",
    criteria={
        "fiction": "Novels and short stories for adult readers, in any genre",
        "nonfiction": "Factual books such as history, science, biography, travel, business or self-help",
        "poetry": "Collections of poems or verse",
        "childrens_young_adult": "Books written for children or teenage readers",
        "other": "Fits none of the categories above, or the text does not describe a book",
    },
)
 
 
def classify(client: TypeSafeClient, record: dict) -> dict:
    state = {"title": record["title"], "description": record["description"]}
    response = client.system_one(state=state, questions={"category": CATEGORY})
    answer = response.answers["category"]
    return {
        "label": answer.choice,
        "confidence": round(answer.confidence, 3),
        "probabilities": answer.probabilities,
        "model": response.model,
        "typesafe_request_id": response.request_id,
    }

How each piece works:

  • state is a small JSON object. The instruction names its keys in backticks, which the TypeSafe docs recommend so the model knows which part to judge.
  • criteria gives every option a short description that separates it from its neighbors. A Choice accepts up to 255 options. We add other so Jev has a place for records that fit nothing.
  • answer.choice is always one of your option keys. Jev cannot return a label outside your list.
  • answer.confidence runs from 0 to 1. TypeSafe computes it from how spread out the probabilities are. The Confidence docs say it describes spread, not correctness, so you still need a test set.
  • response.model records the exact version that answered, such as jev-1.13.0. Now combine extraction, validation, and classification with simple review rules:
python
# pipeline.py
import requests
from typesafe_sdk import TypeSafeAPIConnectionError, TypeSafeAPIError, TypeSafeClient
 
from classify import MODEL, classify
from extract import create_scraper, rerun_scraper, to_record
 
REVIEW_BELOW = 0.5  # starting point; tune it on your test set
 
 
def review_status(record: dict, result: dict | None) -> str:
    if record["missing"]:
        return "needs_review: missing " + ", ".join(record["missing"])
    if result is None:
        return "needs_review: classification_failed"
    if result["label"] == "other":
        return "needs_review: other"
    if result["confidence"] < REVIEW_BELOW:
        return "needs_review: low_confidence"
    return "auto_accepted"
 
 
def run(urls: list[str]) -> list[dict]:
    rows, scraper_id = [], None
    with TypeSafeClient(model=MODEL) as client:
        for url in urls:
            try:
                if scraper_id is None:
                    scraper_id, extracted = create_scraper(url)
                else:
                    extracted = rerun_scraper(scraper_id, url)
            except requests.RequestException as err:
                rows.append({"source_url": url, "review_status": f"extract_failed: {err}"})
                continue
 
            record = to_record(url, extracted)
            result = None
            if not record["missing"]:
                try:
                    result = classify(client, record)
                except (TypeSafeAPIError, TypeSafeAPIConnectionError) as err:
                    record["error"] = repr(err)
 
            rows.append({**record, **(result or {}), "review_status": review_status(record, result)})
    return rows

The SDK already retries rate limits (HTTP 429) with backoff, according to the Models page. The except block catches what is left, such as a bad request or a network failure, and keeps the record for review.

How do you store labels with evidence?

Write each label next to the record it came from, joined on a stable record_id. Keep the source URL, timestamp, model version, and confidence in the same row. Export JSON Lines for full detail and CSV for spreadsheets.

Join answers to stable IDs; export JSON/CSV

python
# export.py
import csv
import json
 
CSV_COLUMNS = [
    "record_id", "source_url", "scraped_at", "title",
    "label", "confidence", "model", "review_status",
]
 
 
def export(rows: list[dict], stem: str = "labels") -> None:
    with open(f"{stem}.jsonl", "w", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")
 
    with open(f"{stem}.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(rows)
 
 
if __name__ == "__main__":
    from pipeline import run
 
    urls = [
        "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
        "https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html",
        "https://books.toscrape.com/catalogue/sharp-objects_997/index.html",
        "https://books.toscrape.com/catalogue/sapiens-a-brief-history-of-humankind_996/index.html",
    ]
    export(run(urls))

The JSONL file keeps everything, including the full probability map and the TypeSafe request ID. The CSV keeps only what a reviewer needs to scan.

Each JSONL row has this shape:

json
{
  "record_id": "<16-char hash of source_url>",
  "source_url": "https://books.toscrape.com/catalogue/...",
  "scraped_at": "<UTC ISO timestamp>",
  "title": "<extracted title>",
  "description": "<extracted description>",
  "site_category": "<breadcrumb category, test label only>",
  "label": "<one of your option keys>",
  "confidence": "<0 to 1>",
  "probabilities": { "<option>": "<0 to 1>" },
  "model": "jev-1.13.0",
  "review_status": "auto_accepted"
}

With this layout, any label can be traced back to the exact page and moment it came from. If someone asks "why is this book tagged poetry?", the answer is one lookup away.

How do you test and troubleshoot the pipeline?

Test with a labeled set, then check each failure type on purpose. Feed the pipeline a broken URL, a page with no description, and a few borderline records. Confirm each one lands in review with a clear reason.

Invalid inputs, failures and low-confidence review

Measure agreement first. The breadcrumb category gives you labels for free. Map store categories to your labels, then compare.

python
# evaluate.py
import json
from collections import Counter
 
SITE_TO_LABEL = {
    "Fiction": "fiction", "Mystery": "fiction", "Historical Fiction": "fiction",
    "Romance": "fiction", "Fantasy": "fiction", "Thriller": "fiction",
    "Nonfiction": "nonfiction", "History": "nonfiction", "Travel": "nonfiction",
    "Science": "nonfiction", "Business": "nonfiction", "Biography": "nonfiction",
    "Poetry": "poetry",
    "Childrens": "childrens_young_adult", "Young Adult": "childrens_young_adult",
}
 
rows = [json.loads(line) for line in open("labels.jsonl", encoding="utf-8")]
scored = [r for r in rows if r.get("label") and r.get("site_category") in SITE_TO_LABEL]
 
results = Counter(
    "match" if r["label"] == SITE_TO_LABEL[r["site_category"]] else "miss" for r in scored
)
print(f"Compared {len(scored)} records: {dict(results)}")
 
for r in scored:
    expected = SITE_TO_LABEL[r["site_category"]]
    if r["label"] != expected:
        print(r["source_url"], "expected", expected, "got", r["label"], r["confidence"])

Run this on 50 to 100 pages before you trust the numbers. Four URLs show that the code works. They say nothing about accuracy.

Then check each failure on purpose.

Problem What you see What to do
Bad or blocked URL extract_failed status Check the URL and your permission to scrape it
Field not found needs_review: missing description Improve the extraction prompt or accept the gap
Page layout changed Many records missing the same field Rerun on a known-good page and compare
Vague option text Two labels often swap places Rewrite option descriptions so they do not overlap
Option order effect Label changes when you reorder criteria TypeSafe's Jev 1.13 notes report a lean toward the first option. Rerun a sample with the options shuffled
Low confidence needs_review: low_confidence Expected for borderline items. Review them and adjust the threshold
Rate limit Slower runs or TypeSafeRateLimitError The SDK retries 429s. Reuse one client and lower your request rate

Tune the review threshold last. Start at 0.5. If reviewers keep approving low-confidence labels, lower it. If wrong labels get through, raise it. The Confidence docs say the right value depends on your data, so let your test set decide.

Read misses before you change code. TypeSafe notes that Jev reads instructions literally. When you catch yourself explaining what an option "really meant," add that explanation to its description.

Frequently asked questions

Which Python package calls Jev?

The documented package is typesafe-sdk, installed with pip install typesafe-sdk. It needs Python 3.10 or newer and reads your key from TYPESAFE_API_KEY. You call TypeSafeClient().system_one() with a state and typed questions. Check the TypeSafe quickstart for the current version before you pin it.

Do I need separate MrScraper and TypeSafe credentials?

Yes. MrScraper and TypeSafe are separate services with separate accounts, API keys, and billing. Your MrScraper token handles extraction requests. Your TypeSafe key handles Jev requests. Store both as environment variables or in a secrets manager, and never put either key in your code.

How should I handle missing fields?

Store a missing field as None and flag the record instead of guessing. Check required fields before classification and skip the Jev call when they are missing. Jev always returns a label, even for empty input, so validation in code is what keeps bad records out of your results.

Can I classify multiple website records?

Yes. Loop over records in your code and send one Jev request per record. Ask several questions about one record in a single request, since TypeSafe evaluates them in parallel. Avoid packing many records into one state. Watch TypeSafe's published rate limits, which can change during early access.

Is this a native integration?

No. This is a custom workflow that connects two separate APIs in your own Python code. MrScraper and TypeSafe do not have a built-in connection. Test the workflow on your own pages and labeled examples, and recheck both APIs' docs before running it in production.

API details checked against the official TypeSafe and MrScraper docs on October 13, 2026. Jev is in early access, and both APIs may change.

Summarize this post

Open it in your assistant of choice with the prompt ready to send.

Take a Taste of Easy Scraping!

Your choices

Cookie preferences

Necessary cookies keep your selection. Optional categories are disabled until you switch them on.

Strictly necessary

Remembers your privacy selection and keeps the site working.

Always on