Initial commit: Hermes Agent Skills collection
This commit is contained in:
+304
@@ -0,0 +1,304 @@
|
||||
# Chefkoch.de Internal API v2 — Discovery & Detail
|
||||
|
||||
Discovered during v5.2 scraper development. This endpoint is **public and unauthenticated** (as of 2026-06) and returns structured JSON for 384,252+ recipes.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://api.chefkoch.de/v2/recipes
|
||||
```
|
||||
|
||||
No API key, no auth headers, no cookies required.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Listing (Discovery)
|
||||
|
||||
```
|
||||
GET /v2/recipes?offset={N}&limit={limit}
|
||||
```
|
||||
|
||||
**Parameters**
|
||||
|
||||
- `offset` — integer, start index (pagination)
|
||||
- `limit` — integer, max 100 per request (default 30)
|
||||
|
||||
**Response shape**
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 384252,
|
||||
"queryId": "",
|
||||
"results": [
|
||||
{
|
||||
"recipe": {
|
||||
"id": "4073161636112189",
|
||||
"title": "Flammkuchen mit Kartoffeln und Schwarzwälder Schinken",
|
||||
"slug": "Flammkuchen-mit-Kartoffeln-und-Schwarzwaelder-Schinken",
|
||||
"siteUrl": "https://www.chefkoch.de/rezepte/4073161636112189/Flammkuchen-mit-Kartoffeln-und-Schwarzwaelder-Schinken.html",
|
||||
"difficulty": 1,
|
||||
"preparationTime": 15,
|
||||
"rating": {"rating": 4.75, "numVotes": 20},
|
||||
"hasImage": true,
|
||||
"isPremium": true,
|
||||
"createdAt": "2021-11-05T13:36:51+01:00"
|
||||
},
|
||||
"score": 384252
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Discovery-only fields** (useful for filtering before detail fetch):
|
||||
- `id` — recipe ID (string)
|
||||
- `title` / `slug` / `siteUrl` — human metadata
|
||||
- `preparationTime` — minutes (int)
|
||||
- `difficulty` — 1=easy, 3=hard
|
||||
- `rating.rating` / `rating.numVotes`
|
||||
- `isPremium` / `isPlus` — premium flag
|
||||
|
||||
### 2. Detail (Full Recipe)
|
||||
|
||||
```
|
||||
GET /v2/recipes/{id}
|
||||
```
|
||||
|
||||
**Response shape** (key fields)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "4073161636112189",
|
||||
"title": "Flammkuchen…",
|
||||
"slug": "Flammkuchen-mit-Kartoffeln-und-Schwarzwaelder-Schinken",
|
||||
"siteUrl": "https://www.chefkoch.de/rezepte/...",
|
||||
"subtitle": "Originalrezept von Viki Fuchs",
|
||||
"additionalDescription": "",
|
||||
"instructions": "Den Ofen auf 220 °C Umluft vorheizen…",
|
||||
"ingredientGroups": [
|
||||
{
|
||||
"header": "Für den Teig: ",
|
||||
"ingredients": [
|
||||
{
|
||||
"id": "8891376",
|
||||
"name": "Öl",
|
||||
"unit": "EL",
|
||||
"unitId": "13",
|
||||
"amount": 4.0,
|
||||
"isBasic": false,
|
||||
"usageInfo": "",
|
||||
"foodId": "184"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["Schnell", "einfach", "Backen", "Frankreich"],
|
||||
"rating": {"rating": 4.75, "numVotes": 20},
|
||||
"preparationTime": 15,
|
||||
"kCalories": 0,
|
||||
"servings": 2,
|
||||
"nutrition": null,
|
||||
"difficulty": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Structured Ingredients (API vs JSON-LD)
|
||||
|
||||
| Aspect | JSON-LD (`recipeIngredient`) | API (`ingredientGroups`) |
|
||||
|--------|------------------------------|--------------------------|
|
||||
| Format | raw strings: `"4 EL Öl"` | objects: `{amount: 4, unit: "EL", name: "Öl"}` |
|
||||
| Groups | flat list | grouped by `header` (e.g. "Für den Teig:") |
|
||||
| Parsing | requires regex heuristics | already parsed |
|
||||
| Accuracy | ~95% (edge cases in parentheses) | ~99% |
|
||||
|
||||
**Normalization recipe** (from API objects to flat strings):
|
||||
|
||||
```python
|
||||
ingredients = []
|
||||
for group in data.get("ingredientGroups", []):
|
||||
header = group.get("header", "").strip()
|
||||
if header:
|
||||
ingredients.append(f"--- {header} ---")
|
||||
for ing in group.get("ingredients", []):
|
||||
parts = [str(p) for p in [ing.get("amount"), ing.get("unit"), ing.get("name")] if p]
|
||||
if parts:
|
||||
ingredients.append(" ".join(parts))
|
||||
```
|
||||
|
||||
## Rate Limits & Politeness
|
||||
|
||||
**Observed behavior:**
|
||||
- No explicit rate limiting (no 429 responses observed at 16 concurrent requests)
|
||||
- Server responds reliably with `application/json`
|
||||
- Total count fluctuates slightly (cache propagation)
|
||||
|
||||
**Recommended politeness:**
|
||||
- 0.3s between listing batches
|
||||
- 16 concurrent detail fetches (ThreadPoolExecutor)
|
||||
- No backoff needed for normal operation
|
||||
|
||||
## Comparison: API vs Playwright
|
||||
|
||||
| Metric | Playwright v4/v5 | API v5.2 |
|
||||
|--------|------------------|----------|
|
||||
| Discovery | Playwright scroll + DOM parse | 1 HTTP GET per 100 recipes |
|
||||
| Detail fetch | urllib.requests + JSON-LD regex | 1 HTTP GET per recipe |
|
||||
| Avg per recipe | ~3–5s | ~0.2s |
|
||||
| Browser dependency | Yes (chromium binary) | No |
|
||||
| Headless issues | Cookie banners, JS timing | None |
|
||||
| Max recipes reachable | ~25k (search exhaustion) | ~384k (full catalog) |
|
||||
| Bot detection risk | Moderate (requires stealth) | Very low (same API as site frontend) |
|
||||
|
||||
## Migration from Playwright to API
|
||||
|
||||
```python
|
||||
# old (v5.1)
|
||||
urls = await _fetch_links(page, seed_url) # Playwright
|
||||
recipe = await _scrape_one(page, url) # requests + JSON-LD
|
||||
|
||||
# new (v5.2)
|
||||
recipes_meta = _fetch_recipe_list(offset, limit=100) # API listing
|
||||
recipe = _fetch_recipe_detail(meta["id"]) # API detail
|
||||
```
|
||||
|
||||
## Listing API: Offset Ceiling Reality
|
||||
|
||||
The unfiltered listing endpoint (`/v2/recipes?offset=N&limit=100`) appears to return **the same global list** regardless of any `sort=`, `order=`, `with=`, or `difficulty=` parameters tested (2026-06-22). The only way to get diversity is to advance `offset`.
|
||||
|
||||
**CRITICAL:** Offset has a practical ceiling. In testing:
|
||||
- `offset=0` returns 100 results ✅
|
||||
- `offset=2000` returns 0 results ❌ (server silently drops)
|
||||
|
||||
This means the unfiltered listing CANNOT be used to reach the full 384k catalog by offset alone. For deep scraping, use the **search query strategy** below.
|
||||
|
||||
## Search Queries via `?query=`
|
||||
|
||||
The endpoint supports text search with real filtering:
|
||||
|
||||
```
|
||||
GET /v2/recipes?query={term}&offset=0&limit=100
|
||||
```
|
||||
|
||||
Verified search queries (with approximate counts as of 2026-06):
|
||||
|
||||
| Query | Count | Usable offsets |
|
||||
|-------|-------|----------------|
|
||||
| Hauptspeise | ~158,000 | 0–~1000 |
|
||||
| Kuchen | ~58,000 | 0–~1000 |
|
||||
| Vegan | ~28,000 | 0–~1000 |
|
||||
| Italienisch | ~7,000 | 0–~1000 |
|
||||
| (blank) | ~384,000 | 0–~2000 (barely) |
|
||||
|
||||
**Search offset ceiling:** ~1000 results (10 pages of 100). Beyond that, the search returns empty.
|
||||
|
||||
## Deep Scraper Strategy for 25k+ Recipes (v5.2)
|
||||
|
||||
Because no single query reaches beyond ~1000–2000 offsets, the scraper must **rotate queries**:
|
||||
|
||||
1. Maintain a list of 50+ search terms (categories, ingredients, cuisines)
|
||||
2. Each run: pick a rotation window (e.g., 20 terms)
|
||||
3. For each term: random offset between 0 and ~100, fetch listing
|
||||
4. Filter known IDs, fetch details in parallel
|
||||
5. Rotate to next term
|
||||
|
||||
This prevents seed exhaustion while staying inside each query's usable window.
|
||||
|
||||
```python
|
||||
QUERIES = [
|
||||
"Hauptspeise", "Vorspeise", "Nachtisch", "Beilage", "Fruehstueck",
|
||||
"Backen", "Grillen", "Salat", "Suppe", "Eintopf", "Pasta", "Dessert",
|
||||
"Kuchen", "Brot", "Getraenk", "Fingerfood", "Curry", "Wok", "Auflauf",
|
||||
"Gratin", "Risotto", "Lasagne", "Lachs", "Haehnchen", "Rind", "Schwein",
|
||||
"Fisch", "Gemuese", "Kartoffeln", "Reis", "Linsen", "Vegan", "Vegetarisch",
|
||||
"Low-Carb", "Schnell", "Einfach", "Italienisch", "Asiatisch", "Tacos",
|
||||
"Bowl", "Wrap", "Nudeln", "Haehnchenbrust", "Hackfleisch", "Burger",
|
||||
"Pizza", "Pfannkuchen", "Quiche", "Muffins", "Smoothie",
|
||||
]
|
||||
|
||||
def _fetch_list(query, offset, limit=100):
|
||||
url = f"https://api.chefkoch.de/v2/recipes?query={query}&offset={offset}&limit={limit}"
|
||||
...
|
||||
```
|
||||
|
||||
## Performance observed (2026-06-22):
|
||||
|
||||
**Initial projection under-estimated actual throughput significantly.**
|
||||
|
||||
With 12 workers: ~10–15 recipes/sec (throughput ceiling is detail-fetch latency, not listing)
|
||||
With 16 workers: ~800–900 recipes/minute (~13–15 recipes/sec)
|
||||
With 32 workers: ~750 recipes/minute sustained (diminishing returns from context switching)
|
||||
|
||||
**Key insight:** The listing fetch is negligible latency (~100ms). Detail fetch dominates (~200ms per recipe at 32 concurrent). 16 concurrent detail fetches saturates the network + server response pipe. More workers add no value.
|
||||
|
||||
**Recommended configuration:**
|
||||
- 16 ThreadPool workers (sweet spot)
|
||||
- 0.3s delay between listing batches
|
||||
- limit=100 per listing request
|
||||
- Each run rotates to next query when hitting the offset ceiling
|
||||
|
||||
## State Management for API Scraper
|
||||
|
||||
### State file format
|
||||
|
||||
```json
|
||||
{
|
||||
"query_index": 0,
|
||||
"api_offset": 0,
|
||||
"source_counts": {"chefkoch": 25020},
|
||||
"new_this_run": 0,
|
||||
"failed_listings": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Field semantics:**
|
||||
- `query_index` — position in `QUERIES` list (rotates when exhausted)
|
||||
- `api_offset` — current offset within the active query's pagination window
|
||||
- `source_counts` — total recipes scraped per source
|
||||
- `new_this_run` — deduplicated count since last state write
|
||||
- `failed_listings` — consecutive failed listing requests (used for fast-exit)
|
||||
|
||||
### CRITICAL: Offset increment must be `+= limit`, not `+= 1`
|
||||
|
||||
The scraper fetches **batches of `limit` recipes per listing call**. State must advance `api_offset` by `limit` (e.g., 100), not by 1. Incrementing by 1 causes the same 100-recipe batch to be re-fetched on every subsequent run, producing **zero new recipes** while appearing to "run successfully".
|
||||
|
||||
```python
|
||||
# CORRECT — advances to next batch
|
||||
def main():
|
||||
state = _load_state()
|
||||
offset = state.get("api_offset", 0)
|
||||
limit = 100
|
||||
# ... fetch listing at offset ...
|
||||
state["api_offset"] = offset + limit # 0 → 100 → 200
|
||||
_save_state(state)
|
||||
|
||||
# WRONG — re-fetches same batch every time
|
||||
def main():
|
||||
state = _load_state()
|
||||
offset = state.get("api_offset", 0)
|
||||
# ... fetch listing at offset ...
|
||||
state["api_offset"] = offset + 1 # 0 → 1; next run: fetches offset 1 (same as offset 0 in effect)
|
||||
```
|
||||
|
||||
**The same batch always returns the same 100 recipe IDs.** Deduplication drops everything, `new_this_run` stays at 0, but the state increments by 1 each run, crawling nowhere at ~1 recipe offset per launch.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Do not use** `api.chefkoch.de/v2/recipes/{id}/ingredients` — returns `resource_not_found` (404)
|
||||
2. **Ingredients live inside the detail response** under `ingredientGroups` — there is no separate endpoint
|
||||
3. **Tag list may contain empty strings** — filter with `.strip()` before storage
|
||||
4. **Count is approximate** — the `count` field drifts slightly; do not hardcode it as a loop bound
|
||||
5. **Offset-based pagination with a hard ceiling** — unfiltered listing exhausts at ~2000; search queries at ~1000
|
||||
6. **Query parameter filtering is NOT supported on the listing endpoint** — `sort=`, `difficulty=`, etc. are ignored
|
||||
7. **Do not cache the `count` field as a loop bound** — use query rotation instead
|
||||
|
||||
## Headers Required
|
||||
|
||||
Minimal headers suffice; full mimicry not required:
|
||||
|
||||
```python
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
|
||||
"Referer": "https://www.chefkoch.de/",
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user