# Infinite Refill Scraper v4 (Pattern B)

## When to Use

- Target is >10,000 recipes (batch seeds run out)
- Continuous background operation without daily cronjob intervention
- Resilient against search exhaustion (alphabet/categories eventually cover everything)

## Architecture

```
Infinite Seed Generator     URL Discovery (Playwright)     Recipe Extraction (Requests)
     │                              │                                    │
     ▼                              ▼                                    ▼
[Alphabet combos]  ──►  Search pages  ──►  new URLs  ──►  JSON-LD parse  ──►  real_recipes.jsonl
[Categories+rand]  ──►  (5 workers)  ──►  (queue)    ──►  (fast, no JS)  ──►  append-only
[Random 3-letter]  ──►              ──►  dedup       ──►                ──►  crash-safe
```

## Seed Generator (Python)

Cycles through 4 strategies infinitely, never running out:

```python
ALPHABET = "abcdefghijklmnopqrstuvwxyzäöü"
CATEGORIES = [
    "Rezepte", "Hauptspeise", "Vorspeise", "Nachtisch", "Beilage",
    "Fruehstueck", "Backen", "Grillen", "Salat", "Suppe", "Eintopf",
    "Pasta", "Dessert", "Kuchen", "Brot", "Getraenk", "Fingerfood",
]

def _next_seeds(batch_size=200, cycle_counter):
    seeds = []
    while len(seeds) < batch_size:
        cycle = cycle_counter % 4
        if cycle == 0:
            # Alphabetic 2-letter combos (e.g. "ab", "ac" ...):
            a = ALPHABET[cycle_counter % len(ALPHABET)]
            b = ALPHABET[(cycle_counter // len(ALPHABET)) % len(ALPHABET)]
            term = a + b
        elif cycle == 1:
            # Category + random letter (e.g. "Pasta+a")
            cat = CATEGORIES[cycle_counter % len(CATEGORIES)]
            letter = random.choice(ALPHABET)
            term = cat + "+" + letter
        elif cycle == 2:
            # Just category (e.g. "Pasta")
            term = CATEGORIES[cycle_counter % len(CATEGORIES)]
        else:
            # Random 3-letter nonsense (e.g. "xyz")
            term = "".join(random.choices(ALPHABET, k=3))
        seeds.append(f"https://www.chefkoch.de/rs/s0/{term}/Rezepte.html")
        cycle_counter += 1
    return seeds, cycle_counter
```

**Coverage estimate:** 29 letters × 29 letters = 841 combos for 2-letter alone. Plus 17 categories × 29 = 493. Plus pure category = 17. Plus random 3-letter = infinite. Total: effectively unlimited.

## Two-Phase Extraction

### Phase 1: Link Discovery

```python
async def _fetch_links(page, url, timeout=30):
    await page.goto(url, wait_until="domcontentloaded", timeout=timeout*1000)
    await asyncio.sleep(1.5)  # Let JS render
    links = await page.eval_on_selector_all(
        "a[href*='/rezepte/']",
        "els => els.map(e => e.href)"
    )
    # Deduplicate and filter
    seen = set()
    result = []
    for l in links:
        if "chefkoch.de/rezepte/" in l and l not in seen:
            seen.add(l)
            result.append(l)
    return result
```

### Phase 2: Recipe Extraction (NO Playwright)

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

async def _scrape_one(url, timeout=15):
    req = urllib.request.Request(url, headers={
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ...",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
    })
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        html = resp.read().decode("utf-8", errors="ignore")
    
    blocks = re.findall(
        r'<script type="application/ld\+json">(.*?)</script>',
        html, re.DOTALL | re.IGNORECASE
    )
    for block in blocks:
        data = json.loads(block.strip())
        if isinstance(data, list):
            for item in data:
                if isinstance(item, dict) and item.get("@type") == "Recipe":
                    return _normalize_recipe(item, url)
        elif isinstance(data, dict) and data.get("@type") == "Recipe":
            return _normalize_recipe(data, url)
    # Fallback: look for mainEntity
    for block in blocks:
        data = json.loads(block.strip())
        if isinstance(data, dict) and "mainEntity" in data:
            me = data["mainEntity"]
            if isinstance(me, dict) and me.get("@type") == "Recipe":
                return _normalize_recipe(me, url)
    return None
