---
seo_title: Distill (Phase 1): Strukturierte Daten extrahieren | EnConvert
meta_desc: Private Beta, Phase 1: schemagesteuerte strukturierte Extraktion aus jeder URL. Erst ein kostenloser CSS-Pass, dann ein LLM-Fallback, Rückgabe in deiner JSON-Form.
keywords: strukturierte daten von website extrahieren api, web scraping api json schema, llm web scraping api, css selektor extraktion api, firecrawl extract alternative, produktdaten scrapen api, website daten auslesen api, webseite zu json api
---

# Strukturierte Daten von Websites extrahieren (API)

<div class="alert alert-warning">
<strong>Private Beta.</strong> Distill ist heute mit deinem normalen API-Schlüssel aufrufbar, in jedem Tarif einschließlich Founding, und zieht wie jeder andere Aufruf von deinem monatlichen Ops-Kontingent ab. Angekündigt oder allgemein verfügbar ist Distill nicht: Request- und Response-Formen können sich jederzeit ohne Vorankündigung ändern, und es gibt keine Stabilitäts- oder Supportzusage, baue also noch nichts Tragendes darauf. Die Roadmap steht unter <a href="/de/docs/coming-soon">Demnächst</a>, und jedes Release wird im <a href="/de/changelog">Changelog</a> angekündigt.
</div>

`POST /v2/distill` ist eine API, um strukturierte Daten aus Websites zu
extrahieren: Sie zieht Felder aus einer oder mehreren URLs heraus, passend
zu einem von dir gelieferten Schema, entweder einem JSON-Schema-Objekt
oder einer flachen `{field: description}`-Map. Sie läuft als
Zwei-Pass-Engine: zuerst ein kostenloser CSS-Pass (Crawl4AIs
`JsonCssExtractionStrategy`, gesteuert von deinen Selektoren), dann ein
gedeckelter, LLM-gestützter Pass für die Felder, die der CSS-Pass leer
gelassen hat. Das `data`-Feld der Antwort kommt
garantiert exakt in der angeforderten Form zurück. Es wird EnConverts
Antwort auf Firecrawl `/extract` sein.

Hier der kleinste nützliche Aufruf. Sende eine URL und ein flaches
`{field: description}`-Schema und erhalte die extrahierten Felder zurück:

```bash
curl -X POST https://api.enconvert.com/v2/distill \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": ["https://example.com/product/widget"],
    "schema": {
      "name": "the product name",
      "price": "the listed price",
      "in_stock": "whether it is in stock"
    }
  }'
```

Die Antwort enthält ein Ergebnis pro URL, die extrahierten `data` und
welche Stufe sie geliefert hat:

```json
{
    "operation_id": "dst_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7",
    "total": 1,
    "completed": 1,
    "failed": 0,
    "results": [
        {
            "url": "https://example.com/product/widget",
            "url_final": "https://example.com/product/widget",
            "status": "completed",
            "data": {
                "name": "Widget Pro",
                "price": "$49.00",
                "in_stock": "yes"
            },
            "extraction_tier": "llm",
            "fields_from_css": 0,
            "fields_from_llm": 3,
            "render_quality": 0.91,
            "tokens": {"input": 4120, "output": 38},
            "cost_cents": 0.45,
            "warnings": []
        }
    ],
    "total_cost_cents": 0.45,
    "warnings": []
}
```

---

## Endpunkte

| Methode | Pfad | Zweck |
|--------|------|---------|
| `POST` | `/v2/distill` | Führt distill für eine explizite URL-Liste aus, oder ermittelt zunächst mit discover die URLs einer Site und führt distill für jede aus, jeweils gegen ein Schema. |

**Content-Type:** `application/json`.

Anders als [perceive](/de/docs/endpoints/perceive.md) ist distill ein einzelner
synchroner Endpunkt: Es gibt keinen separaten GET-Re-Fetch oder
asynchronen Batch-Pfad. Jede URL wird sequenziell über das gemeinsam
genutzte Headless-Chrome-Singleton gerendert, und die vollständige
Ergebnismenge kommt in einer einzigen Antwort zurück.

