chefkoch-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ """Fachlogik: Suche, Namensauflösung, Skalierung und Einkaufsliste."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ import unicodedata
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ from .client import ApiClient
12
+ from .errors import NoResultsError, NotFoundError, UsageError
13
+ from .parsing import (
14
+ TAG_VEGAN,
15
+ TAG_VEGETARISCH,
16
+ normalize_recipe,
17
+ normalize_search_payload,
18
+ recipe_tag_ids,
19
+ )
20
+
21
+ SEARCH_WINDOW = 500
22
+ LOOKUP_LIMIT = 10
23
+ DEFAULT_LIMIT = 10
24
+ DEFAULT_WATCH_LIMIT = 50
25
+ SORT_CHOICES = ("bewertung", "zeit", "neu")
26
+ DIFFICULTIES = {"simpel": 1, "normal": 2, "pfiffig": 3}
27
+ CHEFKOCH_HOSTS = {"www.chefkoch.de", "chefkoch.de"}
28
+
29
+
30
+ def _validate_limit(value: int, name: str = "--limit") -> int:
31
+ if value < 1 or value > 100:
32
+ raise UsageError(
33
+ f"{name} muss zwischen 1 und 100 liegen.",
34
+ next_step="Beispiel: --limit 10",
35
+ )
36
+ return value
37
+
38
+
39
+ def _validate_page(value: int) -> int:
40
+ if value < 1:
41
+ raise UsageError(
42
+ "--seite muss mindestens 1 sein.",
43
+ next_step="Beispiel: --seite 1",
44
+ )
45
+ return value
46
+
47
+
48
+ def _validate_max_time(value: int | None) -> int | None:
49
+ if value is not None and value < 0:
50
+ raise UsageError(
51
+ "--max-zeit darf nicht negativ sein.",
52
+ next_step="Beispiel: --max-zeit 30",
53
+ )
54
+ return value
55
+
56
+
57
+ def _validate_min_rating(value: float | None) -> float | None:
58
+ if value is None:
59
+ return None
60
+ if value < 0 or value > 5:
61
+ raise UsageError(
62
+ "--min-bewertung muss zwischen 0 und 5 liegen.",
63
+ next_step="Beispiel: --min-bewertung 4.5",
64
+ )
65
+ return value
66
+
67
+
68
+ def _validate_difficulty(value: str | None) -> int | None:
69
+ if value is None:
70
+ return None
71
+ if value not in DIFFICULTIES:
72
+ raise UsageError(
73
+ f"Unbekannte Schwierigkeit: {value!r}.",
74
+ next_step="Erlaubt sind simpel, normal oder pfiffig.",
75
+ )
76
+ return DIFFICULTIES[value]
77
+
78
+
79
+ def sort_results(items: list[dict[str, Any]], sort: str) -> list[dict[str, Any]]:
80
+ if sort not in SORT_CHOICES:
81
+ raise UsageError(
82
+ f"Unbekannte Sortierung: {sort!r}.",
83
+ next_step="Erlaubt sind bewertung, zeit oder neu.",
84
+ )
85
+ if sort == "zeit":
86
+ return sorted(
87
+ items,
88
+ key=lambda item: (
89
+ item.get("preparationTime")
90
+ if item.get("preparationTime") is not None
91
+ else float("inf"),
92
+ -(item.get("rating") or 0.0),
93
+ ),
94
+ )
95
+ if sort == "neu":
96
+ return sorted(
97
+ items,
98
+ key=lambda item: (
99
+ str(item.get("createdAt") or ""),
100
+ str(item.get("id") or ""),
101
+ ),
102
+ reverse=True,
103
+ )
104
+ return sorted(
105
+ items,
106
+ key=lambda item: (
107
+ -(item.get("rating") or 0.0),
108
+ -int(item.get("numVotes") or 0),
109
+ str(item.get("title") or "").casefold(),
110
+ ),
111
+ )
112
+
113
+
114
+ def search_recipes(
115
+ client: ApiClient,
116
+ query: str,
117
+ *,
118
+ vegetarisch: bool = False,
119
+ vegan: bool = False,
120
+ max_zeit: int | None = None,
121
+ min_bewertung: float | None = None,
122
+ schwierigkeit: str | None = None,
123
+ sort: str = "bewertung",
124
+ limit: int = DEFAULT_LIMIT,
125
+ seite: int = 1,
126
+ ) -> dict[str, Any]:
127
+ text = str(query or "").strip()
128
+ if not text:
129
+ raise UsageError(
130
+ "Der Suchbegriff fehlt.",
131
+ next_step="Beispiel: chefkoch suche lasagne",
132
+ )
133
+ _validate_limit(limit)
134
+ _validate_page(seite)
135
+ _validate_max_time(max_zeit)
136
+ _validate_min_rating(min_bewertung)
137
+ difficulty = _validate_difficulty(schwierigkeit)
138
+ if sort not in SORT_CHOICES:
139
+ raise UsageError(
140
+ f"Unbekannte Sortierung: {sort!r}.",
141
+ next_step="Erlaubt sind bewertung, zeit oder neu.",
142
+ )
143
+
144
+ tags = TAG_VEGAN if vegan else TAG_VEGETARISCH if vegetarisch else None
145
+ payload = client.search(
146
+ text,
147
+ limit=SEARCH_WINDOW,
148
+ offset=0,
149
+ minimum_rating=min_bewertung,
150
+ maximum_time=max_zeit,
151
+ tags=tags,
152
+ )
153
+ count, items = normalize_search_payload(payload)
154
+
155
+ filtered: list[dict[str, Any]] = []
156
+ for item in items:
157
+ if max_zeit is not None:
158
+ preparation = item.get("preparationTime")
159
+ if preparation is None or preparation > max_zeit:
160
+ continue
161
+ if min_bewertung is not None:
162
+ rating = item.get("rating")
163
+ if rating is None or rating < min_bewertung:
164
+ continue
165
+ if difficulty is not None and item.get("difficulty") != difficulty:
166
+ continue
167
+ filtered.append(item)
168
+
169
+ filtered = sort_results(filtered, sort)
170
+
171
+ if tags is not None:
172
+ wanted = {tags}
173
+ need = limit * seite
174
+ verified: list[dict[str, Any]] = []
175
+ for item in filtered:
176
+ if len(verified) >= need:
177
+ break
178
+ try:
179
+ detail = client.recipe(str(item.get("id")))
180
+ except NotFoundError:
181
+ continue
182
+ detail_tags = recipe_tag_ids(detail)
183
+ if not wanted.issubset(detail_tags):
184
+ continue
185
+ item = dict(item)
186
+ item["vegetarian"] = TAG_VEGETARISCH in detail_tags
187
+ item["vegan"] = TAG_VEGAN in detail_tags
188
+ verified.append(item)
189
+ filtered = verified
190
+
191
+ page_items = filtered[(seite - 1) * limit : seite * limit]
192
+ if not page_items:
193
+ raise NoResultsError(
194
+ f"Keine Treffer für {text!r} mit diesen Filtern.",
195
+ next_step="Entferne einen Filter oder nutze einen anderen Suchbegriff.",
196
+ )
197
+
198
+ return {
199
+ "query": text,
200
+ "page": seite,
201
+ "limit": limit,
202
+ "total": count,
203
+ "count": len(page_items),
204
+ "results": page_items,
205
+ }
206
+
207
+
208
+ def search_candidates(client: ApiClient, name: str) -> list[dict[str, Any]]:
209
+ text = str(name or "").strip()
210
+ if not text:
211
+ raise UsageError(
212
+ "Der Rezeptname fehlt.",
213
+ next_step="Beispiel: chefkoch rezept \"vegane lasagne\"",
214
+ )
215
+ payload = client.search(text, limit=LOOKUP_LIMIT)
216
+ _count, items = normalize_search_payload(payload)
217
+ return sort_results(items, "bewertung")
218
+
219
+
220
+ _URL_RE = re.compile(r"^/rezepte/(\d+)(?:/[^/]*)?$")
221
+
222
+
223
+ def parse_recipe_reference(value: str) -> tuple[str, str]:
224
+ text = str(value or "").strip()
225
+ if not text:
226
+ raise UsageError(
227
+ "Die Rezeptangabe fehlt.",
228
+ next_step="Nutze einen Namen, eine ID oder eine Chefkoch-URL.",
229
+ )
230
+ if text.isdigit():
231
+ return "id", text
232
+
233
+ candidate = text
234
+ if "://" not in candidate and (
235
+ candidate.startswith("www.") or candidate.startswith("chefkoch.de/")
236
+ ):
237
+ candidate = "https://" + candidate
238
+ if "://" in candidate:
239
+ parts = urlsplit(candidate)
240
+ if parts.scheme not in {"http", "https"}:
241
+ raise UsageError(
242
+ f"Ungültige URL: {text!r}.",
243
+ next_step="Nutze eine https://www.chefkoch.de/rezepte/<id>/... URL.",
244
+ )
245
+ host = (parts.hostname or "").lower()
246
+ if host not in CHEFKOCH_HOSTS:
247
+ raise UsageError(
248
+ f"Fremder Host in der URL: {host or text!r}.",
249
+ next_step="Nutze eine URL von www.chefkoch.de.",
250
+ )
251
+ match = _URL_RE.match(parts.path)
252
+ if not match:
253
+ raise UsageError(
254
+ f"Die URL enthält keine Rezept-ID: {text!r}.",
255
+ next_step="Nutze eine URL der Form https://www.chefkoch.de/rezepte/<id>/...",
256
+ )
257
+ return "id", match.group(1)
258
+ return "name", text
259
+
260
+
261
+ def validate_servings(value: int | None) -> int | None:
262
+ if value is None:
263
+ return None
264
+ if value < 1:
265
+ raise UsageError(
266
+ "--portionen muss mindestens 1 sein.",
267
+ next_step="Beispiel: --portionen 4",
268
+ )
269
+ return value
270
+
271
+
272
+ def _normalize_key(value: object) -> str:
273
+ text = unicodedata.normalize("NFC", str(value or ""))
274
+ text = " ".join(text.split())
275
+ return text.casefold()
276
+
277
+
278
+ def _optional_amount(value: object) -> float | None:
279
+ if value is None or isinstance(value, bool):
280
+ return None
281
+ try:
282
+ number = float(value)
283
+ except (TypeError, ValueError):
284
+ return None
285
+ if not math.isfinite(number) or number == 0:
286
+ return None
287
+ return number
288
+
289
+
290
+ def build_shopping_list(
291
+ recipes: list[dict[str, Any]], portions: int | None
292
+ ) -> dict[str, Any]:
293
+ aggregates: dict[tuple[str, str], dict[str, Any]] = {}
294
+ recipe_rows: list[dict[str, Any]] = []
295
+ for recipe in recipes:
296
+ recipe_rows.append(
297
+ {
298
+ "id": recipe.get("id"),
299
+ "servings": recipe.get("servings"),
300
+ "requestedServings": recipe.get("requestedServings"),
301
+ }
302
+ )
303
+ recipe_id = str(recipe.get("id") or "")
304
+ for group in recipe.get("ingredientGroups") or []:
305
+ for ingredient in group.get("ingredients") or []:
306
+ name = str(ingredient.get("name") or "").strip()
307
+ unit = str(ingredient.get("unit") or "").strip()
308
+ key = (_normalize_key(name), _normalize_key(unit))
309
+ entry = aggregates.get(key)
310
+ if entry is None:
311
+ entry = {
312
+ "name": name,
313
+ "unit": unit,
314
+ "amount": None,
315
+ "recipeIds": [],
316
+ }
317
+ aggregates[key] = entry
318
+ amount = _optional_amount(ingredient.get("amount"))
319
+ if amount is not None:
320
+ if entry["amount"] is None:
321
+ entry["amount"] = 0.0
322
+ entry["amount"] += amount
323
+ if recipe_id and recipe_id not in entry["recipeIds"]:
324
+ entry["recipeIds"].append(recipe_id)
325
+
326
+ items = sorted(
327
+ aggregates.values(),
328
+ key=lambda entry: (
329
+ str(entry["name"]).casefold(),
330
+ str(entry["unit"]).casefold(),
331
+ ),
332
+ )
333
+ for entry in items:
334
+ if entry["amount"] is not None:
335
+ entry["amount"] = round(entry["amount"], 2)
336
+ return {"recipes": recipe_rows, "items": items}
337
+
338
+
339
+ def load_recipe(
340
+ client: ApiClient, reference: str, requested_servings: int | None
341
+ ) -> dict[str, Any]:
342
+ """Lädt ein Rezept per Name, ID oder URL ohne interaktive Auswahl."""
343
+ kind, value = parse_recipe_reference(reference)
344
+ if kind == "name":
345
+ candidates = search_candidates(client, value)
346
+ if not candidates:
347
+ raise NoResultsError(
348
+ f"Kein Rezept zu {value!r} gefunden.",
349
+ next_step="Nutze einen anderen Namen oder `chefkoch suche ...`.",
350
+ )
351
+ if len(candidates) > 1:
352
+ raise UsageError(
353
+ f"Der Name {value!r} ist mehrdeutig.",
354
+ next_step="Nutze eine ID/URL oder die interaktive Auswahl im Terminal.",
355
+ )
356
+ recipe_id = str(candidates[0]["id"])
357
+ else:
358
+ recipe_id = value
359
+ payload = client.recipe(recipe_id)
360
+ return normalize_recipe(payload, requested_servings)
chefkoch_cli/watch.py ADDED
@@ -0,0 +1,188 @@
1
+ """Persistenter Watch-State und Vergleich neuer Treffer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import service
13
+ from .client import ApiClient
14
+ from .errors import StateError
15
+
16
+ STATE_VERSION = 1
17
+
18
+
19
+ def state_path() -> Path:
20
+ base = os.environ.get("XDG_STATE_HOME") or os.path.join(
21
+ os.path.expanduser("~"), ".local", "state"
22
+ )
23
+ return Path(base) / "chefkoch-cli" / "watch.json"
24
+
25
+
26
+ def _watch_key(
27
+ query: str,
28
+ *,
29
+ vegetarisch: bool,
30
+ vegan: bool,
31
+ max_zeit: int | None,
32
+ min_bewertung: float | None,
33
+ schwierigkeit: str | None,
34
+ limit: int,
35
+ ) -> str:
36
+ return json.dumps(
37
+ {
38
+ "query": query,
39
+ "vegetarian": vegetarisch,
40
+ "vegan": vegan,
41
+ "maxTime": max_zeit,
42
+ "minRating": min_bewertung,
43
+ "difficulty": schwierigkeit,
44
+ "limit": limit,
45
+ },
46
+ ensure_ascii=False,
47
+ sort_keys=True,
48
+ )
49
+
50
+
51
+ def load_state(path: Path) -> dict[str, Any]:
52
+ if not path.exists():
53
+ return {"version": STATE_VERSION, "watches": {}}
54
+ try:
55
+ raw = path.read_text(encoding="utf-8")
56
+ except OSError as exc:
57
+ raise StateError(
58
+ f"Watch-State konnte nicht gelesen werden: {exc}.",
59
+ next_step=f"Prüfe die Rechte von {path}.",
60
+ ) from None
61
+ try:
62
+ data = json.loads(raw)
63
+ except json.JSONDecodeError:
64
+ raise StateError(
65
+ f"Watch-State ist beschädigt: {path}.",
66
+ next_step="Lösche die Datei oder repariere das JSON und versuche es erneut.",
67
+ ) from None
68
+ if not isinstance(data, dict) or not isinstance(data.get("watches"), dict):
69
+ raise StateError(
70
+ f"Watch-State hat ein unbekanntes Format: {path}.",
71
+ next_step="Lösche die Datei und versuche es erneut.",
72
+ )
73
+ return data
74
+
75
+
76
+ def save_state(path: Path, state: dict[str, Any]) -> None:
77
+ directory = path.parent
78
+ try:
79
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
80
+ os.chmod(directory, 0o700)
81
+ except OSError as exc:
82
+ raise StateError(
83
+ f"Watch-State-Verzeichnis konnte nicht angelegt werden: {exc}.",
84
+ next_step=f"Prüfe die Rechte von {directory}.",
85
+ ) from None
86
+
87
+ handle = None
88
+ temp_path = None
89
+ try:
90
+ handle, temp_name = tempfile.mkstemp(prefix=".watch-", dir=str(directory))
91
+ temp_path = Path(temp_name)
92
+ with os.fdopen(handle, "w", encoding="utf-8") as stream:
93
+ handle = None
94
+ json.dump(state, stream, ensure_ascii=False, indent=2)
95
+ stream.write("\n")
96
+ stream.flush()
97
+ os.fsync(stream.fileno())
98
+ os.chmod(temp_path, 0o600)
99
+ os.replace(temp_path, path)
100
+ temp_path = None
101
+ except OSError as exc:
102
+ raise StateError(
103
+ f"Watch-State konnte nicht geschrieben werden: {exc}.",
104
+ next_step=f"Prüfe die Rechte von {directory}.",
105
+ ) from None
106
+ finally:
107
+ if handle is not None:
108
+ try:
109
+ os.close(handle)
110
+ except OSError:
111
+ pass
112
+ if temp_path is not None:
113
+ try:
114
+ temp_path.unlink()
115
+ except OSError:
116
+ pass
117
+
118
+
119
+ def run_watch(
120
+ client: ApiClient,
121
+ query: str,
122
+ *,
123
+ vegetarisch: bool = False,
124
+ vegan: bool = False,
125
+ max_zeit: int | None = None,
126
+ min_bewertung: float | None = None,
127
+ schwierigkeit: str | None = None,
128
+ limit: int = service.DEFAULT_WATCH_LIMIT,
129
+ path: Path | None = None,
130
+ ) -> dict[str, Any]:
131
+ data = service.search_recipes(
132
+ client,
133
+ query,
134
+ vegetarisch=vegetarisch,
135
+ vegan=vegan,
136
+ max_zeit=max_zeit,
137
+ min_bewertung=min_bewertung,
138
+ schwierigkeit=schwierigkeit,
139
+ sort="bewertung",
140
+ limit=limit,
141
+ seite=1,
142
+ )
143
+ current = list(data.get("results") or [])
144
+ current_ids = [str(item.get("id")) for item in current]
145
+
146
+ target = path or state_path()
147
+ state = load_state(target)
148
+ key = _watch_key(
149
+ data["query"],
150
+ vegetarisch=vegetarisch,
151
+ vegan=vegan,
152
+ max_zeit=max_zeit,
153
+ min_bewertung=min_bewertung,
154
+ schwierigkeit=schwierigkeit,
155
+ limit=limit,
156
+ )
157
+ previous = state["watches"].get(key)
158
+ if previous is None:
159
+ previous_ids: set[str] = set()
160
+ changed = True
161
+ else:
162
+ previous_ids = {
163
+ str(item)
164
+ for item in previous.get("ids", [])
165
+ if isinstance(item, (str, int))
166
+ }
167
+ changed = False
168
+
169
+ new_items = [item for item in current if str(item.get("id")) not in previous_ids]
170
+ if previous is not None and not new_items:
171
+ changed = False
172
+ elif previous is not None and new_items:
173
+ changed = True
174
+
175
+ checked_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
176
+ state["version"] = STATE_VERSION
177
+ state["watches"][key] = {
178
+ "ids": current_ids,
179
+ "checkedAt": checked_at,
180
+ }
181
+ save_state(target, state)
182
+
183
+ return {
184
+ "query": data["query"],
185
+ "changed": bool(changed and new_items),
186
+ "new": new_items,
187
+ "checkedAt": checked_at,
188
+ }
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: chefkoch-cli
3
+ Version: 0.1.1
4
+ Summary: Chefkoch.de-Rezepte im Terminal: Suche, Rezeptdetails, Einkaufsliste und Watch
5
+ Author-email: smlfg <186922365+smlfg@users.noreply.github.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/smlfg/chefkoch-cli
8
+ Project-URL: Repository, https://github.com/smlfg/chefkoch-cli
9
+ Keywords: chefkoch,recipes,cli,terminal
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: httpx>=0.24
20
+ Dynamic: license-file
21
+
22
+ # chefkoch-cli
23
+
24
+ Chefkoch.de-Rezepte im Terminal: Suche mit Filtern, Rezeptdetails mit auf
25
+ Portionen skalierten Zutaten, Einkaufsliste aus mehreren Rezepten und Watch auf
26
+ neue Rezepte. Ausschließlich lesend, ohne Login.
27
+
28
+ ## Schnellstart
29
+
30
+ Voraussetzung: Python 3.10+ und `pipx`.
31
+
32
+ ```bash
33
+ pipx install git+https://github.com/smlfg/chefkoch-cli
34
+ chefkoch suche lasagne --limit 5
35
+ chefkoch rezept "vegane lasagne" --portionen 4
36
+ chefkoch einkaufsliste "die ultimative vegane lasagne" "vegetarische spinat gemüse lasagne" --portionen 4
37
+ ```
38
+
39
+ Die Suche nimmt den Begriff positional. `chefkoch rezept` und
40
+ `chefkoch einkaufsliste` akzeptieren Namen, IDs oder Chefkoch-URLs; bei
41
+ mehreren Treffern erscheint im Terminal eine nummerierte Auswahl.
42
+
43
+ ## Befehle und Beispiele
44
+
45
+ ### Suchen
46
+
47
+ ```bash
48
+ chefkoch suche lasagne --limit 5
49
+ chefkoch suche chili --vegetarisch --max-zeit 30 --min-bewertung 4.5
50
+ chefkoch suche suppe --vegan --sort zeit
51
+ chefkoch suche kuchen --sort neu --limit 10 --seite 2
52
+ ```
53
+
54
+ Filter: `--vegetarisch`, `--vegan`, `--max-zeit N`, `--min-bewertung X`,
55
+ `--schwierigkeit simpel|normal|pfiffig`, `--sort bewertung|zeit|neu`,
56
+ `--limit N`, `--seite N`.
57
+
58
+ ### Rezept anzeigen
59
+
60
+ ```bash
61
+ chefkoch rezept "vegane lasagne" --portionen 4
62
+ chefkoch rezept 745721177147257
63
+ chefkoch rezept https://www.chefkoch.de/rezepte/745721177147257/Lasagne.html --portionen 8
64
+ ```
65
+
66
+ Ein Name sucht und zeigt bei mehreren Treffern eine nummerierte Auswahl. Eine
67
+ ID oder URL lädt das Rezept direkt. `--portionen` skaliert die Zutaten.
68
+
69
+ ### Einkaufsliste
70
+
71
+ ```bash
72
+ chefkoch einkaufsliste "die ultimative vegane lasagne" "vegetarische spinat gemüse lasagne" --portionen 4
73
+ ```
74
+
75
+ Mehrere Namen, IDs oder URLs ergeben eine zusammengefasste Liste. Gleiche
76
+ Zutaten mit gleicher Einheit werden summiert, unterschiedliche Einheiten
77
+ bleiben getrennt.
78
+
79
+ ### Watch
80
+
81
+ ```bash
82
+ chefkoch watch "vegane lasagne" --min-bewertung 4.5
83
+ ```
84
+
85
+ Watch merkt sich die zuletzt gesehenen IDs pro Suchbegriff und Filter und zeigt
86
+ beim nächsten Lauf nur neue Treffer.
87
+
88
+ ## Ausgabe und JSON
89
+
90
+ Im Terminal ist die Ausgabe lesbar (Trefferliste, Rezept, Einkaufsliste). JSON
91
+ erscheint nur mit `--json` oder wenn stdout kein Terminal ist. Im JSON-Modus
92
+ steht genau ein Objekt auf stdout, auch bei Fehlern:
93
+
94
+ ```bash
95
+ chefkoch suche lasagne --limit 5 --json
96
+ chefkoch rezept "vegane lasagne" --json
97
+ ```
98
+
99
+ ## Konfiguration
100
+
101
+ | Variable | Default | Bedeutung |
102
+ |---|---|---|
103
+ | `CHEFKOCH_BASE_URL` | `https://api.chefkoch.de` | API-Basis (Test/Fake-Server) |
104
+ | `CHEFKOCH_MIN_INTERVAL` | `1` | Mindestabstand zwischen Requests in Sekunden; `0` nur für Tests |
105
+ | `XDG_STATE_HOME` | `~/.local/state` | Basis für den Watch-State |
106
+
107
+ Der Watch-State liegt unter
108
+ `$XDG_STATE_HOME/chefkoch-cli/watch.json` (Fallback
109
+ `~/.local/state/chefkoch-cli/watch.json`) und enthält nur IDs, Suchschlüssel
110
+ und Zeitstempel. Weitere Config-Dateien gibt es nicht.
111
+
112
+ ## Exit-Codes
113
+
114
+ | Exit | Bedeutung |
115
+ |---|---|
116
+ | 0 | Erfolg, inklusive `--help` und `--version` |
117
+ | 1 | Allgemeiner Fehler (z. B. kaputter Watch-State) |
118
+ | 2 | Usage: fehlende/ungültige Argumente, mehrdeutiger Name ohne TTY |
119
+ | 3 | Netz/Quelle: Timeout, HTTP-Fehler, Rezept-404, keine Treffer |
120
+ | 4 | Login erforderlich (nicht verwendet) bzw. Daten-/Parse-Fehler |
121
+
122
+ ## Datenquelle und Grenzen
123
+
124
+ `chefkoch` nutzt die inoffizielle API `https://api.chefkoch.de` über
125
+ `GET /v2/recipes` und `GET /v2/recipes/<id>`. Die Website
126
+ `www.chefkoch.de/rezepte/*?*` und `/v2/search/suggestions` werden nicht
127
+ abgerufen. Das Tool sendet keine Cookies, speichert keine Zugangsdaten und
128
+ führt keine schreibenden Web-Aktionen aus.
129
+
130
+ Die API ist nicht offiziell dokumentiert und kann sich jederzeit ändern.
131
+ Details und Befehlsvertrag stehen in `ANFORDERUNGEN.md`, `INTERFACE.md` und
132
+ `docs/UX.md`.
133
+
134
+ ## Lizenz
135
+
136
+ MIT, Copyright smlfg. Siehe `LICENSE`.
@@ -0,0 +1,15 @@
1
+ chefkoch_cli/__init__.py,sha256=FDmOf5BkpFBnN9MjsK3h81We20yUK0q56HC7rUpuLpI,89
2
+ chefkoch_cli/__main__.py,sha256=XbauCIYrA6-e--k62SiZF9OlIottxtHf5BNNAdvHVq8,164
3
+ chefkoch_cli/cli.py,sha256=tIedQmABNe4Ad6ouy0vALFrIKH5G4jevdTCs9MFFB04,13350
4
+ chefkoch_cli/client.py,sha256=Gi_6RSWu6iBztCD85KMWNw7WLRGeb9xa5717e2cXHIY,6869
5
+ chefkoch_cli/errors.py,sha256=3JHCcwhUyMWeGm-4pg1EXXAZhIlct3k73E7-96TlTAw,1185
6
+ chefkoch_cli/formatting.py,sha256=MSmrRd069B6wxhu2cNfcEsJOMFWZK5whmc3zSyrFF_U,4109
7
+ chefkoch_cli/parsing.py,sha256=de_PqaAsggTZjzVwe-7cYeR33VdzX7PbncWJwGHl35c,6820
8
+ chefkoch_cli/service.py,sha256=-1vEWPGGL89VmgzCyjmtF-ScyXwvB162594tpLwhSOU,11338
9
+ chefkoch_cli/watch.py,sha256=nyg03o3I2XVD9T9AVi31O7E0MYBp1aP0Kb32z_rZKXI,5332
10
+ chefkoch_cli-0.1.1.dist-info/licenses/LICENSE,sha256=PgaoXcbUpXfpF2HoZaVO_1YL1NL_swFlxDUrvrO5dgk,1062
11
+ chefkoch_cli-0.1.1.dist-info/METADATA,sha256=_LJpEFRu2k0W3FCl74nq91XR5fL5-vDwa8cX-Otn2wI,4616
12
+ chefkoch_cli-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
13
+ chefkoch_cli-0.1.1.dist-info/entry_points.txt,sha256=1kJRU6ayeD0Izau_ZiaIzOY956-VN9zKCh6lmtKkiOU,51
14
+ chefkoch_cli-0.1.1.dist-info/top_level.txt,sha256=oPqi9JlEK4DvRwTllcbQt_g1PEBtoa3rFJSCBODcAfs,13
15
+ chefkoch_cli-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+