```

**Why not use Playwright for recipe pages?** JSON-LD is embedded in raw HTML and extracts faster (no browser overhead). Playwright is only needed for search pages because Chefkoch serves a JS shell there.

## State Management

```python
STATE = {
    "seed_cycle": 0,        # Position in infinite sequence
    "started_at": "",       # ISO timestamp
    "recipes_target": 25000
}
```

Save every batch. On restart, the script resumes from the last seed_cycle.

## Normalization Function

```python
def _normalize_recipe(data, url):
    rid = (data.get("identifier", {}).get("value", "") 
           or url.split("/")[-1].split("-")[0])
    title = data.get("name", "")
    if not title:
        return None
    
    keywords = data.get("keywords", "")
    tags = [t.strip().lower() for t in keywords.split(",") if t.strip()]
    recipe_cat = data.get("recipeCategory", "")
    cuisine = data.get("recipeCuisine", "")
    if recipe_cat:
        tags.append(recipe_cat.lower())
    if cuisine:
        tags.append(cuisine.lower())
    tags = list(set(tags))
    
    rating = data.get("aggregateRating", {})
    nutrition = data.get("nutrition", {})
    
    return {
        "id": str(rid),
        "title": title,
        "description": data.get("description", ""),
        "source_url": url,
        "ingredients": data.get("recipeIngredient", []),
        "instructions": data.get("recipeInstructions", ""),
        "tags": tags,
        "category": recipe_cat or (tags[0] if tags else "Sonstiges"),
        "rating_value": rating.get("ratingValue"),
        "rating_count": rating.get("ratingCount"),
        "prep_time": data.get("prepTime", ""),
        "cook_time": data.get("cookTime", ""),
        "total_time": data.get("totalTime", ""),
        "calories": str(nutrition.get("calories", "")),
        "scraped_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
    }
```

## Execution Pattern

### DO NOT use LLM-based cronjob

❌ **WRONG:** A cronjob that uses `hermes` LLM agent to run Python. Fails with:
- `Error 400: model is required` (if no model configured)
- `No Anthropic credentials` (if wrong provider configured)
- Unnecessary cost: paying LLM tokens just to run `wc -l` and `python script.py`

✅ **CORRECT:** `terminal(background=True)` with `notify_on_complete`

```python
# Start directly
cd /profile/nutrition-coach && \
  source .venv/bin/activate && \
  python3 /path/to/chefkoch_scraper_v4.py

# In Hermes Agent:
# terminal(background=True, notify_on_complete=True)
```

### Why background process beats cron-checked scraper

| Aspect | Cronjob (daily check + start) | Background process |
|--------|-------------------------------|-------------------|
| Failure mode | LLM errors, credential mismatches | None (pure Python) |
| Latency | Up to 24h until next check | Continuous |
| State tracking | External (cron state DB) | Internal (scraper_state.json) |
| Backpressure | Manual | Automatic (sleep on queue overflow) |
| Monitoring | Cron output files | Log tail + notify_on_complete |
| Cost | LLM tokens per check | Zero |

## Performance Benchmarks

| Configuration | Recipes/min | 5,000 recipes | 25,000 recipes |
|--------------|-------------|---------------|----------------|
| 5 workers (batch, fixed seeds) | 60–80 | ~60–90 min | N/A (seeds exhausted) |
| Infinite v4 (single context) | 40–60 | ~80–120 min | ~7–10 hours |
| Infinite v4 (5 contexts, remote) | 80–120 | ~40–60 min | ~4–6 hours |

**Note:** v4 uses a single Playwright page context for discovery (lighter than 5 full workers). The extraction is HTTP requests which are much faster.*

## Known v4 Failure Modes

If the scraper is stuck at the exact same recipe count for multiple cron runs (check `scraper.log` — repeated `START run | current: N/N` with identical `N`), one of these three root causes is almost certainly present.

### Cause 1: `SEED_CYCLE` not declared global in `main()`

**Symptom:** `seed_cycle` in state JSON is `0` even after dozens of runs. The same exhausted 2-letter seeds are visited forever.

**Root cause:** In Python, a bare assignment `SEED_CYCLE = state["seed_cycle"]` inside `main()` creates a *local* variable if `global SEED_CYCLE` is not declared. The module-level global stays at `0`, so `_next_seeds()` always starts from `0`.

**Fix:** Add in `main()`:
```python
state.setdefault("seed_cycle", 0)
global SEED_CYCLE        # ← required
SEED_CYCLE = state["seed_cycle"]
```

**Reproduce:** Open Python, import the module, inspect `chefkoch_scraper_v4.SEED_CYCLE` after `main()` runs — it remains `0` without the `global` line.

### Cause 2: Playwright EPIPE / `Executable doesn't exist`

**Symptom:** Log shows `Executable doesn't exist at ...chrome-linux/chrome` or Node `EPIPE` errors. The process exits immediately.

**Root cause:** Environment variable `PLAYWRIGHT_BROWSERS_PATH` is set in the caller's shell but not propagated to the background process spawned by `terminal(background=True)`. The agent's background launcher creates a new non-login bash that inherits `PATH` but not the caller's env overrides.

**Fix:** Hardcode the browser path inside the script itself:
```python
import os
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "/home/.../.cache/ms-playwright")
```

Do **not** rely on shell `export` when launching via `terminal(background=True)`.

### Cause 3: Stale `completed_seeds` blockers in state

**Symptom:** Scraper runs but repeatedly visits the same search pages with no new URLs, or many search pages return zero links.

**Root cause:** `scraper_state.json` contains a `completed_seeds` dict that prevents revisiting certain terms. If these terms were exhausted naturally, the seeds never advance.