---

## Authentifizierung

Authentifiziere dich mit einem privaten Schlüssel im `X-API-Key`-Header
für Server-zu-Server-Aufrufe. Diesen Weg verwenden die folgenden
Beispiele.

```http
X-API-Key: sk_your_private_key
```

Öffentliche Schlüssel mit einem JWT-Bearer-Token funktionieren ebenfalls,
nach demselben Ablauf wie bei jedem anderen Endpunkt: Erzeuge ein Token
mit deinem `pk_`-Schlüssel und sende es als `Authorization: Bearer
<token>`. Der vollständige Ablauf, einschließlich Domain-Lock und
Token-Refresh, steht in [der Authentifizierungsanleitung](/de/docs/authentication.md).

Jeder API-Schlüssel trägt eine Allowlist erlaubter Endpunkte. Steht
`/v2/distill` nicht auf der Liste des Schlüssels, wird die Anfrage mit
`403` abgelehnt.

---

## Wie distill funktioniert

Eine Anfrage führt distill für eine Liste von URLs gegen ein Schema aus.
Der Ablauf ist für jede URL derselbe:

1. **URL-Liste auflösen.** Bei `urls` ist die Liste exakt das, was du
   gesendet hast (dedupliziert, Reihenfolge erhalten). Bei
   `discover_from` führt distill zuerst [discover](/de/docs/coming-soon/discover.md)
   auf der Seed-URL aus (parst die Sitemap, crawlt, oder beides) und
   verarbeitet anschließend die entdeckten URLs bis zu `max_pages`.
2. **Rendern.** Jede URL wird einmal in Headless-Chrome gerendert, über
   dieselbe Capture-Pipeline, die auch [perceive](/de/docs/endpoints/perceive.md)
   antreibt. Das Rendering wird auf SSRF geprüft und, falls
   `respect_robots=true`, gegen die `robots.txt` der Site abgeglichen. Es
   werden keine Artefakte in den Storage hochgeladen, denn distill braucht
   nur das gerenderte DOM.
3. **Pass 1: CSS (kostenlos).** Hast du ein `css_schema` mitgeschickt,
   läuft der CSS-Extraktor über das gerenderte HTML und füllt jedes
   selektor-adressierbare Feld zu null LLM-Kosten. Dieser Pass ist auf 10
   Sekunden zeitlich begrenzt; bei Timeout fällt die URL mit einer
   Warnung in den LLM-Pass durch.
4. **Pass 2: LLM (gedeckelt, nur bei Bedarf).** Distill sammelt die
   Schema-Felder, die der CSS-Pass fehlend oder leer gelassen hat, und
   eskaliert *nur diese Felder* an die LLM-Stufe, unter harten
   Budgetgrenzen pro Aufruf und pro Zeitraum. Hat dein Plan keine
   LLM-Stufe, wurde die Seite als blockiert markiert, oder wird eine
   Budgetgrenze erreicht, wird der LLM-Pass übersprungen, und die
   fehlenden Felder kommen mit einer Warnung als `null` zurück.
5. **Normalisieren.** Das zusammengeführte Ergebnis wird exakt auf die
   Keys deines Schemas umgeformt: Fehlende Skalare werden zu `null`,
   fehlende Arrays zu `[]`, und alle zusätzlichen Keys werden verworfen.
   Die Formgarantie gilt unabhängig davon, was CSS oder das LLM geliefert
   haben.

Pro abgeschlossener URL wird eine Op berechnet, und erst nachdem die
URL fertig ist. Render-Fehlschläge (SSRF-Ablehnung, Robots-Blockade,
ein abgestürztes Rendering) erzeugen eine Ergebniszeile mit `failed` und
kosten keine Ops.

---

## Request-Parameter

Du musst **genau eines** von `urls` oder `discover_from` angeben, plus
ein `schema`. Beides oder keines von beiden zu senden, ergibt `422`.

### Quelle: explizite URLs

