# HelloFresh.de — Recipe Scraping via SSR Hydration

HelloFresh.de serves recipe content as Next.js SSR pages with recipe data embedded in `__NEXT_DATA__` hydration scripts. No public API is exposed; extraction requires HTML parsing of the dehydrated React Query state.

## Architecture

Next.js + TanStack Query. Recipe data fetched server-side and serialized into `__NEXT_DATA__.props.pageProps.ssrPayload.dehydratedState.queries[]`.

### Key query keys

| Query key | Data |
|-----------|------|
| `['recipe.search', {...}]` | Recipe list for category page |
| `['foodContentHubRecipe.byId', {'id': '...'}]` | Full recipe detail |

## Category Page (Discovery)

```python
import urllib.request, json, re

def hf_fetch_category(category: str, skip=0, take=50) -> tuple[list[dict], int]:
    url = f"https://www.hellofresh.de/recipes/{category}?skip={skip}&take={take}"
    headers = {
        "User-Agent": "Mozilla/5.0...", "Accept": "text/html...",
        "Accept-Language": "de-DE,de;q=0.9", "Referer": "https://www.hellofresh.de/",
    }
    req = urllib.request.Request(url, headers=headers)
    with urllib.request.urlopen(req, timeout=15) as resp:
        html = resp.read().decode("utf-8", errors="ignore")
        match = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
        data = json.loads(match.group(1))
        ssr = data['props']['pageProps']['ssrPayload']
        for q in ssr.get('dehydratedState', {}).get('queries', []):
            key = q.get('queryKey', [])
            if len(key) > 0 and key[0] == 'recipe.search':
                d = q['state']['data']
                return d.get('items', []), d.get('total', 0)
        return [], 0
```

**Category slugs** (62 total): amerikanische-rezepte, asiatische-rezepte, beliebteste-rezepte, burger, chinesische-rezepte, deutsche-rezepte, einfache-rezepte, eintopf-rezepte, familien-rezepte, flammkuchen-rezepte, fleisch-rezepte, franzosische-rezepte, fusions-rezepte, gesunde-rezepte, glutenfreie-rezepte, griechische-rezepte, grillrezepte, indische-rezepte, italienische-rezepte, japanische-rezepte, kalorienreduzierte-rezepte, karibische-rezepte, koreanische-rezepte, low-carb-rezepte, marokkanische-rezepte, mediterrane-rezepte, meeresfruechte-rezepte, mexikanische-rezepte, orientalische-rezepte, pasta-rezepte, pizza, proteinreiche-rezepte, rezepte-naher-osten, salat-rezepte, sandwich-rezepte, schnelle-gerichte, schnitzel, skandinavische-rezepte, spanische-rezepte, suppen-rezepte, thailandische-rezepte, tofu, tuerkische-rezepte, vegane-rezepte, vegetarische-rezepte, vietnamesische-rezepte, weihnachtsessen, zitrone, etc.

## Recipe Detail

```python
def hf_fetch_detail(recipe_id: str, slug: str) -> dict | None:
    url = f"https://www.hellofresh.de/recipes/{slug}-{recipe_id}"
    req = urllib.request.Request(url, headers=headers)
    with urllib.request.urlopen(req, timeout=15) as resp:
        html = resp.read().decode("utf-8", errors="ignore")
        match = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
        data = json.loads(match.group(1))
        ssr = data['props']['pageProps']['ssrPayload']
        for q in ssr.get('dehydratedState', {}).get('queries', []):
            key = q.get('queryKey', [])
            if len(key) > 0 and key[0] == 'foodContentHubRecipe.byId':
                return q['state']['data'].get('recipe')
        return None
```

## Normalization

| Field | Source | Notes |
|-------|--------|-------|
| id | `recipe.id` | Strip `-de-DE` suffix |
| title | `recipe.name` | |
| description | `recipe.description` or `recipe.headline` | |
| ingredients | `yields[0].ingredients` + `ingredients` lookup | Two parallel arrays |
| instructions | `steps[].instructionsMarkdown` -> strip HTML | |
| prep_time | `recipe.prepTime` ISO 8601 | Parse PT30M -> 30 |
| servings | `yields[0].yields` | Default 2 |
| calories | `nutrition[]` where name contains "kcal" | |
| tags | `recipe.tags[].name` | Skip "SEO" |
| category | `recipe.cuisines` | May be str or dict with `name` |

### Ingredient assembly

```python
def assemble_ingredients(recipe):
    ing_lookup = {ing['id']: ing['name'] for ing in recipe.get('ingredients', [])}
    ingredients = []
    for ying in recipe.get('yields', [{}])[0].get('ingredients', []):
        name = ing_lookup.get(ying.get('id', ''), '')
        amount = ying.get('amount', 0)
        unit = ying.get('unit', '')
        if name and amount is not None:
            if unit and unit != 'nach Geschmack':
                ingredients.append(f"{amount} {unit} {name}")
            elif unit == 'nach Geschmack':
                ingredients.append(f"{name} (nach Geschmack)")
            else:
                ingredients.append(f"{amount} {name}")
        elif name:
            ingredients.append(name)
    return ingredients
```

## Performance

- Category page: ~0.5-1.0s
- Detail page: ~0.5-1.5s
- Throughput: ~10-20 recipes/min with 8 workers
- Total public recipes: ~5,000-10,000

## Pitfalls

1. **No public API** - HTML hydration only; may break on Next.js upgrade
2. **`cuisines` polymorphic** - `list[str]` or `list[dict]`; check `isinstance`
3. **Amount of 0** means "nach Geschmank" or optional
4. **Deduplicate by id** - recipes appear across categories
5. **Keep polite** - 8 workers max, 0.5s between batches
