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.
- chefkoch_cli/__init__.py +5 -0
- chefkoch_cli/__main__.py +8 -0
- chefkoch_cli/cli.py +447 -0
- chefkoch_cli/client.py +192 -0
- chefkoch_cli/errors.py +63 -0
- chefkoch_cli/formatting.py +136 -0
- chefkoch_cli/parsing.py +197 -0
- chefkoch_cli/service.py +360 -0
- chefkoch_cli/watch.py +188 -0
- chefkoch_cli-0.1.1.dist-info/METADATA +136 -0
- chefkoch_cli-0.1.1.dist-info/RECORD +15 -0
- chefkoch_cli-0.1.1.dist-info/WHEEL +5 -0
- chefkoch_cli-0.1.1.dist-info/entry_points.txt +2 -0
- chefkoch_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- chefkoch_cli-0.1.1.dist-info/top_level.txt +1 -0
chefkoch_cli/service.py
ADDED
|
@@ -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,,
|