| Parameter | Typ | Standard | Beschreibung |
|-----------|------|---------|-------------|
| `urls` | `string[]` | -- | Explizite URLs für distill. Jede muss mit `http://` oder `https://` beginnen und darf höchstens 2,048 Zeichen lang sein. Max. 50 URLs pro Request (`MAX_DISTILL_URLS`). Schließt sich mit `discover_from` gegenseitig aus. |

### Quelle: discover, dann distill

| Parameter | Typ | Standard | Beschreibung |
|-----------|------|---------|-------------|
| `discover_from` | `object` | -- | Ermittelt zunächst mit discover die URLs einer Site, führt distill dann für jede einzelne aus. Schließt sich mit `urls` gegenseitig aus. Erfordert das Plan-Flag `discover_enabled` (sonst `402`). |
| `discover_from.url` | `string` | -- | Seed-URL. Muss mit `http://` oder `https://` beginnen. Max. 2,048 Zeichen. |
| `discover_from.mode` | `string` | `hybrid` | `sitemap`, `crawl` oder `hybrid`. Dieselben Modi wie beim [discover-Endpunkt](/de/docs/coming-soon/discover.md). |
| `discover_from.max_pages` | `integer` | `10` | Obergrenze für die Anzahl der über discover ermittelten und mit distill verarbeiteten URLs, von 1 bis 50. Jede ist ein vollständiges Rendering, daher begrenzt durch `MAX_DISTILL_URLS`. |

### Schema oder Prompt

Gib **entweder** ein `schema` (die gewünschte Ausgabeform) **oder**
einen `prompt` (eine Beschreibung in natürlicher Sprache, was extrahiert
werden soll) an. Genau eines von beiden ist erforderlich; sendest du
beide, gewinnt `schema`.

| Parameter | Typ | Standard | Beschreibung |
|-----------|------|---------|-------------|
| `schema` | `object` | `null` | Die Ausgabeform, gesendet unter dem JSON-Key `schema`. Entweder ein JSON-Schema-Objekt (`{"type": "object", "properties": {...}}`) oder eine nachsichtige flache `{field: description}`-Map. Max. 200 Top-Level-Properties. Die `data` der Antwort entspricht garantiert dieser Form. Ein strukturell ungültiges Schema ergibt `422`. |
| `prompt` | `string` | `null` | Eine Beschreibung in natürlicher Sprache, was extrahiert werden soll. Wird er ohne `schema` angegeben, synthetisiert distill daraus das Extraktionsschema (ein einzelnes Modell) und führt dann die normale Zwei-Pass-Engine aus. Max. 2,000 Zeichen. |

Das Schema ist der Vertrag. Sendest du ein JSON-Schema-Objekt, liest
distill dessen `properties`; sendest du eine flache Map, benennt jeder
Key ein Feld, und jeder Value ist die Beschreibung, die ans LLM übergeben
wird. So oder so kommt `data` mit exakt den Top-Level-Keys des Schemas
zurück.

**Prompt-only-Modus.** Sendest du einen `prompt` statt eines `schema`,
synthetisiert distill zuerst ein Schema von Feldern aus deinem Prompt
und extrahiert dann dagegen. Die synthetisierten Felder werden in der
Antwort als `synthesized_schema` zurückgegeben. Der Prompt-only-Modus
verwendet die LLM-Extraktionsstufe und erfordert einen Plan, der sie
enthält; ohne einen solchen liefert distill eine klare Warnung, statt zu
raten.

### CSS-Schema (optional)

Liefere ein `css_schema`, um Felder kostenlos zu beantworten, bevor ein
LLM-Aufruf stattfindet. Ohne es fällt jedes Feld direkt in den LLM-Pass.