**Fix:** Wipe or prune the dict before a fresh run:
```python
import json
s = json.load(open("scraper_state.json"))
s.pop("completed_seeds", None)
s["seed_cycle"] = max(s.get("seed_cycle", 0), 1000)
json.dump(s, open("scraper_state.json", "w"), ensure_ascii=False)
```

### Quick diagnostic flow

```bash
# 1. Check recipe count isn't moving
wc -l ~/.hermes/profiles/nutrition-coach/real_recipes.jsonl

# 2. Check state
python3 -c "import json; s=json.load(open('scraper_state.json')); print('seed_cycle:', s.get('seed_cycle')); print('completed_seeds keys:', list(s.get('completed_seeds',{}).keys())[:5])"

# 3. Check Playwright browser
find /home/debian/.cache/ms-playwright -name chrome | head -1

# 4. Check if process is actually scraping (not just discovering)
ps auxf | grep -E 'chefkoch|chrome' | grep -v grep | wc -l
```

See `references/v4-known-issues-and-fixes.md` for the full patch snippets and state-reset script.

## File Locations (standard)

```
~/.hermes/profiles/nutrition-coach/
├── real_recipes.jsonl          # Append-only output
├── scraper_state.json          # Resume state
├── scraper.log                 # Human-readable log
└── scripts/
    └── chefkoch_scraper_v4.py  # Standalone script
```

## Monitoring

```bash
# Check progress
tail -n 5 ~/.hermes/profiles/nutrition-coach/scraper.log
wc -l ~/.hermes/profiles/nutrition-coach/real_recipes.jsonl

# Check if running
ps aux | grep chefkoch_scraper

# Restart if needed
cd ~/.hermes/profiles/nutrition-coach && \
  source .venv/bin/activate && \
  nohup python3 scripts/chefkoch_scraper_v4.py > scraper.log 2>&1 &
```

## Error Recovery

```
Error streak >= 100 → Script aborts automatically
→ Check Chefkoch availability: curl -I https://www.chefkoch.de
→ Verify Playwright install: playwright install chromium
→ Check disk space: df -h
→ Restart with fresh state (or resume from last saved seed_cycle)
```

## Production Deployment

### Browser path detection (common error)

If Playwright fails with `Executable doesn't exist at ...chrome-headless-shell`:

```bash
# Find existing browser installations
find /home/debian -name "chrome" -o -name "chromium" 2>/dev/null

# Usually found under /home/debian/.cache/ms-playwright/
# Set the environment variable before starting:
export PLAYWRIGHT_BROWSERS_PATH=/home/debian/.cache/ms-playwright
```

If the browser is not installed at all:
```bash
cd /profile/nutrition-coach && source .venv/bin/activate
playwright install chromium
```

**Do NOT rely on `playwright install` in the crontab — install once, reference via env var.**

### nohup wrapper for daemon mode

Create `~/.hermes/profiles/nutrition-coach/scripts/start_scraper_v4.sh`:

```bash
#!/bin/bash
LOG="/home/debian/.hermes/profiles/nutrition-coach/scraper_v4.out"
PIDFILE="/tmp/chefkoch_scraper_v4.pid"
if [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
    echo "Scraper already running (PID $(cat $PIDFILE))"; exit 0
fi
cd /home/debian/.hermes/profiles/nutrition-coach || exit 1
export PYTHONUNBUFFERED=1
export PLAYWRIGHT_BROWSERS_PATH=/home/debian/.cache/ms-playwright
nohup python3 scripts/chefkoch_scraper_v4.py --no-agent > "$LOG" 2>&1 &
echo $! > "$PIDFILE"
echo "Scraper started (PID $!)"
```

Then: `chmod +x scripts/start_scraper_v4.sh`

**Path note:** In this environment the script lives under `scripts/chefkoch_scraper_v4.py` inside the profile directory, not `~/.hermes/scripts/`. Always check `ls scripts/` before constructing the invocation path.

### Watchdog cronjob (no_agent)

```bash
cronjob action=create \
  name=chefkoch-scraper-watchdog \
  script=start_scraper_v4.sh \
  schedule="*/10 * * * *" \
  no_agent=true \
  deliver=local
```

This restarts the scraper if it crashes, without involving any LLM.

### DO NOT use `terminal(background=true)` with `tee` or complex pipelines

❌ `terminal(background=true, command="... | tee log.out")` — Output buffering causes `tee` to swallow data, and the `terminal` pseudo-tty layer conflicts with Playwright's subprocess management. Result: log file remains empty or stale, process output invisible.

✅ Start via explicit file redirect with `PYTHONUNBUFFERED=1`:

```python
# In Hermes Agent — background process with file redirect
terminal(
    background=True,
    command="cd /profile && PYTHONUNBUFFERED=1 python3 scripts/chefkoch_scraper_v4.py --no-agent >> scraper_v4.out 2>&1"
)
```

Or use the `start_scraper_v4.sh` wrapper script shown above. Always verify progress with `wc -l real_recipes.jsonl` and `tail scraper_v4.out` rather than relying solely on process output capture.

