pyhellofresh-uk 0.1.5__tar.gz
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.
- pyhellofresh_uk-0.1.5/.github/workflows/publish.yml +41 -0
- pyhellofresh_uk-0.1.5/.gitignore +6 -0
- pyhellofresh_uk-0.1.5/PKG-INFO +9 -0
- pyhellofresh_uk-0.1.5/README.md +0 -0
- pyhellofresh_uk-0.1.5/pyproject.toml +28 -0
- pyhellofresh_uk-0.1.5/src/pyhellofresh/__init__.py +18 -0
- pyhellofresh_uk-0.1.5/src/pyhellofresh/client.py +436 -0
- pyhellofresh_uk-0.1.5/src/pyhellofresh/exceptions.py +21 -0
- pyhellofresh_uk-0.1.5/src/pyhellofresh/models.py +64 -0
- pyhellofresh_uk-0.1.5/uv.lock +625 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
name: Build distribution
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- name: Install uv
|
|
15
|
+
uses: astral-sh/setup-uv@v5
|
|
16
|
+
|
|
17
|
+
- name: Build package
|
|
18
|
+
run: uv build
|
|
19
|
+
|
|
20
|
+
- name: Upload dist artifacts
|
|
21
|
+
uses: actions/upload-artifact@v4
|
|
22
|
+
with:
|
|
23
|
+
name: dist
|
|
24
|
+
path: dist/
|
|
25
|
+
|
|
26
|
+
publish:
|
|
27
|
+
name: Publish to PyPI
|
|
28
|
+
needs: build
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
environment: release
|
|
31
|
+
permissions:
|
|
32
|
+
id-token: write
|
|
33
|
+
steps:
|
|
34
|
+
- name: Download dist artifacts
|
|
35
|
+
uses: actions/download-artifact@v4
|
|
36
|
+
with:
|
|
37
|
+
name: dist
|
|
38
|
+
path: dist/
|
|
39
|
+
|
|
40
|
+
- name: Publish to PyPI
|
|
41
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyhellofresh-uk
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: Async Python library for the HelloFresh UK API
|
|
5
|
+
Project-URL: Homepage, https://github.com/caraar12345/pyhellofresh-uk
|
|
6
|
+
Project-URL: Repository, https://github.com/caraar12345/pyhellofresh-uk
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyhellofresh-uk"
|
|
3
|
+
version = "0.1.5"
|
|
4
|
+
description = "Async Python library for the HelloFresh UK API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"aiohttp>=3.9.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Homepage = "https://github.com/caraar12345/pyhellofresh-uk"
|
|
14
|
+
Repository = "https://github.com/caraar12345/pyhellofresh-uk"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/pyhellofresh"]
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=8.0.0",
|
|
26
|
+
"pytest-asyncio>=0.23.0",
|
|
27
|
+
"aioresponses>=0.7.6",
|
|
28
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""pyhellofresh-uk: Async Python library for the HelloFresh UK API."""
|
|
2
|
+
|
|
3
|
+
from .client import HelloFreshClient
|
|
4
|
+
from .exceptions import ApiError, AuthenticationError, CloudflareBlockError, HelloFreshError
|
|
5
|
+
from .models import AuthToken, CustomerInfo, Meal, UpcomingDelivery, WeeklyDelivery
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ApiError",
|
|
9
|
+
"AuthToken",
|
|
10
|
+
"AuthenticationError",
|
|
11
|
+
"CloudflareBlockError",
|
|
12
|
+
"CustomerInfo",
|
|
13
|
+
"HelloFreshClient",
|
|
14
|
+
"HelloFreshError",
|
|
15
|
+
"Meal",
|
|
16
|
+
"UpcomingDelivery",
|
|
17
|
+
"WeeklyDelivery",
|
|
18
|
+
]
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Async client for the HelloFresh UK API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from yarl import URL
|
|
10
|
+
|
|
11
|
+
from .exceptions import ApiError, AuthenticationError, CloudflareBlockError
|
|
12
|
+
from .models import AuthToken, CustomerInfo, Meal, UpcomingDelivery, WeeklyDelivery
|
|
13
|
+
|
|
14
|
+
_LOGGER = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_BASE_URL = "https://www.hellofresh.co.uk"
|
|
17
|
+
_AUTH0_URL = "https://hellofresh-live.eu.auth0.com"
|
|
18
|
+
_AUTH0_CLIENT_ID = "B1n0Q24hv7e4AHc7yG1WwQyuMvpCAIya"
|
|
19
|
+
_COUNTRY = "GB"
|
|
20
|
+
_LOCALE = "en-GB"
|
|
21
|
+
_TOKEN_EXPIRY_BUFFER = timedelta(seconds=60)
|
|
22
|
+
_PDF_URL_TEMPLATE = f"{_BASE_URL}/recipecards/card/{{recipe_id}}-en-GB.pdf"
|
|
23
|
+
|
|
24
|
+
_USER_AGENT = (
|
|
25
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
26
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
27
|
+
"Chrome/146.0.0.0 Safari/537.36"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_FETCH_HEADERS = {
|
|
31
|
+
"User-Agent": _USER_AGENT,
|
|
32
|
+
"Accept-Language": "en-GB,en;q=0.9",
|
|
33
|
+
"sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
|
|
34
|
+
"sec-ch-ua-mobile": "?0",
|
|
35
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
36
|
+
"Origin": _BASE_URL,
|
|
37
|
+
"Sec-Fetch-Site": "same-origin",
|
|
38
|
+
"Sec-Fetch-Mode": "cors",
|
|
39
|
+
"Sec-Fetch-Dest": "empty",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_NAV_HEADERS = {
|
|
43
|
+
"User-Agent": _USER_AGENT,
|
|
44
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
45
|
+
"Accept-Language": "en-GB,en;q=0.9",
|
|
46
|
+
"sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
|
|
47
|
+
"sec-ch-ua-mobile": "?0",
|
|
48
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
49
|
+
"Sec-Fetch-Site": "none",
|
|
50
|
+
"Sec-Fetch-Mode": "navigate",
|
|
51
|
+
"Sec-Fetch-Dest": "document",
|
|
52
|
+
"Upgrade-Insecure-Requests": "1",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HelloFreshClient:
|
|
57
|
+
"""Async client for the HelloFresh UK API.
|
|
58
|
+
|
|
59
|
+
The client owns its aiohttp session with a persistent cookie jar.
|
|
60
|
+
|
|
61
|
+
When ``flaresolverr_url`` is provided, it is used *only* for the initial
|
|
62
|
+
cookie pre-visit. All subsequent requests go directly to HelloFresh.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
email: str,
|
|
68
|
+
password: str,
|
|
69
|
+
*,
|
|
70
|
+
flaresolverr_url: str | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._email = email
|
|
73
|
+
self._password = password
|
|
74
|
+
self._flaresolverr_url = flaresolverr_url.rstrip("/") if flaresolverr_url else None
|
|
75
|
+
self._session: aiohttp.ClientSession | None = None
|
|
76
|
+
self._token: AuthToken | None = None
|
|
77
|
+
|
|
78
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
79
|
+
if self._session is None or self._session.closed:
|
|
80
|
+
self._session = aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar())
|
|
81
|
+
return self._session
|
|
82
|
+
|
|
83
|
+
async def close(self) -> None:
|
|
84
|
+
"""Close the underlying HTTP session."""
|
|
85
|
+
if self._session and not self._session.closed:
|
|
86
|
+
await self._session.close()
|
|
87
|
+
self._session = None
|
|
88
|
+
|
|
89
|
+
async def __aenter__(self) -> HelloFreshClient:
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
async def __aexit__(self, *args: object) -> None:
|
|
93
|
+
await self.close()
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# Cloudflare cookie acquisition
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
async def _acquire_cf_cookies(self) -> str:
|
|
100
|
+
"""Visit the login page to obtain CF cookies. Returns the User-Agent to use."""
|
|
101
|
+
session = self._ensure_session()
|
|
102
|
+
|
|
103
|
+
if self._flaresolverr_url:
|
|
104
|
+
return await self._acquire_cf_cookies_via_flaresolverr(session)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
async with session.get(
|
|
108
|
+
f"{_BASE_URL}/login",
|
|
109
|
+
headers=_NAV_HEADERS,
|
|
110
|
+
allow_redirects=True,
|
|
111
|
+
) as resp:
|
|
112
|
+
_LOGGER.debug("CF pre-visit status: %s", resp.status)
|
|
113
|
+
except Exception as err: # noqa: BLE001
|
|
114
|
+
_LOGGER.debug("CF pre-visit failed (continuing): %s", err)
|
|
115
|
+
|
|
116
|
+
return _USER_AGENT
|
|
117
|
+
|
|
118
|
+
async def _acquire_cf_cookies_via_flaresolverr(
|
|
119
|
+
self, session: aiohttp.ClientSession
|
|
120
|
+
) -> str:
|
|
121
|
+
assert self._flaresolverr_url is not None
|
|
122
|
+
_LOGGER.debug("Acquiring CF cookies via FlareSolverr")
|
|
123
|
+
|
|
124
|
+
async with session.post(
|
|
125
|
+
f"{self._flaresolverr_url}/v1",
|
|
126
|
+
json={"cmd": "request.get", "url": f"{_BASE_URL}/login", "maxTimeout": 60000},
|
|
127
|
+
) as resp:
|
|
128
|
+
fls = await resp.json()
|
|
129
|
+
|
|
130
|
+
if fls.get("status") != "ok":
|
|
131
|
+
raise ApiError(0, f"FlareSolverr: {fls.get('message', 'unknown error')}")
|
|
132
|
+
|
|
133
|
+
solution = fls["solution"]
|
|
134
|
+
cookies = {c["name"]: c["value"] for c in solution.get("cookies", [])}
|
|
135
|
+
if cookies:
|
|
136
|
+
session.cookie_jar.update_cookies(cookies, URL(f"{_BASE_URL}/"))
|
|
137
|
+
_LOGGER.debug("Imported %d CF cookies from FlareSolverr", len(cookies))
|
|
138
|
+
|
|
139
|
+
return solution.get("userAgent", _USER_AGENT)
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Authentication
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
async def authenticate(self) -> AuthToken:
|
|
146
|
+
"""Authenticate with email and password, returning a fresh token."""
|
|
147
|
+
user_agent = await self._acquire_cf_cookies()
|
|
148
|
+
session = self._ensure_session()
|
|
149
|
+
|
|
150
|
+
async with session.post(
|
|
151
|
+
f"{_BASE_URL}/gw/login",
|
|
152
|
+
params={"country": _COUNTRY, "locale": _LOCALE},
|
|
153
|
+
json={"username": self._email, "password": self._password},
|
|
154
|
+
headers={
|
|
155
|
+
**_FETCH_HEADERS,
|
|
156
|
+
"User-Agent": user_agent,
|
|
157
|
+
"Accept": "application/json, text/plain, */*",
|
|
158
|
+
"Referer": f"{_BASE_URL}/login",
|
|
159
|
+
},
|
|
160
|
+
) as resp:
|
|
161
|
+
if resp.status == 401:
|
|
162
|
+
raise AuthenticationError("Invalid email or password")
|
|
163
|
+
if resp.status == 403:
|
|
164
|
+
raise CloudflareBlockError(
|
|
165
|
+
"Cloudflare blocked the login request. "
|
|
166
|
+
"Provide a FlareSolverr URL to bypass this."
|
|
167
|
+
)
|
|
168
|
+
if resp.status != 200:
|
|
169
|
+
text = await resp.text()
|
|
170
|
+
raise ApiError(resp.status, text)
|
|
171
|
+
data = await resp.json()
|
|
172
|
+
|
|
173
|
+
issued_at = datetime.fromtimestamp(data["issued_at"], tz=UTC)
|
|
174
|
+
token = AuthToken(
|
|
175
|
+
access_token=data["access_token"],
|
|
176
|
+
refresh_token=data["refresh_token"],
|
|
177
|
+
token_type=data["token_type"],
|
|
178
|
+
expires_at=issued_at + timedelta(seconds=data["expires_in"]),
|
|
179
|
+
refresh_expires_at=issued_at + timedelta(seconds=data["refresh_expires_in"]),
|
|
180
|
+
customer_id=data["user_data"]["id"],
|
|
181
|
+
)
|
|
182
|
+
self._token = token
|
|
183
|
+
return token
|
|
184
|
+
|
|
185
|
+
async def _refresh_token(self, refresh_token: str) -> AuthToken:
|
|
186
|
+
session = self._ensure_session()
|
|
187
|
+
async with session.post(
|
|
188
|
+
f"{_AUTH0_URL}/oauth/token",
|
|
189
|
+
data={
|
|
190
|
+
"grant_type": "refresh_token",
|
|
191
|
+
"refresh_token": refresh_token,
|
|
192
|
+
"client_id": _AUTH0_CLIENT_ID,
|
|
193
|
+
},
|
|
194
|
+
) as resp:
|
|
195
|
+
if resp.status != 200:
|
|
196
|
+
raise AuthenticationError("Refresh token is invalid or expired")
|
|
197
|
+
data = await resp.json()
|
|
198
|
+
|
|
199
|
+
now = datetime.now(tz=UTC)
|
|
200
|
+
token = AuthToken(
|
|
201
|
+
access_token=data["access_token"],
|
|
202
|
+
refresh_token=data.get("refresh_token", refresh_token),
|
|
203
|
+
token_type=data.get("token_type", "Bearer"),
|
|
204
|
+
expires_at=now + timedelta(seconds=data["expires_in"]),
|
|
205
|
+
refresh_expires_at=self._token.refresh_expires_at
|
|
206
|
+
if self._token
|
|
207
|
+
else now + timedelta(days=60),
|
|
208
|
+
customer_id=self._token.customer_id if self._token else "",
|
|
209
|
+
)
|
|
210
|
+
self._token = token
|
|
211
|
+
return token
|
|
212
|
+
|
|
213
|
+
async def _ensure_token(self) -> str:
|
|
214
|
+
if self._token is None:
|
|
215
|
+
await self.authenticate()
|
|
216
|
+
assert self._token is not None
|
|
217
|
+
return self._token.access_token
|
|
218
|
+
|
|
219
|
+
if datetime.now(tz=UTC) >= self._token.expires_at - _TOKEN_EXPIRY_BUFFER:
|
|
220
|
+
_LOGGER.debug("Access token near expiry, refreshing")
|
|
221
|
+
try:
|
|
222
|
+
await self._refresh_token(self._token.refresh_token)
|
|
223
|
+
except AuthenticationError:
|
|
224
|
+
_LOGGER.debug("Refresh failed, re-authenticating with credentials")
|
|
225
|
+
await self.authenticate()
|
|
226
|
+
|
|
227
|
+
assert self._token is not None
|
|
228
|
+
return self._token.access_token
|
|
229
|
+
|
|
230
|
+
def set_token(self, token: AuthToken) -> None:
|
|
231
|
+
"""Inject a previously obtained token (e.g. from HA config entry)."""
|
|
232
|
+
self._token = token
|
|
233
|
+
|
|
234
|
+
# ------------------------------------------------------------------
|
|
235
|
+
# Core request helper
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
async def _request(self, path: str, params: dict) -> dict:
|
|
239
|
+
"""Authenticated GET with one CF-cookie retry on HTML 403."""
|
|
240
|
+
session = self._ensure_session()
|
|
241
|
+
access_token = await self._ensure_token()
|
|
242
|
+
|
|
243
|
+
for attempt in range(2):
|
|
244
|
+
async with session.get(
|
|
245
|
+
f"{_BASE_URL}{path}",
|
|
246
|
+
params=params,
|
|
247
|
+
headers={
|
|
248
|
+
**_FETCH_HEADERS,
|
|
249
|
+
"Accept": "*/*",
|
|
250
|
+
"Authorization": f"Bearer {access_token}",
|
|
251
|
+
},
|
|
252
|
+
) as resp:
|
|
253
|
+
if resp.status == 401:
|
|
254
|
+
raise AuthenticationError("Access token rejected by API")
|
|
255
|
+
|
|
256
|
+
if resp.status == 403:
|
|
257
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
258
|
+
if "text/html" in content_type and self._flaresolverr_url and attempt == 0:
|
|
259
|
+
_LOGGER.debug(
|
|
260
|
+
"Cloudflare block on API call, refreshing cookies via FlareSolverr"
|
|
261
|
+
)
|
|
262
|
+
await self._acquire_cf_cookies()
|
|
263
|
+
continue
|
|
264
|
+
text = await resp.text()
|
|
265
|
+
if "text/html" in content_type:
|
|
266
|
+
raise CloudflareBlockError(f"Cloudflare blocked request to {path}")
|
|
267
|
+
raise ApiError(resp.status, text)
|
|
268
|
+
|
|
269
|
+
if resp.status != 200:
|
|
270
|
+
text = await resp.text()
|
|
271
|
+
raise ApiError(resp.status, text)
|
|
272
|
+
|
|
273
|
+
return await resp.json()
|
|
274
|
+
|
|
275
|
+
raise CloudflareBlockError(f"Cloudflare block persisted after cookie refresh for {path}")
|
|
276
|
+
|
|
277
|
+
def _std_params(self, **extra: str | int) -> dict:
|
|
278
|
+
"""Standard params (country + locale) merged with extras."""
|
|
279
|
+
return {"country": _COUNTRY, "locale": _LOCALE, **extra}
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
# API methods
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
async def get_customer_info(self) -> CustomerInfo:
|
|
286
|
+
"""Return customer account information."""
|
|
287
|
+
data = await self._request("/gw/api/customers/me/info", self._std_params())
|
|
288
|
+
return CustomerInfo(
|
|
289
|
+
id=data["id"],
|
|
290
|
+
uuid=data["uuid"],
|
|
291
|
+
first_name=data["firstName"],
|
|
292
|
+
last_name=data["lastName"],
|
|
293
|
+
email=data["email"],
|
|
294
|
+
active_subscription_id=data["activeSubscriptionId"],
|
|
295
|
+
subscription_ids=data["subscriptionIds"],
|
|
296
|
+
customer_plan_ids=data["customerPlanIds"],
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
async def get_current_week_meals(
|
|
300
|
+
self,
|
|
301
|
+
subscription_id: int,
|
|
302
|
+
*,
|
|
303
|
+
week: str | None = None,
|
|
304
|
+
) -> WeeklyDelivery:
|
|
305
|
+
"""Return meals for the specified week (defaults to the current ISO week)."""
|
|
306
|
+
if week is None:
|
|
307
|
+
iso = datetime.now().isocalendar()
|
|
308
|
+
week = f"{iso.year}-W{iso.week:02d}"
|
|
309
|
+
|
|
310
|
+
data = await self._request(
|
|
311
|
+
"/gw/my-deliveries/past-deliveries",
|
|
312
|
+
self._std_params(**{
|
|
313
|
+
"from": week,
|
|
314
|
+
"rating-scale": "5",
|
|
315
|
+
"subscription": str(subscription_id),
|
|
316
|
+
}),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
for entry in data.get("weeks", []):
|
|
320
|
+
if entry.get("week") == week:
|
|
321
|
+
return WeeklyDelivery(
|
|
322
|
+
week=week,
|
|
323
|
+
meals=_parse_meals(entry.get("meals", [])),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return WeeklyDelivery(week=week, meals=[])
|
|
327
|
+
|
|
328
|
+
async def get_upcoming_delivery(
|
|
329
|
+
self,
|
|
330
|
+
subscription_id: int,
|
|
331
|
+
customer_plan_id: str,
|
|
332
|
+
) -> UpcomingDelivery | None:
|
|
333
|
+
"""Return the next scheduled delivery with currently selected meals.
|
|
334
|
+
|
|
335
|
+
Returns None if no upcoming (non-delivered) delivery is found.
|
|
336
|
+
"""
|
|
337
|
+
now = datetime.now()
|
|
338
|
+
iso = now.isocalendar()
|
|
339
|
+
range_start = f"{iso.year}-W{iso.week:02d}"
|
|
340
|
+
# Look 8 weeks ahead to cover skipped/holiday weeks
|
|
341
|
+
range_end_dt = now + timedelta(weeks=8)
|
|
342
|
+
range_end_iso = range_end_dt.isocalendar()
|
|
343
|
+
range_end = f"{range_end_iso.year}-W{range_end_iso.week:02d}"
|
|
344
|
+
|
|
345
|
+
data = await self._request(
|
|
346
|
+
"/gw/api/customers/me/deliveries",
|
|
347
|
+
self._std_params(rangeStart=range_start, rangeEnd=range_end),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
next_item = _find_next_delivery(data.get("items", []))
|
|
351
|
+
if next_item is None:
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
week = next_item["id"]
|
|
355
|
+
cutoff_date = datetime.fromisoformat(next_item["cutoffDate"])
|
|
356
|
+
delivery_date = datetime.fromisoformat(next_item["deliveryDate"])
|
|
357
|
+
status = next_item.get("status", "")
|
|
358
|
+
product = next_item.get("product", {})
|
|
359
|
+
delivery_option = next_item.get("deliveryOption", {})
|
|
360
|
+
|
|
361
|
+
menu_data = await self._request(
|
|
362
|
+
"/gw/my-deliveries/menu",
|
|
363
|
+
{
|
|
364
|
+
"customerPlanId": customer_plan_id,
|
|
365
|
+
"delivery-option": delivery_option.get("handle", ""),
|
|
366
|
+
"exclude": "",
|
|
367
|
+
"exclude-feedback": "true",
|
|
368
|
+
"include-filters": "true",
|
|
369
|
+
"include-future-feedback": "false",
|
|
370
|
+
"locale": _LOCALE,
|
|
371
|
+
"preference": "chefschoice",
|
|
372
|
+
"product-sku": product.get("handle", ""),
|
|
373
|
+
"servings": str(product.get("specs", {}).get("size", 2)),
|
|
374
|
+
"subscription": str(subscription_id),
|
|
375
|
+
"week": week,
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
selected = [
|
|
380
|
+
_parse_meal_from_menu(m)
|
|
381
|
+
for m in menu_data.get("meals", [])
|
|
382
|
+
if m.get("selection") and m["selection"].get("quantity", 0) > 0
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
return UpcomingDelivery(
|
|
386
|
+
week=week,
|
|
387
|
+
cutoff_date=cutoff_date,
|
|
388
|
+
delivery_date=delivery_date,
|
|
389
|
+
status=status,
|
|
390
|
+
meals=selected,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
# Helpers
|
|
396
|
+
# ------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
def _find_next_delivery(items: list[dict]) -> dict | None:
|
|
399
|
+
"""Return the earliest non-delivered delivery, or None."""
|
|
400
|
+
for item in items:
|
|
401
|
+
if item.get("status") != "DELIVERED":
|
|
402
|
+
return item
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _parse_meals(raw: list[dict]) -> list[Meal]:
|
|
407
|
+
return [
|
|
408
|
+
Meal(
|
|
409
|
+
id=m["id"],
|
|
410
|
+
name=m["name"],
|
|
411
|
+
headline=m.get("headline", ""),
|
|
412
|
+
image_url=m.get("image", ""),
|
|
413
|
+
website_url=m.get("websiteURL", ""),
|
|
414
|
+
pdf_url=_PDF_URL_TEMPLATE.format(recipe_id=m["id"]),
|
|
415
|
+
prep_time=m.get("prepTime"),
|
|
416
|
+
total_time=m.get("totalTime"),
|
|
417
|
+
category=m.get("category"),
|
|
418
|
+
)
|
|
419
|
+
for m in raw
|
|
420
|
+
if "id" in m and "name" in m
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _parse_meal_from_menu(item: dict) -> Meal:
|
|
425
|
+
r = item.get("recipe", {})
|
|
426
|
+
return Meal(
|
|
427
|
+
id=r["id"],
|
|
428
|
+
name=r["name"],
|
|
429
|
+
headline=r.get("headline", ""),
|
|
430
|
+
image_url=r.get("image", ""),
|
|
431
|
+
website_url=r.get("websiteURL", ""),
|
|
432
|
+
pdf_url=_PDF_URL_TEMPLATE.format(recipe_id=r["id"]),
|
|
433
|
+
prep_time=r.get("prepTime"),
|
|
434
|
+
total_time=r.get("totalTime"),
|
|
435
|
+
category=r.get("category"),
|
|
436
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exceptions for the HelloFresh UK API library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HelloFreshError(Exception):
|
|
5
|
+
"""Base exception for HelloFresh errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthenticationError(HelloFreshError):
|
|
9
|
+
"""Raised when authentication fails."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CloudflareBlockError(HelloFreshError):
|
|
13
|
+
"""Raised when Cloudflare blocks the request (403)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiError(HelloFreshError):
|
|
17
|
+
"""Raised when an API call fails."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, status: int, message: str) -> None:
|
|
20
|
+
self.status = status
|
|
21
|
+
super().__init__(f"API error {status}: {message}")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Data models for the HelloFresh UK API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AuthToken:
|
|
9
|
+
"""Authentication token data."""
|
|
10
|
+
|
|
11
|
+
access_token: str
|
|
12
|
+
refresh_token: str
|
|
13
|
+
token_type: str
|
|
14
|
+
expires_at: datetime
|
|
15
|
+
refresh_expires_at: datetime
|
|
16
|
+
customer_id: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CustomerInfo:
|
|
21
|
+
"""HelloFresh customer information."""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
uuid: str
|
|
25
|
+
first_name: str
|
|
26
|
+
last_name: str
|
|
27
|
+
email: str
|
|
28
|
+
active_subscription_id: int
|
|
29
|
+
subscription_ids: list[int]
|
|
30
|
+
customer_plan_ids: list[str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Meal:
|
|
35
|
+
"""A HelloFresh meal/recipe."""
|
|
36
|
+
|
|
37
|
+
id: str
|
|
38
|
+
name: str
|
|
39
|
+
headline: str
|
|
40
|
+
image_url: str
|
|
41
|
+
website_url: str
|
|
42
|
+
pdf_url: str
|
|
43
|
+
prep_time: str | None = None
|
|
44
|
+
total_time: str | None = None
|
|
45
|
+
category: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class WeeklyDelivery:
|
|
50
|
+
"""Meals for a delivered week (from past-deliveries endpoint)."""
|
|
51
|
+
|
|
52
|
+
week: str
|
|
53
|
+
meals: list[Meal] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class UpcomingDelivery:
|
|
58
|
+
"""Details of the next scheduled delivery."""
|
|
59
|
+
|
|
60
|
+
week: str
|
|
61
|
+
cutoff_date: datetime
|
|
62
|
+
delivery_date: datetime
|
|
63
|
+
status: str
|
|
64
|
+
meals: list[Meal] = field(default_factory=list)
|