| Parameter | Typ | Standard | Beschreibung |
|-----------|------|---------|-------------|
| `css_schema.baseSelector` | `string` | -- | CSS-Selektor für den sich wiederholenden Container; pro Treffer wird ein Datensatz extrahiert. 1 bis 1,024 Zeichen. Erforderlich, wenn `css_schema` vorhanden ist. |
| `css_schema.fields` | `CssField[]` | -- | 1 bis 128 Felddefinitionen, die aus jedem Container ausgelesen werden. Erforderlich. |
| `css_schema.name` | `string` | `"distill"` | Optionale Bezeichnung für das Schema. Max. 128 Zeichen. |
| `css_schema.target_field` | `string` | abgeleitet | Welche Top-Level-Ausgabe-Property die CSS-Datensätze füllen: Eine Array-Property erhält die vollständige Datensatzliste, eine Skalar-/Objekt-Property erhält den ersten Datensatz. Fehlt der Wert, ermittelt distill die Property automatisch, sofern das Schema genau eine Array-Property hat. Max. 128 Zeichen. |

Jeder Eintrag in `fields` ist ein `CssField`:

| Feld | Typ | Standard | Beschreibung |
|-------|------|---------|-------------|
| `name` | `string` | -- | Ausgabe-Key für dieses Feld. 1 bis 128 Zeichen. Erforderlich. |
| `type` | `string` | -- | Eine von `text`, `attribute`, `html`, `regex`, `nested`, `list`, `nested_list`. Erforderlich. |
| `selector` | `string` | `null` | CSS-Sub-Selektor. Optional bei Blatt-Typen (`text`/`attribute`/`html`/`regex`); erforderlich bei `nested`/`list`/`nested_list`. Max. 1,024 Zeichen. |
| `attribute` | `string` | `null` | Name des auszulesenden Attributs. Erforderlich, wenn `type` gleich `attribute` ist. Max. 128 Zeichen. |
| `pattern` | `string` | `null` | Regex-Pattern. Erforderlich, wenn `type` gleich `regex` ist. Wird am Edge kompiliert; ein Pattern mit verschachtelten, erneut quantifizierten Gruppen (eine ReDoS-Form wie `(a+)+`) wird mit `422` abgelehnt. Max. 1,024 Zeichen. |
| `default` | any | `null` | Wert, wenn der Selektor nichts trifft. |
| `transform` | `string` | `null` | Eine von `lowercase`, `uppercase`, `strip`. |
| `fields` | `CssField[]` | `null` | Kindfelder, für `nested`/`list`/`nested_list`. Max. 64 Kinder; maximale Verschachtelungstiefe 5. |

Wichtig zu wissen: Der Feldtyp `computed` aus Crawl4AI wird bewusst nicht
akzeptiert. Seine Expression-Form führt `eval` auf Caller-Input aus, und
seine Callable-Form kann keine JSON-Grenze überqueren, weshalb distill
nur die sieben oben genannten sicheren Typen zulässt.

### Render-Einstellungen

Distill rendert nur das DOM und stellt daher nur eine kleine Teilmenge
der [perceive-Render-Optionen](/de/docs/endpoints/perceive.md) bereit.

| Parameter | Typ | Standard | Beschreibung |
|-----------|------|---------|-------------|
| `wait_for` | `string` | `null` | Wartet nach der Navigation auf einen CSS-Selektor oder einen JS-Ausdruck (`"js:window.dataReady === true"`). Max. 1,024 Zeichen. |
| `wait_timeout_ms` | `integer` | `30000` | Wie lange `wait_for` warten darf, in Millisekunden. 0 bis 60,000. |
| `headers` | `object` | `null` | Benutzerdefinierte Request-Header für das Rendering. |
| `cookies` | `array` | `null` | Cookies, die vor der Navigation injiziert werden. |
| `respect_robots` | `boolean` | `false` | Bei `true` wird eine von der `robots.txt` der Site untersagte URL abgelehnt, und das Ergebnis dieser URL wird als `failed` markiert. |

---

## Antwort

`POST /v2/distill` liefert eine `DistillResponse`:

| Feld | Typ | Beschreibung |
|-------|------|-------------|
| `operation_id` | `string` | Opake ID (`dst_...`). Bei Support-Anfragen angeben. |
| `total` | `integer` | Anzahl der verarbeiteten URLs (Zeilen in `results`). |
| `completed` | `integer` | URLs, die gerendert wurden und ein `data`-Objekt geliefert haben. |
| `failed` | `integer` | URLs, deren Rendering abgelehnt wurde oder abgestürzt ist. |
| `results` | `object[]` | Ein `DistillItemResult` pro URL, unten beschrieben. |
| `total_cost_cents` | `number` | Summe der LLM-Kosten pro URL über die gesamte Anfrage, in Cent. |
| `synthesized_schema` | `object` | Nur im Prompt-only-Modus vorhanden: das Schema, das aus deinem `prompt` synthetisiert und für die Extraktion verwendet wurde. |
| `warnings` | `string[]` | Hinweise auf Request-Ebene (z. B. Ops-Kontingent mitten in der Liste erschöpft, Crawl-Fehler bei discover). |

Jeder Eintrag in `results` ist ein `DistillItemResult`:

| Feld | Typ | Beschreibung |
|-------|------|-------------|
| `url` | `string` | Die URL, die du gesendet hast (oder die ermittelt wurde). |
| `url_final` | `string` | Die URL nach Redirects. Entfällt bei einer `failed`-Zeile. |
| `status` | `string` | `completed` oder `failed`. |
| `data` | `object` | Die extrahierten Daten, normalisiert auf exakt die Keys deines Schemas. `null` bei einer `failed`-Zeile. |
| `extraction_tier` | `string` | `css` (nur CSS), `llm` (nur LLM), `mixed` (beide haben beigetragen) oder `none` (nichts gefunden). |
| `fields_from_css` | `integer` | Anzahl der Felder, die der CSS-Pass gefüllt hat. |
| `fields_from_llm` | `integer` | Anzahl der Felder, die der LLM-Pass gefüllt hat. |
| `render_quality` | `number` | 0.0 bis 1.0. Niedrige Werte weisen auf Anti-Bot-Challenges oder Login-Walls hin. |
| `tokens` | `object` | `{input, output}` verwendete LLM-Tokens. Null, außer der LLM-Pass lief. |
| `cost_cents` | `number` | LLM-Kosten in Cent für diese URL. Null, außer der LLM-Pass lief. |
| `error` | `string` | Nur gesetzt, wenn `status` gleich `failed` ist. Eine generische Meldung, denn interne Render-Details bleiben serverseitig. |
| `warnings` | `string[]` | Hinweise pro URL: ein CSS-Timeout, ein übersprungener LLM-Pass, eine erreichte Budgetgrenze. |

Um ganz klar zu sein: Die Antwort enthält keine signierten Download-URLs
und keine gespeicherten Artefakte. Distill liefert die strukturierten
`data` inline und sonst nichts. Willst du zusätzlich das Markdown,
HTML, einen Screenshot oder ein PDF der Seite, ist dafür
[perceive](/de/docs/endpoints/perceive.md) da.

---

## Das Zwei-Pass-Kostenmodell

Der CSS-Pass ist kostenlos. Der LLM-Pass kostet Geld, daher löst distill
ihn so gezielt wie möglich aus und deckelt ihn aus mehreren Richtungen.

**Es eskaliert nur die fehlenden Felder.** Nach dem CSS-Pass berechnet
distill, welche Schema-Felder noch leer sind: ein Skalar, der als
`null`/`""` zurückkam, ein Array, das leer zurückkam, oder ein Array,
dessen Elemente ein deklariertes Unterfeld vermissen lassen. Nur diese
Feldnamen fließen in ein reduziertes Schema für den LLM-Aufruf ein, was
Prompt und Kosten minimal hält.

**Der LLM-Pass wird vollständig übersprungen, wenn** eine der folgenden
Bedingungen zutrifft; distill liefert dann das reine CSS-Ergebnis mit
einer Warnung, statt zu überziehen:

- Dein Plan hat keine LLM-Stufe (`llm_extraction_enabled` plus einen
  `agent_model_tier` ungleich `none`).
- Die `render_quality` der Seite hat sie als durch Anti-Bot-Schutz
  blockiert markiert.
- Das Pro-Request-LLM-Budget für diesen Aufruf ist erreicht, oder die
  Pro-Zeitraum-Budgetgrenze ist erreicht.

**Die Budgetgrenzen sind gestaffelt:**

| Grenze | Wert | Geltungsbereich |
|-----|-------|-------|
| Pro Aufruf | $0.05 (`PER_REQUEST_CAP_CENTS`) | Worst-Case-Kostenprognose eines einzelnen LLM-Aufrufs. Darüber → wird übersprungen, noch bevor Netzwerk-I/O stattfindet. |
| Pro Request | $0.50 (`_REQUEST_LLM_BUDGET_CENTS`) | Gesamte LLM-Ausgaben über alle URLs eines `/v2/distill`-Aufrufs. Verbleibende URLs liefern nur CSS-Ergebnisse. |
| Eskalationen pro Request | 50 (`_MAX_LLM_ESCALATIONS`) | Höchstens ein LLM-Aufruf pro URL, hart gedeckelt. |
| Pro Zeitraum | Dein monatliches AI-Credit-Guthaben: $5 / $15 / $40 pro Monat auf Indie / Studio / Production, ungenutzte Credits werden übertragen | `ch_usage_periods.llm_cost_cents` gegen die gewährten Credits der Periode (`usage.reserve_llm_budget`). |

> **Hinweis.** Das Pro-Zeitraum-Budget wird vor dem Aufruf atomar
> reserviert und danach auf die tatsächlichen Kosten abgerechnet, sodass
> gleichzeitige Aufrufe die Grenze nicht gemeinsam überschreiten können.
> Ein Projekt ohne aktive Usage-Period-Zeile schlägt sicherheitshalber
> fehl (fail closed), denn Ausgaben, die EnConvert nicht zuordnen kann,
> sind Ausgaben, die nicht getätigt werden. Wird eine Grenze erreicht, kommen
> die betroffenen Felder mit einer Warnung als `null` zurück; die
> Anfrage ist trotzdem erfolgreich.

Läuft der LLM-Pass, meldet `extraction_tier` `llm` oder `mixed`, und
`tokens` sowie `cost_cents` melden die entstandenen Kosten. Läuft er
nicht, sind beide null.

---

## CSS-Datensätze auf dein Schema abbilden

Der Standardfall ist eine Listing-Seite: eine sich wiederholende Zeile
und ein Ausgabeschema mit einer Array-Property, die die Zeilen aufnimmt.
Gib distill ein `css_schema`, dessen `baseSelector` die Zeile trifft und
dessen `fields` die Spalten auslesen, und es füllt das Array kostenlos:

```bash
curl -X POST https://api.enconvert.com/v2/distill \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": ["https://example.com/products"],
    "schema": {
      "type": "object",
      "properties": {
        "products": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "name": {"type": "string"},
              "price": {"type": "string"},
              "sku": {"type": "string"}
            }
          }
        }
      }
    },
    "css_schema": {
      "baseSelector": ".product-card",
      "target_field": "products",
      "fields": [
        {"name": "name", "type": "text", "selector": ".title"},
        {"name": "price", "type": "text", "selector": ".price"},
        {"name": "sku", "type": "attribute",
         "selector": ".product-card", "attribute": "data-sku"}
      ]
    }
  }'
```

Wie Datensätze im Schema landen:

- `target_field` auf eine **Array**-Property gesetzt → diese Property
  erhält die vollständige Datensatzliste.
- `target_field` auf eine **Skalar-/Objekt**-Property gesetzt → sie
  erhält den ersten Datensatz.
- `target_field` weggelassen, Schema hat **genau eine** Array-Property →
  distill ermittelt sie automatisch und füllt diese Property.
- Andernfalls → der erste Datensatz wird als einzelnes flaches Objekt
  behandelt, und seine passenden Keys werden auf die oberste Ebene
  gehoben.

Füllt CSS das Array, aber einigen Elementen fehlt ein deklariertes
Unterfeld (sagen wir, `sku` fehlt bei der Hälfte der Karten), eskaliert
distill `products` an den LLM-Pass, um die Lücken zu füllen. Das ist der
Zwei-Pass-Unterschied gegenüber einem simplen Scraper: strukturiert, wo
es geht, modellgestützt, wo es sein muss.

---

## Codebeispiele

### curl: flaches Schema, nur LLM

```bash
curl -X POST https://api.enconvert.com/v2/distill \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": ["https://example.com/article"],
    "schema": {
      "headline": "the article headline",
      "author": "the author name",
      "published": "the publish date"
    }
  }'
```

### curl: discover, dann distill

```bash
curl -X POST https://api.enconvert.com/v2/distill \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "discover_from": {
      "url": "https://example.com/blog",
      "mode": "sitemap",
      "max_pages": 25
    },
    "schema": {
      "title": "the post title",
      "summary": "a one-line summary"
    }
  }'
```

### Python

```python
import requests

response = requests.post(
    "https://api.enconvert.com/v2/distill",
    headers={"X-API-Key": "sk_your_private_key"},
    json={
        "urls": ["https://example.com/product/widget"],
        "schema": {
            "name": "the product name",
            "price": "the listed price",
            "in_stock": "whether it is in stock",
        },
    },
)
response.raise_for_status()
result = response.json()

for item in result["results"]:
    if item["status"] == "completed":
        print(item["url"], "->", item["data"])
    else:
        print(item["url"], "FAILED:", item["error"])

print("total cost (cents):", result["total_cost_cents"])
```

### Node.js

```javascript
const res = await fetch("https://api.enconvert.com/v2/distill", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "X-API-Key": "sk_your_private_key"
    },
    body: JSON.stringify({
        urls: ["https://example.com/product/widget"],
        schema: {
            name: "the product name",
            price: "the listed price",
            in_stock: "whether it is in stock"
        }
    })
});

const result = await res.json();

for (const item of result.results) {
    if (item.status === "completed") {
        console.log(item.url, "->", item.data);
    } else {
        console.log(item.url, "FAILED:", item.error);
    }
}

console.log("total cost (cents):", result.total_cost_cents);
```

---

## Fehlerantworten

| Status | Bedingung |
|--------|------|
| `400 Bad Request` | Eine URL (oder der `discover_from`-Seed) löst zu einer privaten, Loopback- oder Link-Local-Adresse auf, hat keinen Hostnamen oder scheitert anderweitig an der SSRF-Prüfung. Wird pro URL während des Renderings ausgelöst. |
| `401 Unauthorized` | Fehlender oder ungültiger API-Schlüssel / JWT-Token. |
| `402 Payment Required` | distill ist nicht in deinem aktuellen Plan enthalten, dein monatliches Ops-Kontingent ist erschöpft, oder `discover_from` wurde ohne das Plan-Flag `discover_enabled` gesendet. |
| `403 Forbidden` | `/v2/distill` steht nicht in den erlaubten Endpunkten des API-Schlüssels. |
| `422 Unprocessable Entity` | Kein `schema`, beides oder keines von `urls`/`discover_from`, ein strukturell ungültiges Schema, über 200 Schema-Properties, ein ungültiges `CssField` (fehlendes `attribute`/`pattern`/`fields` für seinen Typ, ein nicht kompilierbarer oder ReDoS-anfälliger Regex), oder CSS-Feld-Verschachtelung tiefer als 5. |
| `500 Internal Server Error` | Die Orchestrierung ist unerwartet fehlgeschlagen. Die Meldung enthält die `operation_id` zur Angabe beim Support. |

Ein paar Status-Feinheiten, die man auseinanderhalten sollte: Eine
einzelne URL, deren Rendering wegen SSRF abgelehnt wird, führt nur dann
zu einem `400`, wenn sie der Seed einer `discover_from`-Anfrage ist; bei
einer expliziten `urls`-Liste wird eine per-URL-Render-Ablehnung
stattdessen zu einer Ergebniszeile mit `failed`, statt die gesamte
Anfrage scheitern zu lassen. Eine erreichte LLM-Budgetgrenze ist niemals
ein Fehler, denn sie degradiert zu einem reinen CSS-Ergebnis mit einer
Warnung. Die vollständige Status-Code-Referenz steht in
[der Fehlercode-Anleitung](/de/docs/reference/errors.md).

---

## Limits

| Limit | Wert |
|-------|-------|
| URLs pro Request (`urls`) | 50 (`MAX_DISTILL_URLS`) |
| `discover_from.max_pages` | 1 bis 50 |
| URL-Länge | 2,048 Zeichen |
| Schema-Top-Level-Properties | 200 (`MAX_SCHEMA_PROPERTIES`) |
| `css_schema.fields` | 1 bis 128 |
| `CssField.fields`-Kinder | 64 |
| CSS-Feld-Verschachtelungstiefe | 5 (`MAX_CSS_FIELD_DEPTH`) |
| `wait_for`-Länge | 1,024 Zeichen |
| `wait_timeout_ms` | 0 bis 60,000 ms |
| CSS-Pass-Timeout | 10 Sekunden pro URL |
| LLM-Grenze pro Aufruf | $0.05 |
| LLM-Grenze pro Request | $0.50 |
| LLM-Eskalationen pro Request | 50 |
| LLM-Grenze pro Zeitraum | Monatliches AI-Credit-Guthaben ($5 / $15 / $40 je Tarif; ungenutzte Credits werden übertragen) |
| Monatliche Ops (über alle Endpunkte geteilt, 1 pro abgeschlossener URL) | 500 / 3.000 / 15.000 / 50.000 je Tarif; siehe [Preise](/de/pricing.md) |

---

## Häufig gestellte Fragen

### Wie extrahiere ich strukturierte Daten von einer Website mit einer REST-API?

Sende `POST /v2/distill` mit `urls` (bis zu 50 pro Request) und einem `schema`, entweder ein JSON-Schema-Objekt oder eine flache `{field: description}`-Map. Die `data` der Antwort kommt normalisiert auf exakt die Top-Level-Keys deines Schemas zurück.

### Kann ich eine Website ohne CSS-Selektoren in ein JSON-Schema scrapen?

Ja. `css_schema` ist optional, und ohne es fällt jedes Feld direkt in den LLM-gestützten Pass. Ein mitgeschicktes `css_schema` füllt selektor-adressierbare Felder kostenlos und eskaliert nur die Felder, die der CSS-Pass leer gelassen hat.

### Was kostet der LLM-Extraktionspass, und wie wird er gedeckelt?

Die Budgetgrenzen sind gestaffelt: $0.05 pro LLM-Aufruf, $0.50 pro `/v2/distill`-Request, höchstens 50 Eskalationen pro Request und pro Zeitraum dein monatliches AI-Credit-Guthaben ($5 / $15 / $40 auf Indie / Studio / Production; ungenutzte Credits werden übertragen). LLM-Extraktion verbraucht Credits, keine Ops. Das Erreichen einer Grenze lässt die Anfrage nie scheitern: Betroffene Felder kommen mit einer Warnung als `null` zurück.

### Warum sind manche Felder in meiner distill-Antwort null?

Fehlende Skalare werden auf `null` normalisiert (fehlende Arrays auf `[]`), um die Formgarantie zu wahren. Der LLM-Pass wird übersprungen (mit einer Warnung), wenn dein Plan keine LLM-Stufe hat, die `render_quality` der Seite sie als blockiert markiert hat oder eine Budgetgrenze erreicht wurde.

### Kann ich eine ganze Site crawlen und aus jeder Seite dasselbe Schema extrahieren?

Ja. Sende statt `urls` ein `discover_from` mit einer Seed-`url`, einem `mode` (`sitemap`, `crawl` oder `hybrid`) und `max_pages` (1 bis 50). Das erfordert das Plan-Flag `discover_enabled`; ohne es wird die Anfrage mit `402` abgelehnt.
